diff --git a/Furious/Core/CoreManager.py b/Furious/Core/CoreManager.py index 2865b88a..6b256e0c 100644 --- a/Furious/Core/CoreManager.py +++ b/Furious/Core/CoreManager.py @@ -22,167 +22,23 @@ from __future__ import annotations from Furious.Frozenlib import * from Furious.Interface import * from Furious.Library import * +from Furious.Plugins import getPluginRegistry from Furious.Qt import * from Furious.Core.CoreProcessWorker import * -from Furious.Core.XrayCore import * -from Furious.Core.Hysteria1 import * -from Furious.Core.Hysteria2 import * from Furious.Core.Tun2socks import * from typing import Callable, Tuple, Union import os -import uuid import logging import tempfile import functools -__all__ = ['cleanRoutingRule', 'CoreManager'] +__all__ = ['CoreManager'] logger = logging.getLogger(__name__) -def fixLogObjectPath(config: ConfigFactory, attr: str, value: str, log=True): - """Resolve and normalize one log-file path in a core configuration.""" - try: - path = config['log'][attr] - except Exception: - # Any non-exit exceptions - - config['log'][attr] = path = '' - - if not isinstance(path, str) and not isinstance(path, bytes): - config['log'][attr] = path = '' - - if path == '': - if SystemRuntime.isPythonw() and ProcessOutputRedirector.TemporaryDir.isValid(): - # Redirect implementation for pythonw environment - config['log'][attr] = ProcessOutputRedirector.TemporaryDir.filePath(value) - else: - # Relative path fails if booting on start up - # on Windows, when packed using nuitka... - - # Fix relative path if needed. User cannot feel this operation. - config['log'][attr] = absolutePath(path) - - result = config['log'][attr] - - if result: - try: - # Create a new file - with open(result, 'x', encoding='utf-8'): - pass - except FileExistsError: - pass - except Exception: - # Any non-exit exceptions - - pass - - if log: - logger.info( - f'{XrayCore.name()}: {attr} log is specified as \'{path}\'. ' - f'Fixed to \'{result}\'' - ) - - -def cleanRoutingRule(rule: dict): - """Remove empty match fields from an Xray routing rule.""" - if not isinstance(rule, dict): - return None - - result = { - 'type': 'field', - 'outboundTag': str(rule.get('outboundTag', 'proxy')).strip() or 'proxy', - } - - for key in [ - 'domain', - 'ip', - 'sourceIP', - 'localIP', - 'user', - 'protocol', - 'inboundTag', - 'process', - ]: - value = rule.get(key, []) - - if isinstance(value, list): - value = list(str(item).strip() for item in value if str(item).strip()) - - if value: - result[key] = value - - for key in [ - 'port', - 'sourcePort', - 'localPort', - 'network', - 'vlessRoute', - 'balancerTag', - 'ruleTag', - ]: - value = str(rule.get(key, '')).strip() - - if value: - result[key] = value - - if len(result) <= 2: - return None - - return result - - -def customRoutingObjectFromSettings(routing: str): - """Load a named custom routing object from persistent settings.""" - prefix = 'Custom:' - - if not isinstance(routing, str) or not routing.startswith(prefix): - return None - - unique = routing[len(prefix) :] - routingProfile = Storage.UserRoutings().get(unique) - - if not isinstance(routingProfile, dict): - return None - - if not routingProfile.get('enabled', True): - return None - - domainStrategy = routingProfile.get('domainStrategy', 'AsIs') - - if domainStrategy not in ['AsIs', 'IPIfNonMatch', 'IPOnDemand']: - domainStrategy = 'AsIs' - - rules = list( - filter( - lambda rule: rule is not None, - list(cleanRoutingRule(rule) for rule in routingProfile.get('rules', [])), - ) - ) - - return { - 'domainStrategy': domainStrategy, - 'domainMatcher': 'hybrid', - 'rules': rules, - } - - -def routingObjectHasDirectRule(routingObject: dict) -> bool: - """Return whether an Xray routing object contains a direct rule.""" - try: - return any( - rule.get('outboundTag') == 'direct' - for rule in routingObject.get('rules', []) - if isinstance(rule, dict) - ) - except Exception: - # Any non-exit exceptions - - return False - - def getUserTUNSettings(*args, **kwargs): """Return one user-defined TUN setting or its fallback value.""" return Storage.UserTUNSettings().get(*args, **kwargs) @@ -221,7 +77,6 @@ class CoreManager(Mixins.CleanupOnExit): self.uniqueCleanup = False self.processesPool = list() - @functools.singledispatchmethod def _startCore( self, config, @@ -232,181 +87,21 @@ class CoreManager(Mixins.CleanupOnExit): log=True, **kwargs, ) -> Tuple[Union[CoreProcessWorker, None], bool]: - """Return the start core value used by the core manager.""" - return None, False + """Start a configuration through the plugin that owns it.""" + plugin = getPluginRegistry().pluginForConfig(config) + if plugin is None: + return None, False - @_startCore.register(ConfigXray) - def _( - self, - config, - routing, - exitCallback=None, - msgCallback=None, - proxyModeOnly=False, - log=True, - **kwargs, - ): - """Handle the registered singledispatch variant.""" - if config.get('log') is None or not isinstance(config['log'], dict): - config['log'] = { - 'access': '', - 'error': '', - 'loglevel': 'warning', - } - - logRedirectValue = str(uuid.uuid4()) - - # Fix logObject - for attr in ['access', 'error']: - fixLogObjectPath(config, attr, logRedirectValue, log) - - if routing == AppBuiltinRouting.BypassMainlandChina.value: - # TUN Mode handling - if not proxyModeOnly and SystemRuntime.isTUNMode(): - showMBoxDirectRulesNotAllowed() - - return None, False - - routingObject = { - 'domainStrategy': 'IPIfNonMatch', - 'domainMatcher': 'hybrid', - 'rules': [ - { - 'type': 'field', - 'domain': [ - 'geosite:category-ads-all', - ], - 'outboundTag': 'block', - }, - { - 'type': 'field', - 'domain': [ - 'geosite:cn', - ], - 'outboundTag': 'direct', - }, - { - 'type': 'field', - 'ip': [ - 'geoip:private', - 'geoip:cn', - ], - 'outboundTag': 'direct', - }, - { - 'type': 'field', - 'port': '0-65535', - 'outboundTag': 'proxy', - }, - ], - } - elif routing == AppBuiltinRouting.Global.value: - routingObject = {} - elif customRoutingObjectFromSettings(routing) is not None: - routingObject = customRoutingObjectFromSettings(routing) - - # - # TODO: Warning: no check for custom routing - # - # if ( - # routingObjectHasDirectRule(routingObject) - # and not proxyModeOnly - # and SystemRuntime.isTUNMode() - # ): - # showMBoxDirectRulesNotAllowed() - # - # return None, False - elif routing == AppBuiltinRouting.Custom.value: - routingObject = config.get('routing', {}) - else: - routingObject = {} - - if log: - logger.info(f'core {XrayCore.name()} configured') - logger.info(f'routing is {routing}') - logger.info(f'RoutingObject: {routingObject}') - - config['routing'] = routingObject - - process = XrayCore(exitCallback=exitCallback, msgCallback=msgCallback) - success = process.start(config, **kwargs) - - return process, success - - @_startCore.register(ConfigHysteria1) - def _( - self, - config, - routing, - exitCallback=None, - msgCallback=None, - proxyModeOnly=False, - log=True, - **kwargs, - ): - """Handle the registered singledispatch variant.""" - if routing == AppBuiltinRouting.BypassMainlandChina.value: - # TUN Mode handling - if not proxyModeOnly and SystemRuntime.isTUNMode(): - showMBoxDirectRulesNotAllowed() - - return None, False - - routingObject = { - 'rule': DATA_DIR / 'hysteria' / 'bypass-mainland-China.acl', - 'mmdb': DATA_DIR / 'hysteria' / 'country.mmdb', - } - elif routing == AppBuiltinRouting.Global.value: - routingObject = { - 'rule': '', - 'mmdb': '', - } - elif routing == AppBuiltinRouting.Custom.value: - routingObject = { - 'rule': config.get('acl', ''), - 'mmdb': config.get('mmdb', ''), - } - else: - routingObject = { - 'rule': '', - 'mmdb': '', - } - - if 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( + return plugin.startCore( config, - Hysteria1.rule(routingObject.get('rule', '')), - Hysteria1.mmdb(routingObject.get('mmdb', '')), + routing, + exitCallback=exitCallback, + msgCallback=msgCallback, + proxyModeOnly=proxyModeOnly, + log=log, **kwargs, ) - return process, success - - @_startCore.register(ConfigHysteria2) - def _( - self, - config, - routing, - exitCallback=None, - msgCallback=None, - proxyModeOnly=False, - log=True, - **kwargs, - ): - """Handle the registered singledispatch variant.""" - if log: - logger.info(f'core {Hysteria2.name()} configured') - - process = Hysteria2(exitCallback=exitCallback, msgCallback=msgCallback) - success = process.start(config, **kwargs) - - return process, success - @staticmethod def waitForTUNDeviceBroughtUp(func: Callable[[str], bool], deviceName: str) -> bool: """Wait for the platform to expose the configured TUN device.""" diff --git a/Furious/Core/Hysteria1.py b/Furious/Core/Hysteria1.py index 7efebc12..bc5c6170 100644 --- a/Furious/Core/Hysteria1.py +++ b/Furious/Core/Hysteria1.py @@ -1,160 +1,5 @@ -# Copyright (C) 2024–present Loren Eteval & contributors -# -# This file is part of Furious. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Wrap the embedded Hysteria 1 core process.""" +"""Provide compatibility imports for the official Hysteria 1 core binding.""" from __future__ import annotations -from Furious.Frozenlib import * -from Furious.Core.CoreProcessWorker import * - -from enum import Enum -from typing import Union - -import time -import logging -import functools -import multiprocessing - -logger = logging.getLogger(__name__) - -__all__ = ['Hysteria1'] - - -def startHysteria1(jsonString, rule, mmdb, msgQueue: multiprocessing.Queue): - """Start hysteria1.""" - try: - import hysteria - except ImportError: - # Fake running process - while True: - time.sleep(3600) - else: - if versionToValue(hysteria.__version__) <= versionToValue('1.3.5'): - redirect = False - else: - redirect = True - - ProcessOutputRedirector.launch( - msgQueue, - functools.partial(hysteria.startFromJSON, jsonString, rule, mmdb), - redirect, - ) - - -class Hysteria1(CoreProcessWorker): - """Manage the embedded Hysteria 1 core subprocess.""" - - class ExitCode(Enum): - """Enumerate process exit codes.""" - - ConfigurationError = 23 - RemoteNetworkError = 3 - # Windows shutting down - SystemShuttingDown = 0x40010004 - - def __init__(self, **kwargs): - """Initialize the Hysteria1.""" - super().__init__(**kwargs) - - @staticmethod - def loadOptionalFile(pathLike, fileType: str): - """Load optional file.""" - if isinstance(pathLike, str) and pathLike == '': - return '' - - try: - path = absolutePath(str(pathLike)) - except Exception: - # Any non-exit exceptions - - logger.error(f'invalid hysteria1 {fileType} path. Fall back to empty') - - return '' - - try: - with open(path, 'rb') as file: - data = file.read() - - logger.info(f'hysteria1 {fileType} \'{path}\' load success') - - return data - except Exception as ex: - # Any non-exit exceptions - - logger.error( - f'hysteria1 {fileType} \'{path}\' load failed. {ex}. ' - f'Fall back to empty' - ) - - return '' - - @staticmethod - def rule(rulePath): - """Return the rule value.""" - return Hysteria1.loadOptionalFile(rulePath, 'rule') - - @staticmethod - def mmdb(mmdbPath): - """Return the mmdb value.""" - return Hysteria1.loadOptionalFile(mmdbPath, 'mmdb') - - @staticmethod - def name() -> str: - """Return the process implementation name.""" - return 'Hysteria1' - - @staticmethod - def version() -> str: - """Return the bundled core version.""" - try: - import hysteria - - return hysteria.__version__ - except Exception: - # Any non-exit exceptions - - return '0.0.0' - - def launchSpec( - self, config: Union[str, dict], rule, mmdb, **kwargs - ) -> Union[CoreLaunchSpec, None]: - """Build the child-process launch specification.""" - param = self.toJSONString(config) - - if not param: - return None - - return CoreLaunchSpec( - target=startHysteria1, - args=( - param, - rule, - mmdb, - self.msgQueue, - ), - processKwargs=kwargs, - ) - - def start(self, config: Union[str, dict], rule, mmdb, **kwargs) -> bool: - """Start the hysteria1.""" - launchSpec = self.launchSpec(config, rule, mmdb, **kwargs) - - if launchSpec is None: - return False - - return self.startWithSpec(launchSpec) +from Furious.Plugins.Official.Hysteria1.Core import * diff --git a/Furious/Core/Hysteria2.py b/Furious/Core/Hysteria2.py index ae0d474d..fb139787 100644 --- a/Furious/Core/Hysteria2.py +++ b/Furious/Core/Hysteria2.py @@ -1,117 +1,5 @@ -# Copyright (C) 2024–present Loren Eteval & contributors -# -# This file is part of Furious. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Wrap the embedded Hysteria 2 core process.""" +"""Provide compatibility imports for the official Hysteria 2 core binding.""" from __future__ import annotations -from Furious.Frozenlib import * -from Furious.Core.CoreProcessWorker import * - -from enum import Enum -from typing import Union - -import time -import logging -import functools -import multiprocessing - -logger = logging.getLogger(__name__) - -__all__ = ['Hysteria2'] - - -def startHysteria2(jsonString: str, msgQueue: multiprocessing.Queue): - """Start hysteria2.""" - try: - import hysteria2 - except ImportError: - # Fake running process - while True: - time.sleep(3600) - else: - if versionToValue(hysteria2.__version__) <= versionToValue('2.0.0.1'): - redirect = False - else: - redirect = True - - ProcessOutputRedirector.launch( - msgQueue, - functools.partial(hysteria2.startFromJSON, jsonString), - redirect, - ) - - -class Hysteria2(CoreProcessWorker): - """Manage the embedded Hysteria 2 core subprocess.""" - - class ExitCode(Enum): - """Enumerate process exit codes.""" - - ConfigurationError = 23 - # Windows: 4294967295. Darwin, Linux: 255 (-1) - ServerStartFailure = 4294967295 if PLATFORM == 'Windows' else 255 - # Windows shutting down - SystemShuttingDown = 0x40010004 - - def __init__(self, **kwargs): - """Initialize the Hysteria2.""" - super().__init__(**kwargs) - - @staticmethod - def name() -> str: - """Return the process implementation name.""" - return 'Hysteria2' - - @staticmethod - def version() -> str: - """Return the bundled core version.""" - try: - import hysteria2 - - return hysteria2.__version__ - except Exception: - # Any non-exit exceptions - - return '0.0.0' - - def launchSpec( - self, config: Union[str, dict], **kwargs - ) -> Union[CoreLaunchSpec, None]: - """Build the child-process launch specification.""" - param = self.toJSONString(config) - - if not param: - return None - - return CoreLaunchSpec( - target=startHysteria2, - args=( - param, - self.msgQueue, - ), - processKwargs=kwargs, - ) - - def start(self, config: Union[str, dict], **kwargs) -> bool: - """Start the hysteria2.""" - launchSpec = self.launchSpec(config, **kwargs) - - if launchSpec is None: - return False - - return self.startWithSpec(launchSpec) +from Furious.Plugins.Official.Hysteria2.Core import * diff --git a/Furious/Core/XrayCore.py b/Furious/Core/XrayCore.py index 4d8ace82..af51ce33 100644 --- a/Furious/Core/XrayCore.py +++ b/Furious/Core/XrayCore.py @@ -1,180 +1,5 @@ -# Copyright (C) 2024–present Loren Eteval & contributors -# -# This file is part of Furious. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Wrap the embedded Xray core process.""" +"""Provide compatibility imports for the official Xray core binding.""" from __future__ import annotations -from Furious.Frozenlib import * -from Furious.Interface import * -from Furious.Library import * -from Furious.Core.CoreProcessWorker import * - -from enum import Enum -from typing import Union - -import io -import time -import threading -import functools -import multiprocessing - -__all__ = ['XrayCore'] - - -def startXrayCore(jsonString: str, msgQueue: multiprocessing.Queue): - """Start Xray core.""" - try: - import xray - except ImportError: - # Fake running process - while True: - time.sleep(3600) - else: - if versionToValue(xray.__version__) <= versionToValue('1.8.4'): - redirect = False - else: - # Can be redirected - redirect = True - - if not SystemRuntime.isPythonw(): - return ProcessOutputRedirector.launch( - msgQueue, - functools.partial(xray.startFromJSON, jsonString), - redirect, - ) - - if not redirect: - return xray.startFromJSON(jsonString) - - try: - jsonObject = UJSONEncoder.decode(jsonString) - except Exception: - # Any non-exit exceptions - - xray.startFromJSON(jsonString) - else: - loggingPath = list() - fileStreams = list() - - for loggingAttr in ['access', 'error']: - try: - path = jsonObject['log'][loggingAttr] - except Exception: - # Any non-exit exceptions - - continue - - if path not in loggingPath: - loggingPath.append(path) - - try: - stream = open(path, 'rb') - except Exception: - # Any non-exit exceptions - - pass - else: - stream.seek(0, io.SEEK_END) - - fileStreams.append(stream) - - def produceMsg(): - """Forward one process-output message to the shared queue.""" - while True: - for file in fileStreams: - for line in iter(file.readline, b''): - if line and not line.isspace(): - try: - msgQueue.put_nowait(line.decode('utf-8', 'replace')) - except Exception: - # Any non-exit exceptions - - pass - - time.sleep(MsgQueue.MSG_PRODUCE_THRESHOLD / 1000) - - try: - if fileStreams: - msgThread = threading.Thread(target=produceMsg, daemon=True) - msgThread.start() - - xray.startFromJSON(jsonString) - finally: - for stream in fileStreams: - stream.close() - - -class XrayCore(CoreProcessWorker): - """Manage the embedded Xray core subprocess.""" - - class ExitCode(Enum): - """Enumerate process exit codes.""" - - ConfigurationError = 23 - # Windows: 4294967295. Darwin, Linux: 255 (-1) - ServerStartFailure = 4294967295 if PLATFORM == 'Windows' else 255 - # Windows shutting down - SystemShuttingDown = 0x40010004 - - def __init__(self, **kwargs): - """Initialize the XrayCore.""" - super().__init__(**kwargs) - - @staticmethod - def name() -> str: - """Return the process implementation name.""" - return 'Xray-core' - - @staticmethod - def version() -> str: - """Return the bundled core version.""" - try: - import xray - - return xray.__version__ - except Exception: - # Any non-exit exceptions - - return '0.0.0' - - def launchSpec( - self, config: Union[str, ConfigFactory, dict], **kwargs - ) -> Union[CoreLaunchSpec, None]: - """Build the child-process launch specification.""" - param = self.toJSONString(config) - - if not param: - return None - - return CoreLaunchSpec( - target=startXrayCore, - args=( - param, - self.msgQueue, - ), - processKwargs=kwargs, - ) - - def start(self, config: Union[str, ConfigFactory, dict], **kwargs) -> bool: - """Start the Xray core.""" - launchSpec = self.launchSpec(config, **kwargs) - - if launchSpec is None: - return False - - return self.startWithSpec(launchSpec) +from Furious.Plugins.Official.Xray.Core import * diff --git a/Furious/Core/__init__.py b/Furious/Core/__init__.py index 7d72a47c..32a7bcbe 100644 --- a/Furious/Core/__init__.py +++ b/Furious/Core/__init__.py @@ -19,8 +19,5 @@ from __future__ import annotations -from .XrayCore import * -from .Hysteria1 import * -from .Hysteria2 import * from .Tun2socks import * from .CoreManager import * diff --git a/Furious/Library/Configuration.py b/Furious/Library/Configuration.py index 962ad475..1d2f90e5 100644 --- a/Furious/Library/Configuration.py +++ b/Furious/Library/Configuration.py @@ -1,2547 +1,6 @@ -# Copyright (C) 2024–present Loren Eteval & contributors -# -# This file is part of Furious. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Parse, build, import, and export supported proxy configurations.""" +"""Provide compatibility imports for the official plugin configuration models.""" from __future__ import annotations -from Furious.Frozenlib import * -from Furious.Interface import * -from Furious.Library.Encoder import * - -from typing import Union, Tuple - -import copy -import functools -import urllib.parse - -quote = functools.partial(urllib.parse.quote, safe='') -unquote = functools.partial(urllib.parse.unquote) -parse_qsl = functools.partial(urllib.parse.parse_qsl) -urlparse = functools.partial(urllib.parse.urlparse) -urlunparse = functools.partial(urllib.parse.urlunparse) - - -def queryStringFromItems(items): - """Encode non-empty query items as a URI query string.""" - return urllib.parse.urlencode( - list((key, value) for key, value in items if value != '' and value is not None), - doseq=True, - safe='%', - ) - - -__all__ = [ - 'BLANK_CONFIG_XRAY', - 'BLANK_CONFIG_HYSTERIA1', - 'BLANK_CONFIG_HYSTERIA2', - 'ConfigXray', - 'ConfigHysteria1', - 'ConfigHysteria2', - 'configXrayEmptyProxyOutboundObject', - 'configFactoryFromDict', - 'configFactoryFromAny', - 'configFactoryBlank', -] - - -class ConfigXrayProxyOutboundObjectV(dict): - """Represent and transform Xray proxy outbound object v configuration data.""" - - def __init__( - self, - protocol, - remote_host, - remote_port, - uuid_, - encryption, - type_, - security, - **kwargs, - ): - """Initialize the ConfigXrayProxyOutboundObjectV.""" - ( - networkObjectArgs, - securityArgs, - TLSObjectArgs, - finalmaskArgs, - ) = ( - {}, - {}, - {}, - {}, - ) - - if type_ == 'h2': - networkKey = 'httpSettings' - else: - networkKey = f'{type_}Settings' - - networkObjectArgs[networkKey] = ( - ConfigXray.kwargs2ProxyStreamSettingsNetworkObject( - type_, remote_host, kwargs - ) - ) - - if security: - securityArgs['security'] = security - - if security != 'none': - TLSObjectArgs[ - # tlsSettings, realitySettings - f'{security}Settings' - ] = ConfigXray.kwargs2ProxyStreamSettingsTLSObject( - protocol, remote_host, security, kwargs - ) - - try: - if kwargs.get('fm', ''): - finalmask = UJSONEncoder.decode(unquote(kwargs.get('fm', ''))) - - if finalmask: - finalmaskArgs['finalmask'] = finalmask - except Exception: - # Any non-exit exceptions - - pass - - super().__init__( - **{ - 'tag': 'proxy', - 'protocol': protocol, - 'settings': { - 'vnext': [ - { - 'address': remote_host, - 'port': int(remote_port), - 'users': [ - ConfigXray.kwargs2ProxyUserObject( - protocol, uuid_, encryption, kwargs - ), - ], - }, - ] - }, - 'streamSettings': { - 'network': type_, - **networkObjectArgs, - **securityArgs, - **TLSObjectArgs, - **finalmaskArgs, - }, - 'mux': { - 'enabled': False, - 'concurrency': -1, - }, - }, - ) - - -class ConfigXrayProxyOutboundObjectSS(dict): - """Represent and transform Xray proxy outbound object ss configuration data.""" - - def __init__(self, method, password, address, port): - """Initialize the ConfigXrayProxyOutboundObjectSS.""" - super().__init__( - **{ - 'tag': 'proxy', - 'protocol': 'shadowsocks', - 'settings': { - 'servers': [ - { - 'address': address, - 'port': int(port), - 'method': method, - 'password': password, - 'email': PROXY_OUTBOUND_USER_EMAIL, - 'ota': False, - }, - ] - }, - 'streamSettings': { - 'network': 'tcp', - }, - 'mux': { - 'enabled': False, - 'concurrency': -1, - }, - } - ) - - -class ConfigXrayProxyOutboundObjectSocks(dict): - """Represent and transform Xray proxy outbound object SOCKS configuration data.""" - - def __init__(self, address, port, user='', password=''): - """Initialize the ConfigXrayProxyOutboundObjectSocks.""" - settings = { - 'address': address, - 'port': int(port), - } - - if user != '' or password != '': - settings['user'] = user - settings['pass'] = password - - super().__init__( - **{ - 'tag': 'proxy', - 'protocol': 'socks', - 'settings': settings, - 'streamSettings': { - 'network': 'tcp', - }, - 'mux': { - 'enabled': False, - 'concurrency': -1, - }, - } - ) - - -class ConfigXrayProxyOutboundObjectTrojan(dict): - """Represent and transform Xray proxy outbound object trojan configuration data.""" - - def __init__(self, password, address, port, type_, security, **kwargs): - """Initialize the ConfigXrayProxyOutboundObjectTrojan.""" - networkObjectArgs, securityArgs, TLSObjectArgs = {}, {}, {} - - if type_ == 'h2': - networkKey = 'httpSettings' - else: - networkKey = f'{type_}Settings' - - networkObjectArgs[networkKey] = ( - ConfigXray.kwargs2ProxyStreamSettingsNetworkObject(type_, address, kwargs) - ) - - if security: - securityArgs['security'] = security - - if security != 'none': - TLSObjectArgs[ - # tlsSettings, realitySettings - f'{security}Settings' - ] = ConfigXray.kwargs2ProxyStreamSettingsTLSObject( - 'trojan', address, security, kwargs - ) - - super().__init__( - **{ - 'tag': 'proxy', - 'protocol': 'trojan', - 'settings': { - 'servers': [ - { - 'address': address, - 'port': int(port), - 'password': password, - 'email': PROXY_OUTBOUND_USER_EMAIL, - }, - ] - }, - 'streamSettings': { - 'network': type_, - **networkObjectArgs, - **securityArgs, - **TLSObjectArgs, - }, - 'mux': { - 'enabled': False, - 'concurrency': -1, - }, - }, - ) - - -BLANK_CONFIG_XRAY = { - # log - 'log': { - 'access': '', - 'error': '', - 'loglevel': 'warning', - }, - # inbounds - 'inbounds': [ - { - 'tag': 'socks', - 'port': 10808, - 'listen': '127.0.0.1', - 'protocol': 'socks', - 'sniffing': { - 'enabled': True, - 'destOverride': [ - 'http', - 'tls', - ], - }, - 'settings': { - 'auth': 'noauth', - 'udp': True, - 'allowTransparent': False, - }, - }, - { - 'tag': 'http', - 'port': 10809, - 'listen': '127.0.0.1', - 'protocol': 'http', - 'sniffing': { - 'enabled': True, - 'destOverride': [ - 'http', - 'tls', - ], - }, - 'settings': { - 'auth': 'noauth', - 'udp': True, - 'allowTransparent': False, - }, - }, - ], - # outbounds - 'outbounds': [ - # proxy - { - 'tag': 'proxy', - 'protocol': '', - 'settings': {}, - 'streamSettings': { - 'network': 'tcp', - }, - 'mux': { - 'enabled': False, - 'concurrency': -1, - }, - }, - # direct - { - 'tag': 'direct', - 'protocol': 'freedom', - 'settings': {}, - }, - # block - { - 'tag': 'block', - 'protocol': 'blackhole', - 'settings': { - 'response': { - 'type': 'http', - } - }, - }, - ], - # routing - 'routing': {}, -} - - -class ConfigXray(ConfigFactory): - """Represent Xray configuration and supported share-link formats.""" - - def __init__(self, config: Union[str, dict] = '', **kwargs): - """Initialize the ConfigXray.""" - super().__init__(config, **kwargs) - - def coreName(self) -> str: - """Return the core implementation name.""" - return 'Xray-core' - - @staticmethod - def getProxyOutboundObject(config: dict, default=None) -> dict: - """Return proxy outbound object.""" - if not isinstance(config.get('outbounds'), list): - config['outbounds'] = [] - - for outbound in config['outbounds']: - if isinstance(outbound, dict): - tag = outbound.get('tag') - - if isinstance(tag, str) and tag == 'proxy': - return outbound - - if isinstance(default, dict): - # Proxy outbound not found. Add it - config['outbounds'].append(default) - - return {} - - @staticmethod - def getProxyOutboundStream(config: dict, **kwargs) -> dict: - """Return proxy outbound stream.""" - proxyOutbound = ConfigXray.getProxyOutboundObject(config, **kwargs) - - if not isinstance(proxyOutbound.get('streamSettings'), dict): - proxyOutbound['streamSettings'] = {} - - return proxyOutbound['streamSettings'] - - @staticmethod - def getProxyOutboundServer(config: dict, protocol: Protocol, **kwargs) -> dict: - """Return proxy outbound server.""" - value, proxyOutbound = ( - protocol.value.lower(), - ConfigXray.getProxyOutboundObject(config, **kwargs), - ) - - try: - if value == 'vless' or value == 'vmess': - server = proxyOutbound['settings']['vnext'][0] - elif value == 'socks': - server = proxyOutbound['settings'] - elif value == 'shadowsocks' or value == 'trojan': - server = proxyOutbound['settings']['servers'][0] - else: - server = {} - except Exception: - # Any non-exit exceptions - - server = {} - - if not isinstance(server, dict): - server = {} - - return server - - @staticmethod - def getProxyOutboundUser(config: dict, protocol: Protocol, **kwargs) -> dict: - """Return proxy outbound user.""" - proxyOutboundServer = ConfigXray.getProxyOutboundServer( - config, protocol, **kwargs - ) - - try: - user = proxyOutboundServer['users'][0] - except Exception: - # Any non-exit exceptions - - user = {} - - if not isinstance(user, dict): - user = {} - - return user - - @property - def proxyOutboundObject(self) -> dict: - """Return the proxy outbound object value.""" - try: - return ConfigXray.getProxyOutboundObject(self) - except Exception: - # Any non-exit exceptions - - return {} - - @property - def proxyProtocol(self) -> str: - """Return the proxy protocol value.""" - try: - return self.proxyOutboundObject['protocol'] - except Exception: - # Any non-exit exceptions - - return '' - - @property - def proxyServerObject(self) -> dict: - """Return the proxy server object value.""" - try: - proxyProtocol = self.proxyProtocol.lower() - - if proxyProtocol == 'vmess' or proxyProtocol == 'vless': - return self.proxyOutboundObject['settings']['vnext'][0] - - if proxyProtocol == 'socks': - return self.proxyOutboundObject['settings'] - - if proxyProtocol == 'shadowsocks' or proxyProtocol == 'trojan': - return self.proxyOutboundObject['settings']['servers'][0] - - return {} - except Exception: - # Any non-exit exceptions - - return {} - - @property - def proxyUserObject(self) -> dict: - """Return the proxy user object value.""" - try: - if self.proxyProtocol.lower() == 'socks': - return self.proxyServerObject - - return self.proxyServerObject['users'][0] - except Exception: - # Any non-exit exceptions - - return {} - - @property - def proxyStreamSettingsObject(self) -> dict: - """Return the proxy stream settings object value.""" - try: - return ConfigXray.getProxyOutboundStream(self) - except Exception: - # Any non-exit exceptions - - return {} - - @property - def proxyStreamSettingsTLS(self) -> str: - """Return the proxy stream settings TLS value.""" - try: - return self.proxyStreamSettingsObject.get('security', 'none') - except Exception: - # Any non-exit exceptions - - return '' - - @property - def proxyStreamSettingsTLSObject(self) -> dict: - """Return the proxy stream settings TLS object value.""" - try: - # tlsSettings, realitySettings - TLSKey = f'{self.proxyStreamSettingsTLS}Settings' - - return self.proxyStreamSettingsObject[TLSKey] - except Exception: - # Any non-exit exceptions - - return {} - - @property - def proxyStreamSettingsNetwork(self) -> str: - """Return the proxy stream settings network value.""" - try: - return self.proxyStreamSettingsObject['network'] - except Exception: - # Any non-exit exceptions - - return '' - - @property - def proxyStreamSettingsNetworkObject(self) -> dict: - """Return the proxy stream settings network object value.""" - try: - if self.proxyStreamSettingsNetwork == 'h2': - networkKey = 'httpSettings' - else: - networkKey = f'{self.proxyStreamSettingsNetwork}Settings' - - # Automatically use 'tcp' or 'raw' if needed - if ( - networkKey == 'tcpSettings' - and networkKey not in self.proxyStreamSettingsObject - ): - networkKey = 'rawSettings' - elif ( - networkKey == 'rawSettings' - and networkKey not in self.proxyStreamSettingsObject - ): - networkKey = 'tcpSettings' - - return self.proxyStreamSettingsObject[networkKey] - except Exception: - # Any non-exit exceptions - - return {} - - @property - def kwargsFromVMessProxyStreamSettingsNetworkObject(self) -> dict: - """Return the kwargs from v mess proxy stream settings network object value.""" - kwargs = {} - - try: - proxyStream = self.proxyStreamSettingsObject - - if 'finalmask' in proxyStream: - kwargs['fm'] = quote(UJSONEncoder.encode(proxyStream['finalmask'])) - except Exception: - # Any non-exit exceptions - - pass - - network = self.proxyStreamSettingsNetwork - networkObject = self.proxyStreamSettingsNetworkObject - - if not networkObject: - return kwargs - - def hasKey(key): - """Return whether key.""" - return networkObject.get(key) is not None - - if network == 'tcp' or network == 'raw': - try: - kwargs['type'] = networkObject['header']['type'] - except Exception: - # Any non-exit exceptions - - pass - - elif network == 'kcp': - try: - # Get order matters here - if hasKey('seed'): - kwargs['path'] = networkObject['seed'] - - kwargs['type'] = networkObject['header']['type'] - except Exception: - # Any non-exit exceptions - - pass - - elif network == 'ws': - try: - # Get order matters here - if hasKey('path'): - kwargs['path'] = quote(networkObject['path']) - - if networkObject['headers']['Host']: - kwargs['host'] = quote(networkObject['headers']['Host']) - except Exception: - # Any non-exit exceptions - - pass - - elif network == 'h2' or network == 'http': - try: - # Get order matters here - if hasKey('path'): - kwargs['path'] = quote(networkObject['path']) - - kwargs['host'] = quote(','.join(networkObject['host'])) - except Exception: - # Any non-exit exceptions - - pass - - elif network == 'quic': - try: - # Get order matters here - if hasKey('security'): - kwargs['host'] = networkObject['security'] - - if hasKey('key'): - kwargs['path'] = quote(networkObject['key']) - - kwargs['type'] = networkObject['header']['type'] - except Exception: - # Any non-exit exceptions - - pass - - elif network == 'grpc': - if hasKey('serviceName'): - kwargs['path'] = networkObject['serviceName'] - - elif network == 'httpupgrade': - try: - # Get order matters here - if hasKey('path'): - kwargs['path'] = quote(networkObject['path']) - - kwargs['host'] = quote(networkObject['host']) - except Exception: - # Any non-exit exceptions - - pass - - elif network == 'splithttp': - try: - # Get order matters here - if hasKey('path'): - kwargs['path'] = quote(networkObject['path']) - - kwargs['host'] = quote(networkObject['host']) - except Exception: - # Any non-exit exceptions - - pass - - elif network == 'xhttp': - try: - if hasKey('host'): - kwargs['host'] = quote(networkObject['host']) - - if hasKey('path'): - kwargs['path'] = quote(networkObject['path']) - - if hasKey('mode'): - kwargs['mode'] = networkObject['mode'] - - if hasKey('extra'): - kwargs['extra'] = quote(UJSONEncoder.encode(networkObject['extra'])) - except Exception: - # Any non-exit exceptions - - pass - - return kwargs - - @property - def kwargsFromVLESSProxyStreamSettingsNetworkObject(self) -> dict: - """Return the kwargs from VLESS proxy stream settings network object value.""" - kwargs = {} - - try: - proxyStream = self.proxyStreamSettingsObject - - if 'finalmask' in proxyStream: - kwargs['fm'] = quote(UJSONEncoder.encode(proxyStream['finalmask'])) - except Exception: - # Any non-exit exceptions - - pass - - network = self.proxyStreamSettingsNetwork - networkObject = self.proxyStreamSettingsNetworkObject - - if not networkObject: - return kwargs - - def hasKey(key): - """Return whether key.""" - return networkObject.get(key) is not None - - if network == 'tcp' or network == 'raw': - try: - kwargs['headerType'] = networkObject['header']['type'] - except Exception: - # Any non-exit exceptions - - pass - - elif network == 'kcp': - try: - # Get order matters here - if hasKey('seed'): - kwargs['seed'] = networkObject['seed'] - - kwargs['headerType'] = networkObject['header']['type'] - except Exception: - # Any non-exit exceptions - - pass - - elif network == 'ws': - try: - # Get order matters here - if hasKey('path'): - kwargs['path'] = quote(networkObject['path']) - - if networkObject['headers']['Host']: - kwargs['host'] = quote(networkObject['headers']['Host']) - except Exception: - # Any non-exit exceptions - - pass - - elif network == 'h2' or network == 'http': - try: - # Get order matters here - if hasKey('path'): - kwargs['path'] = quote(networkObject['path']) - - kwargs['host'] = quote(','.join(networkObject['host'])) - except Exception: - # Any non-exit exceptions - - pass - - elif network == 'quic': - try: - # Get order matters here - if hasKey('security'): - kwargs['quicSecurity'] = networkObject['security'] - - if hasKey('key'): - kwargs['path'] = quote(networkObject['key']) - - kwargs['headerType'] = networkObject['header']['type'] - except Exception: - # Any non-exit exceptions - - pass - - elif network == 'grpc': - if hasKey('serviceName'): - kwargs['serviceName'] = networkObject['serviceName'] - - if hasKey('multiMode'): - if networkObject['multiMode']: - kwargs['mode'] = 'multi' - - if hasKey('authority'): - kwargs['authority'] = networkObject['authority'] - - elif network == 'httpupgrade': - try: - # Get order matters here - if hasKey('path'): - kwargs['path'] = quote(networkObject['path']) - - kwargs['host'] = quote(networkObject['host']) - except Exception: - # Any non-exit exceptions - - pass - - elif network == 'splithttp': - try: - # Get order matters here - if hasKey('path'): - kwargs['path'] = quote(networkObject['path']) - - kwargs['host'] = quote(networkObject['host']) - except Exception: - # Any non-exit exceptions - - pass - - elif network == 'xhttp': - try: - if hasKey('host'): - kwargs['host'] = quote(networkObject['host']) - - if hasKey('path'): - kwargs['path'] = quote(networkObject['path']) - - if hasKey('mode'): - kwargs['mode'] = networkObject['mode'] - - if hasKey('extra'): - kwargs['extra'] = quote(UJSONEncoder.encode(networkObject['extra'])) - except Exception: - # Any non-exit exceptions - - pass - - return kwargs - - @staticmethod - def kwargs2ProxyStreamSettingsNetworkObject(type_, remote_host, kwargs) -> dict: - # Note: - # v2rayN share standard doesn't require unquote. Still - # unquote value according to (VMess AEAD / VLESS) standard - - """Build Xray transport settings from share-link keyword arguments.""" - if type_ == 'tcp' or type_ == 'raw': - TcpObject = {} - - if kwargs.get('headerType', 'none'): - headerType = kwargs.get('headerType', 'none') - - # Some dumb share link set this value to 'auto'. Protect it - if headerType != 'auto': - TcpObject['header'] = { - 'type': headerType, - } - - # Request settings for HTTP - if headerType == 'http' and kwargs.get('host'): - TcpObject['header']['request'] = { - 'version': '1.1', - 'method': 'GET', - 'path': [kwargs.get('path', '/')], - 'headers': { - 'Host': [unquote(kwargs.get('host'))], - 'User-Agent': [ - ( - 'Mozilla/5.0 (Windows NT 10.0; WOW64) ' - 'AppleWebKit/537.36 (KHTML, like Gecko) ' - 'Chrome/53.0.2785.143 Safari/537.36' - ), - ( - 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_0_2 like Mac OS X) ' - 'AppleWebKit/601.1 (KHTML, like Gecko) ' - 'CriOS/53.0.2785.109 ' - 'Mobile/14A456 ' - 'Safari/601.1.46' - ), - ], - 'Accept-Encoding': ['gzip, deflate'], - 'Connection': ['keep-alive'], - 'Pragma': 'no-cache', - }, - } - - return TcpObject - - elif type_ == 'kcp': - KcpObject = { - # Extension. From v2rayN - 'uplinkCapacity': 12, - 'downlinkCapacity': 100, - } - - if kwargs.get('headerType', 'none'): - headerType = kwargs.get('headerType', 'none') - - # Some dumb share link set this value to 'auto'. Protect it - if headerType != 'auto': - KcpObject['header'] = { - 'type': headerType, - } - - if kwargs.get('seed'): - KcpObject['seed'] = kwargs.get('seed') - - return KcpObject - - elif type_ == 'ws': - WebSocketObject = {} - - if kwargs.get('path', '/'): - WebSocketObject['path'] = unquote(kwargs.get('path', '/')) - - if kwargs.get('host'): - WebSocketObject['headers'] = { - 'Host': unquote(kwargs.get('host')), - } - - return WebSocketObject - - elif type_ == 'h2' or type_ == 'http': - HttpObject = {} - - if kwargs.get('host', remote_host): - # Protect "xxx," format - HttpObject['host'] = list( - filter( - lambda x: x != '', - unquote(kwargs.get('host', remote_host)).split(','), - ) - ) - - if kwargs.get('path', '/'): - HttpObject['path'] = unquote(kwargs.get('path', '/')) - - return HttpObject - - elif type_ == 'quic': - QuicObject = {} - - if kwargs.get('quicSecurity', 'none'): - QuicObject['security'] = kwargs.get('quicSecurity', 'none') - - if kwargs.get('key'): - QuicObject['key'] = unquote(kwargs.get('key')) - - if kwargs.get('headerType', 'none'): - headerType = kwargs.get('headerType', 'none') - - # Some dumb share link set this value to 'auto'. Protect it - if headerType != 'auto': - QuicObject['header'] = { - 'type': headerType, - } - - return QuicObject - - elif type_ == 'grpc': - GRPCObject = {} - - if kwargs.get('serviceName'): - GRPCObject['serviceName'] = kwargs.get('serviceName') - - if kwargs.get('mode', 'gun'): - GRPCObject['multiMode'] = kwargs.get('mode', 'gun') == 'multi' - - if kwargs.get('authority'): - GRPCObject['authority'] = kwargs.get('authority') - - return GRPCObject - - elif type_ == 'httpupgrade': - httpUpgradeObject = {} - - if kwargs.get('path', '/'): - httpUpgradeObject['path'] = unquote(kwargs.get('path', '/')) - - if kwargs.get('host'): - httpUpgradeObject['host'] = unquote(kwargs.get('host')) - - return httpUpgradeObject - - elif type_ == 'splithttp': - splitHttpObject = {} - - if kwargs.get('path', '/'): - splitHttpObject['path'] = unquote(kwargs.get('path', '/')) - - if kwargs.get('host'): - splitHttpObject['host'] = unquote(kwargs.get('host')) - - return splitHttpObject - - elif type_ == 'xhttp': - xhttpObject = {} - - if kwargs.get('host', ''): - xhttpObject['host'] = unquote(kwargs.get('host', '')) - - if kwargs.get('path', '/'): - xhttpObject['path'] = unquote(kwargs.get('path', '/')) - - if kwargs.get('mode', ''): - xhttpObject['mode'] = kwargs.get('mode', '') - - try: - if kwargs.get('extra', ''): - xhttpObject['extra'] = UJSONEncoder.decode( - unquote(kwargs.get('extra', '')) - ) - except Exception: - # Any non-exit exceptions - - pass - - return xhttpObject - - else: - return {} - - @property - def kwargsFromProxyStreamSettingsTLSObject(self) -> dict: - """Return the kwargs from proxy stream settings TLS object value.""" - kwargs = {} - - TLS = self.proxyStreamSettingsTLS - TLSObject = self.proxyStreamSettingsTLSObject - - if TLS == '' or TLS == 'none': - return kwargs - - if TLS == 'reality' or TLS == 'tls': - if TLSObject.get('fingerprint'): - kwargs['fp'] = TLSObject['fingerprint'] - - if TLSObject.get('serverName'): - kwargs['sni'] = TLSObject['serverName'] - - if TLSObject.get('alpn'): - kwargs['alpn'] = quote(','.join(TLSObject['alpn'])) - - if TLSObject.get('echConfigList'): - kwargs['ech'] = quote(TLSObject['echConfigList']) - - if TLSObject.get('pinnedPeerCertSha256'): - kwargs['pcs'] = quote(TLSObject['pinnedPeerCertSha256']) - - if TLSObject.get('verifyPeerCertByName'): - kwargs['vcn'] = quote(TLSObject['verifyPeerCertByName']) - - if TLS == 'reality': - # More kwargs for reality - if TLSObject.get('publicKey'): - kwargs['pbk'] = TLSObject['publicKey'] - - if TLSObject.get('shortId'): - kwargs['sid'] = TLSObject['shortId'] - - if TLSObject.get('mldsa65Verify'): - kwargs['pqv'] = TLSObject['mldsa65Verify'] - - if TLSObject.get('spiderX'): - kwargs['spx'] = quote(TLSObject['spiderX']) - - return kwargs - - @staticmethod - def kwargs2ProxyStreamSettingsTLSObject( - protocol, remote_host, security, kwargs - ) -> dict: - """Build Xray TLS or REALITY settings from share-link keyword arguments.""" - TLSObject = {} - - if security == 'reality' or security == 'tls': - # Note: If specify default value 'chrome', some share link fails. - # Leave default value as empty - fp = kwargs.get('fp') - sni = kwargs.get('sni') - # Protect "xxx," format - alpn = list( - filter( - lambda x: x != '', - unquote(kwargs.get('alpn', '')).split(','), - ) - ) - ech = unquote(kwargs.get('ech', '')) - pcs = unquote(kwargs.get('pcs', '')) - vcn = unquote(kwargs.get('vcn', '')) - - if fp: - TLSObject['fingerprint'] = fp - - if sni: - TLSObject['serverName'] = sni - else: - host = '' - - if protocol == 'vmess': - host = kwargs.get('host') - - if protocol == 'vless': - host = remote_host - - if host: - TLSObject['serverName'] = host - - if alpn: - TLSObject['alpn'] = alpn - - if ech: - TLSObject['echConfigList'] = ech - - if pcs: - TLSObject['pinnedPeerCertSha256'] = pcs - - if vcn: - TLSObject['verifyPeerCertByName'] = vcn - - if security == 'reality': - # More args for reality - pbk = kwargs.get('pbk') - sid = kwargs.get('sid', '') - pqv = kwargs.get('pqv', '') - spx = kwargs.get('spx', '') - - if pbk: - TLSObject['publicKey'] = pbk - - TLSObject['shortId'] = sid - TLSObject['mldsa65Verify'] = pqv - TLSObject['spiderX'] = unquote(spx) - - return TLSObject - - @staticmethod - def kwargs2ProxyUserObject(protocol, uuid_, encryption, kwargs) -> dict: - """Build an Xray proxy user object from share-link values.""" - if protocol == 'vmess': - UserObject = { - 'id': uuid_, - 'security': encryption, - 'email': PROXY_OUTBOUND_USER_EMAIL, - } - - # For VMess(v2rayN share standard) only. - if kwargs.get('aid') is not None: - # int: extra guard - UserObject['alterId'] = int(kwargs.get('aid')) - - return UserObject - - if protocol == 'vless': - UserObject = { - 'id': uuid_, - 'encryption': encryption, - 'email': PROXY_OUTBOUND_USER_EMAIL, - } - - if kwargs.get('flow'): - # flow is empty, TLS. Otherwise, XTLS. - UserObject['flow'] = kwargs.get('flow') - - return UserObject - - return {} - - @staticmethod - def URI2ProxyOutboundObjectVMess(URI: str) -> Tuple[str, dict]: - """Parse a VMess URI into a remark and Xray proxy outbound.""" - try: - myHead, myBody = URI.split('://') - except Exception: - # Any non-exit exceptions - - return '', {} - - try: - myData = PyBase64Encoder.decode(myBody) - myJSON = UJSONEncoder.decode(myData) - except Exception: - # Any non-exit exceptions - - return ConfigXray.URI2ProxyOutboundObjectVLESS(URI, protocol='vmess') - else: - - def getOrDefault(key, default=''): - """Return or default.""" - return myJSON.get(key, default) - - remark = unquote(getOrDefault('ps')) - - return ( - remark, - # Ignore: v - ConfigXrayProxyOutboundObjectV( - 'vmess', - getOrDefault('add'), - int(getOrDefault('port', '0')), - getOrDefault('id'), - getOrDefault('scy', 'auto'), - getOrDefault('net', 'tcp'), - getOrDefault('tls', 'none'), - # kwargs. v2rayN share standard -> (VMess AEAD / VLESS) standard - aid=int(getOrDefault('aid', '0')), - headerType=getOrDefault('type', 'none'), - host=getOrDefault('host'), - quicSecurity=getOrDefault('host', 'none'), - path=getOrDefault('path'), - key=getOrDefault('path'), - seed=getOrDefault('path'), - serviceName=getOrDefault('path'), - sni=getOrDefault('sni'), - alpn=getOrDefault('alpn'), - # Note: If specify default value 'chrome', some share link fails. - # Leave default value as empty - fp=getOrDefault('fp'), - ), - ) - - @staticmethod - def URI2ProxyOutboundObjectVLESS(URI: str, **kwargs) -> Tuple[str, dict]: - """Parse a VLESS URI into a remark and Xray proxy outbound.""" - result = urlparse(URI) - remark = unquote(result.fragment) - queryObject = {key: value for key, value in parse_qsl(result.query)} - - uuid_, server = result.netloc.split('@') - - remote_host, remote_port = parseHostPort(server) - - encryption = queryObject.pop('encryption', 'none') - type_ = queryObject.pop('type', 'tcp') - security = queryObject.pop('security', 'none') - - return ( - remark, - ConfigXrayProxyOutboundObjectV( - kwargs.pop('protocol', 'vless'), - remote_host, - int(remote_port), - uuid_, - encryption, - type_, - security, - # kwargs - **queryObject, - ), - ) - - @staticmethod - def URI2ProxyOutboundObjectSS(URI: str) -> Tuple[str, dict]: - """Parse a Shadowsocks URI into a remark and Xray proxy outbound.""" - try: - result = urlparse(URI) - remark = unquote(result.fragment) - except Exception: - # Any non-exit exceptions - - return '', {} - - def getSSParams(): - # Begin SIP002... - """Return ss params.""" - try: - # Try pack with 3 element - userinfo, server = result.netloc.split('@') - - # Some old SS share link doesn't add padding - # in base64 encoding. Add padding to userinfo - return [ - *PyBase64Encoder.decode(userinfo + '===').decode().split(':', 1), - *parseHostPort(server), - ] - except Exception: - # Any non-exit exceptions - - pass - - try: - # Try pack with 4 element - userinfo, server = result.netloc.split('@') - - return [*userinfo.split(':', 1), *parseHostPort(server)] - except Exception: - # Any non-exit exceptions - - pass - - try: - # ss://base64...#fragment - userinfo, server = ( - PyBase64Encoder.decode(result.netloc).decode().split('@') - ) - - return [*userinfo.split(':', 1), *parseHostPort(server)] - except Exception: - # Any non-exit exceptions - - pass - - raise ValueError(f'Invalid SS URI format {URI}') - - return ( - remark, - ConfigXrayProxyOutboundObjectSS(*getSSParams()), - ) - - @staticmethod - def URI2ProxyOutboundObjectSocks(URI: str) -> Tuple[str, dict]: - """Parse a SOCKS URI into a remark and Xray proxy outbound.""" - result = urlparse(URI) - remark = unquote(result.fragment) - - address = result.hostname or '' - port = result.port or 0 - user = unquote(result.username or '') - password = unquote(result.password or '') - - if address == '' or port == 0: - raise ValueError(f'Invalid SOCKS URI format {URI}') - - return ( - remark, - ConfigXrayProxyOutboundObjectSocks(address, port, user, password), - ) - - @staticmethod - def URI2ProxyOutboundObjectTrojan(URI: str) -> Tuple[str, dict]: - """Parse a Trojan URI into a remark and Xray proxy outbound.""" - result = urlparse(URI) - remark = unquote(result.fragment) - queryObject = {key: value for key, value in parse_qsl(result.query)} - - password, server = result.netloc.split('@') - - address, port = parseHostPort(server) - - type_ = queryObject.pop('type', 'tcp') - # For Trojan: Assign tls by default - security = queryObject.pop('security', 'tls') - - return ( - remark, - ConfigXrayProxyOutboundObjectTrojan( - password, address, port, type_, security, **queryObject - ), - ) - - @staticmethod - def URI2ProxyOutboundObject(URI: str) -> Tuple[str, dict]: - """Dispatch a supported share URI to its Xray outbound parser.""" - if URI.startswith('vmess://'): - return ConfigXray.URI2ProxyOutboundObjectVMess(URI) - - if URI.startswith('vless://'): - return ConfigXray.URI2ProxyOutboundObjectVLESS(URI) - - if URI.startswith('ss://'): - return ConfigXray.URI2ProxyOutboundObjectSS(URI) - - if URI.startswith(('socks://', 'socks5://', 'socks5h://')): - return ConfigXray.URI2ProxyOutboundObjectSocks(URI) - - if URI.startswith('trojan://'): - return ConfigXray.URI2ProxyOutboundObjectTrojan(URI) - - raise ValueError(f'Unrecognized URI scheme {URI}') - - @property - def itemProtocol(self) -> str: - """Return the item protocol value.""" - return Protocol.toEnum(self.proxyProtocol).value - - @property - def itemAddress(self) -> str: - """Return the item address value.""" - addr = self.proxyServerObject.get('address', '') - - return str(addr) - - @property - def itemPort(self) -> str: - """Return the item port value.""" - port = self.proxyServerObject.get('port', '') - - return str(port) - - @property - def itemTransport(self) -> str: - """Return the item transport value.""" - return self.proxyStreamSettingsNetwork - - @property - def itemTLS(self) -> str: - """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) - - return super().toJSONString(indent=indent) - - def toURI(self, remark: str = '') -> str: - """Export the configuration as a share URI.""" - if remark == '': - override = self.itemRemark - else: - override = remark - - protocol = self.proxyProtocol.lower() - - if protocol == 'vmess': - netloc = PyBase64Encoder.encode( - UJSONEncoder.encode( - { - 'v': '2', - 'ps': override, - **{ - key: str(self.proxyServerObject[value]) - for key, value in { - 'add': 'address', - 'port': 'port', - }.items() - }, - **{ - key: str(self.proxyUserObject[value]) - for key, value in { - 'id': 'id', - 'aid': 'alterId', - 'scy': 'security', - }.items() - }, - 'net': self.proxyStreamSettingsNetwork, - 'tls': self.proxyStreamSettingsTLS, - # kwargs - **self.kwargsFromVMessProxyStreamSettingsNetworkObject, - **self.kwargsFromProxyStreamSettingsTLSObject, - } - ).encode() - ).decode() - - return urlunparse(['vmess', netloc, '', '', {}, '']) - - if protocol == 'vless': - flowArg = {} - - if self.proxyUserObject.get('flow'): - flowArg['flow'] = self.proxyUserObject['flow'] - - netloc = ( - self.proxyUserObject['id'] - + '@' - + self.proxyServerObject['address'] - + ':' - + str(self.proxyServerObject['port']) - ) - - query = queryStringFromItems( - { - 'encryption': self.proxyUserObject['encryption'], - 'type': self.proxyStreamSettingsNetwork, - 'security': self.proxyStreamSettingsTLS, - # kwargs - **flowArg, - **self.kwargsFromVLESSProxyStreamSettingsNetworkObject, - **self.kwargsFromProxyStreamSettingsTLSObject, - }.items() - ) - - return urlunparse(['vless', netloc, '', '', query, quote(override)]) - - if protocol == 'shadowsocks': - method, password, address, port = list( - self.proxyServerObject[value] - for value in ['method', 'password', 'address', 'port'] - ) - - netloc = f'{quote(method)}:{quote(password)}@{address}:{port}' - - return urlunparse(['ss', netloc, '', '', '', quote(override)]) - - if protocol == 'socks': - address, port = list( - self.proxyServerObject[value] for value in ['address', 'port'] - ) - user = self.proxyUserObject.get('user', '') - password = self.proxyUserObject.get('pass', '') - - if user != '' or password != '': - netloc = f'{quote(user)}:{quote(password)}@{address}:{port}' - else: - netloc = f'{address}:{port}' - - return urlunparse(['socks5', netloc, '', '', '', quote(override)]) - - if protocol == 'trojan': - password, address, port = list( - self.proxyServerObject[value] - for value in ['password', 'address', 'port'] - ) - - netloc = f'{quote(password)}@{address}:{port}' - - query = queryStringFromItems( - { - 'type': self.proxyStreamSettingsNetwork, - 'security': self.proxyStreamSettingsTLS, - # kwargs - **self.kwargsFromVLESSProxyStreamSettingsNetworkObject, - **self.kwargsFromProxyStreamSettingsTLSObject, - }.items() - ) - - return urlunparse(['trojan', netloc, '', '', query, quote(override)]) - - # Unrecognized protocol - return '' - - def fromURI(self, URI: str) -> bool: - """Populate the configuration from a share URI.""" - try: - remark, proxyOutboundObject = ConfigXray.URI2ProxyOutboundObject(URI) - - factory = copy.deepcopy(BLANK_CONFIG_XRAY) - factory['outbounds'] = [ - # proxy - proxyOutboundObject, - # direct - { - 'tag': 'direct', - 'protocol': 'freedom', - 'settings': {}, - }, - # block - { - 'tag': 'block', - 'protocol': 'blackhole', - 'settings': { - 'response': { - 'type': 'http', - } - }, - }, - ] - - dict.__init__(self, **factory) - - self.setExtras('remark', remark) - - return True - except Exception: - # Any non-exit exceptions - - return False - - def httpProxy(self) -> str: - """Return the configured local HTTP proxy endpoint.""" - try: - for inbound in self['inbounds']: - if inbound['protocol'] == 'http': - # Note: If there are multiple http inbounds - # satisfied, the first one will be chosen. - return str(inbound['listen']) + ':' + str(inbound['port']) - - return '' - except Exception: - # Any non-exit exceptions - - return '' - - def socksProxy(self) -> str: - """Return the configured local SOCKS proxy endpoint.""" - try: - for inbound in self['inbounds']: - if inbound['protocol'] == 'socks': - # Note: If there are multiple socks inbounds - # satisfied, the first one will be chosen. - return str(inbound['listen']) + ':' + str(inbound['port']) - - return '' - except Exception: - # Any non-exit exceptions - - return '' - - def setHttpProxy(self, endpoint: str) -> bool: - """Set the local HTTP proxy endpoint.""" - try: - if self.get('inbounds') is None: - self['inbounds'] = [] - - if not isinstance(self['inbounds'], list): - self['inbounds'] = [] - - if endpoint == '': - for inbound in self['inbounds']: - if inbound['protocol'] == 'http': - self['inbounds'].remove(inbound) - - # Remove first chosen - return True - - # None satisfied. Return success - return True - - listen, port = parseHostPort(endpoint) - - for inbound in self['inbounds']: - if inbound['protocol'] == 'http': - # Note: If there are multiple http inbounds - # satisfied, the first one will be chosen. - inbound['listen'], inbound['port'] = listen, int(port) - - return True - - # No entry. Add new one - self['inbounds'].append( - { - 'tag': 'http', - 'port': int(port), - 'listen': listen, - 'protocol': 'http', - 'sniffing': { - 'enabled': True, - 'destOverride': [ - 'http', - 'tls', - ], - }, - 'settings': { - 'auth': 'noauth', - 'udp': True, - 'allowTransparent': False, - }, - } - ) - - return True - except Exception: - # Any non-exit exceptions - - return False - - def setSocksProxy(self, endpoint: str) -> bool: - """Set the local SOCKS proxy endpoint.""" - try: - if self.get('inbounds') is None: - self['inbounds'] = [] - - if not isinstance(self['inbounds'], list): - self['inbounds'] = [] - - if endpoint == '': - for inbound in self['inbounds']: - if inbound['protocol'] == 'socks': - self['inbounds'].remove(inbound) - - # Remove first chosen - return True - - # None satisfied. Return success - return True - - listen, port = parseHostPort(endpoint) - - for inbound in self['inbounds']: - if inbound['protocol'] == 'socks': - # Note: If there are multiple socks inbounds - # satisfied, the first one will be chosen. - inbound['listen'], inbound['port'] = listen, int(port) - - return True - - # No entry. Add new one - self['inbounds'].append( - { - 'tag': 'socks', - 'port': int(port), - 'listen': listen, - 'protocol': 'socks', - 'sniffing': { - 'enabled': True, - 'destOverride': [ - 'http', - 'tls', - ], - }, - 'settings': { - 'auth': 'noauth', - 'udp': True, - 'allowTransparent': False, - }, - } - ) - - return True - except Exception: - # Any non-exit exceptions - - return False - - -BLANK_CONFIG_HYSTERIA1 = { - 'server': '', - 'protocol': 'udp', - 'socks5': { - 'listen': '127.0.0.1:10808', - }, - 'http': { - 'listen': '127.0.0.1:10809', - }, -} - - -class ConfigHysteria1(ConfigFactory): - """Represent Hysteria 1 client configuration and share links.""" - - def __init__(self, config: Union[str, dict] = '', **kwargs): - """Initialize the ConfigHysteria1.""" - super().__init__(config, **kwargs) - - def coreName(self) -> str: - """Return the core implementation name.""" - return 'Hysteria1' - - @property - def itemProtocol(self) -> str: - """Return the item protocol value.""" - return Protocol.Hysteria1.value - - @property - def itemAddress(self) -> str: - """Return the item address value.""" - server = self.get('server', '') - - pos = server.rfind(':') - - if pos == -1: - return server - else: - return server[:pos] - - @property - def itemPort(self) -> str: - """Return the item port value.""" - server = self.get('server', '') - - pos = server.rfind(':') - - if pos == -1: - return '' - else: - return server[pos + 1 :] - - @property - def itemTransport(self) -> str: - """Return the item transport value.""" - return '' - - @property - def itemTLS(self) -> str: - """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) - - return super().toJSONString(indent=indent) - - def toURI(self, remark: str = '') -> str: - """Export the configuration as a share URI.""" - if remark == '': - override = self.itemRemark - else: - override = remark - - try: - netloc, mport = self['server'].split(',') - except Exception: - # Any non-exit exceptions - - netloc, mport = self['server'], '' - - if mport: - mportArgs = {'mport': mport} - else: - mportArgs = {} - - protocol = self.get('protocol', 'udp') - - if self.get('auth_str'): - # auth in string - authArgs = {'auth': self['auth_str']} - else: - authArgs = {} - - if self.get('server_name'): - peerArgs = {'peer': self['server_name']} - else: - peerArgs = {} - - if self.get('insecure') is True: - insecureArgs = {'insecure': '1'} - else: - insecureArgs = {} - - upmbps = self['up_mbps'] - downmbps = self['down_mbps'] - - if self.get('alpn'): - alpnArgs = {'alpn': self['alpn']} - else: - alpnArgs = {} - - if self.get('obfs'): - obfsParamArgs = {'obfsParam': self['obfs']} - else: - obfsParamArgs = {} - - query = queryStringFromItems( - { - **mportArgs, - 'protocol': protocol, - **authArgs, - **peerArgs, - **insecureArgs, - 'upmbps': upmbps, - 'downmbps': downmbps, - **alpnArgs, - **obfsParamArgs, - }.items() - ) - - return urlunparse(['hysteria', netloc, '', '', query, quote(override)]) - - def fromURI(self, URI: str) -> bool: - """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': - raise ValueError('Invalid hysteria1 URI scheme') - - server = result.netloc - - mport = queryObject.get('mport', '') - - if mport: - server += f',{mport}' - - protocol = queryObject.get('protocol', 'udp') - - if queryObject.get('auth', ''): - # auth in string - authArgs = {'auth_str': queryObject['auth']} - else: - authArgs = {} - - if queryObject.get('peer', ''): - peerArgs = {'server_name': queryObject['peer']} - else: - peerArgs = {} - - insecure = queryObject.get('insecure', False) - - if isinstance(insecure, bool): - pass - elif insecure == '1': - insecure = True - else: - insecure = False - - upmbps = queryObject.get('upmbps', 24) - downmbps = queryObject.get('downmbps', 96) - - if queryObject.get('alpn', ''): - alpnArgs = {'alpn': queryObject['alpn']} - else: - alpnArgs = {} - - # obfs mode seems not required, ignored... - - if queryObject.get('obfsParam', ''): - obfsParamArgs = {'obfs': queryObject['obfsParam']} - else: - obfsParamArgs = {} - - dict.__init__( - self, - **{ - 'server': server, - 'protocol': protocol, - **authArgs, - **obfsParamArgs, - 'up_mbps': int(upmbps), - 'down_mbps': int(downmbps), - **peerArgs, - **alpnArgs, - 'insecure': insecure, - 'socks5': { - 'listen': '127.0.0.1:10808', - }, - 'http': { - 'listen': '127.0.0.1:10809', - }, - }, - ) - - self.setExtras('remark', remark) - - return True - except Exception: - # Any non-exit exceptions - - return False - - def httpProxy(self) -> str: - """Return the configured local HTTP proxy endpoint.""" - try: - return self['http']['listen'] - except Exception: - # Any non-exit exceptions - - return '' - - def socksProxy(self) -> str: - """Return the configured local SOCKS proxy endpoint.""" - try: - return self['socks5']['listen'] - except Exception: - # Any non-exit exceptions - - return '' - - def setHttpProxy(self, endpoint: str) -> bool: - """Set the local HTTP proxy endpoint.""" - try: - if endpoint == '': - self.pop('http', None) - - return True - - if self.get('http') is None: - self['http'] = {} - - self['http']['listen'] = endpoint - - return True - except Exception: - # Any non-exit exceptions - - return False - - def setSocksProxy(self, endpoint: str) -> bool: - """Set the local SOCKS proxy endpoint.""" - try: - if endpoint == '': - self.pop('socks5', None) - - return True - - if self.get('socks5') is None: - self['socks5'] = {} - - self['socks5']['listen'] = endpoint - - return True - except Exception: - # Any non-exit exceptions - - return False - - -BLANK_CONFIG_HYSTERIA2 = { - 'server': '', - 'tls': { - 'insecure': False, - }, - 'socks5': { - 'listen': '127.0.0.1:10808', - }, - 'http': { - 'listen': '127.0.0.1:10809', - }, -} - - -class ConfigHysteria2(ConfigFactory): - """Represent Hysteria 2 client configuration and share links.""" - - def __init__(self, config: Union[str, dict] = '', **kwargs): - """Initialize the ConfigHysteria2.""" - super().__init__(config, **kwargs) - - def coreName(self) -> str: - """Return the core implementation name.""" - return 'Hysteria2' - - @property - def itemProtocol(self) -> str: - """Return the item protocol value.""" - return Protocol.Hysteria2.value - - @staticmethod - def splitServerAddressPort(server: str) -> Tuple[str, str]: - """Split a standard or Realm-mode server value into display fields.""" - if server.startswith(('realm://', 'realm+http://')): - result = urlparse(server) - - try: - port = result.port - except ValueError: - port = None - - return result.hostname or '', str(port) if port is not None else '' - - pos = server.rfind(':') - - if pos == -1: - return server, '' - else: - return server[:pos], server[pos + 1 :] - - @property - def itemAddress(self) -> str: - """Return the item address value.""" - address, _port = self.splitServerAddressPort(self.get('server', '')) - - return address - - @property - def itemPort(self) -> str: - """Return the item port value.""" - _address, port = self.splitServerAddressPort(self.get('server', '')) - - return port - - @property - def itemTransport(self) -> str: - """Return the item transport value.""" - return '' - - @property - def itemTLS(self) -> str: - """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) - - return super().toJSONString(indent=indent) - - @staticmethod - def splitHysteria2RealmQueryItems(queryItems): - """Separate Realm transport parameters from Hysteria 2 parameters.""" - realmItems = [] - hysteria2Items = [] - - for key, value in queryItems: - if key in ['stun', 'lport']: - realmItems.append((key, value)) - else: - hysteria2Items.append((key, value)) - - return realmItems, hysteria2Items - - def toURI(self, remark: str = '') -> str: - """Export the configuration as a share URI.""" - if remark == '': - override = self.itemRemark - else: - override = remark - - TLSArg, obfsArg = {}, {} - - if self.get('tls'): - if self['tls'].get('sni'): - TLSArg['sni'] = self['tls']['sni'] - - if self['tls'].get('insecure') is True: - TLSArg['insecure'] = '1' - else: - TLSArg['insecure'] = '0' - - if self['tls'].get('pinSHA256'): - TLSArg['pinSHA256'] = self['tls']['pinSHA256'] - - if self.get('obfs'): - obfsType = self['obfs'].get('type', 'salamander') - - obfsArg['obfs'] = obfsType - obfsArg['obfs-password'] = self['obfs'][obfsType]['password'] - - if self['server'].startswith(('realm://', 'realm+http://')): - result = urlparse(self['server']) - - if result.scheme == 'realm+http': - scheme = 'hysteria2+realm+http' - else: - scheme = 'hysteria2+realm' - - realmItems, _hysteria2Items = self.splitHysteria2RealmQueryItems( - parse_qsl(result.query) - ) - query = queryStringFromItems( - [ - ('auth', self['auth']), - *TLSArg.items(), - *obfsArg.items(), - *realmItems, - ] - ) - - return urlunparse( - [scheme, result.netloc, result.path, '', query, quote(override)] - ) - - netloc, query = ( - self['auth'] + '@' + self['server'], - queryStringFromItems([*TLSArg.items(), *obfsArg.items()]), - ) - - return urlunparse(['hysteria2', netloc, '', '', query, quote(override)]) - - def fromURI(self, URI: str) -> bool: - """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} - - if result.scheme not in [ - 'hysteria2', - 'hy2', - 'hysteria2+realm', - 'hysteria2+realm+http', - ]: - raise ValueError('Invalid hysteria2 URI scheme') - - if result.scheme in ['hysteria2+realm', 'hysteria2+realm+http']: - auth = queryObject.get('auth', '') - realmItems, _hysteria2Items = self.splitHysteria2RealmQueryItems( - queryItems - ) - - if result.scheme == 'hysteria2+realm+http': - realmScheme = 'realm+http' - else: - realmScheme = 'realm' - - server = urlunparse( - [ - realmScheme, - result.netloc, - result.path, - '', - queryStringFromItems(realmItems), - '', - ] - ) - else: - auth, server = result.netloc.split('@') - - obfs = queryObject.get('obfs', '') - obfsPassword = queryObject.get('obfs-password', '') - sni = queryObject.get('sni', '') - - insecure = queryObject.get('insecure', False) - - if isinstance(insecure, bool): - pass - elif insecure == '1': - insecure = True - else: - insecure = False - - pinSHA256 = queryObject.get('pinSHA256', '') - - obfsArg = {} - pinSHA256Arg = {} - - if obfs and obfsPassword: - obfsArg['obfs'] = { - 'type': obfs, - obfs: { - 'password': obfsPassword, - }, - } - - if pinSHA256: - pinSHA256Arg['pinSHA256'] = pinSHA256 - - dict.__init__( - self, - **{ - 'server': server, - 'auth': auth, - **obfsArg, - 'tls': { - 'sni': sni, - 'insecure': insecure, - **pinSHA256Arg, - }, - 'socks5': { - 'listen': '127.0.0.1:10808', - }, - 'http': { - 'listen': '127.0.0.1:10809', - }, - }, - ) - - self.setExtras('remark', remark) - - return True - except Exception: - # Any non-exit exceptions - - return False - - def httpProxy(self) -> str: - """Return the configured local HTTP proxy endpoint.""" - try: - return self['http']['listen'] - except Exception: - # Any non-exit exceptions - - return '' - - def socksProxy(self) -> str: - """Return the configured local SOCKS proxy endpoint.""" - try: - return self['socks5']['listen'] - except Exception: - # Any non-exit exceptions - - return '' - - def setHttpProxy(self, endpoint: str) -> bool: - """Set the local HTTP proxy endpoint.""" - try: - if endpoint == '': - self.pop('http', None) - - return True - - if self.get('http') is None: - self['http'] = {} - - self['http']['listen'] = endpoint - - return True - except Exception: - # Any non-exit exceptions - - return False - - def setSocksProxy(self, endpoint: str) -> bool: - """Set the local SOCKS proxy endpoint.""" - try: - if endpoint == '': - self.pop('socks5', None) - - return True - - if self.get('socks5') is None: - self['socks5'] = {} - - self['socks5']['listen'] = endpoint - - return True - except Exception: - # Any non-exit exceptions - - return False - - -def configXrayEmptyProxyOutboundObject(protocol: Protocol) -> dict: - """Return the config Xray empty proxy outbound object value used by the application.""" - value = protocol.value.lower() - - if value == 'vless' or value == 'vmess': - return { - 'tag': 'proxy', - 'protocol': value, - 'settings': { - 'vnext': [ - { - 'address': '', - 'port': 0, - 'users': [ - { - 'email': PROXY_OUTBOUND_USER_EMAIL, - }, - ], - }, - ] - }, - 'streamSettings': { - 'network': 'tcp', - }, - 'mux': { - 'enabled': False, - 'concurrency': -1, - }, - } - elif value == 'shadowsocks': - return { - 'tag': 'proxy', - 'protocol': 'shadowsocks', - 'settings': { - 'servers': [ - { - 'address': '', - 'port': 0, - 'method': '', - 'password': '', - 'email': PROXY_OUTBOUND_USER_EMAIL, - 'ota': False, - } - ] - }, - 'streamSettings': { - 'network': 'tcp', - }, - 'mux': { - 'enabled': False, - 'concurrency': -1, - }, - } - elif value == 'socks': - return { - 'tag': 'proxy', - 'protocol': 'socks', - 'settings': { - 'address': '', - 'port': 0, - }, - 'streamSettings': { - 'network': 'tcp', - }, - 'mux': { - 'enabled': False, - 'concurrency': -1, - }, - } - elif value == 'trojan': - return { - 'tag': 'proxy', - 'protocol': 'trojan', - 'settings': { - 'servers': [ - { - 'address': '', - 'port': 0, - 'password': '', - 'email': PROXY_OUTBOUND_USER_EMAIL, - } - ] - }, - 'streamSettings': { - 'network': 'tcp', - }, - 'mux': { - 'enabled': False, - 'concurrency': -1, - }, - } - else: - return {} - - -def configFactoryFromDict(config: dict, **kwargs) -> ConfigFactory: - """Return the config factory from dict value used by the application.""" - if not isinstance(config, dict): - return ConfigFactory() - - def hasField(field): - """Return whether field.""" - return config.get(field) is not None - - if hasField('inbounds') or hasField('outbounds'): - # Assuming is Xray-Core - return ConfigXray(config, **kwargs) - - if hasField('server'): - if ( - hasField('protocol') - or hasField('up_mbps') - or hasField('down_mbps') - or hasField('auth_str') - or hasField('alpn') - or hasField('server_name') - or hasField('insecure') - or hasField('recv_window_conn') - or hasField('recv_window') - or isinstance(config.get('obfs'), str) - or hasField('fast_open') - or hasField('lazy_start') - ): - return ConfigHysteria1(config, **kwargs) - if ( - hasField('auth') - or hasField('tls') - or hasField('transport') - or hasField('quic') - or hasField('bandwidth') - or hasField('tcpForwarding') - or hasField('udpForwarding') - or hasField('tcpTProxy') - or hasField('udpTProxy') - or isinstance(config.get('obfs'), dict) - or hasField('fastOpen') - or hasField('lazy') - ): - return ConfigHysteria2(config, **kwargs) - - # Copied to factory unrecognized - return ConfigFactory(config, **kwargs) - - -def configFactoryFromAny(config: Union[str, dict], **kwargs) -> ConfigFactory: - """Return the config factory from any value used by the application.""" - if isinstance(config, str): - if config.startswith( - ( - 'vmess://', - 'vless://', - 'ss://', - 'trojan://', - 'socks://', - 'socks5://', - 'socks5h://', - ) - ): - return ConfigXray(config, **kwargs) - if config.startswith('hysteria://'): - return ConfigHysteria1(config, **kwargs) - if config.startswith( - ( - 'hy2://', - 'hysteria2://', - 'hysteria2+realm://', - 'hysteria2+realm+http://', - ) - ): - return ConfigHysteria2(config, **kwargs) - - try: - # Try to construct from JSON string - return configFactoryFromDict(UJSONEncoder.decode(config), **kwargs) - except Exception: - # Any non-exit exceptions - - return ConfigFactory(**kwargs) - - if isinstance(config, dict): - return configFactoryFromDict(config, **kwargs) - - return ConfigFactory(**kwargs) - - -def configFactoryBlank(protocol: Protocol) -> ConfigFactory: - """Return the config factory blank value used by the application.""" - if protocol == Protocol.VMess or protocol == Protocol.VLESS: - factory = configFactoryFromDict(BLANK_CONFIG_XRAY) - factory['outbounds'][0]['protocol'] = protocol.value.lower() - factory['outbounds'][0]['settings']['vnext'] = [ - { - 'address': '', - 'port': 0, - 'users': [{'email': PROXY_OUTBOUND_USER_EMAIL}], - }, - ] - - return factory - - if protocol == Protocol.Socks: - factory = configFactoryFromDict(BLANK_CONFIG_XRAY) - factory['outbounds'][0]['protocol'] = protocol.value.lower() - factory['outbounds'][0]['settings'] = { - 'address': '', - 'port': 0, - } - - return factory - - if protocol == Protocol.Shadowsocks or protocol == Protocol.Trojan: - factory = configFactoryFromDict(BLANK_CONFIG_XRAY) - factory['outbounds'][0]['protocol'] = protocol.value.lower() - factory['outbounds'][0]['settings']['servers'] = [ - { - 'address': '', - 'port': 0, - 'email': PROXY_OUTBOUND_USER_EMAIL, - }, - ] - - return factory - - if protocol == Protocol.Hysteria1: - factory = configFactoryFromDict(BLANK_CONFIG_HYSTERIA1) - - return factory - if protocol == Protocol.Hysteria2: - factory = configFactoryFromDict(BLANK_CONFIG_HYSTERIA2) - - return factory - - return ConfigFactory() +from Furious.Plugins.Official.Configuration import * +from Furious.Plugins.Configuration import * diff --git a/Furious/Library/UserServers.py b/Furious/Library/UserServers.py index 8252083a..039f7c89 100644 --- a/Furious/Library/UserServers.py +++ b/Furious/Library/UserServers.py @@ -22,7 +22,7 @@ from __future__ import annotations from Furious.Frozenlib import * from Furious.Interface import * from Furious.Library.Encoder import * -from Furious.Library.Configuration import configFactoryFromAny +from Furious.Plugins.Configuration import configFactoryFromAny __all__ = ['UserServers'] diff --git a/Furious/Library/__init__.py b/Furious/Library/__init__.py index 9aa90431..418ec4ef 100644 --- a/Furious/Library/__init__.py +++ b/Furious/Library/__init__.py @@ -19,6 +19,7 @@ from __future__ import annotations +from Furious.Plugins.Configuration import * + from .Encoder import * from .Storage import * -from .Configuration import * diff --git a/Furious/Plugins/API.py b/Furious/Plugins/API.py new file mode 100644 index 00000000..62afaf55 --- /dev/null +++ b/Furious/Plugins/API.py @@ -0,0 +1,98 @@ +"""Define the public contract implemented by Furious core plugins.""" + +from __future__ import annotations + +from dataclasses import dataclass + +__all__ = ['PLUGIN_API_VERSION', 'PluginProtocol', 'FuriousPlugin'] + +PLUGIN_API_VERSION = 1 + + +@dataclass(frozen=True) +class PluginProtocol: + """Describe one server protocol contributed by a plugin.""" + + id: str + displayName: str + addActionText: str + menuOrder: int = 0 + separatorBefore: bool = False + + +class FuriousPlugin: + """Provide configuration, UI, and process hooks for one proxy core family.""" + + apiVersion = PLUGIN_API_VERSION + pluginId = '' + displayName = '' + protocols = tuple() + configurationTypes = tuple() + coreTypes = tuple() + + def configFromString(self, config: str, **kwargs): + """Construct a supported configuration from text or return ``None``.""" + return None + + def configFromDict(self, config: dict, **kwargs): + """Construct a supported configuration mapping or return ``None``.""" + return None + + def blankConfig(self, protocol, **kwargs): + """Construct a blank configuration for a contributed protocol.""" + return None + + def createEditorForProtocol(self, protocol, parent=None, **kwargs): + """Create the configuration editor for a contributed protocol.""" + return None + + def createEditorForConfig(self, config, parent=None, **kwargs): + """Create the configuration editor for a plugin configuration.""" + return self.createEditorForProtocol(config.itemProtocol, parent, **kwargs) + + def createRoutingEditor(self, parent=None, **kwargs): + """Create an optional plugin-specific routing editor.""" + return None + + def createMainWindowTools(self, parent=None, **kwargs): + """Return optional actions for the main window's Tools menu.""" + return tuple() + + def routingChoices(self): + """Return optional ``(label, value)`` routing choices for the tray.""" + return tuple() + + def configureEnvironment(self): + """Set optional process environment required by the plugin core.""" + + def startCore( + self, + config, + routing, + exitCallback=None, + msgCallback=None, + proxyModeOnly=False, + log=True, + **kwargs, + ): + """Start the plugin core and return ``(process, success)``.""" + return None, False + + def prepareDownloadTest(self, config, port: int): + """Return a proxy-only configuration for a download-speed test.""" + return None + + def coreVersions(self): + """Return core version strings that should be treated as versions in logs.""" + return tuple() + + def logTimestampPatterns(self): + """Return regular expressions for timestamps emitted by the plugin core.""" + return tuple() + + def coreExitMessage(self, core, exitcode: int): + """Return a user-facing message key for a plugin-specific exit code.""" + return None + + def afterConnected(self, httpProxy=None): + """Perform optional plugin maintenance after a connection succeeds.""" diff --git a/Furious/Plugins/Configuration.py b/Furious/Plugins/Configuration.py new file mode 100644 index 00000000..1b3eaa22 --- /dev/null +++ b/Furious/Plugins/Configuration.py @@ -0,0 +1,52 @@ +"""Dispatch configuration creation through the process-wide plugin registry.""" + +from __future__ import annotations + +from Furious.Interface.ConfigFactory import ConfigFactory + +from typing import Union + +import ujson + +__all__ = ['configFactoryFromDict', 'configFactoryFromAny', 'configFactoryBlank'] + + +def configFactoryFromDict(config: dict, **kwargs) -> ConfigFactory: + """Construct a configuration mapping through the first matching plugin.""" + if not isinstance(config, dict): + return ConfigFactory() + + from .Registry import getPluginRegistry + + factory = getPluginRegistry().configFromDict(config, **kwargs) + + return factory if factory is not None else ConfigFactory(config, **kwargs) + + +def configFactoryFromAny(config: Union[str, dict], **kwargs) -> ConfigFactory: + """Construct configuration data through the first matching plugin.""" + if isinstance(config, str): + from .Registry import getPluginRegistry + + factory = getPluginRegistry().configFromString(config, **kwargs) + if factory is not None: + return factory + + try: + return configFactoryFromDict(ujson.loads(config), **kwargs) + except Exception: + return ConfigFactory(**kwargs) + + if isinstance(config, dict): + return configFactoryFromDict(config, **kwargs) + + return ConfigFactory(**kwargs) + + +def configFactoryBlank(protocol, **kwargs) -> ConfigFactory: + """Construct a blank configuration through the owning plugin.""" + from .Registry import getPluginRegistry + + factory = getPluginRegistry().blankConfig(protocol, **kwargs) + + return factory if factory is not None else ConfigFactory() diff --git a/Furious/Plugins/Official/Configuration.py b/Furious/Plugins/Official/Configuration.py new file mode 100644 index 00000000..5f4abfb6 --- /dev/null +++ b/Furious/Plugins/Official/Configuration.py @@ -0,0 +1,2404 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Parse, build, import, and export supported proxy configurations.""" + +from __future__ import annotations + +from Furious.Frozenlib import * +from Furious.Interface import * +from Furious.Library.Encoder import * + +from typing import Union, Tuple + +import copy +import functools +import urllib.parse + +quote = functools.partial(urllib.parse.quote, safe='') +unquote = functools.partial(urllib.parse.unquote) +parse_qsl = functools.partial(urllib.parse.parse_qsl) +urlparse = functools.partial(urllib.parse.urlparse) +urlunparse = functools.partial(urllib.parse.urlunparse) + + +def queryStringFromItems(items): + """Encode non-empty query items as a URI query string.""" + return urllib.parse.urlencode( + list((key, value) for key, value in items if value != '' and value is not None), + doseq=True, + safe='%', + ) + + +__all__ = [ + 'BLANK_CONFIG_XRAY', + 'BLANK_CONFIG_HYSTERIA1', + 'BLANK_CONFIG_HYSTERIA2', + 'ConfigXray', + 'ConfigHysteria1', + 'ConfigHysteria2', + 'configXrayEmptyProxyOutboundObject', +] + + +class ConfigXrayProxyOutboundObjectV(dict): + """Represent and transform Xray proxy outbound object v configuration data.""" + + def __init__( + self, + protocol, + remote_host, + remote_port, + uuid_, + encryption, + type_, + security, + **kwargs, + ): + """Initialize the ConfigXrayProxyOutboundObjectV.""" + ( + networkObjectArgs, + securityArgs, + TLSObjectArgs, + finalmaskArgs, + ) = ( + {}, + {}, + {}, + {}, + ) + + if type_ == 'h2': + networkKey = 'httpSettings' + else: + networkKey = f'{type_}Settings' + + networkObjectArgs[networkKey] = ( + ConfigXray.kwargs2ProxyStreamSettingsNetworkObject( + type_, remote_host, kwargs + ) + ) + + if security: + securityArgs['security'] = security + + if security != 'none': + TLSObjectArgs[ + # tlsSettings, realitySettings + f'{security}Settings' + ] = ConfigXray.kwargs2ProxyStreamSettingsTLSObject( + protocol, remote_host, security, kwargs + ) + + try: + if kwargs.get('fm', ''): + finalmask = UJSONEncoder.decode(unquote(kwargs.get('fm', ''))) + + if finalmask: + finalmaskArgs['finalmask'] = finalmask + except Exception: + # Any non-exit exceptions + + pass + + super().__init__( + **{ + 'tag': 'proxy', + 'protocol': protocol, + 'settings': { + 'vnext': [ + { + 'address': remote_host, + 'port': int(remote_port), + 'users': [ + ConfigXray.kwargs2ProxyUserObject( + protocol, uuid_, encryption, kwargs + ), + ], + }, + ] + }, + 'streamSettings': { + 'network': type_, + **networkObjectArgs, + **securityArgs, + **TLSObjectArgs, + **finalmaskArgs, + }, + 'mux': { + 'enabled': False, + 'concurrency': -1, + }, + }, + ) + + +class ConfigXrayProxyOutboundObjectSS(dict): + """Represent and transform Xray proxy outbound object ss configuration data.""" + + def __init__(self, method, password, address, port): + """Initialize the ConfigXrayProxyOutboundObjectSS.""" + super().__init__( + **{ + 'tag': 'proxy', + 'protocol': 'shadowsocks', + 'settings': { + 'servers': [ + { + 'address': address, + 'port': int(port), + 'method': method, + 'password': password, + 'email': PROXY_OUTBOUND_USER_EMAIL, + 'ota': False, + }, + ] + }, + 'streamSettings': { + 'network': 'tcp', + }, + 'mux': { + 'enabled': False, + 'concurrency': -1, + }, + } + ) + + +class ConfigXrayProxyOutboundObjectSocks(dict): + """Represent and transform Xray proxy outbound object SOCKS configuration data.""" + + def __init__(self, address, port, user='', password=''): + """Initialize the ConfigXrayProxyOutboundObjectSocks.""" + settings = { + 'address': address, + 'port': int(port), + } + + if user != '' or password != '': + settings['user'] = user + settings['pass'] = password + + super().__init__( + **{ + 'tag': 'proxy', + 'protocol': 'socks', + 'settings': settings, + 'streamSettings': { + 'network': 'tcp', + }, + 'mux': { + 'enabled': False, + 'concurrency': -1, + }, + } + ) + + +class ConfigXrayProxyOutboundObjectTrojan(dict): + """Represent and transform Xray proxy outbound object trojan configuration data.""" + + def __init__(self, password, address, port, type_, security, **kwargs): + """Initialize the ConfigXrayProxyOutboundObjectTrojan.""" + networkObjectArgs, securityArgs, TLSObjectArgs = {}, {}, {} + + if type_ == 'h2': + networkKey = 'httpSettings' + else: + networkKey = f'{type_}Settings' + + networkObjectArgs[networkKey] = ( + ConfigXray.kwargs2ProxyStreamSettingsNetworkObject(type_, address, kwargs) + ) + + if security: + securityArgs['security'] = security + + if security != 'none': + TLSObjectArgs[ + # tlsSettings, realitySettings + f'{security}Settings' + ] = ConfigXray.kwargs2ProxyStreamSettingsTLSObject( + 'trojan', address, security, kwargs + ) + + super().__init__( + **{ + 'tag': 'proxy', + 'protocol': 'trojan', + 'settings': { + 'servers': [ + { + 'address': address, + 'port': int(port), + 'password': password, + 'email': PROXY_OUTBOUND_USER_EMAIL, + }, + ] + }, + 'streamSettings': { + 'network': type_, + **networkObjectArgs, + **securityArgs, + **TLSObjectArgs, + }, + 'mux': { + 'enabled': False, + 'concurrency': -1, + }, + }, + ) + + +BLANK_CONFIG_XRAY = { + # log + 'log': { + 'access': '', + 'error': '', + 'loglevel': 'warning', + }, + # inbounds + 'inbounds': [ + { + 'tag': 'socks', + 'port': 10808, + 'listen': '127.0.0.1', + 'protocol': 'socks', + 'sniffing': { + 'enabled': True, + 'destOverride': [ + 'http', + 'tls', + ], + }, + 'settings': { + 'auth': 'noauth', + 'udp': True, + 'allowTransparent': False, + }, + }, + { + 'tag': 'http', + 'port': 10809, + 'listen': '127.0.0.1', + 'protocol': 'http', + 'sniffing': { + 'enabled': True, + 'destOverride': [ + 'http', + 'tls', + ], + }, + 'settings': { + 'auth': 'noauth', + 'udp': True, + 'allowTransparent': False, + }, + }, + ], + # outbounds + 'outbounds': [ + # proxy + { + 'tag': 'proxy', + 'protocol': '', + 'settings': {}, + 'streamSettings': { + 'network': 'tcp', + }, + 'mux': { + 'enabled': False, + 'concurrency': -1, + }, + }, + # direct + { + 'tag': 'direct', + 'protocol': 'freedom', + 'settings': {}, + }, + # block + { + 'tag': 'block', + 'protocol': 'blackhole', + 'settings': { + 'response': { + 'type': 'http', + } + }, + }, + ], + # routing + 'routing': {}, +} + + +class ConfigXray(ConfigFactory): + """Represent Xray configuration and supported share-link formats.""" + + def __init__(self, config: Union[str, dict] = '', **kwargs): + """Initialize the ConfigXray.""" + super().__init__(config, **kwargs) + + def coreName(self) -> str: + """Return the core implementation name.""" + return 'Xray-core' + + @staticmethod + def getProxyOutboundObject(config: dict, default=None) -> dict: + """Return proxy outbound object.""" + if not isinstance(config.get('outbounds'), list): + config['outbounds'] = [] + + for outbound in config['outbounds']: + if isinstance(outbound, dict): + tag = outbound.get('tag') + + if isinstance(tag, str) and tag == 'proxy': + return outbound + + if isinstance(default, dict): + # Proxy outbound not found. Add it + config['outbounds'].append(default) + + return {} + + @staticmethod + def getProxyOutboundStream(config: dict, **kwargs) -> dict: + """Return proxy outbound stream.""" + proxyOutbound = ConfigXray.getProxyOutboundObject(config, **kwargs) + + if not isinstance(proxyOutbound.get('streamSettings'), dict): + proxyOutbound['streamSettings'] = {} + + return proxyOutbound['streamSettings'] + + @staticmethod + def getProxyOutboundServer(config: dict, protocol: Protocol, **kwargs) -> dict: + """Return proxy outbound server.""" + value, proxyOutbound = ( + protocol.value.lower(), + ConfigXray.getProxyOutboundObject(config, **kwargs), + ) + + try: + if value == 'vless' or value == 'vmess': + server = proxyOutbound['settings']['vnext'][0] + elif value == 'socks': + server = proxyOutbound['settings'] + elif value == 'shadowsocks' or value == 'trojan': + server = proxyOutbound['settings']['servers'][0] + else: + server = {} + except Exception: + # Any non-exit exceptions + + server = {} + + if not isinstance(server, dict): + server = {} + + return server + + @staticmethod + def getProxyOutboundUser(config: dict, protocol: Protocol, **kwargs) -> dict: + """Return proxy outbound user.""" + proxyOutboundServer = ConfigXray.getProxyOutboundServer( + config, protocol, **kwargs + ) + + try: + user = proxyOutboundServer['users'][0] + except Exception: + # Any non-exit exceptions + + user = {} + + if not isinstance(user, dict): + user = {} + + return user + + @property + def proxyOutboundObject(self) -> dict: + """Return the proxy outbound object value.""" + try: + return ConfigXray.getProxyOutboundObject(self) + except Exception: + # Any non-exit exceptions + + return {} + + @property + def proxyProtocol(self) -> str: + """Return the proxy protocol value.""" + try: + return self.proxyOutboundObject['protocol'] + except Exception: + # Any non-exit exceptions + + return '' + + @property + def proxyServerObject(self) -> dict: + """Return the proxy server object value.""" + try: + proxyProtocol = self.proxyProtocol.lower() + + if proxyProtocol == 'vmess' or proxyProtocol == 'vless': + return self.proxyOutboundObject['settings']['vnext'][0] + + if proxyProtocol == 'socks': + return self.proxyOutboundObject['settings'] + + if proxyProtocol == 'shadowsocks' or proxyProtocol == 'trojan': + return self.proxyOutboundObject['settings']['servers'][0] + + return {} + except Exception: + # Any non-exit exceptions + + return {} + + @property + def proxyUserObject(self) -> dict: + """Return the proxy user object value.""" + try: + if self.proxyProtocol.lower() == 'socks': + return self.proxyServerObject + + return self.proxyServerObject['users'][0] + except Exception: + # Any non-exit exceptions + + return {} + + @property + def proxyStreamSettingsObject(self) -> dict: + """Return the proxy stream settings object value.""" + try: + return ConfigXray.getProxyOutboundStream(self) + except Exception: + # Any non-exit exceptions + + return {} + + @property + def proxyStreamSettingsTLS(self) -> str: + """Return the proxy stream settings TLS value.""" + try: + return self.proxyStreamSettingsObject.get('security', 'none') + except Exception: + # Any non-exit exceptions + + return '' + + @property + def proxyStreamSettingsTLSObject(self) -> dict: + """Return the proxy stream settings TLS object value.""" + try: + # tlsSettings, realitySettings + TLSKey = f'{self.proxyStreamSettingsTLS}Settings' + + return self.proxyStreamSettingsObject[TLSKey] + except Exception: + # Any non-exit exceptions + + return {} + + @property + def proxyStreamSettingsNetwork(self) -> str: + """Return the proxy stream settings network value.""" + try: + return self.proxyStreamSettingsObject['network'] + except Exception: + # Any non-exit exceptions + + return '' + + @property + def proxyStreamSettingsNetworkObject(self) -> dict: + """Return the proxy stream settings network object value.""" + try: + if self.proxyStreamSettingsNetwork == 'h2': + networkKey = 'httpSettings' + else: + networkKey = f'{self.proxyStreamSettingsNetwork}Settings' + + # Automatically use 'tcp' or 'raw' if needed + if ( + networkKey == 'tcpSettings' + and networkKey not in self.proxyStreamSettingsObject + ): + networkKey = 'rawSettings' + elif ( + networkKey == 'rawSettings' + and networkKey not in self.proxyStreamSettingsObject + ): + networkKey = 'tcpSettings' + + return self.proxyStreamSettingsObject[networkKey] + except Exception: + # Any non-exit exceptions + + return {} + + @property + def kwargsFromVMessProxyStreamSettingsNetworkObject(self) -> dict: + """Return the kwargs from v mess proxy stream settings network object value.""" + kwargs = {} + + try: + proxyStream = self.proxyStreamSettingsObject + + if 'finalmask' in proxyStream: + kwargs['fm'] = quote(UJSONEncoder.encode(proxyStream['finalmask'])) + except Exception: + # Any non-exit exceptions + + pass + + network = self.proxyStreamSettingsNetwork + networkObject = self.proxyStreamSettingsNetworkObject + + if not networkObject: + return kwargs + + def hasKey(key): + """Return whether key.""" + return networkObject.get(key) is not None + + if network == 'tcp' or network == 'raw': + try: + kwargs['type'] = networkObject['header']['type'] + except Exception: + # Any non-exit exceptions + + pass + + elif network == 'kcp': + try: + # Get order matters here + if hasKey('seed'): + kwargs['path'] = networkObject['seed'] + + kwargs['type'] = networkObject['header']['type'] + except Exception: + # Any non-exit exceptions + + pass + + elif network == 'ws': + try: + # Get order matters here + if hasKey('path'): + kwargs['path'] = quote(networkObject['path']) + + if networkObject['headers']['Host']: + kwargs['host'] = quote(networkObject['headers']['Host']) + except Exception: + # Any non-exit exceptions + + pass + + elif network == 'h2' or network == 'http': + try: + # Get order matters here + if hasKey('path'): + kwargs['path'] = quote(networkObject['path']) + + kwargs['host'] = quote(','.join(networkObject['host'])) + except Exception: + # Any non-exit exceptions + + pass + + elif network == 'quic': + try: + # Get order matters here + if hasKey('security'): + kwargs['host'] = networkObject['security'] + + if hasKey('key'): + kwargs['path'] = quote(networkObject['key']) + + kwargs['type'] = networkObject['header']['type'] + except Exception: + # Any non-exit exceptions + + pass + + elif network == 'grpc': + if hasKey('serviceName'): + kwargs['path'] = networkObject['serviceName'] + + elif network == 'httpupgrade': + try: + # Get order matters here + if hasKey('path'): + kwargs['path'] = quote(networkObject['path']) + + kwargs['host'] = quote(networkObject['host']) + except Exception: + # Any non-exit exceptions + + pass + + elif network == 'splithttp': + try: + # Get order matters here + if hasKey('path'): + kwargs['path'] = quote(networkObject['path']) + + kwargs['host'] = quote(networkObject['host']) + except Exception: + # Any non-exit exceptions + + pass + + elif network == 'xhttp': + try: + if hasKey('host'): + kwargs['host'] = quote(networkObject['host']) + + if hasKey('path'): + kwargs['path'] = quote(networkObject['path']) + + if hasKey('mode'): + kwargs['mode'] = networkObject['mode'] + + if hasKey('extra'): + kwargs['extra'] = quote(UJSONEncoder.encode(networkObject['extra'])) + except Exception: + # Any non-exit exceptions + + pass + + return kwargs + + @property + def kwargsFromVLESSProxyStreamSettingsNetworkObject(self) -> dict: + """Return the kwargs from VLESS proxy stream settings network object value.""" + kwargs = {} + + try: + proxyStream = self.proxyStreamSettingsObject + + if 'finalmask' in proxyStream: + kwargs['fm'] = quote(UJSONEncoder.encode(proxyStream['finalmask'])) + except Exception: + # Any non-exit exceptions + + pass + + network = self.proxyStreamSettingsNetwork + networkObject = self.proxyStreamSettingsNetworkObject + + if not networkObject: + return kwargs + + def hasKey(key): + """Return whether key.""" + return networkObject.get(key) is not None + + if network == 'tcp' or network == 'raw': + try: + kwargs['headerType'] = networkObject['header']['type'] + except Exception: + # Any non-exit exceptions + + pass + + elif network == 'kcp': + try: + # Get order matters here + if hasKey('seed'): + kwargs['seed'] = networkObject['seed'] + + kwargs['headerType'] = networkObject['header']['type'] + except Exception: + # Any non-exit exceptions + + pass + + elif network == 'ws': + try: + # Get order matters here + if hasKey('path'): + kwargs['path'] = quote(networkObject['path']) + + if networkObject['headers']['Host']: + kwargs['host'] = quote(networkObject['headers']['Host']) + except Exception: + # Any non-exit exceptions + + pass + + elif network == 'h2' or network == 'http': + try: + # Get order matters here + if hasKey('path'): + kwargs['path'] = quote(networkObject['path']) + + kwargs['host'] = quote(','.join(networkObject['host'])) + except Exception: + # Any non-exit exceptions + + pass + + elif network == 'quic': + try: + # Get order matters here + if hasKey('security'): + kwargs['quicSecurity'] = networkObject['security'] + + if hasKey('key'): + kwargs['path'] = quote(networkObject['key']) + + kwargs['headerType'] = networkObject['header']['type'] + except Exception: + # Any non-exit exceptions + + pass + + elif network == 'grpc': + if hasKey('serviceName'): + kwargs['serviceName'] = networkObject['serviceName'] + + if hasKey('multiMode'): + if networkObject['multiMode']: + kwargs['mode'] = 'multi' + + if hasKey('authority'): + kwargs['authority'] = networkObject['authority'] + + elif network == 'httpupgrade': + try: + # Get order matters here + if hasKey('path'): + kwargs['path'] = quote(networkObject['path']) + + kwargs['host'] = quote(networkObject['host']) + except Exception: + # Any non-exit exceptions + + pass + + elif network == 'splithttp': + try: + # Get order matters here + if hasKey('path'): + kwargs['path'] = quote(networkObject['path']) + + kwargs['host'] = quote(networkObject['host']) + except Exception: + # Any non-exit exceptions + + pass + + elif network == 'xhttp': + try: + if hasKey('host'): + kwargs['host'] = quote(networkObject['host']) + + if hasKey('path'): + kwargs['path'] = quote(networkObject['path']) + + if hasKey('mode'): + kwargs['mode'] = networkObject['mode'] + + if hasKey('extra'): + kwargs['extra'] = quote(UJSONEncoder.encode(networkObject['extra'])) + except Exception: + # Any non-exit exceptions + + pass + + return kwargs + + @staticmethod + def kwargs2ProxyStreamSettingsNetworkObject(type_, remote_host, kwargs) -> dict: + # Note: + # v2rayN share standard doesn't require unquote. Still + # unquote value according to (VMess AEAD / VLESS) standard + + """Build Xray transport settings from share-link keyword arguments.""" + if type_ == 'tcp' or type_ == 'raw': + TcpObject = {} + + if kwargs.get('headerType', 'none'): + headerType = kwargs.get('headerType', 'none') + + # Some dumb share link set this value to 'auto'. Protect it + if headerType != 'auto': + TcpObject['header'] = { + 'type': headerType, + } + + # Request settings for HTTP + if headerType == 'http' and kwargs.get('host'): + TcpObject['header']['request'] = { + 'version': '1.1', + 'method': 'GET', + 'path': [kwargs.get('path', '/')], + 'headers': { + 'Host': [unquote(kwargs.get('host'))], + 'User-Agent': [ + ( + 'Mozilla/5.0 (Windows NT 10.0; WOW64) ' + 'AppleWebKit/537.36 (KHTML, like Gecko) ' + 'Chrome/53.0.2785.143 Safari/537.36' + ), + ( + 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_0_2 like Mac OS X) ' + 'AppleWebKit/601.1 (KHTML, like Gecko) ' + 'CriOS/53.0.2785.109 ' + 'Mobile/14A456 ' + 'Safari/601.1.46' + ), + ], + 'Accept-Encoding': ['gzip, deflate'], + 'Connection': ['keep-alive'], + 'Pragma': 'no-cache', + }, + } + + return TcpObject + + elif type_ == 'kcp': + KcpObject = { + # Extension. From v2rayN + 'uplinkCapacity': 12, + 'downlinkCapacity': 100, + } + + if kwargs.get('headerType', 'none'): + headerType = kwargs.get('headerType', 'none') + + # Some dumb share link set this value to 'auto'. Protect it + if headerType != 'auto': + KcpObject['header'] = { + 'type': headerType, + } + + if kwargs.get('seed'): + KcpObject['seed'] = kwargs.get('seed') + + return KcpObject + + elif type_ == 'ws': + WebSocketObject = {} + + if kwargs.get('path', '/'): + WebSocketObject['path'] = unquote(kwargs.get('path', '/')) + + if kwargs.get('host'): + WebSocketObject['headers'] = { + 'Host': unquote(kwargs.get('host')), + } + + return WebSocketObject + + elif type_ == 'h2' or type_ == 'http': + HttpObject = {} + + if kwargs.get('host', remote_host): + # Protect "xxx," format + HttpObject['host'] = list( + filter( + lambda x: x != '', + unquote(kwargs.get('host', remote_host)).split(','), + ) + ) + + if kwargs.get('path', '/'): + HttpObject['path'] = unquote(kwargs.get('path', '/')) + + return HttpObject + + elif type_ == 'quic': + QuicObject = {} + + if kwargs.get('quicSecurity', 'none'): + QuicObject['security'] = kwargs.get('quicSecurity', 'none') + + if kwargs.get('key'): + QuicObject['key'] = unquote(kwargs.get('key')) + + if kwargs.get('headerType', 'none'): + headerType = kwargs.get('headerType', 'none') + + # Some dumb share link set this value to 'auto'. Protect it + if headerType != 'auto': + QuicObject['header'] = { + 'type': headerType, + } + + return QuicObject + + elif type_ == 'grpc': + GRPCObject = {} + + if kwargs.get('serviceName'): + GRPCObject['serviceName'] = kwargs.get('serviceName') + + if kwargs.get('mode', 'gun'): + GRPCObject['multiMode'] = kwargs.get('mode', 'gun') == 'multi' + + if kwargs.get('authority'): + GRPCObject['authority'] = kwargs.get('authority') + + return GRPCObject + + elif type_ == 'httpupgrade': + httpUpgradeObject = {} + + if kwargs.get('path', '/'): + httpUpgradeObject['path'] = unquote(kwargs.get('path', '/')) + + if kwargs.get('host'): + httpUpgradeObject['host'] = unquote(kwargs.get('host')) + + return httpUpgradeObject + + elif type_ == 'splithttp': + splitHttpObject = {} + + if kwargs.get('path', '/'): + splitHttpObject['path'] = unquote(kwargs.get('path', '/')) + + if kwargs.get('host'): + splitHttpObject['host'] = unquote(kwargs.get('host')) + + return splitHttpObject + + elif type_ == 'xhttp': + xhttpObject = {} + + if kwargs.get('host', ''): + xhttpObject['host'] = unquote(kwargs.get('host', '')) + + if kwargs.get('path', '/'): + xhttpObject['path'] = unquote(kwargs.get('path', '/')) + + if kwargs.get('mode', ''): + xhttpObject['mode'] = kwargs.get('mode', '') + + try: + if kwargs.get('extra', ''): + xhttpObject['extra'] = UJSONEncoder.decode( + unquote(kwargs.get('extra', '')) + ) + except Exception: + # Any non-exit exceptions + + pass + + return xhttpObject + + else: + return {} + + @property + def kwargsFromProxyStreamSettingsTLSObject(self) -> dict: + """Return the kwargs from proxy stream settings TLS object value.""" + kwargs = {} + + TLS = self.proxyStreamSettingsTLS + TLSObject = self.proxyStreamSettingsTLSObject + + if TLS == '' or TLS == 'none': + return kwargs + + if TLS == 'reality' or TLS == 'tls': + if TLSObject.get('fingerprint'): + kwargs['fp'] = TLSObject['fingerprint'] + + if TLSObject.get('serverName'): + kwargs['sni'] = TLSObject['serverName'] + + if TLSObject.get('alpn'): + kwargs['alpn'] = quote(','.join(TLSObject['alpn'])) + + if TLSObject.get('echConfigList'): + kwargs['ech'] = quote(TLSObject['echConfigList']) + + if TLSObject.get('pinnedPeerCertSha256'): + kwargs['pcs'] = quote(TLSObject['pinnedPeerCertSha256']) + + if TLSObject.get('verifyPeerCertByName'): + kwargs['vcn'] = quote(TLSObject['verifyPeerCertByName']) + + if TLS == 'reality': + # More kwargs for reality + if TLSObject.get('publicKey'): + kwargs['pbk'] = TLSObject['publicKey'] + + if TLSObject.get('shortId'): + kwargs['sid'] = TLSObject['shortId'] + + if TLSObject.get('mldsa65Verify'): + kwargs['pqv'] = TLSObject['mldsa65Verify'] + + if TLSObject.get('spiderX'): + kwargs['spx'] = quote(TLSObject['spiderX']) + + return kwargs + + @staticmethod + def kwargs2ProxyStreamSettingsTLSObject( + protocol, remote_host, security, kwargs + ) -> dict: + """Build Xray TLS or REALITY settings from share-link keyword arguments.""" + TLSObject = {} + + if security == 'reality' or security == 'tls': + # Note: If specify default value 'chrome', some share link fails. + # Leave default value as empty + fp = kwargs.get('fp') + sni = kwargs.get('sni') + # Protect "xxx," format + alpn = list( + filter( + lambda x: x != '', + unquote(kwargs.get('alpn', '')).split(','), + ) + ) + ech = unquote(kwargs.get('ech', '')) + pcs = unquote(kwargs.get('pcs', '')) + vcn = unquote(kwargs.get('vcn', '')) + + if fp: + TLSObject['fingerprint'] = fp + + if sni: + TLSObject['serverName'] = sni + else: + host = '' + + if protocol == 'vmess': + host = kwargs.get('host') + + if protocol == 'vless': + host = remote_host + + if host: + TLSObject['serverName'] = host + + if alpn: + TLSObject['alpn'] = alpn + + if ech: + TLSObject['echConfigList'] = ech + + if pcs: + TLSObject['pinnedPeerCertSha256'] = pcs + + if vcn: + TLSObject['verifyPeerCertByName'] = vcn + + if security == 'reality': + # More args for reality + pbk = kwargs.get('pbk') + sid = kwargs.get('sid', '') + pqv = kwargs.get('pqv', '') + spx = kwargs.get('spx', '') + + if pbk: + TLSObject['publicKey'] = pbk + + TLSObject['shortId'] = sid + TLSObject['mldsa65Verify'] = pqv + TLSObject['spiderX'] = unquote(spx) + + return TLSObject + + @staticmethod + def kwargs2ProxyUserObject(protocol, uuid_, encryption, kwargs) -> dict: + """Build an Xray proxy user object from share-link values.""" + if protocol == 'vmess': + UserObject = { + 'id': uuid_, + 'security': encryption, + 'email': PROXY_OUTBOUND_USER_EMAIL, + } + + # For VMess(v2rayN share standard) only. + if kwargs.get('aid') is not None: + # int: extra guard + UserObject['alterId'] = int(kwargs.get('aid')) + + return UserObject + + if protocol == 'vless': + UserObject = { + 'id': uuid_, + 'encryption': encryption, + 'email': PROXY_OUTBOUND_USER_EMAIL, + } + + if kwargs.get('flow'): + # flow is empty, TLS. Otherwise, XTLS. + UserObject['flow'] = kwargs.get('flow') + + return UserObject + + return {} + + @staticmethod + def URI2ProxyOutboundObjectVMess(URI: str) -> Tuple[str, dict]: + """Parse a VMess URI into a remark and Xray proxy outbound.""" + try: + myHead, myBody = URI.split('://') + except Exception: + # Any non-exit exceptions + + return '', {} + + try: + myData = PyBase64Encoder.decode(myBody) + myJSON = UJSONEncoder.decode(myData) + except Exception: + # Any non-exit exceptions + + return ConfigXray.URI2ProxyOutboundObjectVLESS(URI, protocol='vmess') + else: + + def getOrDefault(key, default=''): + """Return or default.""" + return myJSON.get(key, default) + + remark = unquote(getOrDefault('ps')) + + return ( + remark, + # Ignore: v + ConfigXrayProxyOutboundObjectV( + 'vmess', + getOrDefault('add'), + int(getOrDefault('port', '0')), + getOrDefault('id'), + getOrDefault('scy', 'auto'), + getOrDefault('net', 'tcp'), + getOrDefault('tls', 'none'), + # kwargs. v2rayN share standard -> (VMess AEAD / VLESS) standard + aid=int(getOrDefault('aid', '0')), + headerType=getOrDefault('type', 'none'), + host=getOrDefault('host'), + quicSecurity=getOrDefault('host', 'none'), + path=getOrDefault('path'), + key=getOrDefault('path'), + seed=getOrDefault('path'), + serviceName=getOrDefault('path'), + sni=getOrDefault('sni'), + alpn=getOrDefault('alpn'), + # Note: If specify default value 'chrome', some share link fails. + # Leave default value as empty + fp=getOrDefault('fp'), + ), + ) + + @staticmethod + def URI2ProxyOutboundObjectVLESS(URI: str, **kwargs) -> Tuple[str, dict]: + """Parse a VLESS URI into a remark and Xray proxy outbound.""" + result = urlparse(URI) + remark = unquote(result.fragment) + queryObject = {key: value for key, value in parse_qsl(result.query)} + + uuid_, server = result.netloc.split('@') + + remote_host, remote_port = parseHostPort(server) + + encryption = queryObject.pop('encryption', 'none') + type_ = queryObject.pop('type', 'tcp') + security = queryObject.pop('security', 'none') + + return ( + remark, + ConfigXrayProxyOutboundObjectV( + kwargs.pop('protocol', 'vless'), + remote_host, + int(remote_port), + uuid_, + encryption, + type_, + security, + # kwargs + **queryObject, + ), + ) + + @staticmethod + def URI2ProxyOutboundObjectSS(URI: str) -> Tuple[str, dict]: + """Parse a Shadowsocks URI into a remark and Xray proxy outbound.""" + try: + result = urlparse(URI) + remark = unquote(result.fragment) + except Exception: + # Any non-exit exceptions + + return '', {} + + def getSSParams(): + # Begin SIP002... + """Return ss params.""" + try: + # Try pack with 3 element + userinfo, server = result.netloc.split('@') + + # Some old SS share link doesn't add padding + # in base64 encoding. Add padding to userinfo + return [ + *PyBase64Encoder.decode(userinfo + '===').decode().split(':', 1), + *parseHostPort(server), + ] + except Exception: + # Any non-exit exceptions + + pass + + try: + # Try pack with 4 element + userinfo, server = result.netloc.split('@') + + return [*userinfo.split(':', 1), *parseHostPort(server)] + except Exception: + # Any non-exit exceptions + + pass + + try: + # ss://base64...#fragment + userinfo, server = ( + PyBase64Encoder.decode(result.netloc).decode().split('@') + ) + + return [*userinfo.split(':', 1), *parseHostPort(server)] + except Exception: + # Any non-exit exceptions + + pass + + raise ValueError(f'Invalid SS URI format {URI}') + + return ( + remark, + ConfigXrayProxyOutboundObjectSS(*getSSParams()), + ) + + @staticmethod + def URI2ProxyOutboundObjectSocks(URI: str) -> Tuple[str, dict]: + """Parse a SOCKS URI into a remark and Xray proxy outbound.""" + result = urlparse(URI) + remark = unquote(result.fragment) + + address = result.hostname or '' + port = result.port or 0 + user = unquote(result.username or '') + password = unquote(result.password or '') + + if address == '' or port == 0: + raise ValueError(f'Invalid SOCKS URI format {URI}') + + return ( + remark, + ConfigXrayProxyOutboundObjectSocks(address, port, user, password), + ) + + @staticmethod + def URI2ProxyOutboundObjectTrojan(URI: str) -> Tuple[str, dict]: + """Parse a Trojan URI into a remark and Xray proxy outbound.""" + result = urlparse(URI) + remark = unquote(result.fragment) + queryObject = {key: value for key, value in parse_qsl(result.query)} + + password, server = result.netloc.split('@') + + address, port = parseHostPort(server) + + type_ = queryObject.pop('type', 'tcp') + # For Trojan: Assign tls by default + security = queryObject.pop('security', 'tls') + + return ( + remark, + ConfigXrayProxyOutboundObjectTrojan( + password, address, port, type_, security, **queryObject + ), + ) + + @staticmethod + def URI2ProxyOutboundObject(URI: str) -> Tuple[str, dict]: + """Dispatch a supported share URI to its Xray outbound parser.""" + if URI.startswith('vmess://'): + return ConfigXray.URI2ProxyOutboundObjectVMess(URI) + + if URI.startswith('vless://'): + return ConfigXray.URI2ProxyOutboundObjectVLESS(URI) + + if URI.startswith('ss://'): + return ConfigXray.URI2ProxyOutboundObjectSS(URI) + + if URI.startswith(('socks://', 'socks5://', 'socks5h://')): + return ConfigXray.URI2ProxyOutboundObjectSocks(URI) + + if URI.startswith('trojan://'): + return ConfigXray.URI2ProxyOutboundObjectTrojan(URI) + + raise ValueError(f'Unrecognized URI scheme {URI}') + + @property + def itemProtocol(self) -> str: + """Return the item protocol value.""" + return Protocol.toEnum(self.proxyProtocol).value + + @property + def itemAddress(self) -> str: + """Return the item address value.""" + addr = self.proxyServerObject.get('address', '') + + return str(addr) + + @property + def itemPort(self) -> str: + """Return the item port value.""" + port = self.proxyServerObject.get('port', '') + + return str(port) + + @property + def itemTransport(self) -> str: + """Return the item transport value.""" + return self.proxyStreamSettingsNetwork + + @property + def itemTLS(self) -> str: + """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) + + return super().toJSONString(indent=indent) + + def toURI(self, remark: str = '') -> str: + """Export the configuration as a share URI.""" + if remark == '': + override = self.itemRemark + else: + override = remark + + protocol = self.proxyProtocol.lower() + + if protocol == 'vmess': + netloc = PyBase64Encoder.encode( + UJSONEncoder.encode( + { + 'v': '2', + 'ps': override, + **{ + key: str(self.proxyServerObject[value]) + for key, value in { + 'add': 'address', + 'port': 'port', + }.items() + }, + **{ + key: str(self.proxyUserObject[value]) + for key, value in { + 'id': 'id', + 'aid': 'alterId', + 'scy': 'security', + }.items() + }, + 'net': self.proxyStreamSettingsNetwork, + 'tls': self.proxyStreamSettingsTLS, + # kwargs + **self.kwargsFromVMessProxyStreamSettingsNetworkObject, + **self.kwargsFromProxyStreamSettingsTLSObject, + } + ).encode() + ).decode() + + return urlunparse(['vmess', netloc, '', '', {}, '']) + + if protocol == 'vless': + flowArg = {} + + if self.proxyUserObject.get('flow'): + flowArg['flow'] = self.proxyUserObject['flow'] + + netloc = ( + self.proxyUserObject['id'] + + '@' + + self.proxyServerObject['address'] + + ':' + + str(self.proxyServerObject['port']) + ) + + query = queryStringFromItems( + { + 'encryption': self.proxyUserObject['encryption'], + 'type': self.proxyStreamSettingsNetwork, + 'security': self.proxyStreamSettingsTLS, + # kwargs + **flowArg, + **self.kwargsFromVLESSProxyStreamSettingsNetworkObject, + **self.kwargsFromProxyStreamSettingsTLSObject, + }.items() + ) + + return urlunparse(['vless', netloc, '', '', query, quote(override)]) + + if protocol == 'shadowsocks': + method, password, address, port = list( + self.proxyServerObject[value] + for value in ['method', 'password', 'address', 'port'] + ) + + netloc = f'{quote(method)}:{quote(password)}@{address}:{port}' + + return urlunparse(['ss', netloc, '', '', '', quote(override)]) + + if protocol == 'socks': + address, port = list( + self.proxyServerObject[value] for value in ['address', 'port'] + ) + user = self.proxyUserObject.get('user', '') + password = self.proxyUserObject.get('pass', '') + + if user != '' or password != '': + netloc = f'{quote(user)}:{quote(password)}@{address}:{port}' + else: + netloc = f'{address}:{port}' + + return urlunparse(['socks5', netloc, '', '', '', quote(override)]) + + if protocol == 'trojan': + password, address, port = list( + self.proxyServerObject[value] + for value in ['password', 'address', 'port'] + ) + + netloc = f'{quote(password)}@{address}:{port}' + + query = queryStringFromItems( + { + 'type': self.proxyStreamSettingsNetwork, + 'security': self.proxyStreamSettingsTLS, + # kwargs + **self.kwargsFromVLESSProxyStreamSettingsNetworkObject, + **self.kwargsFromProxyStreamSettingsTLSObject, + }.items() + ) + + return urlunparse(['trojan', netloc, '', '', query, quote(override)]) + + # Unrecognized protocol + return '' + + def fromURI(self, URI: str) -> bool: + """Populate the configuration from a share URI.""" + try: + remark, proxyOutboundObject = ConfigXray.URI2ProxyOutboundObject(URI) + + factory = copy.deepcopy(BLANK_CONFIG_XRAY) + factory['outbounds'] = [ + # proxy + proxyOutboundObject, + # direct + { + 'tag': 'direct', + 'protocol': 'freedom', + 'settings': {}, + }, + # block + { + 'tag': 'block', + 'protocol': 'blackhole', + 'settings': { + 'response': { + 'type': 'http', + } + }, + }, + ] + + dict.__init__(self, **factory) + + self.setExtras('remark', remark) + + return True + except Exception: + # Any non-exit exceptions + + return False + + def httpProxy(self) -> str: + """Return the configured local HTTP proxy endpoint.""" + try: + for inbound in self['inbounds']: + if inbound['protocol'] == 'http': + # Note: If there are multiple http inbounds + # satisfied, the first one will be chosen. + return str(inbound['listen']) + ':' + str(inbound['port']) + + return '' + except Exception: + # Any non-exit exceptions + + return '' + + def socksProxy(self) -> str: + """Return the configured local SOCKS proxy endpoint.""" + try: + for inbound in self['inbounds']: + if inbound['protocol'] == 'socks': + # Note: If there are multiple socks inbounds + # satisfied, the first one will be chosen. + return str(inbound['listen']) + ':' + str(inbound['port']) + + return '' + except Exception: + # Any non-exit exceptions + + return '' + + def setHttpProxy(self, endpoint: str) -> bool: + """Set the local HTTP proxy endpoint.""" + try: + if self.get('inbounds') is None: + self['inbounds'] = [] + + if not isinstance(self['inbounds'], list): + self['inbounds'] = [] + + if endpoint == '': + for inbound in self['inbounds']: + if inbound['protocol'] == 'http': + self['inbounds'].remove(inbound) + + # Remove first chosen + return True + + # None satisfied. Return success + return True + + listen, port = parseHostPort(endpoint) + + for inbound in self['inbounds']: + if inbound['protocol'] == 'http': + # Note: If there are multiple http inbounds + # satisfied, the first one will be chosen. + inbound['listen'], inbound['port'] = listen, int(port) + + return True + + # No entry. Add new one + self['inbounds'].append( + { + 'tag': 'http', + 'port': int(port), + 'listen': listen, + 'protocol': 'http', + 'sniffing': { + 'enabled': True, + 'destOverride': [ + 'http', + 'tls', + ], + }, + 'settings': { + 'auth': 'noauth', + 'udp': True, + 'allowTransparent': False, + }, + } + ) + + return True + except Exception: + # Any non-exit exceptions + + return False + + def setSocksProxy(self, endpoint: str) -> bool: + """Set the local SOCKS proxy endpoint.""" + try: + if self.get('inbounds') is None: + self['inbounds'] = [] + + if not isinstance(self['inbounds'], list): + self['inbounds'] = [] + + if endpoint == '': + for inbound in self['inbounds']: + if inbound['protocol'] == 'socks': + self['inbounds'].remove(inbound) + + # Remove first chosen + return True + + # None satisfied. Return success + return True + + listen, port = parseHostPort(endpoint) + + for inbound in self['inbounds']: + if inbound['protocol'] == 'socks': + # Note: If there are multiple socks inbounds + # satisfied, the first one will be chosen. + inbound['listen'], inbound['port'] = listen, int(port) + + return True + + # No entry. Add new one + self['inbounds'].append( + { + 'tag': 'socks', + 'port': int(port), + 'listen': listen, + 'protocol': 'socks', + 'sniffing': { + 'enabled': True, + 'destOverride': [ + 'http', + 'tls', + ], + }, + 'settings': { + 'auth': 'noauth', + 'udp': True, + 'allowTransparent': False, + }, + } + ) + + return True + except Exception: + # Any non-exit exceptions + + return False + + +BLANK_CONFIG_HYSTERIA1 = { + 'server': '', + 'protocol': 'udp', + 'socks5': { + 'listen': '127.0.0.1:10808', + }, + 'http': { + 'listen': '127.0.0.1:10809', + }, +} + + +class ConfigHysteria1(ConfigFactory): + """Represent Hysteria 1 client configuration and share links.""" + + def __init__(self, config: Union[str, dict] = '', **kwargs): + """Initialize the ConfigHysteria1.""" + super().__init__(config, **kwargs) + + def coreName(self) -> str: + """Return the core implementation name.""" + return 'Hysteria1' + + @property + def itemProtocol(self) -> str: + """Return the item protocol value.""" + return Protocol.Hysteria1.value + + @property + def itemAddress(self) -> str: + """Return the item address value.""" + server = self.get('server', '') + + pos = server.rfind(':') + + if pos == -1: + return server + else: + return server[:pos] + + @property + def itemPort(self) -> str: + """Return the item port value.""" + server = self.get('server', '') + + pos = server.rfind(':') + + if pos == -1: + return '' + else: + return server[pos + 1 :] + + @property + def itemTransport(self) -> str: + """Return the item transport value.""" + return '' + + @property + def itemTLS(self) -> str: + """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) + + return super().toJSONString(indent=indent) + + def toURI(self, remark: str = '') -> str: + """Export the configuration as a share URI.""" + if remark == '': + override = self.itemRemark + else: + override = remark + + try: + netloc, mport = self['server'].split(',') + except Exception: + # Any non-exit exceptions + + netloc, mport = self['server'], '' + + if mport: + mportArgs = {'mport': mport} + else: + mportArgs = {} + + protocol = self.get('protocol', 'udp') + + if self.get('auth_str'): + # auth in string + authArgs = {'auth': self['auth_str']} + else: + authArgs = {} + + if self.get('server_name'): + peerArgs = {'peer': self['server_name']} + else: + peerArgs = {} + + if self.get('insecure') is True: + insecureArgs = {'insecure': '1'} + else: + insecureArgs = {} + + upmbps = self['up_mbps'] + downmbps = self['down_mbps'] + + if self.get('alpn'): + alpnArgs = {'alpn': self['alpn']} + else: + alpnArgs = {} + + if self.get('obfs'): + obfsParamArgs = {'obfsParam': self['obfs']} + else: + obfsParamArgs = {} + + query = queryStringFromItems( + { + **mportArgs, + 'protocol': protocol, + **authArgs, + **peerArgs, + **insecureArgs, + 'upmbps': upmbps, + 'downmbps': downmbps, + **alpnArgs, + **obfsParamArgs, + }.items() + ) + + return urlunparse(['hysteria', netloc, '', '', query, quote(override)]) + + def fromURI(self, URI: str) -> bool: + """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': + raise ValueError('Invalid hysteria1 URI scheme') + + server = result.netloc + + mport = queryObject.get('mport', '') + + if mport: + server += f',{mport}' + + protocol = queryObject.get('protocol', 'udp') + + if queryObject.get('auth', ''): + # auth in string + authArgs = {'auth_str': queryObject['auth']} + else: + authArgs = {} + + if queryObject.get('peer', ''): + peerArgs = {'server_name': queryObject['peer']} + else: + peerArgs = {} + + insecure = queryObject.get('insecure', False) + + if isinstance(insecure, bool): + pass + elif insecure == '1': + insecure = True + else: + insecure = False + + upmbps = queryObject.get('upmbps', 24) + downmbps = queryObject.get('downmbps', 96) + + if queryObject.get('alpn', ''): + alpnArgs = {'alpn': queryObject['alpn']} + else: + alpnArgs = {} + + # obfs mode seems not required, ignored... + + if queryObject.get('obfsParam', ''): + obfsParamArgs = {'obfs': queryObject['obfsParam']} + else: + obfsParamArgs = {} + + dict.__init__( + self, + **{ + 'server': server, + 'protocol': protocol, + **authArgs, + **obfsParamArgs, + 'up_mbps': int(upmbps), + 'down_mbps': int(downmbps), + **peerArgs, + **alpnArgs, + 'insecure': insecure, + 'socks5': { + 'listen': '127.0.0.1:10808', + }, + 'http': { + 'listen': '127.0.0.1:10809', + }, + }, + ) + + self.setExtras('remark', remark) + + return True + except Exception: + # Any non-exit exceptions + + return False + + def httpProxy(self) -> str: + """Return the configured local HTTP proxy endpoint.""" + try: + return self['http']['listen'] + except Exception: + # Any non-exit exceptions + + return '' + + def socksProxy(self) -> str: + """Return the configured local SOCKS proxy endpoint.""" + try: + return self['socks5']['listen'] + except Exception: + # Any non-exit exceptions + + return '' + + def setHttpProxy(self, endpoint: str) -> bool: + """Set the local HTTP proxy endpoint.""" + try: + if endpoint == '': + self.pop('http', None) + + return True + + if self.get('http') is None: + self['http'] = {} + + self['http']['listen'] = endpoint + + return True + except Exception: + # Any non-exit exceptions + + return False + + def setSocksProxy(self, endpoint: str) -> bool: + """Set the local SOCKS proxy endpoint.""" + try: + if endpoint == '': + self.pop('socks5', None) + + return True + + if self.get('socks5') is None: + self['socks5'] = {} + + self['socks5']['listen'] = endpoint + + return True + except Exception: + # Any non-exit exceptions + + return False + + +BLANK_CONFIG_HYSTERIA2 = { + 'server': '', + 'tls': { + 'insecure': False, + }, + 'socks5': { + 'listen': '127.0.0.1:10808', + }, + 'http': { + 'listen': '127.0.0.1:10809', + }, +} + + +class ConfigHysteria2(ConfigFactory): + """Represent Hysteria 2 client configuration and share links.""" + + def __init__(self, config: Union[str, dict] = '', **kwargs): + """Initialize the ConfigHysteria2.""" + super().__init__(config, **kwargs) + + def coreName(self) -> str: + """Return the core implementation name.""" + return 'Hysteria2' + + @property + def itemProtocol(self) -> str: + """Return the item protocol value.""" + return Protocol.Hysteria2.value + + @staticmethod + def splitServerAddressPort(server: str) -> Tuple[str, str]: + """Split a standard or Realm-mode server value into display fields.""" + if server.startswith(('realm://', 'realm+http://')): + result = urlparse(server) + + try: + port = result.port + except ValueError: + port = None + + return result.hostname or '', str(port) if port is not None else '' + + pos = server.rfind(':') + + if pos == -1: + return server, '' + else: + return server[:pos], server[pos + 1 :] + + @property + def itemAddress(self) -> str: + """Return the item address value.""" + address, _port = self.splitServerAddressPort(self.get('server', '')) + + return address + + @property + def itemPort(self) -> str: + """Return the item port value.""" + _address, port = self.splitServerAddressPort(self.get('server', '')) + + return port + + @property + def itemTransport(self) -> str: + """Return the item transport value.""" + return '' + + @property + def itemTLS(self) -> str: + """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) + + return super().toJSONString(indent=indent) + + @staticmethod + def splitHysteria2RealmQueryItems(queryItems): + """Separate Realm transport parameters from Hysteria 2 parameters.""" + realmItems = [] + hysteria2Items = [] + + for key, value in queryItems: + if key in ['stun', 'lport']: + realmItems.append((key, value)) + else: + hysteria2Items.append((key, value)) + + return realmItems, hysteria2Items + + def toURI(self, remark: str = '') -> str: + """Export the configuration as a share URI.""" + if remark == '': + override = self.itemRemark + else: + override = remark + + TLSArg, obfsArg = {}, {} + + if self.get('tls'): + if self['tls'].get('sni'): + TLSArg['sni'] = self['tls']['sni'] + + if self['tls'].get('insecure') is True: + TLSArg['insecure'] = '1' + else: + TLSArg['insecure'] = '0' + + if self['tls'].get('pinSHA256'): + TLSArg['pinSHA256'] = self['tls']['pinSHA256'] + + if self.get('obfs'): + obfsType = self['obfs'].get('type', 'salamander') + + obfsArg['obfs'] = obfsType + obfsArg['obfs-password'] = self['obfs'][obfsType]['password'] + + if self['server'].startswith(('realm://', 'realm+http://')): + result = urlparse(self['server']) + + if result.scheme == 'realm+http': + scheme = 'hysteria2+realm+http' + else: + scheme = 'hysteria2+realm' + + realmItems, _hysteria2Items = self.splitHysteria2RealmQueryItems( + parse_qsl(result.query) + ) + query = queryStringFromItems( + [ + ('auth', self['auth']), + *TLSArg.items(), + *obfsArg.items(), + *realmItems, + ] + ) + + return urlunparse( + [scheme, result.netloc, result.path, '', query, quote(override)] + ) + + netloc, query = ( + self['auth'] + '@' + self['server'], + queryStringFromItems([*TLSArg.items(), *obfsArg.items()]), + ) + + return urlunparse(['hysteria2', netloc, '', '', query, quote(override)]) + + def fromURI(self, URI: str) -> bool: + """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} + + if result.scheme not in [ + 'hysteria2', + 'hy2', + 'hysteria2+realm', + 'hysteria2+realm+http', + ]: + raise ValueError('Invalid hysteria2 URI scheme') + + if result.scheme in ['hysteria2+realm', 'hysteria2+realm+http']: + auth = queryObject.get('auth', '') + realmItems, _hysteria2Items = self.splitHysteria2RealmQueryItems( + queryItems + ) + + if result.scheme == 'hysteria2+realm+http': + realmScheme = 'realm+http' + else: + realmScheme = 'realm' + + server = urlunparse( + [ + realmScheme, + result.netloc, + result.path, + '', + queryStringFromItems(realmItems), + '', + ] + ) + else: + auth, server = result.netloc.split('@') + + obfs = queryObject.get('obfs', '') + obfsPassword = queryObject.get('obfs-password', '') + sni = queryObject.get('sni', '') + + insecure = queryObject.get('insecure', False) + + if isinstance(insecure, bool): + pass + elif insecure == '1': + insecure = True + else: + insecure = False + + pinSHA256 = queryObject.get('pinSHA256', '') + + obfsArg = {} + pinSHA256Arg = {} + + if obfs and obfsPassword: + obfsArg['obfs'] = { + 'type': obfs, + obfs: { + 'password': obfsPassword, + }, + } + + if pinSHA256: + pinSHA256Arg['pinSHA256'] = pinSHA256 + + dict.__init__( + self, + **{ + 'server': server, + 'auth': auth, + **obfsArg, + 'tls': { + 'sni': sni, + 'insecure': insecure, + **pinSHA256Arg, + }, + 'socks5': { + 'listen': '127.0.0.1:10808', + }, + 'http': { + 'listen': '127.0.0.1:10809', + }, + }, + ) + + self.setExtras('remark', remark) + + return True + except Exception: + # Any non-exit exceptions + + return False + + def httpProxy(self) -> str: + """Return the configured local HTTP proxy endpoint.""" + try: + return self['http']['listen'] + except Exception: + # Any non-exit exceptions + + return '' + + def socksProxy(self) -> str: + """Return the configured local SOCKS proxy endpoint.""" + try: + return self['socks5']['listen'] + except Exception: + # Any non-exit exceptions + + return '' + + def setHttpProxy(self, endpoint: str) -> bool: + """Set the local HTTP proxy endpoint.""" + try: + if endpoint == '': + self.pop('http', None) + + return True + + if self.get('http') is None: + self['http'] = {} + + self['http']['listen'] = endpoint + + return True + except Exception: + # Any non-exit exceptions + + return False + + def setSocksProxy(self, endpoint: str) -> bool: + """Set the local SOCKS proxy endpoint.""" + try: + if endpoint == '': + self.pop('socks5', None) + + return True + + if self.get('socks5') is None: + self['socks5'] = {} + + self['socks5']['listen'] = endpoint + + return True + except Exception: + # Any non-exit exceptions + + return False + + +def configXrayEmptyProxyOutboundObject(protocol: Protocol) -> dict: + """Return the config Xray empty proxy outbound object value used by the application.""" + value = protocol.value.lower() + + if value == 'vless' or value == 'vmess': + return { + 'tag': 'proxy', + 'protocol': value, + 'settings': { + 'vnext': [ + { + 'address': '', + 'port': 0, + 'users': [ + { + 'email': PROXY_OUTBOUND_USER_EMAIL, + }, + ], + }, + ] + }, + 'streamSettings': { + 'network': 'tcp', + }, + 'mux': { + 'enabled': False, + 'concurrency': -1, + }, + } + elif value == 'shadowsocks': + return { + 'tag': 'proxy', + 'protocol': 'shadowsocks', + 'settings': { + 'servers': [ + { + 'address': '', + 'port': 0, + 'method': '', + 'password': '', + 'email': PROXY_OUTBOUND_USER_EMAIL, + 'ota': False, + } + ] + }, + 'streamSettings': { + 'network': 'tcp', + }, + 'mux': { + 'enabled': False, + 'concurrency': -1, + }, + } + elif value == 'socks': + return { + 'tag': 'proxy', + 'protocol': 'socks', + 'settings': { + 'address': '', + 'port': 0, + }, + 'streamSettings': { + 'network': 'tcp', + }, + 'mux': { + 'enabled': False, + 'concurrency': -1, + }, + } + elif value == 'trojan': + return { + 'tag': 'proxy', + 'protocol': 'trojan', + 'settings': { + 'servers': [ + { + 'address': '', + 'port': 0, + 'password': '', + 'email': PROXY_OUTBOUND_USER_EMAIL, + } + ] + }, + 'streamSettings': { + 'network': 'tcp', + }, + 'mux': { + 'enabled': False, + 'concurrency': -1, + }, + } + else: + return {} diff --git a/Furious/Plugins/Official/Hysteria1/Core.py b/Furious/Plugins/Official/Hysteria1/Core.py new file mode 100644 index 00000000..7efebc12 --- /dev/null +++ b/Furious/Plugins/Official/Hysteria1/Core.py @@ -0,0 +1,160 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Wrap the embedded Hysteria 1 core process.""" + +from __future__ import annotations + +from Furious.Frozenlib import * +from Furious.Core.CoreProcessWorker import * + +from enum import Enum +from typing import Union + +import time +import logging +import functools +import multiprocessing + +logger = logging.getLogger(__name__) + +__all__ = ['Hysteria1'] + + +def startHysteria1(jsonString, rule, mmdb, msgQueue: multiprocessing.Queue): + """Start hysteria1.""" + try: + import hysteria + except ImportError: + # Fake running process + while True: + time.sleep(3600) + else: + if versionToValue(hysteria.__version__) <= versionToValue('1.3.5'): + redirect = False + else: + redirect = True + + ProcessOutputRedirector.launch( + msgQueue, + functools.partial(hysteria.startFromJSON, jsonString, rule, mmdb), + redirect, + ) + + +class Hysteria1(CoreProcessWorker): + """Manage the embedded Hysteria 1 core subprocess.""" + + class ExitCode(Enum): + """Enumerate process exit codes.""" + + ConfigurationError = 23 + RemoteNetworkError = 3 + # Windows shutting down + SystemShuttingDown = 0x40010004 + + def __init__(self, **kwargs): + """Initialize the Hysteria1.""" + super().__init__(**kwargs) + + @staticmethod + def loadOptionalFile(pathLike, fileType: str): + """Load optional file.""" + if isinstance(pathLike, str) and pathLike == '': + return '' + + try: + path = absolutePath(str(pathLike)) + except Exception: + # Any non-exit exceptions + + logger.error(f'invalid hysteria1 {fileType} path. Fall back to empty') + + return '' + + try: + with open(path, 'rb') as file: + data = file.read() + + logger.info(f'hysteria1 {fileType} \'{path}\' load success') + + return data + except Exception as ex: + # Any non-exit exceptions + + logger.error( + f'hysteria1 {fileType} \'{path}\' load failed. {ex}. ' + f'Fall back to empty' + ) + + return '' + + @staticmethod + def rule(rulePath): + """Return the rule value.""" + return Hysteria1.loadOptionalFile(rulePath, 'rule') + + @staticmethod + def mmdb(mmdbPath): + """Return the mmdb value.""" + return Hysteria1.loadOptionalFile(mmdbPath, 'mmdb') + + @staticmethod + def name() -> str: + """Return the process implementation name.""" + return 'Hysteria1' + + @staticmethod + def version() -> str: + """Return the bundled core version.""" + try: + import hysteria + + return hysteria.__version__ + except Exception: + # Any non-exit exceptions + + return '0.0.0' + + def launchSpec( + self, config: Union[str, dict], rule, mmdb, **kwargs + ) -> Union[CoreLaunchSpec, None]: + """Build the child-process launch specification.""" + param = self.toJSONString(config) + + if not param: + return None + + return CoreLaunchSpec( + target=startHysteria1, + args=( + param, + rule, + mmdb, + self.msgQueue, + ), + processKwargs=kwargs, + ) + + def start(self, config: Union[str, dict], rule, mmdb, **kwargs) -> bool: + """Start the hysteria1.""" + launchSpec = self.launchSpec(config, rule, mmdb, **kwargs) + + if launchSpec is None: + return False + + return self.startWithSpec(launchSpec) diff --git a/Furious/Plugins/Official/Hysteria1/GuiHysteria1.py b/Furious/Plugins/Official/Hysteria1/GuiHysteria1.py new file mode 100644 index 00000000..b2b75130 --- /dev/null +++ b/Furious/Plugins/Official/Hysteria1/GuiHysteria1.py @@ -0,0 +1,386 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Provide widgets for GUI hysteria1.""" + +from __future__ import annotations + +from Furious.Frozenlib import * +from Furious.Interface import * +from Furious.Qt import * +from Furious.Qt import gettext as _ + +from PySide6 import QtCore +from PySide6.QtGui import * +from PySide6.QtWidgets import * + +import logging +import functools + +__all__ = ['GuiHysteria1'] + +logger = logging.getLogger(__name__) + + +class GuiHy1ItemTextInput(GuiEditorItemTextInput): + """Represent GUI hy1 item text input.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiHy1ItemTextInput.""" + key = kwargs.pop('key', '') + + super().__init__(*args, **kwargs) + + self.key = key + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + oldValue = config.get(self.key, '') + newValue = self.text() + + if isinstance(oldValue, str): + if newValue != oldValue: + if newValue == '': + # Remove field + config.pop(self.key, None) + else: + config[self.key] = newValue + + return True + else: + return False + else: + config[self.key] = newValue + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + self.setText(config.get(self.key, '')) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiHy1ItemBasicProtocol(GuiEditorItemTextComboBox): + """Represent GUI hy1 item basic protocol.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiHy1ItemBasicProtocol.""" + super().__init__(*args, **kwargs) + + self.addItems(['', 'udp', 'wechat-video', 'faketcp']) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + oldProtocol = config.get('protocol', '') + newProtocol = self.text() + + if isinstance(oldProtocol, str): + if newProtocol != oldProtocol: + config['protocol'] = newProtocol + + return True + else: + return False + else: + config['protocol'] = newProtocol + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + self.setText(config.get('protocol', 'udp')) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiHy1ItemSpeedUpMbps(GuiEditorItemTextSpinBox): + """Represent GUI hy1 item speed up mbps.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiHy1ItemSpeedUpMbps.""" + super().__init__(*args, **kwargs) + + # Range + self.setRange(0, 1048576) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + oldUpMbps = config.get('up_mbps') + newUpMbps = self.value() + + if isinstance(oldUpMbps, int): + if newUpMbps != oldUpMbps: + config['up_mbps'] = newUpMbps + + return True + else: + return False + else: + config['up_mbps'] = newUpMbps + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + self.setValue(config.get('up_mbps')) + except Exception: + # Any non-exit exceptions + + self.setValue(24) + + +class GuiHy1ItemSpeedDownMbps(GuiEditorItemTextSpinBox): + """Represent GUI hy1 item speed down mbps.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiHy1ItemSpeedDownMbps.""" + super().__init__(*args, **kwargs) + + # Range + self.setRange(0, 1048576) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + oldDownMbps = config.get('down_mbps') + newDownMbps = self.value() + + if isinstance(oldDownMbps, int): + if newDownMbps != oldDownMbps: + config['down_mbps'] = newDownMbps + + return True + else: + return False + else: + config['down_mbps'] = newDownMbps + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + self.setValue(config.get('down_mbps')) + except Exception: + # Any non-exit exceptions + + self.setValue(96) + + +class GuiHy1ItemTLSInsecure(GuiEditorItemTextCheckBox): + """Represent GUI hy1 item TLS insecure.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiHy1ItemTLSInsecure.""" + super().__init__(*args, **kwargs) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + try: + oldChecked = config.get('insecure') + except Exception: + # Any non-exit exceptions + + oldChecked = False + + newChecked = self.isChecked() + + if newChecked: + if oldChecked is not True: + config['insecure'] = True + + return True + else: + return False + else: + if oldChecked is True: + config['insecure'] = False + + return True + if oldChecked is False: + return False + + # Invalid value for insecure. Remove it + config.pop('insecure', None) + + # Modified silently + return False + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + checked = config['insecure'] + except Exception: + # Any non-exit exceptions + + checked = False + + if not isinstance(checked, bool): + checked = False + + self.setChecked(checked) + + +class GuiHy1ProjectWebsiteURL(AppQLabel): + """Represent GUI hy1 project website URL.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiHy1ProjectWebsiteURL.""" + super().__init__(*args, **kwargs) + + self.setWebsiteURL() + self.linkActivated.connect(self.handleLinkActivated) + + def setWebsiteURL(self): + """Set website URL.""" + self.setText( + '

' + '' + '' + + _('Project Website') + + '

' + ) + + @staticmethod + def handleLinkActivated(link: str): + """Handle link activated.""" + if QDesktopServices.openUrl(QtCore.QUrl(link)): + logger.info(f'open link \'{link}\' success') + else: + logger.error(f'open link \'{link}\' failed') + + def retranslate(self): + """Refresh translated text for the GUI hy1 project website URL.""" + self.setWebsiteURL() + + +class GuiHy1GroupBoxBasic(GuiEditorWidgetQGroupBox): + """Represent GUI hy1 group box basic.""" + + def __init__(self, **kwargs): + """Initialize the GuiHy1GroupBoxBasic.""" + super().__init__(_('Basic Configuration'), **kwargs) + + def containerSequence(self): + """Return the editor item containers in display order.""" + return [ + GuiEditorItemBasicRemark(title=_('Remark')), + GuiHy1ItemTextInput(title=_('Server'), key='server'), + GuiHy1ItemBasicProtocol(title=_('Protocol')), + GuiHy1ItemTextInput(title='auth_str', translatable=False, key='auth_str'), + GuiHy1ItemTextInput(title='obfs', translatable=False, key='obfs'), + ] + + +class GuiHy1GroupBoxProxy(GuiEditorWidgetQGroupBox): + """Represent GUI hy1 group box proxy.""" + + def __init__(self, **kwargs): + """Initialize the GuiHy1GroupBoxProxy.""" + super().__init__(_('Proxy'), **kwargs) + + def containerSequence(self): + """Return the editor item containers in display order.""" + return [ + GuiEditorItemProxyHttp(title='http', translatable=False), + GuiEditorItemProxySocks(title='socks', translatable=False), + ] + + +class GuiHy1GroupBoxSpeed(GuiEditorWidgetQGroupBox): + """Represent GUI hy1 group box speed.""" + + def __init__(self, **kwargs): + """Initialize the GuiHy1GroupBoxSpeed.""" + super().__init__(_('Speed'), **kwargs) + + def containerSequence(self): + """Return the editor item containers in display order.""" + return [ + GuiHy1ItemSpeedUpMbps(title='up_mbps', translatable=False), + GuiHy1ItemSpeedDownMbps(title='down_mbps', translatable=False), + ] + + +class GuiHy1GroupBoxTLS(GuiEditorWidgetQGroupBox): + """Represent GUI hy1 group box TLS.""" + + def __init__(self, **kwargs): + """Initialize the GuiHy1GroupBoxTLS.""" + super().__init__('TLS', **kwargs) + + self.translatable = False + + def containerSequence(self): + """Return the editor item containers in display order.""" + return [ + GuiHy1ItemTextInput(title='sni', translatable=False, key='server_name'), + GuiHy1ItemTextInput(title='alpn', translatable=False, key='alpn'), + GuiHy1ItemTextInput(title='ca', translatable=False, key='ca'), + GuiHy1ItemTLSInsecure(title='insecure', translatable=False), + ] + + +class GuiHy1GroupBoxOther(GuiEditorItemFactory, AppQGroupBox): + """Represent GUI hy1 group box other.""" + + def __init__(self, **kwargs): + """Initialize the GuiHy1GroupBoxOther.""" + super().__init__(_('Other'), **kwargs) + + self._website = GuiHy1ProjectWebsiteURL() + + layout = QFormLayout() + layout.addRow(self._website) + + self.setLayout(layout) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + return False + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + pass + + +class GuiHysteria1(GuiEditorWidgetQDialog): + """Represent GUI hysteria1.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiHysteria1.""" + super().__init__(*args, **kwargs) + + self.setTabText(Protocol.Hysteria1.value) + + @functools.lru_cache(None) + def groupBoxSequence(self): + """Return the configuration group boxes in display order.""" + return [ + GuiHy1GroupBoxBasic(), + GuiHy1GroupBoxProxy(), + GuiHy1GroupBoxSpeed(), + GuiHy1GroupBoxTLS(), + # GuiHy1GroupBoxOther(), + ] diff --git a/Furious/Plugins/Official/Hysteria1/Plugin.py b/Furious/Plugins/Official/Hysteria1/Plugin.py new file mode 100644 index 00000000..985c9153 --- /dev/null +++ b/Furious/Plugins/Official/Hysteria1/Plugin.py @@ -0,0 +1,149 @@ +"""Integrate Hysteria 1 configuration, editor, routing, and startup.""" + +from __future__ import annotations + +from Furious.Frozenlib import * +from Furious.Plugins.API import FuriousPlugin, PluginProtocol +from Furious.Plugins.Official.Configuration import ( + BLANK_CONFIG_HYSTERIA1, + ConfigHysteria1, +) + +import copy +import logging + +from .Core import Hysteria1 + +__all__ = ['Hysteria1Plugin'] + +logger = logging.getLogger(__name__) + + +class Hysteria1Plugin(FuriousPlugin): + """Provide official Hysteria 1 support.""" + + pluginId = 'official.hysteria1' + displayName = 'Hysteria1' + protocols = ( + PluginProtocol( + 'hysteria1', + 'hysteria1', + 'Add Hysteria1 Server...', + 50, + True, + ), + ) + configurationTypes = (ConfigHysteria1,) + coreTypes = (Hysteria1,) + + def configFromString(self, config: str, **kwargs): + """Parse a Hysteria 1 share URI.""" + return ( + ConfigHysteria1(config, **kwargs) + if config.startswith('hysteria://') + else None + ) + + def configFromDict(self, config: dict, **kwargs): + """Recognize Hysteria 1 configuration mappings.""" + if config.get('server') is None: + return None + + fields = ( + 'protocol', + 'up_mbps', + 'down_mbps', + 'auth_str', + 'alpn', + 'server_name', + 'insecure', + 'recv_window_conn', + 'recv_window', + 'fast_open', + 'lazy_start', + ) + if any(config.get(field) is not None for field in fields) or isinstance( + config.get('obfs'), str + ): + return ConfigHysteria1(config, **kwargs) + + return None + + def blankConfig(self, protocol, **kwargs): + """Construct a blank Hysteria 1 configuration.""" + return ConfigHysteria1(copy.deepcopy(BLANK_CONFIG_HYSTERIA1), **kwargs) + + def createEditorForProtocol(self, protocol, parent=None, **kwargs): + """Create the Hysteria 1 editor.""" + from .GuiHysteria1 import GuiHysteria1 + + return GuiHysteria1(parent=parent, **kwargs) + + def startCore( + self, + config, + routing, + exitCallback=None, + msgCallback=None, + proxyModeOnly=False, + log=True, + **kwargs, + ): + """Configure Hysteria 1 routing files and start its core.""" + if routing == AppBuiltinRouting.BypassMainlandChina.value: + if not proxyModeOnly and SystemRuntime.isTUNMode(): + from Furious.Qt.QtWidgets import showMBoxDirectRulesNotAllowed + + showMBoxDirectRulesNotAllowed() + + return None, False + + routingObject = { + 'rule': DATA_DIR / 'hysteria' / 'bypass-mainland-China.acl', + 'mmdb': DATA_DIR / 'hysteria' / 'country.mmdb', + } + elif routing == AppBuiltinRouting.Custom.value: + routingObject = { + 'rule': config.get('acl', ''), + 'mmdb': config.get('mmdb', ''), + } + else: + routingObject = {'rule': '', 'mmdb': ''} + + if 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, + ) + + return process, success + + def prepareDownloadTest(self, config, port: int): + """Create a Hysteria 1 configuration with one local HTTP proxy.""" + configcopy = config.deepcopy() + configcopy['http'] = { + 'listen': f'127.0.0.1:{port}', + 'timeout': 300, + 'disable_udp': False, + } + configcopy.pop('socks5', '') + + return configcopy + + def coreVersions(self): + """Return the bundled Hysteria 1 version.""" + return (Hysteria1.version(),) + + def coreExitMessage(self, core, exitcode: int): + """Interpret the Hysteria 1 remote-network exit code.""" + if exitcode == Hysteria1.ExitCode.RemoteNetworkError.value: + return 'Connection to server has been lost' + + return None diff --git a/Furious/Plugins/Official/Hysteria1/__init__.py b/Furious/Plugins/Official/Hysteria1/__init__.py new file mode 100644 index 00000000..792549b1 --- /dev/null +++ b/Furious/Plugins/Official/Hysteria1/__init__.py @@ -0,0 +1,5 @@ +"""Expose the official Hysteria 1 plugin implementation.""" + +from __future__ import annotations + +from .Plugin import * diff --git a/Furious/Plugins/Official/Hysteria2/Core.py b/Furious/Plugins/Official/Hysteria2/Core.py new file mode 100644 index 00000000..ae0d474d --- /dev/null +++ b/Furious/Plugins/Official/Hysteria2/Core.py @@ -0,0 +1,117 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Wrap the embedded Hysteria 2 core process.""" + +from __future__ import annotations + +from Furious.Frozenlib import * +from Furious.Core.CoreProcessWorker import * + +from enum import Enum +from typing import Union + +import time +import logging +import functools +import multiprocessing + +logger = logging.getLogger(__name__) + +__all__ = ['Hysteria2'] + + +def startHysteria2(jsonString: str, msgQueue: multiprocessing.Queue): + """Start hysteria2.""" + try: + import hysteria2 + except ImportError: + # Fake running process + while True: + time.sleep(3600) + else: + if versionToValue(hysteria2.__version__) <= versionToValue('2.0.0.1'): + redirect = False + else: + redirect = True + + ProcessOutputRedirector.launch( + msgQueue, + functools.partial(hysteria2.startFromJSON, jsonString), + redirect, + ) + + +class Hysteria2(CoreProcessWorker): + """Manage the embedded Hysteria 2 core subprocess.""" + + class ExitCode(Enum): + """Enumerate process exit codes.""" + + ConfigurationError = 23 + # Windows: 4294967295. Darwin, Linux: 255 (-1) + ServerStartFailure = 4294967295 if PLATFORM == 'Windows' else 255 + # Windows shutting down + SystemShuttingDown = 0x40010004 + + def __init__(self, **kwargs): + """Initialize the Hysteria2.""" + super().__init__(**kwargs) + + @staticmethod + def name() -> str: + """Return the process implementation name.""" + return 'Hysteria2' + + @staticmethod + def version() -> str: + """Return the bundled core version.""" + try: + import hysteria2 + + return hysteria2.__version__ + except Exception: + # Any non-exit exceptions + + return '0.0.0' + + def launchSpec( + self, config: Union[str, dict], **kwargs + ) -> Union[CoreLaunchSpec, None]: + """Build the child-process launch specification.""" + param = self.toJSONString(config) + + if not param: + return None + + return CoreLaunchSpec( + target=startHysteria2, + args=( + param, + self.msgQueue, + ), + processKwargs=kwargs, + ) + + def start(self, config: Union[str, dict], **kwargs) -> bool: + """Start the hysteria2.""" + launchSpec = self.launchSpec(config, **kwargs) + + if launchSpec is None: + return False + + return self.startWithSpec(launchSpec) diff --git a/Furious/Plugins/Official/Hysteria2/GuiHysteria2.py b/Furious/Plugins/Official/Hysteria2/GuiHysteria2.py new file mode 100644 index 00000000..76d95bc9 --- /dev/null +++ b/Furious/Plugins/Official/Hysteria2/GuiHysteria2.py @@ -0,0 +1,891 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Provide widgets for GUI hysteria2.""" + +from __future__ import annotations + +from Furious.Frozenlib import * +from Furious.Interface import * +from Furious.Qt import * +from Furious.Qt import gettext as _ + +from PySide6 import QtCore +from PySide6.QtGui import * +from PySide6.QtWidgets import * + +import logging +import functools + +__all__ = ['GuiHysteria2'] + +logger = logging.getLogger(__name__) + +HY2_OBFS_TYPES = ['', 'salamander', 'gecko'] + + +class GuiHy2ItemBasicServer(GuiEditorItemTextInput): + """Represent GUI hy2 item basic server.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiHy2ItemBasicServer.""" + super().__init__(*args, **kwargs) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + oldServer = config.get('server', '') + newServer = self.text() + + if isinstance(oldServer, str): + if newServer != oldServer: + config['server'] = newServer + + return True + else: + return False + else: + config['server'] = newServer + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + self.setText(config.get('server', '')) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiHy2ItemBasicAuth(GuiEditorItemTextInput): + """Represent GUI hy2 item basic auth.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiHy2ItemBasicAuth.""" + super().__init__(*args, **kwargs) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + oldAuth = config.get('auth') + newAuth = self.text() + + if newAuth == '': + if oldAuth is None: + return False + else: + config.pop('auth', None) + + return True + else: + if isinstance(oldAuth, str): + if newAuth != oldAuth: + config['auth'] = newAuth + + return True + else: + return False + else: + config['auth'] = newAuth + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + self.setText(config.get('auth', '')) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiHy2ItemBasicCongestionComboBox(GuiEditorItemTextComboBox): + """Represent GUI hy2 item basic congestion combo box.""" + + CONGESTION_KEYS = ['type', 'bbrProfile'] + + def __init__(self, *args, **kwargs): + """Initialize the GuiHy2ItemBasicCongestionComboBox.""" + key = kwargs.pop('key', '') + + assert key in self.CONGESTION_KEYS + + super().__init__(*args, **kwargs) + + self.key = key + + if key == 'type': + # TODO: Future extension + self.addItems(['', 'bbr', 'reno']) + elif key == 'bbrProfile': + # TODO: Future extension + self.addItems(['', 'standard', 'conservative', 'aggressive']) + else: + # Should not reach here + raise + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + newValue = self.text() + + if newValue == '': + congestion = config.get('congestion') + + if isinstance(congestion, dict): + if len(congestion) > 0: + if congestion.get(self.key) is not None: + oldValue = congestion[self.key] + + congestion.pop(self.key, None) + + if not isinstance(oldValue, str) or oldValue != '': + modified = True + else: + modified = False + else: + modified = False + + # TODO: Future extension + anyValid = any( + congestion.get(key, '') for key in self.CONGESTION_KEYS + ) + + if not anyValid: + config.pop('congestion', None) + + return True + else: + return modified + else: + config.pop('congestion', None) + + # Modified silently + return False + else: + config.pop('congestion', None) + + # Modified silently + return False + else: + if not isinstance(config.get('congestion'), dict): + config['congestion'] = {} + + oldValue = config['congestion'].get(self.key, '') + + if not isinstance(oldValue, str) or newValue != oldValue: + config['congestion'][self.key] = newValue + + return True + else: + return False + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + value = config['congestion'][self.key] + except Exception: + # Any non-exit exceptions + + value = '' + + self.setText(value) + + +class GuiHy2ItemObfsType(GuiEditorItemTextComboBox): + """Represent GUI hy2 item obfs type.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiHy2ItemObfsType.""" + super().__init__(*args, **kwargs) + + self.addItems(HY2_OBFS_TYPES) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + try: + oldObfsType = config['obfs']['type'] + except Exception: + # Any non-exit exceptions + + oldObfsType = '' + + newObfsType = self.text() + + if newObfsType == '': + if config.get('obfs') is None: + return False + else: + config.pop('obfs', None) + + return True + else: + if not isinstance(oldObfsType, str) or newObfsType != oldObfsType: + if not isinstance(config.get('obfs'), dict): + config['obfs'] = {} + + try: + config['obfs']['type'] = newObfsType + except Exception: + # Any non-exit exceptions + + pass + + return True + else: + return False + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + obfsType = config['obfs']['type'] + except Exception: + # Any non-exit exceptions + + obfsType = '' + + if isinstance(obfsType, str) and obfsType in HY2_OBFS_TYPES: + self.setText(obfsType) + else: + self.setText('') + + +class GuiHy2ItemObfsPassword(GuiEditorItemTextInput): + """Represent GUI hy2 item obfs password.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiHy2ItemObfsPassword.""" + self.obfsType = kwargs.pop('obfsType', 'salamander') + + super().__init__(*args, **kwargs) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + newObfsPassword = self.text() + + try: + obfsType = config['obfs']['type'] + except Exception: + # Any non-exit exceptions + + obfsType = '' + + if not isinstance(obfsType, str) or obfsType != self.obfsType: + return False + + modified = False + + if not isinstance(config.get('obfs'), dict): + config['obfs'] = {} + + modified = True + + if not isinstance(config['obfs'].get(obfsType), dict): + config['obfs'][obfsType] = {} + + modified = True + + try: + oldObfsPassword = config['obfs'][obfsType]['password'] + except Exception: + # Any non-exit exceptions + + oldObfsPassword = '' + + if not isinstance(oldObfsPassword, str) or newObfsPassword != oldObfsPassword: + config['obfs'][obfsType]['password'] = newObfsPassword + + return True + else: + return modified + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + obfsType = config['obfs']['type'] + except Exception: + # Any non-exit exceptions + + obfsType = '' + + if not isinstance(obfsType, str) or obfsType != self.obfsType: + self.setText('') + + return + + try: + obfsPassword = config['obfs'][obfsType]['password'] + except Exception: + # Any non-exit exceptions + + obfsPassword = '' + + self.setText(obfsPassword) + + +class GuiHy2ItemObfsPacketSize(GuiEditorItemTextSpinBox): + """Represent GUI hy2 item obfs packet size.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiHy2ItemObfsPacketSize.""" + self.obfsType, self.key, self.default = ( + kwargs.pop('obfsType', 'gecko'), + kwargs.pop('key', ''), + kwargs.pop('default', 0), + ) + + super().__init__(*args, **kwargs) + + self.setRange(1, 2048) + self.setValue(self.default) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + try: + obfsType = config['obfs']['type'] + except Exception: + # Any non-exit exceptions + + obfsType = '' + + if not isinstance(obfsType, str) or obfsType != self.obfsType: + return False + + if not isinstance(config.get('obfs'), dict): + config['obfs'] = {} + + if not isinstance(config['obfs'].get(obfsType), dict): + config['obfs'][obfsType] = {} + + oldValue = config['obfs'][obfsType].get(self.key) + newValue = self.value() + + if not isinstance(oldValue, int) or newValue != oldValue: + config['obfs'][obfsType][self.key] = newValue + + return True + + return False + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + obfsType = config['obfs']['type'] + except Exception: + # Any non-exit exceptions + + obfsType = '' + + if not isinstance(obfsType, str) or obfsType != self.obfsType: + self.setValue(self.default) + + return + + try: + value = config['obfs'][obfsType][self.key] + except Exception: + # Any non-exit exceptions + + value = self.default + + if not isinstance(value, int): + value = self.default + + self.setValue(value) + + +class GuiHy2PageObfsXXX(GuiEditorWidgetQWidget): + """Represent GUI hy2 page obfs xxx.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiHy2PageObfsXXX.""" + super().__init__(*args, **kwargs) + + def setObfsTypeText(self, text: str): + """Set obfs type text.""" + obfsType = self._containers[0] + + if isinstance(obfsType, GuiEditorItemTextComboBox): + obfsType.setText(text) + + def connectActivated(self, func): + """Connect activated.""" + obfsType = self._containers[0] + + if isinstance(obfsType, GuiEditorItemTextComboBox): + obfsType.connectActivated(func) + + +class GuiHy2PageObfsEmpty(GuiHy2PageObfsXXX): + """Represent GUI hy2 page obfs empty.""" + + def containerSequence(self): + """Return the editor item containers in display order.""" + return [ + GuiHy2ItemObfsType(title='obfs-type', translatable=False), + ] + + +class GuiHy2PageObfsSalamander(GuiHy2PageObfsXXX): + """Represent GUI hy2 page obfs salamander.""" + + def containerSequence(self): + """Return the editor item containers in display order.""" + return [ + GuiHy2ItemObfsType(title='obfs-type', translatable=False), + GuiHy2ItemObfsPassword( + title='obfs-password', + obfsType='salamander', + translatable=False, + ), + ] + + +class GuiHy2PageObfsGecko(GuiHy2PageObfsXXX): + """Represent GUI hy2 page obfs gecko.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiHy2PageObfsGecko.""" + super().__init__(*args, **kwargs) + + self.minPacketSizeItem = self._containers[2] + self.maxPacketSizeItem = self._containers[3] + self.minPacketSizeItem._input.valueChanged.connect( + self.handleMinPacketSizeChanged + ) + + self.handleMinPacketSizeChanged(self.minPacketSizeItem.value()) + + def containerSequence(self): + """Return the editor item containers in display order.""" + return [ + GuiHy2ItemObfsType(title='obfs-type', translatable=False), + GuiHy2ItemObfsPassword( + title='obfs-password', + obfsType='gecko', + translatable=False, + ), + GuiHy2ItemObfsPacketSize( + title='minPacketSize', + key='minPacketSize', + default=512, + translatable=False, + ), + GuiHy2ItemObfsPacketSize( + title='maxPacketSize', + key='maxPacketSize', + default=1200, + translatable=False, + ), + ] + + def handleMinPacketSizeChanged(self, value: int): + """Handle min packet size changed.""" + self.maxPacketSizeItem._input.setMinimum(value) + + if self.maxPacketSizeItem.value() < value: + self.maxPacketSizeItem.setValue(value) + + +class GuiHy2ObfsPageStackedWidget(QStackedWidget): + """Provide the GUI hy2 obfs page stacked widget.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiHy2ObfsPageStackedWidget.""" + super().__init__(*args, **kwargs) + + self._pages = [ + GuiHy2PageObfsEmpty(), + GuiHy2PageObfsSalamander(), + GuiHy2PageObfsGecko(), + ] + + for page in self._pages: + self.addWidget(page) + + def page(self, index: int) -> GuiHy2PageObfsXXX: + """Return the page value.""" + return self._pages[index] + + def connectActivated(self, func): + """Connect activated.""" + for page in self._pages: + page.connectActivated(func) + + +class GuiHy2ItemTLSTextInput(GuiEditorItemTextInput): + """Represent GUI hy2 item TLS text input.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiHy2ItemTLSTextInput.""" + key = kwargs.pop('key', '') + + super().__init__(*args, **kwargs) + + self.key = key + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + newValue = self.text() + + if newValue == '': + tls = config.get('tls') + + if isinstance(tls, dict): + if len(tls) > 0: + if tls.get(self.key) is not None: + oldValue = tls[self.key] + + tls.pop(self.key, None) + + if not isinstance(oldValue, str) or oldValue != '': + modified = True + else: + # Modified silently + modified = False + else: + modified = False + + # TODO: Future extension + anyValid = any( + tls.get(key, '') + for key in ['sni', 'insecure', 'pinSHA256', 'ca'] + ) + + if not anyValid: + config.pop('tls', None) + + return True + else: + return modified + else: + config.pop('tls', None) + + # Modified silently + return False + else: + config.pop('tls', None) + + # Modified silently + return False + else: + if not isinstance(config.get('tls'), dict): + config['tls'] = {} + + oldValue = config['tls'].get(self.key, '') + + if not isinstance(oldValue, str) or newValue != oldValue: + config['tls'][self.key] = newValue + + return True + else: + return False + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + value = config['tls'][self.key] + except Exception: + # Any non-exit exceptions + + value = '' + + self.setText(value) + + +class GuiHy2ItemTLSInsecure(GuiEditorItemTextCheckBox): + """Represent GUI hy2 item TLS insecure.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiHy2ItemTLSInsecure.""" + super().__init__(*args, **kwargs) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + try: + oldChecked = config['tls']['insecure'] + except Exception: + # Any non-exit exceptions + + oldChecked = False + + newChecked = self.isChecked() + + if newChecked: + if not isinstance(config.get('tls'), dict): + config['tls'] = {} + + if oldChecked is not True: + config['tls']['insecure'] = True + + return True + else: + return False + else: + if not isinstance(config.get('tls'), dict): + config['tls'] = {} + + if oldChecked is not False: + config['tls']['insecure'] = False + + return True + else: + return False + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + checked = config['tls']['insecure'] + except Exception: + # Any non-exit exceptions + + checked = False + + if not isinstance(checked, bool): + checked = False + + self.setChecked(checked) + + +class GuiHy2GroupBoxBasic(GuiEditorWidgetQGroupBox): + """Represent GUI hy2 group box basic.""" + + def __init__(self, **kwargs): + """Initialize the GuiHy2GroupBoxBasic.""" + super().__init__(_('Basic Configuration'), **kwargs) + + def containerSequence(self): + """Return the editor item containers in display order.""" + return [ + GuiEditorItemBasicRemark(title=_('Remark')), + GuiHy2ItemBasicServer(title=_('Server')), + GuiHy2ItemBasicAuth(title='auth', translatable=False), + GuiHy2ItemBasicCongestionComboBox( + title='congestion-type', key='type', translatable=False + ), + GuiHy2ItemBasicCongestionComboBox( + title='congestion-profile', key='bbrProfile', translatable=False + ), + ] + + +class GuiHy2GroupBoxProxy(GuiEditorWidgetQGroupBox): + """Represent GUI hy2 group box proxy.""" + + def __init__(self, **kwargs): + """Initialize the GuiHy2GroupBoxProxy.""" + super().__init__(_('Proxy'), **kwargs) + + def containerSequence(self): + """Return the editor item containers in display order.""" + return [ + GuiEditorItemProxyHttp(title='http', translatable=False), + GuiEditorItemProxySocks(title='socks', translatable=False), + ] + + +class GuiHy2GroupBoxObfs(GuiEditorItemFactory, AppQGroupBox): + """Represent GUI hy2 group box obfs.""" + + def __init__(self, **kwargs): + """Initialize the GuiHy2GroupBoxObfs.""" + translatable = kwargs.pop('translatable', False) + + super().__init__('obfs', **kwargs, translatable=translatable) + + self._config = ConfigFactory() + + self._widget = GuiHy2ObfsPageStackedWidget() + self._widget.connectActivated(self.handleActivated) + + layout = QFormLayout() + layout.addRow(self._widget) + + self.setLayout(layout) + + def currentIndex(self) -> int: + """Return the current index value.""" + return self._widget.currentIndex() + + def setCurrentIndex(self, index: int): + """Set current index.""" + self._widget.setCurrentIndex(index) + + def page(self, index: int) -> GuiHy2PageObfsXXX: + """Return the page value.""" + return self._widget.page(index) + + def handleActivated(self, index: int): + """Handle activated.""" + page = self.page(index) + page.factoryToInput(self._config) + page.setObfsTypeText(HY2_OBFS_TYPES[index]) + + self.setCurrentIndex(index) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + oldObfs = config.get('obfs') + oldObfsType = '' + + if isinstance(oldObfs, dict): + oldObfsType = oldObfs.get('type', '') + + newObfsType = HY2_OBFS_TYPES[self.currentIndex()] + + if newObfsType == '': + if isinstance(oldObfs, dict): + config.pop('obfs', None) + + return True + + return False + + if newObfsType not in HY2_OBFS_TYPES: + return False + + if not isinstance(config.get('obfs'), dict): + config['obfs'] = {} + + modified = oldObfsType != newObfsType + config['obfs']['type'] = newObfsType + + for obfsType in HY2_OBFS_TYPES: + if obfsType and obfsType != newObfsType: + config['obfs'].pop(obfsType, None) + + modified |= self.page(self.currentIndex()).inputToFactory(config) + + return modified + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + self._config = config + + try: + obfsType = config['obfs']['type'] + except Exception: + # Any non-exit exceptions + + obfsType = '' + + if not isinstance(obfsType, str) or obfsType not in HY2_OBFS_TYPES: + obfsType = '' + + index = HY2_OBFS_TYPES.index(obfsType) + + self.page(index).factoryToInput(config) + self.page(index).setObfsTypeText(obfsType) + self.setCurrentIndex(index) + + +class GuiHy2GroupBoxTLS(GuiEditorWidgetQGroupBox): + """Represent GUI hy2 group box TLS.""" + + def __init__(self, **kwargs): + """Initialize the GuiHy2GroupBoxTLS.""" + super().__init__('TLS', **kwargs) + + self.translatable = False + + def containerSequence(self): + """Return the editor item containers in display order.""" + return [ + GuiHy2ItemTLSTextInput(title='sni', key='sni', translatable=False), + GuiHy2ItemTLSTextInput( + title='pinSHA256', key='pinSHA256', translatable=False + ), + GuiHy2ItemTLSTextInput(title='ca', key='ca', translatable=False), + GuiHy2ItemTLSInsecure(title='insecure', translatable=False), + ] + + +class GuiHy2ProjectWebsiteURL(AppQLabel): + """Represent GUI hy2 project website URL.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiHy2ProjectWebsiteURL.""" + super().__init__(*args, **kwargs) + + self.setWebsiteURL() + self.linkActivated.connect(self.handleLinkActivated) + + def setWebsiteURL(self): + """Set website URL.""" + self.setText( + '

' + '' + '' + + _('Project Website') + + '

' + ) + + @staticmethod + def handleLinkActivated(link: str): + """Handle link activated.""" + if QDesktopServices.openUrl(QtCore.QUrl(link)): + logger.info(f'open link \'{link}\' success') + else: + logger.error(f'open link \'{link}\' failed') + + def retranslate(self): + """Refresh translated text for the GUI hy2 project website URL.""" + self.setWebsiteURL() + + +class GuiHy2GroupBoxOther(GuiEditorItemFactory, AppQGroupBox): + """Represent GUI hy2 group box other.""" + + def __init__(self, **kwargs): + """Initialize the GuiHy2GroupBoxOther.""" + super().__init__(_('Other'), **kwargs) + + self._website = GuiHy2ProjectWebsiteURL() + + layout = QFormLayout() + layout.addRow(self._website) + + self.setLayout(layout) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + return False + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + pass + + +class GuiHysteria2(GuiEditorWidgetQDialog): + """Represent GUI hysteria2.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiHysteria2.""" + super().__init__(*args, **kwargs) + + self.setTabText(Protocol.Hysteria2.value) + + @functools.lru_cache(None) + def groupBoxSequence(self): + """Return the configuration group boxes in display order.""" + return [ + GuiHy2GroupBoxBasic(), + GuiHy2GroupBoxProxy(), + GuiHy2GroupBoxObfs(), + GuiHy2GroupBoxTLS(), + # GuiHy2GroupBoxOther(), + ] diff --git a/Furious/Plugins/Official/Hysteria2/Plugin.py b/Furious/Plugins/Official/Hysteria2/Plugin.py new file mode 100644 index 00000000..aacf339f --- /dev/null +++ b/Furious/Plugins/Official/Hysteria2/Plugin.py @@ -0,0 +1,122 @@ +"""Integrate Hysteria 2 configuration, editor, and process startup.""" + +from __future__ import annotations + +from Furious.Plugins.API import FuriousPlugin, PluginProtocol +from Furious.Plugins.Official.Configuration import ( + BLANK_CONFIG_HYSTERIA2, + ConfigHysteria2, +) + +import copy +import logging + +from .Core import Hysteria2 + +__all__ = ['Hysteria2Plugin'] + +logger = logging.getLogger(__name__) + + +class Hysteria2Plugin(FuriousPlugin): + """Provide official Hysteria 2 support.""" + + pluginId = 'official.hysteria2' + displayName = 'Hysteria2' + protocols = ( + PluginProtocol( + 'hysteria2', + 'hysteria2', + 'Add Hysteria2 Server...', + 60, + ), + ) + configurationTypes = (ConfigHysteria2,) + coreTypes = (Hysteria2,) + + def configFromString(self, config: str, **kwargs): + """Parse a Hysteria 2 share URI.""" + if config.startswith( + ( + 'hy2://', + 'hysteria2://', + 'hysteria2+realm://', + 'hysteria2+realm+http://', + ) + ): + return ConfigHysteria2(config, **kwargs) + + return None + + def configFromDict(self, config: dict, **kwargs): + """Recognize Hysteria 2 configuration mappings.""" + if config.get('server') is None: + return None + + fields = ( + 'auth', + 'tls', + 'transport', + 'quic', + 'bandwidth', + 'tcpForwarding', + 'udpForwarding', + 'tcpTProxy', + 'udpTProxy', + 'fastOpen', + 'lazy', + ) + if any(config.get(field) is not None for field in fields) or isinstance( + config.get('obfs'), dict + ): + return ConfigHysteria2(config, **kwargs) + + return None + + def blankConfig(self, protocol, **kwargs): + """Construct a blank Hysteria 2 configuration.""" + return ConfigHysteria2(copy.deepcopy(BLANK_CONFIG_HYSTERIA2), **kwargs) + + def createEditorForProtocol(self, protocol, parent=None, **kwargs): + """Create the Hysteria 2 editor.""" + from .GuiHysteria2 import GuiHysteria2 + + return GuiHysteria2(parent=parent, **kwargs) + + def startCore( + self, + config, + routing, + exitCallback=None, + msgCallback=None, + proxyModeOnly=False, + log=True, + **kwargs, + ): + """Start the Hysteria 2 core.""" + if log: + logger.info(f'core {Hysteria2.name()} configured') + + process = Hysteria2(exitCallback=exitCallback, msgCallback=msgCallback) + + return process, process.start(config, **kwargs) + + def prepareDownloadTest(self, config, port: int): + """Create a Hysteria 2 configuration with one local HTTP proxy.""" + configcopy = config.deepcopy() + configcopy['http'] = { + 'listen': f'127.0.0.1:{port}', + 'timeout': 300, + 'disable_udp': False, + } + configcopy.pop('socks5', '') + + return configcopy + + def coreVersions(self): + """Return the bundled Hysteria 2 version.""" + return (Hysteria2.version(),) + + def logTimestampPatterns(self): + """Return the timestamp format emitted by Hysteria 2.""" + return (r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})',) diff --git a/Furious/Plugins/Official/Hysteria2/__init__.py b/Furious/Plugins/Official/Hysteria2/__init__.py new file mode 100644 index 00000000..b3692236 --- /dev/null +++ b/Furious/Plugins/Official/Hysteria2/__init__.py @@ -0,0 +1,5 @@ +"""Expose the official Hysteria 2 plugin implementation.""" + +from __future__ import annotations + +from .Plugin import * diff --git a/Furious/Plugins/Official/Xray/Core.py b/Furious/Plugins/Official/Xray/Core.py new file mode 100644 index 00000000..4d8ace82 --- /dev/null +++ b/Furious/Plugins/Official/Xray/Core.py @@ -0,0 +1,180 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Wrap the embedded Xray core process.""" + +from __future__ import annotations + +from Furious.Frozenlib import * +from Furious.Interface import * +from Furious.Library import * +from Furious.Core.CoreProcessWorker import * + +from enum import Enum +from typing import Union + +import io +import time +import threading +import functools +import multiprocessing + +__all__ = ['XrayCore'] + + +def startXrayCore(jsonString: str, msgQueue: multiprocessing.Queue): + """Start Xray core.""" + try: + import xray + except ImportError: + # Fake running process + while True: + time.sleep(3600) + else: + if versionToValue(xray.__version__) <= versionToValue('1.8.4'): + redirect = False + else: + # Can be redirected + redirect = True + + if not SystemRuntime.isPythonw(): + return ProcessOutputRedirector.launch( + msgQueue, + functools.partial(xray.startFromJSON, jsonString), + redirect, + ) + + if not redirect: + return xray.startFromJSON(jsonString) + + try: + jsonObject = UJSONEncoder.decode(jsonString) + except Exception: + # Any non-exit exceptions + + xray.startFromJSON(jsonString) + else: + loggingPath = list() + fileStreams = list() + + for loggingAttr in ['access', 'error']: + try: + path = jsonObject['log'][loggingAttr] + except Exception: + # Any non-exit exceptions + + continue + + if path not in loggingPath: + loggingPath.append(path) + + try: + stream = open(path, 'rb') + except Exception: + # Any non-exit exceptions + + pass + else: + stream.seek(0, io.SEEK_END) + + fileStreams.append(stream) + + def produceMsg(): + """Forward one process-output message to the shared queue.""" + while True: + for file in fileStreams: + for line in iter(file.readline, b''): + if line and not line.isspace(): + try: + msgQueue.put_nowait(line.decode('utf-8', 'replace')) + except Exception: + # Any non-exit exceptions + + pass + + time.sleep(MsgQueue.MSG_PRODUCE_THRESHOLD / 1000) + + try: + if fileStreams: + msgThread = threading.Thread(target=produceMsg, daemon=True) + msgThread.start() + + xray.startFromJSON(jsonString) + finally: + for stream in fileStreams: + stream.close() + + +class XrayCore(CoreProcessWorker): + """Manage the embedded Xray core subprocess.""" + + class ExitCode(Enum): + """Enumerate process exit codes.""" + + ConfigurationError = 23 + # Windows: 4294967295. Darwin, Linux: 255 (-1) + ServerStartFailure = 4294967295 if PLATFORM == 'Windows' else 255 + # Windows shutting down + SystemShuttingDown = 0x40010004 + + def __init__(self, **kwargs): + """Initialize the XrayCore.""" + super().__init__(**kwargs) + + @staticmethod + def name() -> str: + """Return the process implementation name.""" + return 'Xray-core' + + @staticmethod + def version() -> str: + """Return the bundled core version.""" + try: + import xray + + return xray.__version__ + except Exception: + # Any non-exit exceptions + + return '0.0.0' + + def launchSpec( + self, config: Union[str, ConfigFactory, dict], **kwargs + ) -> Union[CoreLaunchSpec, None]: + """Build the child-process launch specification.""" + param = self.toJSONString(config) + + if not param: + return None + + return CoreLaunchSpec( + target=startXrayCore, + args=( + param, + self.msgQueue, + ), + processKwargs=kwargs, + ) + + def start(self, config: Union[str, ConfigFactory, dict], **kwargs) -> bool: + """Start the Xray core.""" + launchSpec = self.launchSpec(config, **kwargs) + + if launchSpec is None: + return False + + return self.startWithSpec(launchSpec) diff --git a/Furious/Plugins/Official/Xray/GuiShadowsocks.py b/Furious/Plugins/Official/Xray/GuiShadowsocks.py new file mode 100644 index 00000000..04a7265a --- /dev/null +++ b/Furious/Plugins/Official/Xray/GuiShadowsocks.py @@ -0,0 +1,237 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Provide widgets for GUI shadowsocks.""" + +from __future__ import annotations + +from Furious.Frozenlib import * +from Furious.Interface import * +from Furious.Library import * +from Furious.Plugins.Official.Configuration import ( + ConfigXray, + configXrayEmptyProxyOutboundObject, +) +from Furious.Qt import * +from Furious.Qt import gettext as _ +from Furious.Plugins.Official.Xray.GuiVTransport import * +from Furious.Plugins.Official.Xray.GuiVTLS import * + +import functools + +__all__ = ['GuiShadowsocks'] + +getProxyOutboundServer = functools.partial( + ConfigXray.getProxyOutboundServer, + protocol=Protocol.Shadowsocks, + default=configXrayEmptyProxyOutboundObject(Protocol.Shadowsocks), +) + + +class GuiSSItemTextInput(GuiEditorItemTextInput): + """Represent GUI ss item text input.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiSSItemTextInput.""" + key = kwargs.pop('key', '') + + super().__init__(*args, **kwargs) + + self.key = key + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + proxyOutboundServer = getProxyOutboundServer(config) + + oldValue = proxyOutboundServer.get(self.key, '') + newValue = self.text() + + if isinstance(oldValue, str): + if newValue != oldValue: + if newValue == '': + # Remove field + proxyOutboundServer.pop(self.key, None) + else: + proxyOutboundServer[self.key] = newValue + + return True + else: + return False + else: + proxyOutboundServer[self.key] = newValue + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + proxyOutboundServer = getProxyOutboundServer(config) + + self.setText(proxyOutboundServer.get(self.key, '')) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiSSItemBasicPort(GuiEditorItemTextSpinBox): + """Represent GUI ss item basic port.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiSSItemBasicPort.""" + super().__init__(*args, **kwargs) + + # Range + self.setRange(0, 65535) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + proxyOutboundServer = getProxyOutboundServer(config) + + oldPort = proxyOutboundServer.get('port') + newPort = self.value() + + if isinstance(oldPort, int): + if newPort != oldPort: + proxyOutboundServer['port'] = newPort + + return True + else: + return False + else: + proxyOutboundServer['port'] = newPort + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + proxyOutboundServer = getProxyOutboundServer(config) + + self.setValue(proxyOutboundServer.get('port', 0)) + except Exception: + # Any non-exit exceptions + + self.setValue(0) + + +class GuiSSItemBasicMethod(GuiEditorItemTextComboBox): + """Represent GUI ss item basic method.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiSSItemBasicMethod.""" + super().__init__(*args, **kwargs) + + self.addItems( + [ + '', + 'aes-128-gcm', + 'aes-256-gcm', + 'chacha20-poly1305', + 'chacha20-ietf-poly1305', + 'xchacha20-poly1305', + 'xchacha20-ietf-poly1305', + 'none', + 'plain', + '2022-blake3-aes-128-gcm', + '2022-blake3-aes-256-gcm', + '2022-blake3-chacha20-poly1305', + ] + ) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + proxyOutboundServer = getProxyOutboundServer(config) + + oldMethod = proxyOutboundServer.get('method', '') + newMethod = self.text() + + if isinstance(oldMethod, str): + if newMethod != oldMethod: + proxyOutboundServer['method'] = newMethod + + return True + else: + return False + else: + proxyOutboundServer['method'] = newMethod + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + proxyOutboundServer = getProxyOutboundServer(config) + + self.setText(proxyOutboundServer.get('method', '')) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiSSGroupBoxBasic(GuiEditorWidgetQGroupBox): + """Represent GUI ss group box basic.""" + + def __init__(self, **kwargs): + """Initialize the GuiSSGroupBoxBasic.""" + super().__init__(_('Basic Configuration'), **kwargs) + + def containerSequence(self): + """Return the editor item containers in display order.""" + return [ + GuiEditorItemBasicRemark(title=_('Remark')), + GuiSSItemTextInput(title=_('Address'), key='address'), + GuiSSItemBasicPort(title=_('Port')), + GuiSSItemBasicMethod(title=_('Encryption')), + GuiSSItemTextInput(title=_('Password'), key='password'), + ] + + +class GuiSSGroupBoxProxy(GuiEditorWidgetQGroupBox): + """Represent GUI ss group box proxy.""" + + def __init__(self, **kwargs): + """Initialize the GuiSSGroupBoxProxy.""" + super().__init__(_('Proxy'), **kwargs) + + def containerSequence(self): + """Return the editor item containers in display order.""" + return [ + GuiEditorItemProxyHttp(title='http', translatable=False), + GuiEditorItemProxySocks(title='socks', translatable=False), + ] + + +class GuiShadowsocks(GuiEditorWidgetQDialog): + """Represent GUI shadowsocks.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiShadowsocks.""" + super().__init__(*args, **kwargs) + + self.setTabText(Protocol.Shadowsocks.value) + + @functools.lru_cache(None) + def groupBoxSequence(self): + """Return the configuration group boxes in display order.""" + return [ + GuiSSGroupBoxBasic(), + GuiSSGroupBoxProxy(), + GuiVTransportQGroupBox(), + GuiVTLSQGroupBox(), + ] diff --git a/Furious/Plugins/Official/Xray/GuiSocks.py b/Furious/Plugins/Official/Xray/GuiSocks.py new file mode 100644 index 00000000..69eaef2a --- /dev/null +++ b/Furious/Plugins/Official/Xray/GuiSocks.py @@ -0,0 +1,180 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Provide widgets for GUI SOCKS.""" + +from __future__ import annotations + +from Furious.Frozenlib import * +from Furious.Interface import * +from Furious.Library import * +from Furious.Plugins.Official.Configuration import ( + ConfigXray, + configXrayEmptyProxyOutboundObject, +) +from Furious.Qt import * +from Furious.Qt import gettext as _ +from Furious.Plugins.Official.Xray.GuiVTransport import * +from Furious.Plugins.Official.Xray.GuiVTLS import * + +import functools + +__all__ = ['GuiSocks'] + +getProxyOutboundServer = functools.partial( + ConfigXray.getProxyOutboundServer, + protocol=Protocol.Socks, + default=configXrayEmptyProxyOutboundObject(Protocol.Socks), +) + + +class GuiSocksItemTextInput(GuiEditorItemTextInput): + """Represent GUI SOCKS item text input.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiSocksItemTextInput.""" + key = kwargs.pop('key', '') + + super().__init__(*args, **kwargs) + + self.key = key + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + proxyOutboundServer = getProxyOutboundServer(config) + + oldValue = proxyOutboundServer.get(self.key, '') + newValue = self.text() + + if isinstance(oldValue, str): + if newValue != oldValue: + if newValue == '': + proxyOutboundServer.pop(self.key, None) + else: + proxyOutboundServer[self.key] = newValue + + return True + + return False + + proxyOutboundServer[self.key] = newValue + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + proxyOutboundServer = getProxyOutboundServer(config) + + self.setText(proxyOutboundServer.get(self.key, '')) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiSocksItemBasicPort(GuiEditorItemTextSpinBox): + """Represent GUI SOCKS item basic port.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiSocksItemBasicPort.""" + super().__init__(*args, **kwargs) + + self.setRange(0, 65535) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + proxyOutboundServer = getProxyOutboundServer(config) + + oldPort = proxyOutboundServer.get('port') + newPort = self.value() + + if isinstance(oldPort, int): + if newPort != oldPort: + proxyOutboundServer['port'] = newPort + + return True + + return False + + proxyOutboundServer['port'] = newPort + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + proxyOutboundServer = getProxyOutboundServer(config) + + self.setValue(proxyOutboundServer.get('port', 0)) + except Exception: + # Any non-exit exceptions + + self.setValue(0) + + +class GuiSocksGroupBoxBasic(GuiEditorWidgetQGroupBox): + """Represent GUI SOCKS group box basic.""" + + def __init__(self, **kwargs): + """Initialize the GuiSocksGroupBoxBasic.""" + super().__init__(_('Basic Configuration'), **kwargs) + + def containerSequence(self): + """Return the editor item containers in display order.""" + return [ + GuiEditorItemBasicRemark(title=_('Remark')), + GuiSocksItemTextInput(title=_('Address'), key='address'), + GuiSocksItemBasicPort(title=_('Port')), + GuiSocksItemTextInput(title=_('Username'), key='user'), + GuiSocksItemTextInput(title=_('Password'), key='pass'), + ] + + +class GuiSocksGroupBoxProxy(GuiEditorWidgetQGroupBox): + """Represent GUI SOCKS group box proxy.""" + + def __init__(self, **kwargs): + """Initialize the GuiSocksGroupBoxProxy.""" + super().__init__(_('Proxy'), **kwargs) + + def containerSequence(self): + """Return the editor item containers in display order.""" + return [ + GuiEditorItemProxyHttp(title='http', translatable=False), + GuiEditorItemProxySocks(title='socks', translatable=False), + ] + + +class GuiSocks(GuiEditorWidgetQDialog): + """Represent GUI SOCKS.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiSocks.""" + super().__init__(*args, **kwargs) + + self.setTabText(Protocol.Socks.value) + + @functools.lru_cache(None) + def groupBoxSequence(self): + """Return the configuration group boxes in display order.""" + return [ + GuiSocksGroupBoxBasic(), + GuiSocksGroupBoxProxy(), + GuiVTransportQGroupBox(), + GuiVTLSQGroupBox(), + ] diff --git a/Furious/Plugins/Official/Xray/GuiTrojan.py b/Furious/Plugins/Official/Xray/GuiTrojan.py new file mode 100644 index 00000000..02303eec --- /dev/null +++ b/Furious/Plugins/Official/Xray/GuiTrojan.py @@ -0,0 +1,181 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Provide widgets for GUI trojan.""" + +from __future__ import annotations + +from Furious.Frozenlib import * +from Furious.Interface import * +from Furious.Library import * +from Furious.Plugins.Official.Configuration import ( + ConfigXray, + configXrayEmptyProxyOutboundObject, +) +from Furious.Qt import * +from Furious.Qt import gettext as _ +from Furious.Plugins.Official.Xray.GuiVTransport import * +from Furious.Plugins.Official.Xray.GuiVTLS import * + +import functools + +__all__ = ['GuiTrojan'] + +getProxyOutboundServer = functools.partial( + ConfigXray.getProxyOutboundServer, + protocol=Protocol.Trojan, + default=configXrayEmptyProxyOutboundObject(Protocol.Trojan), +) + + +class GuiTrojanItemTextInput(GuiEditorItemTextInput): + """Represent GUI trojan item text input.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiTrojanItemTextInput.""" + key = kwargs.pop('key', '') + + super().__init__(*args, **kwargs) + + self.key = key + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + proxyOutboundServer = getProxyOutboundServer(config) + + oldValue = proxyOutboundServer.get(self.key, '') + newValue = self.text() + + if isinstance(oldValue, str): + if newValue != oldValue: + if newValue == '': + # Remove field + proxyOutboundServer.pop(self.key, None) + else: + proxyOutboundServer[self.key] = newValue + + return True + else: + return False + else: + proxyOutboundServer[self.key] = newValue + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + proxyOutboundServer = getProxyOutboundServer(config) + + self.setText(proxyOutboundServer.get(self.key, '')) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiTrojanItemBasicPort(GuiEditorItemTextSpinBox): + """Represent GUI trojan item basic port.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiTrojanItemBasicPort.""" + super().__init__(*args, **kwargs) + + # Range + self.setRange(0, 65535) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + proxyOutboundServer = getProxyOutboundServer(config) + + oldPort = proxyOutboundServer.get('port') + newPort = self.value() + + if isinstance(oldPort, int): + if newPort != oldPort: + proxyOutboundServer['port'] = newPort + + return True + else: + return False + else: + proxyOutboundServer['port'] = newPort + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + proxyOutboundServer = getProxyOutboundServer(config) + + self.setValue(proxyOutboundServer.get('port', 0)) + except Exception: + # Any non-exit exceptions + + self.setValue(0) + + +class GuiTrojanGroupBoxBasic(GuiEditorWidgetQGroupBox): + """Represent GUI trojan group box basic.""" + + def __init__(self, **kwargs): + """Initialize the GuiTrojanGroupBoxBasic.""" + super().__init__(_('Basic Configuration'), **kwargs) + + def containerSequence(self): + """Return the editor item containers in display order.""" + return [ + GuiEditorItemBasicRemark(title=_('Remark')), + GuiTrojanItemTextInput(title=_('Address'), key='address'), + GuiTrojanItemBasicPort(title=_('Port')), + GuiTrojanItemTextInput(title=_('Password'), key='password'), + ] + + +class GuiTrojanGroupBoxProxy(GuiEditorWidgetQGroupBox): + """Represent GUI trojan group box proxy.""" + + def __init__(self, **kwargs): + """Initialize the GuiTrojanGroupBoxProxy.""" + super().__init__(_('Proxy'), **kwargs) + + def containerSequence(self): + """Return the editor item containers in display order.""" + return [ + GuiEditorItemProxyHttp(title='http', translatable=False), + GuiEditorItemProxySocks(title='socks', translatable=False), + ] + + +class GuiTrojan(GuiEditorWidgetQDialog): + """Represent GUI trojan.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiTrojan.""" + super().__init__(*args, **kwargs) + + self.setTabText(Protocol.Trojan.value) + + @functools.lru_cache(None) + def groupBoxSequence(self): + """Return the configuration group boxes in display order.""" + return [ + GuiTrojanGroupBoxBasic(), + GuiTrojanGroupBoxProxy(), + GuiVTransportQGroupBox(), + GuiVTLSQGroupBox(), + ] diff --git a/Furious/Plugins/Official/Xray/GuiVLESS.py b/Furious/Plugins/Official/Xray/GuiVLESS.py new file mode 100644 index 00000000..b7589121 --- /dev/null +++ b/Furious/Plugins/Official/Xray/GuiVLESS.py @@ -0,0 +1,380 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Provide widgets for GUI VLESS.""" + +from __future__ import annotations + +from Furious.Frozenlib import * +from Furious.Interface import * +from Furious.Library import * +from Furious.Plugins.Official.Configuration import ( + ConfigXray, + configXrayEmptyProxyOutboundObject, +) +from Furious.Qt import * +from Furious.Qt import gettext as _ +from Furious.Plugins.Official.Xray.GuiVTransport import * +from Furious.Plugins.Official.Xray.GuiVTLS import * + +from PySide6.QtWidgets import * + +import uuid +import functools + +__all__ = ['GuiVLESS'] + +getProxyOutboundServer = functools.partial( + ConfigXray.getProxyOutboundServer, + protocol=Protocol.VLESS, + default=configXrayEmptyProxyOutboundObject(Protocol.VLESS), +) + +getProxyOutboundUser = functools.partial( + ConfigXray.getProxyOutboundUser, + protocol=Protocol.VLESS, + default=configXrayEmptyProxyOutboundObject(Protocol.VLESS), +) + + +class GuiVLESSItemBasicAddress(GuiEditorItemTextInput): + """Represent GUI VLESS item basic address.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVLESSItemBasicAddress.""" + super().__init__(*args, **kwargs) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + proxyOutboundServer = getProxyOutboundServer(config) + + oldAddress = proxyOutboundServer.get('address', '') + newAddress = self.text() + + if isinstance(oldAddress, str): + if newAddress != oldAddress: + if newAddress == '': + # Remove field + proxyOutboundServer.pop('address', None) + else: + proxyOutboundServer['address'] = newAddress + + return True + else: + return False + else: + proxyOutboundServer['address'] = newAddress + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + proxyOutboundServer = getProxyOutboundServer(config) + + self.setText(proxyOutboundServer.get('address', '')) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiVLESSItemBasicPort(GuiEditorItemTextSpinBox): + """Represent GUI VLESS item basic port.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVLESSItemBasicPort.""" + super().__init__(*args, **kwargs) + + # Range + self.setRange(0, 65535) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + proxyOutboundServer = getProxyOutboundServer(config) + + oldPort = proxyOutboundServer.get('port') + newPort = self.value() + + if isinstance(oldPort, int): + if newPort != oldPort: + proxyOutboundServer['port'] = newPort + + return True + else: + return False + else: + proxyOutboundServer['port'] = newPort + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + proxyOutboundServer = getProxyOutboundServer(config) + + self.setValue(proxyOutboundServer.get('port', 0)) + except Exception: + # Any non-exit exceptions + + self.setValue(0) + + +class GuiVLESSItemBasicId(GuiEditorItemTextInput): + """Represent GUI VLESS item basic id.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVLESSItemBasicId.""" + super().__init__(*args, **kwargs) + + self.generateButton = AppQPushButton(_('Generate')) + self.generateButton.clicked.connect(self.generateUUID) + + self._widget = QWidget() + + layout = QHBoxLayout(self._widget) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self._input) + layout.addWidget(self.generateButton) + + def generateUUID(self): + """Handle generate uuid for the GUI VLESS item basic id.""" + self.setText(str(uuid.uuid4())) + + def widgets(self): + """Return the widgets owned by this editor item.""" + return self._title, self._widget + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + proxyOutboundUser = getProxyOutboundUser(config) + + oldId = proxyOutboundUser.get('id', '') + newId = self.text() + + if isinstance(oldId, str): + if newId != oldId: + if newId == '': + # Remove field + proxyOutboundUser.pop('id', None) + else: + proxyOutboundUser['id'] = newId + + return True + else: + return False + else: + proxyOutboundUser['id'] = newId + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + proxyOutboundUser = getProxyOutboundUser(config) + + self.setText(proxyOutboundUser.get('id', '')) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiVLESSItemBasicEncryption(GuiEditorItemTextInput): + """Represent GUI VLESS item basic encryption.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVLESSItemBasicEncryption.""" + super().__init__(*args, **kwargs) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + proxyOutboundUser = getProxyOutboundUser(config) + + oldEncryption = proxyOutboundUser.get('encryption', '') + newEncryption = self.text() + + if isinstance(oldEncryption, str): + if newEncryption != oldEncryption: + if newEncryption == '': + # Remove field + proxyOutboundUser.pop('encryption', None) + else: + proxyOutboundUser['encryption'] = newEncryption + + return True + else: + return False + else: + proxyOutboundUser['encryption'] = newEncryption + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + proxyOutboundUser = getProxyOutboundUser(config) + + self.setText(proxyOutboundUser.get('encryption', '')) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiVLESSItemBasicFlow(GuiEditorItemTextComboBox): + """Represent GUI VLESS item basic flow.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVLESSItemBasicFlow.""" + super().__init__(*args, **kwargs) + + self.addItems( + [ + '', + 'none', + 'xtls-rprx-vision', + 'xtls-rprx-vision-udp443', + ] + ) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + proxyOutboundUser = getProxyOutboundUser(config) + + oldFlow = proxyOutboundUser.get('flow', '') + newFlow = self.text() + + if isinstance(oldFlow, str): + if newFlow != oldFlow: + proxyOutboundUser['flow'] = newFlow + + return True + else: + return False + else: + proxyOutboundUser['flow'] = newFlow + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + proxyOutboundUser = getProxyOutboundUser(config) + + self.setText(proxyOutboundUser.get('flow', '')) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiVLESSGroupBoxBasic(GuiEditorWidgetQGroupBox): + """Represent GUI VLESS group box basic.""" + + def __init__(self, **kwargs): + """Initialize the GuiVLESSGroupBoxBasic.""" + super().__init__(_('Basic Configuration'), **kwargs) + + def setupPageLayout(self): + """Set up page layout.""" + layout = QGridLayout() + layout.setColumnStretch(1, 1) + layout.setColumnStretch(3, 1) + + def keepSpinBoxCompact(widget: QWidget): + """Handle keep spin box compact for the GUI VLESS group box basic.""" + if isinstance(widget, QSpinBox): + widget.setSizePolicy( + QSizePolicy.Policy.Fixed, + widget.sizePolicy().verticalPolicy(), + ) + + def addPair(index: int, row: int, column: int): + """Add pair.""" + label, inputWidget = self._containers[index].widgets() + + keepSpinBoxCompact(inputWidget) + + layout.addWidget(label, row, column) + layout.addWidget(inputWidget, row, column + 1) + + def addFullRow(index: int, row: int): + """Add full row.""" + label, inputWidget = self._containers[index].widgets() + + keepSpinBoxCompact(inputWidget) + + layout.addWidget(label, row, 0) + layout.addWidget(inputWidget, row, 1, 1, 3) + + addFullRow(0, 0) + addFullRow(1, 1) + addFullRow(2, 2) + addFullRow(3, 3) + addPair(4, 4, 0) + addPair(5, 4, 2) + + layout.setRowStretch(5, 1) + + return layout + + def containerSequence(self): + """Return the editor item containers in display order.""" + return [ + GuiEditorItemBasicRemark(title=_('Remark')), + GuiVLESSItemBasicAddress(title=_('Address')), + GuiVLESSItemBasicPort(title=_('Port')), + GuiVLESSItemBasicId(title='Id', translatable=False), + GuiVLESSItemBasicEncryption(title=_('Encryption')), + GuiVLESSItemBasicFlow(title=_('Flow')), + ] + + +class GuiVLESSGroupBoxProxy(GuiEditorWidgetQGroupBox): + """Represent GUI VLESS group box proxy.""" + + def __init__(self, **kwargs): + """Initialize the GuiVLESSGroupBoxProxy.""" + super().__init__(_('Proxy'), **kwargs) + + def containerSequence(self): + """Return the editor item containers in display order.""" + return [ + GuiEditorItemProxyHttp(title='http', translatable=False), + GuiEditorItemProxySocks(title='socks', translatable=False), + ] + + +class GuiVLESS(GuiEditorWidgetQDialog): + """Represent GUI VLESS.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVLESS.""" + super().__init__(*args, **kwargs) + + self.setTabText(Protocol.VLESS.value) + + @functools.lru_cache(None) + def groupBoxSequence(self): + """Return the configuration group boxes in display order.""" + return [ + GuiVLESSGroupBoxBasic(), + GuiVLESSGroupBoxProxy(), + GuiVTransportQGroupBox(), + GuiVTLSQGroupBox(), + ] diff --git a/Furious/Plugins/Official/Xray/GuiVMess.py b/Furious/Plugins/Official/Xray/GuiVMess.py new file mode 100644 index 00000000..54b456e7 --- /dev/null +++ b/Furious/Plugins/Official/Xray/GuiVMess.py @@ -0,0 +1,389 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Provide widgets for GUI v mess.""" + +from __future__ import annotations + +from Furious.Frozenlib import * +from Furious.Interface import * +from Furious.Library import * +from Furious.Plugins.Official.Configuration import ( + ConfigXray, + configXrayEmptyProxyOutboundObject, +) +from Furious.Qt import * +from Furious.Qt import gettext as _ +from Furious.Plugins.Official.Xray.GuiVTransport import * +from Furious.Plugins.Official.Xray.GuiVTLS import * + +from PySide6.QtWidgets import * + +import uuid +import functools + +__all__ = ['GuiVMess'] + +getProxyOutboundServer = functools.partial( + ConfigXray.getProxyOutboundServer, + protocol=Protocol.VMess, + default=configXrayEmptyProxyOutboundObject(Protocol.VMess), +) + +getProxyOutboundUser = functools.partial( + ConfigXray.getProxyOutboundUser, + protocol=Protocol.VMess, + default=configXrayEmptyProxyOutboundObject(Protocol.VMess), +) + + +class GuiVMessItemBasicAddress(GuiEditorItemTextInput): + """Represent GUI v mess item basic address.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVMessItemBasicAddress.""" + super().__init__(*args, **kwargs) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + proxyOutboundServer = getProxyOutboundServer(config) + + oldAddress = proxyOutboundServer.get('address', '') + newAddress = self.text() + + if isinstance(oldAddress, str): + if newAddress != oldAddress: + if newAddress == '': + # Remove field + proxyOutboundServer.pop('address', None) + else: + proxyOutboundServer['address'] = newAddress + + return True + else: + return False + else: + proxyOutboundServer['address'] = newAddress + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + proxyOutboundServer = getProxyOutboundServer(config) + + self.setText(proxyOutboundServer.get('address', '')) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiVMessItemBasicPort(GuiEditorItemTextSpinBox): + """Represent GUI v mess item basic port.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVMessItemBasicPort.""" + super().__init__(*args, **kwargs) + + # Range + self.setRange(0, 65535) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + proxyOutboundServer = getProxyOutboundServer(config) + + oldPort = proxyOutboundServer.get('port') + newPort = self.value() + + if isinstance(oldPort, int): + if newPort != oldPort: + proxyOutboundServer['port'] = newPort + + return True + else: + return False + else: + proxyOutboundServer['port'] = newPort + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + proxyOutboundServer = getProxyOutboundServer(config) + + self.setValue(proxyOutboundServer.get('port', 0)) + except Exception: + # Any non-exit exceptions + + self.setValue(0) + + +class GuiVMessItemBasicId(GuiEditorItemTextInput): + """Represent GUI v mess item basic id.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVMessItemBasicId.""" + super().__init__(*args, **kwargs) + + self.generateButton = AppQPushButton(_('Generate')) + self.generateButton.clicked.connect(self.generateUUID) + + self._widget = QWidget() + + layout = QHBoxLayout(self._widget) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self._input) + layout.addWidget(self.generateButton) + + def generateUUID(self): + """Handle generate uuid for the GUI v mess item basic id.""" + self.setText(str(uuid.uuid4())) + + def widgets(self): + """Return the widgets owned by this editor item.""" + return self._title, self._widget + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + proxyOutboundUser = getProxyOutboundUser(config) + + oldId = proxyOutboundUser.get('id', '') + newId = self.text() + + if isinstance(oldId, str): + if newId != oldId: + if newId == '': + # Remove field + proxyOutboundUser.pop('id', None) + else: + proxyOutboundUser['id'] = newId + + return True + else: + return False + else: + proxyOutboundUser['id'] = newId + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + proxyOutboundUser = getProxyOutboundUser(config) + + self.setText(proxyOutboundUser.get('id', '')) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiVMessItemBasicAlterId(GuiEditorItemTextSpinBox): + """Represent GUI v mess item basic alter id.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVMessItemBasicAlterId.""" + super().__init__(*args, **kwargs) + + # Range + self.setRange(0, 65535) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + proxyOutboundUser = getProxyOutboundUser(config) + + oldAlterId = proxyOutboundUser.get('alterId') + newAlterId = self.value() + + if isinstance(oldAlterId, int): + if newAlterId != oldAlterId: + proxyOutboundUser['alterId'] = newAlterId + + return True + else: + return False + else: + proxyOutboundUser['alterId'] = newAlterId + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + proxyOutboundUser = getProxyOutboundUser(config) + + self.setValue(proxyOutboundUser.get('alterId', 0)) + except Exception: + # Any non-exit exceptions + + self.setValue(0) + + +class GuiVMessItemBasicSecurity(GuiEditorItemTextComboBox): + """Represent GUI v mess item basic security.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVMessItemBasicSecurity.""" + super().__init__(*args, **kwargs) + + self.addItems( + [ + '', + 'aes-128-gcm', + 'chacha20-poly1305', + 'auto', + 'none', + 'zero', + ] + ) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + proxyOutboundUser = getProxyOutboundUser(config) + + oldSecurity = proxyOutboundUser.get('security', '') + newSecurity = self.text() + + if isinstance(oldSecurity, str): + if newSecurity != oldSecurity: + proxyOutboundUser['security'] = newSecurity + + return True + else: + return False + else: + proxyOutboundUser['security'] = newSecurity + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + proxyOutboundUser = getProxyOutboundUser(config) + + self.setText(proxyOutboundUser.get('security', '')) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiVMessGroupBoxBasic(GuiEditorWidgetQGroupBox): + """Represent GUI v mess group box basic.""" + + def __init__(self, **kwargs): + """Initialize the GuiVMessGroupBoxBasic.""" + super().__init__(_('Basic Configuration'), **kwargs) + + def setupPageLayout(self): + """Set up page layout.""" + layout = QGridLayout() + layout.setColumnStretch(4, 1) + + def keepSpinBoxCompact(widget: QWidget): + """Handle keep spin box compact for the GUI v mess group box basic.""" + if isinstance(widget, QSpinBox): + widget.setSizePolicy( + QSizePolicy.Policy.Fixed, + widget.sizePolicy().verticalPolicy(), + ) + + def keepComboBoxCompact(widget: QWidget): + """Handle keep combo box compact for the GUI v mess group box basic.""" + if isinstance(widget, QComboBox): + widget.setSizePolicy( + QSizePolicy.Policy.Fixed, + widget.sizePolicy().verticalPolicy(), + ) + + def addPair(index: int, row: int, column: int): + """Add pair.""" + label, inputWidget = self._containers[index].widgets() + + keepSpinBoxCompact(inputWidget) + + layout.addWidget(label, row, column) + layout.addWidget(inputWidget, row, column + 1) + + def addFullRow(index: int, row: int): + """Add full row.""" + label, inputWidget = self._containers[index].widgets() + + keepSpinBoxCompact(inputWidget) + + layout.addWidget(label, row, 0) + layout.addWidget(inputWidget, row, 1, 1, 4) + + addFullRow(0, 0) + addFullRow(1, 1) + addPair(2, 2, 0) + addPair(4, 2, 2) + addFullRow(3, 3) + keepComboBoxCompact(self._containers[5].widgets()[1]) + addFullRow(5, 4) + + layout.setRowStretch(5, 1) + + return layout + + def containerSequence(self): + """Return the editor item containers in display order.""" + return [ + GuiEditorItemBasicRemark(title=_('Remark')), + GuiVMessItemBasicAddress(title=_('Address')), + GuiVMessItemBasicPort(title=_('Port')), + GuiVMessItemBasicId(title='Id', translatable=False), + GuiVMessItemBasicAlterId(title='AlterId', translatable=False), + GuiVMessItemBasicSecurity(title='Security', translatable=False), + ] + + +class GuiVMessGroupBoxProxy(GuiEditorWidgetQGroupBox): + """Represent GUI v mess group box proxy.""" + + def __init__(self, **kwargs): + """Initialize the GuiVMessGroupBoxProxy.""" + super().__init__(_('Proxy'), **kwargs) + + def containerSequence(self): + """Return the editor item containers in display order.""" + return [ + GuiEditorItemProxyHttp(title='http', translatable=False), + GuiEditorItemProxySocks(title='socks', translatable=False), + ] + + +class GuiVMess(GuiEditorWidgetQDialog): + """Represent GUI v mess.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVMess.""" + super().__init__(*args, **kwargs) + + self.setTabText(Protocol.VMess.value) + + @functools.lru_cache(None) + def groupBoxSequence(self): + """Return the configuration group boxes in display order.""" + return [ + GuiVMessGroupBoxBasic(), + GuiVMessGroupBoxProxy(), + GuiVTransportQGroupBox(), + GuiVTLSQGroupBox(), + ] diff --git a/Furious/Plugins/Official/Xray/GuiVTLS.py b/Furious/Plugins/Official/Xray/GuiVTLS.py new file mode 100644 index 00000000..27250a28 --- /dev/null +++ b/Furious/Plugins/Official/Xray/GuiVTLS.py @@ -0,0 +1,803 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Provide widgets for GUI vtls.""" + +from __future__ import annotations + +from Furious.Interface import * +from Furious.Library import * +from Furious.Plugins.Official.Configuration import ConfigXray +from Furious.Qt import * + +from PySide6 import QtCore +from PySide6.QtWidgets import * + +from typing import Callable + +__all__ = ['GuiVTLSQGroupBox'] + +STREAM_SECURITY = [ + '', + 'none', + 'tls', + 'reality', +] + + +class GuiVTLSItemSecurity(GuiEditorItemTextComboBox): + """Represent GUI vtls item security.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTLSItemSecurity.""" + super().__init__(*args, **kwargs) + + self.addItems(STREAM_SECURITY) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + streamSettings = ConfigXray.getProxyOutboundStream(config) + + try: + oldSecurity = streamSettings['security'] + except Exception: + # Any non-exit exceptions + + oldSecurity = '' + + newSecurity = self.text() + + def setNewSecurity(): + """Set new security.""" + if newSecurity == '': + streamSettings.pop('security', None) + else: + streamSettings['security'] = newSecurity + + for security in ['tls', 'reality']: + if security == newSecurity: + continue + + securityKey = f'{security}Settings' + + # Remove irrelevant settings + streamSettings.pop(securityKey, None) + + if isinstance(oldSecurity, str): + if newSecurity != oldSecurity: + setNewSecurity() + + return True + else: + return False + else: + setNewSecurity() + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + streamSettings = ConfigXray.getProxyOutboundStream(config) + + self.setText(streamSettings.get('security', '')) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiVTLSItemXXXServerName(GuiEditorItemTextInput): + """Represent GUI vtls item xxx server name.""" + + def __init__(self, *args, **kwargs): + # Mandatory + """Initialize the GuiVTLSItemXXXServerName.""" + securityKey = kwargs.pop('securityKey') + + super().__init__(*args, **kwargs) + + self.securityKey = securityKey + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + streamSettings = ConfigXray.getProxyOutboundStream(config) + + if not isinstance(streamSettings.get(self.securityKey), dict): + streamSettings[self.securityKey] = {} + + xxxObject = streamSettings[self.securityKey] + + try: + oldServerName = xxxObject.get('serverName', '') + except Exception: + # Any non-exit exceptions + + oldServerName = '' + + newServerName = self.text() + + def setNewServerName(): + """Set new server name.""" + if newServerName == '': + xxxObject.pop('serverName', None) + else: + xxxObject['serverName'] = newServerName + + if isinstance(oldServerName, str): + if newServerName != oldServerName: + setNewServerName() + + return True + else: + return False + else: + setNewServerName() + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + xxxObject = ConfigXray.getProxyOutboundStream(config)[self.securityKey] + + self.setText(xxxObject.get('serverName', '')) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiVTLSItemTLSServerName(GuiVTLSItemXXXServerName): + """Represent GUI vtls item TLS server name.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTLSItemTLSServerName.""" + securityKey = kwargs.pop('securityKey', 'tlsSettings') + + super().__init__(*args, **kwargs, securityKey=securityKey) + + +class GuiVTLSItemRealityServerName(GuiVTLSItemXXXServerName): + """Represent GUI vtls item reality server name.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTLSItemRealityServerName.""" + securityKey = kwargs.pop('securityKey', 'realitySettings') + + super().__init__(*args, **kwargs, securityKey=securityKey) + + +class GuiVTLSItemXXXFingerprint(GuiEditorItemTextInput): + """Represent GUI vtls item xxx fingerprint.""" + + def __init__(self, *args, **kwargs): + # Mandatory + """Initialize the GuiVTLSItemXXXFingerprint.""" + securityKey = kwargs.pop('securityKey') + + super().__init__(*args, **kwargs) + + self.securityKey = securityKey + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + streamSettings = ConfigXray.getProxyOutboundStream(config) + + if not isinstance(streamSettings.get(self.securityKey), dict): + streamSettings[self.securityKey] = {} + + xxxObject = streamSettings[self.securityKey] + + try: + oldFingerprint = xxxObject.get('fingerprint', '') + except Exception: + # Any non-exit exceptions + + oldFingerprint = '' + + newFingerprint = self.text() + + def setNewFingerprint(): + """Set new fingerprint.""" + if newFingerprint == '': + xxxObject.pop('fingerprint', None) + else: + xxxObject['fingerprint'] = newFingerprint + + if isinstance(oldFingerprint, str): + if newFingerprint != oldFingerprint: + setNewFingerprint() + + return True + else: + return False + else: + setNewFingerprint() + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + xxxObject = ConfigXray.getProxyOutboundStream(config)[self.securityKey] + + self.setText(xxxObject.get('fingerprint', '')) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiVTLSItemTLSFingerprint(GuiVTLSItemXXXFingerprint): + """Represent GUI vtls item TLS fingerprint.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTLSItemTLSFingerprint.""" + securityKey = kwargs.pop('securityKey', 'tlsSettings') + + super().__init__(*args, **kwargs, securityKey=securityKey) + + +class GuiVTLSItemRealityFingerprint(GuiVTLSItemXXXFingerprint): + """Represent GUI vtls item reality fingerprint.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTLSItemRealityFingerprint.""" + securityKey = kwargs.pop('securityKey', 'realitySettings') + + super().__init__(*args, **kwargs, securityKey=securityKey) + + +class GuiVTLSItemTLSAlpn(GuiEditorItemTextInput): + """Represent GUI vtls item TLS alpn.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTLSItemTLSAlpn.""" + super().__init__(*args, **kwargs) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + streamSettings = ConfigXray.getProxyOutboundStream(config) + + if not isinstance(streamSettings.get('tlsSettings'), dict): + streamSettings['tlsSettings'] = {} + + tlsObject = streamSettings['tlsSettings'] + + try: + oldAlpn = ','.join(tlsObject.get('alpn', [])) + except Exception: + # Any non-exit exceptions + + oldAlpn = '' + + newAlpn = self.text() + + def setNewAlpn(): + """Set new alpn.""" + if newAlpn == '': + tlsObject.pop('alpn', None) + else: + tlsObject['alpn'] = newAlpn.split(',') + + if isinstance(oldAlpn, str): + if newAlpn != oldAlpn: + setNewAlpn() + + return True + else: + return False + else: + setNewAlpn() + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + tlsObject = ConfigXray.getProxyOutboundStream(config)['tlsSettings'] + + self.setText(','.join(tlsObject.get('alpn', []))) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiVTLSItemTLSXXXTextInput(GuiEditorItemTextInput): + """Represent GUI vtls item tlsxxx text input.""" + + def __init__(self, *args, **kwargs): + # Mandatory + """Initialize the GuiVTLSItemTLSXXXTextInput.""" + key = kwargs.pop('key') + + super().__init__(*args, **kwargs) + + self.key = key + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + streamSettings = ConfigXray.getProxyOutboundStream(config) + + if not isinstance(streamSettings.get('tlsSettings'), dict): + streamSettings['tlsSettings'] = {} + + tlsObject = streamSettings['tlsSettings'] + + oldValue = tlsObject.get(self.key, '') + newValue = self.text() + + def setNewTLSXXXValue(): + """Set new tlsxxx value.""" + if newValue == '': + tlsObject.pop(self.key, None) + else: + tlsObject[self.key] = newValue + + if isinstance(oldValue, str): + if newValue != oldValue: + setNewTLSXXXValue() + + return True + else: + return False + else: + setNewTLSXXXValue() + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + tlsObject = ConfigXray.getProxyOutboundStream(config)['tlsSettings'] + + self.setText(tlsObject.get(self.key, '')) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiVTLSItemTLSAllowInsecure(GuiEditorItemTextCheckBox): + """Represent GUI vtls item TLS allow insecure.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTLSItemTLSAllowInsecure.""" + super().__init__(*args, **kwargs) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + streamSettings = ConfigXray.getProxyOutboundStream(config) + + if not isinstance(streamSettings.get('tlsSettings'), dict): + streamSettings['tlsSettings'] = {} + + tlsObject = streamSettings['tlsSettings'] + + try: + oldInsecure = tlsObject.get('allowInsecure', False) + except Exception: + # Any non-exit exceptions + + oldInsecure = False + + newInsecure = self.isChecked() + + if newInsecure: + if oldInsecure is not True: + tlsObject['allowInsecure'] = True + + return True + else: + return False + else: + if oldInsecure is not False: + tlsObject['allowInsecure'] = False + + return True + else: + return False + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + tlsObject = ConfigXray.getProxyOutboundStream(config)['tlsSettings'] + + self.setChecked(tlsObject.get('allowInsecure', False)) + except Exception: + # Any non-exit exceptions + + self.setChecked(False) + + +class GuiVTLSItemRealityXXX(GuiEditorItemTextInput): + """Represent GUI vtls item reality xxx.""" + + def __init__(self, *args, **kwargs): + # Mandatory + """Initialize the GuiVTLSItemRealityXXX.""" + realityKey = kwargs.pop('realityKey') + + super().__init__(*args, **kwargs) + + self.realityKey = realityKey + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + streamSettings = ConfigXray.getProxyOutboundStream(config) + + if not isinstance(streamSettings.get('realitySettings'), dict): + streamSettings['realitySettings'] = {} + + realityObject = streamSettings['realitySettings'] + + try: + oldPublicKey = realityObject.get(self.realityKey, '') + except Exception: + # Any non-exit exceptions + + oldPublicKey = '' + + newPublicKey = self.text() + + def setNewPublicKey(): + """Set new public key.""" + if newPublicKey == '': + realityObject.pop(self.realityKey, None) + else: + realityObject[self.realityKey] = newPublicKey + + if isinstance(oldPublicKey, str): + if newPublicKey != oldPublicKey: + setNewPublicKey() + + return True + else: + return False + else: + setNewPublicKey() + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + realityObject = ConfigXray.getProxyOutboundStream(config)['realitySettings'] + + self.setText(realityObject.get(self.realityKey, '')) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiVTLSItemRealityPublicKey(GuiVTLSItemRealityXXX): + """Represent GUI vtls item reality public key.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTLSItemRealityPublicKey.""" + realityKey = kwargs.pop('realityKey', 'publicKey') + + super().__init__(*args, **kwargs, realityKey=realityKey) + + +class GuiVTLSItemRealityShortId(GuiVTLSItemRealityXXX): + """Represent GUI vtls item reality short id.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTLSItemRealityShortId.""" + realityKey = kwargs.pop('realityKey', 'shortId') + + super().__init__(*args, **kwargs, realityKey=realityKey) + + +class GuiVTLSItemRealityMldsa65Verify(GuiVTLSItemRealityXXX): + """Represent GUI vtls item reality mldsa65 verify.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTLSItemRealityMldsa65Verify.""" + realityKey = kwargs.pop('realityKey', 'mldsa65Verify') + + super().__init__(*args, **kwargs, realityKey=realityKey) + + +class GuiVTLSItemRealitySpiderX(GuiVTLSItemRealityXXX): + """Represent GUI vtls item reality spider x.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTLSItemRealitySpiderX.""" + realityKey = kwargs.pop('realityKey', 'spiderX') + + super().__init__(*args, **kwargs, realityKey=realityKey) + + +class GuiVTLSPageXXX(GuiEditorWidgetQWidget): + """Represent GUI vtls page xxx.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTLSPageXXX.""" + super().__init__(*args, **kwargs) + + def setSecurityText(self, text: str): + """Set security text.""" + security = self._containers[0] + + if isinstance(security, GuiEditorItemTextComboBox): + security.setText(text) + + def connectActivated(self, func: Callable): + """Connect activated.""" + security = self._containers[0] + + if isinstance(security, GuiEditorItemTextComboBox): + security.connectActivated(func) + + +class GuiVTLSPageEmpty(GuiVTLSPageXXX): + """Represent GUI vtls page empty.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTLSPageEmpty.""" + super().__init__(*args, **kwargs) + + def containerSequence(self): + """Return the editor item containers in display order.""" + return [ + GuiVTLSItemSecurity(title='TLS', translatable=False), + ] + + +class GuiVTLSPageNone(GuiVTLSPageXXX): + """Represent GUI vtls page none.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTLSPageNone.""" + super().__init__(*args, **kwargs) + + def containerSequence(self): + """Return the editor item containers in display order.""" + return [ + GuiVTLSItemSecurity(title='TLS', translatable=False), + ] + + +class GuiVTLSPageTLS(GuiVTLSPageXXX): + """Represent GUI vtls page TLS.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTLSPageTLS.""" + super().__init__(*args, **kwargs) + + def setupLayout(self): + """Set up layout.""" + layout = QFormLayout() + layout.setFormAlignment(QtCore.Qt.AlignmentFlag.AlignLeft) + layout.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) + + basicLayout = QGridLayout() + basicLayout.setColumnStretch(1, 1) + basicLayout.setColumnStretch(3, 1) + + def addPair(index: int, row: int, column: int): + """Add pair.""" + label, inputWidget = self._containers[index].widgets() + + basicLayout.addWidget(label, row, column) + basicLayout.addWidget(inputWidget, row, column + 1) + + def addBasicFullRow(index: int, row: int): + """Add basic full row.""" + label, inputWidget = self._containers[index].widgets() + + basicLayout.addWidget(label, row, 0) + basicLayout.addWidget(inputWidget, row, 1, 1, 3) + + def addFullRow(index: int): + """Add full row.""" + widgets = self._containers[index].widgets() + + if len(widgets) == 1: + layout.addRow(widgets[0]) + else: + label, inputWidget = widgets + + layout.addRow(label, inputWidget) + + addPair(0, 0, 0) + addPair(2, 0, 2) + addPair(1, 1, 0) + addPair(3, 1, 2) + addBasicFullRow(4, 2) + + layout.addRow(basicLayout) + + addFullRow(5) + addFullRow(6) + addFullRow(7) + + self.setLayout(layout) + + def containerSequence(self): + """Return the editor item containers in display order.""" + return [ + GuiVTLSItemSecurity(title='TLS', translatable=False), + GuiVTLSItemTLSServerName(title='SNI', translatable=False), + GuiVTLSItemTLSFingerprint(title='Fingerprint', translatable=False), + GuiVTLSItemTLSAlpn(title='Alpn', translatable=False), + GuiVTLSItemTLSXXXTextInput( + title='EchConfigList', + key='echConfigList', + translatable=False, + ), + GuiVTLSItemTLSXXXTextInput( + title='VerifyPeerCertByName', + key='verifyPeerCertByName', + translatable=False, + ), + GuiVTLSItemTLSXXXTextInput( + title='PinnedPeerCertSha256', + key='pinnedPeerCertSha256', + translatable=False, + ), + GuiVTLSItemTLSAllowInsecure(title='AllowInsecure', translatable=False), + ] + + +class GuiVTLSPageReality(GuiVTLSPageXXX): + """Represent GUI vtls page reality.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTLSPageReality.""" + super().__init__(*args, **kwargs) + + def setupLayout(self): + """Set up layout.""" + layout = QGridLayout() + layout.setColumnStretch(1, 1) + layout.setColumnStretch(3, 1) + layout.setHorizontalSpacing(12) + + def addPair(index: int, row: int, column: int): + """Add pair.""" + label, inputWidget = self._containers[index].widgets() + + layout.addWidget(label, row, column) + layout.addWidget(inputWidget, row, column + 1) + + def addFullRow(index: int, row: int): + """Add full row.""" + label, inputWidget = self._containers[index].widgets() + + layout.addWidget(label, row, 0) + layout.addWidget(inputWidget, row, 1, 1, 3) + + addPair(0, 0, 0) + addPair(2, 0, 2) + addPair(1, 1, 0) + addPair(4, 1, 2) + addFullRow(3, 2) + addFullRow(5, 3) + addFullRow(6, 4) + + self.setLayout(layout) + + def containerSequence(self): + """Return the editor item containers in display order.""" + return [ + GuiVTLSItemSecurity(title='TLS', translatable=False), + GuiVTLSItemRealityServerName(title='SNI', translatable=False), + GuiVTLSItemRealityFingerprint(title='Fingerprint', translatable=False), + GuiVTLSItemRealityPublicKey(title='PublicKey', translatable=False), + GuiVTLSItemRealityShortId(title='ShortId', translatable=False), + GuiVTLSItemRealityMldsa65Verify(title='Mldsa65Verify', translatable=False), + GuiVTLSItemRealitySpiderX(title='SpiderX', translatable=False), + ] + + +class GuiVTLSPageStackedWidget(QStackedWidget): + """Provide the GUI vtls page stacked widget.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTLSPageStackedWidget.""" + super().__init__(*args, **kwargs) + + # Corresponds to stream security + self._pages = [ + GuiVTLSPageEmpty(), + GuiVTLSPageNone(), + GuiVTLSPageTLS(), + GuiVTLSPageReality(), + ] + + for page in self._pages: + self.addWidget(page) + + def page(self, index: int) -> GuiVTLSPageXXX: + """Return the page value.""" + return self._pages[index] + + def connectActivated(self, func: Callable): + """Connect activated.""" + for page in self._pages: + page.connectActivated(func) + + +class GuiVTLSQGroupBox(GuiEditorItemFactory, AppQGroupBox): + """Group the GUI vtlsq editor controls.""" + + def __init__(self, **kwargs): + """Initialize the GuiVTLSQGroupBox.""" + translatable = kwargs.pop('translatable', False) + + super().__init__('TLS', **kwargs, translatable=translatable) + + self._config = ConfigFactory() + + self._widget = GuiVTLSPageStackedWidget() + self._widget.connectActivated(self.handleActivated) + + layout = QFormLayout() + layout.addRow(self._widget) + + self.setLayout(layout) + + def currentIndex(self) -> int: + """Return the current index value.""" + return self._widget.currentIndex() + + def setCurrentIndex(self, index: int): + """Set current index.""" + self._widget.setCurrentIndex(index) + + def page(self, index: int) -> GuiVTLSPageXXX: + """Return the page value.""" + return self._widget.page(index) + + def handleActivated(self, index: int): + """Handle activated.""" + page = self.page(index) + page.factoryToInput(self._config) + page.setSecurityText(STREAM_SECURITY[index]) + + self.setCurrentIndex(index) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + return self.page(self.currentIndex()).inputToFactory(config) + + def factoryToInput(self, config: ConfigFactory): + # Shallow copy + """Load the configuration value into the editor.""" + self._config = config + + streamSettings = ConfigXray.getProxyOutboundStream(config) + security = streamSettings.get('security', '') + + if not isinstance(security, str): + return + + try: + index = STREAM_SECURITY.index(security) + except Exception: + # Any non-exit exceptions + + pass + else: + self.page(index).factoryToInput(config) + self.setCurrentIndex(index) diff --git a/Furious/Plugins/Official/Xray/GuiVTransport.py b/Furious/Plugins/Official/Xray/GuiVTransport.py new file mode 100644 index 00000000..0224e55e --- /dev/null +++ b/Furious/Plugins/Official/Xray/GuiVTransport.py @@ -0,0 +1,2001 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Provide widgets for GUI v transport.""" + +from __future__ import annotations + +from Furious.Interface import * +from Furious.Library import * +from Furious.Plugins.Official.Configuration import ConfigXray +from Furious.Qt import * +from Furious.Qt import gettext as _ + +from PySide6.QtWidgets import * + +from typing import Callable + +__all__ = ['GuiVTransportQGroupBox'] + +STREAM_NETWORK = [ + 'tcp', + 'raw', + 'kcp', + 'ws', + 'h2', + 'quic', + 'grpc', + 'httpupgrade', + 'splithttp', + 'xhttp', + 'hysteria', +] + + +class GuiVTransportItemNetwork(GuiEditorItemTextComboBox): + """Represent GUI v transport item network.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTransportItemNetwork.""" + super().__init__(*args, **kwargs) + + self.addItems(STREAM_NETWORK) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + streamSettings = ConfigXray.getProxyOutboundStream(config) + + try: + oldNetwork = streamSettings['network'] + except Exception: + # Any non-exit exceptions + + oldNetwork = '' + + newNetwork = self.text() + + def setNewNetwork(): + """Set new network.""" + streamSettings['network'] = newNetwork + + for network in STREAM_NETWORK: + if network == newNetwork: + continue + + if network == 'h2': + networkKey = 'httpSettings' + else: + networkKey = f'{network}Settings' + + # Remove irrelevant settings + streamSettings.pop(networkKey, None) + + if isinstance(oldNetwork, str): + if newNetwork != oldNetwork: + setNewNetwork() + + return True + else: + return False + else: + setNewNetwork() + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + streamSettings = ConfigXray.getProxyOutboundStream(config) + + self.setText(streamSettings.get('network', '')) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiVTransportItemFinalMask(GuiEditorItemTextInput): + """Represent GUI v transport item final mask.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTransportItemFinalMask.""" + super().__init__(*args, **kwargs) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + streamSettings = ConfigXray.getProxyOutboundStream(config) + + oldFinalMaskObject = streamSettings.get('finalmask') + newFinalMaskText = self.text().strip() + + if newFinalMaskText == '': + if 'finalmask' in streamSettings: + streamSettings.pop('finalmask', None) + + return True + + return False + + try: + newFinalMaskObject = UJSONEncoder.decode(newFinalMaskText) + except Exception: + # Any non-exit exceptions + + newFinalMaskObject = {} + + if not isinstance(oldFinalMaskObject, dict): + streamSettings['finalmask'] = newFinalMaskObject + + return True + + if newFinalMaskObject != oldFinalMaskObject: + streamSettings['finalmask'] = newFinalMaskObject + + return True + + return False + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + finalMaskObject = ConfigXray.getProxyOutboundStream(config)['finalmask'] + + self.setText(UJSONEncoder.encode(finalMaskObject)) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiVTransportItemTypeXXX(GuiEditorItemTextComboBox): + """Represent GUI v transport item type xxx.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTransportItemTypeXXX.""" + networkKey = kwargs.pop('networkKey', '') + + super().__init__(*args, **kwargs) + + self.networkKey = networkKey + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + streamSettings = ConfigXray.getProxyOutboundStream(config) + + if not isinstance(streamSettings.get(self.networkKey), dict): + streamSettings[self.networkKey] = {} + + xxxObject = streamSettings[self.networkKey] + + try: + oldType = xxxObject['header']['type'] + except Exception: + # Any non-exit exceptions + + oldType = '' + + newType = self.text() + + def setNewType(): + """Set new type.""" + if not isinstance(xxxObject.get('header'), dict): + xxxObject['header'] = {} + + if newType == '': + xxxObject['header'].pop('type', None) + else: + xxxObject['header']['type'] = newType + + if isinstance(oldType, str): + if newType != oldType: + setNewType() + + return True + else: + return False + else: + setNewType() + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + xxxObject = ConfigXray.getProxyOutboundStream(config)[self.networkKey] + + self.setText(xxxObject['header']['type']) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiVTransportItemTypeTcpOrRaw(GuiVTransportItemTypeXXX): + """Represent GUI v transport item type TCP or raw.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTransportItemTypeTcpOrRaw.""" + networkKey = kwargs.pop('networkKey', 'tcpSettings') + + super().__init__(*args, **kwargs, networkKey=networkKey) + + self.addItems( + [ + '', + 'none', + 'http', + ] + ) + + +class GuiVTransportItemHostTcpOrRaw(GuiEditorItemTextInput): + """Represent GUI v transport item host TCP or raw.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTransportItemHostTcpOrRaw.""" + networkKey = kwargs.pop('networkKey', 'tcpSettings') + + super().__init__(*args, **kwargs) + + self.networkKey = networkKey + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + streamSettings = ConfigXray.getProxyOutboundStream(config) + + if not isinstance(streamSettings.get(self.networkKey), dict): + streamSettings[self.networkKey] = {} + + tcpObject = streamSettings[self.networkKey] + + try: + oldHost = ','.join(tcpObject['header']['request']['headers']['Host']) + except Exception: + # Any non-exit exceptions + + oldHost = '' + + newHost = self.text() + + def setNewHost(): + """Set new host.""" + if not isinstance(tcpObject.get('header'), dict): + tcpObject['header'] = {} + + if not isinstance(tcpObject['header'].get('request'), dict): + tcpObject['header']['request'] = {} + + if not isinstance(tcpObject['header']['request'].get('headers'), dict): + tcpObject['header']['request']['headers'] = {} + + if newHost == '': + tcpObject['header']['request']['headers'].pop('Host', None) + else: + tcpObject['header']['request']['headers']['Host'] = newHost.split(',') + + if isinstance(oldHost, str): + if newHost != oldHost: + setNewHost() + + return True + else: + return False + else: + setNewHost() + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + tcpObject = ConfigXray.getProxyOutboundStream(config)[self.networkKey] + + self.setText(','.join(tcpObject['header']['request']['headers']['Host'])) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiVTransportItemPathTcpOrRaw(GuiEditorItemTextInput): + """Represent GUI v transport item path TCP or raw.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTransportItemPathTcpOrRaw.""" + networkKey = kwargs.pop('networkKey', 'tcpSettings') + + super().__init__(*args, **kwargs) + + self.networkKey = networkKey + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + streamSettings = ConfigXray.getProxyOutboundStream(config) + + if not isinstance(streamSettings.get(self.networkKey), dict): + streamSettings[self.networkKey] = {} + + tcpObject = streamSettings[self.networkKey] + + try: + oldPath = ','.join(tcpObject['header']['request']['path']) + except Exception: + # Any non-exit exceptions + + oldPath = '' + + newPath = self.text() + + def setNewPath(): + """Set new path.""" + if not isinstance(tcpObject.get('header'), dict): + tcpObject['header'] = {} + + if not isinstance(tcpObject['header'].get('request'), dict): + tcpObject['header']['request'] = {} + + if newPath == '': + tcpObject['header']['request'].pop('path', None) + else: + tcpObject['header']['request']['path'] = newPath.split(',') + + if isinstance(oldPath, str): + if newPath != oldPath: + setNewPath() + + return True + else: + return False + else: + setNewPath() + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + tcpObject = ConfigXray.getProxyOutboundStream(config)[self.networkKey] + + self.setText(','.join(tcpObject['header']['request']['path'])) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiVTransportItemTypeKcp(GuiVTransportItemTypeXXX): + """Represent GUI v transport item type kcp.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTransportItemTypeKcp.""" + networkKey = kwargs.pop('networkKey', 'kcpSettings') + + super().__init__(*args, **kwargs, networkKey=networkKey) + + self.addItems( + [ + '', + 'none', + 'srtp', + 'utp', + 'wechat-video', + 'dtls', + 'wireguard', + 'dns', + ] + ) + + +class GuiVTransportItemSeedKcp(GuiEditorItemTextInput): + """Represent GUI v transport item seed kcp.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTransportItemSeedKcp.""" + super().__init__(*args, **kwargs) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + streamSettings = ConfigXray.getProxyOutboundStream(config) + + if not isinstance(streamSettings.get('kcpSettings'), dict): + streamSettings['kcpSettings'] = {} + + kcpObject = streamSettings['kcpSettings'] + + try: + oldSeed = kcpObject.get('seed', '') + except Exception: + # Any non-exit exceptions + + oldSeed = '' + + newSeed = self.text() + + def setNewSeed(): + """Set new seed.""" + if newSeed == '': + kcpObject.pop('seed', None) + else: + kcpObject['seed'] = newSeed + + if isinstance(oldSeed, str): + if newSeed != oldSeed: + setNewSeed() + + return True + else: + return False + else: + setNewSeed() + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + kcpObject = ConfigXray.getProxyOutboundStream(config)['kcpSettings'] + + self.setText(kcpObject.get('seed', '')) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiVTransportItemHostWs(GuiEditorItemTextInput): + """Represent GUI v transport item host ws.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTransportItemHostWs.""" + super().__init__(*args, **kwargs) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + streamSettings = ConfigXray.getProxyOutboundStream(config) + + if not isinstance(streamSettings.get('wsSettings'), dict): + streamSettings['wsSettings'] = {} + + wsObject = streamSettings['wsSettings'] + + try: + oldHost = wsObject['headers']['Host'] + except Exception: + # Any non-exit exceptions + + oldHost = '' + + newHost = self.text() + + def setNewHost(): + """Set new host.""" + if not isinstance(wsObject.get('headers'), dict): + wsObject['headers'] = {} + + if newHost == '': + wsObject['headers'].pop('Host', None) + else: + wsObject['headers']['Host'] = newHost + + if isinstance(oldHost, str): + if newHost != oldHost: + setNewHost() + + return True + else: + return False + else: + setNewHost() + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + wsObject = ConfigXray.getProxyOutboundStream(config)['wsSettings'] + + self.setText(wsObject['headers']['Host']) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiVTransportItemPathWs(GuiEditorItemTextInput): + """Represent GUI v transport item path ws.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTransportItemPathWs.""" + super().__init__(*args, **kwargs) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + streamSettings = ConfigXray.getProxyOutboundStream(config) + + if not isinstance(streamSettings.get('wsSettings'), dict): + streamSettings['wsSettings'] = {} + + wsObject = streamSettings['wsSettings'] + + try: + oldPath = wsObject.get('path', '') + except Exception: + # Any non-exit exceptions + + oldPath = '' + + newPath = self.text() + + def setNewPath(): + """Set new path.""" + if newPath == '': + wsObject.pop('path', None) + else: + wsObject['path'] = newPath + + if isinstance(oldPath, str): + if newPath != oldPath: + setNewPath() + + return True + else: + return False + else: + setNewPath() + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + wsObject = ConfigXray.getProxyOutboundStream(config)['wsSettings'] + + self.setText(wsObject.get('path', '')) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiVTransportItemHostHttpUpgrade(GuiEditorItemTextInput): + """Represent GUI v transport item host HTTP upgrade.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTransportItemHostHttpUpgrade.""" + super().__init__(*args, **kwargs) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + streamSettings = ConfigXray.getProxyOutboundStream(config) + + if not isinstance(streamSettings.get('httpupgradeSettings'), dict): + streamSettings['httpupgradeSettings'] = {} + + httpUpgradeObject = streamSettings['httpupgradeSettings'] + + try: + oldHost = httpUpgradeObject.get('host', '') + except Exception: + # Any non-exit exceptions + + oldHost = '' + + newHost = self.text() + + def setNewHost(): + """Set new host.""" + if newHost == '': + httpUpgradeObject.pop('host', None) + else: + httpUpgradeObject['host'] = newHost + + if isinstance(oldHost, str): + if newHost != oldHost: + setNewHost() + + return True + else: + return False + else: + setNewHost() + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + httpUpgradeObject = ConfigXray.getProxyOutboundStream(config)[ + 'httpupgradeSettings' + ] + + self.setText(httpUpgradeObject.get('host', '')) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiVTransportItemPathHttpUpgrade(GuiEditorItemTextInput): + """Represent GUI v transport item path HTTP upgrade.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTransportItemPathHttpUpgrade.""" + super().__init__(*args, **kwargs) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + streamSettings = ConfigXray.getProxyOutboundStream(config) + + if not isinstance(streamSettings.get('httpupgradeSettings'), dict): + streamSettings['httpupgradeSettings'] = {} + + httpUpgradeObject = streamSettings['httpupgradeSettings'] + + try: + oldPath = httpUpgradeObject.get('path', '') + except Exception: + # Any non-exit exceptions + + oldPath = '' + + newPath = self.text() + + def setNewPath(): + """Set new path.""" + if newPath == '': + httpUpgradeObject.pop('path', None) + else: + httpUpgradeObject['path'] = newPath + + if isinstance(oldPath, str): + if newPath != oldPath: + setNewPath() + + return True + else: + return False + else: + setNewPath() + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + httpUpgradeObject = ConfigXray.getProxyOutboundStream(config)[ + 'httpupgradeSettings' + ] + + self.setText(httpUpgradeObject.get('path', '')) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiVTransportItemHostSplitHttp(GuiEditorItemTextInput): + """Represent GUI v transport item host split HTTP.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTransportItemHostSplitHttp.""" + super().__init__(*args, **kwargs) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + streamSettings = ConfigXray.getProxyOutboundStream(config) + + if not isinstance(streamSettings.get('splithttpSettings'), dict): + streamSettings['splithttpSettings'] = {} + + splitHttpObject = streamSettings['splithttpSettings'] + + try: + oldHost = splitHttpObject.get('host', '') + except Exception: + # Any non-exit exceptions + + oldHost = '' + + newHost = self.text() + + def setNewHost(): + """Set new host.""" + if newHost == '': + splitHttpObject.pop('host', None) + else: + splitHttpObject['host'] = newHost + + if isinstance(oldHost, str): + if newHost != oldHost: + setNewHost() + + return True + else: + return False + else: + setNewHost() + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + splitHttpObject = ConfigXray.getProxyOutboundStream(config)[ + 'splithttpSettings' + ] + + self.setText(splitHttpObject.get('host', '')) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiVTransportItemPathSplitHttp(GuiEditorItemTextInput): + """Represent GUI v transport item path split HTTP.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTransportItemPathSplitHttp.""" + super().__init__(*args, **kwargs) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + streamSettings = ConfigXray.getProxyOutboundStream(config) + + if not isinstance(streamSettings.get('splithttpSettings'), dict): + streamSettings['splithttpSettings'] = {} + + splitHttpObject = streamSettings['splithttpSettings'] + + try: + oldPath = splitHttpObject.get('path', '') + except Exception: + # Any non-exit exceptions + + oldPath = '' + + newPath = self.text() + + def setNewPath(): + """Set new path.""" + if newPath == '': + splitHttpObject.pop('path', None) + else: + splitHttpObject['path'] = newPath + + if isinstance(oldPath, str): + if newPath != oldPath: + setNewPath() + + return True + else: + return False + else: + setNewPath() + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + splitHttpObject = ConfigXray.getProxyOutboundStream(config)[ + 'splithttpSettings' + ] + + self.setText(splitHttpObject.get('path', '')) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiVTransportItemHostXHttp(GuiEditorItemTextInput): + """Represent GUI v transport item host x HTTP.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTransportItemHostXHttp.""" + super().__init__(*args, **kwargs) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + streamSettings = ConfigXray.getProxyOutboundStream(config) + + if not isinstance(streamSettings.get('xhttpSettings'), dict): + streamSettings['xhttpSettings'] = {} + + xhttpObject = streamSettings['xhttpSettings'] + + try: + oldHost = xhttpObject.get('host', '') + except Exception: + # Any non-exit exceptions + + oldHost = '' + + newHost = self.text() + + def setNewHost(): + """Set new host.""" + if newHost == '': + xhttpObject.pop('host', None) + else: + xhttpObject['host'] = newHost + + if isinstance(oldHost, str): + if newHost != oldHost: + setNewHost() + + return True + else: + return False + else: + setNewHost() + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + xhttpObject = ConfigXray.getProxyOutboundStream(config)['xhttpSettings'] + + self.setText(xhttpObject.get('host', '')) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiVTransportItemPathXHttp(GuiEditorItemTextInput): + """Represent GUI v transport item path x HTTP.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTransportItemPathXHttp.""" + super().__init__(*args, **kwargs) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + streamSettings = ConfigXray.getProxyOutboundStream(config) + + if not isinstance(streamSettings.get('xhttpSettings'), dict): + streamSettings['xhttpSettings'] = {} + + xhttpObject = streamSettings['xhttpSettings'] + + try: + oldPath = xhttpObject.get('path', '') + except Exception: + # Any non-exit exceptions + + oldPath = '' + + newPath = self.text() + + def setNewPath(): + """Set new path.""" + if newPath == '': + xhttpObject.pop('path', None) + else: + xhttpObject['path'] = newPath + + if isinstance(oldPath, str): + if newPath != oldPath: + setNewPath() + + return True + else: + return False + else: + setNewPath() + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + xhttpObject = ConfigXray.getProxyOutboundStream(config)['xhttpSettings'] + + self.setText(xhttpObject.get('path', '')) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiVTransportItemModeXHttp(GuiEditorItemTextComboBox): + """Represent GUI v transport item mode x HTTP.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTransportItemModeXHttp.""" + super().__init__(*args, **kwargs) + + self.addItems( + [ + '', + 'auto', + 'packet-up', + 'stream-up', + ] + ) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + streamSettings = ConfigXray.getProxyOutboundStream(config) + + if not isinstance(streamSettings.get('xhttpSettings'), dict): + streamSettings['xhttpSettings'] = {} + + xhttpObject = streamSettings['xhttpSettings'] + + try: + oldMode = xhttpObject.get('mode', '') + except Exception: + # Any non-exit exceptions + + oldMode = '' + + newMode = self.text() + + def setNewMode(): + """Set new mode.""" + if newMode == '': + xhttpObject.pop('mode', None) + else: + xhttpObject['mode'] = newMode + + if isinstance(oldMode, str): + if newMode != oldMode: + setNewMode() + + return True + else: + return False + else: + setNewMode() + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + xhttpObject = ConfigXray.getProxyOutboundStream(config)['xhttpSettings'] + + self.setText(xhttpObject.get('mode', '')) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiVTransportItemExtraXHttp(GuiEditorItemTextInput): + """Represent GUI v transport item extra x HTTP.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTransportItemExtraXHttp.""" + super().__init__(*args, **kwargs) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + streamSettings = ConfigXray.getProxyOutboundStream(config) + + if not isinstance(streamSettings.get('xhttpSettings'), dict): + streamSettings['xhttpSettings'] = {} + + xhttpObject = streamSettings['xhttpSettings'] + + try: + if xhttpObject.get('extra', ''): + oldExtra = UJSONEncoder.encode(xhttpObject.get('extra', '')) + else: + oldExtra = '' + except Exception: + # Any non-exit exceptions + + oldExtra = '' + + newExtra = self.text() + + def setNewExtra(): + """Set new extra.""" + if newExtra == '': + xhttpObject.pop('extra', None) + else: + try: + xhttpObject['extra'] = UJSONEncoder.decode(newExtra) + except Exception: + # Any non-exit exceptions + + xhttpObject.pop('extra', None) + + if isinstance(oldExtra, str): + if newExtra != oldExtra: + setNewExtra() + + return True + else: + return False + else: + setNewExtra() + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + xhttpObject = ConfigXray.getProxyOutboundStream(config)['xhttpSettings'] + + if xhttpObject.get('extra', ''): + self.setText(UJSONEncoder.encode(xhttpObject.get('extra', ''))) + else: + self.setText('') + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiVTransportItemHostH2(GuiEditorItemTextInput): + """Represent GUI v transport item host h2.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTransportItemHostH2.""" + super().__init__(*args, **kwargs) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + streamSettings = ConfigXray.getProxyOutboundStream(config) + + if not isinstance(streamSettings.get('httpSettings'), dict): + streamSettings['httpSettings'] = {} + + httpObject = streamSettings['httpSettings'] + + try: + oldHost = ','.join(httpObject.get('host', [])) + except Exception: + # Any non-exit exceptions + + oldHost = '' + + newHost = self.text() + + def setNewHost(): + """Set new host.""" + if newHost == '': + httpObject.pop('host', None) + else: + httpObject['host'] = newHost.split(',') + + if isinstance(oldHost, str): + if newHost != oldHost: + setNewHost() + + return True + else: + return False + else: + setNewHost() + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + httpObject = ConfigXray.getProxyOutboundStream(config)['httpSettings'] + + self.setText(','.join(httpObject.get('host', []))) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiVTransportItemPathH2(GuiEditorItemTextInput): + """Represent GUI v transport item path h2.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTransportItemPathH2.""" + super().__init__(*args, **kwargs) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + streamSettings = ConfigXray.getProxyOutboundStream(config) + + if not isinstance(streamSettings.get('httpSettings'), dict): + streamSettings['httpSettings'] = {} + + httpObject = streamSettings['httpSettings'] + + try: + oldPath = httpObject.get('path', '') + except Exception: + # Any non-exit exceptions + + oldPath = '' + + newPath = self.text() + + def setNewPath(): + """Set new path.""" + if newPath == '': + httpObject.pop('path', None) + else: + httpObject['path'] = newPath + + if isinstance(oldPath, str): + if newPath != oldPath: + setNewPath() + + return True + else: + return False + else: + setNewPath() + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + httpObject = ConfigXray.getProxyOutboundStream(config)['httpSettings'] + + self.setText(httpObject.get('path', '')) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiVTransportItemTypeQuic(GuiVTransportItemTypeXXX): + """Represent GUI v transport item type quic.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTransportItemTypeQuic.""" + networkKey = kwargs.pop('networkKey', 'quicSettings') + + super().__init__(*args, **kwargs, networkKey=networkKey) + + self.addItems( + [ + '', + 'none', + 'srtp', + 'utp', + 'wechat-video', + 'dtls', + 'wireguard', + ] + ) + + +class GuiVTransportItemSecurityQuic(GuiEditorItemTextComboBox): + """Represent GUI v transport item security quic.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTransportItemSecurityQuic.""" + super().__init__(*args, **kwargs) + + self.addItems( + [ + '', + 'none', + 'aes-128-gcm', + 'chacha20-poly1305', + ] + ) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + streamSettings = ConfigXray.getProxyOutboundStream(config) + + if not isinstance(streamSettings.get('quicSettings'), dict): + streamSettings['quicSettings'] = {} + + quicObject = streamSettings['quicSettings'] + + try: + oldSecurity = quicObject.get('security', '') + except Exception: + # Any non-exit exceptions + + oldSecurity = '' + + newSecurity = self.text() + + def setNewSecurity(): + """Set new security.""" + quicObject['security'] = newSecurity + + if isinstance(oldSecurity, str): + if newSecurity != oldSecurity: + setNewSecurity() + + return True + else: + return False + else: + setNewSecurity() + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + quicObject = ConfigXray.getProxyOutboundStream(config)['quicSettings'] + + self.setText(quicObject.get('security', '')) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiVTransportItemKeyQuic(GuiEditorItemTextInput): + """Represent GUI v transport item key quic.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTransportItemKeyQuic.""" + super().__init__(*args, **kwargs) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + streamSettings = ConfigXray.getProxyOutboundStream(config) + + if not isinstance(streamSettings.get('quicSettings'), dict): + streamSettings['quicSettings'] = {} + + quicObject = streamSettings['quicSettings'] + + try: + oldKey = quicObject.get('key', '') + except Exception: + # Any non-exit exceptions + + oldKey = '' + + newKey = self.text() + + def setNewKey(): + """Set new key.""" + if newKey == '': + quicObject.pop('key', None) + else: + quicObject['key'] = newKey + + if isinstance(oldKey, str): + if newKey != oldKey: + setNewKey() + + return True + else: + return False + else: + setNewKey() + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + quicObject = ConfigXray.getProxyOutboundStream(config)['quicSettings'] + + self.setText(quicObject.get('key', '')) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiVTransportItemModeGRPC(GuiEditorItemTextComboBox): + """Represent GUI v transport item mode grpc.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTransportItemModeGRPC.""" + super().__init__(*args, **kwargs) + + self.addItems( + [ + '', + 'gun', + 'multi', + ] + ) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + streamSettings = ConfigXray.getProxyOutboundStream(config) + + if not isinstance(streamSettings.get('grpcSettings'), dict): + streamSettings['grpcSettings'] = {} + + grpcObject = streamSettings['grpcSettings'] + + try: + oldMode = grpcObject.get('multiMode', False) + except Exception: + # Any non-exit exceptions + + oldMode = False + + if oldMode is True: + oldMode = 'multi' + else: + oldMode = 'gun' + + newMode = self.text() + + def setNewMode(): + """Set new mode.""" + if newMode == 'multi': + grpcObject['multiMode'] = True + else: + grpcObject['multiMode'] = False + + if isinstance(oldMode, str): + if newMode != oldMode: + setNewMode() + + return True + else: + return False + else: + setNewMode() + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + grpcObject = ConfigXray.getProxyOutboundStream(config)['grpcSettings'] + + multi = grpcObject.get('multiMode', False) + + if multi is True: + self.setText('multi') + elif multi is False: + self.setText('gun') + else: + self.setText('') + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiVTransportItemAuthorityGRPC(GuiEditorItemTextInput): + """Represent GUI v transport item authority grpc.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTransportItemAuthorityGRPC.""" + super().__init__(*args, **kwargs) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + streamSettings = ConfigXray.getProxyOutboundStream(config) + + if not isinstance(streamSettings.get('grpcSettings'), dict): + streamSettings['grpcSettings'] = {} + + grpcObject = streamSettings['grpcSettings'] + + try: + oldAuthority = grpcObject.get('authority', '') + except Exception: + # Any non-exit exceptions + + oldAuthority = '' + + newAuthority = self.text() + + def setNewAuthority(): + """Set new authority.""" + if newAuthority == '': + grpcObject.pop('authority', None) + else: + grpcObject['authority'] = newAuthority + + if isinstance(oldAuthority, str): + if newAuthority != oldAuthority: + setNewAuthority() + + return True + else: + return False + else: + setNewAuthority() + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + grpcObject = ConfigXray.getProxyOutboundStream(config)['grpcSettings'] + + self.setText(grpcObject.get('authority', '')) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiVTransportItemServiceNameGRPC(GuiEditorItemTextInput): + """Represent GUI v transport item service name grpc.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTransportItemServiceNameGRPC.""" + super().__init__(*args, **kwargs) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + streamSettings = ConfigXray.getProxyOutboundStream(config) + + if not isinstance(streamSettings.get('grpcSettings'), dict): + streamSettings['grpcSettings'] = {} + + grpcObject = streamSettings['grpcSettings'] + + try: + oldServiceName = grpcObject.get('serviceName', '') + except Exception: + # Any non-exit exceptions + + oldServiceName = '' + + newServiceName = self.text() + + def setNewServiceName(): + """Set new service name.""" + if newServiceName == '': + grpcObject.pop('serviceName', None) + else: + grpcObject['serviceName'] = newServiceName + + if isinstance(oldServiceName, str): + if newServiceName != oldServiceName: + setNewServiceName() + + return True + else: + return False + else: + setNewServiceName() + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + grpcObject = ConfigXray.getProxyOutboundStream(config)['grpcSettings'] + + self.setText(grpcObject.get('serviceName', '')) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiVTransportItemVersionHysteria(GuiEditorItemTextSpinBox): + """Represent GUI v transport item version hysteria.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTransportItemVersionHysteria.""" + super().__init__(*args, **kwargs) + + # Range. 0 means invalid + self.setRange(0, 2) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + streamSettings = ConfigXray.getProxyOutboundStream(config) + + if not isinstance(streamSettings.get('hysteriaSettings'), dict): + streamSettings['hysteriaSettings'] = {} + + hysteriaObject = streamSettings['hysteriaSettings'] + + oldVersion = hysteriaObject.get('version', 0) + newVersion = self.value() + + if isinstance(oldVersion, int): + if newVersion != oldVersion: + hysteriaObject['version'] = newVersion + + return True + else: + return False + else: + hysteriaObject['version'] = newVersion + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + hysteriaObject = ConfigXray.getProxyOutboundStream(config)[ + 'hysteriaSettings' + ] + + self.setValue(hysteriaObject.get('version', 0)) + except Exception: + # Any non-exit exceptions + + self.setValue(0) + + +class GuiVTransportItemAuthHysteria(GuiEditorItemTextInput): + """Represent GUI v transport item auth hysteria.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTransportItemAuthHysteria.""" + super().__init__(*args, **kwargs) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + streamSettings = ConfigXray.getProxyOutboundStream(config) + + if not isinstance(streamSettings.get('hysteriaSettings'), dict): + streamSettings['hysteriaSettings'] = {} + + hysteriaObject = streamSettings['hysteriaSettings'] + + try: + oldAuth = hysteriaObject.get('auth', '') + except Exception: + # Any non-exit exceptions + + oldAuth = '' + + newAuth = self.text() + + def setNewAuth(): + """Set new auth.""" + if newAuth == '': + hysteriaObject.pop('auth', None) + else: + hysteriaObject['auth'] = newAuth + + if isinstance(oldAuth, str): + if newAuth != oldAuth: + setNewAuth() + + return True + else: + return False + else: + setNewAuth() + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + hysteriaObject = ConfigXray.getProxyOutboundStream(config)[ + 'hysteriaSettings' + ] + + self.setText(hysteriaObject.get('auth', '')) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiVTransportItemPasswordHysteria(GuiEditorItemTextInput): + """Represent GUI v transport item password hysteria.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTransportItemPasswordHysteria.""" + super().__init__(*args, **kwargs) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + streamSettings = ConfigXray.getProxyOutboundStream(config) + + if not isinstance(streamSettings.get('finalmask'), dict): + streamSettings['finalmask'] = {} + + finalmaskObject = streamSettings['finalmask'] + + if not isinstance(finalmaskObject.get('salamander'), dict): + finalmaskObject['salamander'] = {} + + salamander = finalmaskObject['salamander'] + + try: + oldPassword = salamander.get('password', '') + except Exception: + # Any non-exit exceptions + + oldPassword = '' + + newPassword = self.text() + + def setNewPassword(): + """Set new password.""" + if newPassword == '': + finalmaskObject.pop('salamander', None) + else: + salamander['password'] = newPassword + + if isinstance(oldPassword, str): + if newPassword != oldPassword: + setNewPassword() + + return True + else: + return False + else: + setNewPassword() + + return True + + def factoryToInput(self, config: ConfigFactory): + """Load the configuration value into the editor.""" + try: + salamander = ConfigXray.getProxyOutboundStream(config)['finalmask'][ + 'salamander' + ] + + self.setText(salamander.get('password', '')) + except Exception: + # Any non-exit exceptions + + self.setText('') + + +class GuiVTransportPageXXX(GuiEditorWidgetQWidget): + """Represent GUI v transport page xxx.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTransportPageXXX.""" + super().__init__(*args, **kwargs) + + def setNetworkText(self, text: str): + """Set network text.""" + network = self._containers[0] + + if isinstance(network, GuiEditorItemTextComboBox): + network.setText(text) + + def connectActivated(self, func: Callable): + """Connect activated.""" + network = self._containers[0] + + if isinstance(network, GuiEditorItemTextComboBox): + network.connectActivated(func) + + +class GuiVTransportPageTcp(GuiVTransportPageXXX): + """Represent GUI v transport page TCP.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTransportPageTcp.""" + super().__init__(*args, **kwargs) + + def containerSequence(self): + """Return the editor item containers in display order.""" + return [ + GuiVTransportItemNetwork(title='Network', translatable=False), + GuiVTransportItemFinalMask(title='Finalmask', translatable=False), + GuiVTransportItemTypeTcpOrRaw( + title='Type', networkKey='tcpSettings', translatable=False + ), + GuiVTransportItemHostTcpOrRaw( + title='Host', networkKey='tcpSettings', translatable=False + ), + GuiVTransportItemPathTcpOrRaw( + title='Path', networkKey='tcpSettings', translatable=False + ), + ] + + +class GuiVTransportPageRaw(GuiVTransportPageXXX): + """Represent GUI v transport page raw.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTransportPageRaw.""" + super().__init__(*args, **kwargs) + + def containerSequence(self): + """Return the editor item containers in display order.""" + return [ + GuiVTransportItemNetwork(title='Network', translatable=False), + GuiVTransportItemFinalMask(title='Finalmask', translatable=False), + GuiVTransportItemTypeTcpOrRaw( + title='Type', networkKey='rawSettings', translatable=False + ), + GuiVTransportItemHostTcpOrRaw( + title='Host', networkKey='rawSettings', translatable=False + ), + GuiVTransportItemPathTcpOrRaw( + title='Path', networkKey='rawSettings', translatable=False + ), + ] + + +class GuiVTransportPageKcp(GuiVTransportPageXXX): + """Represent GUI v transport page kcp.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTransportPageKcp.""" + super().__init__(*args, **kwargs) + + def containerSequence(self): + """Return the editor item containers in display order.""" + return [ + GuiVTransportItemNetwork(title='Network', translatable=False), + GuiVTransportItemFinalMask(title='Finalmask', translatable=False), + GuiVTransportItemTypeKcp(title='Type', translatable=False), + GuiVTransportItemSeedKcp(title='KCP seed', translatable=False), + ] + + +class GuiVTransportPageWs(GuiVTransportPageXXX): + """Represent GUI v transport page ws.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTransportPageWs.""" + super().__init__(*args, **kwargs) + + def containerSequence(self): + """Return the editor item containers in display order.""" + return [ + GuiVTransportItemNetwork(title='Network', translatable=False), + GuiVTransportItemFinalMask(title='Finalmask', translatable=False), + GuiVTransportItemHostWs(title='Host', translatable=False), + GuiVTransportItemPathWs(title='Path', translatable=False), + ] + + +class GuiVTransportPageHttpUpgrade(GuiVTransportPageXXX): + """Represent GUI v transport page HTTP upgrade.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTransportPageHttpUpgrade.""" + super().__init__(*args, **kwargs) + + def containerSequence(self): + """Return the editor item containers in display order.""" + return [ + GuiVTransportItemNetwork(title='Network', translatable=False), + GuiVTransportItemFinalMask(title='Finalmask', translatable=False), + GuiVTransportItemHostHttpUpgrade(title='Host', translatable=False), + GuiVTransportItemPathHttpUpgrade(title='Path', translatable=False), + ] + + +class GuiVTransportPageSplitHttp(GuiVTransportPageXXX): + """Represent GUI v transport page split HTTP.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTransportPageSplitHttp.""" + super().__init__(*args, **kwargs) + + def containerSequence(self): + """Return the editor item containers in display order.""" + return [ + GuiVTransportItemNetwork(title='Network', translatable=False), + GuiVTransportItemFinalMask(title='Finalmask', translatable=False), + GuiVTransportItemHostSplitHttp(title='Host', translatable=False), + GuiVTransportItemPathSplitHttp(title='Path', translatable=False), + ] + + +class GuiVTransportPageXHttp(GuiVTransportPageXXX): + """Represent GUI v transport page x HTTP.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTransportPageXHttp.""" + super().__init__(*args, **kwargs) + + def containerSequence(self): + """Return the editor item containers in display order.""" + return [ + GuiVTransportItemNetwork(title='Network', translatable=False), + GuiVTransportItemFinalMask(title='Finalmask', translatable=False), + GuiVTransportItemHostXHttp(title='Host', translatable=False), + GuiVTransportItemPathXHttp(title='Path', translatable=False), + GuiVTransportItemModeXHttp(title='Mode', translatable=False), + GuiVTransportItemExtraXHttp(title='Extra', translatable=False), + ] + + +class GuiVTransportPageH2(GuiVTransportPageXXX): + """Represent GUI v transport page h2.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTransportPageH2.""" + super().__init__(*args, **kwargs) + + def containerSequence(self): + """Return the editor item containers in display order.""" + return [ + GuiVTransportItemNetwork(title='Network', translatable=False), + GuiVTransportItemFinalMask(title='Finalmask', translatable=False), + GuiVTransportItemHostH2(title='Host', translatable=False), + GuiVTransportItemPathH2(title='Path', translatable=False), + ] + + +class GuiVTransportPageQuic(GuiVTransportPageXXX): + """Represent GUI v transport page quic.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTransportPageQuic.""" + super().__init__(*args, **kwargs) + + def containerSequence(self): + """Return the editor item containers in display order.""" + return [ + GuiVTransportItemNetwork(title='Network', translatable=False), + GuiVTransportItemFinalMask(title='Finalmask', translatable=False), + GuiVTransportItemTypeQuic(title='Type', translatable=False), + GuiVTransportItemSecurityQuic(title='QUIC Security', translatable=False), + GuiVTransportItemKeyQuic(title='QUIC Key', translatable=False), + ] + + +class GuiVTransportPageGRPC(GuiVTransportPageXXX): + """Represent GUI v transport page grpc.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTransportPageGRPC.""" + super().__init__(*args, **kwargs) + + def containerSequence(self): + """Return the editor item containers in display order.""" + return [ + GuiVTransportItemNetwork(title='Network', translatable=False), + GuiVTransportItemFinalMask(title='Finalmask', translatable=False), + GuiVTransportItemModeGRPC(title='gRPC mode', translatable=False), + GuiVTransportItemAuthorityGRPC(title='gRPC authority', translatable=False), + GuiVTransportItemServiceNameGRPC( + title='gRPC serviceName', translatable=False + ), + ] + + +class GuiVTransportPageHysteria(GuiVTransportPageXXX): + """Represent GUI v transport page hysteria.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTransportPageHysteria.""" + super().__init__(*args, **kwargs) + + def containerSequence(self): + """Return the editor item containers in display order.""" + return [ + GuiVTransportItemNetwork(title='Network', translatable=False), + GuiVTransportItemFinalMask(title='Finalmask', translatable=False), + GuiVTransportItemVersionHysteria(title='Version', translatable=False), + GuiVTransportItemAuthHysteria(title='Auth', translatable=False), + # GuiVTransportItemPasswordHysteria(title='Password', translatable=False), + ] + + +class GuiVTransportPageStackedWidget(QStackedWidget): + """Provide the GUI v transport page stacked widget.""" + + def __init__(self, *args, **kwargs): + """Initialize the GuiVTransportPageStackedWidget.""" + super().__init__(*args, **kwargs) + + # Corresponds to stream network + self._pages = [ + GuiVTransportPageTcp(), + GuiVTransportPageRaw(), + GuiVTransportPageKcp(), + GuiVTransportPageWs(), + GuiVTransportPageH2(), + GuiVTransportPageQuic(), + GuiVTransportPageGRPC(), + GuiVTransportPageHttpUpgrade(), + GuiVTransportPageSplitHttp(), + GuiVTransportPageXHttp(), + GuiVTransportPageHysteria(), + ] + + assert len(STREAM_NETWORK) == len(self._pages) + + for page in self._pages: + self.addWidget(page) + + def page(self, index: int) -> GuiVTransportPageXXX: + """Return the page value.""" + return self._pages[index] + + def connectActivated(self, func: Callable): + """Connect activated.""" + for page in self._pages: + page.connectActivated(func) + + +class GuiVTransportQGroupBox(GuiEditorItemFactory, AppQGroupBox): + """Group the GUI v transport q editor controls.""" + + def __init__(self, **kwargs): + """Initialize the GuiVTransportQGroupBox.""" + super().__init__(_('Transport'), **kwargs) + + self._config = ConfigFactory() + + self._widget = GuiVTransportPageStackedWidget() + self._widget.connectActivated(self.handleActivated) + + layout = QFormLayout() + layout.addRow(self._widget) + + self.setLayout(layout) + + def currentIndex(self) -> int: + """Return the current index value.""" + return self._widget.currentIndex() + + def setCurrentIndex(self, index: int): + """Set current index.""" + self._widget.setCurrentIndex(index) + + def page(self, index: int) -> GuiVTransportPageXXX: + """Return the page value.""" + return self._widget.page(index) + + def handleActivated(self, index: int): + """Handle activated.""" + page = self.page(index) + page.factoryToInput(self._config) + page.setNetworkText(STREAM_NETWORK[index]) + + self.setCurrentIndex(index) + + def inputToFactory(self, config: ConfigFactory) -> bool: + """Apply the current editor value to the configuration.""" + return self.page(self.currentIndex()).inputToFactory(config) + + def factoryToInput(self, config: ConfigFactory): + # Shallow copy + """Load the configuration value into the editor.""" + self._config = config + + streamSettings = ConfigXray.getProxyOutboundStream(config) + network = streamSettings.get('network', '') + + if not isinstance(network, str): + return + + def alterNetwork(sourceNetwork, targetNetwork): + """Handle alter network for the GUI v transport q group box.""" + nonlocal network + + if network == sourceNetwork: + network = targetNetwork + + streamSettings['network'] = network + + # Adjust alternative values + # https://xtls.github.io/config/transport.html#streamsettingsobject + alterNetwork('http', 'h2') + alterNetwork('gun', 'grpc') + alterNetwork('mkcp', 'kcp') + + try: + index = STREAM_NETWORK.index(network) + except Exception: + # Any non-exit exceptions + + pass + else: + self.page(index).factoryToInput(config) + self.setCurrentIndex(index) diff --git a/Furious/Plugins/Official/Xray/Plugin.py b/Furious/Plugins/Official/Xray/Plugin.py new file mode 100644 index 00000000..5e4103f0 --- /dev/null +++ b/Furious/Plugins/Official/Xray/Plugin.py @@ -0,0 +1,398 @@ +"""Integrate Xray configuration, editors, routing, and process startup.""" + +from __future__ import annotations + +from Furious.Core.CoreProcessWorker import ProcessOutputRedirector +from Furious.Frozenlib import * +from Furious.Library.Storage import Storage +from Furious.Plugins.API import FuriousPlugin, PluginProtocol +from Furious.Plugins.Official.Configuration import ( + BLANK_CONFIG_XRAY, + ConfigXray, + configXrayEmptyProxyOutboundObject, +) +from Furious.Qt.QtWidgets import showMBoxDirectRulesNotAllowed + +import copy +import logging +import os +import uuid + +from .Core import XrayCore + +__all__ = ['XrayPlugin'] + +logger = logging.getLogger(__name__) + + +def _protocolId(protocol) -> str: + """Return a normalized protocol identifier.""" + return str(getattr(protocol, 'value', protocol)).strip().casefold() + + +def fixLogObjectPath(config, attr: str, value: str, log=True): + """Resolve and normalize one log-file path in an Xray configuration.""" + try: + path = config['log'][attr] + except Exception: + config['log'][attr] = path = '' + + if not isinstance(path, (str, bytes)): + config['log'][attr] = path = '' + + if path == '': + if SystemRuntime.isPythonw() and ProcessOutputRedirector.TemporaryDir.isValid(): + config['log'][attr] = ProcessOutputRedirector.TemporaryDir.filePath(value) + else: + config['log'][attr] = absolutePath(path) + + result = config['log'][attr] + if result: + try: + with open(result, 'x', encoding='utf-8'): + pass + except FileExistsError: + pass + except Exception: + pass + + if log: + logger.info( + f'{XrayCore.name()}: {attr} log is specified as \'{path}\'. ' + f'Fixed to \'{result}\'' + ) + + +def cleanRoutingRule(rule: dict): + """Remove empty match fields from an Xray routing rule.""" + if not isinstance(rule, dict): + return None + + result = { + 'type': 'field', + 'outboundTag': str(rule.get('outboundTag', 'proxy')).strip() or 'proxy', + } + + for key in [ + 'domain', + 'ip', + 'sourceIP', + 'localIP', + 'user', + 'protocol', + 'inboundTag', + 'process', + ]: + value = rule.get(key, []) + if isinstance(value, list): + value = list(str(item).strip() for item in value if str(item).strip()) + if value: + result[key] = value + + for key in [ + 'port', + 'sourcePort', + 'localPort', + 'network', + 'vlessRoute', + 'balancerTag', + 'ruleTag', + ]: + value = str(rule.get(key, '')).strip() + if value: + result[key] = value + + return result if len(result) > 2 else None + + +def customRoutingObjectFromSettings(routing: str): + """Load a named custom Xray routing object from persistent settings.""" + prefix = 'Custom:' + if not isinstance(routing, str) or not routing.startswith(prefix): + return None + + routingProfile = Storage.UserRoutings().get(routing[len(prefix) :]) + if not isinstance(routingProfile, dict) or not routingProfile.get('enabled', True): + return None + + domainStrategy = routingProfile.get('domainStrategy', 'AsIs') + if domainStrategy not in ['AsIs', 'IPIfNonMatch', 'IPOnDemand']: + domainStrategy = 'AsIs' + + rules = list( + filter( + lambda rule: rule is not None, + list(cleanRoutingRule(rule) for rule in routingProfile.get('rules', [])), + ) + ) + + return { + 'domainStrategy': domainStrategy, + 'domainMatcher': 'hybrid', + 'rules': rules, + } + + +class XrayPlugin(FuriousPlugin): + """Provide official Xray-core support.""" + + pluginId = 'official.xray' + displayName = 'Xray-core' + protocols = ( + PluginProtocol('VMess', 'VMess', 'Add VMess Server...', 10), + PluginProtocol('VLESS', 'VLESS', 'Add VLESS Server...', 20), + PluginProtocol('Shadowsocks', 'Shadowsocks', 'Add Shadowsocks Server...', 30), + PluginProtocol('Trojan', 'Trojan', 'Add Trojan Server...', 40), + PluginProtocol('SOCKS', 'SOCKS', 'Add SOCKS Server...', 70, True), + ) + configurationTypes = (ConfigXray,) + coreTypes = (XrayCore,) + + def configFromString(self, config: str, **kwargs): + """Parse an Xray share URI when its scheme is supported.""" + if config.startswith( + ( + 'vmess://', + 'vless://', + 'ss://', + 'trojan://', + 'socks://', + 'socks5://', + 'socks5h://', + ) + ): + return ConfigXray(config, **kwargs) + + return None + + def configFromDict(self, config: dict, **kwargs): + """Recognize Xray configuration mappings by their inbound/outbound fields.""" + if config.get('inbounds') is not None or config.get('outbounds') is not None: + return ConfigXray(config, **kwargs) + + return None + + def blankConfig(self, protocol, **kwargs): + """Construct a blank Xray configuration for one supported protocol.""" + protocolId = _protocolId(protocol) + factory = ConfigXray(copy.deepcopy(BLANK_CONFIG_XRAY), **kwargs) + outbound = factory['outbounds'][0] + + if protocolId in ('vmess', 'vless'): + outbound['protocol'] = protocolId + outbound['settings']['vnext'] = [ + { + 'address': '', + 'port': 0, + 'users': [{'email': PROXY_OUTBOUND_USER_EMAIL}], + }, + ] + elif protocolId == 'socks': + outbound['protocol'] = protocolId + outbound['settings'] = {'address': '', 'port': 0} + elif protocolId in ('shadowsocks', 'trojan'): + outbound['protocol'] = protocolId + outbound['settings']['servers'] = [ + { + 'address': '', + 'port': 0, + 'email': PROXY_OUTBOUND_USER_EMAIL, + }, + ] + else: + return None + + return factory + + def createEditorForProtocol(self, protocol, parent=None, **kwargs): + """Create the Xray editor matching a protocol identifier.""" + from .GuiShadowsocks import GuiShadowsocks + from .GuiSocks import GuiSocks + from .GuiTrojan import GuiTrojan + from .GuiVLESS import GuiVLESS + from .GuiVMess import GuiVMess + + editors = { + 'vmess': GuiVMess, + 'vless': GuiVLESS, + 'shadowsocks': GuiShadowsocks, + 'socks': GuiSocks, + 'trojan': GuiTrojan, + } + editorType = editors.get(_protocolId(protocol)) + + return editorType(parent=parent, **kwargs) if editorType is not None else None + + def createEditorForConfig(self, config, parent=None, **kwargs): + """Create the editor matching an Xray outbound protocol.""" + return self.createEditorForProtocol(config.proxyProtocol, parent, **kwargs) + + def createRoutingEditor(self, parent=None, **kwargs): + """Create Xray's custom routing-profile editor.""" + from .UserRoutingWindow import UserRoutingWindow + + return UserRoutingWindow(parent=parent, **kwargs) + + def createMainWindowTools(self, parent=None, **kwargs): + """Create Xray asset-management actions for the host Tools menu.""" + from Furious.Qt import AppQAction, gettext + + from .XrayAssetViewerWindow import XrayAssetViewerWindow + + assetViewer = XrayAssetViewerWindow(parent=parent) + if parent is not None: + parent.xrayAssetViewerWindow = assetViewer + + return ( + AppQAction( + gettext('Manage Xray-core Asset File...'), + callback=assetViewer.show, + ), + ) + + def routingChoices(self): + """Return enabled named Xray routing profiles.""" + return tuple( + (routing.get('remark', ''), f'Custom:{unique}') + for unique, routing in Storage.UserRoutings().items() + if routing.get('enabled', True) + ) + + def configureEnvironment(self): + """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.""" + if config.get('log') is None or not isinstance(config['log'], dict): + config['log'] = {'access': '', 'error': '', 'loglevel': 'warning'} + + logRedirectValue = str(uuid.uuid4()) + for attr in ['access', 'error']: + fixLogObjectPath(config, attr, logRedirectValue, log) + + if routing == AppBuiltinRouting.BypassMainlandChina.value: + if not proxyModeOnly and SystemRuntime.isTUNMode(): + showMBoxDirectRulesNotAllowed() + + return None, False + + routingObject = { + 'domainStrategy': 'IPIfNonMatch', + 'domainMatcher': 'hybrid', + 'rules': [ + { + 'type': 'field', + 'domain': ['geosite:category-ads-all'], + 'outboundTag': 'block', + }, + { + 'type': 'field', + 'domain': ['geosite:cn'], + 'outboundTag': 'direct', + }, + { + 'type': 'field', + 'ip': ['geoip:private', 'geoip:cn'], + 'outboundTag': 'direct', + }, + { + 'type': 'field', + 'port': '0-65535', + 'outboundTag': 'proxy', + }, + ], + } + elif routing == AppBuiltinRouting.Global.value: + routingObject = {} + elif customRoutingObjectFromSettings(routing) is not None: + routingObject = customRoutingObjectFromSettings(routing) + elif routing == AppBuiltinRouting.Custom.value: + routingObject = config.get('routing', {}) + else: + routingObject = {} + + if log: + logger.info(f'core {XrayCore.name()} configured') + logger.info(f'routing is {routing}') + logger.info(f'RoutingObject: {routingObject}') + + config['routing'] = routingObject + process = XrayCore(exitCallback=exitCallback, msgCallback=msgCallback) + + return process, process.start(config, **kwargs) + + def prepareDownloadTest(self, config, port: int): + """Create an Xray configuration with one local HTTP test inbound.""" + configcopy = config.deepcopy() + configcopy['inbounds'] = [ + { + 'tag': 'http', + 'port': port, + 'listen': '127.0.0.1', + 'protocol': 'http', + 'sniffing': { + 'enabled': True, + 'destOverride': ['http', 'tls'], + }, + 'settings': { + 'auth': 'noauth', + 'udp': True, + 'allowTransparent': False, + }, + }, + ] + + try: + for outboundObject in configcopy['outbounds']: + if outboundObject['tag'] == 'proxy': + outboundObject['tag'] = f'proxy{port}' + except Exception: + pass + + return configcopy + + def coreVersions(self): + """Return the bundled Xray-core version.""" + return (XrayCore.version(),) + + def logTimestampPatterns(self): + """Return the timestamp format emitted by Xray-core.""" + return (r'\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2}.\d{6}',) + + def afterConnected(self, httpProxy=None): + """Update Xray geo assets after connecting when enabled.""" + if not SystemRuntime.isAssetsFolderWritable(): + logger.info( + f'skipped auto assets update due to assets folder ' + f'\'{XRAY_ASSET_DIR}\' not writable' + ) + + return + + logger.info(f'assets folder \'{XRAY_ASSET_DIR}\' is writable. Continue') + if not AppSettings.isStateON_('AutoUpdateAssetFiles'): + logger.info('skipped auto assets update due to settings') + + return + + from .XrayAssetDownloadManager import XrayAssetDownloadManager + + try: + assetDownloadManager = self._assetDownloadManager + except AttributeError: + self._assetDownloadManager = assetDownloadManager = ( + XrayAssetDownloadManager() + ) + + assetDownloadManager.configureHttpProxy(httpProxy) + assetDownloadManager.download() diff --git a/Furious/Plugins/Official/Xray/UserRoutingWindow.py b/Furious/Plugins/Official/Xray/UserRoutingWindow.py new file mode 100644 index 00000000..ef7c8c59 --- /dev/null +++ b/Furious/Plugins/Official/Xray/UserRoutingWindow.py @@ -0,0 +1,1249 @@ +# Copyright (C) 2024-present Loren Eteval & contributors +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Provide the application window for user routing window.""" + +from __future__ import annotations + +from Furious.Frozenlib import * +from .Plugin import cleanRoutingRule +from Furious.Library import * +from Furious.Qt import * +from Furious.Qt import gettext as _ + +from PySide6 import QtCore +from PySide6.QtGui import * +from PySide6.QtWidgets import * + +import copy +import uuid +import logging +import functools + +__all__ = ['UserRoutingWindow'] + +logger = logging.getLogger(__name__) + +registerAppSettings('UserRoutingWindowGeometry') +registerAppSettings('UserRoutingWindowState') +registerAppSettings('UserRoutingHeaderViewState') + + +DOMAIN_STRATEGIES, ROUTING_STATES = ( + ['AsIs', 'IPIfNonMatch', 'IPOnDemand'], + ['Enabled', 'Disabled'], +) + + +def valuesToList(text: str) -> list[str]: + """Return the values to list value used by the application.""" + values = list() + + for line in text.splitlines(): + for value in line.split(','): + value = value.strip() + + if value: + values.append(value) + + return values + + +def listToText(value) -> str: + """Return the list to text value used by the application.""" + if isinstance(value, list): + return '\n'.join(str(item) for item in value) + + return '' + + +def routingStateText(state: str) -> str: + """Return the routing state text value used by the application.""" + if state == 'Enabled': + return _('Enabled') + elif state == 'Disabled': + return _('Disabled') + + return _(state) + + +def addRoutingStateItems(combo: QComboBox): + """Add routing state items.""" + combo.clear() + + for state in ROUTING_STATES: + combo.addItem(routingStateText(state), state) + + +def comboCurrentData(combo: QComboBox, default=''): + """Return the combo current data value used by the application.""" + data = combo.currentData() + + if data is None: + return default + + return data + + +def routingObjectFromProfile(routingProfile: dict): + """Return the routing object from profile value used by the application.""" + if not isinstance(routingProfile, dict): + routingProfile = dict() + + domainStrategy = routingProfile.get('domainStrategy', 'AsIs') + + if domainStrategy not in DOMAIN_STRATEGIES: + domainStrategy = 'AsIs' + + rules = list( + filter( + lambda rule: rule is not None, + list(cleanRoutingRule(rule) for rule in routingProfile.get('rules', [])), + ) + ) + + return { + 'domainStrategy': domainStrategy, + 'domainMatcher': 'hybrid', + 'rules': rules, + } + + +class RoutingPreviewDialog(AppQDialog): + """Present the routing preview dialog.""" + + def __init__(self, routingProfile: dict, parent=None): + """Initialize the RoutingPreviewDialog.""" + super().__init__(parent) + + self.setWindowTitle(_('Preview Routing')) + self.setWindowModality(QtCore.Qt.WindowModality.WindowModal) + + if PLATFORM == 'Darwin': + self.toolbar = AppQToolBar( + AppQAction( + _('Close'), + icon=bootstrapIcon('window-x.svg'), + callback=lambda: self.close(), + ), + parent=self, + ) + else: + self.toolbar = None + + self.textEditor = DraculaJSONTextEditor(fontFamily=AppFontName()) + self.textEditor.setLineWrapMode(DraculaJSONTextEditor.LineWrapMode.NoWrap) + self.textEditor.setPlainText( + UJSONEncoder.encode(routingObjectFromProfile(routingProfile), indent=4) + ) + self.textEditor.setReadOnly(True) + + layout = QVBoxLayout() + + if self.toolbar is not None: + layout.addWidget(self.toolbar) + + layout.addWidget(self.textEditor) + + self.setLayout(layout) + + def setWidthAndHeight(self): + """Apply the default size for the routing preview dialog.""" + self.setFixedSize(400, int(400 * GOLDEN_RATIO)) + + +class RoutingTextEditDialog(AppQDialog): + """Present the routing text edit dialog.""" + + def __init__(self, text='', parent=None): + """Initialize the RoutingTextEditDialog.""" + super().__init__(parent) + + self.setWindowTitle(_('Edit Text')) + self.setWindowModality(QtCore.Qt.WindowModality.WindowModal) + + self.textEdit = QTextEdit(text) + + self.dialogBtns = AppQDialogButtonBox(QtCore.Qt.Orientation.Horizontal) + self.dialogBtns.addButton(_('OK'), AppQDialogButtonBox.ButtonRole.AcceptRole) + self.dialogBtns.addButton( + _('Cancel'), + AppQDialogButtonBox.ButtonRole.RejectRole, + ) + self.dialogBtns.accepted.connect(self.accept) + self.dialogBtns.rejected.connect(self.reject) + + layout = QVBoxLayout() + layout.addWidget(self.textEdit) + layout.addWidget(self.dialogBtns) + + self.setLayout(layout) + + def setWidthAndHeight(self): + """Apply the default size for the routing text edit dialog.""" + self.setFixedSize(760, 470) + + def text(self): + """Return the text value.""" + return self.textEdit.toPlainText() + + +class RoutingTextEdit(Mixins.QTranslatable, QTextEdit): + """Represent routing text edit.""" + + def __init__(self, text='', parent=None): + """Initialize the RoutingTextEdit.""" + super().__init__(text, parent) + + self.setToolTip(_('Double-click to enlarge')) + + def mouseDoubleClickEvent(self, event): + """Handle the mouse double click event.""" + dialog = RoutingTextEditDialog(self.toPlainText(), parent=self) + + def handleResultCode(code): + """Handle result code.""" + if code == PySide6Legacy.enumValueWrapper(AppQDialog.DialogCode.Accepted): + self.setPlainText(dialog.text()) + + dialog.finished.connect(handleResultCode) + dialog.open() + + event.accept() + + def retranslate(self): + """Refresh translated text for the routing text edit.""" + self.setToolTip(_(self.toolTip())) + + +class RoutingDocumentationURL(AppQLabel): + """Represent routing documentation URL.""" + + URL = 'https://xtls.github.io/config/routing.html' + + def __init__(self, *args, **kwargs): + """Initialize the RoutingDocumentationURL.""" + super().__init__(*args, **kwargs) + + self.setWebsiteURL() + self.linkActivated.connect(self.handleLinkActivated) + + def setWebsiteURL(self): + """Set website URL.""" + self.setText( + '

' + f'' + '' + + _('Routing Documentation') + + '

' + ) + + @staticmethod + def handleLinkActivated(link: str): + """Handle link activated.""" + if QDesktopServices.openUrl(QtCore.QUrl(link)): + logger.info(f'open link \'{link}\' success') + else: + logger.error(f'open link \'{link}\' failed') + + def retranslate(self): + """Refresh translated text for the routing documentation URL.""" + self.setWebsiteURL() + + +class RoutingProfilesModel(QtCore.QAbstractTableModel): + """Expose routing profiles data through a Qt item model.""" + + Headers = ['Remark', 'Domain Strategy', 'State'] + + def rowCount(self, parent=QtCore.QModelIndex()) -> int: + """Return the number of rows exposed by the model.""" + if parent.isValid(): + return 0 + + return len(Storage.UserRoutings()) + + def columnCount(self, parent=QtCore.QModelIndex()) -> int: + """Return the number of columns exposed by the model.""" + if parent.isValid(): + return 0 + + return len(self.Headers) + + def flags(self, index): + """Return the Qt item flags for a model index.""" + if not index.isValid(): + return QtCore.Qt.ItemFlag.NoItemFlags + + return QtCore.Qt.ItemFlag.ItemIsEnabled | QtCore.Qt.ItemFlag.ItemIsSelectable + + def headerData(self, section, orientation, role=QtCore.Qt.ItemDataRole.DisplayRole): + """Return display data for a table header section.""" + if role != QtCore.Qt.ItemDataRole.DisplayRole: + return None + + if orientation == QtCore.Qt.Orientation.Horizontal: + header = self.Headers[section] + + if header in ['Remark', 'State']: + return _(header) + + return header + + return section + 1 + + def routingUniqueByRow(self, row: int): + """Return the routing unique by row value used by the routing profiles model.""" + return list(Storage.UserRoutings().keys())[row] + + def routingByRow(self, row: int): + """Return the routing by row value used by the routing profiles model.""" + return Storage.UserRoutings()[self.routingUniqueByRow(row)] + + def data(self, index, role=QtCore.Qt.ItemDataRole.DisplayRole): + """Return the data managed by the routing profiles model.""" + if not index.isValid(): + return None + + row, column = index.row(), index.column() + + if row < 0 or row >= len(Storage.UserRoutings()): + return None + + routing = self.routingByRow(row) + + if role in [ + QtCore.Qt.ItemDataRole.DisplayRole, + QtCore.Qt.ItemDataRole.EditRole, + ]: + if column == 0: + return routing.get('remark', '') + elif column == 1: + return routing.get('domainStrategy', 'AsIs') + elif column == 2: + return routingStateText( + 'Enabled' if routing.get('enabled', True) else 'Disabled' + ) + + return None + + def setData(self, index, value, role=QtCore.Qt.ItemDataRole.EditRole): + """Update model data for the requested role.""" + return False + + def emitAllChanged(self): + """Handle emit all changed for the routing profiles model.""" + if self.rowCount() == 0: + return + + self.dataChanged.emit( + self.index(0, 0), + self.index(self.rowCount() - 1, self.columnCount() - 1), + ) + + +class RoutingRuleEditDialog(AppQDialog): + """Present the routing rule edit dialog.""" + + MatchInputHeight = 72 + ShortInputWidth = 240 + + @staticmethod + def textEdit(text=''): + """Return the text edit value used by the routing rule edit dialog.""" + widget = RoutingTextEdit(text) + widget.setFixedHeight(RoutingRuleEditDialog.MatchInputHeight) + + return widget + + @staticmethod + def lineEdit(text=''): + """Return the line edit value used by the routing rule edit dialog.""" + widget = QLineEdit(text) + widget.setFixedHeight(RoutingRuleEditDialog.MatchInputHeight) + + return widget + + def __init__(self, rule: dict, parent=None): + """Initialize the RoutingRuleEditDialog.""" + super().__init__(parent) + + self.rule = dict(rule) + + self.setWindowTitle(_('Edit Routing Rule')) + self.setWindowModality(QtCore.Qt.WindowModality.WindowModal) + + self.ruleTagEdit = self.lineEdit(self.rule.get('ruleTag', '')) + + self.outboundEdit = self.lineEdit(self.rule.get('outboundTag', 'proxy')) + self.outboundEdit.setMaximumWidth(360) + + self.balancerTagEdit = self.lineEdit(str(self.rule.get('balancerTag', ''))) + + self.networkCombo = AppQComboBox(translatable=False) + self.networkCombo.addItems(['', 'tcp', 'udp', 'tcp,udp']) + self.networkCombo.setCurrentText(self.rule.get('network', '')) + self.networkCombo.setMaximumWidth(self.ShortInputWidth) + + self.portEdit = self.textEdit(str(self.rule.get('port', ''))) + self.portEdit.setMaximumWidth(360) + + self.sourcePortEdit = self.textEdit(str(self.rule.get('sourcePort', ''))) + self.sourcePortEdit.setMaximumWidth(360) + + self.localPortEdit = self.textEdit(str(self.rule.get('localPort', ''))) + self.localPortEdit.setMaximumWidth(360) + + self.vlessRouteEdit = self.textEdit(str(self.rule.get('vlessRoute', ''))) + self.vlessRouteEdit.setMaximumWidth(360) + + self.domainEdit = self.textEdit(listToText(self.rule.get('domain', []))) + self.ipEdit = self.textEdit(listToText(self.rule.get('ip', []))) + self.sourceIPEdit = self.textEdit(listToText(self.rule.get('sourceIP', []))) + self.localIPEdit = self.textEdit(listToText(self.rule.get('localIP', []))) + self.userEdit = self.textEdit(listToText(self.rule.get('user', []))) + self.protocolEdit = self.textEdit(listToText(self.rule.get('protocol', []))) + self.inboundTagEdit = self.textEdit(listToText(self.rule.get('inboundTag', []))) + self.processEdit = self.textEdit(listToText(self.rule.get('process', []))) + + self.dialogBtns = AppQDialogButtonBox(QtCore.Qt.Orientation.Horizontal) + self.dialogBtns.addButton(_('OK'), AppQDialogButtonBox.ButtonRole.AcceptRole) + self.dialogBtns.addButton( + _('Cancel'), + AppQDialogButtonBox.ButtonRole.RejectRole, + ) + self.dialogBtns.accepted.connect(self.accept) + self.dialogBtns.rejected.connect(self.reject) + + generalLayout = QFormLayout() + generalLayout.addRow( + AppQLabel('Rule Tag', translatable=False), + self.ruleTagEdit, + ) + generalLayout.addRow( + AppQLabel(_('OutBound (e.g. proxy/direct/block)')), + self.outboundEdit, + ) + generalLayout.addRow( + AppQLabel('Balancer Tag', translatable=False), + self.balancerTagEdit, + ) + generalLayout.addRow( + AppQLabel('Network', translatable=False), + self.networkCombo, + ) + + generalGroup = AppQGroupBox( + 'General', + translatable=False, + ) + generalGroup.setLayout(generalLayout) + + destinationLayout = QFormLayout() + destinationLayout.addRow( + AppQLabel(_('Domain (one per line or comma-separated)')), + self.domainEdit, + ) + destinationLayout.addRow( + AppQLabel(_('IP (one per line or comma-separated)')), + self.ipEdit, + ) + destinationLayout.addRow( + AppQLabel(_('Port (comma/range, e.g. 53,443,1000-2000)')), + self.portEdit, + ) + destinationLayout.addRow( + AppQLabel(_('VLESS Route (comma/range, e.g. 53,443,1000-2000)')), + self.vlessRouteEdit, + ) + + destinationGroup = AppQGroupBox( + 'Destination Match', + translatable=False, + ) + destinationGroup.setLayout(destinationLayout) + + sourceLayout = QFormLayout() + sourceLayout.addRow( + AppQLabel(_('Source IP (one per line or comma-separated)')), + self.sourceIPEdit, + ) + sourceLayout.addRow( + AppQLabel(_('Source Port (comma/range, e.g. 53,443,1000-2000)')), + self.sourcePortEdit, + ) + sourceLayout.addRow( + AppQLabel(_('Local IP (one per line or comma-separated)')), + self.localIPEdit, + ) + sourceLayout.addRow( + AppQLabel(_('Local Port (comma/range, e.g. 53,443,1000-2000)')), + self.localPortEdit, + ) + + sourceGroup = AppQGroupBox( + 'Source / Local Match', + translatable=False, + ) + sourceGroup.setLayout(sourceLayout) + + identityLayout = QFormLayout() + identityLayout.addRow( + AppQLabel(_('User (one per line or comma-separated)')), + self.userEdit, + ) + identityLayout.addRow( + AppQLabel(_('Inbound Tag (one per line or comma-separated)')), + self.inboundTagEdit, + ) + identityLayout.addRow( + AppQLabel(_('Process (one per line or comma-separated)')), + self.processEdit, + ) + identityLayout.addRow( + AppQLabel(_('Protocol (one per line or comma-separated)')), + self.protocolEdit, + ) + + identityGroup = AppQGroupBox( + 'Identity / Process / Protocol Match', + translatable=False, + ) + identityGroup.setLayout(identityLayout) + + matchGrid = QGridLayout() + matchGrid.addWidget(generalGroup, 0, 0) + matchGrid.addWidget(destinationGroup, 0, 1) + matchGrid.addWidget(sourceGroup, 1, 0) + matchGrid.addWidget(identityGroup, 1, 1) + matchGrid.setColumnStretch(0, 1) + matchGrid.setColumnStretch(1, 1) + + bottomLayout = QHBoxLayout() + bottomLayout.addWidget(RoutingDocumentationURL()) + bottomLayout.addStretch(1) + bottomLayout.addWidget(self.dialogBtns) + + layout = QVBoxLayout() + layout.addLayout(matchGrid) + layout.addLayout(bottomLayout) + + self.setLayout(layout) + + def setWidthAndHeight(self): + """Apply the default size for the routing rule edit dialog.""" + self.resize(int(800 * GOLDEN_RATIO), 800) + + def routingRule(self): + """Return the routing rule value used by the routing rule edit dialog.""" + rule = { + 'type': 'field', + 'outboundTag': self.outboundEdit.text().strip() or 'proxy', + } + + for key, value in [ + ('ruleTag', self.ruleTagEdit.text().strip()), + ('network', self.networkCombo.currentText().strip()), + ('port', self.portEdit.toPlainText().strip()), + ('sourcePort', self.sourcePortEdit.toPlainText().strip()), + ('localPort', self.localPortEdit.toPlainText().strip()), + ('vlessRoute', self.vlessRouteEdit.toPlainText().strip()), + ('balancerTag', self.balancerTagEdit.text().strip()), + ]: + if value: + rule[key] = value + + for key, widget in [ + ('domain', self.domainEdit), + ('ip', self.ipEdit), + ('sourceIP', self.sourceIPEdit), + ('localIP', self.localIPEdit), + ('user', self.userEdit), + ('protocol', self.protocolEdit), + ('inboundTag', self.inboundTagEdit), + ('process', self.processEdit), + ]: + values = valuesToList(widget.toPlainText()) + + if values: + rule[key] = values + + return rule + + +class RoutingRemarkEditDialog(AppQDialog): + """Present the routing remark edit dialog.""" + + def __init__(self, remark: str, parent=None): + """Initialize the RoutingRemarkEditDialog.""" + super().__init__(parent) + + self.setWindowTitle(_('Edit Routing Remark')) + self.setWindowModality(QtCore.Qt.WindowModality.WindowModal) + + self.remarkEdit = QLineEdit(remark) + + self.dialogBtns = AppQDialogButtonBox(QtCore.Qt.Orientation.Horizontal) + self.dialogBtns.addButton(_('OK'), AppQDialogButtonBox.ButtonRole.AcceptRole) + self.dialogBtns.addButton( + _('Cancel'), + AppQDialogButtonBox.ButtonRole.RejectRole, + ) + self.dialogBtns.accepted.connect(self.accept) + self.dialogBtns.rejected.connect(self.reject) + + layout = QFormLayout() + layout.addRow(AppQLabel(_('Remark')), self.remarkEdit) + layout.addRow(self.dialogBtns) + + self.setLayout(layout) + + def setWidthAndHeight(self): + """Apply the default size for the routing remark edit dialog.""" + self.resize(420, 120) + + def remark(self): + """Return the remark value used by the routing remark edit dialog.""" + return self.remarkEdit.text().strip() + + +class RoutingProfileEditDialog(AppQDialog): + """Present the routing profile edit dialog.""" + + def __init__(self, parent=None): + """Initialize the RoutingProfileEditDialog.""" + super().__init__(parent) + + self.setWindowTitle(_('Add Routing')) + self.setWindowModality(QtCore.Qt.WindowModality.WindowModal) + + self.remarkEdit = QLineEdit(_('New Routing')) + + self.domainStrategyCombo = AppQComboBox(translatable=False) + self.domainStrategyCombo.addItems(DOMAIN_STRATEGIES) + self.domainStrategyCombo.setCurrentText('AsIs') + + self.enabledCombo = AppQComboBox(translatable=False) + addRoutingStateItems(self.enabledCombo) + self.enabledCombo.setCurrentIndex(ROUTING_STATES.index('Enabled')) + + self.dialogBtns = AppQDialogButtonBox(QtCore.Qt.Orientation.Horizontal) + self.dialogBtns.addButton(_('OK'), AppQDialogButtonBox.ButtonRole.AcceptRole) + self.dialogBtns.addButton( + _('Cancel'), + AppQDialogButtonBox.ButtonRole.RejectRole, + ) + self.dialogBtns.accepted.connect(self.accept) + self.dialogBtns.rejected.connect(self.reject) + + layout = QFormLayout() + layout.addRow(AppQLabel(_('Remark')), self.remarkEdit) + layout.addRow( + AppQLabel('Domain Strategy', translatable=False), + self.domainStrategyCombo, + ) + layout.addRow(AppQLabel(_('State')), self.enabledCombo) + layout.addRow(self.dialogBtns) + + self.setLayout(layout) + + def setWidthAndHeight(self): + """Apply the default size for the routing profile edit dialog.""" + self.resize(460, 160) + + def routing(self): + """Return the routing value used by the routing profile edit dialog.""" + return { + 'remark': self.remarkEdit.text().strip() or _('New Routing'), + 'domainStrategy': self.domainStrategyCombo.currentText(), + 'enabled': comboCurrentData(self.enabledCombo, 'Enabled') == 'Enabled', + 'rules': [], + } + + +class RoutingRulesQListWidget(AppQListWidget): + """Provide the routing rules Qt list widget.""" + + editRequested, deleteRequested = ( + QtCore.Signal(), + QtCore.Signal(), + ) + + def __init__(self, routing: dict, parent=None): + """Initialize the RoutingRulesQListWidget.""" + super().__init__(parent) + + self.routing = routing + + self.setAlternatingRowColors(True) + self.setSelectionBehavior(AppQListWidget.SelectionBehavior.SelectRows) + self.setSelectionMode(AppQListWidget.SelectionMode.ExtendedSelection) + self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) + + self.itemDoubleClicked.connect(lambda _item: self.editRequested.emit()) + self.customContextMenuRequested.connect(self.handleCustomContextMenuRequested) + + self.contextDeleteAction = AppQAction( + _('Delete'), + callback=lambda: self.deleteRequested.emit(), + ) + self.contextMenu = AppQMenu(self.contextDeleteAction, parent=self) + + self.flushAll() + + def rules(self): + """Return the rules value used by the routing rules Qt list widget.""" + rules = self.routing.setdefault('rules', list()) + + if not isinstance(rules, list): + self.routing['rules'] = rules = list() + + return rules + + def ruleAt(self, index: int): + """Return the rule at value used by the routing rules Qt list widget.""" + return self.rules()[index] + + def ruleText(self, rule: dict) -> str: + """Return the rule text value used by the routing rules Qt list widget.""" + name, outbound, domains, ips = ( + rule.get('ruleTag', '') or 'Untitled Rule', + rule.get('outboundTag', 'proxy'), + len(rule.get('domain', [])), + len(rule.get('ip', [])), + ) + + return f'{name} -> {outbound} ({domains} domains, {ips} IPs)' + + def selectedRuleText(self): + """Select ed rule text.""" + indexes = self.selectedIndex + + if not indexes: + return '' + + return self.ruleText(self.ruleAt(indexes[0])) + + def appendRule(self, rule: dict): + """Append rule.""" + self.rules().append(rule) + self.flushAll() + + def setRule(self, index: int, rule: dict): + """Set rule.""" + self.rules()[index] = rule + self.flushAll() + + def deleteRules(self, indexes: list[int]): + """Delete rules.""" + for i in range(len(indexes)): + self.rules().pop(indexes[i] - i) + + self.flushAll() + + def flushAll(self): + """Refresh all.""" + self.clear() + + for rule in self.rules(): + self.addItem(self.ruleText(rule)) + + @QtCore.Slot(QtCore.QPoint) + def handleCustomContextMenuRequested(self, point): + """Handle custom context menu requested.""" + self.contextMenu.exec(self.viewport().mapToGlobal(point)) + + +class RoutingRulesDialog(AppQDialog): + """Present the routing rules dialog.""" + + def __init__(self, routing: dict, parent=None): + """Initialize the RoutingRulesDialog.""" + super().__init__(parent) + + self.routing = routing + self.setWindowTitle(_('Routing Rules')) + self.setWindowModality(QtCore.Qt.WindowModality.WindowModal) + + if PLATFORM == 'Darwin': + self.toolbar = AppQToolBar( + AppQAction( + _('Close'), + icon=bootstrapIcon('window-x.svg'), + callback=lambda: self.close(), + ), + parent=self, + ) + else: + self.toolbar = None + + self.listWidget = RoutingRulesQListWidget(self.routing, parent=self) + self.listWidget.editRequested.connect(self.editRule) + self.listWidget.deleteRequested.connect(self.deleteRule) + + self.addButton = AppQPushButton(_('Add')) + self.addButton.clicked.connect(self.addRule) + + self.deleteButton = AppQPushButton(_('Delete')) + self.deleteButton.clicked.connect(self.deleteRule) + + buttonLayout = QHBoxLayout() + buttonLayout.addWidget(self.addButton) + buttonLayout.addWidget(self.deleteButton) + + layout = QVBoxLayout() + + if self.toolbar is not None: + layout.addWidget(self.toolbar) + + layout.addWidget(self.listWidget) + layout.addLayout(buttonLayout) + + self.setLayout(layout) + + def setWidthAndHeight(self): + """Apply the default size for the routing rules dialog.""" + self.setFixedSize(760, 470) + + def addRule(self): + """Add rule.""" + rule = {'type': 'field', 'outboundTag': 'proxy', 'ruleTag': 'New Rule'} + dialog = RoutingRuleEditDialog(rule, parent=None) + + def handleResultCode(code): + """Handle result code.""" + if code == PySide6Legacy.enumValueWrapper(AppQDialog.DialogCode.Accepted): + self.listWidget.appendRule(dialog.routingRule()) + + dialog.finished.connect(handleResultCode) + dialog.open() + + def editRule(self): + """Handle edit rule for the routing rules dialog.""" + indexes = self.listWidget.selectedIndex + + if len(indexes) != 1: + return + + index = indexes[0] + dialog = RoutingRuleEditDialog(self.listWidget.ruleAt(index), parent=None) + + def handleResultCode(_index, code): + """Handle result code.""" + if code == PySide6Legacy.enumValueWrapper(AppQDialog.DialogCode.Accepted): + self.listWidget.setRule(_index, dialog.routingRule()) + + dialog.finished.connect(functools.partial(handleResultCode, index)) + dialog.open() + + def deleteRule(self): + """Delete rule.""" + indexes = self.listWidget.selectedIndex + + if len(indexes) == 0: + return + + def handleResultCode(_indexes, code): + """Handle result code.""" + if code == PySide6Legacy.enumValueWrapper( + AppQMessageBox.StandardButton.Yes + ): + self.listWidget.deleteRules(_indexes) + else: + # Do not delete + pass + + if PLATFORM == 'Windows': + # Windows + mbox = MBoxQuestionDelete(icon=AppQMessageBox.Icon.Question) + else: + # macOS & linux + mbox = MBoxQuestionDelete( + icon=AppQMessageBox.Icon.Question, + parent=self, + ) + mbox.setWindowModality(QtCore.Qt.WindowModality.WindowModal) + + mbox.isMulti = bool(len(indexes) > 1) + mbox.possibleRemark = self.listWidget.selectedRuleText() + mbox.setText(mbox.customText()) + mbox.finished.connect(functools.partial(handleResultCode, indexes)) + + # Show the MessageBox asynchronously + mbox.open() + + +class UserRoutingQTableViewHorizontalHeader(AppQHeaderView): + """Provide the user routing Qt table view horizontal table header.""" + + def __init__(self, *args, **kwargs): + """Initialize the UserRoutingQTableViewHorizontalHeader.""" + super().__init__(QtCore.Qt.Orientation.Horizontal, *args, **kwargs) + + +class UserRoutingTableView(Mixins.QTranslatable, AppQTableView): + """Represent user routing table view.""" + + RowHeight = 42 + + def __init__(self, parent=None): + """Initialize the UserRoutingTableView.""" + super().__init__(parent) + + self.sourceModel = RoutingProfilesModel(parent=self) + self.setModel(self.sourceModel) + self.setHorizontalHeader( + UserRoutingQTableViewHorizontalHeader( + parent=self, + sectionSizeSettingsName='UserRoutingHeaderViewState', + ) + ) + self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + self.setDragDropMode(QAbstractItemView.DragDropMode.NoDragDrop) + self.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.setSortingEnabled(False) + self.doubleClicked.connect(self.editSelectedRules) + self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) + self.customContextMenuRequested.connect(self.handleCustomContextMenuRequested) + + self.contextPreviewAction = AppQAction( + _('Preview'), + icon=bootstrapIcon('file-earmark-text.svg'), + callback=lambda: self.previewSelectedItem(), + ) + self.contextRenameAction = AppQAction( + _('Rename'), + callback=lambda: self.renameSelectedItem(), + ) + self.contextDeleteAction = AppQAction( + _('Delete'), + callback=lambda: self.deleteSelectedItem(), + ) + self.contextMenu = AppQMenu( + self.contextPreviewAction, + self.contextRenameAction, + AppQSeperator(), + self.contextDeleteAction, + parent=self, + ) + + self.setDefaultRowHeight(self.RowHeight) + self.configureHeader() + self.flushAll() + + def configureHeader(self): + """Configure header.""" + header = self.horizontalHeader() + header.setSectionsMovable(True) + header.setFirstSectionMovable(True) + header.setCustomSectionResizeMode() + header.restoreSectionSize() + + @property + def selectedIndex(self): + """Return the selected index value.""" + return sorted( + list(set(index.row() for index in self.selectionModel().selectedRows())) + ) + + def routingUniqueByRow(self, row): + """Return the routing unique by row value used by the user routing table view.""" + return self.sourceModel.routingUniqueByRow(row) + + @QtCore.Slot(QtCore.QPoint) + def handleCustomContextMenuRequested(self, point): + """Handle custom context menu requested.""" + indexes = self.selectedIndex + + isUniqueFlag = len(indexes) == 1 + + for action in [self.contextPreviewAction, self.contextRenameAction]: + action.setEnabled(isUniqueFlag) + + self.contextMenu.exec(self.viewport().mapToGlobal(point)) + + def flushItem(self, row: int, column: int): + """Refresh item.""" + index = self.sourceModel.index(row, column) + + if column == 1: + combo = AppQComboBox(parent=self.viewport(), translatable=False) + combo.addItems(DOMAIN_STRATEGIES) + combo.setMinimumWidth(0) + combo.setCurrentText( + self.sourceModel.routingByRow(row).get('domainStrategy', 'AsIs') + ) + combo.currentTextChanged.connect( + lambda text, _row=row: self.setDomainStrategy(_row, text) + ) + + self.setIndexWidget(index, combo) + elif column == 2: + combo = AppQComboBox(parent=self.viewport(), translatable=False) + addRoutingStateItems(combo) + combo.setMinimumWidth(0) + combo.setCurrentIndex( + ROUTING_STATES.index( + 'Enabled' + if self.sourceModel.routingByRow(row).get('enabled', True) + else 'Disabled' + ) + ) + combo.currentIndexChanged.connect( + lambda _index, _row=row, _combo=combo: self.setEnabled( + _row, + comboCurrentData(_combo, 'Enabled'), + ) + ) + + self.setIndexWidget(index, combo) + + def flushRow(self, row: int): + """Refresh row.""" + for column in range(self.sourceModel.columnCount()): + self.flushItem(row, column) + + def flushAll(self): + """Refresh all.""" + self.sourceModel.emitAllChanged() + + for row in range(self.sourceModel.rowCount()): + self.flushRow(row) + + def retranslate(self): + """Refresh translated text for the user routing table view.""" + self.sourceModel.headerDataChanged.emit( + QtCore.Qt.Orientation.Horizontal, + 0, + self.sourceModel.columnCount() - 1, + ) + self.flushAll() + + def setDomainStrategy(self, row: int, text: str): + """Set domain strategy.""" + if row < 0 or row >= self.sourceModel.rowCount(): + return + + self.sourceModel.routingByRow(row)['domainStrategy'] = text + self.sourceModel.emitAllChanged() + + def setEnabled(self, row: int, state: str): + """Set enabled.""" + if row < 0 or row >= self.sourceModel.rowCount(): + return + + self.sourceModel.routingByRow(row)['enabled'] = state == 'Enabled' + self.sourceModel.emitAllChanged() + + def appendNewItem(self): + """Append new item.""" + dialog = RoutingProfileEditDialog(parent=self) + + def handleResultCode(code): + """Handle result code.""" + if code != PySide6Legacy.enumValueWrapper(AppQDialog.DialogCode.Accepted): + return + + row = self.sourceModel.rowCount() + unique = str(uuid.uuid4()) + + self.sourceModel.beginInsertRows(QtCore.QModelIndex(), row, row) + + Storage.UserRoutings()[unique] = dialog.routing() + + self.sourceModel.endInsertRows() + self.flushRow(row) + + dialog.finished.connect(handleResultCode) + dialog.open() + + def deleteSelectedItem(self): + """Delete selected item.""" + indexes = self.selectedIndex + + if not indexes: + # Nothing selected. Do nothing + return + + def handleResultCode(_indexes, code): + """Handle result code.""" + if code != PySide6Legacy.enumValueWrapper( + AppQMessageBox.StandardButton.Yes + ): + return + + for i in range(len(_indexes)): + deleteIndex = _indexes[i] - i + deleteUnique = self.routingUniqueByRow(deleteIndex) + + self.sourceModel.beginRemoveRows( + QtCore.QModelIndex(), + deleteIndex, + deleteIndex, + ) + + Storage.UserRoutings().pop(deleteUnique) + + self.sourceModel.endRemoveRows() + + self.flushAll() + + if PLATFORM == 'Windows': + # Windows + mbox = MBoxQuestionDelete(icon=AppQMessageBox.Icon.Question) + else: + # macOS & linux + mbox = MBoxQuestionDelete( + icon=AppQMessageBox.Icon.Question, + parent=self, + ) + mbox.setWindowModality(QtCore.Qt.WindowModality.WindowModal) + + mbox.isMulti = bool(len(indexes) > 1) + mbox.possibleRemark = self.sourceModel.data( + self.sourceModel.index(indexes[0], 0), + QtCore.Qt.ItemDataRole.DisplayRole, + ) + mbox.setText(mbox.customText()) + mbox.finished.connect(functools.partial(handleResultCode, indexes)) + + # Show the MessageBox asynchronously + mbox.open() + + def renameSelectedItem(self): + """Handle rename selected item for the user routing table view.""" + indexes = self.selectedIndex + + if not indexes: + # Nothing selected. Do nothing + return + + if len(indexes) > 1: + # Multiple items selected. Do nothing + return + + routing = self.sourceModel.routingByRow(indexes[0]) + + dialog = RoutingRemarkEditDialog(routing.get('remark', ''), parent=self) + + def handleResultCode(code): + """Handle result code.""" + if code != PySide6Legacy.enumValueWrapper(AppQDialog.DialogCode.Accepted): + return + + remark = dialog.remark() + + if remark: + routing['remark'] = remark + self.sourceModel.emitAllChanged() + + dialog.finished.connect(handleResultCode) + dialog.open() + + def previewSelectedItem(self): + """Handle preview selected item for the user routing table view.""" + indexes = self.selectedIndex + + if len(indexes) != 1: + return + + routing = copy.deepcopy(self.sourceModel.routingByRow(indexes[0])) + + dialog = RoutingPreviewDialog(routing, parent=self) + dialog.open() + + def editSelectedRules(self): + """Handle edit selected rules for the user routing table view.""" + indexes = self.selectedIndex + + if not indexes: + # Nothing selected. Do nothing + return + + if len(indexes) > 1: + # Multiple items selected. Do nothing + return + + routing = self.sourceModel.routingByRow(indexes[0]) + + dialog = RoutingRulesDialog(routing, parent=self) + dialog.finished.connect(lambda _code: self.flushAll()) + dialog.open() + + +class UserRoutingWindow(AppQMainWindow): + """Present the user routing window.""" + + DEFAULT_WINDOW_SIZE = QtCore.QSize(980, 560) + + def __init__(self, *args, **kwargs): + """Initialize the UserRoutingWindow.""" + super().__init__(*args, **kwargs) + + self.setWindowTitle(_('Edit Routing')) + + self.tableView = UserRoutingTableView(parent=self) + self.toolbar = AppQToolBar( + AppQAction( + _('Add'), + icon=bootstrapIcon('plus-lg.svg'), + callback=lambda: self.tableView.appendNewItem(), + ), + AppQAction( + _('Delete'), + icon=bootstrapIcon('dash-lg.svg'), + callback=lambda: self.tableView.deleteSelectedItem(), + ), + parent=self, + ) + self.toolbar.setObjectName('UserRoutingWindow_AppQToolBar') + self.addToolBar(self.toolbar) + + centralWidget = QWidget() + + layout = QVBoxLayout(centralWidget) + layout.addWidget(self.tableView) + + self.setCentralWidget(centralWidget) + + def setWidthAndHeight(self): + """Apply the default size for the user routing window.""" + if AppSettings.get('UserRoutingWindowGeometry') is None: + self.resize(UserRoutingWindow.DEFAULT_WINDOW_SIZE) + else: + if PLATFORM == 'Darwin': + self.resize(UserRoutingWindow.DEFAULT_WINDOW_SIZE) + else: + try: + self.restoreGeometry(AppSettings.get('UserRoutingWindowGeometry')) + except Exception: + # Any non-exit exceptions + + self.resize(UserRoutingWindow.DEFAULT_WINDOW_SIZE) + + try: + self.restoreState(AppSettings.get('UserRoutingWindowState')) + except Exception: + # Any non-exit exceptions + + pass + + def cleanup(self): + """Release resources owned by the user routing window.""" + AppSettings.set('UserRoutingWindowGeometry', self.saveGeometry()) + AppSettings.set('UserRoutingWindowState', self.saveState()) diff --git a/Furious/Plugins/Official/Xray/XrayAssetDownloadManager.py b/Furious/Plugins/Official/Xray/XrayAssetDownloadManager.py new file mode 100644 index 00000000..0006005a --- /dev/null +++ b/Furious/Plugins/Official/Xray/XrayAssetDownloadManager.py @@ -0,0 +1,236 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Provide Qt support for Xray asset download manager.""" + +from __future__ import annotations + +from Furious.Frozenlib import * +from Furious.Qt.WebGETManager import * + +from PySide6 import QtCore + +from typing import AnyStr, Union, Callable + +import os +import logging +import hashlib +import functools + +__all__ = ['XrayAssetDownloadManager'] + +logger = logging.getLogger(__name__) + + +class SHA256Worker(QtCore.QObject, QtCore.QRunnable): + """Run SHA-256 work in the background.""" + + finished = QtCore.Signal(str) + + def __init__(self, string=b''): + # Explictly called __init__ + """Initialize the SHA256Worker.""" + QtCore.QObject.__init__(self) + QtCore.QRunnable.__init__(self) + + self.string = string + + def run(self): + """Run the SHA-256 worker task.""" + self.finished.emit(hashlib.sha256(self.string).hexdigest()) + + +class XrayAssetSHA256DownloadManager(WebGETManager): + """Coordinate Xray asset SHA-256 download operations.""" + + def __init__(self, parent=None, **kwargs): + """Initialize the XrayAssetSHA256DownloadManager.""" + actionMessage = kwargs.pop('actionMessage', 'download sha256') + + super().__init__(parent, actionMessage=actionMessage) + + @staticmethod + def fileContent(filepath, mode='rb') -> AnyStr: + """Return the file content value used by the Xray asset SHA-256 download manager.""" + try: + with open(filepath, mode) as file: + return file.read() + except Exception: + # Any non-exit exceptions + + return b'' + + def successCallback(self, networkReply, **kwargs): + """Handle a successful network operation.""" + filepath = kwargs.pop('filepath', '') + downloadCallback = kwargs.pop('downloadCallback', None) + + data = networkReply.readAll().data() + + if isinstance(data, bytes): + datastr = data.decode('utf-8', 'replace') + else: + datastr = str(data) + + logger.debug(f'data: {data}') + + # 6068a73edfa08b63080b8d362bd4c5b069689a3548f413f43cfa58227a201571 geosite.dat + # 3a12beaa33c81b6751b45833a0a942b9462420d91182e7fa1d768b418e604049 geoip.dat + value = datastr.split()[0] + + basename = os.path.basename(filepath) + + def handleFinished(_digest, _value=''): + """Handle finished.""" + logger.debug( + f'computed digest is \'{_digest}\' while repo digest is \'{_value}\'' + ) + + if _digest != _value: + logger.info(f'digest not equal for {basename}. Start downloading asset') + + if callable(downloadCallback): + downloadCallback() + else: + logger.info(f'digest equal for {basename}. Nothing to do') + + worker = SHA256Worker(self.fileContent(filepath)) + + worker.setAutoDelete(True) + worker.finished.connect(functools.partial(handleFinished, _value=value)) + + AppThreadPool().start(worker) + + def download(self, url, filepath, downloadCallback: Callable[[], None]): + """Download the Xray asset SHA-256 download manager.""" + self.webGET( + url, + filepath=str(filepath), + downloadCallback=downloadCallback, + ) + + +class XrayAssetAssetsDownloadManager(WebGETManager): + """Coordinate Xray asset assets download operations.""" + + def __init__(self, parent=None, **kwargs): + """Initialize the XrayAssetAssetsDownloadManager.""" + actionMessage = kwargs.pop('actionMessage', 'download assets') + + super().__init__(parent, actionMessage=actionMessage) + + def successCallback(self, networkReply, **kwargs): + """Handle a successful network operation.""" + filepath = kwargs.pop('filepath', '') + + saveFile = QtCore.QSaveFile(filepath) + + data = networkReply.readAll().data() + + if not saveFile.open(QtCore.QSaveFile.OpenModeFlag.WriteOnly): + logger.error( + f'failed to open \'{filepath}\' for writing. {saveFile.errorString()}' + ) + else: + saveFile.write(data) + + if not saveFile.commit(): + logger.error( + f'save file to \'{filepath}\' failed. {saveFile.errorString()}' + ) + else: + logger.info(f'save file to \'{filepath}\' success') + + def download(self, url, filepath): + """Download the Xray asset assets download manager.""" + self.webGET(url, filepath=str(filepath)) + + +class XrayAssetPairDownloadHelper: + """Represent Xray asset pair download helper.""" + + def __init__(self, *args, **kwargs): + """Initialize the XrayAssetPairDownloadHelper.""" + sha256ActionMessage = kwargs.pop('sha256ActionMessage', 'download sha256') + assetsActionMessage = kwargs.pop('assetsActionMessage', 'download assets') + + super().__init__(*args, **kwargs) + + self.sha256Downloader = XrayAssetSHA256DownloadManager( + actionMessage=sha256ActionMessage + ) + self.assetsDownloader = XrayAssetAssetsDownloadManager( + actionMessage=assetsActionMessage + ) + + def configureHttpProxy(self, httpProxy: Union[str, None]) -> bool: + """Configure HTTP proxy.""" + return all( + [ + self.sha256Downloader.configureHttpProxy(httpProxy), + self.assetsDownloader.configureHttpProxy(httpProxy), + ] + ) + + def download(self, sha256URL, assetsURL, filepath): + """Download the Xray asset pair download helper.""" + self.sha256Downloader.download( + url=sha256URL, + filepath=filepath, + downloadCallback=functools.partial( + self.assetsDownloader.download, assetsURL, filepath + ), + ) + + +class XrayAssetDownloadManager: + """Coordinate Xray asset download operations.""" + + def __init__(self, *args, **kwargs): + """Initialize the XrayAssetDownloadManager.""" + super().__init__(*args, **kwargs) + + self.downloadHelperGeosite = XrayAssetPairDownloadHelper( + sha256ActionMessage='download geosite sha256', + assetsActionMessage='download geosite assets', + ) + self.downloadHelperGeoip = XrayAssetPairDownloadHelper( + sha256ActionMessage='download geoip sha256', + assetsActionMessage='download geoip assets', + ) + + def configureHttpProxy(self, httpProxy: Union[str, None]) -> bool: + """Configure HTTP proxy.""" + return all( + [ + self.downloadHelperGeosite.configureHttpProxy(httpProxy), + self.downloadHelperGeoip.configureHttpProxy(httpProxy), + ] + ) + + def download(self): + """Download the Xray asset download manager.""" + self.downloadHelperGeosite.download( + sha256URL=URL_GEOSITE_SHA256, + assetsURL=URL_GEOSITE, + filepath=XRAY_ASSET_PATH_GEOSITE, + ) + self.downloadHelperGeoip.download( + sha256URL=URL_GEOIP_SHA256, + assetsURL=URL_GEOIP, + filepath=XRAY_ASSET_PATH_GEOIP, + ) diff --git a/Furious/Plugins/Official/Xray/XrayAssetViewerQListWidget.py b/Furious/Plugins/Official/Xray/XrayAssetViewerQListWidget.py new file mode 100644 index 00000000..aeee7010 --- /dev/null +++ b/Furious/Plugins/Official/Xray/XrayAssetViewerQListWidget.py @@ -0,0 +1,272 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Provide widgets for Xray asset viewer Qt list widget.""" + +from __future__ import annotations + +from Furious.Frozenlib import * +from Furious.Qt import * +from Furious.Qt import gettext as _ + +from PySide6 import QtCore +from PySide6.QtGui import * +from PySide6.QtWidgets import * + +import os +import shutil +import logging +import datetime +import functools + +__all__ = ['XrayAssetViewerQListWidget'] + +logger = logging.getLogger(__name__) + + +class MBoxAssetExists(AppQMessageBox): + """Represent m box asset exists.""" + + def __init__(self, *args, **kwargs): + """Initialize the MBoxAssetExists.""" + super().__init__(*args, **kwargs) + + self.setStandardButtons( + AppQMessageBox.StandardButton.Yes | AppQMessageBox.StandardButton.No + ) + + def retranslate(self): + """Refresh translated text for the m box asset exists.""" + self.setWindowTitle(_(self.windowTitle())) + self.setText(_(self.text())) + + # Ignore informative text, buttons + + self.moveToCenter() + + +class XrayAssetViewerQListWidget(Mixins.ThemeAware, AppQListWidget): + """Provide the Xray asset viewer Qt list widget.""" + + def __init__(self, *args, **kwargs): + """Initialize the XrayAssetViewerQListWidget.""" + super().__init__(*args, **kwargs) + + self.setAlternatingRowColors(True) + + self.setSelectionBehavior(AppQListWidget.SelectionBehavior.SelectRows) + self.setSelectionMode(AppQListWidget.SelectionMode.ExtendedSelection) + self.setIconSize(QtCore.QSize(64, 64)) + + if PLATFORM == 'Linux' and SystemRuntime.ubuntuRelease() == '20.04': + self.initialTheme = APP().theme() + else: + self.initialTheme = None + + self.flushItem() + + logger.info(f'Xray-core asset dir is \'{XRAY_ASSET_DIR}\'') + + contextMenuActions = [ + AppQAction( + _('Delete'), + callback=lambda: self.deleteSelectedItem(), + ), + ] + + self.contextMenu = AppQMenu(*contextMenuActions) + + # Add actions to self in order to activate shortcuts + self.addActions(self.contextMenu.actions()) + + self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) + self.customContextMenuRequested.connect(self.handleCustomContextMenuRequested) + + @QtCore.Slot(QtCore.QPoint) + def handleCustomContextMenuRequested(self, point): + """Handle custom context menu requested.""" + self.contextMenu.exec(self.viewport().mapToGlobal(point)) + + def flushItemByTheme(self, theme: str): + """Refresh item by theme.""" + self.clear() + + maxlen = max( + len(filename) + for filename in os.listdir(XRAY_ASSET_DIR) + if os.path.isfile(XRAY_ASSET_DIR / filename) + ) + + for filename in os.listdir(XRAY_ASSET_DIR): + if os.path.isfile(XRAY_ASSET_DIR / filename): + try: + # Exception may be raised + epoch = os.path.getmtime(XRAY_ASSET_DIR / filename) + except Exception: + # Any non-exit exceptions + + mdate = '' + else: + mdate = datetime.datetime.fromtimestamp(epoch).strftime( + '%Y-%m-%d %H:%M:%S' + ) + + item = QListWidgetItem(f'{filename:{maxlen + 6}}{mdate}') + item.setFont(QFont(AppFontName())) + + if AppSettings.isStateON_('DarkMode'): + # Custom dark mode + item.setIcon(bootstrapIconWhite('file-earmark.svg')) + else: + if theme == 'Dark': + if PLATFORM == 'Windows': + # Windows + if versionToValue(PYSIDE6_VERSION) < versionToValue( + '6.7.0' + ): + # PySide6 < 6.7.0 has no system theme handling on Windows. + # Always use black icon + item.setIcon(bootstrapIcon('file-earmark.svg')) + else: + # PySide6 has system theme handling. + item.setIcon(bootstrapIconWhite('file-earmark.svg')) + else: + item.setIcon(bootstrapIconWhite('file-earmark.svg')) + else: + item.setIcon(bootstrapIcon('file-earmark.svg')) + + self.addItem(item) + + def flushItem(self): + """Refresh item.""" + if PLATFORM == 'Linux' and SystemRuntime.ubuntuRelease() == '20.04': + assert self.initialTheme is not None + + # Ubuntu 20.04. Flush by initial theme(Ubuntu 20.04 theme changes bug) + self.flushItemByTheme(self.initialTheme) + else: + self.flushItemByTheme(APP().theme()) + + def appendNewItem(self, filename: str): + """Append new item.""" + + def append(_filename): + """Append the Xray asset viewer Qt list widget.""" + try: + shutil.copy(_filename, XRAY_ASSET_DIR) + except shutil.SameFileError: + # Same file imported. Do nothing + pass + except Exception as ex: + # Any non-exit exception + + _mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Critical) + _mbox.setWindowTitle(_('Import')) + _mbox.setText(_('Error import asset file')) + _mbox.setInformativeText(str(ex)) + + # Show the MessageBox asynchronously + _mbox.open() + else: + self.flushItem() + + _mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Information) + _mbox.setWindowTitle(_('Import')) + _mbox.setText(_('Import asset file success')) + + # Show the MessageBox asynchronously + _mbox.open() + + basename = os.path.basename(filename) + + if os.path.isfile(XRAY_ASSET_DIR / basename): + + def handleResultCode(_filename, code): + """Handle result code.""" + if code == PySide6Legacy.enumValueWrapper( + AppQMessageBox.StandardButton.Yes + ): + append(_filename) + else: + # Do not overwrite + pass + + mbox = MBoxAssetExists(icon=AppQMessageBox.Icon.Question) + mbox.setWindowTitle(_('Import')) + mbox.setText(_('Asset file already exists. Overwrite?')) + mbox.setInformativeText(basename) + mbox.finished.connect(functools.partial(handleResultCode, filename)) + + # Show the MessageBox asynchronously + mbox.open() + else: + append(filename) + + def deleteSelectedItem(self): + """Delete selected item.""" + indexes = self.selectedIndex + + if len(indexes) == 0: + # Nothing selected + return + + def handleResultCode(_indexes, code): + """Handle result code.""" + if code == PySide6Legacy.enumValueWrapper( + AppQMessageBox.StandardButton.Yes + ): + for index in _indexes: + os.remove(XRAY_ASSET_DIR / self.item(index).text()) + + self.flushItem() + else: + # Do not delete + pass + + if PLATFORM == 'Windows': + # Windows + mbox = MBoxQuestionDelete(icon=AppQMessageBox.Icon.Question) + else: + # macOS & linux + mbox = MBoxQuestionDelete( + icon=AppQMessageBox.Icon.Question, parent=self.parent() + ) + mbox.setWindowModality(QtCore.Qt.WindowModality.WindowModal) + + mbox.isMulti = bool(len(indexes) > 1) + mbox.possibleRemark = f'{self.item(indexes[0]).text()}' + mbox.setText(mbox.customText()) + mbox.finished.connect(functools.partial(handleResultCode, indexes)) + + # Show the MessageBox asynchronously + mbox.open() + + def keyPressEvent(self, event): + """Handle a key press for the Xray asset viewer Qt list widget.""" + if event.key() == QtCore.Qt.Key.Key_Delete: + self.deleteSelectedItem() + else: + super().keyPressEvent(event) + + def themeChangedCallback(self, theme): + """Update the Xray asset viewer Qt list widget for a theme change.""" + if PLATFORM == 'Linux' and SystemRuntime.ubuntuRelease() == '20.04': + # Ubuntu 20.04 system dark theme does not + # change menu color. Do nothing + pass + else: + self.flushItemByTheme(theme) diff --git a/Furious/Plugins/Official/Xray/XrayAssetViewerWindow.py b/Furious/Plugins/Official/Xray/XrayAssetViewerWindow.py new file mode 100644 index 00000000..c1f16dea --- /dev/null +++ b/Furious/Plugins/Official/Xray/XrayAssetViewerWindow.py @@ -0,0 +1,113 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Provide the application window for Xray asset viewer window.""" + +from __future__ import annotations + +from Furious.Frozenlib import * +from Furious.Qt import * +from Furious.Qt import gettext as _ +from .XrayAssetViewerQListWidget import * + +from PySide6.QtGui import * +from PySide6.QtWidgets import * + +import logging + +__all__ = ['XrayAssetViewerWindow'] + +logger = logging.getLogger(__name__) + + +class XrayAssetViewerWindow(AppQMainWindow): + """Present the Xray asset viewer window.""" + + def __init__(self, *args, **kwargs): + """Initialize the XrayAssetViewerWindow.""" + super().__init__(*args, **kwargs) + + self.setWindowTitle(_('Xray-core Asset File')) + + self.xrayAssetViewerWidget = XrayAssetViewerQListWidget(parent=self) + self.setCentralWidget(self.xrayAssetViewerWidget) + + if versionToValue(PYSIDE6_VERSION) <= versionToValue('6.1.3'): + openAssetDirectoryActions = [] + else: + # openUrl will crash on PySide6 6.1.3, probably a Qt bug + openAssetDirectoryActions = [ + AppQAction( + _('Open Asset Directory'), + callback=lambda: self.openAssetDirectory(), + shortcut=QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier.ControlModifier, + QtCore.Qt.Key.Key_O, + ), + ), + ] + + self.fileMenu = AppQMenu( + AppQAction( + _('Refresh'), + callback=lambda: self.flushItem(), + shortcut=QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier.ControlModifier, + QtCore.Qt.Key.Key_R, + ), + ), + AppQSeperator(), + *openAssetDirectoryActions, + AppQAction( + _('Import From File...'), + callback=lambda: self.appendNewItem(), + ), + AppQSeperator(), + AppQAction( + _('Exit'), + callback=lambda: self.close(), + ), + title=_('File'), + parent=self.menuBar(), + ) + + self.menuBar().addMenu(self.fileMenu) + + def setWidthAndHeight(self): + """Apply the default size for the Xray asset viewer window.""" + self.resize(360, 360 * GOLDEN_RATIO) + + def flushItem(self): + """Refresh item.""" + self.xrayAssetViewerWidget.flushItem() + + @staticmethod + def openAssetDirectory(): + """Open asset directory.""" + if QDesktopServices.openUrl(QtCore.QUrl.fromLocalFile(XRAY_ASSET_DIR)): + logger.info(f'open Xray-core asset dir success') + else: + logger.error(f'open Xray-core asset dir failed') + + def appendNewItem(self): + """Append new item.""" + filename, selectedFilter = QFileDialog.getOpenFileName( + None, _('Import File'), filter=_('All files (*)') + ) + + if filename: + self.xrayAssetViewerWidget.appendNewItem(filename) diff --git a/Furious/Plugins/Official/Xray/__init__.py b/Furious/Plugins/Official/Xray/__init__.py new file mode 100644 index 00000000..e4e7239d --- /dev/null +++ b/Furious/Plugins/Official/Xray/__init__.py @@ -0,0 +1,5 @@ +"""Expose the official Xray plugin implementation.""" + +from __future__ import annotations + +from .Plugin import * diff --git a/Furious/Plugins/Official/__init__.py b/Furious/Plugins/Official/__init__.py new file mode 100644 index 00000000..f3deb4f9 --- /dev/null +++ b/Furious/Plugins/Official/__init__.py @@ -0,0 +1,16 @@ +"""Register the core plugins maintained with Furious itself.""" + +from __future__ import annotations + +__all__ = ['registerOfficialPlugins'] + + +def registerOfficialPlugins(registry): + """Register the bundled Xray and Hysteria plugins.""" + from .Xray.Plugin import XrayPlugin + from .Hysteria1.Plugin import Hysteria1Plugin + from .Hysteria2.Plugin import Hysteria2Plugin + + registry.register(XrayPlugin()) + registry.register(Hysteria1Plugin()) + registry.register(Hysteria2Plugin()) diff --git a/Furious/Plugins/Registry.py b/Furious/Plugins/Registry.py new file mode 100644 index 00000000..5037240a --- /dev/null +++ b/Furious/Plugins/Registry.py @@ -0,0 +1,350 @@ +"""Register official plugins and discover third-party Furious plugins.""" + +from __future__ import annotations + +from importlib import metadata + +import logging +import threading + +from .API import PLUGIN_API_VERSION, FuriousPlugin, PluginProtocol + +__all__ = [ + 'PLUGIN_ENTRY_POINT_GROUP', + 'PluginRegistry', + 'getPluginRegistry', + 'registerPlugin', +] + +PLUGIN_ENTRY_POINT_GROUP = 'furious.plugins' + +logger = logging.getLogger(__name__) + + +def _normalizeProtocol(protocol) -> str: + """Return a case-insensitive protocol identifier.""" + value = getattr(protocol, 'value', protocol) + + return str(value).strip().casefold() + + +class PluginRegistry: + """Store plugins and route host operations to the owning plugin.""" + + def __init__(self): + """Initialize an empty plugin registry.""" + self._plugins = {} + self._protocols = {} + self._configurationTypes = {} + self._coreTypes = {} + + def register(self, plugin: FuriousPlugin): + """Register a plugin after validating all of its public identifiers.""" + if isinstance(plugin, type) and issubclass(plugin, FuriousPlugin): + plugin = plugin() + + if not isinstance(plugin, FuriousPlugin): + raise TypeError('plugin must be a FuriousPlugin instance') + if plugin.apiVersion != PLUGIN_API_VERSION: + raise ValueError( + f'plugin API {plugin.apiVersion!r} is not supported; ' + f'expected {PLUGIN_API_VERSION}' + ) + + pluginId = str(plugin.pluginId).strip() + if not pluginId: + raise ValueError('plugin ID cannot be empty') + if pluginId in self._plugins: + raise ValueError(f'plugin {pluginId!r} is already registered') + + protocolKeys = [] + for descriptor in plugin.protocols: + if not isinstance(descriptor, PluginProtocol): + raise TypeError('plugin protocols must contain PluginProtocol values') + + key = _normalizeProtocol(descriptor.id) + if not key: + raise ValueError('plugin protocol ID cannot be empty') + if key in self._protocols or key in protocolKeys: + raise ValueError(f'protocol {descriptor.id!r} is already registered') + + protocolKeys.append(key) + + configurationTypes = [] + for configType in plugin.configurationTypes: + if not isinstance(configType, type): + raise TypeError('plugin configuration types must be classes') + if any( + issubclass(configType, registeredType) + or issubclass(registeredType, configType) + for registeredType in ( + *self._configurationTypes, + *configurationTypes, + ) + ): + raise ValueError( + f'configuration type {configType.__name__!r} overlaps a registered type' + ) + configurationTypes.append(configType) + + coreTypes = [] + for coreType in plugin.coreTypes: + if not isinstance(coreType, type): + raise TypeError('plugin core types must be classes') + if any( + issubclass(coreType, registeredType) + or issubclass(registeredType, coreType) + for registeredType in ( + *self._coreTypes, + *coreTypes, + ) + ): + raise ValueError( + f'core type {coreType.__name__!r} overlaps a registered type' + ) + coreTypes.append(coreType) + + self._plugins[pluginId] = plugin + + for key, descriptor in zip(protocolKeys, plugin.protocols): + self._protocols[key] = (plugin, descriptor) + for configType in plugin.configurationTypes: + self._configurationTypes[configType] = plugin + for coreType in plugin.coreTypes: + self._coreTypes[coreType] = plugin + + logger.info(f'registered plugin {pluginId!r}') + + return plugin + + def plugins(self): + """Return registered plugins in registration order.""" + return tuple(self._plugins.values()) + + def protocolDescriptors(self): + """Return contributed protocols in their requested menu order.""" + descriptors = list( + descriptor for _plugin, descriptor in self._protocols.values() + ) + + return tuple(sorted(descriptors, key=lambda descriptor: descriptor.menuOrder)) + + def pluginForProtocol(self, protocol): + """Return the plugin that owns a protocol identifier.""" + entry = self._protocols.get(_normalizeProtocol(protocol)) + + return entry[0] if entry is not None else None + + def pluginForConfig(self, config): + """Return the plugin that owns a configuration instance.""" + for configType, plugin in self._configurationTypes.items(): + if isinstance(config, configType): + return plugin + + return None + + def pluginForCore(self, core): + """Return the plugin that owns a running core instance.""" + for coreType, plugin in self._coreTypes.items(): + if isinstance(core, coreType): + return plugin + + return None + + def configFromString(self, config: str, **kwargs): + """Ask plugins to parse textual configuration data.""" + for plugin in self.plugins(): + factory = plugin.configFromString(config, **kwargs) + if factory is not None: + return factory + + return None + + def configFromDict(self, config: dict, **kwargs): + """Ask plugins to parse configuration mapping data.""" + for plugin in self.plugins(): + factory = plugin.configFromDict(config, **kwargs) + if factory is not None: + return factory + + return None + + def blankConfig(self, protocol, **kwargs): + """Construct a blank configuration through the owning plugin.""" + plugin = self.pluginForProtocol(protocol) + + return plugin.blankConfig(protocol, **kwargs) if plugin is not None else None + + def createEditorForProtocol(self, protocol, parent=None, **kwargs): + """Construct a protocol editor through the owning plugin.""" + plugin = self.pluginForProtocol(protocol) + if plugin is None: + return None + + return plugin.createEditorForProtocol(protocol, parent=parent, **kwargs) + + def createEditorForConfig(self, config, parent=None, **kwargs): + """Construct a configuration editor through the owning plugin.""" + plugin = self.pluginForConfig(config) + if plugin is None: + return None + + return plugin.createEditorForConfig(config, parent=parent, **kwargs) + + def routingEditors(self, parent=None, **kwargs): + """Create every optional plugin-specific routing editor.""" + editors = [] + for plugin in self.plugins(): + try: + editor = plugin.createRoutingEditor(parent=parent, **kwargs) + if editor is not None: + editors.append((plugin, editor)) + except Exception as ex: + logger.error( + f'failed to create routing editor for {plugin.pluginId!r}: {ex}' + ) + + return tuple(editors) + + def mainWindowTools(self, parent=None, **kwargs): + """Return Tools-menu actions contributed by registered plugins.""" + actions = [] + for plugin in self.plugins(): + try: + actions.extend(plugin.createMainWindowTools(parent=parent, **kwargs)) + except Exception as ex: + logger.error( + f'failed to create main-window tools for {plugin.pluginId!r}: {ex}' + ) + + return tuple(actions) + + def routingChoices(self): + """Return unique routing choices contributed by registered plugins.""" + choices = [] + values = set() + for plugin in self.plugins(): + try: + for label, value in plugin.routingChoices(): + if value not in values: + choices.append((label, value)) + values.add(value) + except Exception as ex: + logger.error( + f'failed to obtain routing choices for {plugin.pluginId!r}: {ex}' + ) + + return tuple(choices) + + def configureEnvironment(self): + """Allow every plugin to configure its core process environment.""" + for plugin in self.plugins(): + try: + plugin.configureEnvironment() + except Exception as ex: + logger.error(f'environment hook failed for {plugin.pluginId!r}: {ex}') + + def coreVersions(self): + """Return version strings reported by every registered plugin core.""" + versions = [] + for plugin in self.plugins(): + try: + versions.extend(plugin.coreVersions()) + except Exception as ex: + logger.error( + f'failed to obtain core versions for {plugin.pluginId!r}: {ex}' + ) + + return tuple(filter(None, versions)) + + def logTimestampPatterns(self): + """Return timestamp expressions contributed by registered plugins.""" + patterns = [] + for plugin in self.plugins(): + try: + patterns.extend(plugin.logTimestampPatterns()) + except Exception as ex: + logger.error( + f'failed to obtain log patterns for {plugin.pluginId!r}: {ex}' + ) + + return tuple(filter(None, patterns)) + + def coreExitMessage(self, core, exitcode: int): + """Return the owning plugin's special exit message, if any.""" + plugin = self.pluginForCore(core) + if plugin is None: + return None + + try: + return plugin.coreExitMessage(core, exitcode) + except Exception as ex: + logger.error(f'failed to interpret core exit for {plugin.pluginId!r}: {ex}') + + return None + + def afterConnected(self, httpProxy=None): + """Notify every registered plugin after a connection succeeds.""" + for plugin in self.plugins(): + try: + plugin.afterConnected(httpProxy) + except Exception as ex: + logger.error( + f'post-connection hook failed for {plugin.pluginId!r}: {ex}' + ) + + def discover(self): + """Load third-party plugins exposed through Python entry points.""" + try: + entryPoints = metadata.entry_points() + if hasattr(entryPoints, 'select'): + entryPoints = entryPoints.select(group=PLUGIN_ENTRY_POINT_GROUP) + else: + entryPoints = entryPoints.get(PLUGIN_ENTRY_POINT_GROUP, tuple()) + except Exception as ex: + logger.error(f'failed to enumerate Furious plugins: {ex}') + + return + + for entryPoint in entryPoints: + try: + plugin = entryPoint.load() + if isinstance(plugin, type) and issubclass(plugin, FuriousPlugin): + plugin = plugin() + elif callable(plugin) and not isinstance(plugin, FuriousPlugin): + plugin = plugin() + + if isinstance(plugin, (tuple, list)): + for item in plugin: + self.register(item) + else: + self.register(plugin) + except Exception as ex: + logger.error(f'failed to load plugin {entryPoint.name!r}: {ex}') + + +_registry = PluginRegistry() +_registryLock = threading.RLock() +_registryInitialized = False + + +def getPluginRegistry() -> PluginRegistry: + """Return the process-wide registry after lazy official/external discovery.""" + global _registry, _registryInitialized + + with _registryLock: + if not _registryInitialized: + from .Official import registerOfficialPlugins + + registry = PluginRegistry() + registerOfficialPlugins(registry) + registry.discover() + _registry = registry + _registryInitialized = True + + return _registry + + +def registerPlugin(plugin: FuriousPlugin): + """Register a plugin programmatically and return it.""" + return getPluginRegistry().register(plugin) diff --git a/Furious/Plugins/__init__.py b/Furious/Plugins/__init__.py new file mode 100644 index 00000000..06fbd62f --- /dev/null +++ b/Furious/Plugins/__init__.py @@ -0,0 +1,7 @@ +"""Expose the Furious plugin API and process-wide plugin registry.""" + +from __future__ import annotations + +from .API import * +from .Configuration import * +from .Registry import * diff --git a/Furious/Qt/TextEditorTheme.py b/Furious/Qt/TextEditorTheme.py index 00857270..1e31653f 100644 --- a/Furious/Qt/TextEditorTheme.py +++ b/Furious/Qt/TextEditorTheme.py @@ -19,7 +19,8 @@ from __future__ import annotations -from Furious.Core import * +from Furious.Core.Tun2socks import Tun2socks +from Furious.Plugins import getPluginRegistry from PySide6 import QtCore from PySide6.QtGui import * @@ -111,9 +112,7 @@ class AppQSyntaxHighlighter(QSyntaxHighlighter): shouldHighlight = True for version in [ - XrayCore.version(), - Hysteria1.version(), - Hysteria2.version(), + *getPluginRegistry().coreVersions(), Tun2socks.version(), ]: if captured == version: @@ -197,10 +196,8 @@ class DraculaLoggerSyntaxHighlighter(AppQSyntaxHighlighter): EditorHighlightRules( # Application logging timestamp r'\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3}\]' + r'|' - # Xray-core logging timestamp - + r'\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2}.\d{6}' + r'|' - # hysteria2 logging timestamp - + r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})' + r'|' + # Plugin-core logging timestamps + + r'|'.join(getPluginRegistry().logTimestampPatterns()) + r'|' # tun2socks logging timestamp + r'\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2}', '#7F7F7F', diff --git a/Furious/Qt/XrayAssetDownloadManager.py b/Furious/Qt/XrayAssetDownloadManager.py index 0006005a..19ee6362 100644 --- a/Furious/Qt/XrayAssetDownloadManager.py +++ b/Furious/Qt/XrayAssetDownloadManager.py @@ -1,236 +1,5 @@ -# Copyright (C) 2024–present Loren Eteval & contributors -# -# This file is part of Furious. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Provide Qt support for Xray asset download manager.""" +"""Provide compatibility imports for the official Xray asset updater.""" from __future__ import annotations -from Furious.Frozenlib import * -from Furious.Qt.WebGETManager import * - -from PySide6 import QtCore - -from typing import AnyStr, Union, Callable - -import os -import logging -import hashlib -import functools - -__all__ = ['XrayAssetDownloadManager'] - -logger = logging.getLogger(__name__) - - -class SHA256Worker(QtCore.QObject, QtCore.QRunnable): - """Run SHA-256 work in the background.""" - - finished = QtCore.Signal(str) - - def __init__(self, string=b''): - # Explictly called __init__ - """Initialize the SHA256Worker.""" - QtCore.QObject.__init__(self) - QtCore.QRunnable.__init__(self) - - self.string = string - - def run(self): - """Run the SHA-256 worker task.""" - self.finished.emit(hashlib.sha256(self.string).hexdigest()) - - -class XrayAssetSHA256DownloadManager(WebGETManager): - """Coordinate Xray asset SHA-256 download operations.""" - - def __init__(self, parent=None, **kwargs): - """Initialize the XrayAssetSHA256DownloadManager.""" - actionMessage = kwargs.pop('actionMessage', 'download sha256') - - super().__init__(parent, actionMessage=actionMessage) - - @staticmethod - def fileContent(filepath, mode='rb') -> AnyStr: - """Return the file content value used by the Xray asset SHA-256 download manager.""" - try: - with open(filepath, mode) as file: - return file.read() - except Exception: - # Any non-exit exceptions - - return b'' - - def successCallback(self, networkReply, **kwargs): - """Handle a successful network operation.""" - filepath = kwargs.pop('filepath', '') - downloadCallback = kwargs.pop('downloadCallback', None) - - data = networkReply.readAll().data() - - if isinstance(data, bytes): - datastr = data.decode('utf-8', 'replace') - else: - datastr = str(data) - - logger.debug(f'data: {data}') - - # 6068a73edfa08b63080b8d362bd4c5b069689a3548f413f43cfa58227a201571 geosite.dat - # 3a12beaa33c81b6751b45833a0a942b9462420d91182e7fa1d768b418e604049 geoip.dat - value = datastr.split()[0] - - basename = os.path.basename(filepath) - - def handleFinished(_digest, _value=''): - """Handle finished.""" - logger.debug( - f'computed digest is \'{_digest}\' while repo digest is \'{_value}\'' - ) - - if _digest != _value: - logger.info(f'digest not equal for {basename}. Start downloading asset') - - if callable(downloadCallback): - downloadCallback() - else: - logger.info(f'digest equal for {basename}. Nothing to do') - - worker = SHA256Worker(self.fileContent(filepath)) - - worker.setAutoDelete(True) - worker.finished.connect(functools.partial(handleFinished, _value=value)) - - AppThreadPool().start(worker) - - def download(self, url, filepath, downloadCallback: Callable[[], None]): - """Download the Xray asset SHA-256 download manager.""" - self.webGET( - url, - filepath=str(filepath), - downloadCallback=downloadCallback, - ) - - -class XrayAssetAssetsDownloadManager(WebGETManager): - """Coordinate Xray asset assets download operations.""" - - def __init__(self, parent=None, **kwargs): - """Initialize the XrayAssetAssetsDownloadManager.""" - actionMessage = kwargs.pop('actionMessage', 'download assets') - - super().__init__(parent, actionMessage=actionMessage) - - def successCallback(self, networkReply, **kwargs): - """Handle a successful network operation.""" - filepath = kwargs.pop('filepath', '') - - saveFile = QtCore.QSaveFile(filepath) - - data = networkReply.readAll().data() - - if not saveFile.open(QtCore.QSaveFile.OpenModeFlag.WriteOnly): - logger.error( - f'failed to open \'{filepath}\' for writing. {saveFile.errorString()}' - ) - else: - saveFile.write(data) - - if not saveFile.commit(): - logger.error( - f'save file to \'{filepath}\' failed. {saveFile.errorString()}' - ) - else: - logger.info(f'save file to \'{filepath}\' success') - - def download(self, url, filepath): - """Download the Xray asset assets download manager.""" - self.webGET(url, filepath=str(filepath)) - - -class XrayAssetPairDownloadHelper: - """Represent Xray asset pair download helper.""" - - def __init__(self, *args, **kwargs): - """Initialize the XrayAssetPairDownloadHelper.""" - sha256ActionMessage = kwargs.pop('sha256ActionMessage', 'download sha256') - assetsActionMessage = kwargs.pop('assetsActionMessage', 'download assets') - - super().__init__(*args, **kwargs) - - self.sha256Downloader = XrayAssetSHA256DownloadManager( - actionMessage=sha256ActionMessage - ) - self.assetsDownloader = XrayAssetAssetsDownloadManager( - actionMessage=assetsActionMessage - ) - - def configureHttpProxy(self, httpProxy: Union[str, None]) -> bool: - """Configure HTTP proxy.""" - return all( - [ - self.sha256Downloader.configureHttpProxy(httpProxy), - self.assetsDownloader.configureHttpProxy(httpProxy), - ] - ) - - def download(self, sha256URL, assetsURL, filepath): - """Download the Xray asset pair download helper.""" - self.sha256Downloader.download( - url=sha256URL, - filepath=filepath, - downloadCallback=functools.partial( - self.assetsDownloader.download, assetsURL, filepath - ), - ) - - -class XrayAssetDownloadManager: - """Coordinate Xray asset download operations.""" - - def __init__(self, *args, **kwargs): - """Initialize the XrayAssetDownloadManager.""" - super().__init__(*args, **kwargs) - - self.downloadHelperGeosite = XrayAssetPairDownloadHelper( - sha256ActionMessage='download geosite sha256', - assetsActionMessage='download geosite assets', - ) - self.downloadHelperGeoip = XrayAssetPairDownloadHelper( - sha256ActionMessage='download geoip sha256', - assetsActionMessage='download geoip assets', - ) - - def configureHttpProxy(self, httpProxy: Union[str, None]) -> bool: - """Configure HTTP proxy.""" - return all( - [ - self.downloadHelperGeosite.configureHttpProxy(httpProxy), - self.downloadHelperGeoip.configureHttpProxy(httpProxy), - ] - ) - - def download(self): - """Download the Xray asset download manager.""" - self.downloadHelperGeosite.download( - sha256URL=URL_GEOSITE_SHA256, - assetsURL=URL_GEOSITE, - filepath=XRAY_ASSET_PATH_GEOSITE, - ) - self.downloadHelperGeoip.download( - sha256URL=URL_GEOIP_SHA256, - assetsURL=URL_GEOIP, - filepath=XRAY_ASSET_PATH_GEOIP, - ) +from Furious.Plugins.Official.Xray.XrayAssetDownloadManager import * diff --git a/Furious/Qt/__init__.py b/Furious/Qt/__init__.py index 1195869e..9275004a 100644 --- a/Furious/Qt/__init__.py +++ b/Furious/Qt/__init__.py @@ -32,4 +32,3 @@ from .TextEditor import * from .TextEditorTheme import * from .UpdatesManager import * from .WebGETManager import * -from .XrayAssetDownloadManager import * diff --git a/Furious/TrayActions/Connect.py b/Furious/TrayActions/Connect.py index 8a548386..941a7b1d 100644 --- a/Furious/TrayActions/Connect.py +++ b/Furious/TrayActions/Connect.py @@ -22,9 +22,10 @@ from __future__ import annotations from Furious.Frozenlib import * from Furious.Interface import * from Furious.Library import * +from Furious.Plugins import getPluginRegistry from Furious.Qt import * from Furious.Qt import gettext as _ -from Furious.Core import * +from Furious.Core.CoreManager import CoreManager from Furious.Widget.ConnectProgressBar import ConnectProgressBar from PySide6 import QtCore @@ -75,7 +76,6 @@ class ConnectAction(AppQAction): self.actionTimer.timeout.connect(lambda: self.callActionFromQueue()) self.updatesManager = UpdatesManager() - self.assetDownloadManager = XrayAssetDownloadManager() def reset(self): """Restore the connect action to its initial state.""" @@ -340,19 +340,7 @@ class ConnectAction(AppQAction): hasNewVersionCallback=newVersionCallback, ) - if not SystemRuntime.isAssetsFolderWritable(): - logger.info( - f'skipped auto assets update due to assets folder \'{XRAY_ASSET_DIR}\' not writable' - ) - else: - logger.info(f'assets folder \'{XRAY_ASSET_DIR}\' is writable. Continue') - - if AppSettings.isStateON_('AutoUpdateAssetFiles'): - # Automatically update assets - self.assetDownloadManager.configureHttpProxy(connectedHttpProxy) - self.assetDownloadManager.download() - else: - logger.info('skipped auto assets update due to settings') + getPluginRegistry().afterConnected(connectedHttpProxy) def callActionFromQueue(self): """Call action from queue.""" @@ -406,16 +394,16 @@ class ConnectAction(AppQAction): return None - if isinstance(core, Hysteria1): - if exitcode == Hysteria1.ExitCode.RemoteNetworkError.value: - putItem( - functools.partial( - self.doDisconnectWithTrayMessage, - f'{core.name()}: ' + _('Connection to server has been lost'), - ) + pluginMessage = getPluginRegistry().coreExitMessage(core, exitcode) + if pluginMessage: + putItem( + functools.partial( + self.doDisconnectWithTrayMessage, + f'{core.name()}: ' + _(pluginMessage), ) + ) - return None + return None putItem( functools.partial( diff --git a/Furious/TrayActions/Routing.py b/Furious/TrayActions/Routing.py index 1f24dad4..0115f258 100644 --- a/Furious/TrayActions/Routing.py +++ b/Furious/TrayActions/Routing.py @@ -21,6 +21,7 @@ from __future__ import annotations from Furious.Frozenlib import * from Furious.Library import * +from Furious.Plugins import getPluginRegistry from Furious.Qt import * from Furious.Qt import gettext as _ @@ -86,16 +87,12 @@ class RoutingAction(AppQAction): ) customActions = list() - - for unique, routing in Storage.UserRoutings().items(): - if not routing.get('enabled', True): - continue - + for label, value in getPluginRegistry().routingChoices(): action = RoutingChildAction( - routing.get('remark', ''), - routingValue=f'Custom:{unique}', + label, + routingValue=value, checkable=True, - checked=AppSettings.get('Routing') == f'Custom:{unique}', + checked=AppSettings.get('Routing') == value, ) customActions.append(action) diff --git a/Furious/Widget/Application.py b/Furious/Widget/Application.py index 6ac88bc0..76679d06 100644 --- a/Furious/Widget/Application.py +++ b/Furious/Widget/Application.py @@ -294,9 +294,10 @@ class Application(ApplicationFactory, SingletonApplication): @staticmethod def addEnviron(): - # Xray environment variables """Add environ.""" - os.environ['XRAY_LOCATION_ASSET'] = str(XRAY_ASSET_DIR) + from Furious.Plugins import getPluginRegistry + + getPluginRegistry().configureEnvironment() if SystemRuntime.flatpakID(): # https://github.com/flatpak/flatpak/issues/3438 diff --git a/Furious/Widget/GuiHysteria1.py b/Furious/Widget/GuiHysteria1.py index b2b75130..88777528 100644 --- a/Furious/Widget/GuiHysteria1.py +++ b/Furious/Widget/GuiHysteria1.py @@ -1,386 +1,5 @@ -# Copyright (C) 2024–present Loren Eteval & contributors -# -# This file is part of Furious. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Provide widgets for GUI hysteria1.""" +"""Provide compatibility imports for the official Hysteria 1 editor.""" from __future__ import annotations -from Furious.Frozenlib import * -from Furious.Interface import * -from Furious.Qt import * -from Furious.Qt import gettext as _ - -from PySide6 import QtCore -from PySide6.QtGui import * -from PySide6.QtWidgets import * - -import logging -import functools - -__all__ = ['GuiHysteria1'] - -logger = logging.getLogger(__name__) - - -class GuiHy1ItemTextInput(GuiEditorItemTextInput): - """Represent GUI hy1 item text input.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiHy1ItemTextInput.""" - key = kwargs.pop('key', '') - - super().__init__(*args, **kwargs) - - self.key = key - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - oldValue = config.get(self.key, '') - newValue = self.text() - - if isinstance(oldValue, str): - if newValue != oldValue: - if newValue == '': - # Remove field - config.pop(self.key, None) - else: - config[self.key] = newValue - - return True - else: - return False - else: - config[self.key] = newValue - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - self.setText(config.get(self.key, '')) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiHy1ItemBasicProtocol(GuiEditorItemTextComboBox): - """Represent GUI hy1 item basic protocol.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiHy1ItemBasicProtocol.""" - super().__init__(*args, **kwargs) - - self.addItems(['', 'udp', 'wechat-video', 'faketcp']) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - oldProtocol = config.get('protocol', '') - newProtocol = self.text() - - if isinstance(oldProtocol, str): - if newProtocol != oldProtocol: - config['protocol'] = newProtocol - - return True - else: - return False - else: - config['protocol'] = newProtocol - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - self.setText(config.get('protocol', 'udp')) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiHy1ItemSpeedUpMbps(GuiEditorItemTextSpinBox): - """Represent GUI hy1 item speed up mbps.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiHy1ItemSpeedUpMbps.""" - super().__init__(*args, **kwargs) - - # Range - self.setRange(0, 1048576) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - oldUpMbps = config.get('up_mbps') - newUpMbps = self.value() - - if isinstance(oldUpMbps, int): - if newUpMbps != oldUpMbps: - config['up_mbps'] = newUpMbps - - return True - else: - return False - else: - config['up_mbps'] = newUpMbps - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - self.setValue(config.get('up_mbps')) - except Exception: - # Any non-exit exceptions - - self.setValue(24) - - -class GuiHy1ItemSpeedDownMbps(GuiEditorItemTextSpinBox): - """Represent GUI hy1 item speed down mbps.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiHy1ItemSpeedDownMbps.""" - super().__init__(*args, **kwargs) - - # Range - self.setRange(0, 1048576) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - oldDownMbps = config.get('down_mbps') - newDownMbps = self.value() - - if isinstance(oldDownMbps, int): - if newDownMbps != oldDownMbps: - config['down_mbps'] = newDownMbps - - return True - else: - return False - else: - config['down_mbps'] = newDownMbps - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - self.setValue(config.get('down_mbps')) - except Exception: - # Any non-exit exceptions - - self.setValue(96) - - -class GuiHy1ItemTLSInsecure(GuiEditorItemTextCheckBox): - """Represent GUI hy1 item TLS insecure.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiHy1ItemTLSInsecure.""" - super().__init__(*args, **kwargs) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - try: - oldChecked = config.get('insecure') - except Exception: - # Any non-exit exceptions - - oldChecked = False - - newChecked = self.isChecked() - - if newChecked: - if oldChecked is not True: - config['insecure'] = True - - return True - else: - return False - else: - if oldChecked is True: - config['insecure'] = False - - return True - if oldChecked is False: - return False - - # Invalid value for insecure. Remove it - config.pop('insecure', None) - - # Modified silently - return False - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - checked = config['insecure'] - except Exception: - # Any non-exit exceptions - - checked = False - - if not isinstance(checked, bool): - checked = False - - self.setChecked(checked) - - -class GuiHy1ProjectWebsiteURL(AppQLabel): - """Represent GUI hy1 project website URL.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiHy1ProjectWebsiteURL.""" - super().__init__(*args, **kwargs) - - self.setWebsiteURL() - self.linkActivated.connect(self.handleLinkActivated) - - def setWebsiteURL(self): - """Set website URL.""" - self.setText( - '

' - '' - '' - + _('Project Website') - + '

' - ) - - @staticmethod - def handleLinkActivated(link: str): - """Handle link activated.""" - if QDesktopServices.openUrl(QtCore.QUrl(link)): - logger.info(f'open link \'{link}\' success') - else: - logger.error(f'open link \'{link}\' failed') - - def retranslate(self): - """Refresh translated text for the GUI hy1 project website URL.""" - self.setWebsiteURL() - - -class GuiHy1GroupBoxBasic(GuiEditorWidgetQGroupBox): - """Represent GUI hy1 group box basic.""" - - def __init__(self, **kwargs): - """Initialize the GuiHy1GroupBoxBasic.""" - super().__init__(_('Basic Configuration'), **kwargs) - - def containerSequence(self): - """Return the editor item containers in display order.""" - return [ - GuiEditorItemBasicRemark(title=_('Remark')), - GuiHy1ItemTextInput(title=_('Server'), key='server'), - GuiHy1ItemBasicProtocol(title=_('Protocol')), - GuiHy1ItemTextInput(title='auth_str', translatable=False, key='auth_str'), - GuiHy1ItemTextInput(title='obfs', translatable=False, key='obfs'), - ] - - -class GuiHy1GroupBoxProxy(GuiEditorWidgetQGroupBox): - """Represent GUI hy1 group box proxy.""" - - def __init__(self, **kwargs): - """Initialize the GuiHy1GroupBoxProxy.""" - super().__init__(_('Proxy'), **kwargs) - - def containerSequence(self): - """Return the editor item containers in display order.""" - return [ - GuiEditorItemProxyHttp(title='http', translatable=False), - GuiEditorItemProxySocks(title='socks', translatable=False), - ] - - -class GuiHy1GroupBoxSpeed(GuiEditorWidgetQGroupBox): - """Represent GUI hy1 group box speed.""" - - def __init__(self, **kwargs): - """Initialize the GuiHy1GroupBoxSpeed.""" - super().__init__(_('Speed'), **kwargs) - - def containerSequence(self): - """Return the editor item containers in display order.""" - return [ - GuiHy1ItemSpeedUpMbps(title='up_mbps', translatable=False), - GuiHy1ItemSpeedDownMbps(title='down_mbps', translatable=False), - ] - - -class GuiHy1GroupBoxTLS(GuiEditorWidgetQGroupBox): - """Represent GUI hy1 group box TLS.""" - - def __init__(self, **kwargs): - """Initialize the GuiHy1GroupBoxTLS.""" - super().__init__('TLS', **kwargs) - - self.translatable = False - - def containerSequence(self): - """Return the editor item containers in display order.""" - return [ - GuiHy1ItemTextInput(title='sni', translatable=False, key='server_name'), - GuiHy1ItemTextInput(title='alpn', translatable=False, key='alpn'), - GuiHy1ItemTextInput(title='ca', translatable=False, key='ca'), - GuiHy1ItemTLSInsecure(title='insecure', translatable=False), - ] - - -class GuiHy1GroupBoxOther(GuiEditorItemFactory, AppQGroupBox): - """Represent GUI hy1 group box other.""" - - def __init__(self, **kwargs): - """Initialize the GuiHy1GroupBoxOther.""" - super().__init__(_('Other'), **kwargs) - - self._website = GuiHy1ProjectWebsiteURL() - - layout = QFormLayout() - layout.addRow(self._website) - - self.setLayout(layout) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - return False - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - pass - - -class GuiHysteria1(GuiEditorWidgetQDialog): - """Represent GUI hysteria1.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiHysteria1.""" - super().__init__(*args, **kwargs) - - self.setTabText(Protocol.Hysteria1.value) - - @functools.lru_cache(None) - def groupBoxSequence(self): - """Return the configuration group boxes in display order.""" - return [ - GuiHy1GroupBoxBasic(), - GuiHy1GroupBoxProxy(), - GuiHy1GroupBoxSpeed(), - GuiHy1GroupBoxTLS(), - # GuiHy1GroupBoxOther(), - ] +from Furious.Plugins.Official.Hysteria1.GuiHysteria1 import * diff --git a/Furious/Widget/GuiHysteria2.py b/Furious/Widget/GuiHysteria2.py index 76d95bc9..d1559721 100644 --- a/Furious/Widget/GuiHysteria2.py +++ b/Furious/Widget/GuiHysteria2.py @@ -1,891 +1,5 @@ -# Copyright (C) 2024–present Loren Eteval & contributors -# -# This file is part of Furious. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Provide widgets for GUI hysteria2.""" +"""Provide compatibility imports for the official Hysteria 2 editor.""" from __future__ import annotations -from Furious.Frozenlib import * -from Furious.Interface import * -from Furious.Qt import * -from Furious.Qt import gettext as _ - -from PySide6 import QtCore -from PySide6.QtGui import * -from PySide6.QtWidgets import * - -import logging -import functools - -__all__ = ['GuiHysteria2'] - -logger = logging.getLogger(__name__) - -HY2_OBFS_TYPES = ['', 'salamander', 'gecko'] - - -class GuiHy2ItemBasicServer(GuiEditorItemTextInput): - """Represent GUI hy2 item basic server.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiHy2ItemBasicServer.""" - super().__init__(*args, **kwargs) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - oldServer = config.get('server', '') - newServer = self.text() - - if isinstance(oldServer, str): - if newServer != oldServer: - config['server'] = newServer - - return True - else: - return False - else: - config['server'] = newServer - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - self.setText(config.get('server', '')) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiHy2ItemBasicAuth(GuiEditorItemTextInput): - """Represent GUI hy2 item basic auth.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiHy2ItemBasicAuth.""" - super().__init__(*args, **kwargs) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - oldAuth = config.get('auth') - newAuth = self.text() - - if newAuth == '': - if oldAuth is None: - return False - else: - config.pop('auth', None) - - return True - else: - if isinstance(oldAuth, str): - if newAuth != oldAuth: - config['auth'] = newAuth - - return True - else: - return False - else: - config['auth'] = newAuth - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - self.setText(config.get('auth', '')) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiHy2ItemBasicCongestionComboBox(GuiEditorItemTextComboBox): - """Represent GUI hy2 item basic congestion combo box.""" - - CONGESTION_KEYS = ['type', 'bbrProfile'] - - def __init__(self, *args, **kwargs): - """Initialize the GuiHy2ItemBasicCongestionComboBox.""" - key = kwargs.pop('key', '') - - assert key in self.CONGESTION_KEYS - - super().__init__(*args, **kwargs) - - self.key = key - - if key == 'type': - # TODO: Future extension - self.addItems(['', 'bbr', 'reno']) - elif key == 'bbrProfile': - # TODO: Future extension - self.addItems(['', 'standard', 'conservative', 'aggressive']) - else: - # Should not reach here - raise - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - newValue = self.text() - - if newValue == '': - congestion = config.get('congestion') - - if isinstance(congestion, dict): - if len(congestion) > 0: - if congestion.get(self.key) is not None: - oldValue = congestion[self.key] - - congestion.pop(self.key, None) - - if not isinstance(oldValue, str) or oldValue != '': - modified = True - else: - modified = False - else: - modified = False - - # TODO: Future extension - anyValid = any( - congestion.get(key, '') for key in self.CONGESTION_KEYS - ) - - if not anyValid: - config.pop('congestion', None) - - return True - else: - return modified - else: - config.pop('congestion', None) - - # Modified silently - return False - else: - config.pop('congestion', None) - - # Modified silently - return False - else: - if not isinstance(config.get('congestion'), dict): - config['congestion'] = {} - - oldValue = config['congestion'].get(self.key, '') - - if not isinstance(oldValue, str) or newValue != oldValue: - config['congestion'][self.key] = newValue - - return True - else: - return False - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - value = config['congestion'][self.key] - except Exception: - # Any non-exit exceptions - - value = '' - - self.setText(value) - - -class GuiHy2ItemObfsType(GuiEditorItemTextComboBox): - """Represent GUI hy2 item obfs type.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiHy2ItemObfsType.""" - super().__init__(*args, **kwargs) - - self.addItems(HY2_OBFS_TYPES) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - try: - oldObfsType = config['obfs']['type'] - except Exception: - # Any non-exit exceptions - - oldObfsType = '' - - newObfsType = self.text() - - if newObfsType == '': - if config.get('obfs') is None: - return False - else: - config.pop('obfs', None) - - return True - else: - if not isinstance(oldObfsType, str) or newObfsType != oldObfsType: - if not isinstance(config.get('obfs'), dict): - config['obfs'] = {} - - try: - config['obfs']['type'] = newObfsType - except Exception: - # Any non-exit exceptions - - pass - - return True - else: - return False - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - obfsType = config['obfs']['type'] - except Exception: - # Any non-exit exceptions - - obfsType = '' - - if isinstance(obfsType, str) and obfsType in HY2_OBFS_TYPES: - self.setText(obfsType) - else: - self.setText('') - - -class GuiHy2ItemObfsPassword(GuiEditorItemTextInput): - """Represent GUI hy2 item obfs password.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiHy2ItemObfsPassword.""" - self.obfsType = kwargs.pop('obfsType', 'salamander') - - super().__init__(*args, **kwargs) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - newObfsPassword = self.text() - - try: - obfsType = config['obfs']['type'] - except Exception: - # Any non-exit exceptions - - obfsType = '' - - if not isinstance(obfsType, str) or obfsType != self.obfsType: - return False - - modified = False - - if not isinstance(config.get('obfs'), dict): - config['obfs'] = {} - - modified = True - - if not isinstance(config['obfs'].get(obfsType), dict): - config['obfs'][obfsType] = {} - - modified = True - - try: - oldObfsPassword = config['obfs'][obfsType]['password'] - except Exception: - # Any non-exit exceptions - - oldObfsPassword = '' - - if not isinstance(oldObfsPassword, str) or newObfsPassword != oldObfsPassword: - config['obfs'][obfsType]['password'] = newObfsPassword - - return True - else: - return modified - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - obfsType = config['obfs']['type'] - except Exception: - # Any non-exit exceptions - - obfsType = '' - - if not isinstance(obfsType, str) or obfsType != self.obfsType: - self.setText('') - - return - - try: - obfsPassword = config['obfs'][obfsType]['password'] - except Exception: - # Any non-exit exceptions - - obfsPassword = '' - - self.setText(obfsPassword) - - -class GuiHy2ItemObfsPacketSize(GuiEditorItemTextSpinBox): - """Represent GUI hy2 item obfs packet size.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiHy2ItemObfsPacketSize.""" - self.obfsType, self.key, self.default = ( - kwargs.pop('obfsType', 'gecko'), - kwargs.pop('key', ''), - kwargs.pop('default', 0), - ) - - super().__init__(*args, **kwargs) - - self.setRange(1, 2048) - self.setValue(self.default) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - try: - obfsType = config['obfs']['type'] - except Exception: - # Any non-exit exceptions - - obfsType = '' - - if not isinstance(obfsType, str) or obfsType != self.obfsType: - return False - - if not isinstance(config.get('obfs'), dict): - config['obfs'] = {} - - if not isinstance(config['obfs'].get(obfsType), dict): - config['obfs'][obfsType] = {} - - oldValue = config['obfs'][obfsType].get(self.key) - newValue = self.value() - - if not isinstance(oldValue, int) or newValue != oldValue: - config['obfs'][obfsType][self.key] = newValue - - return True - - return False - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - obfsType = config['obfs']['type'] - except Exception: - # Any non-exit exceptions - - obfsType = '' - - if not isinstance(obfsType, str) or obfsType != self.obfsType: - self.setValue(self.default) - - return - - try: - value = config['obfs'][obfsType][self.key] - except Exception: - # Any non-exit exceptions - - value = self.default - - if not isinstance(value, int): - value = self.default - - self.setValue(value) - - -class GuiHy2PageObfsXXX(GuiEditorWidgetQWidget): - """Represent GUI hy2 page obfs xxx.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiHy2PageObfsXXX.""" - super().__init__(*args, **kwargs) - - def setObfsTypeText(self, text: str): - """Set obfs type text.""" - obfsType = self._containers[0] - - if isinstance(obfsType, GuiEditorItemTextComboBox): - obfsType.setText(text) - - def connectActivated(self, func): - """Connect activated.""" - obfsType = self._containers[0] - - if isinstance(obfsType, GuiEditorItemTextComboBox): - obfsType.connectActivated(func) - - -class GuiHy2PageObfsEmpty(GuiHy2PageObfsXXX): - """Represent GUI hy2 page obfs empty.""" - - def containerSequence(self): - """Return the editor item containers in display order.""" - return [ - GuiHy2ItemObfsType(title='obfs-type', translatable=False), - ] - - -class GuiHy2PageObfsSalamander(GuiHy2PageObfsXXX): - """Represent GUI hy2 page obfs salamander.""" - - def containerSequence(self): - """Return the editor item containers in display order.""" - return [ - GuiHy2ItemObfsType(title='obfs-type', translatable=False), - GuiHy2ItemObfsPassword( - title='obfs-password', - obfsType='salamander', - translatable=False, - ), - ] - - -class GuiHy2PageObfsGecko(GuiHy2PageObfsXXX): - """Represent GUI hy2 page obfs gecko.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiHy2PageObfsGecko.""" - super().__init__(*args, **kwargs) - - self.minPacketSizeItem = self._containers[2] - self.maxPacketSizeItem = self._containers[3] - self.minPacketSizeItem._input.valueChanged.connect( - self.handleMinPacketSizeChanged - ) - - self.handleMinPacketSizeChanged(self.minPacketSizeItem.value()) - - def containerSequence(self): - """Return the editor item containers in display order.""" - return [ - GuiHy2ItemObfsType(title='obfs-type', translatable=False), - GuiHy2ItemObfsPassword( - title='obfs-password', - obfsType='gecko', - translatable=False, - ), - GuiHy2ItemObfsPacketSize( - title='minPacketSize', - key='minPacketSize', - default=512, - translatable=False, - ), - GuiHy2ItemObfsPacketSize( - title='maxPacketSize', - key='maxPacketSize', - default=1200, - translatable=False, - ), - ] - - def handleMinPacketSizeChanged(self, value: int): - """Handle min packet size changed.""" - self.maxPacketSizeItem._input.setMinimum(value) - - if self.maxPacketSizeItem.value() < value: - self.maxPacketSizeItem.setValue(value) - - -class GuiHy2ObfsPageStackedWidget(QStackedWidget): - """Provide the GUI hy2 obfs page stacked widget.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiHy2ObfsPageStackedWidget.""" - super().__init__(*args, **kwargs) - - self._pages = [ - GuiHy2PageObfsEmpty(), - GuiHy2PageObfsSalamander(), - GuiHy2PageObfsGecko(), - ] - - for page in self._pages: - self.addWidget(page) - - def page(self, index: int) -> GuiHy2PageObfsXXX: - """Return the page value.""" - return self._pages[index] - - def connectActivated(self, func): - """Connect activated.""" - for page in self._pages: - page.connectActivated(func) - - -class GuiHy2ItemTLSTextInput(GuiEditorItemTextInput): - """Represent GUI hy2 item TLS text input.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiHy2ItemTLSTextInput.""" - key = kwargs.pop('key', '') - - super().__init__(*args, **kwargs) - - self.key = key - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - newValue = self.text() - - if newValue == '': - tls = config.get('tls') - - if isinstance(tls, dict): - if len(tls) > 0: - if tls.get(self.key) is not None: - oldValue = tls[self.key] - - tls.pop(self.key, None) - - if not isinstance(oldValue, str) or oldValue != '': - modified = True - else: - # Modified silently - modified = False - else: - modified = False - - # TODO: Future extension - anyValid = any( - tls.get(key, '') - for key in ['sni', 'insecure', 'pinSHA256', 'ca'] - ) - - if not anyValid: - config.pop('tls', None) - - return True - else: - return modified - else: - config.pop('tls', None) - - # Modified silently - return False - else: - config.pop('tls', None) - - # Modified silently - return False - else: - if not isinstance(config.get('tls'), dict): - config['tls'] = {} - - oldValue = config['tls'].get(self.key, '') - - if not isinstance(oldValue, str) or newValue != oldValue: - config['tls'][self.key] = newValue - - return True - else: - return False - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - value = config['tls'][self.key] - except Exception: - # Any non-exit exceptions - - value = '' - - self.setText(value) - - -class GuiHy2ItemTLSInsecure(GuiEditorItemTextCheckBox): - """Represent GUI hy2 item TLS insecure.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiHy2ItemTLSInsecure.""" - super().__init__(*args, **kwargs) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - try: - oldChecked = config['tls']['insecure'] - except Exception: - # Any non-exit exceptions - - oldChecked = False - - newChecked = self.isChecked() - - if newChecked: - if not isinstance(config.get('tls'), dict): - config['tls'] = {} - - if oldChecked is not True: - config['tls']['insecure'] = True - - return True - else: - return False - else: - if not isinstance(config.get('tls'), dict): - config['tls'] = {} - - if oldChecked is not False: - config['tls']['insecure'] = False - - return True - else: - return False - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - checked = config['tls']['insecure'] - except Exception: - # Any non-exit exceptions - - checked = False - - if not isinstance(checked, bool): - checked = False - - self.setChecked(checked) - - -class GuiHy2GroupBoxBasic(GuiEditorWidgetQGroupBox): - """Represent GUI hy2 group box basic.""" - - def __init__(self, **kwargs): - """Initialize the GuiHy2GroupBoxBasic.""" - super().__init__(_('Basic Configuration'), **kwargs) - - def containerSequence(self): - """Return the editor item containers in display order.""" - return [ - GuiEditorItemBasicRemark(title=_('Remark')), - GuiHy2ItemBasicServer(title=_('Server')), - GuiHy2ItemBasicAuth(title='auth', translatable=False), - GuiHy2ItemBasicCongestionComboBox( - title='congestion-type', key='type', translatable=False - ), - GuiHy2ItemBasicCongestionComboBox( - title='congestion-profile', key='bbrProfile', translatable=False - ), - ] - - -class GuiHy2GroupBoxProxy(GuiEditorWidgetQGroupBox): - """Represent GUI hy2 group box proxy.""" - - def __init__(self, **kwargs): - """Initialize the GuiHy2GroupBoxProxy.""" - super().__init__(_('Proxy'), **kwargs) - - def containerSequence(self): - """Return the editor item containers in display order.""" - return [ - GuiEditorItemProxyHttp(title='http', translatable=False), - GuiEditorItemProxySocks(title='socks', translatable=False), - ] - - -class GuiHy2GroupBoxObfs(GuiEditorItemFactory, AppQGroupBox): - """Represent GUI hy2 group box obfs.""" - - def __init__(self, **kwargs): - """Initialize the GuiHy2GroupBoxObfs.""" - translatable = kwargs.pop('translatable', False) - - super().__init__('obfs', **kwargs, translatable=translatable) - - self._config = ConfigFactory() - - self._widget = GuiHy2ObfsPageStackedWidget() - self._widget.connectActivated(self.handleActivated) - - layout = QFormLayout() - layout.addRow(self._widget) - - self.setLayout(layout) - - def currentIndex(self) -> int: - """Return the current index value.""" - return self._widget.currentIndex() - - def setCurrentIndex(self, index: int): - """Set current index.""" - self._widget.setCurrentIndex(index) - - def page(self, index: int) -> GuiHy2PageObfsXXX: - """Return the page value.""" - return self._widget.page(index) - - def handleActivated(self, index: int): - """Handle activated.""" - page = self.page(index) - page.factoryToInput(self._config) - page.setObfsTypeText(HY2_OBFS_TYPES[index]) - - self.setCurrentIndex(index) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - oldObfs = config.get('obfs') - oldObfsType = '' - - if isinstance(oldObfs, dict): - oldObfsType = oldObfs.get('type', '') - - newObfsType = HY2_OBFS_TYPES[self.currentIndex()] - - if newObfsType == '': - if isinstance(oldObfs, dict): - config.pop('obfs', None) - - return True - - return False - - if newObfsType not in HY2_OBFS_TYPES: - return False - - if not isinstance(config.get('obfs'), dict): - config['obfs'] = {} - - modified = oldObfsType != newObfsType - config['obfs']['type'] = newObfsType - - for obfsType in HY2_OBFS_TYPES: - if obfsType and obfsType != newObfsType: - config['obfs'].pop(obfsType, None) - - modified |= self.page(self.currentIndex()).inputToFactory(config) - - return modified - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - self._config = config - - try: - obfsType = config['obfs']['type'] - except Exception: - # Any non-exit exceptions - - obfsType = '' - - if not isinstance(obfsType, str) or obfsType not in HY2_OBFS_TYPES: - obfsType = '' - - index = HY2_OBFS_TYPES.index(obfsType) - - self.page(index).factoryToInput(config) - self.page(index).setObfsTypeText(obfsType) - self.setCurrentIndex(index) - - -class GuiHy2GroupBoxTLS(GuiEditorWidgetQGroupBox): - """Represent GUI hy2 group box TLS.""" - - def __init__(self, **kwargs): - """Initialize the GuiHy2GroupBoxTLS.""" - super().__init__('TLS', **kwargs) - - self.translatable = False - - def containerSequence(self): - """Return the editor item containers in display order.""" - return [ - GuiHy2ItemTLSTextInput(title='sni', key='sni', translatable=False), - GuiHy2ItemTLSTextInput( - title='pinSHA256', key='pinSHA256', translatable=False - ), - GuiHy2ItemTLSTextInput(title='ca', key='ca', translatable=False), - GuiHy2ItemTLSInsecure(title='insecure', translatable=False), - ] - - -class GuiHy2ProjectWebsiteURL(AppQLabel): - """Represent GUI hy2 project website URL.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiHy2ProjectWebsiteURL.""" - super().__init__(*args, **kwargs) - - self.setWebsiteURL() - self.linkActivated.connect(self.handleLinkActivated) - - def setWebsiteURL(self): - """Set website URL.""" - self.setText( - '

' - '' - '' - + _('Project Website') - + '

' - ) - - @staticmethod - def handleLinkActivated(link: str): - """Handle link activated.""" - if QDesktopServices.openUrl(QtCore.QUrl(link)): - logger.info(f'open link \'{link}\' success') - else: - logger.error(f'open link \'{link}\' failed') - - def retranslate(self): - """Refresh translated text for the GUI hy2 project website URL.""" - self.setWebsiteURL() - - -class GuiHy2GroupBoxOther(GuiEditorItemFactory, AppQGroupBox): - """Represent GUI hy2 group box other.""" - - def __init__(self, **kwargs): - """Initialize the GuiHy2GroupBoxOther.""" - super().__init__(_('Other'), **kwargs) - - self._website = GuiHy2ProjectWebsiteURL() - - layout = QFormLayout() - layout.addRow(self._website) - - self.setLayout(layout) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - return False - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - pass - - -class GuiHysteria2(GuiEditorWidgetQDialog): - """Represent GUI hysteria2.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiHysteria2.""" - super().__init__(*args, **kwargs) - - self.setTabText(Protocol.Hysteria2.value) - - @functools.lru_cache(None) - def groupBoxSequence(self): - """Return the configuration group boxes in display order.""" - return [ - GuiHy2GroupBoxBasic(), - GuiHy2GroupBoxProxy(), - GuiHy2GroupBoxObfs(), - GuiHy2GroupBoxTLS(), - # GuiHy2GroupBoxOther(), - ] +from Furious.Plugins.Official.Hysteria2.GuiHysteria2 import * diff --git a/Furious/Widget/GuiShadowsocks.py b/Furious/Widget/GuiShadowsocks.py index a8ac85ee..8f6cdd00 100644 --- a/Furious/Widget/GuiShadowsocks.py +++ b/Furious/Widget/GuiShadowsocks.py @@ -1,233 +1,5 @@ -# Copyright (C) 2024–present Loren Eteval & contributors -# -# This file is part of Furious. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Provide widgets for GUI shadowsocks.""" +"""Provide compatibility imports for the official Xray Shadowsocks editor.""" from __future__ import annotations -from Furious.Frozenlib import * -from Furious.Interface import * -from Furious.Library import * -from Furious.Qt import * -from Furious.Qt import gettext as _ -from Furious.Widget.GuiVTransport import * -from Furious.Widget.GuiVTLS import * - -import functools - -__all__ = ['GuiShadowsocks'] - -getProxyOutboundServer = functools.partial( - ConfigXray.getProxyOutboundServer, - protocol=Protocol.Shadowsocks, - default=configXrayEmptyProxyOutboundObject(Protocol.Shadowsocks), -) - - -class GuiSSItemTextInput(GuiEditorItemTextInput): - """Represent GUI ss item text input.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiSSItemTextInput.""" - key = kwargs.pop('key', '') - - super().__init__(*args, **kwargs) - - self.key = key - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - proxyOutboundServer = getProxyOutboundServer(config) - - oldValue = proxyOutboundServer.get(self.key, '') - newValue = self.text() - - if isinstance(oldValue, str): - if newValue != oldValue: - if newValue == '': - # Remove field - proxyOutboundServer.pop(self.key, None) - else: - proxyOutboundServer[self.key] = newValue - - return True - else: - return False - else: - proxyOutboundServer[self.key] = newValue - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - proxyOutboundServer = getProxyOutboundServer(config) - - self.setText(proxyOutboundServer.get(self.key, '')) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiSSItemBasicPort(GuiEditorItemTextSpinBox): - """Represent GUI ss item basic port.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiSSItemBasicPort.""" - super().__init__(*args, **kwargs) - - # Range - self.setRange(0, 65535) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - proxyOutboundServer = getProxyOutboundServer(config) - - oldPort = proxyOutboundServer.get('port') - newPort = self.value() - - if isinstance(oldPort, int): - if newPort != oldPort: - proxyOutboundServer['port'] = newPort - - return True - else: - return False - else: - proxyOutboundServer['port'] = newPort - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - proxyOutboundServer = getProxyOutboundServer(config) - - self.setValue(proxyOutboundServer.get('port', 0)) - except Exception: - # Any non-exit exceptions - - self.setValue(0) - - -class GuiSSItemBasicMethod(GuiEditorItemTextComboBox): - """Represent GUI ss item basic method.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiSSItemBasicMethod.""" - super().__init__(*args, **kwargs) - - self.addItems( - [ - '', - 'aes-128-gcm', - 'aes-256-gcm', - 'chacha20-poly1305', - 'chacha20-ietf-poly1305', - 'xchacha20-poly1305', - 'xchacha20-ietf-poly1305', - 'none', - 'plain', - '2022-blake3-aes-128-gcm', - '2022-blake3-aes-256-gcm', - '2022-blake3-chacha20-poly1305', - ] - ) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - proxyOutboundServer = getProxyOutboundServer(config) - - oldMethod = proxyOutboundServer.get('method', '') - newMethod = self.text() - - if isinstance(oldMethod, str): - if newMethod != oldMethod: - proxyOutboundServer['method'] = newMethod - - return True - else: - return False - else: - proxyOutboundServer['method'] = newMethod - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - proxyOutboundServer = getProxyOutboundServer(config) - - self.setText(proxyOutboundServer.get('method', '')) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiSSGroupBoxBasic(GuiEditorWidgetQGroupBox): - """Represent GUI ss group box basic.""" - - def __init__(self, **kwargs): - """Initialize the GuiSSGroupBoxBasic.""" - super().__init__(_('Basic Configuration'), **kwargs) - - def containerSequence(self): - """Return the editor item containers in display order.""" - return [ - GuiEditorItemBasicRemark(title=_('Remark')), - GuiSSItemTextInput(title=_('Address'), key='address'), - GuiSSItemBasicPort(title=_('Port')), - GuiSSItemBasicMethod(title=_('Encryption')), - GuiSSItemTextInput(title=_('Password'), key='password'), - ] - - -class GuiSSGroupBoxProxy(GuiEditorWidgetQGroupBox): - """Represent GUI ss group box proxy.""" - - def __init__(self, **kwargs): - """Initialize the GuiSSGroupBoxProxy.""" - super().__init__(_('Proxy'), **kwargs) - - def containerSequence(self): - """Return the editor item containers in display order.""" - return [ - GuiEditorItemProxyHttp(title='http', translatable=False), - GuiEditorItemProxySocks(title='socks', translatable=False), - ] - - -class GuiShadowsocks(GuiEditorWidgetQDialog): - """Represent GUI shadowsocks.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiShadowsocks.""" - super().__init__(*args, **kwargs) - - self.setTabText(Protocol.Shadowsocks.value) - - @functools.lru_cache(None) - def groupBoxSequence(self): - """Return the configuration group boxes in display order.""" - return [ - GuiSSGroupBoxBasic(), - GuiSSGroupBoxProxy(), - GuiVTransportQGroupBox(), - GuiVTLSQGroupBox(), - ] +from Furious.Plugins.Official.Xray.GuiShadowsocks import * diff --git a/Furious/Widget/GuiSocks.py b/Furious/Widget/GuiSocks.py index 1cffead3..4d9f684b 100644 --- a/Furious/Widget/GuiSocks.py +++ b/Furious/Widget/GuiSocks.py @@ -1,176 +1,5 @@ -# Copyright (C) 2024–present Loren Eteval & contributors -# -# This file is part of Furious. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Provide widgets for GUI SOCKS.""" +"""Provide compatibility imports for the official Xray SOCKS editor.""" from __future__ import annotations -from Furious.Frozenlib import * -from Furious.Interface import * -from Furious.Library import * -from Furious.Qt import * -from Furious.Qt import gettext as _ -from Furious.Widget.GuiVTransport import * -from Furious.Widget.GuiVTLS import * - -import functools - -__all__ = ['GuiSocks'] - -getProxyOutboundServer = functools.partial( - ConfigXray.getProxyOutboundServer, - protocol=Protocol.Socks, - default=configXrayEmptyProxyOutboundObject(Protocol.Socks), -) - - -class GuiSocksItemTextInput(GuiEditorItemTextInput): - """Represent GUI SOCKS item text input.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiSocksItemTextInput.""" - key = kwargs.pop('key', '') - - super().__init__(*args, **kwargs) - - self.key = key - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - proxyOutboundServer = getProxyOutboundServer(config) - - oldValue = proxyOutboundServer.get(self.key, '') - newValue = self.text() - - if isinstance(oldValue, str): - if newValue != oldValue: - if newValue == '': - proxyOutboundServer.pop(self.key, None) - else: - proxyOutboundServer[self.key] = newValue - - return True - - return False - - proxyOutboundServer[self.key] = newValue - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - proxyOutboundServer = getProxyOutboundServer(config) - - self.setText(proxyOutboundServer.get(self.key, '')) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiSocksItemBasicPort(GuiEditorItemTextSpinBox): - """Represent GUI SOCKS item basic port.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiSocksItemBasicPort.""" - super().__init__(*args, **kwargs) - - self.setRange(0, 65535) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - proxyOutboundServer = getProxyOutboundServer(config) - - oldPort = proxyOutboundServer.get('port') - newPort = self.value() - - if isinstance(oldPort, int): - if newPort != oldPort: - proxyOutboundServer['port'] = newPort - - return True - - return False - - proxyOutboundServer['port'] = newPort - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - proxyOutboundServer = getProxyOutboundServer(config) - - self.setValue(proxyOutboundServer.get('port', 0)) - except Exception: - # Any non-exit exceptions - - self.setValue(0) - - -class GuiSocksGroupBoxBasic(GuiEditorWidgetQGroupBox): - """Represent GUI SOCKS group box basic.""" - - def __init__(self, **kwargs): - """Initialize the GuiSocksGroupBoxBasic.""" - super().__init__(_('Basic Configuration'), **kwargs) - - def containerSequence(self): - """Return the editor item containers in display order.""" - return [ - GuiEditorItemBasicRemark(title=_('Remark')), - GuiSocksItemTextInput(title=_('Address'), key='address'), - GuiSocksItemBasicPort(title=_('Port')), - GuiSocksItemTextInput(title=_('Username'), key='user'), - GuiSocksItemTextInput(title=_('Password'), key='pass'), - ] - - -class GuiSocksGroupBoxProxy(GuiEditorWidgetQGroupBox): - """Represent GUI SOCKS group box proxy.""" - - def __init__(self, **kwargs): - """Initialize the GuiSocksGroupBoxProxy.""" - super().__init__(_('Proxy'), **kwargs) - - def containerSequence(self): - """Return the editor item containers in display order.""" - return [ - GuiEditorItemProxyHttp(title='http', translatable=False), - GuiEditorItemProxySocks(title='socks', translatable=False), - ] - - -class GuiSocks(GuiEditorWidgetQDialog): - """Represent GUI SOCKS.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiSocks.""" - super().__init__(*args, **kwargs) - - self.setTabText(Protocol.Socks.value) - - @functools.lru_cache(None) - def groupBoxSequence(self): - """Return the configuration group boxes in display order.""" - return [ - GuiSocksGroupBoxBasic(), - GuiSocksGroupBoxProxy(), - GuiVTransportQGroupBox(), - GuiVTLSQGroupBox(), - ] +from Furious.Plugins.Official.Xray.GuiSocks import * diff --git a/Furious/Widget/GuiTrojan.py b/Furious/Widget/GuiTrojan.py index d0b78b02..b95cf767 100644 --- a/Furious/Widget/GuiTrojan.py +++ b/Furious/Widget/GuiTrojan.py @@ -1,177 +1,5 @@ -# Copyright (C) 2024–present Loren Eteval & contributors -# -# This file is part of Furious. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Provide widgets for GUI trojan.""" +"""Provide compatibility imports for the official Xray Trojan editor.""" from __future__ import annotations -from Furious.Frozenlib import * -from Furious.Interface import * -from Furious.Library import * -from Furious.Qt import * -from Furious.Qt import gettext as _ -from Furious.Widget.GuiVTransport import * -from Furious.Widget.GuiVTLS import * - -import functools - -__all__ = ['GuiTrojan'] - -getProxyOutboundServer = functools.partial( - ConfigXray.getProxyOutboundServer, - protocol=Protocol.Trojan, - default=configXrayEmptyProxyOutboundObject(Protocol.Trojan), -) - - -class GuiTrojanItemTextInput(GuiEditorItemTextInput): - """Represent GUI trojan item text input.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiTrojanItemTextInput.""" - key = kwargs.pop('key', '') - - super().__init__(*args, **kwargs) - - self.key = key - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - proxyOutboundServer = getProxyOutboundServer(config) - - oldValue = proxyOutboundServer.get(self.key, '') - newValue = self.text() - - if isinstance(oldValue, str): - if newValue != oldValue: - if newValue == '': - # Remove field - proxyOutboundServer.pop(self.key, None) - else: - proxyOutboundServer[self.key] = newValue - - return True - else: - return False - else: - proxyOutboundServer[self.key] = newValue - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - proxyOutboundServer = getProxyOutboundServer(config) - - self.setText(proxyOutboundServer.get(self.key, '')) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiTrojanItemBasicPort(GuiEditorItemTextSpinBox): - """Represent GUI trojan item basic port.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiTrojanItemBasicPort.""" - super().__init__(*args, **kwargs) - - # Range - self.setRange(0, 65535) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - proxyOutboundServer = getProxyOutboundServer(config) - - oldPort = proxyOutboundServer.get('port') - newPort = self.value() - - if isinstance(oldPort, int): - if newPort != oldPort: - proxyOutboundServer['port'] = newPort - - return True - else: - return False - else: - proxyOutboundServer['port'] = newPort - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - proxyOutboundServer = getProxyOutboundServer(config) - - self.setValue(proxyOutboundServer.get('port', 0)) - except Exception: - # Any non-exit exceptions - - self.setValue(0) - - -class GuiTrojanGroupBoxBasic(GuiEditorWidgetQGroupBox): - """Represent GUI trojan group box basic.""" - - def __init__(self, **kwargs): - """Initialize the GuiTrojanGroupBoxBasic.""" - super().__init__(_('Basic Configuration'), **kwargs) - - def containerSequence(self): - """Return the editor item containers in display order.""" - return [ - GuiEditorItemBasicRemark(title=_('Remark')), - GuiTrojanItemTextInput(title=_('Address'), key='address'), - GuiTrojanItemBasicPort(title=_('Port')), - GuiTrojanItemTextInput(title=_('Password'), key='password'), - ] - - -class GuiTrojanGroupBoxProxy(GuiEditorWidgetQGroupBox): - """Represent GUI trojan group box proxy.""" - - def __init__(self, **kwargs): - """Initialize the GuiTrojanGroupBoxProxy.""" - super().__init__(_('Proxy'), **kwargs) - - def containerSequence(self): - """Return the editor item containers in display order.""" - return [ - GuiEditorItemProxyHttp(title='http', translatable=False), - GuiEditorItemProxySocks(title='socks', translatable=False), - ] - - -class GuiTrojan(GuiEditorWidgetQDialog): - """Represent GUI trojan.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiTrojan.""" - super().__init__(*args, **kwargs) - - self.setTabText(Protocol.Trojan.value) - - @functools.lru_cache(None) - def groupBoxSequence(self): - """Return the configuration group boxes in display order.""" - return [ - GuiTrojanGroupBoxBasic(), - GuiTrojanGroupBoxProxy(), - GuiVTransportQGroupBox(), - GuiVTLSQGroupBox(), - ] +from Furious.Plugins.Official.Xray.GuiTrojan import * diff --git a/Furious/Widget/GuiVLESS.py b/Furious/Widget/GuiVLESS.py index 5ac05ff1..f35cb18a 100644 --- a/Furious/Widget/GuiVLESS.py +++ b/Furious/Widget/GuiVLESS.py @@ -1,376 +1,5 @@ -# Copyright (C) 2024–present Loren Eteval & contributors -# -# This file is part of Furious. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Provide widgets for GUI VLESS.""" +"""Provide compatibility imports for the official Xray VLESS editor.""" from __future__ import annotations -from Furious.Frozenlib import * -from Furious.Interface import * -from Furious.Library import * -from Furious.Qt import * -from Furious.Qt import gettext as _ -from Furious.Widget.GuiVTransport import * -from Furious.Widget.GuiVTLS import * - -from PySide6.QtWidgets import * - -import uuid -import functools - -__all__ = ['GuiVLESS'] - -getProxyOutboundServer = functools.partial( - ConfigXray.getProxyOutboundServer, - protocol=Protocol.VLESS, - default=configXrayEmptyProxyOutboundObject(Protocol.VLESS), -) - -getProxyOutboundUser = functools.partial( - ConfigXray.getProxyOutboundUser, - protocol=Protocol.VLESS, - default=configXrayEmptyProxyOutboundObject(Protocol.VLESS), -) - - -class GuiVLESSItemBasicAddress(GuiEditorItemTextInput): - """Represent GUI VLESS item basic address.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVLESSItemBasicAddress.""" - super().__init__(*args, **kwargs) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - proxyOutboundServer = getProxyOutboundServer(config) - - oldAddress = proxyOutboundServer.get('address', '') - newAddress = self.text() - - if isinstance(oldAddress, str): - if newAddress != oldAddress: - if newAddress == '': - # Remove field - proxyOutboundServer.pop('address', None) - else: - proxyOutboundServer['address'] = newAddress - - return True - else: - return False - else: - proxyOutboundServer['address'] = newAddress - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - proxyOutboundServer = getProxyOutboundServer(config) - - self.setText(proxyOutboundServer.get('address', '')) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiVLESSItemBasicPort(GuiEditorItemTextSpinBox): - """Represent GUI VLESS item basic port.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVLESSItemBasicPort.""" - super().__init__(*args, **kwargs) - - # Range - self.setRange(0, 65535) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - proxyOutboundServer = getProxyOutboundServer(config) - - oldPort = proxyOutboundServer.get('port') - newPort = self.value() - - if isinstance(oldPort, int): - if newPort != oldPort: - proxyOutboundServer['port'] = newPort - - return True - else: - return False - else: - proxyOutboundServer['port'] = newPort - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - proxyOutboundServer = getProxyOutboundServer(config) - - self.setValue(proxyOutboundServer.get('port', 0)) - except Exception: - # Any non-exit exceptions - - self.setValue(0) - - -class GuiVLESSItemBasicId(GuiEditorItemTextInput): - """Represent GUI VLESS item basic id.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVLESSItemBasicId.""" - super().__init__(*args, **kwargs) - - self.generateButton = AppQPushButton(_('Generate')) - self.generateButton.clicked.connect(self.generateUUID) - - self._widget = QWidget() - - layout = QHBoxLayout(self._widget) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(self._input) - layout.addWidget(self.generateButton) - - def generateUUID(self): - """Handle generate uuid for the GUI VLESS item basic id.""" - self.setText(str(uuid.uuid4())) - - def widgets(self): - """Return the widgets owned by this editor item.""" - return self._title, self._widget - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - proxyOutboundUser = getProxyOutboundUser(config) - - oldId = proxyOutboundUser.get('id', '') - newId = self.text() - - if isinstance(oldId, str): - if newId != oldId: - if newId == '': - # Remove field - proxyOutboundUser.pop('id', None) - else: - proxyOutboundUser['id'] = newId - - return True - else: - return False - else: - proxyOutboundUser['id'] = newId - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - proxyOutboundUser = getProxyOutboundUser(config) - - self.setText(proxyOutboundUser.get('id', '')) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiVLESSItemBasicEncryption(GuiEditorItemTextInput): - """Represent GUI VLESS item basic encryption.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVLESSItemBasicEncryption.""" - super().__init__(*args, **kwargs) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - proxyOutboundUser = getProxyOutboundUser(config) - - oldEncryption = proxyOutboundUser.get('encryption', '') - newEncryption = self.text() - - if isinstance(oldEncryption, str): - if newEncryption != oldEncryption: - if newEncryption == '': - # Remove field - proxyOutboundUser.pop('encryption', None) - else: - proxyOutboundUser['encryption'] = newEncryption - - return True - else: - return False - else: - proxyOutboundUser['encryption'] = newEncryption - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - proxyOutboundUser = getProxyOutboundUser(config) - - self.setText(proxyOutboundUser.get('encryption', '')) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiVLESSItemBasicFlow(GuiEditorItemTextComboBox): - """Represent GUI VLESS item basic flow.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVLESSItemBasicFlow.""" - super().__init__(*args, **kwargs) - - self.addItems( - [ - '', - 'none', - 'xtls-rprx-vision', - 'xtls-rprx-vision-udp443', - ] - ) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - proxyOutboundUser = getProxyOutboundUser(config) - - oldFlow = proxyOutboundUser.get('flow', '') - newFlow = self.text() - - if isinstance(oldFlow, str): - if newFlow != oldFlow: - proxyOutboundUser['flow'] = newFlow - - return True - else: - return False - else: - proxyOutboundUser['flow'] = newFlow - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - proxyOutboundUser = getProxyOutboundUser(config) - - self.setText(proxyOutboundUser.get('flow', '')) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiVLESSGroupBoxBasic(GuiEditorWidgetQGroupBox): - """Represent GUI VLESS group box basic.""" - - def __init__(self, **kwargs): - """Initialize the GuiVLESSGroupBoxBasic.""" - super().__init__(_('Basic Configuration'), **kwargs) - - def setupPageLayout(self): - """Set up page layout.""" - layout = QGridLayout() - layout.setColumnStretch(1, 1) - layout.setColumnStretch(3, 1) - - def keepSpinBoxCompact(widget: QWidget): - """Handle keep spin box compact for the GUI VLESS group box basic.""" - if isinstance(widget, QSpinBox): - widget.setSizePolicy( - QSizePolicy.Policy.Fixed, - widget.sizePolicy().verticalPolicy(), - ) - - def addPair(index: int, row: int, column: int): - """Add pair.""" - label, inputWidget = self._containers[index].widgets() - - keepSpinBoxCompact(inputWidget) - - layout.addWidget(label, row, column) - layout.addWidget(inputWidget, row, column + 1) - - def addFullRow(index: int, row: int): - """Add full row.""" - label, inputWidget = self._containers[index].widgets() - - keepSpinBoxCompact(inputWidget) - - layout.addWidget(label, row, 0) - layout.addWidget(inputWidget, row, 1, 1, 3) - - addFullRow(0, 0) - addFullRow(1, 1) - addFullRow(2, 2) - addFullRow(3, 3) - addPair(4, 4, 0) - addPair(5, 4, 2) - - layout.setRowStretch(5, 1) - - return layout - - def containerSequence(self): - """Return the editor item containers in display order.""" - return [ - GuiEditorItemBasicRemark(title=_('Remark')), - GuiVLESSItemBasicAddress(title=_('Address')), - GuiVLESSItemBasicPort(title=_('Port')), - GuiVLESSItemBasicId(title='Id', translatable=False), - GuiVLESSItemBasicEncryption(title=_('Encryption')), - GuiVLESSItemBasicFlow(title=_('Flow')), - ] - - -class GuiVLESSGroupBoxProxy(GuiEditorWidgetQGroupBox): - """Represent GUI VLESS group box proxy.""" - - def __init__(self, **kwargs): - """Initialize the GuiVLESSGroupBoxProxy.""" - super().__init__(_('Proxy'), **kwargs) - - def containerSequence(self): - """Return the editor item containers in display order.""" - return [ - GuiEditorItemProxyHttp(title='http', translatable=False), - GuiEditorItemProxySocks(title='socks', translatable=False), - ] - - -class GuiVLESS(GuiEditorWidgetQDialog): - """Represent GUI VLESS.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVLESS.""" - super().__init__(*args, **kwargs) - - self.setTabText(Protocol.VLESS.value) - - @functools.lru_cache(None) - def groupBoxSequence(self): - """Return the configuration group boxes in display order.""" - return [ - GuiVLESSGroupBoxBasic(), - GuiVLESSGroupBoxProxy(), - GuiVTransportQGroupBox(), - GuiVTLSQGroupBox(), - ] +from Furious.Plugins.Official.Xray.GuiVLESS import * diff --git a/Furious/Widget/GuiVMess.py b/Furious/Widget/GuiVMess.py index f588224f..7d6dfa20 100644 --- a/Furious/Widget/GuiVMess.py +++ b/Furious/Widget/GuiVMess.py @@ -1,385 +1,5 @@ -# Copyright (C) 2024–present Loren Eteval & contributors -# -# This file is part of Furious. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Provide widgets for GUI v mess.""" +"""Provide compatibility imports for the official Xray VMess editor.""" from __future__ import annotations -from Furious.Frozenlib import * -from Furious.Interface import * -from Furious.Library import * -from Furious.Qt import * -from Furious.Qt import gettext as _ -from Furious.Widget.GuiVTransport import * -from Furious.Widget.GuiVTLS import * - -from PySide6.QtWidgets import * - -import uuid -import functools - -__all__ = ['GuiVMess'] - -getProxyOutboundServer = functools.partial( - ConfigXray.getProxyOutboundServer, - protocol=Protocol.VMess, - default=configXrayEmptyProxyOutboundObject(Protocol.VMess), -) - -getProxyOutboundUser = functools.partial( - ConfigXray.getProxyOutboundUser, - protocol=Protocol.VMess, - default=configXrayEmptyProxyOutboundObject(Protocol.VMess), -) - - -class GuiVMessItemBasicAddress(GuiEditorItemTextInput): - """Represent GUI v mess item basic address.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVMessItemBasicAddress.""" - super().__init__(*args, **kwargs) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - proxyOutboundServer = getProxyOutboundServer(config) - - oldAddress = proxyOutboundServer.get('address', '') - newAddress = self.text() - - if isinstance(oldAddress, str): - if newAddress != oldAddress: - if newAddress == '': - # Remove field - proxyOutboundServer.pop('address', None) - else: - proxyOutboundServer['address'] = newAddress - - return True - else: - return False - else: - proxyOutboundServer['address'] = newAddress - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - proxyOutboundServer = getProxyOutboundServer(config) - - self.setText(proxyOutboundServer.get('address', '')) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiVMessItemBasicPort(GuiEditorItemTextSpinBox): - """Represent GUI v mess item basic port.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVMessItemBasicPort.""" - super().__init__(*args, **kwargs) - - # Range - self.setRange(0, 65535) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - proxyOutboundServer = getProxyOutboundServer(config) - - oldPort = proxyOutboundServer.get('port') - newPort = self.value() - - if isinstance(oldPort, int): - if newPort != oldPort: - proxyOutboundServer['port'] = newPort - - return True - else: - return False - else: - proxyOutboundServer['port'] = newPort - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - proxyOutboundServer = getProxyOutboundServer(config) - - self.setValue(proxyOutboundServer.get('port', 0)) - except Exception: - # Any non-exit exceptions - - self.setValue(0) - - -class GuiVMessItemBasicId(GuiEditorItemTextInput): - """Represent GUI v mess item basic id.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVMessItemBasicId.""" - super().__init__(*args, **kwargs) - - self.generateButton = AppQPushButton(_('Generate')) - self.generateButton.clicked.connect(self.generateUUID) - - self._widget = QWidget() - - layout = QHBoxLayout(self._widget) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(self._input) - layout.addWidget(self.generateButton) - - def generateUUID(self): - """Handle generate uuid for the GUI v mess item basic id.""" - self.setText(str(uuid.uuid4())) - - def widgets(self): - """Return the widgets owned by this editor item.""" - return self._title, self._widget - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - proxyOutboundUser = getProxyOutboundUser(config) - - oldId = proxyOutboundUser.get('id', '') - newId = self.text() - - if isinstance(oldId, str): - if newId != oldId: - if newId == '': - # Remove field - proxyOutboundUser.pop('id', None) - else: - proxyOutboundUser['id'] = newId - - return True - else: - return False - else: - proxyOutboundUser['id'] = newId - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - proxyOutboundUser = getProxyOutboundUser(config) - - self.setText(proxyOutboundUser.get('id', '')) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiVMessItemBasicAlterId(GuiEditorItemTextSpinBox): - """Represent GUI v mess item basic alter id.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVMessItemBasicAlterId.""" - super().__init__(*args, **kwargs) - - # Range - self.setRange(0, 65535) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - proxyOutboundUser = getProxyOutboundUser(config) - - oldAlterId = proxyOutboundUser.get('alterId') - newAlterId = self.value() - - if isinstance(oldAlterId, int): - if newAlterId != oldAlterId: - proxyOutboundUser['alterId'] = newAlterId - - return True - else: - return False - else: - proxyOutboundUser['alterId'] = newAlterId - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - proxyOutboundUser = getProxyOutboundUser(config) - - self.setValue(proxyOutboundUser.get('alterId', 0)) - except Exception: - # Any non-exit exceptions - - self.setValue(0) - - -class GuiVMessItemBasicSecurity(GuiEditorItemTextComboBox): - """Represent GUI v mess item basic security.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVMessItemBasicSecurity.""" - super().__init__(*args, **kwargs) - - self.addItems( - [ - '', - 'aes-128-gcm', - 'chacha20-poly1305', - 'auto', - 'none', - 'zero', - ] - ) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - proxyOutboundUser = getProxyOutboundUser(config) - - oldSecurity = proxyOutboundUser.get('security', '') - newSecurity = self.text() - - if isinstance(oldSecurity, str): - if newSecurity != oldSecurity: - proxyOutboundUser['security'] = newSecurity - - return True - else: - return False - else: - proxyOutboundUser['security'] = newSecurity - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - proxyOutboundUser = getProxyOutboundUser(config) - - self.setText(proxyOutboundUser.get('security', '')) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiVMessGroupBoxBasic(GuiEditorWidgetQGroupBox): - """Represent GUI v mess group box basic.""" - - def __init__(self, **kwargs): - """Initialize the GuiVMessGroupBoxBasic.""" - super().__init__(_('Basic Configuration'), **kwargs) - - def setupPageLayout(self): - """Set up page layout.""" - layout = QGridLayout() - layout.setColumnStretch(4, 1) - - def keepSpinBoxCompact(widget: QWidget): - """Handle keep spin box compact for the GUI v mess group box basic.""" - if isinstance(widget, QSpinBox): - widget.setSizePolicy( - QSizePolicy.Policy.Fixed, - widget.sizePolicy().verticalPolicy(), - ) - - def keepComboBoxCompact(widget: QWidget): - """Handle keep combo box compact for the GUI v mess group box basic.""" - if isinstance(widget, QComboBox): - widget.setSizePolicy( - QSizePolicy.Policy.Fixed, - widget.sizePolicy().verticalPolicy(), - ) - - def addPair(index: int, row: int, column: int): - """Add pair.""" - label, inputWidget = self._containers[index].widgets() - - keepSpinBoxCompact(inputWidget) - - layout.addWidget(label, row, column) - layout.addWidget(inputWidget, row, column + 1) - - def addFullRow(index: int, row: int): - """Add full row.""" - label, inputWidget = self._containers[index].widgets() - - keepSpinBoxCompact(inputWidget) - - layout.addWidget(label, row, 0) - layout.addWidget(inputWidget, row, 1, 1, 4) - - addFullRow(0, 0) - addFullRow(1, 1) - addPair(2, 2, 0) - addPair(4, 2, 2) - addFullRow(3, 3) - keepComboBoxCompact(self._containers[5].widgets()[1]) - addFullRow(5, 4) - - layout.setRowStretch(5, 1) - - return layout - - def containerSequence(self): - """Return the editor item containers in display order.""" - return [ - GuiEditorItemBasicRemark(title=_('Remark')), - GuiVMessItemBasicAddress(title=_('Address')), - GuiVMessItemBasicPort(title=_('Port')), - GuiVMessItemBasicId(title='Id', translatable=False), - GuiVMessItemBasicAlterId(title='AlterId', translatable=False), - GuiVMessItemBasicSecurity(title='Security', translatable=False), - ] - - -class GuiVMessGroupBoxProxy(GuiEditorWidgetQGroupBox): - """Represent GUI v mess group box proxy.""" - - def __init__(self, **kwargs): - """Initialize the GuiVMessGroupBoxProxy.""" - super().__init__(_('Proxy'), **kwargs) - - def containerSequence(self): - """Return the editor item containers in display order.""" - return [ - GuiEditorItemProxyHttp(title='http', translatable=False), - GuiEditorItemProxySocks(title='socks', translatable=False), - ] - - -class GuiVMess(GuiEditorWidgetQDialog): - """Represent GUI v mess.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVMess.""" - super().__init__(*args, **kwargs) - - self.setTabText(Protocol.VMess.value) - - @functools.lru_cache(None) - def groupBoxSequence(self): - """Return the configuration group boxes in display order.""" - return [ - GuiVMessGroupBoxBasic(), - GuiVMessGroupBoxProxy(), - GuiVTransportQGroupBox(), - GuiVTLSQGroupBox(), - ] +from Furious.Plugins.Official.Xray.GuiVMess import * diff --git a/Furious/Widget/GuiVTLS.py b/Furious/Widget/GuiVTLS.py index 088de8d0..8f2451a9 100644 --- a/Furious/Widget/GuiVTLS.py +++ b/Furious/Widget/GuiVTLS.py @@ -1,802 +1,5 @@ -# Copyright (C) 2024–present Loren Eteval & contributors -# -# This file is part of Furious. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Provide widgets for GUI vtls.""" +"""Provide compatibility imports for the official Xray TLS editor components.""" from __future__ import annotations -from Furious.Interface import * -from Furious.Library import * -from Furious.Qt import * - -from PySide6 import QtCore -from PySide6.QtWidgets import * - -from typing import Callable - -__all__ = ['GuiVTLSQGroupBox'] - -STREAM_SECURITY = [ - '', - 'none', - 'tls', - 'reality', -] - - -class GuiVTLSItemSecurity(GuiEditorItemTextComboBox): - """Represent GUI vtls item security.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTLSItemSecurity.""" - super().__init__(*args, **kwargs) - - self.addItems(STREAM_SECURITY) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - streamSettings = ConfigXray.getProxyOutboundStream(config) - - try: - oldSecurity = streamSettings['security'] - except Exception: - # Any non-exit exceptions - - oldSecurity = '' - - newSecurity = self.text() - - def setNewSecurity(): - """Set new security.""" - if newSecurity == '': - streamSettings.pop('security', None) - else: - streamSettings['security'] = newSecurity - - for security in ['tls', 'reality']: - if security == newSecurity: - continue - - securityKey = f'{security}Settings' - - # Remove irrelevant settings - streamSettings.pop(securityKey, None) - - if isinstance(oldSecurity, str): - if newSecurity != oldSecurity: - setNewSecurity() - - return True - else: - return False - else: - setNewSecurity() - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - streamSettings = ConfigXray.getProxyOutboundStream(config) - - self.setText(streamSettings.get('security', '')) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiVTLSItemXXXServerName(GuiEditorItemTextInput): - """Represent GUI vtls item xxx server name.""" - - def __init__(self, *args, **kwargs): - # Mandatory - """Initialize the GuiVTLSItemXXXServerName.""" - securityKey = kwargs.pop('securityKey') - - super().__init__(*args, **kwargs) - - self.securityKey = securityKey - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - streamSettings = ConfigXray.getProxyOutboundStream(config) - - if not isinstance(streamSettings.get(self.securityKey), dict): - streamSettings[self.securityKey] = {} - - xxxObject = streamSettings[self.securityKey] - - try: - oldServerName = xxxObject.get('serverName', '') - except Exception: - # Any non-exit exceptions - - oldServerName = '' - - newServerName = self.text() - - def setNewServerName(): - """Set new server name.""" - if newServerName == '': - xxxObject.pop('serverName', None) - else: - xxxObject['serverName'] = newServerName - - if isinstance(oldServerName, str): - if newServerName != oldServerName: - setNewServerName() - - return True - else: - return False - else: - setNewServerName() - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - xxxObject = ConfigXray.getProxyOutboundStream(config)[self.securityKey] - - self.setText(xxxObject.get('serverName', '')) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiVTLSItemTLSServerName(GuiVTLSItemXXXServerName): - """Represent GUI vtls item TLS server name.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTLSItemTLSServerName.""" - securityKey = kwargs.pop('securityKey', 'tlsSettings') - - super().__init__(*args, **kwargs, securityKey=securityKey) - - -class GuiVTLSItemRealityServerName(GuiVTLSItemXXXServerName): - """Represent GUI vtls item reality server name.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTLSItemRealityServerName.""" - securityKey = kwargs.pop('securityKey', 'realitySettings') - - super().__init__(*args, **kwargs, securityKey=securityKey) - - -class GuiVTLSItemXXXFingerprint(GuiEditorItemTextInput): - """Represent GUI vtls item xxx fingerprint.""" - - def __init__(self, *args, **kwargs): - # Mandatory - """Initialize the GuiVTLSItemXXXFingerprint.""" - securityKey = kwargs.pop('securityKey') - - super().__init__(*args, **kwargs) - - self.securityKey = securityKey - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - streamSettings = ConfigXray.getProxyOutboundStream(config) - - if not isinstance(streamSettings.get(self.securityKey), dict): - streamSettings[self.securityKey] = {} - - xxxObject = streamSettings[self.securityKey] - - try: - oldFingerprint = xxxObject.get('fingerprint', '') - except Exception: - # Any non-exit exceptions - - oldFingerprint = '' - - newFingerprint = self.text() - - def setNewFingerprint(): - """Set new fingerprint.""" - if newFingerprint == '': - xxxObject.pop('fingerprint', None) - else: - xxxObject['fingerprint'] = newFingerprint - - if isinstance(oldFingerprint, str): - if newFingerprint != oldFingerprint: - setNewFingerprint() - - return True - else: - return False - else: - setNewFingerprint() - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - xxxObject = ConfigXray.getProxyOutboundStream(config)[self.securityKey] - - self.setText(xxxObject.get('fingerprint', '')) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiVTLSItemTLSFingerprint(GuiVTLSItemXXXFingerprint): - """Represent GUI vtls item TLS fingerprint.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTLSItemTLSFingerprint.""" - securityKey = kwargs.pop('securityKey', 'tlsSettings') - - super().__init__(*args, **kwargs, securityKey=securityKey) - - -class GuiVTLSItemRealityFingerprint(GuiVTLSItemXXXFingerprint): - """Represent GUI vtls item reality fingerprint.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTLSItemRealityFingerprint.""" - securityKey = kwargs.pop('securityKey', 'realitySettings') - - super().__init__(*args, **kwargs, securityKey=securityKey) - - -class GuiVTLSItemTLSAlpn(GuiEditorItemTextInput): - """Represent GUI vtls item TLS alpn.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTLSItemTLSAlpn.""" - super().__init__(*args, **kwargs) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - streamSettings = ConfigXray.getProxyOutboundStream(config) - - if not isinstance(streamSettings.get('tlsSettings'), dict): - streamSettings['tlsSettings'] = {} - - tlsObject = streamSettings['tlsSettings'] - - try: - oldAlpn = ','.join(tlsObject.get('alpn', [])) - except Exception: - # Any non-exit exceptions - - oldAlpn = '' - - newAlpn = self.text() - - def setNewAlpn(): - """Set new alpn.""" - if newAlpn == '': - tlsObject.pop('alpn', None) - else: - tlsObject['alpn'] = newAlpn.split(',') - - if isinstance(oldAlpn, str): - if newAlpn != oldAlpn: - setNewAlpn() - - return True - else: - return False - else: - setNewAlpn() - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - tlsObject = ConfigXray.getProxyOutboundStream(config)['tlsSettings'] - - self.setText(','.join(tlsObject.get('alpn', []))) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiVTLSItemTLSXXXTextInput(GuiEditorItemTextInput): - """Represent GUI vtls item tlsxxx text input.""" - - def __init__(self, *args, **kwargs): - # Mandatory - """Initialize the GuiVTLSItemTLSXXXTextInput.""" - key = kwargs.pop('key') - - super().__init__(*args, **kwargs) - - self.key = key - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - streamSettings = ConfigXray.getProxyOutboundStream(config) - - if not isinstance(streamSettings.get('tlsSettings'), dict): - streamSettings['tlsSettings'] = {} - - tlsObject = streamSettings['tlsSettings'] - - oldValue = tlsObject.get(self.key, '') - newValue = self.text() - - def setNewTLSXXXValue(): - """Set new tlsxxx value.""" - if newValue == '': - tlsObject.pop(self.key, None) - else: - tlsObject[self.key] = newValue - - if isinstance(oldValue, str): - if newValue != oldValue: - setNewTLSXXXValue() - - return True - else: - return False - else: - setNewTLSXXXValue() - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - tlsObject = ConfigXray.getProxyOutboundStream(config)['tlsSettings'] - - self.setText(tlsObject.get(self.key, '')) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiVTLSItemTLSAllowInsecure(GuiEditorItemTextCheckBox): - """Represent GUI vtls item TLS allow insecure.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTLSItemTLSAllowInsecure.""" - super().__init__(*args, **kwargs) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - streamSettings = ConfigXray.getProxyOutboundStream(config) - - if not isinstance(streamSettings.get('tlsSettings'), dict): - streamSettings['tlsSettings'] = {} - - tlsObject = streamSettings['tlsSettings'] - - try: - oldInsecure = tlsObject.get('allowInsecure', False) - except Exception: - # Any non-exit exceptions - - oldInsecure = False - - newInsecure = self.isChecked() - - if newInsecure: - if oldInsecure is not True: - tlsObject['allowInsecure'] = True - - return True - else: - return False - else: - if oldInsecure is not False: - tlsObject['allowInsecure'] = False - - return True - else: - return False - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - tlsObject = ConfigXray.getProxyOutboundStream(config)['tlsSettings'] - - self.setChecked(tlsObject.get('allowInsecure', False)) - except Exception: - # Any non-exit exceptions - - self.setChecked(False) - - -class GuiVTLSItemRealityXXX(GuiEditorItemTextInput): - """Represent GUI vtls item reality xxx.""" - - def __init__(self, *args, **kwargs): - # Mandatory - """Initialize the GuiVTLSItemRealityXXX.""" - realityKey = kwargs.pop('realityKey') - - super().__init__(*args, **kwargs) - - self.realityKey = realityKey - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - streamSettings = ConfigXray.getProxyOutboundStream(config) - - if not isinstance(streamSettings.get('realitySettings'), dict): - streamSettings['realitySettings'] = {} - - realityObject = streamSettings['realitySettings'] - - try: - oldPublicKey = realityObject.get(self.realityKey, '') - except Exception: - # Any non-exit exceptions - - oldPublicKey = '' - - newPublicKey = self.text() - - def setNewPublicKey(): - """Set new public key.""" - if newPublicKey == '': - realityObject.pop(self.realityKey, None) - else: - realityObject[self.realityKey] = newPublicKey - - if isinstance(oldPublicKey, str): - if newPublicKey != oldPublicKey: - setNewPublicKey() - - return True - else: - return False - else: - setNewPublicKey() - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - realityObject = ConfigXray.getProxyOutboundStream(config)['realitySettings'] - - self.setText(realityObject.get(self.realityKey, '')) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiVTLSItemRealityPublicKey(GuiVTLSItemRealityXXX): - """Represent GUI vtls item reality public key.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTLSItemRealityPublicKey.""" - realityKey = kwargs.pop('realityKey', 'publicKey') - - super().__init__(*args, **kwargs, realityKey=realityKey) - - -class GuiVTLSItemRealityShortId(GuiVTLSItemRealityXXX): - """Represent GUI vtls item reality short id.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTLSItemRealityShortId.""" - realityKey = kwargs.pop('realityKey', 'shortId') - - super().__init__(*args, **kwargs, realityKey=realityKey) - - -class GuiVTLSItemRealityMldsa65Verify(GuiVTLSItemRealityXXX): - """Represent GUI vtls item reality mldsa65 verify.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTLSItemRealityMldsa65Verify.""" - realityKey = kwargs.pop('realityKey', 'mldsa65Verify') - - super().__init__(*args, **kwargs, realityKey=realityKey) - - -class GuiVTLSItemRealitySpiderX(GuiVTLSItemRealityXXX): - """Represent GUI vtls item reality spider x.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTLSItemRealitySpiderX.""" - realityKey = kwargs.pop('realityKey', 'spiderX') - - super().__init__(*args, **kwargs, realityKey=realityKey) - - -class GuiVTLSPageXXX(GuiEditorWidgetQWidget): - """Represent GUI vtls page xxx.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTLSPageXXX.""" - super().__init__(*args, **kwargs) - - def setSecurityText(self, text: str): - """Set security text.""" - security = self._containers[0] - - if isinstance(security, GuiEditorItemTextComboBox): - security.setText(text) - - def connectActivated(self, func: Callable): - """Connect activated.""" - security = self._containers[0] - - if isinstance(security, GuiEditorItemTextComboBox): - security.connectActivated(func) - - -class GuiVTLSPageEmpty(GuiVTLSPageXXX): - """Represent GUI vtls page empty.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTLSPageEmpty.""" - super().__init__(*args, **kwargs) - - def containerSequence(self): - """Return the editor item containers in display order.""" - return [ - GuiVTLSItemSecurity(title='TLS', translatable=False), - ] - - -class GuiVTLSPageNone(GuiVTLSPageXXX): - """Represent GUI vtls page none.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTLSPageNone.""" - super().__init__(*args, **kwargs) - - def containerSequence(self): - """Return the editor item containers in display order.""" - return [ - GuiVTLSItemSecurity(title='TLS', translatable=False), - ] - - -class GuiVTLSPageTLS(GuiVTLSPageXXX): - """Represent GUI vtls page TLS.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTLSPageTLS.""" - super().__init__(*args, **kwargs) - - def setupLayout(self): - """Set up layout.""" - layout = QFormLayout() - layout.setFormAlignment(QtCore.Qt.AlignmentFlag.AlignLeft) - layout.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) - - basicLayout = QGridLayout() - basicLayout.setColumnStretch(1, 1) - basicLayout.setColumnStretch(3, 1) - - def addPair(index: int, row: int, column: int): - """Add pair.""" - label, inputWidget = self._containers[index].widgets() - - basicLayout.addWidget(label, row, column) - basicLayout.addWidget(inputWidget, row, column + 1) - - def addBasicFullRow(index: int, row: int): - """Add basic full row.""" - label, inputWidget = self._containers[index].widgets() - - basicLayout.addWidget(label, row, 0) - basicLayout.addWidget(inputWidget, row, 1, 1, 3) - - def addFullRow(index: int): - """Add full row.""" - widgets = self._containers[index].widgets() - - if len(widgets) == 1: - layout.addRow(widgets[0]) - else: - label, inputWidget = widgets - - layout.addRow(label, inputWidget) - - addPair(0, 0, 0) - addPair(2, 0, 2) - addPair(1, 1, 0) - addPair(3, 1, 2) - addBasicFullRow(4, 2) - - layout.addRow(basicLayout) - - addFullRow(5) - addFullRow(6) - addFullRow(7) - - self.setLayout(layout) - - def containerSequence(self): - """Return the editor item containers in display order.""" - return [ - GuiVTLSItemSecurity(title='TLS', translatable=False), - GuiVTLSItemTLSServerName(title='SNI', translatable=False), - GuiVTLSItemTLSFingerprint(title='Fingerprint', translatable=False), - GuiVTLSItemTLSAlpn(title='Alpn', translatable=False), - GuiVTLSItemTLSXXXTextInput( - title='EchConfigList', - key='echConfigList', - translatable=False, - ), - GuiVTLSItemTLSXXXTextInput( - title='VerifyPeerCertByName', - key='verifyPeerCertByName', - translatable=False, - ), - GuiVTLSItemTLSXXXTextInput( - title='PinnedPeerCertSha256', - key='pinnedPeerCertSha256', - translatable=False, - ), - GuiVTLSItemTLSAllowInsecure(title='AllowInsecure', translatable=False), - ] - - -class GuiVTLSPageReality(GuiVTLSPageXXX): - """Represent GUI vtls page reality.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTLSPageReality.""" - super().__init__(*args, **kwargs) - - def setupLayout(self): - """Set up layout.""" - layout = QGridLayout() - layout.setColumnStretch(1, 1) - layout.setColumnStretch(3, 1) - layout.setHorizontalSpacing(12) - - def addPair(index: int, row: int, column: int): - """Add pair.""" - label, inputWidget = self._containers[index].widgets() - - layout.addWidget(label, row, column) - layout.addWidget(inputWidget, row, column + 1) - - def addFullRow(index: int, row: int): - """Add full row.""" - label, inputWidget = self._containers[index].widgets() - - layout.addWidget(label, row, 0) - layout.addWidget(inputWidget, row, 1, 1, 3) - - addPair(0, 0, 0) - addPair(2, 0, 2) - addPair(1, 1, 0) - addPair(4, 1, 2) - addFullRow(3, 2) - addFullRow(5, 3) - addFullRow(6, 4) - - self.setLayout(layout) - - def containerSequence(self): - """Return the editor item containers in display order.""" - return [ - GuiVTLSItemSecurity(title='TLS', translatable=False), - GuiVTLSItemRealityServerName(title='SNI', translatable=False), - GuiVTLSItemRealityFingerprint(title='Fingerprint', translatable=False), - GuiVTLSItemRealityPublicKey(title='PublicKey', translatable=False), - GuiVTLSItemRealityShortId(title='ShortId', translatable=False), - GuiVTLSItemRealityMldsa65Verify(title='Mldsa65Verify', translatable=False), - GuiVTLSItemRealitySpiderX(title='SpiderX', translatable=False), - ] - - -class GuiVTLSPageStackedWidget(QStackedWidget): - """Provide the GUI vtls page stacked widget.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTLSPageStackedWidget.""" - super().__init__(*args, **kwargs) - - # Corresponds to stream security - self._pages = [ - GuiVTLSPageEmpty(), - GuiVTLSPageNone(), - GuiVTLSPageTLS(), - GuiVTLSPageReality(), - ] - - for page in self._pages: - self.addWidget(page) - - def page(self, index: int) -> GuiVTLSPageXXX: - """Return the page value.""" - return self._pages[index] - - def connectActivated(self, func: Callable): - """Connect activated.""" - for page in self._pages: - page.connectActivated(func) - - -class GuiVTLSQGroupBox(GuiEditorItemFactory, AppQGroupBox): - """Group the GUI vtlsq editor controls.""" - - def __init__(self, **kwargs): - """Initialize the GuiVTLSQGroupBox.""" - translatable = kwargs.pop('translatable', False) - - super().__init__('TLS', **kwargs, translatable=translatable) - - self._config = ConfigFactory() - - self._widget = GuiVTLSPageStackedWidget() - self._widget.connectActivated(self.handleActivated) - - layout = QFormLayout() - layout.addRow(self._widget) - - self.setLayout(layout) - - def currentIndex(self) -> int: - """Return the current index value.""" - return self._widget.currentIndex() - - def setCurrentIndex(self, index: int): - """Set current index.""" - self._widget.setCurrentIndex(index) - - def page(self, index: int) -> GuiVTLSPageXXX: - """Return the page value.""" - return self._widget.page(index) - - def handleActivated(self, index: int): - """Handle activated.""" - page = self.page(index) - page.factoryToInput(self._config) - page.setSecurityText(STREAM_SECURITY[index]) - - self.setCurrentIndex(index) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - return self.page(self.currentIndex()).inputToFactory(config) - - def factoryToInput(self, config: ConfigFactory): - # Shallow copy - """Load the configuration value into the editor.""" - self._config = config - - streamSettings = ConfigXray.getProxyOutboundStream(config) - security = streamSettings.get('security', '') - - if not isinstance(security, str): - return - - try: - index = STREAM_SECURITY.index(security) - except Exception: - # Any non-exit exceptions - - pass - else: - self.page(index).factoryToInput(config) - self.setCurrentIndex(index) +from Furious.Plugins.Official.Xray.GuiVTLS import * diff --git a/Furious/Widget/GuiVTransport.py b/Furious/Widget/GuiVTransport.py index 29e69b2b..cbb891fa 100644 --- a/Furious/Widget/GuiVTransport.py +++ b/Furious/Widget/GuiVTransport.py @@ -1,2000 +1,5 @@ -# Copyright (C) 2024–present Loren Eteval & contributors -# -# This file is part of Furious. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Provide widgets for GUI v transport.""" +"""Provide compatibility imports for the official Xray transport editors.""" from __future__ import annotations -from Furious.Interface import * -from Furious.Library import * -from Furious.Qt import * -from Furious.Qt import gettext as _ - -from PySide6.QtWidgets import * - -from typing import Callable - -__all__ = ['GuiVTransportQGroupBox'] - -STREAM_NETWORK = [ - 'tcp', - 'raw', - 'kcp', - 'ws', - 'h2', - 'quic', - 'grpc', - 'httpupgrade', - 'splithttp', - 'xhttp', - 'hysteria', -] - - -class GuiVTransportItemNetwork(GuiEditorItemTextComboBox): - """Represent GUI v transport item network.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTransportItemNetwork.""" - super().__init__(*args, **kwargs) - - self.addItems(STREAM_NETWORK) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - streamSettings = ConfigXray.getProxyOutboundStream(config) - - try: - oldNetwork = streamSettings['network'] - except Exception: - # Any non-exit exceptions - - oldNetwork = '' - - newNetwork = self.text() - - def setNewNetwork(): - """Set new network.""" - streamSettings['network'] = newNetwork - - for network in STREAM_NETWORK: - if network == newNetwork: - continue - - if network == 'h2': - networkKey = 'httpSettings' - else: - networkKey = f'{network}Settings' - - # Remove irrelevant settings - streamSettings.pop(networkKey, None) - - if isinstance(oldNetwork, str): - if newNetwork != oldNetwork: - setNewNetwork() - - return True - else: - return False - else: - setNewNetwork() - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - streamSettings = ConfigXray.getProxyOutboundStream(config) - - self.setText(streamSettings.get('network', '')) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiVTransportItemFinalMask(GuiEditorItemTextInput): - """Represent GUI v transport item final mask.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTransportItemFinalMask.""" - super().__init__(*args, **kwargs) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - streamSettings = ConfigXray.getProxyOutboundStream(config) - - oldFinalMaskObject = streamSettings.get('finalmask') - newFinalMaskText = self.text().strip() - - if newFinalMaskText == '': - if 'finalmask' in streamSettings: - streamSettings.pop('finalmask', None) - - return True - - return False - - try: - newFinalMaskObject = UJSONEncoder.decode(newFinalMaskText) - except Exception: - # Any non-exit exceptions - - newFinalMaskObject = {} - - if not isinstance(oldFinalMaskObject, dict): - streamSettings['finalmask'] = newFinalMaskObject - - return True - - if newFinalMaskObject != oldFinalMaskObject: - streamSettings['finalmask'] = newFinalMaskObject - - return True - - return False - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - finalMaskObject = ConfigXray.getProxyOutboundStream(config)['finalmask'] - - self.setText(UJSONEncoder.encode(finalMaskObject)) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiVTransportItemTypeXXX(GuiEditorItemTextComboBox): - """Represent GUI v transport item type xxx.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTransportItemTypeXXX.""" - networkKey = kwargs.pop('networkKey', '') - - super().__init__(*args, **kwargs) - - self.networkKey = networkKey - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - streamSettings = ConfigXray.getProxyOutboundStream(config) - - if not isinstance(streamSettings.get(self.networkKey), dict): - streamSettings[self.networkKey] = {} - - xxxObject = streamSettings[self.networkKey] - - try: - oldType = xxxObject['header']['type'] - except Exception: - # Any non-exit exceptions - - oldType = '' - - newType = self.text() - - def setNewType(): - """Set new type.""" - if not isinstance(xxxObject.get('header'), dict): - xxxObject['header'] = {} - - if newType == '': - xxxObject['header'].pop('type', None) - else: - xxxObject['header']['type'] = newType - - if isinstance(oldType, str): - if newType != oldType: - setNewType() - - return True - else: - return False - else: - setNewType() - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - xxxObject = ConfigXray.getProxyOutboundStream(config)[self.networkKey] - - self.setText(xxxObject['header']['type']) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiVTransportItemTypeTcpOrRaw(GuiVTransportItemTypeXXX): - """Represent GUI v transport item type TCP or raw.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTransportItemTypeTcpOrRaw.""" - networkKey = kwargs.pop('networkKey', 'tcpSettings') - - super().__init__(*args, **kwargs, networkKey=networkKey) - - self.addItems( - [ - '', - 'none', - 'http', - ] - ) - - -class GuiVTransportItemHostTcpOrRaw(GuiEditorItemTextInput): - """Represent GUI v transport item host TCP or raw.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTransportItemHostTcpOrRaw.""" - networkKey = kwargs.pop('networkKey', 'tcpSettings') - - super().__init__(*args, **kwargs) - - self.networkKey = networkKey - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - streamSettings = ConfigXray.getProxyOutboundStream(config) - - if not isinstance(streamSettings.get(self.networkKey), dict): - streamSettings[self.networkKey] = {} - - tcpObject = streamSettings[self.networkKey] - - try: - oldHost = ','.join(tcpObject['header']['request']['headers']['Host']) - except Exception: - # Any non-exit exceptions - - oldHost = '' - - newHost = self.text() - - def setNewHost(): - """Set new host.""" - if not isinstance(tcpObject.get('header'), dict): - tcpObject['header'] = {} - - if not isinstance(tcpObject['header'].get('request'), dict): - tcpObject['header']['request'] = {} - - if not isinstance(tcpObject['header']['request'].get('headers'), dict): - tcpObject['header']['request']['headers'] = {} - - if newHost == '': - tcpObject['header']['request']['headers'].pop('Host', None) - else: - tcpObject['header']['request']['headers']['Host'] = newHost.split(',') - - if isinstance(oldHost, str): - if newHost != oldHost: - setNewHost() - - return True - else: - return False - else: - setNewHost() - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - tcpObject = ConfigXray.getProxyOutboundStream(config)[self.networkKey] - - self.setText(','.join(tcpObject['header']['request']['headers']['Host'])) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiVTransportItemPathTcpOrRaw(GuiEditorItemTextInput): - """Represent GUI v transport item path TCP or raw.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTransportItemPathTcpOrRaw.""" - networkKey = kwargs.pop('networkKey', 'tcpSettings') - - super().__init__(*args, **kwargs) - - self.networkKey = networkKey - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - streamSettings = ConfigXray.getProxyOutboundStream(config) - - if not isinstance(streamSettings.get(self.networkKey), dict): - streamSettings[self.networkKey] = {} - - tcpObject = streamSettings[self.networkKey] - - try: - oldPath = ','.join(tcpObject['header']['request']['path']) - except Exception: - # Any non-exit exceptions - - oldPath = '' - - newPath = self.text() - - def setNewPath(): - """Set new path.""" - if not isinstance(tcpObject.get('header'), dict): - tcpObject['header'] = {} - - if not isinstance(tcpObject['header'].get('request'), dict): - tcpObject['header']['request'] = {} - - if newPath == '': - tcpObject['header']['request'].pop('path', None) - else: - tcpObject['header']['request']['path'] = newPath.split(',') - - if isinstance(oldPath, str): - if newPath != oldPath: - setNewPath() - - return True - else: - return False - else: - setNewPath() - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - tcpObject = ConfigXray.getProxyOutboundStream(config)[self.networkKey] - - self.setText(','.join(tcpObject['header']['request']['path'])) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiVTransportItemTypeKcp(GuiVTransportItemTypeXXX): - """Represent GUI v transport item type kcp.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTransportItemTypeKcp.""" - networkKey = kwargs.pop('networkKey', 'kcpSettings') - - super().__init__(*args, **kwargs, networkKey=networkKey) - - self.addItems( - [ - '', - 'none', - 'srtp', - 'utp', - 'wechat-video', - 'dtls', - 'wireguard', - 'dns', - ] - ) - - -class GuiVTransportItemSeedKcp(GuiEditorItemTextInput): - """Represent GUI v transport item seed kcp.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTransportItemSeedKcp.""" - super().__init__(*args, **kwargs) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - streamSettings = ConfigXray.getProxyOutboundStream(config) - - if not isinstance(streamSettings.get('kcpSettings'), dict): - streamSettings['kcpSettings'] = {} - - kcpObject = streamSettings['kcpSettings'] - - try: - oldSeed = kcpObject.get('seed', '') - except Exception: - # Any non-exit exceptions - - oldSeed = '' - - newSeed = self.text() - - def setNewSeed(): - """Set new seed.""" - if newSeed == '': - kcpObject.pop('seed', None) - else: - kcpObject['seed'] = newSeed - - if isinstance(oldSeed, str): - if newSeed != oldSeed: - setNewSeed() - - return True - else: - return False - else: - setNewSeed() - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - kcpObject = ConfigXray.getProxyOutboundStream(config)['kcpSettings'] - - self.setText(kcpObject.get('seed', '')) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiVTransportItemHostWs(GuiEditorItemTextInput): - """Represent GUI v transport item host ws.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTransportItemHostWs.""" - super().__init__(*args, **kwargs) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - streamSettings = ConfigXray.getProxyOutboundStream(config) - - if not isinstance(streamSettings.get('wsSettings'), dict): - streamSettings['wsSettings'] = {} - - wsObject = streamSettings['wsSettings'] - - try: - oldHost = wsObject['headers']['Host'] - except Exception: - # Any non-exit exceptions - - oldHost = '' - - newHost = self.text() - - def setNewHost(): - """Set new host.""" - if not isinstance(wsObject.get('headers'), dict): - wsObject['headers'] = {} - - if newHost == '': - wsObject['headers'].pop('Host', None) - else: - wsObject['headers']['Host'] = newHost - - if isinstance(oldHost, str): - if newHost != oldHost: - setNewHost() - - return True - else: - return False - else: - setNewHost() - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - wsObject = ConfigXray.getProxyOutboundStream(config)['wsSettings'] - - self.setText(wsObject['headers']['Host']) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiVTransportItemPathWs(GuiEditorItemTextInput): - """Represent GUI v transport item path ws.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTransportItemPathWs.""" - super().__init__(*args, **kwargs) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - streamSettings = ConfigXray.getProxyOutboundStream(config) - - if not isinstance(streamSettings.get('wsSettings'), dict): - streamSettings['wsSettings'] = {} - - wsObject = streamSettings['wsSettings'] - - try: - oldPath = wsObject.get('path', '') - except Exception: - # Any non-exit exceptions - - oldPath = '' - - newPath = self.text() - - def setNewPath(): - """Set new path.""" - if newPath == '': - wsObject.pop('path', None) - else: - wsObject['path'] = newPath - - if isinstance(oldPath, str): - if newPath != oldPath: - setNewPath() - - return True - else: - return False - else: - setNewPath() - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - wsObject = ConfigXray.getProxyOutboundStream(config)['wsSettings'] - - self.setText(wsObject.get('path', '')) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiVTransportItemHostHttpUpgrade(GuiEditorItemTextInput): - """Represent GUI v transport item host HTTP upgrade.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTransportItemHostHttpUpgrade.""" - super().__init__(*args, **kwargs) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - streamSettings = ConfigXray.getProxyOutboundStream(config) - - if not isinstance(streamSettings.get('httpupgradeSettings'), dict): - streamSettings['httpupgradeSettings'] = {} - - httpUpgradeObject = streamSettings['httpupgradeSettings'] - - try: - oldHost = httpUpgradeObject.get('host', '') - except Exception: - # Any non-exit exceptions - - oldHost = '' - - newHost = self.text() - - def setNewHost(): - """Set new host.""" - if newHost == '': - httpUpgradeObject.pop('host', None) - else: - httpUpgradeObject['host'] = newHost - - if isinstance(oldHost, str): - if newHost != oldHost: - setNewHost() - - return True - else: - return False - else: - setNewHost() - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - httpUpgradeObject = ConfigXray.getProxyOutboundStream(config)[ - 'httpupgradeSettings' - ] - - self.setText(httpUpgradeObject.get('host', '')) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiVTransportItemPathHttpUpgrade(GuiEditorItemTextInput): - """Represent GUI v transport item path HTTP upgrade.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTransportItemPathHttpUpgrade.""" - super().__init__(*args, **kwargs) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - streamSettings = ConfigXray.getProxyOutboundStream(config) - - if not isinstance(streamSettings.get('httpupgradeSettings'), dict): - streamSettings['httpupgradeSettings'] = {} - - httpUpgradeObject = streamSettings['httpupgradeSettings'] - - try: - oldPath = httpUpgradeObject.get('path', '') - except Exception: - # Any non-exit exceptions - - oldPath = '' - - newPath = self.text() - - def setNewPath(): - """Set new path.""" - if newPath == '': - httpUpgradeObject.pop('path', None) - else: - httpUpgradeObject['path'] = newPath - - if isinstance(oldPath, str): - if newPath != oldPath: - setNewPath() - - return True - else: - return False - else: - setNewPath() - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - httpUpgradeObject = ConfigXray.getProxyOutboundStream(config)[ - 'httpupgradeSettings' - ] - - self.setText(httpUpgradeObject.get('path', '')) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiVTransportItemHostSplitHttp(GuiEditorItemTextInput): - """Represent GUI v transport item host split HTTP.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTransportItemHostSplitHttp.""" - super().__init__(*args, **kwargs) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - streamSettings = ConfigXray.getProxyOutboundStream(config) - - if not isinstance(streamSettings.get('splithttpSettings'), dict): - streamSettings['splithttpSettings'] = {} - - splitHttpObject = streamSettings['splithttpSettings'] - - try: - oldHost = splitHttpObject.get('host', '') - except Exception: - # Any non-exit exceptions - - oldHost = '' - - newHost = self.text() - - def setNewHost(): - """Set new host.""" - if newHost == '': - splitHttpObject.pop('host', None) - else: - splitHttpObject['host'] = newHost - - if isinstance(oldHost, str): - if newHost != oldHost: - setNewHost() - - return True - else: - return False - else: - setNewHost() - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - splitHttpObject = ConfigXray.getProxyOutboundStream(config)[ - 'splithttpSettings' - ] - - self.setText(splitHttpObject.get('host', '')) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiVTransportItemPathSplitHttp(GuiEditorItemTextInput): - """Represent GUI v transport item path split HTTP.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTransportItemPathSplitHttp.""" - super().__init__(*args, **kwargs) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - streamSettings = ConfigXray.getProxyOutboundStream(config) - - if not isinstance(streamSettings.get('splithttpSettings'), dict): - streamSettings['splithttpSettings'] = {} - - splitHttpObject = streamSettings['splithttpSettings'] - - try: - oldPath = splitHttpObject.get('path', '') - except Exception: - # Any non-exit exceptions - - oldPath = '' - - newPath = self.text() - - def setNewPath(): - """Set new path.""" - if newPath == '': - splitHttpObject.pop('path', None) - else: - splitHttpObject['path'] = newPath - - if isinstance(oldPath, str): - if newPath != oldPath: - setNewPath() - - return True - else: - return False - else: - setNewPath() - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - splitHttpObject = ConfigXray.getProxyOutboundStream(config)[ - 'splithttpSettings' - ] - - self.setText(splitHttpObject.get('path', '')) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiVTransportItemHostXHttp(GuiEditorItemTextInput): - """Represent GUI v transport item host x HTTP.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTransportItemHostXHttp.""" - super().__init__(*args, **kwargs) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - streamSettings = ConfigXray.getProxyOutboundStream(config) - - if not isinstance(streamSettings.get('xhttpSettings'), dict): - streamSettings['xhttpSettings'] = {} - - xhttpObject = streamSettings['xhttpSettings'] - - try: - oldHost = xhttpObject.get('host', '') - except Exception: - # Any non-exit exceptions - - oldHost = '' - - newHost = self.text() - - def setNewHost(): - """Set new host.""" - if newHost == '': - xhttpObject.pop('host', None) - else: - xhttpObject['host'] = newHost - - if isinstance(oldHost, str): - if newHost != oldHost: - setNewHost() - - return True - else: - return False - else: - setNewHost() - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - xhttpObject = ConfigXray.getProxyOutboundStream(config)['xhttpSettings'] - - self.setText(xhttpObject.get('host', '')) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiVTransportItemPathXHttp(GuiEditorItemTextInput): - """Represent GUI v transport item path x HTTP.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTransportItemPathXHttp.""" - super().__init__(*args, **kwargs) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - streamSettings = ConfigXray.getProxyOutboundStream(config) - - if not isinstance(streamSettings.get('xhttpSettings'), dict): - streamSettings['xhttpSettings'] = {} - - xhttpObject = streamSettings['xhttpSettings'] - - try: - oldPath = xhttpObject.get('path', '') - except Exception: - # Any non-exit exceptions - - oldPath = '' - - newPath = self.text() - - def setNewPath(): - """Set new path.""" - if newPath == '': - xhttpObject.pop('path', None) - else: - xhttpObject['path'] = newPath - - if isinstance(oldPath, str): - if newPath != oldPath: - setNewPath() - - return True - else: - return False - else: - setNewPath() - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - xhttpObject = ConfigXray.getProxyOutboundStream(config)['xhttpSettings'] - - self.setText(xhttpObject.get('path', '')) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiVTransportItemModeXHttp(GuiEditorItemTextComboBox): - """Represent GUI v transport item mode x HTTP.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTransportItemModeXHttp.""" - super().__init__(*args, **kwargs) - - self.addItems( - [ - '', - 'auto', - 'packet-up', - 'stream-up', - ] - ) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - streamSettings = ConfigXray.getProxyOutboundStream(config) - - if not isinstance(streamSettings.get('xhttpSettings'), dict): - streamSettings['xhttpSettings'] = {} - - xhttpObject = streamSettings['xhttpSettings'] - - try: - oldMode = xhttpObject.get('mode', '') - except Exception: - # Any non-exit exceptions - - oldMode = '' - - newMode = self.text() - - def setNewMode(): - """Set new mode.""" - if newMode == '': - xhttpObject.pop('mode', None) - else: - xhttpObject['mode'] = newMode - - if isinstance(oldMode, str): - if newMode != oldMode: - setNewMode() - - return True - else: - return False - else: - setNewMode() - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - xhttpObject = ConfigXray.getProxyOutboundStream(config)['xhttpSettings'] - - self.setText(xhttpObject.get('mode', '')) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiVTransportItemExtraXHttp(GuiEditorItemTextInput): - """Represent GUI v transport item extra x HTTP.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTransportItemExtraXHttp.""" - super().__init__(*args, **kwargs) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - streamSettings = ConfigXray.getProxyOutboundStream(config) - - if not isinstance(streamSettings.get('xhttpSettings'), dict): - streamSettings['xhttpSettings'] = {} - - xhttpObject = streamSettings['xhttpSettings'] - - try: - if xhttpObject.get('extra', ''): - oldExtra = UJSONEncoder.encode(xhttpObject.get('extra', '')) - else: - oldExtra = '' - except Exception: - # Any non-exit exceptions - - oldExtra = '' - - newExtra = self.text() - - def setNewExtra(): - """Set new extra.""" - if newExtra == '': - xhttpObject.pop('extra', None) - else: - try: - xhttpObject['extra'] = UJSONEncoder.decode(newExtra) - except Exception: - # Any non-exit exceptions - - xhttpObject.pop('extra', None) - - if isinstance(oldExtra, str): - if newExtra != oldExtra: - setNewExtra() - - return True - else: - return False - else: - setNewExtra() - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - xhttpObject = ConfigXray.getProxyOutboundStream(config)['xhttpSettings'] - - if xhttpObject.get('extra', ''): - self.setText(UJSONEncoder.encode(xhttpObject.get('extra', ''))) - else: - self.setText('') - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiVTransportItemHostH2(GuiEditorItemTextInput): - """Represent GUI v transport item host h2.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTransportItemHostH2.""" - super().__init__(*args, **kwargs) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - streamSettings = ConfigXray.getProxyOutboundStream(config) - - if not isinstance(streamSettings.get('httpSettings'), dict): - streamSettings['httpSettings'] = {} - - httpObject = streamSettings['httpSettings'] - - try: - oldHost = ','.join(httpObject.get('host', [])) - except Exception: - # Any non-exit exceptions - - oldHost = '' - - newHost = self.text() - - def setNewHost(): - """Set new host.""" - if newHost == '': - httpObject.pop('host', None) - else: - httpObject['host'] = newHost.split(',') - - if isinstance(oldHost, str): - if newHost != oldHost: - setNewHost() - - return True - else: - return False - else: - setNewHost() - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - httpObject = ConfigXray.getProxyOutboundStream(config)['httpSettings'] - - self.setText(','.join(httpObject.get('host', []))) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiVTransportItemPathH2(GuiEditorItemTextInput): - """Represent GUI v transport item path h2.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTransportItemPathH2.""" - super().__init__(*args, **kwargs) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - streamSettings = ConfigXray.getProxyOutboundStream(config) - - if not isinstance(streamSettings.get('httpSettings'), dict): - streamSettings['httpSettings'] = {} - - httpObject = streamSettings['httpSettings'] - - try: - oldPath = httpObject.get('path', '') - except Exception: - # Any non-exit exceptions - - oldPath = '' - - newPath = self.text() - - def setNewPath(): - """Set new path.""" - if newPath == '': - httpObject.pop('path', None) - else: - httpObject['path'] = newPath - - if isinstance(oldPath, str): - if newPath != oldPath: - setNewPath() - - return True - else: - return False - else: - setNewPath() - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - httpObject = ConfigXray.getProxyOutboundStream(config)['httpSettings'] - - self.setText(httpObject.get('path', '')) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiVTransportItemTypeQuic(GuiVTransportItemTypeXXX): - """Represent GUI v transport item type quic.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTransportItemTypeQuic.""" - networkKey = kwargs.pop('networkKey', 'quicSettings') - - super().__init__(*args, **kwargs, networkKey=networkKey) - - self.addItems( - [ - '', - 'none', - 'srtp', - 'utp', - 'wechat-video', - 'dtls', - 'wireguard', - ] - ) - - -class GuiVTransportItemSecurityQuic(GuiEditorItemTextComboBox): - """Represent GUI v transport item security quic.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTransportItemSecurityQuic.""" - super().__init__(*args, **kwargs) - - self.addItems( - [ - '', - 'none', - 'aes-128-gcm', - 'chacha20-poly1305', - ] - ) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - streamSettings = ConfigXray.getProxyOutboundStream(config) - - if not isinstance(streamSettings.get('quicSettings'), dict): - streamSettings['quicSettings'] = {} - - quicObject = streamSettings['quicSettings'] - - try: - oldSecurity = quicObject.get('security', '') - except Exception: - # Any non-exit exceptions - - oldSecurity = '' - - newSecurity = self.text() - - def setNewSecurity(): - """Set new security.""" - quicObject['security'] = newSecurity - - if isinstance(oldSecurity, str): - if newSecurity != oldSecurity: - setNewSecurity() - - return True - else: - return False - else: - setNewSecurity() - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - quicObject = ConfigXray.getProxyOutboundStream(config)['quicSettings'] - - self.setText(quicObject.get('security', '')) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiVTransportItemKeyQuic(GuiEditorItemTextInput): - """Represent GUI v transport item key quic.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTransportItemKeyQuic.""" - super().__init__(*args, **kwargs) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - streamSettings = ConfigXray.getProxyOutboundStream(config) - - if not isinstance(streamSettings.get('quicSettings'), dict): - streamSettings['quicSettings'] = {} - - quicObject = streamSettings['quicSettings'] - - try: - oldKey = quicObject.get('key', '') - except Exception: - # Any non-exit exceptions - - oldKey = '' - - newKey = self.text() - - def setNewKey(): - """Set new key.""" - if newKey == '': - quicObject.pop('key', None) - else: - quicObject['key'] = newKey - - if isinstance(oldKey, str): - if newKey != oldKey: - setNewKey() - - return True - else: - return False - else: - setNewKey() - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - quicObject = ConfigXray.getProxyOutboundStream(config)['quicSettings'] - - self.setText(quicObject.get('key', '')) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiVTransportItemModeGRPC(GuiEditorItemTextComboBox): - """Represent GUI v transport item mode grpc.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTransportItemModeGRPC.""" - super().__init__(*args, **kwargs) - - self.addItems( - [ - '', - 'gun', - 'multi', - ] - ) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - streamSettings = ConfigXray.getProxyOutboundStream(config) - - if not isinstance(streamSettings.get('grpcSettings'), dict): - streamSettings['grpcSettings'] = {} - - grpcObject = streamSettings['grpcSettings'] - - try: - oldMode = grpcObject.get('multiMode', False) - except Exception: - # Any non-exit exceptions - - oldMode = False - - if oldMode is True: - oldMode = 'multi' - else: - oldMode = 'gun' - - newMode = self.text() - - def setNewMode(): - """Set new mode.""" - if newMode == 'multi': - grpcObject['multiMode'] = True - else: - grpcObject['multiMode'] = False - - if isinstance(oldMode, str): - if newMode != oldMode: - setNewMode() - - return True - else: - return False - else: - setNewMode() - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - grpcObject = ConfigXray.getProxyOutboundStream(config)['grpcSettings'] - - multi = grpcObject.get('multiMode', False) - - if multi is True: - self.setText('multi') - elif multi is False: - self.setText('gun') - else: - self.setText('') - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiVTransportItemAuthorityGRPC(GuiEditorItemTextInput): - """Represent GUI v transport item authority grpc.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTransportItemAuthorityGRPC.""" - super().__init__(*args, **kwargs) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - streamSettings = ConfigXray.getProxyOutboundStream(config) - - if not isinstance(streamSettings.get('grpcSettings'), dict): - streamSettings['grpcSettings'] = {} - - grpcObject = streamSettings['grpcSettings'] - - try: - oldAuthority = grpcObject.get('authority', '') - except Exception: - # Any non-exit exceptions - - oldAuthority = '' - - newAuthority = self.text() - - def setNewAuthority(): - """Set new authority.""" - if newAuthority == '': - grpcObject.pop('authority', None) - else: - grpcObject['authority'] = newAuthority - - if isinstance(oldAuthority, str): - if newAuthority != oldAuthority: - setNewAuthority() - - return True - else: - return False - else: - setNewAuthority() - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - grpcObject = ConfigXray.getProxyOutboundStream(config)['grpcSettings'] - - self.setText(grpcObject.get('authority', '')) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiVTransportItemServiceNameGRPC(GuiEditorItemTextInput): - """Represent GUI v transport item service name grpc.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTransportItemServiceNameGRPC.""" - super().__init__(*args, **kwargs) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - streamSettings = ConfigXray.getProxyOutboundStream(config) - - if not isinstance(streamSettings.get('grpcSettings'), dict): - streamSettings['grpcSettings'] = {} - - grpcObject = streamSettings['grpcSettings'] - - try: - oldServiceName = grpcObject.get('serviceName', '') - except Exception: - # Any non-exit exceptions - - oldServiceName = '' - - newServiceName = self.text() - - def setNewServiceName(): - """Set new service name.""" - if newServiceName == '': - grpcObject.pop('serviceName', None) - else: - grpcObject['serviceName'] = newServiceName - - if isinstance(oldServiceName, str): - if newServiceName != oldServiceName: - setNewServiceName() - - return True - else: - return False - else: - setNewServiceName() - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - grpcObject = ConfigXray.getProxyOutboundStream(config)['grpcSettings'] - - self.setText(grpcObject.get('serviceName', '')) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiVTransportItemVersionHysteria(GuiEditorItemTextSpinBox): - """Represent GUI v transport item version hysteria.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTransportItemVersionHysteria.""" - super().__init__(*args, **kwargs) - - # Range. 0 means invalid - self.setRange(0, 2) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - streamSettings = ConfigXray.getProxyOutboundStream(config) - - if not isinstance(streamSettings.get('hysteriaSettings'), dict): - streamSettings['hysteriaSettings'] = {} - - hysteriaObject = streamSettings['hysteriaSettings'] - - oldVersion = hysteriaObject.get('version', 0) - newVersion = self.value() - - if isinstance(oldVersion, int): - if newVersion != oldVersion: - hysteriaObject['version'] = newVersion - - return True - else: - return False - else: - hysteriaObject['version'] = newVersion - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - hysteriaObject = ConfigXray.getProxyOutboundStream(config)[ - 'hysteriaSettings' - ] - - self.setValue(hysteriaObject.get('version', 0)) - except Exception: - # Any non-exit exceptions - - self.setValue(0) - - -class GuiVTransportItemAuthHysteria(GuiEditorItemTextInput): - """Represent GUI v transport item auth hysteria.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTransportItemAuthHysteria.""" - super().__init__(*args, **kwargs) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - streamSettings = ConfigXray.getProxyOutboundStream(config) - - if not isinstance(streamSettings.get('hysteriaSettings'), dict): - streamSettings['hysteriaSettings'] = {} - - hysteriaObject = streamSettings['hysteriaSettings'] - - try: - oldAuth = hysteriaObject.get('auth', '') - except Exception: - # Any non-exit exceptions - - oldAuth = '' - - newAuth = self.text() - - def setNewAuth(): - """Set new auth.""" - if newAuth == '': - hysteriaObject.pop('auth', None) - else: - hysteriaObject['auth'] = newAuth - - if isinstance(oldAuth, str): - if newAuth != oldAuth: - setNewAuth() - - return True - else: - return False - else: - setNewAuth() - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - hysteriaObject = ConfigXray.getProxyOutboundStream(config)[ - 'hysteriaSettings' - ] - - self.setText(hysteriaObject.get('auth', '')) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiVTransportItemPasswordHysteria(GuiEditorItemTextInput): - """Represent GUI v transport item password hysteria.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTransportItemPasswordHysteria.""" - super().__init__(*args, **kwargs) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - streamSettings = ConfigXray.getProxyOutboundStream(config) - - if not isinstance(streamSettings.get('finalmask'), dict): - streamSettings['finalmask'] = {} - - finalmaskObject = streamSettings['finalmask'] - - if not isinstance(finalmaskObject.get('salamander'), dict): - finalmaskObject['salamander'] = {} - - salamander = finalmaskObject['salamander'] - - try: - oldPassword = salamander.get('password', '') - except Exception: - # Any non-exit exceptions - - oldPassword = '' - - newPassword = self.text() - - def setNewPassword(): - """Set new password.""" - if newPassword == '': - finalmaskObject.pop('salamander', None) - else: - salamander['password'] = newPassword - - if isinstance(oldPassword, str): - if newPassword != oldPassword: - setNewPassword() - - return True - else: - return False - else: - setNewPassword() - - return True - - def factoryToInput(self, config: ConfigFactory): - """Load the configuration value into the editor.""" - try: - salamander = ConfigXray.getProxyOutboundStream(config)['finalmask'][ - 'salamander' - ] - - self.setText(salamander.get('password', '')) - except Exception: - # Any non-exit exceptions - - self.setText('') - - -class GuiVTransportPageXXX(GuiEditorWidgetQWidget): - """Represent GUI v transport page xxx.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTransportPageXXX.""" - super().__init__(*args, **kwargs) - - def setNetworkText(self, text: str): - """Set network text.""" - network = self._containers[0] - - if isinstance(network, GuiEditorItemTextComboBox): - network.setText(text) - - def connectActivated(self, func: Callable): - """Connect activated.""" - network = self._containers[0] - - if isinstance(network, GuiEditorItemTextComboBox): - network.connectActivated(func) - - -class GuiVTransportPageTcp(GuiVTransportPageXXX): - """Represent GUI v transport page TCP.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTransportPageTcp.""" - super().__init__(*args, **kwargs) - - def containerSequence(self): - """Return the editor item containers in display order.""" - return [ - GuiVTransportItemNetwork(title='Network', translatable=False), - GuiVTransportItemFinalMask(title='Finalmask', translatable=False), - GuiVTransportItemTypeTcpOrRaw( - title='Type', networkKey='tcpSettings', translatable=False - ), - GuiVTransportItemHostTcpOrRaw( - title='Host', networkKey='tcpSettings', translatable=False - ), - GuiVTransportItemPathTcpOrRaw( - title='Path', networkKey='tcpSettings', translatable=False - ), - ] - - -class GuiVTransportPageRaw(GuiVTransportPageXXX): - """Represent GUI v transport page raw.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTransportPageRaw.""" - super().__init__(*args, **kwargs) - - def containerSequence(self): - """Return the editor item containers in display order.""" - return [ - GuiVTransportItemNetwork(title='Network', translatable=False), - GuiVTransportItemFinalMask(title='Finalmask', translatable=False), - GuiVTransportItemTypeTcpOrRaw( - title='Type', networkKey='rawSettings', translatable=False - ), - GuiVTransportItemHostTcpOrRaw( - title='Host', networkKey='rawSettings', translatable=False - ), - GuiVTransportItemPathTcpOrRaw( - title='Path', networkKey='rawSettings', translatable=False - ), - ] - - -class GuiVTransportPageKcp(GuiVTransportPageXXX): - """Represent GUI v transport page kcp.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTransportPageKcp.""" - super().__init__(*args, **kwargs) - - def containerSequence(self): - """Return the editor item containers in display order.""" - return [ - GuiVTransportItemNetwork(title='Network', translatable=False), - GuiVTransportItemFinalMask(title='Finalmask', translatable=False), - GuiVTransportItemTypeKcp(title='Type', translatable=False), - GuiVTransportItemSeedKcp(title='KCP seed', translatable=False), - ] - - -class GuiVTransportPageWs(GuiVTransportPageXXX): - """Represent GUI v transport page ws.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTransportPageWs.""" - super().__init__(*args, **kwargs) - - def containerSequence(self): - """Return the editor item containers in display order.""" - return [ - GuiVTransportItemNetwork(title='Network', translatable=False), - GuiVTransportItemFinalMask(title='Finalmask', translatable=False), - GuiVTransportItemHostWs(title='Host', translatable=False), - GuiVTransportItemPathWs(title='Path', translatable=False), - ] - - -class GuiVTransportPageHttpUpgrade(GuiVTransportPageXXX): - """Represent GUI v transport page HTTP upgrade.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTransportPageHttpUpgrade.""" - super().__init__(*args, **kwargs) - - def containerSequence(self): - """Return the editor item containers in display order.""" - return [ - GuiVTransportItemNetwork(title='Network', translatable=False), - GuiVTransportItemFinalMask(title='Finalmask', translatable=False), - GuiVTransportItemHostHttpUpgrade(title='Host', translatable=False), - GuiVTransportItemPathHttpUpgrade(title='Path', translatable=False), - ] - - -class GuiVTransportPageSplitHttp(GuiVTransportPageXXX): - """Represent GUI v transport page split HTTP.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTransportPageSplitHttp.""" - super().__init__(*args, **kwargs) - - def containerSequence(self): - """Return the editor item containers in display order.""" - return [ - GuiVTransportItemNetwork(title='Network', translatable=False), - GuiVTransportItemFinalMask(title='Finalmask', translatable=False), - GuiVTransportItemHostSplitHttp(title='Host', translatable=False), - GuiVTransportItemPathSplitHttp(title='Path', translatable=False), - ] - - -class GuiVTransportPageXHttp(GuiVTransportPageXXX): - """Represent GUI v transport page x HTTP.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTransportPageXHttp.""" - super().__init__(*args, **kwargs) - - def containerSequence(self): - """Return the editor item containers in display order.""" - return [ - GuiVTransportItemNetwork(title='Network', translatable=False), - GuiVTransportItemFinalMask(title='Finalmask', translatable=False), - GuiVTransportItemHostXHttp(title='Host', translatable=False), - GuiVTransportItemPathXHttp(title='Path', translatable=False), - GuiVTransportItemModeXHttp(title='Mode', translatable=False), - GuiVTransportItemExtraXHttp(title='Extra', translatable=False), - ] - - -class GuiVTransportPageH2(GuiVTransportPageXXX): - """Represent GUI v transport page h2.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTransportPageH2.""" - super().__init__(*args, **kwargs) - - def containerSequence(self): - """Return the editor item containers in display order.""" - return [ - GuiVTransportItemNetwork(title='Network', translatable=False), - GuiVTransportItemFinalMask(title='Finalmask', translatable=False), - GuiVTransportItemHostH2(title='Host', translatable=False), - GuiVTransportItemPathH2(title='Path', translatable=False), - ] - - -class GuiVTransportPageQuic(GuiVTransportPageXXX): - """Represent GUI v transport page quic.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTransportPageQuic.""" - super().__init__(*args, **kwargs) - - def containerSequence(self): - """Return the editor item containers in display order.""" - return [ - GuiVTransportItemNetwork(title='Network', translatable=False), - GuiVTransportItemFinalMask(title='Finalmask', translatable=False), - GuiVTransportItemTypeQuic(title='Type', translatable=False), - GuiVTransportItemSecurityQuic(title='QUIC Security', translatable=False), - GuiVTransportItemKeyQuic(title='QUIC Key', translatable=False), - ] - - -class GuiVTransportPageGRPC(GuiVTransportPageXXX): - """Represent GUI v transport page grpc.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTransportPageGRPC.""" - super().__init__(*args, **kwargs) - - def containerSequence(self): - """Return the editor item containers in display order.""" - return [ - GuiVTransportItemNetwork(title='Network', translatable=False), - GuiVTransportItemFinalMask(title='Finalmask', translatable=False), - GuiVTransportItemModeGRPC(title='gRPC mode', translatable=False), - GuiVTransportItemAuthorityGRPC(title='gRPC authority', translatable=False), - GuiVTransportItemServiceNameGRPC( - title='gRPC serviceName', translatable=False - ), - ] - - -class GuiVTransportPageHysteria(GuiVTransportPageXXX): - """Represent GUI v transport page hysteria.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTransportPageHysteria.""" - super().__init__(*args, **kwargs) - - def containerSequence(self): - """Return the editor item containers in display order.""" - return [ - GuiVTransportItemNetwork(title='Network', translatable=False), - GuiVTransportItemFinalMask(title='Finalmask', translatable=False), - GuiVTransportItemVersionHysteria(title='Version', translatable=False), - GuiVTransportItemAuthHysteria(title='Auth', translatable=False), - # GuiVTransportItemPasswordHysteria(title='Password', translatable=False), - ] - - -class GuiVTransportPageStackedWidget(QStackedWidget): - """Provide the GUI v transport page stacked widget.""" - - def __init__(self, *args, **kwargs): - """Initialize the GuiVTransportPageStackedWidget.""" - super().__init__(*args, **kwargs) - - # Corresponds to stream network - self._pages = [ - GuiVTransportPageTcp(), - GuiVTransportPageRaw(), - GuiVTransportPageKcp(), - GuiVTransportPageWs(), - GuiVTransportPageH2(), - GuiVTransportPageQuic(), - GuiVTransportPageGRPC(), - GuiVTransportPageHttpUpgrade(), - GuiVTransportPageSplitHttp(), - GuiVTransportPageXHttp(), - GuiVTransportPageHysteria(), - ] - - assert len(STREAM_NETWORK) == len(self._pages) - - for page in self._pages: - self.addWidget(page) - - def page(self, index: int) -> GuiVTransportPageXXX: - """Return the page value.""" - return self._pages[index] - - def connectActivated(self, func: Callable): - """Connect activated.""" - for page in self._pages: - page.connectActivated(func) - - -class GuiVTransportQGroupBox(GuiEditorItemFactory, AppQGroupBox): - """Group the GUI v transport q editor controls.""" - - def __init__(self, **kwargs): - """Initialize the GuiVTransportQGroupBox.""" - super().__init__(_('Transport'), **kwargs) - - self._config = ConfigFactory() - - self._widget = GuiVTransportPageStackedWidget() - self._widget.connectActivated(self.handleActivated) - - layout = QFormLayout() - layout.addRow(self._widget) - - self.setLayout(layout) - - def currentIndex(self) -> int: - """Return the current index value.""" - return self._widget.currentIndex() - - def setCurrentIndex(self, index: int): - """Set current index.""" - self._widget.setCurrentIndex(index) - - def page(self, index: int) -> GuiVTransportPageXXX: - """Return the page value.""" - return self._widget.page(index) - - def handleActivated(self, index: int): - """Handle activated.""" - page = self.page(index) - page.factoryToInput(self._config) - page.setNetworkText(STREAM_NETWORK[index]) - - self.setCurrentIndex(index) - - def inputToFactory(self, config: ConfigFactory) -> bool: - """Apply the current editor value to the configuration.""" - return self.page(self.currentIndex()).inputToFactory(config) - - def factoryToInput(self, config: ConfigFactory): - # Shallow copy - """Load the configuration value into the editor.""" - self._config = config - - streamSettings = ConfigXray.getProxyOutboundStream(config) - network = streamSettings.get('network', '') - - if not isinstance(network, str): - return - - def alterNetwork(sourceNetwork, targetNetwork): - """Handle alter network for the GUI v transport q group box.""" - nonlocal network - - if network == sourceNetwork: - network = targetNetwork - - streamSettings['network'] = network - - # Adjust alternative values - # https://xtls.github.io/config/transport.html#streamsettingsobject - alterNetwork('http', 'h2') - alterNetwork('gun', 'grpc') - alterNetwork('mkcp', 'kcp') - - try: - index = STREAM_NETWORK.index(network) - except Exception: - # Any non-exit exceptions - - pass - else: - self.page(index).factoryToInput(config) - self.setCurrentIndex(index) +from Furious.Plugins.Official.Xray.GuiVTransport import * diff --git a/Furious/Widget/UserServersQTableView.py b/Furious/Widget/UserServersQTableView.py index 3f2e113e..c17d592a 100644 --- a/Furious/Widget/UserServersQTableView.py +++ b/Furious/Widget/UserServersQTableView.py @@ -22,17 +22,11 @@ from __future__ import annotations from Furious.Frozenlib import * from Furious.Interface import * from Furious.Library import * +from Furious.Plugins import getPluginRegistry from Furious.Qt import * from Furious.Qt import gettext as _ -from Furious.Core import * +from Furious.Core.CoreManager import CoreManager from Furious.TrayActions.Import import * -from Furious.Widget.GuiHysteria1 import * -from Furious.Widget.GuiHysteria2 import * -from Furious.Widget.GuiShadowsocks import * -from Furious.Widget.GuiSocks import * -from Furious.Widget.GuiTrojan import * -from Furious.Widget.GuiVLESS import * -from Furious.Widget.GuiVMess import * from Furious.Widget.WaitingSpinner import * from Furious.Window.QRCodeWindow import * from Furious.Window.TextEditorWindow import * @@ -544,81 +538,25 @@ class TestDownloadSpeedWorker(WebGETManager): pass - @functools.singledispatchmethod def _startCore(self, config) -> bool: - """Return the start core value used by the test download speed worker.""" - self.factory.setExtras('speedResult', 'Invalid') - self.sync() + """Prepare and start a download test through the owning plugin.""" + plugin = getPluginRegistry().pluginForConfig(config) + if plugin is None: + self.factory.setExtras('speedResult', 'Invalid') + self.sync() - # Unrecognized core. Return - return False + return False + + configcopy = plugin.prepareDownloadTest(config, self.port) + if configcopy is None: + self.factory.setExtras('speedResult', 'Invalid') + self.sync() + + return False - @_startCore.register(ConfigXray) - def _(self, config) -> bool: - """Handle the registered singledispatch variant.""" self.factory.setExtras('speedResult', 'Starting') self.sync() - configcopy = config.deepcopy() - # Force redirect - configcopy['inbounds'] = [ - { - 'tag': 'http', - 'port': self.port, - 'listen': '127.0.0.1', - 'protocol': 'http', - 'sniffing': { - 'enabled': True, - 'destOverride': [ - 'http', - 'tls', - ], - }, - 'settings': { - 'auth': 'noauth', - 'udp': True, - 'allowTransparent': False, - }, - }, - ] - - try: - for outboundObject in configcopy['outbounds']: - if outboundObject['tag'] == f'proxy': - # Avoid confusion with potentially existing 'proxy' tag - outboundObject['tag'] = f'proxy{self.port}' - except Exception: - # Any non-exit exceptions - - pass - - return self.coreManager.start( - configcopy, - AppBuiltinRouting.Global.value, - self.coreExitCallback, - msgCallbackCore=self.coreMsgCallback, - deepcopy=False, - proxyModeOnly=True, - log=False, - ) - - @_startCore.register(ConfigHysteria1) - @_startCore.register(ConfigHysteria2) - def _(self, config) -> bool: - """Handle the registered singledispatch variant.""" - self.factory.setExtras('speedResult', 'Starting') - self.sync() - - configcopy = config.deepcopy() - # Force redirect - configcopy['http'] = { - 'listen': f'127.0.0.1:{self.port}', - 'timeout': 300, - 'disable_udp': False, - } - # No socks inbounds - configcopy.pop('socks5', '') - return self.coreManager.start( configcopy, AppBuiltinRouting.Global.value, @@ -1872,51 +1810,27 @@ class UserServersQTableView( APP().systemTray.ConnectAction.doReconnect() @functools.lru_cache(None) - def getGuiEditorByProtocol(self, protocol: Protocol, **kwargs): + def getGuiEditorByProtocol(self, protocol, **kwargs): """Return GUI editor by protocol.""" - logger.debug(f'getGuiEditorByProtocol called with protocol {protocol.value}') + protocolId = getattr(protocol, 'value', protocol) + logger.debug(f'getGuiEditorByProtocol called with protocol {protocolId}') - if protocol == Protocol.VMess: - return GuiVMess(parent=self, **kwargs) - if protocol == Protocol.VLESS: - return GuiVLESS(parent=self, **kwargs) - if protocol == Protocol.Shadowsocks: - return GuiShadowsocks(parent=self, **kwargs) - if protocol == Protocol.Socks: - return GuiSocks(parent=self, **kwargs) - if protocol == Protocol.Trojan: - return GuiTrojan(parent=self, **kwargs) - if protocol == Protocol.Hysteria2: - return GuiHysteria2(parent=self, **kwargs) - if protocol == Protocol.Hysteria1: - return GuiHysteria1(parent=self, **kwargs) + return getPluginRegistry().createEditorForProtocol( + protocol, + parent=self, + **kwargs, + ) - return None - - @functools.singledispatchmethod def getGuiEditorByFactory( self, factory, **kwargs ) -> Union[GuiEditorWidgetQDialog, None]: """Return GUI editor by factory.""" - return None - - @getGuiEditorByFactory.register(ConfigXray) - def _(self, factory, **kwargs): - """Handle the registered singledispatch variant.""" - return self.getGuiEditorByProtocol( - Protocol.toEnum(factory.proxyProtocol), **kwargs + return getPluginRegistry().createEditorForConfig( + factory, + parent=self, + **kwargs, ) - @getGuiEditorByFactory.register(ConfigHysteria1) - def _(self, factory, **kwargs): - """Handle the registered singledispatch variant.""" - return self.getGuiEditorByProtocol(Protocol.Hysteria1, **kwargs) - - @getGuiEditorByFactory.register(ConfigHysteria2) - def _(self, factory, **kwargs): - """Handle the registered singledispatch variant.""" - return self.getGuiEditorByProtocol(Protocol.Hysteria2, **kwargs) - @QtCore.Slot(QtCore.QModelIndex) def handleItemDoubleClicked(self, modelIndex: QtCore.QModelIndex): """Handle item double clicked.""" @@ -2078,7 +1992,7 @@ class UserServersQTableView( def addServerViaGui( self, - protocol: Protocol, + protocol, windowTitle: str = APPLICATION_NAME, **kwargs, ): diff --git a/Furious/Widget/XrayAssetViewerQListWidget.py b/Furious/Widget/XrayAssetViewerQListWidget.py index aeee7010..179d88eb 100644 --- a/Furious/Widget/XrayAssetViewerQListWidget.py +++ b/Furious/Widget/XrayAssetViewerQListWidget.py @@ -1,272 +1,5 @@ -# Copyright (C) 2024–present Loren Eteval & contributors -# -# This file is part of Furious. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Provide widgets for Xray asset viewer Qt list widget.""" +"""Provide compatibility imports for the official Xray asset viewer widget.""" from __future__ import annotations -from Furious.Frozenlib import * -from Furious.Qt import * -from Furious.Qt import gettext as _ - -from PySide6 import QtCore -from PySide6.QtGui import * -from PySide6.QtWidgets import * - -import os -import shutil -import logging -import datetime -import functools - -__all__ = ['XrayAssetViewerQListWidget'] - -logger = logging.getLogger(__name__) - - -class MBoxAssetExists(AppQMessageBox): - """Represent m box asset exists.""" - - def __init__(self, *args, **kwargs): - """Initialize the MBoxAssetExists.""" - super().__init__(*args, **kwargs) - - self.setStandardButtons( - AppQMessageBox.StandardButton.Yes | AppQMessageBox.StandardButton.No - ) - - def retranslate(self): - """Refresh translated text for the m box asset exists.""" - self.setWindowTitle(_(self.windowTitle())) - self.setText(_(self.text())) - - # Ignore informative text, buttons - - self.moveToCenter() - - -class XrayAssetViewerQListWidget(Mixins.ThemeAware, AppQListWidget): - """Provide the Xray asset viewer Qt list widget.""" - - def __init__(self, *args, **kwargs): - """Initialize the XrayAssetViewerQListWidget.""" - super().__init__(*args, **kwargs) - - self.setAlternatingRowColors(True) - - self.setSelectionBehavior(AppQListWidget.SelectionBehavior.SelectRows) - self.setSelectionMode(AppQListWidget.SelectionMode.ExtendedSelection) - self.setIconSize(QtCore.QSize(64, 64)) - - if PLATFORM == 'Linux' and SystemRuntime.ubuntuRelease() == '20.04': - self.initialTheme = APP().theme() - else: - self.initialTheme = None - - self.flushItem() - - logger.info(f'Xray-core asset dir is \'{XRAY_ASSET_DIR}\'') - - contextMenuActions = [ - AppQAction( - _('Delete'), - callback=lambda: self.deleteSelectedItem(), - ), - ] - - self.contextMenu = AppQMenu(*contextMenuActions) - - # Add actions to self in order to activate shortcuts - self.addActions(self.contextMenu.actions()) - - self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) - self.customContextMenuRequested.connect(self.handleCustomContextMenuRequested) - - @QtCore.Slot(QtCore.QPoint) - def handleCustomContextMenuRequested(self, point): - """Handle custom context menu requested.""" - self.contextMenu.exec(self.viewport().mapToGlobal(point)) - - def flushItemByTheme(self, theme: str): - """Refresh item by theme.""" - self.clear() - - maxlen = max( - len(filename) - for filename in os.listdir(XRAY_ASSET_DIR) - if os.path.isfile(XRAY_ASSET_DIR / filename) - ) - - for filename in os.listdir(XRAY_ASSET_DIR): - if os.path.isfile(XRAY_ASSET_DIR / filename): - try: - # Exception may be raised - epoch = os.path.getmtime(XRAY_ASSET_DIR / filename) - except Exception: - # Any non-exit exceptions - - mdate = '' - else: - mdate = datetime.datetime.fromtimestamp(epoch).strftime( - '%Y-%m-%d %H:%M:%S' - ) - - item = QListWidgetItem(f'{filename:{maxlen + 6}}{mdate}') - item.setFont(QFont(AppFontName())) - - if AppSettings.isStateON_('DarkMode'): - # Custom dark mode - item.setIcon(bootstrapIconWhite('file-earmark.svg')) - else: - if theme == 'Dark': - if PLATFORM == 'Windows': - # Windows - if versionToValue(PYSIDE6_VERSION) < versionToValue( - '6.7.0' - ): - # PySide6 < 6.7.0 has no system theme handling on Windows. - # Always use black icon - item.setIcon(bootstrapIcon('file-earmark.svg')) - else: - # PySide6 has system theme handling. - item.setIcon(bootstrapIconWhite('file-earmark.svg')) - else: - item.setIcon(bootstrapIconWhite('file-earmark.svg')) - else: - item.setIcon(bootstrapIcon('file-earmark.svg')) - - self.addItem(item) - - def flushItem(self): - """Refresh item.""" - if PLATFORM == 'Linux' and SystemRuntime.ubuntuRelease() == '20.04': - assert self.initialTheme is not None - - # Ubuntu 20.04. Flush by initial theme(Ubuntu 20.04 theme changes bug) - self.flushItemByTheme(self.initialTheme) - else: - self.flushItemByTheme(APP().theme()) - - def appendNewItem(self, filename: str): - """Append new item.""" - - def append(_filename): - """Append the Xray asset viewer Qt list widget.""" - try: - shutil.copy(_filename, XRAY_ASSET_DIR) - except shutil.SameFileError: - # Same file imported. Do nothing - pass - except Exception as ex: - # Any non-exit exception - - _mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Critical) - _mbox.setWindowTitle(_('Import')) - _mbox.setText(_('Error import asset file')) - _mbox.setInformativeText(str(ex)) - - # Show the MessageBox asynchronously - _mbox.open() - else: - self.flushItem() - - _mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Information) - _mbox.setWindowTitle(_('Import')) - _mbox.setText(_('Import asset file success')) - - # Show the MessageBox asynchronously - _mbox.open() - - basename = os.path.basename(filename) - - if os.path.isfile(XRAY_ASSET_DIR / basename): - - def handleResultCode(_filename, code): - """Handle result code.""" - if code == PySide6Legacy.enumValueWrapper( - AppQMessageBox.StandardButton.Yes - ): - append(_filename) - else: - # Do not overwrite - pass - - mbox = MBoxAssetExists(icon=AppQMessageBox.Icon.Question) - mbox.setWindowTitle(_('Import')) - mbox.setText(_('Asset file already exists. Overwrite?')) - mbox.setInformativeText(basename) - mbox.finished.connect(functools.partial(handleResultCode, filename)) - - # Show the MessageBox asynchronously - mbox.open() - else: - append(filename) - - def deleteSelectedItem(self): - """Delete selected item.""" - indexes = self.selectedIndex - - if len(indexes) == 0: - # Nothing selected - return - - def handleResultCode(_indexes, code): - """Handle result code.""" - if code == PySide6Legacy.enumValueWrapper( - AppQMessageBox.StandardButton.Yes - ): - for index in _indexes: - os.remove(XRAY_ASSET_DIR / self.item(index).text()) - - self.flushItem() - else: - # Do not delete - pass - - if PLATFORM == 'Windows': - # Windows - mbox = MBoxQuestionDelete(icon=AppQMessageBox.Icon.Question) - else: - # macOS & linux - mbox = MBoxQuestionDelete( - icon=AppQMessageBox.Icon.Question, parent=self.parent() - ) - mbox.setWindowModality(QtCore.Qt.WindowModality.WindowModal) - - mbox.isMulti = bool(len(indexes) > 1) - mbox.possibleRemark = f'{self.item(indexes[0]).text()}' - mbox.setText(mbox.customText()) - mbox.finished.connect(functools.partial(handleResultCode, indexes)) - - # Show the MessageBox asynchronously - mbox.open() - - def keyPressEvent(self, event): - """Handle a key press for the Xray asset viewer Qt list widget.""" - if event.key() == QtCore.Qt.Key.Key_Delete: - self.deleteSelectedItem() - else: - super().keyPressEvent(event) - - def themeChangedCallback(self, theme): - """Update the Xray asset viewer Qt list widget for a theme change.""" - if PLATFORM == 'Linux' and SystemRuntime.ubuntuRelease() == '20.04': - # Ubuntu 20.04 system dark theme does not - # change menu color. Do nothing - pass - else: - self.flushItemByTheme(theme) +from Furious.Plugins.Official.Xray.XrayAssetViewerQListWidget import * diff --git a/Furious/Widget/__init__.py b/Furious/Widget/__init__.py index c77ee9cd..49afdc1b 100644 --- a/Furious/Widget/__init__.py +++ b/Furious/Widget/__init__.py @@ -23,17 +23,9 @@ from .Application import * from .ConnectProgressBar import * from .GuiCustomizeNetworkTest import * from .GuiCustomizeProxyBypass import * -from .GuiHysteria1 import * -from .GuiHysteria2 import * -from .GuiShadowsocks import * -from .GuiSocks import * -from .GuiTrojan import * from .GuiTUNSettings import * -from .GuiVLESS import * -from .GuiVMess import * from .IndentSpinBox import * from .SystemTrayIcon import * from .UserServersQTableView import * from .UserSubsQTableView import * from .WaitingSpinner import * -from .XrayAssetViewerQListWidget import * diff --git a/Furious/Window/AppMainWindow.py b/Furious/Window/AppMainWindow.py index 0956a707..fc36ebbb 100644 --- a/Furious/Window/AppMainWindow.py +++ b/Furious/Window/AppMainWindow.py @@ -22,6 +22,7 @@ from __future__ import annotations from Furious.Frozenlib import * from Furious.Interface import * from Furious.Library import * +from Furious.Plugins import getPluginRegistry from Furious.Qt import * from Furious.Qt import gettext as _ from Furious.Widget.UserServersQTableView import * @@ -29,8 +30,6 @@ from Furious.Widget.GuiCustomizeNetworkTest import * from Furious.Widget.GuiCustomizeProxyBypass import * from Furious.Widget.GuiTUNSettings import * from Furious.Window.UserSubsWindow import * -from Furious.Window.UserRoutingWindow import * -from Furious.Window.XrayAssetViewerWindow import * from PySide6 import QtCore from PySide6.QtGui import * @@ -284,8 +283,13 @@ class AppMainWindow(AppQMainWindow): showProgress=False, ), ) - self.userRoutingWindow = UserRoutingWindow(parent=self) - self.xrayAssetViewerWindow = XrayAssetViewerWindow(parent=self) + pluginRegistry = getPluginRegistry() + pluginRoutingEditors = pluginRegistry.routingEditors(parent=self) + self.pluginRoutingEditors = tuple( + editor for _plugin, editor in pluginRoutingEditors + ) + if self.pluginRoutingEditors: + self.userRoutingWindow = self.pluginRoutingEditors[0] self.customizeProxyBypassDialog = GuiCustomizeProxyBypassDialog(parent=self) self.customizeNetworkTestDialog = GuiCustomizeNetworkTestDialog(parent=self) @@ -322,57 +326,31 @@ class AppMainWindow(AppQMainWindow): ), ] - serverActions = [ - AppQAction( - _('Add VMess Server...'), - callback=lambda: self.userServersQTableWidget.addServerViaGui( - Protocol.VMess, _('Add VMess Server...') - ), - ), - AppQAction( - _('Add VLESS Server...'), - callback=lambda: self.userServersQTableWidget.addServerViaGui( - Protocol.VLESS, _('Add VLESS Server...') - ), - ), - AppQAction( - _('Add Shadowsocks Server...'), - callback=lambda: self.userServersQTableWidget.addServerViaGui( - Protocol.Shadowsocks, _('Add Shadowsocks Server...') - ), - ), - AppQAction( - _('Add Trojan Server...'), - callback=lambda: self.userServersQTableWidget.addServerViaGui( - Protocol.Trojan, _('Add Trojan Server...') - ), - ), - AppQSeperator(), - AppQAction( - _('Add Hysteria1 Server...'), - callback=lambda: self.userServersQTableWidget.addServerViaGui( - Protocol.Hysteria1, _('Add Hysteria1 Server...') - ), - ), - AppQAction( - _('Add Hysteria2 Server...'), - callback=lambda: self.userServersQTableWidget.addServerViaGui( - Protocol.Hysteria2, _('Add Hysteria2 Server...') - ), - ), - AppQSeperator(), - AppQAction( - _('Add SOCKS Server...'), - callback=lambda: self.userServersQTableWidget.addServerViaGui( - Protocol.Socks, _('Add SOCKS Server...') - ), - ), - AppQSeperator(), + serverActions = [] + for descriptor in pluginRegistry.protocolDescriptors(): + if descriptor.separatorBefore and serverActions: + serverActions.append(AppQSeperator()) + + actionText = _(descriptor.addActionText) + serverActions.append( + AppQAction( + actionText, + callback=functools.partial( + self.userServersQTableWidget.addServerViaGui, + descriptor.id, + actionText, + ), + ) + ) + + if serverActions: + serverActions.append(AppQSeperator()) + serverActions.append( AppQAction( _('New Empty Configuration'), callback=lambda: self.userServersQTableWidget.newEmptyItem(), - ), - ] + ) + ) subsActions = [ AppQAction( @@ -448,6 +426,7 @@ class AppMainWindow(AppQMainWindow): AppQSeperator(), ] + pluginTools = pluginRegistry.mainWindowTools(parent=self) toolsActions = [ *customizeTUNSettingsAction, AppQAction( @@ -464,11 +443,41 @@ class AppMainWindow(AppQMainWindow): AppQSeperator(), *restartAsAdminAction, *openAppFolderAction, - AppQAction( - _('Manage Xray-core Asset File...'), - callback=lambda: self.xrayAssetViewerWindow.show(), - ), ] + if pluginTools: + toolsActions.extend(pluginTools) + + routingActions = [ + AppQAction( + _(plugin.displayName), + callback=editor.show, + ) + for plugin, editor in pluginRoutingEditors + ] + if len(routingActions) == 1: + routingToolbarActions = [ + AppQSeperator(), + AppQAction( + _('Routing'), + icon=bootstrapIcon('signpost.svg'), + checkable=False, + callback=routingActions[0].trigger, + ), + ] + elif routingActions: + routingToolbarActions = [ + AppQSeperator(), + AppQAction( + _('Routing'), + icon=bootstrapIcon('signpost.svg'), + menu=AppQMenu(*routingActions), + useSetMenu=False, + useActionGroup=False, + checkable=False, + ), + ] + else: + routingToolbarActions = [] if hasattr(AppQAction, 'setMenu'): self.toolbar = AppQToolBar( @@ -498,13 +507,7 @@ class AppMainWindow(AppQMainWindow): useActionGroup=False, checkable=False, ), - AppQSeperator(), - AppQAction( - _('Routing'), - icon=bootstrapIcon('signpost.svg'), - checkable=False, - callback=lambda: self.userRoutingWindow.show(), - ), + *routingToolbarActions, AppQSeperator(), AppQAction( _('Tools'), @@ -552,6 +555,15 @@ class AppMainWindow(AppQMainWindow): 'actions': [*subsActions], } + routingMenus = [] + if routingActions: + routingMenus.append( + { + 'name': 'Routing', + 'actions': [*routingActions], + } + ) + toolsMenu = { 'name': 'Tools', 'actions': [*toolsActions], @@ -581,12 +593,20 @@ class AppMainWindow(AppQMainWindow): _('Log'), _('Server'), _('Subscription'), + _('Routing'), _('Tools'), _('Help'), ] # Menus - for menuDict in (logMenu, serverMenu, subsMenu, toolsMenu, helpMenu): + for menuDict in ( + logMenu, + serverMenu, + subsMenu, + *routingMenus, + toolsMenu, + helpMenu, + ): menuName = menuDict['name'] menuObjName = f'_{menuName}Menu' menu = AppQMenu( diff --git a/Furious/Window/UserRoutingWindow.py b/Furious/Window/UserRoutingWindow.py index 07812e53..4ffeded7 100644 --- a/Furious/Window/UserRoutingWindow.py +++ b/Furious/Window/UserRoutingWindow.py @@ -1,1249 +1,5 @@ -# Copyright (C) 2024-present Loren Eteval & contributors -# -# This file is part of Furious. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Provide the application window for user routing window.""" +"""Provide compatibility imports for the official Xray routing editor.""" from __future__ import annotations -from Furious.Frozenlib import * -from Furious.Core.CoreManager import cleanRoutingRule -from Furious.Library import * -from Furious.Qt import * -from Furious.Qt import gettext as _ - -from PySide6 import QtCore -from PySide6.QtGui import * -from PySide6.QtWidgets import * - -import copy -import uuid -import logging -import functools - -__all__ = ['UserRoutingWindow'] - -logger = logging.getLogger(__name__) - -registerAppSettings('UserRoutingWindowGeometry') -registerAppSettings('UserRoutingWindowState') -registerAppSettings('UserRoutingHeaderViewState') - - -DOMAIN_STRATEGIES, ROUTING_STATES = ( - ['AsIs', 'IPIfNonMatch', 'IPOnDemand'], - ['Enabled', 'Disabled'], -) - - -def valuesToList(text: str) -> list[str]: - """Return the values to list value used by the application.""" - values = list() - - for line in text.splitlines(): - for value in line.split(','): - value = value.strip() - - if value: - values.append(value) - - return values - - -def listToText(value) -> str: - """Return the list to text value used by the application.""" - if isinstance(value, list): - return '\n'.join(str(item) for item in value) - - return '' - - -def routingStateText(state: str) -> str: - """Return the routing state text value used by the application.""" - if state == 'Enabled': - return _('Enabled') - elif state == 'Disabled': - return _('Disabled') - - return _(state) - - -def addRoutingStateItems(combo: QComboBox): - """Add routing state items.""" - combo.clear() - - for state in ROUTING_STATES: - combo.addItem(routingStateText(state), state) - - -def comboCurrentData(combo: QComboBox, default=''): - """Return the combo current data value used by the application.""" - data = combo.currentData() - - if data is None: - return default - - return data - - -def routingObjectFromProfile(routingProfile: dict): - """Return the routing object from profile value used by the application.""" - if not isinstance(routingProfile, dict): - routingProfile = dict() - - domainStrategy = routingProfile.get('domainStrategy', 'AsIs') - - if domainStrategy not in DOMAIN_STRATEGIES: - domainStrategy = 'AsIs' - - rules = list( - filter( - lambda rule: rule is not None, - list(cleanRoutingRule(rule) for rule in routingProfile.get('rules', [])), - ) - ) - - return { - 'domainStrategy': domainStrategy, - 'domainMatcher': 'hybrid', - 'rules': rules, - } - - -class RoutingPreviewDialog(AppQDialog): - """Present the routing preview dialog.""" - - def __init__(self, routingProfile: dict, parent=None): - """Initialize the RoutingPreviewDialog.""" - super().__init__(parent) - - self.setWindowTitle(_('Preview Routing')) - self.setWindowModality(QtCore.Qt.WindowModality.WindowModal) - - if PLATFORM == 'Darwin': - self.toolbar = AppQToolBar( - AppQAction( - _('Close'), - icon=bootstrapIcon('window-x.svg'), - callback=lambda: self.close(), - ), - parent=self, - ) - else: - self.toolbar = None - - self.textEditor = DraculaJSONTextEditor(fontFamily=AppFontName()) - self.textEditor.setLineWrapMode(DraculaJSONTextEditor.LineWrapMode.NoWrap) - self.textEditor.setPlainText( - UJSONEncoder.encode(routingObjectFromProfile(routingProfile), indent=4) - ) - self.textEditor.setReadOnly(True) - - layout = QVBoxLayout() - - if self.toolbar is not None: - layout.addWidget(self.toolbar) - - layout.addWidget(self.textEditor) - - self.setLayout(layout) - - def setWidthAndHeight(self): - """Apply the default size for the routing preview dialog.""" - self.setFixedSize(400, int(400 * GOLDEN_RATIO)) - - -class RoutingTextEditDialog(AppQDialog): - """Present the routing text edit dialog.""" - - def __init__(self, text='', parent=None): - """Initialize the RoutingTextEditDialog.""" - super().__init__(parent) - - self.setWindowTitle(_('Edit Text')) - self.setWindowModality(QtCore.Qt.WindowModality.WindowModal) - - self.textEdit = QTextEdit(text) - - self.dialogBtns = AppQDialogButtonBox(QtCore.Qt.Orientation.Horizontal) - self.dialogBtns.addButton(_('OK'), AppQDialogButtonBox.ButtonRole.AcceptRole) - self.dialogBtns.addButton( - _('Cancel'), - AppQDialogButtonBox.ButtonRole.RejectRole, - ) - self.dialogBtns.accepted.connect(self.accept) - self.dialogBtns.rejected.connect(self.reject) - - layout = QVBoxLayout() - layout.addWidget(self.textEdit) - layout.addWidget(self.dialogBtns) - - self.setLayout(layout) - - def setWidthAndHeight(self): - """Apply the default size for the routing text edit dialog.""" - self.setFixedSize(760, 470) - - def text(self): - """Return the text value.""" - return self.textEdit.toPlainText() - - -class RoutingTextEdit(Mixins.QTranslatable, QTextEdit): - """Represent routing text edit.""" - - def __init__(self, text='', parent=None): - """Initialize the RoutingTextEdit.""" - super().__init__(text, parent) - - self.setToolTip(_('Double-click to enlarge')) - - def mouseDoubleClickEvent(self, event): - """Handle the mouse double click event.""" - dialog = RoutingTextEditDialog(self.toPlainText(), parent=self) - - def handleResultCode(code): - """Handle result code.""" - if code == PySide6Legacy.enumValueWrapper(AppQDialog.DialogCode.Accepted): - self.setPlainText(dialog.text()) - - dialog.finished.connect(handleResultCode) - dialog.open() - - event.accept() - - def retranslate(self): - """Refresh translated text for the routing text edit.""" - self.setToolTip(_(self.toolTip())) - - -class RoutingDocumentationURL(AppQLabel): - """Represent routing documentation URL.""" - - URL = 'https://xtls.github.io/config/routing.html' - - def __init__(self, *args, **kwargs): - """Initialize the RoutingDocumentationURL.""" - super().__init__(*args, **kwargs) - - self.setWebsiteURL() - self.linkActivated.connect(self.handleLinkActivated) - - def setWebsiteURL(self): - """Set website URL.""" - self.setText( - '

' - f'' - '' - + _('Routing Documentation') - + '

' - ) - - @staticmethod - def handleLinkActivated(link: str): - """Handle link activated.""" - if QDesktopServices.openUrl(QtCore.QUrl(link)): - logger.info(f'open link \'{link}\' success') - else: - logger.error(f'open link \'{link}\' failed') - - def retranslate(self): - """Refresh translated text for the routing documentation URL.""" - self.setWebsiteURL() - - -class RoutingProfilesModel(QtCore.QAbstractTableModel): - """Expose routing profiles data through a Qt item model.""" - - Headers = ['Remark', 'Domain Strategy', 'State'] - - def rowCount(self, parent=QtCore.QModelIndex()) -> int: - """Return the number of rows exposed by the model.""" - if parent.isValid(): - return 0 - - return len(Storage.UserRoutings()) - - def columnCount(self, parent=QtCore.QModelIndex()) -> int: - """Return the number of columns exposed by the model.""" - if parent.isValid(): - return 0 - - return len(self.Headers) - - def flags(self, index): - """Return the Qt item flags for a model index.""" - if not index.isValid(): - return QtCore.Qt.ItemFlag.NoItemFlags - - return QtCore.Qt.ItemFlag.ItemIsEnabled | QtCore.Qt.ItemFlag.ItemIsSelectable - - def headerData(self, section, orientation, role=QtCore.Qt.ItemDataRole.DisplayRole): - """Return display data for a table header section.""" - if role != QtCore.Qt.ItemDataRole.DisplayRole: - return None - - if orientation == QtCore.Qt.Orientation.Horizontal: - header = self.Headers[section] - - if header in ['Remark', 'State']: - return _(header) - - return header - - return section + 1 - - def routingUniqueByRow(self, row: int): - """Return the routing unique by row value used by the routing profiles model.""" - return list(Storage.UserRoutings().keys())[row] - - def routingByRow(self, row: int): - """Return the routing by row value used by the routing profiles model.""" - return Storage.UserRoutings()[self.routingUniqueByRow(row)] - - def data(self, index, role=QtCore.Qt.ItemDataRole.DisplayRole): - """Return the data managed by the routing profiles model.""" - if not index.isValid(): - return None - - row, column = index.row(), index.column() - - if row < 0 or row >= len(Storage.UserRoutings()): - return None - - routing = self.routingByRow(row) - - if role in [ - QtCore.Qt.ItemDataRole.DisplayRole, - QtCore.Qt.ItemDataRole.EditRole, - ]: - if column == 0: - return routing.get('remark', '') - elif column == 1: - return routing.get('domainStrategy', 'AsIs') - elif column == 2: - return routingStateText( - 'Enabled' if routing.get('enabled', True) else 'Disabled' - ) - - return None - - def setData(self, index, value, role=QtCore.Qt.ItemDataRole.EditRole): - """Update model data for the requested role.""" - return False - - def emitAllChanged(self): - """Handle emit all changed for the routing profiles model.""" - if self.rowCount() == 0: - return - - self.dataChanged.emit( - self.index(0, 0), - self.index(self.rowCount() - 1, self.columnCount() - 1), - ) - - -class RoutingRuleEditDialog(AppQDialog): - """Present the routing rule edit dialog.""" - - MatchInputHeight = 72 - ShortInputWidth = 240 - - @staticmethod - def textEdit(text=''): - """Return the text edit value used by the routing rule edit dialog.""" - widget = RoutingTextEdit(text) - widget.setFixedHeight(RoutingRuleEditDialog.MatchInputHeight) - - return widget - - @staticmethod - def lineEdit(text=''): - """Return the line edit value used by the routing rule edit dialog.""" - widget = QLineEdit(text) - widget.setFixedHeight(RoutingRuleEditDialog.MatchInputHeight) - - return widget - - def __init__(self, rule: dict, parent=None): - """Initialize the RoutingRuleEditDialog.""" - super().__init__(parent) - - self.rule = dict(rule) - - self.setWindowTitle(_('Edit Routing Rule')) - self.setWindowModality(QtCore.Qt.WindowModality.WindowModal) - - self.ruleTagEdit = self.lineEdit(self.rule.get('ruleTag', '')) - - self.outboundEdit = self.lineEdit(self.rule.get('outboundTag', 'proxy')) - self.outboundEdit.setMaximumWidth(360) - - self.balancerTagEdit = self.lineEdit(str(self.rule.get('balancerTag', ''))) - - self.networkCombo = AppQComboBox(translatable=False) - self.networkCombo.addItems(['', 'tcp', 'udp', 'tcp,udp']) - self.networkCombo.setCurrentText(self.rule.get('network', '')) - self.networkCombo.setMaximumWidth(self.ShortInputWidth) - - self.portEdit = self.textEdit(str(self.rule.get('port', ''))) - self.portEdit.setMaximumWidth(360) - - self.sourcePortEdit = self.textEdit(str(self.rule.get('sourcePort', ''))) - self.sourcePortEdit.setMaximumWidth(360) - - self.localPortEdit = self.textEdit(str(self.rule.get('localPort', ''))) - self.localPortEdit.setMaximumWidth(360) - - self.vlessRouteEdit = self.textEdit(str(self.rule.get('vlessRoute', ''))) - self.vlessRouteEdit.setMaximumWidth(360) - - self.domainEdit = self.textEdit(listToText(self.rule.get('domain', []))) - self.ipEdit = self.textEdit(listToText(self.rule.get('ip', []))) - self.sourceIPEdit = self.textEdit(listToText(self.rule.get('sourceIP', []))) - self.localIPEdit = self.textEdit(listToText(self.rule.get('localIP', []))) - self.userEdit = self.textEdit(listToText(self.rule.get('user', []))) - self.protocolEdit = self.textEdit(listToText(self.rule.get('protocol', []))) - self.inboundTagEdit = self.textEdit(listToText(self.rule.get('inboundTag', []))) - self.processEdit = self.textEdit(listToText(self.rule.get('process', []))) - - self.dialogBtns = AppQDialogButtonBox(QtCore.Qt.Orientation.Horizontal) - self.dialogBtns.addButton(_('OK'), AppQDialogButtonBox.ButtonRole.AcceptRole) - self.dialogBtns.addButton( - _('Cancel'), - AppQDialogButtonBox.ButtonRole.RejectRole, - ) - self.dialogBtns.accepted.connect(self.accept) - self.dialogBtns.rejected.connect(self.reject) - - generalLayout = QFormLayout() - generalLayout.addRow( - AppQLabel('Rule Tag', translatable=False), - self.ruleTagEdit, - ) - generalLayout.addRow( - AppQLabel(_('OutBound (e.g. proxy/direct/block)')), - self.outboundEdit, - ) - generalLayout.addRow( - AppQLabel('Balancer Tag', translatable=False), - self.balancerTagEdit, - ) - generalLayout.addRow( - AppQLabel('Network', translatable=False), - self.networkCombo, - ) - - generalGroup = AppQGroupBox( - 'General', - translatable=False, - ) - generalGroup.setLayout(generalLayout) - - destinationLayout = QFormLayout() - destinationLayout.addRow( - AppQLabel(_('Domain (one per line or comma-separated)')), - self.domainEdit, - ) - destinationLayout.addRow( - AppQLabel(_('IP (one per line or comma-separated)')), - self.ipEdit, - ) - destinationLayout.addRow( - AppQLabel(_('Port (comma/range, e.g. 53,443,1000-2000)')), - self.portEdit, - ) - destinationLayout.addRow( - AppQLabel(_('VLESS Route (comma/range, e.g. 53,443,1000-2000)')), - self.vlessRouteEdit, - ) - - destinationGroup = AppQGroupBox( - 'Destination Match', - translatable=False, - ) - destinationGroup.setLayout(destinationLayout) - - sourceLayout = QFormLayout() - sourceLayout.addRow( - AppQLabel(_('Source IP (one per line or comma-separated)')), - self.sourceIPEdit, - ) - sourceLayout.addRow( - AppQLabel(_('Source Port (comma/range, e.g. 53,443,1000-2000)')), - self.sourcePortEdit, - ) - sourceLayout.addRow( - AppQLabel(_('Local IP (one per line or comma-separated)')), - self.localIPEdit, - ) - sourceLayout.addRow( - AppQLabel(_('Local Port (comma/range, e.g. 53,443,1000-2000)')), - self.localPortEdit, - ) - - sourceGroup = AppQGroupBox( - 'Source / Local Match', - translatable=False, - ) - sourceGroup.setLayout(sourceLayout) - - identityLayout = QFormLayout() - identityLayout.addRow( - AppQLabel(_('User (one per line or comma-separated)')), - self.userEdit, - ) - identityLayout.addRow( - AppQLabel(_('Inbound Tag (one per line or comma-separated)')), - self.inboundTagEdit, - ) - identityLayout.addRow( - AppQLabel(_('Process (one per line or comma-separated)')), - self.processEdit, - ) - identityLayout.addRow( - AppQLabel(_('Protocol (one per line or comma-separated)')), - self.protocolEdit, - ) - - identityGroup = AppQGroupBox( - 'Identity / Process / Protocol Match', - translatable=False, - ) - identityGroup.setLayout(identityLayout) - - matchGrid = QGridLayout() - matchGrid.addWidget(generalGroup, 0, 0) - matchGrid.addWidget(destinationGroup, 0, 1) - matchGrid.addWidget(sourceGroup, 1, 0) - matchGrid.addWidget(identityGroup, 1, 1) - matchGrid.setColumnStretch(0, 1) - matchGrid.setColumnStretch(1, 1) - - bottomLayout = QHBoxLayout() - bottomLayout.addWidget(RoutingDocumentationURL()) - bottomLayout.addStretch(1) - bottomLayout.addWidget(self.dialogBtns) - - layout = QVBoxLayout() - layout.addLayout(matchGrid) - layout.addLayout(bottomLayout) - - self.setLayout(layout) - - def setWidthAndHeight(self): - """Apply the default size for the routing rule edit dialog.""" - self.resize(int(800 * GOLDEN_RATIO), 800) - - def routingRule(self): - """Return the routing rule value used by the routing rule edit dialog.""" - rule = { - 'type': 'field', - 'outboundTag': self.outboundEdit.text().strip() or 'proxy', - } - - for key, value in [ - ('ruleTag', self.ruleTagEdit.text().strip()), - ('network', self.networkCombo.currentText().strip()), - ('port', self.portEdit.toPlainText().strip()), - ('sourcePort', self.sourcePortEdit.toPlainText().strip()), - ('localPort', self.localPortEdit.toPlainText().strip()), - ('vlessRoute', self.vlessRouteEdit.toPlainText().strip()), - ('balancerTag', self.balancerTagEdit.text().strip()), - ]: - if value: - rule[key] = value - - for key, widget in [ - ('domain', self.domainEdit), - ('ip', self.ipEdit), - ('sourceIP', self.sourceIPEdit), - ('localIP', self.localIPEdit), - ('user', self.userEdit), - ('protocol', self.protocolEdit), - ('inboundTag', self.inboundTagEdit), - ('process', self.processEdit), - ]: - values = valuesToList(widget.toPlainText()) - - if values: - rule[key] = values - - return rule - - -class RoutingRemarkEditDialog(AppQDialog): - """Present the routing remark edit dialog.""" - - def __init__(self, remark: str, parent=None): - """Initialize the RoutingRemarkEditDialog.""" - super().__init__(parent) - - self.setWindowTitle(_('Edit Routing Remark')) - self.setWindowModality(QtCore.Qt.WindowModality.WindowModal) - - self.remarkEdit = QLineEdit(remark) - - self.dialogBtns = AppQDialogButtonBox(QtCore.Qt.Orientation.Horizontal) - self.dialogBtns.addButton(_('OK'), AppQDialogButtonBox.ButtonRole.AcceptRole) - self.dialogBtns.addButton( - _('Cancel'), - AppQDialogButtonBox.ButtonRole.RejectRole, - ) - self.dialogBtns.accepted.connect(self.accept) - self.dialogBtns.rejected.connect(self.reject) - - layout = QFormLayout() - layout.addRow(AppQLabel(_('Remark')), self.remarkEdit) - layout.addRow(self.dialogBtns) - - self.setLayout(layout) - - def setWidthAndHeight(self): - """Apply the default size for the routing remark edit dialog.""" - self.resize(420, 120) - - def remark(self): - """Return the remark value used by the routing remark edit dialog.""" - return self.remarkEdit.text().strip() - - -class RoutingProfileEditDialog(AppQDialog): - """Present the routing profile edit dialog.""" - - def __init__(self, parent=None): - """Initialize the RoutingProfileEditDialog.""" - super().__init__(parent) - - self.setWindowTitle(_('Add Routing')) - self.setWindowModality(QtCore.Qt.WindowModality.WindowModal) - - self.remarkEdit = QLineEdit(_('New Routing')) - - self.domainStrategyCombo = AppQComboBox(translatable=False) - self.domainStrategyCombo.addItems(DOMAIN_STRATEGIES) - self.domainStrategyCombo.setCurrentText('AsIs') - - self.enabledCombo = AppQComboBox(translatable=False) - addRoutingStateItems(self.enabledCombo) - self.enabledCombo.setCurrentIndex(ROUTING_STATES.index('Enabled')) - - self.dialogBtns = AppQDialogButtonBox(QtCore.Qt.Orientation.Horizontal) - self.dialogBtns.addButton(_('OK'), AppQDialogButtonBox.ButtonRole.AcceptRole) - self.dialogBtns.addButton( - _('Cancel'), - AppQDialogButtonBox.ButtonRole.RejectRole, - ) - self.dialogBtns.accepted.connect(self.accept) - self.dialogBtns.rejected.connect(self.reject) - - layout = QFormLayout() - layout.addRow(AppQLabel(_('Remark')), self.remarkEdit) - layout.addRow( - AppQLabel('Domain Strategy', translatable=False), - self.domainStrategyCombo, - ) - layout.addRow(AppQLabel(_('State')), self.enabledCombo) - layout.addRow(self.dialogBtns) - - self.setLayout(layout) - - def setWidthAndHeight(self): - """Apply the default size for the routing profile edit dialog.""" - self.resize(460, 160) - - def routing(self): - """Return the routing value used by the routing profile edit dialog.""" - return { - 'remark': self.remarkEdit.text().strip() or _('New Routing'), - 'domainStrategy': self.domainStrategyCombo.currentText(), - 'enabled': comboCurrentData(self.enabledCombo, 'Enabled') == 'Enabled', - 'rules': [], - } - - -class RoutingRulesQListWidget(AppQListWidget): - """Provide the routing rules Qt list widget.""" - - editRequested, deleteRequested = ( - QtCore.Signal(), - QtCore.Signal(), - ) - - def __init__(self, routing: dict, parent=None): - """Initialize the RoutingRulesQListWidget.""" - super().__init__(parent) - - self.routing = routing - - self.setAlternatingRowColors(True) - self.setSelectionBehavior(AppQListWidget.SelectionBehavior.SelectRows) - self.setSelectionMode(AppQListWidget.SelectionMode.ExtendedSelection) - self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) - - self.itemDoubleClicked.connect(lambda _item: self.editRequested.emit()) - self.customContextMenuRequested.connect(self.handleCustomContextMenuRequested) - - self.contextDeleteAction = AppQAction( - _('Delete'), - callback=lambda: self.deleteRequested.emit(), - ) - self.contextMenu = AppQMenu(self.contextDeleteAction, parent=self) - - self.flushAll() - - def rules(self): - """Return the rules value used by the routing rules Qt list widget.""" - rules = self.routing.setdefault('rules', list()) - - if not isinstance(rules, list): - self.routing['rules'] = rules = list() - - return rules - - def ruleAt(self, index: int): - """Return the rule at value used by the routing rules Qt list widget.""" - return self.rules()[index] - - def ruleText(self, rule: dict) -> str: - """Return the rule text value used by the routing rules Qt list widget.""" - name, outbound, domains, ips = ( - rule.get('ruleTag', '') or 'Untitled Rule', - rule.get('outboundTag', 'proxy'), - len(rule.get('domain', [])), - len(rule.get('ip', [])), - ) - - return f'{name} -> {outbound} ({domains} domains, {ips} IPs)' - - def selectedRuleText(self): - """Select ed rule text.""" - indexes = self.selectedIndex - - if not indexes: - return '' - - return self.ruleText(self.ruleAt(indexes[0])) - - def appendRule(self, rule: dict): - """Append rule.""" - self.rules().append(rule) - self.flushAll() - - def setRule(self, index: int, rule: dict): - """Set rule.""" - self.rules()[index] = rule - self.flushAll() - - def deleteRules(self, indexes: list[int]): - """Delete rules.""" - for i in range(len(indexes)): - self.rules().pop(indexes[i] - i) - - self.flushAll() - - def flushAll(self): - """Refresh all.""" - self.clear() - - for rule in self.rules(): - self.addItem(self.ruleText(rule)) - - @QtCore.Slot(QtCore.QPoint) - def handleCustomContextMenuRequested(self, point): - """Handle custom context menu requested.""" - self.contextMenu.exec(self.viewport().mapToGlobal(point)) - - -class RoutingRulesDialog(AppQDialog): - """Present the routing rules dialog.""" - - def __init__(self, routing: dict, parent=None): - """Initialize the RoutingRulesDialog.""" - super().__init__(parent) - - self.routing = routing - self.setWindowTitle(_('Routing Rules')) - self.setWindowModality(QtCore.Qt.WindowModality.WindowModal) - - if PLATFORM == 'Darwin': - self.toolbar = AppQToolBar( - AppQAction( - _('Close'), - icon=bootstrapIcon('window-x.svg'), - callback=lambda: self.close(), - ), - parent=self, - ) - else: - self.toolbar = None - - self.listWidget = RoutingRulesQListWidget(self.routing, parent=self) - self.listWidget.editRequested.connect(self.editRule) - self.listWidget.deleteRequested.connect(self.deleteRule) - - self.addButton = AppQPushButton(_('Add')) - self.addButton.clicked.connect(self.addRule) - - self.deleteButton = AppQPushButton(_('Delete')) - self.deleteButton.clicked.connect(self.deleteRule) - - buttonLayout = QHBoxLayout() - buttonLayout.addWidget(self.addButton) - buttonLayout.addWidget(self.deleteButton) - - layout = QVBoxLayout() - - if self.toolbar is not None: - layout.addWidget(self.toolbar) - - layout.addWidget(self.listWidget) - layout.addLayout(buttonLayout) - - self.setLayout(layout) - - def setWidthAndHeight(self): - """Apply the default size for the routing rules dialog.""" - self.setFixedSize(760, 470) - - def addRule(self): - """Add rule.""" - rule = {'type': 'field', 'outboundTag': 'proxy', 'ruleTag': 'New Rule'} - dialog = RoutingRuleEditDialog(rule, parent=None) - - def handleResultCode(code): - """Handle result code.""" - if code == PySide6Legacy.enumValueWrapper(AppQDialog.DialogCode.Accepted): - self.listWidget.appendRule(dialog.routingRule()) - - dialog.finished.connect(handleResultCode) - dialog.open() - - def editRule(self): - """Handle edit rule for the routing rules dialog.""" - indexes = self.listWidget.selectedIndex - - if len(indexes) != 1: - return - - index = indexes[0] - dialog = RoutingRuleEditDialog(self.listWidget.ruleAt(index), parent=None) - - def handleResultCode(_index, code): - """Handle result code.""" - if code == PySide6Legacy.enumValueWrapper(AppQDialog.DialogCode.Accepted): - self.listWidget.setRule(_index, dialog.routingRule()) - - dialog.finished.connect(functools.partial(handleResultCode, index)) - dialog.open() - - def deleteRule(self): - """Delete rule.""" - indexes = self.listWidget.selectedIndex - - if len(indexes) == 0: - return - - def handleResultCode(_indexes, code): - """Handle result code.""" - if code == PySide6Legacy.enumValueWrapper( - AppQMessageBox.StandardButton.Yes - ): - self.listWidget.deleteRules(_indexes) - else: - # Do not delete - pass - - if PLATFORM == 'Windows': - # Windows - mbox = MBoxQuestionDelete(icon=AppQMessageBox.Icon.Question) - else: - # macOS & linux - mbox = MBoxQuestionDelete( - icon=AppQMessageBox.Icon.Question, - parent=self, - ) - mbox.setWindowModality(QtCore.Qt.WindowModality.WindowModal) - - mbox.isMulti = bool(len(indexes) > 1) - mbox.possibleRemark = self.listWidget.selectedRuleText() - mbox.setText(mbox.customText()) - mbox.finished.connect(functools.partial(handleResultCode, indexes)) - - # Show the MessageBox asynchronously - mbox.open() - - -class UserRoutingQTableViewHorizontalHeader(AppQHeaderView): - """Provide the user routing Qt table view horizontal table header.""" - - def __init__(self, *args, **kwargs): - """Initialize the UserRoutingQTableViewHorizontalHeader.""" - super().__init__(QtCore.Qt.Orientation.Horizontal, *args, **kwargs) - - -class UserRoutingTableView(Mixins.QTranslatable, AppQTableView): - """Represent user routing table view.""" - - RowHeight = 42 - - def __init__(self, parent=None): - """Initialize the UserRoutingTableView.""" - super().__init__(parent) - - self.sourceModel = RoutingProfilesModel(parent=self) - self.setModel(self.sourceModel) - self.setHorizontalHeader( - UserRoutingQTableViewHorizontalHeader( - parent=self, - sectionSizeSettingsName='UserRoutingHeaderViewState', - ) - ) - self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) - self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) - self.setDragDropMode(QAbstractItemView.DragDropMode.NoDragDrop) - self.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) - self.setSortingEnabled(False) - self.doubleClicked.connect(self.editSelectedRules) - self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) - self.customContextMenuRequested.connect(self.handleCustomContextMenuRequested) - - self.contextPreviewAction = AppQAction( - _('Preview'), - icon=bootstrapIcon('file-earmark-text.svg'), - callback=lambda: self.previewSelectedItem(), - ) - self.contextRenameAction = AppQAction( - _('Rename'), - callback=lambda: self.renameSelectedItem(), - ) - self.contextDeleteAction = AppQAction( - _('Delete'), - callback=lambda: self.deleteSelectedItem(), - ) - self.contextMenu = AppQMenu( - self.contextPreviewAction, - self.contextRenameAction, - AppQSeperator(), - self.contextDeleteAction, - parent=self, - ) - - self.setDefaultRowHeight(self.RowHeight) - self.configureHeader() - self.flushAll() - - def configureHeader(self): - """Configure header.""" - header = self.horizontalHeader() - header.setSectionsMovable(True) - header.setFirstSectionMovable(True) - header.setCustomSectionResizeMode() - header.restoreSectionSize() - - @property - def selectedIndex(self): - """Return the selected index value.""" - return sorted( - list(set(index.row() for index in self.selectionModel().selectedRows())) - ) - - def routingUniqueByRow(self, row): - """Return the routing unique by row value used by the user routing table view.""" - return self.sourceModel.routingUniqueByRow(row) - - @QtCore.Slot(QtCore.QPoint) - def handleCustomContextMenuRequested(self, point): - """Handle custom context menu requested.""" - indexes = self.selectedIndex - - isUniqueFlag = len(indexes) == 1 - - for action in [self.contextPreviewAction, self.contextRenameAction]: - action.setEnabled(isUniqueFlag) - - self.contextMenu.exec(self.viewport().mapToGlobal(point)) - - def flushItem(self, row: int, column: int): - """Refresh item.""" - index = self.sourceModel.index(row, column) - - if column == 1: - combo = AppQComboBox(parent=self.viewport(), translatable=False) - combo.addItems(DOMAIN_STRATEGIES) - combo.setMinimumWidth(0) - combo.setCurrentText( - self.sourceModel.routingByRow(row).get('domainStrategy', 'AsIs') - ) - combo.currentTextChanged.connect( - lambda text, _row=row: self.setDomainStrategy(_row, text) - ) - - self.setIndexWidget(index, combo) - elif column == 2: - combo = AppQComboBox(parent=self.viewport(), translatable=False) - addRoutingStateItems(combo) - combo.setMinimumWidth(0) - combo.setCurrentIndex( - ROUTING_STATES.index( - 'Enabled' - if self.sourceModel.routingByRow(row).get('enabled', True) - else 'Disabled' - ) - ) - combo.currentIndexChanged.connect( - lambda _index, _row=row, _combo=combo: self.setEnabled( - _row, - comboCurrentData(_combo, 'Enabled'), - ) - ) - - self.setIndexWidget(index, combo) - - def flushRow(self, row: int): - """Refresh row.""" - for column in range(self.sourceModel.columnCount()): - self.flushItem(row, column) - - def flushAll(self): - """Refresh all.""" - self.sourceModel.emitAllChanged() - - for row in range(self.sourceModel.rowCount()): - self.flushRow(row) - - def retranslate(self): - """Refresh translated text for the user routing table view.""" - self.sourceModel.headerDataChanged.emit( - QtCore.Qt.Orientation.Horizontal, - 0, - self.sourceModel.columnCount() - 1, - ) - self.flushAll() - - def setDomainStrategy(self, row: int, text: str): - """Set domain strategy.""" - if row < 0 or row >= self.sourceModel.rowCount(): - return - - self.sourceModel.routingByRow(row)['domainStrategy'] = text - self.sourceModel.emitAllChanged() - - def setEnabled(self, row: int, state: str): - """Set enabled.""" - if row < 0 or row >= self.sourceModel.rowCount(): - return - - self.sourceModel.routingByRow(row)['enabled'] = state == 'Enabled' - self.sourceModel.emitAllChanged() - - def appendNewItem(self): - """Append new item.""" - dialog = RoutingProfileEditDialog(parent=self) - - def handleResultCode(code): - """Handle result code.""" - if code != PySide6Legacy.enumValueWrapper(AppQDialog.DialogCode.Accepted): - return - - row = self.sourceModel.rowCount() - unique = str(uuid.uuid4()) - - self.sourceModel.beginInsertRows(QtCore.QModelIndex(), row, row) - - Storage.UserRoutings()[unique] = dialog.routing() - - self.sourceModel.endInsertRows() - self.flushRow(row) - - dialog.finished.connect(handleResultCode) - dialog.open() - - def deleteSelectedItem(self): - """Delete selected item.""" - indexes = self.selectedIndex - - if not indexes: - # Nothing selected. Do nothing - return - - def handleResultCode(_indexes, code): - """Handle result code.""" - if code != PySide6Legacy.enumValueWrapper( - AppQMessageBox.StandardButton.Yes - ): - return - - for i in range(len(_indexes)): - deleteIndex = _indexes[i] - i - deleteUnique = self.routingUniqueByRow(deleteIndex) - - self.sourceModel.beginRemoveRows( - QtCore.QModelIndex(), - deleteIndex, - deleteIndex, - ) - - Storage.UserRoutings().pop(deleteUnique) - - self.sourceModel.endRemoveRows() - - self.flushAll() - - if PLATFORM == 'Windows': - # Windows - mbox = MBoxQuestionDelete(icon=AppQMessageBox.Icon.Question) - else: - # macOS & linux - mbox = MBoxQuestionDelete( - icon=AppQMessageBox.Icon.Question, - parent=self, - ) - mbox.setWindowModality(QtCore.Qt.WindowModality.WindowModal) - - mbox.isMulti = bool(len(indexes) > 1) - mbox.possibleRemark = self.sourceModel.data( - self.sourceModel.index(indexes[0], 0), - QtCore.Qt.ItemDataRole.DisplayRole, - ) - mbox.setText(mbox.customText()) - mbox.finished.connect(functools.partial(handleResultCode, indexes)) - - # Show the MessageBox asynchronously - mbox.open() - - def renameSelectedItem(self): - """Handle rename selected item for the user routing table view.""" - indexes = self.selectedIndex - - if not indexes: - # Nothing selected. Do nothing - return - - if len(indexes) > 1: - # Multiple items selected. Do nothing - return - - routing = self.sourceModel.routingByRow(indexes[0]) - - dialog = RoutingRemarkEditDialog(routing.get('remark', ''), parent=self) - - def handleResultCode(code): - """Handle result code.""" - if code != PySide6Legacy.enumValueWrapper(AppQDialog.DialogCode.Accepted): - return - - remark = dialog.remark() - - if remark: - routing['remark'] = remark - self.sourceModel.emitAllChanged() - - dialog.finished.connect(handleResultCode) - dialog.open() - - def previewSelectedItem(self): - """Handle preview selected item for the user routing table view.""" - indexes = self.selectedIndex - - if len(indexes) != 1: - return - - routing = copy.deepcopy(self.sourceModel.routingByRow(indexes[0])) - - dialog = RoutingPreviewDialog(routing, parent=self) - dialog.open() - - def editSelectedRules(self): - """Handle edit selected rules for the user routing table view.""" - indexes = self.selectedIndex - - if not indexes: - # Nothing selected. Do nothing - return - - if len(indexes) > 1: - # Multiple items selected. Do nothing - return - - routing = self.sourceModel.routingByRow(indexes[0]) - - dialog = RoutingRulesDialog(routing, parent=self) - dialog.finished.connect(lambda _code: self.flushAll()) - dialog.open() - - -class UserRoutingWindow(AppQMainWindow): - """Present the user routing window.""" - - DEFAULT_WINDOW_SIZE = QtCore.QSize(980, 560) - - def __init__(self, *args, **kwargs): - """Initialize the UserRoutingWindow.""" - super().__init__(*args, **kwargs) - - self.setWindowTitle(_('Edit Routing')) - - self.tableView = UserRoutingTableView(parent=self) - self.toolbar = AppQToolBar( - AppQAction( - _('Add'), - icon=bootstrapIcon('plus-lg.svg'), - callback=lambda: self.tableView.appendNewItem(), - ), - AppQAction( - _('Delete'), - icon=bootstrapIcon('dash-lg.svg'), - callback=lambda: self.tableView.deleteSelectedItem(), - ), - parent=self, - ) - self.toolbar.setObjectName('UserRoutingWindow_AppQToolBar') - self.addToolBar(self.toolbar) - - centralWidget = QWidget() - - layout = QVBoxLayout(centralWidget) - layout.addWidget(self.tableView) - - self.setCentralWidget(centralWidget) - - def setWidthAndHeight(self): - """Apply the default size for the user routing window.""" - if AppSettings.get('UserRoutingWindowGeometry') is None: - self.resize(UserRoutingWindow.DEFAULT_WINDOW_SIZE) - else: - if PLATFORM == 'Darwin': - self.resize(UserRoutingWindow.DEFAULT_WINDOW_SIZE) - else: - try: - self.restoreGeometry(AppSettings.get('UserRoutingWindowGeometry')) - except Exception: - # Any non-exit exceptions - - self.resize(UserRoutingWindow.DEFAULT_WINDOW_SIZE) - - try: - self.restoreState(AppSettings.get('UserRoutingWindowState')) - except Exception: - # Any non-exit exceptions - - pass - - def cleanup(self): - """Release resources owned by the user routing window.""" - AppSettings.set('UserRoutingWindowGeometry', self.saveGeometry()) - AppSettings.set('UserRoutingWindowState', self.saveState()) +from Furious.Plugins.Official.Xray.UserRoutingWindow import * diff --git a/Furious/Window/XrayAssetViewerWindow.py b/Furious/Window/XrayAssetViewerWindow.py index e3f8941b..a29fe1e5 100644 --- a/Furious/Window/XrayAssetViewerWindow.py +++ b/Furious/Window/XrayAssetViewerWindow.py @@ -1,113 +1,5 @@ -# Copyright (C) 2024–present Loren Eteval & contributors -# -# This file is part of Furious. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Provide the application window for Xray asset viewer window.""" +"""Provide compatibility imports for the official Xray asset viewer window.""" from __future__ import annotations -from Furious.Frozenlib import * -from Furious.Qt import * -from Furious.Qt import gettext as _ -from Furious.Widget.XrayAssetViewerQListWidget import * - -from PySide6.QtGui import * -from PySide6.QtWidgets import * - -import logging - -__all__ = ['XrayAssetViewerWindow'] - -logger = logging.getLogger(__name__) - - -class XrayAssetViewerWindow(AppQMainWindow): - """Present the Xray asset viewer window.""" - - def __init__(self, *args, **kwargs): - """Initialize the XrayAssetViewerWindow.""" - super().__init__(*args, **kwargs) - - self.setWindowTitle(_('Xray-core Asset File')) - - self.xrayAssetViewerWidget = XrayAssetViewerQListWidget(parent=self) - self.setCentralWidget(self.xrayAssetViewerWidget) - - if versionToValue(PYSIDE6_VERSION) <= versionToValue('6.1.3'): - openAssetDirectoryActions = [] - else: - # openUrl will crash on PySide6 6.1.3, probably a Qt bug - openAssetDirectoryActions = [ - AppQAction( - _('Open Asset Directory'), - callback=lambda: self.openAssetDirectory(), - shortcut=QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier.ControlModifier, - QtCore.Qt.Key.Key_O, - ), - ), - ] - - self.fileMenu = AppQMenu( - AppQAction( - _('Refresh'), - callback=lambda: self.flushItem(), - shortcut=QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier.ControlModifier, - QtCore.Qt.Key.Key_R, - ), - ), - AppQSeperator(), - *openAssetDirectoryActions, - AppQAction( - _('Import From File...'), - callback=lambda: self.appendNewItem(), - ), - AppQSeperator(), - AppQAction( - _('Exit'), - callback=lambda: self.close(), - ), - title=_('File'), - parent=self.menuBar(), - ) - - self.menuBar().addMenu(self.fileMenu) - - def setWidthAndHeight(self): - """Apply the default size for the Xray asset viewer window.""" - self.resize(360, 360 * GOLDEN_RATIO) - - def flushItem(self): - """Refresh item.""" - self.xrayAssetViewerWidget.flushItem() - - @staticmethod - def openAssetDirectory(): - """Open asset directory.""" - if QDesktopServices.openUrl(QtCore.QUrl.fromLocalFile(XRAY_ASSET_DIR)): - logger.info(f'open Xray-core asset dir success') - else: - logger.error(f'open Xray-core asset dir failed') - - def appendNewItem(self): - """Append new item.""" - filename, selectedFilter = QFileDialog.getOpenFileName( - None, _('Import File'), filter=_('All files (*)') - ) - - if filename: - self.xrayAssetViewerWidget.appendNewItem(filename) +from Furious.Plugins.Official.Xray.XrayAssetViewerWindow import * diff --git a/Furious/Window/__init__.py b/Furious/Window/__init__.py index 3dd14ce0..6ac73d5d 100644 --- a/Furious/Window/__init__.py +++ b/Furious/Window/__init__.py @@ -24,5 +24,3 @@ from .LogViewerWindow import * from .QRCodeWindow import * from .TextEditorWindow import * from .UserSubsWindow import * -from .UserRoutingWindow import * -from .XrayAssetViewerWindow import *