diff --git a/Furious/Action/Connect.py b/Furious/Action/Connect.py index 11adf4d..81c73eb 100644 --- a/Furious/Action/Connect.py +++ b/Furious/Action/Connect.py @@ -1,11 +1,11 @@ from Furious.Core.Core import XrayCore, Hysteria from Furious.Core.Intellisense import Intellisense from Furious.Core.Configuration import Configuration -from Furious.Action.Routing import SUPPORTED_ROUTING_DETAIL +from Furious.Action.Routing import BUILTIN_ROUTING_TABLE, BUILTIN_ROUTING from Furious.Gui.Action import Action from Furious.Widget.ConnectingProgressBar import ConnectingProgressBar from Furious.Widget.Widget import MessageBox -from Furious.Utility.Constants import APPLICATION_NAME, PROXY_SERVER_BYPASS +from Furious.Utility.Constants import APP, APPLICATION_NAME, PROXY_SERVER_BYPASS from Furious.Utility.Utility import ( Switch, SupportConnectedCallback, @@ -16,7 +16,6 @@ from Furious.Utility.Translator import gettext as _ from Furious.Utility.Proxy import Proxy from PySide6 import QtCore -from PySide6.QtWidgets import QApplication from PySide6.QtNetwork import ( QNetworkAccessManager, QNetworkReply, @@ -180,7 +179,7 @@ class ConnectAction(Action): self.Hysteria.stop() def showConnectingProgressBar(self): - if QApplication.instance().ShowProgressBarWhenConnecting == Switch.ON_: + if APP().ShowProgressBarWhenConnecting == Switch.ON_: self.connectingProgressBar.progressBar.setValue(0) # Update the progress bar every 50ms self.connectingProgressBar.timer.start(50) @@ -209,7 +208,7 @@ class ConnectAction(Action): def setDisabledAction(self, value): self.setDisabled(value) - QApplication.instance().tray.RoutingAction.setDisabled(value) + APP().tray.RoutingAction.setDisabled(value) def setConnectingStatus(self, showProgressBar=True): if showProgressBar: @@ -220,13 +219,13 @@ class ConnectAction(Action): self.setText(_('Connecting')) self.setIcon(bootstrapIcon('lock-fill.svg')) - QApplication.instance().tray.setPlainIcon() + APP().tray.setPlainIcon() def setConnectedStatus(self): self.hideConnectingProgressBar(done=True) self.setDisabledAction(False) - QApplication.instance().tray.setConnectedIcon() + APP().tray.setConnectedIcon() # Finished. Reset connecting flag self.connectingFlag = False @@ -255,8 +254,8 @@ class ConnectAction(Action): self.setIcon(bootstrapIcon('unlock-fill.svg')) self.setChecked(False) - QApplication.instance().Connect = Switch.OFF - QApplication.instance().tray.setPlainIcon() + APP().Connect = Switch.OFF + APP().tray.setPlainIcon() self.proxyServer = '' @@ -276,12 +275,12 @@ class ConnectAction(Action): @property def MainWidget(self): # Handy reference - return QApplication.instance().MainWidget + return APP().MainWidget @property def activatedServer(self): try: - activatedIndex = int(QApplication.instance().ActivatedItemIndex) + activatedIndex = int(APP().ActivatedItemIndex) if activatedIndex < 0: return None @@ -395,10 +394,9 @@ class ConnectAction(Action): if validateProxyServer(proxyServer): self.coreRunning = True - routing = QApplication.instance().Routing + routing = APP().Routing - logger.info(f'Core {XrayCore.name()} configured') - logger.info(f'Routing is {routing}') + logger.info(f'core {XrayCore.name()} configured') def fixLoggingRelativePath(attr): # Relative path fails if booting on start up @@ -426,53 +424,35 @@ class ConnectAction(Action): fixLoggingRelativePath('access') fixLoggingRelativePath('error') - if routing == 'Bypass': - self.coreJSON['routing'] = { - 'domainStrategy': 'IPIfNonMatch', - 'domainMatcher': 'hybrid', - 'rules': ( - # ads - { - 'type': 'field', - 'outboundTag': 'block', - 'domain': ['geosite:category-ads-all'], - }, - # geosite - { - 'type': 'field', - 'outboundTag': 'direct', - 'domain': ['geosite:cn'], - }, - # geoip - { - 'type': 'field', - 'outboundTag': 'direct', - 'ip': ['geoip:private', 'geoip:cn'], - }, - # Proxy everything - { - 'type': 'field', - 'port': '0-65535', - 'outboundTag': 'proxy', - }, - ), - } - if routing == 'Global': - self.coreJSON['routing'] = { - 'domainStrategy': 'IPIfNonMatch', - 'domainMatcher': 'hybrid', - 'rules': ( - # Proxy everything - { - 'type': 'field', - 'port': '0-65535', - 'outboundTag': 'proxy', - }, - ), - } - if routing == 'Custom': + # Filter Custom + if routing in BUILTIN_ROUTING[:-1]: + routingObject = BUILTIN_ROUTING_TABLE[routing][XrayCore.name()] + + logger.info(f'routing is {routing}') + logger.info(f'RoutingObject: {routingObject}') + + self.coreJSON['routing'] = routingObject + elif routing == 'Custom': + logger.info(f'routing is {routing}') + logger.info(f'RoutingObject: {self.XrayRouting}') + # Assign user routing self.coreJSON['routing'] = self.XrayRouting + else: + try: + routingWidget = APP().editRoutingWidget + + route = routingWidget.RoutesList[int(routing)] + + logger.info(f'routing is {route["remark"]}') + logger.info(f'RoutingObject: {route[XrayCore.name()]}') + + self.coreJSON['routing'] = route[XrayCore.name()] + except Exception: + # Any non-exit exceptions + + # Fast fail + self.coreJSON = {} # Refresh configuration modified before. User cannot feel self.coreText = ujson.dumps( @@ -498,33 +478,52 @@ class ConnectAction(Action): if validateProxyServer(proxyServer): self.coreRunning = True - routing = QApplication.instance().Routing + routing = APP().Routing - logger.info(f'Core {Hysteria.name()} configured') - logger.info(f'Routing is {routing}') + logger.info(f'core {Hysteria.name()} configured') + + # Filter Global, Custom + if routing in BUILTIN_ROUTING[:-2]: + logger.info(f'routing is {routing}') + + routingObject = BUILTIN_ROUTING_TABLE[routing][Hysteria.name()] - if routing == 'Bypass': self.Hysteria.start( - self.coreText, Hysteria.rule(), Hysteria.mmdb() + self.coreText, + Hysteria.rule(routingObject.get('acl')), + Hysteria.mmdb(routingObject.get('mmdb')), ) - if routing == 'Global': + elif routing == 'Global': + logger.info(f'routing is {routing}') + self.Hysteria.start(self.coreText, '', '') - if routing == 'Custom': - if self.coreJSON.get('acl') is not None: - # acl specified - if self.coreJSON.get('mmdb') is not None: - # mmdb specified. Start "as is" - self.Hysteria.start( - self.coreText, - Hysteria.rule(self.coreJSON['acl']), - Hysteria.mmdb(self.coreJSON['mmdb']), - ) - else: - # mmdb not specified. Route as global - self.Hysteria.start(self.coreText, '', '') - else: - # acl not specified. Route as global - self.Hysteria.start(self.coreText, '', '') + elif routing == 'Custom': + logger.info(f'routing is {routing}') + + self.Hysteria.start( + self.coreText, + Hysteria.rule(self.coreJSON.get('acl')), + Hysteria.mmdb(self.coreJSON.get('mmdb')), + ) + else: + try: + routingWidget = APP().editRoutingWidget + + route = routingWidget.RoutesList[int(routing)] + + logger.info(f'routing is {route["remark"]}') + logger.info(f'RoutingObject: {route[Hysteria.name()]}') + + self.Hysteria.start( + self.coreText, + Hysteria.rule(route[Hysteria.name()].get('acl')), + Hysteria.mmdb(route[Hysteria.name()].get('mmdb')), + ) + except Exception: + # Any non-exit exceptions + + # Fast fail + self.Hysteria.start('', '', '') return Hysteria.name() @@ -572,24 +571,28 @@ class ConnectAction(Action): else: logger.info(f'{self.coreName}: connection test success. Connected') - QApplication.instance().Connect = Switch.ON_ + APP().Connect = Switch.ON_ # Connected status self.setConnectedStatus() if showRoutingChangedMessage: # Routing changed - QApplication.instance().tray.showMessage( - _('Routing changed: ') - + _( - f'{SUPPORTED_ROUTING_DETAIL[QApplication.instance().Routing]}' + try: + routingWidget = APP().editRoutingWidget + + route = routingWidget.RoutesList[int(APP().Routing)] + + APP().tray.showMessage( + _('Routing changed: ') + f'{route["remark"]}' + ) + except ValueError: + APP().tray.showMessage( + _('Routing changed: ') + _(f'{APP().Routing}') ) - ) else: # Connected - QApplication.instance().tray.showMessage( - f'{self.coreName}: {_("Connected")}' - ) + APP().tray.showMessage(f'{self.coreName}: {_("Connected")}') self.networkReply.finished.connect(finishedCallback) @@ -600,11 +603,8 @@ class ConnectAction(Action): # Connecting self.connectingFlag = True - if ( - not QApplication.instance().Configuration - or len(self.MainWidget.ServerList) == 0 - ): - QApplication.instance().Connect = Switch.OFF + if not APP().Configuration or len(self.MainWidget.ServerList) == 0: + APP().Connect = Switch.OFF self.setChecked(False) self.connectingFlag = False @@ -615,7 +615,7 @@ class ConnectAction(Action): myText = self.activatedServer if myText is None: - QApplication.instance().Connect = Switch.OFF + APP().Connect = Switch.OFF self.setChecked(False) self.connectingFlag = False @@ -624,7 +624,7 @@ class ConnectAction(Action): return if myText == '': - QApplication.instance().Connect = Switch.OFF + APP().Connect = Switch.OFF self.setChecked(False) self.connectingFlag = False @@ -637,7 +637,7 @@ class ConnectAction(Action): except Exception: # Any non-exit exceptions - QApplication.instance().Connect = Switch.OFF + APP().Connect = Switch.OFF self.setChecked(False) self.connectingFlag = False @@ -657,6 +657,9 @@ class ConnectAction(Action): self.connectingAction() def connectingAction(self, showProgressBar=True, showRoutingChangedMessage=False): + # Connecting. Redefined + self.connectingFlag = True + # Connecting status self.setConnectingStatus(showProgressBar) @@ -703,7 +706,7 @@ class ConnectAction(Action): SupportConnectedCallback.callDisconnectedCallback() - QApplication.instance().tray.showMessage(reason) + APP().tray.showMessage(reason) def reconnectAction(self, reason=''): self.disconnectAction(reason) diff --git a/Furious/Action/EditConfiguration.py b/Furious/Action/EditConfiguration.py index d10fdfc..a5dc862 100644 --- a/Furious/Action/EditConfiguration.py +++ b/Furious/Action/EditConfiguration.py @@ -1,9 +1,8 @@ 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 PySide6.QtWidgets import QApplication - class EditConfigurationAction(Action): def __init__(self): @@ -13,4 +12,4 @@ class EditConfigurationAction(Action): ) def triggeredCallback(self, checked): - QApplication.instance().MainWidget.show() + APP().MainWidget.show() diff --git a/Furious/Action/Exit.py b/Furious/Action/Exit.py index ee8c112..4e00959 100644 --- a/Furious/Action/Exit.py +++ b/Furious/Action/Exit.py @@ -1,9 +1,8 @@ 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 PySide6.QtWidgets import QApplication - class ExitAction(Action): def __init__(self): @@ -13,4 +12,4 @@ class ExitAction(Action): ) def triggeredCallback(self, checked): - QApplication.instance().exit() + APP().exit() diff --git a/Furious/Action/Export.py b/Furious/Action/Export.py index 8c43883..d2f8ac9 100644 --- a/Furious/Action/Export.py +++ b/Furious/Action/Export.py @@ -2,6 +2,7 @@ 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 _ @@ -110,7 +111,7 @@ class ExportJSONResultBox(MessageBox): def exportLink(selectedIndex): - serverList = QApplication.instance().MainWidget.ServerList + serverList = APP().MainWidget.ServerList serverLink = [] successStr = [] failureStr = [] @@ -140,7 +141,7 @@ class ExportLinkAction(Action): self.exportLinkResult = ExportLinkResultBox('', '', isQRCodeExport=False) def triggeredCallback(self, checked): - selectedIndex = QApplication.instance().MainWidget.selectedIndex + selectedIndex = APP().MainWidget.selectedIndex if len(selectedIndex) == 0: # Nothing selected. Do nothing @@ -166,7 +167,7 @@ class ExportQRCodeAction(Action): self.exportLinkResult = ExportLinkResultBox('', '', isQRCodeExport=True) def triggeredCallback(self, checked): - selectedIndex = QApplication.instance().MainWidget.selectedIndex + selectedIndex = APP().MainWidget.selectedIndex if len(selectedIndex) == 0: # Nothing selected. Do nothing @@ -194,7 +195,7 @@ class ExportJSONAction(Action): self.exportJSONResult = ExportJSONResultBox() def triggeredCallback(self, checked): - selectedIndex = QApplication.instance().MainWidget.selectedIndex + selectedIndex = APP().MainWidget.selectedIndex if len(selectedIndex) == 0: # Nothing selected. Do nothing @@ -203,7 +204,7 @@ class ExportJSONAction(Action): try: QApplication.clipboard().setText( '\n'.join( - QApplication.instance().MainWidget.ServerList[index]['config'] + APP().MainWidget.ServerList[index]['config'] for index in selectedIndex ) ) diff --git a/Furious/Action/Import.py b/Furious/Action/Import.py index a204024..681f739 100644 --- a/Furious/Action/Import.py +++ b/Furious/Action/Import.py @@ -6,11 +6,12 @@ from Furious.Core.Configuration import ( ) 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, - Storage, + ServerStorage, bootstrapIcon, protocolRepr, ) @@ -145,7 +146,7 @@ class ImportLinkAction(Action): if choice == MessageBox.ButtonRole.AcceptRole.value: # Go to edit - QApplication.instance().MainWidget.show() + APP().MainWidget.show() else: # OK. Do nothing pass @@ -186,7 +187,7 @@ class ImportLinkAction(Action): remark = urllib.parse.unquote(getOrDefault('ps')) - QApplication.instance().MainWidget.importServer( + APP().MainWidget.importServer( remark, ujson.dumps( myJSON, @@ -246,7 +247,7 @@ class ImportLinkAction(Action): ) ) - QApplication.instance().MainWidget.importServer( + APP().MainWidget.importServer( remark, ujson.dumps( myJSON, indent=2, ensure_ascii=False, escape_forward_slashes=False @@ -297,7 +298,7 @@ class ImportLinkAction(Action): ) ) - QApplication.instance().MainWidget.importServer( + APP().MainWidget.importServer( remark, ujson.dumps( myJSON, indent=2, ensure_ascii=False, escape_forward_slashes=False @@ -323,7 +324,7 @@ class ImportLinkAction(Action): ProxyOutboundObjectSS(*re.split(r'[@:]', myData)) ) - QApplication.instance().MainWidget.importServer( + APP().MainWidget.importServer( 'sslegacy', ujson.dumps( myJSON, indent=2, ensure_ascii=False, escape_forward_slashes=False @@ -380,7 +381,7 @@ class ImportLinkAction(Action): if result: # Sync it - Storage.sync() + ServerStorage.sync() self.showLinkImportOK(remark) else: @@ -388,7 +389,7 @@ class ImportLinkAction(Action): else: successRemark = [] - rowCount = QApplication.instance().MainWidget.rowCount + rowCount = APP().MainWidget.rowCount for shareLink in splitByNewLine: remark, result = self.parseShareLink(shareLink) @@ -402,7 +403,7 @@ class ImportLinkAction(Action): return # At least one server's been imported. Sync it - Storage.sync() + ServerStorage.sync() if len(successRemark) == 1: # Fall back to single @@ -445,7 +446,7 @@ class ImportJSONAction(Action): if choice == MessageBox.ButtonRole.AcceptRole.value: # Go to edit - QApplication.instance().MainWidget.show() + APP().MainWidget.show() else: # OK. Do nothing pass @@ -460,7 +461,7 @@ class ImportJSONAction(Action): self.showJSONErrorBox() else: - QApplication.instance().MainWidget.importServer( + APP().MainWidget.importServer( _('Untitled'), self.clipboard, syncStorage=True ) diff --git a/Furious/Action/Language.py b/Furious/Action/Language.py index 2ffd54d..30242b2 100644 --- a/Furious/Action/Language.py +++ b/Furious/Action/Language.py @@ -1,5 +1,6 @@ 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, @@ -8,8 +9,6 @@ from Furious.Utility.Translator import ( LANGUAGE_TO_ABBR, ) -from PySide6.QtWidgets import QApplication - import logging logger = logging.getLogger(__name__) @@ -24,10 +23,10 @@ class LanguageChildAction(Action): def triggeredCallback(self, checked): checkedLanguage = LANGUAGE_TO_ABBR[self.text()] - if QApplication.instance().Language != checkedLanguage: + if APP().Language != checkedLanguage: logger.info(f'set language to \'{self.text()}\'') - QApplication.instance().Language = checkedLanguage + APP().Language = checkedLanguage Translatable.retranslateAll() @@ -43,8 +42,7 @@ class LanguageAction(Action): # Language representation text, checkable=True, - checked=text - == ABBR_TO_LANGUAGE[QApplication.instance().Language], + checked=text == ABBR_TO_LANGUAGE[APP().Language], ) for text in list(LANGUAGE_TO_ABBR.keys()) ), diff --git a/Furious/Action/Routing.py b/Furious/Action/Routing.py index 5093296..199babd 100644 --- a/Furious/Action/Routing.py +++ b/Furious/Action/Routing.py @@ -1,50 +1,232 @@ -from Furious.Gui.Action import Action +from Furious.Core.Core import XrayCore, Hysteria +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 _ -from PySide6.QtWidgets import QApplication -SUPPORTED_ROUTING = ('Bypass', 'Global', 'Custom') - -SUPPORTED_ROUTING_DETAIL = { - 'Bypass': 'Bypass Mainland China', - 'Global': 'Global', - 'Custom': 'Custom', +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', + }, + ], + }, + Hysteria.name(): { + 'acl': str(DATA_DIR / 'hysteria' / 'bypass-mainland-China.acl'), + 'mmdb': str(DATA_DIR / 'hysteria' / 'country.mmdb'), + }, + }, + '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", + }, + ], + }, + Hysteria.name(): { + 'acl': str(DATA_DIR / 'hysteria' / 'bypass-Iran.acl'), + 'mmdb': str(DATA_DIR / 'hysteria' / 'country.mmdb'), + }, + }, + 'Global': { + XrayCore.name(): { + 'domainStrategy': 'IPIfNonMatch', + 'domainMatcher': 'hybrid', + 'rules': [ + # Proxy everything + { + 'type': 'field', + 'port': '0-65535', + 'outboundTag': 'proxy', + }, + ], + }, + Hysteria.name(): {}, + }, + 'Custom': { + XrayCore.name(): {}, + Hysteria.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 + 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().editRoutingWidget.activateItemByIndex( + routingToIndex(), activate=False + ) + APP().Routing = textEnglish + # Activate + APP().editRoutingWidget.activateItemByIndex(routingToIndex(), activate=True) + + if APP().tray.ConnectAction.isConnected(): + # Connected. Re-configure connection + APP().tray.ConnectAction.connectingAction( + showProgressBar=True, + showRoutingChangedMessage=True, + ) + class RoutingChildAction(Action): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - def triggeredCallback(self, checked): - for key, value in SUPPORTED_ROUTING_DETAIL.items(): - if self.textCompare(value): - if QApplication.instance().Routing != key: - QApplication.instance().Routing = key + self.translatable = False - if QApplication.instance().tray.ConnectAction.isConnected(): + 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): + # De-activate + APP().editRoutingWidget.activateItemByIndex( + routingToIndex(), activate=False + ) + APP().Routing = str(index) + # Activate + APP().editRoutingWidget.activateItemByIndex( + routingToIndex(), activate=True + ) + + if APP().tray.ConnectAction.isConnected(): # Connected. Re-configure connection - QApplication.instance().tray.ConnectAction.connectingAction( - showProgressBar=False, + APP().tray.ConnectAction.connectingAction( + showProgressBar=True, showRoutingChangedMessage=True, ) + # 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( - RoutingChildAction( - _(SUPPORTED_ROUTING_DETAIL[mode]), + BuiltinRoutingChildAction( + _(routing), checkable=True, - checked=mode == QApplication.instance().Routing, + checked=APP().Routing == routing, ) - for mode in SUPPORTED_ROUTING + for routing in BUILTIN_ROUTING + ), + *list( + RoutingChildAction( + route['remark'], + checkable=True, + checked=APP().Routing == str(index), + ) + for index, route in enumerate(APP().editRoutingWidget.RoutesList) ), ), ) diff --git a/Furious/Action/Settings.py b/Furious/Action/Settings.py index dcd3449..695571d 100644 --- a/Furious/Action/Settings.py +++ b/Furious/Action/Settings.py @@ -1,11 +1,10 @@ from Furious.Gui.Action import Action from Furious.Widget.Widget import Menu +from Furious.Utility.Constants import APP from Furious.Utility.Utility import bootstrapIcon, Switch from Furious.Utility.Translator import gettext as _ from Furious.Utility.StartupOnBoot import StartupOnBoot -from PySide6.QtWidgets import QApplication - class SettingsChildAction(Action): def __init__(self, *args, **kwargs): @@ -16,23 +15,23 @@ class SettingsChildAction(Action): if checked: StartupOnBoot.on_() - QApplication.instance().StartupOnBoot = Switch.ON_ + APP().StartupOnBoot = Switch.ON_ else: StartupOnBoot.off() - QApplication.instance().StartupOnBoot = Switch.OFF + APP().StartupOnBoot = Switch.OFF if self.textCompare('Show Progress Bar When Connecting'): if checked: - QApplication.instance().ShowProgressBarWhenConnecting = Switch.ON_ + APP().ShowProgressBarWhenConnecting = Switch.ON_ else: - QApplication.instance().ShowProgressBarWhenConnecting = Switch.OFF + APP().ShowProgressBarWhenConnecting = Switch.OFF if self.textCompare('Show Tab And Spaces In Editor'): if checked: - QApplication.instance().ShowTabAndSpacesInEditor = Switch.ON_ + APP().ShowTabAndSpacesInEditor = Switch.ON_ else: - QApplication.instance().ShowTabAndSpacesInEditor = Switch.OFF + APP().ShowTabAndSpacesInEditor = Switch.OFF - QApplication.instance().MainWidget.showTabAndSpacesIfNecessary() + APP().MainWidget.showTabAndSpacesIfNecessary() class SettingsAction(Action): @@ -44,19 +43,17 @@ class SettingsAction(Action): SettingsChildAction( _('Startup On Boot'), checkable=True, - checked=QApplication.instance().StartupOnBoot == Switch.ON_, + checked=APP().StartupOnBoot == Switch.ON_, ), SettingsChildAction( _('Show Progress Bar When Connecting'), checkable=True, - checked=QApplication.instance().ShowProgressBarWhenConnecting - == Switch.ON_, + checked=APP().ShowProgressBarWhenConnecting == Switch.ON_, ), SettingsChildAction( _('Show Tab And Spaces In Editor'), checkable=True, - checked=QApplication.instance().ShowTabAndSpacesInEditor - == Switch.ON_, + checked=APP().ShowTabAndSpacesInEditor == Switch.ON_, ), ), useActionGroup=False, diff --git a/Furious/Core/Core.py b/Furious/Core/Core.py index e6e7633..dcf287d 100644 --- a/Furious/Core/Core.py +++ b/Furious/Core/Core.py @@ -142,33 +142,39 @@ class Hysteria(Core): super().__init__(*args, **kwargs) @staticmethod - def rule(rulePath=''): - if rulePath == '': - path = DATA_DIR / 'hysteria' / 'bypass-mainland-China.acl' - else: - path = getAbsolutePath(rulePath) + def rule(rulePath): + path = getAbsolutePath(str(rulePath)) try: with open(path, 'rb') as file: - return file.read() - except Exception: + data = file.read() + + logger.info(f'hysteria rule \'{path}\' load success') + + return data + except Exception as ex: # Any non-exit exceptions + logger.error(f'hysteria rule \'{path}\' load failed. {ex}') + return '' @staticmethod - def mmdb(mmdbPath=''): - if mmdbPath == '': - path = DATA_DIR / 'hysteria' / 'country.mmdb' - else: - path = getAbsolutePath(mmdbPath) + def mmdb(mmdbPath): + path = getAbsolutePath(str(mmdbPath)) try: with open(path, 'rb') as file: - return file.read() - except Exception: + data = file.read() + + logger.info(f'hysteria mmdb \'{path}\' load success') + + return data + except Exception as ex: # Any non-exit exceptions + logger.error(f'hysteria mmdb \'{path}\' load failed. {ex}') + return '' @staticmethod diff --git a/Furious/Gui/Action.py b/Furious/Gui/Action.py index 42627a0..c77669c 100644 --- a/Furious/Gui/Action.py +++ b/Furious/Gui/Action.py @@ -9,7 +9,6 @@ from Furious.Utility.Translator import Translatable, gettext as _ from PySide6 import QtCore from PySide6.QtGui import QAction, QActionGroup -from PySide6.QtWidgets import QApplication import logging import darkdetect @@ -47,6 +46,11 @@ class Action(Translatable, SupportThemeChangedCallback, QAction): self._actionGroup = ActionGroup(self, *menu.actions()) self.setActionGroup(self._actionGroup) + else: + self._actionGroup = None + else: + self._menu = None + self._actionGroup = None self.setCheckable(checkable) self.setChecked(checked) @@ -65,6 +69,20 @@ class Action(Translatable, SupportThemeChangedCallback, QAction): self.triggered.connect(triggerSignal) + def addAction(self, action): + if self._menu is not None: + self._menu.addAction(action) + + if self._actionGroup is not None: + self._actionGroup.addAction(action) + + def removeAction(self, action): + if self._menu is not None: + self._menu.removeAction(action) + + if self._actionGroup is not None: + self._actionGroup.removeAction(action) + @property def textEnglish(self): return _(self.text(), 'EN') @@ -127,7 +145,7 @@ class Action(Translatable, SupportThemeChangedCallback, QAction): def retranslate(self): def recursiveTranslate(action, memo): - if action not in memo and not action.isSeparator(): + if action not in memo and not action.isSeparator() and action.translatable: action.setText(_(action.text())) memo[action] = True diff --git a/Furious/Utility/Constants.py b/Furious/Utility/Constants.py index 70b62e8..bdb2821 100644 --- a/Furious/Utility/Constants.py +++ b/Furious/Utility/Constants.py @@ -1,10 +1,14 @@ from Furious.Version import __version__ from PySide6 import QtCore +from PySide6.QtWidgets import QApplication import math import pathlib import platform +import functools + +APP = functools.partial(QApplication.instance) APPLICATION_NAME = 'Furious' APPLICATION_VERSION = __version__ @@ -24,9 +28,9 @@ GOLDEN_RATIO = (math.sqrt(5) - 1) / 2 SYSTEM_LANGUAGE = QtCore.QLocale().name()[:2].upper() -ROOT_DIR = pathlib.Path(__file__).resolve().parent.parent -DATA_DIR = ROOT_DIR / 'Data' -CRASH_LOG_DIR = ROOT_DIR / 'CrashLog' +ROOT_DIR = pathlib.Path(__file__).resolve().parent.parent.parent +DATA_DIR = ROOT_DIR / APPLICATION_NAME / 'Data' +CRASH_LOG_DIR = ROOT_DIR / APPLICATION_NAME / 'CrashLog' PROXY_SERVER_BYPASS = ( 'localhost;*.local;127.*;10.*;172.16.*;172.17.*;' diff --git a/Furious/Utility/Process.py b/Furious/Utility/Process.py index 971fde6..2f087f0 100644 --- a/Furious/Utility/Process.py +++ b/Furious/Utility/Process.py @@ -1,7 +1,5 @@ from Furious.Widget.Application import Application -from Furious.Utility.Constants import PLATFORM, CRASH_LOG_DIR - -from PySide6.QtWidgets import QApplication +from Furious.Utility.Constants import APP, PLATFORM, CRASH_LOG_DIR import os import sys @@ -41,8 +39,8 @@ class Process(ProcessContext.Process): self.saveCrashLog(exceptionType, exceptionValue, tb) - if QApplication.instance() is not None: - QApplication.instance().exit(error) + if APP() is not None: + APP().exit(error) else: sys.exit(error) @@ -63,10 +61,10 @@ class Process(ProcessContext.Process): traceback.format_exception(exceptionType, exceptionValue, tb), ) - if QApplication.instance() is None: + if APP() is None: crashLog = f'{stackLog}' else: - crashLog = f'{QApplication.instance().log()}\n{stackLog}' + crashLog = f'{APP().log()}\n{stackLog}' with open(CRASH_LOG_DIR / self.logFileName, 'w', encoding='utf-8') as file: file.write(crashLog) diff --git a/Furious/Utility/Settings.py b/Furious/Utility/Settings.py index ec6a3ba..f517c6e 100644 --- a/Furious/Utility/Settings.py +++ b/Furious/Utility/Settings.py @@ -1,4 +1,3 @@ -from Furious.Action.Routing import SUPPORTED_ROUTING from Furious.Action.Language import SUPPORTED_LANGUAGE from Furious.Utility.Constants import SYSTEM_LANGUAGE from Furious.Utility.Utility import Switch @@ -53,22 +52,42 @@ SMART_CHOSEN_LANGUAGE = ( ) SUPPORTED_SETTINGS = ( + # Connected last time or not Settings('Connect', Switch.RANGE), - Settings('Routing', SUPPORTED_ROUTING), + # User Routing option + Settings('Routing'), + # User Custom Routing object + Settings('CustomRouting'), + # User Configuration Settings('Configuration'), + # User Activated Server Index Settings('ActivatedItemIndex'), + # Main Widget Window Size Settings('MainWidgetWindowSize'), + # Edit Routing Widget Window Size + Settings('RoutesWidgetWindowSize'), + # Edit Routing Widget Editor Window Size + Settings('RoutesEditorWindowSize'), + # Main Widget Section Size Settings('ServerWidgetSectionSizeTable'), + # Edit Routing Widget Section Size + Settings('RoutesWidgetSectionSizeTable'), + # Main Widget Font Point Size Settings('EditorWidgetPointSize'), + # Log Viewer Widget Font Point Size Settings('ViewerWidgetPointSize'), + # 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), ) diff --git a/Furious/Utility/StartupOnBoot.py b/Furious/Utility/StartupOnBoot.py index 77a9627..38b48c5 100644 --- a/Furious/Utility/StartupOnBoot.py +++ b/Furious/Utility/StartupOnBoot.py @@ -6,7 +6,6 @@ from Furious.Utility.Constants import ( ) from PySide6 import QtCore -from PySide6.QtWidgets import QApplication import os import sys diff --git a/Furious/Utility/Theme.py b/Furious/Utility/Theme.py index 8a997fc..16c983c 100644 --- a/Furious/Utility/Theme.py +++ b/Furious/Utility/Theme.py @@ -40,6 +40,8 @@ class DraculaTheme(Theme): HighlightRules(r'"[^"\\]*(\\.[^"\\]*)*"', '#F1FA8C'), # JSON keys. Green HighlightRules(r'"([^"\\]*(\\.[^"\\]*)*)"\s*:', '#50FA7B', isJSONKey=True), + # Comments(only for hints on display). Grey + HighlightRules(r'^#.*', '#6272a4'), ] def highlightBlock(self, text): diff --git a/Furious/Utility/Translator.py b/Furious/Utility/Translator.py index 7dded39..5d0f9ad 100644 --- a/Furious/Utility/Translator.py +++ b/Furious/Utility/Translator.py @@ -1,6 +1,5 @@ -from Furious.Utility.Constants import APPLICATION_NAME - -from PySide6.QtWidgets import QApplication +from Furious.Core.Core import XrayCore, Hysteria +from Furious.Utility.Constants import APP, APPLICATION_NAME class Translatable: @@ -9,6 +8,8 @@ class Translatable: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self.translatable = True + Translatable.Object.append(self) def retranslate(self): @@ -19,7 +20,8 @@ class Translatable: for ob in Translatable.Object: assert isinstance(ob, Translatable) - ob.retranslate() + if ob.translatable: + ob.retranslate() class Translator: @@ -58,9 +60,9 @@ def installTranslation(translation): def gettext(source, locale=None): if locale is None: - assert QApplication.instance() is not None + assert APP() is not None - return _trans.translate(source, QApplication.instance().Language) + return _trans.translate(source, APP().Language) else: assert locale in list(LANGUAGE_TO_ABBR.values()) @@ -83,6 +85,8 @@ NO_TRANSLATION = [ APPLICATION_NAME, *LANGUAGE_TO_ABBR.keys(), 'TLS', + XrayCore.name(), + Hysteria.name(), ] NO_TRANSLATION_DICT = {key: True for key in NO_TRANSLATION} @@ -124,6 +128,11 @@ TRANSLATION = { 'ZH': '绕过中国大陆', 'TW': '繞過中國大陸', }, + 'Bypass Iran': { + 'ES': 'Evitar Irán', + 'ZH': '绕过伊朗', + 'TW': '繞過伊朗', + }, 'Global': { 'ES': 'Global', 'ZH': '全球', @@ -134,6 +143,11 @@ TRANSLATION = { 'ZH': '自定义', 'TW': '自定義', }, + 'Edit Routing...': { + 'ES': 'Editar Enrutamiento...', + 'ZH': '编辑路由...', + 'TW': '編輯路由...', + }, 'Import': { 'ES': 'Importar', 'ZH': '导入', @@ -496,16 +510,16 @@ TRANSLATION = { 'ZH': '删除', 'TW': '刪除', }, - 'Delete this configuration?': { - 'ES': '¿Eliminar esta configuración?', - 'ZH': '删除此配置?', - 'TW': '刪除此配置?', - }, 'Delete these configuration?': { 'ES': '¿Eliminar esta configuración?', 'ZH': '删除这些配置?', 'TW': '刪除這些配置?', }, + 'Delete this configuration?': { + 'ES': '¿Eliminar esta configuración?', + 'ZH': '删除此配置?', + 'TW': '刪除此配置?', + }, 'Connecting. Please wait.': { 'ES': 'Conectando. Espere por favor.', 'ZH': '正在连接,请稍候。', @@ -637,6 +651,93 @@ TRANSLATION = { 'ZH': '滚动到激活的服务器', 'TW': '滾動到激活的服務器', }, + # Edit Routing widget + 'Edit Routing': { + 'ES': 'Editar Enrutamiento', + 'ZH': '编辑路由', + 'TW': '編輯路由', + }, + 'Type': { + 'ES': 'Tipo', + 'ZH': '类型', + 'TW': '類型', + }, + 'Built-in': { + 'ES': 'Incorporado', + 'ZH': '内置', + 'TW': '内置', + }, + 'Routing Rules': { + 'ES': 'Reglas de Enrutamiento', + 'ZH': '路由规则', + 'TW': '路由規則', + }, + 'User Defined': { + 'ES': 'Usuario Definido', + 'ZH': '用户定义', + 'TW': '用戶定義', + }, + 'Add routing': { + 'ES': 'Agregar enrutamiento', + 'ZH': '添加路由', + 'TW': '添加路由', + }, + 'Enter routing remark:': { + 'ES': 'Introducir comentario de enrutamiento:', + 'ZH': '输入路由名称:', + 'TW': '輸入路由名稱:', + }, + 'Add': { + 'ES': 'Agregar', + 'ZH': '添加', + 'TW': '添加', + }, + 'Import Asset File...': { + 'ES': 'Importar Archivo de Activos...', + 'ZH': '导入资源文件...', + 'TW': '導入資源文件...', + }, + 'Error saving routing configuration': { + 'ES': 'Error al guardar la configuración de enrutamiento', + 'ZH': '保存路由配置时出错', + 'TW': '保存路由配置時出錯', + }, + # Asset Viewer + 'Asset File': { + 'ES': 'Activos', + 'ZH': '资源文件', + 'TW': '資源文件', + }, + 'Asset file already exists. Overwrite?': { + 'ES': 'Archivo de activos ya existe. ¿Sobrescribir?', + 'ZH': '资源文件已存在。是否覆盖?', + 'TW': '資源文件已存在。是否覆蓋?', + }, + 'Error import asset file.': { + 'ES': 'Error al importar el archivo de activos.', + 'ZH': '导入资源文件出错。', + 'TW': '導入資源文件出錯。', + }, + 'Import asset file success.': { + 'ES': 'Importar archivo de activos con éxito.', + 'ZH': '导入资源文件成功。', + 'TW': '導入資源文件成功。', + }, + 'Delete these asset files?': { + 'ES': '¿Eliminar estos archivos de activos?', + 'ZH': '删除这些资源文件?', + 'TW': '刪除這些資源文件?', + }, + 'Delete this asset file?': { + 'ES': '¿Eliminar este archivo de activos?', + 'ZH': '删除该资源文件?', + 'TW': '刪除該資源文件?', + }, + 'All files (*)': { + 'ES': 'Todos los archivos (*)', + 'ZH': '所有文件 (*)', + 'TW': '所有文件 (*)', + }, # Log Viewer 'Log Viewer': { 'ES': 'Visor de Registro', diff --git a/Furious/Utility/Utility.py b/Furious/Utility/Utility.py index 9a50a88..83c0fa6 100644 --- a/Furious/Utility/Utility.py +++ b/Furious/Utility/Utility.py @@ -1,5 +1,5 @@ from Furious.Gui.Icon import Icon -from Furious.Utility.Constants import PLATFORM, ROOT_DIR +from Furious.Utility.Constants import APP, PLATFORM, ROOT_DIR from PySide6.QtWidgets import QApplication @@ -46,29 +46,27 @@ class StateContext: self._ob.setDisabled(False) -class Storage: +class ServerStorage: EMPTY_OBJECT = {'model': []} @staticmethod def init(): - return copy.deepcopy(Storage.EMPTY_OBJECT) + return copy.deepcopy(ServerStorage.EMPTY_OBJECT) @staticmethod def sync(ob=None): if ob is None: # Object is up-to-date - QApplication.instance().Configuration = Storage.toStorage( - QApplication.instance().MainWidget.StorageObj - ) + APP().Configuration = ServerStorage.toStorage(APP().MainWidget.StorageObj) else: # Object is up-to-date - QApplication.instance().Configuration = Storage.toStorage(ob) + APP().Configuration = ServerStorage.toStorage(ob) @staticmethod def toObject(st): if not st: - # Storage does not exist, or is empty - return Storage.init() + # Server storage does not exist, or is empty + return ServerStorage.init() return ujson.loads(Base64Encoder.decode(st)) @@ -80,8 +78,46 @@ class Storage: @staticmethod def clear(): - QApplication.instance().Configuration = '' - QApplication.instance().ActivatedItemIndex = str(-1) + APP().Configuration = '' + APP().ActivatedItemIndex = str(-1) + + +class RoutesStorage: + # remark, corename, routes + EMPTY_OBJECT = {'model': []} + + @staticmethod + def init(): + return copy.deepcopy(RoutesStorage.EMPTY_OBJECT) + + @staticmethod + def sync(ob=None): + if ob is None: + # Object is up-to-date + APP().CustomRouting = RoutesStorage.toStorage( + APP().editRoutingWidget.StorageObj + ) + else: + # Object is up-to-date + APP().CustomRouting = RoutesStorage.toStorage(ob) + + @staticmethod + def toObject(st): + if not st: + # Server storage does not exist, or is empty + return RoutesStorage.init() + + return ujson.loads(Base64Encoder.decode(st)) + + @staticmethod + def toStorage(ob): + return Base64Encoder.encode( + ujson.dumps(ob, ensure_ascii=False, escape_forward_slashes=False).encode() + ) + + @staticmethod + def clear(): + APP().CustomRouting = '' class Switch: diff --git a/Furious/Widget/Application.py b/Furious/Widget/Application.py index 2123d54..50effb5 100644 --- a/Furious/Widget/Application.py +++ b/Furious/Widget/Application.py @@ -1,5 +1,6 @@ from Furious.Widget.SystemTrayIcon import SystemTrayIcon from Furious.Widget.EditConfiguration import EditConfigurationWidget +from Furious.Widget.EditRouting import EditRoutingWidget from Furious.Widget.LogViewer import LogViewerWidget from Furious.Utility.Constants import ( APPLICATION_NAME, @@ -140,6 +141,8 @@ class Application(SingletonApplication): self.customFontEnabled = False self.customFontName = '' + self.editRoutingWidget = None + self.logViewerWidget = None self.logViewerHandle = None self.logStreamHandle = None @@ -181,7 +184,7 @@ class Application(SingletonApplication): logging.basicConfig( format='[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s', - level=logging.DEBUG, + level=logging.INFO, handlers=(self.logViewerHandle, self.logStreamHandle), ) logging.raiseExceptions = False @@ -244,6 +247,7 @@ class Application(SingletonApplication): self.aboutToQuit.connect(self.cleanup) self.MainWidget = EditConfigurationWidget() + self.editRoutingWidget = EditRoutingWidget() self.tray = SystemTrayIcon() self.tray.show() @@ -257,12 +261,16 @@ class Application(SingletonApplication): Proxy.off() Proxy.daemonOff() - if self.tray is not None: - self.tray.ConnectAction.stopCore() + # Avoid calling core exit callback. Cores are cleaned by OS. + # if self.tray is not None: + # self.tray.ConnectAction.stopCore() if self.MainWidget is not None: self.MainWidget.syncSettings() + if self.editRoutingWidget is not None: + self.editRoutingWidget.syncSettings() + if self.logViewerWidget is not None: self.logViewerWidget.syncSettings() diff --git a/Furious/Widget/AssetViewer.py b/Furious/Widget/AssetViewer.py new file mode 100644 index 0000000..61a3a5b --- /dev/null +++ b/Furious/Widget/AssetViewer.py @@ -0,0 +1,276 @@ +from Furious.Gui.Action import Action, Seperator +from Furious.Widget.Widget import ListWidget, Menu, MessageBox +from Furious.Utility.Constants import APPLICATION_NAME, PLATFORM, GOLDEN_RATIO, ROOT_DIR +from Furious.Utility.Utility import ( + StateContext, + SupportConnectedCallback, + SupportThemeChangedCallback, + bootstrapIcon, + bootstrapIconWhite, + 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, QMainWindow + +import os +import shutil +import logging +import threading +import watchfiles +import darkdetect + +logger = logging.getLogger(__name__) + + +def watchFileChanges(callback): + for changes in watchfiles.watch(AssetViewerWidget.AssetDir): + if callable(callback): + callback() + + +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() == MessageBox.StandardButton.No.value: + # 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.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 WatchFiles(QtCore.QObject): + filesChanged = QtCore.Signal() + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + +class AssetViewerWidget( + Translatable, SupportConnectedCallback, SupportThemeChangedCallback, QMainWindow +): + 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)) + + self.flushItem() + self.setCentralWidget(self.listWidget) + + # Check filesystem events + self.watchFiles = WatchFiles() + self.watchFiles.filesChanged.connect(lambda: self.flushItem()) + + self.watchFilesListenerThread = threading.Thread( + target=watchFileChanges, + args=(self.watchFiles.filesChanged.emit,), + daemon=True, + ) + self.watchFilesListenerThread.start() + + 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) + + self.setGeometry(100, 100, 512 * GOLDEN_RATIO, 512) + + moveToCenter(self) + + @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() == MessageBox.StandardButton.No.value: + # Do not delete + return + + for index in indexes: + os.remove(AssetViewerWidget.AssetDir / self.listWidget.item(index).text()) + + def flushItem(self): + self.listWidget.clear() + + for filename in os.listdir(AssetViewerWidget.AssetDir): + if os.path.isfile(AssetViewerWidget.AssetDir / filename): + item = QListWidgetItem(filename) + + if darkdetect.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 closeEvent(self, event): + event.ignore() + + self.hide() + + def connectedCallback(self): + self.setWindowIcon(bootstrapIcon('rocket-takeoff-connected-dark.svg')) + + def disconnectedCallback(self): + self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg')) + + def themeChangedCallback(self, theme): + # TODO + pass + + def retranslate(self): + with StateContext(self): + self.setWindowTitle(_(self.windowTitle())) diff --git a/Furious/Widget/ConnectingProgressBar.py b/Furious/Widget/ConnectingProgressBar.py index d919230..0e8b015 100644 --- a/Furious/Widget/ConnectingProgressBar.py +++ b/Furious/Widget/ConnectingProgressBar.py @@ -1,12 +1,16 @@ from Furious.Utility.Constants import APPLICATION_NAME, GOLDEN_RATIO -from Furious.Utility.Utility import bootstrapIcon, StateContext +from Furious.Utility.Utility import ( + bootstrapIcon, + StateContext, + SupportConnectedCallback, +) from Furious.Utility.Translator import Translatable, gettext as _ from PySide6 import QtCore from PySide6.QtWidgets import QHBoxLayout, QProgressBar, QWidget -class ConnectingProgressBar(Translatable, QWidget): +class ConnectingProgressBar(Translatable, SupportConnectedCallback, QWidget): def __init__(self, parent=None): super().__init__(parent) @@ -54,6 +58,38 @@ class ConnectingProgressBar(Translatable, QWidget): self.hide() + def connectedCallback(self): + self.setWindowIcon(bootstrapIcon('rocket-takeoff-connected-dark.svg')) + + self.progressBar.setStyleSheet( + f'QProgressBar {{' + f' border-radius: 2px;' + f' text-align: center;' + f'}}' + f'' + f'QProgressBar::chunk {{' + f' background-color: #EB212E;' + f' width: 10px;' + f' margin: 0.5px;' + f'}}' + ) + + def disconnectedCallback(self): + self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg')) + + self.progressBar.setStyleSheet( + f'QProgressBar {{' + f' border-radius: 2px;' + f' text-align: center;' + f'}}' + f'' + f'QProgressBar::chunk {{' + f' background-color: #43ACED;' + f' width: 10px;' + f' margin: 0.5px;' + f'}}' + ) + def retranslate(self): with StateContext(self): self.setWindowTitle(_(self.windowTitle())) diff --git a/Furious/Widget/EditConfiguration.py b/Furious/Widget/EditConfiguration.py index 3767e14..90bb278 100644 --- a/Furious/Widget/EditConfiguration.py +++ b/Furious/Widget/EditConfiguration.py @@ -4,26 +4,30 @@ from Furious.Core.Intellisense import Intellisense from Furious.Core.Configuration import Configuration from Furious.Gui.Action import Action, Seperator from Furious.Widget.Widget import ( + HeaderView, Menu, MessageBox, PushButton, + StyledItemDelegate, + TableWidget, TabWidget, ZoomablePlainTextEdit, ) from Furious.Widget.IndentSpinBox import IndentSpinBox from Furious.Utility.Constants import ( - PLATFORM, + APP, APPLICATION_NAME, APPLICATION_VERSION, APPLICATION_ABOUT_PAGE, APPLICATION_REPO_OWNER_NAME, APPLICATION_REPO_NAME, + PLATFORM, GOLDEN_RATIO, ) from Furious.Utility.Utility import ( Base64Encoder, StateContext, - Storage, + ServerStorage, Switch, SupportConnectedCallback, bootstrapIcon, @@ -34,10 +38,9 @@ from Furious.Utility.Translator import Translatable, gettext as _ from Furious.Utility.Theme import DraculaTheme from PySide6 import QtCore -from PySide6.QtGui import QBrush, QColor, QDesktopServices, QFont, QTextOption +from PySide6.QtGui import QDesktopServices, QFont, QTextOption from PySide6.QtWidgets import ( QAbstractItemView, - QApplication, QDialog, QFileDialog, QGridLayout, @@ -110,11 +113,12 @@ class ImportFileAction(Action): try: with open(filename, 'r', encoding='utf-8') as file: plainText = file.read() - except Exception: + except Exception as ex: # Any non-exit exceptions self.openErrorBox.setWindowTitle(_('Error opening file')) self.openErrorBox.setText(_('Invalid configuration file.')) + self.openErrorBox.setInformativeText(str(ex)) # Show the MessageBox and wait for user to close it self.openErrorBox.exec() @@ -218,7 +222,7 @@ class ScrollToActivatedServerAction(Action): ) def triggeredCallback(self, checked): - if QApplication.instance().MainWidget.modified: + if APP().MainWidget.modified: self.parent().saveChangeFirst.exec() return @@ -273,7 +277,7 @@ def questionReconnect(saveConfInfo): if choice == MessageBox.ButtonRole.AcceptRole.value: # Reconnect - QApplication.instance().tray.ConnectAction.reconnectAction() + APP().tray.ConnectAction.reconnectAction() else: # OK. Do nothing pass @@ -308,7 +312,7 @@ class SaveAsServerAction(Action): ) # Sync it - Storage.sync() + ServerStorage.sync() self.parent().markAsSaved() @@ -318,7 +322,7 @@ class SaveAsServerAction(Action): if myFocusIndex == self.parent().activatedItemIndex: # Activated configuration modified - if server and QApplication.instance().tray.ConnectAction.isConnected(): + if server and APP().tray.ConnectAction.isConnected(): questionReconnect(self.saveConfInfo) return True @@ -594,12 +598,20 @@ class ZoomOutAction(Action): self.parent().plainTextEdit.zoomOut() +class RoutingAction(Action): + def __init__(self, **kwargs): + super().__init__(_('Edit Routing...'), **kwargs) + + def triggeredCallback(self, checked): + APP().editRoutingWidget.show() + + class ShowLogAction(Action): def __init__(self, **kwargs): super().__init__(_('Show Log...'), **kwargs) def triggeredCallback(self, checked): - QApplication.instance().logViewerWidget.showMaximized() + APP().logViewerWidget.showMaximized() class WhetherToUpdateInfoBox(MessageBox): @@ -637,7 +649,7 @@ class CheckForUpdatesAction(Action): ) def triggeredCallback(self, checked): - proxyServer = QApplication.instance().tray.ConnectAction.proxyServer + proxyServer = APP().tray.ConnectAction.proxyServer if proxyServer: logger.info(f'check for updates uses proxy server {proxyServer}') @@ -828,7 +840,7 @@ class SaveChangeFirst(MessageBox): self.moveToCenter() -class NormalServerHorizontalHeader(QHeaderView): +class NormalServerHorizontalHeader(HeaderView): class SortOrder: Ascending_ = False Descending = True @@ -836,8 +848,6 @@ class NormalServerHorizontalHeader(QHeaderView): def __init__(self, *args, **kwargs): super().__init__(QtCore.Qt.Orientation.Horizontal, *args, **kwargs) - self.setSectionsClickable(True) - self.sectionClicked.connect(self.handleSectionClicked) self.sectionResized.connect(self.handleSectionResized) @@ -848,7 +858,7 @@ class NormalServerHorizontalHeader(QHeaderView): @QtCore.Slot(int) def handleSectionClicked(self, clickedIndex): - if QApplication.instance().MainWidget.modified: + if APP().MainWidget.modified: self.parent().saveChangeFirst.exec() return @@ -890,7 +900,7 @@ class NormalServerHorizontalHeader(QHeaderView): ) # Sync it - Storage.sync() + ServerStorage.sync() # Flush it self.parent().flushAll() @@ -936,37 +946,10 @@ class NormalServerHorizontalHeader(QHeaderView): self.parent().sectionSizeTable[str(index)] = newSize -class NormalServerVerticalHeader(SupportConnectedCallback, QHeaderView): +class NormalServerVerticalHeader(HeaderView): def __init__(self, *args, **kwargs): super().__init__(QtCore.Qt.Orientation.Vertical, *args, **kwargs) - self.setSectionsClickable(True) - - self.setStyleSheet( - f'QHeaderView::section:hover {{ background-color: #008AE1; }}' - ) - - def connectedCallback(self): - self.setStyleSheet( - f'QHeaderView::section:hover {{ background-color: #EB212E; }}' - ) - - def disconnectedCallback(self): - self.setStyleSheet( - f'QHeaderView::section:hover {{ background-color: #008AE1; }}' - ) - - -class NormalServerWidgetItemDelegate(QStyledItemDelegate): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - def createEditor(self, parent, option, index): - editor = QLineEdit(parent) - editor.setFont(QFont(QApplication.instance().customFontName)) - - return editor - def toJSONMayBeNull(text): try: @@ -977,7 +960,7 @@ def toJSONMayBeNull(text): return {} -class NormalServerWidget(Translatable, SupportConnectedCallback, QTableWidget): +class NormalServerWidget(Translatable, SupportConnectedCallback, TableWidget): # Might be extended in the future HEADER_LABEL = [ 'Remark', @@ -1028,7 +1011,7 @@ class NormalServerWidget(Translatable, SupportConnectedCallback, QTableWidget): self.editorTab = self.parent().editorTab # Delegate - self.delegate = NormalServerWidgetItemDelegate(parent=self) + self.delegate = StyledItemDelegate(parent=self) self.setItemDelegate(self.delegate) # Scroll bar value list. v, h @@ -1056,9 +1039,7 @@ class NormalServerWidget(Translatable, SupportConnectedCallback, QTableWidget): try: # Restore horizontal section size - self.sectionSizeTable = ujson.loads( - QApplication.instance().ServerWidgetSectionSizeTable - ) + self.sectionSizeTable = ujson.loads(APP().ServerWidgetSectionSizeTable) # Block resize callback self.horizontalHeader().blockSignals(True) @@ -1083,9 +1064,7 @@ class NormalServerWidget(Translatable, SupportConnectedCallback, QTableWidget): ) for column in range(self.horizontalHeader().count()): - self.horizontalHeaderItem(column).setFont( - QFont(QApplication.instance().customFontName) - ) + self.horizontalHeaderItem(column).setFont(QFont(APP().customFontName)) # Install custom header self.setVerticalHeader(NormalServerVerticalHeader(self)) @@ -1160,7 +1139,7 @@ class NormalServerWidget(Translatable, SupportConnectedCallback, QTableWidget): @property def activatedItemIndex(self): try: - return int(QApplication.instance().ActivatedItemIndex) + return int(APP().ActivatedItemIndex) except Exception: # Any non-exit exceptions @@ -1170,10 +1149,6 @@ class NormalServerWidget(Translatable, SupportConnectedCallback, QTableWidget): def activatedItem(self): return self.item(self.activatedItemIndex, 0) - @property - def selectedIndex(self): - return sorted(list(set(index.row() for index in self.selectedIndexes()))) - @property def currentFocus(self): try: @@ -1193,7 +1168,7 @@ class NormalServerWidget(Translatable, SupportConnectedCallback, QTableWidget): if oldItem is None: # Item does not exists - newItem.setFont(QFont(QApplication.instance().customFontName)) + newItem.setFont(QFont(APP().customFontName)) else: # Use existing newItem.setFont(oldItem.font()) @@ -1272,48 +1247,13 @@ class NormalServerWidget(Translatable, SupportConnectedCallback, QTableWidget): # Avoid unnecessary encode/write, etc. if remarkModified: - Storage.sync() - - def setSelectionColor(self, color): - self.setStyleSheet(f'QTableWidget {{ selection-background-color: {color}; }}') + ServerStorage.sync() def activateItemByIndex(self, index, activate=True): + super().activateItemByIndex(index, activate) + if activate: - for column in range(self.columnCount()): - serverItem = self.item(int(index), column) - - if serverItem is None: - # Do nothing - return - - serverFont = serverItem.font() - serverFont.setBold(True) - - serverItem.setFont(serverFont) - - if QApplication.instance().tray is None: - # Initializing - serverItem.setForeground(QColor('#008AE1')) - else: - if QApplication.instance().tray.ConnectAction.isConnected(): - serverItem.setForeground(QColor('#EB212E')) - else: - serverItem.setForeground(QColor('#008AE1')) - - QApplication.instance().ActivatedItemIndex = str(index) - else: - for column in range(self.columnCount()): - serverItem = self.item(int(index), column) - - if serverItem is None: - # Do nothing - return - - serverFont = serverItem.font() - serverFont.setBold(False) - - serverItem.setFont(serverFont) - serverItem.setForeground(QBrush()) + APP().ActivatedItemIndex = str(index) def setCurrentItemByIndex(self, index): self.setCurrentItem(self.item(index, 0)) @@ -1322,7 +1262,7 @@ class NormalServerWidget(Translatable, SupportConnectedCallback, QTableWidget): swapListItem(self.ServerList, index0, index1) swapListItem(self.ScrollList, index0, index1) - # Storage sync is done by caller!!! + # Server storage sync is done by caller!!! self.flushRow(index0, self.ServerList[index0]) self.flushRow(index1, self.ServerList[index1]) @@ -1362,7 +1302,7 @@ class NormalServerWidget(Translatable, SupportConnectedCallback, QTableWidget): self.swapItem(index, index - 1) def moveUpSelectedItem(self): - if QApplication.instance().MainWidget.modified: + if APP().MainWidget.modified: self.saveChangeFirst.exec() return @@ -1382,7 +1322,7 @@ class NormalServerWidget(Translatable, SupportConnectedCallback, QTableWidget): self.moveUpItemByIndex(index) # Sync it - Storage.sync() + ServerStorage.sync() # Don't use setCurrentItemXXX since it will # trigger handleCurrentItemChanged. Just switch context @@ -1405,7 +1345,7 @@ class NormalServerWidget(Translatable, SupportConnectedCallback, QTableWidget): self.swapItem(index, index + 1) def moveDownSelectedItem(self): - if QApplication.instance().MainWidget.modified: + if APP().MainWidget.modified: self.saveChangeFirst.exec() return @@ -1425,7 +1365,7 @@ class NormalServerWidget(Translatable, SupportConnectedCallback, QTableWidget): self.moveDownItemByIndex(index) # Sync it - Storage.sync() + ServerStorage.sync() # Don't use setCurrentItemXXX since it will # trigger handleCurrentItemChanged. Just switch context @@ -1441,7 +1381,7 @@ class NormalServerWidget(Translatable, SupportConnectedCallback, QTableWidget): ) def duplicateSelectedItem(self): - if QApplication.instance().MainWidget.modified: + if APP().MainWidget.modified: self.saveChangeFirst.exec() return @@ -1454,15 +1394,13 @@ class NormalServerWidget(Translatable, SupportConnectedCallback, QTableWidget): for index in indexes: if 0 <= index < len(self.ServerList): - QApplication.instance().MainWidget.importServer( - **self.ServerList[index] - ) + APP().MainWidget.importServer(**self.ServerList[index]) # Sync it - Storage.sync() + ServerStorage.sync() def deleteSelectedItem(self): - if QApplication.instance().MainWidget.modified: + if APP().MainWidget.modified: self.saveChangeFirst.exec() return @@ -1499,18 +1437,16 @@ class NormalServerWidget(Translatable, SupportConnectedCallback, QTableWidget): self.ScrollList.pop(takedRow) if not takedActivated and takedRow < self.activatedItemIndex: - QApplication.instance().ActivatedItemIndex = str( - self.activatedItemIndex - 1 - ) + APP().ActivatedItemIndex = str(self.activatedItemIndex - 1) # Sync it - Storage.sync() + ServerStorage.sync() if takedActivated: - if QApplication.instance().tray.ConnectAction.isConnected(): + if APP().tray.ConnectAction.isConnected(): # Trigger disconnect - QApplication.instance().tray.ConnectAction.trigger() - QApplication.instance().ActivatedItemIndex = str(-1) + APP().tray.ConnectAction.trigger() + APP().ActivatedItemIndex = str(-1) # Don't use setCurrentItemXXX since the # selection may not be changed. Just switch context @@ -1563,15 +1499,12 @@ class NormalServerWidget(Translatable, SupportConnectedCallback, QTableWidget): self.saveScrollBarValue(prevRow) - if ( - QApplication.instance().MainWidget is not None - and QApplication.instance().MainWidget.modified - ): + if APP().MainWidget is not None and APP().MainWidget.modified: choice = self.questionSaveBox.exec() if choice == MessageBox.ButtonRole.AcceptRole.value: # Save - if QApplication.instance().MainWidget.SaveAsServerAction.save( + if APP().MainWidget.SaveAsServerAction.save( successCallback=lambda: self.switchContext(currRow), ): pass @@ -1583,7 +1516,7 @@ class NormalServerWidget(Translatable, SupportConnectedCallback, QTableWidget): # Discard self.switchContext(currRow) - QApplication.instance().MainWidget.markAsSaved() # Fake saved + APP().MainWidget.markAsSaved() # Fake saved elif choice == MessageBox.ButtonRole.RejectRole.value: # Cancel. Do not switch @@ -1606,15 +1539,12 @@ class NormalServerWidget(Translatable, SupportConnectedCallback, QTableWidget): # Same item activated. Do nothing return - if ( - QApplication.instance().MainWidget is not None - and QApplication.instance().MainWidget.modified - ): + if APP().MainWidget is not None and APP().MainWidget.modified: self.saveChangeFirst.exec() return - if QApplication.instance().tray.ConnectAction.isConnecting(): + if APP().tray.ConnectAction.isConnecting(): # Show the MessageBox asynchronously self.progressWaitBox.open() @@ -1629,8 +1559,8 @@ class NormalServerWidget(Translatable, SupportConnectedCallback, QTableWidget): # Activate self.activateItemByIndex(newIndex, activate=True) - if QApplication.instance().tray.ConnectAction.isConnected(): - QApplication.instance().tray.ConnectAction.reconnectAction() + if APP().tray.ConnectAction.isConnected(): + APP().tray.ConnectAction.reconnectAction() @QtCore.Slot(QtCore.QPoint) def handleCustomContextMenuRequested(self, point): @@ -1672,18 +1602,18 @@ class EditConfigurationWidget(Translatable, SupportConnectedCallback, QMainWindo self.restoreErrorBox = MessageBox(parent=self) try: - self.StorageObj = Storage.toObject(QApplication.instance().Configuration) + self.StorageObj = ServerStorage.toObject(APP().Configuration) # Check model. Shallow copy self.ServerList = self.StorageObj['model'] except Exception: # Any non-exit exceptions - self.StorageObj = Storage.init() + self.StorageObj = ServerStorage.init() # Shallow copy self.ServerList = self.StorageObj['model'] # Clear it - Storage.clear() + ServerStorage.clear() Configuration.corruptedError(self.restoreErrorBox) @@ -1722,7 +1652,7 @@ class EditConfigurationWidget(Translatable, SupportConnectedCallback, QMainWindo self.plainTextEdit.setStyleSheet( DraculaTheme.getStyleSheet( widgetName='QPlainTextEdit', - fontFamily=QApplication.instance().customFontName, + fontFamily=APP().customFontName, ) ) @@ -1730,7 +1660,7 @@ class EditConfigurationWidget(Translatable, SupportConnectedCallback, QMainWindo try: font = self.plainTextEdit.font() - font.setPointSize(int(QApplication.instance().EditorWidgetPointSize)) + font.setPointSize(int(APP().EditorWidgetPointSize)) self.plainTextEdit.setFont(font) except Exception: @@ -1849,6 +1779,13 @@ class EditConfigurationWidget(Translatable, SupportConnectedCallback, QMainWindo ], } + routingMenu = { + 'name': 'Routing', + 'actions': [ + RoutingAction(parent=self), + ], + } + viewMenu = { 'name': 'View', 'actions': [ @@ -1868,13 +1805,16 @@ class EditConfigurationWidget(Translatable, SupportConnectedCallback, QMainWindo } # Menus - for menuDict in (fileMenu, editMenu, viewMenu, helpMenu): + for menuDict in (fileMenu, editMenu, routingMenu, viewMenu, helpMenu): menuName = menuDict['name'] menuObjName = f'_{menuName}Menu' menu = Menu(*menuDict['actions'], title=_(menuName), parent=self.menuBar()) for action in menuDict['actions']: if isinstance(action, Action): + if hasattr(self, f'{action}'): + logger.warning(f'{self} already has action {action}') + setattr(self, f'{action}', action) # Set reference @@ -1886,10 +1826,7 @@ class EditConfigurationWidget(Translatable, SupportConnectedCallback, QMainWindo self.setGeometry( 100, 100, - *list( - int(size) - for size in QApplication.instance().MainWidgetWindowSize.split(',') - ), + *list(int(size) for size in APP().MainWidgetWindowSize.split(',')), ) except Exception: # Any non-exit exceptions @@ -1905,7 +1842,7 @@ class EditConfigurationWidget(Translatable, SupportConnectedCallback, QMainWindo if syncStorage: # Sync it - Storage.sync() + ServerStorage.sync() row = self.normalServerWidget.rowCount() @@ -1914,21 +1851,21 @@ class EditConfigurationWidget(Translatable, SupportConnectedCallback, QMainWindo self.normalServerWidget.flushRow(row, server) item = QTableWidgetItem(str(row + 1)) - item.setFont(QFont(QApplication.instance().customFontName)) + item.setFont(QFont(APP().customFontName)) if len(self.ServerList) == 1: # The first one. Click it self.normalServerWidget.setCurrentItemByIndex(0) # Try to be user-friendly in some extreme cases - if not QApplication.instance().tray.ConnectAction.isConnected(): + if not APP().tray.ConnectAction.isConnected(): # Activate automatically self.normalServerWidget.activateItemByIndex(0) def showTabAndSpacesIfNecessary(self): textOption = QTextOption() - if QApplication.instance().ShowTabAndSpacesInEditor == Switch.ON_: + if APP().ShowTabAndSpacesInEditor == Switch.ON_: textOption.setFlags(QTextOption.Flag.ShowTabsAndSpaces) self.plainTextEdit.document().setDefaultTextOption(textOption) @@ -2009,17 +1946,15 @@ class EditConfigurationWidget(Translatable, SupportConnectedCallback, QMainWindo self.questionSave() def syncSettings(self): - QApplication.instance().MainWidgetWindowSize = ( + APP().MainWidgetWindowSize = ( f'{self.geometry().width()},{self.geometry().height()}' ) - QApplication.instance().ServerWidgetSectionSizeTable = ujson.dumps( + APP().ServerWidgetSectionSizeTable = ujson.dumps( self.normalServerWidget.sectionSizeTable, ensure_ascii=False, escape_forward_slashes=False, ) - QApplication.instance().EditorWidgetPointSize = str( - self.plainTextEdit.font().pointSize() - ) + APP().EditorWidgetPointSize = str(self.plainTextEdit.font().pointSize()) def connectedCallback(self): self.setWindowIcon(bootstrapIcon('rocket-takeoff-connected-dark.svg')) diff --git a/Furious/Widget/EditRouting.py b/Furious/Widget/EditRouting.py new file mode 100644 index 0000000..a24b3ee --- /dev/null +++ b/Furious/Widget/EditRouting.py @@ -0,0 +1,1127 @@ +from Furious.Action.Routing import ( + BUILTIN_ROUTING_TABLE, + BUILTIN_ROUTING, + RoutingChildAction, + routingToIndex, +) +from Furious.Core.Core import XrayCore, Hysteria +from Furious.Gui.Action import Action, Seperator +from Furious.Widget.Widget import ( + HeaderView, + Menu, + MessageBox, + PushButton, + StyledItemDelegate, + TableWidget, + TabWidget, + ZoomableTextBrowser, + ZoomablePlainTextEdit, +) +from Furious.Widget.EditConfiguration import ( + JSONErrorBox, + SaveConfInfo, + QuestionSaveBox, + QuestionDeleteBox, +) +from Furious.Widget.AssetViewer import AssetViewerWidget +from Furious.Utility.Constants import APP, APPLICATION_NAME, GOLDEN_RATIO, DATA_DIR +from Furious.Utility.Utility import ( + RoutesStorage, + StateContext, + SupportConnectedCallback, + bootstrapIcon, + moveToCenter, +) +from Furious.Utility.Translator import Translatable, gettext as _ +from Furious.Utility.Theme import DraculaTheme + +from PySide6 import QtCore +from PySide6.QtGui import QFont +from PySide6.QtWidgets import ( + QAbstractItemView, + QFileDialog, + QGridLayout, + QHBoxLayout, + QHeaderView, + QInputDialog, + QLabel, + QLineEdit, + QMainWindow, + QPlainTextEdit, + QSplitter, + QStyledItemDelegate, + QTableWidget, + QTableWidgetItem, + QTextBrowser, + QVBoxLayout, + QWidget, +) + +import ujson +import logging +import functools + +logger = logging.getLogger(__name__) + + +BUILTIN_ROUTING_TEXT = { + 'Bypass Mainland China': { + XrayCore.name(): ( + f'# Xray-core Routing rules\n' + f'# \n' + f'# This rule is read-only.\n' + f'# \n' + f'# This is a built-in RoutingObject that\n' + f'# implements Bypass Mainland China feature.\n' + f'# \n' + f'# There are four RuleObject(rules) from top\n' + f'# to bottom which implement:\n' + f'# 1. Block ads.(geosite:category-ads-all)\n' + f'# 2. Bypass China site.(geosite:cn)\n' + f'# 3. Bypass private and China IP.(geoip:private, geoip:cn)\n' + f'# 4. Proxy traffic sent from port 0-65535, in this\n' + f'# case meaning any other unmatched traffic.\n' + f'# respectively.\n' + f'# \n' + f'# "geoip.dat" and "geosite.dat" are shipped with\n' + f'# {APPLICATION_NAME} by default. However, you can import\n' + f'# and replace them if you need.\n' + f'# \n' + f'# See more information:\n' + f'# \n' + f'# https://xtls.github.io/config/routing.html\n\n' + ) + + ujson.dumps( + BUILTIN_ROUTING_TABLE['Bypass Mainland China'][XrayCore.name()], + indent=2, + ensure_ascii=False, + escape_forward_slashes=False, + ), + Hysteria.name(): ( + f'# Hysteria Routing rules\n' + f'# \n' + f'# This rule is read-only.\n' + f'# \n' + f'# Hysteria will use rule file defined in "acl"\n' + f'# key and database file defined in "mmdb" key.\n' + f'# \n' + f'# "bypass-mainland-China.acl" and "country.mmdb"\n' + f'# are shipped with {APPLICATION_NAME} by default.\n' + f'# Their locations are listed below.\n' + f'# \n' + f'# See more information:\n' + f'# \n' + f'# https://hysteria.network/docs/acl/\n\n' + ) + + ujson.dumps( + BUILTIN_ROUTING_TABLE['Bypass Mainland China'][Hysteria.name()], + indent=2, + ensure_ascii=False, + escape_forward_slashes=False, + ), + }, + 'Bypass Iran': { + XrayCore.name(): ( + f'# Xray-core Routing rules\n' + f'# \n' + f'# This rule is read-only.\n' + f'# \n' + f'# This is a built-in RoutingObject that\n' + f'# implements Bypass Iran feature.\n' + f'# \n' + f'# There are four RuleObject(rules) from top\n' + f'# to bottom which implement:\n' + f'# 1. Block ads.(geosite:category-ads-all, iran:ads)\n' + f'# 2. Bypass Iran site.(iran:ir, iran:other)\n' + f'# 3. Bypass private and Iran IP.(geoip:private, geoip:ir)\n' + f'# 4. Proxy traffic sent from port 0-65535, in this\n' + f'# case meaning any other unmatched traffic.\n' + f'# respectively.\n' + f'# \n' + f'# "geoip.dat", "geosite.dat" and "iran.dat" are shipped\n' + f'# with {APPLICATION_NAME} by default. However, you can import\n' + f'# and replace them if you need.\n' + f'# \n' + f'# See more information:\n' + f'# \n' + f'# https://xtls.github.io/config/routing.html\n\n' + ) + + ujson.dumps( + BUILTIN_ROUTING_TABLE['Bypass Iran'][XrayCore.name()], + indent=2, + ensure_ascii=False, + escape_forward_slashes=False, + ), + Hysteria.name(): ( + f'# Hysteria Routing rules\n' + f'# \n' + f'# This rule is read-only.\n' + f'# \n' + f'# Hysteria will use rule file defined in "acl"\n' + f'# key and database file defined in "mmdb" key.\n' + f'# \n' + f'# "bypass-Iran.acl" and "country.mmdb" are\n' + f'# shipped with {APPLICATION_NAME} by default.\n' + f'# Their locations are listed below.\n' + f'# \n' + f'# See more information:\n' + f'# \n' + f'# https://hysteria.network/docs/acl/\n\n' + ) + + ujson.dumps( + BUILTIN_ROUTING_TABLE['Bypass Iran'][Hysteria.name()], + indent=2, + ensure_ascii=False, + escape_forward_slashes=False, + ), + }, + 'Global': { + XrayCore.name(): ( + f'# Xray-core Routing rules\n' + f'# \n' + f'# This rule is read-only.\n' + f'# \n' + f'# This is a built-in RoutingObject that\n' + f'# implements Global feature.\n' + f'# \n' + f'# There is only one RuleObject(rules)\n' + f'# which implements:\n' + f'# 1. Proxy traffic sent from port 0-65535, in this\n' + f'# case meaning all traffic.\n' + f'# \n' + f'# See more information:\n' + f'# \n' + f'# https://xtls.github.io/config/routing.html\n\n' + ) + + ujson.dumps( + BUILTIN_ROUTING_TABLE['Global'][XrayCore.name()], + indent=2, + ensure_ascii=False, + escape_forward_slashes=False, + ), + Hysteria.name(): ( + f'# Hysteria Routing rules\n' + f'# \n' + f'# This rule is read-only.\n' + f'# \n' + f'# If no rule file or mmdb file is provided, hysteria\n' + f'# will fall back to proxy all traffic.\n\n' + ) + + ujson.dumps( + BUILTIN_ROUTING_TABLE['Global'][Hysteria.name()], + indent=2, + ensure_ascii=False, + escape_forward_slashes=False, + ), + }, + 'Custom': { + XrayCore.name(): ( + f'# Xray-core Routing rules\n' + f'# \n' + f'# This rule is read-only.\n' + f'# \n' + f'# "Custom" will use RoutingObject defined\n' + f'# in current user configuration.\n\n' + ), + Hysteria.name(): ( + f'# Hysteria Routing rules\n' + f'# \n' + f'# This rule is read-only.\n' + f'# \n' + f'# "Custom" will use rule file and mmdb file\n' + f'# defined in current user configuration.\n\n' + ), + }, +} + + +DEFAULT_USER_ROUTING = { + XrayCore.name(): { + 'domainStrategy': 'IPIfNonMatch', + 'domainMatcher': 'hybrid', + 'rules': [ + # Proxy everything + { + 'type': 'field', + 'port': '0-65535', + 'outboundTag': 'proxy', + }, + ], + }, + Hysteria.name(): { + 'acl': '', + 'mmdb': str(DATA_DIR / 'hysteria' / 'country.mmdb'), + }, +} + + +USER_ROUTING_TEXT = { + XrayCore.name(): ( + f'# Xray-core Routing rules\n' + f'# \n' + f'# Define your own routing rules here.\n' + f'# \n' + f'# Need any help? You can check those built-in\n' + f'# routing rules shipped with {APPLICATION_NAME} first.\n' + f'# \n' + f'# Note: JSON does not support comments. Any\n' + f'# lines starting with "#" will be discarded\n' + f'# by {APPLICATION_NAME} forever.\n\n' + ) + + ujson.dumps( + DEFAULT_USER_ROUTING[XrayCore.name()], + indent=2, + ensure_ascii=False, + escape_forward_slashes=False, + ), + Hysteria.name(): ( + f'# Hysteria Routing rules\n' + f'# \n' + f'# Define your own routing rules here.\n' + f'# A built-in mmdb filepath is defined\n' + f'# for convenience.\n' + f'# \n' + f'# Need any help? You can check those built-in\n' + f'# routing rules shipped with {APPLICATION_NAME} first.\n' + f'# \n' + f'# Note: JSON does not support comments. Any\n' + f'# lines starting with "#" will be discarded\n' + f'# by {APPLICATION_NAME} forever.\n' + f'# \n' + f'# Note: If any one of filepath is given but\n' + f'# does not exist, hysteria will fall back to\n' + f'# proxy all traffic.\n\n' + ) + + ujson.dumps( + DEFAULT_USER_ROUTING[Hysteria.name()], + indent=2, + ensure_ascii=False, + escape_forward_slashes=False, + ), +} + + +class ImportAssetFileAction(Action): + def __init__(self, **kwargs): + super().__init__(_('Import Asset File...'), **kwargs) + + def triggeredCallback(self, checked): + self.parent().assetViewer.show() + + +def questionFastReconnect(saveConfInfo): + saveConfInfo.setWindowTitle(_('Hint')) + saveConfInfo.setText( + _( + f'{APPLICATION_NAME} is currently connected.\n\n' + f'The new configuration will take effect the next time you connect.' + ) + ) + + # Show the MessageBox and wait for user to close it + choice = saveConfInfo.exec() + + if choice == MessageBox.ButtonRole.AcceptRole.value: + # Reconnect + APP().tray.ConnectAction.connectingAction( + showProgressBar=True, + showRoutingChangedMessage=False, + ) + else: + # OK. Do nothing + pass + + +class SaveAction(Action): + def __init__(self, **kwargs): + super().__init__(_('Save'), icon=bootstrapIcon('save.svg'), **kwargs) + + self.setShortcut( + QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier.ControlModifier, QtCore.Qt.Key.Key_S + ) + ) + + self.saveErrorBox = JSONErrorBox(icon=MessageBox.Icon.Critical) + self.saveConfInfo = SaveConfInfo(icon=MessageBox.Icon.Information) + + def getParentIndex(self): + routingEditor = self.parent() + routingEditorRef = ( + APP().editRoutingWidget.editRoutingTableWidget.routingEditorRef + ) + + for index, editor in enumerate(routingEditorRef[len(BUILTIN_ROUTING) :]): + if id(routingEditor) == id(editor): + # Found reference + return index + + return -1 + + def save(self, successCallback=None): + if not self.parent().modified: + return True + + coreList = [] + textList = [] + jsonList = [] + + editorTab = self.parent().editorTab + + for index in range(editorTab.count()): + core = editorTab.tabText(index) + plainText = editorTab.widget(index).toPlainText() + text = functools.reduce( + lambda x, y: x + y, + list( + filter( + # Discard any comments + lambda x: not x.strip().startswith('#'), + plainText.split('\n'), + ) + ), + ) + + coreList.append(core) + textList.append(plainText) + + try: + jsonList.append(ujson.loads(text)) + except ujson.JSONDecodeError as decodeError: + self.saveErrorBox.decodeError = f'{core}: {decodeError}' + self.saveErrorBox.setWindowTitle( + _('Error saving routing configuration') + ) + self.saveErrorBox.setText(self.saveErrorBox.getText()) + + # Show the MessageBox and wait for user to close it + self.saveErrorBox.exec() + + return False + + # Fast save + # Check parent(i.e. routing option) is not deleted by user. + parentIndex = self.getParentIndex() + + if parentIndex >= 0: + for core, json in zip(coreList, jsonList): + APP().editRoutingWidget.RoutesList[parentIndex][core] = json + + # Sync it + RoutesStorage.sync() + + # Refresh last saved text + self.parent().editorLastSavedText = textList + self.parent().markAsSaved() + + if callable(successCallback): + successCallback() + + if parentIndex == routingToIndex() - len(BUILTIN_ROUTING): + # Activated routing modified + + if APP().tray.ConnectAction.isConnected(): + questionFastReconnect(self.saveConfInfo) + else: + # Corresponding routing option has been deleted. Fake saved + logger.info('trying to save a deleted routing. Ignore') + + self.parent().markAsSaved() + + if callable(successCallback): + successCallback() + + return True + + def triggeredCallback(self, checked): + self.save() + + +class ExitAction(Action): + def __init__(self, **kwargs): + super().__init__(_('Exit'), **kwargs) + + def triggeredCallback(self, checked): + self.parent().hide() + + +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): + for editor in self.parent().editorRef: + editor.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): + for editor in self.parent().editorRef: + editor.zoomOut() + + +class RoutingTextBrowser(ZoomableTextBrowser): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.routingEditorWidget = kwargs.get('parent') + + def wheelEvent(self, event): + if event.modifiers() == QtCore.Qt.KeyboardModifier.ControlModifier: + delta = event.angleDelta().y() + + if delta > 0: + for editor in self.routingEditorWidget.editorRef: + editor.zoomIn() + if delta < 0: + for editor in self.routingEditorWidget.editorRef: + editor.zoomOut() + else: + super().wheelEvent(event) + + +class RoutingPlainTextEdit(ZoomablePlainTextEdit): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.routingEditorWidget = kwargs.get('parent') + + def wheelEvent(self, event): + if event.modifiers() == QtCore.Qt.KeyboardModifier.ControlModifier: + delta = event.angleDelta().y() + + if delta > 0: + for editor in self.routingEditorWidget.editorRef: + editor.zoomIn() + if delta < 0: + for editor in self.routingEditorWidget.editorRef: + editor.zoomOut() + else: + super().wheelEvent(event) + + +class RoutingEditor(Translatable, SupportConnectedCallback, QMainWindow): + def __init__(self, isBuiltin, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.isBuiltin = isBuiltin + + self.setWindowTitle(_('Edit Routing')) + + if APP().tray is not None and APP().tray.ConnectAction.isConnected(): + self.setWindowIcon(bootstrapIcon('rocket-takeoff-connected-dark.svg')) + else: + self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg')) + + self.assetViewer = AssetViewerWidget() + + # MessageBox + self.questionSaveBox = QuestionSaveBox( + icon=MessageBox.Icon.Question, parent=self + ) + + self.title = '' + + # Modified flag + self.modified = False + self.modifiedMark = ' *' + + self.editorRef = [] + self.editorLastSavedText = [] + self.themeReference = [] + + self.editorTab = TabWidget(parent=self) + self.setCentralWidget(self.editorTab) + + if self.isBuiltin: + fileMenuActions = [ + ImportAssetFileAction(parent=self), + Seperator(), + ExitAction(parent=self), + ] + else: + fileMenuActions = [ + ImportAssetFileAction(parent=self), + Seperator(), + SaveAction(parent=self), + Seperator(), + ExitAction(parent=self), + ] + + viewMenuActions = [ + ZoomInAction(parent=self), + ZoomOutAction(parent=self), + ] + + for menu in (fileMenuActions, 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._viewMenu = Menu(*viewMenuActions, title=_('View'), parent=self) + + self.menuBar().addMenu(self._fileMenu) + self.menuBar().addMenu(self._viewMenu) + + def addTabWithData(self, core, text): + if self.isBuiltin: + editor = RoutingTextBrowser(parent=self) + editor.setLineWrapMode(QTextBrowser.LineWrapMode.NoWrap) + editor.setStyleSheet( + DraculaTheme.getStyleSheet( + widgetName='QTextBrowser', + fontFamily=APP().customFontName, + ) + ) + else: + editor = RoutingPlainTextEdit(parent=self) + editor.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) + editor.setStyleSheet( + DraculaTheme.getStyleSheet( + widgetName='QPlainTextEdit', + fontFamily=APP().customFontName, + ) + ) + + @QtCore.Slot(bool) + def handleModification(changed): + if changed: + editor.document().setModified(False) + self.markAsModified() + + editor.modificationChanged.connect(handleModification) + + # Theme + self.themeReference.append(DraculaTheme(editor.document())) + + editor.blockSignals(True) + editor.setPlainText(text) + editor.blockSignals(False) + + self.editorRef.append(editor) + self.editorLastSavedText.append(text) + self.editorTab.addTab(editor, core) + + def markAsModified(self): + # Built-in is read-only. Should not be modified + assert not self.isBuiltin + + self.modified = True + self.setWindowTitle(self.title + self.modifiedMark) + + def markAsSaved(self): + # Built-in is read-only. Should not be modified + assert not self.isBuiltin + + self.modified = False + self.setWindowTitle(self.title) + + def questionSave(self): + if self.modified: + choice = self.questionSaveBox.exec() + + if choice == MessageBox.ButtonRole.AcceptRole.value: + # Save + # If save success, close the window. + return self.SaveAction.save(successCallback=lambda: self.hide()) + + if choice == MessageBox.ButtonRole.DestructiveRole.value: + # Discard + self.hide() + + # Restore last saved text + for editor, text in zip(self.editorRef, self.editorLastSavedText): + editor.blockSignals(True) + editor.setPlainText(text) + editor.blockSignals(False) + + self.markAsSaved() # Fake saved + + return True + + if choice == MessageBox.ButtonRole.RejectRole.value: + # Cancel. Do nothing + return False + else: + self.hide() + + return True + + def closeEvent(self, event): + event.ignore() + + self.questionSave() + + def connectedCallback(self): + self.setWindowIcon(bootstrapIcon('rocket-takeoff-connected-dark.svg')) + + def disconnectedCallback(self): + self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg')) + + def retranslate(self): + if self.isBuiltin: + # Built-in routing is translatable + with StateContext(self): + self.setWindowTitle(_(self.windowTitle())) + + +class EditRoutingTableHorizontalHeader(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 EditRoutingTableVerticalHeader(HeaderView): + def __init__(self, *args, **kwargs): + super().__init__(QtCore.Qt.Orientation.Vertical, *args, **kwargs) + + +class EditRoutingTableWidget(Translatable, SupportConnectedCallback, TableWidget): + # Might be extended in the future + HEADER_LABEL = [ + 'Remark', + 'Type', + ] + + # Corresponds to header label + BUILTIN_ITEM_GET_FUNC = [ + lambda row: _(BUILTIN_ROUTING[row]), + lambda row: _('Built-in'), + ] + + USER_ITEM_GET_FUNC = [ + lambda text: text, + lambda text: _('User Defined'), + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.editRoutingWidget = kwargs.get('parent') + + self.questionDeleteBox = QuestionDeleteBox( + icon=MessageBox.Icon.Question, parent=self.parent() + ) + + # Handy reference + self.RoutesList = self.editRoutingWidget.RoutesList + + self.routingEditorRef = [] + + # Column count + self.setColumnCount(len(EditRoutingTableWidget.HEADER_LABEL)) + + # Delegate + self.delegate = StyledItemDelegate(parent=self) + self.setItemDelegate(self.delegate) + + # Install custom header + self.setHorizontalHeader(EditRoutingTableHorizontalHeader(self)) + + self.initTableFromData() + self.activateItemByIndex(routingToIndex(), activate=True) + + # 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().RoutesWidgetSectionSizeTable) + + # 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 EditRoutingTableWidget.HEADER_LABEL) + ) + + for column in range(self.horizontalHeader().count()): + self.horizontalHeaderItem(column).setFont(QFont(APP().customFontName)) + + # Install custom header + self.setVerticalHeader(EditRoutingTableVerticalHeader(self)) + + # Selection + self.setSelectionColor('#008AE1') + 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) + + # Signals + self.itemActivated.connect(self.handleItemActivated) + + def initTableFromData(self): + for row in range(len(BUILTIN_ROUTING)): + self.appendDataByColumn( + lambda column: EditRoutingTableWidget.BUILTIN_ITEM_GET_FUNC[column](row) + ) + + routingEditor = self.appendRoutingEditor( + self.item(row, 0).text(), isBuiltin=True + ) + + for core in [XrayCore.name(), Hysteria.name()]: + routingEditor.addTabWithData( + core, + BUILTIN_ROUTING_TEXT[BUILTIN_ROUTING[row]][core], + ) + + for row in range( + len(BUILTIN_ROUTING), + len(BUILTIN_ROUTING) + len(self.RoutesList), + ): + routesIndex = row - len(BUILTIN_ROUTING) + + remark = self.RoutesList[routesIndex]['remark'] + + self.appendDataByColumn( + lambda column: EditRoutingTableWidget.USER_ITEM_GET_FUNC[column](remark) + ) + + routingEditor = self.appendRoutingEditor( + self.item(row, 0).text(), isBuiltin=False + ) + + for core in [XrayCore.name(), Hysteria.name()]: + routingEditor.addTabWithData( + core, + ujson.dumps( + self.RoutesList[routesIndex][core], + indent=2, + ensure_ascii=False, + escape_forward_slashes=False, + ), + ) + + @QtCore.Slot(QTableWidgetItem) + def handleItemActivated(self, item): + self.routingEditorRef[item.row()].show() + + def appendRoutingEditor(self, title, isBuiltin=False): + routingEditor = RoutingEditor(isBuiltin) + routingEditor.title = title + routingEditor.setWindowTitle(title) + + try: + routingEditor.setGeometry( + 100, + 100, + *list(int(size) for size in APP().RoutesEditorWindowSize.split(',')), + ) + except Exception: + # Any non-exit exceptions + + routingEditor.setGeometry(100, 100, 728 * GOLDEN_RATIO, 728) + + moveToCenter(routingEditor) + + self.routingEditorRef.append(routingEditor) + + return routingEditor + + def deleteSelectedItem(self): + # Builtin routing cannot be deleted + indexes = list(filter(lambda x: x >= len(BUILTIN_ROUTING), self.selectedIndex)) + + if len(indexes) == 0: + # Nothing to do + return + + self.questionDeleteBox.isMulti = bool(len(indexes) > 1) + self.questionDeleteBox.possibleRemark = f'{self.item(indexes[0], 0).text()}' + self.questionDeleteBox.setText(self.questionDeleteBox.getText()) + + if self.questionDeleteBox.exec() == MessageBox.StandardButton.No.value: + # Do not delete + return + + try: + index = int(APP().Routing) + except ValueError: + takedCurrentRouting = False + else: + if index + len(BUILTIN_ROUTING) in indexes: + # Current routing will be deleted + takedCurrentRouting = True + else: + takedCurrentRouting = False + + routingAction = APP().tray.RoutingAction + + for i in range(len(indexes)): + takedRow = indexes[i] - i + + self.removeRow(takedRow) + self.routingEditorRef.pop(takedRow) + self.RoutesList.pop(takedRow - len(BUILTIN_ROUTING)) + + # Remove entry from routing menu + routingAction.removeAction(routingAction.menu().actions()[takedRow]) + + routingIndex = routingToIndex() + + if not takedCurrentRouting and takedRow < routingIndex: + APP().Routing = str(routingIndex - len(BUILTIN_ROUTING) - 1) + + # Sync it + RoutesStorage.sync() + + if takedCurrentRouting: + currentRow = self.currentIndex().row() + + self.setCurrentItem(self.item(currentRow, 0)) + + # Trigger current + routingAction.menu().actions()[currentRow].trigger() + + 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 + ) + + self.setItem(row, column, item) + + def connectedCallback(self): + self.setSelectionColor('#EB212E') + # Reactivate with possible color + self.activateItemByIndex(routingToIndex(), activate=True) + + def disconnectedCallback(self): + self.setSelectionColor('#008AE1') + # Reactivate with possible color + self.activateItemByIndex(routingToIndex(), activate=True) + + def retranslate(self): + self.setHorizontalHeaderLabels( + list(_(label) for label in EditRoutingTableWidget.HEADER_LABEL) + ) + + for row in range(self.rowCount()): + if row < len(BUILTIN_ROUTING): + for column in range(self.columnCount()): + item = self.item(row, column) + + item.setText(_(item.text())) + else: + for column in range(self.columnCount()): + # Do not translate user remark + if column != 0: + item = self.item(row, column) + + item.setText(_(item.text())) + + +class EditRoutingWidget(Translatable, SupportConnectedCallback, QMainWindow): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.setWindowTitle(_('Edit Routing')) + self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg')) + + self.assetViewer = AssetViewerWidget() + + try: + self.StorageObj = RoutesStorage.toObject(APP().CustomRouting) + # Check model. Shallow copy + self.RoutesList = self.StorageObj['model'] + except Exception: + # Any non-exit exceptions + + self.StorageObj = RoutesStorage.init() + # Shallow copy + self.RoutesList = self.StorageObj['model'] + + # Clear it + RoutesStorage.clear() + + self.editRoutingTableWidget = EditRoutingTableWidget(parent=self) + + self.editorTab = TabWidget(self) + self.editorTab.addTab(self.editRoutingTableWidget, _('Routing Rules')) + + # Buttons + self.addButton = PushButton(_('Add')) + self.addButton.clicked.connect(lambda: self.addRoute()) + 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) + + fileMenuActions = [ + ImportAssetFileAction(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) + + try: + self.setGeometry( + 100, + 100, + *list(int(size) for size in APP().RoutesWidgetWindowSize.split(',')), + ) + except Exception: + # Any non-exit exceptions + + self.setGeometry(100, 100, 640, 640 * GOLDEN_RATIO) + + moveToCenter(self) + + def addRoute(self): + # Get text for a new item from the user + text, ok = QInputDialog.getText( + self, _('Add routing'), _('Enter routing remark:') + ) + + if text and ok: + self.editRoutingTableWidget.appendDataByColumn( + lambda column: EditRoutingTableWidget.USER_ITEM_GET_FUNC[column](text) + ) + + routingEditor = self.editRoutingTableWidget.appendRoutingEditor( + text, isBuiltin=False + ) + + for core in [XrayCore.name(), Hysteria.name()]: + routingEditor.addTabWithData( + core, + USER_ROUTING_TEXT[core], + ) + + self.RoutesList.append({'remark': text, **DEFAULT_USER_ROUTING}) + + # Sync it + RoutesStorage.sync() + + routingAction = APP().tray.RoutingAction + # Add entry in routing menu + routingAction.addAction( + RoutingChildAction(text, checkable=True, checked=False) + ) + + def deleteSelectedItem(self): + self.editRoutingTableWidget.deleteSelectedItem() + + def activateItemByIndex(self, index, activate=True): + self.editRoutingTableWidget.activateItemByIndex(index, activate) + + def syncSettings(self): + APP().RoutesWidgetWindowSize = ( + f'{self.geometry().width()},{self.geometry().height()}' + ) + + if len(self.editRoutingTableWidget.routingEditorRef): + APP().RoutesEditorWindowSize = ( + f'{self.editRoutingTableWidget.routingEditorRef[0].geometry().width()},' + f'{self.editRoutingTableWidget.routingEditorRef[0].geometry().height()}' + ) + + APP().RoutesWidgetSectionSizeTable = ujson.dumps( + self.editRoutingTableWidget.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) + + def closeEvent(self, event): + event.ignore() + + self.syncSettings() + self.hide() + + def connectedCallback(self): + self.setWindowIcon(bootstrapIcon('rocket-takeoff-connected-dark.svg')) + + def disconnectedCallback(self): + self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg')) + + def retranslate(self): + with StateContext(self): + self.setWindowTitle(_(self.windowTitle())) diff --git a/Furious/Widget/LogViewer.py b/Furious/Widget/LogViewer.py index 6eb2af4..6438511 100644 --- a/Furious/Widget/LogViewer.py +++ b/Furious/Widget/LogViewer.py @@ -1,5 +1,6 @@ from Furious.Gui.Action import Action, Seperator from Furious.Widget.Widget import Menu, MessageBox, ZoomableTextBrowser +from Furious.Utility.Constants import APP from Furious.Utility.Utility import ( StateContext, SupportConnectedCallback, @@ -9,7 +10,11 @@ from Furious.Utility.Translator import Translatable, gettext as _ from Furious.Utility.Theme import DraculaTheme from PySide6 import QtCore -from PySide6.QtWidgets import QApplication, QFileDialog, QMainWindow, QTextBrowser +from PySide6.QtWidgets import QFileDialog, QMainWindow, QTextBrowser + +import logging + +logger = logging.getLogger(__name__) class SaveErrorBox(MessageBox): @@ -141,14 +146,14 @@ class LogViewerWidget(Translatable, SupportConnectedCallback, QMainWindow): self.textBrowser.setStyleSheet( DraculaTheme.getStyleSheet( widgetName='QTextBrowser', - fontFamily=QApplication.instance().customFontName, + fontFamily=APP().customFontName, ) ) try: # Restore point size font = self.textBrowser.font() - font.setPointSize(int(QApplication.instance().ViewerWidgetPointSize)) + font.setPointSize(int(APP().ViewerWidgetPointSize)) self.textBrowser.setFont(font) except Exception: @@ -178,6 +183,9 @@ class LogViewerWidget(Translatable, SupportConnectedCallback, QMainWindow): 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) @@ -192,9 +200,7 @@ class LogViewerWidget(Translatable, SupportConnectedCallback, QMainWindow): return self.textBrowser.toPlainText() def syncSettings(self): - QApplication.instance().ViewerWidgetPointSize = str( - self.textBrowser.font().pointSize() - ) + APP().ViewerWidgetPointSize = str(self.textBrowser.font().pointSize()) def closeEvent(self, event): event.ignore() diff --git a/Furious/Widget/SystemTrayIcon.py b/Furious/Widget/SystemTrayIcon.py index 724ec12..4704106 100644 --- a/Furious/Widget/SystemTrayIcon.py +++ b/Furious/Widget/SystemTrayIcon.py @@ -9,14 +9,23 @@ from Furious.Action import ( ) from Furious.Gui.Action import Action, Seperator from Furious.Widget.Widget import Menu -from Furious.Utility.Constants import APPLICATION_NAME, APPLICATION_VERSION, PLATFORM +from Furious.Utility.Constants import ( + APP, + APPLICATION_NAME, + APPLICATION_VERSION, + PLATFORM, +) from Furious.Utility.Utility import bootstrapIcon, StateContext, Switch from Furious.Utility.Translator import Translatable, gettext as _ from Furious.Utility.Proxy import Proxy from Furious.Utility.StartupOnBoot import StartupOnBoot from PySide6 import QtCore -from PySide6.QtWidgets import QApplication, QSystemTrayIcon +from PySide6.QtWidgets import QSystemTrayIcon + +import logging + +logger = logging.getLogger(__name__) class SystemTrayIcon(Translatable, QSystemTrayIcon): @@ -41,6 +50,9 @@ class SystemTrayIcon(Translatable, QSystemTrayIcon): for action in actions: if isinstance(action, Action): + if hasattr(self, f'{action}'): + logger.warning(f'{self} already has action {action}') + setattr(self, f'{action}', action) self._menu = Menu(*actions) @@ -48,11 +60,11 @@ class SystemTrayIcon(Translatable, QSystemTrayIcon): self.setContextMenu(self._menu) def bootstrap(self): - if QApplication.instance().StartupOnBoot == Switch.ON_: + if APP().StartupOnBoot == Switch.ON_: # Rrefresh startup application location StartupOnBoot.on_() - if QApplication.instance().Connect == Switch.ON_: + if APP().Connect == Switch.ON_: # Trigger connect action self.ConnectAction.trigger() @@ -77,7 +89,7 @@ class SystemTrayIcon(Translatable, QSystemTrayIcon): @QtCore.Slot(QSystemTrayIcon.ActivationReason) def handleActivated(self, reason): if reason == QSystemTrayIcon.ActivationReason.DoubleClick: - QApplication.instance().MainWidget.show() + APP().MainWidget.show() def setApplicationToolTip(self): self.setToolTip(f'{_(APPLICATION_NAME)} {APPLICATION_VERSION}') diff --git a/Furious/Widget/Widget.py b/Furious/Widget/Widget.py index f5c2c52..22195e0 100644 --- a/Furious/Widget/Widget.py +++ b/Furious/Widget/Widget.py @@ -1,4 +1,5 @@ from Furious.Gui.Action import Action, Seperator +from Furious.Utility.Constants import APP from Furious.Utility.Utility import ( StateContext, SupportConnectedCallback, @@ -8,16 +9,53 @@ from Furious.Utility.Utility import ( from Furious.Utility.Translator import Translatable, gettext as _ from PySide6 import QtCore +from PySide6.QtGui import QBrush, QColor, QFont from PySide6.QtWidgets import ( + QHeaderView, + QInputDialog, + QLineEdit, + QListWidget, QMenu, QMessageBox, QPlainTextEdit, QPushButton, + QStyledItemDelegate, + QTableWidget, QTabWidget, QTextBrowser, ) +class HeaderView(SupportConnectedCallback, QHeaderView): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.setSectionsClickable(True) + + self.setStyleSheet( + f'QHeaderView::section:hover {{ background-color: #008AE1; }}' + ) + + def connectedCallback(self): + self.setStyleSheet( + f'QHeaderView::section:hover {{ background-color: #EB212E; }}' + ) + + def disconnectedCallback(self): + self.setStyleSheet( + f'QHeaderView::section:hover {{ background-color: #008AE1; }}' + ) + + +class ListWidget(QListWidget): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + @property + def selectedIndex(self): + return sorted(list(set(index.row() for index in self.selectedIndexes()))) + + class Menu(Translatable, QMenu): def __init__(self, *actions, **kwargs): super().__init__(**kwargs) @@ -99,19 +137,111 @@ class PushButton(Translatable, QPushButton): 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) + + @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().tray is None: + # Initializing + item.setForeground(QColor('#008AE1')) + else: + if APP().tray.ConnectAction.isConnected(): + item.setForeground(QColor('#EB212E')) + else: + item.setForeground(QColor('#008AE1')) + 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 i in range(self.tabBar().count()): - self.tabBar().setTabText(i, _(self.tabBar().tabText(i))) + 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 + def getIndent(line): + indent = '' + + for char in line: + if char.isspace(): + indent += char + else: + break + + return indent + + 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 keyPressEvent(self, event): + if ( + event.key() == QtCore.Qt.Key.Key_Return + or event.key() == QtCore.Qt.Key.Key_Enter + ): + self.smartIndent(event) + else: + super().keyPressEvent(event) + def wheelEvent(self, event): if event.modifiers() == QtCore.Qt.KeyboardModifier.ControlModifier: delta = event.angleDelta().y()