from Furious.Core.Core import XrayCore, Hysteria from Furious.Core.Intellisense import Intellisense from Furious.Core.Configuration import Configuration from Furious.Action.Routing import SUPPORTED_ROUTING_DETAIL from Furious.Gui.Action import Action from Furious.Widget.ConnectingProgressBar import ConnectingProgressBar from Furious.Widget.Widget import MessageBox from Furious.Utility.Constants import APPLICATION_NAME, PROXY_SERVER_BYPASS from Furious.Utility.Utility import ( Switch, SupportConnectedCallback, bootstrapIcon, getAbsolutePath, ) from Furious.Utility.Translator import gettext as _ from Furious.Utility.Proxy import Proxy from PySide6 import QtCore from PySide6.QtWidgets import QApplication from PySide6.QtNetwork import ( QNetworkAccessManager, QNetworkReply, QNetworkRequest, QNetworkProxy, ) import ujson import random import logging logger = logging.getLogger(__name__) class HttpProxyServerError(Exception): pass class ConnectAction(Action): def __init__(self): super().__init__( _('Connect'), icon=bootstrapIcon('unlock-fill.svg'), checkable=True, ) self.connectingProgressBar = ConnectingProgressBar() self.configurationEmptyBox = MessageBox() self.configurationIcingBox = MessageBox() self.configurationErrorBox = MessageBox() self.configurationTampered = MessageBox() self.httpProxyConfErrorBox = MessageBox() self.proxyServer = '' self.networkAccessManager = QNetworkAccessManager(parent=self) self.networkReply = None self.coreName = '' self.coreText = '' self.coreJSON = {} self.coreRunning = False self.XrayRouting = {} self.connectingFlag = False # Note: The connection test is carried out item by item # from top to bottom. If any of these succeed, # connected action will be executed. # "Popular" sites that's been endorsed by some government. self.testPool = [ # Messaging 'https://telegram.org/', # Social media 'https://twitter.com/', # Videos 'https://www.youtube.com/', ] self.testTime = 0 self.XrayCore = XrayCore() self.Hysteria = Hysteria() def XrayCoreExitCallback(self, exitcode): if self.coreName: # If core is running assert self.coreRunning assert self.coreName == XrayCore.name() if exitcode == XrayCore.ExitCode.ConfigurationError: if not self.isConnecting(): # Protect connecting action. Mandatory return self.disconnectAction( f'{XrayCore.name()}: {_("Invalid server configuration")}' ) if exitcode == XrayCore.ExitCode.ServerStartFailure: if not self.isConnecting(): # Protect connecting action. Mandatory return self.disconnectAction( f'{XrayCore.name()}: {_("Failed to start core")}' ) if not self.isConnecting(): # Protect connecting action. Mandatory self.disconnectAction( f'{XrayCore.name()}: {_("Core terminated unexpectedly")}' ) def HysteriaExitCallback(self, exitcode): if self.coreName: # If core is running assert self.coreRunning assert self.coreName == Hysteria.name() if exitcode == Hysteria.ExitCode.ConfigurationError: if not self.isConnecting(): # Protect connecting action. Mandatory return self.disconnectAction( f'{Hysteria.name()}: {_("Invalid server configuration")}' ) if exitcode == Hysteria.ExitCode.RemoteNetworkError: if not self.isConnecting(): # Protect connecting action. Mandatory return self.disconnectAction( f'{Hysteria.name()}: {_("Connection to server has been lost")}' ) if not self.isConnecting(): # Protect connecting action. Mandatory self.disconnectAction( f'{Hysteria.name()}: {_("Core terminated unexpectedly")}' ) def stopCore(self): # Stop any potentially running core self.XrayCore.stop() self.Hysteria.stop() def showConnectingProgressBar(self): if QApplication.instance().ShowProgressBarWhenConnecting == Switch.ON_: self.connectingProgressBar.progressBar.setValue(0) # Update the progress bar every 50ms self.connectingProgressBar.timer.start(50) self.connectingProgressBar.show() return self def hideConnectingProgressBar(self, done=False): if done: self.connectingProgressBar.progressBar.setValue(100) self.connectingProgressBar.hide() self.connectingProgressBar.timer.stop() return self def moveConnectingProgressBar(self): # Progressing if self.connectingProgressBar.progressBar.value() <= 45: self.connectingProgressBar.progressBar.setValue(random.randint(45, 50)) # Lower timer frequency self.connectingProgressBar.timer.start(250) return self def setDisabledAction(self, value): self.setDisabled(value) QApplication.instance().tray.RoutingAction.setDisabled(value) def setConnectingStatus(self, showProgressBar=True): if showProgressBar: self.showConnectingProgressBar() # Do not accept new action self.setDisabledAction(True) self.setText(_('Connecting')) self.setIcon(bootstrapIcon('lock-fill.svg')) QApplication.instance().tray.setPlainIcon() def setConnectedStatus(self): self.hideConnectingProgressBar(done=True) self.setDisabledAction(False) QApplication.instance().tray.setConnectedIcon() # Finished. Reset connecting flag self.connectingFlag = False # Connected self.setText(_('Disconnect')) SupportConnectedCallback.callConnectedCallback() def isConnecting(self): return self.connectingFlag def isConnected(self): return self.textCompare('Disconnect') def reset(self): # Reset everything self.XrayCore.registerExitCallback(None) self.Hysteria.registerExitCallback(None) self.stopCore() self.hideConnectingProgressBar() self.setText(_('Connect')) self.setIcon(bootstrapIcon('unlock-fill.svg')) self.setChecked(False) QApplication.instance().Connect = Switch.OFF QApplication.instance().tray.setPlainIcon() self.proxyServer = '' self.coreName = '' self.coreText = '' self.coreJSON = {} self.coreRunning = False self.XrayRouting = {} # Accept new action self.setDisabledAction(False) self.connectingFlag = False @property def MainWidget(self): # Handy reference return QApplication.instance().MainWidget @property def activatedServer(self): try: activatedIndex = int(QApplication.instance().ActivatedItemIndex) if activatedIndex < 0: return None else: return self.MainWidget.ServerList[activatedIndex]['config'] except Exception: # Any non-exit exceptions return None def errorConfiguration(self): self.configurationErrorBox.setIcon(MessageBox.Icon.Critical) self.configurationErrorBox.setWindowTitle(_('Unable to connect')) self.configurationErrorBox.setText(_('Invalid server configuration.')) # Show the MessageBox and wait for user to close it self.configurationErrorBox.exec() def errorConfigurationEmpty(self): self.configurationEmptyBox.setIcon(MessageBox.Icon.Critical) self.configurationEmptyBox.setWindowTitle(_('Unable to connect')) self.configurationEmptyBox.setText( _('Server configuration empty. Please configure your server first.') ) # Show the MessageBox and wait for user to close it self.configurationEmptyBox.exec() def errorConfigurationNotActivated(self): self.configurationIcingBox.setIcon(MessageBox.Icon.Information) self.configurationIcingBox.setWindowTitle(_('Unable to connect')) self.configurationIcingBox.setText( _('Select and double click to activate configuration and connect.') ) # Show the MessageBox and wait for user to close it self.configurationIcingBox.exec() def errorHttpProxyConf(self): self.httpProxyConfErrorBox.setIcon(MessageBox.Icon.Critical) self.httpProxyConfErrorBox.setWindowTitle(_('Unable to connect')) self.httpProxyConfErrorBox.setText( _( f'{APPLICATION_NAME} cannot find any valid http proxy ' f'endpoint in your server configuration.' ) ) self.httpProxyConfErrorBox.setInformativeText( _('Please complete your server configuration.') ) # Show the MessageBox and wait for user to close it self.httpProxyConfErrorBox.exec() def configureCore(self): def validateProxyServer(server): # Validate proxy server try: host, port = server.split(':') if int(port) < 0 or int(port) > 65535: raise ValueError except Exception: # Any non-exit exceptions self.reset() self.errorHttpProxyConf() return False else: self.proxyServer = server return True self.stopCore() self.XrayCore.registerExitCallback( lambda exitcode: self.XrayCoreExitCallback(exitcode) ) self.Hysteria.registerExitCallback( lambda exitcode: self.HysteriaExitCallback(exitcode) ) proxyServer = None if Intellisense.getCoreType(self.coreJSON) == XrayCore.name(): # Assuming is XrayCore configuration proxyHost = None proxyPort = None try: for inbound in self.coreJSON['inbounds']: if inbound['protocol'] == 'http': proxyHost = inbound['listen'] proxyPort = inbound['port'] # Note: If there are multiple http inbounds # satisfied, the first one will be chosen. break if proxyHost is None or proxyPort is None: # No HTTP proxy endpoint configured raise HttpProxyServerError proxyServer = f'{proxyHost}:{proxyPort}' except (KeyError, HttpProxyServerError): self.reset() self.errorHttpProxyConf() else: if validateProxyServer(proxyServer): self.coreRunning = True routing = QApplication.instance().Routing logger.info(f'Core {XrayCore.name()} configured') logger.info(f'Routing is {routing}') def fixLoggingRelativePath(attr): # Relative path fails if booting on start up # on Windows, when packed using nuitka... # Fix relative path if needed. User cannot feel this operation. try: path = self.coreJSON['log'][attr] except KeyError: pass else: if path and ( isinstance(path, str) or isinstance(path, bytes) ): fix = getAbsolutePath(path) logger.info( f'{XrayCore.name()}: {attr} log is specified as \'{path}\'. ' f'Fixed to \'{fix}\'' ) self.coreJSON['log'][attr] = fix fixLoggingRelativePath('access') fixLoggingRelativePath('error') if routing == 'Bypass': self.coreJSON['routing'] = { 'domainStrategy': 'IPIfNonMatch', 'domainMatcher': 'hybrid', 'rules': ( # ads { 'type': 'field', 'outboundTag': 'block', 'domain': ['geosite:category-ads-all'], }, # geosite { 'type': 'field', 'outboundTag': 'direct', 'domain': ['geosite:cn'], }, # geoip { 'type': 'field', 'outboundTag': 'direct', 'ip': ['geoip:private', 'geoip:cn'], }, # Proxy everything { 'type': 'field', 'port': '0-65535', 'outboundTag': 'proxy', }, ), } if routing == 'Global': self.coreJSON['routing'] = { 'domainStrategy': 'IPIfNonMatch', 'domainMatcher': 'hybrid', 'rules': ( # Proxy everything { 'type': 'field', 'port': '0-65535', 'outboundTag': 'proxy', }, ), } if routing == 'Custom': # Assign user routing self.coreJSON['routing'] = self.XrayRouting # Refresh configuration modified before. User cannot feel self.coreText = ujson.dumps( self.coreJSON, ensure_ascii=False, escape_forward_slashes=False ) # Start core self.XrayCore.start(self.coreText) return XrayCore.name() if Intellisense.getCoreType(self.coreJSON) == Hysteria.name(): # Assuming is Hysteria configuration try: proxyServer = self.coreJSON['http']['listen'] if proxyServer is None: # No HTTP proxy endpoint configured raise HttpProxyServerError except (KeyError, HttpProxyServerError): self.reset() self.errorHttpProxyConf() else: if validateProxyServer(proxyServer): self.coreRunning = True routing = QApplication.instance().Routing logger.info(f'Core {Hysteria.name()} configured') logger.info(f'Routing is {routing}') if routing == 'Bypass': self.Hysteria.start( self.coreText, Hysteria.rule(), Hysteria.mmdb() ) if routing == 'Global': self.Hysteria.start(self.coreText, '', '') if routing == 'Custom': if self.coreJSON.get('acl') is not None: # acl specified if self.coreJSON.get('mmdb') is not None: # mmdb specified. Start "as is" self.Hysteria.start( self.coreText, Hysteria.rule(self.coreJSON['acl']), Hysteria.mmdb(self.coreJSON['mmdb']), ) else: # mmdb not specified. Route as global self.Hysteria.start(self.coreText, '', '') else: # acl not specified. Route as global self.Hysteria.start(self.coreText, '', '') return Hysteria.name() # No matching core return '' def startConnectionTest(self, showRoutingChangedMessage=False): selected = self.testPool[self.testTime] self.testTime += 1 logger.info(f'start connection test. Try: {selected}') # Checked. split should not throw exceptions proxyHost, proxyPort = self.proxyServer.split(':') # Checked. int(proxyPort) should not throw exceptions self.networkAccessManager.setProxy( QNetworkProxy(QNetworkProxy.ProxyType.HttpProxy, proxyHost, int(proxyPort)) ) self.networkReply = self.networkAccessManager.get( QNetworkRequest(QtCore.QUrl(selected)) ) @QtCore.Slot() def finishedCallback(): assert isinstance(self.networkReply, QNetworkReply) if self.networkReply.error() != QNetworkReply.NetworkError.NoError: logger.error( f'{self.coreName}: connection test failed. {self.networkReply.errorString()}' ) if self.testTime < len(self.testPool): # Try next self.startConnectionTest(showRoutingChangedMessage) else: self.disconnectAction( f'{self.coreName}: {_("Connection test failed")}' ) else: logger.info(f'{self.coreName}: connection test success. Connected') QApplication.instance().Connect = Switch.ON_ # Connected status self.setConnectedStatus() if showRoutingChangedMessage: # Routing changed QApplication.instance().tray.showMessage( _('Routing changed: ') + _( f'{SUPPORTED_ROUTING_DETAIL[QApplication.instance().Routing]}' ) ) else: # Connected QApplication.instance().tray.showMessage( f'{self.coreName}: {_("Connected")}' ) self.networkReply.finished.connect(finishedCallback) def connectAction(self): # Connect action assert self.textCompare('Connect') # Connecting self.connectingFlag = True if ( not QApplication.instance().Configuration or len(self.MainWidget.ServerList) == 0 ): QApplication.instance().Connect = Switch.OFF self.setChecked(False) self.connectingFlag = False self.errorConfigurationEmpty() return myText = self.activatedServer if myText is None: QApplication.instance().Connect = Switch.OFF self.setChecked(False) self.connectingFlag = False self.errorConfigurationNotActivated() return if myText == '': QApplication.instance().Connect = Switch.OFF self.setChecked(False) self.connectingFlag = False self.errorConfigurationEmpty() return try: myJSON = Configuration.toJSON(myText) except Exception: # Any non-exit exceptions QApplication.instance().Connect = Switch.OFF self.setChecked(False) self.connectingFlag = False # Invalid configuratoin self.errorConfiguration() else: # Get server configuration success. Continue. # Note: use self.reset() to restore state self.coreText = myText self.coreJSON = myJSON # Memorize user routing if possible self.XrayRouting = myJSON.get('routing', {}) self.connectingAction() def connectingAction(self, showProgressBar=True, showRoutingChangedMessage=False): # Connecting status self.setConnectingStatus(showProgressBar) # Configure connect self.coreName = self.configureCore() if not self.coreName: # No matching core self.reset() self.errorConfiguration() return if not self.coreRunning: # No valid HTTP proxy endpoint / Core has exited. # reset / disconnect has been called return try: Proxy.set(self.proxyServer, PROXY_SERVER_BYPASS) except Exception: # Any non-exit exceptions Proxy.off() self.reset() self.errorConfiguration() else: self.moveConnectingProgressBar() # Reset try time self.testTime = 0 self.startConnectionTest(showRoutingChangedMessage) def disconnectAction(self, reason=''): Proxy.off() self.reset() SupportConnectedCallback.callDisconnectedCallback() QApplication.instance().tray.showMessage(reason) def reconnectAction(self, reason=''): self.disconnectAction(reason) self.trigger() def triggeredCallback(self, checked): if checked: self.connectAction() else: # Disconnect action assert self.textCompare('Disconnect') assert self.connectingFlag is False self.disconnectAction(f'{self.coreName}: {_("Disconnected")}')