Sync rework changes

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2024-02-07 12:33:03 +08:00
parent 83ec031e04
commit 6fff6f05a9
99 changed files with 13037 additions and 13411 deletions
File diff suppressed because it is too large Load Diff
-262
View File
@@ -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()
-700
View File
@@ -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,
)
-261
View File
@@ -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)
),
),
)
-161
View File
@@ -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
-963
View File
@@ -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',
},
}
-455
View File
@@ -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)
+429
View File
@@ -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()
+163
View File
@@ -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
+102
View File
@@ -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
-213
View File
@@ -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 ''
-190
View File
@@ -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
View File
@@ -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()
+167
View File
@@ -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
+6 -1
View File
@@ -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 *
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 *
+34
View File
@@ -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
+216
View File
@@ -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
+96
View File
@@ -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
+31
View File
@@ -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
+55
View File
@@ -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
+31
View File
@@ -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
+63
View File
@@ -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 ''
+24
View File
@@ -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
+81
View File
@@ -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()
+19
View File
@@ -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 *
+177
View File
@@ -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 *
+70
View File
@@ -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()
+207
View File
@@ -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()
+157
View File
@@ -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()
+71
View File
@@ -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()
+112
View File
@@ -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
+120
View File
@@ -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__()
+55
View File
@@ -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
+581
View File
@@ -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'))
+322
View File
@@ -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'}}'
)
+162
View File
@@ -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,
)
)
+29
View File
@@ -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 *
+66
View File
@@ -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()
+60
View File
@@ -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()
+19
View File
@@ -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 *
+351
View File
@@ -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'),
+287
View File
@@ -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())
),
+72
View File
@@ -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
),
),
)
+178
View File
@@ -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
+68
View File
@@ -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',
),
),
)
+25
View File
@@ -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
+132
View File
@@ -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)
+57
View File
@@ -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)
+11 -24
View File
@@ -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'
+46
View File
@@ -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()
-325
View File
@@ -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()
-128
View File
@@ -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}
+8 -13
View File
@@ -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()
+471
View File
@@ -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()
+107
View File
@@ -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
View File
@@ -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())
+12 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
-279
View File
@@ -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
-469
View File
@@ -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)
-68
View File
@@ -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()
+23 -21
View File
@@ -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()))
-296
View File
@@ -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()
+36 -50
View File
@@ -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
-223
View File
@@ -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
+236
View File
@@ -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))
-530
View File
@@ -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)
+8 -1
View File
@@ -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 *
+405
View File
@@ -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()}',
)
+183
View File
@@ -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()
+81
View File
@@ -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()
+425
View File
@@ -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
+177
View File
@@ -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()}',
)
+112
View File
@@ -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)
+23
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+4 -7
View File
@@ -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
View File
@@ -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()
+3 -4
View File
@@ -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': [