mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-10-06 05:47:59 +03:00
Add Tor Routing support
Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
@@ -160,3 +160,6 @@ cython_debug/
|
||||
.idea/
|
||||
|
||||
.DS_Store
|
||||
|
||||
# Tor data
|
||||
Furious/Data/tordata
|
||||
|
||||
+121
-6
@@ -1,4 +1,5 @@
|
||||
from Furious.Core.Core import XrayCore, Hysteria
|
||||
from Furious.Core.TorRelay import TorRelay
|
||||
from Furious.Core.Intellisense import Intellisense
|
||||
from Furious.Core.Configuration import Configuration
|
||||
from Furious.Action.Routing import BUILTIN_ROUTING_TABLE, BUILTIN_ROUTING
|
||||
@@ -16,6 +17,7 @@ from Furious.Utility.Translator import gettext as _
|
||||
from Furious.Utility.Proxy import Proxy
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtNetwork import (
|
||||
QNetworkAccessManager,
|
||||
QNetworkReply,
|
||||
@@ -82,6 +84,7 @@ class ConnectAction(Action):
|
||||
|
||||
self.XrayCore = XrayCore()
|
||||
self.Hysteria = Hysteria()
|
||||
self.TorRelay = TorRelay()
|
||||
|
||||
def XrayCoreExitCallback(self, exitcode):
|
||||
if self.coreName:
|
||||
@@ -181,6 +184,8 @@ class ConnectAction(Action):
|
||||
self.XrayCore.stop()
|
||||
self.Hysteria.stop()
|
||||
|
||||
self.TorRelay.stop()
|
||||
|
||||
self.coreRunning = False
|
||||
|
||||
def showConnectingProgressBar(self):
|
||||
@@ -331,6 +336,11 @@ class ConnectAction(Action):
|
||||
# Show the MessageBox and wait for user to close it
|
||||
self.httpProxyConfErrorBox.exec()
|
||||
|
||||
@property
|
||||
def torRelayStorageObj(self):
|
||||
# Handy reference
|
||||
return APP().torRelaySettingsWidget.StorageObj
|
||||
|
||||
def configureCore(self):
|
||||
def validateProxyServer(server):
|
||||
# Validate proxy server
|
||||
@@ -387,8 +397,6 @@ class ConnectAction(Action):
|
||||
self.errorHttpProxyConf()
|
||||
else:
|
||||
if validateProxyServer(proxyServer):
|
||||
self.coreRunning = True
|
||||
|
||||
routing = APP().Routing
|
||||
|
||||
logger.info(f'core {XrayCore.name()} configured')
|
||||
@@ -419,9 +427,13 @@ class ConnectAction(Action):
|
||||
fixLoggingRelativePath('access')
|
||||
fixLoggingRelativePath('error')
|
||||
|
||||
# Filter Custom
|
||||
# Filter Route My Traffic Through Tor, Custom
|
||||
if routing in list(
|
||||
filter(lambda x: x != 'Custom', BUILTIN_ROUTING)
|
||||
filter(
|
||||
lambda x: x != 'Route My Traffic Through Tor'
|
||||
and x != 'Custom',
|
||||
BUILTIN_ROUTING,
|
||||
)
|
||||
):
|
||||
routingObject = BUILTIN_ROUTING_TABLE[routing][XrayCore.name()]
|
||||
|
||||
@@ -429,6 +441,29 @@ class ConnectAction(Action):
|
||||
logger.info(f'RoutingObject: {routingObject}')
|
||||
|
||||
self.coreJSON['routing'] = routingObject
|
||||
elif routing == 'Route My Traffic Through Tor':
|
||||
logger.info(f'routing is {routing}')
|
||||
|
||||
if TorRelay.checkIfExists():
|
||||
logger.info(
|
||||
f'find Tor CLI in path success. Version: {TorRelay.version()}'
|
||||
)
|
||||
|
||||
routingObject = {}
|
||||
|
||||
logger.info(f'RoutingObject: {routingObject}')
|
||||
|
||||
self.coreJSON['routing'] = routingObject
|
||||
else:
|
||||
logger.error('find Tor CLI in path failed')
|
||||
|
||||
self.coreRunning = False
|
||||
|
||||
self.disconnectReason = (
|
||||
f'{XrayCore.name()}: {_("Cannot find Tor CLI in PATH")}'
|
||||
)
|
||||
|
||||
return XrayCore.name()
|
||||
elif routing == 'Custom':
|
||||
logger.info(f'routing is {routing}')
|
||||
logger.info(f'RoutingObject: {self.XrayRouting}')
|
||||
@@ -455,6 +490,8 @@ class ConnectAction(Action):
|
||||
# Fast fail
|
||||
self.coreJSON = {}
|
||||
|
||||
self.coreRunning = True
|
||||
|
||||
# Refresh configuration modified before. User cannot feel
|
||||
self.coreText = ujson.dumps(
|
||||
self.coreJSON, ensure_ascii=False, escape_forward_slashes=False
|
||||
@@ -462,6 +499,9 @@ class ConnectAction(Action):
|
||||
# Start core
|
||||
self.XrayCore.start(self.coreText)
|
||||
|
||||
if routing == 'Route My Traffic Through Tor':
|
||||
self.startTorRelay(XrayCore.name(), proxyServer)
|
||||
|
||||
return XrayCore.name()
|
||||
|
||||
if Intellisense.getCoreType(self.coreJSON) == Hysteria.name():
|
||||
@@ -483,10 +523,13 @@ class ConnectAction(Action):
|
||||
|
||||
logger.info(f'core {Hysteria.name()} configured')
|
||||
|
||||
# Filter Global, Custom
|
||||
# Filter Route My Traffic Through Tor, Global, Custom
|
||||
if routing in list(
|
||||
filter(
|
||||
lambda x: x != 'Global' and x != 'Custom', BUILTIN_ROUTING
|
||||
lambda x: x != 'Route My Traffic Through Tor'
|
||||
and x != 'Global'
|
||||
and x != 'Custom',
|
||||
BUILTIN_ROUTING,
|
||||
)
|
||||
):
|
||||
logger.info(f'routing is {routing}')
|
||||
@@ -498,6 +541,27 @@ class ConnectAction(Action):
|
||||
Hysteria.rule(routingObject.get('acl')),
|
||||
Hysteria.mmdb(routingObject.get('mmdb')),
|
||||
)
|
||||
elif routing == 'Route My Traffic Through Tor':
|
||||
logger.info(f'routing is {routing}')
|
||||
|
||||
if TorRelay.checkIfExists():
|
||||
logger.info(
|
||||
f'find Tor CLI in path success. Version: {TorRelay.version()}'
|
||||
)
|
||||
|
||||
self.Hysteria.start(self.coreText, '', '')
|
||||
|
||||
self.startTorRelay(Hysteria.name(), proxyServer)
|
||||
else:
|
||||
logger.error('find Tor CLI in path failed')
|
||||
|
||||
self.coreRunning = False
|
||||
|
||||
self.disconnectReason = (
|
||||
f'{Hysteria.name()}: {_("Cannot find Tor CLI in PATH")}'
|
||||
)
|
||||
|
||||
return Hysteria.name()
|
||||
elif routing == 'Global':
|
||||
logger.info(f'routing is {routing}')
|
||||
|
||||
@@ -539,6 +603,57 @@ class ConnectAction(Action):
|
||||
# No matching core
|
||||
return ''
|
||||
|
||||
def startTorRelay(self, core, proxyServer, startCounter=0, step=1):
|
||||
# Redirect Proxy
|
||||
self.proxyServer = (
|
||||
f'127.0.0.1:{self.torRelayStorageObj.get("httpsTunnelPort", 9049)}'
|
||||
)
|
||||
|
||||
try:
|
||||
timeout = 1000 * int(
|
||||
self.torRelayStorageObj.get('relayEstablishTimeout', 15)
|
||||
)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
# 15s
|
||||
timeout = 1000 * 15
|
||||
|
||||
logger.info(f'{TorRelay.name()} bootstrap timeout is {timeout // 1000}s')
|
||||
|
||||
# Start Tor Relay
|
||||
if self.torRelayStorageObj.get('useProxy', True):
|
||||
# Use proxy
|
||||
self.TorRelay.start(proxyServer=proxyServer)
|
||||
else:
|
||||
# Do not use proxy
|
||||
self.TorRelay.start()
|
||||
|
||||
while (
|
||||
self.coreRunning
|
||||
and startCounter < timeout
|
||||
and self.TorRelay.bootstrapPercentage != 100
|
||||
):
|
||||
QTest.qWait(step)
|
||||
|
||||
startCounter += step
|
||||
|
||||
if not self.coreRunning:
|
||||
# Interrupted externally. Return
|
||||
return
|
||||
|
||||
if self.TorRelay.bootstrapPercentage != 100:
|
||||
# bootstrap timeout
|
||||
self.stopCore()
|
||||
# Assign disconnect reason
|
||||
self.disconnectReason = (
|
||||
f'{core}: {_(f"{TorRelay.name()} establish timeout")}'
|
||||
)
|
||||
|
||||
logger.error(f'{TorRelay.name()} establish failed')
|
||||
else:
|
||||
logger.info(f'{TorRelay.name()} establish success')
|
||||
|
||||
def startConnectionTest(
|
||||
self, showRoutingChangedMessage=False, currentRouting='', isBuiltinRouting=False
|
||||
):
|
||||
|
||||
@@ -95,6 +95,10 @@ BUILTIN_ROUTING_TABLE = {
|
||||
'mmdb': (DATA_DIR / 'hysteria' / 'country.mmdb').as_posix(),
|
||||
},
|
||||
},
|
||||
'Route My Traffic Through Tor': {
|
||||
XrayCore.name(): {},
|
||||
Hysteria.name(): {},
|
||||
},
|
||||
'Global': {
|
||||
XrayCore.name(): {
|
||||
'domainStrategy': 'IPIfNonMatch',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from Furious.Gui.Action import Action
|
||||
from Furious.Gui.Action import Action, Seperator
|
||||
from Furious.Widget.Widget import Menu
|
||||
from Furious.Utility.Constants import APP
|
||||
from Furious.Utility.Utility import bootstrapIcon, Switch
|
||||
@@ -34,6 +34,22 @@ class SettingsChildAction(Action):
|
||||
APP().ServerWidget.showTabAndSpacesIfNecessary()
|
||||
|
||||
|
||||
class RoutingChildAction(Action):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(_('Routing Settings...'), **kwargs)
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
APP().RoutesWidget.show()
|
||||
|
||||
|
||||
class TorRelaySettingsChildAction(Action):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
APP().torRelaySettingsWidget.open()
|
||||
|
||||
|
||||
class SettingsAction(Action):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
@@ -55,6 +71,10 @@ class SettingsAction(Action):
|
||||
checkable=True,
|
||||
checked=APP().ShowTabAndSpacesInEditor == Switch.ON_,
|
||||
),
|
||||
Seperator(),
|
||||
RoutingChildAction(),
|
||||
Seperator(),
|
||||
TorRelaySettingsChildAction(_('Tor Relay Settings...')),
|
||||
),
|
||||
useActionGroup=False,
|
||||
)
|
||||
|
||||
@@ -63,7 +63,7 @@ class Core:
|
||||
|
||||
if self.checkAlive():
|
||||
# Start core daemon
|
||||
self._daemonTimer.start(100)
|
||||
self._daemonTimer.start(1)
|
||||
|
||||
def stop(self):
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
from Furious.Core.Core import Core
|
||||
from Furious.Utility.Constants import (
|
||||
APP,
|
||||
DATA_DIR,
|
||||
DEFAULT_TOR_SOCKS_PORT,
|
||||
DEFAULT_TOR_HTTPS_PORT,
|
||||
)
|
||||
from Furious.Utility.Utility import AsyncSubprocessMessage
|
||||
|
||||
from PySide6 import QtCore
|
||||
|
||||
import re
|
||||
import signal
|
||||
import logging
|
||||
import threading
|
||||
import subprocess
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TorRelayStarter(AsyncSubprocessMessage):
|
||||
BOOTSTRAP_STATUS = re.compile('Bootstrapped ([0-9]+)%')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.connectTimeoutCallback(self.processLine)
|
||||
|
||||
self.torRelay = None
|
||||
|
||||
self.bootstrapPercentage = 0
|
||||
|
||||
@property
|
||||
def torRelayStorageObj(self):
|
||||
# Handy reference
|
||||
return APP().torRelaySettingsWidget.StorageObj
|
||||
|
||||
def launch(self, proxyServer):
|
||||
self.torRelay = subprocess.Popen(
|
||||
['tor', '-f', '-'],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
|
||||
torConfig = (
|
||||
f'SocksPort {self.torRelayStorageObj.get("socksTunnelPort", DEFAULT_TOR_SOCKS_PORT)}\n'
|
||||
f'HTTPTunnelPort {self.torRelayStorageObj.get("httpsTunnelPort", DEFAULT_TOR_HTTPS_PORT)}\n'
|
||||
f'Log {self.torRelayStorageObj.get("logLevel", "notice")} stdout\n'
|
||||
f'DataDirectory {DATA_DIR / "tordata"}\n'
|
||||
)
|
||||
|
||||
if proxyServer:
|
||||
logger.info(f'{TorRelay.name()} uses proxy server {proxyServer}')
|
||||
|
||||
# Use proxy
|
||||
self.torRelay.stdin.write(f'{torConfig}HTTPSProxy {proxyServer}\n'.encode())
|
||||
else:
|
||||
self.torRelay.stdin.write(torConfig.encode())
|
||||
|
||||
self.torRelay.stdin.close()
|
||||
|
||||
self.startDaemonThread(self.torRelay.stdout)
|
||||
# Begin get message
|
||||
self.startTimer()
|
||||
|
||||
@QtCore.Slot()
|
||||
def processLine(self):
|
||||
line = self.getLineNoWait()
|
||||
|
||||
if line:
|
||||
APP().torViewerWidget.textBrowser.append(line)
|
||||
|
||||
match = TorRelayStarter.BOOTSTRAP_STATUS.search(line)
|
||||
|
||||
if match:
|
||||
percentage = int(match.group(1))
|
||||
|
||||
self.bootstrapPercentage = percentage
|
||||
|
||||
logger.info(f'{TorRelay.name()} bootstrapped {percentage}%')
|
||||
|
||||
|
||||
class TorRelay(Core):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.starter = TorRelayStarter()
|
||||
|
||||
@staticmethod
|
||||
def checkIfExists():
|
||||
try:
|
||||
subprocess.run(
|
||||
['tor', '--version'],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=True,
|
||||
)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def name():
|
||||
return 'Tor Relay'
|
||||
|
||||
@staticmethod
|
||||
def version():
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['tor', '--version'],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=True,
|
||||
)
|
||||
|
||||
# First line, 3rd param...
|
||||
return result.stdout.decode().split('\n')[0].split()[2]
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return '0.0.0'
|
||||
|
||||
@property
|
||||
def bootstrapPercentage(self):
|
||||
return self.starter.bootstrapPercentage
|
||||
|
||||
def start(self, *args, **kwargs):
|
||||
logger.info(f'{self.name()} {self.version()} started')
|
||||
|
||||
self.starter.launch(kwargs.get('proxyServer', ''))
|
||||
|
||||
def stop(self):
|
||||
if isinstance(self.starter.torRelay, subprocess.Popen):
|
||||
# Stop timer
|
||||
self.starter.stopTimer()
|
||||
|
||||
# Terminated by signal SIGTERM
|
||||
self.starter.torRelay.send_signal(signal.SIGTERM)
|
||||
|
||||
exitcode = self.starter.torRelay.wait()
|
||||
|
||||
logger.info(f'{self.name()} terminated with exitcode {exitcode}')
|
||||
|
||||
self.starter.bootstrapPercentage = 0
|
||||
# Reset relay
|
||||
self.starter.torRelay = None
|
||||
@@ -38,6 +38,13 @@ PROXY_SERVER_BYPASS = (
|
||||
'172.26.*;172.27.*;172.28.*;172.29.*;172.30.*;172.31.*;192.168.*'
|
||||
)
|
||||
|
||||
TOR_FAQ_URL = 'https://support.torproject.org/faq/'
|
||||
TOR_FAQ_LABEL = f'Tor FAQ: <a href=\"{TOR_FAQ_URL}\">{TOR_FAQ_URL}</a>'
|
||||
|
||||
# Avoid standard Tor port in case of running Tor services
|
||||
DEFAULT_TOR_SOCKS_PORT = 9048
|
||||
DEFAULT_TOR_HTTPS_PORT = 9047
|
||||
|
||||
|
||||
class Color:
|
||||
LIGHT_RED_ = '#FF7276'
|
||||
|
||||
@@ -65,6 +65,8 @@ SUPPORTED_SETTINGS = (
|
||||
Settings('Configuration'),
|
||||
# User Activated Server Index
|
||||
Settings('ActivatedItemIndex'),
|
||||
# User Tor Relay Settings,
|
||||
Settings('TorRelaySettings'),
|
||||
# Server Widget Window Size
|
||||
Settings('ServerWidgetWindowSize'),
|
||||
# Routes Widget Window Size
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from Furious.Core.Core import XrayCore, Hysteria
|
||||
from Furious.Utility.Constants import APP, APPLICATION_NAME
|
||||
from Furious.Core.TorRelay import TorRelay
|
||||
from Furious.Utility.Constants import APP, APPLICATION_NAME, TOR_FAQ_LABEL
|
||||
|
||||
|
||||
class Translatable:
|
||||
@@ -87,6 +88,9 @@ NO_TRANSLATION = [
|
||||
'TLS',
|
||||
XrayCore.name(),
|
||||
Hysteria.name(),
|
||||
TOR_FAQ_LABEL,
|
||||
'socks',
|
||||
'http',
|
||||
]
|
||||
|
||||
NO_TRANSLATION_DICT = {key: True for key in NO_TRANSLATION}
|
||||
@@ -133,6 +137,11 @@ TRANSLATION = {
|
||||
'ZH': '绕过伊朗',
|
||||
'TW': '繞過伊朗',
|
||||
},
|
||||
'Route My Traffic Through Tor': {
|
||||
'ES': 'Enrutar Mi Tráfico a Través de Tor',
|
||||
'ZH': '通过Tor路由我的流量',
|
||||
'TW': '通過Tor路由我的流量',
|
||||
},
|
||||
'Global': {
|
||||
'ES': 'Global',
|
||||
'ZH': '全球',
|
||||
@@ -143,11 +152,6 @@ TRANSLATION = {
|
||||
'ZH': '自定义',
|
||||
'TW': '自定義',
|
||||
},
|
||||
'Edit Routing...': {
|
||||
'ES': 'Editar Enrutamiento...',
|
||||
'ZH': '编辑路由...',
|
||||
'TW': '編輯路由...',
|
||||
},
|
||||
'Import': {
|
||||
'ES': 'Importar',
|
||||
'ZH': '导入',
|
||||
@@ -193,15 +197,30 @@ TRANSLATION = {
|
||||
'ZH': '在编辑器中显示制表符和空格',
|
||||
'TW': '在編輯器中顯示制表符和空格',
|
||||
},
|
||||
'Routing Settings...': {
|
||||
'ES': 'Ajustes de Enrutamiento...',
|
||||
'ZH': '路由设置...',
|
||||
'TW': '路由設置...',
|
||||
},
|
||||
'Tor Relay Settings...': {
|
||||
'ES': 'Ajustes de Tor Relay...',
|
||||
'ZH': 'Tor Relay设置...',
|
||||
'TW': 'Tor Relay設置...',
|
||||
},
|
||||
'Help': {
|
||||
'ES': 'Ayuda',
|
||||
'ZH': '帮助',
|
||||
'TW': '幫助',
|
||||
},
|
||||
'Show Log...': {
|
||||
'ES': 'Mostrar Registro...',
|
||||
'ZH': '显示日志...',
|
||||
'TW': '顯示日志...',
|
||||
f'Show {APPLICATION_NAME} Log...': {
|
||||
'ES': f'Mostrar Registro de {APPLICATION_NAME}...',
|
||||
'ZH': f'显示{APPLICATION_NAME}日志...',
|
||||
'TW': f'顯示{APPLICATION_NAME}日志...',
|
||||
},
|
||||
f'Show {TorRelay.name()} Log...': {
|
||||
'ES': f'Mostrar Registro de {TorRelay.name()}...',
|
||||
'ZH': f'显示{TorRelay.name()}日志...',
|
||||
'TW': f'顯示{TorRelay.name()}日志...',
|
||||
},
|
||||
'Check For Updates': {
|
||||
'ES': 'Comprobar Actualizaciones',
|
||||
@@ -771,6 +790,42 @@ TRANSLATION = {
|
||||
'ZH': '日志查看器',
|
||||
'TW': '日志查看器',
|
||||
},
|
||||
# Tor Relay Settings
|
||||
'Tor Relay Settings': {
|
||||
'ES': 'Ajustes de Tor Relay',
|
||||
'ZH': 'Tor Relay设置',
|
||||
'TW': 'Tor Relay設置',
|
||||
},
|
||||
'Tunnel Port': {
|
||||
'ES': 'Puerto de Túnel',
|
||||
'ZH': '隧道端口',
|
||||
'TW': '隧道端口',
|
||||
},
|
||||
'Proxy': {
|
||||
'ES': 'Proxy',
|
||||
'ZH': '代理',
|
||||
'TW': '代理',
|
||||
},
|
||||
'Other': {
|
||||
'ES': 'Otro',
|
||||
'ZH': '其它',
|
||||
'TW': '其它',
|
||||
},
|
||||
'Use Proxy': {
|
||||
'ES': 'Usa Proxy',
|
||||
'ZH': '使用代理',
|
||||
'TW': '使用代理',
|
||||
},
|
||||
'Log Level': {
|
||||
'ES': 'Nivel de Registro',
|
||||
'ZH': '日志级别',
|
||||
'TW': '日志級別',
|
||||
},
|
||||
'Relay Establish Timeout(seconds)': {
|
||||
'ES': 'Establecer Tiempo de Espera de Relay(segundos)',
|
||||
'ZH': 'Relay建立超时(秒)',
|
||||
'TW': 'Relay建立超時(秒)',
|
||||
},
|
||||
# Check For Updates
|
||||
'Check for updates failed.': {
|
||||
'ES': 'Error al comprobar actualizaciones.',
|
||||
@@ -838,6 +893,16 @@ TRANSLATION = {
|
||||
'ZH': '路由已更改:',
|
||||
'TW': '路由已更改:',
|
||||
},
|
||||
f'{TorRelay.name()} establish timeout': {
|
||||
'ES': f'Establecer tiempo de espera de {TorRelay.name()}',
|
||||
'ZH': f'{TorRelay.name()}建立超时',
|
||||
'TW': f'{TorRelay.name()}建立超時',
|
||||
},
|
||||
'Cannot find Tor CLI in PATH': {
|
||||
'ES': 'No se puede encontrar Tor CLI en PATH',
|
||||
'ZH': '在PATH中找不到Tor CLI',
|
||||
'TW': '在PATH中找不到Tor CLI',
|
||||
},
|
||||
# Errors
|
||||
f'{APPLICATION_NAME} is not be able to run on this operating system.': {
|
||||
'ES': f'{APPLICATION_NAME} no puede ejecutarse en este sistema operativo.',
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
from Furious.Gui.Icon import Icon
|
||||
from Furious.Utility.Constants import APP, PLATFORM, ROOT_DIR
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
import os
|
||||
import copy
|
||||
import ujson
|
||||
import queue
|
||||
import logging
|
||||
import pybase64
|
||||
import threading
|
||||
import functools
|
||||
import subprocess
|
||||
|
||||
@@ -118,6 +121,87 @@ class RoutesStorage:
|
||||
APP().CustomRouting = ''
|
||||
|
||||
|
||||
class TorRelaySettingsStorage:
|
||||
EMPTY_OBJECT = {
|
||||
'socksTunnelPort': 9050,
|
||||
'httpsTunnelPort': 9049,
|
||||
'useProxy': True,
|
||||
'logLevel': 'notice',
|
||||
'relayEstablishTimeout': 15,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def init():
|
||||
return copy.deepcopy(TorRelaySettingsStorage.EMPTY_OBJECT)
|
||||
|
||||
@staticmethod
|
||||
def sync(ob=None):
|
||||
if ob is None:
|
||||
# Object is up-to-date
|
||||
APP().TorRelaySettings = TorRelaySettingsStorage.toStorage(
|
||||
APP().torRelaySettingsWidget.StorageObj
|
||||
)
|
||||
else:
|
||||
# Object is up-to-date
|
||||
APP().TorRelaySettings = TorRelaySettingsStorage.toStorage(ob)
|
||||
|
||||
@staticmethod
|
||||
def toObject(st):
|
||||
if not st:
|
||||
# Server storage does not exist, or is empty
|
||||
return TorRelaySettingsStorage.init()
|
||||
|
||||
return ujson.loads(Base64Encoder.decode(st))
|
||||
|
||||
@staticmethod
|
||||
def toStorage(ob):
|
||||
return Base64Encoder.encode(
|
||||
ujson.dumps(ob, ensure_ascii=False, escape_forward_slashes=False).encode()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def clear():
|
||||
APP().TorRelaySettings = ''
|
||||
|
||||
|
||||
class AsyncSubprocessMessage:
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.msgQueue = queue.Queue()
|
||||
self.msgTimer = QtCore.QTimer()
|
||||
|
||||
self.daemonThread = None
|
||||
|
||||
def connectTimeoutCallback(self, callback):
|
||||
self.msgTimer.timeout.connect(callback)
|
||||
|
||||
def startDaemonThread(self, stdout):
|
||||
def enqueue(msgQueue):
|
||||
for line in iter(stdout.readline, b''):
|
||||
msgQueue.put(line.decode('utf-8', 'replace').strip())
|
||||
|
||||
stdout.close()
|
||||
|
||||
self.daemonThread = threading.Thread(
|
||||
target=enqueue, args=(self.msgQueue,), daemon=True
|
||||
)
|
||||
self.daemonThread.start()
|
||||
|
||||
def startTimer(self, msec=1):
|
||||
self.msgTimer.start(msec)
|
||||
|
||||
def stopTimer(self):
|
||||
self.msgTimer.stop()
|
||||
|
||||
def getLineNoWait(self):
|
||||
try:
|
||||
return self.msgQueue.get_nowait()
|
||||
except queue.Empty:
|
||||
# Queue is empty
|
||||
return ''
|
||||
|
||||
|
||||
class Switch:
|
||||
OFF = '0'
|
||||
ON_ = '1'
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from Furious.Widget.SystemTrayIcon import SystemTrayIcon
|
||||
from Furious.Widget.EditConfiguration import EditConfigurationWidget
|
||||
from Furious.Widget.EditRouting import EditRoutingWidget
|
||||
from Furious.Widget.LogViewer import LogViewerWidget
|
||||
from Furious.Widget.LogViewer import LogViewerWidget, TorViewerWidget
|
||||
from Furious.Widget.TorRelaySettings import TorRelaySettingsWidget
|
||||
from Furious.Utility.Constants import (
|
||||
APPLICATION_NAME,
|
||||
APPLICATION_VERSION,
|
||||
@@ -142,6 +143,8 @@ class Application(SingletonApplication):
|
||||
self.customFontName = ''
|
||||
|
||||
self.logViewerWidget = None
|
||||
self.torViewerWidget = None
|
||||
|
||||
self.logViewerHandle = None
|
||||
self.logStreamHandle = None
|
||||
|
||||
@@ -150,6 +153,7 @@ class Application(SingletonApplication):
|
||||
|
||||
self.ServerWidget = None
|
||||
self.RoutesWidget = None
|
||||
self.torRelaySettingsWidget = None
|
||||
|
||||
def __getattr__(self, key):
|
||||
try:
|
||||
@@ -178,6 +182,8 @@ class Application(SingletonApplication):
|
||||
|
||||
def configureLogging(self):
|
||||
self.logViewerWidget = LogViewerWidget()
|
||||
self.torViewerWidget = TorViewerWidget()
|
||||
|
||||
self.logViewerHandle = LogViewerHandle(self.logViewerWidget.textBrowser)
|
||||
self.logStreamHandle = logging.StreamHandler()
|
||||
|
||||
@@ -247,6 +253,7 @@ class Application(SingletonApplication):
|
||||
|
||||
self.ServerWidget = EditConfigurationWidget()
|
||||
self.RoutesWidget = EditRoutingWidget()
|
||||
self.torRelaySettingsWidget = TorRelaySettingsWidget()
|
||||
|
||||
self.tray = SystemTrayIcon()
|
||||
self.tray.show()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from Furious.Core.TorRelay import TorRelay
|
||||
from Furious.Action.Import import ImportLinkAction, ImportJSONAction
|
||||
from Furious.Action.Export import ExportLinkAction, ExportJSONAction, ExportQRCodeAction
|
||||
from Furious.Core.Intellisense import Intellisense
|
||||
@@ -600,22 +601,22 @@ class ZoomOutAction(Action):
|
||||
self.parent().plainTextEdit.zoomOut()
|
||||
|
||||
|
||||
class RoutingAction(Action):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(_('Edit Routing...'), **kwargs)
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
APP().RoutesWidget.show()
|
||||
|
||||
|
||||
class ShowLogAction(Action):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(_('Show Log...'), **kwargs)
|
||||
super().__init__(_(f'Show {APPLICATION_NAME} Log...'), **kwargs)
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
APP().logViewerWidget.showMaximized()
|
||||
|
||||
|
||||
class ShowTorRelayLogAction(Action):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(_(f'Show {TorRelay.name()} Log...'), **kwargs)
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
APP().torViewerWidget.showMaximized()
|
||||
|
||||
|
||||
class WhetherToUpdateInfoBox(MessageBox):
|
||||
def __init__(self, version='0.0.0', *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -1799,13 +1800,6 @@ class EditConfigurationWidget(MainWindow):
|
||||
],
|
||||
}
|
||||
|
||||
routingMenu = {
|
||||
'name': 'Routing',
|
||||
'actions': [
|
||||
RoutingAction(parent=self),
|
||||
],
|
||||
}
|
||||
|
||||
viewMenu = {
|
||||
'name': 'View',
|
||||
'actions': [
|
||||
@@ -1818,6 +1812,7 @@ class EditConfigurationWidget(MainWindow):
|
||||
'name': 'Help',
|
||||
'actions': [
|
||||
ShowLogAction(parent=self),
|
||||
ShowTorRelayLogAction(parent=self),
|
||||
Seperator(),
|
||||
CheckForUpdatesAction(parent=self),
|
||||
AboutAction(parent=self),
|
||||
@@ -1825,7 +1820,7 @@ class EditConfigurationWidget(MainWindow):
|
||||
}
|
||||
|
||||
# Menus
|
||||
for menuDict in (fileMenu, editMenu, routingMenu, viewMenu, helpMenu):
|
||||
for menuDict in (fileMenu, editMenu, viewMenu, helpMenu):
|
||||
menuName = menuDict['name']
|
||||
menuObjName = f'_{menuName}Menu'
|
||||
menu = Menu(*menuDict['actions'], title=_(menuName), parent=self.menuBar())
|
||||
|
||||
@@ -30,6 +30,8 @@ from Furious.Utility.Constants import (
|
||||
APPLICATION_NAME,
|
||||
GOLDEN_RATIO,
|
||||
DATA_DIR,
|
||||
TOR_FAQ_URL,
|
||||
TOR_FAQ_LABEL,
|
||||
Color,
|
||||
)
|
||||
from Furious.Utility.Utility import (
|
||||
@@ -141,6 +143,20 @@ BUILTIN_ROUTING_TEXT = {
|
||||
escape_forward_slashes=False,
|
||||
),
|
||||
},
|
||||
'Route My Traffic Through Tor': {
|
||||
XrayCore.name(): (
|
||||
f'# Xray-core Routing rules\n'
|
||||
f'# \n'
|
||||
f'# This rule is read-only.\n'
|
||||
f'# \n'
|
||||
f'# Use Tor to achieve better anonymity, but {APPLICATION_NAME}\n'
|
||||
f'# never claims nor guarantees *FULL* anonymity.\n'
|
||||
f'# \n'
|
||||
f'# See more information:\n'
|
||||
f'# \n'
|
||||
f'# {TOR_FAQ_URL}\n\n'
|
||||
),
|
||||
},
|
||||
'Global': {
|
||||
XrayCore.name(): (
|
||||
f'# Xray-core Routing rules\n'
|
||||
@@ -192,6 +208,9 @@ ROUTING_HINT = {
|
||||
'Bypass Iran': {
|
||||
Hysteria.name(): '',
|
||||
},
|
||||
'Route My Traffic Through Tor': {
|
||||
Hysteria.name(): TOR_FAQ_LABEL,
|
||||
},
|
||||
'Global': {
|
||||
Hysteria.name(): HYSTERIA_ROUTING_HINT,
|
||||
},
|
||||
@@ -441,7 +460,7 @@ class EditRoutingAction(Action):
|
||||
if dialog.isBuiltin:
|
||||
# Built-in. Nothing to do
|
||||
logger.info(
|
||||
f'built-in routing dialog {BUILTIN_ROUTING[index]} called exec. Nothing to do'
|
||||
f'built-in routing dialog \'{BUILTIN_ROUTING[index]}\' called exec. Nothing to do'
|
||||
)
|
||||
|
||||
continue
|
||||
@@ -729,6 +748,11 @@ class RoutingDialog(Translatable, SupportConnectedCallback, QDialog):
|
||||
|
||||
if hint:
|
||||
self.hint = QLabel(hint)
|
||||
self.hint.setTextFormat(QtCore.Qt.TextFormat.RichText)
|
||||
self.hint.setTextInteractionFlags(
|
||||
QtCore.Qt.TextInteractionFlag.TextBrowserInteraction
|
||||
)
|
||||
self.hint.setOpenExternalLinks(True)
|
||||
|
||||
layout.addRow(self.hint)
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
from Furious.Widget.Widget import GroupBox, Label
|
||||
from Furious.Utility.Constants import (
|
||||
APP,
|
||||
DEFAULT_TOR_SOCKS_PORT,
|
||||
DEFAULT_TOR_HTTPS_PORT,
|
||||
)
|
||||
from Furious.Utility.Utility import (
|
||||
StateContext,
|
||||
SupportConnectedCallback,
|
||||
TorRelaySettingsStorage,
|
||||
bootstrapIcon,
|
||||
moveToCenter,
|
||||
)
|
||||
from Furious.Utility.Translator import Translatable, gettext as _
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QFormLayout,
|
||||
QGridLayout,
|
||||
QSpinBox,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
|
||||
class TorRelaySettingsWidget(Translatable, SupportConnectedCallback, QDialog):
|
||||
TOR_LOG_LEVEL = ['err', 'warn', 'notice', 'info', 'debug']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.setWindowTitle(_('Tor Relay Settings'))
|
||||
self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg'))
|
||||
|
||||
try:
|
||||
self.StorageObj = TorRelaySettingsStorage.toObject(APP().TorRelaySettings)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
self.StorageObj = TorRelaySettingsStorage.init()
|
||||
|
||||
# Clear it
|
||||
TorRelaySettingsStorage.clear()
|
||||
|
||||
self.endpointGroupBox = GroupBox(_('Tunnel Port'), parent=self)
|
||||
self.useProxyGroupBox = GroupBox(_('Proxy'), parent=self)
|
||||
self.torOtherGroupBox = GroupBox(_('Other'), parent=self)
|
||||
|
||||
# Begin endpoint groupbox
|
||||
self.socksTunnelLabel = Label(_('socks'), parent=self)
|
||||
self.httpsTunnelLabel = Label(_('http'), parent=self)
|
||||
|
||||
self.socksTunnelSpinBox = QSpinBox(parent=self)
|
||||
self.socksTunnelSpinBox.setMinimum(0)
|
||||
self.socksTunnelSpinBox.setMaximum(65535)
|
||||
|
||||
self.httpsTunnelSpinBox = QSpinBox(parent=self)
|
||||
self.httpsTunnelSpinBox.setMinimum(0)
|
||||
self.httpsTunnelSpinBox.setMaximum(65535)
|
||||
|
||||
self.socksTunnelWidget = QWidget(parent=self)
|
||||
self.socksTunnelLayout = QFormLayout(parent=self.socksTunnelWidget)
|
||||
self.socksTunnelLayout.addRow(self.socksTunnelLabel, self.socksTunnelSpinBox)
|
||||
self.socksTunnelWidget.setLayout(self.socksTunnelLayout)
|
||||
|
||||
self.httpsTunnelWidget = QWidget(parent=self)
|
||||
self.httpsTunnelLayout = QFormLayout(parent=self.httpsTunnelWidget)
|
||||
self.httpsTunnelLayout.addRow(self.httpsTunnelLabel, self.httpsTunnelSpinBox)
|
||||
self.httpsTunnelWidget.setLayout(self.httpsTunnelLayout)
|
||||
|
||||
self.endpointGroupBoxLayout = QGridLayout(parent=self.endpointGroupBox)
|
||||
self.endpointGroupBoxLayout.addWidget(self.socksTunnelWidget, 0, 0)
|
||||
self.endpointGroupBoxLayout.addWidget(self.httpsTunnelWidget, 0, 1)
|
||||
|
||||
self.endpointGroupBox.setLayout(self.endpointGroupBoxLayout)
|
||||
# End endpoint groupbox
|
||||
|
||||
# Begin useProxy groupbox
|
||||
self.useProxyLabel = Label(_('Use Proxy'), parent=self)
|
||||
|
||||
self.useProxyCheckBox = QCheckBox(parent=self)
|
||||
|
||||
self.useProxyWidget = QWidget(parent=self)
|
||||
self.useProxyLayout = QFormLayout(parent=self.useProxyWidget)
|
||||
self.useProxyLayout.addRow(self.useProxyLabel, self.useProxyCheckBox)
|
||||
self.useProxyWidget.setLayout(self.useProxyLayout)
|
||||
|
||||
self.useProxyGroupBoxLayout = QGridLayout(parent=self.useProxyGroupBox)
|
||||
self.useProxyGroupBoxLayout.addWidget(self.useProxyWidget, 0, 0)
|
||||
|
||||
self.useProxyGroupBox.setLayout(self.useProxyGroupBoxLayout)
|
||||
# End useProxy groupbox
|
||||
|
||||
# Begin torOther groupbox
|
||||
self.torLogLevelLabel = Label(_('Log Level'), parent=self)
|
||||
|
||||
self.torLogLevelComboBox = QComboBox(parent=self)
|
||||
self.torLogLevelComboBox.addItems(TorRelaySettingsWidget.TOR_LOG_LEVEL)
|
||||
|
||||
self.torRelayTimeoutLabel = Label(
|
||||
_('Relay Establish Timeout(seconds)'), parent=self
|
||||
)
|
||||
|
||||
self.torRelayTimeoutSpinBox = QSpinBox(parent=self)
|
||||
self.torRelayTimeoutSpinBox.setMinimum(0)
|
||||
self.torRelayTimeoutSpinBox.setMaximum(3600)
|
||||
|
||||
self.torLogLevelWidget = QWidget(parent=self)
|
||||
self.torLogLevelLayout = QFormLayout(parent=self.torLogLevelWidget)
|
||||
self.torLogLevelLayout.addRow(self.torLogLevelLabel, self.torLogLevelComboBox)
|
||||
self.torLogLevelWidget.setLayout(self.torLogLevelLayout)
|
||||
|
||||
self.torRelayTimeoutWidget = QWidget(parent=self)
|
||||
self.torRelayTimeoutLayout = QFormLayout(parent=self.torRelayTimeoutWidget)
|
||||
self.torRelayTimeoutLayout.addRow(
|
||||
self.torRelayTimeoutLabel, self.torRelayTimeoutSpinBox
|
||||
)
|
||||
self.torRelayTimeoutWidget.setLayout(self.torRelayTimeoutLayout)
|
||||
|
||||
self.torOtherGroupBoxLayout = QGridLayout(parent=self.torOtherGroupBox)
|
||||
self.torOtherGroupBoxLayout.addWidget(self.torLogLevelWidget, 0, 0)
|
||||
self.torOtherGroupBoxLayout.addWidget(self.torRelayTimeoutWidget, 0, 1)
|
||||
|
||||
self.torOtherGroupBox.setLayout(self.torOtherGroupBoxLayout)
|
||||
# End torOther groupbox
|
||||
|
||||
# Restore value
|
||||
self.restoreValueFromObject()
|
||||
|
||||
# Dialog buttons
|
||||
self.dialogBtns = QDialogButtonBox(
|
||||
QtCore.Qt.Orientation.Horizontal, parent=self
|
||||
)
|
||||
|
||||
self.dialogBtns.addButton(_('OK'), QDialogButtonBox.ButtonRole.AcceptRole)
|
||||
self.dialogBtns.addButton(_('Cancel'), QDialogButtonBox.ButtonRole.RejectRole)
|
||||
self.dialogBtns.accepted.connect(self.handleAccepted)
|
||||
self.dialogBtns.rejected.connect(self.handleRejected)
|
||||
|
||||
# Central Widget
|
||||
self.fakeCentralWidget = QWidget(parent=self)
|
||||
|
||||
self.layout = QGridLayout(parent=self.fakeCentralWidget)
|
||||
self.layout.addWidget(self.endpointGroupBox, 0, 0)
|
||||
self.layout.addWidget(self.useProxyGroupBox, 1, 0)
|
||||
self.layout.addWidget(self.torOtherGroupBox, 2, 0)
|
||||
self.layout.addWidget(self.dialogBtns, 3, 0)
|
||||
|
||||
self.setLayout(self.layout)
|
||||
|
||||
@QtCore.Slot()
|
||||
def handleAccepted(self):
|
||||
# Sync object
|
||||
self.StorageObj['socksTunnelPort'] = self.socksTunnelSpinBox.value()
|
||||
self.StorageObj['httpsTunnelPort'] = self.httpsTunnelSpinBox.value()
|
||||
self.StorageObj['useProxy'] = self.useProxyCheckBox.isChecked()
|
||||
self.StorageObj['logLevel'] = self.torLogLevelComboBox.currentText()
|
||||
self.StorageObj['relayEstablishTimeout'] = self.torRelayTimeoutSpinBox.value()
|
||||
|
||||
# Sync it
|
||||
TorRelaySettingsStorage.sync()
|
||||
|
||||
self.hide()
|
||||
|
||||
@QtCore.Slot()
|
||||
def handleRejected(self):
|
||||
self.restoreValueFromObject()
|
||||
self.hide()
|
||||
|
||||
def restoreValueFromObject(self):
|
||||
# Restore value
|
||||
self.socksTunnelSpinBox.setValue(
|
||||
self.StorageObj.get('socksTunnelPort', DEFAULT_TOR_SOCKS_PORT)
|
||||
)
|
||||
self.httpsTunnelSpinBox.setValue(
|
||||
self.StorageObj.get('httpsTunnelPort', DEFAULT_TOR_HTTPS_PORT)
|
||||
)
|
||||
|
||||
self.useProxyCheckBox.setChecked(self.StorageObj.get('useProxy', True))
|
||||
|
||||
self.torLogLevelComboBox.setCurrentText(
|
||||
self.StorageObj.get('logLevel', 'notice')
|
||||
)
|
||||
self.torRelayTimeoutSpinBox.setValue(
|
||||
self.StorageObj.get('relayEstablishTimeout', 15)
|
||||
)
|
||||
|
||||
def closeEvent(self, event):
|
||||
event.ignore()
|
||||
|
||||
self.restoreValueFromObject()
|
||||
self.hide()
|
||||
|
||||
def connectedCallback(self):
|
||||
self.setWindowIcon(bootstrapIcon('rocket-takeoff-connected-dark.svg'))
|
||||
|
||||
def disconnectedCallback(self):
|
||||
self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg'))
|
||||
|
||||
def retranslate(self):
|
||||
with StateContext(self):
|
||||
self.setWindowTitle(_(self.windowTitle()))
|
||||
|
||||
for button in self.dialogBtns.buttons():
|
||||
button.setText(_(button.text()))
|
||||
|
||||
moveToCenter(self)
|
||||
@@ -12,8 +12,10 @@ from Furious.Utility.Translator import Translatable, gettext as _
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtGui import QBrush, QColor, QFont, QTextCursor
|
||||
from PySide6.QtWidgets import (
|
||||
QGroupBox,
|
||||
QHeaderView,
|
||||
QInputDialog,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
QMainWindow,
|
||||
@@ -29,6 +31,15 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
|
||||
class GroupBox(Translatable, QGroupBox):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def retranslate(self):
|
||||
with StateContext(self):
|
||||
self.setTitle(_(self.title()))
|
||||
|
||||
|
||||
class HeaderView(SupportConnectedCallback, QHeaderView):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -50,6 +61,15 @@ class HeaderView(SupportConnectedCallback, QHeaderView):
|
||||
)
|
||||
|
||||
|
||||
class Label(Translatable, QLabel):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def retranslate(self):
|
||||
with StateContext(self):
|
||||
self.setText(_(self.text()))
|
||||
|
||||
|
||||
class ListWidget(SupportConnectedCallback, QListWidget):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
Reference in New Issue
Block a user