@@ -0,0 +1,162 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/#use-with-ide
|
||||
.pdm.toml
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
.idea/
|
||||
|
||||
.DS_Store
|
||||
@@ -0,0 +1,669 @@
|
||||
from Furious.Core.Core import XrayCore, Hysteria
|
||||
from Furious.Core.Intellisense import Intellisense
|
||||
from Furious.Core.Configuration import Configuration
|
||||
from Furious.Action.Routing import SUPPORTED_ROUTING_DETAIL
|
||||
from Furious.Gui.Action import Action
|
||||
from Furious.Widget.ConnectingProgressBar import ConnectingProgressBar
|
||||
from Furious.Widget.Widget import MessageBox
|
||||
from Furious.Utility.Constants import APPLICATION_NAME, PROXY_SERVER_BYPASS
|
||||
from Furious.Utility.Utility import (
|
||||
Switch,
|
||||
SupportConnectedCallback,
|
||||
bootstrapIcon,
|
||||
getAbsolutePath,
|
||||
)
|
||||
from Furious.Utility.Translator import gettext as _
|
||||
from Furious.Utility.Proxy import Proxy
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from PySide6.QtNetwork import (
|
||||
QNetworkAccessManager,
|
||||
QNetworkReply,
|
||||
QNetworkRequest,
|
||||
QNetworkProxy,
|
||||
)
|
||||
|
||||
import ujson
|
||||
import random
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HttpProxyServerError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ConnectAction(Action):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
_('Connect'),
|
||||
icon=bootstrapIcon('unlock-fill.svg'),
|
||||
checkable=True,
|
||||
)
|
||||
|
||||
self.connectingProgressBar = ConnectingProgressBar()
|
||||
|
||||
self.configurationEmptyBox = MessageBox()
|
||||
self.configurationIcingBox = MessageBox()
|
||||
self.configurationErrorBox = MessageBox()
|
||||
self.configurationTampered = MessageBox()
|
||||
self.httpProxyConfErrorBox = MessageBox()
|
||||
|
||||
self.proxyServer = ''
|
||||
|
||||
self.networkAccessManager = QNetworkAccessManager(parent=self)
|
||||
self.networkReply = None
|
||||
|
||||
self.coreName = ''
|
||||
self.coreText = ''
|
||||
self.coreJSON = {}
|
||||
self.coreRunning = False
|
||||
self.XrayRouting = {}
|
||||
|
||||
self.connectingFlag = False
|
||||
|
||||
# Note: The connection test is carried out item by item
|
||||
# from top to bottom. If any of these succeed,
|
||||
# connected action will be executed.
|
||||
|
||||
# "Popular" sites that's been endorsed by some government.
|
||||
self.testPool = [
|
||||
# Messaging
|
||||
'https://telegram.org/',
|
||||
# Social media
|
||||
'https://twitter.com/',
|
||||
# Videos
|
||||
'https://www.youtube.com/',
|
||||
]
|
||||
self.testTime = 0
|
||||
|
||||
self.XrayCore = XrayCore()
|
||||
self.Hysteria = Hysteria()
|
||||
|
||||
def XrayCoreExitCallback(self, exitcode):
|
||||
if self.coreName:
|
||||
# If core is running
|
||||
assert self.coreRunning
|
||||
assert self.coreName == XrayCore.name()
|
||||
|
||||
if exitcode == XrayCore.ExitCode.ConfigurationError:
|
||||
if not self.isConnecting():
|
||||
# Protect connecting action. Mandatory
|
||||
return self.disconnectAction(
|
||||
f'{XrayCore.name()}: {_("Invalid server configuration")}'
|
||||
)
|
||||
|
||||
if exitcode == XrayCore.ExitCode.ServerStartFailure:
|
||||
if not self.isConnecting():
|
||||
# Protect connecting action. Mandatory
|
||||
return self.disconnectAction(
|
||||
f'{XrayCore.name()}: {_("Failed to start core")}'
|
||||
)
|
||||
|
||||
if not self.isConnecting():
|
||||
# Protect connecting action. Mandatory
|
||||
self.disconnectAction(
|
||||
f'{XrayCore.name()}: {_("Core terminated unexpectedly")}'
|
||||
)
|
||||
|
||||
def HysteriaExitCallback(self, exitcode):
|
||||
if self.coreName:
|
||||
# If core is running
|
||||
assert self.coreRunning
|
||||
assert self.coreName == Hysteria.name()
|
||||
|
||||
if exitcode == Hysteria.ExitCode.ConfigurationError:
|
||||
if not self.isConnecting():
|
||||
# Protect connecting action. Mandatory
|
||||
return self.disconnectAction(
|
||||
f'{Hysteria.name()}: {_("Invalid server configuration")}'
|
||||
)
|
||||
|
||||
if exitcode == Hysteria.ExitCode.RemoteNetworkError:
|
||||
if not self.isConnecting():
|
||||
# Protect connecting action. Mandatory
|
||||
return self.disconnectAction(
|
||||
f'{Hysteria.name()}: {_("Connection to server has been lost")}'
|
||||
)
|
||||
|
||||
if not self.isConnecting():
|
||||
# Protect connecting action. Mandatory
|
||||
self.disconnectAction(
|
||||
f'{Hysteria.name()}: {_("Core terminated unexpectedly")}'
|
||||
)
|
||||
|
||||
def stopCore(self):
|
||||
# Stop any potentially running core
|
||||
self.XrayCore.stop()
|
||||
self.Hysteria.stop()
|
||||
|
||||
def showConnectingProgressBar(self):
|
||||
if QApplication.instance().ShowProgressBarWhenConnecting == Switch.ON_:
|
||||
self.connectingProgressBar.progressBar.setValue(0)
|
||||
# Update the progress bar every 50ms
|
||||
self.connectingProgressBar.timer.start(50)
|
||||
self.connectingProgressBar.show()
|
||||
|
||||
return self
|
||||
|
||||
def hideConnectingProgressBar(self, done=False):
|
||||
if done:
|
||||
self.connectingProgressBar.progressBar.setValue(100)
|
||||
|
||||
self.connectingProgressBar.hide()
|
||||
self.connectingProgressBar.timer.stop()
|
||||
|
||||
return self
|
||||
|
||||
def moveConnectingProgressBar(self):
|
||||
# Progressing
|
||||
if self.connectingProgressBar.progressBar.value() <= 45:
|
||||
self.connectingProgressBar.progressBar.setValue(random.randint(45, 50))
|
||||
# Lower timer frequency
|
||||
self.connectingProgressBar.timer.start(250)
|
||||
|
||||
return self
|
||||
|
||||
def setDisabledAction(self, value):
|
||||
self.setDisabled(value)
|
||||
|
||||
QApplication.instance().tray.RoutingAction.setDisabled(value)
|
||||
|
||||
def setConnectingStatus(self, showProgressBar=True):
|
||||
if showProgressBar:
|
||||
self.showConnectingProgressBar()
|
||||
|
||||
# Do not accept new action
|
||||
self.setDisabledAction(True)
|
||||
self.setText(_('Connecting'))
|
||||
self.setIcon(bootstrapIcon('lock-fill.svg'))
|
||||
|
||||
QApplication.instance().tray.setPlainIcon()
|
||||
|
||||
def setConnectedStatus(self):
|
||||
self.hideConnectingProgressBar(done=True)
|
||||
self.setDisabledAction(False)
|
||||
|
||||
QApplication.instance().tray.setConnectedIcon()
|
||||
|
||||
# Finished. Reset connecting flag
|
||||
self.connectingFlag = False
|
||||
|
||||
# Connected
|
||||
self.setText(_('Disconnect'))
|
||||
|
||||
SupportConnectedCallback.callConnectedCallback()
|
||||
|
||||
def isConnecting(self):
|
||||
return self.connectingFlag
|
||||
|
||||
def isConnected(self):
|
||||
return self.textCompare('Disconnect')
|
||||
|
||||
def reset(self):
|
||||
# Reset everything
|
||||
|
||||
self.XrayCore.registerExitCallback(None)
|
||||
self.Hysteria.registerExitCallback(None)
|
||||
|
||||
self.stopCore()
|
||||
|
||||
self.hideConnectingProgressBar()
|
||||
self.setText(_('Connect'))
|
||||
self.setIcon(bootstrapIcon('unlock-fill.svg'))
|
||||
self.setChecked(False)
|
||||
|
||||
QApplication.instance().Connect = Switch.OFF
|
||||
QApplication.instance().tray.setPlainIcon()
|
||||
|
||||
self.proxyServer = ''
|
||||
|
||||
self.coreName = ''
|
||||
self.coreText = ''
|
||||
self.coreJSON = {}
|
||||
self.coreRunning = False
|
||||
self.XrayRouting = {}
|
||||
|
||||
# Accept new action
|
||||
self.setDisabledAction(False)
|
||||
|
||||
self.connectingFlag = False
|
||||
|
||||
@property
|
||||
def MainWidget(self):
|
||||
# Handy reference
|
||||
return QApplication.instance().MainWidget
|
||||
|
||||
@property
|
||||
def activatedServer(self):
|
||||
try:
|
||||
activatedIndex = int(QApplication.instance().ActivatedItemIndex)
|
||||
|
||||
if activatedIndex < 0:
|
||||
return None
|
||||
else:
|
||||
return self.MainWidget.ServerList[activatedIndex]['config']
|
||||
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return None
|
||||
|
||||
def errorConfiguration(self):
|
||||
self.configurationErrorBox.setIcon(MessageBox.Icon.Critical)
|
||||
self.configurationErrorBox.setWindowTitle(_('Unable to connect'))
|
||||
self.configurationErrorBox.setText(_('Invalid server configuration.'))
|
||||
|
||||
# Show the MessageBox and wait for user to close it
|
||||
self.configurationErrorBox.exec()
|
||||
|
||||
def errorConfigurationEmpty(self):
|
||||
self.configurationEmptyBox.setIcon(MessageBox.Icon.Critical)
|
||||
self.configurationEmptyBox.setWindowTitle(_('Unable to connect'))
|
||||
self.configurationEmptyBox.setText(
|
||||
_('Server configuration empty. Please configure your server first.')
|
||||
)
|
||||
|
||||
# Show the MessageBox and wait for user to close it
|
||||
self.configurationEmptyBox.exec()
|
||||
|
||||
def errorConfigurationNotActivated(self):
|
||||
self.configurationIcingBox.setIcon(MessageBox.Icon.Information)
|
||||
self.configurationIcingBox.setWindowTitle(_('Unable to connect'))
|
||||
self.configurationIcingBox.setText(
|
||||
_('Select and double click to activate configuration and connect.')
|
||||
)
|
||||
|
||||
# Show the MessageBox and wait for user to close it
|
||||
self.configurationIcingBox.exec()
|
||||
|
||||
def errorHttpProxyConf(self):
|
||||
self.httpProxyConfErrorBox.setIcon(MessageBox.Icon.Critical)
|
||||
self.httpProxyConfErrorBox.setWindowTitle(_('Unable to connect'))
|
||||
self.httpProxyConfErrorBox.setText(
|
||||
_(
|
||||
f'{APPLICATION_NAME} cannot find any valid http proxy '
|
||||
f'endpoint in your server configuration.'
|
||||
)
|
||||
)
|
||||
self.httpProxyConfErrorBox.setInformativeText(
|
||||
_('Please complete your server configuration.')
|
||||
)
|
||||
|
||||
# Show the MessageBox and wait for user to close it
|
||||
self.httpProxyConfErrorBox.exec()
|
||||
|
||||
def configureCore(self):
|
||||
def validateProxyServer(server):
|
||||
# Validate proxy server
|
||||
try:
|
||||
host, port = server.split(':')
|
||||
|
||||
if int(port) < 0 or int(port) > 65535:
|
||||
raise ValueError
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
self.reset()
|
||||
self.errorHttpProxyConf()
|
||||
|
||||
return False
|
||||
else:
|
||||
self.proxyServer = server
|
||||
|
||||
return True
|
||||
|
||||
self.stopCore()
|
||||
|
||||
self.XrayCore.registerExitCallback(
|
||||
lambda exitcode: self.XrayCoreExitCallback(exitcode)
|
||||
)
|
||||
self.Hysteria.registerExitCallback(
|
||||
lambda exitcode: self.HysteriaExitCallback(exitcode)
|
||||
)
|
||||
|
||||
proxyServer = None
|
||||
|
||||
if Intellisense.getCoreType(self.coreJSON) == XrayCore.name():
|
||||
# Assuming is XrayCore configuration
|
||||
proxyHost = None
|
||||
proxyPort = None
|
||||
|
||||
try:
|
||||
for inbound in self.coreJSON['inbounds']:
|
||||
if inbound['protocol'] == 'http':
|
||||
proxyHost = inbound['listen']
|
||||
proxyPort = inbound['port']
|
||||
|
||||
# Note: If there are multiple http inbounds
|
||||
# satisfied, the first one will be chosen.
|
||||
break
|
||||
|
||||
if proxyHost is None or proxyPort is None:
|
||||
# No HTTP proxy endpoint configured
|
||||
raise HttpProxyServerError
|
||||
|
||||
proxyServer = f'{proxyHost}:{proxyPort}'
|
||||
except (KeyError, HttpProxyServerError):
|
||||
self.reset()
|
||||
self.errorHttpProxyConf()
|
||||
else:
|
||||
if validateProxyServer(proxyServer):
|
||||
self.coreRunning = True
|
||||
|
||||
routing = QApplication.instance().Routing
|
||||
|
||||
logger.info(f'Core {XrayCore.name()} configured')
|
||||
logger.info(f'Routing is {routing}')
|
||||
|
||||
def fixLoggingRelativePath(attr):
|
||||
# Relative path fails if booting on start up
|
||||
# on Windows, when packed using nuitka...
|
||||
|
||||
# Fix relative path if needed. User cannot feel this operation.
|
||||
|
||||
try:
|
||||
path = self.coreJSON['log'][attr]
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
if path and (
|
||||
isinstance(path, str) or isinstance(path, bytes)
|
||||
):
|
||||
fix = getAbsolutePath(path)
|
||||
|
||||
logger.info(
|
||||
f'{XrayCore.name()}: {attr} log is specified as \'{path}\'. '
|
||||
f'Fixed to \'{fix}\''
|
||||
)
|
||||
|
||||
self.coreJSON['log'][attr] = fix
|
||||
|
||||
fixLoggingRelativePath('access')
|
||||
fixLoggingRelativePath('error')
|
||||
|
||||
if routing == 'Bypass':
|
||||
self.coreJSON['routing'] = {
|
||||
'domainStrategy': 'IPIfNonMatch',
|
||||
'domainMatcher': 'hybrid',
|
||||
'rules': (
|
||||
# ads
|
||||
{
|
||||
'type': 'field',
|
||||
'outboundTag': 'block',
|
||||
'domain': ['geosite:category-ads-all'],
|
||||
},
|
||||
# geosite
|
||||
{
|
||||
'type': 'field',
|
||||
'outboundTag': 'direct',
|
||||
'domain': ['geosite:cn'],
|
||||
},
|
||||
# geoip
|
||||
{
|
||||
'type': 'field',
|
||||
'outboundTag': 'direct',
|
||||
'ip': ['geoip:private', 'geoip:cn'],
|
||||
},
|
||||
# Proxy everything
|
||||
{
|
||||
'type': 'field',
|
||||
'port': '0-65535',
|
||||
'outboundTag': 'proxy',
|
||||
},
|
||||
),
|
||||
}
|
||||
if routing == 'Global':
|
||||
self.coreJSON['routing'] = {
|
||||
'domainStrategy': 'IPIfNonMatch',
|
||||
'domainMatcher': 'hybrid',
|
||||
'rules': (
|
||||
# Proxy everything
|
||||
{
|
||||
'type': 'field',
|
||||
'port': '0-65535',
|
||||
'outboundTag': 'proxy',
|
||||
},
|
||||
),
|
||||
}
|
||||
if routing == 'Custom':
|
||||
# Assign user routing
|
||||
self.coreJSON['routing'] = self.XrayRouting
|
||||
|
||||
# Refresh configuration modified before. User cannot feel
|
||||
self.coreText = ujson.dumps(
|
||||
self.coreJSON, ensure_ascii=False, escape_forward_slashes=False
|
||||
)
|
||||
# Start core
|
||||
self.XrayCore.start(self.coreText)
|
||||
|
||||
return XrayCore.name()
|
||||
|
||||
if Intellisense.getCoreType(self.coreJSON) == Hysteria.name():
|
||||
# Assuming is Hysteria configuration
|
||||
try:
|
||||
proxyServer = self.coreJSON['http']['listen']
|
||||
|
||||
if proxyServer is None:
|
||||
# No HTTP proxy endpoint configured
|
||||
raise HttpProxyServerError
|
||||
except (KeyError, HttpProxyServerError):
|
||||
self.reset()
|
||||
self.errorHttpProxyConf()
|
||||
else:
|
||||
if validateProxyServer(proxyServer):
|
||||
self.coreRunning = True
|
||||
|
||||
routing = QApplication.instance().Routing
|
||||
|
||||
logger.info(f'Core {Hysteria.name()} configured')
|
||||
logger.info(f'Routing is {routing}')
|
||||
|
||||
if routing == 'Bypass':
|
||||
self.Hysteria.start(
|
||||
self.coreText, Hysteria.rule(), Hysteria.mmdb()
|
||||
)
|
||||
if routing == 'Global':
|
||||
self.Hysteria.start(self.coreText, '', '')
|
||||
if routing == 'Custom':
|
||||
if self.coreJSON.get('acl') is not None:
|
||||
# acl specified
|
||||
if self.coreJSON.get('mmdb') is not None:
|
||||
# mmdb specified. Start "as is"
|
||||
self.Hysteria.start(
|
||||
self.coreText,
|
||||
Hysteria.rule(self.coreJSON['acl']),
|
||||
Hysteria.mmdb(self.coreJSON['mmdb']),
|
||||
)
|
||||
else:
|
||||
# mmdb not specified. Route as global
|
||||
self.Hysteria.start(self.coreText, '', '')
|
||||
else:
|
||||
# acl not specified. Route as global
|
||||
self.Hysteria.start(self.coreText, '', '')
|
||||
|
||||
return Hysteria.name()
|
||||
|
||||
# No matching core
|
||||
return ''
|
||||
|
||||
def startConnectionTest(self, showRoutingChangedMessage=False):
|
||||
selected = self.testPool[self.testTime]
|
||||
|
||||
self.testTime += 1
|
||||
|
||||
logger.info(f'start connection test. Try: {selected}')
|
||||
|
||||
# Checked. split should not throw exceptions
|
||||
proxyHost, proxyPort = self.proxyServer.split(':')
|
||||
|
||||
# Checked. int(proxyPort) should not throw exceptions
|
||||
self.networkAccessManager.setProxy(
|
||||
QNetworkProxy(QNetworkProxy.ProxyType.HttpProxy, proxyHost, int(proxyPort))
|
||||
)
|
||||
|
||||
self.networkReply = self.networkAccessManager.get(
|
||||
QNetworkRequest(QtCore.QUrl(selected))
|
||||
)
|
||||
|
||||
@QtCore.Slot()
|
||||
def finishedCallback():
|
||||
assert isinstance(self.networkReply, QNetworkReply)
|
||||
|
||||
if self.networkReply.error() != QNetworkReply.NetworkError.NoError:
|
||||
logger.error(
|
||||
f'{self.coreName}: connection test failed. {self.networkReply.errorString()}'
|
||||
)
|
||||
|
||||
if self.testTime < len(self.testPool):
|
||||
# Try next
|
||||
self.startConnectionTest(showRoutingChangedMessage)
|
||||
else:
|
||||
self.disconnectAction(
|
||||
f'{self.coreName}: {_("Connection test failed")}'
|
||||
)
|
||||
else:
|
||||
logger.info(f'{self.coreName}: connection test success. Connected')
|
||||
|
||||
QApplication.instance().Connect = Switch.ON_
|
||||
|
||||
# Connected status
|
||||
self.setConnectedStatus()
|
||||
|
||||
if showRoutingChangedMessage:
|
||||
# Routing changed
|
||||
QApplication.instance().tray.showMessage(
|
||||
_('Routing changed: ')
|
||||
+ _(
|
||||
f'{SUPPORTED_ROUTING_DETAIL[QApplication.instance().Routing]}'
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Connected
|
||||
QApplication.instance().tray.showMessage(
|
||||
f'{self.coreName}: {_("Connected")}'
|
||||
)
|
||||
|
||||
self.networkReply.finished.connect(finishedCallback)
|
||||
|
||||
def connectAction(self):
|
||||
# Connect action
|
||||
assert self.textCompare('Connect')
|
||||
|
||||
# Connecting
|
||||
self.connectingFlag = True
|
||||
|
||||
if (
|
||||
not QApplication.instance().Configuration
|
||||
or len(self.MainWidget.ServerList) == 0
|
||||
):
|
||||
QApplication.instance().Connect = Switch.OFF
|
||||
|
||||
self.setChecked(False)
|
||||
self.connectingFlag = False
|
||||
self.errorConfigurationEmpty()
|
||||
|
||||
return
|
||||
|
||||
myText = self.activatedServer
|
||||
|
||||
if myText is None:
|
||||
QApplication.instance().Connect = Switch.OFF
|
||||
|
||||
self.setChecked(False)
|
||||
self.connectingFlag = False
|
||||
self.errorConfigurationNotActivated()
|
||||
|
||||
return
|
||||
|
||||
if myText == '':
|
||||
QApplication.instance().Connect = Switch.OFF
|
||||
|
||||
self.setChecked(False)
|
||||
self.connectingFlag = False
|
||||
self.errorConfigurationEmpty()
|
||||
|
||||
return
|
||||
|
||||
try:
|
||||
myJSON = Configuration.toJSON(myText)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
QApplication.instance().Connect = Switch.OFF
|
||||
|
||||
self.setChecked(False)
|
||||
self.connectingFlag = False
|
||||
|
||||
# Invalid configuratoin
|
||||
self.errorConfiguration()
|
||||
else:
|
||||
# Get server configuration success. Continue.
|
||||
# Note: use self.reset() to restore state
|
||||
|
||||
self.coreText = myText
|
||||
self.coreJSON = myJSON
|
||||
|
||||
# Memorize user routing if possible
|
||||
self.XrayRouting = myJSON.get('routing', {})
|
||||
|
||||
self.connectingAction()
|
||||
|
||||
def connectingAction(self, showProgressBar=True, showRoutingChangedMessage=False):
|
||||
# Connecting status
|
||||
self.setConnectingStatus(showProgressBar)
|
||||
|
||||
# Configure connect
|
||||
self.coreName = self.configureCore()
|
||||
|
||||
if not self.coreName:
|
||||
# No matching core
|
||||
self.reset()
|
||||
self.errorConfiguration()
|
||||
|
||||
return
|
||||
|
||||
if not self.coreRunning:
|
||||
# No valid HTTP proxy endpoint / Core has exited.
|
||||
# reset / disconnect has been called
|
||||
return
|
||||
|
||||
try:
|
||||
Proxy.set(self.proxyServer, PROXY_SERVER_BYPASS)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
Proxy.off()
|
||||
|
||||
self.reset()
|
||||
self.errorConfiguration()
|
||||
else:
|
||||
self.moveConnectingProgressBar()
|
||||
# Reset try time
|
||||
self.testTime = 0
|
||||
self.startConnectionTest(showRoutingChangedMessage)
|
||||
|
||||
def disconnectAction(self, reason=''):
|
||||
Proxy.off()
|
||||
|
||||
self.reset()
|
||||
|
||||
SupportConnectedCallback.callDisconnectedCallback()
|
||||
|
||||
QApplication.instance().tray.showMessage(reason)
|
||||
|
||||
def reconnectAction(self, reason=''):
|
||||
self.disconnectAction(reason)
|
||||
self.trigger()
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
if checked:
|
||||
self.connectAction()
|
||||
else:
|
||||
# Disconnect action
|
||||
assert self.textCompare('Disconnect')
|
||||
assert self.connectingFlag is False
|
||||
|
||||
self.disconnectAction(f'{self.coreName}: {_("Disconnected")}')
|
||||
@@ -0,0 +1,16 @@
|
||||
from Furious.Gui.Action import Action
|
||||
from Furious.Utility.Utility import bootstrapIcon
|
||||
from Furious.Utility.Translator import gettext as _
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
|
||||
class EditConfigurationAction(Action):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
_('Edit Configuration...'),
|
||||
icon=bootstrapIcon('pencil-square.svg'),
|
||||
)
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
QApplication.instance().MainWidget.show()
|
||||
@@ -0,0 +1,16 @@
|
||||
from Furious.Gui.Action import Action
|
||||
from Furious.Utility.Utility import bootstrapIcon
|
||||
from Furious.Utility.Translator import gettext as _
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
|
||||
class ExitAction(Action):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
_('Exit'),
|
||||
icon=bootstrapIcon('box-arrow-in-left.svg'),
|
||||
)
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
QApplication.instance().exit()
|
||||
@@ -0,0 +1,216 @@
|
||||
from Furious.Core.Configuration import Configuration
|
||||
from Furious.Gui.Action import Action
|
||||
from Furious.Widget.Widget import Menu, MessageBox
|
||||
from Furious.Widget.ExportQRCode import ExportQRCode
|
||||
from Furious.Utility.Utility import StateContext, bootstrapIcon
|
||||
from Furious.Utility.Translator import gettext as _
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
import ujson
|
||||
|
||||
|
||||
class ExportLinkResultBox(MessageBox):
|
||||
def __init__(self, successStr, failureStr, isQRCodeExport, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.successStr = successStr
|
||||
self.failureStr = failureStr
|
||||
self.isQRCodeExport = isQRCodeExport
|
||||
|
||||
self.setWindowTitle(_('Export'))
|
||||
|
||||
if len(self.failureStr):
|
||||
self.setIcon(MessageBox.Icon.Critical)
|
||||
else:
|
||||
self.setIcon(MessageBox.Icon.Information)
|
||||
|
||||
def getText(self):
|
||||
if len(self.successStr) == 0:
|
||||
return (
|
||||
(
|
||||
_('Export as QR code failed:')
|
||||
if self.isQRCodeExport
|
||||
else _('Export share link to clipboard failed:')
|
||||
)
|
||||
+ f'\n\n'
|
||||
+ '\n'.join(
|
||||
list(
|
||||
f'{index + 1}: {failure}'
|
||||
for index, failure in enumerate(self.failureStr)
|
||||
)
|
||||
)
|
||||
)
|
||||
elif len(self.failureStr) == 0:
|
||||
return (
|
||||
(
|
||||
_('Export as QR code success:')
|
||||
if self.isQRCodeExport
|
||||
else _('Export share link to clipboard success:')
|
||||
)
|
||||
+ f'\n\n'
|
||||
+ '\n'.join(
|
||||
list(
|
||||
f'{index + 1}: {success}'
|
||||
for index, success in enumerate(self.successStr)
|
||||
)
|
||||
)
|
||||
)
|
||||
else:
|
||||
return (
|
||||
(
|
||||
_('Export as QR code partially success:')
|
||||
if self.isQRCodeExport
|
||||
else _('Export share link to clipboard partially success:')
|
||||
)
|
||||
+ f'\n\n'
|
||||
+ '\n'.join(
|
||||
list(
|
||||
f'{index + 1}: {success}'
|
||||
for index, success in enumerate(self.successStr)
|
||||
)
|
||||
)
|
||||
+ f'\n\n'
|
||||
+ _('Failed:')
|
||||
+ f'\n\n'
|
||||
+ '\n'.join(
|
||||
list(
|
||||
f'{index + 1}: {failure}'
|
||||
for index, failure in enumerate(self.failureStr)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def retranslate(self):
|
||||
with StateContext(self):
|
||||
self.setWindowTitle(_(self.windowTitle()))
|
||||
self.setText(self.getText())
|
||||
|
||||
# Ignore informative text, buttons
|
||||
|
||||
self.moveToCenter()
|
||||
|
||||
|
||||
class ExportJSONResultBox(MessageBox):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.setWindowTitle(_('Export'))
|
||||
self.setIcon(MessageBox.Icon.Information)
|
||||
self.setText(_('Export JSON configuration to clipboard success.'))
|
||||
|
||||
def retranslate(self):
|
||||
with StateContext(self):
|
||||
self.setWindowTitle(_(self.windowTitle()))
|
||||
self.setText(_(self.text()))
|
||||
|
||||
# Ignore informative text, buttons
|
||||
|
||||
self.moveToCenter()
|
||||
|
||||
|
||||
def exportLink(selectedIndex):
|
||||
serverList = QApplication.instance().MainWidget.ServerList
|
||||
serverLink = []
|
||||
successStr = []
|
||||
failureStr = []
|
||||
|
||||
for index in selectedIndex:
|
||||
try:
|
||||
serverLink.append(
|
||||
Configuration.export(
|
||||
serverList[index]['remark'],
|
||||
ujson.loads(serverList[index]['config']),
|
||||
)
|
||||
)
|
||||
|
||||
successStr.append(f'{index + 1} - {serverList[index]["remark"]}')
|
||||
except Exception as ex:
|
||||
# Any non-exit Exceptions
|
||||
|
||||
failureStr.append(f'{index + 1} - {serverList[index]["remark"]}: {ex}')
|
||||
|
||||
return serverLink, successStr, failureStr
|
||||
|
||||
|
||||
class ExportLinkAction(Action):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(_('Export Share Link To Clipboard'), **kwargs)
|
||||
|
||||
self.exportLinkResult = ExportLinkResultBox('', '', isQRCodeExport=False)
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
selectedIndex = QApplication.instance().MainWidget.selectedIndex
|
||||
|
||||
if len(selectedIndex) == 0:
|
||||
# Nothing selected. Do nothing
|
||||
return
|
||||
|
||||
serverLink, successStr, failureStr = exportLink(selectedIndex)
|
||||
|
||||
QApplication.clipboard().setText('\n'.join(serverLink))
|
||||
|
||||
self.exportLinkResult.successStr = successStr
|
||||
self.exportLinkResult.failureStr = failureStr
|
||||
self.exportLinkResult.setText(self.exportLinkResult.getText())
|
||||
|
||||
# Show the MessageBox and wait for user to close it
|
||||
self.exportLinkResult.exec()
|
||||
|
||||
|
||||
class ExportQRCodeAction(Action):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(_('Export As QR Code'), **kwargs)
|
||||
|
||||
self.exportQRCode = ExportQRCode()
|
||||
self.exportLinkResult = ExportLinkResultBox('', '', isQRCodeExport=True)
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
selectedIndex = QApplication.instance().MainWidget.selectedIndex
|
||||
|
||||
if len(selectedIndex) == 0:
|
||||
# Nothing selected. Do nothing
|
||||
return
|
||||
|
||||
serverLink, successStr, failureStr = exportLink(selectedIndex)
|
||||
|
||||
if len(successStr) > 0:
|
||||
self.exportQRCode.editorTab.clear()
|
||||
self.exportQRCode.initTabWithData(list(zip(successStr, serverLink)))
|
||||
self.exportQRCode.show()
|
||||
|
||||
self.exportLinkResult.successStr = successStr
|
||||
self.exportLinkResult.failureStr = failureStr
|
||||
self.exportLinkResult.setText(self.exportLinkResult.getText())
|
||||
|
||||
# Show the MessageBox and wait for user to close it
|
||||
self.exportLinkResult.exec()
|
||||
|
||||
|
||||
class ExportJSONAction(Action):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(_('Export JSON Configuration To Clipboard'), **kwargs)
|
||||
|
||||
self.exportJSONResult = ExportJSONResultBox()
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
selectedIndex = QApplication.instance().MainWidget.selectedIndex
|
||||
|
||||
if len(selectedIndex) == 0:
|
||||
# Nothing selected. Do nothing
|
||||
return
|
||||
|
||||
try:
|
||||
QApplication.clipboard().setText(
|
||||
'\n'.join(
|
||||
QApplication.instance().MainWidget.ServerList[index]['config']
|
||||
for index in selectedIndex
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
pass
|
||||
else:
|
||||
# Show the MessageBox and wait for user to close it
|
||||
self.exportJSONResult.exec()
|
||||
@@ -0,0 +1,401 @@
|
||||
from Furious.Core.Configuration import (
|
||||
Configuration,
|
||||
XrayCoreConfiguration,
|
||||
ProxyOutboundObject,
|
||||
)
|
||||
from Furious.Gui.Action import Action
|
||||
from Furious.Widget.Widget import Menu, MessageBox
|
||||
from Furious.Utility.Utility import (
|
||||
Base64Encoder,
|
||||
StateContext,
|
||||
Storage,
|
||||
bootstrapIcon,
|
||||
protocolRepr,
|
||||
)
|
||||
from Furious.Utility.Translator import gettext as _
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
import re
|
||||
import ujson
|
||||
import logging
|
||||
import urllib.parse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ImportErrorBox(MessageBox):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def retranslate(self):
|
||||
with StateContext(self):
|
||||
self.setWindowTitle(_(self.windowTitle()))
|
||||
self.setText(_(self.text()))
|
||||
|
||||
# Ignore informative text, buttons
|
||||
|
||||
self.moveToCenter()
|
||||
|
||||
|
||||
class JSONImportOKBox(MessageBox):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.setWindowTitle(_('Import'))
|
||||
self.setText(_('Import JSON configuration success.'))
|
||||
|
||||
self.addButton(_('Go to edit'), MessageBox.ButtonRole.AcceptRole)
|
||||
self.addButton(_('OK'), MessageBox.ButtonRole.RejectRole)
|
||||
|
||||
|
||||
class LinkImportOKBox(MessageBox):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.remark = ''
|
||||
|
||||
self.setWindowTitle(_('Import'))
|
||||
|
||||
self.addButton(_('Go to edit'), MessageBox.ButtonRole.AcceptRole)
|
||||
self.addButton(_('OK'), MessageBox.ButtonRole.RejectRole)
|
||||
|
||||
def getText(self):
|
||||
if self.remark:
|
||||
return _('Import share link success: ') + f'{self.remark}'
|
||||
else:
|
||||
return _('Import share link success.')
|
||||
|
||||
def retranslate(self):
|
||||
self.setWindowTitle(_(self.windowTitle()))
|
||||
self.setText(self.getText())
|
||||
|
||||
# Ignore informative text
|
||||
|
||||
for button in self.buttons():
|
||||
button.setText(_(button.text()))
|
||||
|
||||
self.moveToCenter()
|
||||
|
||||
|
||||
class ImportLinkResultBox(MessageBox):
|
||||
def __init__(self, successRemark, rowCount, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.successRemark = successRemark
|
||||
self.rowCount = rowCount
|
||||
|
||||
self.setWindowTitle(_('Import'))
|
||||
self.setIcon(MessageBox.Icon.Information)
|
||||
self.setText(self.getText())
|
||||
|
||||
def getText(self):
|
||||
return (
|
||||
_('Import share link success: ')
|
||||
+ f'\n\n'
|
||||
+ '\n'.join(
|
||||
list(
|
||||
f'{index + 1} - {remark}. {_("Imported to row")} {self.rowCount + index + 1}'
|
||||
for index, remark in enumerate(self.successRemark)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def retranslate(self):
|
||||
with StateContext(self):
|
||||
self.setWindowTitle(_(self.windowTitle()))
|
||||
self.setText(self.getText())
|
||||
|
||||
# Ignore informative text, buttons
|
||||
|
||||
self.moveToCenter()
|
||||
|
||||
|
||||
class ImportLinkAction(Action):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(_('Import Share Link From Clipboard'), **kwargs)
|
||||
|
||||
self.clipboard = ''
|
||||
|
||||
self.linkErrorBox = ImportErrorBox(icon=MessageBox.Icon.Critical)
|
||||
self.linkImportOK = LinkImportOKBox(icon=MessageBox.Icon.Information)
|
||||
|
||||
def showLinkErrorBox(self):
|
||||
self.linkErrorBox.setWindowTitle(_('Import'))
|
||||
if len(self.clipboard) > 1000:
|
||||
self.linkErrorBox.setText(_('Invalid share link.'))
|
||||
self.linkErrorBox.setInformativeText('')
|
||||
else:
|
||||
self.linkErrorBox.setText(
|
||||
_('Invalid share link. The content of the clipboard is:')
|
||||
)
|
||||
self.linkErrorBox.setInformativeText(self.clipboard)
|
||||
|
||||
# Show the MessageBox and wait for user to close it
|
||||
self.linkErrorBox.exec()
|
||||
|
||||
def showLinkImportOK(self, remark):
|
||||
self.linkImportOK.remark = remark
|
||||
self.linkImportOK.setText(self.linkImportOK.getText())
|
||||
|
||||
# Show the MessageBox and wait for user to close it
|
||||
choice = self.linkImportOK.exec()
|
||||
|
||||
if choice == MessageBox.ButtonRole.AcceptRole.value:
|
||||
# Go to edit
|
||||
QApplication.instance().MainWidget.show()
|
||||
else:
|
||||
# OK. Do nothing
|
||||
pass
|
||||
|
||||
def parseShareLinkVMess(self, data, isV2rayN):
|
||||
if isV2rayN:
|
||||
|
||||
def getOrDefault(key, default=''):
|
||||
return data.get(key, default)
|
||||
|
||||
try:
|
||||
# Ignore: v
|
||||
myJSON = XrayCoreConfiguration.build(
|
||||
ProxyOutboundObject(
|
||||
'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'),
|
||||
)
|
||||
)
|
||||
|
||||
remark = urllib.parse.unquote(getOrDefault('ps'))
|
||||
|
||||
QApplication.instance().MainWidget.importServer(
|
||||
remark,
|
||||
ujson.dumps(
|
||||
myJSON,
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
escape_forward_slashes=False,
|
||||
),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'import share link from clipboard success. '
|
||||
f'Remark: {remark}. Protocol: VMess. (V2rayN share standard)'
|
||||
)
|
||||
|
||||
return remark, True
|
||||
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return '', False
|
||||
else:
|
||||
return self.parseShareLinkStandard('vmess', data)
|
||||
|
||||
@staticmethod
|
||||
def parseShareLinkStandard(protocol, data):
|
||||
try:
|
||||
parseResult = urllib.parse.urlparse(data)
|
||||
|
||||
queryObject = {
|
||||
key: value for key, value in urllib.parse.parse_qsl(parseResult.query)
|
||||
}
|
||||
|
||||
remark = urllib.parse.unquote(parseResult.fragment)
|
||||
|
||||
uuid_, remote_host, remote_port = re.split(r'[@:]', parseResult.path)
|
||||
|
||||
encryption = queryObject.get('encryption', 'none')
|
||||
type_ = queryObject.get('type', 'tcp')
|
||||
security = queryObject.get('security', 'none')
|
||||
|
||||
# Remove redundant items
|
||||
queryObject.pop('encryption', '')
|
||||
queryObject.pop('type', '')
|
||||
queryObject.pop('security', '')
|
||||
|
||||
myJSON = XrayCoreConfiguration.build(
|
||||
ProxyOutboundObject(
|
||||
protocol,
|
||||
remote_host,
|
||||
int(remote_port),
|
||||
uuid_,
|
||||
encryption,
|
||||
type_,
|
||||
security,
|
||||
# kwargs
|
||||
**queryObject,
|
||||
)
|
||||
)
|
||||
|
||||
QApplication.instance().MainWidget.importServer(
|
||||
remark,
|
||||
ujson.dumps(
|
||||
myJSON, indent=2, ensure_ascii=False, escape_forward_slashes=False
|
||||
),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'import share link from clipboard success. '
|
||||
f'Remark: {remark}. Protocol: {protocolRepr(protocol)}'
|
||||
)
|
||||
|
||||
return remark, True
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return '', False
|
||||
|
||||
def parseShareLink(self, shareLink):
|
||||
try:
|
||||
myHead, myBody = shareLink.split('://')
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return '', False
|
||||
else:
|
||||
if myHead.lower() == 'vmess':
|
||||
try:
|
||||
myData = Base64Encoder.decode(myBody).decode()
|
||||
myJSON = Configuration.toJSON(myData)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return self.parseShareLinkVMess(myBody, isV2rayN=False)
|
||||
else:
|
||||
return self.parseShareLinkVMess(myJSON, isV2rayN=True)
|
||||
|
||||
if myHead.lower() == 'vless':
|
||||
return self.parseShareLinkStandard('vless', myBody)
|
||||
|
||||
return '', False
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
self.clipboard = QApplication.clipboard().text().strip()
|
||||
|
||||
try:
|
||||
splitByNewLine = self.clipboard.split('\n')
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
remark, result = self.parseShareLink(self.clipboard)
|
||||
|
||||
if result:
|
||||
# Sync it
|
||||
Storage.sync()
|
||||
|
||||
self.showLinkImportOK(remark)
|
||||
else:
|
||||
self.showLinkErrorBox()
|
||||
else:
|
||||
successRemark = []
|
||||
|
||||
rowCount = QApplication.instance().MainWidget.rowCount
|
||||
|
||||
for shareLink in splitByNewLine:
|
||||
remark, result = self.parseShareLink(shareLink)
|
||||
|
||||
if result:
|
||||
successRemark.append(remark)
|
||||
|
||||
if len(successRemark) == 0:
|
||||
self.showLinkErrorBox()
|
||||
|
||||
return
|
||||
|
||||
# At least one server's been imported. Sync it
|
||||
Storage.sync()
|
||||
|
||||
if len(successRemark) == 1:
|
||||
# Fall back to single
|
||||
|
||||
self.showLinkImportOK(successRemark[0])
|
||||
else:
|
||||
importLinkResult = ImportLinkResultBox(successRemark, rowCount)
|
||||
|
||||
# Show the MessageBox and wait for user to close it
|
||||
importLinkResult.exec()
|
||||
|
||||
|
||||
class ImportJSONAction(Action):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(_('Import JSON Configuration From Clipboard'), **kwargs)
|
||||
|
||||
self.clipboard = ''
|
||||
|
||||
self.jsonErrorBox = ImportErrorBox(icon=MessageBox.Icon.Critical)
|
||||
self.jsonImportOK = JSONImportOKBox(icon=MessageBox.Icon.Information)
|
||||
|
||||
def showJSONErrorBox(self):
|
||||
self.jsonErrorBox.setWindowTitle(_('Import'))
|
||||
|
||||
if len(self.clipboard) > 1000:
|
||||
self.jsonErrorBox.setText(_('Invalid JSON data.'))
|
||||
self.jsonErrorBox.setInformativeText('')
|
||||
else:
|
||||
self.jsonErrorBox.setText(
|
||||
_('Invalid JSON data. The content of the clipboard is:')
|
||||
)
|
||||
self.jsonErrorBox.setInformativeText(self.clipboard)
|
||||
|
||||
# Show the MessageBox and wait for user to close it
|
||||
self.jsonErrorBox.exec()
|
||||
|
||||
def showJSONImportOK(self):
|
||||
# Show the MessageBox and wait for user to close it
|
||||
choice = self.jsonImportOK.exec()
|
||||
|
||||
if choice == MessageBox.ButtonRole.AcceptRole.value:
|
||||
# Go to edit
|
||||
QApplication.instance().MainWidget.show()
|
||||
else:
|
||||
# OK. Do nothing
|
||||
pass
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
self.clipboard = QApplication.clipboard().text().strip()
|
||||
|
||||
try:
|
||||
myJSON = Configuration.toJSON(self.clipboard)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
self.showJSONErrorBox()
|
||||
else:
|
||||
QApplication.instance().MainWidget.importServer(
|
||||
_('Untitled'), self.clipboard, syncStorage=True
|
||||
)
|
||||
|
||||
logger.info('import JSON configuration from clipboard success')
|
||||
|
||||
self.showJSONImportOK()
|
||||
|
||||
|
||||
class ImportAction(Action):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
_('Import'),
|
||||
icon=bootstrapIcon('lightning-charge.svg'),
|
||||
menu=Menu(
|
||||
ImportLinkAction(),
|
||||
ImportJSONAction(),
|
||||
),
|
||||
useActionGroup=False,
|
||||
checkable=True,
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
from Furious.Gui.Action import Action
|
||||
from Furious.Widget.Widget import Menu
|
||||
from Furious.Utility.Utility import bootstrapIcon
|
||||
from Furious.Utility.Translator import (
|
||||
Translatable,
|
||||
gettext as _,
|
||||
ABBR_TO_LANGUAGE,
|
||||
LANGUAGE_TO_ABBR,
|
||||
)
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SUPPORTED_LANGUAGE = tuple(LANGUAGE_TO_ABBR.values())
|
||||
|
||||
|
||||
class LanguageChildAction(Action):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
checkedLanguage = LANGUAGE_TO_ABBR[self.text()]
|
||||
|
||||
if QApplication.instance().Language != checkedLanguage:
|
||||
logger.info(f'set language to \'{self.text()}\'')
|
||||
|
||||
QApplication.instance().Language = checkedLanguage
|
||||
|
||||
Translatable.retranslateAll()
|
||||
|
||||
|
||||
class LanguageAction(Action):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
_('Language'),
|
||||
icon=bootstrapIcon('globe2.svg'),
|
||||
menu=Menu(
|
||||
*list(
|
||||
LanguageChildAction(
|
||||
# Language representation
|
||||
text,
|
||||
checkable=True,
|
||||
checked=text
|
||||
== ABBR_TO_LANGUAGE[QApplication.instance().Language],
|
||||
)
|
||||
for text in list(LANGUAGE_TO_ABBR.keys())
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
from Furious.Gui.Action import Action
|
||||
from Furious.Widget.Widget import Menu
|
||||
from Furious.Utility.Utility import Switch, bootstrapIcon
|
||||
from Furious.Utility.Translator import gettext as _
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
SUPPORTED_ROUTING = ('Bypass', 'Global', 'Custom')
|
||||
|
||||
SUPPORTED_ROUTING_DETAIL = {
|
||||
'Bypass': 'Bypass Mainland China',
|
||||
'Global': 'Global',
|
||||
'Custom': 'Custom',
|
||||
}
|
||||
|
||||
|
||||
class RoutingChildAction(Action):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
for key, value in SUPPORTED_ROUTING_DETAIL.items():
|
||||
if self.textCompare(value):
|
||||
if QApplication.instance().Routing != key:
|
||||
QApplication.instance().Routing = key
|
||||
|
||||
if QApplication.instance().tray.ConnectAction.isConnected():
|
||||
# Connected. Re-configure connection
|
||||
QApplication.instance().tray.ConnectAction.connectingAction(
|
||||
showProgressBar=False,
|
||||
showRoutingChangedMessage=True,
|
||||
)
|
||||
|
||||
|
||||
class RoutingAction(Action):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
_('Routing'),
|
||||
icon=bootstrapIcon('shuffle.svg'),
|
||||
menu=Menu(
|
||||
*list(
|
||||
RoutingChildAction(
|
||||
_(SUPPORTED_ROUTING_DETAIL[mode]),
|
||||
checkable=True,
|
||||
checked=mode == QApplication.instance().Routing,
|
||||
)
|
||||
for mode in SUPPORTED_ROUTING
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,63 @@
|
||||
from Furious.Gui.Action import Action
|
||||
from Furious.Widget.Widget import Menu
|
||||
from Furious.Utility.Utility import bootstrapIcon, Switch
|
||||
from Furious.Utility.Translator import gettext as _
|
||||
from Furious.Utility.StartupOnBoot import StartupOnBoot
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
|
||||
class SettingsChildAction(Action):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
if self.textCompare('Startup On Boot'):
|
||||
if checked:
|
||||
StartupOnBoot.on_()
|
||||
|
||||
QApplication.instance().StartupOnBoot = Switch.ON_
|
||||
else:
|
||||
StartupOnBoot.off()
|
||||
|
||||
QApplication.instance().StartupOnBoot = Switch.OFF
|
||||
if self.textCompare('Show Progress Bar When Connecting'):
|
||||
if checked:
|
||||
QApplication.instance().ShowProgressBarWhenConnecting = Switch.ON_
|
||||
else:
|
||||
QApplication.instance().ShowProgressBarWhenConnecting = Switch.OFF
|
||||
if self.textCompare('Show Tab And Spaces In Editor'):
|
||||
if checked:
|
||||
QApplication.instance().ShowTabAndSpacesInEditor = Switch.ON_
|
||||
else:
|
||||
QApplication.instance().ShowTabAndSpacesInEditor = Switch.OFF
|
||||
|
||||
QApplication.instance().MainWidget.showTabAndSpacesIfNecessary()
|
||||
|
||||
|
||||
class SettingsAction(Action):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
_('Settings'),
|
||||
icon=bootstrapIcon('gear-wide-connected.svg'),
|
||||
menu=Menu(
|
||||
SettingsChildAction(
|
||||
_('Startup On Boot'),
|
||||
checkable=True,
|
||||
checked=QApplication.instance().StartupOnBoot == Switch.ON_,
|
||||
),
|
||||
SettingsChildAction(
|
||||
_('Show Progress Bar When Connecting'),
|
||||
checkable=True,
|
||||
checked=QApplication.instance().ShowProgressBarWhenConnecting
|
||||
== Switch.ON_,
|
||||
),
|
||||
SettingsChildAction(
|
||||
_('Show Tab And Spaces In Editor'),
|
||||
checkable=True,
|
||||
checked=QApplication.instance().ShowTabAndSpacesInEditor
|
||||
== Switch.ON_,
|
||||
),
|
||||
),
|
||||
useActionGroup=False,
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
from .Connect import ConnectAction
|
||||
from .EditConfiguration import EditConfigurationAction
|
||||
from .Exit import ExitAction
|
||||
from .Import import ImportAction
|
||||
from .Language import LanguageAction
|
||||
from .Routing import RoutingAction
|
||||
from .Settings import SettingsAction
|
||||
@@ -0,0 +1,592 @@
|
||||
from Furious.Core.Core import XrayCore
|
||||
from Furious.Core.Intellisense import Intellisense
|
||||
from Furious.Widget.Widget import MessageBox
|
||||
from Furious.Utility.Constants import APPLICATION_NAME
|
||||
from Furious.Utility.Utility import Base64Encoder, bootstrapIcon
|
||||
from Furious.Utility.Translator import gettext as _
|
||||
|
||||
import copy
|
||||
import ujson
|
||||
import functools
|
||||
import urllib.parse
|
||||
|
||||
quote = functools.partial(urllib.parse.quote)
|
||||
unquote = functools.partial(urllib.parse.unquote)
|
||||
urlunparse = functools.partial(urllib.parse.urlunparse)
|
||||
|
||||
|
||||
class UnsupportedServerExport(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Configuration:
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def toJSON(text):
|
||||
return ujson.loads(text)
|
||||
|
||||
@staticmethod
|
||||
def corruptedError(errorBox, isAsync=False):
|
||||
assert isinstance(errorBox, MessageBox)
|
||||
|
||||
errorBox.setIcon(MessageBox.Icon.Critical)
|
||||
errorBox.setWindowTitle(_('Server configuration corrupted'))
|
||||
errorBox.setText(
|
||||
_(
|
||||
f'{APPLICATION_NAME} cannot restore your server configuration. '
|
||||
f'It may have been tampered with.'
|
||||
)
|
||||
)
|
||||
errorBox.setInformativeText(
|
||||
_(f'The configuration content has been cleared by {APPLICATION_NAME}.')
|
||||
)
|
||||
|
||||
if isAsync:
|
||||
# Show the MessageBox asynchronously
|
||||
errorBox.open()
|
||||
else:
|
||||
# Show the MessageBox and wait for user to close it
|
||||
errorBox.exec()
|
||||
|
||||
@staticmethod
|
||||
def export(remark, ob):
|
||||
if Intellisense.getCoreType(ob) == XrayCore.name():
|
||||
|
||||
def hasKey(obj, key):
|
||||
return obj.get(key) is not None
|
||||
|
||||
def getStreamNetSettings(protocol, streamObject, netType):
|
||||
kwargs = {}
|
||||
|
||||
netobj = streamObject[
|
||||
ProxyOutboundObject.getStreamNetworkSettingsName(netType)
|
||||
]
|
||||
|
||||
if netType == 'tcp':
|
||||
try:
|
||||
if protocol.lower() == 'vmess':
|
||||
kwargs['type'] = netobj['header']['type']
|
||||
|
||||
if protocol.lower() == 'vless':
|
||||
kwargs['headerType'] = netobj['header']['type']
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
pass
|
||||
|
||||
elif netType == 'kcp':
|
||||
try:
|
||||
# Get order matters here
|
||||
if protocol.lower() == 'vmess':
|
||||
if hasKey(netobj, 'seed'):
|
||||
kwargs['path'] = netobj['seed']
|
||||
|
||||
kwargs['type'] = netobj['header']['type']
|
||||
|
||||
if protocol.lower() == 'vless':
|
||||
if hasKey(netobj, 'seed'):
|
||||
kwargs['seed'] = netobj['seed']
|
||||
|
||||
kwargs['headerType'] = netobj['header']['type']
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
pass
|
||||
|
||||
elif netType == 'ws':
|
||||
try:
|
||||
# Get order matters here
|
||||
if hasKey(netobj, 'path'):
|
||||
kwargs['path'] = quote(netobj['path'])
|
||||
|
||||
if netobj['headers']['Host']:
|
||||
kwargs['host'] = quote(netobj['headers']['Host'])
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
pass
|
||||
|
||||
elif netType == 'h2' or netType == 'http':
|
||||
try:
|
||||
# Get order matters here
|
||||
if hasKey(netobj, 'path'):
|
||||
kwargs['path'] = quote(netobj['path'])
|
||||
|
||||
kwargs['host'] = quote(','.join(netobj['host']))
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
pass
|
||||
|
||||
elif netType == 'quic':
|
||||
try:
|
||||
# Get order matters here
|
||||
if protocol.lower() == 'vmess':
|
||||
if hasKey(netobj, 'security'):
|
||||
kwargs['host'] = netobj['security']
|
||||
|
||||
if hasKey(netobj, 'key'):
|
||||
kwargs['path'] = quote(netobj['key'])
|
||||
|
||||
kwargs['type'] = netobj['header']['type']
|
||||
|
||||
if protocol.lower() == 'vless':
|
||||
if hasKey(netobj, 'security'):
|
||||
kwargs['quicSecurity'] = netobj['security']
|
||||
|
||||
if hasKey(netobj, 'key'):
|
||||
kwargs['path'] = quote(netobj['key'])
|
||||
|
||||
kwargs['headerType'] = netobj['header']['type']
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
pass
|
||||
|
||||
elif netType == 'grpc':
|
||||
if protocol.lower() == 'vmess':
|
||||
if hasKey(netobj, 'serviceName'):
|
||||
kwargs['path'] = netobj['serviceName']
|
||||
|
||||
if protocol.lower() == 'vless':
|
||||
if hasKey(netobj, 'serviceName'):
|
||||
kwargs['serviceName'] = netobj['serviceName']
|
||||
|
||||
return kwargs
|
||||
|
||||
def getStreamTLSSettings(streamObject, tlsType):
|
||||
if tlsType == 'none':
|
||||
return {}
|
||||
|
||||
kwargs = {}
|
||||
|
||||
tlsobj = streamObject[
|
||||
ProxyOutboundObject.getStreamTLSSettingsName(tlsType)
|
||||
]
|
||||
|
||||
if tlsType == 'reality' or tlsType == 'tls':
|
||||
if tlsobj.get('fingerprint'):
|
||||
kwargs['fp'] = tlsobj['fingerprint']
|
||||
|
||||
if tlsobj.get('serverName'):
|
||||
kwargs['sni'] = tlsobj['serverName']
|
||||
|
||||
if tlsobj.get('alpn'):
|
||||
kwargs['alpn'] = quote(','.join(tlsobj['alpn']))
|
||||
|
||||
if tlsType == 'reality':
|
||||
# More kwargs for reality
|
||||
if tlsobj.get('publicKey'):
|
||||
kwargs['pbk'] = tlsobj['publicKey']
|
||||
|
||||
if tlsobj.get('shortId'):
|
||||
kwargs['sid'] = tlsobj['shortId']
|
||||
|
||||
if tlsobj.get('spiderX'):
|
||||
kwargs['spx'] = quote(tlsobj['spiderX'])
|
||||
|
||||
return kwargs
|
||||
|
||||
# Begin export
|
||||
coreProtocol = Intellisense.getCoreProtocol(ob).lower()
|
||||
|
||||
if coreProtocol == 'vmess' or coreProtocol == 'vless':
|
||||
proxyOutbound = None
|
||||
|
||||
for outbound in ob['outbounds']:
|
||||
if outbound['tag'] == 'proxy':
|
||||
proxyOutbound = outbound
|
||||
|
||||
break
|
||||
|
||||
if proxyOutbound is None:
|
||||
raise Exception('No proxy outbound found')
|
||||
|
||||
proxyServer = proxyOutbound['settings']['vnext'][0]
|
||||
proxyServerUser = proxyServer['users'][0]
|
||||
|
||||
proxyStream = proxyOutbound['streamSettings']
|
||||
proxyStreamNet = proxyStream['network']
|
||||
proxyStreamTLS = proxyStream['security']
|
||||
|
||||
if coreProtocol == 'vmess':
|
||||
return (
|
||||
'vmess://'
|
||||
+ Base64Encoder.encode(
|
||||
ujson.dumps(
|
||||
{
|
||||
'v': '2',
|
||||
'ps': quote(remark),
|
||||
'add': proxyServer['address'],
|
||||
'port': proxyServer['port'],
|
||||
'id': proxyServerUser['id'],
|
||||
'aid': proxyServerUser['alterId'],
|
||||
'scy': proxyServerUser['security'],
|
||||
'net': proxyStreamNet,
|
||||
'tls': proxyStreamTLS,
|
||||
# kwargs
|
||||
**getStreamNetSettings(
|
||||
coreProtocol, proxyStream, proxyStreamNet
|
||||
),
|
||||
**getStreamTLSSettings(proxyStream, proxyStreamTLS),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
escape_forward_slashes=False,
|
||||
).encode()
|
||||
).decode()
|
||||
)
|
||||
|
||||
if coreProtocol == 'vless':
|
||||
flowArg = {}
|
||||
|
||||
if proxyServerUser.get('flow'):
|
||||
flowArg['flow'] = proxyServerUser['flow']
|
||||
|
||||
netloc = f'{proxyServerUser["id"]}@{proxyServer["address"]}:{proxyServer["port"]}'
|
||||
|
||||
query = '&'.join(
|
||||
f'{key}={value}'
|
||||
for key, value in {
|
||||
'encryption': proxyServerUser['encryption'],
|
||||
'type': proxyStreamNet,
|
||||
'security': proxyStreamTLS,
|
||||
# kwargs
|
||||
**flowArg,
|
||||
**getStreamNetSettings(
|
||||
coreProtocol, proxyStream, proxyStreamNet
|
||||
),
|
||||
**getStreamTLSSettings(proxyStream, proxyStreamTLS),
|
||||
}.items()
|
||||
)
|
||||
|
||||
return urlunparse(['vless', netloc, '', '', query, quote(remark)])
|
||||
else:
|
||||
raise UnsupportedServerExport('Unsupported core protocol export')
|
||||
|
||||
|
||||
class ProxyOutboundObject:
|
||||
def __init__(
|
||||
self,
|
||||
protocol,
|
||||
remote_host,
|
||||
remote_port,
|
||||
uuid_,
|
||||
encryption,
|
||||
type_,
|
||||
security,
|
||||
**kwargs,
|
||||
):
|
||||
self.protocol = protocol
|
||||
self.remote_host = remote_host
|
||||
self.remote_port = remote_port
|
||||
self.uuid_ = uuid_
|
||||
self.encryption = encryption
|
||||
self.type_ = type_
|
||||
self.security = security
|
||||
|
||||
self.kwargs = kwargs
|
||||
|
||||
def getTLSSettings(self, security):
|
||||
TLSObject = {}
|
||||
|
||||
if security == 'reality' or security == 'tls':
|
||||
# Note: If specify default value 'chrome', some share link fails.
|
||||
# Leave default value as empty
|
||||
fp = self.kwargs.get('fp')
|
||||
sni = self.kwargs.get('sni', self.remote_host)
|
||||
# Protect "xxx," format
|
||||
alpn = list(
|
||||
filter(
|
||||
lambda x: x != '',
|
||||
unquote(self.kwargs.get('alpn', '')).split(','),
|
||||
)
|
||||
)
|
||||
|
||||
if fp:
|
||||
TLSObject['fingerprint'] = fp
|
||||
if sni:
|
||||
TLSObject['serverName'] = sni
|
||||
if alpn:
|
||||
TLSObject['alpn'] = alpn
|
||||
|
||||
if security == 'reality':
|
||||
# More args for reality
|
||||
pbk = self.kwargs.get('pbk')
|
||||
sid = self.kwargs.get('sid', '')
|
||||
spx = self.kwargs.get('spx', '')
|
||||
|
||||
if pbk:
|
||||
TLSObject['publicKey'] = pbk
|
||||
|
||||
TLSObject['shortId'] = sid
|
||||
TLSObject['spiderX'] = unquote(spx)
|
||||
|
||||
return TLSObject
|
||||
|
||||
@staticmethod
|
||||
def getStreamTLSSettingsName(security):
|
||||
# tlsSettings, realitySettings
|
||||
return f'{security}Settings'
|
||||
|
||||
@staticmethod
|
||||
def getStreamNetworkSettingsName(type_):
|
||||
if type_ == 'h2':
|
||||
return 'httpSettings'
|
||||
else:
|
||||
return f'{type_}Settings'
|
||||
|
||||
def getStreamNetworkSettings(self):
|
||||
# Note:
|
||||
# V2rayN share standard doesn't require unquote. Still
|
||||
# unquote value according to (VMess AEAD / VLESS) standard
|
||||
|
||||
if self.type_ == 'tcp':
|
||||
TcpObject = {}
|
||||
|
||||
if self.kwargs.get('headerType', 'none'):
|
||||
TcpObject['header'] = {
|
||||
'type': self.kwargs.get('headerType', 'none'),
|
||||
}
|
||||
|
||||
return TcpObject
|
||||
|
||||
elif self.type_ == 'kcp':
|
||||
KcpObject = {
|
||||
# Extension. From V2rayN
|
||||
'uplinkCapacity': 12,
|
||||
'downlinkCapacity': 100,
|
||||
}
|
||||
|
||||
if self.kwargs.get('headerType', 'none'):
|
||||
KcpObject['header'] = {
|
||||
'type': self.kwargs.get('headerType', 'none'),
|
||||
}
|
||||
|
||||
if self.kwargs.get('seed'):
|
||||
KcpObject['seed'] = self.kwargs.get('seed')
|
||||
|
||||
return KcpObject
|
||||
|
||||
elif self.type_ == 'ws':
|
||||
WebSocketObject = {}
|
||||
|
||||
if self.kwargs.get('path', '/'):
|
||||
WebSocketObject['path'] = unquote(self.kwargs.get('path', '/'))
|
||||
|
||||
if self.kwargs.get('host'):
|
||||
WebSocketObject['headers'] = {
|
||||
'Host': unquote(self.kwargs.get('host')),
|
||||
}
|
||||
|
||||
return WebSocketObject
|
||||
|
||||
elif self.type_ == 'h2' or self.type_ == 'http':
|
||||
HttpObject = {}
|
||||
|
||||
if self.kwargs.get('host', self.remote_host):
|
||||
# Protect "xxx," format
|
||||
HttpObject['host'] = list(
|
||||
filter(
|
||||
lambda x: x != '',
|
||||
unquote(self.kwargs.get('host', self.remote_host)).split(','),
|
||||
)
|
||||
)
|
||||
|
||||
if self.kwargs.get('path', '/'):
|
||||
HttpObject['path'] = unquote(self.kwargs.get('path', '/'))
|
||||
|
||||
return HttpObject
|
||||
|
||||
elif self.type_ == 'quic':
|
||||
QuicObject = {}
|
||||
|
||||
if self.kwargs.get('quicSecurity', 'none'):
|
||||
QuicObject['security'] = self.kwargs.get('quicSecurity', 'none')
|
||||
|
||||
if self.kwargs.get('key'):
|
||||
QuicObject['key'] = unquote(self.kwargs.get('key'))
|
||||
|
||||
if self.kwargs.get('headerType', 'none'):
|
||||
QuicObject['header'] = {
|
||||
'type': self.kwargs.get('headerType', 'none'),
|
||||
}
|
||||
|
||||
return QuicObject
|
||||
|
||||
elif self.type_ == 'grpc':
|
||||
GRPCObject = {}
|
||||
|
||||
if self.kwargs.get('serviceName'):
|
||||
GRPCObject['serviceName'] = self.kwargs.get('serviceName')
|
||||
|
||||
if self.kwargs.get('mode', 'gun'):
|
||||
GRPCObject['multiMode'] = self.kwargs.get('mode', 'gun') == 'multi'
|
||||
|
||||
return GRPCObject
|
||||
|
||||
def getUserObject(self):
|
||||
if self.protocol == 'vmess':
|
||||
UserObject = {
|
||||
'id': self.uuid_,
|
||||
'security': self.encryption,
|
||||
# Extension
|
||||
# TODO
|
||||
'email': 'user@furious.com',
|
||||
}
|
||||
|
||||
# For VMess(V2rayN share standard) only.
|
||||
if self.kwargs.get('aid') is not None:
|
||||
UserObject['alterId'] = self.kwargs.get('aid')
|
||||
|
||||
return UserObject
|
||||
|
||||
if self.protocol == 'vless':
|
||||
UserObject = {
|
||||
'id': self.uuid_,
|
||||
'encryption': self.encryption,
|
||||
# Extension
|
||||
# TODO
|
||||
'email': 'user@furious.com',
|
||||
}
|
||||
|
||||
if self.kwargs.get('flow'):
|
||||
# flow is empty, TLS. Otherwise, XTLS.
|
||||
UserObject['flow'] = self.kwargs.get('flow')
|
||||
|
||||
return UserObject
|
||||
|
||||
return {}
|
||||
|
||||
def build(self):
|
||||
myJSON = {
|
||||
'tag': 'proxy',
|
||||
'protocol': self.protocol,
|
||||
'settings': {
|
||||
'vnext': [
|
||||
{
|
||||
'address': self.remote_host,
|
||||
'port': self.remote_port,
|
||||
'users': [
|
||||
self.getUserObject(),
|
||||
],
|
||||
},
|
||||
]
|
||||
},
|
||||
'streamSettings': {
|
||||
'network': self.type_,
|
||||
'security': self.security,
|
||||
},
|
||||
'mux': {
|
||||
'enabled': False,
|
||||
'concurrency': -1,
|
||||
},
|
||||
}
|
||||
|
||||
if self.security != 'none':
|
||||
# tlsSettings, realitySettings
|
||||
myJSON['streamSettings'][
|
||||
ProxyOutboundObject.getStreamTLSSettingsName(self.security)
|
||||
] = self.getTLSSettings(self.security)
|
||||
|
||||
# Stream network settings
|
||||
myJSON['streamSettings'][
|
||||
ProxyOutboundObject.getStreamNetworkSettingsName(self.type_)
|
||||
] = self.getStreamNetworkSettings()
|
||||
|
||||
return myJSON
|
||||
|
||||
|
||||
class Outbounds:
|
||||
def __init__(self, proxyOutboundObject):
|
||||
self.proxyOutboundObject = proxyOutboundObject
|
||||
|
||||
def build(self):
|
||||
return [
|
||||
# Proxy
|
||||
self.proxyOutboundObject.build(),
|
||||
# Direct
|
||||
{
|
||||
'tag': 'direct',
|
||||
'protocol': 'freedom',
|
||||
'settings': {},
|
||||
},
|
||||
# Block
|
||||
{
|
||||
'tag': 'block',
|
||||
'protocol': 'blackhole',
|
||||
'settings': {
|
||||
'response': {
|
||||
'type': 'http',
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class XrayCoreConfiguration:
|
||||
DEFAULT_CONF = {
|
||||
# Default log configuration
|
||||
'log': {
|
||||
'access': '',
|
||||
'error': '',
|
||||
'loglevel': 'warning',
|
||||
},
|
||||
# Default inbounds configuration
|
||||
'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,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def getDefaultJSON():
|
||||
return copy.deepcopy(XrayCoreConfiguration.DEFAULT_CONF)
|
||||
|
||||
@staticmethod
|
||||
def build(proxyOutboundObject):
|
||||
# log, inbounds
|
||||
myJSON = XrayCoreConfiguration.getDefaultJSON()
|
||||
|
||||
# Add outbounds
|
||||
myJSON['outbounds'] = Outbounds(proxyOutboundObject).build()
|
||||
|
||||
# Add empty routing
|
||||
myJSON['routing'] = {}
|
||||
|
||||
return myJSON
|
||||
@@ -0,0 +1,193 @@
|
||||
from Furious.Utility.Constants import PLATFORM, ROOT_DIR, DATA_DIR
|
||||
from Furious.Utility.Utility import getAbsolutePath
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtTest import QTest
|
||||
|
||||
import logging
|
||||
import multiprocessing
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Core:
|
||||
def __init__(self, *args, exitCallback=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self._process = None
|
||||
self._exitCallback = exitCallback
|
||||
|
||||
@QtCore.Slot()
|
||||
def timeoutCallback():
|
||||
self.checkAlive()
|
||||
|
||||
self._daemonTimer = QtCore.QTimer()
|
||||
self._daemonTimer.timeout.connect(timeoutCallback)
|
||||
|
||||
@staticmethod
|
||||
def name():
|
||||
raise NotImplementedError
|
||||
|
||||
@staticmethod
|
||||
def version():
|
||||
raise NotImplementedError
|
||||
|
||||
def registerExitCallback(self, exitCallback):
|
||||
self._exitCallback = exitCallback
|
||||
|
||||
def checkAlive(self):
|
||||
assert isinstance(self._process, multiprocessing.Process)
|
||||
|
||||
if self._process.is_alive():
|
||||
return True
|
||||
else:
|
||||
logger.error(
|
||||
f'{self.name()} stopped unexpectedly with exitcode {self._process.exitcode}'
|
||||
)
|
||||
|
||||
self._daemonTimer.stop()
|
||||
|
||||
if callable(self._exitCallback):
|
||||
self._exitCallback(self._process.exitcode)
|
||||
|
||||
return False
|
||||
|
||||
def start(self, *args, **kwargs):
|
||||
logger.info(f'{self.name()} {self.version()} started')
|
||||
|
||||
self._process = multiprocessing.Process(*args, **kwargs, daemon=True)
|
||||
self._process.start()
|
||||
|
||||
# Wait for the core to start up completely
|
||||
QTest.qWait(2000)
|
||||
|
||||
if self.checkAlive():
|
||||
# Start core daemon
|
||||
self._daemonTimer.start(100)
|
||||
|
||||
def stop(self):
|
||||
if (
|
||||
isinstance(self._process, multiprocessing.Process)
|
||||
and self._process.is_alive()
|
||||
):
|
||||
self._daemonTimer.stop()
|
||||
|
||||
self._process.terminate()
|
||||
self._process.join()
|
||||
|
||||
logger.info(
|
||||
f'{self.name()} terminated with exitcode {self._process.exitcode}'
|
||||
)
|
||||
|
||||
|
||||
def startXrayCore(json):
|
||||
try:
|
||||
import xray
|
||||
except ImportError:
|
||||
# Fake running process
|
||||
while True:
|
||||
pass
|
||||
else:
|
||||
xray.startFromJSON(json)
|
||||
|
||||
|
||||
class XrayCore(Core):
|
||||
class ExitCode:
|
||||
ConfigurationError = 23
|
||||
# Windows: 4294967295. Darwin, Linux: 255 (-1)
|
||||
ServerStartFailure = 4294967295 if PLATFORM == 'Windows' else 255
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def name():
|
||||
return 'Xray-core'
|
||||
|
||||
@staticmethod
|
||||
def version():
|
||||
try:
|
||||
import xray
|
||||
|
||||
return xray.__version__
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return '0.0.0'
|
||||
|
||||
def start(self, json):
|
||||
super().start(
|
||||
target=startXrayCore,
|
||||
args=(json,),
|
||||
)
|
||||
|
||||
|
||||
def startHysteria(json, rule, mmdb):
|
||||
try:
|
||||
import hysteria
|
||||
except ImportError:
|
||||
# Fake running process
|
||||
while True:
|
||||
pass
|
||||
else:
|
||||
hysteria.startFromJSON(json, rule, mmdb)
|
||||
|
||||
|
||||
class Hysteria(Core):
|
||||
class ExitCode:
|
||||
ConfigurationError = 23
|
||||
RemoteNetworkError = 3
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def rule(rulePath=''):
|
||||
if rulePath == '':
|
||||
path = DATA_DIR / 'hysteria' / 'bypass-mainland-China.acl'
|
||||
else:
|
||||
path = getAbsolutePath(rulePath)
|
||||
|
||||
try:
|
||||
with open(path, 'rb') as file:
|
||||
return file.read()
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return ''
|
||||
|
||||
@staticmethod
|
||||
def mmdb(mmdbPath=''):
|
||||
if mmdbPath == '':
|
||||
path = DATA_DIR / 'hysteria' / 'country.mmdb'
|
||||
else:
|
||||
path = getAbsolutePath(mmdbPath)
|
||||
|
||||
try:
|
||||
with open(path, 'rb') as file:
|
||||
return file.read()
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return ''
|
||||
|
||||
@staticmethod
|
||||
def name():
|
||||
return 'Hysteria'
|
||||
|
||||
@staticmethod
|
||||
def version():
|
||||
try:
|
||||
import hysteria
|
||||
|
||||
return hysteria.__version__
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return '0.0.0'
|
||||
|
||||
def start(self, json, rule, mmdb):
|
||||
super().start(
|
||||
target=startHysteria,
|
||||
args=(json, rule, mmdb),
|
||||
)
|
||||
@@ -0,0 +1,111 @@
|
||||
from Furious.Core.Core import XrayCore, Hysteria
|
||||
from Furious.Utility.Utility import protocolRepr
|
||||
|
||||
|
||||
class Intellisense:
|
||||
@staticmethod
|
||||
def getCoreType(ob):
|
||||
if ob.get('inbounds') is not None or ob.get('outbounds') is not None:
|
||||
# Assuming is XrayCore
|
||||
return XrayCore.name()
|
||||
|
||||
if ob.get('server') is not None:
|
||||
# Assuming is Hysteria. This behavior might be changed in the future
|
||||
return Hysteria.name()
|
||||
|
||||
return ''
|
||||
|
||||
@staticmethod
|
||||
def getCoreProtocol(ob):
|
||||
try:
|
||||
if Intellisense.getCoreType(ob) == XrayCore.name():
|
||||
for outbound in ob['outbounds']:
|
||||
if outbound['tag'] == 'proxy':
|
||||
return protocolRepr(outbound['protocol'])
|
||||
|
||||
if Intellisense.getCoreType(ob) == Hysteria.name():
|
||||
return 'hysteria'
|
||||
|
||||
return ''
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return ''
|
||||
|
||||
@staticmethod
|
||||
def getCoreAddr(ob):
|
||||
try:
|
||||
if Intellisense.getCoreType(ob) == XrayCore.name():
|
||||
for outbound in ob['outbounds']:
|
||||
if outbound['tag'] == 'proxy':
|
||||
return ';'.join(
|
||||
list(
|
||||
f'{server["address"]}'
|
||||
for server in outbound['settings']['vnext']
|
||||
)
|
||||
)
|
||||
|
||||
if Intellisense.getCoreType(ob) == Hysteria.name():
|
||||
return ob['server'].split(':')[0]
|
||||
|
||||
return ''
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return ''
|
||||
|
||||
@staticmethod
|
||||
def getCorePort(ob):
|
||||
try:
|
||||
if Intellisense.getCoreType(ob) == XrayCore.name():
|
||||
for outbound in ob['outbounds']:
|
||||
if outbound['tag'] == 'proxy':
|
||||
return ';'.join(
|
||||
list(
|
||||
f'{server["port"]}'
|
||||
for server in outbound['settings']['vnext']
|
||||
)
|
||||
)
|
||||
|
||||
if Intellisense.getCoreType(ob) == Hysteria.name():
|
||||
return ob['server'].split(':')[1]
|
||||
|
||||
return ''
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return ''
|
||||
|
||||
@staticmethod
|
||||
def getCoreTransport(ob):
|
||||
try:
|
||||
if Intellisense.getCoreType(ob) == XrayCore.name():
|
||||
for outbound in ob['outbounds']:
|
||||
if outbound['tag'] == 'proxy':
|
||||
return outbound['streamSettings']['network']
|
||||
|
||||
if Intellisense.getCoreType(ob) == Hysteria.name():
|
||||
return ''
|
||||
|
||||
return ''
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return ''
|
||||
|
||||
@staticmethod
|
||||
def getCoreTLS(ob):
|
||||
try:
|
||||
if Intellisense.getCoreType(ob) == XrayCore.name():
|
||||
for outbound in ob['outbounds']:
|
||||
if outbound['tag'] == 'proxy':
|
||||
return outbound['streamSettings']['security']
|
||||
|
||||
if Intellisense.getCoreType(ob) == Hysteria.name():
|
||||
return ''
|
||||
|
||||
return ''
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return ''
|
||||
@@ -0,0 +1,153 @@
|
||||
from Furious.Utility.Constants import PLATFORM
|
||||
from Furious.Utility.Utility import (
|
||||
SupportThemeChangedCallback,
|
||||
bootstrapIcon,
|
||||
bootstrapIconWhite,
|
||||
getUbuntuRelease,
|
||||
)
|
||||
from Furious.Utility.Translator import Translatable, gettext as _
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtGui import QAction, QActionGroup
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
import logging
|
||||
import darkdetect
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Action(Translatable, SupportThemeChangedCallback, QAction):
|
||||
def __init__(
|
||||
self,
|
||||
text,
|
||||
icon=None,
|
||||
menu=None,
|
||||
useActionGroup=True,
|
||||
checkable=False,
|
||||
checked=False,
|
||||
callback=None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(text=text, **kwargs)
|
||||
|
||||
self.iconFileName = ''
|
||||
|
||||
if icon is not None:
|
||||
self.setIcon(icon)
|
||||
|
||||
if menu is not None:
|
||||
# Create reference
|
||||
self._menu = menu
|
||||
|
||||
self.setMenu(menu)
|
||||
|
||||
if useActionGroup:
|
||||
# Create reference
|
||||
self._actionGroup = ActionGroup(self, *menu.actions())
|
||||
|
||||
self.setActionGroup(self._actionGroup)
|
||||
|
||||
self.setCheckable(checkable)
|
||||
self.setChecked(checked)
|
||||
|
||||
# Handy callback to be able to link with lambda
|
||||
self.callback = callback
|
||||
|
||||
@QtCore.Slot(bool)
|
||||
def triggerSignal(paramChecked):
|
||||
logger.info(f'action is \'{self.textEnglish}\'. Checked is {paramChecked}')
|
||||
|
||||
if callable(self.callback):
|
||||
self.callback()
|
||||
|
||||
self.triggeredCallback(paramChecked)
|
||||
|
||||
self.triggered.connect(triggerSignal)
|
||||
|
||||
@property
|
||||
def textEnglish(self):
|
||||
return _(self.text(), 'EN')
|
||||
|
||||
def __str__(self):
|
||||
return self.__class__.__name__
|
||||
|
||||
def textCompare(self, compare):
|
||||
return self.textEnglish == compare
|
||||
|
||||
@staticmethod
|
||||
def getIconFileName(fileName):
|
||||
try:
|
||||
return fileName.split('/')[-1]
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return ''
|
||||
|
||||
def setIcon(self, icon):
|
||||
self.iconFileName = self.getIconFileName(icon.iconFileName)
|
||||
|
||||
if self.iconFileName:
|
||||
if darkdetect.theme() == 'Dark':
|
||||
if PLATFORM == 'Windows':
|
||||
# Windows. Always use black icon
|
||||
super().setIcon(bootstrapIcon(self.iconFileName))
|
||||
else:
|
||||
if getUbuntuRelease() == '20.04':
|
||||
# Ubuntu 20.04 system dark theme does not change menu color.
|
||||
# Make it go black always
|
||||
super().setIcon(bootstrapIcon(self.iconFileName))
|
||||
else:
|
||||
super().setIcon(bootstrapIconWhite(self.iconFileName))
|
||||
else:
|
||||
super().setIcon(bootstrapIcon(self.iconFileName))
|
||||
else:
|
||||
# Fall back
|
||||
super().setIcon(icon)
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
# Not a mandatory re-implementation in child class
|
||||
pass
|
||||
|
||||
def themeChangedCallback(self, theme):
|
||||
if self.iconFileName:
|
||||
if theme == 'Dark':
|
||||
if PLATFORM == 'Windows':
|
||||
# Windows. Always use black icon
|
||||
super().setIcon(bootstrapIcon(self.iconFileName))
|
||||
else:
|
||||
if getUbuntuRelease() == '20.04':
|
||||
# Ubuntu 20.04 system dark theme does not change menu color.
|
||||
# Make it go black always
|
||||
super().setIcon(bootstrapIcon(self.iconFileName))
|
||||
else:
|
||||
super().setIcon(bootstrapIconWhite(self.iconFileName))
|
||||
else:
|
||||
super().setIcon(bootstrapIcon(self.iconFileName))
|
||||
|
||||
def retranslate(self):
|
||||
def recursiveTranslate(action, memo):
|
||||
if action not in memo and not action.isSeparator():
|
||||
action.setText(_(action.text()))
|
||||
|
||||
memo[action] = True
|
||||
|
||||
if action.menu() is not None:
|
||||
for childAction in action.menu().actions():
|
||||
recursiveTranslate(childAction, memo)
|
||||
|
||||
# Do not use StateContext because it's been managed somewhere else!!!
|
||||
recursiveTranslate(self, dict())
|
||||
|
||||
|
||||
class ActionGroup(QActionGroup):
|
||||
def __init__(self, parent, *actions):
|
||||
super().__init__(parent)
|
||||
|
||||
for action in actions:
|
||||
self.addAction(action)
|
||||
|
||||
|
||||
class Seperator(QAction):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
@@ -0,0 +1,8 @@
|
||||
from PySide6.QtGui import QIcon
|
||||
|
||||
|
||||
class Icon(QIcon):
|
||||
def __init__(self, iconFileName):
|
||||
super().__init__(iconFileName)
|
||||
|
||||
self.iconFileName = iconFileName
|
||||
@@ -0,0 +1,36 @@
|
||||
from Furious.Version import __version__
|
||||
|
||||
from PySide6 import QtCore
|
||||
|
||||
import math
|
||||
import pathlib
|
||||
import platform
|
||||
|
||||
APPLICATION_NAME = 'Furious'
|
||||
APPLICATION_VERSION = __version__
|
||||
# TODO
|
||||
APPLICATION_MACOS_SIGNATURE = 'com.furious'
|
||||
APPLICATION_ABOUT_PAGE = 'https://www.baidu.com/'
|
||||
APPLICATION_REPO_OWNER_NAME = 'xtls'
|
||||
APPLICATION_REPO_NAME = 'xray-core'
|
||||
|
||||
ORGANIZATION_NAME = 'Furious'
|
||||
ORGANIZATION_DOMAIN = 'furious.network'
|
||||
|
||||
PLATFORM = platform.system()
|
||||
|
||||
LOCAL_SERVER_NAME = '891ad49d-8996-43cb-820c-d9baf42a04de'
|
||||
|
||||
GOLDEN_RATIO = (math.sqrt(5) - 1) / 2
|
||||
|
||||
SYSTEM_LANGUAGE = QtCore.QLocale().name()[:2].upper()
|
||||
|
||||
ROOT_DIR = pathlib.Path(__file__).resolve().parent.parent
|
||||
DATA_DIR = ROOT_DIR / 'data'
|
||||
CRASH_LOG_DIR = ROOT_DIR / 'crashLog'
|
||||
|
||||
PROXY_SERVER_BYPASS = (
|
||||
'localhost;*.local;127.*;10.*;172.16.*;172.17.*;'
|
||||
'172.18.*;172.19.*;172.20.*;172.21.*;172.22.*;172.23.*;172.24.*;172.25.*;'
|
||||
'172.26.*;172.27.*;172.28.*;172.29.*;172.30.*;172.31.*;192.168.*'
|
||||
)
|
||||
@@ -0,0 +1,85 @@
|
||||
from Furious.Widget.Application import Application
|
||||
from Furious.Utility.Constants import PLATFORM, CRASH_LOG_DIR
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
import datetime
|
||||
import traceback
|
||||
import functools
|
||||
import multiprocessing
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if PLATFORM == 'Windows':
|
||||
ProcessContext = multiprocessing
|
||||
else:
|
||||
ProcessContext = multiprocessing.get_context('spawn')
|
||||
|
||||
|
||||
class Process(ProcessContext.Process):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
|
||||
self.startUpTime = str(datetime.datetime.now()).replace(':', '')
|
||||
self.logFileName = f'{self.startUpTime}.log'
|
||||
self.fileWritten = multiprocessing.Manager().Value('b', False)
|
||||
self.application = None
|
||||
|
||||
def exceptHook(self, exceptionType, exceptionValue, tb):
|
||||
if logger.level < logging.CRITICAL:
|
||||
traceback.print_exception(exceptionType, exceptionValue, tb)
|
||||
|
||||
if isinstance(exceptionValue, AssertionError):
|
||||
error = Application.ErrorCode.AssertionError
|
||||
else:
|
||||
error = Application.ErrorCode.UnknownException
|
||||
|
||||
logger.error(f'stopped with exitcode {error}')
|
||||
|
||||
self.saveCrashLog(exceptionType, exceptionValue, tb)
|
||||
|
||||
if QApplication.instance() is not None:
|
||||
QApplication.instance().exit(error)
|
||||
else:
|
||||
sys.exit(error)
|
||||
|
||||
def saveCrashLog(self, exceptionType, exceptionValue, tb):
|
||||
try:
|
||||
os.mkdir(CRASH_LOG_DIR)
|
||||
except FileExistsError:
|
||||
# Directory already exists
|
||||
pass
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return
|
||||
|
||||
try:
|
||||
stackLog = functools.reduce(
|
||||
lambda x, y: x + y,
|
||||
traceback.format_exception(exceptionType, exceptionValue, tb),
|
||||
)
|
||||
|
||||
if QApplication.instance() is None:
|
||||
crashLog = f'{stackLog}'
|
||||
else:
|
||||
crashLog = f'{QApplication.instance().log()}\n{stackLog}'
|
||||
|
||||
with open(CRASH_LOG_DIR / self.logFileName, 'w', encoding='utf-8') as file:
|
||||
file.write(crashLog)
|
||||
|
||||
self.fileWritten.value = True
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
pass
|
||||
|
||||
def run(self):
|
||||
sys.excepthook = self.exceptHook
|
||||
|
||||
self.application = Application(sys.argv)
|
||||
|
||||
sys.exit(self.application.run())
|
||||
@@ -0,0 +1,249 @@
|
||||
from Furious.Utility.Constants import PLATFORM
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def runCommand(*args, **kwargs):
|
||||
return subprocess.run(*args, **kwargs)
|
||||
|
||||
|
||||
def linuxProxyConfig(proxy_args, arg0, arg1):
|
||||
runCommand(
|
||||
[
|
||||
'gsettings',
|
||||
'set',
|
||||
'org.gnome.system.' + proxy_args,
|
||||
arg0,
|
||||
arg1,
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
|
||||
def darwinProxyConfig(operation, *args):
|
||||
def getNetworkServices():
|
||||
command = runCommand(
|
||||
['networksetup', '-listallnetworkservices'],
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
|
||||
service = list(filter(lambda x: x != '', command.stdout.decode().split('\n')))
|
||||
|
||||
return service[1:]
|
||||
|
||||
for serviceName in getNetworkServices():
|
||||
runCommand(
|
||||
[
|
||||
'networksetup',
|
||||
f'-{operation}',
|
||||
serviceName,
|
||||
*args,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class _Proxy:
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self._daemonThread = None
|
||||
|
||||
@staticmethod
|
||||
def pac(pac_url):
|
||||
def _pac():
|
||||
if PLATFORM == 'Windows':
|
||||
try:
|
||||
import sysproxy
|
||||
|
||||
return sysproxy.pac(pac_url)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return False
|
||||
|
||||
if PLATFORM == 'Linux':
|
||||
try:
|
||||
linuxProxyConfig('proxy', 'autoconfig-url', pac_url)
|
||||
linuxProxyConfig('proxy', 'mode', 'auto')
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
if PLATFORM == 'Darwin':
|
||||
try:
|
||||
darwinProxyConfig('setautoproxyurl', pac_url)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
if _pac():
|
||||
logger.info('set proxy PAC success')
|
||||
else:
|
||||
logger.error('set proxy PAC failed')
|
||||
|
||||
@staticmethod
|
||||
def set(server, bypass):
|
||||
def _set():
|
||||
if PLATFORM == 'Windows':
|
||||
try:
|
||||
import sysproxy
|
||||
|
||||
return sysproxy.set(server, bypass)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return False
|
||||
|
||||
if PLATFORM == 'Linux':
|
||||
try:
|
||||
host, port = server.split(':')
|
||||
|
||||
linuxProxyConfig('proxy.http', 'host', host)
|
||||
linuxProxyConfig('proxy.http', 'port', port)
|
||||
linuxProxyConfig('proxy.https', 'host', host)
|
||||
linuxProxyConfig('proxy.https', 'port', port)
|
||||
linuxProxyConfig(
|
||||
'proxy', 'ignore-hosts', str(list(bypass.split(';')))
|
||||
)
|
||||
linuxProxyConfig('proxy', 'mode', 'manual')
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
if PLATFORM == 'Darwin':
|
||||
try:
|
||||
darwinProxyConfig('setwebproxy', *server.split(':'))
|
||||
darwinProxyConfig('setsecurewebproxy', *server.split(':'))
|
||||
darwinProxyConfig('setproxybypassdomains', *bypass.split(';'))
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
if _set():
|
||||
logger.info(f'set proxy server {server} success')
|
||||
else:
|
||||
logger.error(f'set proxy server {server} failed')
|
||||
|
||||
@staticmethod
|
||||
def off():
|
||||
def _off():
|
||||
if PLATFORM == 'Windows':
|
||||
try:
|
||||
import sysproxy
|
||||
|
||||
return sysproxy.off()
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return False
|
||||
|
||||
if PLATFORM == 'Linux':
|
||||
try:
|
||||
linuxProxyConfig('proxy', 'mode', 'none')
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
if PLATFORM == 'Darwin':
|
||||
try:
|
||||
darwinProxyConfig('setwebproxystate', 'off')
|
||||
darwinProxyConfig('setsecurewebproxystate', 'off')
|
||||
darwinProxyConfig('setautoproxystate', 'off')
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
if _off():
|
||||
logger.info('turn off proxy success')
|
||||
else:
|
||||
logger.error('turn off proxy failed')
|
||||
|
||||
def daemonOn_(self):
|
||||
def _daemonOn_():
|
||||
if PLATFORM == 'Windows':
|
||||
try:
|
||||
import sysproxy
|
||||
import threading
|
||||
|
||||
if self._daemonThread is not None:
|
||||
return False
|
||||
|
||||
self._daemonThread = threading.Thread(
|
||||
target=lambda: sysproxy.daemon_on_(), daemon=True
|
||||
)
|
||||
self._daemonThread.start()
|
||||
|
||||
return True
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return False
|
||||
|
||||
if _daemonOn_():
|
||||
logger.info('turn on proxy daemon success')
|
||||
else:
|
||||
logger.error('turn on proxy daemon failed')
|
||||
|
||||
def daemonOff(self):
|
||||
def _daemonOff():
|
||||
if PLATFORM == 'Windows':
|
||||
try:
|
||||
import sysproxy
|
||||
import threading
|
||||
|
||||
if self._daemonThread is None:
|
||||
# Already in off state
|
||||
return True
|
||||
|
||||
assert isinstance(self._daemonThread, threading.Thread)
|
||||
|
||||
if sysproxy.daemon_off():
|
||||
self._daemonThread.join()
|
||||
|
||||
# Reset it
|
||||
self._daemonThread = None
|
||||
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return False
|
||||
|
||||
# Note: try to resolve non-Windows proxy daemon issue by
|
||||
# turning on StartupOnBoot by default. This should be
|
||||
# friendly for most of the users
|
||||
|
||||
if _daemonOff():
|
||||
logger.info('turn off proxy daemon success')
|
||||
else:
|
||||
logger.error('turn off proxy daemon failed')
|
||||
|
||||
|
||||
Proxy = _Proxy()
|
||||
@@ -0,0 +1,75 @@
|
||||
from Furious.Action.Routing import SUPPORTED_ROUTING
|
||||
from Furious.Action.Language import SUPPORTED_LANGUAGE
|
||||
from Furious.Utility.Constants import SYSTEM_LANGUAGE
|
||||
from Furious.Utility.Utility import Switch
|
||||
|
||||
from PySide6 import QtCore
|
||||
|
||||
|
||||
class Settings:
|
||||
def __init__(self, name, protectedRange=None, default=None):
|
||||
self.name = name
|
||||
self.protectedRange = protectedRange
|
||||
|
||||
if protectedRange is None:
|
||||
self.default = default
|
||||
else:
|
||||
self.default = protectedRange[0] if default is None else default
|
||||
|
||||
@staticmethod
|
||||
def get(key):
|
||||
if key in Memo:
|
||||
settings = Memo[key]
|
||||
|
||||
value = QtCore.QSettings().value(settings.name)
|
||||
|
||||
if settings.protectedRange is None or value in settings.protectedRange:
|
||||
return value
|
||||
|
||||
# Value not in protected range. Set to default
|
||||
QtCore.QSettings().setValue(settings.name, settings.default)
|
||||
|
||||
return settings.default
|
||||
else:
|
||||
raise AttributeError(f'Settings \'{key}\' not found')
|
||||
|
||||
@staticmethod
|
||||
def set(key, value):
|
||||
if key in Memo:
|
||||
settings = Memo[key]
|
||||
|
||||
if settings.protectedRange is None or value in settings.protectedRange:
|
||||
QtCore.QSettings().setValue(settings.name, value)
|
||||
else:
|
||||
# Value not in protected range. Raise exception
|
||||
raise ValueError(f'Invalid settings value {value}')
|
||||
else:
|
||||
raise AttributeError(f'Settings \'{key}\' not found')
|
||||
|
||||
|
||||
# Handy stuff
|
||||
SMART_CHOSEN_LANGUAGE = (
|
||||
SYSTEM_LANGUAGE if SYSTEM_LANGUAGE in SUPPORTED_LANGUAGE else 'EN'
|
||||
)
|
||||
|
||||
SUPPORTED_SETTINGS = (
|
||||
Settings('Connect', Switch.RANGE),
|
||||
Settings('Routing', SUPPORTED_ROUTING),
|
||||
Settings('Configuration'),
|
||||
Settings('ActivatedItemIndex'),
|
||||
Settings('MainWidgetWindowSize'),
|
||||
Settings('ServerWidgetSectionSizeTable'),
|
||||
Settings('EditorWidgetPointSize'),
|
||||
Settings('ViewerWidgetPointSize'),
|
||||
Settings('Language', SUPPORTED_LANGUAGE, SMART_CHOSEN_LANGUAGE),
|
||||
Settings('StartupOnBoot', Switch.RANGE, Switch.ON_), # On by default
|
||||
Settings(
|
||||
# For user experience: On by default
|
||||
'ShowProgressBarWhenConnecting',
|
||||
Switch.RANGE,
|
||||
Switch.ON_,
|
||||
),
|
||||
Settings('ShowTabAndSpacesInEditor', Switch.RANGE),
|
||||
)
|
||||
|
||||
Memo = {settings.name: settings for settings in SUPPORTED_SETTINGS}
|
||||
@@ -0,0 +1,179 @@
|
||||
from Furious.Utility.Constants import (
|
||||
APPLICATION_NAME,
|
||||
APPLICATION_VERSION,
|
||||
APPLICATION_MACOS_SIGNATURE,
|
||||
PLATFORM,
|
||||
)
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
import plistlib
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StartupOnBoot:
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
@staticmethod
|
||||
def on_():
|
||||
def _on():
|
||||
if sys.argv[0].endswith('.py'):
|
||||
# Script mode
|
||||
logger.info('ignore turn on StartupOnBoot in script mode')
|
||||
|
||||
return True
|
||||
|
||||
if PLATFORM == 'Windows':
|
||||
settings = QtCore.QSettings(
|
||||
'HKEY_CURRENT_USER\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run',
|
||||
QtCore.QSettings.Format.NativeFormat,
|
||||
)
|
||||
|
||||
appFilePath = sys.argv[0].replace('/', '\\')
|
||||
|
||||
settings.setValue(APPLICATION_NAME, f'\"{appFilePath}\"')
|
||||
|
||||
return True
|
||||
|
||||
if PLATFORM == 'Linux':
|
||||
autostartDir = os.path.join(
|
||||
os.path.expanduser('~'), '.config', 'autostart'
|
||||
)
|
||||
|
||||
try:
|
||||
os.mkdir(autostartDir)
|
||||
except FileExistsError:
|
||||
pass
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(
|
||||
os.path.join(autostartDir, f'{APPLICATION_NAME}.desktop'),
|
||||
'w',
|
||||
encoding='utf-8',
|
||||
) as file:
|
||||
file.write(
|
||||
f'[Desktop Entry]\n'
|
||||
f'Exec={sys.argv[0]}\n'
|
||||
f'Version={APPLICATION_VERSION}\n'
|
||||
f'Type=Application\n'
|
||||
f'Categories=Development\n'
|
||||
f'Name={APPLICATION_NAME}\n'
|
||||
f'Terminal=false\n'
|
||||
f'X-GNOME-Autostart-enabled=true\n'
|
||||
f'StartupNotify=false\n'
|
||||
f'X-GNOME-Autostart-Delay=10\n'
|
||||
f'X-MATE-Autostart-Delay=10\n'
|
||||
f'X-KDE-autostart-after=panel\n'
|
||||
)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
if PLATFORM == 'Darwin':
|
||||
autostartDir = os.path.join(
|
||||
os.path.expanduser('~'), 'Library', 'LaunchAgents'
|
||||
)
|
||||
|
||||
try:
|
||||
os.mkdir(autostartDir)
|
||||
except FileExistsError:
|
||||
pass
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return False
|
||||
|
||||
try:
|
||||
plistData = {
|
||||
'Label': f'{APPLICATION_MACOS_SIGNATURE}.{APPLICATION_NAME}',
|
||||
'ProgramArguments': [sys.argv[0]],
|
||||
'RunAtLoad': True,
|
||||
}
|
||||
|
||||
with open(
|
||||
os.path.join(
|
||||
autostartDir,
|
||||
f'{APPLICATION_MACOS_SIGNATURE}.{APPLICATION_NAME}.plist',
|
||||
),
|
||||
'wb',
|
||||
) as file:
|
||||
plistlib.dump(plistData, file)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
if _on():
|
||||
logger.info('turn on StartupOnBoot success')
|
||||
else:
|
||||
logger.error('turn on StartupOnBoot failed')
|
||||
|
||||
@staticmethod
|
||||
def off():
|
||||
def _off():
|
||||
if PLATFORM == 'Windows':
|
||||
settings = QtCore.QSettings(
|
||||
'HKEY_CURRENT_USER\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run',
|
||||
QtCore.QSettings.Format.NativeFormat,
|
||||
)
|
||||
|
||||
settings.remove(APPLICATION_NAME)
|
||||
|
||||
return True
|
||||
|
||||
if PLATFORM == 'Linux':
|
||||
autostartDir = os.path.join(
|
||||
os.path.expanduser('~'), '.config', 'autostart'
|
||||
)
|
||||
|
||||
try:
|
||||
os.remove(os.path.join(autostartDir, f'{APPLICATION_NAME}.desktop'))
|
||||
except FileNotFoundError:
|
||||
return True
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
if PLATFORM == 'Darwin':
|
||||
autostartDir = os.path.join(
|
||||
os.path.expanduser('~'), 'Library', 'LaunchAgents'
|
||||
)
|
||||
|
||||
try:
|
||||
os.remove(
|
||||
os.path.join(
|
||||
autostartDir,
|
||||
f'{APPLICATION_MACOS_SIGNATURE}.{APPLICATION_NAME}.plist',
|
||||
)
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return True
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
if _off():
|
||||
logger.info('turn off StartupOnBoot success')
|
||||
else:
|
||||
logger.error('turn off StartupOnBoot failed')
|
||||
@@ -0,0 +1,70 @@
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtGui import QColor, QFont, QSyntaxHighlighter, QTextCharFormat
|
||||
|
||||
|
||||
class HighlightRules:
|
||||
def __init__(self, regex, color, isBold=False, isJSONKey=False):
|
||||
self.regex = QtCore.QRegularExpression(regex)
|
||||
self.color = QColor(color)
|
||||
|
||||
self.rules = QTextCharFormat()
|
||||
self.rules.setForeground(self.color)
|
||||
|
||||
if isBold:
|
||||
self.rules.setFontWeight(QFont.Weight.Bold)
|
||||
|
||||
self.isJSONKey = isJSONKey
|
||||
|
||||
|
||||
class Theme(QSyntaxHighlighter):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def getStyleSheet(*args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class DraculaTheme(Theme):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.highlightRules = [
|
||||
# Keywords: true, false, null. Pink. Bold
|
||||
HighlightRules(r'\b(true|false|null)\b', '#FF79C6', isBold=True),
|
||||
# Numbers. Purple
|
||||
HighlightRules(r'[-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?', '#BD93F9'),
|
||||
# Symbols: :, [, ], {, }. White
|
||||
HighlightRules(r'[:,\[\]\{\}]', '#F8F8F2'),
|
||||
# Double-quoted strings. Yellow
|
||||
HighlightRules(r'"[^"\\]*(\\.[^"\\]*)*"', '#F1FA8C'),
|
||||
# JSON keys. Green
|
||||
HighlightRules(r'"([^"\\]*(\\.[^"\\]*)*)"\s*:', '#50FA7B', isJSONKey=True),
|
||||
]
|
||||
|
||||
def highlightBlock(self, text):
|
||||
for highlightRule in self.highlightRules:
|
||||
iterator = highlightRule.regex.globalMatch(text)
|
||||
|
||||
while iterator.hasNext():
|
||||
match = iterator.next()
|
||||
|
||||
if highlightRule.isJSONKey:
|
||||
# JSON keys. Ignore trailing :
|
||||
capturedLength = match.capturedLength() - 1
|
||||
else:
|
||||
capturedLength = match.capturedLength()
|
||||
|
||||
self.setFormat(
|
||||
match.capturedStart(), capturedLength, highlightRule.rules
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def getStyleSheet(widgetName, fontFamily):
|
||||
return (
|
||||
f'{widgetName} {{'
|
||||
f' background-color: #282A36;'
|
||||
f' color: #F8F8F2;'
|
||||
f' font-family: \'{fontFamily}\';'
|
||||
f'}}'
|
||||
)
|
||||
@@ -0,0 +1,746 @@
|
||||
from Furious.Utility.Constants import APPLICATION_NAME
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
|
||||
class Translatable:
|
||||
Object = list()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
Translatable.Object.append(self)
|
||||
|
||||
def retranslate(self):
|
||||
raise NotImplementedError
|
||||
|
||||
@staticmethod
|
||||
def retranslateAll():
|
||||
for ob in Translatable.Object:
|
||||
assert isinstance(ob, Translatable)
|
||||
|
||||
ob.retranslate()
|
||||
|
||||
|
||||
class Translator:
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self.translation = dict()
|
||||
self.dictEnglish = dict()
|
||||
|
||||
def install(self, translation):
|
||||
self.translation = translation
|
||||
|
||||
for key, value in translation.items():
|
||||
# English -> English
|
||||
value['EN'] = key
|
||||
|
||||
for lang, text in value.items():
|
||||
self.dictEnglish[text] = key
|
||||
|
||||
# English -> English
|
||||
self.dictEnglish.update(dict(list((key, key) for key in translation.keys())))
|
||||
|
||||
def translate(self, source, locale):
|
||||
if source in NO_TRANSLATION_DICT:
|
||||
return source
|
||||
else:
|
||||
return self.translation[self.dictEnglish[source]][locale]
|
||||
|
||||
|
||||
_trans = Translator()
|
||||
|
||||
|
||||
def installTranslation(translation):
|
||||
_trans.install(translation)
|
||||
|
||||
|
||||
def gettext(source, locale=None):
|
||||
if locale is None:
|
||||
assert QApplication.instance() is not None
|
||||
|
||||
return _trans.translate(source, QApplication.instance().Language)
|
||||
else:
|
||||
assert locale in list(LANGUAGE_TO_ABBR.values())
|
||||
|
||||
return _trans.translate(source, locale)
|
||||
|
||||
|
||||
LANGUAGE_TO_ABBR = {
|
||||
'English': 'EN',
|
||||
'Español': 'ES',
|
||||
'简体中文': 'ZH',
|
||||
'繁體中文': 'TW',
|
||||
}
|
||||
|
||||
ABBR_TO_LANGUAGE = {value: key for key, value in LANGUAGE_TO_ABBR.items()}
|
||||
|
||||
# DO-NOT-TRANSLATE text
|
||||
NO_TRANSLATION = [
|
||||
'',
|
||||
'OK',
|
||||
APPLICATION_NAME,
|
||||
*LANGUAGE_TO_ABBR.keys(),
|
||||
'TLS',
|
||||
]
|
||||
|
||||
NO_TRANSLATION_DICT = {key: True for key in NO_TRANSLATION}
|
||||
|
||||
TRANSLATION = {
|
||||
# Tray Actions
|
||||
'Connect': {
|
||||
'ES': 'Conectar',
|
||||
'ZH': '连接',
|
||||
'TW': '連接',
|
||||
},
|
||||
'Connecting': {
|
||||
'ES': 'Conectando',
|
||||
'ZH': '正在连接',
|
||||
'TW': '正在連接',
|
||||
},
|
||||
'Connected': {
|
||||
'ES': 'Conectado',
|
||||
'ZH': '已连接',
|
||||
'TW': '已連接',
|
||||
},
|
||||
'Disconnect': {
|
||||
'ES': 'Desconectar',
|
||||
'ZH': '断开连接',
|
||||
'TW': '斷開連接',
|
||||
},
|
||||
'Disconnected': {
|
||||
'ES': 'Desconectado',
|
||||
'ZH': '已断开',
|
||||
'TW': '已斷開',
|
||||
},
|
||||
'Routing': {
|
||||
'ES': 'Enrutamiento',
|
||||
'ZH': '路由',
|
||||
'TW': '路由',
|
||||
},
|
||||
'Bypass Mainland China': {
|
||||
'ES': 'Evitar China Continental',
|
||||
'ZH': '绕过中国大陆',
|
||||
'TW': '繞過中國大陸',
|
||||
},
|
||||
'Global': {
|
||||
'ES': 'Global',
|
||||
'ZH': '全球',
|
||||
'TW': '全球',
|
||||
},
|
||||
'Custom': {
|
||||
'ES': 'Personalizado',
|
||||
'ZH': '自定义',
|
||||
'TW': '自定義',
|
||||
},
|
||||
'Import': {
|
||||
'ES': 'Importar',
|
||||
'ZH': '导入',
|
||||
'TW': '導入',
|
||||
},
|
||||
'Import JSON Configuration From Clipboard': {
|
||||
'ES': 'Importar Configuración JSON desde el Portapapeles',
|
||||
'ZH': '从剪贴板导入JSON配置',
|
||||
'TW': '從剪貼板導入JSON配置',
|
||||
},
|
||||
'Import Share Link From Clipboard': {
|
||||
'ES': 'Importar Enlace Compartido desde el Portapapeles',
|
||||
'ZH': '从剪贴板导入分享链接',
|
||||
'TW': '從剪貼板導入分享鏈接',
|
||||
},
|
||||
'Edit Configuration...': {
|
||||
'ES': 'Editar Configuración...',
|
||||
'ZH': '编辑配置...',
|
||||
'TW': '編輯配置...',
|
||||
},
|
||||
'Language': {
|
||||
'ES': 'Idioma',
|
||||
'ZH': '语言',
|
||||
'TW': '語言',
|
||||
},
|
||||
'Settings': {
|
||||
'ES': 'Ajustes',
|
||||
'ZH': '设置',
|
||||
'TW': '設置',
|
||||
},
|
||||
'Startup On Boot': {
|
||||
'ES': 'Inició en el Arranque',
|
||||
'ZH': '开机启动',
|
||||
'TW': '開機啓動',
|
||||
},
|
||||
'Show Progress Bar When Connecting': {
|
||||
'ES': 'Mostrar Barra de Progreso al Conectarse',
|
||||
'ZH': '连接时显示进度条',
|
||||
'TW': '連接時顯示進度條',
|
||||
},
|
||||
'Show Tab And Spaces In Editor': {
|
||||
'ES': 'Mostrar Pestaña y Espacios en el Editor',
|
||||
'ZH': '在编辑器中显示制表符和空格',
|
||||
'TW': '在編輯器中顯示制表符和空格',
|
||||
},
|
||||
'Help': {
|
||||
'ES': 'Ayuda',
|
||||
'ZH': '帮助',
|
||||
'TW': '幫助',
|
||||
},
|
||||
'Show Log...': {
|
||||
'ES': 'Mostrar Registro...',
|
||||
'ZH': '显示日志...',
|
||||
'TW': '顯示日志...',
|
||||
},
|
||||
'Check For Updates': {
|
||||
'ES': 'Comprobar Actualizaciones',
|
||||
'ZH': '检查更新',
|
||||
'TW': '檢查更新',
|
||||
},
|
||||
'About': {
|
||||
'ES': 'Sobre',
|
||||
'ZH': '关于',
|
||||
'TW': '關於',
|
||||
},
|
||||
'Exit': {
|
||||
'ES': 'Salida',
|
||||
'ZH': '退出',
|
||||
'TW': '退出',
|
||||
},
|
||||
# Server configuration empty
|
||||
'Unable to connect': {
|
||||
'ES': 'No se puede conectar',
|
||||
'ZH': '无法连接',
|
||||
'TW': '無法連接',
|
||||
},
|
||||
'Server configuration empty. Please configure your server first.': {
|
||||
'ES': 'Configuración del servidor vacía. Por favor, configure su servidor primero.',
|
||||
'ZH': '服务器配置为空。请先配置您的服务器。',
|
||||
'TW': '服務器配置爲空。請先配置您的服務器。',
|
||||
},
|
||||
'Select and double click to activate configuration and connect.': {
|
||||
'ES': 'Seleccione y haga doble clic para activar la configuración y conectarse.',
|
||||
'ZH': '选中并双击以激活配置并连接。',
|
||||
'TW': '選中并雙擊以激活配置并連接。',
|
||||
},
|
||||
# Http Proxy Configuration Error
|
||||
f'{APPLICATION_NAME} cannot find any valid http proxy '
|
||||
f'endpoint in your server configuration.': {
|
||||
'ES': f'{APPLICATION_NAME} no puede encontrar ningún extremo de '
|
||||
f'proxy http válido en la configuración de su servidor.',
|
||||
'ZH': f'{APPLICATION_NAME}在您的服务器配置中找不到任何有效的http代理端点。',
|
||||
'TW': f'{APPLICATION_NAME}在您的服務器配置中找不到任何有效的http代理端點。',
|
||||
},
|
||||
'Please complete your server configuration.': {
|
||||
'ES': 'Complete la configuración de su servidor.',
|
||||
'ZH': '请完善您的服务器配置。',
|
||||
'TW': '請完善您的服務器配置。',
|
||||
},
|
||||
# Import Share Link From Clipboard MessageBox
|
||||
'Invalid share link.': {
|
||||
'ES': 'Enlace compartido no válidos.',
|
||||
'ZH': '无效的分享链接。',
|
||||
'TW': '無效的分享鏈接。',
|
||||
},
|
||||
'Invalid share link. The content of the clipboard is:': {
|
||||
'ES': 'Enlace compartido no válidos. El contenido del portapapeles es:',
|
||||
'ZH': '无效的分享链接。剪贴板的内容为:',
|
||||
'TW': '無效的分享鏈接。剪貼板的内容為:',
|
||||
},
|
||||
'Import share link success: ': {
|
||||
'ES': 'Importación de enlace compartido exitosa: ',
|
||||
'ZH': '导入分享链接成功:',
|
||||
'TW': '導入分享鏈接成功:',
|
||||
},
|
||||
'Imported to row': {
|
||||
'ES': 'Importado a la fila',
|
||||
'ZH': '导入至行',
|
||||
'TW': '導入至行',
|
||||
},
|
||||
'Import share link success.': {
|
||||
'ES': 'Importación de enlace compartido exitosa.',
|
||||
'ZH': '导入分享链接成功。',
|
||||
'TW': '導入分享鏈接成功。',
|
||||
},
|
||||
'Go to edit': {
|
||||
'ES': 'Ir a editar',
|
||||
'ZH': '前往编辑',
|
||||
'TW': '前往編輯',
|
||||
},
|
||||
# Import JSON Configuration From Clipboard MessageBox
|
||||
'Invalid JSON data.': {
|
||||
'ES': 'Datos JSON no válidos.',
|
||||
'ZH': '无效的JSON数据。',
|
||||
'TW': '無效的JSON數據。',
|
||||
},
|
||||
'Invalid JSON data. The content of the clipboard is:': {
|
||||
'ES': 'Datos JSON no válidos. El contenido del portapapeles es:',
|
||||
'ZH': '无效的JSON数据。剪贴板的内容为:',
|
||||
'TW': '無效的JSON數據。剪貼板的内容為:',
|
||||
},
|
||||
'Import JSON configuration success.': {
|
||||
'ES': 'Importación de configuración JSON exitosa.',
|
||||
'ZH': '导入JSON配置成功。',
|
||||
'TW': '導入JSON配置成功。',
|
||||
},
|
||||
# Export
|
||||
'Export': {
|
||||
'ES': 'Exportar',
|
||||
'ZH': '导出',
|
||||
'TW': '導出',
|
||||
},
|
||||
'Export Share Link To Clipboard': {
|
||||
'ES': 'Exportar Enlace Compartido al Portapapeles',
|
||||
'ZH': '导出分享链接至剪贴板',
|
||||
'TW': '導出分享鏈接至剪貼板',
|
||||
},
|
||||
'Export share link to clipboard failed:': {
|
||||
'ES': 'Exportar enlace compartido al portapapeles falló:',
|
||||
'ZH': '导出分享链接至剪贴板失败:',
|
||||
'TW': '導出分享鏈接至剪貼板失敗:',
|
||||
},
|
||||
'Export share link to clipboard success:': {
|
||||
'ES': 'Exportar enlace compartido al portapapeles con éxito:',
|
||||
'ZH': '导出分享链接至剪贴板成功:',
|
||||
'TW': '導出分享鏈接至剪貼板成功:',
|
||||
},
|
||||
'Export share link to clipboard partially success:': {
|
||||
'ES': 'Exportar enlace compartido al portapapeles parcialmente exitoso:',
|
||||
'ZH': '导出分享链接至剪贴板部分成功:',
|
||||
'TW': '導出分享鏈接至剪貼板部分成功:',
|
||||
},
|
||||
'Failed:': {
|
||||
'ES': 'Fallido:',
|
||||
'ZH': '失败:',
|
||||
'TW': '失敗:',
|
||||
},
|
||||
'Export As QR Code': {
|
||||
'ES': 'Exportar Como Código QR',
|
||||
'ZH': '导出为二维码',
|
||||
'TW': '導出為二維碼',
|
||||
},
|
||||
'Export as QR code failed:': {
|
||||
'ES': 'Exportar como código QR falló:',
|
||||
'ZH': '导出为二维码失败:',
|
||||
'TW': '導出為二維碼失敗:',
|
||||
},
|
||||
'Export as QR code success:': {
|
||||
'ES': 'Exportar como código QR con éxito:',
|
||||
'ZH': '导出为二维码成功:',
|
||||
'TW': '導出為二維碼成功:',
|
||||
},
|
||||
'Export as QR code partially success:': {
|
||||
'ES': 'Exportar como código QR parcialmente exitoso:',
|
||||
'ZH': '导出为二维码部分成功:',
|
||||
'TW': '導出為二維碼部分成功:',
|
||||
},
|
||||
'Export JSON Configuration To Clipboard': {
|
||||
'ES': 'Exportar Configuración JSON al Portapapeles',
|
||||
'ZH': '导出JSON配置至剪贴板',
|
||||
'TW': '導出JSON配置至剪貼板',
|
||||
},
|
||||
'Export JSON configuration to clipboard success.': {
|
||||
'ES': 'Exportar configuración JSON al portapapeles con éxito.',
|
||||
'ZH': '导出JSON配置至剪贴板成功。',
|
||||
'TW': '導出JSON配置至剪貼板成功。',
|
||||
},
|
||||
# Edit Server Configuration Widget
|
||||
'Edit Server Configuration': {
|
||||
'ES': 'Editar Configuración del Servidor',
|
||||
'ZH': '编辑服务器配置',
|
||||
'TW': '編輯服務器配置',
|
||||
},
|
||||
'File': {
|
||||
'ES': 'Archivo',
|
||||
'ZH': '文件',
|
||||
'TW': '文件',
|
||||
},
|
||||
'New...': {
|
||||
'ES': 'Nueva...',
|
||||
'ZH': '新建...',
|
||||
'TW': '新建...',
|
||||
},
|
||||
'Untitled': {
|
||||
'ES': 'Sin Título',
|
||||
'ZH': '未命名',
|
||||
'TW': '未命名',
|
||||
},
|
||||
'Import From File...': {
|
||||
'ES': 'Importar desde Archivo...',
|
||||
'ZH': '从文件导入...',
|
||||
'TW': '從文件導入...',
|
||||
},
|
||||
'Import File': {
|
||||
'ES': 'Importar Archivo',
|
||||
'ZH': '导入文件',
|
||||
'TW': '導入文件',
|
||||
},
|
||||
'Save': {
|
||||
'ES': 'Guardar',
|
||||
'ZH': '保存',
|
||||
'TW': '保存',
|
||||
},
|
||||
'Save As...': {
|
||||
'ES': 'Guardar Como...',
|
||||
'ZH': '另存为...',
|
||||
'TW': '另存爲...',
|
||||
},
|
||||
'Save File': {
|
||||
'ES': 'Guardar Archivo',
|
||||
'ZH': '保存文件',
|
||||
'TW': '保存文件',
|
||||
},
|
||||
'Text files (*.json);;All files (*)': {
|
||||
'ES': 'Archivos de texto (*.json);;Todos los archivos (*)',
|
||||
'ZH': '文本文档 (*.json);;所有文件 (*)',
|
||||
'TW': '文本文檔 (*.json);;所有文件 (*)',
|
||||
},
|
||||
'Text files (*.txt);;All files (*)': {
|
||||
'ES': 'Archivos de texto (*.txt);;Todos los archivos (*)',
|
||||
'ZH': '文本文档 (*.txt);;所有文件 (*)',
|
||||
'TW': '文本文檔 (*.txt);;所有文件 (*)',
|
||||
},
|
||||
'Error opening file': {
|
||||
'ES': 'Error al abrir archivo',
|
||||
'ZH': '打开文件时出错',
|
||||
'TW': '打開文件時出錯',
|
||||
},
|
||||
'Error saving configuration': {
|
||||
'ES': 'Error al guardar la configuración',
|
||||
'ZH': '保存配置时出错',
|
||||
'TW': '保存配置時出錯',
|
||||
},
|
||||
'Error saving file': {
|
||||
'ES': 'Error al guardar archivo',
|
||||
'ZH': '保存文件时出错',
|
||||
'TW': '保存文件時出錯',
|
||||
},
|
||||
'Error saving log': {
|
||||
'ES': 'Error al guardar registro',
|
||||
'ZH': '保存日志时出错',
|
||||
'TW': '保存日志時出錯',
|
||||
},
|
||||
'Unable to save log.': {
|
||||
'ES': 'No se puede guardar el registro.',
|
||||
'ZH': '无法保存日志。',
|
||||
'TW': '無法保存日志。',
|
||||
},
|
||||
'Server configuration corrupted': {
|
||||
'ES': 'Configuración del servidor corrupta',
|
||||
'ZH': '服务器配置已损坏',
|
||||
'TW': '服務器配置已損壞',
|
||||
},
|
||||
f'{APPLICATION_NAME} cannot restore your server configuration. '
|
||||
f'It may have been tampered with.': {
|
||||
'ES': f'{APPLICATION_NAME} no puede restaurar la configuración de su servidor. '
|
||||
f'Puede que haya sido manipulado.',
|
||||
'ZH': f'{APPLICATION_NAME}无法恢复您的服务器配置。它可能已被篡改。',
|
||||
'TW': f'{APPLICATION_NAME}無法恢復您的服務器配置。它可能已被篡改。',
|
||||
},
|
||||
f'The configuration content has been cleared by {APPLICATION_NAME}.': {
|
||||
'ES': f'El contenido de la configuración ha sido borrado por {APPLICATION_NAME}.',
|
||||
'ZH': f'配置内容已被{APPLICATION_NAME}清空。',
|
||||
'TW': f'配置内容已被{APPLICATION_NAME}清空。',
|
||||
},
|
||||
'Invalid configuration file.': {
|
||||
'ES': 'Archivo de configuración inválido.',
|
||||
'ZH': '无效的配置文件。',
|
||||
'TW': '無效的配置文件。',
|
||||
},
|
||||
'Hint': {
|
||||
'ES': 'Pista',
|
||||
'ZH': '提示',
|
||||
'TW': '提示',
|
||||
},
|
||||
f'{APPLICATION_NAME} is currently connected.\n\n'
|
||||
f'The new configuration will take effect the next time you connect.': {
|
||||
'ES': f'{APPLICATION_NAME} está actualmente conectado.\n\n'
|
||||
f'La nueva configuración entrará en vigor la próxima vez que te conectes.',
|
||||
'ZH': f'{APPLICATION_NAME}当前已连接。\n\n' f'新配置将在您下次连接时生效。',
|
||||
'TW': f'{APPLICATION_NAME}當前已連接。\n\n' f'新配置將在您下次連接時生效。',
|
||||
},
|
||||
'Reconnect': {
|
||||
'ES': 'Reconectar',
|
||||
'ZH': '重新连接',
|
||||
'TW': '重新連接',
|
||||
},
|
||||
'Invalid server configuration.': {
|
||||
'ES': 'Configuración de servidor no válida.',
|
||||
'ZH': '无效的服务器配置。',
|
||||
'TW': '無效的服務器配置。',
|
||||
},
|
||||
'Please check your configuration is in valid JSON format:': {
|
||||
'ES': 'Verifique que su configuración esté en formato JSON válido:',
|
||||
'ZH': '请检查您的配置是否为有效的JSON格式:',
|
||||
'TW': '請檢查您的配置是否為有效的JSON格式:',
|
||||
},
|
||||
'Save Changes': {
|
||||
'ES': 'Guardar Cambios',
|
||||
'ZH': '保存更改',
|
||||
'TW': '保存更改',
|
||||
},
|
||||
'The content has been modified. Do you want to save the changes?': {
|
||||
'ES': 'El contenido ha sido modificado. ¿Desea guardar los cambios?',
|
||||
'ZH': '内容已修改。您要保存更改吗?',
|
||||
'TW': '内容已修改。您要保存更改嗎?',
|
||||
},
|
||||
'Discard': {
|
||||
'ES': 'Descartar',
|
||||
'ZH': '不保存',
|
||||
'TW': '不保存',
|
||||
},
|
||||
'Cancel': {
|
||||
'ES': 'Cancelar',
|
||||
'ZH': '取消',
|
||||
'TW': '取消',
|
||||
},
|
||||
'Delete': {
|
||||
'ES': 'Eliminar',
|
||||
'ZH': '删除',
|
||||
'TW': '刪除',
|
||||
},
|
||||
'Delete this configuration?': {
|
||||
'ES': '¿Eliminar esta configuración?',
|
||||
'ZH': '删除此配置?',
|
||||
'TW': '刪除此配置?',
|
||||
},
|
||||
'Delete these configuration?': {
|
||||
'ES': '¿Eliminar esta configuración?',
|
||||
'ZH': '删除这些配置?',
|
||||
'TW': '刪除這些配置?',
|
||||
},
|
||||
'Connecting. Please wait.': {
|
||||
'ES': 'Conectando. Espere por favor.',
|
||||
'ZH': '正在连接,请稍候。',
|
||||
'TW': '正在連接,請稍候。',
|
||||
},
|
||||
'Please save any changes to continue.': {
|
||||
'ES': 'Guarde los cambios para continuar.',
|
||||
'ZH': '请保存所有更改以继续。',
|
||||
'TW': '請保存所有更改以繼續',
|
||||
},
|
||||
'Edit': {
|
||||
'ES': 'Editar',
|
||||
'ZH': '编辑',
|
||||
'TW': '編輯',
|
||||
},
|
||||
'Undo': {
|
||||
'ES': 'Deshacer',
|
||||
'ZH': '撤销',
|
||||
'TW': '撤銷',
|
||||
},
|
||||
'Redo': {
|
||||
'ES': 'Rehacer',
|
||||
'ZH': '重做',
|
||||
'TW': '重做',
|
||||
},
|
||||
'Cut': {
|
||||
'ES': 'Cortar',
|
||||
'ZH': '剪切',
|
||||
'TW': '剪切',
|
||||
},
|
||||
'Copy': {
|
||||
'ES': 'Copiar',
|
||||
'ZH': '拷贝',
|
||||
'TW': '拷貝',
|
||||
},
|
||||
'Paste': {
|
||||
'ES': 'Pegar',
|
||||
'ZH': '粘贴',
|
||||
'TW': '粘貼',
|
||||
},
|
||||
'Select All': {
|
||||
'ES': 'Seleccionar Todo',
|
||||
'ZH': '全选',
|
||||
'TW': '全選',
|
||||
},
|
||||
'Set Indent': {
|
||||
'ES': 'Establecer sangría',
|
||||
'ZH': '设置缩进',
|
||||
'TW': '設置縮進',
|
||||
},
|
||||
'Indent...': {
|
||||
'ES': 'Sangría...',
|
||||
'ZH': '缩进...',
|
||||
'TW': '縮進...',
|
||||
},
|
||||
'Indent:': {
|
||||
'ES': 'Sangría:',
|
||||
'ZH': '缩进:',
|
||||
'TW': '縮進:',
|
||||
},
|
||||
'Error setting indent': {
|
||||
'ES': 'Error al establecer sangría',
|
||||
'ZH': '设置缩进时出错',
|
||||
'TW': '設置縮進時出錯',
|
||||
},
|
||||
'View': {
|
||||
'ES': 'Vista',
|
||||
'ZH': '显示',
|
||||
'TW': '顯示',
|
||||
},
|
||||
'Zoom In': {
|
||||
'ES': 'Acercar',
|
||||
'ZH': '变大',
|
||||
'TW': '變大',
|
||||
},
|
||||
'Zoom Out': {
|
||||
'ES': 'Alejar',
|
||||
'ZH': '变小',
|
||||
'TW': '變小',
|
||||
},
|
||||
'Remark': {
|
||||
'ES': 'Observación',
|
||||
'ZH': '别名',
|
||||
'TW': '別名',
|
||||
},
|
||||
'Protocol': {
|
||||
'ES': 'Protocolo',
|
||||
'ZH': '协议',
|
||||
'TW': '協議',
|
||||
},
|
||||
'Address': {
|
||||
'ES': 'Dirección',
|
||||
'ZH': '地址',
|
||||
'TW': '地址',
|
||||
},
|
||||
'Port': {
|
||||
'ES': 'Puerto',
|
||||
'ZH': '端口',
|
||||
'TW': '端口',
|
||||
},
|
||||
'Transport': {
|
||||
'ES': 'Transporte',
|
||||
'ZH': '传输方式',
|
||||
'TW': '傳輸方式',
|
||||
},
|
||||
'Server': {
|
||||
'ES': 'Servidor',
|
||||
'ZH': '服务器',
|
||||
'TW': '服務器',
|
||||
},
|
||||
# Server Widget buttons
|
||||
'Move Up': {
|
||||
'ES': 'Ascender',
|
||||
'ZH': '上移',
|
||||
'TW': '上移',
|
||||
},
|
||||
'Move Down': {
|
||||
'ES': 'Mover Hacia Abajo',
|
||||
'ZH': '下移',
|
||||
'TW': '下移',
|
||||
},
|
||||
'Duplicate': {
|
||||
'ES': 'Duplicado',
|
||||
'ZH': '克隆',
|
||||
'TW': '克隆',
|
||||
},
|
||||
'Scroll To Activated Server': {
|
||||
'ES': 'Desplácese Hasta el Servidor Activado',
|
||||
'ZH': '滚动到激活的服务器',
|
||||
'TW': '滾動到激活的服務器',
|
||||
},
|
||||
# Log Viewer
|
||||
'Log Viewer': {
|
||||
'ES': 'Visor de Registro',
|
||||
'ZH': '日志查看器',
|
||||
'TW': '日志查看器',
|
||||
},
|
||||
# Check For Updates
|
||||
'Check for updates failed.': {
|
||||
'ES': 'Error al comprobar actualizaciones.',
|
||||
'ZH': '检查更新失败。',
|
||||
'TW': '檢查更新失敗。',
|
||||
},
|
||||
'New version available: ': {
|
||||
'ES': 'Nueva versión disponible: ',
|
||||
'ZH': '新版本可用:',
|
||||
'TW': '新版本可用:',
|
||||
},
|
||||
'Go to download page?': {
|
||||
'ES': '¿Ir a la página de descarga?',
|
||||
'ZH': '是否前往下载页面?',
|
||||
'TW': '是否前往下載頁面?',
|
||||
},
|
||||
f'{APPLICATION_NAME} is already the latest version.': {
|
||||
'ES': f'{APPLICATION_NAME} ya es la última versión.',
|
||||
'ZH': f'{APPLICATION_NAME}已是最新版本。',
|
||||
'TW': f'{APPLICATION_NAME}已是最新版本。',
|
||||
},
|
||||
f'Thank you for using {APPLICATION_NAME}.': {
|
||||
'ES': f'Gracias por usar {APPLICATION_NAME}.',
|
||||
'ZH': f'感谢您使用{APPLICATION_NAME}。',
|
||||
'TW': f'感謝您使用{APPLICATION_NAME}。',
|
||||
},
|
||||
# Tray
|
||||
'Unconfigured server': {
|
||||
'ES': 'Servidor no configurado',
|
||||
'ZH': '未配置的服务器',
|
||||
'TW': '未配置的服務器',
|
||||
},
|
||||
'Already started': {
|
||||
'ES': 'Ya ha comenzado',
|
||||
'ZH': '已启动',
|
||||
'TW': '已啓動',
|
||||
},
|
||||
'Invalid server configuration': {
|
||||
'ES': 'Configuración de servidor no válida',
|
||||
'ZH': '无效的服务器配置',
|
||||
'TW': '無效的服務器配置',
|
||||
},
|
||||
'Failed to start core': {
|
||||
'ES': 'No se pudo iniciar el núcleo',
|
||||
'ZH': '内核启动失败',
|
||||
'TW': '内核啓動失敗',
|
||||
},
|
||||
'Connection test failed': {
|
||||
'ES': 'Falló la prueba de conexión',
|
||||
'ZH': '连接测试失败',
|
||||
'TW': '連接測試失敗',
|
||||
},
|
||||
'Connection to server has been lost': {
|
||||
'ES': 'Se perdió la conexión con el servidor',
|
||||
'ZH': '与服务器的连接丢失',
|
||||
'TW': '與服務器的連接丟失',
|
||||
},
|
||||
'Core terminated unexpectedly': {
|
||||
'ES': 'Núcleo terminado inesperadamente',
|
||||
'ZH': '内核意外终止',
|
||||
'TW': '内核意外終止',
|
||||
},
|
||||
'Routing changed: ': {
|
||||
'ES': 'Ruta cambiada: ',
|
||||
'ZH': '路由已更改:',
|
||||
'TW': '路由已更改:',
|
||||
},
|
||||
# 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.',
|
||||
'ZH': f'{APPLICATION_NAME}无法在此操作系统上运行。',
|
||||
'TW': f'{APPLICATION_NAME}無法在此操作系統上運行。',
|
||||
},
|
||||
'Operating system information:': {
|
||||
'ES': 'Información del sistema operativo:',
|
||||
'ZH': '操作系统信息:',
|
||||
'TW': '操作系統信息:',
|
||||
},
|
||||
f'{APPLICATION_NAME} stopped unexpectedly due to an unknown exception.': {
|
||||
'ES': f'{APPLICATION_NAME} se detuvo inesperadamente debido a una excepción desconocida.',
|
||||
'ZH': f'由于一个未知异常,{APPLICATION_NAME}意外终止。',
|
||||
'TW': f'由於一個未知異常,{APPLICATION_NAME}意外終止。',
|
||||
},
|
||||
f'{APPLICATION_NAME} encountered an internal error and needs to be stopped.': {
|
||||
'ES': f'{APPLICATION_NAME} encontró un error interno y debe detenerse',
|
||||
'ZH': f'{APPLICATION_NAME}遇到内部错误,需要終止。',
|
||||
'TW': f'{APPLICATION_NAME}遇到内部錯誤,需要終止。',
|
||||
},
|
||||
'Crash log has been saved to: ': {
|
||||
'ES': 'El registro de errores se ha guardado en: ',
|
||||
'ZH': '崩溃日志已保存到:',
|
||||
'TW': '崩潰日志已保存到:',
|
||||
},
|
||||
'Open crash log': {
|
||||
'ES': 'Abrir registro de fallas',
|
||||
'ZH': '打开崩溃日志',
|
||||
'TW': '打開崩潰日志',
|
||||
},
|
||||
}
|
||||
|
||||
installTranslation(TRANSLATION)
|
||||
@@ -0,0 +1,204 @@
|
||||
from Furious.Gui.Icon import Icon
|
||||
from Furious.Utility.Constants import PLATFORM, ROOT_DIR
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
import os
|
||||
import copy
|
||||
import ujson
|
||||
import logging
|
||||
import pybase64
|
||||
import functools
|
||||
import subprocess
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Base64Encoder:
|
||||
@staticmethod
|
||||
def encode(text):
|
||||
return pybase64.b64encode(text)
|
||||
|
||||
@staticmethod
|
||||
def decode(text):
|
||||
return pybase64.b64decode(text, validate=True)
|
||||
|
||||
|
||||
class StateContext:
|
||||
def __init__(self, ob, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
assert hasattr(ob, 'setDisabled')
|
||||
|
||||
self._ob = ob
|
||||
|
||||
def __enter__(self):
|
||||
self._ob.setDisabled(True)
|
||||
|
||||
def __exit__(self, exceptionType, exceptionValue, tb):
|
||||
self._ob.setDisabled(False)
|
||||
|
||||
|
||||
class Storage:
|
||||
EMPTY_OBJECT = {'model': []}
|
||||
|
||||
@staticmethod
|
||||
def init():
|
||||
return copy.deepcopy(Storage.EMPTY_OBJECT)
|
||||
|
||||
@staticmethod
|
||||
def sync(ob=None):
|
||||
if ob is None:
|
||||
# Object is up-to-date
|
||||
QApplication.instance().Configuration = Storage.toStorage(
|
||||
QApplication.instance().MainWidget.StorageObj
|
||||
)
|
||||
else:
|
||||
# Object is up-to-date
|
||||
QApplication.instance().Configuration = Storage.toStorage(ob)
|
||||
|
||||
@staticmethod
|
||||
def toObject(st):
|
||||
if not st:
|
||||
# Storage does not exist, or is empty
|
||||
return Storage.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():
|
||||
QApplication.instance().Configuration = ''
|
||||
QApplication.instance().ActivatedItemIndex = str(-1)
|
||||
|
||||
|
||||
class Switch:
|
||||
OFF = '0'
|
||||
ON_ = '1'
|
||||
|
||||
RANGE = [OFF, ON_]
|
||||
|
||||
|
||||
class SupportConnectedCallback:
|
||||
Object = list()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
SupportConnectedCallback.Object.append(self)
|
||||
|
||||
def disconnectedCallback(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def connectedCallback(self):
|
||||
raise NotImplementedError
|
||||
|
||||
@staticmethod
|
||||
def callConnectedCallback():
|
||||
for ob in SupportConnectedCallback.Object:
|
||||
assert isinstance(ob, SupportConnectedCallback)
|
||||
|
||||
ob.connectedCallback()
|
||||
|
||||
@staticmethod
|
||||
def callDisconnectedCallback():
|
||||
for ob in SupportConnectedCallback.Object:
|
||||
assert isinstance(ob, SupportConnectedCallback)
|
||||
|
||||
ob.disconnectedCallback()
|
||||
|
||||
|
||||
class SupportThemeChangedCallback:
|
||||
Object = list()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
SupportThemeChangedCallback.Object.append(self)
|
||||
|
||||
def themeChangedCallback(self, theme):
|
||||
raise NotImplementedError
|
||||
|
||||
@staticmethod
|
||||
def callThemeChangedCallback(theme):
|
||||
logger.info(f'system theme changed to {theme}')
|
||||
|
||||
for ob in SupportThemeChangedCallback.Object:
|
||||
assert isinstance(ob, SupportThemeChangedCallback)
|
||||
|
||||
ob.themeChangedCallback(theme)
|
||||
|
||||
|
||||
def icon(prefix, name):
|
||||
if name.startswith('rocket-takeoff'):
|
||||
# Colorful. Use default
|
||||
return Icon(f':/Icons/bootstrap/{name}')
|
||||
else:
|
||||
return Icon(f':/Icons/{prefix}/{name}')
|
||||
|
||||
|
||||
bootstrapIcon = functools.partial(icon, 'bootstrap')
|
||||
bootstrapIconWhite = functools.partial(icon, 'bootstrap/white')
|
||||
|
||||
|
||||
def protocolRepr(protocol):
|
||||
if protocol.lower() == 'vmess':
|
||||
return 'VMess'
|
||||
|
||||
if protocol.lower() == 'vless':
|
||||
return 'VLESS'
|
||||
|
||||
return ''
|
||||
|
||||
|
||||
def getAbsolutePath(path):
|
||||
return path if os.path.isabs(path) else str(ROOT_DIR / path)
|
||||
|
||||
|
||||
def swapListItem(listOrTuple, index0, index1):
|
||||
swap = listOrTuple[index0]
|
||||
|
||||
listOrTuple[index0] = listOrTuple[index1]
|
||||
listOrTuple[index1] = swap
|
||||
|
||||
|
||||
def moveToCenter(widget, parent=None):
|
||||
geometry = widget.geometry()
|
||||
|
||||
if parent is None:
|
||||
center = QApplication.primaryScreen().availableGeometry().center()
|
||||
else:
|
||||
center = parent.geometry().center()
|
||||
|
||||
geometry.moveCenter(center)
|
||||
|
||||
widget.move(geometry.topLeft())
|
||||
|
||||
|
||||
def getUbuntuRelease():
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['cat', '/etc/lsb-release'],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=True,
|
||||
)
|
||||
|
||||
values = dict(
|
||||
list(line.split('='))
|
||||
for line in filter(lambda x: x != '', result.stdout.decode().split('\n'))
|
||||
)
|
||||
|
||||
if values['DISTRIB_ID'] == 'Ubuntu':
|
||||
return values['DISTRIB_RELEASE']
|
||||
else:
|
||||
return ''
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return ''
|
||||
@@ -0,0 +1 @@
|
||||
__version__ = '0.1.0'
|
||||
@@ -0,0 +1,303 @@
|
||||
from Furious.Widget.SystemTrayIcon import SystemTrayIcon
|
||||
from Furious.Widget.EditConfiguration import EditConfigurationWidget
|
||||
from Furious.Widget.LogViewer import LogViewerWidget
|
||||
from Furious.Utility.Constants import (
|
||||
APPLICATION_NAME,
|
||||
APPLICATION_VERSION,
|
||||
ORGANIZATION_NAME,
|
||||
ORGANIZATION_DOMAIN,
|
||||
PLATFORM,
|
||||
LOCAL_SERVER_NAME,
|
||||
SYSTEM_LANGUAGE,
|
||||
DATA_DIR,
|
||||
)
|
||||
from Furious.Utility.Utility import SupportThemeChangedCallback
|
||||
from Furious.Utility.Proxy import Proxy
|
||||
from Furious.Utility.Settings import Settings
|
||||
from Furious.Utility.Translator import gettext as _
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtGui import QFontDatabase
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from PySide6.QtNetwork import QLocalServer, QLocalSocket
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import logging
|
||||
import traceback
|
||||
import threading
|
||||
import darkdetect
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def getPythonVersion():
|
||||
return '.'.join(str(info) for info in sys.version_info)
|
||||
|
||||
|
||||
def rateLimited(maxCallPerSecond):
|
||||
"""
|
||||
Decorator function that limits the rate at which a function can be called.
|
||||
"""
|
||||
interval = 1.0 / float(maxCallPerSecond)
|
||||
called = time.monotonic()
|
||||
|
||||
def decorator(func):
|
||||
def wrapper(*args, **kwargs):
|
||||
# Previously called
|
||||
nonlocal called
|
||||
|
||||
elapsed = time.monotonic() - called
|
||||
waitsec = interval - elapsed
|
||||
|
||||
if waitsec > 0:
|
||||
time.sleep(waitsec)
|
||||
|
||||
result = func(*args, **kwargs)
|
||||
called = time.monotonic()
|
||||
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
class SystemTrayUnavailable(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class LogViewerHandle(logging.Handler):
|
||||
def __init__(self, textBrowser):
|
||||
super().__init__()
|
||||
|
||||
self.textBrowser = textBrowser
|
||||
|
||||
def emit(self, record):
|
||||
self.textBrowser.append(self.format(record))
|
||||
|
||||
|
||||
class SingletonApplication(QApplication):
|
||||
def __init__(self, argv):
|
||||
super().__init__(argv)
|
||||
|
||||
self.serverName = LOCAL_SERVER_NAME
|
||||
|
||||
self.socket = QLocalSocket(self)
|
||||
self.server = QLocalServer(self)
|
||||
|
||||
def checkForExistingApp(self):
|
||||
self.socket.connectToServer(self.serverName)
|
||||
|
||||
if self.socket.waitForConnected(1000):
|
||||
# Show tray message in the started instance
|
||||
self.socket.close()
|
||||
|
||||
# Do not start
|
||||
return True
|
||||
else:
|
||||
# New instance
|
||||
self.server.newConnection.connect(self.showExistingApp)
|
||||
|
||||
if not self.server.listen(self.serverName):
|
||||
# Do not start
|
||||
return True
|
||||
|
||||
# Start
|
||||
return False
|
||||
|
||||
@QtCore.Slot()
|
||||
def showExistingApp(self):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class ApplicationThemeDetector(QtCore.QObject):
|
||||
themeChanged = QtCore.Signal(str)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class Application(SingletonApplication):
|
||||
class ErrorCode:
|
||||
ExitSuccess = 0
|
||||
UnknownException = 1
|
||||
PlatformNotSupported = 2
|
||||
AssertionError = 3
|
||||
|
||||
def __init__(self, argv):
|
||||
super().__init__(argv)
|
||||
|
||||
self.setApplicationName(APPLICATION_NAME)
|
||||
self.setApplicationVersion(APPLICATION_VERSION)
|
||||
self.setOrganizationName(ORGANIZATION_NAME)
|
||||
self.setOrganizationDomain(ORGANIZATION_DOMAIN)
|
||||
|
||||
self.tray = None
|
||||
|
||||
self.customFontLoadMsg = ''
|
||||
self.customFontEnabled = False
|
||||
self.customFontName = ''
|
||||
|
||||
self.logViewerWidget = None
|
||||
self.logViewerHandle = None
|
||||
self.logStreamHandle = None
|
||||
|
||||
self.themeDetector = None
|
||||
self.themeListenerThread = None
|
||||
|
||||
self.MainWidget = None
|
||||
|
||||
# Shared action
|
||||
self.ImportLinkAction = None
|
||||
self.ImportJSONAction = None
|
||||
|
||||
def __getattr__(self, key):
|
||||
try:
|
||||
return Settings.get(key)
|
||||
except AttributeError:
|
||||
raise
|
||||
|
||||
def __setattr__(self, key, value):
|
||||
try:
|
||||
Settings.set(key, value)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
super().__setattr__(key, value)
|
||||
|
||||
@rateLimited(maxCallPerSecond=2)
|
||||
@QtCore.Slot()
|
||||
def showExistingApp(self):
|
||||
if isinstance(self.tray, SystemTrayIcon):
|
||||
logger.info('attempting to start multiple instance. Show tray message')
|
||||
|
||||
self.tray.showMessage(_('Already started'))
|
||||
else:
|
||||
# The tray hasn't been initialized. Do nothing
|
||||
pass
|
||||
|
||||
def configureLogging(self):
|
||||
self.logViewerWidget = LogViewerWidget()
|
||||
self.logViewerHandle = LogViewerHandle(self.logViewerWidget.textBrowser)
|
||||
self.logStreamHandle = logging.StreamHandler()
|
||||
|
||||
logging.basicConfig(
|
||||
format='[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s',
|
||||
level=logging.DEBUG,
|
||||
handlers=(self.logViewerHandle, self.logStreamHandle),
|
||||
)
|
||||
logging.raiseExceptions = False
|
||||
|
||||
def addCustomFont(self):
|
||||
fontFile = str(DATA_DIR / 'font' / 'CascadiaMono')
|
||||
fontName = 'Cascadia Mono'
|
||||
|
||||
if QFontDatabase.addApplicationFont(fontFile) != -1:
|
||||
self.customFontLoadMsg = f'custom font {fontName} load success'
|
||||
|
||||
self.customFontEnabled = True
|
||||
self.customFontName = fontName
|
||||
else:
|
||||
self.customFontLoadMsg = f'custom font {fontName} load failed'
|
||||
|
||||
@staticmethod
|
||||
def addEnviron():
|
||||
# Xray environment variables
|
||||
os.environ['XRAY_LOCATION_ASSET'] = str(DATA_DIR / 'xray')
|
||||
|
||||
def initTray(self):
|
||||
if not SystemTrayIcon.isSystemTrayAvailable():
|
||||
raise SystemTrayUnavailable(
|
||||
'SystemTrayIcon is not available on this platform'
|
||||
)
|
||||
|
||||
self.addEnviron()
|
||||
self.addCustomFont()
|
||||
self.configureLogging()
|
||||
|
||||
self.themeDetector = ApplicationThemeDetector()
|
||||
self.themeDetector.themeChanged.connect(
|
||||
SupportThemeChangedCallback.callThemeChangedCallback
|
||||
)
|
||||
|
||||
self.themeListenerThread = threading.Thread(
|
||||
target=darkdetect.listener,
|
||||
args=(self.themeDetector.themeChanged.emit,),
|
||||
daemon=True,
|
||||
)
|
||||
self.themeListenerThread.start()
|
||||
|
||||
logger.info(f'application version: {APPLICATION_VERSION}')
|
||||
logger.info(f'python version: {getPythonVersion()}. Platform: {PLATFORM}')
|
||||
logger.info(f'system version: {sys.version}')
|
||||
logger.info(f'sys.executable: {sys.executable}')
|
||||
logger.info(f'sys.argv: {sys.argv}')
|
||||
logger.info(f'appFilePath: {self.applicationFilePath()}')
|
||||
logger.info(f'system language is {SYSTEM_LANGUAGE}')
|
||||
logger.info(self.customFontLoadMsg)
|
||||
|
||||
# Mandatory
|
||||
self.setQuitOnLastWindowClosed(False)
|
||||
|
||||
# Reset proxy
|
||||
Proxy.off()
|
||||
Proxy.daemonOn_()
|
||||
|
||||
self.aboutToQuit.connect(self.cleanup)
|
||||
|
||||
self.MainWidget = EditConfigurationWidget()
|
||||
|
||||
self.tray = SystemTrayIcon()
|
||||
self.tray.show()
|
||||
self.tray.setApplicationToolTip()
|
||||
self.tray.bootstrap()
|
||||
|
||||
return self
|
||||
|
||||
@QtCore.Slot()
|
||||
def cleanup(self):
|
||||
Proxy.off()
|
||||
Proxy.daemonOff()
|
||||
|
||||
if self.tray is not None:
|
||||
self.tray.ConnectAction.stopCore()
|
||||
|
||||
if self.MainWidget is not None:
|
||||
self.MainWidget.syncSettings()
|
||||
|
||||
if self.logViewerWidget is not None:
|
||||
self.logViewerWidget.syncSettings()
|
||||
|
||||
def exit(self, exitcode=0):
|
||||
if self.MainWidget is not None:
|
||||
if self.MainWidget.questionSave():
|
||||
super().exit(exitcode)
|
||||
else:
|
||||
super().exit(exitcode)
|
||||
|
||||
def log(self):
|
||||
if self.logViewerWidget is not None:
|
||||
return self.logViewerWidget.log()
|
||||
else:
|
||||
return ''
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
if not self.checkForExistingApp():
|
||||
return self.initTray().exec()
|
||||
|
||||
# See: https://github.com/python/cpython/issues/79908
|
||||
# sys.exit(None) in multiprocessing will produce
|
||||
# exitcode 1 in some Python version, which is
|
||||
# not what we want.
|
||||
return Application.ErrorCode.ExitSuccess
|
||||
except SystemTrayUnavailable:
|
||||
return Application.ErrorCode.PlatformNotSupported
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
return Application.ErrorCode.UnknownException
|
||||
@@ -0,0 +1,59 @@
|
||||
from Furious.Utility.Constants import APPLICATION_NAME, GOLDEN_RATIO
|
||||
from Furious.Utility.Utility import bootstrapIcon, StateContext
|
||||
from Furious.Utility.Translator import Translatable, gettext as _
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtWidgets import QHBoxLayout, QProgressBar, QWidget
|
||||
|
||||
|
||||
class ConnectingProgressBar(Translatable, QWidget):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self.setWindowTitle(_(APPLICATION_NAME))
|
||||
self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg'))
|
||||
self.setFixedSize(280, int(100 * GOLDEN_RATIO))
|
||||
|
||||
@QtCore.Slot()
|
||||
def updateProgressBar():
|
||||
# Update the progress bar value
|
||||
if self.progressBar.value() < 90:
|
||||
self.progressBar.setValue(self.progressBar.value() + 1)
|
||||
|
||||
# Stop the timer when the progress bar reaches 100%
|
||||
if self.progressBar.value() >= 100:
|
||||
self.timer.stop()
|
||||
|
||||
# Create a progress bar widget
|
||||
self.progressBar = QProgressBar(self)
|
||||
self.progressBar.setRange(0, 100)
|
||||
self.progressBar.setStyleSheet(
|
||||
f'QProgressBar {{'
|
||||
f' border-radius: 2px;'
|
||||
f' text-align: center;'
|
||||
f'}}'
|
||||
f''
|
||||
f'QProgressBar::chunk {{'
|
||||
f' background-color: #43ACED;'
|
||||
f' width: 10px;'
|
||||
f' margin: 0.5px;'
|
||||
f'}}'
|
||||
)
|
||||
|
||||
# create a timer to update the progress bar
|
||||
self.timer = QtCore.QTimer(self)
|
||||
self.timer.timeout.connect(updateProgressBar)
|
||||
|
||||
self.layout = QHBoxLayout()
|
||||
self.layout.addWidget(self.progressBar)
|
||||
|
||||
self.setLayout(self.layout)
|
||||
|
||||
def closeEvent(self, event):
|
||||
event.ignore()
|
||||
|
||||
self.hide()
|
||||
|
||||
def retranslate(self):
|
||||
with StateContext(self):
|
||||
self.setWindowTitle(_(self.windowTitle()))
|
||||
@@ -0,0 +1,55 @@
|
||||
from Furious.Utility.Constants import APPLICATION_NAME
|
||||
from Furious.Utility.Utility import SupportConnectedCallback, bootstrapIcon
|
||||
from Furious.Utility.Translator import gettext as _
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtGui import QPixmap
|
||||
from PySide6.QtWidgets import QLabel, QMainWindow, QTabWidget
|
||||
|
||||
import io
|
||||
import pyqrcode
|
||||
|
||||
|
||||
class ExportQRCode(SupportConnectedCallback, QMainWindow):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self.setWindowTitle(_(APPLICATION_NAME))
|
||||
self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg'))
|
||||
|
||||
self.labelList = []
|
||||
|
||||
self.editorTab = QTabWidget(self)
|
||||
self.editorTab.setTabsClosable(True)
|
||||
self.editorTab.tabCloseRequested.connect(self.handleTabCloseRequested)
|
||||
|
||||
self.setCentralWidget(self.editorTab)
|
||||
|
||||
def initTabWithData(self, data):
|
||||
for text, link in data:
|
||||
qrdata = io.BytesIO()
|
||||
|
||||
qrcode = pyqrcode.create(link)
|
||||
qrcode.png(qrdata, scale=5)
|
||||
|
||||
pixmap = QPixmap()
|
||||
pixmap.loadFromData(qrdata.getvalue(), 'PNG')
|
||||
|
||||
label = QLabel(parent=self.editorTab)
|
||||
label.setPixmap(pixmap)
|
||||
|
||||
self.labelList.append(label)
|
||||
self.editorTab.addTab(label, text)
|
||||
|
||||
@QtCore.Slot(int)
|
||||
def handleTabCloseRequested(self, index):
|
||||
self.editorTab.removeTab(index)
|
||||
|
||||
if self.editorTab.count() == 0:
|
||||
self.hide()
|
||||
|
||||
def connectedCallback(self):
|
||||
self.setWindowIcon(bootstrapIcon('rocket-takeoff-connected-dark.svg'))
|
||||
|
||||
def disconnectedCallback(self):
|
||||
self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg'))
|
||||
@@ -0,0 +1,62 @@
|
||||
from Furious.Utility.Utility import (
|
||||
StateContext,
|
||||
SupportConnectedCallback,
|
||||
bootstrapIcon,
|
||||
)
|
||||
from Furious.Utility.Translator import Translatable, gettext as _
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QFormLayout,
|
||||
QLabel,
|
||||
QSpinBox,
|
||||
)
|
||||
|
||||
|
||||
class IndentSpinBox(Translatable, SupportConnectedCallback, QDialog):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self.setWindowTitle(_('Set Indent'))
|
||||
self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg'))
|
||||
|
||||
self.indentText = QLabel(_('Indent:'))
|
||||
|
||||
self.indentSpin = QSpinBox()
|
||||
self.indentSpin.setRange(0, 8)
|
||||
self.indentSpin.setValue(2)
|
||||
|
||||
self.dialogBtns = QDialogButtonBox(QtCore.Qt.Orientation.Horizontal)
|
||||
|
||||
self.dialogBtns.addButton(_('OK'), QDialogButtonBox.ButtonRole.AcceptRole)
|
||||
self.dialogBtns.addButton(_('Cancel'), QDialogButtonBox.ButtonRole.RejectRole)
|
||||
self.dialogBtns.accepted.connect(self.accept)
|
||||
self.dialogBtns.rejected.connect(self.reject)
|
||||
|
||||
layout = QFormLayout()
|
||||
|
||||
layout.addRow(self.indentText, self.indentSpin)
|
||||
layout.addRow(self.dialogBtns)
|
||||
layout.setFormAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def value(self):
|
||||
return self.indentSpin.value()
|
||||
|
||||
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()))
|
||||
|
||||
self.indentText.setText(_(self.indentText.text()))
|
||||
|
||||
for button in self.dialogBtns.buttons():
|
||||
button.setText(_(button.text()))
|
||||
@@ -0,0 +1,213 @@
|
||||
from Furious.Gui.Action import Action, Seperator
|
||||
from Furious.Widget.Widget import Menu, MessageBox, ZoomableTextBrowser
|
||||
from Furious.Utility.Utility import (
|
||||
StateContext,
|
||||
SupportConnectedCallback,
|
||||
bootstrapIcon,
|
||||
)
|
||||
from Furious.Utility.Translator import Translatable, gettext as _
|
||||
from Furious.Utility.Theme import DraculaTheme
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtWidgets import QApplication, QFileDialog, QMainWindow, QTextBrowser
|
||||
|
||||
|
||||
class SaveErrorBox(MessageBox):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.saveError = ''
|
||||
|
||||
def getText(self):
|
||||
if self.saveError:
|
||||
return _('Unable to save log.') + f'\n\n{self.saveError}'
|
||||
else:
|
||||
return _('Unable to save log.')
|
||||
|
||||
def retranslate(self):
|
||||
with StateContext(self):
|
||||
self.setWindowTitle(_(self.windowTitle()))
|
||||
self.setText(self.getText())
|
||||
|
||||
# Ignore informative text, buttons
|
||||
|
||||
self.moveToCenter()
|
||||
|
||||
|
||||
class SaveAsFileAction(Action):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(_('Save As...'), **kwargs)
|
||||
|
||||
self.saveErrorBox = SaveErrorBox(
|
||||
icon=MessageBox.Icon.Critical, parent=self.parent()
|
||||
)
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
filename, selectedFilter = QFileDialog.getSaveFileName(
|
||||
self.parent(), _('Save File'), filter=_('Text files (*.txt);;All files (*)')
|
||||
)
|
||||
|
||||
if filename:
|
||||
try:
|
||||
with open(filename, 'w', encoding='utf-8') as file:
|
||||
file.write(self.parent().textBrowser.toPlainText())
|
||||
except Exception as ex:
|
||||
# Any non-exit exceptions
|
||||
|
||||
self.saveErrorBox.saveError = str(ex)
|
||||
self.saveErrorBox.setWindowTitle(_('Error saving log'))
|
||||
self.saveErrorBox.setText(self.saveErrorBox.getText())
|
||||
|
||||
# Show the MessageBox and wait for user to close it
|
||||
self.saveErrorBox.exec()
|
||||
|
||||
|
||||
class ExitAction(Action):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(_('Exit'), **kwargs)
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
self.parent().syncSettings()
|
||||
self.parent().hide()
|
||||
|
||||
|
||||
class CopyAction(Action):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(_('Copy'), icon=bootstrapIcon('files.svg'), **kwargs)
|
||||
|
||||
self.setShortcut(
|
||||
QtCore.QKeyCombination(
|
||||
QtCore.Qt.KeyboardModifier.ControlModifier,
|
||||
QtCore.Qt.Key.Key_C,
|
||||
)
|
||||
)
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
self.parent().textBrowser.copy()
|
||||
|
||||
|
||||
class SelectAllAction(Action):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(_('Select All'), **kwargs)
|
||||
|
||||
self.setShortcut(
|
||||
QtCore.QKeyCombination(
|
||||
QtCore.Qt.KeyboardModifier.ControlModifier,
|
||||
QtCore.Qt.Key.Key_A,
|
||||
)
|
||||
)
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
self.parent().textBrowser.selectAll()
|
||||
|
||||
|
||||
class ZoomInAction(Action):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(_('Zoom In'), **kwargs)
|
||||
|
||||
self.setShortcut(
|
||||
QtCore.QKeyCombination(
|
||||
QtCore.Qt.KeyboardModifier.ControlModifier, QtCore.Qt.Key.Key_Plus
|
||||
)
|
||||
)
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
self.parent().textBrowser.zoomIn()
|
||||
|
||||
|
||||
class ZoomOutAction(Action):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(_('Zoom Out'), **kwargs)
|
||||
|
||||
self.setShortcut(
|
||||
QtCore.QKeyCombination(
|
||||
QtCore.Qt.KeyboardModifier.ControlModifier, QtCore.Qt.Key.Key_Minus
|
||||
)
|
||||
)
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
self.parent().textBrowser.zoomOut()
|
||||
|
||||
|
||||
class LogViewerWidget(Translatable, SupportConnectedCallback, QMainWindow):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.setWindowTitle(_('Log Viewer'))
|
||||
self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg'))
|
||||
|
||||
self.textBrowser = ZoomableTextBrowser(parent=self)
|
||||
self.textBrowser.setLineWrapMode(QTextBrowser.LineWrapMode.NoWrap)
|
||||
self.textBrowser.setStyleSheet(
|
||||
DraculaTheme.getStyleSheet(
|
||||
widgetName='QTextBrowser',
|
||||
fontFamily=QApplication.instance().customFontName,
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
# Restore point size
|
||||
font = self.textBrowser.font()
|
||||
font.setPointSize(int(QApplication.instance().ViewerWidgetPointSize))
|
||||
|
||||
self.textBrowser.setFont(font)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
pass
|
||||
|
||||
self.setCentralWidget(self.textBrowser)
|
||||
|
||||
fileMenuActions = [
|
||||
SaveAsFileAction(parent=self),
|
||||
Seperator(),
|
||||
ExitAction(parent=self),
|
||||
]
|
||||
|
||||
editMenuActions = [
|
||||
CopyAction(parent=self),
|
||||
Seperator(),
|
||||
SelectAllAction(parent=self),
|
||||
]
|
||||
|
||||
viewMenuActions = [
|
||||
ZoomInAction(parent=self),
|
||||
ZoomOutAction(parent=self),
|
||||
]
|
||||
|
||||
for menu in (fileMenuActions, editMenuActions, viewMenuActions):
|
||||
for action in menu:
|
||||
if isinstance(action, Action):
|
||||
setattr(self, f'{action}', action)
|
||||
|
||||
self._fileMenu = Menu(*fileMenuActions, title=_('File'), parent=self)
|
||||
self._editMenu = Menu(*editMenuActions, title=_('Edit'), parent=self)
|
||||
self._viewMenu = Menu(*viewMenuActions, title=_('View'), parent=self)
|
||||
|
||||
self.menuBar().addMenu(self._fileMenu)
|
||||
self.menuBar().addMenu(self._editMenu)
|
||||
self.menuBar().addMenu(self._viewMenu)
|
||||
|
||||
def log(self):
|
||||
return self.textBrowser.toPlainText()
|
||||
|
||||
def syncSettings(self):
|
||||
QApplication.instance().ViewerWidgetPointSize = str(
|
||||
self.textBrowser.font().pointSize()
|
||||
)
|
||||
|
||||
def closeEvent(self, event):
|
||||
event.ignore()
|
||||
|
||||
self.syncSettings()
|
||||
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()))
|
||||
@@ -0,0 +1,87 @@
|
||||
from Furious.Action import (
|
||||
ConnectAction,
|
||||
EditConfigurationAction,
|
||||
RoutingAction,
|
||||
ImportAction,
|
||||
LanguageAction,
|
||||
SettingsAction,
|
||||
ExitAction,
|
||||
)
|
||||
from Furious.Gui.Action import Action, Seperator
|
||||
from Furious.Widget.Widget import Menu
|
||||
from Furious.Utility.Constants import APPLICATION_NAME, APPLICATION_VERSION, PLATFORM
|
||||
from Furious.Utility.Utility import bootstrapIcon, StateContext, Switch
|
||||
from Furious.Utility.Translator import Translatable, gettext as _
|
||||
from Furious.Utility.Proxy import Proxy
|
||||
from Furious.Utility.StartupOnBoot import StartupOnBoot
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtWidgets import QApplication, QSystemTrayIcon
|
||||
|
||||
|
||||
class SystemTrayIcon(Translatable, QSystemTrayIcon):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.setPlainIcon()
|
||||
self.activated.connect(self.handleActivated)
|
||||
|
||||
actions = [
|
||||
ConnectAction(),
|
||||
RoutingAction(),
|
||||
Seperator(),
|
||||
ImportAction(),
|
||||
EditConfigurationAction(),
|
||||
Seperator(),
|
||||
LanguageAction(),
|
||||
SettingsAction(),
|
||||
Seperator(),
|
||||
ExitAction(),
|
||||
]
|
||||
|
||||
for action in actions:
|
||||
if isinstance(action, Action):
|
||||
setattr(self, f'{action}', action)
|
||||
|
||||
self._menu = Menu(*actions)
|
||||
|
||||
self.setContextMenu(self._menu)
|
||||
|
||||
def bootstrap(self):
|
||||
if QApplication.instance().StartupOnBoot == Switch.ON_:
|
||||
# Rrefresh startup application location
|
||||
StartupOnBoot.on_()
|
||||
|
||||
if QApplication.instance().Connect == Switch.ON_:
|
||||
# Trigger connect action
|
||||
self.ConnectAction.trigger()
|
||||
|
||||
def showMessage(self, msg, *args, **kwargs):
|
||||
if msg:
|
||||
super().showMessage(_(APPLICATION_NAME), msg, *args, **kwargs)
|
||||
|
||||
def setPlainIcon(self):
|
||||
if PLATFORM == 'Darwin':
|
||||
# Darker
|
||||
self.setIcon(bootstrapIcon('rocket-takeoff-dark.svg'))
|
||||
else:
|
||||
self.setIcon(bootstrapIcon('rocket-takeoff.svg'))
|
||||
|
||||
def setConnectedIcon(self):
|
||||
if PLATFORM == 'Darwin':
|
||||
# Darker
|
||||
self.setIcon(bootstrapIcon('rocket-takeoff-connected-dark.svg'))
|
||||
else:
|
||||
self.setIcon(bootstrapIcon('rocket-takeoff-connected.svg'))
|
||||
|
||||
@QtCore.Slot(QSystemTrayIcon.ActivationReason)
|
||||
def handleActivated(self, reason):
|
||||
if reason == QSystemTrayIcon.ActivationReason.DoubleClick:
|
||||
QApplication.instance().MainWidget.show()
|
||||
|
||||
def setApplicationToolTip(self):
|
||||
self.setToolTip(f'{_(APPLICATION_NAME)} {APPLICATION_VERSION}')
|
||||
|
||||
def retranslate(self):
|
||||
# Nothing to do
|
||||
pass
|
||||
@@ -0,0 +1,140 @@
|
||||
from Furious.Gui.Action import Action, Seperator
|
||||
from Furious.Utility.Utility import (
|
||||
StateContext,
|
||||
SupportConnectedCallback,
|
||||
bootstrapIcon,
|
||||
moveToCenter,
|
||||
)
|
||||
from Furious.Utility.Translator import Translatable, gettext as _
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtWidgets import (
|
||||
QMenu,
|
||||
QMessageBox,
|
||||
QPlainTextEdit,
|
||||
QPushButton,
|
||||
QTabWidget,
|
||||
QTextBrowser,
|
||||
)
|
||||
|
||||
|
||||
class Menu(Translatable, QMenu):
|
||||
def __init__(self, *actions, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
|
||||
for action in actions:
|
||||
if isinstance(action, Seperator):
|
||||
self.addSeparator()
|
||||
else:
|
||||
assert isinstance(action, Action)
|
||||
|
||||
self.addAction(action)
|
||||
|
||||
self.setStyleSheet(
|
||||
f'QMenu::item {{'
|
||||
f' background-color: solid;'
|
||||
f'}}'
|
||||
f''
|
||||
f'QMenu::item:selected {{'
|
||||
f' background-color: #43ACED;'
|
||||
f'}}'
|
||||
)
|
||||
|
||||
def retranslate(self):
|
||||
with StateContext(self):
|
||||
self.setTitle(_(self.title()))
|
||||
|
||||
|
||||
class MessageBox(Translatable, SupportConnectedCallback, QMessageBox):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg'))
|
||||
|
||||
def moveToCenter(self):
|
||||
moveToCenter(self, self.parentWidget())
|
||||
|
||||
return self
|
||||
|
||||
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()))
|
||||
self.setText(_(self.text()))
|
||||
|
||||
try:
|
||||
self.setInformativeText(_(self.informativeText()))
|
||||
except KeyError:
|
||||
# Any translatable informative text
|
||||
pass
|
||||
|
||||
for button in self.buttons():
|
||||
button.setText(_(button.text()))
|
||||
|
||||
self.moveToCenter()
|
||||
|
||||
def exec(self):
|
||||
self.show()
|
||||
self.moveToCenter()
|
||||
|
||||
return super().exec()
|
||||
|
||||
def open(self):
|
||||
self.show()
|
||||
self.moveToCenter()
|
||||
|
||||
return super().open()
|
||||
|
||||
|
||||
class PushButton(Translatable, QPushButton):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def retranslate(self):
|
||||
self.setText(_(self.text()))
|
||||
|
||||
|
||||
class TabWidget(Translatable, QTabWidget):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def retranslate(self):
|
||||
for i in range(self.tabBar().count()):
|
||||
self.tabBar().setTabText(i, _(self.tabBar().tabText(i)))
|
||||
|
||||
|
||||
class ZoomablePlainTextEdit(QPlainTextEdit):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def wheelEvent(self, event):
|
||||
if event.modifiers() == QtCore.Qt.KeyboardModifier.ControlModifier:
|
||||
delta = event.angleDelta().y()
|
||||
|
||||
if delta > 0:
|
||||
self.zoomIn()
|
||||
if delta < 0:
|
||||
self.zoomOut()
|
||||
else:
|
||||
super().wheelEvent(event)
|
||||
|
||||
|
||||
class ZoomableTextBrowser(QTextBrowser):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def wheelEvent(self, event):
|
||||
if event.modifiers() == QtCore.Qt.KeyboardModifier.ControlModifier:
|
||||
delta = event.angleDelta().y()
|
||||
|
||||
if delta > 0:
|
||||
self.zoomIn()
|
||||
if delta < 0:
|
||||
self.zoomOut()
|
||||
else:
|
||||
super().wheelEvent(event)
|
||||
@@ -0,0 +1 @@
|
||||
from .Utility import Resources
|
||||
@@ -0,0 +1,126 @@
|
||||
from Furious.Widget.Application import Application
|
||||
from Furious.Widget.Widget import MessageBox
|
||||
from Furious.Utility.Constants import APPLICATION_NAME, CRASH_LOG_DIR
|
||||
from Furious.Utility.Utility import bootstrapIcon
|
||||
from Furious.Utility.Process import Process
|
||||
from Furious.Utility.Translator import gettext as _
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtGui import QDesktopServices
|
||||
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
import traceback
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
process = Process()
|
||||
|
||||
process.start()
|
||||
process.join()
|
||||
|
||||
exitcode = process.exitcode
|
||||
|
||||
if exitcode == 0:
|
||||
sys.exit(exitcode)
|
||||
|
||||
# For Qt runtime. Not used
|
||||
_app = Application(sys.argv)
|
||||
|
||||
if exitcode == Application.ErrorCode.PlatformNotSupported:
|
||||
messageBox = MessageBox(icon=MessageBox.Icon.Critical)
|
||||
|
||||
messageBox.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg'))
|
||||
messageBox.setWindowTitle(_(APPLICATION_NAME))
|
||||
messageBox.setText(
|
||||
_(f'{APPLICATION_NAME} is not be able to run on this operating system.')
|
||||
)
|
||||
|
||||
if hasattr(os, 'uname'):
|
||||
messageBox.setInformativeText(
|
||||
f'{_("Operating system information:")}\n'
|
||||
+ '\n'.join(
|
||||
list(
|
||||
f'{arg}: {val}'
|
||||
for arg, val in zip(
|
||||
(
|
||||
'sysname',
|
||||
'nodename',
|
||||
'release',
|
||||
'version',
|
||||
'machine',
|
||||
),
|
||||
os.uname(),
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Show the MessageBox and wait for user to close it
|
||||
messageBox.exec()
|
||||
else:
|
||||
if exitcode == Application.ErrorCode.AssertionError:
|
||||
# Assertion error
|
||||
text = _(
|
||||
f'{APPLICATION_NAME} encountered an internal error and needs to be stopped.'
|
||||
)
|
||||
else:
|
||||
# Unknown exception
|
||||
text = _(
|
||||
f'{APPLICATION_NAME} stopped unexpectedly due to an unknown exception.'
|
||||
)
|
||||
|
||||
messageBox = MessageBox(icon=MessageBox.Icon.Critical)
|
||||
|
||||
messageBox.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg'))
|
||||
messageBox.setWindowTitle(_(APPLICATION_NAME))
|
||||
messageBox.setText(text)
|
||||
|
||||
if process.fileWritten.value:
|
||||
# Crash log saved
|
||||
|
||||
crashLogFile = str(CRASH_LOG_DIR / process.logFileName)
|
||||
|
||||
messageBox.setInformativeText(
|
||||
_('Crash log has been saved to: ') + f'{crashLogFile}'
|
||||
)
|
||||
messageBox.addButton(
|
||||
_('Open crash log'), MessageBox.ButtonRole.AcceptRole
|
||||
)
|
||||
messageBox.addButton(_('OK'), MessageBox.ButtonRole.RejectRole)
|
||||
|
||||
# Show the MessageBox and wait for user to close it
|
||||
choice = messageBox.exec()
|
||||
|
||||
if choice == MessageBox.ButtonRole.AcceptRole.value:
|
||||
# Open
|
||||
if QDesktopServices.openUrl(
|
||||
QtCore.QUrl.fromLocalFile(crashLogFile)
|
||||
):
|
||||
logger.info(f'open crash log {crashLogFile} success')
|
||||
else:
|
||||
logger.error(f'open crash log {crashLogFile} failed')
|
||||
else:
|
||||
# OK. Do nothing
|
||||
pass
|
||||
else:
|
||||
# Crash log not saved
|
||||
|
||||
# Show the MessageBox and wait for user to close it
|
||||
messageBox.exec()
|
||||
|
||||
sys.exit(exitcode)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
sys.exit(-1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-return-left" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M14.5 1.5a.5.5 0 0 1 .5.5v4.8a2.5 2.5 0 0 1-2.5 2.5H2.707l3.347 3.346a.5.5 0 0 1-.708.708l-4.2-4.2a.5.5 0 0 1 0-.708l4-4a.5.5 0 1 1 .708.708L2.707 8.3H12.5A1.5 1.5 0 0 0 14 6.8V2a.5.5 0 0 1 .5-.5z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 373 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-return-right" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M1.5 1.5A.5.5 0 0 0 1 2v4.8a2.5 2.5 0 0 0 2.5 2.5h9.793l-3.347 3.346a.5.5 0 0 0 .708.708l4.2-4.2a.5.5 0 0 0 0-.708l-4-4a.5.5 0 0 0-.708.708L13.293 8.3H3.5A1.5 1.5 0 0 1 2 6.8V2a.5.5 0 0 0-.5-.5z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 372 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-box-arrow-in-left" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M10 3.5a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5v9a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5v-2a.5.5 0 0 1 1 0v2A1.5 1.5 0 0 1 9.5 14h-8A1.5 1.5 0 0 1 0 12.5v-9A1.5 1.5 0 0 1 1.5 2h8A1.5 1.5 0 0 1 11 3.5v2a.5.5 0 0 1-1 0v-2z"/>
|
||||
<path fill-rule="evenodd" d="M4.146 8.354a.5.5 0 0 1 0-.708l3-3a.5.5 0 1 1 .708.708L5.707 7.5H14.5a.5.5 0 0 1 0 1H5.707l2.147 2.146a.5.5 0 0 1-.708.708l-3-3z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 552 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-files" viewBox="0 0 16 16">
|
||||
<path d="M13 0H6a2 2 0 0 0-2 2 2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2 2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm0 13V4a2 2 0 0 0-2-2H5a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1zM3 4a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-folder2-open" viewBox="0 0 16 16">
|
||||
<path d="M1 3.5A1.5 1.5 0 0 1 2.5 2h2.764c.958 0 1.76.56 2.311 1.184C7.985 3.648 8.48 4 9 4h4.5A1.5 1.5 0 0 1 15 5.5v.64c.57.265.94.876.856 1.546l-.64 5.124A2.5 2.5 0 0 1 12.733 15H3.266a2.5 2.5 0 0 1-2.481-2.19l-.64-5.124A1.5 1.5 0 0 1 1 6.14V3.5zM2 6h12v-.5a.5.5 0 0 0-.5-.5H9c-.964 0-1.71-.629-2.174-1.154C6.374 3.334 5.82 3 5.264 3H2.5a.5.5 0 0 0-.5.5V6zm-.367 1a.5.5 0 0 0-.496.562l.64 5.124A1.5 1.5 0 0 0 3.266 14h9.468a1.5 1.5 0 0 0 1.489-1.314l.64-5.124A.5.5 0 0 0 14.367 7H1.633z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 631 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-gear-wide-connected" viewBox="0 0 16 16">
|
||||
<path d="M7.068.727c.243-.97 1.62-.97 1.864 0l.071.286a.96.96 0 0 0 1.622.434l.205-.211c.695-.719 1.888-.03 1.613.931l-.08.284a.96.96 0 0 0 1.187 1.187l.283-.081c.96-.275 1.65.918.931 1.613l-.211.205a.96.96 0 0 0 .434 1.622l.286.071c.97.243.97 1.62 0 1.864l-.286.071a.96.96 0 0 0-.434 1.622l.211.205c.719.695.03 1.888-.931 1.613l-.284-.08a.96.96 0 0 0-1.187 1.187l.081.283c.275.96-.918 1.65-1.613.931l-.205-.211a.96.96 0 0 0-1.622.434l-.071.286c-.243.97-1.62.97-1.864 0l-.071-.286a.96.96 0 0 0-1.622-.434l-.205.211c-.695.719-1.888.03-1.613-.931l.08-.284a.96.96 0 0 0-1.186-1.187l-.284.081c-.96.275-1.65-.918-.931-1.613l.211-.205a.96.96 0 0 0-.434-1.622l-.286-.071c-.97-.243-.97-1.62 0-1.864l.286-.071a.96.96 0 0 0 .434-1.622l-.211-.205c-.719-.695-.03-1.888.931-1.613l.284.08a.96.96 0 0 0 1.187-1.186l-.081-.284c-.275-.96.918-1.65 1.613-.931l.205.211a.96.96 0 0 0 1.622-.434l.071-.286zM12.973 8.5H8.25l-2.834 3.779A4.998 4.998 0 0 0 12.973 8.5zm0-1a4.998 4.998 0 0 0-7.557-3.779l2.834 3.78h4.723zM5.048 3.967c-.03.021-.058.043-.087.065l.087-.065zm-.431.355A4.984 4.984 0 0 0 3.002 8c0 1.455.622 2.765 1.615 3.678L7.375 8 4.617 4.322zm.344 7.646.087.065-.087-.065z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-globe2" viewBox="0 0 16 16">
|
||||
<path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm7.5-6.923c-.67.204-1.335.82-1.887 1.855-.143.268-.276.56-.395.872.705.157 1.472.257 2.282.287V1.077zM4.249 3.539c.142-.384.304-.744.481-1.078a6.7 6.7 0 0 1 .597-.933A7.01 7.01 0 0 0 3.051 3.05c.362.184.763.349 1.198.49zM3.509 7.5c.036-1.07.188-2.087.436-3.008a9.124 9.124 0 0 1-1.565-.667A6.964 6.964 0 0 0 1.018 7.5h2.49zm1.4-2.741a12.344 12.344 0 0 0-.4 2.741H7.5V5.091c-.91-.03-1.783-.145-2.591-.332zM8.5 5.09V7.5h2.99a12.342 12.342 0 0 0-.399-2.741c-.808.187-1.681.301-2.591.332zM4.51 8.5c.035.987.176 1.914.399 2.741A13.612 13.612 0 0 1 7.5 10.91V8.5H4.51zm3.99 0v2.409c.91.03 1.783.145 2.591.332.223-.827.364-1.754.4-2.741H8.5zm-3.282 3.696c.12.312.252.604.395.872.552 1.035 1.218 1.65 1.887 1.855V11.91c-.81.03-1.577.13-2.282.287zm.11 2.276a6.696 6.696 0 0 1-.598-.933 8.853 8.853 0 0 1-.481-1.079 8.38 8.38 0 0 0-1.198.49 7.01 7.01 0 0 0 2.276 1.522zm-1.383-2.964A13.36 13.36 0 0 1 3.508 8.5h-2.49a6.963 6.963 0 0 0 1.362 3.675c.47-.258.995-.482 1.565-.667zm6.728 2.964a7.009 7.009 0 0 0 2.275-1.521 8.376 8.376 0 0 0-1.197-.49 8.853 8.853 0 0 1-.481 1.078 6.688 6.688 0 0 1-.597.933zM8.5 11.909v3.014c.67-.204 1.335-.82 1.887-1.855.143-.268.276-.56.395-.872A12.63 12.63 0 0 0 8.5 11.91zm3.555-.401c.57.185 1.095.409 1.565.667A6.963 6.963 0 0 0 14.982 8.5h-2.49a13.36 13.36 0 0 1-.437 3.008zM14.982 7.5a6.963 6.963 0 0 0-1.362-3.675c-.47.258-.995.482-1.565.667.248.92.4 1.938.437 3.008h2.49zM11.27 2.461c.177.334.339.694.482 1.078a8.368 8.368 0 0 0 1.196-.49 7.01 7.01 0 0 0-2.275-1.52c.218.283.418.597.597.932zm-.488 1.343a7.765 7.765 0 0 0-.395-.872C9.835 1.897 9.17 1.282 8.5 1.077V4.09c.81-.03 1.577-.13 2.282-.287z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-lightning-charge" viewBox="0 0 16 16">
|
||||
<path d="M11.251.068a.5.5 0 0 1 .227.58L9.677 6.5H13a.5.5 0 0 1 .364.843l-8 8.5a.5.5 0 0 1-.842-.49L6.323 9.5H3a.5.5 0 0 1-.364-.843l8-8.5a.5.5 0 0 1 .615-.09zM4.157 8.5H7a.5.5 0 0 1 .478.647L6.11 13.59l5.732-6.09H9a.5.5 0 0 1-.478-.647L9.89 2.41 4.157 8.5z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 404 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-lock-fill" viewBox="0 0 16 16">
|
||||
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 273 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-pencil-square" viewBox="0 0 16 16">
|
||||
<path d="M15.502 1.94a.5.5 0 0 1 0 .706L14.459 3.69l-2-2L13.502.646a.5.5 0 0 1 .707 0l1.293 1.293zm-1.75 2.456-2-2L4.939 9.21a.5.5 0 0 0-.121.196l-.805 2.414a.25.25 0 0 0 .316.316l2.414-.805a.5.5 0 0 0 .196-.12l6.813-6.814z"/>
|
||||
<path fill-rule="evenodd" d="M1 13.5A1.5 1.5 0 0 0 2.5 15h11a1.5 1.5 0 0 0 1.5-1.5v-6a.5.5 0 0 0-1 0v6a.5.5 0 0 1-.5.5h-11a.5.5 0 0 1-.5-.5v-11a.5.5 0 0 1 .5-.5H9a.5.5 0 0 0 0-1H2.5A1.5 1.5 0 0 0 1 2.5v11z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 578 B |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-rocket-takeoff" viewBox="0 0 16 16">
|
||||
<defs>
|
||||
<linearGradient id="grad1" x1="0%" y1="100%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:rgb(255,0,0);stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:rgb(255,154,153);stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g fill="url(#grad1)">
|
||||
<path
|
||||
d="M9.752 6.193c.599.6 1.73.437 2.528-.362.798-.799.96-1.932.362-2.531-.599-.6-1.73-.438-2.528.361-.798.8-.96 1.933-.362 2.532Z" />
|
||||
<path
|
||||
d="M15.811 3.312c-.363 1.534-1.334 3.626-3.64 6.218l-.24 2.408a2.56 2.56 0 0 1-.732 1.526L8.817 15.85a.51.51 0 0 1-.867-.434l.27-1.899c.04-.28-.013-.593-.131-.956a9.42 9.42 0 0 0-.249-.657l-.082-.202c-.815-.197-1.578-.662-2.191-1.277-.614-.615-1.079-1.379-1.275-2.195l-.203-.083a9.556 9.556 0 0 0-.655-.248c-.363-.119-.675-.172-.955-.132l-1.896.27A.51.51 0 0 1 .15 7.17l2.382-2.386c.41-.41.947-.67 1.524-.734h.006l2.4-.238C9.005 1.55 11.087.582 12.623.208c.89-.217 1.59-.232 2.08-.188.244.023.435.06.57.093.067.017.12.033.16.045.184.06.279.13.351.295l.029.073a3.475 3.475 0 0 1 .157.721c.055.485.051 1.178-.159 2.065Zm-4.828 7.475.04-.04-.107 1.081a1.536 1.536 0 0 1-.44.913l-1.298 1.3.054-.38c.072-.506-.034-.993-.172-1.418a8.548 8.548 0 0 0-.164-.45c.738-.065 1.462-.38 2.087-1.006ZM5.205 5c-.625.626-.94 1.351-1.004 2.09a8.497 8.497 0 0 0-.45-.164c-.424-.138-.91-.244-1.416-.172l-.38.054 1.3-1.3c.245-.246.566-.401.91-.44l1.08-.107-.04.039Zm9.406-3.961c-.38-.034-.967-.027-1.746.163-1.558.38-3.917 1.496-6.937 4.521-.62.62-.799 1.34-.687 2.051.107.676.483 1.362 1.048 1.928.564.565 1.25.941 1.924 1.049.71.112 1.429-.067 2.048-.688 3.079-3.083 4.192-5.444 4.556-6.987.183-.771.18-1.345.138-1.713a2.835 2.835 0 0 0-.045-.283 3.078 3.078 0 0 0-.3-.041Z" />
|
||||
<path
|
||||
d="M7.009 12.139a7.632 7.632 0 0 1-1.804-1.352A7.568 7.568 0 0 1 3.794 8.86c-1.102.992-1.965 5.054-1.839 5.18.125.126 3.936-.896 5.054-1.902Z" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-rocket-takeoff" viewBox="0 0 16 16">
|
||||
<defs>
|
||||
<linearGradient id="grad1" x1="0%" y1="100%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:rgb(255,0,0);stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:rgb(255,255,255);stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g fill="url(#grad1)">
|
||||
<path
|
||||
d="M9.752 6.193c.599.6 1.73.437 2.528-.362.798-.799.96-1.932.362-2.531-.599-.6-1.73-.438-2.528.361-.798.8-.96 1.933-.362 2.532Z" />
|
||||
<path
|
||||
d="M15.811 3.312c-.363 1.534-1.334 3.626-3.64 6.218l-.24 2.408a2.56 2.56 0 0 1-.732 1.526L8.817 15.85a.51.51 0 0 1-.867-.434l.27-1.899c.04-.28-.013-.593-.131-.956a9.42 9.42 0 0 0-.249-.657l-.082-.202c-.815-.197-1.578-.662-2.191-1.277-.614-.615-1.079-1.379-1.275-2.195l-.203-.083a9.556 9.556 0 0 0-.655-.248c-.363-.119-.675-.172-.955-.132l-1.896.27A.51.51 0 0 1 .15 7.17l2.382-2.386c.41-.41.947-.67 1.524-.734h.006l2.4-.238C9.005 1.55 11.087.582 12.623.208c.89-.217 1.59-.232 2.08-.188.244.023.435.06.57.093.067.017.12.033.16.045.184.06.279.13.351.295l.029.073a3.475 3.475 0 0 1 .157.721c.055.485.051 1.178-.159 2.065Zm-4.828 7.475.04-.04-.107 1.081a1.536 1.536 0 0 1-.44.913l-1.298 1.3.054-.38c.072-.506-.034-.993-.172-1.418a8.548 8.548 0 0 0-.164-.45c.738-.065 1.462-.38 2.087-1.006ZM5.205 5c-.625.626-.94 1.351-1.004 2.09a8.497 8.497 0 0 0-.45-.164c-.424-.138-.91-.244-1.416-.172l-.38.054 1.3-1.3c.245-.246.566-.401.91-.44l1.08-.107-.04.039Zm9.406-3.961c-.38-.034-.967-.027-1.746.163-1.558.38-3.917 1.496-6.937 4.521-.62.62-.799 1.34-.687 2.051.107.676.483 1.362 1.048 1.928.564.565 1.25.941 1.924 1.049.71.112 1.429-.067 2.048-.688 3.079-3.083 4.192-5.444 4.556-6.987.183-.771.18-1.345.138-1.713a2.835 2.835 0 0 0-.045-.283 3.078 3.078 0 0 0-.3-.041Z" />
|
||||
<path
|
||||
d="M7.009 12.139a7.632 7.632 0 0 1-1.804-1.352A7.568 7.568 0 0 1 3.794 8.86c-1.102.992-1.965 5.054-1.839 5.18.125.126 3.936-.896 5.054-1.902Z" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-rocket-takeoff" viewBox="0 0 16 16">
|
||||
<defs>
|
||||
<linearGradient id="grad1" x1="0%" y1="100%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:rgb(0,138,225);stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:rgb(135,206,250);stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g fill="url(#grad1)">
|
||||
<path
|
||||
d="M9.752 6.193c.599.6 1.73.437 2.528-.362.798-.799.96-1.932.362-2.531-.599-.6-1.73-.438-2.528.361-.798.8-.96 1.933-.362 2.532Z" />
|
||||
<path
|
||||
d="M15.811 3.312c-.363 1.534-1.334 3.626-3.64 6.218l-.24 2.408a2.56 2.56 0 0 1-.732 1.526L8.817 15.85a.51.51 0 0 1-.867-.434l.27-1.899c.04-.28-.013-.593-.131-.956a9.42 9.42 0 0 0-.249-.657l-.082-.202c-.815-.197-1.578-.662-2.191-1.277-.614-.615-1.079-1.379-1.275-2.195l-.203-.083a9.556 9.556 0 0 0-.655-.248c-.363-.119-.675-.172-.955-.132l-1.896.27A.51.51 0 0 1 .15 7.17l2.382-2.386c.41-.41.947-.67 1.524-.734h.006l2.4-.238C9.005 1.55 11.087.582 12.623.208c.89-.217 1.59-.232 2.08-.188.244.023.435.06.57.093.067.017.12.033.16.045.184.06.279.13.351.295l.029.073a3.475 3.475 0 0 1 .157.721c.055.485.051 1.178-.159 2.065Zm-4.828 7.475.04-.04-.107 1.081a1.536 1.536 0 0 1-.44.913l-1.298 1.3.054-.38c.072-.506-.034-.993-.172-1.418a8.548 8.548 0 0 0-.164-.45c.738-.065 1.462-.38 2.087-1.006ZM5.205 5c-.625.626-.94 1.351-1.004 2.09a8.497 8.497 0 0 0-.45-.164c-.424-.138-.91-.244-1.416-.172l-.38.054 1.3-1.3c.245-.246.566-.401.91-.44l1.08-.107-.04.039Zm9.406-3.961c-.38-.034-.967-.027-1.746.163-1.558.38-3.917 1.496-6.937 4.521-.62.62-.799 1.34-.687 2.051.107.676.483 1.362 1.048 1.928.564.565 1.25.941 1.924 1.049.71.112 1.429-.067 2.048-.688 3.079-3.083 4.192-5.444 4.556-6.987.183-.771.18-1.345.138-1.713a2.835 2.835 0 0 0-.045-.283 3.078 3.078 0 0 0-.3-.041Z" />
|
||||
<path
|
||||
d="M7.009 12.139a7.632 7.632 0 0 1-1.804-1.352A7.568 7.568 0 0 1 3.794 8.86c-1.102.992-1.965 5.054-1.839 5.18.125.126 3.936-.896 5.054-1.902Z" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-rocket-takeoff" viewBox="0 0 16 16">
|
||||
<defs>
|
||||
<linearGradient id="grad1" x1="0%" y1="100%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:rgb(0,138,225);stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:rgb(135,206,250);stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g fill="url(#grad1)">
|
||||
<path
|
||||
d="M9.752 6.193c.599.6 1.73.437 2.528-.362.798-.799.96-1.932.362-2.531-.599-.6-1.73-.438-2.528.361-.798.8-.96 1.933-.362 2.532Z" />
|
||||
<path
|
||||
d="M15.811 3.312c-.363 1.534-1.334 3.626-3.64 6.218l-.24 2.408a2.56 2.56 0 0 1-.732 1.526L8.817 15.85a.51.51 0 0 1-.867-.434l.27-1.899c.04-.28-.013-.593-.131-.956a9.42 9.42 0 0 0-.249-.657l-.082-.202c-.815-.197-1.578-.662-2.191-1.277-.614-.615-1.079-1.379-1.275-2.195l-.203-.083a9.556 9.556 0 0 0-.655-.248c-.363-.119-.675-.172-.955-.132l-1.896.27A.51.51 0 0 1 .15 7.17l2.382-2.386c.41-.41.947-.67 1.524-.734h.006l2.4-.238C9.005 1.55 11.087.582 12.623.208c.89-.217 1.59-.232 2.08-.188.244.023.435.06.57.093.067.017.12.033.16.045.184.06.279.13.351.295l.029.073a3.475 3.475 0 0 1 .157.721c.055.485.051 1.178-.159 2.065Zm-4.828 7.475.04-.04-.107 1.081a1.536 1.536 0 0 1-.44.913l-1.298 1.3.054-.38c.072-.506-.034-.993-.172-1.418a8.548 8.548 0 0 0-.164-.45c.738-.065 1.462-.38 2.087-1.006ZM5.205 5c-.625.626-.94 1.351-1.004 2.09a8.497 8.497 0 0 0-.45-.164c-.424-.138-.91-.244-1.416-.172l-.38.054 1.3-1.3c.245-.246.566-.401.91-.44l1.08-.107-.04.039Zm9.406-3.961c-.38-.034-.967-.027-1.746.163-1.558.38-3.917 1.496-6.937 4.521-.62.62-.799 1.34-.687 2.051.107.676.483 1.362 1.048 1.928.564.565 1.25.941 1.924 1.049.71.112 1.429-.067 2.048-.688 3.079-3.083 4.192-5.444 4.556-6.987.183-.771.18-1.345.138-1.713a2.835 2.835 0 0 0-.045-.283 3.078 3.078 0 0 0-.3-.041Z" />
|
||||
<path
|
||||
d="M7.009 12.139a7.632 7.632 0 0 1-1.804-1.352A7.568 7.568 0 0 1 3.794 8.86c-1.102.992-1.965 5.054-1.839 5.18.125.126 3.936-.896 5.054-1.902Z" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||
class="bi bi-rocket-takeoff" viewBox="0 0 16 16">
|
||||
<defs>
|
||||
<linearGradient id="grad1" x1="0%" y1="100%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:rgb(0,138,225);stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:rgb(255,255,255);stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g fill="url(#grad1)">
|
||||
<path
|
||||
d="M9.752 6.193c.599.6 1.73.437 2.528-.362.798-.799.96-1.932.362-2.531-.599-.6-1.73-.438-2.528.361-.798.8-.96 1.933-.362 2.532Z" />
|
||||
<path
|
||||
d="M15.811 3.312c-.363 1.534-1.334 3.626-3.64 6.218l-.24 2.408a2.56 2.56 0 0 1-.732 1.526L8.817 15.85a.51.51 0 0 1-.867-.434l.27-1.899c.04-.28-.013-.593-.131-.956a9.42 9.42 0 0 0-.249-.657l-.082-.202c-.815-.197-1.578-.662-2.191-1.277-.614-.615-1.079-1.379-1.275-2.195l-.203-.083a9.556 9.556 0 0 0-.655-.248c-.363-.119-.675-.172-.955-.132l-1.896.27A.51.51 0 0 1 .15 7.17l2.382-2.386c.41-.41.947-.67 1.524-.734h.006l2.4-.238C9.005 1.55 11.087.582 12.623.208c.89-.217 1.59-.232 2.08-.188.244.023.435.06.57.093.067.017.12.033.16.045.184.06.279.13.351.295l.029.073a3.475 3.475 0 0 1 .157.721c.055.485.051 1.178-.159 2.065Zm-4.828 7.475.04-.04-.107 1.081a1.536 1.536 0 0 1-.44.913l-1.298 1.3.054-.38c.072-.506-.034-.993-.172-1.418a8.548 8.548 0 0 0-.164-.45c.738-.065 1.462-.38 2.087-1.006ZM5.205 5c-.625.626-.94 1.351-1.004 2.09a8.497 8.497 0 0 0-.45-.164c-.424-.138-.91-.244-1.416-.172l-.38.054 1.3-1.3c.245-.246.566-.401.91-.44l1.08-.107-.04.039Zm9.406-3.961c-.38-.034-.967-.027-1.746.163-1.558.38-3.917 1.496-6.937 4.521-.62.62-.799 1.34-.687 2.051.107.676.483 1.362 1.048 1.928.564.565 1.25.941 1.924 1.049.71.112 1.429-.067 2.048-.688 3.079-3.083 4.192-5.444 4.556-6.987.183-.771.18-1.345.138-1.713a2.835 2.835 0 0 0-.045-.283 3.078 3.078 0 0 0-.3-.041Z" />
|
||||
<path
|
||||
d="M7.009 12.139a7.632 7.632 0 0 1-1.804-1.352A7.568 7.568 0 0 1 3.794 8.86c-1.102.992-1.965 5.054-1.839 5.18.125.126 3.936-.896 5.054-1.902Z" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-save" viewBox="0 0 16 16">
|
||||
<path d="M2 1a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H9.5a1 1 0 0 0-1 1v7.293l2.646-2.647a.5.5 0 0 1 .708.708l-3.5 3.5a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L7.5 9.293V2a2 2 0 0 1 2-2H14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h2.5a.5.5 0 0 1 0 1H2z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 439 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-scissors" viewBox="0 0 16 16">
|
||||
<path d="M3.5 3.5c-.614-.884-.074-1.962.858-2.5L8 7.226 11.642 1c.932.538 1.472 1.616.858 2.5L8.81 8.61l1.556 2.661a2.5 2.5 0 1 1-.794.637L8 9.73l-1.572 2.177a2.5 2.5 0 1 1-.794-.637L7.19 8.61 3.5 3.5zm2.5 10a1.5 1.5 0 1 0-3 0 1.5 1.5 0 0 0 3 0zm7 0a1.5 1.5 0 1 0-3 0 1.5 1.5 0 0 0 3 0z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 425 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-shuffle" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M0 3.5A.5.5 0 0 1 .5 3H1c2.202 0 3.827 1.24 4.874 2.418.49.552.865 1.102 1.126 1.532.26-.43.636-.98 1.126-1.532C9.173 4.24 10.798 3 13 3v1c-1.798 0-3.173 1.01-4.126 2.082A9.624 9.624 0 0 0 7.556 8a9.624 9.624 0 0 0 1.317 1.918C9.828 10.99 11.204 12 13 12v1c-2.202 0-3.827-1.24-4.874-2.418A10.595 10.595 0 0 1 7 9.05c-.26.43-.636.98-1.126 1.532C4.827 11.76 3.202 13 1 13H.5a.5.5 0 0 1 0-1H1c1.798 0 3.173-1.01 4.126-2.082A9.624 9.624 0 0 0 6.444 8a9.624 9.624 0 0 0-1.317-1.918C4.172 5.01 2.796 4 1 4H.5a.5.5 0 0 1-.5-.5z"/>
|
||||
<path d="M13 5.466V1.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384l-2.36 1.966a.25.25 0 0 1-.41-.192zm0 9v-3.932a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384l-2.36 1.966a.25.25 0 0 1-.41-.192z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 904 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-unlock-fill" viewBox="0 0 16 16">
|
||||
<path d="M11 1a2 2 0 0 0-2 2v4a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h5V3a3 3 0 0 1 6 0v4a.5.5 0 0 1-1 0V3a2 2 0 0 0-2-2z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 286 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-return-left" viewBox="0 0 16 16">
|
||||
<path fill="white" fill-rule="evenodd" d="M14.5 1.5a.5.5 0 0 1 .5.5v4.8a2.5 2.5 0 0 1-2.5 2.5H2.707l3.347 3.346a.5.5 0 0 1-.708.708l-4.2-4.2a.5.5 0 0 1 0-.708l4-4a.5.5 0 1 1 .708.708L2.707 8.3H12.5A1.5 1.5 0 0 0 14 6.8V2a.5.5 0 0 1 .5-.5z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 386 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-return-right" viewBox="0 0 16 16">
|
||||
<path fill="white" fill-rule="evenodd" d="M1.5 1.5A.5.5 0 0 0 1 2v4.8a2.5 2.5 0 0 0 2.5 2.5h9.793l-3.347 3.346a.5.5 0 0 0 .708.708l4.2-4.2a.5.5 0 0 0 0-.708l-4-4a.5.5 0 0 0-.708.708L13.293 8.3H3.5A1.5 1.5 0 0 1 2 6.8V2a.5.5 0 0 0-.5-.5z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-box-arrow-in-left" viewBox="0 0 16 16">
|
||||
<path fill="white" fill-rule="evenodd" d="M10 3.5a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5v9a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5v-2a.5.5 0 0 1 1 0v2A1.5 1.5 0 0 1 9.5 14h-8A1.5 1.5 0 0 1 0 12.5v-9A1.5 1.5 0 0 1 1.5 2h8A1.5 1.5 0 0 1 11 3.5v2a.5.5 0 0 1-1 0v-2z"/>
|
||||
<path fill="white" fill-rule="evenodd" d="M4.146 8.354a.5.5 0 0 1 0-.708l3-3a.5.5 0 1 1 .708.708L5.707 7.5H14.5a.5.5 0 0 1 0 1H5.707l2.147 2.146a.5.5 0 0 1-.708.708l-3-3z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 578 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-files" viewBox="0 0 16 16">
|
||||
<path fill="white" d="M13 0H6a2 2 0 0 0-2 2 2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2 2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm0 13V4a2 2 0 0 0-2-2H5a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1zM3 4a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 398 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-folder2-open" viewBox="0 0 16 16">
|
||||
<path fill="white" d="M1 3.5A1.5 1.5 0 0 1 2.5 2h2.764c.958 0 1.76.56 2.311 1.184C7.985 3.648 8.48 4 9 4h4.5A1.5 1.5 0 0 1 15 5.5v.64c.57.265.94.876.856 1.546l-.64 5.124A2.5 2.5 0 0 1 12.733 15H3.266a2.5 2.5 0 0 1-2.481-2.19l-.64-5.124A1.5 1.5 0 0 1 1 6.14V3.5zM2 6h12v-.5a.5.5 0 0 0-.5-.5H9c-.964 0-1.71-.629-2.174-1.154C6.374 3.334 5.82 3 5.264 3H2.5a.5.5 0 0 0-.5.5V6zm-.367 1a.5.5 0 0 0-.496.562l.64 5.124A1.5 1.5 0 0 0 3.266 14h9.468a1.5 1.5 0 0 0 1.489-1.314l.64-5.124A.5.5 0 0 0 14.367 7H1.633z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 644 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-gear-wide-connected" viewBox="0 0 16 16">
|
||||
<path fill="white" d="M7.068.727c.243-.97 1.62-.97 1.864 0l.071.286a.96.96 0 0 0 1.622.434l.205-.211c.695-.719 1.888-.03 1.613.931l-.08.284a.96.96 0 0 0 1.187 1.187l.283-.081c.96-.275 1.65.918.931 1.613l-.211.205a.96.96 0 0 0 .434 1.622l.286.071c.97.243.97 1.62 0 1.864l-.286.071a.96.96 0 0 0-.434 1.622l.211.205c.719.695.03 1.888-.931 1.613l-.284-.08a.96.96 0 0 0-1.187 1.187l.081.283c.275.96-.918 1.65-1.613.931l-.205-.211a.96.96 0 0 0-1.622.434l-.071.286c-.243.97-1.62.97-1.864 0l-.071-.286a.96.96 0 0 0-1.622-.434l-.205.211c-.695.719-1.888.03-1.613-.931l.08-.284a.96.96 0 0 0-1.186-1.187l-.284.081c-.96.275-1.65-.918-.931-1.613l.211-.205a.96.96 0 0 0-.434-1.622l-.286-.071c-.97-.243-.97-1.62 0-1.864l.286-.071a.96.96 0 0 0 .434-1.622l-.211-.205c-.719-.695-.03-1.888.931-1.613l.284.08a.96.96 0 0 0 1.187-1.186l-.081-.284c-.275-.96.918-1.65 1.613-.931l.205.211a.96.96 0 0 0 1.622-.434l.071-.286zM12.973 8.5H8.25l-2.834 3.779A4.998 4.998 0 0 0 12.973 8.5zm0-1a4.998 4.998 0 0 0-7.557-3.779l2.834 3.78h4.723zM5.048 3.967c-.03.021-.058.043-.087.065l.087-.065zm-.431.355A4.984 4.984 0 0 0 3.002 8c0 1.455.622 2.765 1.615 3.678L7.375 8 4.617 4.322zm.344 7.646.087.065-.087-.065z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-globe2" viewBox="0 0 16 16">
|
||||
<path fill="white" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm7.5-6.923c-.67.204-1.335.82-1.887 1.855-.143.268-.276.56-.395.872.705.157 1.472.257 2.282.287V1.077zM4.249 3.539c.142-.384.304-.744.481-1.078a6.7 6.7 0 0 1 .597-.933A7.01 7.01 0 0 0 3.051 3.05c.362.184.763.349 1.198.49zM3.509 7.5c.036-1.07.188-2.087.436-3.008a9.124 9.124 0 0 1-1.565-.667A6.964 6.964 0 0 0 1.018 7.5h2.49zm1.4-2.741a12.344 12.344 0 0 0-.4 2.741H7.5V5.091c-.91-.03-1.783-.145-2.591-.332zM8.5 5.09V7.5h2.99a12.342 12.342 0 0 0-.399-2.741c-.808.187-1.681.301-2.591.332zM4.51 8.5c.035.987.176 1.914.399 2.741A13.612 13.612 0 0 1 7.5 10.91V8.5H4.51zm3.99 0v2.409c.91.03 1.783.145 2.591.332.223-.827.364-1.754.4-2.741H8.5zm-3.282 3.696c.12.312.252.604.395.872.552 1.035 1.218 1.65 1.887 1.855V11.91c-.81.03-1.577.13-2.282.287zm.11 2.276a6.696 6.696 0 0 1-.598-.933 8.853 8.853 0 0 1-.481-1.079 8.38 8.38 0 0 0-1.198.49 7.01 7.01 0 0 0 2.276 1.522zm-1.383-2.964A13.36 13.36 0 0 1 3.508 8.5h-2.49a6.963 6.963 0 0 0 1.362 3.675c.47-.258.995-.482 1.565-.667zm6.728 2.964a7.009 7.009 0 0 0 2.275-1.521 8.376 8.376 0 0 0-1.197-.49 8.853 8.853 0 0 1-.481 1.078 6.688 6.688 0 0 1-.597.933zM8.5 11.909v3.014c.67-.204 1.335-.82 1.887-1.855.143-.268.276-.56.395-.872A12.63 12.63 0 0 0 8.5 11.91zm3.555-.401c.57.185 1.095.409 1.565.667A6.963 6.963 0 0 0 14.982 8.5h-2.49a13.36 13.36 0 0 1-.437 3.008zM14.982 7.5a6.963 6.963 0 0 0-1.362-3.675c-.47.258-.995.482-1.565.667.248.92.4 1.938.437 3.008h2.49zM11.27 2.461c.177.334.339.694.482 1.078a8.368 8.368 0 0 0 1.196-.49 7.01 7.01 0 0 0-2.275-1.52c.218.283.418.597.597.932zm-.488 1.343a7.765 7.765 0 0 0-.395-.872C9.835 1.897 9.17 1.282 8.5 1.077V4.09c.81-.03 1.577-.13 2.282-.287z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-lightning-charge" viewBox="0 0 16 16">
|
||||
<path fill="white" d="M11.251.068a.5.5 0 0 1 .227.58L9.677 6.5H13a.5.5 0 0 1 .364.843l-8 8.5a.5.5 0 0 1-.842-.49L6.323 9.5H3a.5.5 0 0 1-.364-.843l8-8.5a.5.5 0 0 1 .615-.09zM4.157 8.5H7a.5.5 0 0 1 .478.647L6.11 13.59l5.732-6.09H9a.5.5 0 0 1-.478-.647L9.89 2.41 4.157 8.5z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 417 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-lock-fill" viewBox="0 0 16 16">
|
||||
<path fill="white" d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 286 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-pencil-square" viewBox="0 0 16 16">
|
||||
<path fill="white" d="M15.502 1.94a.5.5 0 0 1 0 .706L14.459 3.69l-2-2L13.502.646a.5.5 0 0 1 .707 0l1.293 1.293zm-1.75 2.456-2-2L4.939 9.21a.5.5 0 0 0-.121.196l-.805 2.414a.25.25 0 0 0 .316.316l2.414-.805a.5.5 0 0 0 .196-.12l6.813-6.814z"/>
|
||||
<path fill="white" fill-rule="evenodd" d="M1 13.5A1.5 1.5 0 0 0 2.5 15h11a1.5 1.5 0 0 0 1.5-1.5v-6a.5.5 0 0 0-1 0v6a.5.5 0 0 1-.5.5h-11a.5.5 0 0 1-.5-.5v-11a.5.5 0 0 1 .5-.5H9a.5.5 0 0 0 0-1H2.5A1.5 1.5 0 0 0 1 2.5v11z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 604 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-save" viewBox="0 0 16 16">
|
||||
<path fill="white" d="M2 1a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H9.5a1 1 0 0 0-1 1v7.293l2.646-2.647a.5.5 0 0 1 .708.708l-3.5 3.5a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L7.5 9.293V2a2 2 0 0 1 2-2H14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h2.5a.5.5 0 0 1 0 1H2z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 452 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-scissors" viewBox="0 0 16 16">
|
||||
<path fill="white" d="M3.5 3.5c-.614-.884-.074-1.962.858-2.5L8 7.226 11.642 1c.932.538 1.472 1.616.858 2.5L8.81 8.61l1.556 2.661a2.5 2.5 0 1 1-.794.637L8 9.73l-1.572 2.177a2.5 2.5 0 1 1-.794-.637L7.19 8.61 3.5 3.5zm2.5 10a1.5 1.5 0 1 0-3 0 1.5 1.5 0 0 0 3 0zm7 0a1.5 1.5 0 1 0-3 0 1.5 1.5 0 0 0 3 0z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 438 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-shuffle" viewBox="0 0 16 16">
|
||||
<path fill="white" fill-rule="evenodd" d="M0 3.5A.5.5 0 0 1 .5 3H1c2.202 0 3.827 1.24 4.874 2.418.49.552.865 1.102 1.126 1.532.26-.43.636-.98 1.126-1.532C9.173 4.24 10.798 3 13 3v1c-1.798 0-3.173 1.01-4.126 2.082A9.624 9.624 0 0 0 7.556 8a9.624 9.624 0 0 0 1.317 1.918C9.828 10.99 11.204 12 13 12v1c-2.202 0-3.827-1.24-4.874-2.418A10.595 10.595 0 0 1 7 9.05c-.26.43-.636.98-1.126 1.532C4.827 11.76 3.202 13 1 13H.5a.5.5 0 0 1 0-1H1c1.798 0 3.173-1.01 4.126-2.082A9.624 9.624 0 0 0 6.444 8a9.624 9.624 0 0 0-1.317-1.918C4.172 5.01 2.796 4 1 4H.5a.5.5 0 0 1-.5-.5z"/>
|
||||
<path fill="white" d="M13 5.466V1.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384l-2.36 1.966a.25.25 0 0 1-.41-.192zm0 9v-3.932a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384l-2.36 1.966a.25.25 0 0 1-.41-.192z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 930 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-unlock-fill" viewBox="0 0 16 16">
|
||||
<path fill="white" d="M11 1a2 2 0 0 0-2 2v4a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h5V3a3 3 0 0 1 6 0v4a.5.5 0 0 1-1 0V3a2 2 0 0 0-2-2z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 299 B |
|
After Width: | Height: | Size: 378 KiB |
@@ -0,0 +1,104 @@
|
||||
# Furious
|
||||
|
||||
A cross-platform GUI client that launches your beloved GFW to outer space.
|
||||
Support [Xray-core](https://github.com/XTLS/Xray-core)
|
||||
and [hysteria](https://github.com/apernet/hysteria).
|
||||
|
||||
## Features
|
||||
|
||||
* Runs seamlessly on Windows, macOS and Linux.
|
||||
* Built-in support for [Xray-core](https://github.com/XTLS/Xray-core)
|
||||
and [hysteria](https://github.com/apernet/hysteria). Cores are actually Python bindings that shipped with the source
|
||||
code. See more information: [Xray-core-python](https://github.com/LorenEteval/Xray-core-python)
|
||||
and [hysteria-python](https://github.com/LorenEteval/hysteria-python).
|
||||
* Support import from JSON or share link(`vmess://...` or `vless://...`, including the newest REALITY share standard).
|
||||
* Support export to JSON, share link or QRCode.
|
||||
* Two built-in routing mode support: Bypass Mainland China(with Ads filter) and Global. You can also choose to use your
|
||||
own routing rules.
|
||||
* Built-in editor support.
|
||||
* Ability to store and handle hundreds of servers, or even more.
|
||||
* VPN-client user experience.
|
||||
* Support system theme detection and switch to dark/light theme automatically.
|
||||
* Multiple language support: English, Spanish, Simplified Chinese and Traditional Chinese.
|
||||
* Built-in user-friendly feature.
|
||||
* ...
|
||||
|
||||
## Sreenshot
|
||||
|
||||
### Windows
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### macOS
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### Ubuntu
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Install
|
||||
|
||||
> Note: Due to better binary files compatibility on Windows, if you are Windows user, you can skip this section and
|
||||
> download zip file in the release page that contains pre-built binaries. Otherwise you need to follow the instructions
|
||||
> below.
|
||||
|
||||
### Core Building Tools
|
||||
|
||||
> Note: These steps are the same in [Xray-core-python](https://github.com/LorenEteval/Xray-core-python)
|
||||
> or [hysteria-python](https://github.com/LorenEteval/hysteria-python) *Core Building Tools* steps.
|
||||
|
||||
As mentioned above, cores are shipped as Python bindings to support cross-platform running. So to install Furious you
|
||||
must have tools ready for building these bindings for your current platform first. Core building requires:
|
||||
|
||||
* [go](https://go.dev/doc/install) in your PATH. go 1.20.0 and above is recommended. To check go is ready,
|
||||
type `go version`. Also, if google service is blocked in your region(such as Mainland China), you have to configure
|
||||
your GOPROXY to be able to pull go packages. For Chinese users, refer to [goproxy.cn](https://goproxy.cn/) for more
|
||||
information.
|
||||
* [cmake](https://cmake.org/download/) in your PATH. To check cmake is ready, type `cmake --version`.
|
||||
* A working GNU C++ compiler(i.e. GNU C++ toolchains). To check GNU C++ compiler is ready, type `g++ --version`. These
|
||||
tools should have been installed in Linux or macOS by default. If you don't have GNU C++ toolchains(especially for
|
||||
Windows users) anyway:
|
||||
|
||||
* For Linux users: type `sudo apt update && sudo apt install g++` and that should work out fine.
|
||||
* For Windows users: install [MinGW-w64](https://sourceforge.net/projects/mingw-w64/files/mingw-w64/)
|
||||
or [Cygwin](https://www.cygwin.com/) and make sure you have add them to PATH.
|
||||
|
||||
### Install Furious
|
||||
|
||||
```
|
||||
pip install Furious-GUI
|
||||
```
|
||||
|
||||
If the installation is successful, you will have a executable script(or `.exe` on Windows) in your PATH(if it's not in
|
||||
the PATH, you can always add the script location to the PATH later). That's Furious's application entry point.
|
||||
|
||||
Any time you want to launch Furious, type:
|
||||
|
||||
```
|
||||
Furious
|
||||
```
|
||||
|
||||
Furious will enable startup on boot by default if it's launched for the first time. Happy browsing!
|
||||
|
||||
## Core Installation Script
|
||||
|
||||
Below are some one-click/automatic installation script that's been tested to work in Furious.
|
||||
|
||||
| Project Address | Supported Core Installation | Share Link Import Support? | JSON Import Support? |
|
||||
|-------------------------------------------------------------------|:-----------------------------:|:--------------------------:|:--------------------:|
|
||||
| [233boy/v2ray](https://github.com/233boy/v2ray) | v2ray-core | Yes | / |
|
||||
| [mack-a/v2ray-agent](https://github.com/mack-a/v2ray-agent) | v2ray-core/Xray-core/hysteria | Yes | Yes |
|
||||
| [zxcvos/Xray-script](https://github.com/zxcvos/Xray-script) | Xray-core | Yes | / |
|
||||
| [aleskxyz/reality-ezpz](https://github.com/aleskxyz/reality-ezpz) | Xray-core | Yes | / |
|
||||
| [emptysuns/Hi_Hysteria](https://github.com/emptysuns/Hi_Hysteria) | hysteria | No | Yes |
|
||||
|
||||
## License
|
||||
|
||||
License under [GPL v3.0](https://github.com/LorenEteval/Furious/blob/main/LICENSE)
|
||||
@@ -0,0 +1,40 @@
|
||||
<!DOCTYPE RCC>
|
||||
<RCC version="1.0">
|
||||
<qresource prefix="Icons/bootstrap">
|
||||
<file alias="arrow-return-left.svg">Icons/bootstrap/arrow-return-left.svg</file>
|
||||
<file alias="arrow-return-right.svg">Icons/bootstrap/arrow-return-right.svg</file>
|
||||
<file alias="box-arrow-in-left.svg">Icons/bootstrap/box-arrow-in-left.svg</file>
|
||||
<file alias="files.svg">Icons/bootstrap/files.svg</file>
|
||||
<file alias="folder2-open.svg">Icons/bootstrap/folder2-open.svg</file>
|
||||
<file alias="gear-wide-connected.svg">Icons/bootstrap/gear-wide-connected.svg</file>
|
||||
<file alias="globe2.svg">Icons/bootstrap/globe2.svg</file>
|
||||
<file alias="lightning-charge.svg">Icons/bootstrap/lightning-charge.svg</file>
|
||||
<file alias="lock-fill.svg">Icons/bootstrap/lock-fill.svg</file>
|
||||
<file alias="pencil-square.svg">Icons/bootstrap/pencil-square.svg</file>
|
||||
<file alias="rocket-takeoff.svg">Icons/bootstrap/rocket-takeoff.svg</file>
|
||||
<file alias="rocket-takeoff-connected.svg">Icons/bootstrap/rocket-takeoff-connected.svg</file>
|
||||
<file alias="rocket-takeoff-connected-dark.svg">Icons/bootstrap/rocket-takeoff-connected-dark.svg</file>
|
||||
<file alias="rocket-takeoff-dark.svg">Icons/bootstrap/rocket-takeoff-dark.svg</file>
|
||||
<file alias="rocket-takeoff-window.svg">Icons/bootstrap/rocket-takeoff-window.svg</file>
|
||||
<file alias="save.svg">Icons/bootstrap/save.svg</file>
|
||||
<file alias="scissors.svg">Icons/bootstrap/scissors.svg</file>
|
||||
<file alias="shuffle.svg">Icons/bootstrap/shuffle.svg</file>
|
||||
<file alias="unlock-fill.svg">Icons/bootstrap/unlock-fill.svg</file>
|
||||
</qresource>
|
||||
<qresource prefix="Icons/bootstrap/white">
|
||||
<file alias="arrow-return-left.svg">Icons/bootstrap/white/arrow-return-left.svg</file>
|
||||
<file alias="arrow-return-right.svg">Icons/bootstrap/white/arrow-return-right.svg</file>
|
||||
<file alias="box-arrow-in-left.svg">Icons/bootstrap/white/box-arrow-in-left.svg</file>
|
||||
<file alias="files.svg">Icons/bootstrap/white/files.svg</file>
|
||||
<file alias="folder2-open.svg">Icons/bootstrap/white/folder2-open.svg</file>
|
||||
<file alias="gear-wide-connected.svg">Icons/bootstrap/white/gear-wide-connected.svg</file>
|
||||
<file alias="globe2.svg">Icons/bootstrap/white/globe2.svg</file>
|
||||
<file alias="lightning-charge.svg">Icons/bootstrap/white/lightning-charge.svg</file>
|
||||
<file alias="lock-fill.svg">Icons/bootstrap/white/lock-fill.svg</file>
|
||||
<file alias="pencil-square.svg">Icons/bootstrap/white/pencil-square.svg</file>
|
||||
<file alias="save.svg">Icons/bootstrap/white/save.svg</file>
|
||||
<file alias="scissors.svg">Icons/bootstrap/white/scissors.svg</file>
|
||||
<file alias="shuffle.svg">Icons/bootstrap/white/shuffle.svg</file>
|
||||
<file alias="unlock-fill.svg">Icons/bootstrap/white/unlock-fill.svg</file>
|
||||
</qresource>
|
||||
</RCC>
|
||||
@@ -0,0 +1,9 @@
|
||||
PySide6-Essentials
|
||||
Xray-core
|
||||
hysteria
|
||||
ujson
|
||||
pybase64
|
||||
pyqrcode
|
||||
sysproxy; sys_platform == 'win32'
|
||||
darkdetect; sys_platform != 'darwin'
|
||||
darkdetect[macos-listener]; sys_platform == 'darwin'
|
||||
@@ -0,0 +1,64 @@
|
||||
from Furious.Version import __version__
|
||||
from Furious.Utility.Constants import PLATFORM
|
||||
|
||||
from setuptools import setup, find_packages
|
||||
|
||||
|
||||
def get_install_requires():
|
||||
requires = [
|
||||
'PySide6-Essentials',
|
||||
'Xray-core',
|
||||
'hysteria',
|
||||
'ujson',
|
||||
'pybase64',
|
||||
'pyqrcode',
|
||||
]
|
||||
|
||||
if PLATFORM == 'Windows':
|
||||
requires.append('sysproxy')
|
||||
|
||||
if PLATFORM == 'Darwin':
|
||||
requires.append('darkdetect[macos-listener]')
|
||||
else:
|
||||
requires.append('darkdetect')
|
||||
|
||||
return requires
|
||||
|
||||
|
||||
with open('README.md', 'r', encoding='utf-8') as file:
|
||||
long_description = file.read()
|
||||
|
||||
setup(
|
||||
name='Furious-GUI',
|
||||
version=__version__,
|
||||
license='GPL v3.0',
|
||||
description='A cross-platform GUI client that launches your beloved GFW to outer space.',
|
||||
long_description=long_description,
|
||||
long_description_content_type='text/markdown',
|
||||
author='Loren Eteval',
|
||||
author_email='loren.eteval@proton.me',
|
||||
url='https://github.com/LorenEteval/Furious',
|
||||
packages=find_packages(),
|
||||
package_data={'Furious': ['data/**']},
|
||||
include_package_data=True,
|
||||
install_requires=get_install_requires(),
|
||||
entry_points={
|
||||
'gui_scripts': [
|
||||
'Furious = Furious.__main__:main',
|
||||
],
|
||||
},
|
||||
classifiers=[
|
||||
'Development Status :: 5 - Production/Stable',
|
||||
'License :: OSI Approved :: GNU General Public License v3 (GPLv3)',
|
||||
'Intended Audience :: End Users/Desktop',
|
||||
'Programming Language :: Python :: 3.8',
|
||||
'Programming Language :: Python :: 3.9',
|
||||
'Programming Language :: Python :: 3.10',
|
||||
'Programming Language :: Python :: 3.11',
|
||||
'Programming Language :: Python :: Implementation :: CPython',
|
||||
'Programming Language :: Python :: Implementation :: PyPy',
|
||||
'Operating System :: OS Independent',
|
||||
'Topic :: Internet',
|
||||
'Topic :: Internet :: Proxy Servers',
|
||||
],
|
||||
)
|
||||