mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-09-22 23:08:08 +03:00
Sync rework changes
Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,262 +0,0 @@
|
||||
# Copyright (C) 2023 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
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.Constants import APP
|
||||
from Furious.Utility.Utility import StateContext, bootstrapIcon
|
||||
from Furious.Utility.Translator import gettext as _
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
|
||||
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'))
|
||||
|
||||
def getText(self):
|
||||
text = self.getTextAll()
|
||||
|
||||
if len(text) <= 1000:
|
||||
return text
|
||||
else:
|
||||
# Limited
|
||||
if len(self.successStr) == 0:
|
||||
return (
|
||||
_('Export as QR code failed:')
|
||||
if self.isQRCodeExport
|
||||
else _('Export share link to clipboard failed:')
|
||||
) + f'\n\n...'
|
||||
elif len(self.failureStr) == 0:
|
||||
return (
|
||||
_('Export as QR code success:')
|
||||
if self.isQRCodeExport
|
||||
else _('Export share link to clipboard success:')
|
||||
) + f'\n\n...'
|
||||
else:
|
||||
return (
|
||||
_('Export as QR code partially success:')
|
||||
if self.isQRCodeExport
|
||||
else _('Export share link to clipboard partially success:')
|
||||
) + f'\n\n...'
|
||||
|
||||
def getTextAll(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 = APP().ServerWidget.ServerList
|
||||
serverLink = []
|
||||
successStr = []
|
||||
failureStr = []
|
||||
|
||||
for index in selectedIndex:
|
||||
try:
|
||||
serverLink.append(
|
||||
Configuration.export(
|
||||
serverList[index]['remark'],
|
||||
Configuration.toJSON(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
|
||||
|
||||
|
||||
def showExportLinkResult(resultBox, successStr, failureStr):
|
||||
resultBox.successStr = successStr
|
||||
resultBox.failureStr = failureStr
|
||||
resultBox.setText(resultBox.getText())
|
||||
|
||||
if len(successStr) == 0:
|
||||
resultBox.setIcon(MessageBox.Icon.Critical)
|
||||
else:
|
||||
resultBox.setIcon(MessageBox.Icon.Information)
|
||||
|
||||
# Show the MessageBox and wait for user to close it
|
||||
resultBox.exec()
|
||||
|
||||
|
||||
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 = APP().ServerWidget.selectedIndex
|
||||
|
||||
if len(selectedIndex) == 0:
|
||||
# Nothing selected. Do nothing
|
||||
return
|
||||
|
||||
serverLink, successStr, failureStr = exportLink(selectedIndex)
|
||||
|
||||
QApplication.clipboard().setText('\n'.join(serverLink))
|
||||
|
||||
showExportLinkResult(self.exportLinkResult, successStr, failureStr)
|
||||
|
||||
|
||||
class ExportQRCodeAction(Action):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(
|
||||
_('Export As QR Code'),
|
||||
icon=bootstrapIcon('qr-code.svg'),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self.exportQRCode = ExportQRCode()
|
||||
self.exportLinkResult = ExportLinkResultBox('', '', isQRCodeExport=True)
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
selectedIndex = APP().ServerWidget.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.labelList.clear()
|
||||
self.exportQRCode.initTabWithData(list(zip(successStr, serverLink)))
|
||||
self.exportQRCode.show()
|
||||
|
||||
showExportLinkResult(self.exportLinkResult, successStr, failureStr)
|
||||
|
||||
|
||||
class ExportJSONAction(Action):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(_('Export JSON Configuration To Clipboard'), **kwargs)
|
||||
|
||||
self.exportJSONResult = ExportJSONResultBox()
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
selectedIndex = APP().ServerWidget.selectedIndex
|
||||
|
||||
if len(selectedIndex) == 0:
|
||||
# Nothing selected. Do nothing
|
||||
return
|
||||
|
||||
try:
|
||||
QApplication.clipboard().setText(
|
||||
'\n'.join(
|
||||
APP().ServerWidget.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()
|
||||
@@ -1,700 +0,0 @@
|
||||
# Copyright (C) 2023 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from Furious.Core.Configuration import (
|
||||
Configuration,
|
||||
XrayCoreConfiguration,
|
||||
ProxyOutboundObject,
|
||||
ProxyOutboundObjectSS,
|
||||
ProxyOutboundObjectTrojan,
|
||||
Hysteria2Configuration,
|
||||
)
|
||||
from Furious.Gui.Action import Action
|
||||
from Furious.Widget.Widget import Menu, MessageBox
|
||||
from Furious.Utility.Constants import APP
|
||||
from Furious.Utility.Utility import (
|
||||
Base64Encoder,
|
||||
Protocol,
|
||||
StateContext,
|
||||
ServerStorage,
|
||||
bootstrapIcon,
|
||||
parseHostPort,
|
||||
enumValueWrapper,
|
||||
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):
|
||||
text = self.getTextAll()
|
||||
|
||||
if len(text) <= 1000:
|
||||
return text
|
||||
else:
|
||||
# Limited
|
||||
return _('Import share link success: ') + f'\n\n...'
|
||||
|
||||
def getTextAll(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:
|
||||
# Limited
|
||||
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 == enumValueWrapper(MessageBox.ButtonRole.AcceptRole):
|
||||
# Go to edit
|
||||
APP().ServerWidget.show()
|
||||
else:
|
||||
# OK. Do nothing
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def parseShareLinkVMess(data, isV2rayN, importServerArgs=None):
|
||||
if importServerArgs is None:
|
||||
importServerArgs = {}
|
||||
|
||||
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'))
|
||||
|
||||
APP().ServerWidget.importServer(
|
||||
remark,
|
||||
ujson.dumps(
|
||||
myJSON,
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
escape_forward_slashes=False,
|
||||
),
|
||||
**importServerArgs,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
'import share link success. '
|
||||
f'Remark: {remark}. Protocol: VMess. (V2rayN share standard)'
|
||||
)
|
||||
|
||||
return remark, True
|
||||
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return '', False
|
||||
else:
|
||||
return ImportLinkAction.parseShareLinkStandard(
|
||||
'vmess', data, importServerArgs
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def parseShareLinkStandard(protocol, data, importServerArgs=None):
|
||||
if importServerArgs is None:
|
||||
importServerArgs = {}
|
||||
|
||||
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_, server = parseResult.netloc.split('@')
|
||||
|
||||
remote_host, remote_port = parseHostPort(server)
|
||||
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
||||
APP().ServerWidget.importServer(
|
||||
remark,
|
||||
ujson.dumps(
|
||||
myJSON, indent=2, ensure_ascii=False, escape_forward_slashes=False
|
||||
),
|
||||
**importServerArgs,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f'import share link success. '
|
||||
f'Remark: {remark}. Protocol: {protocolRepr(protocol)}'
|
||||
)
|
||||
|
||||
return remark, True
|
||||
except Exception as ex:
|
||||
# Any non-exit exceptions
|
||||
|
||||
logger.error(f'import share link failed: {data}. Exception: {ex}')
|
||||
|
||||
return '', False
|
||||
|
||||
@staticmethod
|
||||
def parseShareLinkSIP002(data, importServerArgs=None):
|
||||
if importServerArgs is None:
|
||||
importServerArgs = {}
|
||||
|
||||
try:
|
||||
result = urllib.parse.urlparse(data)
|
||||
remark = urllib.parse.unquote(result.fragment)
|
||||
|
||||
try:
|
||||
# Try pack with 4 element
|
||||
|
||||
methodPassword, server = result.netloc.split('@')
|
||||
|
||||
method, password = methodPassword.split(':')
|
||||
|
||||
address, port = parseHostPort(server)
|
||||
except ValueError:
|
||||
# Unpack error. Try pack with 3 element
|
||||
userinfo, server = result.netloc.split('@')
|
||||
|
||||
address, port = parseHostPort(server)
|
||||
|
||||
# Some old SS share link doesn't add padding
|
||||
# in base64 encoding. Add padding to userinfo
|
||||
method, password = (
|
||||
Base64Encoder.decode(userinfo + '===').decode().split(':')
|
||||
)
|
||||
except Exception as ex:
|
||||
# Any non-exit exceptions
|
||||
|
||||
raise ex
|
||||
|
||||
myJSON = XrayCoreConfiguration.build(
|
||||
ProxyOutboundObjectSS(
|
||||
urllib.parse.unquote(method),
|
||||
urllib.parse.unquote(password),
|
||||
address,
|
||||
port,
|
||||
)
|
||||
)
|
||||
|
||||
APP().ServerWidget.importServer(
|
||||
remark,
|
||||
ujson.dumps(
|
||||
myJSON, indent=2, ensure_ascii=False, escape_forward_slashes=False
|
||||
),
|
||||
**importServerArgs,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f'import share link success. '
|
||||
f'Remark: {remark}. Protocol: {Protocol.Shadowsocks}'
|
||||
)
|
||||
|
||||
return remark, True
|
||||
except Exception as ex:
|
||||
# Any non-exit exceptions
|
||||
|
||||
logger.error(f'import share link failed: {data}. Exception: {ex}')
|
||||
|
||||
return '', False
|
||||
|
||||
@staticmethod
|
||||
def parseShareLinkSS(data, importServerArgs=None):
|
||||
if importServerArgs is None:
|
||||
importServerArgs = {}
|
||||
|
||||
try:
|
||||
# ss://base64...#fragment
|
||||
|
||||
result = urllib.parse.urlparse(data)
|
||||
remark = urllib.parse.unquote(result.fragment)
|
||||
|
||||
if remark == '':
|
||||
remark = 'sslegacy'
|
||||
|
||||
myData = Base64Encoder.decode(result.netloc).decode()
|
||||
|
||||
methodPassword, server = myData.split('@')
|
||||
|
||||
myJSON = XrayCoreConfiguration.build(
|
||||
ProxyOutboundObjectSS(
|
||||
*methodPassword.split(':'), *parseHostPort(server)
|
||||
)
|
||||
)
|
||||
|
||||
APP().ServerWidget.importServer(
|
||||
remark,
|
||||
ujson.dumps(
|
||||
myJSON, indent=2, ensure_ascii=False, escape_forward_slashes=False
|
||||
),
|
||||
**importServerArgs,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f'import share link success. '
|
||||
f'Remark: {remark}. Protocol: {Protocol.Shadowsocks}'
|
||||
)
|
||||
|
||||
return remark, True
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return ImportLinkAction.parseShareLinkSIP002(data, importServerArgs)
|
||||
|
||||
@staticmethod
|
||||
def parseShareLinkTrojan(data, importServerArgs=None):
|
||||
if importServerArgs is None:
|
||||
importServerArgs = {}
|
||||
|
||||
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)
|
||||
|
||||
password, server = parseResult.netloc.split('@')
|
||||
|
||||
address, port = parseHostPort(server)
|
||||
|
||||
type_ = queryObject.get('type', 'tcp')
|
||||
# For Trojan: Assign tls by default
|
||||
security = queryObject.get('security', 'tls')
|
||||
|
||||
# Remove redundant items
|
||||
queryObject.pop('type', '')
|
||||
queryObject.pop('security', '')
|
||||
|
||||
myJSON = XrayCoreConfiguration.build(
|
||||
ProxyOutboundObjectTrojan(
|
||||
password,
|
||||
address,
|
||||
int(port),
|
||||
type_,
|
||||
security,
|
||||
# kwargs
|
||||
**queryObject,
|
||||
)
|
||||
)
|
||||
|
||||
APP().ServerWidget.importServer(
|
||||
remark,
|
||||
ujson.dumps(
|
||||
myJSON, indent=2, ensure_ascii=False, escape_forward_slashes=False
|
||||
),
|
||||
**importServerArgs,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f'import share link success. '
|
||||
f'Remark: {remark}. Protocol: {Protocol.Trojan}'
|
||||
)
|
||||
|
||||
return remark, True
|
||||
except Exception as ex:
|
||||
# Any non-exit exceptions
|
||||
|
||||
logger.error(f'import share link failed: {data}. Exception: {ex}')
|
||||
|
||||
return '', False
|
||||
|
||||
@staticmethod
|
||||
def parseShareLinkHysteria2(data, importServerArgs=None):
|
||||
if importServerArgs is None:
|
||||
importServerArgs = {}
|
||||
|
||||
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)
|
||||
|
||||
if remark == '':
|
||||
remark = _('Untitled')
|
||||
|
||||
auth, server = parseResult.netloc.split('@')
|
||||
|
||||
myJSON = Hysteria2Configuration(
|
||||
server,
|
||||
auth,
|
||||
# kwargs
|
||||
**queryObject,
|
||||
).build()
|
||||
|
||||
APP().ServerWidget.importServer(
|
||||
remark,
|
||||
ujson.dumps(
|
||||
myJSON, indent=4, ensure_ascii=False, escape_forward_slashes=False
|
||||
),
|
||||
**importServerArgs,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f'import share link success. '
|
||||
f'Remark: {remark}. Protocol: {Protocol.Hysteria2}'
|
||||
)
|
||||
|
||||
return remark, True
|
||||
except Exception as ex:
|
||||
# Any non-exit exceptions
|
||||
|
||||
logger.error(f'import share link failed: {data}. Exception: {ex}')
|
||||
|
||||
return '', False
|
||||
|
||||
@staticmethod
|
||||
def parseShareLink(shareLink, importServerArgs=None):
|
||||
if importServerArgs is None:
|
||||
importServerArgs = {}
|
||||
|
||||
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 ImportLinkAction.parseShareLinkVMess(
|
||||
shareLink,
|
||||
isV2rayN=False,
|
||||
importServerArgs=importServerArgs,
|
||||
)
|
||||
else:
|
||||
return ImportLinkAction.parseShareLinkVMess(
|
||||
myJSON,
|
||||
isV2rayN=True,
|
||||
importServerArgs=importServerArgs,
|
||||
)
|
||||
|
||||
if myHead.lower() == 'vless':
|
||||
return ImportLinkAction.parseShareLinkStandard(
|
||||
'vless',
|
||||
shareLink,
|
||||
importServerArgs,
|
||||
)
|
||||
|
||||
if myHead.lower() == 'ss':
|
||||
return ImportLinkAction.parseShareLinkSS(
|
||||
shareLink,
|
||||
importServerArgs,
|
||||
)
|
||||
|
||||
if myHead.lower() == 'trojan':
|
||||
return ImportLinkAction.parseShareLinkTrojan(
|
||||
shareLink,
|
||||
importServerArgs,
|
||||
)
|
||||
|
||||
if myHead.lower() == 'hysteria2' or myHead.lower() == 'hy2':
|
||||
return ImportLinkAction.parseShareLinkHysteria2(
|
||||
shareLink,
|
||||
importServerArgs,
|
||||
)
|
||||
|
||||
logger.error(f'unsupported share link: {shareLink}')
|
||||
|
||||
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 = ImportLinkAction.parseShareLink(self.clipboard)
|
||||
|
||||
if result:
|
||||
# Sync it
|
||||
ServerStorage.sync()
|
||||
|
||||
self.showLinkImportOK(remark)
|
||||
else:
|
||||
self.showLinkErrorBox()
|
||||
else:
|
||||
successRemark = []
|
||||
|
||||
rowCount = APP().ServerWidget.rowCount
|
||||
|
||||
for shareLink in splitByNewLine:
|
||||
remark, result = ImportLinkAction.parseShareLink(shareLink)
|
||||
|
||||
if result:
|
||||
successRemark.append(remark)
|
||||
|
||||
if len(successRemark) == 0:
|
||||
self.showLinkErrorBox()
|
||||
|
||||
return
|
||||
|
||||
# At least one server's been imported. Sync it
|
||||
ServerStorage.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:
|
||||
# Limited
|
||||
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 == enumValueWrapper(MessageBox.ButtonRole.AcceptRole):
|
||||
# Go to edit
|
||||
APP().ServerWidget.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:
|
||||
APP().ServerWidget.importServer(
|
||||
_('Untitled'), self.clipboard, syncStorage=True
|
||||
)
|
||||
|
||||
logger.debug('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,
|
||||
)
|
||||
@@ -1,261 +0,0 @@
|
||||
# Copyright (C) 2023 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from Furious.Core.Core import XrayCore, Hysteria1
|
||||
from Furious.Gui.Action import Action, Seperator
|
||||
from Furious.Widget.Widget import Menu
|
||||
from Furious.Utility.Constants import APP, DATA_DIR
|
||||
from Furious.Utility.Utility import Switch, bootstrapIcon
|
||||
from Furious.Utility.Translator import gettext as _
|
||||
|
||||
|
||||
BUILTIN_ROUTING_TABLE = {
|
||||
'Bypass Mainland China': {
|
||||
XrayCore.name(): {
|
||||
'domainStrategy': 'IPIfNonMatch',
|
||||
'domainMatcher': 'hybrid',
|
||||
'rules': [
|
||||
# ads
|
||||
{
|
||||
'type': 'field',
|
||||
'domain': [
|
||||
'geosite:category-ads-all',
|
||||
],
|
||||
'outboundTag': 'block',
|
||||
},
|
||||
# geosite
|
||||
{
|
||||
'type': 'field',
|
||||
'domain': [
|
||||
'geosite:cn',
|
||||
],
|
||||
'outboundTag': 'direct',
|
||||
},
|
||||
# geoip
|
||||
{
|
||||
'type': 'field',
|
||||
'ip': [
|
||||
'geoip:private',
|
||||
'geoip:cn',
|
||||
],
|
||||
'outboundTag': 'direct',
|
||||
},
|
||||
# Proxy everything
|
||||
{
|
||||
'type': 'field',
|
||||
'port': '0-65535',
|
||||
'outboundTag': 'proxy',
|
||||
},
|
||||
],
|
||||
},
|
||||
Hysteria1.name(): {
|
||||
'acl': (DATA_DIR / 'hysteria' / 'bypass-mainland-China.acl').as_posix(),
|
||||
'mmdb': (DATA_DIR / 'hysteria' / 'country.mmdb').as_posix(),
|
||||
},
|
||||
},
|
||||
'Bypass Iran': {
|
||||
XrayCore.name(): {
|
||||
'domainStrategy': 'IPIfNonMatch',
|
||||
'domainMatcher': 'hybrid',
|
||||
'rules': [
|
||||
# ads
|
||||
{
|
||||
'type': 'field',
|
||||
'domain': [
|
||||
'geosite:category-ads-all',
|
||||
'iran:ads',
|
||||
],
|
||||
'outboundTag': 'block',
|
||||
},
|
||||
# Iran sites
|
||||
{
|
||||
'type': 'field',
|
||||
'domain': [
|
||||
'iran:ir',
|
||||
'iran:other',
|
||||
],
|
||||
'outboundTag': 'direct',
|
||||
},
|
||||
# Iran IP
|
||||
{
|
||||
'type': 'field',
|
||||
'ip': [
|
||||
'geoip:private',
|
||||
'geoip:ir',
|
||||
],
|
||||
'outboundTag': 'direct',
|
||||
},
|
||||
# Proxy everything
|
||||
{
|
||||
'type': 'field',
|
||||
'port': '0-65535',
|
||||
'outboundTag': 'proxy',
|
||||
},
|
||||
],
|
||||
},
|
||||
Hysteria1.name(): {
|
||||
'acl': (DATA_DIR / 'hysteria' / 'bypass-Iran.acl').as_posix(),
|
||||
'mmdb': (DATA_DIR / 'hysteria' / 'country.mmdb').as_posix(),
|
||||
},
|
||||
},
|
||||
'Route My Traffic Through Tor': {
|
||||
XrayCore.name(): {},
|
||||
Hysteria1.name(): {},
|
||||
},
|
||||
'Global': {
|
||||
XrayCore.name(): {
|
||||
'domainStrategy': 'IPIfNonMatch',
|
||||
'domainMatcher': 'hybrid',
|
||||
'rules': [
|
||||
# Proxy everything
|
||||
{
|
||||
'type': 'field',
|
||||
'port': '0-65535',
|
||||
'outboundTag': 'proxy',
|
||||
},
|
||||
],
|
||||
},
|
||||
Hysteria1.name(): {},
|
||||
},
|
||||
'Custom': {
|
||||
XrayCore.name(): {},
|
||||
Hysteria1.name(): {},
|
||||
},
|
||||
}
|
||||
|
||||
BUILTIN_ROUTING = list(BUILTIN_ROUTING_TABLE.keys())
|
||||
|
||||
|
||||
def routingToIndex():
|
||||
currentRouting = APP().Routing
|
||||
|
||||
try:
|
||||
index = int(currentRouting)
|
||||
except ValueError:
|
||||
for i, routing in enumerate(BUILTIN_ROUTING):
|
||||
if routing == currentRouting:
|
||||
return i
|
||||
|
||||
return -1
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return -1
|
||||
else:
|
||||
return index + len(BUILTIN_ROUTING)
|
||||
|
||||
|
||||
class BuiltinRoutingChildAction(Action):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
textEnglish = self.textEnglish
|
||||
|
||||
if APP().Routing != textEnglish:
|
||||
# De-activate
|
||||
APP().RoutesWidget.activateItemByIndex(routingToIndex(), activate=False)
|
||||
APP().Routing = textEnglish
|
||||
# Activate
|
||||
APP().RoutesWidget.activateItemByIndex(routingToIndex(), activate=True)
|
||||
|
||||
if APP().isConnected():
|
||||
# Connected. Re-configure connection
|
||||
APP().tray.ConnectAction.connectingAction(
|
||||
showProgressBar=True,
|
||||
showRoutingChangedMessage=True,
|
||||
currentRouting=textEnglish,
|
||||
isBuiltinRouting=True,
|
||||
)
|
||||
|
||||
|
||||
class RoutingChildAction(Action):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.translatable = False
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
routingAction = APP().tray.RoutingAction
|
||||
|
||||
for index, action in enumerate(
|
||||
# Skip built-in actions.
|
||||
routingAction.menu().actions()[len(BUILTIN_ROUTING) :]
|
||||
):
|
||||
if id(self) == id(action):
|
||||
# Found action
|
||||
|
||||
if APP().Routing != str(index):
|
||||
route = APP().RoutesWidget.RoutesList[index]
|
||||
|
||||
# De-activate
|
||||
APP().RoutesWidget.activateItemByIndex(
|
||||
routingToIndex(), activate=False
|
||||
)
|
||||
APP().Routing = str(index)
|
||||
# Activate
|
||||
APP().RoutesWidget.activateItemByIndex(
|
||||
routingToIndex(), activate=True
|
||||
)
|
||||
|
||||
if APP().isConnected():
|
||||
# Connected. Re-configure connection
|
||||
APP().tray.ConnectAction.connectingAction(
|
||||
showProgressBar=True,
|
||||
showRoutingChangedMessage=True,
|
||||
currentRouting=route['remark'],
|
||||
isBuiltinRouting=False,
|
||||
)
|
||||
|
||||
# Select routing action done
|
||||
return
|
||||
|
||||
raise Exception('Fatal error occurred')
|
||||
|
||||
@property
|
||||
def textEnglish(self):
|
||||
return self.text()
|
||||
|
||||
|
||||
class RoutingAction(Action):
|
||||
def __init__(self):
|
||||
if APP().Routing == 'Bypass':
|
||||
# Update value for backward compatibility
|
||||
APP().Routing = 'Bypass Mainland China'
|
||||
|
||||
super().__init__(
|
||||
_('Routing'),
|
||||
icon=bootstrapIcon('shuffle.svg'),
|
||||
menu=Menu(
|
||||
*list(
|
||||
BuiltinRoutingChildAction(
|
||||
_(routing),
|
||||
checkable=True,
|
||||
checked=APP().Routing == routing,
|
||||
)
|
||||
for routing in BUILTIN_ROUTING
|
||||
),
|
||||
*list(
|
||||
RoutingChildAction(
|
||||
route['remark'],
|
||||
checkable=True,
|
||||
checked=APP().Routing == str(index),
|
||||
)
|
||||
for index, route in enumerate(APP().RoutesWidget.RoutesList)
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -1,161 +0,0 @@
|
||||
# Copyright (C) 2023 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from Furious.Gui.Action import Action, Seperator
|
||||
from Furious.Widget.Widget import Menu
|
||||
from Furious.Utility.Constants import APP, PLATFORM, ADMINISTRATOR_NAME
|
||||
from Furious.Utility.Utility import Switch, bootstrapIcon, isAdministrator, isVPNMode
|
||||
from Furious.Utility.Translator import gettext as _
|
||||
from Furious.Utility.StartupOnBoot import StartupOnBoot
|
||||
|
||||
|
||||
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_()
|
||||
|
||||
APP().StartupOnBoot = Switch.ON_
|
||||
else:
|
||||
StartupOnBoot.off()
|
||||
|
||||
APP().StartupOnBoot = Switch.OFF
|
||||
if self.textCompare('Show Progress Bar When Connecting'):
|
||||
if checked:
|
||||
APP().ShowProgressBarWhenConnecting = Switch.ON_
|
||||
else:
|
||||
APP().ShowProgressBarWhenConnecting = Switch.OFF
|
||||
if self.textCompare('Show Tab And Spaces In Editor'):
|
||||
if checked:
|
||||
APP().ShowTabAndSpacesInEditor = Switch.ON_
|
||||
else:
|
||||
APP().ShowTabAndSpacesInEditor = Switch.OFF
|
||||
|
||||
# Reference
|
||||
ServerWidget = APP().ServerWidget
|
||||
|
||||
currentFocus = ServerWidget.currentFocus
|
||||
|
||||
if currentFocus >= 0:
|
||||
ServerWidget.saveScrollBarValue(currentFocus)
|
||||
|
||||
ServerWidget.showTabAndSpacesIfNecessary()
|
||||
|
||||
if currentFocus >= 0:
|
||||
ServerWidget.restoreScrollBarValue(currentFocus)
|
||||
|
||||
|
||||
class RoutingChildAction(Action):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(_('Routing Settings...'), **kwargs)
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
APP().RoutesWidget.show()
|
||||
|
||||
|
||||
class VPNModeAction(Action):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(
|
||||
_('VPN Mode')
|
||||
if isAdministrator()
|
||||
else _(f'VPN Mode Disabled ({ADMINISTRATOR_NAME})'),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
if not isAdministrator():
|
||||
self.setDisabled(True)
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
assert isAdministrator()
|
||||
|
||||
if checked:
|
||||
APP().VPNMode = Switch.ON_
|
||||
|
||||
if APP().isConnected():
|
||||
if isVPNMode():
|
||||
if PLATFORM == 'Windows' or PLATFORM == 'Darwin':
|
||||
# Currently VPN Mode is only supported on Windows and macOS
|
||||
APP().tray.ConnectAction.startTun2socks(
|
||||
successCallback=lambda: APP().tray.showMessage(
|
||||
_('VPN mode started')
|
||||
)
|
||||
)
|
||||
else:
|
||||
if APP().isConnected():
|
||||
if isVPNMode():
|
||||
if PLATFORM == 'Windows' or PLATFORM == 'Darwin':
|
||||
# Currently VPN Mode is only supported on Windows and macOS
|
||||
APP().tray.ConnectAction.stopTun2socks()
|
||||
APP().tray.showMessage(_('VPN mode stopped'))
|
||||
|
||||
APP().VPNMode = Switch.OFF
|
||||
|
||||
|
||||
class TorRelaySettingsChildAction(Action):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
APP().TorRelayWidget.open()
|
||||
|
||||
|
||||
class SettingsAction(Action):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
_('Settings'),
|
||||
icon=bootstrapIcon('gear-wide-connected.svg'),
|
||||
menu=Menu(
|
||||
SettingsChildAction(
|
||||
_('Startup On Boot'),
|
||||
checkable=True,
|
||||
checked=APP().StartupOnBoot == Switch.ON_,
|
||||
),
|
||||
SettingsChildAction(
|
||||
_('Show Progress Bar When Connecting'),
|
||||
checkable=True,
|
||||
checked=APP().ShowProgressBarWhenConnecting == Switch.ON_,
|
||||
),
|
||||
SettingsChildAction(
|
||||
_('Show Tab And Spaces In Editor'),
|
||||
checkable=True,
|
||||
checked=APP().ShowTabAndSpacesInEditor == Switch.ON_,
|
||||
),
|
||||
VPNModeAction(
|
||||
checkable=True,
|
||||
checked=APP().VPNMode == Switch.ON_,
|
||||
)
|
||||
if PLATFORM == 'Windows' or PLATFORM == 'Darwin'
|
||||
else None,
|
||||
Seperator(),
|
||||
RoutingChildAction(),
|
||||
Seperator(),
|
||||
TorRelaySettingsChildAction(
|
||||
_('Tor Relay Settings...'), icon=bootstrapIcon('incognito.svg')
|
||||
),
|
||||
),
|
||||
useActionGroup=False,
|
||||
)
|
||||
|
||||
def getVPNModeAction(self):
|
||||
if PLATFORM == 'Windows' or PLATFORM == 'Darwin':
|
||||
# 3rd action
|
||||
return self._menu.actions()[3]
|
||||
else:
|
||||
return None
|
||||
@@ -1,963 +0,0 @@
|
||||
# Copyright (C) 2023 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from Furious.Core.Core import XrayCore, Hysteria2
|
||||
from Furious.Core.Intellisense import Intellisense
|
||||
from Furious.Widget.Widget import MessageBox
|
||||
from Furious.Utility.Constants import APPLICATION_NAME, PROXY_OUTBOUND_USER_EMAIL
|
||||
from Furious.Utility.Utility import Base64Encoder, Protocol, bootstrapIcon
|
||||
from Furious.Utility.Translator import gettext as _
|
||||
|
||||
import copy
|
||||
import ujson
|
||||
import functools
|
||||
import urllib.parse
|
||||
|
||||
# '/' will be quoted for V2rayN compatibility.
|
||||
quote = functools.partial(urllib.parse.quote, safe='')
|
||||
unquote = functools.partial(urllib.parse.unquote)
|
||||
urlunparse = functools.partial(urllib.parse.urlunparse)
|
||||
|
||||
|
||||
class UnsupportedServerExport(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ExportFactory:
|
||||
def export(self, remark, jsonObject):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class XrayFactory:
|
||||
@staticmethod
|
||||
def streamTLSSettings(streamObject, tlsType):
|
||||
kwargs = {}
|
||||
|
||||
if tlsType == '' or tlsType == 'none':
|
||||
return kwargs
|
||||
|
||||
tlsobj = streamObject[ProxyOutboundObject.streamTLSKey(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
|
||||
|
||||
@staticmethod
|
||||
def getProxyOutboundObject(jsonObject):
|
||||
for outboundObject in jsonObject['outbounds']:
|
||||
if outboundObject['tag'] == 'proxy':
|
||||
return outboundObject
|
||||
|
||||
raise Exception('No proxy outbound found')
|
||||
|
||||
|
||||
class ExportVMess(ExportFactory):
|
||||
@staticmethod
|
||||
def streamNetSettings(streamObject, netType):
|
||||
kwargs = {}
|
||||
|
||||
netobj = streamObject.get(ProxyOutboundObject.streamNetworkKey(netType))
|
||||
|
||||
if netobj is None:
|
||||
return kwargs
|
||||
|
||||
def hasKey(key):
|
||||
return netobj.get(key) is not None
|
||||
|
||||
if netType == 'tcp':
|
||||
try:
|
||||
kwargs['type'] = netobj['header']['type']
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
pass
|
||||
|
||||
elif netType == 'kcp':
|
||||
try:
|
||||
# Get order matters here
|
||||
if hasKey('seed'):
|
||||
kwargs['path'] = netobj['seed']
|
||||
|
||||
kwargs['type'] = netobj['header']['type']
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
pass
|
||||
|
||||
elif netType == 'ws':
|
||||
try:
|
||||
# Get order matters here
|
||||
if hasKey('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('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 hasKey('security'):
|
||||
kwargs['host'] = netobj['security']
|
||||
|
||||
if hasKey('key'):
|
||||
kwargs['path'] = quote(netobj['key'])
|
||||
|
||||
kwargs['type'] = netobj['header']['type']
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
pass
|
||||
|
||||
elif netType == 'grpc':
|
||||
if hasKey('serviceName'):
|
||||
kwargs['path'] = netobj['serviceName']
|
||||
|
||||
return kwargs
|
||||
|
||||
def export(self, remark, jsonObject):
|
||||
proxyOutbound = XrayFactory.getProxyOutboundObject(jsonObject)
|
||||
|
||||
proxyServer = proxyOutbound['settings']['vnext'][0]
|
||||
proxyServerUser = proxyServer['users'][0]
|
||||
|
||||
proxyStream, proxyStreamNet, proxyStreamTLS = (
|
||||
proxyOutbound['streamSettings'],
|
||||
proxyOutbound['streamSettings']['network'],
|
||||
proxyOutbound['streamSettings'].get('security', 'none'),
|
||||
)
|
||||
|
||||
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
|
||||
**ExportVMess.streamNetSettings(proxyStream, proxyStreamNet),
|
||||
**XrayFactory.streamTLSSettings(proxyStream, proxyStreamTLS),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
escape_forward_slashes=False,
|
||||
).encode()
|
||||
).decode()
|
||||
)
|
||||
|
||||
|
||||
class ExportVLESS(ExportFactory):
|
||||
@staticmethod
|
||||
def streamNetSettings(streamObject, netType):
|
||||
kwargs = {}
|
||||
|
||||
netobj = streamObject.get(ProxyOutboundObject.streamNetworkKey(netType))
|
||||
|
||||
if netobj is None:
|
||||
return kwargs
|
||||
|
||||
def hasKey(key):
|
||||
return netobj.get(key) is not None
|
||||
|
||||
if netType == 'tcp':
|
||||
try:
|
||||
kwargs['headerType'] = netobj['header']['type']
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
pass
|
||||
|
||||
elif netType == 'kcp':
|
||||
try:
|
||||
# Get order matters here
|
||||
if hasKey('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('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('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 hasKey('security'):
|
||||
kwargs['quicSecurity'] = netobj['security']
|
||||
|
||||
if hasKey('key'):
|
||||
kwargs['path'] = quote(netobj['key'])
|
||||
|
||||
kwargs['headerType'] = netobj['header']['type']
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
pass
|
||||
|
||||
elif netType == 'grpc':
|
||||
if hasKey('serviceName'):
|
||||
kwargs['serviceName'] = netobj['serviceName']
|
||||
|
||||
return kwargs
|
||||
|
||||
def export(self, remark, jsonObject):
|
||||
proxyOutbound = XrayFactory.getProxyOutboundObject(jsonObject)
|
||||
|
||||
proxyServer = proxyOutbound['settings']['vnext'][0]
|
||||
proxyServerUser = proxyServer['users'][0]
|
||||
|
||||
proxyStream, proxyStreamNet, proxyStreamTLS = (
|
||||
proxyOutbound['streamSettings'],
|
||||
proxyOutbound['streamSettings']['network'],
|
||||
proxyOutbound['streamSettings'].get('security', 'none'),
|
||||
)
|
||||
|
||||
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,
|
||||
**ExportVLESS.streamNetSettings(proxyStream, proxyStreamNet),
|
||||
**XrayFactory.streamTLSSettings(proxyStream, proxyStreamTLS),
|
||||
}.items()
|
||||
)
|
||||
|
||||
return urlunparse(['vless', netloc, '', '', query, quote(remark)])
|
||||
|
||||
|
||||
class ExportSS(ExportVLESS):
|
||||
def export(self, remark, jsonObject):
|
||||
proxyOutbound = XrayFactory.getProxyOutboundObject(jsonObject)
|
||||
|
||||
proxyServer = proxyOutbound['settings']['servers'][0]
|
||||
|
||||
method, password, address, port = (
|
||||
proxyServer['method'],
|
||||
proxyServer['password'],
|
||||
proxyServer['address'],
|
||||
proxyServer['port'],
|
||||
)
|
||||
|
||||
netloc = f'{quote(method)}:{quote(password)}@{address}:{port}'
|
||||
|
||||
return urlunparse(['ss', netloc, '', '', '', quote(remark)])
|
||||
|
||||
|
||||
class ExportTrojan(ExportVLESS):
|
||||
def export(self, remark, jsonObject):
|
||||
proxyOutbound = XrayFactory.getProxyOutboundObject(jsonObject)
|
||||
|
||||
proxyServer = proxyOutbound['settings']['servers'][0]
|
||||
|
||||
password, address, port = (
|
||||
proxyServer['password'],
|
||||
proxyServer['address'],
|
||||
proxyServer['port'],
|
||||
)
|
||||
|
||||
proxyStream, proxyStreamNet, proxyStreamTLS = (
|
||||
proxyOutbound['streamSettings'],
|
||||
proxyOutbound['streamSettings']['network'],
|
||||
proxyOutbound['streamSettings'].get('security', 'none'),
|
||||
)
|
||||
|
||||
netloc = f'{quote(password)}@{address}:{port}'
|
||||
|
||||
query = '&'.join(
|
||||
f'{key}={value}'
|
||||
for key, value in {
|
||||
'type': proxyStreamNet,
|
||||
'security': proxyStreamTLS,
|
||||
# kwargs
|
||||
**ExportVLESS.streamNetSettings(proxyStream, proxyStreamNet),
|
||||
**XrayFactory.streamTLSSettings(proxyStream, proxyStreamTLS),
|
||||
}.items()
|
||||
)
|
||||
|
||||
return urlunparse(['trojan', netloc, '', '', query, quote(remark)])
|
||||
|
||||
|
||||
class Configuration:
|
||||
ExportClassMap = {
|
||||
Protocol.VMess: ExportVMess,
|
||||
Protocol.VLESS: ExportVLESS,
|
||||
Protocol.Shadowsocks: ExportSS,
|
||||
Protocol.Trojan: ExportTrojan,
|
||||
}
|
||||
|
||||
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, jsonObject):
|
||||
if Intellisense.getCoreType(jsonObject) == XrayCore.name():
|
||||
|
||||
def getExportObject():
|
||||
protocol = Intellisense.getCoreProtocol(jsonObject)
|
||||
|
||||
return Configuration.ExportClassMap[protocol]()
|
||||
|
||||
return getExportObject().export(remark, jsonObject)
|
||||
|
||||
if Intellisense.getCoreType(jsonObject) == Hysteria2.name():
|
||||
netloc = f'{jsonObject["auth"]}@{jsonObject["server"]}'
|
||||
|
||||
tlsArg, obfsArg = {}, {}
|
||||
|
||||
if jsonObject.get('tls'):
|
||||
if jsonObject['tls'].get('sni'):
|
||||
tlsArg['sni'] = jsonObject['tls']['sni']
|
||||
|
||||
if jsonObject['tls'].get('insecure') is True:
|
||||
tlsArg['insecure'] = '1'
|
||||
else:
|
||||
tlsArg['insecure'] = '0'
|
||||
|
||||
if jsonObject['tls'].get('pinSHA256'):
|
||||
tlsArg['pinSHA256'] = jsonObject['tls']['pinSHA256']
|
||||
|
||||
if jsonObject.get('obfs'):
|
||||
obfsType = jsonObject['obfs'].get('type', 'salamander')
|
||||
|
||||
obfsArg['obfs'] = obfsType
|
||||
obfsArg['obfs-password'] = jsonObject['obfs'][obfsType]['password']
|
||||
|
||||
query = '&'.join(
|
||||
f'{key}={value}'
|
||||
for key, value in {
|
||||
**tlsArg,
|
||||
**obfsArg,
|
||||
}.items()
|
||||
)
|
||||
|
||||
return urlunparse(['hysteria2', netloc, '', '', query, quote(remark)])
|
||||
|
||||
raise UnsupportedServerExport('Unsupported core protocol export')
|
||||
|
||||
|
||||
class OutboundObject:
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def build(self):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class ProxyOutboundObject(OutboundObject):
|
||||
def __init__(
|
||||
self,
|
||||
protocol,
|
||||
remote_host,
|
||||
remote_port,
|
||||
uuid_,
|
||||
encryption,
|
||||
type_,
|
||||
security,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__()
|
||||
|
||||
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')
|
||||
# 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
|
||||
else:
|
||||
host = ''
|
||||
|
||||
if self.protocol == 'vmess':
|
||||
host = self.kwargs.get('host')
|
||||
|
||||
if self.protocol == 'vless':
|
||||
host = self.remote_host
|
||||
|
||||
if host:
|
||||
TLSObject['serverName'] = host
|
||||
|
||||
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 streamTLSKey(security):
|
||||
# tlsSettings, realitySettings
|
||||
return f'{security}Settings'
|
||||
|
||||
@staticmethod
|
||||
@functools.lru_cache(None)
|
||||
def streamNetworkKey(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'):
|
||||
headerType = self.kwargs.get('headerType', 'none')
|
||||
|
||||
# Some dumb share link set this value to 'auto'. Protect it
|
||||
if headerType != 'auto':
|
||||
TcpObject['header'] = {
|
||||
'type': headerType,
|
||||
}
|
||||
|
||||
# Request settings for HTTP
|
||||
if headerType == 'http' and self.kwargs.get('host'):
|
||||
TcpObject['header']['request'] = {
|
||||
'version': '1.1',
|
||||
'method': 'GET',
|
||||
'path': [self.kwargs.get('path', '/')],
|
||||
'headers': {
|
||||
'Host': [unquote(self.kwargs.get('host'))],
|
||||
'User-Agent': [
|
||||
'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.143 Safari/537.36',
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 10_0_2 like Mac OS X) AppleWebKit/601.1 (KHTML, like Gecko) CriOS/53.0.2785.109 Mobile/14A456 Safari/601.1.46',
|
||||
],
|
||||
'Accept-Encoding': ['gzip, deflate'],
|
||||
'Connection': ['keep-alive'],
|
||||
'Pragma': 'no-cache',
|
||||
},
|
||||
}
|
||||
|
||||
return TcpObject
|
||||
|
||||
elif self.type_ == 'kcp':
|
||||
KcpObject = {
|
||||
# Extension. From V2rayN
|
||||
'uplinkCapacity': 12,
|
||||
'downlinkCapacity': 100,
|
||||
}
|
||||
|
||||
if self.kwargs.get('headerType', 'none'):
|
||||
headerType = self.kwargs.get('headerType', 'none')
|
||||
|
||||
# Some dumb share link set this value to 'auto'. Protect it
|
||||
if headerType != 'auto':
|
||||
KcpObject['header'] = {
|
||||
'type': headerType,
|
||||
}
|
||||
|
||||
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'):
|
||||
headerType = self.kwargs.get('headerType', 'none')
|
||||
|
||||
# Some dumb share link set this value to 'auto'. Protect it
|
||||
if headerType != 'auto':
|
||||
QuicObject['header'] = {
|
||||
'type': headerType,
|
||||
}
|
||||
|
||||
return QuicObject
|
||||
|
||||
elif 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
|
||||
'email': PROXY_OUTBOUND_USER_EMAIL,
|
||||
}
|
||||
|
||||
# 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
|
||||
'email': PROXY_OUTBOUND_USER_EMAIL,
|
||||
}
|
||||
|
||||
if self.kwargs.get('flow'):
|
||||
# flow is empty, TLS. Otherwise, XTLS.
|
||||
UserObject['flow'] = self.kwargs.get('flow')
|
||||
|
||||
return UserObject
|
||||
|
||||
return {}
|
||||
|
||||
def getDefaultJSON(self):
|
||||
securityArgs = {}
|
||||
|
||||
if self.security:
|
||||
securityArgs['security'] = self.security
|
||||
|
||||
return {
|
||||
'tag': 'proxy',
|
||||
'protocol': self.protocol,
|
||||
'settings': {
|
||||
'vnext': [
|
||||
{
|
||||
'address': self.remote_host,
|
||||
# self.remote_port is already an integer
|
||||
'port': self.remote_port,
|
||||
'users': [
|
||||
self.getUserObject(),
|
||||
],
|
||||
},
|
||||
]
|
||||
},
|
||||
'streamSettings': {
|
||||
'network': self.type_,
|
||||
**securityArgs,
|
||||
},
|
||||
'mux': {
|
||||
'enabled': False,
|
||||
'concurrency': -1,
|
||||
},
|
||||
}
|
||||
|
||||
def build(self):
|
||||
myJSON = self.getDefaultJSON()
|
||||
|
||||
if self.security and self.security != 'none':
|
||||
# tlsSettings, realitySettings
|
||||
myJSON['streamSettings'][
|
||||
ProxyOutboundObject.streamTLSKey(self.security)
|
||||
] = self.getTLSSettings(self.security)
|
||||
|
||||
# Stream network settings
|
||||
myJSON['streamSettings'][
|
||||
ProxyOutboundObject.streamNetworkKey(self.type_)
|
||||
] = self.getStreamNetworkSettings()
|
||||
|
||||
return myJSON
|
||||
|
||||
|
||||
class ProxyOutboundObjectSS(ProxyOutboundObject):
|
||||
def __init__(self, method, password, address, port):
|
||||
super().__init__('shadowsocks', address, port, password, '', 'tcp', '')
|
||||
|
||||
self.method = method
|
||||
|
||||
def getDefaultJSON(self):
|
||||
return {
|
||||
'tag': 'proxy',
|
||||
'protocol': self.protocol,
|
||||
'settings': {
|
||||
'servers': [
|
||||
{
|
||||
'address': self.remote_host,
|
||||
'port': int(self.remote_port),
|
||||
'method': self.method,
|
||||
'password': self.uuid_,
|
||||
'email': PROXY_OUTBOUND_USER_EMAIL,
|
||||
'ota': False,
|
||||
},
|
||||
]
|
||||
},
|
||||
'streamSettings': {
|
||||
'network': 'tcp',
|
||||
},
|
||||
'mux': {
|
||||
'enabled': False,
|
||||
'concurrency': -1,
|
||||
},
|
||||
}
|
||||
|
||||
def build(self):
|
||||
return self.getDefaultJSON()
|
||||
|
||||
|
||||
class ProxyOutboundObjectTrojan(ProxyOutboundObject):
|
||||
def __init__(self, password, address, port, type_, security, **kwargs):
|
||||
super().__init__(
|
||||
'trojan', address, port, password, '', type_, security, **kwargs
|
||||
)
|
||||
|
||||
def getDefaultJSON(self):
|
||||
securityArgs = {}
|
||||
|
||||
if self.security:
|
||||
securityArgs['security'] = self.security
|
||||
|
||||
return {
|
||||
'tag': 'proxy',
|
||||
'protocol': self.protocol,
|
||||
'settings': {
|
||||
'servers': [
|
||||
{
|
||||
'address': self.remote_host,
|
||||
'port': int(self.remote_port),
|
||||
'password': self.uuid_,
|
||||
'email': PROXY_OUTBOUND_USER_EMAIL,
|
||||
},
|
||||
]
|
||||
},
|
||||
'streamSettings': {
|
||||
'network': self.type_,
|
||||
**securityArgs,
|
||||
},
|
||||
'mux': {
|
||||
'enabled': False,
|
||||
'concurrency': -1,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
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_JSON = {
|
||||
# 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_JSON)
|
||||
|
||||
@staticmethod
|
||||
def build(proxyOutboundObject):
|
||||
# log, inbounds
|
||||
myJSON = XrayCoreConfiguration.getDefaultJSON()
|
||||
|
||||
# Add outbounds
|
||||
myJSON['outbounds'] = Outbounds(proxyOutboundObject).build()
|
||||
|
||||
# Add empty routing
|
||||
myJSON['routing'] = {}
|
||||
|
||||
return myJSON
|
||||
|
||||
|
||||
class Hysteria2Configuration:
|
||||
def __init__(self, server, auth, **kwargs):
|
||||
self.server = server
|
||||
self.auth = auth
|
||||
self.obfs = kwargs.get('obfs', '')
|
||||
self.obfsPassword = kwargs.get('obfs-password', '')
|
||||
self.sni = kwargs.get('sni', '')
|
||||
|
||||
insecure = kwargs.get('insecure', False)
|
||||
|
||||
if isinstance(insecure, bool):
|
||||
self.insecure = insecure
|
||||
elif insecure == '1':
|
||||
self.insecure = True
|
||||
else:
|
||||
self.insecure = False
|
||||
|
||||
self.pinSHA256 = kwargs.get('pinSHA256', '')
|
||||
|
||||
def build(self):
|
||||
obfsArg = {}
|
||||
pinSHA256Arg = {}
|
||||
|
||||
if self.obfs and self.obfsPassword:
|
||||
obfsArg['obfs'] = {
|
||||
'type': self.obfs,
|
||||
self.obfs: {
|
||||
'password': self.obfsPassword,
|
||||
},
|
||||
}
|
||||
|
||||
if self.pinSHA256:
|
||||
pinSHA256Arg['pinSHA256'] = self.pinSHA256
|
||||
|
||||
return {
|
||||
'server': self.server,
|
||||
'auth': self.auth,
|
||||
'tls': {
|
||||
'sni': self.sni,
|
||||
'insecure': self.insecure,
|
||||
**pinSHA256Arg,
|
||||
},
|
||||
**obfsArg,
|
||||
'socks5': {
|
||||
'listen': '127.0.0.1:10808',
|
||||
},
|
||||
'http': {
|
||||
'listen': '127.0.0.1:10809',
|
||||
},
|
||||
}
|
||||
@@ -1,455 +0,0 @@
|
||||
# Copyright (C) 2023 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from Furious.Utility.Constants import APP, PLATFORM, LogType
|
||||
from Furious.Utility.Utility import getAbsolutePath, eventLoopWait, isPythonw
|
||||
|
||||
from PySide6 import QtCore
|
||||
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
import time
|
||||
import ujson
|
||||
import logging
|
||||
import functools
|
||||
import threading
|
||||
import multiprocessing
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# 300ms
|
||||
STDOUT_REDIRECT_THRESHOLD = 300
|
||||
|
||||
|
||||
class Core:
|
||||
class ExitCode:
|
||||
ConfigurationError = 23
|
||||
# Windows: 4294967295. Darwin, Linux: 255 (-1)
|
||||
ServerStartFailure = 4294967295 if PLATFORM == 'Windows' else 255
|
||||
# Windows shutting down
|
||||
SystemShuttingDown = 0x40010004
|
||||
|
||||
def __init__(self, *args, exitCallback=None, **kwargs):
|
||||
self._process = None
|
||||
self._exitCallback = exitCallback
|
||||
self._msgQueue = multiprocessing.Queue()
|
||||
|
||||
@QtCore.Slot()
|
||||
def appendCoreLog():
|
||||
line = self.getLineNoWait()
|
||||
|
||||
if line and not line.isspace():
|
||||
APP().logViewerWidget.appendLog(LogType.Core, line)
|
||||
|
||||
self._stdoutTimer = QtCore.QTimer()
|
||||
self._stdoutTimer.timeout.connect(appendCoreLog)
|
||||
|
||||
@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 isAlive(self):
|
||||
if isinstance(self._process, multiprocessing.Process):
|
||||
return self._process.is_alive()
|
||||
else:
|
||||
return False
|
||||
|
||||
def checkAlive(self):
|
||||
if 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._stdoutTimer.stop()
|
||||
self._daemonTimer.stop()
|
||||
|
||||
if callable(self._exitCallback):
|
||||
self._exitCallback(self._process.exitcode)
|
||||
|
||||
# Reset internal process
|
||||
self._process = None
|
||||
|
||||
return False
|
||||
else:
|
||||
return False
|
||||
|
||||
def start(self, *args, **kwargs):
|
||||
logger.info(f'{self.name()} {self.version()} started')
|
||||
|
||||
waitCore = kwargs.pop('waitCore', True)
|
||||
waitTime = kwargs.pop('waitTime', 2000)
|
||||
|
||||
self._process = multiprocessing.Process(**kwargs, daemon=True)
|
||||
self._process.start()
|
||||
|
||||
if waitCore:
|
||||
# Wait for the core to start up completely
|
||||
eventLoopWait(waitTime)
|
||||
|
||||
if self.checkAlive():
|
||||
# Start core daemon
|
||||
self._stdoutTimer.start(STDOUT_REDIRECT_THRESHOLD)
|
||||
self._daemonTimer.start(2000)
|
||||
|
||||
def stop(self):
|
||||
if self.isAlive():
|
||||
self._stdoutTimer.stop()
|
||||
self._daemonTimer.stop()
|
||||
|
||||
self._process.terminate()
|
||||
self._process.join()
|
||||
|
||||
logger.info(
|
||||
f'{self.name()} terminated with exitcode {self._process.exitcode}'
|
||||
)
|
||||
|
||||
def getLineNoWait(self):
|
||||
try:
|
||||
return self._msgQueue.get_nowait()
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return ''
|
||||
|
||||
|
||||
class StdoutRedirectHelper:
|
||||
TemporaryDir = QtCore.QTemporaryDir()
|
||||
|
||||
@staticmethod
|
||||
def launch(msgQueue, entrypoint, redirect):
|
||||
if not callable(entrypoint):
|
||||
return
|
||||
|
||||
if (
|
||||
not StdoutRedirectHelper.TemporaryDir.isValid()
|
||||
or not redirect
|
||||
# pythonw.exe
|
||||
or isPythonw()
|
||||
):
|
||||
# Call entrypoint directly
|
||||
entrypoint()
|
||||
|
||||
return
|
||||
|
||||
temporaryFile = StdoutRedirectHelper.TemporaryDir.filePath(str(uuid.uuid4()))
|
||||
tmpFileStream = open(temporaryFile, 'w+b')
|
||||
stdoutFileno_ = sys.stdout.fileno()
|
||||
stderrFileno_ = sys.stderr.fileno()
|
||||
|
||||
sys.stdout.close()
|
||||
sys.stderr.close()
|
||||
|
||||
# Redirect
|
||||
os.dup2(tmpFileStream.fileno(), stdoutFileno_)
|
||||
os.dup2(tmpFileStream.fileno(), stderrFileno_)
|
||||
|
||||
sys.stdout = tmpFileStream
|
||||
sys.stderr = tmpFileStream
|
||||
|
||||
def produceMsg():
|
||||
with open(temporaryFile, 'rb') as file:
|
||||
while True:
|
||||
for line in iter(file.readline, b''):
|
||||
if line and not line.isspace():
|
||||
try:
|
||||
msgQueue.put_nowait(line.decode('utf-8', 'replace'))
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
pass
|
||||
|
||||
time.sleep(STDOUT_REDIRECT_THRESHOLD / 1000)
|
||||
|
||||
msgThread = threading.Thread(target=produceMsg, daemon=True)
|
||||
msgThread.start()
|
||||
|
||||
with tmpFileStream:
|
||||
entrypoint()
|
||||
|
||||
|
||||
def startXrayCore(json, msgQueue):
|
||||
try:
|
||||
import xray
|
||||
except ImportError:
|
||||
# Fake running process
|
||||
while True:
|
||||
pass
|
||||
else:
|
||||
if xray.__version__ <= '1.8.4':
|
||||
redirect = False
|
||||
else:
|
||||
redirect = True
|
||||
|
||||
# Can be redirected
|
||||
|
||||
if not isPythonw():
|
||||
StdoutRedirectHelper.launch(
|
||||
msgQueue, lambda: xray.startFromJSON(json), redirect
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
if not redirect:
|
||||
xray.startFromJSON(json)
|
||||
|
||||
return
|
||||
|
||||
def xrayPythonwProduceMsg():
|
||||
try:
|
||||
jsonObject = ujson.loads(json)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
xray.startFromJSON(json)
|
||||
|
||||
return
|
||||
|
||||
loggerPath = []
|
||||
fileStream = []
|
||||
|
||||
for attr in ['access', 'error']:
|
||||
try:
|
||||
path = jsonObject['log'][attr]
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
continue
|
||||
|
||||
if path not in loggerPath:
|
||||
loggerPath.append(path)
|
||||
|
||||
try:
|
||||
stream = open(path, 'rb')
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
pass
|
||||
else:
|
||||
stream.seek(0, io.SEEK_END)
|
||||
|
||||
fileStream.append(stream)
|
||||
|
||||
def produceMsg():
|
||||
while True:
|
||||
for file in fileStream:
|
||||
for line in iter(file.readline, b''):
|
||||
if line and not line.isspace():
|
||||
try:
|
||||
msgQueue.put_nowait(line.decode('utf-8', 'replace'))
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
pass
|
||||
|
||||
time.sleep(STDOUT_REDIRECT_THRESHOLD / 1000)
|
||||
|
||||
try:
|
||||
msgThread = threading.Thread(target=produceMsg, daemon=True)
|
||||
msgThread.start()
|
||||
|
||||
xray.startFromJSON(json)
|
||||
finally:
|
||||
for stream in fileStream:
|
||||
stream.close()
|
||||
|
||||
xrayPythonwProduceMsg()
|
||||
|
||||
|
||||
class XrayCore(Core):
|
||||
class ExitCode:
|
||||
ConfigurationError = 23
|
||||
# Windows: 4294967295. Darwin, Linux: 255 (-1)
|
||||
ServerStartFailure = 4294967295 if PLATFORM == 'Windows' else 255
|
||||
# Windows shutting down
|
||||
SystemShuttingDown = 0x40010004
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def name():
|
||||
return 'Xray-core'
|
||||
|
||||
@staticmethod
|
||||
@functools.lru_cache(None)
|
||||
def version():
|
||||
try:
|
||||
import xray
|
||||
|
||||
return xray.__version__
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return '0.0.0'
|
||||
|
||||
def start(self, json, **kwargs):
|
||||
super().start(target=startXrayCore, args=(json, self._msgQueue), **kwargs)
|
||||
|
||||
|
||||
def startHysteria1(json, rule, mmdb, msgQueue):
|
||||
try:
|
||||
import hysteria
|
||||
except ImportError:
|
||||
# Fake running process
|
||||
while True:
|
||||
pass
|
||||
else:
|
||||
if hysteria.__version__ <= '1.3.5':
|
||||
redirect = False
|
||||
else:
|
||||
redirect = True
|
||||
|
||||
StdoutRedirectHelper.launch(
|
||||
msgQueue, lambda: hysteria.startFromJSON(json, rule, mmdb), redirect
|
||||
)
|
||||
|
||||
|
||||
class Hysteria1(Core):
|
||||
class ExitCode:
|
||||
ConfigurationError = 23
|
||||
RemoteNetworkError = 3
|
||||
# Windows shutting down
|
||||
SystemShuttingDown = 0x40010004
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def rule(rulePath):
|
||||
path = getAbsolutePath(str(rulePath))
|
||||
|
||||
try:
|
||||
with open(path, 'rb') as file:
|
||||
data = file.read()
|
||||
|
||||
logger.info(f'hysteria1 rule \'{path}\' load success')
|
||||
|
||||
return data
|
||||
except Exception as ex:
|
||||
# Any non-exit exceptions
|
||||
|
||||
logger.error(f'hysteria1 rule \'{path}\' load failed. {ex}')
|
||||
|
||||
return ''
|
||||
|
||||
@staticmethod
|
||||
def mmdb(mmdbPath):
|
||||
path = getAbsolutePath(str(mmdbPath))
|
||||
|
||||
try:
|
||||
with open(path, 'rb') as file:
|
||||
data = file.read()
|
||||
|
||||
logger.info(f'hysteria1 mmdb \'{path}\' load success')
|
||||
|
||||
return data
|
||||
except Exception as ex:
|
||||
# Any non-exit exceptions
|
||||
|
||||
logger.error(f'hysteria1 mmdb \'{path}\' load failed. {ex}')
|
||||
|
||||
return ''
|
||||
|
||||
@staticmethod
|
||||
def name():
|
||||
return 'Hysteria1'
|
||||
|
||||
@staticmethod
|
||||
@functools.lru_cache(None)
|
||||
def version():
|
||||
try:
|
||||
import hysteria
|
||||
|
||||
return hysteria.__version__
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return '0.0.0'
|
||||
|
||||
def start(self, json, rule, mmdb, **kwargs):
|
||||
super().start(
|
||||
target=startHysteria1, args=(json, rule, mmdb, self._msgQueue), **kwargs
|
||||
)
|
||||
|
||||
|
||||
def startHysteria2(json, msgQueue):
|
||||
try:
|
||||
import hysteria2
|
||||
except ImportError:
|
||||
# Fake running process
|
||||
while True:
|
||||
pass
|
||||
else:
|
||||
if hysteria2.__version__ <= '2.0.0.1':
|
||||
redirect = False
|
||||
else:
|
||||
redirect = True
|
||||
|
||||
StdoutRedirectHelper.launch(
|
||||
msgQueue, lambda: hysteria2.startFromJSON(json), redirect
|
||||
)
|
||||
|
||||
|
||||
class Hysteria2(Core):
|
||||
class ExitCode:
|
||||
ConfigurationError = 23
|
||||
# Windows: 4294967295. Darwin, Linux: 255 (-1)
|
||||
ServerStartFailure = 4294967295 if PLATFORM == 'Windows' else 255
|
||||
# Windows shutting down
|
||||
SystemShuttingDown = 0x40010004
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def name():
|
||||
return 'Hysteria2'
|
||||
|
||||
@staticmethod
|
||||
@functools.lru_cache(None)
|
||||
def version():
|
||||
try:
|
||||
import hysteria2
|
||||
|
||||
return hysteria2.__version__
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return '0.0.0'
|
||||
|
||||
def start(self, json, **kwargs):
|
||||
super().start(target=startHysteria2, args=(json, self._msgQueue), **kwargs)
|
||||
@@ -0,0 +1,429 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.Interface import *
|
||||
from Furious.PyFramework import *
|
||||
from Furious.QtFramework import *
|
||||
from Furious.Library import *
|
||||
from Furious.Utility import *
|
||||
from Furious.Core import *
|
||||
|
||||
import uuid
|
||||
import logging
|
||||
import functools
|
||||
import subprocess
|
||||
|
||||
__all__ = ['CoreManager']
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def fixLogObjectPath(config: ConfigurationFactory, attr: str, value: str, log=True):
|
||||
try:
|
||||
path = config['log'][attr]
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
config['log'][attr] = path = ''
|
||||
|
||||
if not isinstance(path, str) and not isinstance(path, bytes):
|
||||
config['log'][attr] = path = ''
|
||||
|
||||
if path == '':
|
||||
if isPythonw() and StdoutRedirectHelper.TemporaryDir.isValid():
|
||||
# Redirect implementation for pythonw environment
|
||||
config['log'][attr] = StdoutRedirectHelper.TemporaryDir.filePath(value)
|
||||
else:
|
||||
# Relative path fails if booting on start up
|
||||
# on Windows, when packed using nuitka...
|
||||
|
||||
# Fix relative path if needed. User cannot feel this operation.
|
||||
config['log'][attr] = getAbsolutePath(path)
|
||||
|
||||
result = config['log'][attr]
|
||||
|
||||
if result:
|
||||
try:
|
||||
# Create a new file
|
||||
with open(result, 'x'):
|
||||
pass
|
||||
except FileExistsError:
|
||||
pass
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
pass
|
||||
|
||||
if log:
|
||||
logger.info(
|
||||
f'{XrayCore.name()}: {attr} log is specified as \'{path}\'. '
|
||||
f'Fixed to \'{result}\''
|
||||
)
|
||||
|
||||
|
||||
class CoreManager(SupportExitCleanup):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.coresPool = []
|
||||
|
||||
def start(
|
||||
self,
|
||||
config: ConfigurationFactory,
|
||||
routing: str,
|
||||
exitCallback=None,
|
||||
msgCallback=None,
|
||||
tunMsgCallback=None,
|
||||
deepcopy=True,
|
||||
proxyModeOnly=False,
|
||||
log=True,
|
||||
**kwargs,
|
||||
) -> bool:
|
||||
if deepcopy:
|
||||
copy = config.deepcopy()
|
||||
else:
|
||||
copy = config
|
||||
|
||||
if isinstance(copy, ConfigurationXray):
|
||||
if copy.get('log') is None or not isinstance(copy['log'], dict):
|
||||
copy['log'] = {
|
||||
'access': '',
|
||||
'error': '',
|
||||
'loglevel': 'warning',
|
||||
}
|
||||
|
||||
logRedirectValue = str(uuid.uuid4())
|
||||
|
||||
# Fix logObject
|
||||
for attr in ['access', 'error']:
|
||||
fixLogObjectPath(copy, attr, logRedirectValue, log)
|
||||
|
||||
if routing == 'Bypass Mainland China':
|
||||
routingObject = {
|
||||
'domainStrategy': 'IPIfNonMatch',
|
||||
'domainMatcher': 'hybrid',
|
||||
'rules': [
|
||||
{
|
||||
'type': 'field',
|
||||
'domain': [
|
||||
'geosite:category-ads-all',
|
||||
],
|
||||
'outboundTag': 'block',
|
||||
},
|
||||
{
|
||||
'type': 'field',
|
||||
'domain': [
|
||||
'geosite:cn',
|
||||
],
|
||||
'outboundTag': 'direct',
|
||||
},
|
||||
{
|
||||
'type': 'field',
|
||||
'ip': [
|
||||
'geoip:private',
|
||||
'geoip:cn',
|
||||
],
|
||||
'outboundTag': 'direct',
|
||||
},
|
||||
{
|
||||
'type': 'field',
|
||||
'port': '0-65535',
|
||||
'outboundTag': 'proxy',
|
||||
},
|
||||
],
|
||||
}
|
||||
# elif routing == 'Bypass Iran':
|
||||
# routingObject = {
|
||||
# 'domainStrategy': 'IPIfNonMatch',
|
||||
# 'domainMatcher': 'hybrid',
|
||||
# 'rules': [
|
||||
# {
|
||||
# 'type': 'field',
|
||||
# 'domain': [
|
||||
# 'geosite:category-ads-all',
|
||||
# 'iran:ads',
|
||||
# ],
|
||||
# 'outboundTag': 'block',
|
||||
# },
|
||||
# {
|
||||
# 'type': 'field',
|
||||
# 'domain': [
|
||||
# 'iran:ir',
|
||||
# 'iran:other',
|
||||
# ],
|
||||
# 'outboundTag': 'direct',
|
||||
# },
|
||||
# {
|
||||
# 'type': 'field',
|
||||
# 'ip': [
|
||||
# 'geoip:private',
|
||||
# 'geoip:ir',
|
||||
# ],
|
||||
# 'outboundTag': 'direct',
|
||||
# },
|
||||
# {
|
||||
# 'type': 'field',
|
||||
# 'port': '0-65535',
|
||||
# 'outboundTag': 'proxy',
|
||||
# },
|
||||
# ],
|
||||
# }
|
||||
elif routing == 'Global':
|
||||
routingObject = {}
|
||||
elif routing == 'Custom':
|
||||
routingObject = copy.get('routing', {})
|
||||
else:
|
||||
routingObject = {}
|
||||
|
||||
if log:
|
||||
logger.info(f'core {XrayCore.name()} configured')
|
||||
logger.info(f'routing is {routing}')
|
||||
logger.info(f'RoutingObject: {routingObject}')
|
||||
|
||||
copy['routing'] = routingObject
|
||||
|
||||
core = XrayCore(exitCallback=exitCallback, msgCallback=msgCallback)
|
||||
success = core.start(copy, **kwargs)
|
||||
elif isinstance(copy, ConfigurationHysteria1):
|
||||
if routing == 'Bypass Mainland China':
|
||||
routingObject = {
|
||||
'rule': DATA_DIR / 'hysteria' / 'bypass-mainland-China.acl',
|
||||
'mmdb': DATA_DIR / 'hysteria' / 'country.mmdb',
|
||||
}
|
||||
# elif routing == 'Bypass Iran':
|
||||
# routingObject = {
|
||||
# 'rule': DATA_DIR / 'hysteria' / 'bypass-Iran.acl',
|
||||
# 'mmdb': DATA_DIR / 'hysteria' / 'country.mmdb',
|
||||
# }
|
||||
elif routing == 'Global':
|
||||
routingObject = {
|
||||
'rule': '',
|
||||
'mmdb': '',
|
||||
}
|
||||
elif routing == 'Custom':
|
||||
routingObject = {
|
||||
'rule': copy.get('acl', ''),
|
||||
'mmdb': copy.get('mmdb', ''),
|
||||
}
|
||||
else:
|
||||
routingObject = {
|
||||
'rule': '',
|
||||
'mmdb': '',
|
||||
}
|
||||
|
||||
if log:
|
||||
logger.info(f'core {Hysteria1.name()} configured')
|
||||
logger.info(f'routing is {routing}')
|
||||
logger.info(f'RoutingObject: {routingObject}')
|
||||
|
||||
core = Hysteria1(exitCallback=exitCallback, msgCallback=msgCallback)
|
||||
success = core.start(
|
||||
copy,
|
||||
Hysteria1.rule(routingObject.get('rule', '')),
|
||||
Hysteria1.mmdb(routingObject.get('mmdb', '')),
|
||||
**kwargs,
|
||||
)
|
||||
elif isinstance(copy, ConfigurationHysteria2):
|
||||
if log:
|
||||
logger.info(f'core {Hysteria2.name()} configured')
|
||||
|
||||
core = Hysteria2(exitCallback=exitCallback, msgCallback=msgCallback)
|
||||
success = core.start(copy, **kwargs)
|
||||
else:
|
||||
core = None
|
||||
success = False
|
||||
|
||||
if core is not None:
|
||||
self.coresPool.append(core)
|
||||
|
||||
if not success:
|
||||
logger.error(f'core {core.name()} start failed')
|
||||
|
||||
return success
|
||||
|
||||
# VPN Mode handling
|
||||
if not proxyModeOnly and isVPNMode():
|
||||
# Currently VPN Mode is only supported on Windows and macOS
|
||||
if PLATFORM == 'Windows' or PLATFORM == 'Darwin':
|
||||
if PLATFORM == 'Windows':
|
||||
# cleanup first
|
||||
SystemRoutingTable.delete(
|
||||
'0.0.0.0', APPLICATION_TUN_GATEWAY_ADDRESS
|
||||
)
|
||||
|
||||
defaultGateway = SystemRoutingTable.getDefaultGateway()
|
||||
|
||||
if PLATFORM == 'Darwin':
|
||||
# Need this?
|
||||
defaultGateway = list(
|
||||
# Filter TUN Gateway
|
||||
filter(
|
||||
lambda x: x != APPLICATION_TUN_GATEWAY_ADDRESS,
|
||||
defaultGateway,
|
||||
)
|
||||
)
|
||||
|
||||
if len(defaultGateway) != 1:
|
||||
logger.error(f'bad default gateway: {defaultGateway}')
|
||||
|
||||
return False
|
||||
|
||||
if PLATFORM == 'Windows':
|
||||
gateway, interfaceIP = defaultGateway[0]
|
||||
else:
|
||||
gateway, interfaceIP = defaultGateway[0], None
|
||||
|
||||
tun = Tun2socks(exitCallback=exitCallback, msgCallback=tunMsgCallback)
|
||||
self.coresPool.append(tun)
|
||||
|
||||
if not tun.start(
|
||||
APPLICATION_TUN_DEVICE_NAME,
|
||||
APPLICATION_TUN_NETWORK_INTERFACE_NAME,
|
||||
'info',
|
||||
f'socks5://{copy.socksProxyEndpoint()}',
|
||||
'',
|
||||
):
|
||||
return False
|
||||
|
||||
address = copy.itemAddress
|
||||
|
||||
if not isValidIPAddress(address):
|
||||
error, resolved = DNSResolver.resolve(
|
||||
address, *parseHostPort(copy.httpProxyEndpoint())
|
||||
)
|
||||
|
||||
if error:
|
||||
logger.error(f'DNS resolution failed: {address}')
|
||||
|
||||
SystemRoutingTable.Relations.clear()
|
||||
|
||||
return False
|
||||
else:
|
||||
for address in resolved:
|
||||
SystemRoutingTable.Relations.append([address, gateway])
|
||||
else:
|
||||
SystemRoutingTable.Relations.append([address, gateway])
|
||||
|
||||
if PLATFORM == 'Windows':
|
||||
foundDevice = False
|
||||
|
||||
for counter in range(0, 10000, 100):
|
||||
try:
|
||||
result = runExternalCommand(
|
||||
'ipconfig',
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=True,
|
||||
)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
break
|
||||
else:
|
||||
stdout = result.stdout.decode('utf-8', 'replace')
|
||||
|
||||
if stdout.find(APPLICATION_TUN_DEVICE_NAME) >= 0:
|
||||
foundDevice = True
|
||||
|
||||
logger.info(
|
||||
f'find TUN device \'{APPLICATION_TUN_DEVICE_NAME}\' success. '
|
||||
f'Counter: {counter}'
|
||||
)
|
||||
|
||||
break
|
||||
|
||||
PySide6LegacyEventLoopWait(100)
|
||||
|
||||
if not foundDevice:
|
||||
logger.error(
|
||||
f'find TUN device \'{APPLICATION_TUN_DEVICE_NAME}\' failed'
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
alias = SystemRoutingTable.WIN32GetInterfaceAliasByIP(interfaceIP)
|
||||
|
||||
if alias:
|
||||
|
||||
def _windowsCleanup(_alias):
|
||||
SystemRoutingTable.WIN32SetInterfaceDNS(_alias)
|
||||
SystemRoutingTable.WIN32FlushDNSCache()
|
||||
|
||||
tun.cleanup = functools.partial(_windowsCleanup, alias)
|
||||
|
||||
SystemRoutingTable.WIN32SetInterfaceDNS(
|
||||
alias, '127.0.0.1', False
|
||||
)
|
||||
|
||||
SystemRoutingTable.addRelations()
|
||||
SystemRoutingTable.WIN32SetInterfaceDNS(
|
||||
APPLICATION_TUN_DEVICE_NAME, '8.8.8.8', False
|
||||
)
|
||||
SystemRoutingTable.setDeviceGateway(
|
||||
APPLICATION_TUN_DEVICE_NAME,
|
||||
APPLICATION_TUN_IP_ADDRESS,
|
||||
APPLICATION_TUN_GATEWAY_ADDRESS,
|
||||
)
|
||||
SystemRoutingTable.WIN32FlushDNSCache()
|
||||
|
||||
if PLATFORM == 'Darwin':
|
||||
for source in [
|
||||
*list(f'{2 ** (8 - x)}.0.0.0/{x}' for x in range(8, 0, -1)),
|
||||
'198.18.0.0/15',
|
||||
]:
|
||||
SystemRoutingTable.Relations.append(
|
||||
[source, APPLICATION_TUN_GATEWAY_ADDRESS]
|
||||
)
|
||||
|
||||
servers = SystemRoutingTable.DarwinGetDNSServers()
|
||||
|
||||
def _darwinCleanup(_servers):
|
||||
for _service, _dnsserver in _servers:
|
||||
SystemRoutingTable.DarwinSetDNSServers(_service, _dnsserver)
|
||||
|
||||
tun.cleanup = functools.partial(_darwinCleanup, servers)
|
||||
|
||||
for service, dnsserver in servers:
|
||||
SystemRoutingTable.DarwinSetDNSServers(service, '8.8.8.8')
|
||||
|
||||
SystemRoutingTable.setDeviceGateway(
|
||||
APPLICATION_TUN_DEVICE_NAME,
|
||||
APPLICATION_TUN_IP_ADDRESS,
|
||||
APPLICATION_TUN_GATEWAY_ADDRESS,
|
||||
)
|
||||
SystemRoutingTable.addRelations()
|
||||
|
||||
return True
|
||||
|
||||
def allRunning(self) -> bool:
|
||||
return all(core.isRunning() for core in self.coresPool)
|
||||
|
||||
def anyRunning(self) -> bool:
|
||||
return any(core.isRunning() for core in self.coresPool)
|
||||
|
||||
def stopAll(self):
|
||||
if self.coresPool:
|
||||
for core in self.coresPool:
|
||||
if isinstance(core, CoreFactory):
|
||||
core.stop()
|
||||
|
||||
self.coresPool.clear()
|
||||
|
||||
def cleanup(self):
|
||||
self.stopAll()
|
||||
@@ -0,0 +1,163 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.Interface import *
|
||||
from Furious.QtFramework import *
|
||||
from Furious.Library import *
|
||||
from Furious.Utility import *
|
||||
|
||||
from typing import Union
|
||||
|
||||
import time
|
||||
import logging
|
||||
import multiprocessing
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ['Hysteria1']
|
||||
|
||||
|
||||
def startHysteria1(jsonString, rule, mmdb, msgQueue: multiprocessing.Queue):
|
||||
try:
|
||||
import hysteria
|
||||
except ImportError:
|
||||
# Fake running process
|
||||
while True:
|
||||
time.sleep(1)
|
||||
else:
|
||||
if hysteria.__version__ <= '1.3.5':
|
||||
redirect = False
|
||||
else:
|
||||
redirect = True
|
||||
|
||||
StdoutRedirectHelper.launch(
|
||||
msgQueue, lambda: hysteria.startFromJSON(jsonString, rule, mmdb), redirect
|
||||
)
|
||||
|
||||
|
||||
class Hysteria1(CoreProcess):
|
||||
class ExitCode:
|
||||
ConfigurationError = 23
|
||||
RemoteNetworkError = 3
|
||||
# Windows shutting down
|
||||
SystemShuttingDown = 0x40010004
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
|
||||
@staticmethod
|
||||
def rule(rulePath):
|
||||
if isinstance(rulePath, str) and rulePath == '':
|
||||
return ''
|
||||
|
||||
try:
|
||||
path = getAbsolutePath(str(rulePath))
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
logger.error('invalid hysteria1 rule path. Fall back to empty')
|
||||
|
||||
return ''
|
||||
|
||||
try:
|
||||
with open(path, 'rb') as file:
|
||||
data = file.read()
|
||||
|
||||
logger.info(f'hysteria1 rule \'{path}\' load success')
|
||||
|
||||
return data
|
||||
except Exception as ex:
|
||||
# Any non-exit exceptions
|
||||
|
||||
logger.error(
|
||||
f'hysteria1 rule \'{path}\' load failed. {ex}. Fall back to empty'
|
||||
)
|
||||
|
||||
return ''
|
||||
|
||||
@staticmethod
|
||||
def mmdb(mmdbPath):
|
||||
if isinstance(mmdbPath, str) and mmdbPath == '':
|
||||
return ''
|
||||
|
||||
try:
|
||||
path = getAbsolutePath(str(mmdbPath))
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
logger.error('invalid hysteria1 mmdb path. Fall back to empty')
|
||||
|
||||
return ''
|
||||
|
||||
try:
|
||||
with open(path, 'rb') as file:
|
||||
data = file.read()
|
||||
|
||||
logger.info(f'hysteria1 mmdb \'{path}\' load success')
|
||||
|
||||
return data
|
||||
except Exception as ex:
|
||||
# Any non-exit exceptions
|
||||
|
||||
logger.error(
|
||||
f'hysteria1 mmdb \'{path}\' load failed. {ex}. Fall back to empty'
|
||||
)
|
||||
|
||||
return ''
|
||||
|
||||
@staticmethod
|
||||
def name() -> str:
|
||||
return 'Hysteria1'
|
||||
|
||||
@staticmethod
|
||||
def version() -> str:
|
||||
try:
|
||||
import hysteria
|
||||
|
||||
return hysteria.__version__
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return '0.0.0'
|
||||
|
||||
def startFromArgs(self, jsonString: str, rule, mmdb, **kwargs) -> bool:
|
||||
self.registerCurrentJSONConfig(jsonString)
|
||||
|
||||
return super().start(
|
||||
target=startHysteria1,
|
||||
args=(jsonString, rule, mmdb, self.msgQueue),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def start(self, config: Union[str, dict], rule, mmdb, **kwargs) -> bool:
|
||||
if isinstance(config, str):
|
||||
return self.startFromArgs(config, rule, mmdb, **kwargs)
|
||||
elif isinstance(config, ConfigurationFactory):
|
||||
return self.startFromArgs(config.toJSONString(), rule, mmdb, **kwargs)
|
||||
elif isinstance(config, dict):
|
||||
try:
|
||||
jsonString = UJSONEncoder.encode(config)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return False
|
||||
else:
|
||||
return self.startFromArgs(jsonString, rule, mmdb, **kwargs)
|
||||
else:
|
||||
return False
|
||||
@@ -0,0 +1,102 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.Interface import *
|
||||
from Furious.QtFramework import *
|
||||
from Furious.Library import *
|
||||
from Furious.Utility import *
|
||||
|
||||
from typing import Union
|
||||
|
||||
import time
|
||||
import logging
|
||||
import multiprocessing
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ['Hysteria2']
|
||||
|
||||
|
||||
def startHysteria2(jsonString: str, msgQueue: multiprocessing.Queue):
|
||||
try:
|
||||
import hysteria2
|
||||
except ImportError:
|
||||
# Fake running process
|
||||
while True:
|
||||
time.sleep(1)
|
||||
else:
|
||||
if hysteria2.__version__ <= '2.0.0.1':
|
||||
redirect = False
|
||||
else:
|
||||
redirect = True
|
||||
|
||||
StdoutRedirectHelper.launch(
|
||||
msgQueue, lambda: hysteria2.startFromJSON(jsonString), redirect
|
||||
)
|
||||
|
||||
|
||||
class Hysteria2(CoreProcess):
|
||||
class ExitCode:
|
||||
ConfigurationError = 23
|
||||
# Windows: 4294967295. Darwin, Linux: 255 (-1)
|
||||
ServerStartFailure = 4294967295 if PLATFORM == 'Windows' else 255
|
||||
# Windows shutting down
|
||||
SystemShuttingDown = 0x40010004
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
|
||||
@staticmethod
|
||||
def name() -> str:
|
||||
return 'Hysteria2'
|
||||
|
||||
@staticmethod
|
||||
def version() -> str:
|
||||
try:
|
||||
import hysteria2
|
||||
|
||||
return hysteria2.__version__
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return '0.0.0'
|
||||
|
||||
def startFromArgs(self, jsonString: str, **kwargs) -> bool:
|
||||
self.registerCurrentJSONConfig(jsonString)
|
||||
|
||||
return super().start(
|
||||
target=startHysteria2, args=(jsonString, self.msgQueue), **kwargs
|
||||
)
|
||||
|
||||
def start(self, config: Union[str, dict], **kwargs) -> bool:
|
||||
if isinstance(config, str):
|
||||
return self.startFromArgs(config, **kwargs)
|
||||
elif isinstance(config, ConfigurationFactory):
|
||||
return self.startFromArgs(config.toJSONString(), **kwargs)
|
||||
elif isinstance(config, dict):
|
||||
try:
|
||||
jsonString = UJSONEncoder.encode(config)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return False
|
||||
else:
|
||||
return self.startFromArgs(jsonString, **kwargs)
|
||||
else:
|
||||
return False
|
||||
@@ -1,213 +0,0 @@
|
||||
# Copyright (C) 2023 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from Furious.Core.Core import XrayCore, Hysteria1, Hysteria2
|
||||
from Furious.Utility.Utility import Protocol, protocolRepr, parseHostPort
|
||||
|
||||
|
||||
class Intellisense:
|
||||
@staticmethod
|
||||
def getCoreType(ob):
|
||||
def hasField(field):
|
||||
return ob.get(field) is not None
|
||||
|
||||
if hasField('inbounds') or hasField('outbounds'):
|
||||
# Assuming is Xray-Core
|
||||
return XrayCore.name()
|
||||
|
||||
if hasField('server'):
|
||||
if (
|
||||
hasField('protocol')
|
||||
or hasField('up_mbps')
|
||||
or hasField('down_mbps')
|
||||
or hasField('auth_str')
|
||||
or hasField('alpn')
|
||||
or hasField('server_name')
|
||||
or hasField('insecure')
|
||||
or hasField('recv_window_conn')
|
||||
or hasField('recv_window')
|
||||
or isinstance(ob.get('obfs'), str)
|
||||
or hasField('fast_open')
|
||||
or hasField('lazy_start')
|
||||
):
|
||||
return Hysteria1.name()
|
||||
if (
|
||||
hasField('tls')
|
||||
or hasField('transport')
|
||||
or hasField('quic')
|
||||
or hasField('bandwidth')
|
||||
or hasField('tcpForwarding')
|
||||
or hasField('udpForwarding')
|
||||
or hasField('tcpTProxy')
|
||||
or hasField('udpTProxy')
|
||||
or isinstance(ob.get('obfs'), dict)
|
||||
or hasField('fastOpen')
|
||||
or hasField('lazy')
|
||||
):
|
||||
return Hysteria2.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'])
|
||||
|
||||
return ''
|
||||
|
||||
if Intellisense.getCoreType(ob) == Hysteria1.name():
|
||||
return Protocol.Hysteria1
|
||||
|
||||
if Intellisense.getCoreType(ob) == Hysteria2.name():
|
||||
return Protocol.Hysteria2
|
||||
|
||||
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':
|
||||
protocol = protocolRepr(outbound['protocol'])
|
||||
|
||||
if protocol == Protocol.VMess or protocol == Protocol.VLESS:
|
||||
return ';'.join(
|
||||
list(
|
||||
f'{server["address"]}'
|
||||
for server in outbound['settings']['vnext']
|
||||
)
|
||||
)
|
||||
|
||||
if (
|
||||
protocol == Protocol.Shadowsocks
|
||||
or protocol == Protocol.Trojan
|
||||
):
|
||||
return ';'.join(
|
||||
list(
|
||||
f'{server["address"]}'
|
||||
for server in outbound['settings']['servers']
|
||||
)
|
||||
)
|
||||
|
||||
return ''
|
||||
|
||||
if (
|
||||
Intellisense.getCoreType(ob) == Hysteria1.name()
|
||||
or Intellisense.getCoreType(ob) == Hysteria2.name()
|
||||
):
|
||||
server = ob['server']
|
||||
|
||||
pos = server.rfind(':')
|
||||
|
||||
if pos == -1:
|
||||
return server
|
||||
else:
|
||||
return server[:pos]
|
||||
|
||||
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':
|
||||
protocol = protocolRepr(outbound['protocol'])
|
||||
|
||||
if protocol == Protocol.VMess or protocol == Protocol.VLESS:
|
||||
return ';'.join(
|
||||
list(
|
||||
f'{server["port"]}'
|
||||
for server in outbound['settings']['vnext']
|
||||
)
|
||||
)
|
||||
|
||||
if (
|
||||
protocol == Protocol.Shadowsocks
|
||||
or protocol == Protocol.Trojan
|
||||
):
|
||||
return ';'.join(
|
||||
list(
|
||||
f'{server["port"]}'
|
||||
for server in outbound['settings']['servers']
|
||||
)
|
||||
)
|
||||
|
||||
return ''
|
||||
|
||||
if (
|
||||
Intellisense.getCoreType(ob) == Hysteria1.name()
|
||||
or Intellisense.getCoreType(ob) == Hysteria2.name()
|
||||
):
|
||||
server = ob['server']
|
||||
|
||||
pos = server.rfind(':')
|
||||
|
||||
if pos == -1:
|
||||
return ''
|
||||
else:
|
||||
return server[pos + 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']
|
||||
|
||||
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']
|
||||
|
||||
return ''
|
||||
|
||||
return ''
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return ''
|
||||
@@ -1,190 +0,0 @@
|
||||
# Copyright (C) 2023 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from Furious.Core.Core import Core
|
||||
from Furious.Utility.Constants import (
|
||||
APP,
|
||||
PLATFORM,
|
||||
APPLICATION_NAME,
|
||||
DEFAULT_TOR_SOCKS_PORT,
|
||||
DEFAULT_TOR_HTTPS_PORT,
|
||||
LogType,
|
||||
)
|
||||
from Furious.Utility.Utility import AsyncSubprocessMessage, runCommand
|
||||
|
||||
from PySide6 import QtCore
|
||||
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import logging
|
||||
import tempfile
|
||||
import threading
|
||||
import functools
|
||||
import subprocess
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TorRelayStarter(AsyncSubprocessMessage):
|
||||
BOOTSTRAP_STATUS = re.compile(r'Bootstrapped ([0-9]+)%')
|
||||
TORDATA_TEMP_DIR = QtCore.QTemporaryDir()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.connectTimeoutCallback(self.processLine)
|
||||
|
||||
self.torRelay = None
|
||||
|
||||
self.bootstrapPercentage = 0
|
||||
|
||||
@property
|
||||
def torRelayStorageObj(self):
|
||||
# Handy reference
|
||||
return APP().TorRelayWidget.StorageObj
|
||||
|
||||
@staticmethod
|
||||
@functools.lru_cache(None)
|
||||
def getTorDataTempFilepath():
|
||||
if TorRelayStarter.TORDATA_TEMP_DIR.isValid():
|
||||
return TorRelayStarter.TORDATA_TEMP_DIR.path()
|
||||
else:
|
||||
return os.path.join(tempfile.gettempdir(), APPLICATION_NAME, 'tordata')
|
||||
|
||||
def launch(self, proxyServer):
|
||||
if PLATFORM == 'Windows':
|
||||
self.torRelay = subprocess.Popen(
|
||||
['tor', '-f', '-'],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW,
|
||||
)
|
||||
else:
|
||||
self.torRelay = subprocess.Popen(
|
||||
['tor', '-f', '-'],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
|
||||
torDataTempFilepath = TorRelayStarter.getTorDataTempFilepath()
|
||||
|
||||
logger.info(f'Tor CLI data directory is {torDataTempFilepath}')
|
||||
|
||||
torConfig = (
|
||||
f'SocksPort {self.torRelayStorageObj.get("socksTunnelPort", DEFAULT_TOR_SOCKS_PORT)}\n'
|
||||
f'HTTPTunnelPort {self.torRelayStorageObj.get("httpsTunnelPort", DEFAULT_TOR_HTTPS_PORT)}\n'
|
||||
f'Log {self.torRelayStorageObj.get("logLevel", "notice")} stdout\n'
|
||||
f'DataDirectory {torDataTempFilepath}\n'
|
||||
)
|
||||
|
||||
if proxyServer:
|
||||
logger.info(f'{TorRelay.name()} uses proxy server {proxyServer}')
|
||||
|
||||
# Use proxy
|
||||
self.torRelay.stdin.write(f'{torConfig}HTTPSProxy {proxyServer}\n'.encode())
|
||||
else:
|
||||
self.torRelay.stdin.write(torConfig.encode())
|
||||
|
||||
self.torRelay.stdin.close()
|
||||
|
||||
self.startDaemonThread(self.torRelay.stdout)
|
||||
# Begin get message
|
||||
self.startTimer()
|
||||
|
||||
@QtCore.Slot()
|
||||
def processLine(self):
|
||||
line = self.getLineNoWait()
|
||||
|
||||
if line:
|
||||
APP().logViewerWidget.appendLog(LogType.Tor, line)
|
||||
|
||||
match = TorRelayStarter.BOOTSTRAP_STATUS.search(line)
|
||||
|
||||
if match:
|
||||
percentage = int(match.group(1))
|
||||
|
||||
self.bootstrapPercentage = percentage
|
||||
|
||||
logger.info(f'{TorRelay.name()} bootstrapped {percentage}%')
|
||||
|
||||
|
||||
@functools.lru_cache(None)
|
||||
def getTorRelayVersion():
|
||||
try:
|
||||
result = runCommand(
|
||||
['tor', '--version'],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=True,
|
||||
)
|
||||
|
||||
# First line, 3rd param...
|
||||
return result.stdout.decode().split('\n')[0].split()[2]
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return TorRelay.VERSION_NOT_FOUND
|
||||
|
||||
|
||||
class TorRelay(Core):
|
||||
VERSION_NOT_FOUND = '0.0.0'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.starter = TorRelayStarter()
|
||||
|
||||
@staticmethod
|
||||
@functools.lru_cache(None)
|
||||
def checkIfExists():
|
||||
return TorRelay.version() != TorRelay.VERSION_NOT_FOUND
|
||||
|
||||
@staticmethod
|
||||
def name():
|
||||
return 'Tor Relay'
|
||||
|
||||
@staticmethod
|
||||
def version():
|
||||
return getTorRelayVersion()
|
||||
|
||||
@property
|
||||
def bootstrapPercentage(self):
|
||||
return self.starter.bootstrapPercentage
|
||||
|
||||
def start(self, *args, **kwargs):
|
||||
logger.info(f'{self.name()} {self.version()} started')
|
||||
|
||||
self.starter.launch(kwargs.get('proxyServer', ''))
|
||||
|
||||
def stop(self):
|
||||
if isinstance(self.starter.torRelay, subprocess.Popen):
|
||||
# Stop timer
|
||||
self.starter.stopTimer()
|
||||
|
||||
# Terminated by signal SIGTERM
|
||||
self.starter.torRelay.send_signal(signal.SIGTERM)
|
||||
|
||||
exitcode = self.starter.torRelay.wait()
|
||||
|
||||
logger.info(f'{self.name()} terminated with exitcode {exitcode}')
|
||||
|
||||
self.starter.bootstrapPercentage = 0
|
||||
# Reset relay
|
||||
self.starter.torRelay = None
|
||||
+43
-17
@@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2023 Loren Eteval <loren.eteval@proton.me>
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
@@ -15,38 +15,53 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from Furious.Core.Core import Core
|
||||
from Furious.Utility.RoutingTable import RoutingTable
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
from Furious.Interface import *
|
||||
from Furious.QtFramework import *
|
||||
from Furious.Library import *
|
||||
from Furious.Utility import *
|
||||
|
||||
import time
|
||||
import multiprocessing
|
||||
|
||||
__all__ = ['Tun2socks']
|
||||
|
||||
|
||||
def startTun2socks(*args, **kwargs):
|
||||
def startTun2socks(msgQueue: multiprocessing.Queue, *args):
|
||||
try:
|
||||
import tun2socks
|
||||
except ImportError:
|
||||
# Fake running process
|
||||
while True:
|
||||
pass
|
||||
time.sleep(1)
|
||||
else:
|
||||
tun2socks.startFromArgs(*args, **kwargs)
|
||||
if tun2socks.__version__ <= '2.5.1.1':
|
||||
redirect = False
|
||||
else:
|
||||
redirect = True
|
||||
|
||||
StdoutRedirectHelper.launch(
|
||||
msgQueue, lambda: tun2socks.startFromArgs(*args), redirect
|
||||
)
|
||||
|
||||
|
||||
class Tun2socks(Core):
|
||||
class Tun2socks(CoreProcess):
|
||||
class ExitCode:
|
||||
# Windows shutting down
|
||||
SystemShuttingDown = 0x40010004
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
|
||||
self.cleanup = None
|
||||
|
||||
@staticmethod
|
||||
def name():
|
||||
def name() -> str:
|
||||
return 'Tun2socks'
|
||||
|
||||
@staticmethod
|
||||
@functools.lru_cache(None)
|
||||
def version():
|
||||
def version() -> str:
|
||||
try:
|
||||
import tun2socks
|
||||
|
||||
@@ -56,14 +71,25 @@ class Tun2socks(Core):
|
||||
|
||||
return '0.0.0'
|
||||
|
||||
def start(self, device, networkInterface, logLevel, proxy, restAPI, **kwargs):
|
||||
super().start(
|
||||
def start(
|
||||
self,
|
||||
device: str,
|
||||
networkInterface: str,
|
||||
logLevel: str,
|
||||
proxy: str,
|
||||
restAPI: str,
|
||||
**kwargs,
|
||||
) -> bool:
|
||||
return super().start(
|
||||
target=startTun2socks,
|
||||
args=(device, networkInterface, logLevel, proxy, restAPI),
|
||||
args=(self.msgQueue, device, networkInterface, logLevel, proxy, restAPI),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def stop(self):
|
||||
RoutingTable.deleteRelations()
|
||||
SystemRoutingTable.deleteRelations()
|
||||
|
||||
if callable(self.cleanup):
|
||||
self.cleanup()
|
||||
|
||||
super().stop()
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.Interface import *
|
||||
from Furious.QtFramework import *
|
||||
from Furious.Library import *
|
||||
from Furious.Utility import *
|
||||
|
||||
from typing import Union
|
||||
|
||||
import io
|
||||
import time
|
||||
import threading
|
||||
import multiprocessing
|
||||
|
||||
__all__ = ['XrayCore']
|
||||
|
||||
|
||||
def startXrayCore(jsonString: str, msgQueue: multiprocessing.Queue):
|
||||
try:
|
||||
import xray
|
||||
except ImportError:
|
||||
# Fake running process
|
||||
while True:
|
||||
time.sleep(1)
|
||||
else:
|
||||
if xray.__version__ <= '1.8.4':
|
||||
redirect = False
|
||||
else:
|
||||
# Can be redirected
|
||||
redirect = True
|
||||
|
||||
if not isPythonw():
|
||||
return StdoutRedirectHelper.launch(
|
||||
msgQueue, lambda: xray.startFromJSON(jsonString), redirect
|
||||
)
|
||||
|
||||
if not redirect:
|
||||
return xray.startFromJSON(jsonString)
|
||||
|
||||
def xrayPythonwProduceMsg():
|
||||
try:
|
||||
jsonObject = UJSONEncoder.decode(jsonString)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
xray.startFromJSON(jsonString)
|
||||
else:
|
||||
loggingPath = []
|
||||
fileStreams = []
|
||||
|
||||
for loggingAttr in ['access', 'error']:
|
||||
try:
|
||||
path = jsonObject['log'][loggingAttr]
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
continue
|
||||
|
||||
if path not in loggingPath:
|
||||
loggingPath.append(path)
|
||||
|
||||
try:
|
||||
stream = open(path, 'rb')
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
pass
|
||||
else:
|
||||
stream.seek(0, io.SEEK_END)
|
||||
|
||||
fileStreams.append(stream)
|
||||
|
||||
def produceMsg():
|
||||
while True:
|
||||
for file in fileStreams:
|
||||
for line in iter(file.readline, b''):
|
||||
if line and not line.isspace():
|
||||
try:
|
||||
msgQueue.put_nowait(
|
||||
line.decode('utf-8', 'replace')
|
||||
)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
pass
|
||||
|
||||
time.sleep(CoreProcess.MESG_PRODUCE_THRESHOLD / 1000)
|
||||
|
||||
try:
|
||||
if fileStreams:
|
||||
msgThread = threading.Thread(target=produceMsg, daemon=True)
|
||||
msgThread.start()
|
||||
|
||||
xray.startFromJSON(jsonString)
|
||||
finally:
|
||||
for stream in fileStreams:
|
||||
stream.close()
|
||||
|
||||
xrayPythonwProduceMsg()
|
||||
|
||||
|
||||
class XrayCore(CoreProcess):
|
||||
class ExitCode:
|
||||
ConfigurationError = 23
|
||||
# Windows: 4294967295. Darwin, Linux: 255 (-1)
|
||||
ServerStartFailure = 4294967295 if PLATFORM == 'Windows' else 255
|
||||
# Windows shutting down
|
||||
SystemShuttingDown = 0x40010004
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
|
||||
@staticmethod
|
||||
def name() -> str:
|
||||
return 'Xray-core'
|
||||
|
||||
@staticmethod
|
||||
def version() -> str:
|
||||
try:
|
||||
import xray
|
||||
|
||||
return xray.__version__
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return '0.0.0'
|
||||
|
||||
def startFromArgs(self, jsonString: str, **kwargs) -> bool:
|
||||
self.registerCurrentJSONConfig(jsonString)
|
||||
|
||||
return super().start(
|
||||
target=startXrayCore, args=(jsonString, self.msgQueue), **kwargs
|
||||
)
|
||||
|
||||
def start(self, config: Union[str, dict], **kwargs) -> bool:
|
||||
if isinstance(config, str):
|
||||
return self.startFromArgs(config, **kwargs)
|
||||
elif isinstance(config, ConfigurationFactory):
|
||||
return self.startFromArgs(config.toJSONString(), **kwargs)
|
||||
elif isinstance(config, dict):
|
||||
try:
|
||||
jsonString = UJSONEncoder.encode(config)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return False
|
||||
else:
|
||||
return self.startFromArgs(jsonString, **kwargs)
|
||||
else:
|
||||
return False
|
||||
@@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2023 Loren Eteval <loren.eteval@proton.me>
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
@@ -15,3 +15,8 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from .XrayCore import *
|
||||
from .Hysteria1 import *
|
||||
from .Hysteria2 import *
|
||||
from .Tun2socks import *
|
||||
from .CoreManager import *
|
||||
|
||||
Vendored
+1025
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2023 Loren Eteval <loren.eteval@proton.me>
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
@@ -15,10 +15,4 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
from .GenTranslation import *
|
||||
@@ -0,0 +1,34 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = ['ApplicationFactory']
|
||||
|
||||
|
||||
class ApplicationFactory:
|
||||
class ExitCode:
|
||||
ExitSuccess = 0
|
||||
UnknownException = 1
|
||||
PlatformNotSupported = 2
|
||||
AssertionError = 3
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def run(self):
|
||||
raise NotImplementedError
|
||||
@@ -0,0 +1,216 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.Interface.UserServersTableItem import *
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from abc import ABC
|
||||
from typing import Union
|
||||
|
||||
import copy
|
||||
import ujson
|
||||
|
||||
__all__ = ['ConfigurationFactory']
|
||||
|
||||
|
||||
class ConfigurationFactory(UserServersTableItem, dict, ABC):
|
||||
"""
|
||||
ConfigurationFactory is how Furious sees the core config.
|
||||
|
||||
It subclasses from dict and can be constructed from:
|
||||
1. dictionary -- from existing JSON object
|
||||
2. string -- from URI or (valid) JSON string
|
||||
"""
|
||||
|
||||
def __init__(self, config: Union[str, dict] = '', **kwargs):
|
||||
"""
|
||||
Constructs a ConfigurationFactory. The constructor
|
||||
never throws exception
|
||||
|
||||
:param config: The input configuration. Can be a string or dict
|
||||
"""
|
||||
|
||||
# Extra attributes
|
||||
self.kwargs = kwargs
|
||||
|
||||
if isinstance(config, str):
|
||||
try:
|
||||
jsonObject = ujson.loads(config)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
try:
|
||||
self.fromURI(config)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
super().__init__()
|
||||
else:
|
||||
super().__init__(**jsonObject)
|
||||
elif isinstance(config, dict):
|
||||
super().__init__(**config)
|
||||
else:
|
||||
super().__init__()
|
||||
|
||||
def __getitem__(self, item: str):
|
||||
if not isinstance(item, str):
|
||||
raise TypeError(f'Bad type {type(item)} for __getitem__ call')
|
||||
|
||||
return super().__getitem__(item)
|
||||
|
||||
def __setitem__(self, item: str, value):
|
||||
if not isinstance(item, str):
|
||||
raise TypeError(f'Bad type {type(item)} for __setitem__ call')
|
||||
|
||||
return super().__setitem__(item, value)
|
||||
|
||||
def deepcopy(self) -> ConfigurationFactory:
|
||||
return copy.deepcopy(self)
|
||||
|
||||
def coreName(self) -> str:
|
||||
return 'Unknown'
|
||||
|
||||
def isValid(self) -> bool:
|
||||
return bool(self)
|
||||
|
||||
def getExtras(self, item):
|
||||
return self.kwargs.get(item, '')
|
||||
|
||||
def setExtras(self, item, value):
|
||||
self.kwargs[item] = value
|
||||
|
||||
@property
|
||||
def itemRemark(self) -> str:
|
||||
return self.getExtras('remark')
|
||||
|
||||
@property
|
||||
def itemSubscription(self) -> str:
|
||||
try:
|
||||
app = QApplication.instance()
|
||||
|
||||
if app is None:
|
||||
return ''
|
||||
else:
|
||||
subsId = self.getExtras('subsId')
|
||||
subsOb = app.userSubs.data().get(subsId, {})
|
||||
|
||||
return subsOb.get('remark', '')
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return ''
|
||||
|
||||
@property
|
||||
def itemLatency(self) -> str:
|
||||
return self.getExtras('delayResult')
|
||||
|
||||
@property
|
||||
def itemSpeed(self) -> str:
|
||||
return self.getExtras('speedResult')
|
||||
|
||||
def toJSONString(self, **kwargs) -> str:
|
||||
"""
|
||||
Converts self to a JSON string
|
||||
|
||||
:param kwargs: Keyword arguments for encoder
|
||||
:return: JSON string
|
||||
"""
|
||||
|
||||
try:
|
||||
ensure_ascii = kwargs.pop('ensure_ascii', False)
|
||||
escape_forward_slashes = kwargs.pop('escape_forward_slashes', False)
|
||||
indent = kwargs.pop('indent', 4)
|
||||
|
||||
return ujson.dumps(
|
||||
self,
|
||||
ensure_ascii=ensure_ascii,
|
||||
escape_forward_slashes=escape_forward_slashes,
|
||||
indent=indent,
|
||||
**kwargs,
|
||||
)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
# '' is invalid
|
||||
return ''
|
||||
|
||||
def toStorageObject(self) -> dict:
|
||||
if self.kwargs.get('remark') is None:
|
||||
# compatibility: remark field is mandatory in previous application version
|
||||
self.kwargs['remark'] = ''
|
||||
|
||||
# self.toJSONString() is used to maintain backward compatibility
|
||||
return {'config': self.toJSONString(), **self.kwargs}
|
||||
|
||||
def toURI(self, remark: str = '') -> str:
|
||||
"""
|
||||
Converts self to a URI string
|
||||
|
||||
:param remark: Remark (fragment)
|
||||
:return: URI string
|
||||
"""
|
||||
|
||||
return ''
|
||||
|
||||
def fromURI(self, URI: str) -> bool:
|
||||
"""
|
||||
Constructs self from a URI string
|
||||
|
||||
:param URI: URI string
|
||||
:return: True on success, false otherwise
|
||||
"""
|
||||
|
||||
return False
|
||||
|
||||
def httpProxyEndpoint(self) -> str:
|
||||
"""
|
||||
Get current http proxy endpoint
|
||||
|
||||
:return: Http proxy endpoint string
|
||||
"""
|
||||
|
||||
return ''
|
||||
|
||||
def socksProxyEndpoint(self) -> str:
|
||||
"""
|
||||
Get current socks proxy endpoint
|
||||
|
||||
:return: Socks proxy endpoint string
|
||||
"""
|
||||
|
||||
return ''
|
||||
|
||||
def setHttpProxyEndpoint(self, endpoint: str) -> bool:
|
||||
"""
|
||||
Set current http proxy endpoint
|
||||
|
||||
:return: True on success, false otherwise
|
||||
"""
|
||||
|
||||
return False
|
||||
|
||||
def setSocksProxyEndpoint(self, endpoint: str) -> bool:
|
||||
"""
|
||||
Set current socks proxy endpoint
|
||||
|
||||
:return: True on success, false otherwise
|
||||
"""
|
||||
|
||||
return False
|
||||
@@ -0,0 +1,96 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.Utility.Constants import PLATFORM
|
||||
|
||||
from abc import ABC
|
||||
from typing import Callable, Union
|
||||
|
||||
import ujson
|
||||
|
||||
__all__ = ['CoreFactory']
|
||||
|
||||
|
||||
class CoreFactory(ABC):
|
||||
class ExitCode:
|
||||
ConfigurationError = 23
|
||||
# Windows: 4294967295. Darwin, Linux: 255 (-1)
|
||||
ServerStartFailure = 4294967295 if PLATFORM == 'Windows' else 255
|
||||
# Windows shutting down
|
||||
SystemShuttingDown = 0x40010004
|
||||
|
||||
def __init__(self, exitCallback: Callable[[CoreFactory, int], None] = None):
|
||||
self._exitCallback = exitCallback
|
||||
self._jsonConfig = ''
|
||||
|
||||
def registerExitCallback(
|
||||
self, exitCallback: Callable[[CoreFactory, int], None]
|
||||
) -> CoreFactory:
|
||||
self._exitCallback = exitCallback
|
||||
|
||||
return self
|
||||
|
||||
def registerCurrentJSONConfig(self, config: Union[str, dict]) -> CoreFactory:
|
||||
self._jsonConfig = config
|
||||
|
||||
return self
|
||||
|
||||
@staticmethod
|
||||
def name() -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
@staticmethod
|
||||
def version() -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
def jsonConfigString(self) -> str:
|
||||
if isinstance(self._jsonConfig, str):
|
||||
return self._jsonConfig
|
||||
if isinstance(self._jsonConfig, dict):
|
||||
try:
|
||||
return ujson.dumps(
|
||||
self._jsonConfig,
|
||||
ensure_ascii=False,
|
||||
escape_forward_slashes=False,
|
||||
)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return ''
|
||||
|
||||
return ''
|
||||
|
||||
def jsonConfigDict(self) -> dict:
|
||||
if isinstance(self._jsonConfig, str):
|
||||
try:
|
||||
return ujson.loads(self._jsonConfig)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return {}
|
||||
if isinstance(self._jsonConfig, dict):
|
||||
return self._jsonConfig
|
||||
|
||||
return {}
|
||||
|
||||
def start(self, *args, **kwargs) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
def stop(self):
|
||||
raise NotImplementedError
|
||||
@@ -0,0 +1,31 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC
|
||||
from typing import Any
|
||||
|
||||
__all__ = ['Encoder']
|
||||
|
||||
|
||||
class Encoder(ABC):
|
||||
def encode(self, data: Any, **kwargs) -> Any:
|
||||
raise NotImplementedError
|
||||
|
||||
def decode(self, data: Any, **kwargs) -> Any:
|
||||
raise NotImplementedError
|
||||
@@ -0,0 +1,55 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Sequence
|
||||
|
||||
__all__ = ['ItemUpdateProtocol']
|
||||
|
||||
|
||||
class ItemUpdateProtocol:
|
||||
def __init__(self, sequence: Sequence, currentIndex: int, currentItem: Any):
|
||||
self.sequence = sequence
|
||||
self.currentIndex = currentIndex
|
||||
self.currentItem = currentItem
|
||||
|
||||
def currentItemDeleted(self, *args, **kwargs) -> bool:
|
||||
return self.currentIndex < 0 or self.currentIndex >= len(self.sequence)
|
||||
|
||||
def updateImpl(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def updateResult(self):
|
||||
if self.currentItemDeleted():
|
||||
# Deleted. Do nothing
|
||||
return
|
||||
|
||||
if id(self.sequence[self.currentIndex]) == id(self.currentItem):
|
||||
self.updateImpl()
|
||||
else:
|
||||
# Linear find and update
|
||||
for index, item in enumerate(self.sequence):
|
||||
if id(item) == id(self.currentItem):
|
||||
# Found. Update index
|
||||
self.currentIndex = index
|
||||
self.updateImpl()
|
||||
|
||||
# Updated
|
||||
break
|
||||
|
||||
# Not found. Do nothing
|
||||
@@ -0,0 +1,31 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC
|
||||
from typing import Any
|
||||
|
||||
__all__ = ['StorageFactory']
|
||||
|
||||
|
||||
class StorageFactory(ABC):
|
||||
def sync(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def data(self) -> Any:
|
||||
raise NotImplementedError
|
||||
@@ -0,0 +1,63 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC
|
||||
|
||||
__all__ = ['UserServersTableItem']
|
||||
|
||||
|
||||
class UserServersTableItem(ABC):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def itemRemark(self) -> str:
|
||||
return ''
|
||||
|
||||
@property
|
||||
def itemProtocol(self) -> str:
|
||||
return ''
|
||||
|
||||
@property
|
||||
def itemAddress(self) -> str:
|
||||
return ''
|
||||
|
||||
@property
|
||||
def itemPort(self) -> str:
|
||||
return ''
|
||||
|
||||
@property
|
||||
def itemTransport(self) -> str:
|
||||
return ''
|
||||
|
||||
@property
|
||||
def itemTLS(self) -> str:
|
||||
return ''
|
||||
|
||||
@property
|
||||
def itemSubscription(self) -> str:
|
||||
return ''
|
||||
|
||||
@property
|
||||
def itemLatency(self) -> str:
|
||||
return ''
|
||||
|
||||
@property
|
||||
def itemSpeed(self) -> str:
|
||||
return ''
|
||||
@@ -0,0 +1,24 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from .Encoder import *
|
||||
from .UserServersTableItem import *
|
||||
from .ItemUpdateProtocol import *
|
||||
from .Storage import *
|
||||
from .Application import *
|
||||
from .ConfigurationFactory import *
|
||||
from .CoreFactory import *
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.Interface import *
|
||||
|
||||
from typing import Any, AnyStr
|
||||
|
||||
import json
|
||||
import ujson
|
||||
import base64
|
||||
import pybase64
|
||||
|
||||
__all__ = ['JSONEncoder', 'UJSONEncoder', 'Base64Encoder', 'PyBase64Encoder']
|
||||
|
||||
|
||||
class _JSONEncoder(Encoder):
|
||||
def encode(self, data: Any, **kwargs) -> str:
|
||||
ensure_ascii = kwargs.pop('ensure_ascii', False)
|
||||
|
||||
return json.dumps(data, ensure_ascii=ensure_ascii, **kwargs)
|
||||
|
||||
def decode(self, data: AnyStr, **kwargs) -> Any:
|
||||
return json.loads(data, **kwargs)
|
||||
|
||||
|
||||
class _UJSONEncoder(Encoder):
|
||||
def encode(self, data: Any, **kwargs) -> str:
|
||||
ensure_ascii = kwargs.pop('ensure_ascii', False)
|
||||
escape_forward_slashes = kwargs.pop('escape_forward_slashes', False)
|
||||
|
||||
return ujson.dumps(
|
||||
data,
|
||||
ensure_ascii=ensure_ascii,
|
||||
escape_forward_slashes=escape_forward_slashes,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def decode(self, data: AnyStr, **kwargs) -> Any:
|
||||
return ujson.loads(data, **kwargs)
|
||||
|
||||
|
||||
class _Base64Encoder(Encoder):
|
||||
def encode(self, data: Any, **kwargs) -> bytes:
|
||||
return base64.b64encode(data, **kwargs)
|
||||
|
||||
def decode(self, data: Any, **kwargs) -> bytes:
|
||||
validate = kwargs.pop('validate', False)
|
||||
|
||||
return base64.b64decode(data, validate=validate, **kwargs)
|
||||
|
||||
|
||||
class _PyBase64Encoder(Encoder):
|
||||
def encode(self, data: Any, **kwargs) -> bytes:
|
||||
return pybase64.b64encode(data, **kwargs)
|
||||
|
||||
def decode(self, data: Any, **kwargs) -> bytes:
|
||||
validate = kwargs.pop('validate', False)
|
||||
|
||||
return pybase64.b64decode(data, validate=validate, **kwargs)
|
||||
|
||||
|
||||
JSONEncoder = _JSONEncoder()
|
||||
UJSONEncoder = _UJSONEncoder()
|
||||
Base64Encoder = _Base64Encoder()
|
||||
PyBase64Encoder = _PyBase64Encoder()
|
||||
@@ -0,0 +1,19 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from .Configuration import *
|
||||
from .Encoder import *
|
||||
@@ -0,0 +1,177 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
__all__ = [
|
||||
'Translatable',
|
||||
'SupportConnectedCallback',
|
||||
'SupportThemeChangedCallback',
|
||||
'SupportExitCleanup',
|
||||
'SupportImplicitReference',
|
||||
'FastItemDeletionSearch',
|
||||
]
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Translatable:
|
||||
ObjectsPool = list()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.translatable = kwargs.pop('translatable', True)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
Translatable.ObjectsPool.append(self)
|
||||
|
||||
def retranslate(self):
|
||||
raise NotImplementedError
|
||||
|
||||
@staticmethod
|
||||
def retranslateAll():
|
||||
for ob in Translatable.ObjectsPool:
|
||||
assert isinstance(ob, Translatable)
|
||||
|
||||
if ob.translatable:
|
||||
ob.retranslate()
|
||||
|
||||
|
||||
class SupportConnectedCallback:
|
||||
ObjectsPool = list()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
SupportConnectedCallback.ObjectsPool.append(self)
|
||||
|
||||
def disconnectedCallback(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def connectedCallback(self):
|
||||
raise NotImplementedError
|
||||
|
||||
@staticmethod
|
||||
def callConnectedCallback():
|
||||
for ob in SupportConnectedCallback.ObjectsPool:
|
||||
assert isinstance(ob, SupportConnectedCallback)
|
||||
|
||||
ob.connectedCallback()
|
||||
|
||||
@staticmethod
|
||||
def callDisconnectedCallback():
|
||||
for ob in SupportConnectedCallback.ObjectsPool:
|
||||
assert isinstance(ob, SupportConnectedCallback)
|
||||
|
||||
ob.disconnectedCallback()
|
||||
|
||||
|
||||
class SupportThemeChangedCallback:
|
||||
ObjectsPool = list()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
SupportThemeChangedCallback.ObjectsPool.append(self)
|
||||
|
||||
def themeChangedCallback(self, theme: str):
|
||||
raise NotImplementedError
|
||||
|
||||
@staticmethod
|
||||
def callThemeChangedCallbackUnchecked(theme: str):
|
||||
for ob in SupportThemeChangedCallback.ObjectsPool:
|
||||
assert isinstance(ob, SupportThemeChangedCallback)
|
||||
|
||||
ob.themeChangedCallback(theme)
|
||||
|
||||
@staticmethod
|
||||
def callThemeChangedCallback(theme: str):
|
||||
try:
|
||||
app = QApplication.instance()
|
||||
|
||||
if app is not None and app.isDarkModeEnabled():
|
||||
# Ignore application dark detect system
|
||||
logger.info(f'ignore system theme \'{theme}\' changes in dark mode')
|
||||
|
||||
return
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
pass
|
||||
|
||||
logger.info(f'system theme changed to \'{theme}\'')
|
||||
|
||||
SupportThemeChangedCallback.callThemeChangedCallbackUnchecked(theme)
|
||||
|
||||
|
||||
class SupportExitCleanup:
|
||||
ObjectsPool = list()
|
||||
VisitedType = dict()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.uniqueCleanup = kwargs.pop('uniqueCleanup', True)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
SupportExitCleanup.ObjectsPool.append(self)
|
||||
|
||||
def cleanup(self):
|
||||
raise NotImplementedError
|
||||
|
||||
@staticmethod
|
||||
def cleanupAll():
|
||||
for ob in SupportExitCleanup.ObjectsPool:
|
||||
assert isinstance(ob, SupportExitCleanup)
|
||||
|
||||
if ob.uniqueCleanup:
|
||||
obtype = str(type(ob))
|
||||
|
||||
if not SupportExitCleanup.VisitedType.get(obtype, False):
|
||||
ob.cleanup()
|
||||
|
||||
SupportExitCleanup.VisitedType[obtype] = True
|
||||
else:
|
||||
pass
|
||||
else:
|
||||
ob.cleanup()
|
||||
|
||||
|
||||
class SupportImplicitReference:
|
||||
ObjectsPool = list()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
SupportImplicitReference.ObjectsPool.append(self)
|
||||
|
||||
|
||||
class FastItemDeletionSearch:
|
||||
DeletedItem = list()
|
||||
DeletedId = dict()
|
||||
|
||||
@staticmethod
|
||||
def moveToTrash(item):
|
||||
FastItemDeletionSearch.DeletedItem.append(item)
|
||||
FastItemDeletionSearch.DeletedId[id(item)] = True
|
||||
|
||||
@staticmethod
|
||||
def isInTrash(item) -> bool:
|
||||
return id(item) in FastItemDeletionSearch.DeletedId
|
||||
@@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2023 Loren Eteval <loren.eteval@proton.me>
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
@@ -15,3 +15,4 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from .Ancestors import *
|
||||
@@ -0,0 +1,70 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from Furious.PyFramework import *
|
||||
|
||||
__all__ = ['QBlockSignals', 'QTranslatable']
|
||||
|
||||
|
||||
class QProtection:
|
||||
def __init__(self, qobject):
|
||||
self.qobject = qobject
|
||||
self.hasattr = hasattr(self.qobject, 'setDisabled')
|
||||
|
||||
def __enter__(self):
|
||||
if self.hasattr:
|
||||
self.qobject.setDisabled(True)
|
||||
|
||||
def __exit__(self, exceptionType, exceptionValue, tb):
|
||||
if self.hasattr:
|
||||
self.qobject.setDisabled(False)
|
||||
|
||||
|
||||
class QBlockSignals:
|
||||
def __init__(self, qobject):
|
||||
self.qobject = qobject
|
||||
self.hasattr = hasattr(self.qobject, 'blockSignals')
|
||||
|
||||
def __enter__(self):
|
||||
if self.hasattr:
|
||||
self.qobject.blockSignals(True)
|
||||
|
||||
def __exit__(self, exceptionType, exceptionValue, tb):
|
||||
if self.hasattr:
|
||||
self.qobject.blockSignals(False)
|
||||
|
||||
|
||||
class QTranslatable(Translatable):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.useQProtection = kwargs.pop('useQProtection', True)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def retranslate(self):
|
||||
raise NotImplementedError
|
||||
|
||||
@staticmethod
|
||||
def retranslateAll():
|
||||
for ob in QTranslatable.ObjectsPool:
|
||||
assert isinstance(ob, QTranslatable)
|
||||
|
||||
if ob.translatable:
|
||||
if ob.useQProtection:
|
||||
with QProtection(ob):
|
||||
ob.retranslate()
|
||||
else:
|
||||
ob.retranslate()
|
||||
@@ -0,0 +1,207 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.Interface import *
|
||||
from Furious.Utility import *
|
||||
|
||||
from PySide6 import QtCore
|
||||
|
||||
from abc import ABC
|
||||
from typing import Callable
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
import logging
|
||||
import threading
|
||||
import multiprocessing
|
||||
|
||||
__all__ = ['CoreProcess', 'StdoutRedirectHelper']
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CoreProcess(CoreFactory, ABC):
|
||||
MESG_PRODUCE_THRESHOLD = 250
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
exitCallback = kwargs.pop('exitCallback', None)
|
||||
|
||||
super().__init__(exitCallback)
|
||||
|
||||
self._process = None
|
||||
self._msgQueue = multiprocessing.Queue()
|
||||
self._msgCallback = kwargs.pop('msgCallback', None)
|
||||
|
||||
@QtCore.Slot()
|
||||
def handleMsgTimemout():
|
||||
msg = self.getMsgNoWait()
|
||||
|
||||
if msg and not msg.isspace():
|
||||
if callable(self._msgCallback):
|
||||
self._msgCallback(msg)
|
||||
|
||||
self._msgTimer = QtCore.QTimer()
|
||||
self._msgTimer.timeout.connect(handleMsgTimemout)
|
||||
|
||||
@QtCore.Slot()
|
||||
def handleDaemonTimemout():
|
||||
self.checkIsRunning()
|
||||
|
||||
self._daemonTimer = QtCore.QTimer()
|
||||
self._daemonTimer.timeout.connect(handleDaemonTimemout)
|
||||
|
||||
@property
|
||||
def msgQueue(self) -> multiprocessing.Queue:
|
||||
return self._msgQueue
|
||||
|
||||
def registerMsgCallback(self, msgCallback) -> CoreProcess:
|
||||
self._msgCallback = msgCallback
|
||||
|
||||
return self
|
||||
|
||||
def isRunning(self) -> bool:
|
||||
if isinstance(self._process, multiprocessing.Process):
|
||||
return self._process.is_alive()
|
||||
else:
|
||||
return False
|
||||
|
||||
def checkIsRunning(self) -> bool:
|
||||
if 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._msgTimer.stop()
|
||||
self._daemonTimer.stop()
|
||||
|
||||
if callable(self._exitCallback):
|
||||
self._exitCallback(self, self._process.exitcode)
|
||||
|
||||
# Reset internal process
|
||||
self._process = None
|
||||
|
||||
return False
|
||||
else:
|
||||
return False
|
||||
|
||||
def start(self, **kwargs) -> bool:
|
||||
daemon = kwargs.pop('daemon', True)
|
||||
waitCore = kwargs.pop('waitCore', True)
|
||||
waitTime = kwargs.pop('waitTime', 2500)
|
||||
|
||||
self._process = multiprocessing.Process(**kwargs, daemon=daemon)
|
||||
self._process.start()
|
||||
|
||||
logger.info(f'{self.name()} {self.version()} started')
|
||||
|
||||
self._msgTimer.start(self.MESG_PRODUCE_THRESHOLD)
|
||||
|
||||
if waitCore:
|
||||
# Wait for the core to start up completely
|
||||
PySide6LegacyEventLoopWait(waitTime)
|
||||
|
||||
if self.checkIsRunning():
|
||||
# Start core daemon
|
||||
self._daemonTimer.start(CORE_CHECK_ALIVE_INTERVAL)
|
||||
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def stop(self):
|
||||
if self.isRunning():
|
||||
self._msgTimer.stop()
|
||||
self._daemonTimer.stop()
|
||||
|
||||
self._process.terminate()
|
||||
self._process.join()
|
||||
|
||||
logger.info(
|
||||
f'{self.name()} terminated with exitcode {self._process.exitcode}'
|
||||
)
|
||||
|
||||
def getMsgNoWait(self) -> str:
|
||||
try:
|
||||
return self._msgQueue.get_nowait()
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return ''
|
||||
|
||||
|
||||
class StdoutRedirectHelper:
|
||||
TemporaryDir = QtCore.QTemporaryDir()
|
||||
|
||||
@staticmethod
|
||||
def launch(
|
||||
msgQueue: multiprocessing.Queue, entrypoint: Callable[[], None], redirect: bool
|
||||
):
|
||||
if not callable(entrypoint):
|
||||
return
|
||||
|
||||
if (
|
||||
not StdoutRedirectHelper.TemporaryDir.isValid()
|
||||
or not redirect
|
||||
# pythonw.exe
|
||||
or isPythonw()
|
||||
):
|
||||
# Call entrypoint directly
|
||||
entrypoint()
|
||||
|
||||
return
|
||||
|
||||
temporaryFile = StdoutRedirectHelper.TemporaryDir.filePath(str(uuid.uuid4()))
|
||||
tmpFileStream = open(temporaryFile, 'w+b')
|
||||
stdoutFileno_ = sys.stdout.fileno()
|
||||
stderrFileno_ = sys.stderr.fileno()
|
||||
|
||||
sys.stdout.close()
|
||||
sys.stderr.close()
|
||||
|
||||
# Redirect
|
||||
os.dup2(tmpFileStream.fileno(), stdoutFileno_)
|
||||
os.dup2(tmpFileStream.fileno(), stderrFileno_)
|
||||
|
||||
sys.stdout = tmpFileStream
|
||||
sys.stderr = tmpFileStream
|
||||
|
||||
def produceMsg():
|
||||
with open(temporaryFile, 'rb') as file:
|
||||
while True:
|
||||
for line in iter(file.readline, b''):
|
||||
if line and not line.isspace():
|
||||
try:
|
||||
msgQueue.put_nowait(line.decode('utf-8', 'replace'))
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
pass
|
||||
|
||||
time.sleep(CoreProcess.MESG_PRODUCE_THRESHOLD / 1000)
|
||||
|
||||
msgThread = threading.Thread(target=produceMsg, daemon=True)
|
||||
msgThread.start()
|
||||
|
||||
with tmpFileStream:
|
||||
entrypoint()
|
||||
@@ -0,0 +1,157 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.Utility import *
|
||||
from Furious.Library import *
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtNetwork import *
|
||||
|
||||
from typing import Tuple
|
||||
|
||||
import logging
|
||||
|
||||
__all__ = ['DNSResolver']
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DNSResolver:
|
||||
Manager = QNetworkAccessManager()
|
||||
|
||||
@staticmethod
|
||||
def request(address) -> QNetworkRequest:
|
||||
request = QNetworkRequest(
|
||||
QtCore.QUrl(f'https://cloudflare-dns.com/dns-query?name={address}')
|
||||
)
|
||||
request.setRawHeader('accept'.encode(), 'application/dns-json'.encode())
|
||||
|
||||
return request
|
||||
|
||||
@staticmethod
|
||||
def handleFinishedByNetworkReply(networkReply, domain, resultMap):
|
||||
assert isinstance(networkReply, QNetworkReply)
|
||||
|
||||
if networkReply.error() != QNetworkReply.NetworkError.NoError:
|
||||
logger.error(
|
||||
f'DNS resolution for \'{domain}\' failed. {networkReply.errorString()}'
|
||||
)
|
||||
|
||||
resultMap['error'] = True
|
||||
else:
|
||||
logger.info(f'DNS resolution for \'{domain}\' success')
|
||||
|
||||
# Unchecked?
|
||||
replyObject = UJSONEncoder.decode(networkReply.readAll().data())
|
||||
|
||||
for record in replyObject['Answer']:
|
||||
address = record['data']
|
||||
|
||||
logger.info(f'\'{domain}\' resolved to \'{address}\'')
|
||||
|
||||
if isValidIPAddress(address):
|
||||
resultMap['result'][address] = True
|
||||
else:
|
||||
resultMap['depth'] += 1
|
||||
|
||||
newNetworkReply = DNSResolver.Manager.get(
|
||||
DNSResolver.request(address)
|
||||
)
|
||||
newNetworkReply.finished.connect(
|
||||
functools.partial(
|
||||
DNSResolver.handleFinishedByNetworkReply,
|
||||
newNetworkReply,
|
||||
address,
|
||||
resultMap,
|
||||
)
|
||||
)
|
||||
|
||||
resultMap['reference'].append(newNetworkReply)
|
||||
|
||||
resultMap['depth'] -= 1
|
||||
|
||||
@staticmethod
|
||||
def resolve(domain, proxyHost=None, proxyPort=None) -> Tuple[bool, list[str]]:
|
||||
if proxyHost is None or proxyPort is None:
|
||||
DNSResolver.Manager.setProxy(QNetworkProxy.ProxyType.NoProxy)
|
||||
else:
|
||||
try:
|
||||
DNSResolver.Manager.setProxy(
|
||||
QNetworkProxy(
|
||||
QNetworkProxy.ProxyType.HttpProxy, proxyHost, int(proxyPort)
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(f'DNS resolution uses proxy server {proxyHost}:{proxyPort}')
|
||||
except Exception as ex:
|
||||
# Any non-exit exceptions
|
||||
|
||||
logger.error(
|
||||
f'invalid proxy server {proxyHost}:{proxyPort}. {ex}. '
|
||||
f'DNS resolution uses no proxy'
|
||||
)
|
||||
|
||||
DNSResolver.Manager.setProxy(QNetworkProxy.ProxyType.NoProxy)
|
||||
|
||||
resultMap = {
|
||||
'depth': 0,
|
||||
'error': False,
|
||||
'reference': [],
|
||||
'result': {},
|
||||
}
|
||||
|
||||
resultMap['depth'] += 1
|
||||
|
||||
networkReply = DNSResolver.Manager.get(DNSResolver.request(domain))
|
||||
networkReply.finished.connect(
|
||||
functools.partial(
|
||||
DNSResolver.handleFinishedByNetworkReply,
|
||||
networkReply,
|
||||
domain,
|
||||
resultMap,
|
||||
)
|
||||
)
|
||||
|
||||
resultMap['reference'].append(networkReply)
|
||||
|
||||
DNSResolver.wait(resultMap)
|
||||
|
||||
return resultMap['error'], list(resultMap['result'].keys())
|
||||
|
||||
@staticmethod
|
||||
def wait(resultMap, startCounter=0, timeout=30000, step=100):
|
||||
if resultMap['depth'] != 0:
|
||||
logger.info('DNS resolution in progress. Wait')
|
||||
else:
|
||||
return
|
||||
|
||||
while resultMap['depth'] != 0 and startCounter < timeout:
|
||||
PySide6LegacyEventLoopWait(step)
|
||||
|
||||
startCounter += step
|
||||
|
||||
if resultMap['depth'] != 0:
|
||||
logger.error('DNS resolution timeout')
|
||||
|
||||
for networkReply in resultMap['reference']:
|
||||
if (
|
||||
isinstance(networkReply, QNetworkReply)
|
||||
and not networkReply.isFinished()
|
||||
):
|
||||
networkReply.abort()
|
||||
@@ -0,0 +1,71 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.QtFramework.QtGui import bootstrapIcon, AppQIcon
|
||||
from Furious.Utility import *
|
||||
|
||||
import functools
|
||||
|
||||
__all__ = ['ColorRGB', 'AppHue']
|
||||
|
||||
|
||||
class ColorRGB:
|
||||
LIGHT_BLUE = '#43ACED'
|
||||
LIGHT_RED = '#FF7276'
|
||||
LIGHT_PURPLE = '#DA70D6'
|
||||
|
||||
|
||||
class AppHue:
|
||||
@staticmethod
|
||||
def disconnectedColor() -> str:
|
||||
return ColorRGB.LIGHT_BLUE
|
||||
|
||||
@staticmethod
|
||||
def disconnectedWindowIcon() -> AppQIcon:
|
||||
return bootstrapIcon('rocket-takeoff-window.svg')
|
||||
|
||||
@staticmethod
|
||||
@functools.lru_cache(None)
|
||||
def connectedColor() -> str:
|
||||
if not isAdministrator():
|
||||
return ColorRGB.LIGHT_RED
|
||||
else:
|
||||
return ColorRGB.LIGHT_PURPLE
|
||||
|
||||
@staticmethod
|
||||
@functools.lru_cache(None)
|
||||
def connectedWindowIcon() -> AppQIcon:
|
||||
if not isAdministrator():
|
||||
return bootstrapIcon('rocket-takeoff-connected-dark.svg')
|
||||
else:
|
||||
return bootstrapIcon('rocket-takeoff-admin-connected.svg')
|
||||
|
||||
@staticmethod
|
||||
def currentColor() -> str:
|
||||
if APP().isSystemTrayConnected():
|
||||
return AppHue.connectedColor()
|
||||
else:
|
||||
return AppHue.disconnectedColor()
|
||||
|
||||
@staticmethod
|
||||
def currentWindowIcon() -> AppQIcon:
|
||||
if APP().isSystemTrayConnected():
|
||||
return AppHue.connectedWindowIcon()
|
||||
else:
|
||||
return AppHue.disconnectedWindowIcon()
|
||||
@@ -0,0 +1,112 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.Utility import *
|
||||
from Furious.Library import *
|
||||
from Furious.Externals import *
|
||||
|
||||
import functools
|
||||
|
||||
__all__ = [
|
||||
'gettext',
|
||||
'needTransFn',
|
||||
'LANGUAGE_TO_ABBR',
|
||||
'ABBR_TO_LANGUAGE',
|
||||
'SUPPORTED_LANGUAGE',
|
||||
'TranslationPool',
|
||||
]
|
||||
|
||||
|
||||
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():
|
||||
if lang.isupper():
|
||||
# is 'ZH', 'EN', etc.
|
||||
self.dictEnglish[text] = key
|
||||
|
||||
# English -> English
|
||||
self.dictEnglish.update(dict(list((key, key) for key in translation.keys())))
|
||||
|
||||
def translate(self, source, locale):
|
||||
try:
|
||||
return self.translation[self.dictEnglish[source]][locale]
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
# No changes
|
||||
return source
|
||||
|
||||
|
||||
translator = Translator()
|
||||
|
||||
|
||||
def installTranslation(translation):
|
||||
translator.install(translation)
|
||||
|
||||
|
||||
def gettext(source, locale=None):
|
||||
if locale is None:
|
||||
assert APP() is not None
|
||||
|
||||
return translator.translate(source, AppSettings.get('Language'))
|
||||
else:
|
||||
assert locale in SUPPORTED_LANGUAGE
|
||||
|
||||
return translator.translate(source, locale)
|
||||
|
||||
|
||||
# Register new translation type here
|
||||
LANGUAGE_TO_ABBR = {
|
||||
'English': 'EN',
|
||||
'简体中文': 'ZH',
|
||||
}
|
||||
|
||||
ABBR_TO_LANGUAGE = {value: key for key, value in LANGUAGE_TO_ABBR.items()}
|
||||
|
||||
SUPPORTED_LANGUAGE = list(LANGUAGE_TO_ABBR.values())
|
||||
|
||||
installTranslation(TRANSLATION)
|
||||
|
||||
|
||||
class TranslatorHelper:
|
||||
TranslationPool = list()
|
||||
|
||||
@staticmethod
|
||||
def appendText(*texts, **kwargs):
|
||||
source = kwargs.pop('source', '')
|
||||
|
||||
for text in texts:
|
||||
if isinstance(text, str):
|
||||
TranslatorHelper.TranslationPool.append([text, source])
|
||||
|
||||
|
||||
needTransFn = functools.partial(TranslatorHelper.appendText)
|
||||
TranslationPool = TranslatorHelper.TranslationPool
|
||||
@@ -0,0 +1,120 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from Furious.PyFramework import *
|
||||
from Furious.QtFramework.QtNetwork import *
|
||||
from Furious.Utility import *
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtNetwork import *
|
||||
|
||||
import logging
|
||||
import functools
|
||||
|
||||
__all__ = ['NetworkStateManager']
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NetworkStateManager(SupportConnectedCallback, AppQNetworkAccessManager):
|
||||
MIN_JOB_INTERVAL = 2500
|
||||
MAX_JOB_INTERVAL = 2000000000
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self.jobStatus = False
|
||||
self.jobInterval = NetworkStateManager.MIN_JOB_INTERVAL
|
||||
|
||||
self.jobTimeoutTimer = QtCore.QTimer()
|
||||
self.jobArrangeTimer = QtCore.QTimer()
|
||||
|
||||
self.jobArrangeTimer.timeout.connect(lambda: self.startSingleTest())
|
||||
|
||||
def successCallback(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def errorCallback(self, errorString: str):
|
||||
raise NotImplementedError
|
||||
|
||||
def handleFinishedByNetworkReply(self, networkReply):
|
||||
assert isinstance(networkReply, QNetworkReply)
|
||||
|
||||
if networkReply.error() != QNetworkReply.NetworkError.NoError:
|
||||
self.jobTimeoutTimer.stop()
|
||||
|
||||
errorString = networkReply.errorString()
|
||||
|
||||
logger.error(f'connection test failed. {errorString}')
|
||||
|
||||
self.errorCallback(errorString)
|
||||
|
||||
if self.jobStatus is False:
|
||||
self.jobInterval *= 2
|
||||
else:
|
||||
self.jobInterval = NetworkStateManager.MIN_JOB_INTERVAL
|
||||
|
||||
self.jobStatus = False
|
||||
else:
|
||||
self.jobTimeoutTimer.stop()
|
||||
|
||||
logger.info(f'connection test success')
|
||||
|
||||
self.successCallback()
|
||||
|
||||
if self.jobStatus is True:
|
||||
self.jobInterval *= 2
|
||||
else:
|
||||
self.jobInterval = NetworkStateManager.MIN_JOB_INTERVAL
|
||||
|
||||
self.jobStatus = True
|
||||
|
||||
if self.jobInterval >= NetworkStateManager.MAX_JOB_INTERVAL:
|
||||
# Limited
|
||||
self.jobInterval = NetworkStateManager.MAX_JOB_INTERVAL
|
||||
|
||||
self.jobArrangeTimer.start(self.jobInterval)
|
||||
|
||||
def startSingleTest(self):
|
||||
networkReply = self.get(QNetworkRequest(QtCore.QUrl(NETWORK_STATE_TEST_URL)))
|
||||
networkReply.finished.connect(
|
||||
functools.partial(
|
||||
self.handleFinishedByNetworkReply,
|
||||
networkReply,
|
||||
)
|
||||
)
|
||||
|
||||
def abort(_networkReply):
|
||||
if isinstance(_networkReply, QNetworkReply):
|
||||
_networkReply.abort()
|
||||
|
||||
self.jobTimeoutTimer.timeout.connect(functools.partial(abort, networkReply))
|
||||
self.jobTimeoutTimer.start(NetworkStateManager.MIN_JOB_INTERVAL - 500)
|
||||
|
||||
def startTest(self):
|
||||
self.jobArrangeTimer.start(NetworkStateManager.MIN_JOB_INTERVAL)
|
||||
|
||||
def stopTest(self):
|
||||
self.jobArrangeTimer.stop()
|
||||
|
||||
def connectedCallback(self):
|
||||
self.jobInterval = NetworkStateManager.MIN_JOB_INTERVAL
|
||||
|
||||
self.jobArrangeTimer.start(self.jobInterval)
|
||||
|
||||
def disconnectedCallback(self):
|
||||
self.jobArrangeTimer.stop()
|
||||
@@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2023 Loren Eteval <loren.eteval@proton.me>
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
@@ -15,17 +15,13 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from Furious.Utility.Constants import PLATFORM
|
||||
from Furious.Utility.Utility import (
|
||||
SupportThemeChangedCallback,
|
||||
bootstrapIcon,
|
||||
bootstrapIconWhite,
|
||||
getUbuntuRelease,
|
||||
)
|
||||
from Furious.Utility.Translator import Translatable, gettext as _
|
||||
from Furious.QtFramework.Ancestors import QTranslatable
|
||||
from Furious.QtFramework.DynamicTranslate import gettext as _
|
||||
from Furious.PyFramework.Ancestors import *
|
||||
from Furious.Utility import *
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtGui import QAction, QActionGroup
|
||||
from PySide6.QtGui import *
|
||||
|
||||
import logging
|
||||
import functools
|
||||
@@ -33,8 +29,36 @@ import darkdetect
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
'bootstrapIcon',
|
||||
'bootstrapIconWhite',
|
||||
'AppQIcon',
|
||||
'AppQAction',
|
||||
'AppQActionGroup',
|
||||
'AppQSeperator',
|
||||
]
|
||||
|
||||
class Action(Translatable, SupportThemeChangedCallback, QAction):
|
||||
|
||||
class AppQIcon(QIcon):
|
||||
def __init__(self, iconFileName: str):
|
||||
super().__init__(iconFileName)
|
||||
|
||||
self.iconFileName = iconFileName
|
||||
|
||||
|
||||
def iconFn(prefix, name):
|
||||
if name.startswith('rocket-takeoff'):
|
||||
# Colorful. Use default
|
||||
return AppQIcon(f':/Icons/bootstrap/{name}')
|
||||
else:
|
||||
return AppQIcon(f':/Icons/{prefix}/{name}')
|
||||
|
||||
|
||||
bootstrapIcon = functools.partial(iconFn, 'bootstrap')
|
||||
bootstrapIconWhite = functools.partial(iconFn, 'bootstrap/white')
|
||||
|
||||
|
||||
class AppQAction(QTranslatable, SupportThemeChangedCallback, QAction):
|
||||
def __init__(
|
||||
self,
|
||||
text,
|
||||
@@ -43,13 +67,19 @@ class Action(Translatable, SupportThemeChangedCallback, QAction):
|
||||
useActionGroup=True,
|
||||
checkable=False,
|
||||
checked=False,
|
||||
statusTip=None,
|
||||
callback=None,
|
||||
shortcut=None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(text=text, **kwargs)
|
||||
|
||||
# Do not use QProtection because it's been managed somewhere else!!!
|
||||
self.useQProtection = False
|
||||
self.iconFileName = ''
|
||||
|
||||
self.setShortcutVisibleInContextMenu(True)
|
||||
|
||||
if icon is not None:
|
||||
self.setIcon(icon)
|
||||
|
||||
@@ -64,7 +94,7 @@ class Action(Translatable, SupportThemeChangedCallback, QAction):
|
||||
|
||||
if useActionGroup:
|
||||
# Create reference
|
||||
self._actionGroup = ActionGroup(self, *menu.actions())
|
||||
self._actionGroup = AppQActionGroup(self, *menu.actions())
|
||||
|
||||
self.setActionGroup(self._actionGroup)
|
||||
else:
|
||||
@@ -76,9 +106,15 @@ class Action(Translatable, SupportThemeChangedCallback, QAction):
|
||||
self.setCheckable(checkable)
|
||||
self.setChecked(checked)
|
||||
|
||||
if statusTip is not None:
|
||||
self.setStatusTip(statusTip)
|
||||
|
||||
# Handy callback to be able to link with lambda
|
||||
self.callback = callback
|
||||
|
||||
if shortcut is not None:
|
||||
self.setShortcut(shortcut)
|
||||
|
||||
@QtCore.Slot(bool)
|
||||
def triggerSignal(paramChecked):
|
||||
logger.info(f'action is \'{self.textEnglish}\'. Checked is {paramChecked}')
|
||||
@@ -124,51 +160,47 @@ class Action(Translatable, SupportThemeChangedCallback, QAction):
|
||||
|
||||
return ''
|
||||
|
||||
def setIcon(self, icon):
|
||||
def setIconByTheme(self, theme):
|
||||
if not self.iconFileName:
|
||||
return
|
||||
|
||||
if AppSettings.isStateON_('DarkMode'):
|
||||
# Custom dark mode
|
||||
super().setIcon(bootstrapIconWhite(self.iconFileName))
|
||||
|
||||
return
|
||||
|
||||
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 setIcon(self, icon: AppQIcon):
|
||||
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:
|
||||
if not self.iconFileName:
|
||||
# Fall back
|
||||
super().setIcon(icon)
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
# Not a mandatory re-implementation in child class
|
||||
pass
|
||||
else:
|
||||
self.setIconByTheme(darkdetect.theme())
|
||||
|
||||
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))
|
||||
self.setIconByTheme(theme)
|
||||
|
||||
def retranslate(self):
|
||||
def recursiveTranslate(action, memo):
|
||||
if action not in memo and not action.isSeparator() and action.translatable:
|
||||
action.setText(_(action.text()))
|
||||
action.setStatusTip(_(action.statusTip()))
|
||||
|
||||
memo[action] = True
|
||||
|
||||
@@ -179,11 +211,14 @@ class Action(Translatable, SupportThemeChangedCallback, QAction):
|
||||
for childAction in action.menu().actions():
|
||||
recursiveTranslate(childAction, memo)
|
||||
|
||||
# Do not use StateContext because it's been managed somewhere else!!!
|
||||
recursiveTranslate(self, dict())
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
# Not a mandatory re-implementation in child class
|
||||
pass
|
||||
|
||||
class ActionGroup(QActionGroup):
|
||||
|
||||
class AppQActionGroup(QActionGroup):
|
||||
def __init__(self, parent, *actions):
|
||||
super().__init__(parent)
|
||||
|
||||
@@ -191,6 +226,6 @@ class ActionGroup(QActionGroup):
|
||||
self.addAction(action)
|
||||
|
||||
|
||||
class Seperator(QAction):
|
||||
class AppQSeperator(QAction):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
@@ -0,0 +1,55 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.Utility import *
|
||||
|
||||
from PySide6.QtNetwork import *
|
||||
|
||||
from typing import Union
|
||||
|
||||
__all__ = ['AppQNetworkAccessManager']
|
||||
|
||||
|
||||
class AppQNetworkAccessManager(QNetworkAccessManager):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
def configureHttpProxy(self, httpProxy: Union[str, None]) -> bool:
|
||||
if httpProxy is None:
|
||||
useProxy = False
|
||||
else:
|
||||
try:
|
||||
proxyHost, proxyPort = parseHostPort(httpProxy)
|
||||
|
||||
self.setProxy(
|
||||
QNetworkProxy(
|
||||
QNetworkProxy.ProxyType.HttpProxy, proxyHost, int(proxyPort)
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
useProxy = False
|
||||
else:
|
||||
useProxy = True
|
||||
|
||||
if not useProxy:
|
||||
self.setProxy(QNetworkProxy.ProxyType.NoProxy)
|
||||
|
||||
return useProxy
|
||||
@@ -0,0 +1,581 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.QtFramework.Ancestors import *
|
||||
from Furious.QtFramework.DynamicTheme import AppHue
|
||||
from Furious.QtFramework.DynamicTranslate import gettext as _, needTransFn
|
||||
from Furious.QtFramework.QtGui import AppQAction, AppQSeperator
|
||||
from Furious.PyFramework import *
|
||||
from Furious.Utility import *
|
||||
from Furious.Library import *
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtGui import *
|
||||
from PySide6.QtWidgets import *
|
||||
|
||||
import functools
|
||||
|
||||
__all__ = [
|
||||
'moveToCenter',
|
||||
'AppQDialog',
|
||||
'AppQGroupBox',
|
||||
'AppQHeaderView',
|
||||
'AppQLabel',
|
||||
'AppQListWidget',
|
||||
'AppQMainWindow',
|
||||
'AppQMenu',
|
||||
'AppQMenuBar',
|
||||
'AppQMessageBox',
|
||||
'AppQPushButton',
|
||||
'AppQStyledItemDelegate',
|
||||
'AppQTableWidget',
|
||||
'AppQTabWidget',
|
||||
'AppQToolBar',
|
||||
'QuestionDeleteMBox',
|
||||
'NewChangesNextTimeMBox',
|
||||
]
|
||||
|
||||
needTrans = functools.partial(needTransFn, source=__name__)
|
||||
|
||||
|
||||
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())
|
||||
|
||||
|
||||
class AppQDialog(QTranslatable, SupportConnectedCallback, QDialog):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self._firstShowCall = True
|
||||
|
||||
if PLATFORM != 'Darwin':
|
||||
self.setWidthAndHeight()
|
||||
|
||||
self.setWindowIcon(AppHue.currentWindowIcon())
|
||||
|
||||
def setWidthAndHeight(self):
|
||||
pass
|
||||
|
||||
def exec(self):
|
||||
self.show()
|
||||
|
||||
return super().exec()
|
||||
|
||||
def open(self):
|
||||
self.show()
|
||||
|
||||
return super().open()
|
||||
|
||||
def show(self):
|
||||
super().show()
|
||||
|
||||
if PLATFORM == 'Darwin':
|
||||
if self._firstShowCall:
|
||||
APP().processEvents()
|
||||
|
||||
self.setWidthAndHeight()
|
||||
|
||||
self._firstShowCall = False
|
||||
|
||||
moveToCenter(self)
|
||||
|
||||
def retranslate(self):
|
||||
pass
|
||||
|
||||
def disconnectedCallback(self):
|
||||
self.setWindowIcon(AppHue.disconnectedWindowIcon())
|
||||
|
||||
def connectedCallback(self):
|
||||
self.setWindowIcon(AppHue.connectedWindowIcon())
|
||||
|
||||
|
||||
class AppQGroupBox(QTranslatable, QGroupBox):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def retranslate(self):
|
||||
self.setTitle(_(self.title()))
|
||||
|
||||
|
||||
class AppQHeaderView(SupportExitCleanup, SupportConnectedCallback, QHeaderView):
|
||||
def sectionSizeSettingsEmpty(self):
|
||||
return self.sectionSizeSettingsName == ''
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.sectionSizeSettingsName = kwargs.pop('sectionSizeSettingsName', '')
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.columnCount = self.parent().columnCount()
|
||||
self.sectionSizeTable = {}
|
||||
|
||||
self.setSectionsClickable(True)
|
||||
self.setStyleSheet(self.getStyleSheet(AppHue.currentColor()))
|
||||
self.setFont(QFont(APP().customFontName))
|
||||
|
||||
self.sectionResized.connect(self.handleSectionResized)
|
||||
|
||||
def restoreSectionSize(self):
|
||||
if self.sectionSizeSettingsEmpty():
|
||||
return
|
||||
|
||||
try:
|
||||
self.sectionSizeTable = UJSONEncoder.decode(
|
||||
AppSettings.get(self.sectionSizeSettingsName)
|
||||
)
|
||||
|
||||
# Fill missing value
|
||||
for column in range(self.columnCount):
|
||||
if self.sectionSizeTable.get(str(column)) is None:
|
||||
self.sectionSizeTable[str(column)] = self.defaultSectionSize()
|
||||
|
||||
with QBlockSignals(self):
|
||||
for key, value in self.sectionSizeTable.items():
|
||||
self.resizeSection(int(key), value)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
# Leave keys as strings since they will be
|
||||
# loaded as string from json
|
||||
self.sectionSizeTable = {
|
||||
str(column): self.defaultSectionSize()
|
||||
for column in range(self.columnCount)
|
||||
}
|
||||
|
||||
def setCustomSectionResizeMode(self):
|
||||
# Horizontal header resize mode
|
||||
for index in range(self.columnCount):
|
||||
if index < self.columnCount - 1:
|
||||
self.setSectionResizeMode(index, AppQHeaderView.ResizeMode.Interactive)
|
||||
else:
|
||||
self.setSectionResizeMode(index, AppQHeaderView.ResizeMode.Stretch)
|
||||
|
||||
@staticmethod
|
||||
def getStyleSheet(color):
|
||||
return f'QHeaderView::section:hover {{ background-color: {color}; }}'
|
||||
|
||||
def disconnectedCallback(self):
|
||||
self.setStyleSheet(self.getStyleSheet(AppHue.disconnectedColor()))
|
||||
|
||||
def connectedCallback(self):
|
||||
self.setStyleSheet(self.getStyleSheet(AppHue.connectedColor()))
|
||||
|
||||
@QtCore.Slot(int, int, int)
|
||||
def handleSectionResized(self, index: int, oldSize: int, newSize: int):
|
||||
if self.sectionSizeSettingsEmpty():
|
||||
return
|
||||
|
||||
# Keys are string when loaded from json
|
||||
self.sectionSizeTable[str(index)] = newSize
|
||||
|
||||
def cleanup(self):
|
||||
if self.sectionSizeSettingsEmpty():
|
||||
return
|
||||
|
||||
AppSettings.set(
|
||||
self.sectionSizeSettingsName,
|
||||
UJSONEncoder.encode(self.sectionSizeTable),
|
||||
)
|
||||
|
||||
|
||||
class AppQLabel(QTranslatable, QLabel):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def retranslate(self):
|
||||
self.setText(_(self.text()))
|
||||
|
||||
|
||||
class AppQListWidget(SupportConnectedCallback, QListWidget):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.setSelectionColor(AppHue.disconnectedColor())
|
||||
|
||||
def setSelectionColor(self, color):
|
||||
self.setStyleSheet(
|
||||
f'QListWidget::item:selected {{'
|
||||
f' background: {color};'
|
||||
f'}}'
|
||||
f''
|
||||
f'QListWidget::item:hover {{'
|
||||
f' background: {color};'
|
||||
f'}}'
|
||||
)
|
||||
|
||||
@property
|
||||
def selectedIndex(self):
|
||||
return sorted(list(set(index.row() for index in self.selectedIndexes())))
|
||||
|
||||
def disconnectedCallback(self):
|
||||
self.setSelectionColor(AppHue.disconnectedColor())
|
||||
|
||||
def connectedCallback(self):
|
||||
self.setSelectionColor(AppHue.connectedColor())
|
||||
|
||||
|
||||
class AppQMainWindow(
|
||||
QTranslatable,
|
||||
SupportConnectedCallback,
|
||||
SupportExitCleanup,
|
||||
QMainWindow,
|
||||
):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self._firstShowCall = True
|
||||
|
||||
self.setWindowIcon(AppHue.currentWindowIcon())
|
||||
|
||||
self._menuBar = AppQMenuBar(parent=self)
|
||||
self.setMenuBar(self._menuBar)
|
||||
|
||||
if PLATFORM != 'Darwin':
|
||||
self.setWidthAndHeight()
|
||||
|
||||
def setWidthAndHeight(self):
|
||||
pass
|
||||
|
||||
def show(self):
|
||||
super().show()
|
||||
|
||||
if PLATFORM == 'Darwin':
|
||||
if self._firstShowCall:
|
||||
APP().processEvents()
|
||||
|
||||
self.setWidthAndHeight()
|
||||
|
||||
self._firstShowCall = False
|
||||
|
||||
moveToCenter(self)
|
||||
|
||||
def closeEvent(self, event):
|
||||
event.ignore()
|
||||
|
||||
self.hide()
|
||||
|
||||
def retranslate(self):
|
||||
self.setWindowTitle(_(self.windowTitle()))
|
||||
|
||||
def disconnectedCallback(self):
|
||||
self.setWindowIcon(AppHue.disconnectedWindowIcon())
|
||||
|
||||
def connectedCallback(self):
|
||||
self.setWindowIcon(AppHue.connectedWindowIcon())
|
||||
|
||||
def cleanup(self):
|
||||
pass
|
||||
|
||||
|
||||
class AppQMenu(QTranslatable, SupportConnectedCallback, QMenu):
|
||||
def __init__(self, *actions, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
|
||||
# In some old version PySide6, the self.actions() method
|
||||
# does not return with seperators. _actions list append
|
||||
# them all
|
||||
self._actions = []
|
||||
|
||||
for action in actions:
|
||||
if isinstance(action, AppQSeperator):
|
||||
self._actions.append(action)
|
||||
self.addSeparator()
|
||||
elif isinstance(action, AppQAction):
|
||||
self._actions.append(action)
|
||||
self.addAction(action)
|
||||
else:
|
||||
# Do nothing
|
||||
pass
|
||||
|
||||
self.setStyleSheet(self.getStyleSheet(AppHue.currentColor()))
|
||||
|
||||
@staticmethod
|
||||
def getStyleSheet(color):
|
||||
return (
|
||||
f'QMenu::item {{'
|
||||
f' background-color: solid;'
|
||||
f'}}'
|
||||
f''
|
||||
f'QMenu::item:selected {{'
|
||||
f' background-color: {color};'
|
||||
f'}}'
|
||||
)
|
||||
|
||||
def retranslate(self):
|
||||
self.setTitle(_(self.title()))
|
||||
|
||||
def disconnectedCallback(self):
|
||||
self.setStyleSheet(self.getStyleSheet(AppHue.disconnectedColor()))
|
||||
|
||||
def connectedCallback(self):
|
||||
self.setStyleSheet(self.getStyleSheet(AppHue.connectedColor()))
|
||||
|
||||
|
||||
class AppQMenuBar(SupportConnectedCallback, QMenuBar):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.setSelectionColor(AppHue.currentColor())
|
||||
|
||||
@staticmethod
|
||||
def getStyleSheet(color):
|
||||
return (
|
||||
f'QMenuBar::item:selected {{'
|
||||
f' background: {color};'
|
||||
f'}}'
|
||||
f''
|
||||
f'QMenuBar::item:hover {{'
|
||||
f' background: {color};'
|
||||
f'}}'
|
||||
)
|
||||
|
||||
def setSelectionColor(self, color):
|
||||
self.setStyleSheet(self.getStyleSheet(color))
|
||||
|
||||
def disconnectedCallback(self):
|
||||
self.setSelectionColor(AppHue.disconnectedColor())
|
||||
|
||||
def connectedCallback(self):
|
||||
self.setSelectionColor(AppHue.connectedColor())
|
||||
|
||||
|
||||
class AppQMessageBox(QTranslatable, SupportConnectedCallback, QMessageBox):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.setWindowIcon(AppHue.currentWindowIcon())
|
||||
|
||||
def moveToCenter(self):
|
||||
moveToCenter(self, self.parentWidget())
|
||||
|
||||
return self
|
||||
|
||||
def exec(self):
|
||||
self.show()
|
||||
self.moveToCenter()
|
||||
|
||||
return super().exec()
|
||||
|
||||
def open(self):
|
||||
self.show()
|
||||
self.moveToCenter()
|
||||
|
||||
return super().open()
|
||||
|
||||
def retranslate(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():
|
||||
if button.text().find('OK') != -1:
|
||||
# &OK...
|
||||
pass
|
||||
else:
|
||||
button.setText(_(button.text()))
|
||||
|
||||
self.moveToCenter()
|
||||
|
||||
def disconnectedCallback(self):
|
||||
self.setWindowIcon(AppHue.disconnectedWindowIcon())
|
||||
|
||||
def connectedCallback(self):
|
||||
self.setWindowIcon(AppHue.connectedWindowIcon())
|
||||
|
||||
|
||||
class AppQPushButton(QTranslatable, QPushButton):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def retranslate(self):
|
||||
self.setText(_(self.text()))
|
||||
|
||||
|
||||
class AppQStyledItemDelegate(QStyledItemDelegate):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def createEditor(self, parent, option, index):
|
||||
editor = QLineEdit(parent)
|
||||
editor.setFont(QFont(APP().customFontName))
|
||||
|
||||
return editor
|
||||
|
||||
|
||||
class AppQTableWidget(SupportConnectedCallback, QTableWidget):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.setWordWrap(False)
|
||||
|
||||
@property
|
||||
def selectedIndex(self):
|
||||
return sorted(list(set(index.row() for index in self.selectedIndexes())))
|
||||
|
||||
@staticmethod
|
||||
def getStyleSheet(color):
|
||||
return f'QTableWidget {{ selection-background-color: {color}; }}'
|
||||
|
||||
def setSelectionColor(self, color):
|
||||
self.setStyleSheet(self.getStyleSheet(color))
|
||||
|
||||
def activateItemByIndex(self, index, activate):
|
||||
if activate:
|
||||
for column in range(self.columnCount()):
|
||||
item = self.item(int(index), column)
|
||||
|
||||
if item is None:
|
||||
# Do nothing
|
||||
continue
|
||||
|
||||
font = item.font()
|
||||
font.setBold(True)
|
||||
|
||||
item.setFont(font)
|
||||
item.setForeground(QColor(AppHue.currentColor()))
|
||||
else:
|
||||
for column in range(self.columnCount()):
|
||||
item = self.item(int(index), column)
|
||||
|
||||
if item is None:
|
||||
# Do nothing
|
||||
continue
|
||||
|
||||
font = item.font()
|
||||
font.setBold(False)
|
||||
|
||||
item.setFont(font)
|
||||
item.setForeground(QBrush())
|
||||
|
||||
def selectMultipleRows(self, indexes: list[int], clearCurrentSelection: bool):
|
||||
if clearCurrentSelection:
|
||||
self.selectionModel().clearSelection()
|
||||
|
||||
selection = self.selectionModel().selection()
|
||||
|
||||
for index in indexes:
|
||||
selection.select(
|
||||
self.model().index(index, 0),
|
||||
self.model().index(index, self.columnCount() - 1),
|
||||
)
|
||||
|
||||
self.selectionModel().select(
|
||||
selection, QtCore.QItemSelectionModel.SelectionFlag.Select
|
||||
)
|
||||
|
||||
def disconnectedCallback(self):
|
||||
self.setSelectionColor(AppHue.disconnectedColor())
|
||||
|
||||
def connectedCallback(self):
|
||||
self.setSelectionColor(AppHue.connectedColor())
|
||||
|
||||
|
||||
class AppQTabWidget(QTranslatable, QTabWidget):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def retranslate(self):
|
||||
for index in range(self.count()):
|
||||
self.setTabText(index, _(self.tabText(index)))
|
||||
|
||||
|
||||
class AppQToolBar(QTranslatable, QToolBar):
|
||||
def __init__(self, *actions, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
|
||||
self._actions = []
|
||||
|
||||
for action in actions:
|
||||
if isinstance(action, AppQSeperator):
|
||||
self._actions.append(action)
|
||||
self.addSeparator()
|
||||
elif isinstance(action, AppQAction):
|
||||
self._actions.append(action)
|
||||
self.addAction(action)
|
||||
else:
|
||||
# Do nothing
|
||||
pass
|
||||
|
||||
self.setStyleSheet(self.getStyleSheet())
|
||||
|
||||
@staticmethod
|
||||
def getStyleSheet():
|
||||
return f'QToolBar {{ spacing: 5px; }}'
|
||||
|
||||
def retranslate(self):
|
||||
self.setWindowTitle(_(self.windowTitle()))
|
||||
|
||||
|
||||
needTrans(
|
||||
'Delete',
|
||||
'Delete these items?',
|
||||
'Delete this item?',
|
||||
)
|
||||
|
||||
|
||||
class QuestionDeleteMBox(AppQMessageBox):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.isMulti = False
|
||||
self.possibleRemark = ''
|
||||
|
||||
self.setWindowTitle(_('Delete'))
|
||||
self.setStandardButtons(
|
||||
AppQMessageBox.StandardButton.Yes | AppQMessageBox.StandardButton.No
|
||||
)
|
||||
|
||||
def customText(self) -> str:
|
||||
if self.isMulti:
|
||||
return _('Delete these items?')
|
||||
else:
|
||||
return _('Delete this item?') + f'\n\n{self.possibleRemark}'
|
||||
|
||||
def retranslate(self):
|
||||
self.setWindowTitle(_(self.windowTitle()))
|
||||
self.setText(self.customText())
|
||||
|
||||
# Ignore informative text, buttons
|
||||
|
||||
self.moveToCenter()
|
||||
|
||||
|
||||
needTrans('New changes will take effect next time')
|
||||
|
||||
|
||||
class NewChangesNextTimeMBox(AppQMessageBox):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.setIcon(AppQMessageBox.Icon.Information)
|
||||
self.setText(_('New changes will take effect next time'))
|
||||
@@ -0,0 +1,322 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.PyFramework import *
|
||||
from Furious.QtFramework.TextEditorTheme import (
|
||||
DraculaEditorTheme,
|
||||
DraculaJSONSyntaxHighlighter,
|
||||
)
|
||||
from Furious.Utility import *
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtGui import *
|
||||
from PySide6.QtWidgets import *
|
||||
|
||||
from typing import Callable
|
||||
|
||||
import functools
|
||||
|
||||
__all__ = [
|
||||
'AppQPlainTextEdit',
|
||||
'AppQTextBrowser',
|
||||
'DraculaTextEditor',
|
||||
'DraculaJSONTextEditor',
|
||||
'DraculaTextBrowser',
|
||||
]
|
||||
|
||||
|
||||
class SupportPointSizeSettings(SupportExitCleanup):
|
||||
def pointSizeSettingsEmpty(self):
|
||||
return self.pointSizeSettingsName == ''
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.pointSizeSettingsName = kwargs.pop('pointSizeSettingsName', '')
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.uniqueCleanup = False
|
||||
|
||||
self.restorePointSize()
|
||||
|
||||
def restorePointSize(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def cleanup(self):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class AppQPlainTextEdit(SupportPointSizeSettings, QPlainTextEdit):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def restorePointSize(self):
|
||||
if self.pointSizeSettingsEmpty():
|
||||
return
|
||||
|
||||
try:
|
||||
# Restore point size
|
||||
font = self.font()
|
||||
font.setPointSize(int(AppSettings.get(self.pointSizeSettingsName)))
|
||||
|
||||
self.setFont(font)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
@functools.lru_cache(128)
|
||||
def getIndent(line):
|
||||
indent = ''
|
||||
|
||||
for char in line:
|
||||
if char.isspace():
|
||||
indent += char
|
||||
else:
|
||||
break
|
||||
|
||||
return indent
|
||||
|
||||
def getPrevAndNextChar(self, cursor):
|
||||
plainText = self.toPlainText()
|
||||
|
||||
cursor.movePosition(QTextCursor.MoveOperation.Left)
|
||||
|
||||
try:
|
||||
prevChar = plainText[cursor.position()]
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
prevChar = ''
|
||||
|
||||
# Move the cursor to the next character position
|
||||
cursor.movePosition(QTextCursor.MoveOperation.Right)
|
||||
|
||||
try:
|
||||
nextChar = plainText[cursor.position()]
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
nextChar = ''
|
||||
|
||||
return prevChar + nextChar
|
||||
|
||||
def smartIndent(self, event):
|
||||
cursor = self.textCursor()
|
||||
indent = self.getIndent(cursor.block().text())
|
||||
|
||||
# Do newline action
|
||||
super().keyPressEvent(event)
|
||||
|
||||
# Add last line indent
|
||||
cursor.insertText(indent)
|
||||
|
||||
self.setTextCursor(cursor)
|
||||
|
||||
def smartSymbolPair(self, event, pair):
|
||||
plainText = self.toPlainText()
|
||||
|
||||
cursor = self.textCursor()
|
||||
|
||||
if (
|
||||
cursor.position() < len(plainText)
|
||||
and plainText[cursor.position()] == pair[0]
|
||||
):
|
||||
# Do pair0 action
|
||||
super().keyPressEvent(event)
|
||||
else:
|
||||
# Do pair0 action
|
||||
super().keyPressEvent(event)
|
||||
|
||||
# Do pair1 action
|
||||
cursor.insertText(pair[1])
|
||||
# Move to middle
|
||||
cursor.movePosition(QTextCursor.MoveOperation.Left)
|
||||
|
||||
self.setTextCursor(cursor)
|
||||
|
||||
def smartBackspace(self, event):
|
||||
cursor = self.textCursor()
|
||||
chPair = self.getPrevAndNextChar(cursor)
|
||||
|
||||
if chPair == '""' or chPair == '{}' or chPair == '[]':
|
||||
cursor.deleteChar()
|
||||
cursor.deletePreviousChar()
|
||||
else:
|
||||
super().keyPressEvent(event)
|
||||
|
||||
def hideTabAndSpaces(self):
|
||||
textOption = QTextOption()
|
||||
|
||||
self.document().setDefaultTextOption(textOption)
|
||||
# Reset. Set
|
||||
self.setLineWrapMode(QPlainTextEdit.LineWrapMode.WidgetWidth)
|
||||
self.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
|
||||
|
||||
def showTabAndSpaces(self):
|
||||
textOption = QTextOption()
|
||||
textOption.setFlags(QTextOption.Flag.ShowTabsAndSpaces)
|
||||
|
||||
self.document().setDefaultTextOption(textOption)
|
||||
# Reset. Set
|
||||
self.setLineWrapMode(QPlainTextEdit.LineWrapMode.WidgetWidth)
|
||||
self.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
if (
|
||||
event.key() == QtCore.Qt.Key.Key_Return
|
||||
or event.key() == QtCore.Qt.Key.Key_Enter
|
||||
):
|
||||
self.smartIndent(event)
|
||||
elif event.key() == QtCore.Qt.Key.Key_Backspace:
|
||||
self.smartBackspace(event)
|
||||
elif event.key() == QtCore.Qt.Key.Key_QuoteDbl:
|
||||
self.smartSymbolPair(event, '""')
|
||||
elif event.key() == QtCore.Qt.Key.Key_BraceLeft:
|
||||
self.smartSymbolPair(event, '{}')
|
||||
elif event.key() == QtCore.Qt.Key.Key_BracketLeft:
|
||||
self.smartSymbolPair(event, '[]')
|
||||
else:
|
||||
super().keyPressEvent(event)
|
||||
|
||||
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)
|
||||
|
||||
def cleanup(self):
|
||||
if self.pointSizeSettingsEmpty():
|
||||
return
|
||||
|
||||
AppSettings.set(self.pointSizeSettingsName, str(self.font().pointSize()))
|
||||
|
||||
|
||||
class AppQTextBrowser(SupportPointSizeSettings, QTextBrowser):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def appendLine(self, line: str):
|
||||
hScrollBar = self.horizontalScrollBar()
|
||||
vScrollBar = self.verticalScrollBar()
|
||||
scrollEnds = vScrollBar.maximum() - vScrollBar.value() <= 10
|
||||
|
||||
# Fix insertPlainText bug if user cursor is present
|
||||
self.append(line.rstrip())
|
||||
|
||||
if scrollEnds:
|
||||
vScrollBar.setValue(vScrollBar.maximum()) # Scrolls to the bottom
|
||||
hScrollBar.setValue(0) # scroll to the left
|
||||
|
||||
def restorePointSize(self):
|
||||
if self.pointSizeSettingsEmpty():
|
||||
return
|
||||
|
||||
try:
|
||||
# Restore point size
|
||||
font = self.font()
|
||||
font.setPointSize(int(AppSettings.get(self.pointSizeSettingsName)))
|
||||
|
||||
self.setFont(font)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
pass
|
||||
|
||||
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)
|
||||
|
||||
def cleanup(self):
|
||||
if self.pointSizeSettingsEmpty():
|
||||
return
|
||||
|
||||
AppSettings.set(self.pointSizeSettingsName, str(self.font().pointSize()))
|
||||
|
||||
|
||||
class DraculaTextEditor(AppQPlainTextEdit):
|
||||
def __init__(self, *args, **kwargs):
|
||||
fontFamily = kwargs.pop('fontFamily', '')
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self._modificationChangedCb = None
|
||||
self._cursorPositionChangedCb = None
|
||||
|
||||
# Theme
|
||||
self.setStyleSheet(
|
||||
DraculaEditorTheme.getStyleSheet(
|
||||
widgetName='QPlainTextEdit', fontFamily=fontFamily
|
||||
)
|
||||
)
|
||||
|
||||
@QtCore.Slot(bool)
|
||||
def handleModificationChanged(changed):
|
||||
if changed:
|
||||
self.document().setModified(False)
|
||||
|
||||
if callable(self._modificationChangedCb):
|
||||
self._modificationChangedCb()
|
||||
|
||||
@QtCore.Slot()
|
||||
def handleCursorPositionChanged():
|
||||
if callable(self._cursorPositionChangedCb):
|
||||
self._cursorPositionChangedCb(self.textCursor())
|
||||
|
||||
self.modificationChanged.connect(handleModificationChanged)
|
||||
self.cursorPositionChanged.connect(handleCursorPositionChanged)
|
||||
|
||||
def registerCursorPositionChangedCb(self, callback: Callable[[QTextCursor], None]):
|
||||
self._cursorPositionChangedCb = callback
|
||||
|
||||
def registerModificationChangedCb(self, callback: Callable[[], None]):
|
||||
self._modificationChangedCb = callback
|
||||
|
||||
|
||||
class DraculaJSONTextEditor(DraculaTextEditor):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self._syntaxHighlighter = DraculaJSONSyntaxHighlighter(self.document())
|
||||
|
||||
|
||||
class DraculaTextBrowser(AppQTextBrowser):
|
||||
def __init__(self, *args, **kwargs):
|
||||
fontFamily = kwargs.pop('fontFamily', '')
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Theme
|
||||
self.setStyleSheet(
|
||||
DraculaEditorTheme.getStyleSheet(
|
||||
widgetName='QTextBrowser', fontFamily=fontFamily
|
||||
)
|
||||
)
|
||||
@@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2023 Loren Eteval <loren.eteval@proton.me>
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
@@ -16,10 +16,12 @@
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtGui import QColor, QFont, QSyntaxHighlighter, QTextCharFormat
|
||||
from PySide6.QtGui import *
|
||||
|
||||
__all__ = ['DraculaEditorTheme', 'DraculaJSONSyntaxHighlighter']
|
||||
|
||||
|
||||
class HighlightRules:
|
||||
class EditorHighlightRules:
|
||||
def __init__(self, regex, color, isBold=False, isJSONKey=False):
|
||||
self.regex = QtCore.QRegularExpression(regex)
|
||||
self.color = QColor(color)
|
||||
@@ -33,7 +35,7 @@ class HighlightRules:
|
||||
self.isJSONKey = isJSONKey
|
||||
|
||||
|
||||
class Theme(QSyntaxHighlighter):
|
||||
class EditorTheme:
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@@ -42,23 +44,40 @@ class Theme(QSyntaxHighlighter):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class DraculaTheme(Theme):
|
||||
class DraculaEditorTheme(EditorTheme):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def getStyleSheet(widgetName, fontFamily):
|
||||
return (
|
||||
f'{widgetName} {{'
|
||||
f' background-color: #282A36;'
|
||||
f' color: #F8F8F2;'
|
||||
f' font-family: \'{fontFamily}\';'
|
||||
f'}}'
|
||||
)
|
||||
|
||||
|
||||
class DraculaJSONSyntaxHighlighter(QSyntaxHighlighter):
|
||||
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),
|
||||
EditorHighlightRules(r'\b(true|false|null)\b', '#FF79C6', isBold=True),
|
||||
# Numbers. Purple
|
||||
HighlightRules(r'[-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?', '#BD93F9'),
|
||||
EditorHighlightRules(r'[-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?', '#BD93F9'),
|
||||
# Symbols: :, [, ], {, }. White
|
||||
HighlightRules(r'[:,\[\]\{\}]', '#F8F8F2'),
|
||||
EditorHighlightRules(r'[:,\[\]\{\}]', '#F8F8F2'),
|
||||
# Double-quoted strings. Yellow
|
||||
HighlightRules(r'"[^"\\]*(\\.[^"\\]*)*"', '#F1FA8C'),
|
||||
EditorHighlightRules(r'"[^"\\]*(\\.[^"\\]*)*"', '#F1FA8C'),
|
||||
# JSON keys. Green
|
||||
HighlightRules(r'"([^"\\]*(\\.[^"\\]*)*)"\s*:', '#50FA7B', isJSONKey=True),
|
||||
EditorHighlightRules(
|
||||
r'"([^"\\]*(\\.[^"\\]*)*)"\s*:', '#50FA7B', isJSONKey=True
|
||||
),
|
||||
# Comments(only for hints on display). Grey
|
||||
HighlightRules(r'^#.*', '#6272a4'),
|
||||
EditorHighlightRules(r'^#.*', '#6272a4'),
|
||||
]
|
||||
|
||||
def highlightBlock(self, text):
|
||||
@@ -77,13 +96,3 @@ class DraculaTheme(Theme):
|
||||
self.setFormat(
|
||||
match.capturedStart(), capturedLength, highlightRule.rules
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def getStyleSheet(widgetName, fontFamily):
|
||||
return (
|
||||
f'{widgetName} {{'
|
||||
f' background-color: #282A36;'
|
||||
f' color: #F8F8F2;'
|
||||
f' font-family: \'{fontFamily}\';'
|
||||
f'}}'
|
||||
)
|
||||
@@ -0,0 +1,162 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.QtFramework.QtNetwork import *
|
||||
from Furious.QtFramework.QtWidgets import *
|
||||
from Furious.QtFramework.DynamicTranslate import gettext as _, needTransFn
|
||||
from Furious.Utility import *
|
||||
from Furious.Library import *
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtGui import *
|
||||
from PySide6.QtNetwork import *
|
||||
|
||||
from typing import Union
|
||||
|
||||
import logging
|
||||
import operator
|
||||
import functools
|
||||
|
||||
__all__ = ['UpdatesManager']
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
needTrans = functools.partial(needTransFn, source=__name__)
|
||||
|
||||
|
||||
def versionToNumber(version):
|
||||
major_weight = 10000
|
||||
minor_weight = 100
|
||||
patch_weight = 1
|
||||
|
||||
return functools.reduce(
|
||||
operator.add,
|
||||
list(
|
||||
int(ver) * weight
|
||||
for ver, weight in zip(
|
||||
version.split('.'), [major_weight, minor_weight, patch_weight]
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
needTrans('New version available')
|
||||
|
||||
|
||||
class QuestionUpdateMBox(AppQMessageBox):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.version = '0.0.0'
|
||||
self.setWindowTitle(_(APPLICATION_NAME))
|
||||
self.setStandardButtons(
|
||||
AppQMessageBox.StandardButton.Yes | AppQMessageBox.StandardButton.No
|
||||
)
|
||||
|
||||
def customText(self):
|
||||
return _('New version available') + f': {self.version}'
|
||||
|
||||
def retranslate(self):
|
||||
self.setText(self.customText())
|
||||
self.setWindowTitle(_(self.windowTitle()))
|
||||
self.setInformativeText(_(self.informativeText()))
|
||||
|
||||
# Ignore button text
|
||||
|
||||
self.moveToCenter()
|
||||
|
||||
|
||||
needTrans(
|
||||
'Check for updates failed',
|
||||
'Go to download page?',
|
||||
f'{APPLICATION_NAME} is already the latest version',
|
||||
)
|
||||
|
||||
|
||||
class UpdatesManager(AppQNetworkAccessManager):
|
||||
API_URL = (
|
||||
f'https://api.github.com/repos/'
|
||||
f'{APPLICATION_REPO_OWNER_NAME}/{APPLICATION_REPO_NAME}/releases/latest'
|
||||
)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
@staticmethod
|
||||
def handleFinishedByNetworkReply(networkReply):
|
||||
assert isinstance(networkReply, QNetworkReply)
|
||||
|
||||
if networkReply.error() != QNetworkReply.NetworkError.NoError:
|
||||
logger.error(f'check for updates failed. {networkReply.errorString()}')
|
||||
|
||||
mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Critical)
|
||||
mbox.setWindowTitle(_(APPLICATION_NAME))
|
||||
mbox.setText(_('Check for updates failed'))
|
||||
|
||||
# Show the MessageBox and wait for user to close it
|
||||
mbox.exec()
|
||||
else:
|
||||
logger.info('check for updates success')
|
||||
|
||||
# Unchecked?
|
||||
info = UJSONEncoder.decode(networkReply.readAll().data())
|
||||
|
||||
if versionToNumber(info['tag_name']) > versionToNumber(APPLICATION_VERSION):
|
||||
mbox = QuestionUpdateMBox(icon=AppQMessageBox.Icon.Information)
|
||||
mbox.version = info['tag_name']
|
||||
mbox.setText(mbox.customText())
|
||||
mbox.setInformativeText(_('Go to download page?'))
|
||||
|
||||
# Show the MessageBox and wait for user to close it
|
||||
if mbox.exec() == PySide6LegacyEnumValueWrapper(
|
||||
AppQMessageBox.StandardButton.Yes
|
||||
):
|
||||
if QDesktopServices.openUrl(QtCore.QUrl(info['html_url'])):
|
||||
logger.info('open download page success')
|
||||
else:
|
||||
logger.error('open download page failed')
|
||||
else:
|
||||
# Do nothing
|
||||
pass
|
||||
else:
|
||||
mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Information)
|
||||
mbox.setWindowTitle(_(APPLICATION_NAME))
|
||||
mbox.setText(_(f'{APPLICATION_NAME} is already the latest version'))
|
||||
|
||||
# Show the MessageBox and wait for user to close it
|
||||
mbox.exec()
|
||||
|
||||
def configureHttpProxy(self, httpProxy: Union[str, None]) -> bool:
|
||||
useProxy = super().configureHttpProxy(httpProxy)
|
||||
|
||||
if useProxy:
|
||||
logger.info(f'check for updates uses proxy server {httpProxy}')
|
||||
else:
|
||||
logger.info(f'check for updates uses no proxy')
|
||||
|
||||
return useProxy
|
||||
|
||||
def checkForUpdates(self):
|
||||
networkReply = self.get(QNetworkRequest(QtCore.QUrl(self.API_URL)))
|
||||
networkReply.finished.connect(
|
||||
functools.partial(
|
||||
self.handleFinishedByNetworkReply,
|
||||
networkReply,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from .Ancestors import *
|
||||
from .DNSResolver import *
|
||||
from .CoreProcess import *
|
||||
from .DynamicTheme import *
|
||||
from .DynamicTranslate import *
|
||||
from .QtGui import *
|
||||
from .QtWidgets import *
|
||||
from .QtNetwork import *
|
||||
from .UpdatesManager import *
|
||||
from .NetworkStateManager import *
|
||||
from .TextEditor import *
|
||||
from .TextEditorTheme import *
|
||||
@@ -0,0 +1,66 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.Interface import *
|
||||
from Furious.PyFramework import *
|
||||
from Furious.Utility import *
|
||||
from Furious.Library import *
|
||||
|
||||
__all__ = ['UserServers']
|
||||
|
||||
registerAppSettings('Configuration')
|
||||
|
||||
|
||||
class UserServers(SupportExitCleanup, StorageFactory):
|
||||
# remark, config, subsId. (subsId corresponds to unique in user subscription)
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def restore():
|
||||
try:
|
||||
return UJSONEncoder.decode(
|
||||
PyBase64Encoder.decode(AppSettings.get('Configuration'))
|
||||
)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return {'model': []}
|
||||
|
||||
self._data = restore()
|
||||
self._list = list(
|
||||
constructFromAny(model.pop('config', ''), **model)
|
||||
for model in self._data['model']
|
||||
)
|
||||
|
||||
def sync(self):
|
||||
AppSettings.set(
|
||||
'Configuration',
|
||||
PyBase64Encoder.encode(
|
||||
UJSONEncoder.encode(
|
||||
{'model': list(factory.toStorageObject() for factory in self._list)}
|
||||
).encode()
|
||||
),
|
||||
)
|
||||
|
||||
def data(self) -> list[ConfigurationFactory]:
|
||||
# Shallow copy
|
||||
return self._list
|
||||
|
||||
def cleanup(self):
|
||||
self.sync()
|
||||
@@ -0,0 +1,60 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.Interface import *
|
||||
from Furious.PyFramework import *
|
||||
from Furious.Utility import *
|
||||
from Furious.Library import *
|
||||
|
||||
__all__ = ['UserSubs']
|
||||
|
||||
registerAppSettings('CustomSubscription')
|
||||
|
||||
|
||||
class UserSubs(SupportExitCleanup, StorageFactory):
|
||||
# unique: remark, webURL
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def restore():
|
||||
try:
|
||||
return UJSONEncoder.decode(
|
||||
PyBase64Encoder.decode(AppSettings.get('CustomSubscription'))
|
||||
)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return {}
|
||||
|
||||
self._data = restore()
|
||||
|
||||
def sync(self):
|
||||
AppSettings.set(
|
||||
'CustomSubscription',
|
||||
PyBase64Encoder.encode(
|
||||
UJSONEncoder.encode(self._data).encode(),
|
||||
),
|
||||
)
|
||||
|
||||
def data(self) -> dict[str, dict]:
|
||||
# Shallow copy
|
||||
return self._data
|
||||
|
||||
def cleanup(self):
|
||||
self.sync()
|
||||
@@ -0,0 +1,19 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from .UserServers import *
|
||||
from .UserSubs import *
|
||||
@@ -0,0 +1,351 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.Interface import *
|
||||
from Furious.PyFramework import *
|
||||
from Furious.QtFramework import *
|
||||
from Furious.QtFramework import gettext as _
|
||||
from Furious.Utility import *
|
||||
from Furious.Library import *
|
||||
from Furious.Core import *
|
||||
from Furious.Widget.ConnectProgressBar import ConnectProgressBar
|
||||
|
||||
from PySide6 import QtCore
|
||||
|
||||
import queue
|
||||
import logging
|
||||
import functools
|
||||
|
||||
__all__ = ['ConnectAction']
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
registerAppSettings('Connect', isBinary=True)
|
||||
|
||||
needTrans = functools.partial(needTransFn, source=__name__)
|
||||
|
||||
needTrans(
|
||||
'Connect',
|
||||
'Connecting',
|
||||
'Disconnect',
|
||||
'Unable to connect',
|
||||
'Server configuration empty. Please configure your server first',
|
||||
'Select and double click to activate configuration and connect',
|
||||
f'{APPLICATION_NAME} cannot find any valid http proxy '
|
||||
f'endpoint in the configuration',
|
||||
'Please complete your server configuration',
|
||||
'Connected',
|
||||
'Unknown error',
|
||||
'Invalid server configuration',
|
||||
'Failed to start core',
|
||||
'Connection to server has been lost',
|
||||
'Core terminated unexpectedly',
|
||||
'Disconnected',
|
||||
)
|
||||
|
||||
|
||||
def validateProxyServer(server) -> bool:
|
||||
try:
|
||||
host, port = parseHostPort(server)
|
||||
|
||||
if int(port) < 0 or int(port) > 65535:
|
||||
raise ValueError
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
class ConnectAction(AppQAction):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
_('Connect'),
|
||||
icon=bootstrapIcon('unlock-fill.svg'),
|
||||
checkable=True,
|
||||
)
|
||||
|
||||
self.actionQueue = queue.Queue()
|
||||
self.coreManager = CoreManager()
|
||||
self.progressBar = ConnectProgressBar()
|
||||
|
||||
self.actionTimer = QtCore.QTimer()
|
||||
self.actionTimer.timeout.connect(lambda: self.callActionFromQueue())
|
||||
|
||||
def reset(self):
|
||||
self.hideProgressBar(True)
|
||||
self.setText(_('Connect'))
|
||||
self.setIcon(bootstrapIcon('unlock-fill.svg'))
|
||||
self.setChecked(False)
|
||||
|
||||
AppSettings.turnOFF('Connect')
|
||||
|
||||
# Accept new action
|
||||
self.setDisabledAction(False)
|
||||
|
||||
def showProgressBar(self):
|
||||
if AppSettings.isStateON_('ShowProgressBarWhenConnecting'):
|
||||
self.progressBar.setValue(0)
|
||||
# Update the progress bar every 50ms
|
||||
self.progressBar.start(50)
|
||||
self.progressBar.show()
|
||||
|
||||
return self
|
||||
|
||||
def hideProgressBar(self, done: bool):
|
||||
if done:
|
||||
self.progressBar.setValue(100)
|
||||
|
||||
self.progressBar.hide()
|
||||
self.progressBar.stop()
|
||||
|
||||
return self
|
||||
|
||||
def setDisabledAction(self, value):
|
||||
self.setDisabled(value)
|
||||
|
||||
APP().systemTray.RoutingAction.setDisabled(value)
|
||||
APP().systemTray.SystemProxyAction.setDisabled(value)
|
||||
|
||||
if isAdministrator():
|
||||
VPNModeAction = APP().systemTray.SettingsAction.getVPNModeAction()
|
||||
|
||||
if VPNModeAction is not None:
|
||||
VPNModeAction.setDisabled(value)
|
||||
|
||||
def isConnected(self) -> bool:
|
||||
return self.textCompare('Disconnect')
|
||||
|
||||
def isConnecting(self):
|
||||
return self.textCompare('Connecting')
|
||||
|
||||
def doConnecting(self):
|
||||
self.setText(_('Connecting'))
|
||||
self.setIcon(bootstrapIcon('lock-fill.svg'))
|
||||
# Do not accept new action
|
||||
self.setDisabledAction(True)
|
||||
self.showProgressBar()
|
||||
|
||||
def doConnected(self):
|
||||
self.hideProgressBar(True)
|
||||
# Connected
|
||||
self.setText(_('Disconnect'))
|
||||
|
||||
AppSettings.turnON_('Connect')
|
||||
|
||||
SupportConnectedCallback.callConnectedCallback()
|
||||
|
||||
# Accept new action
|
||||
self.setDisabledAction(False)
|
||||
|
||||
def doDisconnect(self):
|
||||
SystemProxy.off()
|
||||
|
||||
self.actionTimer.stop()
|
||||
|
||||
self.coreManager.stopAll()
|
||||
self.reset()
|
||||
|
||||
while not self.actionQueue.empty():
|
||||
try:
|
||||
unused = self.actionQueue.get_nowait()
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
pass
|
||||
|
||||
SupportConnectedCallback.callDisconnectedCallback()
|
||||
|
||||
def doDisconnectWithTrayMessage(self, message: str):
|
||||
self.doDisconnect()
|
||||
|
||||
APP().systemTray.showMessage(message)
|
||||
|
||||
def doConnect(self):
|
||||
# Connect action
|
||||
assert self.textCompare('Connect')
|
||||
|
||||
if not AS_UserServers():
|
||||
AppSettings.turnOFF('Connect')
|
||||
|
||||
self.setChecked(False)
|
||||
|
||||
mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Critical)
|
||||
mbox.setWindowTitle(_('Unable to connect'))
|
||||
mbox.setText(
|
||||
_('Server configuration empty. Please configure your server first')
|
||||
)
|
||||
|
||||
# Show the MessageBox and wait for user to close it
|
||||
mbox.exec()
|
||||
|
||||
return
|
||||
|
||||
if AS_UserActivatedItemIndex() < 0:
|
||||
AppSettings.turnOFF('Connect')
|
||||
|
||||
self.setChecked(False)
|
||||
|
||||
mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Critical)
|
||||
mbox.setWindowTitle(_('Unable to connect'))
|
||||
mbox.setText(
|
||||
_('Select and double click to activate configuration and connect')
|
||||
)
|
||||
|
||||
# Show the MessageBox and wait for user to close it
|
||||
mbox.exec()
|
||||
|
||||
return
|
||||
|
||||
try:
|
||||
config = AS_UserServers()[AS_UserActivatedItemIndex()]
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
AppSettings.turnOFF('Connect')
|
||||
|
||||
self.setChecked(False)
|
||||
|
||||
return
|
||||
|
||||
assert isinstance(config, ConfigurationFactory)
|
||||
|
||||
if not validateProxyServer(config.httpProxyEndpoint()):
|
||||
AppSettings.turnOFF('Connect')
|
||||
|
||||
self.setChecked(False)
|
||||
|
||||
mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Critical)
|
||||
mbox.setWindowTitle(_('Unable to connect'))
|
||||
mbox.setText(
|
||||
_(
|
||||
f'{APPLICATION_NAME} cannot find any valid http proxy '
|
||||
f'endpoint in the configuration'
|
||||
)
|
||||
)
|
||||
mbox.setInformativeText(_('Please complete your server configuration'))
|
||||
|
||||
# Show the MessageBox and wait for user to close it
|
||||
mbox.exec()
|
||||
|
||||
return
|
||||
|
||||
self.doConnecting()
|
||||
|
||||
# Clear previous log
|
||||
APP().logViewerWindowCore.clear()
|
||||
APP().logViewerWindowTun_.clear()
|
||||
|
||||
success = self.coreManager.start(
|
||||
config,
|
||||
routing=AppSettings.get('Routing'),
|
||||
exitCallback=self.coreExitCallback,
|
||||
msgCallback=lambda line: APP().logViewerWindowCore.appendLine(line),
|
||||
tunMsgCallback=lambda line: APP().logViewerWindowTun_.appendLine(line),
|
||||
)
|
||||
|
||||
if self.actionQueue.empty():
|
||||
if success:
|
||||
SystemProxy.set(config.httpProxyEndpoint(), PROXY_SERVER_BYPASS)
|
||||
|
||||
self.doConnected()
|
||||
|
||||
APP().systemTray.showMessage(f'{config.coreName()}: ' + _('Connected'))
|
||||
|
||||
self.actionTimer.start(CORE_CHECK_ALIVE_INTERVAL)
|
||||
else:
|
||||
logger.error('failed to start core manager')
|
||||
|
||||
self.coreManager.stopAll()
|
||||
self.doDisconnectWithTrayMessage(
|
||||
f'{config.coreName()}: ' + _('Unknown error')
|
||||
)
|
||||
else:
|
||||
while not self.actionQueue.empty():
|
||||
self.callActionFromQueue()
|
||||
|
||||
def callActionFromQueue(self):
|
||||
try:
|
||||
action = self.actionQueue.get_nowait()
|
||||
except queue.Empty:
|
||||
# Queue is empty
|
||||
|
||||
pass
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
pass
|
||||
else:
|
||||
if callable(action):
|
||||
action()
|
||||
|
||||
def coreExitCallback(self, core: CoreFactory, exitcode: int):
|
||||
def putItem(item):
|
||||
try:
|
||||
self.actionQueue.put_nowait(item)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
pass
|
||||
|
||||
if exitcode == CoreFactory.ExitCode.SystemShuttingDown:
|
||||
# System shutting down. Do nothing
|
||||
return
|
||||
|
||||
if exitcode == CoreFactory.ExitCode.ConfigurationError:
|
||||
return putItem(
|
||||
functools.partial(
|
||||
self.doDisconnectWithTrayMessage,
|
||||
f'{core.name()}: ' + _('Invalid server configuration'),
|
||||
)
|
||||
)
|
||||
|
||||
if exitcode == CoreFactory.ExitCode.ServerStartFailure:
|
||||
return putItem(
|
||||
functools.partial(
|
||||
self.doDisconnectWithTrayMessage,
|
||||
f'{core.name()}: ' + _('Failed to start core'),
|
||||
)
|
||||
)
|
||||
|
||||
if isinstance(core, Hysteria1):
|
||||
if exitcode == Hysteria1.ExitCode.RemoteNetworkError:
|
||||
return putItem(
|
||||
functools.partial(
|
||||
self.doDisconnectWithTrayMessage,
|
||||
f'{core.name()}: ' + _('Connection to server has been lost'),
|
||||
)
|
||||
)
|
||||
|
||||
return putItem(
|
||||
functools.partial(
|
||||
self.doDisconnectWithTrayMessage,
|
||||
f'{core.name()}: ' + _('Core terminated unexpectedly'),
|
||||
)
|
||||
)
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
if checked:
|
||||
self.doConnect()
|
||||
else:
|
||||
# Disconnect action
|
||||
assert self.textCompare('Disconnect')
|
||||
|
||||
self.doDisconnectWithTrayMessage(_('Disconnected'))
|
||||
@@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2023 Loren Eteval <loren.eteval@proton.me>
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
@@ -15,13 +15,20 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from Furious.Gui.Action import Action
|
||||
from Furious.Utility.Constants import APP
|
||||
from Furious.Utility.Utility import bootstrapIcon
|
||||
from Furious.Utility.Translator import gettext as _
|
||||
from Furious.QtFramework import *
|
||||
from Furious.QtFramework import gettext as _
|
||||
from Furious.Utility import *
|
||||
|
||||
import functools
|
||||
|
||||
__all__ = ['EditConfigurationAction']
|
||||
|
||||
needTrans = functools.partial(needTransFn, source=__name__)
|
||||
|
||||
needTrans('Edit Configuration...')
|
||||
|
||||
|
||||
class EditConfigurationAction(Action):
|
||||
class EditConfigurationAction(AppQAction):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
_('Edit Configuration...'),
|
||||
@@ -29,4 +36,4 @@ class EditConfigurationAction(Action):
|
||||
)
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
APP().ServerWidget.show()
|
||||
APP().mainWindow.show()
|
||||
@@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2023 Loren Eteval <loren.eteval@proton.me>
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
@@ -15,13 +15,20 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from Furious.Gui.Action import Action
|
||||
from Furious.Utility.Constants import APP
|
||||
from Furious.Utility.Utility import bootstrapIcon
|
||||
from Furious.Utility.Translator import gettext as _
|
||||
from Furious.QtFramework import *
|
||||
from Furious.QtFramework import gettext as _
|
||||
from Furious.Utility import *
|
||||
|
||||
import functools
|
||||
|
||||
__all__ = ['ExitAction']
|
||||
|
||||
needTrans = functools.partial(needTransFn, source=__name__)
|
||||
|
||||
needTrans('Exit')
|
||||
|
||||
|
||||
class ExitAction(Action):
|
||||
class ExitAction(AppQAction):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
_('Exit'),
|
||||
@@ -0,0 +1,287 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from Furious.Interface import *
|
||||
from Furious.QtFramework import *
|
||||
from Furious.QtFramework import gettext as _
|
||||
from Furious.Library import *
|
||||
from Furious.Utility import *
|
||||
|
||||
from PySide6.QtWidgets import QApplication, QFileDialog
|
||||
|
||||
import os
|
||||
import logging
|
||||
import functools
|
||||
|
||||
__all__ = [
|
||||
'ImportFromFileAction',
|
||||
'ImportURIFromClipboardAction',
|
||||
'ImportJSONFromClipboardAction',
|
||||
'ImportAction',
|
||||
]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
needTrans = functools.partial(needTransFn, source=__name__)
|
||||
|
||||
needTrans(
|
||||
'Invalid data',
|
||||
'Invalid data. The content of the clipboard is:',
|
||||
)
|
||||
|
||||
|
||||
def showImportErrorMBox(clipboard: str):
|
||||
mbox = ImportErrorMBox(icon=AppQMessageBox.Icon.Critical)
|
||||
|
||||
if len(clipboard) > 1000:
|
||||
# Limited
|
||||
mbox.setText(_('Invalid data'))
|
||||
mbox.setInformativeText('')
|
||||
else:
|
||||
mbox.setText(_('Invalid data. The content of the clipboard is:'))
|
||||
mbox.setInformativeText(clipboard)
|
||||
|
||||
mbox.exec()
|
||||
|
||||
|
||||
def importItemFromClipboard(clipboard: str):
|
||||
factory = constructFromAny(clipboard)
|
||||
|
||||
if not factory.isValid():
|
||||
showImportErrorMBox(clipboard)
|
||||
else:
|
||||
APP().mainWindow.appendNewItemByFactory(factory)
|
||||
|
||||
mbox = ImportSuccessMBox(icon=AppQMessageBox.Icon.Information)
|
||||
mbox.remark = factory.getExtras('remark')
|
||||
mbox.setText(mbox.customText())
|
||||
mbox.exec()
|
||||
|
||||
|
||||
needTrans('Import')
|
||||
|
||||
|
||||
class ImportErrorMBox(AppQMessageBox):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.setWindowTitle(_('Import'))
|
||||
|
||||
def retranslate(self):
|
||||
self.setWindowTitle(_(self.windowTitle()))
|
||||
self.setText(_(self.text()))
|
||||
|
||||
# Ignore informative text, buttons
|
||||
|
||||
self.moveToCenter()
|
||||
|
||||
|
||||
needTrans(
|
||||
'Import',
|
||||
'Import share link success',
|
||||
'Imported to row',
|
||||
)
|
||||
|
||||
|
||||
class ImportMultiSuccessMBox(AppQMessageBox):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.imported = []
|
||||
self.rowCount = 0
|
||||
|
||||
self.setWindowTitle(_('Import'))
|
||||
self.setIcon(AppQMessageBox.Icon.Information)
|
||||
|
||||
def customText(self):
|
||||
text = (
|
||||
_('Import share link success')
|
||||
+ f'\n\n'
|
||||
+ '\n'.join(
|
||||
list(
|
||||
f'{index + 1} - {remark}. '
|
||||
+ _('Imported to row')
|
||||
+ f' {self.rowCount + index + 1}'
|
||||
for index, remark in enumerate(self.imported)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if len(text) <= 1000:
|
||||
return text
|
||||
else:
|
||||
# Limited
|
||||
return _('Import share link success') + f'\n\n...'
|
||||
|
||||
|
||||
needTrans(
|
||||
'Import',
|
||||
'Import success',
|
||||
)
|
||||
|
||||
|
||||
class ImportSuccessMBox(AppQMessageBox):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.remark = ''
|
||||
self.setWindowTitle(_('Import'))
|
||||
|
||||
def customText(self):
|
||||
if self.remark:
|
||||
return _('Import success') + f': {self.remark}'
|
||||
else:
|
||||
return _('Import success')
|
||||
|
||||
def retranslate(self):
|
||||
self.setWindowTitle(_(self.windowTitle()))
|
||||
self.setText(self.customText())
|
||||
|
||||
# Ignore informative text, buttons
|
||||
|
||||
self.moveToCenter()
|
||||
|
||||
|
||||
needTrans(
|
||||
'Import From File...',
|
||||
'Import File',
|
||||
'Text files (*.json);;All files (*)',
|
||||
'Error opening file',
|
||||
'Invalid configuration file',
|
||||
'Invalid data',
|
||||
)
|
||||
|
||||
|
||||
class ImportFromFileAction(AppQAction):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(
|
||||
_('Import From File...'),
|
||||
icon=bootstrapIcon('folder2-open.svg'),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
filename, selectedFilter = QFileDialog.getOpenFileName(
|
||||
None,
|
||||
_('Import File'),
|
||||
filter=_('Text files (*.json);;All files (*)'),
|
||||
)
|
||||
|
||||
if filename:
|
||||
try:
|
||||
with open(filename, 'r', encoding='utf-8') as file:
|
||||
plainText = file.read()
|
||||
except Exception as ex:
|
||||
# Any non-exit exceptions
|
||||
|
||||
mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Critical)
|
||||
mbox.setWindowTitle(_('Error opening file'))
|
||||
mbox.setText(_('Invalid configuration file'))
|
||||
mbox.setInformativeText(str(ex))
|
||||
|
||||
# Show the MessageBox and wait for user to close it
|
||||
mbox.exec()
|
||||
else:
|
||||
factory = constructFromAny(plainText, remark=os.path.basename(filename))
|
||||
|
||||
if factory.isValid():
|
||||
APP().mainWindow.appendNewItemByFactory(factory)
|
||||
|
||||
mbox = ImportSuccessMBox(icon=AppQMessageBox.Icon.Information)
|
||||
mbox.remark = factory.getExtras('remark')
|
||||
mbox.setText(mbox.customText())
|
||||
mbox.exec()
|
||||
else:
|
||||
mbox = ImportErrorMBox(icon=AppQMessageBox.Icon.Critical)
|
||||
mbox.setText(_('Invalid data'))
|
||||
mbox.setInformativeText('')
|
||||
mbox.show()
|
||||
|
||||
|
||||
needTrans('Import Share Link From Clipboard')
|
||||
|
||||
|
||||
class ImportURIFromClipboardAction(AppQAction):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(_('Import Share Link From Clipboard'), **kwargs)
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
clipboard = QApplication.clipboard().text().strip()
|
||||
|
||||
try:
|
||||
split = clipboard.split('\n')
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
importItemFromClipboard(clipboard)
|
||||
else:
|
||||
imported = list()
|
||||
rowCount = len(AS_UserServers())
|
||||
|
||||
for uri in split:
|
||||
factory = constructFromAny(uri)
|
||||
|
||||
if factory.isValid():
|
||||
APP().mainWindow.appendNewItemByFactory(factory)
|
||||
|
||||
imported.append(factory.getExtras('remark'))
|
||||
|
||||
if len(imported) == 0:
|
||||
showImportErrorMBox(clipboard)
|
||||
else:
|
||||
if len(imported) == 1:
|
||||
# Fall back to single
|
||||
mbox = ImportSuccessMBox(icon=AppQMessageBox.Icon.Information)
|
||||
mbox.remark = imported[0]
|
||||
mbox.setText(mbox.customText())
|
||||
mbox.exec()
|
||||
else:
|
||||
mbox = ImportMultiSuccessMBox(icon=AppQMessageBox.Icon.Information)
|
||||
mbox.imported = imported
|
||||
mbox.rowCount = rowCount
|
||||
mbox.setText(mbox.customText())
|
||||
mbox.exec()
|
||||
|
||||
|
||||
needTrans('Import JSON Configuration From Clipboard')
|
||||
|
||||
|
||||
class ImportJSONFromClipboardAction(AppQAction):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(_('Import JSON Configuration From Clipboard'), **kwargs)
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
clipboard = QApplication.clipboard().text().strip()
|
||||
|
||||
importItemFromClipboard(clipboard)
|
||||
|
||||
|
||||
needTrans('Import')
|
||||
|
||||
|
||||
class ImportAction(AppQAction):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
_('Import'),
|
||||
icon=bootstrapIcon('lightning-charge.svg'),
|
||||
menu=AppQMenu(
|
||||
ImportURIFromClipboardAction(),
|
||||
ImportJSONFromClipboardAction(),
|
||||
),
|
||||
useActionGroup=False,
|
||||
checkable=True,
|
||||
)
|
||||
@@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2023 Loren Eteval <loren.eteval@proton.me>
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
@@ -15,51 +15,63 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from Furious.Gui.Action import Action
|
||||
from Furious.Widget.Widget import Menu
|
||||
from Furious.Utility.Constants import APP
|
||||
from Furious.Utility.Utility import bootstrapIcon
|
||||
from Furious.Utility.Translator import (
|
||||
Translatable,
|
||||
gettext as _,
|
||||
ABBR_TO_LANGUAGE,
|
||||
LANGUAGE_TO_ABBR,
|
||||
)
|
||||
from Furious.QtFramework import *
|
||||
from Furious.QtFramework import gettext as _
|
||||
from Furious.Utility import *
|
||||
|
||||
import logging
|
||||
import functools
|
||||
|
||||
__all__ = ['LanguageAction']
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SUPPORTED_LANGUAGE = tuple(LANGUAGE_TO_ABBR.values())
|
||||
# Handy stuff
|
||||
SMART_CHOSEN_LANGUAGE = (
|
||||
SYSTEM_LANGUAGE if SYSTEM_LANGUAGE in SUPPORTED_LANGUAGE else 'EN'
|
||||
)
|
||||
|
||||
registerAppSettings(
|
||||
'Language', validRange=SUPPORTED_LANGUAGE, default=SMART_CHOSEN_LANGUAGE
|
||||
)
|
||||
|
||||
needTrans = functools.partial(needTransFn, source=__name__)
|
||||
|
||||
|
||||
class LanguageChildAction(Action):
|
||||
class LanguageChildAction(AppQAction):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
checkedLanguage = LANGUAGE_TO_ABBR[self.text()]
|
||||
abbr = LANGUAGE_TO_ABBR[self.text()]
|
||||
|
||||
if APP().Language != checkedLanguage:
|
||||
if AppSettings.get('Language') != abbr:
|
||||
logger.info(f'set language to \'{self.text()}\'')
|
||||
|
||||
APP().Language = checkedLanguage
|
||||
AppSettings.set('Language', abbr)
|
||||
|
||||
Translatable.retranslateAll()
|
||||
QTranslatable.retranslateAll()
|
||||
|
||||
def retranslate(self):
|
||||
# Nothing to do
|
||||
pass
|
||||
|
||||
|
||||
class LanguageAction(Action):
|
||||
needTrans('Language')
|
||||
|
||||
|
||||
class LanguageAction(AppQAction):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
_('Language'),
|
||||
icon=bootstrapIcon('globe2.svg'),
|
||||
menu=Menu(
|
||||
menu=AppQMenu(
|
||||
*list(
|
||||
LanguageChildAction(
|
||||
# Language representation
|
||||
text,
|
||||
checkable=True,
|
||||
checked=text == ABBR_TO_LANGUAGE[APP().Language],
|
||||
checked=text == ABBR_TO_LANGUAGE[AppSettings.get('Language')],
|
||||
)
|
||||
for text in list(LANGUAGE_TO_ABBR.keys())
|
||||
),
|
||||
@@ -0,0 +1,72 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from Furious.QtFramework import *
|
||||
from Furious.QtFramework import gettext as _
|
||||
from Furious.Utility import *
|
||||
|
||||
import functools
|
||||
|
||||
__all__ = ['RoutingAction']
|
||||
|
||||
BUILTIN_ROUTING = ['Bypass Mainland China', 'Global', 'Custom']
|
||||
|
||||
registerAppSettings('Routing', validRange=BUILTIN_ROUTING)
|
||||
|
||||
needTrans = functools.partial(needTransFn, source=__name__)
|
||||
|
||||
needTrans(*BUILTIN_ROUTING)
|
||||
|
||||
|
||||
class RoutingChildAction(AppQAction):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
textEnglish = self.textEnglish
|
||||
|
||||
if AppSettings.get('Routing') != textEnglish:
|
||||
AppSettings.set('Routing', textEnglish)
|
||||
|
||||
if APP().isSystemTrayConnected():
|
||||
APP().systemTray.ConnectAction.doDisconnect()
|
||||
APP().systemTray.ConnectAction.trigger()
|
||||
|
||||
|
||||
needTrans('Routing')
|
||||
|
||||
|
||||
class RoutingAction(AppQAction):
|
||||
def __init__(self):
|
||||
if AppSettings.get('Routing') == 'Bypass':
|
||||
# Update value for backward compatibility
|
||||
AppSettings.set('Routing', 'Bypass Mainland China')
|
||||
|
||||
super().__init__(
|
||||
_('Routing'),
|
||||
icon=bootstrapIcon('shuffle.svg'),
|
||||
menu=AppQMenu(
|
||||
*list(
|
||||
RoutingChildAction(
|
||||
_(routing),
|
||||
checkable=True,
|
||||
checked=AppSettings.get('Routing') == routing,
|
||||
)
|
||||
for routing in BUILTIN_ROUTING
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,178 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from Furious.QtFramework import *
|
||||
from Furious.QtFramework import gettext as _
|
||||
from Furious.Utility import *
|
||||
|
||||
from typing import Union
|
||||
|
||||
import functools
|
||||
|
||||
__all__ = ['SettingsAction']
|
||||
|
||||
registerAppSettings('VPNMode', isBinary=True)
|
||||
registerAppSettings('DarkMode', isBinary=True)
|
||||
registerAppSettings('StartupOnBoot', isBinary=True, default=BinarySettings.ON_)
|
||||
registerAppSettings(
|
||||
'ShowProgressBarWhenConnecting', isBinary=True, default=BinarySettings.ON_
|
||||
)
|
||||
registerAppSettings('ShowTabAndSpacesInEditor', isBinary=True)
|
||||
|
||||
needTrans = functools.partial(needTransFn, source=__name__)
|
||||
|
||||
needTrans(
|
||||
'VPN Mode',
|
||||
'VPN Mode Disabled (Administrator)',
|
||||
'VPN Mode Disabled (root)',
|
||||
)
|
||||
|
||||
|
||||
class VPNModeAction(AppQAction):
|
||||
def __init__(self, **kwargs):
|
||||
if isAdministrator():
|
||||
super().__init__(_('VPN Mode'), **kwargs)
|
||||
else:
|
||||
super().__init__(_(f'VPN Mode Disabled ({ADMINISTRATOR_NAME})'), **kwargs)
|
||||
|
||||
self.setDisabled(True)
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
assert isAdministrator()
|
||||
|
||||
if checked:
|
||||
AppSettings.turnON_('VPNMode')
|
||||
else:
|
||||
AppSettings.turnOFF('VPNMode')
|
||||
|
||||
try:
|
||||
if APP().isSystemTrayConnected():
|
||||
mbox = NewChangesNextTimeMBox()
|
||||
mbox.exec()
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class SettingsChildAction(AppQAction):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
if self.textCompare('Dark Mode'):
|
||||
# Settings turn on/off order matters here
|
||||
if checked:
|
||||
AppSettings.turnON_('DarkMode')
|
||||
|
||||
try:
|
||||
APP().switchToDarkMode()
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
pass
|
||||
else:
|
||||
AppSettings.turnOFF('DarkMode')
|
||||
|
||||
try:
|
||||
APP().switchToAutoMode()
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
pass
|
||||
if self.textCompare('Startup On Boot'):
|
||||
if checked:
|
||||
StartupOnBoot.on_()
|
||||
|
||||
AppSettings.turnON_('StartupOnBoot')
|
||||
else:
|
||||
StartupOnBoot.off()
|
||||
|
||||
AppSettings.turnOFF('StartupOnBoot')
|
||||
if self.textCompare('Show Progress Bar When Connecting'):
|
||||
if checked:
|
||||
AppSettings.turnON_('ShowProgressBarWhenConnecting')
|
||||
else:
|
||||
AppSettings.turnOFF('ShowProgressBarWhenConnecting')
|
||||
if self.textCompare('Show Tab And Spaces In Editor'):
|
||||
if checked:
|
||||
APP().mainWindow.showTabAndSpaces()
|
||||
|
||||
AppSettings.turnON_('ShowTabAndSpacesInEditor')
|
||||
else:
|
||||
APP().mainWindow.hideTabAndSpaces()
|
||||
|
||||
AppSettings.turnOFF('ShowTabAndSpacesInEditor')
|
||||
|
||||
|
||||
needTrans(
|
||||
'Settings',
|
||||
'Dark Mode',
|
||||
'Startup On Boot',
|
||||
'Show Progress Bar When Connecting',
|
||||
'Show Tab And Spaces In Editor',
|
||||
)
|
||||
|
||||
|
||||
class SettingsAction(AppQAction):
|
||||
def __init__(self):
|
||||
if PLATFORM == 'Windows' or PLATFORM == 'Darwin':
|
||||
extraActions = [
|
||||
VPNModeAction(
|
||||
checkable=True,
|
||||
checked=AppSettings.isStateON_('VPNMode'),
|
||||
),
|
||||
AppQSeperator(),
|
||||
]
|
||||
else:
|
||||
extraActions = [None]
|
||||
|
||||
super().__init__(
|
||||
_('Settings'),
|
||||
icon=bootstrapIcon('gear-wide-connected.svg'),
|
||||
menu=AppQMenu(
|
||||
*extraActions,
|
||||
SettingsChildAction(
|
||||
_('Dark Mode'),
|
||||
checkable=True,
|
||||
checked=AppSettings.isStateON_('DarkMode'),
|
||||
),
|
||||
SettingsChildAction(
|
||||
_('Startup On Boot'),
|
||||
checkable=True,
|
||||
checked=AppSettings.isStateON_('StartupOnBoot'),
|
||||
),
|
||||
SettingsChildAction(
|
||||
_('Show Progress Bar When Connecting'),
|
||||
checkable=True,
|
||||
checked=AppSettings.isStateON_('ShowProgressBarWhenConnecting'),
|
||||
),
|
||||
SettingsChildAction(
|
||||
_('Show Tab And Spaces In Editor'),
|
||||
checkable=True,
|
||||
checked=AppSettings.isStateON_('ShowTabAndSpacesInEditor'),
|
||||
),
|
||||
),
|
||||
useActionGroup=False,
|
||||
)
|
||||
|
||||
def getVPNModeAction(self) -> Union[AppQAction, None]:
|
||||
if PLATFORM == 'Windows' or PLATFORM == 'Darwin':
|
||||
# 1st action
|
||||
return self._menu.actions()[0]
|
||||
else:
|
||||
return None
|
||||
@@ -0,0 +1,68 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from Furious.QtFramework import *
|
||||
from Furious.QtFramework import gettext as _
|
||||
from Furious.Utility import *
|
||||
|
||||
import functools
|
||||
|
||||
__all__ = ['SystemProxyAction']
|
||||
|
||||
BUILTIN_PROXY_MODE = ['Auto', 'NoChanges']
|
||||
|
||||
registerAppSettings('SystemProxyMode', validRange=BUILTIN_PROXY_MODE)
|
||||
|
||||
needTrans = functools.partial(needTransFn, source=__name__)
|
||||
|
||||
|
||||
class SystemProxyChildAction(AppQAction):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
if self.textCompare('Automatically Configure System Proxy'):
|
||||
AppSettings.set('SystemProxyMode', 'Auto')
|
||||
if self.textCompare('Do Not Change System Proxy'):
|
||||
AppSettings.set('SystemProxyMode', 'NoChanges')
|
||||
|
||||
|
||||
needTrans(
|
||||
'System Proxy',
|
||||
'Automatically Configure System Proxy',
|
||||
'Do Not Change System Proxy',
|
||||
)
|
||||
|
||||
|
||||
class SystemProxyAction(AppQAction):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
_('System Proxy'),
|
||||
icon=bootstrapIcon('hdd-network.svg'),
|
||||
menu=AppQMenu(
|
||||
SystemProxyChildAction(
|
||||
_('Automatically Configure System Proxy'),
|
||||
checkable=True,
|
||||
checked=AppSettings.get('SystemProxyMode') == 'Auto',
|
||||
),
|
||||
SystemProxyChildAction(
|
||||
_('Do Not Change System Proxy'),
|
||||
checkable=True,
|
||||
checked=AppSettings.get('SystemProxyMode') == 'NoChanges',
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from .Connect import *
|
||||
from .EditConfiguration import *
|
||||
from .Exit import *
|
||||
from .Import import *
|
||||
from .Language import *
|
||||
from .Routing import *
|
||||
from .Settings import *
|
||||
from .SystemProxy import *
|
||||
@@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2023 Loren Eteval <loren.eteval@proton.me>
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
@@ -15,18 +15,24 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from Furious.Widget.Application import Application
|
||||
from Furious.Utility.Constants import APP, PLATFORM, CRASH_LOG_DIR
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.Interface import *
|
||||
from Furious.Utility.Constants import *
|
||||
|
||||
from typing import Callable
|
||||
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
import datetime
|
||||
import operator
|
||||
import traceback
|
||||
import functools
|
||||
import traceback
|
||||
import multiprocessing
|
||||
|
||||
__all__ = ['AppMainProcess']
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if PLATFORM == 'Windows':
|
||||
@@ -35,13 +41,15 @@ else:
|
||||
ProcessContext = multiprocessing.get_context('spawn')
|
||||
|
||||
|
||||
class Process(ProcessContext.Process):
|
||||
def __init__(self, **kwargs):
|
||||
class AppMainProcess(ProcessContext.Process):
|
||||
def __init__(self, loaderFn: Callable[[], ApplicationFactory], **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
|
||||
self.startUpTime = str(datetime.datetime.now()).replace(':', '')
|
||||
self.logFileName = f'{self.startUpTime}.log'
|
||||
self.startupTime = str(datetime.datetime.now()).replace(':', '')
|
||||
self.logFileName = f'{self.startupTime}.log'
|
||||
self.fileWritten = multiprocessing.Manager().Value('b', False)
|
||||
|
||||
self.appLoaderFn = loaderFn
|
||||
self.application = None
|
||||
|
||||
def exceptHook(self, exceptionType, exceptionValue, tb):
|
||||
@@ -49,24 +57,25 @@ class Process(ProcessContext.Process):
|
||||
traceback.print_exception(exceptionType, exceptionValue, tb)
|
||||
|
||||
if isinstance(exceptionValue, AssertionError):
|
||||
error = Application.ErrorCode.AssertionError
|
||||
exitcode = ApplicationFactory.ExitCode.AssertionError
|
||||
else:
|
||||
error = Application.ErrorCode.UnknownException
|
||||
exitcode = ApplicationFactory.ExitCode.UnknownException
|
||||
|
||||
logger.error(f'stopped with exitcode {error}')
|
||||
logger.error(f'stopped with exitcode {exitcode}')
|
||||
|
||||
self.saveCrashLog(exceptionType, exceptionValue, tb)
|
||||
|
||||
if APP() is not None:
|
||||
APP().exit(error)
|
||||
APP().exit(exitcode)
|
||||
else:
|
||||
sys.exit(error)
|
||||
sys.exit(exitcode)
|
||||
|
||||
def saveCrashLog(self, exceptionType, exceptionValue, tb):
|
||||
try:
|
||||
os.mkdir(CRASH_LOG_DIR)
|
||||
except FileExistsError:
|
||||
# Directory already exists
|
||||
|
||||
pass
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
@@ -96,6 +105,6 @@ class Process(ProcessContext.Process):
|
||||
def run(self):
|
||||
sys.excepthook = self.exceptHook
|
||||
|
||||
self.application = Application(sys.argv)
|
||||
self.application = self.appLoaderFn()
|
||||
|
||||
sys.exit(self.application.run())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,132 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.Utility.Utility import BinarySettings
|
||||
|
||||
from PySide6 import QtCore
|
||||
|
||||
import logging
|
||||
|
||||
__all__ = ['AppSettings', 'registerAppSettings']
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AppSettings:
|
||||
SettingsPool: dict[str, AppSettings] = dict()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
isBinary=False,
|
||||
validRange: list = None,
|
||||
default=None,
|
||||
):
|
||||
self.name = name
|
||||
self.isBinary = isBinary
|
||||
|
||||
if isBinary:
|
||||
self.validRange = BinarySettings.RANGE
|
||||
self.default = BinarySettings.OFF if default is None else default
|
||||
else:
|
||||
self.validRange = validRange
|
||||
|
||||
if validRange is None:
|
||||
self.default = default
|
||||
else:
|
||||
self.default = validRange[0] if default is None else default
|
||||
|
||||
if name in AppSettings.SettingsPool:
|
||||
raise ValueError(f'\'{name}\' already exists in AppSettings')
|
||||
|
||||
def validate(self, value) -> bool:
|
||||
if self.validRange is None:
|
||||
return True
|
||||
else:
|
||||
return value in self.validRange
|
||||
|
||||
@staticmethod
|
||||
def get(key: str):
|
||||
settings = AppSettings.SettingsPool.get(key)
|
||||
|
||||
if settings is None:
|
||||
raise AttributeError(f'AppSettings \'{key}\' not found')
|
||||
|
||||
assert isinstance(settings, AppSettings)
|
||||
|
||||
value = QtCore.QSettings().value(settings.name)
|
||||
|
||||
if settings.validate(value):
|
||||
return value
|
||||
else:
|
||||
logger.error(
|
||||
f'settings \'{settings.name}\' has value \'{value}\', '
|
||||
f'which is not in valid range {settings.validRange}. '
|
||||
f'Set to default \'{settings.default}\''
|
||||
)
|
||||
|
||||
# Value not in valid range, set to default
|
||||
QtCore.QSettings().setValue(settings.name, settings.default)
|
||||
|
||||
return settings.default
|
||||
|
||||
@staticmethod
|
||||
def isStateON_(key: str) -> bool:
|
||||
value = AppSettings.get(key)
|
||||
|
||||
if value == BinarySettings.ON_:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def isStateOFF(key: str) -> bool:
|
||||
value = AppSettings.get(key)
|
||||
|
||||
if value == BinarySettings.OFF:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def set(key: str, value):
|
||||
settings = AppSettings.SettingsPool.get(key)
|
||||
|
||||
if settings is None:
|
||||
raise AttributeError(f'AppSettings \'{key}\' not found')
|
||||
|
||||
assert isinstance(settings, AppSettings)
|
||||
|
||||
if settings.validate(value):
|
||||
QtCore.QSettings().setValue(settings.name, value)
|
||||
else:
|
||||
# Value not in valid range, raise exception
|
||||
raise ValueError(f'Invalid AppSettings value \'{value}\' for \'{key}\'')
|
||||
|
||||
@staticmethod
|
||||
def turnON_(key: str):
|
||||
AppSettings.set(key, BinarySettings.ON_)
|
||||
|
||||
@staticmethod
|
||||
def turnOFF(key: str):
|
||||
AppSettings.set(key, BinarySettings.OFF)
|
||||
|
||||
|
||||
def registerAppSettings(name: str, *args, **kwargs):
|
||||
AppSettings.SettingsPool[name] = AppSettings(name, *args, **kwargs)
|
||||
@@ -0,0 +1,57 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.Utility.Constants import *
|
||||
from Furious.Utility.AppSettings import *
|
||||
|
||||
import functools
|
||||
|
||||
__all___ = ['AS_UserActivatedItemIndex', 'AS_UserServers', 'AS_UserSubscription']
|
||||
|
||||
|
||||
def _activatedItemIndex() -> int:
|
||||
try:
|
||||
return int(AppSettings.get('ActivatedItemIndex'))
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return -1
|
||||
|
||||
|
||||
def _userServersData() -> list:
|
||||
try:
|
||||
return APP().userServers.data()
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def _userSubscriptionData() -> dict[str, dict]:
|
||||
try:
|
||||
return APP().userSubs.data()
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
AS_UserActivatedItemIndex = functools.partial(_activatedItemIndex)
|
||||
AS_UserServers = functools.partial(_userServersData)
|
||||
AS_UserSubscription = functools.partial(_userSubscriptionData)
|
||||
@@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2023 Loren Eteval <loren.eteval@proton.me>
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
@@ -17,11 +17,9 @@
|
||||
|
||||
from Furious.Version import __version__
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6 import QtCore, __version__ as _pyside6_version
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
import PySide6
|
||||
|
||||
import math
|
||||
import pathlib
|
||||
import platform
|
||||
@@ -40,7 +38,7 @@ ORGANIZATION_NAME = 'Furious'
|
||||
ORGANIZATION_DOMAIN = 'Furious.GUI'
|
||||
|
||||
# Tested: Furious supports minimum PySide6 version 6.1.0
|
||||
PYSIDE6_VERSION = PySide6.__version__
|
||||
PYSIDE6_VERSION = _pyside6_version
|
||||
|
||||
PLATFORM = platform.system()
|
||||
PLATFORM_RELEASE = platform.release()
|
||||
@@ -55,7 +53,9 @@ PROXY_OUTBOUND_USER_EMAIL = f'user@{ORGANIZATION_DOMAIN}'
|
||||
|
||||
ROOT_DIR = pathlib.Path(__file__).resolve().parent.parent.parent
|
||||
DATA_DIR = ROOT_DIR / APPLICATION_NAME / 'Data'
|
||||
XRAY_ASSET_DIR = DATA_DIR / 'xray'
|
||||
CRASH_LOG_DIR = ROOT_DIR / APPLICATION_NAME / 'CrashLog'
|
||||
GEN_TRANSLATION_FILE = ROOT_DIR / APPLICATION_NAME / 'Externals' / 'GenTranslation.py'
|
||||
|
||||
PROXY_SERVER_BYPASS = (
|
||||
'localhost;*.local;127.*;10.*;172.16.*;172.17.*;'
|
||||
@@ -63,6 +63,8 @@ PROXY_SERVER_BYPASS = (
|
||||
'172.26.*;172.27.*;172.28.*;172.29.*;172.30.*;172.31.*;192.168.*'
|
||||
)
|
||||
|
||||
CORE_CHECK_ALIVE_INTERVAL = 2000
|
||||
|
||||
if PLATFORM == 'Windows':
|
||||
APPLICATION_TUN_DEVICE_NAME = APPLICATION_NAME
|
||||
elif PLATFORM == 'Darwin':
|
||||
@@ -78,25 +80,10 @@ if PLATFORM == 'Windows':
|
||||
else:
|
||||
APPLICATION_TUN_IP_ADDRESS = APPLICATION_TUN_GATEWAY_ADDRESS
|
||||
|
||||
TOR_FAQ_URL = 'https://support.torproject.org/faq/'
|
||||
TOR_FAQ_LABEL = f'Tor FAQ: <a href=\"{TOR_FAQ_URL}\">{TOR_FAQ_URL}</a>'
|
||||
|
||||
# Avoid standard Tor port in case of running Tor services
|
||||
DEFAULT_TOR_SOCKS_PORT = 9048
|
||||
DEFAULT_TOR_HTTPS_PORT = 9047
|
||||
# 20s
|
||||
DEFAULT_TOR_RELAY_ESTABLISH_TIMEOUT = 20
|
||||
|
||||
ADMINISTRATOR_NAME = 'Administrator' if PLATFORM == 'Windows' else 'root'
|
||||
|
||||
NETWORK_STATE_TEST_URL = 'http://cp.cloudflare.com'
|
||||
|
||||
class LogType:
|
||||
Core = 'Core'
|
||||
App = 'App'
|
||||
Tor = 'Tor'
|
||||
|
||||
|
||||
class Color:
|
||||
LIGHT_BLUE = '#43ACED'
|
||||
LIGHT_RED_ = '#FF7276'
|
||||
LIGHT_PURPLE = '#DA70D6'
|
||||
UNICODE_LARGE_RED_CIRCLE = u'\U0001F534'
|
||||
UNICODE_LARGE_GREEN_CIRCLE = u'\U0001F7E2'
|
||||
UNICODE_LARGE_ORANGE_CIRCLE = u'\U0001F7E0'
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from Furious.Utility.Constants import *
|
||||
|
||||
import time
|
||||
|
||||
__all__ = ['PySide6LegacyEnumValueWrapper', 'PySide6LegacyEventLoopWait']
|
||||
|
||||
|
||||
def PySide6LegacyEnumValueWrapper(enum):
|
||||
# Protect PySide6 enum wrapper behavior changes
|
||||
if PYSIDE6_VERSION < '6.2.2':
|
||||
return enum
|
||||
else:
|
||||
return enum.value
|
||||
|
||||
|
||||
def PySide6LegacyEventLoopWait(ms):
|
||||
# Protect qWait method does not exist in some
|
||||
# old PySide6 version
|
||||
if PYSIDE6_VERSION < '6.3.1':
|
||||
for counter in range(0, ms, 10):
|
||||
time.sleep(10 / 1000)
|
||||
|
||||
APP().processEvents()
|
||||
else:
|
||||
from PySide6.QtTest import QTest
|
||||
|
||||
QTest.qWait(ms)
|
||||
|
||||
APP().processEvents()
|
||||
@@ -1,325 +0,0 @@
|
||||
# Copyright (C) 2023 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from Furious.Utility.Constants import (
|
||||
PLATFORM,
|
||||
SYSTEM_LANGUAGE,
|
||||
APPLICATION_TUN_GATEWAY_ADDRESS,
|
||||
)
|
||||
from Furious.Utility.Utility import runCommand
|
||||
|
||||
import re
|
||||
import logging
|
||||
import subprocess
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def getDictTuple(returncode, stdout, stderr):
|
||||
return {
|
||||
'returncode': returncode,
|
||||
'stdout': stdout,
|
||||
'stderr': stderr,
|
||||
}
|
||||
|
||||
|
||||
class RoutingTable:
|
||||
Relations = list()
|
||||
|
||||
DEFAULT_GATEWAY_WINDOWS = re.compile(
|
||||
r'0\.0\.0\.0.\s*0\.0\.0\.0.\s*(\S+)',
|
||||
)
|
||||
DEFAULT_GATEWAY_DARWIN = re.compile(
|
||||
r'gateway:\s*(\S+)',
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def add(source, destination):
|
||||
def _add():
|
||||
if PLATFORM == 'Windows':
|
||||
try:
|
||||
result = runCommand(
|
||||
['route', 'add', source, destination, 'metric', '5'],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return -1, '', ''
|
||||
else:
|
||||
if SYSTEM_LANGUAGE == 'ZH':
|
||||
return (
|
||||
result.returncode,
|
||||
result.stdout.decode('gbk', 'replace').strip(),
|
||||
result.stderr.decode('gbk', 'replace').strip(),
|
||||
)
|
||||
else:
|
||||
return (
|
||||
result.returncode,
|
||||
result.stdout.decode('utf-8', 'replace').strip(),
|
||||
result.stderr.decode('utf-8', 'replace').strip(),
|
||||
)
|
||||
|
||||
if PLATFORM == 'Darwin':
|
||||
try:
|
||||
result = runCommand(
|
||||
['route', 'add', '-net', source, destination],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return -1, '', ''
|
||||
else:
|
||||
return (
|
||||
result.returncode,
|
||||
result.stdout.decode('utf-8', 'replace'),
|
||||
result.stderr.decode('utf-8', 'replace'),
|
||||
)
|
||||
|
||||
try:
|
||||
returncode, stdout, stderr = _add()
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
logger.error(f'add rule {source}->{destination} to routing table failed')
|
||||
else:
|
||||
if returncode == 0:
|
||||
logger.info(
|
||||
f'add rule {source}->{destination} to routing table success. '
|
||||
f'{getDictTuple(returncode, stdout, stderr)}'
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
f'add rule {source}->{destination} to routing table failed. '
|
||||
f'{getDictTuple(returncode, stdout, stderr)}'
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def addRelations():
|
||||
for source, destination in RoutingTable.Relations:
|
||||
RoutingTable.add(source, destination)
|
||||
|
||||
@staticmethod
|
||||
def getDefaultGatewayAddress():
|
||||
def _get():
|
||||
if PLATFORM == 'Windows':
|
||||
try:
|
||||
result = runCommand(
|
||||
[
|
||||
'route',
|
||||
'PRINT',
|
||||
'0.0.0.0',
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=True,
|
||||
)
|
||||
|
||||
return RoutingTable.DEFAULT_GATEWAY_WINDOWS.findall(
|
||||
result.stdout.decode('utf-8', 'replace')
|
||||
)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return []
|
||||
|
||||
if PLATFORM == 'Darwin':
|
||||
try:
|
||||
result = runCommand(
|
||||
'route get default'.split(),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=True,
|
||||
)
|
||||
|
||||
return RoutingTable.DEFAULT_GATEWAY_DARWIN.findall(
|
||||
result.stdout.decode('utf-8', 'replace')
|
||||
)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return []
|
||||
|
||||
defaultGateway = _get()
|
||||
|
||||
if defaultGateway:
|
||||
logger.info(f'get default gateway address success. {defaultGateway}')
|
||||
else:
|
||||
logger.error('get default gateway address failed')
|
||||
|
||||
return defaultGateway
|
||||
|
||||
@staticmethod
|
||||
def setDeviceGatewayAddress(deviceName, ipAddress, gatewayAddress):
|
||||
def _set():
|
||||
if PLATFORM == 'Windows':
|
||||
try:
|
||||
result = runCommand(
|
||||
'netsh interface ip set address'.split()
|
||||
+ [
|
||||
deviceName,
|
||||
'static',
|
||||
ipAddress,
|
||||
'255.255.255.0',
|
||||
gatewayAddress,
|
||||
'3',
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return -1, '', ''
|
||||
else:
|
||||
if SYSTEM_LANGUAGE == 'ZH':
|
||||
return (
|
||||
result.returncode,
|
||||
result.stdout.decode('gbk', 'replace').strip(),
|
||||
result.stderr.decode('gbk', 'replace').strip(),
|
||||
)
|
||||
else:
|
||||
return (
|
||||
result.returncode,
|
||||
result.stdout.decode('utf-8', 'replace').strip(),
|
||||
result.stderr.decode('utf-8', 'replace').strip(),
|
||||
)
|
||||
|
||||
if PLATFORM == 'Darwin':
|
||||
try:
|
||||
result = runCommand(
|
||||
[
|
||||
'ifconfig',
|
||||
deviceName,
|
||||
ipAddress,
|
||||
gatewayAddress,
|
||||
'up',
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return -1, '', ''
|
||||
else:
|
||||
return (
|
||||
result.returncode,
|
||||
result.stdout.decode('utf-8', 'replace'),
|
||||
result.stderr.decode('utf-8', 'replace'),
|
||||
)
|
||||
|
||||
try:
|
||||
returncode, stdout, stderr = _set()
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
logger.error(
|
||||
f'set device \'{deviceName}\' gateway address \'{gatewayAddress}\' failed'
|
||||
)
|
||||
else:
|
||||
if returncode == 0:
|
||||
logger.info(
|
||||
f'set device \'{deviceName}\' gateway address \'{gatewayAddress}\' success. '
|
||||
f'{getDictTuple(returncode, stdout, stderr)}'
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
f'set device \'{deviceName}\' gateway address \'{gatewayAddress}\' failed. '
|
||||
f'{getDictTuple(returncode, stdout, stderr)}'
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def delete(source, destination):
|
||||
def _delete():
|
||||
if PLATFORM == 'Windows':
|
||||
try:
|
||||
result = runCommand(
|
||||
['route', 'delete', source, destination],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return -1, '', ''
|
||||
else:
|
||||
if SYSTEM_LANGUAGE == 'ZH':
|
||||
return (
|
||||
result.returncode,
|
||||
result.stdout.decode('gbk', 'replace').strip(),
|
||||
result.stderr.decode('gbk', 'replace').strip(),
|
||||
)
|
||||
else:
|
||||
return (
|
||||
result.returncode,
|
||||
result.stdout.decode('utf-8', 'replace').strip(),
|
||||
result.stderr.decode('utf-8', 'replace').strip(),
|
||||
)
|
||||
|
||||
if PLATFORM == 'Darwin':
|
||||
try:
|
||||
result = runCommand(
|
||||
['route', 'delete', '-net', source, destination],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return -1, '', ''
|
||||
else:
|
||||
return (
|
||||
result.returncode,
|
||||
result.stdout.decode('utf-8', 'replace'),
|
||||
result.stderr.decode('utf-8', 'replace'),
|
||||
)
|
||||
|
||||
try:
|
||||
returncode, stdout, stderr = _delete()
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
logger.error(
|
||||
f'delete rule {source}->{destination} from routing table failed'
|
||||
)
|
||||
else:
|
||||
if returncode == 0:
|
||||
logger.info(
|
||||
f'delete rule {source}->{destination} from routing table success. '
|
||||
f'{getDictTuple(returncode, stdout, stderr)}'
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
f'delete rule {source}->{destination} from routing table failed. '
|
||||
f'{getDictTuple(returncode, stdout, stderr)}'
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def deleteRelations(clear=True):
|
||||
if PLATFORM == 'Windows':
|
||||
if len(RoutingTable.Relations):
|
||||
RoutingTable.delete('0.0.0.0', APPLICATION_TUN_GATEWAY_ADDRESS)
|
||||
|
||||
for source, destination in RoutingTable.Relations[::-1]:
|
||||
RoutingTable.delete(source, destination)
|
||||
|
||||
if clear:
|
||||
RoutingTable.Relations.clear()
|
||||
@@ -1,128 +0,0 @@
|
||||
# Copyright (C) 2023 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from Furious.Action.Language import SUPPORTED_LANGUAGE
|
||||
from Furious.Action.Routing import BUILTIN_ROUTING
|
||||
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 value is not None and (
|
||||
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 = (
|
||||
# Connected last time or not
|
||||
Settings('Connect', Switch.RANGE),
|
||||
# User Routing option
|
||||
Settings('Routing', default=BUILTIN_ROUTING[0]),
|
||||
# User VPN Mode
|
||||
Settings('VPNMode', Switch.RANGE),
|
||||
# Hide editor or not
|
||||
Settings('HideEditor', Switch.RANGE),
|
||||
# User Custom Routing object
|
||||
Settings('CustomRouting'),
|
||||
# User Configuration
|
||||
Settings('Configuration'),
|
||||
# User Custom Subscription object
|
||||
Settings('CustomSubscription'),
|
||||
# User Activated Server Index
|
||||
Settings('ActivatedItemIndex'),
|
||||
# User Tor Relay Settings,
|
||||
Settings('TorRelaySettings'),
|
||||
# Server Widget Window Size
|
||||
Settings('ServerWidgetWindowSize'),
|
||||
# Routes Widget Window Size
|
||||
Settings('RoutesWidgetWindowSize'),
|
||||
# Subscription Widget Window Size
|
||||
Settings('SubscriptionWidgetWindowSize'),
|
||||
# Server Widget Section Size
|
||||
Settings('ServerWidgetSectionSizeTable'),
|
||||
# Routes Widget Section Size
|
||||
Settings('RoutesWidgetSectionSizeTable'),
|
||||
# Subscription Widget Section Size
|
||||
Settings('SubscriptionWidgetSectionSizeTable'),
|
||||
# Server Widget Font Point Size
|
||||
Settings('ServerWidgetPointSize'),
|
||||
# App Log Viewer Widget Font Point Size
|
||||
Settings('AppLogViewerWidgetPointSize'),
|
||||
# Core Log Viewer Widget Font Point Size
|
||||
Settings('CoreLogViewerWidgetPointSize'),
|
||||
# Tor Log Viewer Widget Font Point Size
|
||||
Settings('TorLogViewerWidgetPointSize'),
|
||||
# User selected language
|
||||
Settings('Language', SUPPORTED_LANGUAGE, SMART_CHOSEN_LANGUAGE),
|
||||
# Startup On Boot
|
||||
Settings('StartupOnBoot', Switch.RANGE, Switch.ON_), # On by default
|
||||
# Show Progressbar
|
||||
Settings(
|
||||
# For user experience: On by default
|
||||
'ShowProgressBarWhenConnecting',
|
||||
Switch.RANGE,
|
||||
Switch.ON_,
|
||||
),
|
||||
# Show Tab and spaces
|
||||
Settings('ShowTabAndSpacesInEditor', Switch.RANGE),
|
||||
)
|
||||
|
||||
Memo = {settings.name: settings for settings in SUPPORTED_SETTINGS}
|
||||
@@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2023 Loren Eteval <loren.eteval@proton.me>
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
@@ -15,13 +15,9 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from Furious.Utility.Constants import (
|
||||
APPLICATION_NAME,
|
||||
APPLICATION_VERSION,
|
||||
APPLICATION_MACOS_SIGNATURE,
|
||||
PLATFORM,
|
||||
)
|
||||
from Furious.Utility.Utility import isScriptMode
|
||||
from Furious.Utility.Constants import *
|
||||
from Furious.Utility.AppSettings import *
|
||||
from Furious.Utility.SystemRuntime import isScriptMode
|
||||
|
||||
from PySide6 import QtCore
|
||||
|
||||
@@ -30,16 +26,15 @@ import sys
|
||||
import logging
|
||||
import plistlib
|
||||
|
||||
__all__ = ['StartupOnBoot']
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StartupOnBoot:
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
@staticmethod
|
||||
def on_():
|
||||
def _on():
|
||||
def _on_():
|
||||
if isScriptMode():
|
||||
# Script mode
|
||||
logger.info('ignore turn on StartupOnBoot in script mode')
|
||||
@@ -135,7 +130,7 @@ class StartupOnBoot:
|
||||
else:
|
||||
return True
|
||||
|
||||
if _on():
|
||||
if _on_():
|
||||
logger.info('turn on StartupOnBoot success')
|
||||
else:
|
||||
logger.error('turn on StartupOnBoot failed')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2023 Loren Eteval <loren.eteval@proton.me>
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
@@ -15,17 +15,37 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from Furious.Utility.Constants import PLATFORM
|
||||
from Furious.Utility.Utility import runCommand, parseHostPort
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.Utility.Constants import *
|
||||
from Furious.Utility.Utility import parseHostPort, runExternalCommand
|
||||
from Furious.Utility.AppSettings import *
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
|
||||
__all__ = ['SystemProxy']
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def handleAppSystemProxyMode() -> bool:
|
||||
try:
|
||||
if AppSettings.get('SystemProxyMode') == 'Auto':
|
||||
# Automatically configure
|
||||
return True
|
||||
else:
|
||||
# Do not change
|
||||
return False
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
# Automatically configure
|
||||
return True
|
||||
|
||||
|
||||
def linuxProxyConfig(proxy_args, arg0, arg1):
|
||||
runCommand(
|
||||
runExternalCommand(
|
||||
[
|
||||
'gsettings',
|
||||
'set',
|
||||
@@ -41,7 +61,7 @@ def linuxProxyConfig(proxy_args, arg0, arg1):
|
||||
|
||||
def darwinProxyConfig(operation, *args):
|
||||
def getNetworkServices():
|
||||
command = runCommand(
|
||||
command = runExternalCommand(
|
||||
['networksetup', '-listallnetworkservices'],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
@@ -54,7 +74,7 @@ def darwinProxyConfig(operation, *args):
|
||||
return service[1:]
|
||||
|
||||
for serviceName in getNetworkServices():
|
||||
runCommand(
|
||||
runExternalCommand(
|
||||
[
|
||||
'networksetup',
|
||||
f'-{operation}',
|
||||
@@ -64,10 +84,8 @@ def darwinProxyConfig(operation, *args):
|
||||
)
|
||||
|
||||
|
||||
class _Proxy:
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
class _SystemProxy:
|
||||
def __init__(self):
|
||||
self._daemonThread = None
|
||||
|
||||
@staticmethod
|
||||
@@ -104,10 +122,15 @@ class _Proxy:
|
||||
else:
|
||||
return True
|
||||
|
||||
if not handleAppSystemProxyMode():
|
||||
logger.info(f'ignore proxy PAC \'{pac_url}\' request')
|
||||
|
||||
return
|
||||
|
||||
if _pac():
|
||||
logger.info('set proxy PAC success')
|
||||
logger.info(f'set proxy PAC \'{pac_url}\' success')
|
||||
else:
|
||||
logger.error('set proxy PAC failed')
|
||||
logger.error(f'set proxy PAC \'{pac_url}\' failed')
|
||||
|
||||
@staticmethod
|
||||
def set(server, bypass):
|
||||
@@ -153,6 +176,11 @@ class _Proxy:
|
||||
else:
|
||||
return True
|
||||
|
||||
if not handleAppSystemProxyMode():
|
||||
logger.info(f'ignore proxy server {server} request')
|
||||
|
||||
return
|
||||
|
||||
if _set():
|
||||
logger.info(f'set proxy server {server} success')
|
||||
else:
|
||||
@@ -193,6 +221,11 @@ class _Proxy:
|
||||
else:
|
||||
return True
|
||||
|
||||
if not handleAppSystemProxyMode():
|
||||
logger.info('ignore turn off proxy request')
|
||||
|
||||
return
|
||||
|
||||
if _off():
|
||||
logger.info('turn off proxy success')
|
||||
else:
|
||||
@@ -219,6 +252,11 @@ class _Proxy:
|
||||
|
||||
return False
|
||||
|
||||
if not handleAppSystemProxyMode():
|
||||
logger.info('ignore turn on proxy daemon request')
|
||||
|
||||
return
|
||||
|
||||
if _daemonOn_():
|
||||
logger.info('turn on proxy daemon success')
|
||||
else:
|
||||
@@ -239,7 +277,6 @@ class _Proxy:
|
||||
|
||||
if sysproxy.daemon_off():
|
||||
self._daemonThread.join()
|
||||
|
||||
# Reset it
|
||||
self._daemonThread = None
|
||||
|
||||
@@ -255,10 +292,15 @@ class _Proxy:
|
||||
# turning on StartupOnBoot by default. This should be
|
||||
# friendly for most of the users
|
||||
|
||||
if not handleAppSystemProxyMode():
|
||||
logger.info('ignore turn off proxy daemon request')
|
||||
|
||||
return
|
||||
|
||||
if _daemonOff():
|
||||
logger.info('turn off proxy daemon success')
|
||||
else:
|
||||
logger.error('turn off proxy daemon failed')
|
||||
|
||||
|
||||
Proxy = _Proxy()
|
||||
SystemProxy = _SystemProxy()
|
||||
@@ -0,0 +1,471 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.Utility.Constants import *
|
||||
from Furious.Utility.Utility import runExternalCommand
|
||||
|
||||
import re
|
||||
import logging
|
||||
import subprocess
|
||||
|
||||
__all__ = ['SystemRoutingTable']
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if PLATFORM == 'Windows':
|
||||
if SYSTEM_LANGUAGE == 'ZH':
|
||||
SYSTEM_PREFERRED_ENCODING = 'gbk'
|
||||
else:
|
||||
SYSTEM_PREFERRED_ENCODING = 'utf-8'
|
||||
else:
|
||||
SYSTEM_PREFERRED_ENCODING = 'utf-8'
|
||||
|
||||
|
||||
def dictRepr(returncode, stdout, stderr):
|
||||
return {
|
||||
'returncode': returncode,
|
||||
'stdout': stdout.decode(SYSTEM_PREFERRED_ENCODING, 'replace').strip(),
|
||||
'stderr': stderr.decode(SYSTEM_PREFERRED_ENCODING, 'replace').strip(),
|
||||
}
|
||||
|
||||
|
||||
class SystemRoutingTable:
|
||||
Relations = list()
|
||||
|
||||
DEFAULT_GATEWAY_WIN32 = re.compile(
|
||||
r'0\.0\.0\.0.\s*0\.0\.0\.0.\s*(\S+)\s*(\S+)',
|
||||
)
|
||||
DEFAULT_GATEWAY_MACOS = re.compile(
|
||||
r'gateway:\s*(\S+)',
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def add(sourceIP, destinationIP):
|
||||
def _add():
|
||||
if PLATFORM == 'Windows':
|
||||
try:
|
||||
result = runExternalCommand(
|
||||
['route', 'add', sourceIP, destinationIP, 'metric', '5'],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
raise
|
||||
else:
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
|
||||
if PLATFORM == 'Darwin':
|
||||
try:
|
||||
result = runExternalCommand(
|
||||
['route', 'add', '-net', sourceIP, destinationIP],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
raise
|
||||
else:
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
|
||||
try:
|
||||
returncode, stdout, stderr = _add()
|
||||
except Exception as ex:
|
||||
# Any non-exit exceptions
|
||||
|
||||
logger.error(
|
||||
f'add rule {sourceIP}->{destinationIP} to routing table failed. {ex}'
|
||||
)
|
||||
else:
|
||||
if returncode == 0:
|
||||
logger.info(
|
||||
f'add rule {sourceIP}->{destinationIP} to routing table success. '
|
||||
f'{dictRepr(returncode, stdout, stderr)}'
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
f'add rule {sourceIP}->{destinationIP} to routing table failed. '
|
||||
f'{dictRepr(returncode, stdout, stderr)}'
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def addRelations():
|
||||
for sourceIP, destinationIP in SystemRoutingTable.Relations:
|
||||
SystemRoutingTable.add(sourceIP, destinationIP)
|
||||
|
||||
@staticmethod
|
||||
def WIN32GetInterfaceAliasByIP(ipaddress) -> str:
|
||||
assert PLATFORM == 'Windows'
|
||||
|
||||
try:
|
||||
# Note: Does not work on Windows 7 due to old powershell version
|
||||
|
||||
result = runExternalCommand(
|
||||
f'powershell \"Get-NetIPAddress -IPAddress \'{ipaddress}\' | %{{$_.InterfaceAlias}};\"',
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
shell=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
alias = result.stdout.decode(SYSTEM_PREFERRED_ENCODING, 'strict').strip()
|
||||
|
||||
logger.info(f'get \'{ipaddress}\' interface alias success: {alias}')
|
||||
|
||||
return alias
|
||||
except subprocess.CalledProcessError as err:
|
||||
logger.error(
|
||||
f'get \'{ipaddress}\' interface alias failed. '
|
||||
f'{dictRepr(err.returncode, err.stdout, err.stderr)}'
|
||||
)
|
||||
|
||||
return ''
|
||||
except Exception as ex:
|
||||
# Any non-exit exceptions
|
||||
|
||||
logger.error(f'get \'{ipaddress}\' interface alias failed. {ex}')
|
||||
|
||||
return ''
|
||||
|
||||
@staticmethod
|
||||
def WIN32SetInterfaceDNS(name, address=None, dhcp=True):
|
||||
assert PLATFORM == 'Windows'
|
||||
|
||||
try:
|
||||
if dhcp:
|
||||
result = runExternalCommand(
|
||||
'netsh interface ip set dns'.split() + [f'name=\"{name}\"', 'dhcp'],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=True,
|
||||
)
|
||||
else:
|
||||
assert address is not None
|
||||
|
||||
result = runExternalCommand(
|
||||
'netsh interface ip set dns'.split()
|
||||
+ [f'name=\"{name}\"', 'static', f'{address}'],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as err:
|
||||
logger.error(
|
||||
f'set interface \'{name}\' DNS failed. '
|
||||
f'{dictRepr(err.returncode, err.stdout, err.stderr)}'
|
||||
)
|
||||
except Exception as ex:
|
||||
# Any non-exit exceptions
|
||||
|
||||
logger.error(f'set interface \'{name}\' DNS failed. {ex}')
|
||||
else:
|
||||
logger.info(
|
||||
f'set interface \'{name}\' DNS success. address: {address}. dhcp: {dhcp}. '
|
||||
f'{dictRepr(result.returncode, result.stdout, result.stderr)}'
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def WIN32FlushDNSCache():
|
||||
assert PLATFORM == 'Windows'
|
||||
|
||||
try:
|
||||
result = runExternalCommand(
|
||||
'ipconfig /flushdns'.split(),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as err:
|
||||
logger.error(
|
||||
f'flush system DNS cache failed. '
|
||||
f'{dictRepr(err.returncode, err.stdout, err.stderr)}'
|
||||
)
|
||||
except Exception as ex:
|
||||
# Any non-exit exceptions
|
||||
|
||||
logger.error(f'flush system DNS cache failed. {ex}')
|
||||
else:
|
||||
logger.info(
|
||||
f'flush system DNS cache success. '
|
||||
f'{dictRepr(result.returncode, result.stdout, result.stderr)}'
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def DarwinGetDNSServers() -> list:
|
||||
def getNetworkServices():
|
||||
_command = runExternalCommand(
|
||||
['networksetup', '-listallnetworkservices'],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=True,
|
||||
)
|
||||
|
||||
# Replace with command.stdout.decode('utf-8', 'replace')...?
|
||||
_service = list(
|
||||
filter(lambda x: x != '', _command.stdout.decode().split('\n'))
|
||||
)
|
||||
|
||||
return _service[1:]
|
||||
|
||||
assert PLATFORM == 'Darwin'
|
||||
|
||||
try:
|
||||
services = getNetworkServices()
|
||||
dnsservers = []
|
||||
|
||||
for service in services:
|
||||
result = runExternalCommand(
|
||||
'networksetup -getdnsservers'.split() + [service],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=True,
|
||||
)
|
||||
|
||||
dnsserver = result.stdout.decode().strip()
|
||||
|
||||
if dnsserver.find('DNS Servers') >= 0:
|
||||
# 'There aren't any DNS Servers set on ...'
|
||||
dnsservers.append('')
|
||||
else:
|
||||
dnsservers.append(dnsserver)
|
||||
|
||||
servers = list(zip(services, dnsservers))
|
||||
except Exception as ex:
|
||||
# Any non-exit exceptions
|
||||
|
||||
logger.error(f'get system DNS servers failed. {ex}')
|
||||
|
||||
return []
|
||||
else:
|
||||
logger.info(f'get system DNS servers success. {servers}')
|
||||
|
||||
return servers
|
||||
|
||||
@staticmethod
|
||||
def DarwinSetDNSServers(service: str, dnsserver: str):
|
||||
assert PLATFORM == 'Darwin'
|
||||
|
||||
dnsserverRepr = [dnsserver]
|
||||
|
||||
try:
|
||||
if not dnsserver:
|
||||
result = runExternalCommand(
|
||||
'networksetup -setdnsservers'.split() + [service, 'Empty'],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=True,
|
||||
)
|
||||
else:
|
||||
result = runExternalCommand(
|
||||
'networksetup -setdnsservers'.split()
|
||||
+ [service]
|
||||
+ dnsserver.split('\n'),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as err:
|
||||
logger.error(
|
||||
f'set service \'{service}\' DNS server {dnsserverRepr} failed. '
|
||||
f'{dictRepr(err.returncode, err.stdout, err.stderr)}'
|
||||
)
|
||||
except Exception as ex:
|
||||
# Any non-exit exceptions
|
||||
|
||||
logger.error(
|
||||
f'set service \'{service}\' DNS server {dnsserverRepr} failed. {ex}'
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f'set service \'{service}\' DNS server {dnsserverRepr} success. '
|
||||
f'{dictRepr(result.returncode, result.stdout, result.stderr)}'
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def getDefaultGateway() -> list:
|
||||
def _get():
|
||||
if PLATFORM == 'Windows':
|
||||
# Note: On Windows interface IP is also captured
|
||||
|
||||
result = runExternalCommand(
|
||||
'route print 0.0.0.0'.split(),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=True,
|
||||
)
|
||||
|
||||
return SystemRoutingTable.DEFAULT_GATEWAY_WIN32.findall(
|
||||
result.stdout.decode(SYSTEM_PREFERRED_ENCODING, 'replace')
|
||||
)
|
||||
|
||||
if PLATFORM == 'Darwin':
|
||||
result = runExternalCommand(
|
||||
'route get default'.split(),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=True,
|
||||
)
|
||||
|
||||
return SystemRoutingTable.DEFAULT_GATEWAY_MACOS.findall(
|
||||
result.stdout.decode(SYSTEM_PREFERRED_ENCODING, 'replace')
|
||||
)
|
||||
|
||||
try:
|
||||
defaultGateway = _get()
|
||||
except subprocess.CalledProcessError as err:
|
||||
logger.error(
|
||||
f'get default gateway failed. '
|
||||
f'{dictRepr(err.returncode, err.stdout, err.stderr)}'
|
||||
)
|
||||
|
||||
return []
|
||||
except Exception as ex:
|
||||
# Any non-exit exceptions
|
||||
|
||||
logger.error(f'get default gateway failed. {ex}')
|
||||
|
||||
return []
|
||||
else:
|
||||
logger.info(f'get default gateway success. {defaultGateway}')
|
||||
|
||||
return defaultGateway
|
||||
|
||||
@staticmethod
|
||||
def setDeviceGateway(deviceName, deviceIP, deviceGateway):
|
||||
def _set():
|
||||
if PLATFORM == 'Windows':
|
||||
try:
|
||||
result = runExternalCommand(
|
||||
'netsh interface ip set address'.split()
|
||||
+ [
|
||||
deviceName,
|
||||
'static',
|
||||
deviceIP,
|
||||
'255.255.255.0',
|
||||
deviceGateway,
|
||||
'3',
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
raise
|
||||
else:
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
|
||||
if PLATFORM == 'Darwin':
|
||||
try:
|
||||
result = runExternalCommand(
|
||||
['ifconfig', deviceName, deviceIP, deviceGateway, 'up'],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
raise
|
||||
else:
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
|
||||
try:
|
||||
returncode, stdout, stderr = _set()
|
||||
except Exception as ex:
|
||||
# Any non-exit exceptions
|
||||
|
||||
logger.error(
|
||||
f'set device \'{deviceName}\' gateway \'{deviceGateway}\' failed. {ex}'
|
||||
)
|
||||
else:
|
||||
if returncode == 0:
|
||||
logger.info(
|
||||
f'set device \'{deviceName}\' gateway \'{deviceGateway}\' success. '
|
||||
f'{dictRepr(returncode, stdout, stderr)}'
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
f'set device \'{deviceName}\' gateway \'{deviceGateway}\' failed. '
|
||||
f'{dictRepr(returncode, stdout, stderr)}'
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def delete(sourceIP, destinationIP):
|
||||
def _delete():
|
||||
if PLATFORM == 'Windows':
|
||||
try:
|
||||
result = runExternalCommand(
|
||||
['route', 'delete', sourceIP, destinationIP],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
raise
|
||||
else:
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
|
||||
if PLATFORM == 'Darwin':
|
||||
try:
|
||||
result = runExternalCommand(
|
||||
['route', 'delete', '-net', sourceIP, destinationIP],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
raise
|
||||
else:
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
|
||||
try:
|
||||
returncode, stdout, stderr = _delete()
|
||||
except Exception as ex:
|
||||
# Any non-exit exceptions
|
||||
|
||||
logger.error(
|
||||
f'delete rule {sourceIP}->{destinationIP} from routing table failed. {ex}'
|
||||
)
|
||||
else:
|
||||
if returncode == 0:
|
||||
logger.info(
|
||||
f'delete rule {sourceIP}->{destinationIP} from routing table success. '
|
||||
f'{dictRepr(returncode, stdout, stderr)}'
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
f'delete rule {sourceIP}->{destinationIP} from routing table failed. '
|
||||
f'{dictRepr(returncode, stdout, stderr)}'
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def deleteRelations(clear=True):
|
||||
if PLATFORM == 'Windows':
|
||||
if len(SystemRoutingTable.Relations):
|
||||
SystemRoutingTable.delete('0.0.0.0', APPLICATION_TUN_GATEWAY_ADDRESS)
|
||||
|
||||
for sourceIP, destinationIP in SystemRoutingTable.Relations[::-1]:
|
||||
SystemRoutingTable.delete(sourceIP, destinationIP)
|
||||
|
||||
if clear:
|
||||
SystemRoutingTable.Relations.clear()
|
||||
@@ -0,0 +1,107 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.Utility.Constants import *
|
||||
from Furious.Utility.AppSettings import AppSettings
|
||||
from Furious.Utility.Utility import runExternalCommand
|
||||
|
||||
import os
|
||||
import sys
|
||||
import ctypes
|
||||
import functools
|
||||
import subprocess
|
||||
|
||||
__all__ = [
|
||||
'getPythonVersion',
|
||||
'getUbuntuRelease',
|
||||
'isAdministrator',
|
||||
'isVPNMode',
|
||||
'isScriptMode',
|
||||
'isPythonw',
|
||||
'isWindows7',
|
||||
]
|
||||
|
||||
|
||||
def getPythonVersion():
|
||||
return '.'.join(str(info) for info in sys.version_info)
|
||||
|
||||
|
||||
@functools.lru_cache(None)
|
||||
def getUbuntuRelease() -> str:
|
||||
try:
|
||||
result = runExternalCommand(
|
||||
['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 ''
|
||||
|
||||
|
||||
@functools.lru_cache(None)
|
||||
def isAdministrator() -> bool:
|
||||
if PLATFORM == 'Windows':
|
||||
return ctypes.windll.shell32.IsUserAnAdmin() == 1
|
||||
else:
|
||||
return os.geteuid() == 0
|
||||
|
||||
|
||||
def isVPNMode() -> bool:
|
||||
return isAdministrator() and AppSettings.isStateON_('VPNMode')
|
||||
|
||||
|
||||
def isScriptMode() -> bool:
|
||||
return sys.argv[0].endswith('.py')
|
||||
|
||||
|
||||
def isPythonw() -> bool:
|
||||
def isRealFile(file):
|
||||
if not hasattr(file, 'fileno'):
|
||||
return False
|
||||
|
||||
try:
|
||||
tmp = os.dup(file.fileno())
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return False
|
||||
else:
|
||||
os.close(tmp)
|
||||
|
||||
return True
|
||||
|
||||
# pythonw.exe. Also applies to packed GUI application on Windows
|
||||
return not isRealFile(sys.__stdout__) or not isRealFile(sys.__stderr__)
|
||||
|
||||
|
||||
def isWindows7() -> bool:
|
||||
return PLATFORM == 'Windows' and PLATFORM_RELEASE == '7'
|
||||
File diff suppressed because it is too large
Load Diff
+20
-551
@@ -1,70 +1,25 @@
|
||||
# Copyright (C) 2023 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.Gui.Icon import Icon
|
||||
from Furious.Utility.Constants import (
|
||||
APP,
|
||||
PLATFORM,
|
||||
PLATFORM_RELEASE,
|
||||
ROOT_DIR,
|
||||
PYSIDE6_VERSION,
|
||||
DEFAULT_TOR_SOCKS_PORT,
|
||||
DEFAULT_TOR_HTTPS_PORT,
|
||||
DEFAULT_TOR_RELAY_ESTABLISH_TIMEOUT,
|
||||
Color,
|
||||
)
|
||||
from Furious.Utility.Constants import *
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from PySide6.QtNetwork import (
|
||||
QNetworkAccessManager,
|
||||
QNetworkReply,
|
||||
QNetworkRequest,
|
||||
QNetworkProxy,
|
||||
)
|
||||
from typing import AnyStr, Tuple
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import copy
|
||||
import ujson
|
||||
import queue
|
||||
import ctypes
|
||||
import logging
|
||||
import pybase64
|
||||
import threading
|
||||
import functools
|
||||
import ipaddress
|
||||
import subprocess
|
||||
import urllib.parse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Base64Encoder:
|
||||
@staticmethod
|
||||
@functools.lru_cache(128)
|
||||
def encode(text):
|
||||
return pybase64.b64encode(text)
|
||||
|
||||
@staticmethod
|
||||
@functools.lru_cache(128)
|
||||
def decode(text):
|
||||
return pybase64.b64decode(text, validate=False)
|
||||
__all__ = [
|
||||
'Protocol',
|
||||
'BinarySettings',
|
||||
'protocolRepr',
|
||||
'isValidIPAddress',
|
||||
'parseHostPort',
|
||||
'runExternalCommand',
|
||||
'getAbsolutePath',
|
||||
]
|
||||
|
||||
|
||||
class Protocol:
|
||||
@@ -76,375 +31,15 @@ class Protocol:
|
||||
Hysteria2 = 'hysteria2'
|
||||
|
||||
|
||||
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:
|
||||
def __init__(self, emptyObject, getObjectFn, settingName):
|
||||
self.emptyObject = emptyObject
|
||||
self.getObjectFn = getObjectFn
|
||||
self.settingName = settingName
|
||||
|
||||
def init(self):
|
||||
return copy.deepcopy(self.emptyObject)
|
||||
|
||||
def sync(self, ob=None):
|
||||
if ob is None:
|
||||
# Object is up-to-date
|
||||
setattr(APP(), self.settingName, Storage.toStorage(self.getObjectFn()))
|
||||
else:
|
||||
# Object is up-to-date
|
||||
setattr(APP(), self.settingName, Storage.toStorage(ob))
|
||||
|
||||
def toObject(self, storage):
|
||||
if not storage:
|
||||
# Storage does not exist, or is empty
|
||||
return self.init()
|
||||
|
||||
return ujson.loads(Base64Encoder.decode(storage))
|
||||
|
||||
@staticmethod
|
||||
def toStorage(ob):
|
||||
return Base64Encoder.encode(
|
||||
ujson.dumps(ob, ensure_ascii=False, escape_forward_slashes=False).encode()
|
||||
)
|
||||
|
||||
def clear(self):
|
||||
setattr(APP(), self.settingName, '')
|
||||
|
||||
|
||||
class _ServerStorage(Storage):
|
||||
# remark, config, subsId. (subsId corresponds to unique in Subscription Object)
|
||||
EMPTY_OBJECT = {'model': []}
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
_ServerStorage.EMPTY_OBJECT,
|
||||
lambda: APP().ServerWidget.StorageObj,
|
||||
'Configuration',
|
||||
)
|
||||
|
||||
def clear(self):
|
||||
super().clear()
|
||||
|
||||
APP().ActivatedItemIndex = str(-1)
|
||||
|
||||
|
||||
class _RoutesStorage(Storage):
|
||||
# remark, corename, routes
|
||||
EMPTY_OBJECT = {'model': []}
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
_RoutesStorage.EMPTY_OBJECT,
|
||||
lambda: APP().RoutesWidget.StorageObj,
|
||||
'CustomRouting',
|
||||
)
|
||||
|
||||
|
||||
class _SubscriptionStorage(Storage):
|
||||
# unique: remark, webURL
|
||||
EMPTY_OBJECT = {}
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
_SubscriptionStorage.EMPTY_OBJECT,
|
||||
lambda: APP().SubscriptionWidget.StorageObj,
|
||||
'CustomSubscription',
|
||||
)
|
||||
|
||||
|
||||
class _TorRelaySettingsStorage(Storage):
|
||||
EMPTY_OBJECT = {
|
||||
'socksTunnelPort': DEFAULT_TOR_SOCKS_PORT,
|
||||
'httpsTunnelPort': DEFAULT_TOR_HTTPS_PORT,
|
||||
'useProxy': True,
|
||||
'logLevel': 'notice',
|
||||
'relayEstablishTimeout': DEFAULT_TOR_RELAY_ESTABLISH_TIMEOUT,
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
_TorRelaySettingsStorage.EMPTY_OBJECT,
|
||||
lambda: APP().TorRelayWidget.StorageObj,
|
||||
'TorRelaySettings',
|
||||
)
|
||||
|
||||
|
||||
ServerStorage = _ServerStorage()
|
||||
RoutesStorage = _RoutesStorage()
|
||||
SubscriptionStorage = _SubscriptionStorage()
|
||||
TorRelaySettingsStorage = _TorRelaySettingsStorage()
|
||||
|
||||
|
||||
class AsyncSubprocessMessage:
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.msgQueue = queue.Queue()
|
||||
self.msgTimer = QtCore.QTimer()
|
||||
|
||||
self.daemonThread = None
|
||||
|
||||
def connectTimeoutCallback(self, callback):
|
||||
self.msgTimer.timeout.connect(callback)
|
||||
|
||||
def startDaemonThread(self, stdout):
|
||||
def enqueue(msgQueue):
|
||||
for line in iter(stdout.readline, b''):
|
||||
msgQueue.put(line.decode('utf-8', 'replace').strip())
|
||||
|
||||
stdout.close()
|
||||
|
||||
self.daemonThread = threading.Thread(
|
||||
target=enqueue, args=(self.msgQueue,), daemon=True
|
||||
)
|
||||
self.daemonThread.start()
|
||||
|
||||
def startTimer(self, msec=1):
|
||||
self.msgTimer.start(msec)
|
||||
|
||||
def stopTimer(self):
|
||||
self.msgTimer.stop()
|
||||
|
||||
def getLineNoWait(self):
|
||||
try:
|
||||
return self.msgQueue.get_nowait()
|
||||
except queue.Empty:
|
||||
# Queue is empty
|
||||
|
||||
return ''
|
||||
|
||||
|
||||
class Switch:
|
||||
class BinarySettings:
|
||||
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 NeedSyncSettings:
|
||||
Object = list()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
NeedSyncSettings.Object.append(self)
|
||||
|
||||
def syncSettings(self):
|
||||
raise NotImplementedError
|
||||
|
||||
@staticmethod
|
||||
def syncAll():
|
||||
for ob in NeedSyncSettings.Object:
|
||||
assert isinstance(ob, NeedSyncSettings)
|
||||
|
||||
ob.syncSettings()
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class DNSResolver:
|
||||
networkAccessManager = QNetworkAccessManager()
|
||||
|
||||
@staticmethod
|
||||
def request(address):
|
||||
request = QNetworkRequest(
|
||||
QtCore.QUrl(f'https://cloudflare-dns.com/dns-query?name={address}')
|
||||
)
|
||||
request.setRawHeader('accept'.encode(), 'application/dns-json'.encode())
|
||||
|
||||
return request
|
||||
|
||||
@staticmethod
|
||||
def handleFinishedByNetworkReply(networkReply, domain, resultMap):
|
||||
assert isinstance(networkReply, QNetworkReply)
|
||||
|
||||
if networkReply.error() != QNetworkReply.NetworkError.NoError:
|
||||
logger.error(
|
||||
f'DNS resolution for \'{domain}\' failed. {networkReply.errorString()}'
|
||||
)
|
||||
|
||||
resultMap['error'] = True
|
||||
else:
|
||||
logger.info(f'DNS resolution for \'{domain}\' success')
|
||||
|
||||
# Unchecked?
|
||||
replyObject = ujson.loads(networkReply.readAll().data())
|
||||
|
||||
for record in replyObject['Answer']:
|
||||
address = record['data']
|
||||
|
||||
logger.info(f'\'{domain}\' resolved to \'{address}\'')
|
||||
|
||||
if isValidIPAddress(address):
|
||||
resultMap['result'][address] = True
|
||||
else:
|
||||
resultMap['depth'] += 1
|
||||
|
||||
newNetworkReply = DNSResolver.networkAccessManager.get(
|
||||
DNSResolver.request(address)
|
||||
)
|
||||
newNetworkReply.finished.connect(
|
||||
functools.partial(
|
||||
DNSResolver.handleFinishedByNetworkReply,
|
||||
newNetworkReply,
|
||||
address,
|
||||
resultMap,
|
||||
)
|
||||
)
|
||||
|
||||
resultMap['reference'].append(newNetworkReply)
|
||||
|
||||
resultMap['depth'] -= 1
|
||||
|
||||
@staticmethod
|
||||
def resolve(domain, proxyHost=None, proxyPort=None):
|
||||
if proxyHost is None or proxyPort is None:
|
||||
DNSResolver.networkAccessManager.setProxy(QNetworkProxy.ProxyType.NoProxy)
|
||||
else:
|
||||
try:
|
||||
DNSResolver.networkAccessManager.setProxy(
|
||||
QNetworkProxy(
|
||||
QNetworkProxy.ProxyType.HttpProxy, proxyHost, int(proxyPort)
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(f'DNS resolution uses proxy server {proxyHost}:{proxyPort}')
|
||||
except Exception as ex:
|
||||
# Any non-exit exceptions
|
||||
|
||||
logger.error(
|
||||
f'invalid proxy server {proxyHost}:{proxyPort}. {ex}. '
|
||||
'DNS resolution uses no proxy'
|
||||
)
|
||||
|
||||
DNSResolver.networkAccessManager.setProxy(
|
||||
QNetworkProxy.ProxyType.NoProxy
|
||||
)
|
||||
|
||||
resultMap = {
|
||||
'depth': 0,
|
||||
'error': False,
|
||||
'reference': [],
|
||||
'result': {},
|
||||
}
|
||||
|
||||
resultMap['depth'] += 1
|
||||
|
||||
networkReply = DNSResolver.networkAccessManager.get(DNSResolver.request(domain))
|
||||
networkReply.finished.connect(
|
||||
functools.partial(
|
||||
DNSResolver.handleFinishedByNetworkReply,
|
||||
networkReply,
|
||||
domain,
|
||||
resultMap,
|
||||
)
|
||||
)
|
||||
|
||||
resultMap['reference'].append(networkReply)
|
||||
|
||||
DNSResolver.wait(resultMap)
|
||||
|
||||
return resultMap['error'], list(resultMap['result'].keys())
|
||||
|
||||
@staticmethod
|
||||
def wait(resultMap, startCounter=0, timeout=30000, step=100):
|
||||
if resultMap['depth'] != 0:
|
||||
logger.info('DNS resolution in progress. Wait')
|
||||
else:
|
||||
return
|
||||
|
||||
while resultMap['depth'] != 0 and startCounter < timeout:
|
||||
eventLoopWait(step)
|
||||
|
||||
startCounter += step
|
||||
|
||||
if resultMap['depth'] != 0:
|
||||
logger.error('DNS resolution timeout')
|
||||
|
||||
for networkReply in resultMap['reference']:
|
||||
if (
|
||||
isinstance(networkReply, QNetworkReply)
|
||||
and not networkReply.isFinished()
|
||||
):
|
||||
networkReply.abort()
|
||||
|
||||
|
||||
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')
|
||||
|
||||
|
||||
@functools.lru_cache(None)
|
||||
def protocolRepr(protocol):
|
||||
def protocolRepr(protocol: str) -> str:
|
||||
if protocol.lower() == 'vmess':
|
||||
return Protocol.VMess
|
||||
|
||||
@@ -461,7 +56,7 @@ def protocolRepr(protocol):
|
||||
|
||||
|
||||
@functools.lru_cache(None)
|
||||
def isValidIPAddress(address):
|
||||
def isValidIPAddress(address) -> bool:
|
||||
try:
|
||||
ipaddress.ip_address(address)
|
||||
except Exception:
|
||||
@@ -473,7 +68,7 @@ def isValidIPAddress(address):
|
||||
|
||||
|
||||
# Can throw exceptions
|
||||
def parseHostPort(address):
|
||||
def parseHostPort(address) -> Tuple[AnyStr | None, str]:
|
||||
if address.count('//') == 0:
|
||||
result = urllib.parse.urlsplit('//' + address)
|
||||
else:
|
||||
@@ -482,140 +77,14 @@ def parseHostPort(address):
|
||||
return result.hostname, str(result.port)
|
||||
|
||||
|
||||
def enumValueWrapper(enum):
|
||||
# Protect PySide6 enum wrapper behavior changes
|
||||
if PYSIDE6_VERSION < '6.2.2':
|
||||
return enum
|
||||
else:
|
||||
return enum.value
|
||||
|
||||
|
||||
def eventLoopWait(ms):
|
||||
# Protect qWait method does not exist in some
|
||||
# old PySide6 version
|
||||
if PYSIDE6_VERSION < '6.3.1':
|
||||
for counter in range(0, ms, 10):
|
||||
time.sleep(10 / 1000)
|
||||
|
||||
APP().processEvents()
|
||||
else:
|
||||
from PySide6.QtTest import QTest
|
||||
|
||||
QTest.qWait(ms)
|
||||
|
||||
APP().processEvents()
|
||||
|
||||
|
||||
def runCommand(*args, **kwargs):
|
||||
def runExternalCommand(*args, **kwargs):
|
||||
if PLATFORM == 'Windows':
|
||||
return subprocess.run(
|
||||
*args, creationflags=subprocess.CREATE_NO_WINDOW, **kwargs
|
||||
)
|
||||
creationflags = kwargs.pop('creationflags', subprocess.CREATE_NO_WINDOW)
|
||||
|
||||
return subprocess.run(*args, creationflags=creationflags, **kwargs)
|
||||
else:
|
||||
return subprocess.run(*args, **kwargs)
|
||||
|
||||
|
||||
def getAbsolutePath(path):
|
||||
return path if os.path.isabs(path) else str(ROOT_DIR / path)
|
||||
|
||||
|
||||
@functools.lru_cache(None)
|
||||
def getUbuntuRelease():
|
||||
try:
|
||||
result = runCommand(
|
||||
['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 ''
|
||||
|
||||
|
||||
@functools.lru_cache(None)
|
||||
def getConnectedColor():
|
||||
if not isAdministrator():
|
||||
return Color.LIGHT_RED_
|
||||
else:
|
||||
return Color.LIGHT_PURPLE
|
||||
|
||||
|
||||
@functools.lru_cache(None)
|
||||
def getConnectedWindowIcon():
|
||||
if not isAdministrator():
|
||||
return bootstrapIcon('rocket-takeoff-connected-dark.svg')
|
||||
else:
|
||||
return bootstrapIcon('rocket-takeoff-admin-connected.svg')
|
||||
|
||||
|
||||
def swapListItem(listOrTuple, index0, index1):
|
||||
swap = listOrTuple[index0]
|
||||
|
||||
listOrTuple[index0] = listOrTuple[index1]
|
||||
listOrTuple[index1] = swap
|
||||
|
||||
|
||||
@functools.lru_cache(None)
|
||||
def isAdministrator():
|
||||
if PLATFORM == 'Windows':
|
||||
return ctypes.windll.shell32.IsUserAnAdmin() == 1
|
||||
else:
|
||||
return os.geteuid() == 0
|
||||
|
||||
|
||||
def isVPNMode():
|
||||
return isAdministrator() and APP().VPNMode == Switch.ON_
|
||||
|
||||
|
||||
def isScriptMode():
|
||||
return sys.argv[0].endswith('.py')
|
||||
|
||||
|
||||
def isRealFile(file):
|
||||
if not hasattr(file, 'fileno'):
|
||||
return False
|
||||
|
||||
try:
|
||||
tmp = os.dup(file.fileno())
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return False
|
||||
else:
|
||||
os.close(tmp)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def isPythonw():
|
||||
# pythonw.exe. Also applies to packed GUI application on Windows
|
||||
return not isRealFile(sys.__stdout__) or not isRealFile(sys.__stderr__)
|
||||
|
||||
|
||||
def isWindows7():
|
||||
return PLATFORM == 'Windows' and PLATFORM_RELEASE == '7'
|
||||
|
||||
|
||||
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())
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2023 Loren Eteval <loren.eteval@proton.me>
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
@@ -15,3 +15,14 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from .Constants import *
|
||||
from .PySide6Legacy import *
|
||||
from .StartupOnBoot import *
|
||||
from .SystemProxy import *
|
||||
from .SystemRoutingTable import *
|
||||
from .SystemRuntime import *
|
||||
|
||||
from .AppMainProcess import *
|
||||
from .AppSettings import *
|
||||
from .AppSettingsFn import *
|
||||
from .Utility import *
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2023 Loren Eteval <loren.eteval@proton.me>
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
@@ -15,4 +15,4 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
__version__ = '0.2.13'
|
||||
__version__ = '0.3.0'
|
||||
|
||||
+195
-207
@@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2023 Loren Eteval <loren.eteval@proton.me>
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
@@ -15,54 +15,38 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from Furious.Widget.SystemTrayIcon import SystemTrayIcon
|
||||
from Furious.Widget.EditConfiguration import EditConfigurationWidget
|
||||
from Furious.Widget.EditRouting import EditRoutingWidget
|
||||
from Furious.Widget.EditSubscription import EditSubscriptionWidget
|
||||
from Furious.Widget.LogViewer import LogViewerWidget
|
||||
from Furious.Widget.TorRelaySettings import TorRelaySettingsWidget
|
||||
from Furious.Utility.Constants import (
|
||||
APPLICATION_NAME,
|
||||
APPLICATION_VERSION,
|
||||
ORGANIZATION_NAME,
|
||||
ORGANIZATION_DOMAIN,
|
||||
PYSIDE6_VERSION,
|
||||
PLATFORM,
|
||||
PLATFORM_RELEASE,
|
||||
LOCAL_SERVER_NAME,
|
||||
SYSTEM_LANGUAGE,
|
||||
DATA_DIR,
|
||||
LogType,
|
||||
)
|
||||
from Furious.Utility.Utility import (
|
||||
ServerStorage,
|
||||
SupportThemeChangedCallback,
|
||||
NeedSyncSettings,
|
||||
isScriptMode,
|
||||
isPythonw,
|
||||
)
|
||||
from Furious.Utility.Proxy import Proxy
|
||||
from Furious.Utility.Settings import Settings
|
||||
from Furious.Utility.Translator import gettext as _
|
||||
from Furious.Interface import *
|
||||
from Furious.PyFramework import *
|
||||
from Furious.QtFramework import *
|
||||
from Furious.QtFramework import gettext as _
|
||||
from Furious.Utility import *
|
||||
from Furious.Storage import *
|
||||
from Furious.Widget.SystemTrayIcon import *
|
||||
from Furious.Window.AppMainWindow import *
|
||||
from Furious.Window.LogViewerWindow import *
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtGui import QFontDatabase
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from PySide6.QtNetwork import QLocalServer, QLocalSocket
|
||||
from PySide6.QtGui import *
|
||||
from PySide6.QtNetwork import *
|
||||
from PySide6.QtWidgets import *
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import logging
|
||||
import traceback
|
||||
import threading
|
||||
import traceback
|
||||
import functools
|
||||
import qdarkstyle
|
||||
import darkdetect
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
registerAppSettings('AppLogViewerWidgetPointSize')
|
||||
registerAppSettings('CoreLogViewerWidgetPointSize')
|
||||
registerAppSettings('TunLogViewerWidgetPointSize')
|
||||
|
||||
def getPythonVersion():
|
||||
return '.'.join(str(info) for info in sys.version_info)
|
||||
needTrans = functools.partial(needTransFn, source=__name__)
|
||||
|
||||
|
||||
def rateLimited(maxCallPerSecond):
|
||||
@@ -97,28 +81,41 @@ class SystemTrayUnavailable(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class AppLogViewerHandle(logging.Handler):
|
||||
def __init__(self, textBrowser):
|
||||
class AppLogHandler(logging.Handler):
|
||||
def __init__(self, emitCallback):
|
||||
super().__init__()
|
||||
|
||||
self.textBrowser = textBrowser
|
||||
self.emitCallback = emitCallback
|
||||
|
||||
def emit(self, record):
|
||||
self.textBrowser.append(self.format(record))
|
||||
if callable(self.emitCallback):
|
||||
self.emitCallback(self.format(record))
|
||||
|
||||
|
||||
class SingletonApplication(QApplication):
|
||||
class ApplicationExitHelper(QApplication):
|
||||
def __init__(self, argv):
|
||||
super().__init__(argv)
|
||||
|
||||
# Exiting flag
|
||||
self.exiting = False
|
||||
self._exiting = False
|
||||
|
||||
def setExitingFlag(self, value: bool):
|
||||
self._exiting = value
|
||||
|
||||
def isExiting(self) -> bool:
|
||||
return self._exiting is True
|
||||
|
||||
|
||||
class SingletonApplication(ApplicationExitHelper):
|
||||
def __init__(self, argv):
|
||||
super().__init__(argv)
|
||||
|
||||
self.serverName = LOCAL_SERVER_NAME
|
||||
|
||||
self.socket = QLocalSocket(self)
|
||||
self.server = QLocalServer(self)
|
||||
|
||||
def checkForExistingApp(self):
|
||||
def hasRunningApp(self) -> bool:
|
||||
self.socket.connectToServer(self.serverName)
|
||||
|
||||
if self.socket.waitForConnected(1000):
|
||||
@@ -150,13 +147,15 @@ class ApplicationThemeDetector(QtCore.QObject):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class Application(SingletonApplication):
|
||||
class ErrorCode:
|
||||
ExitSuccess = 0
|
||||
UnknownException = 1
|
||||
PlatformNotSupported = 2
|
||||
AssertionError = 3
|
||||
needTrans(
|
||||
'Already started',
|
||||
'Furious Log',
|
||||
'Core Log',
|
||||
'Tun2socks Log',
|
||||
)
|
||||
|
||||
|
||||
class Application(ApplicationFactory, SingletonApplication):
|
||||
def __init__(self, argv):
|
||||
super().__init__(argv)
|
||||
|
||||
@@ -165,70 +164,60 @@ class Application(SingletonApplication):
|
||||
self.setOrganizationName(ORGANIZATION_NAME)
|
||||
self.setOrganizationDomain(ORGANIZATION_DOMAIN)
|
||||
|
||||
self.tray = None
|
||||
|
||||
# Whether server test has been performed
|
||||
self.testPerformed = False
|
||||
self.systemTray = None
|
||||
|
||||
# Font
|
||||
self.customFontLoadMsg = ''
|
||||
self.customFontEnabled = False
|
||||
self.customFontName = ''
|
||||
|
||||
# Log Viewer Widget
|
||||
self.logViewerWidget = None
|
||||
# Log Handle
|
||||
self.appLogViewerHandle = None
|
||||
self.appLogStreamHandle = None
|
||||
|
||||
# Theme Detect
|
||||
self.currentTheme = None
|
||||
self.themeDetectTimer = None
|
||||
self.themeDetector = None
|
||||
self.themeListenerThread = None
|
||||
|
||||
# Main Widget
|
||||
self.SubscriptionWidget = None
|
||||
self.ServerWidget = None
|
||||
self.RoutesWidget = None
|
||||
self.TorRelayWidget = 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)
|
||||
# Initialize storage
|
||||
self.userServers = UserServers()
|
||||
self.userSubs = UserSubs()
|
||||
|
||||
@rateLimited(maxCallPerSecond=2)
|
||||
@QtCore.Slot()
|
||||
def showExistingApp(self):
|
||||
if isinstance(self.tray, SystemTrayIcon):
|
||||
if isinstance(self.systemTray, SystemTrayIcon):
|
||||
logger.info('attempting to start multiple instance. Show tray message')
|
||||
|
||||
self.tray.showMessage(_('Already started'))
|
||||
self.systemTray.showMessage(_('Already started'))
|
||||
else:
|
||||
# The tray hasn't been initialized. Do nothing
|
||||
pass
|
||||
|
||||
def configureLogging(self):
|
||||
self.logViewerWidget = LogViewerWidget()
|
||||
self.appLogViewerHandle = AppLogViewerHandle(
|
||||
self.logViewerWidget.textBrowser(LogType.App)
|
||||
self.logViewerWindowApp_ = LogViewerWindow(
|
||||
tabTitle=_('Furious Log'),
|
||||
fontFamily=self.customFontName,
|
||||
pointSizeSettingsName='AppLogViewerWidgetPointSize',
|
||||
)
|
||||
self.logViewerWindowCore = LogViewerWindow(
|
||||
tabTitle=_('Core Log'),
|
||||
fontFamily=self.customFontName,
|
||||
pointSizeSettingsName='CoreLogViewerWidgetPointSize',
|
||||
)
|
||||
self.logViewerWindowTun_ = LogViewerWindow(
|
||||
tabTitle=_('Tun2socks Log'),
|
||||
fontFamily=self.customFontName,
|
||||
pointSizeSettingsName='TunLogViewerWidgetPointSize',
|
||||
)
|
||||
self.appLogStreamHandle = logging.StreamHandler()
|
||||
|
||||
logging.basicConfig(
|
||||
format='[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s',
|
||||
level=logging.INFO,
|
||||
handlers=(self.appLogViewerHandle, self.appLogStreamHandle),
|
||||
handlers=(
|
||||
AppLogHandler(
|
||||
lambda record: self.logViewerWindowApp_.appendLine(record)
|
||||
),
|
||||
logging.StreamHandler(),
|
||||
),
|
||||
)
|
||||
logging.raiseExceptions = False
|
||||
|
||||
@@ -239,7 +228,6 @@ class Application(SingletonApplication):
|
||||
if QFontDatabase.addApplicationFont(fontFile) != -1:
|
||||
# Delayed
|
||||
self.customFontLoadMsg = f'custom font {fontName} load success'
|
||||
|
||||
self.customFontEnabled = True
|
||||
self.customFontName = fontName
|
||||
else:
|
||||
@@ -251,149 +239,149 @@ class Application(SingletonApplication):
|
||||
# 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()
|
||||
|
||||
logger.info(f'application version: {APPLICATION_VERSION}')
|
||||
logger.info(
|
||||
f'Qt version: {QtCore.qVersion()}. PySide6 version: {PYSIDE6_VERSION}'
|
||||
)
|
||||
logger.info(
|
||||
f'python version: {getPythonVersion()}. Platform: {PLATFORM}. '
|
||||
f'Platform release: {PLATFORM_RELEASE}'
|
||||
)
|
||||
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'isPythonw: {isPythonw()}')
|
||||
logger.info(f'system language is {SYSTEM_LANGUAGE}')
|
||||
logger.info(self.customFontLoadMsg)
|
||||
logger.info(f'current theme is {darkdetect.theme()}')
|
||||
|
||||
if PLATFORM != 'Windows' and not isScriptMode():
|
||||
logger.info('theme detect method uses timer implementation')
|
||||
|
||||
@QtCore.Slot()
|
||||
def handleTimeout():
|
||||
currentTheme = darkdetect.theme()
|
||||
|
||||
if self.currentTheme != currentTheme:
|
||||
self.currentTheme = currentTheme
|
||||
|
||||
SupportThemeChangedCallback.callThemeChangedCallback(currentTheme)
|
||||
|
||||
self.currentTheme = darkdetect.theme()
|
||||
self.themeDetectTimer = QtCore.QTimer()
|
||||
self.themeDetectTimer.timeout.connect(handleTimeout)
|
||||
self.themeDetectTimer.start(1000)
|
||||
def isSystemTrayConnected(self):
|
||||
if isinstance(self.systemTray, SystemTrayIcon):
|
||||
return self.systemTray.ConnectAction.isConnected()
|
||||
else:
|
||||
logger.info('theme detect method uses listener implementation')
|
||||
return False
|
||||
|
||||
def listener(*args, **kwargs):
|
||||
try:
|
||||
darkdetect.listener(*args, **kwargs)
|
||||
except NotImplementedError:
|
||||
# Not supported by darkdetect. Ignore
|
||||
@staticmethod
|
||||
def isDarkModeEnabled():
|
||||
return AppSettings.isStateON_('DarkMode')
|
||||
|
||||
logger.error(
|
||||
'darkdetect listener is not implemented on this platform'
|
||||
)
|
||||
def switchToDarkMode(self):
|
||||
self.setStyleSheet(qdarkstyle.load_stylesheet_pyside6())
|
||||
|
||||
pass
|
||||
SupportThemeChangedCallback.callThemeChangedCallbackUnchecked('Dark')
|
||||
|
||||
self.themeDetector = ApplicationThemeDetector()
|
||||
self.themeDetector.themeChanged.connect(
|
||||
SupportThemeChangedCallback.callThemeChangedCallback
|
||||
)
|
||||
def switchToAutoMode(self):
|
||||
self.setStyleSheet('')
|
||||
|
||||
self.themeListenerThread = threading.Thread(
|
||||
target=listener,
|
||||
args=(self.themeDetector.themeChanged.emit,),
|
||||
daemon=True,
|
||||
)
|
||||
self.themeListenerThread.start()
|
||||
|
||||
# Mandatory
|
||||
self.setQuitOnLastWindowClosed(False)
|
||||
|
||||
# Reset proxy
|
||||
Proxy.off()
|
||||
Proxy.daemonOn_()
|
||||
|
||||
self.aboutToQuit.connect(self.cleanup)
|
||||
|
||||
self.SubscriptionWidget = EditSubscriptionWidget()
|
||||
self.RoutesWidget = EditRoutingWidget()
|
||||
self.ServerWidget = EditConfigurationWidget()
|
||||
self.TorRelayWidget = TorRelaySettingsWidget()
|
||||
|
||||
self.tray = SystemTrayIcon()
|
||||
self.tray.show()
|
||||
self.tray.setApplicationToolTip()
|
||||
self.tray.bootstrap()
|
||||
|
||||
return self
|
||||
|
||||
def isConnected(self):
|
||||
return self.tray is not None and self.tray.ConnectAction.isConnected()
|
||||
SupportThemeChangedCallback.callThemeChangedCallbackUnchecked(
|
||||
darkdetect.theme()
|
||||
)
|
||||
|
||||
@QtCore.Slot()
|
||||
def cleanup(self):
|
||||
Proxy.off()
|
||||
Proxy.daemonOff()
|
||||
SystemProxy.off()
|
||||
SystemProxy.daemonOff()
|
||||
|
||||
# Try to avoid unnecessary storage sync.
|
||||
# Better way to do this?
|
||||
if self.testPerformed:
|
||||
ServerStorage.sync()
|
||||
SupportExitCleanup.cleanupAll()
|
||||
|
||||
NeedSyncSettings.syncAll()
|
||||
|
||||
if self.tray is not None:
|
||||
self.tray.ConnectAction.stopCore()
|
||||
logger.info('final cleanup done')
|
||||
|
||||
def exit(self, exitcode=0):
|
||||
if self.ServerWidget is not None:
|
||||
if self.ServerWidget.questionSave():
|
||||
# Changes handled. Exit
|
||||
self.ServerWidget.pingThreadPool.clear()
|
||||
self.exiting = True
|
||||
self.setExitingFlag(True)
|
||||
|
||||
super().exit(exitcode)
|
||||
else:
|
||||
self.exiting = True
|
||||
QtCore.QThreadPool.globalInstance().clear()
|
||||
|
||||
super().exit(exitcode)
|
||||
|
||||
def log(self):
|
||||
if self.logViewerWidget is not None:
|
||||
return self.logViewerWidget.log(LogType.App)
|
||||
else:
|
||||
return ''
|
||||
super().exit(exitcode)
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
if not self.checkForExistingApp():
|
||||
return self.initTray().exec()
|
||||
if self.hasRunningApp():
|
||||
# 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 ApplicationFactory.ExitCode.ExitSuccess
|
||||
|
||||
# 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
|
||||
if not SystemTrayIcon.isSystemTrayAvailable():
|
||||
raise SystemTrayUnavailable(
|
||||
'SystemTrayIcon is not available on this platform'
|
||||
)
|
||||
|
||||
self.addEnviron()
|
||||
self.addCustomFont()
|
||||
self.configureLogging()
|
||||
|
||||
logger.info(f'application version: {APPLICATION_VERSION}')
|
||||
logger.info(
|
||||
f'Qt version: {QtCore.qVersion()}. PySide6 version: {PYSIDE6_VERSION}'
|
||||
)
|
||||
logger.info(
|
||||
f'python version: {getPythonVersion()}. Platform: {PLATFORM}. '
|
||||
f'Platform release: {PLATFORM_RELEASE}'
|
||||
)
|
||||
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'isPythonw: {isPythonw()}')
|
||||
logger.info(f'system language is {SYSTEM_LANGUAGE}')
|
||||
logger.info(self.customFontLoadMsg)
|
||||
logger.info(f'current theme is {darkdetect.theme()}')
|
||||
|
||||
if PLATFORM != 'Windows' and not isScriptMode():
|
||||
logger.info('theme detect method uses timer implementation')
|
||||
|
||||
@QtCore.Slot()
|
||||
def handleTimeout():
|
||||
currentTheme = darkdetect.theme()
|
||||
|
||||
if self.currentTheme != currentTheme:
|
||||
self.currentTheme = currentTheme
|
||||
|
||||
SupportThemeChangedCallback.callThemeChangedCallback(
|
||||
currentTheme
|
||||
)
|
||||
|
||||
self.currentTheme = darkdetect.theme()
|
||||
self.themeDetectTimer = QtCore.QTimer()
|
||||
self.themeDetectTimer.timeout.connect(handleTimeout)
|
||||
self.themeDetectTimer.start(1000)
|
||||
else:
|
||||
logger.info('theme detect method uses listener implementation')
|
||||
|
||||
def listener(*args, **kwargs):
|
||||
try:
|
||||
darkdetect.listener(*args, **kwargs)
|
||||
except NotImplementedError:
|
||||
# Not supported by darkdetect. Ignore
|
||||
|
||||
logger.error(
|
||||
'darkdetect listener is not implemented on this platform'
|
||||
)
|
||||
|
||||
pass
|
||||
|
||||
self.themeDetector = ApplicationThemeDetector()
|
||||
self.themeDetector.themeChanged.connect(
|
||||
SupportThemeChangedCallback.callThemeChangedCallback
|
||||
)
|
||||
|
||||
self.themeListenerThread = threading.Thread(
|
||||
target=listener,
|
||||
args=(self.themeDetector.themeChanged.emit,),
|
||||
daemon=True,
|
||||
)
|
||||
self.themeListenerThread.start()
|
||||
|
||||
# Mandatory
|
||||
self.setQuitOnLastWindowClosed(False)
|
||||
|
||||
# Reset proxy
|
||||
SystemProxy.off()
|
||||
SystemProxy.daemonOn_()
|
||||
|
||||
self.aboutToQuit.connect(self.cleanup)
|
||||
|
||||
self.mainWindow = AppMainWindow()
|
||||
self.systemTray = SystemTrayIcon()
|
||||
|
||||
if AppSettings.isStateON_('DarkMode'):
|
||||
self.switchToDarkMode()
|
||||
|
||||
self.systemTray.show()
|
||||
self.systemTray.setCustomToolTip()
|
||||
self.systemTray.bootstrap()
|
||||
|
||||
return self.exec()
|
||||
except SystemTrayUnavailable:
|
||||
return Application.ErrorCode.PlatformNotSupported
|
||||
return ApplicationFactory.ExitCode.PlatformNotSupported
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
return Application.ErrorCode.UnknownException
|
||||
return ApplicationFactory.ExitCode.UnknownException
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
# Copyright (C) 2023 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from Furious.Gui.Action import Action, Seperator
|
||||
from Furious.Widget.Widget import ListWidget, MainWindow, Menu, MessageBox
|
||||
from Furious.Utility.Constants import APPLICATION_NAME, PLATFORM, GOLDEN_RATIO, ROOT_DIR
|
||||
from Furious.Utility.Utility import (
|
||||
StateContext,
|
||||
SupportConnectedCallback,
|
||||
SupportThemeChangedCallback,
|
||||
bootstrapIcon,
|
||||
bootstrapIconWhite,
|
||||
enumValueWrapper,
|
||||
getUbuntuRelease,
|
||||
moveToCenter,
|
||||
)
|
||||
from Furious.Utility.Translator import Translatable, gettext as _
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtGui import QFont
|
||||
from PySide6.QtWidgets import QFileDialog, QListWidget, QListWidgetItem
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import logging
|
||||
import darkdetect
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class QuestionDeleteBox(MessageBox):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.isMulti = False
|
||||
self.possibleRemark = ''
|
||||
|
||||
self.setWindowTitle(_('Delete'))
|
||||
|
||||
self.setStandardButtons(
|
||||
MessageBox.StandardButton.Yes | MessageBox.StandardButton.No
|
||||
)
|
||||
|
||||
def getText(self):
|
||||
if self.isMulti:
|
||||
return _('Delete these asset files?')
|
||||
else:
|
||||
return _('Delete this asset file?') + f'\n\n{self.possibleRemark}'
|
||||
|
||||
def retranslate(self):
|
||||
with StateContext(self):
|
||||
self.setWindowTitle(_(self.windowTitle()))
|
||||
self.setText(self.getText())
|
||||
|
||||
# Ignore informative text, buttons
|
||||
|
||||
self.moveToCenter()
|
||||
|
||||
|
||||
class AssetExistsBox(MessageBox):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.setWindowTitle(_('Import'))
|
||||
|
||||
self.setStandardButtons(
|
||||
MessageBox.StandardButton.Yes | MessageBox.StandardButton.No
|
||||
)
|
||||
|
||||
def retranslate(self):
|
||||
with StateContext(self):
|
||||
self.setWindowTitle(_(self.windowTitle()))
|
||||
self.setText(_(self.text()))
|
||||
|
||||
# Ignore informative text, buttons
|
||||
|
||||
self.moveToCenter()
|
||||
|
||||
|
||||
class ImportAction(Action):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(_('Import From File...'), **kwargs)
|
||||
|
||||
self.assetExistsBox = AssetExistsBox(
|
||||
icon=MessageBox.Icon.Question, parent=self.parent()
|
||||
)
|
||||
self.importErrorBox = MessageBox(
|
||||
icon=MessageBox.Icon.Critical, parent=self.parent()
|
||||
)
|
||||
self.importSuccessBox = MessageBox(
|
||||
icon=MessageBox.Icon.Information, parent=self.parent()
|
||||
)
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
filename, selectedFilter = QFileDialog.getOpenFileName(
|
||||
self.parent(), _('Import File'), filter=_('All files (*)')
|
||||
)
|
||||
|
||||
if filename:
|
||||
basename = os.path.basename(filename)
|
||||
|
||||
if os.path.isfile(AssetViewerWidget.AssetDir / os.path.basename(filename)):
|
||||
self.assetExistsBox.setWindowTitle(_('Import'))
|
||||
self.assetExistsBox.setText(_('Asset file already exists. Overwrite?'))
|
||||
self.assetExistsBox.setInformativeText(basename)
|
||||
|
||||
if self.assetExistsBox.exec() == enumValueWrapper(
|
||||
MessageBox.StandardButton.No
|
||||
):
|
||||
# Do not overwrite
|
||||
return
|
||||
|
||||
try:
|
||||
shutil.copy(filename, AssetViewerWidget.AssetDir)
|
||||
except shutil.SameFileError:
|
||||
# Same file imported. Do nothing
|
||||
pass
|
||||
except Exception as ex:
|
||||
# Any non-exit exception
|
||||
|
||||
self.importErrorBox.setWindowTitle(_('Import'))
|
||||
self.importErrorBox.setText(_('Error import asset file.'))
|
||||
self.importErrorBox.setInformativeText(str(ex))
|
||||
|
||||
# Show the MessageBox and wait for user to close it
|
||||
self.importErrorBox.exec()
|
||||
else:
|
||||
self.parent().flushItem()
|
||||
|
||||
self.importSuccessBox.setWindowTitle(_('Import'))
|
||||
self.importSuccessBox.setText(_('Import asset file success.'))
|
||||
|
||||
# Show the MessageBox and wait for user to close it
|
||||
self.importSuccessBox.exec()
|
||||
|
||||
|
||||
class ExitAction(Action):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(_('Exit'), **kwargs)
|
||||
|
||||
def triggeredCallback(self, checked):
|
||||
self.parent().hide()
|
||||
|
||||
|
||||
class AssetViewerWidget(SupportThemeChangedCallback, MainWindow):
|
||||
AssetDir = ROOT_DIR / APPLICATION_NAME / 'Data' / 'xray'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.setWindowTitle(_('Asset File'))
|
||||
self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg'))
|
||||
|
||||
self.questionDeleteBox = QuestionDeleteBox(
|
||||
icon=MessageBox.Icon.Question, parent=self
|
||||
)
|
||||
|
||||
self.listWidget = ListWidget(parent=self)
|
||||
self.listWidget.setSelectionBehavior(QListWidget.SelectionBehavior.SelectRows)
|
||||
self.listWidget.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection)
|
||||
self.listWidget.setIconSize(QtCore.QSize(64, 64))
|
||||
|
||||
if PLATFORM == 'Linux' and getUbuntuRelease() == '20.04':
|
||||
self.initialTheme = darkdetect.theme()
|
||||
else:
|
||||
self.initialTheme = None
|
||||
|
||||
self.flushItem()
|
||||
self.setCentralWidget(self.listWidget)
|
||||
|
||||
logger.info(f'asset dir is \'{AssetViewerWidget.AssetDir}\'')
|
||||
|
||||
contextMenuActions = [
|
||||
Action(
|
||||
_('Delete'), callback=lambda: self.deleteSelectedItem(), parent=self
|
||||
),
|
||||
]
|
||||
|
||||
self.contextMenu = Menu(*contextMenuActions)
|
||||
|
||||
self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.customContextMenuRequested.connect(self.handleCustomContextMenuRequested)
|
||||
|
||||
fileMenuActions = [
|
||||
ImportAction(parent=self),
|
||||
Seperator(),
|
||||
ExitAction(parent=self),
|
||||
]
|
||||
|
||||
for menu in (fileMenuActions,):
|
||||
for action in menu:
|
||||
if isinstance(action, Action):
|
||||
if hasattr(self, f'{action}'):
|
||||
logger.warning(f'{self} already has action {action}')
|
||||
|
||||
setattr(self, f'{action}', action)
|
||||
|
||||
self._fileMenu = Menu(*fileMenuActions, title=_('File'), parent=self)
|
||||
self.menuBar().addMenu(self._fileMenu)
|
||||
|
||||
def setWidthAndHeight(self):
|
||||
self.setGeometry(100, 100, 360, 360 * GOLDEN_RATIO)
|
||||
|
||||
@QtCore.Slot(QtCore.QPoint)
|
||||
def handleCustomContextMenuRequested(self, point):
|
||||
self.contextMenu.exec(self.mapToGlobal(point))
|
||||
|
||||
def deleteSelectedItem(self):
|
||||
indexes = self.listWidget.selectedIndex
|
||||
|
||||
if len(indexes) == 0:
|
||||
# Nothing selected
|
||||
return
|
||||
|
||||
self.questionDeleteBox.isMulti = bool(len(indexes) > 1)
|
||||
self.questionDeleteBox.possibleRemark = (
|
||||
f'{self.listWidget.item(indexes[0]).text()}'
|
||||
)
|
||||
self.questionDeleteBox.setText(self.questionDeleteBox.getText())
|
||||
|
||||
if self.questionDeleteBox.exec() == enumValueWrapper(
|
||||
MessageBox.StandardButton.No
|
||||
):
|
||||
# Do not delete
|
||||
return
|
||||
|
||||
for index in indexes:
|
||||
os.remove(AssetViewerWidget.AssetDir / self.listWidget.item(index).text())
|
||||
|
||||
self.flushItem()
|
||||
|
||||
def flushItemByTheme(self, theme):
|
||||
self.listWidget.clear()
|
||||
|
||||
for filename in os.listdir(AssetViewerWidget.AssetDir):
|
||||
if os.path.isfile(AssetViewerWidget.AssetDir / filename):
|
||||
item = QListWidgetItem(filename)
|
||||
|
||||
if theme == 'Dark':
|
||||
if PLATFORM == 'Windows':
|
||||
# Windows. Always use black icon
|
||||
item.setIcon(bootstrapIcon('file-earmark.svg'))
|
||||
else:
|
||||
item.setIcon(bootstrapIconWhite('file-earmark.svg'))
|
||||
else:
|
||||
item.setIcon(bootstrapIcon('file-earmark.svg'))
|
||||
|
||||
self.listWidget.addItem(item)
|
||||
|
||||
def flushItem(self):
|
||||
if PLATFORM == 'Linux' and getUbuntuRelease() == '20.04':
|
||||
assert self.initialTheme is not None
|
||||
|
||||
# Ubuntu 20.04. Flush by initial theme
|
||||
self.flushItemByTheme(self.initialTheme)
|
||||
else:
|
||||
self.flushItemByTheme(darkdetect.theme())
|
||||
|
||||
def themeChangedCallback(self, theme):
|
||||
if PLATFORM == 'Linux' and getUbuntuRelease() == '20.04':
|
||||
# Ubuntu 20.04 system dark theme does not
|
||||
# change menu color. Do nothing
|
||||
pass
|
||||
else:
|
||||
self.flushItemByTheme(theme)
|
||||
@@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2023 Loren Eteval <loren.eteval@proton.me>
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
@@ -15,51 +15,57 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from Furious.Utility.Constants import APPLICATION_NAME, Color
|
||||
from Furious.Utility.Utility import (
|
||||
bootstrapIcon,
|
||||
StateContext,
|
||||
SupportConnectedCallback,
|
||||
getConnectedColor,
|
||||
getConnectedWindowIcon,
|
||||
)
|
||||
from Furious.Utility.Translator import Translatable, gettext as _
|
||||
from Furious.PyFramework import *
|
||||
from Furious.QtFramework import *
|
||||
from Furious.QtFramework import gettext as _
|
||||
from Furious.Utility import *
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtWidgets import QHBoxLayout, QProgressBar, QWidget
|
||||
|
||||
__all__ = ['ConnectProgressBar']
|
||||
|
||||
class ConnectingProgressBar(Translatable, SupportConnectedCallback, QWidget):
|
||||
|
||||
class ConnectProgressBar(QTranslatable, SupportConnectedCallback, QWidget):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self.setWindowTitle(_(APPLICATION_NAME))
|
||||
self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg'))
|
||||
self.setWindowIcon(AppHue.currentWindowIcon())
|
||||
self.setFixedSize(280, 61)
|
||||
|
||||
@QtCore.Slot()
|
||||
def updateProgressBar():
|
||||
# Update the progress bar value
|
||||
if self.progressBar.value() < 90:
|
||||
self.progressBar.setValue(self.progressBar.value() + 1)
|
||||
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()
|
||||
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(self.getStyleSheet(Color.LIGHT_BLUE))
|
||||
self._progressBar = QProgressBar(self)
|
||||
self._progressBar.setRange(0, 100)
|
||||
self._progressBar.setStyleSheet(self.getStyleSheet(AppHue.disconnectedColor()))
|
||||
|
||||
# create a timer to update the progress bar
|
||||
self.timer = QtCore.QTimer(self)
|
||||
self.timer.timeout.connect(updateProgressBar)
|
||||
# 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._layout = QHBoxLayout()
|
||||
self._layout.addWidget(self._progressBar)
|
||||
|
||||
self.setLayout(self.layout)
|
||||
self.setLayout(self._layout)
|
||||
|
||||
def setValue(self, value: int):
|
||||
self._progressBar.setValue(value)
|
||||
|
||||
def start(self, msec: int):
|
||||
self._timer.start(msec)
|
||||
|
||||
def stop(self):
|
||||
self._timer.stop()
|
||||
|
||||
@staticmethod
|
||||
def getStyleSheet(color):
|
||||
@@ -81,14 +87,13 @@ class ConnectingProgressBar(Translatable, SupportConnectedCallback, QWidget):
|
||||
|
||||
self.hide()
|
||||
|
||||
def connectedCallback(self):
|
||||
self.setWindowIcon(getConnectedWindowIcon())
|
||||
self.progressBar.setStyleSheet(self.getStyleSheet(getConnectedColor()))
|
||||
|
||||
def disconnectedCallback(self):
|
||||
self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg'))
|
||||
self.progressBar.setStyleSheet(self.getStyleSheet(Color.LIGHT_BLUE))
|
||||
self.setWindowIcon(AppHue.disconnectedWindowIcon())
|
||||
self._progressBar.setStyleSheet(self.getStyleSheet(AppHue.disconnectedColor()))
|
||||
|
||||
def connectedCallback(self):
|
||||
self.setWindowIcon(AppHue.connectedWindowIcon())
|
||||
self._progressBar.setStyleSheet(self.getStyleSheet(AppHue.connectedColor()))
|
||||
|
||||
def retranslate(self):
|
||||
with StateContext(self):
|
||||
self.setWindowTitle(_(self.windowTitle()))
|
||||
self.setWindowTitle(_(self.windowTitle()))
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,469 +0,0 @@
|
||||
# Copyright (C) 2023 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from Furious.Gui.Action import Action
|
||||
from Furious.Widget.Widget import (
|
||||
Dialog,
|
||||
HeaderView,
|
||||
MainWindow,
|
||||
Menu,
|
||||
MessageBox,
|
||||
PushButton,
|
||||
StyledItemDelegate,
|
||||
TableWidget,
|
||||
TabWidget,
|
||||
)
|
||||
from Furious.Widget.EditConfiguration import QuestionDeleteBox
|
||||
from Furious.Utility.Constants import (
|
||||
APP,
|
||||
PLATFORM,
|
||||
GOLDEN_RATIO,
|
||||
Color,
|
||||
)
|
||||
from Furious.Utility.Utility import (
|
||||
ServerStorage,
|
||||
SubscriptionStorage,
|
||||
StateContext,
|
||||
SupportConnectedCallback,
|
||||
bootstrapIcon,
|
||||
enumValueWrapper,
|
||||
moveToCenter,
|
||||
getConnectedColor,
|
||||
)
|
||||
from Furious.Utility.Translator import Translatable, gettext as _
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtGui import QFont
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QFormLayout,
|
||||
QGridLayout,
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
import uuid
|
||||
import ujson
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EditSubsHorizontalHeader(HeaderView):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(QtCore.Qt.Orientation.Horizontal, *args, **kwargs)
|
||||
|
||||
self.sectionResized.connect(self.handleSectionResized)
|
||||
|
||||
@QtCore.Slot(int, int, int)
|
||||
def handleSectionResized(self, index, oldSize, newSize):
|
||||
# Keys are string when loaded from json
|
||||
self.parent().sectionSizeTable[str(index)] = newSize
|
||||
|
||||
|
||||
class EditSubsVerticalHeader(HeaderView):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(QtCore.Qt.Orientation.Vertical, *args, **kwargs)
|
||||
|
||||
|
||||
class EditSubsTableWidget(Translatable, SupportConnectedCallback, TableWidget):
|
||||
# Might be extended in the future
|
||||
HEADER_LABEL = [
|
||||
'Remark',
|
||||
'URL',
|
||||
]
|
||||
|
||||
# Corresponds to header label
|
||||
HEADER_LABEL_GET_FUNC = [
|
||||
lambda subscription: subscription['remark'],
|
||||
lambda subscription: subscription['webURL'],
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.SubscriptionWidget = kwargs.get('parent')
|
||||
|
||||
self.questionDeleteBox = QuestionDeleteBox(
|
||||
icon=MessageBox.Icon.Question, parent=self.parent()
|
||||
)
|
||||
|
||||
# Handy reference
|
||||
self.SubscriptionDict = self.SubscriptionWidget.SubscriptionDict
|
||||
|
||||
# Column count
|
||||
self.setColumnCount(len(EditSubsTableWidget.HEADER_LABEL))
|
||||
|
||||
# Delegate
|
||||
self.delegate = StyledItemDelegate(parent=self)
|
||||
self.setItemDelegate(self.delegate)
|
||||
|
||||
# Install custom header
|
||||
self.setHorizontalHeader(EditSubsHorizontalHeader(self))
|
||||
|
||||
self.initTableFromData()
|
||||
|
||||
# Horizontal header resize mode
|
||||
for index in range(self.columnCount()):
|
||||
if index < self.columnCount() - 1:
|
||||
self.horizontalHeader().setSectionResizeMode(
|
||||
index, QHeaderView.ResizeMode.Interactive
|
||||
)
|
||||
else:
|
||||
self.horizontalHeader().setSectionResizeMode(
|
||||
index, QHeaderView.ResizeMode.Stretch
|
||||
)
|
||||
|
||||
try:
|
||||
# Restore horizontal section size
|
||||
self.sectionSizeTable = ujson.loads(
|
||||
APP().SubscriptionWidgetSectionSizeTable
|
||||
)
|
||||
|
||||
# Fill missing value
|
||||
for column in range(self.columnCount()):
|
||||
if self.sectionSizeTable.get(str(column)) is None:
|
||||
self.sectionSizeTable[
|
||||
str(column)
|
||||
] = self.horizontalHeader().defaultSectionSize()
|
||||
|
||||
# Block resize callback
|
||||
self.horizontalHeader().blockSignals(True)
|
||||
|
||||
for key, value in self.sectionSizeTable.items():
|
||||
self.horizontalHeader().resizeSection(int(key), value)
|
||||
|
||||
# Unblock resize callback
|
||||
self.horizontalHeader().blockSignals(False)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
# Leave keys as strings since they will be
|
||||
# loaded as string from json
|
||||
self.sectionSizeTable = {
|
||||
str(row): self.horizontalHeader().defaultSectionSize()
|
||||
for row in range(self.columnCount())
|
||||
}
|
||||
|
||||
self.setHorizontalHeaderLabels(
|
||||
list(_(label) for label in EditSubsTableWidget.HEADER_LABEL)
|
||||
)
|
||||
|
||||
for column in range(self.horizontalHeader().count()):
|
||||
self.horizontalHeaderItem(column).setFont(QFont(APP().customFontName))
|
||||
|
||||
# Install custom header
|
||||
self.setVerticalHeader(EditSubsVerticalHeader(self))
|
||||
|
||||
# Selection
|
||||
self.setSelectionColor(Color.LIGHT_BLUE)
|
||||
self.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
||||
self.setSelectionMode(QTableWidget.SelectionMode.ExtendedSelection)
|
||||
|
||||
# No drag and drop
|
||||
self.setDragEnabled(False)
|
||||
self.setDragDropMode(QAbstractItemView.DragDropMode.NoDragDrop)
|
||||
self.setDropIndicatorShown(False)
|
||||
self.setDefaultDropAction(QtCore.Qt.DropAction.IgnoreAction)
|
||||
|
||||
contextMenuActions = [
|
||||
Action(
|
||||
_('Delete'),
|
||||
callback=lambda: self.deleteSelectedItem(),
|
||||
parent=self,
|
||||
),
|
||||
]
|
||||
|
||||
self.contextMenu = Menu(*contextMenuActions)
|
||||
self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
|
||||
# Signals
|
||||
self.customContextMenuRequested.connect(self.handleCustomContextMenuRequested)
|
||||
self.itemChanged.connect(self.handleItemChanged)
|
||||
|
||||
@QtCore.Slot(QTableWidgetItem)
|
||||
def handleItemChanged(self, item):
|
||||
unique = self.getUniqueByIndex(item.row())
|
||||
|
||||
keyMap = ['remark', 'webURL']
|
||||
|
||||
if self.SubscriptionDict[unique][keyMap[item.column()]] != item.text():
|
||||
# Modified. Update value
|
||||
self.SubscriptionDict[unique][keyMap[item.column()]] = item.text()
|
||||
|
||||
# Sync it
|
||||
SubscriptionStorage.sync()
|
||||
|
||||
@QtCore.Slot(QtCore.QPoint)
|
||||
def handleCustomContextMenuRequested(self, point):
|
||||
self.contextMenu.exec(self.mapToGlobal(point))
|
||||
|
||||
def getUniqueByIndex(self, index):
|
||||
return list(self.SubscriptionDict.keys())[index]
|
||||
|
||||
def initTableFromData(self):
|
||||
for key, value in self.SubscriptionDict.items():
|
||||
self.appendDataByColumn(
|
||||
lambda column: EditSubsTableWidget.HEADER_LABEL_GET_FUNC[column](value)
|
||||
)
|
||||
|
||||
def deleteSelectedItem(self):
|
||||
indexes = self.selectedIndex
|
||||
|
||||
if len(indexes) == 0:
|
||||
# Nothing to do
|
||||
return
|
||||
|
||||
if APP().ServerWidget is not None and APP().ServerWidget.modified:
|
||||
APP().ServerWidget.saveChangeFirst.exec()
|
||||
|
||||
return
|
||||
|
||||
self.questionDeleteBox.isMulti = bool(len(indexes) > 1)
|
||||
self.questionDeleteBox.possibleRemark = self.item(indexes[0], 0).text()
|
||||
self.questionDeleteBox.setText(self.questionDeleteBox.getText())
|
||||
|
||||
if self.questionDeleteBox.exec() == enumValueWrapper(
|
||||
MessageBox.StandardButton.No
|
||||
):
|
||||
# Do not delete
|
||||
return
|
||||
|
||||
deleted = 0
|
||||
|
||||
for i in range(len(indexes)):
|
||||
takedRow = indexes[i] - i
|
||||
takedUnique = self.getUniqueByIndex(takedRow)
|
||||
|
||||
self.removeRow(takedRow)
|
||||
self.SubscriptionDict.pop(takedUnique)
|
||||
|
||||
deleted += APP().ServerWidget.deleteItemByUnique(
|
||||
takedUnique, syncStorage=False
|
||||
)
|
||||
|
||||
if deleted:
|
||||
# At least on server has been deleted. Sync it
|
||||
ServerStorage.sync()
|
||||
|
||||
# Sync it
|
||||
SubscriptionStorage.sync()
|
||||
|
||||
def appendDataByColumn(self, func):
|
||||
row = self.rowCount()
|
||||
|
||||
self.insertRow(row)
|
||||
|
||||
for column in range(self.columnCount()):
|
||||
item = QTableWidgetItem(func(column))
|
||||
|
||||
item.setFont(QFont(APP().customFontName))
|
||||
item.setFlags(
|
||||
QtCore.Qt.ItemFlag.ItemIsEnabled
|
||||
| QtCore.Qt.ItemFlag.ItemIsSelectable
|
||||
| QtCore.Qt.ItemFlag.ItemIsEditable
|
||||
)
|
||||
|
||||
self.setItem(row, column, item)
|
||||
|
||||
def connectedCallback(self):
|
||||
self.setSelectionColor(getConnectedColor())
|
||||
|
||||
def disconnectedCallback(self):
|
||||
self.setSelectionColor(Color.LIGHT_BLUE)
|
||||
|
||||
def retranslate(self):
|
||||
self.setHorizontalHeaderLabels(
|
||||
list(_(label) for label in EditSubsTableWidget.HEADER_LABEL)
|
||||
)
|
||||
|
||||
|
||||
class AddSubsDialog(Dialog):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self.setWindowTitle(_('Add subscription'))
|
||||
self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg'))
|
||||
|
||||
self.remarkText = QLabel(_('Enter subscription remark:'))
|
||||
self.remarkEdit = QLineEdit()
|
||||
|
||||
self.webURLText = QLabel(_('Enter subscription URL:'))
|
||||
self.webURLEdit = QLineEdit()
|
||||
|
||||
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.remarkText)
|
||||
layout.addRow(self.remarkEdit)
|
||||
layout.addRow(self.webURLText)
|
||||
layout.addRow(self.webURLEdit)
|
||||
layout.addRow(self.dialogBtns)
|
||||
layout.setFormAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def setWidthAndHeight(self):
|
||||
self.setGeometry(100, 100, 456, 150)
|
||||
|
||||
def subscriptionRemark(self):
|
||||
return self.remarkEdit.text()
|
||||
|
||||
def subscriptionWebURL(self):
|
||||
return self.webURLEdit.text()
|
||||
|
||||
def retranslate(self):
|
||||
with StateContext(self):
|
||||
self.setWindowTitle(_(self.windowTitle()))
|
||||
|
||||
self.remarkText.setText(_(self.remarkText.text()))
|
||||
self.webURLText.setText(_(self.webURLText.text()))
|
||||
|
||||
for button in self.dialogBtns.buttons():
|
||||
button.setText(_(button.text()))
|
||||
|
||||
moveToCenter(self)
|
||||
|
||||
|
||||
class EditSubscriptionWidget(MainWindow):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.setWindowTitle(_('Edit Subscription'))
|
||||
self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg'))
|
||||
|
||||
self.addSubsDialog = AddSubsDialog()
|
||||
|
||||
try:
|
||||
self.StorageObj = SubscriptionStorage.toObject(APP().CustomSubscription)
|
||||
# Shallow copy
|
||||
self.SubscriptionDict = self.StorageObj
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
self.StorageObj = SubscriptionStorage.init()
|
||||
# Shallow copy
|
||||
self.SubscriptionDict = self.StorageObj
|
||||
|
||||
# Clear it
|
||||
SubscriptionStorage.clear()
|
||||
|
||||
self.editSubsTableWidget = EditSubsTableWidget(parent=self)
|
||||
|
||||
self.editorTab = TabWidget(self)
|
||||
self.editorTab.addTab(self.editSubsTableWidget, _('Subscription List'))
|
||||
|
||||
# Buttons
|
||||
self.addButton = PushButton(_('Add'))
|
||||
self.addButton.clicked.connect(lambda: self.addSubscription())
|
||||
self.deleteButton = PushButton(_('Delete'))
|
||||
self.deleteButton.clicked.connect(lambda: self.deleteSelectedItem())
|
||||
|
||||
# Button Layout
|
||||
self.buttonWidget = QWidget()
|
||||
self.buttonWidgetLayout = QGridLayout(parent=self.buttonWidget)
|
||||
self.buttonWidgetLayout.addWidget(self.addButton, 0, 0)
|
||||
self.buttonWidgetLayout.addWidget(self.deleteButton, 0, 1)
|
||||
|
||||
self.fakeCentralWidget = QWidget()
|
||||
self.fakeCentralWidgetLayout = QVBoxLayout(self.fakeCentralWidget)
|
||||
self.fakeCentralWidgetLayout.addWidget(self.editorTab)
|
||||
self.fakeCentralWidgetLayout.addWidget(self.buttonWidget)
|
||||
|
||||
self.setCentralWidget(self.fakeCentralWidget)
|
||||
|
||||
def setWidthAndHeight(self):
|
||||
try:
|
||||
self.setGeometry(
|
||||
100,
|
||||
100,
|
||||
*list(
|
||||
int(size) for size in APP().SubscriptionWidgetWindowSize.split(',')
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
self.setGeometry(100, 100, 360 * GOLDEN_RATIO, 360)
|
||||
|
||||
def addSubscription(self):
|
||||
choice = self.addSubsDialog.exec()
|
||||
|
||||
if choice == enumValueWrapper(QDialog.DialogCode.Accepted):
|
||||
subscriptionRemark = self.addSubsDialog.subscriptionRemark()
|
||||
subscriptionWebURL = self.addSubsDialog.subscriptionWebURL()
|
||||
|
||||
if subscriptionRemark:
|
||||
# Unique id. Used by display and deletion
|
||||
unique = str(uuid.uuid4())
|
||||
|
||||
subscription = {
|
||||
unique: {
|
||||
'remark': subscriptionRemark,
|
||||
'webURL': subscriptionWebURL,
|
||||
}
|
||||
}
|
||||
|
||||
self.SubscriptionDict.update(subscription)
|
||||
|
||||
self.appendDataByColumn(
|
||||
lambda column: EditSubsTableWidget.HEADER_LABEL_GET_FUNC[column](
|
||||
subscription[unique]
|
||||
)
|
||||
)
|
||||
|
||||
# Sync it
|
||||
SubscriptionStorage.sync()
|
||||
else:
|
||||
# Do nothing
|
||||
pass
|
||||
|
||||
def appendDataByColumn(self, func):
|
||||
self.editSubsTableWidget.appendDataByColumn(func)
|
||||
|
||||
def deleteSelectedItem(self):
|
||||
self.editSubsTableWidget.deleteSelectedItem()
|
||||
|
||||
def syncSettings(self):
|
||||
APP().SubscriptionWidgetWindowSize = (
|
||||
f'{self.geometry().width()},{self.geometry().height()}'
|
||||
)
|
||||
|
||||
APP().SubscriptionWidgetSectionSizeTable = ujson.dumps(
|
||||
self.editSubsTableWidget.sectionSizeTable,
|
||||
ensure_ascii=False,
|
||||
escape_forward_slashes=False,
|
||||
)
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
if event.key() == QtCore.Qt.Key.Key_Delete:
|
||||
self.deleteSelectedItem()
|
||||
else:
|
||||
super().keyPressEvent(event)
|
||||
@@ -1,68 +0,0 @@
|
||||
# Copyright (C) 2023 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from Furious.Widget.Widget import MainWindow
|
||||
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, QTabWidget
|
||||
|
||||
import io
|
||||
import pyqrcode
|
||||
|
||||
|
||||
class ExportQRCode(MainWindow):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self.setWindowTitle(_(APPLICATION_NAME))
|
||||
self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg'))
|
||||
self.setFixedSize(640, 640)
|
||||
|
||||
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()
|
||||
@@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2023 Loren Eteval <loren.eteval@proton.me>
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
@@ -15,29 +15,32 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from Furious.Widget.Widget import Dialog
|
||||
from Furious.Utility.Utility import (
|
||||
StateContext,
|
||||
SupportConnectedCallback,
|
||||
bootstrapIcon,
|
||||
)
|
||||
from Furious.Utility.Translator import Translatable, gettext as _
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtWidgets import (
|
||||
QDialogButtonBox,
|
||||
QFormLayout,
|
||||
QLabel,
|
||||
QSpinBox,
|
||||
from Furious.QtFramework import *
|
||||
from Furious.QtFramework import gettext as _
|
||||
from Furious.Utility import *
|
||||
|
||||
from PySide6.QtWidgets import *
|
||||
|
||||
import functools
|
||||
|
||||
__all__ = ['IndentSpinBox']
|
||||
|
||||
needTrans = functools.partial(needTransFn, source=__name__)
|
||||
|
||||
needTrans(
|
||||
'Set Indent',
|
||||
'Indent:',
|
||||
'Cancel',
|
||||
)
|
||||
|
||||
|
||||
class IndentSpinBox(Dialog):
|
||||
class IndentSpinBox(AppQDialog):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self.setWindowTitle(_('Set Indent'))
|
||||
self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg'))
|
||||
|
||||
self.indentText = QLabel(_('Indent:'))
|
||||
|
||||
@@ -64,10 +67,9 @@ class IndentSpinBox(Dialog):
|
||||
return self.indentSpin.value()
|
||||
|
||||
def retranslate(self):
|
||||
with StateContext(self):
|
||||
self.setWindowTitle(_(self.windowTitle()))
|
||||
self.setWindowTitle(_(self.windowTitle()))
|
||||
|
||||
self.indentText.setText(_(self.indentText.text()))
|
||||
self.indentText.setText(_(self.indentText.text()))
|
||||
|
||||
for button in self.dialogBtns.buttons():
|
||||
button.setText(_(button.text()))
|
||||
for button in self.dialogBtns.buttons():
|
||||
button.setText(_(button.text()))
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
# Copyright (C) 2023 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from Furious.Gui.Action import Action, Seperator
|
||||
from Furious.Widget.Widget import (
|
||||
MainWindow,
|
||||
Menu,
|
||||
MessageBox,
|
||||
TabWidget,
|
||||
ZoomableTextBrowser,
|
||||
)
|
||||
from Furious.Utility.Constants import APP, APPLICATION_NAME, LogType
|
||||
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 QFileDialog, QTextBrowser, QVBoxLayout, QWidget
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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().currentTextBrowser().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().currentTextBrowser().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().currentTextBrowser().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().currentTextBrowser().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().currentTextBrowser().zoomOut()
|
||||
|
||||
|
||||
class AppLogViewerWidget(QWidget):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.textBrowser = ZoomableTextBrowser(parent=self)
|
||||
self.textBrowser.setLineWrapMode(QTextBrowser.LineWrapMode.NoWrap)
|
||||
self.textBrowser.setStyleSheet(
|
||||
DraculaTheme.getStyleSheet(
|
||||
widgetName='QTextBrowser',
|
||||
fontFamily=APP().customFontName,
|
||||
)
|
||||
)
|
||||
|
||||
self.restorePointSize()
|
||||
|
||||
self.widgetLayout = QVBoxLayout()
|
||||
self.widgetLayout.addWidget(self.textBrowser)
|
||||
|
||||
self.setLayout(self.widgetLayout)
|
||||
|
||||
def pointSizeSetting(self):
|
||||
return f'{self.__class__.__name__}PointSize'
|
||||
|
||||
def restorePointSize(self):
|
||||
try:
|
||||
# Restore point size
|
||||
font = self.textBrowser.font()
|
||||
font.setPointSize(int(getattr(APP(), self.pointSizeSetting())))
|
||||
|
||||
self.textBrowser.setFont(font)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
pass
|
||||
|
||||
def syncSettings(self):
|
||||
setattr(
|
||||
APP(), self.pointSizeSetting(), str(self.textBrowser.font().pointSize())
|
||||
)
|
||||
|
||||
|
||||
class CoreLogViewerWidget(AppLogViewerWidget):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class TorLogViewerWidget(AppLogViewerWidget):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class LogViewerWidget(MainWindow):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.setWindowTitle(_('Log Viewer'))
|
||||
self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg'))
|
||||
|
||||
self.coreLogViewerWidget = CoreLogViewerWidget()
|
||||
self.appLogViewerWidget = AppLogViewerWidget()
|
||||
self.torLogViewerWidget = TorLogViewerWidget()
|
||||
|
||||
self.tabWidget = TabWidget()
|
||||
self.tabWidget.addTab(self.coreLogViewerWidget, _('Core Log'))
|
||||
self.tabWidget.addTab(self.appLogViewerWidget, _(f'{APPLICATION_NAME} Log'))
|
||||
self.tabWidget.addTab(self.torLogViewerWidget, _('Tor Log'))
|
||||
|
||||
self.textBrowserMap = {
|
||||
LogType.Core: self.coreLogViewerWidget.textBrowser,
|
||||
LogType.App: self.appLogViewerWidget.textBrowser,
|
||||
LogType.Tor: self.torLogViewerWidget.textBrowser,
|
||||
}
|
||||
|
||||
self.setCentralWidget(self.tabWidget)
|
||||
|
||||
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):
|
||||
if hasattr(self, f'{action}'):
|
||||
logger.warning(f'{self} already has 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 currentTextBrowser(self):
|
||||
return self.tabWidget.currentWidget().textBrowser
|
||||
|
||||
def textBrowser(self, logType):
|
||||
return self.textBrowserMap[logType]
|
||||
|
||||
def log(self, logType):
|
||||
return self.textBrowser(logType).toPlainText()
|
||||
|
||||
def appendLog(self, logType, line):
|
||||
textBrowser = self.textBrowser(logType)
|
||||
|
||||
hScrollBar = textBrowser.horizontalScrollBar()
|
||||
vScrollBar = textBrowser.verticalScrollBar()
|
||||
scrollEnds = vScrollBar.maximum() - vScrollBar.value() <= 10
|
||||
|
||||
# Fix insertPlainText bug if user cursor is present
|
||||
textBrowser.append(line.rstrip())
|
||||
|
||||
if scrollEnds:
|
||||
vScrollBar.setValue(vScrollBar.maximum()) # Scrolls to the bottom
|
||||
hScrollBar.setValue(0) # scroll to the left
|
||||
|
||||
def clear(self, logType):
|
||||
self.textBrowser(logType).clear()
|
||||
|
||||
def syncSettings(self):
|
||||
for index in range(self.tabWidget.count()):
|
||||
self.tabWidget.widget(index).syncSettings()
|
||||
@@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2023 Loren Eteval <loren.eteval@proton.me>
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
@@ -15,33 +15,11 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from Furious.Action import (
|
||||
ConnectAction,
|
||||
RoutingAction,
|
||||
ImportAction,
|
||||
EditConfigurationAction,
|
||||
LanguageAction,
|
||||
SettingsAction,
|
||||
ExitAction,
|
||||
)
|
||||
from Furious.Gui.Action import Action, Seperator
|
||||
from Furious.Widget.Widget import Menu
|
||||
from Furious.Utility.Constants import (
|
||||
APP,
|
||||
APPLICATION_NAME,
|
||||
APPLICATION_VERSION,
|
||||
PLATFORM,
|
||||
)
|
||||
from Furious.Utility.Utility import (
|
||||
bootstrapIcon,
|
||||
StateContext,
|
||||
Switch,
|
||||
isAdministrator,
|
||||
isWindows7,
|
||||
)
|
||||
from Furious.Utility.Translator import Translatable, gettext as _
|
||||
from Furious.Utility.Proxy import Proxy
|
||||
from Furious.Utility.StartupOnBoot import StartupOnBoot
|
||||
from Furious.PyFramework import *
|
||||
from Furious.QtFramework import *
|
||||
from Furious.QtFramework import gettext as _
|
||||
from Furious.Utility import *
|
||||
from Furious.TrayActions import *
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtWidgets import QSystemTrayIcon
|
||||
@@ -50,49 +28,49 @@ import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ['SystemTrayIcon']
|
||||
|
||||
class SystemTrayIcon(Translatable, QSystemTrayIcon):
|
||||
|
||||
class SystemTrayIcon(QTranslatable, SupportConnectedCallback, QSystemTrayIcon):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.setPlainIcon()
|
||||
self.activated.connect(self.handleActivated)
|
||||
|
||||
actions = [
|
||||
ConnectAction(),
|
||||
RoutingAction(),
|
||||
Seperator(),
|
||||
SystemProxyAction(),
|
||||
AppQSeperator(),
|
||||
ImportAction(),
|
||||
EditConfigurationAction(),
|
||||
Seperator(),
|
||||
AppQSeperator(),
|
||||
LanguageAction(),
|
||||
SettingsAction(),
|
||||
Seperator(),
|
||||
AppQSeperator(),
|
||||
ExitAction(),
|
||||
]
|
||||
|
||||
# Some old version PySide6 does not have setMenu method
|
||||
# for QAction. Protect it. Currently only used in SystemTrayIcon
|
||||
if hasattr(Action, 'setMenu'):
|
||||
if hasattr(AppQAction, 'setMenu'):
|
||||
logger.info('contextMenu uses setMenu implementation')
|
||||
|
||||
for action in actions:
|
||||
if isinstance(action, Action):
|
||||
if isinstance(action, AppQAction):
|
||||
if hasattr(self, f'{action}'):
|
||||
logger.warning(f'{self} already has action {action}')
|
||||
|
||||
setattr(self, f'{action}', action)
|
||||
|
||||
self._menu = Menu(*actions)
|
||||
self._menu = AppQMenu(*actions)
|
||||
self.setContextMenu(self._menu)
|
||||
else:
|
||||
logger.info('contextMenu uses addMenu implementation')
|
||||
|
||||
self._refs = []
|
||||
self._menu = Menu()
|
||||
self._menu = AppQMenu()
|
||||
|
||||
for action in actions:
|
||||
if isinstance(action, Action):
|
||||
if isinstance(action, AppQAction):
|
||||
if hasattr(self, f'{action}'):
|
||||
logger.warning(f'{self} already has action {action}')
|
||||
|
||||
@@ -101,7 +79,7 @@ class SystemTrayIcon(Translatable, QSystemTrayIcon):
|
||||
if action._menu is None:
|
||||
self._menu.addAction(action)
|
||||
else:
|
||||
menu = Menu(*action._menu._actions, title=action.text())
|
||||
menu = AppQMenu(*action._menu._actions, title=action.text())
|
||||
menu.setIcon(action.icon())
|
||||
|
||||
self._refs.append(menu)
|
||||
@@ -111,20 +89,22 @@ class SystemTrayIcon(Translatable, QSystemTrayIcon):
|
||||
|
||||
self.setContextMenu(self._menu)
|
||||
|
||||
self.setDisconnectedIcon()
|
||||
self.activated.connect(self.handleActivated)
|
||||
|
||||
def bootstrap(self):
|
||||
if APP().StartupOnBoot == Switch.ON_:
|
||||
if AppSettings.isStateON_('StartupOnBoot'):
|
||||
# Rrefresh startup application location
|
||||
StartupOnBoot.on_()
|
||||
|
||||
if APP().Connect == Switch.ON_:
|
||||
# Trigger connect action
|
||||
if AppSettings.isStateON_('Connect'):
|
||||
self.ConnectAction.trigger()
|
||||
|
||||
def showMessage(self, msg, *args, **kwargs):
|
||||
if msg:
|
||||
super().showMessage(_(APPLICATION_NAME), msg, *args, **kwargs)
|
||||
def showMessage(self, message: str, *args, **kwargs):
|
||||
if message:
|
||||
super().showMessage(_(APPLICATION_NAME), message, *args, **kwargs)
|
||||
|
||||
def setPlainIcon(self):
|
||||
def setDisconnectedIcon(self):
|
||||
if PLATFORM == 'Darwin' or isWindows7():
|
||||
# Darker
|
||||
self.setIcon(bootstrapIcon('rocket-takeoff-dark.svg'))
|
||||
@@ -144,11 +124,17 @@ class SystemTrayIcon(Translatable, QSystemTrayIcon):
|
||||
@QtCore.Slot(QSystemTrayIcon.ActivationReason)
|
||||
def handleActivated(self, reason):
|
||||
if reason == QSystemTrayIcon.ActivationReason.DoubleClick:
|
||||
APP().ServerWidget.show()
|
||||
APP().mainWindow.show()
|
||||
|
||||
def setApplicationToolTip(self):
|
||||
def setCustomToolTip(self):
|
||||
self.setToolTip(f'{_(APPLICATION_NAME)} {APPLICATION_VERSION}')
|
||||
|
||||
def disconnectedCallback(self):
|
||||
self.setDisconnectedIcon()
|
||||
|
||||
def connectedCallback(self):
|
||||
self.setConnectedIcon()
|
||||
|
||||
def retranslate(self):
|
||||
# Nothing to do
|
||||
pass
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
# Copyright (C) 2023 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from Furious.Widget.Widget import Dialog, GroupBox, Label
|
||||
from Furious.Utility.Constants import (
|
||||
APP,
|
||||
DEFAULT_TOR_SOCKS_PORT,
|
||||
DEFAULT_TOR_HTTPS_PORT,
|
||||
DEFAULT_TOR_RELAY_ESTABLISH_TIMEOUT,
|
||||
)
|
||||
from Furious.Utility.Utility import (
|
||||
StateContext,
|
||||
SupportConnectedCallback,
|
||||
TorRelaySettingsStorage,
|
||||
bootstrapIcon,
|
||||
moveToCenter,
|
||||
)
|
||||
from Furious.Utility.Translator import Translatable, gettext as _
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialogButtonBox,
|
||||
QFormLayout,
|
||||
QGridLayout,
|
||||
QSpinBox,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
|
||||
class TorRelaySettingsWidget(Dialog):
|
||||
TOR_LOG_LEVEL = ['err', 'warn', 'notice', 'info', 'debug']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.setWindowTitle(_('Tor Relay Settings'))
|
||||
self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg'))
|
||||
|
||||
try:
|
||||
self.StorageObj = TorRelaySettingsStorage.toObject(APP().TorRelaySettings)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
self.StorageObj = TorRelaySettingsStorage.init()
|
||||
|
||||
# Clear it
|
||||
TorRelaySettingsStorage.clear()
|
||||
|
||||
self.endpointGroupBox = GroupBox(_('Tunnel Port'), parent=self)
|
||||
self.useProxyGroupBox = GroupBox(_('Proxy'), parent=self)
|
||||
self.torOtherGroupBox = GroupBox(_('Other'), parent=self)
|
||||
|
||||
# Begin endpoint groupbox
|
||||
self.socksTunnelLabel = Label(_('socks'), parent=self)
|
||||
self.httpsTunnelLabel = Label(_('http'), parent=self)
|
||||
|
||||
self.socksTunnelSpinBox = QSpinBox(parent=self)
|
||||
self.socksTunnelSpinBox.setMinimum(0)
|
||||
self.socksTunnelSpinBox.setMaximum(65535)
|
||||
|
||||
self.httpsTunnelSpinBox = QSpinBox(parent=self)
|
||||
self.httpsTunnelSpinBox.setMinimum(0)
|
||||
self.httpsTunnelSpinBox.setMaximum(65535)
|
||||
|
||||
self.socksTunnelWidget = QWidget(parent=self)
|
||||
self.socksTunnelLayout = QFormLayout(parent=self.socksTunnelWidget)
|
||||
self.socksTunnelLayout.addRow(self.socksTunnelLabel, self.socksTunnelSpinBox)
|
||||
self.socksTunnelWidget.setLayout(self.socksTunnelLayout)
|
||||
|
||||
self.httpsTunnelWidget = QWidget(parent=self)
|
||||
self.httpsTunnelLayout = QFormLayout(parent=self.httpsTunnelWidget)
|
||||
self.httpsTunnelLayout.addRow(self.httpsTunnelLabel, self.httpsTunnelSpinBox)
|
||||
self.httpsTunnelWidget.setLayout(self.httpsTunnelLayout)
|
||||
|
||||
self.endpointGroupBoxLayout = QGridLayout(parent=self.endpointGroupBox)
|
||||
self.endpointGroupBoxLayout.addWidget(self.socksTunnelWidget, 0, 0)
|
||||
self.endpointGroupBoxLayout.addWidget(self.httpsTunnelWidget, 0, 1)
|
||||
|
||||
self.endpointGroupBox.setLayout(self.endpointGroupBoxLayout)
|
||||
# End endpoint groupbox
|
||||
|
||||
# Begin useProxy groupbox
|
||||
self.useProxyLabel = Label(_('Use Proxy'), parent=self)
|
||||
|
||||
self.useProxyCheckBox = QCheckBox(parent=self)
|
||||
|
||||
self.useProxyWidget = QWidget(parent=self)
|
||||
self.useProxyLayout = QFormLayout(parent=self.useProxyWidget)
|
||||
self.useProxyLayout.addRow(self.useProxyLabel, self.useProxyCheckBox)
|
||||
self.useProxyWidget.setLayout(self.useProxyLayout)
|
||||
|
||||
self.useProxyGroupBoxLayout = QGridLayout(parent=self.useProxyGroupBox)
|
||||
self.useProxyGroupBoxLayout.addWidget(self.useProxyWidget, 0, 0)
|
||||
|
||||
self.useProxyGroupBox.setLayout(self.useProxyGroupBoxLayout)
|
||||
# End useProxy groupbox
|
||||
|
||||
# Begin torOther groupbox
|
||||
self.torLogLevelLabel = Label(_('Log Level'), parent=self)
|
||||
|
||||
self.torLogLevelComboBox = QComboBox(parent=self)
|
||||
self.torLogLevelComboBox.addItems(TorRelaySettingsWidget.TOR_LOG_LEVEL)
|
||||
|
||||
self.torRelayTimeoutLabel = Label(
|
||||
_('Relay Establish Timeout (seconds)'), parent=self
|
||||
)
|
||||
|
||||
self.torRelayTimeoutSpinBox = QSpinBox(parent=self)
|
||||
self.torRelayTimeoutSpinBox.setMinimum(0)
|
||||
self.torRelayTimeoutSpinBox.setMaximum(3600)
|
||||
|
||||
self.torLogLevelWidget = QWidget(parent=self)
|
||||
self.torLogLevelLayout = QFormLayout(parent=self.torLogLevelWidget)
|
||||
self.torLogLevelLayout.addRow(self.torLogLevelLabel, self.torLogLevelComboBox)
|
||||
self.torLogLevelWidget.setLayout(self.torLogLevelLayout)
|
||||
|
||||
self.torRelayTimeoutWidget = QWidget(parent=self)
|
||||
self.torRelayTimeoutLayout = QFormLayout(parent=self.torRelayTimeoutWidget)
|
||||
self.torRelayTimeoutLayout.addRow(
|
||||
self.torRelayTimeoutLabel, self.torRelayTimeoutSpinBox
|
||||
)
|
||||
self.torRelayTimeoutWidget.setLayout(self.torRelayTimeoutLayout)
|
||||
|
||||
self.torOtherGroupBoxLayout = QGridLayout(parent=self.torOtherGroupBox)
|
||||
self.torOtherGroupBoxLayout.addWidget(self.torLogLevelWidget, 0, 0)
|
||||
self.torOtherGroupBoxLayout.addWidget(self.torRelayTimeoutWidget, 0, 1)
|
||||
|
||||
self.torOtherGroupBox.setLayout(self.torOtherGroupBoxLayout)
|
||||
# End torOther groupbox
|
||||
|
||||
# Restore value
|
||||
self.restoreValueFromObject()
|
||||
|
||||
# Dialog buttons
|
||||
self.dialogBtns = QDialogButtonBox(
|
||||
QtCore.Qt.Orientation.Horizontal, parent=self
|
||||
)
|
||||
|
||||
self.dialogBtns.addButton(_('OK'), QDialogButtonBox.ButtonRole.AcceptRole)
|
||||
self.dialogBtns.addButton(_('Cancel'), QDialogButtonBox.ButtonRole.RejectRole)
|
||||
self.dialogBtns.accepted.connect(self.handleAccepted)
|
||||
self.dialogBtns.rejected.connect(self.handleRejected)
|
||||
|
||||
# Central Widget
|
||||
self.fakeCentralWidget = QWidget(parent=self)
|
||||
|
||||
self.layout = QGridLayout(parent=self.fakeCentralWidget)
|
||||
self.layout.addWidget(self.endpointGroupBox, 0, 0)
|
||||
self.layout.addWidget(self.useProxyGroupBox, 1, 0)
|
||||
self.layout.addWidget(self.torOtherGroupBox, 2, 0)
|
||||
self.layout.addWidget(self.dialogBtns, 3, 0)
|
||||
|
||||
self.setLayout(self.layout)
|
||||
|
||||
@QtCore.Slot()
|
||||
def handleAccepted(self):
|
||||
# Sync object
|
||||
self.StorageObj['socksTunnelPort'] = self.socksTunnelSpinBox.value()
|
||||
self.StorageObj['httpsTunnelPort'] = self.httpsTunnelSpinBox.value()
|
||||
self.StorageObj['useProxy'] = self.useProxyCheckBox.isChecked()
|
||||
self.StorageObj['logLevel'] = self.torLogLevelComboBox.currentText()
|
||||
self.StorageObj['relayEstablishTimeout'] = self.torRelayTimeoutSpinBox.value()
|
||||
|
||||
# Sync it
|
||||
TorRelaySettingsStorage.sync()
|
||||
|
||||
self.hide()
|
||||
|
||||
@QtCore.Slot()
|
||||
def handleRejected(self):
|
||||
self.restoreValueFromObject()
|
||||
self.hide()
|
||||
|
||||
def restoreValueFromObject(self):
|
||||
# Restore value
|
||||
self.socksTunnelSpinBox.setValue(
|
||||
self.StorageObj.get('socksTunnelPort', DEFAULT_TOR_SOCKS_PORT)
|
||||
)
|
||||
self.httpsTunnelSpinBox.setValue(
|
||||
self.StorageObj.get('httpsTunnelPort', DEFAULT_TOR_HTTPS_PORT)
|
||||
)
|
||||
|
||||
self.useProxyCheckBox.setChecked(self.StorageObj.get('useProxy', True))
|
||||
|
||||
self.torLogLevelComboBox.setCurrentText(
|
||||
self.StorageObj.get('logLevel', 'notice')
|
||||
)
|
||||
self.torRelayTimeoutSpinBox.setValue(
|
||||
self.StorageObj.get(
|
||||
'relayEstablishTimeout', DEFAULT_TOR_RELAY_ESTABLISH_TIMEOUT
|
||||
)
|
||||
)
|
||||
|
||||
def closeEvent(self, event):
|
||||
event.ignore()
|
||||
|
||||
self.restoreValueFromObject()
|
||||
self.hide()
|
||||
|
||||
def retranslate(self):
|
||||
with StateContext(self):
|
||||
self.setWindowTitle(_(self.windowTitle()))
|
||||
|
||||
for button in self.dialogBtns.buttons():
|
||||
button.setText(_(button.text()))
|
||||
|
||||
moveToCenter(self)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,236 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.Interface import *
|
||||
from Furious.PyFramework import *
|
||||
from Furious.QtFramework import *
|
||||
from Furious.QtFramework import gettext as _
|
||||
from Furious.Utility import *
|
||||
from Furious.Library import *
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtGui import *
|
||||
from PySide6.QtWidgets import *
|
||||
|
||||
from typing import Callable
|
||||
|
||||
import functools
|
||||
|
||||
__all__ = ['UserSubsQTableWidget']
|
||||
|
||||
registerAppSettings('SubscriptionWidgetSectionSizeTable')
|
||||
|
||||
needTrans = functools.partial(needTransFn, source=__name__)
|
||||
|
||||
|
||||
class UserSubsQTableWidgetHorizontalHeader(AppQHeaderView):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(QtCore.Qt.Orientation.Horizontal, *args, **kwargs)
|
||||
|
||||
|
||||
class UserSubsQTableWidgetVerticalHeader(AppQHeaderView):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(QtCore.Qt.Orientation.Vertical, *args, **kwargs)
|
||||
|
||||
|
||||
class UserSubsQTableWidgetHeaders:
|
||||
def __init__(self, name: str, func: Callable[[dict], str] = None):
|
||||
self.name = name
|
||||
self.func = func
|
||||
|
||||
def __call__(self, item: dict) -> str:
|
||||
if callable(self.func):
|
||||
return self.func(item)
|
||||
else:
|
||||
return ''
|
||||
|
||||
def __eq__(self, other):
|
||||
return str(self) == str(other)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
needTrans(
|
||||
'Remark',
|
||||
'Delete',
|
||||
)
|
||||
|
||||
|
||||
class UserSubsQTableWidget(QTranslatable, AppQTableWidget):
|
||||
Headers = [
|
||||
UserSubsQTableWidgetHeaders('Remark', lambda item: item.get('remark', '')),
|
||||
UserSubsQTableWidgetHeaders('URL', lambda item: item.get('webURL', '')),
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.deleteUniqueCallback = kwargs.pop('deleteUniqueCallback', None)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Delegate
|
||||
self._delegate = AppQStyledItemDelegate(parent=self)
|
||||
self.setItemDelegate(self._delegate)
|
||||
|
||||
# Must set before flush all
|
||||
self.setColumnCount(len(self.Headers))
|
||||
|
||||
# Flush all data to table
|
||||
self.flushAll()
|
||||
|
||||
# Install custom header
|
||||
self.setHorizontalHeader(
|
||||
UserSubsQTableWidgetHorizontalHeader(
|
||||
parent=self,
|
||||
sectionSizeSettingsName='SubscriptionWidgetSectionSizeTable',
|
||||
)
|
||||
)
|
||||
self.setVerticalHeader(UserSubsQTableWidgetVerticalHeader(self))
|
||||
|
||||
self.horizontalHeader().setCustomSectionResizeMode()
|
||||
self.horizontalHeader().restoreSectionSize()
|
||||
|
||||
self.setHorizontalHeaderLabels(list(_(str(header)) for header in self.Headers))
|
||||
|
||||
# Selection
|
||||
self.setSelectionColor(AppHue.disconnectedColor())
|
||||
self.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
||||
self.setSelectionMode(QTableWidget.SelectionMode.ExtendedSelection)
|
||||
|
||||
# No drag and drop
|
||||
self.setDragEnabled(False)
|
||||
self.setDragDropMode(QAbstractItemView.DragDropMode.NoDragDrop)
|
||||
self.setDropIndicatorShown(False)
|
||||
self.setDefaultDropAction(QtCore.Qt.DropAction.IgnoreAction)
|
||||
|
||||
contextMenuActions = [
|
||||
AppQAction(
|
||||
_('Delete'),
|
||||
callback=lambda: self.deleteSelectedItem(),
|
||||
),
|
||||
]
|
||||
|
||||
self.contextMenu = AppQMenu(*contextMenuActions)
|
||||
self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
|
||||
# Signals
|
||||
self.itemChanged.connect(self.handleItemChanged)
|
||||
self.customContextMenuRequested.connect(self.handleCustomContextMenuRequested)
|
||||
|
||||
@QtCore.Slot(QTableWidgetItem)
|
||||
def handleItemChanged(self, item: QTableWidgetItem):
|
||||
unique = list(AS_UserSubscription().keys())[item.row()]
|
||||
keyMap = ['remark', 'webURL']
|
||||
|
||||
AS_UserSubscription()[unique][keyMap[item.column()]] = item.text()
|
||||
|
||||
@QtCore.Slot(QtCore.QPoint)
|
||||
def handleCustomContextMenuRequested(self, point):
|
||||
self.contextMenu.exec(self.mapToGlobal(point))
|
||||
|
||||
def deleteSelectedItem(self):
|
||||
indexes = self.selectedIndex
|
||||
|
||||
if len(indexes) == 0:
|
||||
# Nothing to do
|
||||
return
|
||||
|
||||
mbox = QuestionDeleteMBox(icon=AppQMessageBox.Icon.Question)
|
||||
mbox.isMulti = bool(len(indexes) > 1)
|
||||
mbox.possibleRemark = self.item(indexes[0], 0).text()
|
||||
mbox.setText(mbox.customText())
|
||||
|
||||
if mbox.exec() == PySide6LegacyEnumValueWrapper(
|
||||
AppQMessageBox.StandardButton.No
|
||||
):
|
||||
# Do not delete
|
||||
return
|
||||
|
||||
for i in range(len(indexes)):
|
||||
deleteIndex = indexes[i] - i
|
||||
deleteUnique = list(AS_UserSubscription().keys())[deleteIndex]
|
||||
|
||||
self.removeRow(deleteIndex)
|
||||
|
||||
AS_UserSubscription().pop(deleteUnique)
|
||||
|
||||
if callable(self.deleteUniqueCallback):
|
||||
self.deleteUniqueCallback(deleteUnique)
|
||||
|
||||
def flushItem(self, row, column, item):
|
||||
header = self.Headers[column]
|
||||
|
||||
oldItem = self.item(row, column)
|
||||
newItem = QTableWidgetItem(header(item))
|
||||
|
||||
if oldItem is None:
|
||||
# Item does not exists
|
||||
newItem.setFont(QFont(APP().customFontName))
|
||||
else:
|
||||
# Use existing
|
||||
newItem.setFont(oldItem.font())
|
||||
newItem.setForeground(oldItem.foreground())
|
||||
|
||||
if oldItem.textAlignment() != 0:
|
||||
newItem.setTextAlignment(oldItem.textAlignment())
|
||||
|
||||
# Editable
|
||||
newItem.setFlags(
|
||||
QtCore.Qt.ItemFlag.ItemIsEnabled
|
||||
| QtCore.Qt.ItemFlag.ItemIsSelectable
|
||||
| QtCore.Qt.ItemFlag.ItemIsEditable
|
||||
)
|
||||
|
||||
self.setItem(row, column, newItem)
|
||||
|
||||
def flushRow(self, row, item):
|
||||
for column in list(range(self.columnCount())):
|
||||
self.flushItem(row, column, item)
|
||||
|
||||
def flushAll(self):
|
||||
if self.rowCount() == 0:
|
||||
# Should insert row
|
||||
for index, key in enumerate(AS_UserSubscription()):
|
||||
self.insertRow(index)
|
||||
self.flushRow(index, AS_UserSubscription()[key])
|
||||
else:
|
||||
for index, key in enumerate(AS_UserSubscription()):
|
||||
self.flushRow(index, AS_UserSubscription()[key])
|
||||
|
||||
def appendNewItem(self, **kwargs):
|
||||
unique = kwargs.pop('unique', '')
|
||||
remark = kwargs.pop('remark', '')
|
||||
webURL = kwargs.pop('webURL', '')
|
||||
|
||||
subs = {
|
||||
unique: {
|
||||
'remark': remark,
|
||||
'webURL': webURL,
|
||||
}
|
||||
}
|
||||
|
||||
AS_UserSubscription().update(subs)
|
||||
|
||||
row = self.rowCount()
|
||||
|
||||
self.insertRow(row)
|
||||
self.flushRow(row, subs[unique])
|
||||
|
||||
def retranslate(self):
|
||||
self.setHorizontalHeaderLabels(list(_(str(header)) for header in self.Headers))
|
||||
@@ -1,530 +0,0 @@
|
||||
# Copyright (C) 2023 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from Furious.Gui.Action import Action, Seperator
|
||||
from Furious.Utility.Constants import APP, PLATFORM, Color
|
||||
from Furious.Utility.Utility import (
|
||||
StateContext,
|
||||
SupportConnectedCallback,
|
||||
NeedSyncSettings,
|
||||
bootstrapIcon,
|
||||
moveToCenter,
|
||||
getConnectedColor,
|
||||
getConnectedWindowIcon,
|
||||
)
|
||||
from Furious.Utility.Translator import Translatable, gettext as _
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtGui import QBrush, QColor, QFont, QTextCursor
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QGroupBox,
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
QMainWindow,
|
||||
QMenu,
|
||||
QMenuBar,
|
||||
QMessageBox,
|
||||
QPlainTextEdit,
|
||||
QPushButton,
|
||||
QStyledItemDelegate,
|
||||
QTableWidget,
|
||||
QTabWidget,
|
||||
QTextBrowser,
|
||||
)
|
||||
|
||||
import functools
|
||||
|
||||
|
||||
class Dialog(Translatable, SupportConnectedCallback, QDialog):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if PLATFORM != 'Darwin':
|
||||
self.setWidthAndHeight()
|
||||
|
||||
def setWidthAndHeight(self):
|
||||
pass
|
||||
|
||||
def exec(self):
|
||||
self.show()
|
||||
|
||||
return super().exec()
|
||||
|
||||
def open(self):
|
||||
self.show()
|
||||
|
||||
return super().open()
|
||||
|
||||
def show(self):
|
||||
super().show()
|
||||
|
||||
if PLATFORM == 'Darwin':
|
||||
self.setWidthAndHeight()
|
||||
|
||||
moveToCenter(self)
|
||||
|
||||
def connectedCallback(self):
|
||||
self.setWindowIcon(getConnectedWindowIcon())
|
||||
|
||||
def disconnectedCallback(self):
|
||||
self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg'))
|
||||
|
||||
def retranslate(self):
|
||||
pass
|
||||
|
||||
|
||||
class GroupBox(Translatable, QGroupBox):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def retranslate(self):
|
||||
with StateContext(self):
|
||||
self.setTitle(_(self.title()))
|
||||
|
||||
|
||||
class HeaderView(SupportConnectedCallback, QHeaderView):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.setSectionsClickable(True)
|
||||
|
||||
self.setStyleSheet(
|
||||
f'QHeaderView::section:hover {{ background-color: {Color.LIGHT_BLUE}; }}'
|
||||
)
|
||||
|
||||
def connectedCallback(self):
|
||||
self.setStyleSheet(
|
||||
f'QHeaderView::section:hover {{ background-color: {getConnectedColor()}; }}'
|
||||
)
|
||||
|
||||
def disconnectedCallback(self):
|
||||
self.setStyleSheet(
|
||||
f'QHeaderView::section:hover {{ background-color: {Color.LIGHT_BLUE}; }}'
|
||||
)
|
||||
|
||||
|
||||
class Label(Translatable, QLabel):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def retranslate(self):
|
||||
with StateContext(self):
|
||||
self.setText(_(self.text()))
|
||||
|
||||
|
||||
class ListWidget(SupportConnectedCallback, QListWidget):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.setSelectionColor(Color.LIGHT_BLUE)
|
||||
|
||||
def setSelectionColor(self, color):
|
||||
self.setStyleSheet(
|
||||
f'QListWidget::item:selected {{'
|
||||
f' background: {color};'
|
||||
f'}}'
|
||||
f''
|
||||
f'QListWidget::item:hover {{'
|
||||
f' background: {color};'
|
||||
f'}}'
|
||||
)
|
||||
|
||||
@property
|
||||
def selectedIndex(self):
|
||||
return sorted(list(set(index.row() for index in self.selectedIndexes())))
|
||||
|
||||
def connectedCallback(self):
|
||||
self.setSelectionColor(getConnectedColor())
|
||||
|
||||
def disconnectedCallback(self):
|
||||
self.setSelectionColor(Color.LIGHT_BLUE)
|
||||
|
||||
|
||||
class MainWindow(Translatable, SupportConnectedCallback, NeedSyncSettings, QMainWindow):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self._menuBar = MenuBar(parent=self)
|
||||
self.setMenuBar(self._menuBar)
|
||||
|
||||
if PLATFORM != 'Darwin':
|
||||
self.setWidthAndHeight()
|
||||
|
||||
def syncSettings(self):
|
||||
pass
|
||||
|
||||
def setWidthAndHeight(self):
|
||||
pass
|
||||
|
||||
def show(self):
|
||||
super().show()
|
||||
|
||||
if PLATFORM == 'Darwin':
|
||||
APP().processEvents()
|
||||
|
||||
self.setWidthAndHeight()
|
||||
|
||||
moveToCenter(self)
|
||||
|
||||
def closeEvent(self, event):
|
||||
event.ignore()
|
||||
|
||||
# Sync partial
|
||||
self.syncSettings()
|
||||
self.hide()
|
||||
|
||||
def connectedCallback(self):
|
||||
self.setWindowIcon(getConnectedWindowIcon())
|
||||
|
||||
def disconnectedCallback(self):
|
||||
self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg'))
|
||||
|
||||
def retranslate(self):
|
||||
with StateContext(self):
|
||||
self.setWindowTitle(_(self.windowTitle()))
|
||||
|
||||
|
||||
class Menu(Translatable, SupportConnectedCallback, QMenu):
|
||||
def __init__(self, *actions, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
|
||||
# In some old version PySide6, the self.actions() method
|
||||
# does not return with seperators. _actions list append
|
||||
# them all
|
||||
self._actions = []
|
||||
|
||||
for action in actions:
|
||||
if isinstance(action, Seperator):
|
||||
self._actions.append(action)
|
||||
self.addSeparator()
|
||||
elif isinstance(action, Action):
|
||||
self._actions.append(action)
|
||||
self.addAction(action)
|
||||
else:
|
||||
# Do nothing
|
||||
pass
|
||||
|
||||
if APP().isConnected():
|
||||
self.setStyleSheet(self.getStyleSheet(getConnectedColor()))
|
||||
else:
|
||||
self.setStyleSheet(self.getStyleSheet(Color.LIGHT_BLUE))
|
||||
|
||||
@staticmethod
|
||||
def getStyleSheet(color):
|
||||
return (
|
||||
f'QMenu::item {{'
|
||||
f' background-color: solid;'
|
||||
f'}}'
|
||||
f''
|
||||
f'QMenu::item:selected {{'
|
||||
f' background-color: {color};'
|
||||
f'}}'
|
||||
)
|
||||
|
||||
def connectedCallback(self):
|
||||
self.setStyleSheet(self.getStyleSheet(getConnectedColor()))
|
||||
|
||||
def disconnectedCallback(self):
|
||||
self.setStyleSheet(self.getStyleSheet(Color.LIGHT_BLUE))
|
||||
|
||||
def retranslate(self):
|
||||
with StateContext(self):
|
||||
self.setTitle(_(self.title()))
|
||||
|
||||
|
||||
class MenuBar(SupportConnectedCallback, QMenuBar):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if APP().isConnected():
|
||||
self.setSelectionColor(getConnectedColor())
|
||||
else:
|
||||
self.setSelectionColor(Color.LIGHT_BLUE)
|
||||
|
||||
def setSelectionColor(self, color):
|
||||
self.setStyleSheet(
|
||||
f'QMenuBar::item:selected {{'
|
||||
f' background: {color};'
|
||||
f'}}'
|
||||
f''
|
||||
f'QMenuBar::item:hover {{'
|
||||
f' background: {color};'
|
||||
f'}}'
|
||||
)
|
||||
|
||||
def connectedCallback(self):
|
||||
self.setSelectionColor(getConnectedColor())
|
||||
|
||||
def disconnectedCallback(self):
|
||||
self.setSelectionColor(Color.LIGHT_BLUE)
|
||||
|
||||
|
||||
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(getConnectedWindowIcon())
|
||||
|
||||
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():
|
||||
if button.text().count('OK') > 0:
|
||||
# &OK...
|
||||
pass
|
||||
else:
|
||||
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 StyledItemDelegate(QStyledItemDelegate):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def createEditor(self, parent, option, index):
|
||||
editor = QLineEdit(parent)
|
||||
editor.setFont(QFont(APP().customFontName))
|
||||
|
||||
return editor
|
||||
|
||||
|
||||
class TableWidget(QTableWidget):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.setWordWrap(False)
|
||||
|
||||
@property
|
||||
def selectedIndex(self):
|
||||
return sorted(list(set(index.row() for index in self.selectedIndexes())))
|
||||
|
||||
def setSelectionColor(self, color):
|
||||
self.setStyleSheet(f'QTableWidget {{ selection-background-color: {color}; }}')
|
||||
|
||||
def activateItemByIndex(self, index, activate=True):
|
||||
if activate:
|
||||
for column in range(self.columnCount()):
|
||||
item = self.item(int(index), column)
|
||||
|
||||
if item is None:
|
||||
# Do nothing
|
||||
continue
|
||||
|
||||
font = item.font()
|
||||
font.setBold(True)
|
||||
|
||||
item.setFont(font)
|
||||
|
||||
if APP().isConnected():
|
||||
item.setForeground(QColor(getConnectedColor()))
|
||||
else:
|
||||
item.setForeground(QColor(Color.LIGHT_BLUE))
|
||||
else:
|
||||
for column in range(self.columnCount()):
|
||||
item = self.item(int(index), column)
|
||||
|
||||
if item is None:
|
||||
# Do nothing
|
||||
continue
|
||||
|
||||
font = item.font()
|
||||
font.setBold(False)
|
||||
|
||||
item.setFont(font)
|
||||
item.setForeground(QBrush())
|
||||
|
||||
|
||||
class TabWidget(Translatable, QTabWidget):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def retranslate(self):
|
||||
for index in range(self.count()):
|
||||
self.setTabText(index, _(self.tabText(index)))
|
||||
|
||||
|
||||
class ZoomablePlainTextEdit(QPlainTextEdit):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
@functools.lru_cache(128)
|
||||
def getIndent(line):
|
||||
indent = ''
|
||||
|
||||
for char in line:
|
||||
if char.isspace():
|
||||
indent += char
|
||||
else:
|
||||
break
|
||||
|
||||
return indent
|
||||
|
||||
def getPrevAndNextChar(self, cursor):
|
||||
plainText = self.toPlainText()
|
||||
|
||||
cursor.movePosition(QTextCursor.MoveOperation.Left)
|
||||
|
||||
try:
|
||||
prevChar = plainText[cursor.position()]
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
prevChar = ''
|
||||
|
||||
# Move the cursor to the next character position
|
||||
cursor.movePosition(QTextCursor.MoveOperation.Right)
|
||||
|
||||
try:
|
||||
nextChar = plainText[cursor.position()]
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
nextChar = ''
|
||||
|
||||
return prevChar + nextChar
|
||||
|
||||
def smartIndent(self, event):
|
||||
cursor = self.textCursor()
|
||||
indent = self.getIndent(cursor.block().text())
|
||||
|
||||
# Do newline action
|
||||
super().keyPressEvent(event)
|
||||
|
||||
# Add last line indent
|
||||
cursor.insertText(indent)
|
||||
|
||||
self.setTextCursor(cursor)
|
||||
|
||||
def smartSymbolPair(self, event, pair):
|
||||
plainText = self.toPlainText()
|
||||
|
||||
cursor = self.textCursor()
|
||||
|
||||
if (
|
||||
cursor.position() < len(plainText)
|
||||
and plainText[cursor.position()] == pair[0]
|
||||
):
|
||||
# Do pair0 action
|
||||
super().keyPressEvent(event)
|
||||
else:
|
||||
# Do pair0 action
|
||||
super().keyPressEvent(event)
|
||||
|
||||
# Do pair1 action
|
||||
cursor.insertText(pair[1])
|
||||
# Move to middle
|
||||
cursor.movePosition(QTextCursor.MoveOperation.Left)
|
||||
|
||||
self.setTextCursor(cursor)
|
||||
|
||||
def smartBackspace(self, event):
|
||||
cursor = self.textCursor()
|
||||
chPair = self.getPrevAndNextChar(cursor)
|
||||
|
||||
if chPair == '""' or chPair == '{}' or chPair == '[]':
|
||||
cursor.deleteChar()
|
||||
cursor.deletePreviousChar()
|
||||
else:
|
||||
super().keyPressEvent(event)
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
if (
|
||||
event.key() == QtCore.Qt.Key.Key_Return
|
||||
or event.key() == QtCore.Qt.Key.Key_Enter
|
||||
):
|
||||
self.smartIndent(event)
|
||||
elif event.key() == QtCore.Qt.Key.Key_Backspace:
|
||||
self.smartBackspace(event)
|
||||
elif event.key() == QtCore.Qt.Key.Key_QuoteDbl:
|
||||
self.smartSymbolPair(event, '""')
|
||||
elif event.key() == QtCore.Qt.Key.Key_BraceLeft:
|
||||
self.smartSymbolPair(event, '{}')
|
||||
elif event.key() == QtCore.Qt.Key.Key_BracketLeft:
|
||||
self.smartSymbolPair(event, '[]')
|
||||
else:
|
||||
super().keyPressEvent(event)
|
||||
|
||||
def wheelEvent(self, event):
|
||||
if event.modifiers() == QtCore.Qt.KeyboardModifier.ControlModifier:
|
||||
delta = event.angleDelta().y()
|
||||
|
||||
if delta > 0:
|
||||
self.zoomIn()
|
||||
if delta < 0:
|
||||
self.zoomOut()
|
||||
else:
|
||||
super().wheelEvent(event)
|
||||
|
||||
|
||||
class ZoomableTextBrowser(QTextBrowser):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def wheelEvent(self, event):
|
||||
if event.modifiers() == QtCore.Qt.KeyboardModifier.ControlModifier:
|
||||
delta = event.angleDelta().y()
|
||||
|
||||
if delta > 0:
|
||||
self.zoomIn()
|
||||
if delta < 0:
|
||||
self.zoomOut()
|
||||
else:
|
||||
super().wheelEvent(event)
|
||||
@@ -0,0 +1,209 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.PyFramework import *
|
||||
from Furious.QtFramework import *
|
||||
from Furious.QtFramework import gettext as _
|
||||
from Furious.Utility import *
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtWidgets import *
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import logging
|
||||
import functools
|
||||
import darkdetect
|
||||
|
||||
__all__ = ['XrayAssetViewerQListWidget']
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
needTrans = functools.partial(needTransFn, source=__name__)
|
||||
|
||||
|
||||
class AssetExistsMBox(AppQMessageBox):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.setStandardButtons(
|
||||
AppQMessageBox.StandardButton.Yes | AppQMessageBox.StandardButton.No
|
||||
)
|
||||
|
||||
def retranslate(self):
|
||||
self.setWindowTitle(_(self.windowTitle()))
|
||||
self.setText(_(self.text()))
|
||||
|
||||
# Ignore informative text, buttons
|
||||
|
||||
self.moveToCenter()
|
||||
|
||||
|
||||
needTrans(
|
||||
'Delete',
|
||||
'Import',
|
||||
'Asset file already exists. Overwrite?',
|
||||
'Error import asset file',
|
||||
'Import asset file success',
|
||||
)
|
||||
|
||||
|
||||
class XrayAssetViewerQListWidget(SupportThemeChangedCallback, AppQListWidget):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.setSelectionBehavior(AppQListWidget.SelectionBehavior.SelectRows)
|
||||
self.setSelectionMode(AppQListWidget.SelectionMode.ExtendedSelection)
|
||||
self.setIconSize(QtCore.QSize(64, 64))
|
||||
|
||||
if PLATFORM == 'Linux' and getUbuntuRelease() == '20.04':
|
||||
self.initialTheme = darkdetect.theme()
|
||||
else:
|
||||
self.initialTheme = None
|
||||
|
||||
self.flushItem()
|
||||
|
||||
logger.info(f'Xray-core asset dir is \'{XRAY_ASSET_DIR}\'')
|
||||
|
||||
contextMenuActions = [
|
||||
AppQAction(
|
||||
_('Delete'),
|
||||
callback=lambda: self.deleteSelectedItem(),
|
||||
),
|
||||
]
|
||||
|
||||
self.contextMenu = AppQMenu(*contextMenuActions)
|
||||
|
||||
# Add actions to self in order to activate shortcuts
|
||||
self.addActions(self.contextMenu.actions())
|
||||
|
||||
self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.customContextMenuRequested.connect(self.handleCustomContextMenuRequested)
|
||||
|
||||
@QtCore.Slot(QtCore.QPoint)
|
||||
def handleCustomContextMenuRequested(self, point):
|
||||
self.contextMenu.exec(self.mapToGlobal(point))
|
||||
|
||||
def flushItemByTheme(self, theme: str):
|
||||
self.clear()
|
||||
|
||||
for filename in os.listdir(XRAY_ASSET_DIR):
|
||||
if os.path.isfile(XRAY_ASSET_DIR / filename):
|
||||
item = QListWidgetItem(filename)
|
||||
|
||||
if AppSettings.isStateON_('DarkMode'):
|
||||
# Custom dark mode
|
||||
item.setIcon(bootstrapIconWhite('file-earmark.svg'))
|
||||
else:
|
||||
if theme == 'Dark':
|
||||
if PLATFORM == 'Windows':
|
||||
# Windows. Always use black icon
|
||||
item.setIcon(bootstrapIcon('file-earmark.svg'))
|
||||
else:
|
||||
item.setIcon(bootstrapIconWhite('file-earmark.svg'))
|
||||
else:
|
||||
item.setIcon(bootstrapIcon('file-earmark.svg'))
|
||||
|
||||
self.addItem(item)
|
||||
|
||||
def flushItem(self):
|
||||
if PLATFORM == 'Linux' and getUbuntuRelease() == '20.04':
|
||||
assert self.initialTheme is not None
|
||||
|
||||
# Ubuntu 20.04. Flush by initial theme(Ubuntu 20.04 theme changes bug)
|
||||
self.flushItemByTheme(self.initialTheme)
|
||||
else:
|
||||
self.flushItemByTheme(darkdetect.theme())
|
||||
|
||||
def appendNewItem(self, filename: str):
|
||||
basename = os.path.basename(filename)
|
||||
|
||||
if os.path.isfile(XRAY_ASSET_DIR / basename):
|
||||
mbox = AssetExistsMBox(icon=AppQMessageBox.Icon.Question)
|
||||
mbox.setWindowTitle(_('Import'))
|
||||
mbox.setText(_('Asset file already exists. Overwrite?'))
|
||||
mbox.setInformativeText(basename)
|
||||
|
||||
if mbox.exec() == PySide6LegacyEnumValueWrapper(
|
||||
AppQMessageBox.StandardButton.No
|
||||
):
|
||||
# Do not overwrite
|
||||
return
|
||||
|
||||
try:
|
||||
shutil.copy(filename, XRAY_ASSET_DIR)
|
||||
except shutil.SameFileError:
|
||||
# Same file imported. Do nothing
|
||||
pass
|
||||
except Exception as ex:
|
||||
# Any non-exit exception
|
||||
|
||||
mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Critical)
|
||||
mbox.setWindowTitle(_('Import'))
|
||||
mbox.setText(_('Error import asset file'))
|
||||
mbox.setInformativeText(str(ex))
|
||||
|
||||
# Show the MessageBox and wait for user to close it
|
||||
mbox.exec()
|
||||
else:
|
||||
self.flushItem()
|
||||
|
||||
mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Information)
|
||||
mbox.setWindowTitle(_('Import'))
|
||||
mbox.setText(_('Import asset file success'))
|
||||
|
||||
# Show the MessageBox and wait for user to close it
|
||||
mbox.exec()
|
||||
|
||||
def deleteSelectedItem(self):
|
||||
indexes = self.selectedIndex
|
||||
|
||||
if len(indexes) == 0:
|
||||
# Nothing selected
|
||||
return
|
||||
|
||||
mbox = QuestionDeleteMBox(icon=AppQMessageBox.Icon.Question)
|
||||
mbox.isMulti = bool(len(indexes) > 1)
|
||||
mbox.possibleRemark = f'{self.item(indexes[0]).text()}'
|
||||
mbox.setText(mbox.customText())
|
||||
|
||||
if mbox.exec() == PySide6LegacyEnumValueWrapper(
|
||||
AppQMessageBox.StandardButton.No
|
||||
):
|
||||
# Do not delete
|
||||
return
|
||||
|
||||
for index in indexes:
|
||||
os.remove(XRAY_ASSET_DIR / self.item(index).text())
|
||||
|
||||
self.flushItem()
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
if event.key() == QtCore.Qt.Key.Key_Delete:
|
||||
self.deleteSelectedItem()
|
||||
else:
|
||||
super().keyPressEvent(event)
|
||||
|
||||
def themeChangedCallback(self, theme):
|
||||
if PLATFORM == 'Linux' and getUbuntuRelease() == '20.04':
|
||||
# Ubuntu 20.04 system dark theme does not
|
||||
# change menu color. Do nothing
|
||||
pass
|
||||
else:
|
||||
self.flushItemByTheme(theme)
|
||||
@@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2023 Loren Eteval <loren.eteval@proton.me>
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
@@ -15,3 +15,10 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from .Application import *
|
||||
from .ConnectProgressBar import *
|
||||
from .IndentSpinBox import *
|
||||
from .SystemTrayIcon import *
|
||||
from .UserServersQTableWidget import *
|
||||
from .UserSubsQTableWidget import *
|
||||
from .XrayAssetViewerQListWidget import *
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.Interface import *
|
||||
from Furious.QtFramework import *
|
||||
from Furious.QtFramework import gettext as _
|
||||
from Furious.Library import *
|
||||
from Furious.Utility import *
|
||||
from Furious.Widget.UserServersQTableWidget import *
|
||||
from Furious.Window.UserSubsWindow import *
|
||||
from Furious.Window.LogViewerWindow import *
|
||||
from Furious.Window.XrayAssetViewerWindow import *
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtGui import *
|
||||
from PySide6.QtWidgets import *
|
||||
from PySide6.QtNetwork import *
|
||||
|
||||
from typing import Union
|
||||
|
||||
import logging
|
||||
import functools
|
||||
|
||||
__all__ = ['AppMainWindow']
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
registerAppSettings('ServerWidgetWindowSize')
|
||||
|
||||
needTrans = functools.partial(needTransFn, source=__name__)
|
||||
|
||||
|
||||
def connectedHttpProxyEndpoint() -> Union[str, None]:
|
||||
try:
|
||||
if APP().isSystemTrayConnected():
|
||||
index = AS_UserActivatedItemIndex()
|
||||
|
||||
if index >= 0:
|
||||
return AS_UserServers()[index].httpProxyEndpoint()
|
||||
else:
|
||||
# Should not reach here
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def connectedRemark() -> str:
|
||||
try:
|
||||
if APP().isSystemTrayConnected():
|
||||
index = AS_UserActivatedItemIndex()
|
||||
|
||||
if index >= 0:
|
||||
return f'{index + 1} - ' + AS_UserServers()[index].getExtras('remark')
|
||||
else:
|
||||
# Should not reach here
|
||||
return ''
|
||||
else:
|
||||
return ''
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
return ''
|
||||
|
||||
|
||||
class AppNetworkStateManager(NetworkStateManager):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
def successCallback(self):
|
||||
parent = self.parent()
|
||||
|
||||
if isinstance(parent, AppMainWindow):
|
||||
parent.setNetworkState(True)
|
||||
|
||||
def errorCallback(self, errorString: str):
|
||||
parent = self.parent()
|
||||
|
||||
if isinstance(parent, AppMainWindow):
|
||||
parent.setNetworkState(False, errorString=errorString)
|
||||
|
||||
def startSingleTest(self):
|
||||
if not APP().isSystemTrayConnected():
|
||||
parent = self.parent()
|
||||
|
||||
if isinstance(parent, AppMainWindow):
|
||||
parent.resetNetworkState()
|
||||
|
||||
self.stopTest()
|
||||
|
||||
httpProxyEndpoint = connectedHttpProxyEndpoint()
|
||||
|
||||
if httpProxyEndpoint is None:
|
||||
parent = self.parent()
|
||||
|
||||
if isinstance(parent, AppMainWindow):
|
||||
parent.resetNetworkState()
|
||||
else:
|
||||
self.configureHttpProxy(httpProxyEndpoint)
|
||||
|
||||
super().startSingleTest()
|
||||
|
||||
def disconnectedCallback(self):
|
||||
parent = self.parent()
|
||||
|
||||
if isinstance(parent, AppMainWindow):
|
||||
parent.resetNetworkState()
|
||||
|
||||
self.stopTest()
|
||||
|
||||
|
||||
needTrans(
|
||||
'Server',
|
||||
'Log',
|
||||
'Show Furious Log',
|
||||
'Show Core Log',
|
||||
'Show Tun2socks Log',
|
||||
'Subscription',
|
||||
'Update Subscription (Use Current Proxy)',
|
||||
'Update Subscription (Force Proxy)',
|
||||
'Update Subscription (No Proxy)',
|
||||
'Edit Subscription...',
|
||||
'Tools',
|
||||
'Manage Xray-core Asset File...',
|
||||
'Check For Updates',
|
||||
'About',
|
||||
'Help',
|
||||
)
|
||||
|
||||
|
||||
class AppMainWindow(AppQMainWindow):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.setWindowTitle(_(APPLICATION_NAME))
|
||||
|
||||
self.updatesManager = UpdatesManager()
|
||||
self.networkStateManager = AppNetworkStateManager(parent=self)
|
||||
|
||||
self.userServersQTableWidget = UserServersQTableWidget()
|
||||
self.userSubsWindow = UserSubsWindow(
|
||||
deleteUniqueCallback=lambda unique: self.userServersQTableWidget.deleteItemByIndex(
|
||||
list(
|
||||
index
|
||||
for index, server in enumerate(AS_UserServers())
|
||||
if server.getExtras('subsId') == unique
|
||||
)
|
||||
)
|
||||
)
|
||||
self.xrayAssetViewerWindow = XrayAssetViewerWindow()
|
||||
|
||||
self.mainTab = AppQTabWidget()
|
||||
self.mainTab.addTab(self.userServersQTableWidget, _('Server'))
|
||||
|
||||
logActions = [
|
||||
AppQAction(
|
||||
_('Show Furious Log'),
|
||||
callback=lambda: APP().logViewerWindowApp_.showMaximized(),
|
||||
shortcut=QtCore.QKeyCombination(
|
||||
QtCore.Qt.KeyboardModifier.ControlModifier
|
||||
| QtCore.Qt.KeyboardModifier.ShiftModifier,
|
||||
QtCore.Qt.Key.Key_F,
|
||||
),
|
||||
),
|
||||
AppQAction(
|
||||
_('Show Core Log'),
|
||||
callback=lambda: APP().logViewerWindowCore.showMaximized(),
|
||||
shortcut=QtCore.QKeyCombination(
|
||||
QtCore.Qt.KeyboardModifier.ControlModifier
|
||||
| QtCore.Qt.KeyboardModifier.ShiftModifier,
|
||||
QtCore.Qt.Key.Key_C,
|
||||
),
|
||||
),
|
||||
AppQAction(
|
||||
_('Show Tun2socks Log'),
|
||||
callback=lambda: APP().logViewerWindowTun_.showMaximized(),
|
||||
shortcut=QtCore.QKeyCombination(
|
||||
QtCore.Qt.KeyboardModifier.ControlModifier
|
||||
| QtCore.Qt.KeyboardModifier.ShiftModifier,
|
||||
QtCore.Qt.Key.Key_T,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
subsActions = [
|
||||
AppQAction(
|
||||
_('Update Subscription (Use Current Proxy)'),
|
||||
callback=lambda: self.userServersQTableWidget.updateSubs(
|
||||
connectedHttpProxyEndpoint()
|
||||
),
|
||||
),
|
||||
AppQAction(
|
||||
_('Update Subscription (Force Proxy)'),
|
||||
callback=lambda: self.userServersQTableWidget.updateSubs(
|
||||
'127.0.0.1:10809'
|
||||
),
|
||||
),
|
||||
AppQAction(
|
||||
_('Update Subscription (No Proxy)'),
|
||||
callback=lambda: self.userServersQTableWidget.updateSubs(None),
|
||||
),
|
||||
AppQSeperator(),
|
||||
AppQAction(
|
||||
_('Edit Subscription...'),
|
||||
icon=bootstrapIcon('star.svg'),
|
||||
callback=lambda: self.userSubsWindow.show(),
|
||||
),
|
||||
]
|
||||
|
||||
toolsActions = [
|
||||
AppQAction(
|
||||
_('Manage Xray-core Asset File...'),
|
||||
callback=lambda: self.xrayAssetViewerWindow.show(),
|
||||
),
|
||||
]
|
||||
|
||||
if hasattr(AppQAction, 'setMenu'):
|
||||
self.toolbar = AppQToolBar(
|
||||
AppQAction(
|
||||
_('Log'),
|
||||
icon=bootstrapIcon('pin-angle.svg'),
|
||||
menu=AppQMenu(*logActions),
|
||||
useActionGroup=False,
|
||||
checkable=False,
|
||||
),
|
||||
AppQSeperator(),
|
||||
AppQAction(
|
||||
_('Subscription'),
|
||||
icon=bootstrapIcon('collection.svg'),
|
||||
menu=AppQMenu(*subsActions),
|
||||
useActionGroup=False,
|
||||
checkable=False,
|
||||
),
|
||||
AppQSeperator(),
|
||||
AppQAction(
|
||||
_('Tools'),
|
||||
icon=bootstrapIcon('tools.svg'),
|
||||
menu=AppQMenu(*toolsActions),
|
||||
useActionGroup=False,
|
||||
checkable=False,
|
||||
),
|
||||
AppQSeperator(),
|
||||
AppQAction(
|
||||
_('Check For Updates'),
|
||||
icon=bootstrapIcon('download.svg'),
|
||||
checkable=False,
|
||||
callback=lambda: self.checkForUpdates(),
|
||||
),
|
||||
AppQSeperator(),
|
||||
AppQAction(
|
||||
_('About'),
|
||||
icon=bootstrapIcon('info-circle.svg'),
|
||||
checkable=False,
|
||||
callback=lambda: self.openAboutPage(),
|
||||
),
|
||||
)
|
||||
self.toolbar.setIconSize(QtCore.QSize(64, 32))
|
||||
self.toolbar.setToolButtonStyle(
|
||||
QtCore.Qt.ToolButtonStyle.ToolButtonTextUnderIcon
|
||||
)
|
||||
self.addToolBar(self.toolbar)
|
||||
else:
|
||||
# Menu actions
|
||||
logMenu = {
|
||||
'name': 'Log',
|
||||
'actions': [*logActions],
|
||||
}
|
||||
|
||||
subsMenu = {
|
||||
'name': 'Subscription',
|
||||
'actions': [*subsActions],
|
||||
}
|
||||
|
||||
toolsMenu = {
|
||||
'name': 'Tools',
|
||||
'actions': [*toolsActions],
|
||||
}
|
||||
|
||||
helpMenu = {
|
||||
'name': 'Help',
|
||||
'actions': [
|
||||
AppQAction(
|
||||
_('Check For Updates'),
|
||||
icon=bootstrapIcon('download.svg'),
|
||||
checkable=False,
|
||||
callback=lambda: self.checkForUpdates(),
|
||||
),
|
||||
AppQSeperator(),
|
||||
AppQAction(
|
||||
_('About'),
|
||||
icon=bootstrapIcon('info-circle.svg'),
|
||||
checkable=False,
|
||||
callback=lambda: self.openAboutPage(),
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
# Menus
|
||||
for menuDict in (logMenu, subsMenu, toolsMenu, helpMenu):
|
||||
menuName = menuDict['name']
|
||||
menuObjName = f'_{menuName}Menu'
|
||||
menu = AppQMenu(
|
||||
*menuDict['actions'], title=_(menuName), parent=self.menuBar()
|
||||
)
|
||||
|
||||
# Set reference
|
||||
setattr(self, menuObjName, menu)
|
||||
|
||||
self.menuBar().addMenu(menu)
|
||||
|
||||
self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.NoContextMenu)
|
||||
|
||||
# TODO: Custom status tip
|
||||
# self.setStatusBar(QStatusBar(self))
|
||||
|
||||
self.networkState = AppQLabel(translatable=False)
|
||||
|
||||
self.statusBar().addPermanentWidget(self.networkState)
|
||||
|
||||
self.networkStateManager.startTest()
|
||||
|
||||
self._widget = QWidget()
|
||||
self._layout = QVBoxLayout(self._widget)
|
||||
self._layout.addWidget(self.mainTab)
|
||||
|
||||
self.setCentralWidget(self._widget)
|
||||
|
||||
def appendNewItemByFactory(self, factory: ConfigurationFactory):
|
||||
self.userServersQTableWidget.appendNewItemByFactory(factory)
|
||||
|
||||
def flushRow(self, row: int, item: ConfigurationFactory):
|
||||
self.userServersQTableWidget.flushRow(row, item)
|
||||
|
||||
def showTabAndSpaces(self):
|
||||
self.userServersQTableWidget.showTabAndSpaces()
|
||||
|
||||
def hideTabAndSpaces(self):
|
||||
self.userServersQTableWidget.hideTabAndSpaces()
|
||||
|
||||
def checkForUpdates(self):
|
||||
self.updatesManager.configureHttpProxy(connectedHttpProxyEndpoint())
|
||||
self.updatesManager.checkForUpdates()
|
||||
|
||||
def resetNetworkState(self):
|
||||
self.networkState.setText('')
|
||||
|
||||
def setNetworkState(self, success: bool, **kwargs):
|
||||
remark = connectedRemark()
|
||||
|
||||
if success:
|
||||
if remark:
|
||||
self.networkState.setText(f'{remark} {UNICODE_LARGE_GREEN_CIRCLE}')
|
||||
else:
|
||||
self.resetNetworkState()
|
||||
else:
|
||||
errorString = kwargs.pop('errorString', '')
|
||||
|
||||
if remark:
|
||||
self.networkState.setText(
|
||||
f'{remark} - {errorString} {UNICODE_LARGE_RED_CIRCLE}'
|
||||
)
|
||||
else:
|
||||
self.resetNetworkState()
|
||||
|
||||
@staticmethod
|
||||
def openAboutPage():
|
||||
if QDesktopServices.openUrl(QtCore.QUrl(APPLICATION_ABOUT_PAGE)):
|
||||
logger.info('open about page success')
|
||||
else:
|
||||
logger.error('open about page failed')
|
||||
|
||||
def setWidthAndHeight(self):
|
||||
try:
|
||||
windowSize = AppSettings.get('ServerWidgetWindowSize').split(',')
|
||||
|
||||
self.setGeometry(100, 100, *list(int(size) for size in windowSize))
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
self.setGeometry(100, 100, 1800, 960)
|
||||
|
||||
def cleanup(self):
|
||||
AppSettings.set(
|
||||
'ServerWidgetWindowSize',
|
||||
f'{self.geometry().width()},{self.geometry().height()}',
|
||||
)
|
||||
@@ -0,0 +1,183 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from Furious.QtFramework import *
|
||||
from Furious.QtFramework import gettext as _
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtWidgets import *
|
||||
|
||||
import functools
|
||||
|
||||
__all__ = ['LogViewerWindow']
|
||||
|
||||
needTrans = functools.partial(needTransFn, source=__name__)
|
||||
|
||||
needTrans('Unable to save log')
|
||||
|
||||
|
||||
class SaveErrorMBox(AppQMessageBox):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.saveError = ''
|
||||
|
||||
def customText(self):
|
||||
if self.saveError:
|
||||
return _('Unable to save log') + f'\n\n{self.saveError}'
|
||||
else:
|
||||
return _('Unable to save log')
|
||||
|
||||
def retranslate(self):
|
||||
self.setWindowTitle(_(self.windowTitle()))
|
||||
self.setText(self.customText())
|
||||
|
||||
# Ignore informative text, buttons
|
||||
|
||||
self.moveToCenter()
|
||||
|
||||
|
||||
needTrans(
|
||||
'Save File',
|
||||
'Text files (*.txt);;All files (*)',
|
||||
'Error saving log',
|
||||
)
|
||||
|
||||
|
||||
def saveAsFile(content: str):
|
||||
filename, selectedFilter = QFileDialog.getSaveFileName(
|
||||
None, _('Save File'), filter=_('Text files (*.txt);;All files (*)')
|
||||
)
|
||||
|
||||
if filename:
|
||||
try:
|
||||
with open(filename, 'w', encoding='utf-8') as file:
|
||||
file.write(content)
|
||||
except Exception as ex:
|
||||
# Any non-exit exceptions
|
||||
|
||||
mbox = SaveErrorMBox(icon=AppQMessageBox.Icon.Critical)
|
||||
mbox.saveError = str(ex)
|
||||
mbox.setWindowTitle(_('Error saving log'))
|
||||
mbox.setText(mbox.customText())
|
||||
|
||||
# Show the MessageBox and wait for user to close it
|
||||
mbox.exec()
|
||||
|
||||
|
||||
needTrans(
|
||||
'Log Viewer',
|
||||
'Save As...',
|
||||
'Exit',
|
||||
'File',
|
||||
'Copy',
|
||||
'Select All',
|
||||
'Edit',
|
||||
'Zoom In',
|
||||
'Zoom Out',
|
||||
'View',
|
||||
)
|
||||
|
||||
|
||||
class LogViewerWindow(AppQMainWindow):
|
||||
def __init__(self, *args, **kwargs):
|
||||
tabTitle = kwargs.pop('tabTitle', '')
|
||||
fontFamily = kwargs.pop('fontFamily', '')
|
||||
pointSizeSettingsName = kwargs.pop('pointSizeSettingsName', '')
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.setWindowTitle(_('Log Viewer'))
|
||||
|
||||
self.textBrowser = DraculaTextBrowser(
|
||||
fontFamily=fontFamily,
|
||||
pointSizeSettingsName=pointSizeSettingsName,
|
||||
)
|
||||
self.textBrowser.setLineWrapMode(DraculaTextBrowser.LineWrapMode.NoWrap)
|
||||
|
||||
self.tabWidget = AppQTabWidget()
|
||||
self.tabWidget.addTab(self.textBrowser, tabTitle)
|
||||
|
||||
self.setCentralWidget(self.tabWidget)
|
||||
|
||||
self._fileMenu = AppQMenu(
|
||||
AppQAction(
|
||||
_('Save As...'),
|
||||
callback=lambda: saveAsFile(self.textBrowser.toPlainText()),
|
||||
),
|
||||
AppQSeperator(),
|
||||
AppQAction(
|
||||
_('Exit'),
|
||||
callback=lambda: self.hide(),
|
||||
),
|
||||
title=_('File'),
|
||||
parent=self,
|
||||
)
|
||||
|
||||
self._editMenu = AppQMenu(
|
||||
AppQAction(
|
||||
_('Copy'),
|
||||
icon=bootstrapIcon('files.svg'),
|
||||
callback=lambda: self.textBrowser.copy(),
|
||||
shortcut=QtCore.QKeyCombination(
|
||||
QtCore.Qt.KeyboardModifier.ControlModifier,
|
||||
QtCore.Qt.Key.Key_C,
|
||||
),
|
||||
),
|
||||
AppQSeperator(),
|
||||
AppQAction(
|
||||
_('Select All'),
|
||||
callback=lambda: self.textBrowser.selectAll(),
|
||||
shortcut=QtCore.QKeyCombination(
|
||||
QtCore.Qt.KeyboardModifier.ControlModifier,
|
||||
QtCore.Qt.Key.Key_A,
|
||||
),
|
||||
),
|
||||
title=_('Edit'),
|
||||
parent=self,
|
||||
)
|
||||
|
||||
self._viewMenu = AppQMenu(
|
||||
AppQAction(
|
||||
_('Zoom In'),
|
||||
callback=lambda: self.textBrowser.zoomIn(),
|
||||
shortcut=QtCore.QKeyCombination(
|
||||
QtCore.Qt.KeyboardModifier.ControlModifier,
|
||||
QtCore.Qt.Key.Key_Plus,
|
||||
),
|
||||
),
|
||||
AppQAction(
|
||||
_('Zoom Out'),
|
||||
callback=lambda: self.textBrowser.zoomOut(),
|
||||
shortcut=QtCore.QKeyCombination(
|
||||
QtCore.Qt.KeyboardModifier.ControlModifier,
|
||||
QtCore.Qt.Key.Key_Minus,
|
||||
),
|
||||
),
|
||||
title=_('View'),
|
||||
parent=self,
|
||||
)
|
||||
|
||||
self.menuBar().addMenu(self._fileMenu)
|
||||
self.menuBar().addMenu(self._editMenu)
|
||||
self.menuBar().addMenu(self._viewMenu)
|
||||
|
||||
def appendLine(self, line: str):
|
||||
self.textBrowser.appendLine(line)
|
||||
|
||||
def clear(self):
|
||||
self.textBrowser.clear()
|
||||
@@ -0,0 +1,81 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.Interface import *
|
||||
from Furious.PyFramework import *
|
||||
from Furious.QtFramework import *
|
||||
from Furious.QtFramework import gettext as _
|
||||
from Furious.Utility import *
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtGui import *
|
||||
from PySide6.QtWidgets import *
|
||||
|
||||
import io
|
||||
import pyqrcode
|
||||
|
||||
__all__ = ['QRCodeWindow']
|
||||
|
||||
|
||||
class QRCodeWindow(SupportImplicitReference, AppQMainWindow):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.setWindowTitle(_(APPLICATION_NAME))
|
||||
self.setFixedSize(640, 640)
|
||||
|
||||
self.tabWidget = QTabWidget(self)
|
||||
self.tabWidget.setTabsClosable(True)
|
||||
self.tabWidget.tabCloseRequested.connect(self.handleTabCloseRequested)
|
||||
|
||||
self.setCentralWidget(self.tabWidget)
|
||||
|
||||
def tabCount(self) -> int:
|
||||
return self.tabWidget.count()
|
||||
|
||||
def initTabByIndex(self, indexes):
|
||||
self.tabWidget.clear()
|
||||
|
||||
for index in indexes:
|
||||
qrdata = io.BytesIO()
|
||||
|
||||
config = AS_UserServers()[index]
|
||||
|
||||
uri = config.toURI()
|
||||
|
||||
if uri:
|
||||
qrcode = pyqrcode.create(uri)
|
||||
qrcode.png(qrdata, scale=5)
|
||||
|
||||
pixmap = QPixmap()
|
||||
pixmap.loadFromData(qrdata.getvalue(), 'PNG')
|
||||
|
||||
widget = QLabel(parent=self.tabWidget)
|
||||
widget.setPixmap(pixmap)
|
||||
|
||||
self.tabWidget.addTab(
|
||||
widget, f'{index + 1} - ' + config.getExtras('remark')
|
||||
)
|
||||
|
||||
@QtCore.Slot(int)
|
||||
def handleTabCloseRequested(self, index):
|
||||
self.tabWidget.removeTab(index)
|
||||
|
||||
if self.tabWidget.count() == 0:
|
||||
self.hide()
|
||||
@@ -0,0 +1,425 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.PyFramework import *
|
||||
from Furious.QtFramework import *
|
||||
from Furious.QtFramework import gettext as _
|
||||
from Furious.Utility import *
|
||||
from Furious.Library import *
|
||||
from Furious.Widget.IndentSpinBox import *
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtGui import *
|
||||
from PySide6.QtWidgets import *
|
||||
|
||||
import functools
|
||||
|
||||
__all__ = ['TextEditorWindow']
|
||||
|
||||
registerAppSettings('ServerWidgetPointSize')
|
||||
|
||||
needTrans = functools.partial(needTransFn, source=__name__)
|
||||
|
||||
needTrans(
|
||||
'Save Changes',
|
||||
'The content has been modified. Save changes?',
|
||||
'Save',
|
||||
'Cancel',
|
||||
'Discard',
|
||||
)
|
||||
|
||||
|
||||
class QuestionSaveMBox(AppQMessageBox):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.setWindowTitle(_('Save Changes'))
|
||||
self.setText(_('The content has been modified. Save changes?'))
|
||||
|
||||
self.button0 = self.addButton(_('Save'), AppQMessageBox.ButtonRole.AcceptRole)
|
||||
self.button1 = self.addButton(_('Cancel'), AppQMessageBox.ButtonRole.RejectRole)
|
||||
self.button2 = self.addButton(
|
||||
_('Discard'), AppQMessageBox.ButtonRole.DestructiveRole
|
||||
)
|
||||
|
||||
self.setDefaultButton(self.button0)
|
||||
|
||||
|
||||
needTrans('Please check if the configuration is in valid JSON format')
|
||||
|
||||
|
||||
class JSONDecodeErrorMBox(AppQMessageBox):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.error = ''
|
||||
|
||||
def customText(self):
|
||||
return (
|
||||
_('Please check if the configuration is in valid JSON format')
|
||||
+ f'\n\n{self.error}'
|
||||
)
|
||||
|
||||
def retranslate(self):
|
||||
self.setWindowTitle(_(self.windowTitle()))
|
||||
self.setText(self.customText())
|
||||
|
||||
# Ignore informative text, buttons
|
||||
|
||||
self.moveToCenter()
|
||||
|
||||
|
||||
needTrans(
|
||||
'Save',
|
||||
'Save As...',
|
||||
'Exit',
|
||||
'File',
|
||||
'Undo',
|
||||
'Redo',
|
||||
'Cut',
|
||||
'Copy',
|
||||
'Paste',
|
||||
'Select All',
|
||||
'Indent...',
|
||||
'Edit',
|
||||
'Zoom In',
|
||||
'Zoom Out',
|
||||
'View',
|
||||
'Error saving configuration',
|
||||
'Save File',
|
||||
'Text files (*.json);;All files (*)',
|
||||
'Error Saving File',
|
||||
'Invalid server configuration',
|
||||
'Error setting indent',
|
||||
)
|
||||
|
||||
|
||||
class TextEditorWindow(AppQMainWindow):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.customWindowTitle = ''
|
||||
self.setWindowModality(QtCore.Qt.WindowModality.ApplicationModal)
|
||||
self.setFixedSize(470, int(470 * GOLDEN_RATIO))
|
||||
|
||||
self.indentSpinBox = IndentSpinBox(parent=self)
|
||||
|
||||
# Current editing index
|
||||
self.currentIndex = -1
|
||||
|
||||
self.modified = False
|
||||
self.modifiedMark = ' *'
|
||||
|
||||
self.lineColumnLabel = QLabel('1:1 0')
|
||||
self.statusBar().addPermanentWidget(self.lineColumnLabel)
|
||||
|
||||
def modificationCallback():
|
||||
self.markAsModified()
|
||||
|
||||
def cursorChangedCallback(cursor: QTextCursor):
|
||||
self.lineColumnLabel.setText(
|
||||
f'{cursor.blockNumber() + 1}:{cursor.columnNumber() + 1} {cursor.position()}'
|
||||
)
|
||||
|
||||
self.jsonEditor = DraculaJSONTextEditor(
|
||||
fontFamily=APP().customFontName,
|
||||
pointSizeSettingsName='ServerWidgetPointSize',
|
||||
)
|
||||
self.jsonEditor.setLineWrapMode(DraculaJSONTextEditor.LineWrapMode.NoWrap)
|
||||
self.jsonEditor.registerModificationChangedCb(modificationCallback)
|
||||
self.jsonEditor.registerCursorPositionChangedCb(cursorChangedCallback)
|
||||
|
||||
self.setCentralWidget(self.jsonEditor)
|
||||
|
||||
self.fileMenu = AppQMenu(
|
||||
AppQAction(
|
||||
_('Save'),
|
||||
icon=bootstrapIcon('save.svg'),
|
||||
callback=lambda: self.save(),
|
||||
shortcut=QtCore.QKeyCombination(
|
||||
QtCore.Qt.KeyboardModifier.ControlModifier,
|
||||
QtCore.Qt.Key.Key_S,
|
||||
),
|
||||
),
|
||||
AppQAction(
|
||||
_('Save As...'),
|
||||
callback=lambda: self.saveAsFile(),
|
||||
),
|
||||
AppQSeperator(),
|
||||
AppQAction(
|
||||
_('Exit'),
|
||||
callback=lambda: self.questionSave(),
|
||||
),
|
||||
title=_('File'),
|
||||
parent=self.menuBar(),
|
||||
)
|
||||
|
||||
self.editMenu = AppQMenu(
|
||||
AppQAction(
|
||||
_('Undo'),
|
||||
icon=bootstrapIcon('arrow-return-left.svg'),
|
||||
callback=lambda: self.jsonEditor.undo(),
|
||||
shortcut=QtCore.QKeyCombination(
|
||||
QtCore.Qt.KeyboardModifier.ControlModifier,
|
||||
QtCore.Qt.Key.Key_Z,
|
||||
),
|
||||
),
|
||||
AppQAction(
|
||||
_('Redo'),
|
||||
icon=bootstrapIcon('arrow-return-right.svg'),
|
||||
callback=lambda: self.jsonEditor.redo(),
|
||||
shortcut=QtCore.QKeyCombination(
|
||||
QtCore.Qt.KeyboardModifier.ControlModifier
|
||||
| QtCore.Qt.KeyboardModifier.ShiftModifier,
|
||||
QtCore.Qt.Key.Key_Z,
|
||||
),
|
||||
),
|
||||
AppQSeperator(),
|
||||
AppQAction(
|
||||
_('Cut'),
|
||||
icon=bootstrapIcon('scissors.svg'),
|
||||
callback=lambda: self.jsonEditor.cut(),
|
||||
shortcut=QtCore.QKeyCombination(
|
||||
QtCore.Qt.KeyboardModifier.ControlModifier,
|
||||
QtCore.Qt.Key.Key_X,
|
||||
),
|
||||
),
|
||||
AppQAction(
|
||||
_('Copy'),
|
||||
icon=bootstrapIcon('files.svg'),
|
||||
callback=lambda: self.jsonEditor.copy(),
|
||||
shortcut=QtCore.QKeyCombination(
|
||||
QtCore.Qt.KeyboardModifier.ControlModifier,
|
||||
QtCore.Qt.Key.Key_C,
|
||||
),
|
||||
),
|
||||
AppQAction(
|
||||
_('Paste'),
|
||||
callback=lambda: self.jsonEditor.paste(),
|
||||
shortcut=QtCore.QKeyCombination(
|
||||
QtCore.Qt.KeyboardModifier.ControlModifier,
|
||||
QtCore.Qt.Key.Key_V,
|
||||
),
|
||||
),
|
||||
AppQSeperator(),
|
||||
AppQAction(
|
||||
_('Select All'),
|
||||
callback=lambda: self.jsonEditor.selectAll(),
|
||||
shortcut=QtCore.QKeyCombination(
|
||||
QtCore.Qt.KeyboardModifier.ControlModifier,
|
||||
QtCore.Qt.Key.Key_A,
|
||||
),
|
||||
),
|
||||
AppQSeperator(),
|
||||
AppQAction(
|
||||
_('Indent...'),
|
||||
callback=lambda: self.setIndent(),
|
||||
),
|
||||
title=_('Edit'),
|
||||
parent=self.menuBar(),
|
||||
)
|
||||
|
||||
self.viewMenu = AppQMenu(
|
||||
AppQAction(
|
||||
_('Zoom In'),
|
||||
callback=lambda: self.jsonEditor.zoomIn(),
|
||||
shortcut=QtCore.QKeyCombination(
|
||||
QtCore.Qt.KeyboardModifier.ControlModifier,
|
||||
QtCore.Qt.Key.Key_Plus,
|
||||
),
|
||||
),
|
||||
AppQAction(
|
||||
_('Zoom Out'),
|
||||
callback=lambda: self.jsonEditor.zoomOut(),
|
||||
shortcut=QtCore.QKeyCombination(
|
||||
QtCore.Qt.KeyboardModifier.ControlModifier,
|
||||
QtCore.Qt.Key.Key_Minus,
|
||||
),
|
||||
),
|
||||
title=_('View'),
|
||||
parent=self.menuBar(),
|
||||
)
|
||||
|
||||
self.menuBar().addMenu(self.fileMenu)
|
||||
self.menuBar().addMenu(self.editMenu)
|
||||
self.menuBar().addMenu(self.viewMenu)
|
||||
|
||||
def markAsModified(self):
|
||||
self.modified = True
|
||||
self.setWindowTitle(self.customWindowTitle + self.modifiedMark)
|
||||
|
||||
def markAsSaved(self):
|
||||
self.modified = False
|
||||
self.setWindowTitle(self.customWindowTitle)
|
||||
|
||||
def questionSave(self):
|
||||
if self.modified:
|
||||
mbox = QuestionSaveMBox(icon=AppQMessageBox.Icon.Question)
|
||||
code = mbox.exec()
|
||||
|
||||
if code == PySide6LegacyEnumValueWrapper(
|
||||
AppQMessageBox.ButtonRole.AcceptRole
|
||||
):
|
||||
if self.save():
|
||||
self.hide()
|
||||
if code == PySide6LegacyEnumValueWrapper(
|
||||
AppQMessageBox.ButtonRole.DestructiveRole
|
||||
):
|
||||
self.markAsSaved()
|
||||
self.hide()
|
||||
if code == PySide6LegacyEnumValueWrapper(
|
||||
AppQMessageBox.ButtonRole.RejectRole
|
||||
):
|
||||
# Cancel. Do nothing
|
||||
pass
|
||||
else:
|
||||
self.hide()
|
||||
|
||||
def setPlainText(self, text: str, blockSignals: bool):
|
||||
if blockSignals:
|
||||
with QBlockSignals(self.jsonEditor):
|
||||
self.jsonEditor.setPlainText(text)
|
||||
else:
|
||||
self.jsonEditor.setPlainText(text)
|
||||
|
||||
def save(self) -> bool:
|
||||
index = self.currentIndex
|
||||
|
||||
if index < 0:
|
||||
# Should not reach here. Do nothing
|
||||
self.markAsSaved()
|
||||
|
||||
return True
|
||||
|
||||
plain = self.jsonEditor.toPlainText()
|
||||
|
||||
try:
|
||||
jsonObject = JSONEncoder.decode(plain)
|
||||
except Exception as ex:
|
||||
# Any non-exit exceptions
|
||||
|
||||
mbox = JSONDecodeErrorMBox(icon=AppQMessageBox.Icon.Critical)
|
||||
mbox.error = str(ex)
|
||||
mbox.setWindowTitle(_('Error saving configuration'))
|
||||
mbox.setText(mbox.customText())
|
||||
|
||||
# Show the MessageBox and wait for user to close it
|
||||
mbox.exec()
|
||||
|
||||
return False
|
||||
else:
|
||||
old = AS_UserServers()[index]
|
||||
new = constructFromDict(jsonObject, **old.kwargs)
|
||||
|
||||
FastItemDeletionSearch.moveToTrash(old)
|
||||
|
||||
AS_UserServers()[index] = new
|
||||
|
||||
try:
|
||||
APP().mainWindow.flushRow(index, new)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
pass
|
||||
|
||||
if index == AS_UserActivatedItemIndex():
|
||||
try:
|
||||
if APP().isSystemTrayConnected():
|
||||
mbox = NewChangesNextTimeMBox()
|
||||
mbox.exec()
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
pass
|
||||
|
||||
self.markAsSaved()
|
||||
|
||||
return True
|
||||
|
||||
def saveAsFile(self):
|
||||
filename, selectedFilter = QFileDialog.getSaveFileName(
|
||||
None, _('Save File'), filter=_('Text files (*.json);;All files (*)')
|
||||
)
|
||||
|
||||
if filename:
|
||||
try:
|
||||
with open(filename, 'w', encoding='utf-8') as file:
|
||||
file.write(self.jsonEditor.toPlainText())
|
||||
except Exception as ex:
|
||||
# Any non-exit exceptions
|
||||
|
||||
mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Critical)
|
||||
mbox.setWindowTitle(_('Error Saving File'))
|
||||
mbox.setText(_('Invalid server configuration'))
|
||||
mbox.setInformativeText(str(ex))
|
||||
|
||||
# Show the MessageBox and wait for user to close it
|
||||
mbox.exec()
|
||||
|
||||
def setIndent(self):
|
||||
code = self.indentSpinBox.exec()
|
||||
|
||||
if code == PySide6LegacyEnumValueWrapper(AppQDialog.DialogCode.Accepted):
|
||||
plain = self.jsonEditor.toPlainText()
|
||||
|
||||
try:
|
||||
jsonObject = JSONEncoder.decode(plain)
|
||||
except Exception as ex:
|
||||
# Any non-exit exceptions
|
||||
|
||||
mbox = JSONDecodeErrorMBox(icon=AppQMessageBox.Icon.Critical)
|
||||
mbox.error = str(ex)
|
||||
mbox.setWindowTitle(_('Error setting indent'))
|
||||
mbox.setText(mbox.customText())
|
||||
|
||||
# Show the MessageBox and wait for user to close it
|
||||
mbox.exec()
|
||||
else:
|
||||
text = JSONEncoder.encode(jsonObject, indent=self.indentSpinBox.value())
|
||||
|
||||
self.setPlainText(text, False)
|
||||
else:
|
||||
# Do nothing
|
||||
pass
|
||||
|
||||
def showTabAndSpaces(self):
|
||||
textOption = QTextOption()
|
||||
textOption.setFlags(QTextOption.Flag.ShowTabsAndSpaces)
|
||||
|
||||
self.jsonEditor.document().setDefaultTextOption(textOption)
|
||||
# Reset. Set
|
||||
self.jsonEditor.setLineWrapMode(QPlainTextEdit.LineWrapMode.WidgetWidth)
|
||||
self.jsonEditor.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
|
||||
|
||||
def hideTabAndSpaces(self):
|
||||
textOption = QTextOption()
|
||||
|
||||
self.jsonEditor.document().setDefaultTextOption(textOption)
|
||||
# Reset. Set
|
||||
self.jsonEditor.setLineWrapMode(QPlainTextEdit.LineWrapMode.WidgetWidth)
|
||||
self.jsonEditor.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
|
||||
|
||||
def closeEvent(self, event: QtCore.QEvent):
|
||||
event.ignore()
|
||||
|
||||
self.questionSave()
|
||||
|
||||
def retranslate(self):
|
||||
# Do nothing
|
||||
pass
|
||||
@@ -0,0 +1,177 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from Furious.QtFramework import *
|
||||
from Furious.QtFramework import gettext as _
|
||||
from Furious.Library import *
|
||||
from Furious.Utility import *
|
||||
from Furious.Widget.UserSubsQTableWidget import *
|
||||
|
||||
from PySide6 import QtWidgets
|
||||
from PySide6.QtGui import *
|
||||
from PySide6.QtWidgets import *
|
||||
from PySide6.QtNetwork import *
|
||||
|
||||
import uuid
|
||||
import functools
|
||||
|
||||
__all__ = ['UserSubsWindow']
|
||||
|
||||
registerAppSettings('SubscriptionWidgetWindowSize')
|
||||
|
||||
needTrans = functools.partial(needTransFn, source=__name__)
|
||||
|
||||
needTrans(
|
||||
'Add Subscription',
|
||||
'Enter subscription remark:',
|
||||
'Enter subscription URL:',
|
||||
'Cancel',
|
||||
)
|
||||
|
||||
|
||||
class AddSubsDialog(AppQDialog):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.setWindowTitle(_('Add Subscription'))
|
||||
|
||||
self.remarkText = QLabel(_('Enter subscription remark:'))
|
||||
self.remarkEdit = QLineEdit()
|
||||
|
||||
self.webURLText = QLabel(_('Enter subscription URL:'))
|
||||
self.webURLEdit = QLineEdit()
|
||||
|
||||
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.remarkText)
|
||||
layout.addRow(self.remarkEdit)
|
||||
layout.addRow(self.webURLText)
|
||||
layout.addRow(self.webURLEdit)
|
||||
layout.addRow(self.dialogBtns)
|
||||
layout.setFormAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def setWidthAndHeight(self):
|
||||
self.setGeometry(100, 100, 456, 150)
|
||||
|
||||
def subsRemark(self):
|
||||
return self.remarkEdit.text()
|
||||
|
||||
def subsWebURL(self):
|
||||
return self.webURLEdit.text()
|
||||
|
||||
def retranslate(self):
|
||||
self.setWindowTitle(_(self.windowTitle()))
|
||||
|
||||
self.remarkText.setText(_(self.remarkText.text()))
|
||||
self.webURLText.setText(_(self.webURLText.text()))
|
||||
|
||||
for button in self.dialogBtns.buttons():
|
||||
button.setText(_(button.text()))
|
||||
|
||||
moveToCenter(self)
|
||||
|
||||
|
||||
needTrans(
|
||||
'Edit Subscription',
|
||||
'Subscription List',
|
||||
'Add',
|
||||
'Delete',
|
||||
)
|
||||
|
||||
|
||||
class UserSubsWindow(AppQMainWindow):
|
||||
def __init__(self, *args, **kwargs):
|
||||
callback = kwargs.pop('deleteUniqueCallback', None)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.setWindowTitle(_('Edit Subscription'))
|
||||
|
||||
self.addSubsDialog = AddSubsDialog()
|
||||
self.userSubsQTableWidget = UserSubsQTableWidget(deleteUniqueCallback=callback)
|
||||
|
||||
self.userSubsTab = AppQTabWidget(self)
|
||||
self.userSubsTab.addTab(self.userSubsQTableWidget, _('Subscription List'))
|
||||
|
||||
# Buttons
|
||||
self.addButton = AppQPushButton(_('Add'))
|
||||
self.addButton.clicked.connect(lambda: self.addSubs())
|
||||
self.deleteButton = AppQPushButton(_('Delete'))
|
||||
self.deleteButton.clicked.connect(lambda: self.deleteSelectedItem())
|
||||
|
||||
# Button Layout
|
||||
self.buttonWidget = QWidget()
|
||||
self.buttonWidgetLayout = QGridLayout(parent=self.buttonWidget)
|
||||
self.buttonWidgetLayout.addWidget(self.addButton, 0, 0)
|
||||
self.buttonWidgetLayout.addWidget(self.deleteButton, 0, 1)
|
||||
|
||||
self.fakeCentralWidget = QWidget()
|
||||
self.fakeCentralWidgetLayout = QVBoxLayout(self.fakeCentralWidget)
|
||||
self.fakeCentralWidgetLayout.addWidget(self.userSubsTab)
|
||||
self.fakeCentralWidgetLayout.addWidget(self.buttonWidget)
|
||||
|
||||
self.setCentralWidget(self.fakeCentralWidget)
|
||||
|
||||
def setWidthAndHeight(self):
|
||||
try:
|
||||
windowSize = AppSettings.get('SubscriptionWidgetWindowSize').split(',')
|
||||
|
||||
self.setGeometry(100, 100, *list(int(size) for size in windowSize))
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
|
||||
self.setGeometry(100, 100, 360 * GOLDEN_RATIO, 360)
|
||||
|
||||
def addSubs(self):
|
||||
choice = self.addSubsDialog.exec()
|
||||
|
||||
if choice == PySide6LegacyEnumValueWrapper(QDialog.DialogCode.Accepted):
|
||||
remark = self.addSubsDialog.subsRemark()
|
||||
webURL = self.addSubsDialog.subsWebURL()
|
||||
|
||||
if remark:
|
||||
# Unique id. Used by display and deletion
|
||||
unique = str(uuid.uuid4())
|
||||
|
||||
self.userSubsQTableWidget.appendNewItem(
|
||||
unique=unique, remark=remark, webURL=webURL
|
||||
)
|
||||
else:
|
||||
# Do nothing
|
||||
pass
|
||||
|
||||
def deleteSelectedItem(self):
|
||||
self.userSubsQTableWidget.deleteSelectedItem()
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
if event.key() == QtCore.Qt.Key.Key_Delete:
|
||||
self.deleteSelectedItem()
|
||||
else:
|
||||
super().keyPressEvent(event)
|
||||
|
||||
def cleanup(self):
|
||||
AppSettings.set(
|
||||
'SubscriptionWidgetWindowSize',
|
||||
f'{self.geometry().width()},{self.geometry().height()}',
|
||||
)
|
||||
@@ -0,0 +1,112 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.Interface import *
|
||||
from Furious.QtFramework import *
|
||||
from Furious.QtFramework import gettext as _
|
||||
from Furious.Library import *
|
||||
from Furious.Utility import *
|
||||
from Furious.Widget.XrayAssetViewerQListWidget import *
|
||||
|
||||
from PySide6.QtGui import *
|
||||
from PySide6.QtWidgets import *
|
||||
|
||||
import logging
|
||||
import functools
|
||||
|
||||
__all__ = ['XrayAssetViewerWindow']
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
needTrans = functools.partial(needTransFn, source=__name__)
|
||||
|
||||
needTrans(
|
||||
'Xray-core Asset File',
|
||||
'Refresh',
|
||||
'Open Asset Directory',
|
||||
'Import From File...',
|
||||
'Exit',
|
||||
'File',
|
||||
'Import File',
|
||||
'All files (*)',
|
||||
)
|
||||
|
||||
|
||||
class XrayAssetViewerWindow(AppQMainWindow):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.setWindowTitle(_('Xray-core Asset File'))
|
||||
|
||||
self.xrayAssetViewerWidget = XrayAssetViewerQListWidget()
|
||||
self.setCentralWidget(self.xrayAssetViewerWidget)
|
||||
|
||||
self.fileMenu = AppQMenu(
|
||||
AppQAction(
|
||||
_('Refresh'),
|
||||
callback=lambda: self.flushItem(),
|
||||
shortcut=QtCore.QKeyCombination(
|
||||
QtCore.Qt.KeyboardModifier.ControlModifier,
|
||||
QtCore.Qt.Key.Key_R,
|
||||
),
|
||||
),
|
||||
AppQSeperator(),
|
||||
AppQAction(
|
||||
_('Open Asset Directory'),
|
||||
callback=lambda: self.openAssetDirectory(),
|
||||
shortcut=QtCore.QKeyCombination(
|
||||
QtCore.Qt.KeyboardModifier.ControlModifier,
|
||||
QtCore.Qt.Key.Key_O,
|
||||
),
|
||||
),
|
||||
AppQAction(
|
||||
_('Import From File...'),
|
||||
callback=lambda: self.appendNewItem(),
|
||||
),
|
||||
AppQSeperator(),
|
||||
AppQAction(
|
||||
_('Exit'),
|
||||
callback=lambda: self.hide(),
|
||||
),
|
||||
title=_('File'),
|
||||
parent=self.menuBar(),
|
||||
)
|
||||
|
||||
self.menuBar().addMenu(self.fileMenu)
|
||||
|
||||
def setWidthAndHeight(self):
|
||||
self.setGeometry(100, 100, 360, 360 * GOLDEN_RATIO)
|
||||
|
||||
def flushItem(self):
|
||||
self.xrayAssetViewerWidget.flushItem()
|
||||
|
||||
@staticmethod
|
||||
def openAssetDirectory():
|
||||
if QDesktopServices.openUrl(QtCore.QUrl.fromLocalFile(XRAY_ASSET_DIR)):
|
||||
logger.info(f'open Xray-core asset dir success')
|
||||
else:
|
||||
logger.error(f'open Xray-core asset dir failed')
|
||||
|
||||
def appendNewItem(self):
|
||||
filename, selectedFilter = QFileDialog.getOpenFileName(
|
||||
None, _('Import File'), filter=_('All files (*)')
|
||||
)
|
||||
|
||||
if filename:
|
||||
self.xrayAssetViewerWidget.appendNewItem(filename)
|
||||
@@ -0,0 +1,23 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from .AppMainWindow import *
|
||||
from .LogViewerWindow import *
|
||||
from .QRCodeWindow import *
|
||||
from .TextEditorWindow import *
|
||||
from .UserSubsWindow import *
|
||||
from .XrayAssetViewerWindow import *
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2023 Loren Eteval <loren.eteval@proton.me>
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
@@ -15,4 +15,4 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from .Utility import Resources
|
||||
from .Utility import AppResources
|
||||
|
||||
+43
-28
@@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2023 Loren Eteval <loren.eteval@proton.me>
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
@@ -15,12 +15,11 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from Furious.Interface import *
|
||||
from Furious.QtFramework import *
|
||||
from Furious.QtFramework import gettext as _
|
||||
from Furious.Utility import *
|
||||
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, enumValueWrapper
|
||||
from Furious.Utility.Process import Process
|
||||
from Furious.Utility.Translator import gettext as _
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtGui import QDesktopServices
|
||||
@@ -28,19 +27,33 @@ from PySide6.QtGui import QDesktopServices
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
import functools
|
||||
import traceback
|
||||
|
||||
__all__ = ['main']
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
needTrans = functools.partial(needTransFn, source=__name__)
|
||||
|
||||
needTrans(
|
||||
f'{APPLICATION_NAME} is not able to run on this operating system',
|
||||
'Operating system information',
|
||||
f'{APPLICATION_NAME} encountered an internal error and needs to be stopped',
|
||||
f'{APPLICATION_NAME} stopped unexpectedly due to an unknown exception',
|
||||
'Crash log has been saved to',
|
||||
'Open crash log',
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
process = Process()
|
||||
appMainProcess = AppMainProcess(functools.partial(Application, sys.argv))
|
||||
|
||||
process.start()
|
||||
process.join()
|
||||
appMainProcess.start()
|
||||
appMainProcess.join()
|
||||
|
||||
exitcode = process.exitcode
|
||||
exitcode = appMainProcess.exitcode
|
||||
|
||||
if exitcode == 0:
|
||||
sys.exit(exitcode)
|
||||
@@ -48,18 +61,19 @@ def main():
|
||||
# For Qt runtime. Not used
|
||||
_app = Application(sys.argv)
|
||||
|
||||
if exitcode == Application.ErrorCode.PlatformNotSupported:
|
||||
messageBox = MessageBox(icon=MessageBox.Icon.Critical)
|
||||
if exitcode == ApplicationFactory.ExitCode.PlatformNotSupported:
|
||||
messageBox = AppQMessageBox(icon=AppQMessageBox.Icon.Critical)
|
||||
|
||||
messageBox.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg'))
|
||||
messageBox.setWindowTitle(_(APPLICATION_NAME))
|
||||
messageBox.setText(
|
||||
_(f'{APPLICATION_NAME} is not able to run on this operating system.')
|
||||
_(f'{APPLICATION_NAME} is not able to run on this operating system')
|
||||
)
|
||||
|
||||
if hasattr(os, 'uname'):
|
||||
messageBox.setInformativeText(
|
||||
f'{_("Operating system information:")}\n'
|
||||
_('Operating system information')
|
||||
+ '\n'
|
||||
+ '\n'.join(
|
||||
list(
|
||||
f'{arg}: {val}'
|
||||
@@ -77,43 +91,44 @@ def main():
|
||||
)
|
||||
)
|
||||
|
||||
# Show the MessageBox and wait for user to close it
|
||||
# Show the AppQMessageBox and wait for user to close it
|
||||
messageBox.exec()
|
||||
else:
|
||||
if exitcode == Application.ErrorCode.AssertionError:
|
||||
if exitcode == ApplicationFactory.ExitCode.AssertionError:
|
||||
# Assertion error
|
||||
text = _(
|
||||
f'{APPLICATION_NAME} encountered an internal error and needs to be stopped.'
|
||||
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.'
|
||||
f'{APPLICATION_NAME} stopped unexpectedly due to an unknown exception'
|
||||
)
|
||||
|
||||
messageBox = MessageBox(icon=MessageBox.Icon.Critical)
|
||||
messageBox = AppQMessageBox(icon=AppQMessageBox.Icon.Critical)
|
||||
|
||||
messageBox.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg'))
|
||||
messageBox.setWindowTitle(_(APPLICATION_NAME))
|
||||
messageBox.setText(text)
|
||||
|
||||
if process.fileWritten.value:
|
||||
if appMainProcess.fileWritten.value:
|
||||
# Crash log saved
|
||||
|
||||
crashLogFile = str(CRASH_LOG_DIR / process.logFileName)
|
||||
crashLogFile = str(CRASH_LOG_DIR / appMainProcess.logFileName)
|
||||
|
||||
messageBox.setInformativeText(
|
||||
_('Crash log has been saved to: ') + f'{crashLogFile}'
|
||||
_('Crash log has been saved to') + f' {crashLogFile}'
|
||||
)
|
||||
messageBox.addButton(
|
||||
_('Open crash log'), MessageBox.ButtonRole.AcceptRole
|
||||
_('Open crash log'), AppQMessageBox.ButtonRole.AcceptRole
|
||||
)
|
||||
messageBox.addButton(_('OK'), MessageBox.ButtonRole.RejectRole)
|
||||
messageBox.addButton(_('OK'), AppQMessageBox.ButtonRole.RejectRole)
|
||||
|
||||
# Show the MessageBox and wait for user to close it
|
||||
# Show the AppQMessageBox and wait for user to close it
|
||||
choice = messageBox.exec()
|
||||
|
||||
if choice == enumValueWrapper(MessageBox.ButtonRole.AcceptRole):
|
||||
if choice == PySide6LegacyEnumValueWrapper(
|
||||
AppQMessageBox.ButtonRole.AcceptRole
|
||||
):
|
||||
# Open
|
||||
if QDesktopServices.openUrl(
|
||||
QtCore.QUrl.fromLocalFile(crashLogFile)
|
||||
@@ -127,7 +142,7 @@ def main():
|
||||
else:
|
||||
# Crash log not saved
|
||||
|
||||
# Show the MessageBox and wait for user to close it
|
||||
# Show the AppQMessageBox and wait for user to close it
|
||||
messageBox.exec()
|
||||
|
||||
sys.exit(exitcode)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2023 Loren Eteval <loren.eteval@proton.me>
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
@@ -15,11 +15,8 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from PySide6.QtGui import QIcon
|
||||
from Furious.__main__ import main
|
||||
|
||||
|
||||
class Icon(QIcon):
|
||||
def __init__(self, iconFileName):
|
||||
super().__init__(iconFileName)
|
||||
|
||||
self.iconFileName = iconFileName
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.Core import *
|
||||
from Furious.Interface import *
|
||||
from Furious.Library import *
|
||||
from Furious.Utility import *
|
||||
from Furious.PyFramework import *
|
||||
from Furious.QtFramework import *
|
||||
from Furious.Storage import *
|
||||
from Furious.TrayActions import *
|
||||
from Furious.Widget import *
|
||||
from Furious.Window import *
|
||||
from Furious.__main__ import *
|
||||
from Furious.Externals import *
|
||||
|
||||
import copy
|
||||
import deepl
|
||||
import logging
|
||||
import argparse
|
||||
|
||||
logging.basicConfig(
|
||||
format='[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s',
|
||||
level=logging.INFO,
|
||||
)
|
||||
logging.raiseExceptions = False
|
||||
|
||||
logger = logging.getLogger('Translation')
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('-k', '--key', help='DeepL auth key', required=True)
|
||||
parser.add_argument(
|
||||
'-t', '--target', help='Target translation language', required=True
|
||||
)
|
||||
parser.add_argument(
|
||||
'-i', '--ignore', action='store_true', help='If provided, ignore review value'
|
||||
)
|
||||
parser.add_argument('-p', '--proxy', help='Proxy server used in API')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
target, proxy = args.target, args.proxy
|
||||
|
||||
logger.info(f'target translation language: {target}')
|
||||
logger.info(f'use proxy: {proxy}')
|
||||
|
||||
translation = copy.deepcopy(TRANSLATION)
|
||||
|
||||
for text, source in TranslationPool:
|
||||
if text not in translation:
|
||||
translation[text] = {'source': [source]}
|
||||
else:
|
||||
unit = translation[text]
|
||||
|
||||
if source not in unit['source']:
|
||||
unit['source'].append(source)
|
||||
|
||||
translator = deepl.Translator(args.key, send_platform_info=False, proxy=args.proxy)
|
||||
|
||||
for text in translation.keys():
|
||||
# Remove redundant EN translation
|
||||
translation[text].pop('EN', '')
|
||||
|
||||
targetText = translation[text].get(target, '')
|
||||
isReviewed = translation[text].get('isReviewed', 'False')
|
||||
|
||||
if targetText and isReviewed == 'True' and not args.ignore:
|
||||
# Translation already reviewed. Skip
|
||||
logger.info(
|
||||
f'skip reviewed translation: \'{text}\' --{target}--> \'{targetText}\''
|
||||
)
|
||||
else:
|
||||
result = translator.translate_text(
|
||||
text,
|
||||
source_lang='EN',
|
||||
target_lang=target,
|
||||
context=(
|
||||
f'\'{APPLICATION_NAME}\' is application name. '
|
||||
'Please do not translate this word'
|
||||
),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'query translation: \'{text}\' --{target}--> \'{result.text}\''
|
||||
)
|
||||
|
||||
translation[text][target] = result.text
|
||||
|
||||
if not targetText or translation[text].get('isReviewed') is None:
|
||||
# Target translation does not exist, or does not have 'isReviewed' field.
|
||||
# Set 'isReviewed' field to "False"
|
||||
translation[text]['isReviewed'] = 'False'
|
||||
|
||||
try:
|
||||
# Write back to file
|
||||
with open(GEN_TRANSLATION_FILE, 'w', encoding='utf-8') as file:
|
||||
file.write(f'TRANSLATION = {UJSONEncoder.encode(translation, indent=4)}\n')
|
||||
except Exception as ex:
|
||||
# Any non-exit exceptions
|
||||
|
||||
logger.error(f'flush result to \'{GEN_TRANSLATION_FILE}\' failed. {ex}')
|
||||
else:
|
||||
logger.info(f'flush result to \'{GEN_TRANSLATION_FILE}\' success')
|
||||
|
||||
unreviewed = 0
|
||||
|
||||
for text in translation.keys():
|
||||
isReviewed = translation[text].get('isReviewed', 'False')
|
||||
|
||||
if isReviewed == 'False':
|
||||
unreviewed += 1
|
||||
|
||||
logger.warning(f'have unreviewed translations \'{text}\'')
|
||||
|
||||
if unreviewed > 0:
|
||||
logger.error(f'have {unreviewed} unreviewed translations')
|
||||
else:
|
||||
logger.info(f'all translations have been reviewed')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2023 Loren Eteval <loren.eteval@proton.me>
|
||||
# Copyright (C) 2024 Loren Eteval <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
@@ -37,7 +37,6 @@ setup(
|
||||
include_package_data=True,
|
||||
install_requires=[
|
||||
'PySide6',
|
||||
'Xray-core',
|
||||
'tun2socks > 2.5.1',
|
||||
'ujson',
|
||||
'pybase64',
|
||||
@@ -49,8 +48,8 @@ setup(
|
||||
'darkdetect[macos-listener]; sys_platform == "darwin"',
|
||||
],
|
||||
extras_require={
|
||||
'go1.20': ['hysteria > 1.3.5', 'hysteria2 == 2.0.0.1'],
|
||||
'go1.21': ['hysteria2 > 2.0.4'],
|
||||
'go1.20': ['Xray-core < 1.8.5', 'hysteria > 1.3.5', 'hysteria2 == 2.0.0.1'],
|
||||
'go1.21': ['Xray-core > 1.8.5', 'hysteria2 > 2.0.4'],
|
||||
},
|
||||
entry_points={
|
||||
'gui_scripts': [
|
||||
|
||||
Reference in New Issue
Block a user