Add project files

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2023-07-24 19:49:17 +08:00
parent a90fe42ec4
commit e08021ac85
82 changed files with 63967 additions and 0 deletions
+162
View File
@@ -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
+669
View File
@@ -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")}')
+16
View File
@@ -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()
+16
View File
@@ -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()
+216
View File
@@ -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()
+401
View File
@@ -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,
)
+52
View File
@@ -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())
),
),
)
+50
View File
@@ -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
),
),
)
+63
View File
@@ -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,
)
+7
View File
@@ -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
+592
View File
@@ -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
+193
View File
@@ -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),
)
+111
View File
@@ -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 ''
View File
+153
View File
@@ -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__()
+8
View File
@@ -0,0 +1,8 @@
from PySide6.QtGui import QIcon
class Icon(QIcon):
def __init__(self, iconFileName):
super().__init__(iconFileName)
self.iconFileName = iconFileName
View File
+36
View File
@@ -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.*'
)
+85
View File
@@ -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())
+249
View File
@@ -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()
File diff suppressed because it is too large Load Diff
+75
View File
@@ -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}
+179
View File
@@ -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')
+70
View File
@@ -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'}}'
)
+746
View File
@@ -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)
+204
View File
@@ -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 ''
View File
+1
View File
@@ -0,0 +1 @@
__version__ = '0.1.0'
+303
View File
@@ -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
+59
View File
@@ -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()))
File diff suppressed because it is too large Load Diff
+55
View File
@@ -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'))
+62
View File
@@ -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()))
+213
View File
@@ -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()))
+87
View File
@@ -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
+140
View File
@@ -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)
View File
+1
View File
@@ -0,0 +1 @@
from .Utility import Resources
+126
View File
@@ -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()
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large Load Diff
+3
View File
@@ -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

+3
View File
@@ -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

+4
View File
@@ -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

+3
View File
@@ -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

+3
View File
@@ -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

+3
View File
@@ -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

+3
View File
@@ -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

+3
View File
@@ -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

+3
View File
@@ -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

+4
View File
@@ -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

+17
View File
@@ -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

+17
View File
@@ -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

+17
View File
@@ -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

+3
View File
@@ -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

+3
View File
@@ -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

+4
View File
@@ -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

+3
View File
@@ -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

+3
View File
@@ -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

+3
View File
@@ -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

+3
View File
@@ -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

+3
View File
@@ -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

+4
View File
@@ -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

+3
View File
@@ -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

+3
View File
@@ -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

+4
View File
@@ -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

+3
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

+104
View File
@@ -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
![Windows-Light-EN](https://raw.githubusercontent.com/LorenEteval/Furious/main/screenshot/Windows-Light-EN.png)
![Windows-Light-CN](https://raw.githubusercontent.com/LorenEteval/Furious/main/screenshot/Windows-Light-CN.png)
### macOS
![macOS-Light](https://raw.githubusercontent.com/LorenEteval/Furious/main/screenshot/macOS-Light.png)
![macOS-Dark](https://raw.githubusercontent.com/LorenEteval/Furious/main/screenshot/macOS-Dark.png)
### Ubuntu
![Ubuntu-Light](https://raw.githubusercontent.com/LorenEteval/Furious/main/screenshot/Ubuntu-Light.png)
![Ubuntu-Dark](https://raw.githubusercontent.com/LorenEteval/Furious/main/screenshot/Ubuntu-Dark.png)
## 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)
+40
View File
@@ -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>
+9
View File
@@ -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'
+64
View File
@@ -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',
],
)