diff --git a/Furious/Action/Connect.py b/Furious/Action/Connect.py deleted file mode 100644 index d5bfe6f..0000000 --- a/Furious/Action/Connect.py +++ /dev/null @@ -1,1386 +0,0 @@ -# Copyright (C) 2023 Loren Eteval -# -# This file is part of Furious. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -from Furious.Core.Core import XrayCore, Hysteria1, Hysteria2, StdoutRedirectHelper -from Furious.Core.TorRelay import TorRelay -from Furious.Core.Tun2socks import Tun2socks -from Furious.Core.Intellisense import Intellisense -from Furious.Core.Configuration import Configuration -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 ( - APP, - PLATFORM, - APPLICATION_NAME, - PROXY_SERVER_BYPASS, - APPLICATION_TUN_DEVICE_NAME, - APPLICATION_TUN_NETWORK_INTERFACE_NAME, - APPLICATION_TUN_IP_ADDRESS, - APPLICATION_TUN_GATEWAY_ADDRESS, - DEFAULT_TOR_HTTPS_PORT, - DEFAULT_TOR_SOCKS_PORT, - DEFAULT_TOR_RELAY_ESTABLISH_TIMEOUT, - LogType, -) -from Furious.Utility.Utility import ( - Switch, - SupportConnectedCallback, - DNSResolver, - bootstrapIcon, - getAbsolutePath, - runCommand, - parseHostPort, - eventLoopWait, - isValidIPAddress, - isAdministrator, - isVPNMode, - isPythonw, -) -from Furious.Utility.Translator import gettext as _ -from Furious.Utility.Proxy import Proxy -from Furious.Utility.RoutingTable import RoutingTable - -from PySide6 import QtCore -from PySide6.QtNetwork import ( - QNetworkAccessManager, - QNetworkReply, - QNetworkRequest, - QNetworkProxy, -) - -import uuid -import ujson -import random -import logging -import pathlib -import subprocess - -logger = logging.getLogger(__name__) - - -class HttpsProxyServerError(Exception): - pass - - -class SocksProxyServerError(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.configurationProxyErr = MessageBox() - - self.httpsProxyServer = '' - self.socksProxyServer = '' - - self.networkAccessManager = QNetworkAccessManager(parent=self) - self.networkReply = None - - self.coreName = '' - self.coreText = '' - self.coreJSON = {} - self.coreRunning = False - self.XrayRouting = {} - - self.connectingFlag = False - - self.disconnectReason = '' - - # 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/', - # # Search - # 'https://www.google.com/', - # # Social media - # 'https://twitter.com/', - # # Videos - # 'https://www.youtube.com/', - # ] - self.testPool = [ - 'http://cp.cloudflare.com/', - 'http://www.gstatic.com/generate_204', - 'http://captive.apple.com/', - ] - self.testTime = 0 - self.testFinished = True - self.testTimeoutTimer = QtCore.QTimer() - self.testTimeoutTimer.setSingleShot(True) - - self.XrayCore = XrayCore() - self.Hysteria1 = Hysteria1() - self.Hysteria2 = Hysteria2() - self.Tun2socks = Tun2socks() - self.TorRelay = TorRelay() - - def XrayCoreExitCallback(self, exitcode): - if self.coreName: - # If core is running - assert self.coreRunning - assert self.coreName == XrayCore.name() - - if exitcode == XrayCore.ExitCode.SystemShuttingDown: - # System shutting down. Do nothing - return - - if exitcode == XrayCore.ExitCode.ConfigurationError: - if not self.isConnecting(): - # Protect connecting action. Mandatory - return self.disconnectAction( - f'{XrayCore.name()}: {_("Invalid server configuration")}' - ) - else: - self.coreRunning = False - self.disconnectReason = ( - f'{XrayCore.name()}: {_("Invalid server configuration")}' - ) - - return - - if exitcode == XrayCore.ExitCode.ServerStartFailure: - if not self.isConnecting(): - # Protect connecting action. Mandatory - return self.disconnectAction( - f'{XrayCore.name()}: {_("Failed to start core")}' - ) - else: - self.coreRunning = False - self.disconnectReason = ( - f'{XrayCore.name()}: {_("Failed to start core")}' - ) - - return - - if not self.isConnecting(): - # Protect connecting action. Mandatory - self.disconnectAction( - f'{XrayCore.name()}: {_("Core terminated unexpectedly")}' - ) - else: - self.coreRunning = False - self.disconnectReason = ( - f'{XrayCore.name()}: {_("Core terminated unexpectedly")}' - ) - - def Hysteria1ExitCallback(self, exitcode): - if self.coreName: - # If core is running - assert self.coreRunning - assert self.coreName == Hysteria1.name() - - if exitcode == Hysteria1.ExitCode.SystemShuttingDown: - # System shutting down. Do nothing - return - - if exitcode == Hysteria1.ExitCode.ConfigurationError: - if not self.isConnecting(): - # Protect connecting action. Mandatory - return self.disconnectAction( - f'{Hysteria1.name()}: {_("Invalid server configuration")}' - ) - else: - self.coreRunning = False - self.disconnectReason = ( - f'{Hysteria1.name()}: {_("Invalid server configuration")}' - ) - - return - - if exitcode == Hysteria1.ExitCode.RemoteNetworkError: - if not self.isConnecting(): - # Protect connecting action. Mandatory - return self.disconnectAction( - f'{Hysteria1.name()}: {_("Connection to server has been lost")}' - ) - else: - self.coreRunning = False - self.disconnectReason = ( - f'{Hysteria1.name()}: {_("Connection to server has been lost")}' - ) - - return - - if not self.isConnecting(): - # Protect connecting action. Mandatory - self.disconnectAction( - f'{Hysteria1.name()}: {_("Core terminated unexpectedly")}' - ) - else: - self.coreRunning = False - self.disconnectReason = ( - f'{Hysteria1.name()}: {_("Core terminated unexpectedly")}' - ) - - def Hysteria2ExitCallback(self, exitcode): - if self.coreName: - # If core is running - assert self.coreRunning - assert self.coreName == Hysteria2.name() - - if exitcode == Hysteria2.ExitCode.SystemShuttingDown: - # System shutting down. Do nothing - return - - if exitcode == Hysteria2.ExitCode.ConfigurationError: - if not self.isConnecting(): - # Protect connecting action. Mandatory - return self.disconnectAction( - f'{Hysteria2.name()}: {_("Invalid server configuration")}' - ) - else: - self.coreRunning = False - self.disconnectReason = ( - f'{Hysteria2.name()}: {_("Invalid server configuration")}' - ) - - return - - if exitcode == Hysteria2.ExitCode.ServerStartFailure: - if not self.isConnecting(): - # Protect connecting action. Mandatory - return self.disconnectAction( - f'{Hysteria2.name()}: {_("Failed to start core")}' - ) - else: - self.coreRunning = False - self.disconnectReason = ( - f'{Hysteria2.name()}: {_("Failed to start core")}' - ) - - return - - if not self.isConnecting(): - # Protect connecting action. Mandatory - self.disconnectAction( - f'{Hysteria2.name()}: {_("Core terminated unexpectedly")}' - ) - else: - self.coreRunning = False - self.disconnectReason = ( - f'{Hysteria2.name()}: {_("Core terminated unexpectedly")}' - ) - - def Tun2socksExitCallback(self, exitcode): - if exitcode == Tun2socks.ExitCode.SystemShuttingDown: - # System shutting down. Do nothing - return - - if not self.isConnecting(): - # Protect connecting action. Mandatory - self.disconnectAction( - f'{Tun2socks.name()}: {_("Core terminated unexpectedly")}' - ) - else: - self.coreRunning = False - self.disconnectReason = ( - f'{Tun2socks.name()}: {_("Core terminated unexpectedly")}' - ) - - def stopCore(self): - self.stopTun2socks() - - self.TorRelay.stop() - - self.XrayCore.registerExitCallback(None) - self.Hysteria1.registerExitCallback(None) - self.Hysteria2.registerExitCallback(None) - # Stop any potentially running core - self.XrayCore.stop() - self.Hysteria1.stop() - self.Hysteria2.stop() - - self.coreRunning = False - - def showConnectingProgressBar(self): - if APP().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) - - APP().tray.RoutingAction.setDisabled(value) - - if isAdministrator(): - VPNModeAction = APP().tray.SettingsAction.getVPNModeAction() - - if VPNModeAction is not None: - VPNModeAction.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')) - - def setConnectedStatus(self): - self.hideConnectingProgressBar(done=True) - self.setDisabledAction(False) - - APP().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.stopCore() - - self.hideConnectingProgressBar() - self.setText(_('Connect')) - self.setIcon(bootstrapIcon('unlock-fill.svg')) - self.setChecked(False) - - APP().Connect = Switch.OFF - APP().tray.setPlainIcon() - - self.httpsProxyServer = '' - self.socksProxyServer = '' - - self.coreName = '' - self.coreText = '' - self.coreJSON = {} - self.coreRunning = False - self.XrayRouting = {} - - # Accept new action - self.setDisabledAction(False) - - self.disconnectReason = '' - - self.connectingFlag = False - - @property - def activatedServer(self): - try: - activatedIndex = int(APP().ActivatedItemIndex) - - if activatedIndex < 0: - return None - else: - return APP().ServerWidget.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 errorProxyConf(self, proxyType): - assert proxyType == 'http' or proxyType == 'socks' - - self.configurationProxyErr.setIcon(MessageBox.Icon.Critical) - self.configurationProxyErr.setWindowTitle(_('Unable to connect')) - self.configurationProxyErr.setText( - _( - f'{APPLICATION_NAME} cannot find any valid {proxyType} proxy ' - f'endpoint in your server configuration.' - ) - ) - self.configurationProxyErr.setInformativeText( - _('Please complete your server configuration.') - ) - - # Show the MessageBox and wait for user to close it - self.configurationProxyErr.exec() - - def errorHttpsProxyConf(self): - self.errorProxyConf('http') - - def errorSocksProxyConf(self): - self.errorProxyConf('socks') - - @property - def torRelayStorageObj(self): - # Handy reference - return APP().TorRelayWidget.StorageObj - - def configureCore(self): - def validateHttpsProxyServer(server): - # Validate https proxy server - try: - host, port = parseHostPort(server) - - if int(port) < 0 or int(port) > 65535: - raise ValueError - except Exception: - # Any non-exit exceptions - - self.reset() - self.errorHttpsProxyConf() - - return False - else: - self.httpsProxyServer = server - - return True - - def validateSocksProxyServer(server): - # Validate socks proxy server - try: - host, port = parseHostPort(server) - - if int(port) < 0 or int(port) > 65535: - raise ValueError - except Exception: - # Any non-exit exceptions - - self.reset() - self.errorSocksProxyConf() - - return False - else: - self.socksProxyServer = server - - return True - - # Clear log - APP().logViewerWidget.clear(LogType.Core) - - self.stopCore() - - self.XrayCore.registerExitCallback(self.XrayCoreExitCallback) - self.Hysteria1.registerExitCallback(self.Hysteria1ExitCallback) - self.Hysteria2.registerExitCallback(self.Hysteria2ExitCallback) - self.Tun2socks.registerExitCallback(self.Tun2socksExitCallback) - - httpsProxyServer = None - socksProxyServer = None - - if Intellisense.getCoreType(self.coreJSON) == XrayCore.name(): - # Assuming is Xray-Core configuration - httpsProxyHost = None - httpsProxyPort = None - socksProxyHost = None - socksProxyPort = None - - if isVPNMode(): - # Check socks inbound is valid - try: - for inbound in self.coreJSON['inbounds']: - if inbound['protocol'] == 'socks': - socksProxyHost = inbound['listen'] - socksProxyPort = inbound['port'] - - # Note: If there are multiple socks inbounds - # satisfied, the first one will be chosen. - break - - if socksProxyHost is None or socksProxyPort is None: - # No SOCKS proxy endpoint configured - raise SocksProxyServerError - - socksProxyServer = f'{socksProxyHost}:{socksProxyPort}' - except (KeyError, SocksProxyServerError): - self.reset() - self.errorSocksProxyConf() - - # Return to caller - return XrayCore.name() - else: - # Validate socks proxy server - if not validateSocksProxyServer(socksProxyServer): - # Return to caller - return XrayCore.name() - - try: - for inbound in self.coreJSON['inbounds']: - if inbound['protocol'] == 'http': - httpsProxyHost = inbound['listen'] - httpsProxyPort = inbound['port'] - - # Note: If there are multiple http inbounds - # satisfied, the first one will be chosen. - break - - if httpsProxyHost is None or httpsProxyPort is None: - # No HTTP proxy endpoint configured - raise HttpsProxyServerError - - httpsProxyServer = f'{httpsProxyHost}:{httpsProxyPort}' - except (KeyError, HttpsProxyServerError): - self.reset() - self.errorHttpsProxyConf() - else: - if validateHttpsProxyServer(httpsProxyServer): - logger.info(f'core {XrayCore.name()} configured') - - if self.coreJSON.get('log') is None or not isinstance( - self.coreJSON['log'], dict - ): - self.coreJSON['log'] = { - 'access': '', - 'error': '', - 'loglevel': 'warning', - } - - redirect = str(uuid.uuid4()) - - def fixLogObjectPath(logAttr): - try: - path = self.coreJSON['log'][logAttr] - except Exception: - # Any non-exit exceptions - - self.coreJSON['log'][logAttr] = path = '' - - if not isinstance(path, str) and not isinstance(path, bytes): - self.coreJSON['log'][logAttr] = path = '' - - if path == '': - if ( - isPythonw() - and StdoutRedirectHelper.TemporaryDir.isValid() - ): - # Redirect implementation - self.coreJSON['log'][ - logAttr - ] = StdoutRedirectHelper.TemporaryDir.filePath(redirect) - else: - # Relative path fails if booting on start up - # on Windows, when packed using nuitka... - - # Fix relative path if needed. User cannot feel this operation. - self.coreJSON['log'][logAttr] = getAbsolutePath(path) - - result = self.coreJSON['log'][logAttr] - - if result: - try: - # Create a new file - with open(result, 'x'): - pass - except FileExistsError: - pass - except Exception: - # Any non-exit exceptions - - pass - - logger.info( - f'{XrayCore.name()}: {logAttr} log is specified as \'{path}\'. ' - f'Fixed to \'{result}\'' - ) - - # Fix logObject - for attr in ['access', 'error']: - fixLogObjectPath(attr) - - routing = APP().Routing - - # Filter Route My Traffic Through Tor, Custom - if routing in list( - filter( - lambda x: x != 'Route My Traffic Through Tor' - and x != 'Custom', - BUILTIN_ROUTING, - ) - ): - routingObject = BUILTIN_ROUTING_TABLE[routing][XrayCore.name()] - - logger.info(f'routing is {routing}') - logger.info(f'RoutingObject: {routingObject}') - - self.coreJSON['routing'] = routingObject - elif routing == 'Route My Traffic Through Tor': - logger.info(f'routing is {routing}') - - if TorRelay.checkIfExists(): - logger.info( - f'find Tor CLI in path success. Version: {TorRelay.version()}' - ) - - routingObject = {} - - logger.info(f'RoutingObject: {routingObject}') - - self.coreJSON['routing'] = routingObject - else: - logger.error('find Tor CLI in path failed') - - self.coreRunning = False - self.disconnectReason = ( - f'{XrayCore.name()}: {_("Cannot find Tor CLI in PATH")}' - ) - - return XrayCore.name() - 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: - route = APP().RoutesWidget.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 as ex: - # Any non-exit exceptions - - logger.error( - f'get custom routing object failed: {ex}. Fast fail' - ) - - # Fast fail - self.coreJSON = {} - - self.coreRunning = True - - # Refresh configuration modified before. User cannot feel - self.coreText = ujson.dumps( - self.coreJSON, ensure_ascii=False, escape_forward_slashes=False - ) - # Start core - if self.coreJSON: - self.XrayCore.start(self.coreText) - - if routing == 'Route My Traffic Through Tor': - # Must start Tor Relay first since it will redirect proxy for us - self.startTorRelay(XrayCore.name(), httpsProxyServer) - - if isVPNMode(): - if PLATFORM == 'Windows' or PLATFORM == 'Darwin': - # Currently VPN Mode is only supported on Windows and macOS - self.startTun2socks() - else: - # Fast fail - self.XrayCore.start(self.coreText, waitTime=1000) - - return XrayCore.name() - - if Intellisense.getCoreType(self.coreJSON) == Hysteria1.name(): - # Assuming is Hysteria1 configuration - - if isVPNMode(): - # Check socks inbound is valid - try: - socksProxyServer = self.coreJSON['socks5']['listen'] - - if socksProxyServer is None: - # No SOCKS proxy endpoint configured - raise SocksProxyServerError - except (KeyError, SocksProxyServerError): - self.reset() - self.errorSocksProxyConf() - - # Return to caller - return Hysteria1.name() - else: - # Validate socks proxy server - if not validateSocksProxyServer(socksProxyServer): - # Return to caller - return Hysteria1.name() - - try: - httpsProxyServer = self.coreJSON['http']['listen'] - - if httpsProxyServer is None: - # No HTTP proxy endpoint configured - raise HttpsProxyServerError - except (KeyError, HttpsProxyServerError): - self.reset() - self.errorHttpsProxyConf() - else: - if validateHttpsProxyServer(httpsProxyServer): - logger.info(f'core {Hysteria1.name()} configured') - - self.coreRunning = True - - routing = APP().Routing - - # Filter Route My Traffic Through Tor, Global, Custom - if routing in list( - filter( - lambda x: x != 'Route My Traffic Through Tor' - and x != 'Global' - and x != 'Custom', - BUILTIN_ROUTING, - ) - ): - logger.info(f'routing is {routing}') - - routingObject = BUILTIN_ROUTING_TABLE[routing][Hysteria1.name()] - - self.Hysteria1.start( - self.coreText, - Hysteria1.rule(routingObject.get('acl')), - Hysteria1.mmdb(routingObject.get('mmdb')), - ) - elif routing == 'Route My Traffic Through Tor': - logger.info(f'routing is {routing}') - - if TorRelay.checkIfExists(): - logger.info( - f'find Tor CLI in path success. Version: {TorRelay.version()}' - ) - - self.Hysteria1.start(self.coreText, '', '') - - self.startTorRelay(Hysteria1.name(), httpsProxyServer) - else: - logger.error('find Tor CLI in path failed') - - self.coreRunning = False - self.disconnectReason = f'{Hysteria1.name()}: {_("Cannot find Tor CLI in PATH")}' - - return Hysteria1.name() - elif routing == 'Global': - logger.info(f'routing is {routing}') - - self.Hysteria1.start(self.coreText, '', '') - elif routing == 'Custom': - logger.info(f'routing is {routing}') - - self.Hysteria1.start( - self.coreText, - Hysteria1.rule(self.coreJSON.get('acl')), - Hysteria1.mmdb(self.coreJSON.get('mmdb')), - ) - else: - try: - route = APP().RoutesWidget.RoutesList[int(routing)] - - logger.info(f'routing is {route["remark"]}') - logger.info(f'RoutingObject: {route[Hysteria1.name()]}') - - self.Hysteria1.start( - self.coreText, - Hysteria1.rule(route[Hysteria1.name()].get('acl')), - Hysteria1.mmdb(route[Hysteria1.name()].get('mmdb')), - ) - except Exception as ex: - # Any non-exit exceptions - - logger.error( - f'get custom routing object failed: {ex}. Fast fail' - ) - - # Fast fail - self.coreJSON = {} - self.coreText = '' - - self.Hysteria1.start(self.coreText, '', '', waitTime=1000) - - if self.coreJSON: - if isVPNMode(): - if PLATFORM == 'Windows' or PLATFORM == 'Darwin': - # Currently VPN Mode is only supported on Windows and macOS - self.startTun2socks() - - return Hysteria1.name() - - if Intellisense.getCoreType(self.coreJSON) == Hysteria2.name(): - # Assuming is Hysteria2 configuration - - if isVPNMode(): - # Check socks inbound is valid - try: - socksProxyServer = self.coreJSON['socks5']['listen'] - - if socksProxyServer is None: - # No SOCKS proxy endpoint configured - raise SocksProxyServerError - except (KeyError, SocksProxyServerError): - self.reset() - self.errorSocksProxyConf() - - # Return to caller - return Hysteria2.name() - else: - # Validate socks proxy server - if not validateSocksProxyServer(socksProxyServer): - # Return to caller - return Hysteria2.name() - - try: - httpsProxyServer = self.coreJSON['http']['listen'] - - if httpsProxyServer is None: - # No HTTP proxy endpoint configured - raise HttpsProxyServerError - except (KeyError, HttpsProxyServerError): - self.reset() - self.errorHttpsProxyConf() - else: - if validateHttpsProxyServer(httpsProxyServer): - logger.info(f'core {Hysteria2.name()} configured') - - self.coreRunning = True - - routing = APP().Routing - - if routing == 'Route My Traffic Through Tor': - logger.info(f'routing is {routing}') - - if TorRelay.checkIfExists(): - logger.info( - f'find Tor CLI in path success. Version: {TorRelay.version()}' - ) - - self.Hysteria2.start(self.coreText) - - self.startTorRelay(Hysteria2.name(), httpsProxyServer) - else: - logger.error('find Tor CLI in path failed') - - self.coreRunning = False - self.disconnectReason = f'{Hysteria2.name()}: {_("Cannot find Tor CLI in PATH")}' - - return Hysteria2.name() - else: - logger.info(f'routing is {routing}') - - self.Hysteria2.start(self.coreText) - - if self.coreJSON: - if isVPNMode(): - if PLATFORM == 'Windows' or PLATFORM == 'Darwin': - # Currently VPN Mode is only supported on Windows and macOS - self.startTun2socks() - - return Hysteria2.name() - - # No matching core - return '' - - def startTun2socks(self, successCallback=None): - if not self.coreRunning: - # Core has exited. Do nothing - return - - defaultGateway = list( - # Filter TUN Gateway - filter( - lambda x: x != APPLICATION_TUN_GATEWAY_ADDRESS, - RoutingTable.getDefaultGatewayAddress(), - ) - ) - - if len(defaultGateway) == 0: - logger.error(f'no gateway address found') - - self.coreRunning = False - self.disconnectReason = ( - _('Unable to connect') + ': ' + _('No gateway address found') - ) - - return - - if len(defaultGateway) != 1: - logger.error(f'found multiple gateway addresses: {defaultGateway}') - - self.coreRunning = False - self.disconnectReason = ( - _('Unable to connect') + ': ' + _('Multiple gateway addresses found') - ) - - return - - coreAddr = Intellisense.getCoreAddr(self.coreJSON) - - if not coreAddr: - logger.error(f'invalid server address: {coreAddr}') - - self.coreRunning = False - self.disconnectReason = _('Server address invalid') + f': {coreAddr}' - - return - - def start(): - assert RoutingTable.Relations - - if PLATFORM == 'Darwin': - for source in [ - *list(f'{2 ** (8 - x)}.0.0.0/{x}' for x in range(8, 0, -1)), - '198.18.0.0/15', - ]: - RoutingTable.Relations.append( - [source, APPLICATION_TUN_GATEWAY_ADDRESS] - ) - - self.Tun2socks.start( - APPLICATION_TUN_DEVICE_NAME, - APPLICATION_TUN_NETWORK_INTERFACE_NAME, - 'silent', - f'socks5://{self.socksProxyServer}', - '', - ) - - if PLATFORM == 'Windows': - foundDevice = False - - for counter in range(0, 10000, 100): - try: - result = runCommand( - 'ipconfig', - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - check=True, - ) - except Exception: - # Any non-exit exceptions - - break - else: - if ( - result.stdout.decode('utf-8', 'replace').count( - APPLICATION_TUN_DEVICE_NAME - ) - > 0 - ): - foundDevice = True - - logger.info( - f'find TUN device \'{APPLICATION_TUN_DEVICE_NAME}\' success. ' - f'Counter: {counter}' - ) - - break - - if not self.coreRunning: - foundDevice = False - - break - else: - eventLoopWait(100) - - if not foundDevice: - logger.error( - f'find TUN device \'{APPLICATION_TUN_DEVICE_NAME}\' failed' - ) - - return - - RoutingTable.addRelations() - RoutingTable.setDeviceGatewayAddress( - APPLICATION_TUN_DEVICE_NAME, - APPLICATION_TUN_IP_ADDRESS, - APPLICATION_TUN_GATEWAY_ADDRESS, - ) - - if PLATFORM == 'Darwin': - RoutingTable.setDeviceGatewayAddress( - APPLICATION_TUN_DEVICE_NAME, - APPLICATION_TUN_IP_ADDRESS, - APPLICATION_TUN_GATEWAY_ADDRESS, - ) - RoutingTable.addRelations() - - if callable(successCallback): - successCallback() - - if not isValidIPAddress(coreAddr): - # Checked. Should not throw exceptions - error, resolved = DNSResolver.resolve( - coreAddr, *parseHostPort(self.httpsProxyServer) - ) - - if error: - self.coreRunning = False - self.disconnectReason = _('DNS resolution failed') + f': {coreAddr}' - - RoutingTable.Relations.clear() - else: - for address in resolved: - RoutingTable.Relations.append([address, defaultGateway[0]]) - else: - RoutingTable.Relations.append([coreAddr, defaultGateway[0]]) - - if RoutingTable.Relations: - start() - else: - if self.coreRunning or not self.disconnectReason: - self.coreRunning = False - self.disconnectReason = _('DNS resolution timeout') + f': {coreAddr}' - - def stopTun2socks(self): - self.Tun2socks.registerExitCallback(None) - self.Tun2socks.stop() - - def startTorRelay(self, core, proxyServer, startCounter=0, step=100): - # Redirect Proxy - self.httpsProxyServer = f'127.0.0.1:{self.torRelayStorageObj.get("httpsTunnelPort", DEFAULT_TOR_HTTPS_PORT)}' - self.socksProxyServer = f'127.0.0.1:{self.torRelayStorageObj.get("socksTunnelPort", DEFAULT_TOR_SOCKS_PORT)}' - - try: - timeout = 1000 * int( - self.torRelayStorageObj.get( - 'relayEstablishTimeout', DEFAULT_TOR_RELAY_ESTABLISH_TIMEOUT - ) - ) - except Exception: - # Any non-exit exceptions - - timeout = 1000 * DEFAULT_TOR_RELAY_ESTABLISH_TIMEOUT - - logger.info(f'{TorRelay.name()} bootstrap timeout is {timeout // 1000}s') - - # Start Tor Relay - if self.torRelayStorageObj.get('useProxy', True): - # Use proxy - self.TorRelay.start(proxyServer=proxyServer) - else: - # Do not use proxy - self.TorRelay.start() - - while ( - self.coreRunning - and startCounter < timeout - and self.TorRelay.bootstrapPercentage != 100 - ): - eventLoopWait(step) - - startCounter += step - - if not self.coreRunning: - # Interrupted externally. Return - return - - if self.TorRelay.bootstrapPercentage != 100: - # bootstrap timeout - self.stopCore() - # Assign disconnect reason - self.disconnectReason = ( - f'{core}: {_(f"{TorRelay.name()} establish timeout")}' - ) - - logger.error(f'{TorRelay.name()} establish failed') - else: - logger.info(f'{TorRelay.name()} establish success') - - def startConnectionTest( - self, showRoutingChangedMessage=False, currentRouting='', isBuiltinRouting=False - ): - selected = self.testPool[self.testTime] - - self.testTime += 1 - - logger.info(f'start connection test. Try: {selected}') - logger.info(f'connection test uses proxy server {self.httpsProxyServer}') - - # Checked. Should not throw exceptions - proxyHost, proxyPort = parseHostPort(self.httpsProxyServer) - - # 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) and self.coreRunning: - # Try next - self.startConnectionTest( - showRoutingChangedMessage, currentRouting, isBuiltinRouting - ) - else: - self.testFinished = True - self.testTimeoutTimer.stop() - - if self.disconnectReason: - self.disconnectAction(self.disconnectReason) - else: - self.disconnectAction( - f'{self.coreName}: {_("Connection test failed")}' - ) - else: - logger.info(f'{self.coreName}: connection test success. Connected') - - self.testFinished = True - self.testTimeoutTimer.stop() - self.connectedAction( - showRoutingChangedMessage, currentRouting, isBuiltinRouting - ) - - self.networkReply.finished.connect(finishedCallback) - - def connectAction(self): - # Connect action - assert self.textCompare('Connect') - - # Connecting - self.connectingFlag = True - - if not APP().Configuration or len(APP().ServerWidget.ServerList) == 0: - APP().Connect = Switch.OFF - - self.setChecked(False) - self.connectingFlag = False - self.errorConfigurationEmpty() - - return - - myText = self.activatedServer - - if myText is None: - APP().Connect = Switch.OFF - - self.setChecked(False) - self.connectingFlag = False - self.errorConfigurationNotActivated() - - return - - if myText == '': - APP().Connect = Switch.OFF - - self.setChecked(False) - self.connectingFlag = False - self.errorConfigurationEmpty() - - return - - try: - myJSON = Configuration.toJSON(myText) - except Exception: - # Any non-exit exceptions - - APP().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 checkConnectionTestTimeout(self, timeout=30000): - logger.info(f'connection test timeout is {timeout // 1000}s') - - def handleTimeout(): - if self.coreRunning and not self.testFinished: - # Timeout - logger.error('connection test timeout. Abort') - - assert isinstance(self.networkReply, QNetworkReply) - - self.coreRunning = False - self.networkReply.abort() - - # Check is done by timer - self.testTimeoutTimer.timeout.connect(handleTimeout) - self.testTimeoutTimer.start(timeout) - - def connectingAction( - self, - showProgressBar=True, - showRoutingChangedMessage=False, - currentRouting='', - isBuiltinRouting=False, - **kwargs, - ): - # Connecting. Redefined - self.connectingFlag = True - - # Connecting status - self.setConnectingStatus(showProgressBar) - - # Reset reply. Mandatory - self.networkReply = None - - # Configure connect - self.coreName = self.configureCore() - - if not self.coreName: - # No matching core - self.reset() - self.errorConfiguration() - - return - - if not self.coreRunning: - # 1. No valid HTTP/Socks proxy endpoint. reset / disconnect has been called - - if self.isConnecting(): - # 2. Core has exited - - self.disconnectAction(self.disconnectReason) - - return - - try: - Proxy.set(self.httpsProxyServer, PROXY_SERVER_BYPASS) - except Exception: - # Any non-exit exceptions - - Proxy.off() - - self.reset() - self.errorConfiguration() - else: - self.moveConnectingProgressBar() - - # # Reset test time - # self.testTime = 0 - # # Reset test finished - # self.testFinished = False - # self.startConnectionTest( - # showRoutingChangedMessage, currentRouting, isBuiltinRouting - # ) - # self.checkConnectionTestTimeout() - - self.connectedAction( - showRoutingChangedMessage, currentRouting, isBuiltinRouting - ) - - def connectedAction( - self, showRoutingChangedMessage, currentRouting, isBuiltinRouting - ): - # Connected status - self.setConnectedStatus() - - APP().Connect = Switch.ON_ - - if showRoutingChangedMessage: - # Routing changed - if isBuiltinRouting: - APP().tray.showMessage(_('Routing changed: ') + _(currentRouting)) - else: - APP().tray.showMessage(_('Routing changed: ') + currentRouting) - else: - # Connected - APP().tray.showMessage(f'{self.coreName}: {_("Connected")}') - - def disconnectAction(self, reason=''): - Proxy.off() - - self.reset() - - SupportConnectedCallback.callDisconnectedCallback() - - APP().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")}') diff --git a/Furious/Action/Export.py b/Furious/Action/Export.py deleted file mode 100644 index b9a8aaf..0000000 --- a/Furious/Action/Export.py +++ /dev/null @@ -1,262 +0,0 @@ -# Copyright (C) 2023 Loren Eteval -# -# This file is part of Furious. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -from Furious.Core.Configuration import Configuration -from Furious.Gui.Action import Action -from Furious.Widget.Widget import Menu, MessageBox -from Furious.Widget.ExportQRCode import ExportQRCode -from Furious.Utility.Constants import APP -from Furious.Utility.Utility import StateContext, bootstrapIcon -from Furious.Utility.Translator import gettext as _ - -from PySide6.QtWidgets import QApplication - - -class ExportLinkResultBox(MessageBox): - def __init__(self, successStr, failureStr, isQRCodeExport, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.successStr = successStr - self.failureStr = failureStr - self.isQRCodeExport = isQRCodeExport - - self.setWindowTitle(_('Export')) - - def getText(self): - text = self.getTextAll() - - if len(text) <= 1000: - return text - else: - # Limited - if len(self.successStr) == 0: - return ( - _('Export as QR code failed:') - if self.isQRCodeExport - else _('Export share link to clipboard failed:') - ) + f'\n\n...' - elif len(self.failureStr) == 0: - return ( - _('Export as QR code success:') - if self.isQRCodeExport - else _('Export share link to clipboard success:') - ) + f'\n\n...' - else: - return ( - _('Export as QR code partially success:') - if self.isQRCodeExport - else _('Export share link to clipboard partially success:') - ) + f'\n\n...' - - def getTextAll(self): - if len(self.successStr) == 0: - return ( - ( - _('Export as QR code failed:') - if self.isQRCodeExport - else _('Export share link to clipboard failed:') - ) - + f'\n\n' - + '\n'.join( - list( - f'{index + 1}: {failure}' - for index, failure in enumerate(self.failureStr) - ) - ) - ) - elif len(self.failureStr) == 0: - return ( - ( - _('Export as QR code success:') - if self.isQRCodeExport - else _('Export share link to clipboard success:') - ) - + f'\n\n' - + '\n'.join( - list( - f'{index + 1}: {success}' - for index, success in enumerate(self.successStr) - ) - ) - ) - else: - return ( - ( - _('Export as QR code partially success:') - if self.isQRCodeExport - else _('Export share link to clipboard partially success:') - ) - + f'\n\n' - + '\n'.join( - list( - f'{index + 1}: {success}' - for index, success in enumerate(self.successStr) - ) - ) - + f'\n\n' - + _('Failed:') - + f'\n\n' - + '\n'.join( - list( - f'{index + 1}: {failure}' - for index, failure in enumerate(self.failureStr) - ) - ) - ) - - def retranslate(self): - with StateContext(self): - self.setWindowTitle(_(self.windowTitle())) - self.setText(self.getText()) - - # Ignore informative text, buttons - - self.moveToCenter() - - -class ExportJSONResultBox(MessageBox): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.setWindowTitle(_('Export')) - self.setIcon(MessageBox.Icon.Information) - self.setText(_('Export JSON configuration to clipboard success.')) - - def retranslate(self): - with StateContext(self): - self.setWindowTitle(_(self.windowTitle())) - self.setText(_(self.text())) - - # Ignore informative text, buttons - - self.moveToCenter() - - -def exportLink(selectedIndex): - serverList = APP().ServerWidget.ServerList - serverLink = [] - successStr = [] - failureStr = [] - - for index in selectedIndex: - try: - serverLink.append( - Configuration.export( - serverList[index]['remark'], - Configuration.toJSON(serverList[index]['config']), - ) - ) - - successStr.append(f'{index + 1} - {serverList[index]["remark"]}') - except Exception as ex: - # Any non-exit Exceptions - - failureStr.append(f'{index + 1} - {serverList[index]["remark"]}: {ex}') - - return serverLink, successStr, failureStr - - -def showExportLinkResult(resultBox, successStr, failureStr): - resultBox.successStr = successStr - resultBox.failureStr = failureStr - resultBox.setText(resultBox.getText()) - - if len(successStr) == 0: - resultBox.setIcon(MessageBox.Icon.Critical) - else: - resultBox.setIcon(MessageBox.Icon.Information) - - # Show the MessageBox and wait for user to close it - resultBox.exec() - - -class ExportLinkAction(Action): - def __init__(self, **kwargs): - super().__init__(_('Export Share Link To Clipboard'), **kwargs) - - self.exportLinkResult = ExportLinkResultBox('', '', isQRCodeExport=False) - - def triggeredCallback(self, checked): - selectedIndex = APP().ServerWidget.selectedIndex - - if len(selectedIndex) == 0: - # Nothing selected. Do nothing - return - - serverLink, successStr, failureStr = exportLink(selectedIndex) - - QApplication.clipboard().setText('\n'.join(serverLink)) - - showExportLinkResult(self.exportLinkResult, successStr, failureStr) - - -class ExportQRCodeAction(Action): - def __init__(self, **kwargs): - super().__init__( - _('Export As QR Code'), - icon=bootstrapIcon('qr-code.svg'), - **kwargs, - ) - - self.exportQRCode = ExportQRCode() - self.exportLinkResult = ExportLinkResultBox('', '', isQRCodeExport=True) - - def triggeredCallback(self, checked): - selectedIndex = APP().ServerWidget.selectedIndex - - if len(selectedIndex) == 0: - # Nothing selected. Do nothing - return - - serverLink, successStr, failureStr = exportLink(selectedIndex) - - if len(successStr) > 0: - self.exportQRCode.editorTab.clear() - self.exportQRCode.labelList.clear() - self.exportQRCode.initTabWithData(list(zip(successStr, serverLink))) - self.exportQRCode.show() - - showExportLinkResult(self.exportLinkResult, successStr, failureStr) - - -class ExportJSONAction(Action): - def __init__(self, **kwargs): - super().__init__(_('Export JSON Configuration To Clipboard'), **kwargs) - - self.exportJSONResult = ExportJSONResultBox() - - def triggeredCallback(self, checked): - selectedIndex = APP().ServerWidget.selectedIndex - - if len(selectedIndex) == 0: - # Nothing selected. Do nothing - return - - try: - QApplication.clipboard().setText( - '\n'.join( - APP().ServerWidget.ServerList[index]['config'] - for index in selectedIndex - ) - ) - except Exception: - # Any non-exit exceptions - - pass - else: - # Show the MessageBox and wait for user to close it - self.exportJSONResult.exec() diff --git a/Furious/Action/Import.py b/Furious/Action/Import.py deleted file mode 100644 index 328cda2..0000000 --- a/Furious/Action/Import.py +++ /dev/null @@ -1,700 +0,0 @@ -# Copyright (C) 2023 Loren Eteval -# -# This file is part of Furious. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -from Furious.Core.Configuration import ( - Configuration, - XrayCoreConfiguration, - ProxyOutboundObject, - ProxyOutboundObjectSS, - ProxyOutboundObjectTrojan, - Hysteria2Configuration, -) -from Furious.Gui.Action import Action -from Furious.Widget.Widget import Menu, MessageBox -from Furious.Utility.Constants import APP -from Furious.Utility.Utility import ( - Base64Encoder, - Protocol, - StateContext, - ServerStorage, - bootstrapIcon, - parseHostPort, - enumValueWrapper, - protocolRepr, -) -from Furious.Utility.Translator import gettext as _ - -from PySide6.QtWidgets import QApplication - -import re -import ujson -import logging -import urllib.parse - -logger = logging.getLogger(__name__) - - -class ImportErrorBox(MessageBox): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - def retranslate(self): - with StateContext(self): - self.setWindowTitle(_(self.windowTitle())) - self.setText(_(self.text())) - - # Ignore informative text, buttons - - self.moveToCenter() - - -class JSONImportOKBox(MessageBox): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.setWindowTitle(_('Import')) - self.setText(_('Import JSON configuration success.')) - - self.addButton(_('Go to edit'), MessageBox.ButtonRole.AcceptRole) - self.addButton(_('OK'), MessageBox.ButtonRole.RejectRole) - - -class LinkImportOKBox(MessageBox): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.remark = '' - - self.setWindowTitle(_('Import')) - - self.addButton(_('Go to edit'), MessageBox.ButtonRole.AcceptRole) - self.addButton(_('OK'), MessageBox.ButtonRole.RejectRole) - - def getText(self): - if self.remark: - return _('Import share link success: ') + f'{self.remark}' - else: - return _('Import share link success.') - - def retranslate(self): - self.setWindowTitle(_(self.windowTitle())) - self.setText(self.getText()) - - # Ignore informative text - - for button in self.buttons(): - button.setText(_(button.text())) - - self.moveToCenter() - - -class ImportLinkResultBox(MessageBox): - def __init__(self, successRemark, rowCount, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.successRemark = successRemark - self.rowCount = rowCount - - self.setWindowTitle(_('Import')) - self.setIcon(MessageBox.Icon.Information) - self.setText(self.getText()) - - def getText(self): - text = self.getTextAll() - - if len(text) <= 1000: - return text - else: - # Limited - return _('Import share link success: ') + f'\n\n...' - - def getTextAll(self): - return ( - _('Import share link success: ') - + f'\n\n' - + '\n'.join( - list( - f'{index + 1} - {remark}. {_("Imported to row")} {self.rowCount + index + 1}' - for index, remark in enumerate(self.successRemark) - ) - ) - ) - - def retranslate(self): - with StateContext(self): - self.setWindowTitle(_(self.windowTitle())) - self.setText(self.getText()) - - # Ignore informative text, buttons - - self.moveToCenter() - - -class ImportLinkAction(Action): - def __init__(self, **kwargs): - super().__init__(_('Import Share Link From Clipboard'), **kwargs) - - self.clipboard = '' - - self.linkErrorBox = ImportErrorBox(icon=MessageBox.Icon.Critical) - self.linkImportOK = LinkImportOKBox(icon=MessageBox.Icon.Information) - - def showLinkErrorBox(self): - self.linkErrorBox.setWindowTitle(_('Import')) - - if len(self.clipboard) > 1000: - # Limited - self.linkErrorBox.setText(_('Invalid share link.')) - self.linkErrorBox.setInformativeText('') - else: - self.linkErrorBox.setText( - _('Invalid share link. The content of the clipboard is:') - ) - self.linkErrorBox.setInformativeText(self.clipboard) - - # Show the MessageBox and wait for user to close it - self.linkErrorBox.exec() - - def showLinkImportOK(self, remark): - self.linkImportOK.remark = remark - self.linkImportOK.setText(self.linkImportOK.getText()) - - # Show the MessageBox and wait for user to close it - choice = self.linkImportOK.exec() - - if choice == enumValueWrapper(MessageBox.ButtonRole.AcceptRole): - # Go to edit - APP().ServerWidget.show() - else: - # OK. Do nothing - pass - - @staticmethod - def parseShareLinkVMess(data, isV2rayN, importServerArgs=None): - if importServerArgs is None: - importServerArgs = {} - - if isV2rayN: - - def getOrDefault(key, default=''): - return data.get(key, default) - - try: - # Ignore: v - myJSON = XrayCoreConfiguration.build( - ProxyOutboundObject( - 'vmess', - getOrDefault('add'), - int(getOrDefault('port', '0')), - getOrDefault('id'), - getOrDefault('scy', 'auto'), - getOrDefault('net', 'tcp'), - getOrDefault('tls', 'none'), - # kwargs. V2rayN share standard -> (VMess AEAD / VLESS) standard - aid=int(getOrDefault('aid', '0')), - headerType=getOrDefault('type', 'none'), - host=getOrDefault('host'), - quicSecurity=getOrDefault('host', 'none'), - path=getOrDefault('path'), - key=getOrDefault('path'), - seed=getOrDefault('path'), - serviceName=getOrDefault('path'), - sni=getOrDefault('sni'), - alpn=getOrDefault('alpn'), - # Note: If specify default value 'chrome', some share link fails. - # Leave default value as empty - fp=getOrDefault('fp'), - ) - ) - - remark = urllib.parse.unquote(getOrDefault('ps')) - - APP().ServerWidget.importServer( - remark, - ujson.dumps( - myJSON, - indent=2, - ensure_ascii=False, - escape_forward_slashes=False, - ), - **importServerArgs, - ) - - logger.debug( - 'import share link success. ' - f'Remark: {remark}. Protocol: VMess. (V2rayN share standard)' - ) - - return remark, True - - except Exception: - # Any non-exit exceptions - - return '', False - else: - return ImportLinkAction.parseShareLinkStandard( - 'vmess', data, importServerArgs - ) - - @staticmethod - def parseShareLinkStandard(protocol, data, importServerArgs=None): - if importServerArgs is None: - importServerArgs = {} - - try: - parseResult = urllib.parse.urlparse(data) - - queryObject = { - key: value for key, value in urllib.parse.parse_qsl(parseResult.query) - } - - remark = urllib.parse.unquote(parseResult.fragment) - - uuid_, server = parseResult.netloc.split('@') - - remote_host, remote_port = parseHostPort(server) - - encryption = queryObject.get('encryption', 'none') - type_ = queryObject.get('type', 'tcp') - security = queryObject.get('security', 'none') - - # Remove redundant items - queryObject.pop('encryption', '') - queryObject.pop('type', '') - queryObject.pop('security', '') - - myJSON = XrayCoreConfiguration.build( - ProxyOutboundObject( - protocol, - remote_host, - int(remote_port), - uuid_, - encryption, - type_, - security, - # kwargs - **queryObject, - ) - ) - - APP().ServerWidget.importServer( - remark, - ujson.dumps( - myJSON, indent=2, ensure_ascii=False, escape_forward_slashes=False - ), - **importServerArgs, - ) - - logger.debug( - f'import share link success. ' - f'Remark: {remark}. Protocol: {protocolRepr(protocol)}' - ) - - return remark, True - except Exception as ex: - # Any non-exit exceptions - - logger.error(f'import share link failed: {data}. Exception: {ex}') - - return '', False - - @staticmethod - def parseShareLinkSIP002(data, importServerArgs=None): - if importServerArgs is None: - importServerArgs = {} - - try: - result = urllib.parse.urlparse(data) - remark = urllib.parse.unquote(result.fragment) - - try: - # Try pack with 4 element - - methodPassword, server = result.netloc.split('@') - - method, password = methodPassword.split(':') - - address, port = parseHostPort(server) - except ValueError: - # Unpack error. Try pack with 3 element - userinfo, server = result.netloc.split('@') - - address, port = parseHostPort(server) - - # Some old SS share link doesn't add padding - # in base64 encoding. Add padding to userinfo - method, password = ( - Base64Encoder.decode(userinfo + '===').decode().split(':') - ) - except Exception as ex: - # Any non-exit exceptions - - raise ex - - myJSON = XrayCoreConfiguration.build( - ProxyOutboundObjectSS( - urllib.parse.unquote(method), - urllib.parse.unquote(password), - address, - port, - ) - ) - - APP().ServerWidget.importServer( - remark, - ujson.dumps( - myJSON, indent=2, ensure_ascii=False, escape_forward_slashes=False - ), - **importServerArgs, - ) - - logger.debug( - f'import share link success. ' - f'Remark: {remark}. Protocol: {Protocol.Shadowsocks}' - ) - - return remark, True - except Exception as ex: - # Any non-exit exceptions - - logger.error(f'import share link failed: {data}. Exception: {ex}') - - return '', False - - @staticmethod - def parseShareLinkSS(data, importServerArgs=None): - if importServerArgs is None: - importServerArgs = {} - - try: - # ss://base64...#fragment - - result = urllib.parse.urlparse(data) - remark = urllib.parse.unquote(result.fragment) - - if remark == '': - remark = 'sslegacy' - - myData = Base64Encoder.decode(result.netloc).decode() - - methodPassword, server = myData.split('@') - - myJSON = XrayCoreConfiguration.build( - ProxyOutboundObjectSS( - *methodPassword.split(':'), *parseHostPort(server) - ) - ) - - APP().ServerWidget.importServer( - remark, - ujson.dumps( - myJSON, indent=2, ensure_ascii=False, escape_forward_slashes=False - ), - **importServerArgs, - ) - - logger.debug( - f'import share link success. ' - f'Remark: {remark}. Protocol: {Protocol.Shadowsocks}' - ) - - return remark, True - except Exception: - # Any non-exit exceptions - - return ImportLinkAction.parseShareLinkSIP002(data, importServerArgs) - - @staticmethod - def parseShareLinkTrojan(data, importServerArgs=None): - if importServerArgs is None: - importServerArgs = {} - - try: - parseResult = urllib.parse.urlparse(data) - - queryObject = { - key: value for key, value in urllib.parse.parse_qsl(parseResult.query) - } - - remark = urllib.parse.unquote(parseResult.fragment) - - password, server = parseResult.netloc.split('@') - - address, port = parseHostPort(server) - - type_ = queryObject.get('type', 'tcp') - # For Trojan: Assign tls by default - security = queryObject.get('security', 'tls') - - # Remove redundant items - queryObject.pop('type', '') - queryObject.pop('security', '') - - myJSON = XrayCoreConfiguration.build( - ProxyOutboundObjectTrojan( - password, - address, - int(port), - type_, - security, - # kwargs - **queryObject, - ) - ) - - APP().ServerWidget.importServer( - remark, - ujson.dumps( - myJSON, indent=2, ensure_ascii=False, escape_forward_slashes=False - ), - **importServerArgs, - ) - - logger.debug( - f'import share link success. ' - f'Remark: {remark}. Protocol: {Protocol.Trojan}' - ) - - return remark, True - except Exception as ex: - # Any non-exit exceptions - - logger.error(f'import share link failed: {data}. Exception: {ex}') - - return '', False - - @staticmethod - def parseShareLinkHysteria2(data, importServerArgs=None): - if importServerArgs is None: - importServerArgs = {} - - try: - parseResult = urllib.parse.urlparse(data) - - queryObject = { - key: value for key, value in urllib.parse.parse_qsl(parseResult.query) - } - - remark = urllib.parse.unquote(parseResult.fragment) - - if remark == '': - remark = _('Untitled') - - auth, server = parseResult.netloc.split('@') - - myJSON = Hysteria2Configuration( - server, - auth, - # kwargs - **queryObject, - ).build() - - APP().ServerWidget.importServer( - remark, - ujson.dumps( - myJSON, indent=4, ensure_ascii=False, escape_forward_slashes=False - ), - **importServerArgs, - ) - - logger.debug( - f'import share link success. ' - f'Remark: {remark}. Protocol: {Protocol.Hysteria2}' - ) - - return remark, True - except Exception as ex: - # Any non-exit exceptions - - logger.error(f'import share link failed: {data}. Exception: {ex}') - - return '', False - - @staticmethod - def parseShareLink(shareLink, importServerArgs=None): - if importServerArgs is None: - importServerArgs = {} - - try: - myHead, myBody = shareLink.split('://') - except Exception: - # Any non-exit exceptions - - return '', False - else: - if myHead.lower() == 'vmess': - try: - myData = Base64Encoder.decode(myBody).decode() - myJSON = Configuration.toJSON(myData) - except Exception: - # Any non-exit exceptions - - return ImportLinkAction.parseShareLinkVMess( - shareLink, - isV2rayN=False, - importServerArgs=importServerArgs, - ) - else: - return ImportLinkAction.parseShareLinkVMess( - myJSON, - isV2rayN=True, - importServerArgs=importServerArgs, - ) - - if myHead.lower() == 'vless': - return ImportLinkAction.parseShareLinkStandard( - 'vless', - shareLink, - importServerArgs, - ) - - if myHead.lower() == 'ss': - return ImportLinkAction.parseShareLinkSS( - shareLink, - importServerArgs, - ) - - if myHead.lower() == 'trojan': - return ImportLinkAction.parseShareLinkTrojan( - shareLink, - importServerArgs, - ) - - if myHead.lower() == 'hysteria2' or myHead.lower() == 'hy2': - return ImportLinkAction.parseShareLinkHysteria2( - shareLink, - importServerArgs, - ) - - logger.error(f'unsupported share link: {shareLink}') - - return '', False - - def triggeredCallback(self, checked): - self.clipboard = QApplication.clipboard().text().strip() - - try: - splitByNewLine = self.clipboard.split('\n') - except Exception: - # Any non-exit exceptions - - remark, result = ImportLinkAction.parseShareLink(self.clipboard) - - if result: - # Sync it - ServerStorage.sync() - - self.showLinkImportOK(remark) - else: - self.showLinkErrorBox() - else: - successRemark = [] - - rowCount = APP().ServerWidget.rowCount - - for shareLink in splitByNewLine: - remark, result = ImportLinkAction.parseShareLink(shareLink) - - if result: - successRemark.append(remark) - - if len(successRemark) == 0: - self.showLinkErrorBox() - - return - - # At least one server's been imported. Sync it - ServerStorage.sync() - - if len(successRemark) == 1: - # Fall back to single - - self.showLinkImportOK(successRemark[0]) - else: - importLinkResult = ImportLinkResultBox(successRemark, rowCount) - - # Show the MessageBox and wait for user to close it - importLinkResult.exec() - - -class ImportJSONAction(Action): - def __init__(self, **kwargs): - super().__init__(_('Import JSON Configuration From Clipboard'), **kwargs) - - self.clipboard = '' - - self.jsonErrorBox = ImportErrorBox(icon=MessageBox.Icon.Critical) - self.jsonImportOK = JSONImportOKBox(icon=MessageBox.Icon.Information) - - def showJSONErrorBox(self): - self.jsonErrorBox.setWindowTitle(_('Import')) - - if len(self.clipboard) > 1000: - # Limited - self.jsonErrorBox.setText(_('Invalid JSON data.')) - self.jsonErrorBox.setInformativeText('') - else: - self.jsonErrorBox.setText( - _('Invalid JSON data. The content of the clipboard is:') - ) - self.jsonErrorBox.setInformativeText(self.clipboard) - - # Show the MessageBox and wait for user to close it - self.jsonErrorBox.exec() - - def showJSONImportOK(self): - # Show the MessageBox and wait for user to close it - choice = self.jsonImportOK.exec() - - if choice == enumValueWrapper(MessageBox.ButtonRole.AcceptRole): - # Go to edit - APP().ServerWidget.show() - else: - # OK. Do nothing - pass - - def triggeredCallback(self, checked): - self.clipboard = QApplication.clipboard().text().strip() - - try: - myJSON = Configuration.toJSON(self.clipboard) - except Exception: - # Any non-exit exceptions - - self.showJSONErrorBox() - else: - APP().ServerWidget.importServer( - _('Untitled'), self.clipboard, syncStorage=True - ) - - logger.debug('import JSON configuration from clipboard success') - - self.showJSONImportOK() - - -class ImportAction(Action): - def __init__(self): - super().__init__( - _('Import'), - icon=bootstrapIcon('lightning-charge.svg'), - menu=Menu( - ImportLinkAction(), - ImportJSONAction(), - ), - useActionGroup=False, - checkable=True, - ) diff --git a/Furious/Action/Routing.py b/Furious/Action/Routing.py deleted file mode 100644 index fbf8d38..0000000 --- a/Furious/Action/Routing.py +++ /dev/null @@ -1,261 +0,0 @@ -# Copyright (C) 2023 Loren Eteval -# -# This file is part of Furious. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -from Furious.Core.Core import XrayCore, Hysteria1 -from Furious.Gui.Action import Action, Seperator -from Furious.Widget.Widget import Menu -from Furious.Utility.Constants import APP, DATA_DIR -from Furious.Utility.Utility import Switch, bootstrapIcon -from Furious.Utility.Translator import gettext as _ - - -BUILTIN_ROUTING_TABLE = { - 'Bypass Mainland China': { - XrayCore.name(): { - 'domainStrategy': 'IPIfNonMatch', - 'domainMatcher': 'hybrid', - 'rules': [ - # ads - { - 'type': 'field', - 'domain': [ - 'geosite:category-ads-all', - ], - 'outboundTag': 'block', - }, - # geosite - { - 'type': 'field', - 'domain': [ - 'geosite:cn', - ], - 'outboundTag': 'direct', - }, - # geoip - { - 'type': 'field', - 'ip': [ - 'geoip:private', - 'geoip:cn', - ], - 'outboundTag': 'direct', - }, - # Proxy everything - { - 'type': 'field', - 'port': '0-65535', - 'outboundTag': 'proxy', - }, - ], - }, - Hysteria1.name(): { - 'acl': (DATA_DIR / 'hysteria' / 'bypass-mainland-China.acl').as_posix(), - 'mmdb': (DATA_DIR / 'hysteria' / 'country.mmdb').as_posix(), - }, - }, - 'Bypass Iran': { - XrayCore.name(): { - 'domainStrategy': 'IPIfNonMatch', - 'domainMatcher': 'hybrid', - 'rules': [ - # ads - { - 'type': 'field', - 'domain': [ - 'geosite:category-ads-all', - 'iran:ads', - ], - 'outboundTag': 'block', - }, - # Iran sites - { - 'type': 'field', - 'domain': [ - 'iran:ir', - 'iran:other', - ], - 'outboundTag': 'direct', - }, - # Iran IP - { - 'type': 'field', - 'ip': [ - 'geoip:private', - 'geoip:ir', - ], - 'outboundTag': 'direct', - }, - # Proxy everything - { - 'type': 'field', - 'port': '0-65535', - 'outboundTag': 'proxy', - }, - ], - }, - Hysteria1.name(): { - 'acl': (DATA_DIR / 'hysteria' / 'bypass-Iran.acl').as_posix(), - 'mmdb': (DATA_DIR / 'hysteria' / 'country.mmdb').as_posix(), - }, - }, - 'Route My Traffic Through Tor': { - XrayCore.name(): {}, - Hysteria1.name(): {}, - }, - 'Global': { - XrayCore.name(): { - 'domainStrategy': 'IPIfNonMatch', - 'domainMatcher': 'hybrid', - 'rules': [ - # Proxy everything - { - 'type': 'field', - 'port': '0-65535', - 'outboundTag': 'proxy', - }, - ], - }, - Hysteria1.name(): {}, - }, - 'Custom': { - XrayCore.name(): {}, - Hysteria1.name(): {}, - }, -} - -BUILTIN_ROUTING = list(BUILTIN_ROUTING_TABLE.keys()) - - -def routingToIndex(): - currentRouting = APP().Routing - - try: - index = int(currentRouting) - except ValueError: - for i, routing in enumerate(BUILTIN_ROUTING): - if routing == currentRouting: - return i - - return -1 - except Exception: - # Any non-exit exceptions - - return -1 - else: - return index + len(BUILTIN_ROUTING) - - -class BuiltinRoutingChildAction(Action): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - def triggeredCallback(self, checked): - textEnglish = self.textEnglish - - if APP().Routing != textEnglish: - # De-activate - APP().RoutesWidget.activateItemByIndex(routingToIndex(), activate=False) - APP().Routing = textEnglish - # Activate - APP().RoutesWidget.activateItemByIndex(routingToIndex(), activate=True) - - if APP().isConnected(): - # Connected. Re-configure connection - APP().tray.ConnectAction.connectingAction( - showProgressBar=True, - showRoutingChangedMessage=True, - currentRouting=textEnglish, - isBuiltinRouting=True, - ) - - -class RoutingChildAction(Action): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.translatable = False - - def triggeredCallback(self, checked): - routingAction = APP().tray.RoutingAction - - for index, action in enumerate( - # Skip built-in actions. - routingAction.menu().actions()[len(BUILTIN_ROUTING) :] - ): - if id(self) == id(action): - # Found action - - if APP().Routing != str(index): - route = APP().RoutesWidget.RoutesList[index] - - # De-activate - APP().RoutesWidget.activateItemByIndex( - routingToIndex(), activate=False - ) - APP().Routing = str(index) - # Activate - APP().RoutesWidget.activateItemByIndex( - routingToIndex(), activate=True - ) - - if APP().isConnected(): - # Connected. Re-configure connection - APP().tray.ConnectAction.connectingAction( - showProgressBar=True, - showRoutingChangedMessage=True, - currentRouting=route['remark'], - isBuiltinRouting=False, - ) - - # Select routing action done - return - - raise Exception('Fatal error occurred') - - @property - def textEnglish(self): - return self.text() - - -class RoutingAction(Action): - def __init__(self): - if APP().Routing == 'Bypass': - # Update value for backward compatibility - APP().Routing = 'Bypass Mainland China' - - super().__init__( - _('Routing'), - icon=bootstrapIcon('shuffle.svg'), - menu=Menu( - *list( - BuiltinRoutingChildAction( - _(routing), - checkable=True, - checked=APP().Routing == routing, - ) - for routing in BUILTIN_ROUTING - ), - *list( - RoutingChildAction( - route['remark'], - checkable=True, - checked=APP().Routing == str(index), - ) - for index, route in enumerate(APP().RoutesWidget.RoutesList) - ), - ), - ) diff --git a/Furious/Action/Settings.py b/Furious/Action/Settings.py deleted file mode 100644 index f10612c..0000000 --- a/Furious/Action/Settings.py +++ /dev/null @@ -1,161 +0,0 @@ -# Copyright (C) 2023 Loren Eteval -# -# This file is part of Furious. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -from Furious.Gui.Action import Action, Seperator -from Furious.Widget.Widget import Menu -from Furious.Utility.Constants import APP, PLATFORM, ADMINISTRATOR_NAME -from Furious.Utility.Utility import Switch, bootstrapIcon, isAdministrator, isVPNMode -from Furious.Utility.Translator import gettext as _ -from Furious.Utility.StartupOnBoot import StartupOnBoot - - -class SettingsChildAction(Action): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - def triggeredCallback(self, checked): - if self.textCompare('Startup On Boot'): - if checked: - StartupOnBoot.on_() - - APP().StartupOnBoot = Switch.ON_ - else: - StartupOnBoot.off() - - APP().StartupOnBoot = Switch.OFF - if self.textCompare('Show Progress Bar When Connecting'): - if checked: - APP().ShowProgressBarWhenConnecting = Switch.ON_ - else: - APP().ShowProgressBarWhenConnecting = Switch.OFF - if self.textCompare('Show Tab And Spaces In Editor'): - if checked: - APP().ShowTabAndSpacesInEditor = Switch.ON_ - else: - APP().ShowTabAndSpacesInEditor = Switch.OFF - - # Reference - ServerWidget = APP().ServerWidget - - currentFocus = ServerWidget.currentFocus - - if currentFocus >= 0: - ServerWidget.saveScrollBarValue(currentFocus) - - ServerWidget.showTabAndSpacesIfNecessary() - - if currentFocus >= 0: - ServerWidget.restoreScrollBarValue(currentFocus) - - -class RoutingChildAction(Action): - def __init__(self, **kwargs): - super().__init__(_('Routing Settings...'), **kwargs) - - def triggeredCallback(self, checked): - APP().RoutesWidget.show() - - -class VPNModeAction(Action): - def __init__(self, **kwargs): - super().__init__( - _('VPN Mode') - if isAdministrator() - else _(f'VPN Mode Disabled ({ADMINISTRATOR_NAME})'), - **kwargs, - ) - - if not isAdministrator(): - self.setDisabled(True) - - def triggeredCallback(self, checked): - assert isAdministrator() - - if checked: - APP().VPNMode = Switch.ON_ - - if APP().isConnected(): - if isVPNMode(): - if PLATFORM == 'Windows' or PLATFORM == 'Darwin': - # Currently VPN Mode is only supported on Windows and macOS - APP().tray.ConnectAction.startTun2socks( - successCallback=lambda: APP().tray.showMessage( - _('VPN mode started') - ) - ) - else: - if APP().isConnected(): - if isVPNMode(): - if PLATFORM == 'Windows' or PLATFORM == 'Darwin': - # Currently VPN Mode is only supported on Windows and macOS - APP().tray.ConnectAction.stopTun2socks() - APP().tray.showMessage(_('VPN mode stopped')) - - APP().VPNMode = Switch.OFF - - -class TorRelaySettingsChildAction(Action): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - def triggeredCallback(self, checked): - APP().TorRelayWidget.open() - - -class SettingsAction(Action): - def __init__(self): - super().__init__( - _('Settings'), - icon=bootstrapIcon('gear-wide-connected.svg'), - menu=Menu( - SettingsChildAction( - _('Startup On Boot'), - checkable=True, - checked=APP().StartupOnBoot == Switch.ON_, - ), - SettingsChildAction( - _('Show Progress Bar When Connecting'), - checkable=True, - checked=APP().ShowProgressBarWhenConnecting == Switch.ON_, - ), - SettingsChildAction( - _('Show Tab And Spaces In Editor'), - checkable=True, - checked=APP().ShowTabAndSpacesInEditor == Switch.ON_, - ), - VPNModeAction( - checkable=True, - checked=APP().VPNMode == Switch.ON_, - ) - if PLATFORM == 'Windows' or PLATFORM == 'Darwin' - else None, - Seperator(), - RoutingChildAction(), - Seperator(), - TorRelaySettingsChildAction( - _('Tor Relay Settings...'), icon=bootstrapIcon('incognito.svg') - ), - ), - useActionGroup=False, - ) - - def getVPNModeAction(self): - if PLATFORM == 'Windows' or PLATFORM == 'Darwin': - # 3rd action - return self._menu.actions()[3] - else: - return None diff --git a/Furious/Core/Configuration.py b/Furious/Core/Configuration.py deleted file mode 100644 index 7f6bb48..0000000 --- a/Furious/Core/Configuration.py +++ /dev/null @@ -1,963 +0,0 @@ -# Copyright (C) 2023 Loren Eteval -# -# This file is part of Furious. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -from Furious.Core.Core import XrayCore, Hysteria2 -from Furious.Core.Intellisense import Intellisense -from Furious.Widget.Widget import MessageBox -from Furious.Utility.Constants import APPLICATION_NAME, PROXY_OUTBOUND_USER_EMAIL -from Furious.Utility.Utility import Base64Encoder, Protocol, bootstrapIcon -from Furious.Utility.Translator import gettext as _ - -import copy -import ujson -import functools -import urllib.parse - -# '/' will be quoted for V2rayN compatibility. -quote = functools.partial(urllib.parse.quote, safe='') -unquote = functools.partial(urllib.parse.unquote) -urlunparse = functools.partial(urllib.parse.urlunparse) - - -class UnsupportedServerExport(Exception): - pass - - -class ExportFactory: - def export(self, remark, jsonObject): - raise NotImplementedError - - -class XrayFactory: - @staticmethod - def streamTLSSettings(streamObject, tlsType): - kwargs = {} - - if tlsType == '' or tlsType == 'none': - return kwargs - - tlsobj = streamObject[ProxyOutboundObject.streamTLSKey(tlsType)] - - if tlsType == 'reality' or tlsType == 'tls': - if tlsobj.get('fingerprint'): - kwargs['fp'] = tlsobj['fingerprint'] - - if tlsobj.get('serverName'): - kwargs['sni'] = tlsobj['serverName'] - - if tlsobj.get('alpn'): - kwargs['alpn'] = quote(','.join(tlsobj['alpn'])) - - if tlsType == 'reality': - # More kwargs for reality - if tlsobj.get('publicKey'): - kwargs['pbk'] = tlsobj['publicKey'] - - if tlsobj.get('shortId'): - kwargs['sid'] = tlsobj['shortId'] - - if tlsobj.get('spiderX'): - kwargs['spx'] = quote(tlsobj['spiderX']) - - return kwargs - - @staticmethod - def getProxyOutboundObject(jsonObject): - for outboundObject in jsonObject['outbounds']: - if outboundObject['tag'] == 'proxy': - return outboundObject - - raise Exception('No proxy outbound found') - - -class ExportVMess(ExportFactory): - @staticmethod - def streamNetSettings(streamObject, netType): - kwargs = {} - - netobj = streamObject.get(ProxyOutboundObject.streamNetworkKey(netType)) - - if netobj is None: - return kwargs - - def hasKey(key): - return netobj.get(key) is not None - - if netType == 'tcp': - try: - kwargs['type'] = netobj['header']['type'] - except Exception: - # Any non-exit exceptions - - pass - - elif netType == 'kcp': - try: - # Get order matters here - if hasKey('seed'): - kwargs['path'] = netobj['seed'] - - kwargs['type'] = netobj['header']['type'] - except Exception: - # Any non-exit exceptions - - pass - - elif netType == 'ws': - try: - # Get order matters here - if hasKey('path'): - kwargs['path'] = quote(netobj['path']) - - if netobj['headers']['Host']: - kwargs['host'] = quote(netobj['headers']['Host']) - except Exception: - # Any non-exit exceptions - - pass - - elif netType == 'h2' or netType == 'http': - try: - # Get order matters here - if hasKey('path'): - kwargs['path'] = quote(netobj['path']) - - kwargs['host'] = quote(','.join(netobj['host'])) - except Exception: - # Any non-exit exceptions - - pass - - elif netType == 'quic': - try: - # Get order matters here - if hasKey('security'): - kwargs['host'] = netobj['security'] - - if hasKey('key'): - kwargs['path'] = quote(netobj['key']) - - kwargs['type'] = netobj['header']['type'] - except Exception: - # Any non-exit exceptions - - pass - - elif netType == 'grpc': - if hasKey('serviceName'): - kwargs['path'] = netobj['serviceName'] - - return kwargs - - def export(self, remark, jsonObject): - proxyOutbound = XrayFactory.getProxyOutboundObject(jsonObject) - - proxyServer = proxyOutbound['settings']['vnext'][0] - proxyServerUser = proxyServer['users'][0] - - proxyStream, proxyStreamNet, proxyStreamTLS = ( - proxyOutbound['streamSettings'], - proxyOutbound['streamSettings']['network'], - proxyOutbound['streamSettings'].get('security', 'none'), - ) - - return ( - 'vmess://' - + Base64Encoder.encode( - ujson.dumps( - { - 'v': '2', - 'ps': quote(remark), - 'add': proxyServer['address'], - 'port': proxyServer['port'], - 'id': proxyServerUser['id'], - 'aid': proxyServerUser['alterId'], - 'scy': proxyServerUser['security'], - 'net': proxyStreamNet, - 'tls': proxyStreamTLS, - # kwargs - **ExportVMess.streamNetSettings(proxyStream, proxyStreamNet), - **XrayFactory.streamTLSSettings(proxyStream, proxyStreamTLS), - }, - ensure_ascii=False, - escape_forward_slashes=False, - ).encode() - ).decode() - ) - - -class ExportVLESS(ExportFactory): - @staticmethod - def streamNetSettings(streamObject, netType): - kwargs = {} - - netobj = streamObject.get(ProxyOutboundObject.streamNetworkKey(netType)) - - if netobj is None: - return kwargs - - def hasKey(key): - return netobj.get(key) is not None - - if netType == 'tcp': - try: - kwargs['headerType'] = netobj['header']['type'] - except Exception: - # Any non-exit exceptions - - pass - - elif netType == 'kcp': - try: - # Get order matters here - if hasKey('seed'): - kwargs['seed'] = netobj['seed'] - - kwargs['headerType'] = netobj['header']['type'] - except Exception: - # Any non-exit exceptions - - pass - - elif netType == 'ws': - try: - # Get order matters here - if hasKey('path'): - kwargs['path'] = quote(netobj['path']) - - if netobj['headers']['Host']: - kwargs['host'] = quote(netobj['headers']['Host']) - except Exception: - # Any non-exit exceptions - - pass - - elif netType == 'h2' or netType == 'http': - try: - # Get order matters here - if hasKey('path'): - kwargs['path'] = quote(netobj['path']) - - kwargs['host'] = quote(','.join(netobj['host'])) - except Exception: - # Any non-exit exceptions - - pass - - elif netType == 'quic': - try: - # Get order matters here - if hasKey('security'): - kwargs['quicSecurity'] = netobj['security'] - - if hasKey('key'): - kwargs['path'] = quote(netobj['key']) - - kwargs['headerType'] = netobj['header']['type'] - except Exception: - # Any non-exit exceptions - - pass - - elif netType == 'grpc': - if hasKey('serviceName'): - kwargs['serviceName'] = netobj['serviceName'] - - return kwargs - - def export(self, remark, jsonObject): - proxyOutbound = XrayFactory.getProxyOutboundObject(jsonObject) - - proxyServer = proxyOutbound['settings']['vnext'][0] - proxyServerUser = proxyServer['users'][0] - - proxyStream, proxyStreamNet, proxyStreamTLS = ( - proxyOutbound['streamSettings'], - proxyOutbound['streamSettings']['network'], - proxyOutbound['streamSettings'].get('security', 'none'), - ) - - flowArg = {} - - if proxyServerUser.get('flow'): - flowArg['flow'] = proxyServerUser['flow'] - - netloc = ( - f'{proxyServerUser["id"]}@{proxyServer["address"]}:{proxyServer["port"]}' - ) - - query = '&'.join( - f'{key}={value}' - for key, value in { - 'encryption': proxyServerUser['encryption'], - 'type': proxyStreamNet, - 'security': proxyStreamTLS, - # kwargs - **flowArg, - **ExportVLESS.streamNetSettings(proxyStream, proxyStreamNet), - **XrayFactory.streamTLSSettings(proxyStream, proxyStreamTLS), - }.items() - ) - - return urlunparse(['vless', netloc, '', '', query, quote(remark)]) - - -class ExportSS(ExportVLESS): - def export(self, remark, jsonObject): - proxyOutbound = XrayFactory.getProxyOutboundObject(jsonObject) - - proxyServer = proxyOutbound['settings']['servers'][0] - - method, password, address, port = ( - proxyServer['method'], - proxyServer['password'], - proxyServer['address'], - proxyServer['port'], - ) - - netloc = f'{quote(method)}:{quote(password)}@{address}:{port}' - - return urlunparse(['ss', netloc, '', '', '', quote(remark)]) - - -class ExportTrojan(ExportVLESS): - def export(self, remark, jsonObject): - proxyOutbound = XrayFactory.getProxyOutboundObject(jsonObject) - - proxyServer = proxyOutbound['settings']['servers'][0] - - password, address, port = ( - proxyServer['password'], - proxyServer['address'], - proxyServer['port'], - ) - - proxyStream, proxyStreamNet, proxyStreamTLS = ( - proxyOutbound['streamSettings'], - proxyOutbound['streamSettings']['network'], - proxyOutbound['streamSettings'].get('security', 'none'), - ) - - netloc = f'{quote(password)}@{address}:{port}' - - query = '&'.join( - f'{key}={value}' - for key, value in { - 'type': proxyStreamNet, - 'security': proxyStreamTLS, - # kwargs - **ExportVLESS.streamNetSettings(proxyStream, proxyStreamNet), - **XrayFactory.streamTLSSettings(proxyStream, proxyStreamTLS), - }.items() - ) - - return urlunparse(['trojan', netloc, '', '', query, quote(remark)]) - - -class Configuration: - ExportClassMap = { - Protocol.VMess: ExportVMess, - Protocol.VLESS: ExportVLESS, - Protocol.Shadowsocks: ExportSS, - Protocol.Trojan: ExportTrojan, - } - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - @staticmethod - def toJSON(text): - return ujson.loads(text) - - @staticmethod - def corruptedError(errorBox, isAsync=False): - assert isinstance(errorBox, MessageBox) - - errorBox.setIcon(MessageBox.Icon.Critical) - errorBox.setWindowTitle(_('Server configuration corrupted')) - errorBox.setText( - _( - f'{APPLICATION_NAME} cannot restore your server configuration. ' - f'It may have been tampered with.' - ) - ) - errorBox.setInformativeText( - _(f'The configuration content has been cleared by {APPLICATION_NAME}.') - ) - - if isAsync: - # Show the MessageBox asynchronously - errorBox.open() - else: - # Show the MessageBox and wait for user to close it - errorBox.exec() - - @staticmethod - def export(remark, jsonObject): - if Intellisense.getCoreType(jsonObject) == XrayCore.name(): - - def getExportObject(): - protocol = Intellisense.getCoreProtocol(jsonObject) - - return Configuration.ExportClassMap[protocol]() - - return getExportObject().export(remark, jsonObject) - - if Intellisense.getCoreType(jsonObject) == Hysteria2.name(): - netloc = f'{jsonObject["auth"]}@{jsonObject["server"]}' - - tlsArg, obfsArg = {}, {} - - if jsonObject.get('tls'): - if jsonObject['tls'].get('sni'): - tlsArg['sni'] = jsonObject['tls']['sni'] - - if jsonObject['tls'].get('insecure') is True: - tlsArg['insecure'] = '1' - else: - tlsArg['insecure'] = '0' - - if jsonObject['tls'].get('pinSHA256'): - tlsArg['pinSHA256'] = jsonObject['tls']['pinSHA256'] - - if jsonObject.get('obfs'): - obfsType = jsonObject['obfs'].get('type', 'salamander') - - obfsArg['obfs'] = obfsType - obfsArg['obfs-password'] = jsonObject['obfs'][obfsType]['password'] - - query = '&'.join( - f'{key}={value}' - for key, value in { - **tlsArg, - **obfsArg, - }.items() - ) - - return urlunparse(['hysteria2', netloc, '', '', query, quote(remark)]) - - raise UnsupportedServerExport('Unsupported core protocol export') - - -class OutboundObject: - def __init__(self): - super().__init__() - - def build(self): - raise NotImplementedError - - -class ProxyOutboundObject(OutboundObject): - def __init__( - self, - protocol, - remote_host, - remote_port, - uuid_, - encryption, - type_, - security, - **kwargs, - ): - super().__init__() - - self.protocol = protocol - self.remote_host = remote_host - self.remote_port = remote_port - self.uuid_ = uuid_ - self.encryption = encryption - self.type_ = type_ - self.security = security - - self.kwargs = kwargs - - def getTLSSettings(self, security): - TLSObject = {} - - if security == 'reality' or security == 'tls': - # Note: If specify default value 'chrome', some share link fails. - # Leave default value as empty - fp = self.kwargs.get('fp') - sni = self.kwargs.get('sni') - # Protect "xxx," format - alpn = list( - filter( - lambda x: x != '', - unquote(self.kwargs.get('alpn', '')).split(','), - ) - ) - - if fp: - TLSObject['fingerprint'] = fp - - if sni: - TLSObject['serverName'] = sni - else: - host = '' - - if self.protocol == 'vmess': - host = self.kwargs.get('host') - - if self.protocol == 'vless': - host = self.remote_host - - if host: - TLSObject['serverName'] = host - - if alpn: - TLSObject['alpn'] = alpn - - if security == 'reality': - # More args for reality - pbk = self.kwargs.get('pbk') - sid = self.kwargs.get('sid', '') - spx = self.kwargs.get('spx', '') - - if pbk: - TLSObject['publicKey'] = pbk - - TLSObject['shortId'] = sid - TLSObject['spiderX'] = unquote(spx) - - return TLSObject - - @staticmethod - def streamTLSKey(security): - # tlsSettings, realitySettings - return f'{security}Settings' - - @staticmethod - @functools.lru_cache(None) - def streamNetworkKey(type_): - if type_ == 'h2': - return 'httpSettings' - else: - return f'{type_}Settings' - - def getStreamNetworkSettings(self): - # Note: - # V2rayN share standard doesn't require unquote. Still - # unquote value according to (VMess AEAD / VLESS) standard - - if self.type_ == 'tcp': - TcpObject = {} - - if self.kwargs.get('headerType', 'none'): - headerType = self.kwargs.get('headerType', 'none') - - # Some dumb share link set this value to 'auto'. Protect it - if headerType != 'auto': - TcpObject['header'] = { - 'type': headerType, - } - - # Request settings for HTTP - if headerType == 'http' and self.kwargs.get('host'): - TcpObject['header']['request'] = { - 'version': '1.1', - 'method': 'GET', - 'path': [self.kwargs.get('path', '/')], - 'headers': { - 'Host': [unquote(self.kwargs.get('host'))], - 'User-Agent': [ - 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.143 Safari/537.36', - 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_0_2 like Mac OS X) AppleWebKit/601.1 (KHTML, like Gecko) CriOS/53.0.2785.109 Mobile/14A456 Safari/601.1.46', - ], - 'Accept-Encoding': ['gzip, deflate'], - 'Connection': ['keep-alive'], - 'Pragma': 'no-cache', - }, - } - - return TcpObject - - elif self.type_ == 'kcp': - KcpObject = { - # Extension. From V2rayN - 'uplinkCapacity': 12, - 'downlinkCapacity': 100, - } - - if self.kwargs.get('headerType', 'none'): - headerType = self.kwargs.get('headerType', 'none') - - # Some dumb share link set this value to 'auto'. Protect it - if headerType != 'auto': - KcpObject['header'] = { - 'type': headerType, - } - - if self.kwargs.get('seed'): - KcpObject['seed'] = self.kwargs.get('seed') - - return KcpObject - - elif self.type_ == 'ws': - WebSocketObject = {} - - if self.kwargs.get('path', '/'): - WebSocketObject['path'] = unquote(self.kwargs.get('path', '/')) - - if self.kwargs.get('host'): - WebSocketObject['headers'] = { - 'Host': unquote(self.kwargs.get('host')), - } - - return WebSocketObject - - elif self.type_ == 'h2' or self.type_ == 'http': - HttpObject = {} - - if self.kwargs.get('host', self.remote_host): - # Protect "xxx," format - HttpObject['host'] = list( - filter( - lambda x: x != '', - unquote(self.kwargs.get('host', self.remote_host)).split(','), - ) - ) - - if self.kwargs.get('path', '/'): - HttpObject['path'] = unquote(self.kwargs.get('path', '/')) - - return HttpObject - - elif self.type_ == 'quic': - QuicObject = {} - - if self.kwargs.get('quicSecurity', 'none'): - QuicObject['security'] = self.kwargs.get('quicSecurity', 'none') - - if self.kwargs.get('key'): - QuicObject['key'] = unquote(self.kwargs.get('key')) - - if self.kwargs.get('headerType', 'none'): - headerType = self.kwargs.get('headerType', 'none') - - # Some dumb share link set this value to 'auto'. Protect it - if headerType != 'auto': - QuicObject['header'] = { - 'type': headerType, - } - - return QuicObject - - elif self.type_ == 'grpc': - GRPCObject = {} - - if self.kwargs.get('serviceName'): - GRPCObject['serviceName'] = self.kwargs.get('serviceName') - - if self.kwargs.get('mode', 'gun'): - GRPCObject['multiMode'] = self.kwargs.get('mode', 'gun') == 'multi' - - return GRPCObject - - def getUserObject(self): - if self.protocol == 'vmess': - UserObject = { - 'id': self.uuid_, - 'security': self.encryption, - # Extension - 'email': PROXY_OUTBOUND_USER_EMAIL, - } - - # For VMess(V2rayN share standard) only. - if self.kwargs.get('aid') is not None: - UserObject['alterId'] = self.kwargs.get('aid') - - return UserObject - - if self.protocol == 'vless': - UserObject = { - 'id': self.uuid_, - 'encryption': self.encryption, - # Extension - 'email': PROXY_OUTBOUND_USER_EMAIL, - } - - if self.kwargs.get('flow'): - # flow is empty, TLS. Otherwise, XTLS. - UserObject['flow'] = self.kwargs.get('flow') - - return UserObject - - return {} - - def getDefaultJSON(self): - securityArgs = {} - - if self.security: - securityArgs['security'] = self.security - - return { - 'tag': 'proxy', - 'protocol': self.protocol, - 'settings': { - 'vnext': [ - { - 'address': self.remote_host, - # self.remote_port is already an integer - 'port': self.remote_port, - 'users': [ - self.getUserObject(), - ], - }, - ] - }, - 'streamSettings': { - 'network': self.type_, - **securityArgs, - }, - 'mux': { - 'enabled': False, - 'concurrency': -1, - }, - } - - def build(self): - myJSON = self.getDefaultJSON() - - if self.security and self.security != 'none': - # tlsSettings, realitySettings - myJSON['streamSettings'][ - ProxyOutboundObject.streamTLSKey(self.security) - ] = self.getTLSSettings(self.security) - - # Stream network settings - myJSON['streamSettings'][ - ProxyOutboundObject.streamNetworkKey(self.type_) - ] = self.getStreamNetworkSettings() - - return myJSON - - -class ProxyOutboundObjectSS(ProxyOutboundObject): - def __init__(self, method, password, address, port): - super().__init__('shadowsocks', address, port, password, '', 'tcp', '') - - self.method = method - - def getDefaultJSON(self): - return { - 'tag': 'proxy', - 'protocol': self.protocol, - 'settings': { - 'servers': [ - { - 'address': self.remote_host, - 'port': int(self.remote_port), - 'method': self.method, - 'password': self.uuid_, - 'email': PROXY_OUTBOUND_USER_EMAIL, - 'ota': False, - }, - ] - }, - 'streamSettings': { - 'network': 'tcp', - }, - 'mux': { - 'enabled': False, - 'concurrency': -1, - }, - } - - def build(self): - return self.getDefaultJSON() - - -class ProxyOutboundObjectTrojan(ProxyOutboundObject): - def __init__(self, password, address, port, type_, security, **kwargs): - super().__init__( - 'trojan', address, port, password, '', type_, security, **kwargs - ) - - def getDefaultJSON(self): - securityArgs = {} - - if self.security: - securityArgs['security'] = self.security - - return { - 'tag': 'proxy', - 'protocol': self.protocol, - 'settings': { - 'servers': [ - { - 'address': self.remote_host, - 'port': int(self.remote_port), - 'password': self.uuid_, - 'email': PROXY_OUTBOUND_USER_EMAIL, - }, - ] - }, - 'streamSettings': { - 'network': self.type_, - **securityArgs, - }, - 'mux': { - 'enabled': False, - 'concurrency': -1, - }, - } - - -class Outbounds: - def __init__(self, proxyOutboundObject): - self.proxyOutboundObject = proxyOutboundObject - - def build(self): - return [ - # Proxy - self.proxyOutboundObject.build(), - # Direct - { - 'tag': 'direct', - 'protocol': 'freedom', - 'settings': {}, - }, - # Block - { - 'tag': 'block', - 'protocol': 'blackhole', - 'settings': { - 'response': { - 'type': 'http', - } - }, - }, - ] - - -class XrayCoreConfiguration: - DEFAULT_JSON = { - # Default log configuration - 'log': { - 'access': '', - 'error': '', - 'loglevel': 'warning', - }, - # Default inbounds configuration - 'inbounds': [ - { - 'tag': 'socks', - 'port': 10808, - 'listen': '127.0.0.1', - 'protocol': 'socks', - 'sniffing': { - 'enabled': True, - 'destOverride': [ - 'http', - 'tls', - ], - }, - 'settings': { - 'auth': 'noauth', - 'udp': True, - 'allowTransparent': False, - }, - }, - { - 'tag': 'http', - 'port': 10809, - 'listen': '127.0.0.1', - 'protocol': 'http', - 'sniffing': { - 'enabled': True, - 'destOverride': [ - 'http', - 'tls', - ], - }, - 'settings': { - 'auth': 'noauth', - 'udp': True, - 'allowTransparent': False, - }, - }, - ], - } - - @staticmethod - def getDefaultJSON(): - return copy.deepcopy(XrayCoreConfiguration.DEFAULT_JSON) - - @staticmethod - def build(proxyOutboundObject): - # log, inbounds - myJSON = XrayCoreConfiguration.getDefaultJSON() - - # Add outbounds - myJSON['outbounds'] = Outbounds(proxyOutboundObject).build() - - # Add empty routing - myJSON['routing'] = {} - - return myJSON - - -class Hysteria2Configuration: - def __init__(self, server, auth, **kwargs): - self.server = server - self.auth = auth - self.obfs = kwargs.get('obfs', '') - self.obfsPassword = kwargs.get('obfs-password', '') - self.sni = kwargs.get('sni', '') - - insecure = kwargs.get('insecure', False) - - if isinstance(insecure, bool): - self.insecure = insecure - elif insecure == '1': - self.insecure = True - else: - self.insecure = False - - self.pinSHA256 = kwargs.get('pinSHA256', '') - - def build(self): - obfsArg = {} - pinSHA256Arg = {} - - if self.obfs and self.obfsPassword: - obfsArg['obfs'] = { - 'type': self.obfs, - self.obfs: { - 'password': self.obfsPassword, - }, - } - - if self.pinSHA256: - pinSHA256Arg['pinSHA256'] = self.pinSHA256 - - return { - 'server': self.server, - 'auth': self.auth, - 'tls': { - 'sni': self.sni, - 'insecure': self.insecure, - **pinSHA256Arg, - }, - **obfsArg, - 'socks5': { - 'listen': '127.0.0.1:10808', - }, - 'http': { - 'listen': '127.0.0.1:10809', - }, - } diff --git a/Furious/Core/Core.py b/Furious/Core/Core.py deleted file mode 100644 index d8d3735..0000000 --- a/Furious/Core/Core.py +++ /dev/null @@ -1,455 +0,0 @@ -# Copyright (C) 2023 Loren Eteval -# -# This file is part of Furious. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -from Furious.Utility.Constants import APP, PLATFORM, LogType -from Furious.Utility.Utility import getAbsolutePath, eventLoopWait, isPythonw - -from PySide6 import QtCore - -import io -import os -import sys -import uuid -import time -import ujson -import logging -import functools -import threading -import multiprocessing - -logger = logging.getLogger(__name__) - - -# 300ms -STDOUT_REDIRECT_THRESHOLD = 300 - - -class Core: - class ExitCode: - ConfigurationError = 23 - # Windows: 4294967295. Darwin, Linux: 255 (-1) - ServerStartFailure = 4294967295 if PLATFORM == 'Windows' else 255 - # Windows shutting down - SystemShuttingDown = 0x40010004 - - def __init__(self, *args, exitCallback=None, **kwargs): - self._process = None - self._exitCallback = exitCallback - self._msgQueue = multiprocessing.Queue() - - @QtCore.Slot() - def appendCoreLog(): - line = self.getLineNoWait() - - if line and not line.isspace(): - APP().logViewerWidget.appendLog(LogType.Core, line) - - self._stdoutTimer = QtCore.QTimer() - self._stdoutTimer.timeout.connect(appendCoreLog) - - @QtCore.Slot() - def timeoutCallback(): - self.checkAlive() - - self._daemonTimer = QtCore.QTimer() - self._daemonTimer.timeout.connect(timeoutCallback) - - @staticmethod - def name(): - raise NotImplementedError - - @staticmethod - def version(): - raise NotImplementedError - - def registerExitCallback(self, exitCallback): - self._exitCallback = exitCallback - - def isAlive(self): - if isinstance(self._process, multiprocessing.Process): - return self._process.is_alive() - else: - return False - - def checkAlive(self): - if isinstance(self._process, multiprocessing.Process): - if self._process.is_alive(): - return True - else: - logger.error( - f'{self.name()} stopped unexpectedly with exitcode {self._process.exitcode}' - ) - - self._stdoutTimer.stop() - self._daemonTimer.stop() - - if callable(self._exitCallback): - self._exitCallback(self._process.exitcode) - - # Reset internal process - self._process = None - - return False - else: - return False - - def start(self, *args, **kwargs): - logger.info(f'{self.name()} {self.version()} started') - - waitCore = kwargs.pop('waitCore', True) - waitTime = kwargs.pop('waitTime', 2000) - - self._process = multiprocessing.Process(**kwargs, daemon=True) - self._process.start() - - if waitCore: - # Wait for the core to start up completely - eventLoopWait(waitTime) - - if self.checkAlive(): - # Start core daemon - self._stdoutTimer.start(STDOUT_REDIRECT_THRESHOLD) - self._daemonTimer.start(2000) - - def stop(self): - if self.isAlive(): - self._stdoutTimer.stop() - self._daemonTimer.stop() - - self._process.terminate() - self._process.join() - - logger.info( - f'{self.name()} terminated with exitcode {self._process.exitcode}' - ) - - def getLineNoWait(self): - try: - return self._msgQueue.get_nowait() - except Exception: - # Any non-exit exceptions - - return '' - - -class StdoutRedirectHelper: - TemporaryDir = QtCore.QTemporaryDir() - - @staticmethod - def launch(msgQueue, entrypoint, redirect): - if not callable(entrypoint): - return - - if ( - not StdoutRedirectHelper.TemporaryDir.isValid() - or not redirect - # pythonw.exe - or isPythonw() - ): - # Call entrypoint directly - entrypoint() - - return - - temporaryFile = StdoutRedirectHelper.TemporaryDir.filePath(str(uuid.uuid4())) - tmpFileStream = open(temporaryFile, 'w+b') - stdoutFileno_ = sys.stdout.fileno() - stderrFileno_ = sys.stderr.fileno() - - sys.stdout.close() - sys.stderr.close() - - # Redirect - os.dup2(tmpFileStream.fileno(), stdoutFileno_) - os.dup2(tmpFileStream.fileno(), stderrFileno_) - - sys.stdout = tmpFileStream - sys.stderr = tmpFileStream - - def produceMsg(): - with open(temporaryFile, 'rb') as file: - while True: - for line in iter(file.readline, b''): - if line and not line.isspace(): - try: - msgQueue.put_nowait(line.decode('utf-8', 'replace')) - except Exception: - # Any non-exit exceptions - - pass - - time.sleep(STDOUT_REDIRECT_THRESHOLD / 1000) - - msgThread = threading.Thread(target=produceMsg, daemon=True) - msgThread.start() - - with tmpFileStream: - entrypoint() - - -def startXrayCore(json, msgQueue): - try: - import xray - except ImportError: - # Fake running process - while True: - pass - else: - if xray.__version__ <= '1.8.4': - redirect = False - else: - redirect = True - - # Can be redirected - - if not isPythonw(): - StdoutRedirectHelper.launch( - msgQueue, lambda: xray.startFromJSON(json), redirect - ) - - return - - if not redirect: - xray.startFromJSON(json) - - return - - def xrayPythonwProduceMsg(): - try: - jsonObject = ujson.loads(json) - except Exception: - # Any non-exit exceptions - - xray.startFromJSON(json) - - return - - loggerPath = [] - fileStream = [] - - for attr in ['access', 'error']: - try: - path = jsonObject['log'][attr] - except Exception: - # Any non-exit exceptions - - continue - - if path not in loggerPath: - loggerPath.append(path) - - try: - stream = open(path, 'rb') - except Exception: - # Any non-exit exceptions - - pass - else: - stream.seek(0, io.SEEK_END) - - fileStream.append(stream) - - def produceMsg(): - while True: - for file in fileStream: - for line in iter(file.readline, b''): - if line and not line.isspace(): - try: - msgQueue.put_nowait(line.decode('utf-8', 'replace')) - except Exception: - # Any non-exit exceptions - - pass - - time.sleep(STDOUT_REDIRECT_THRESHOLD / 1000) - - try: - msgThread = threading.Thread(target=produceMsg, daemon=True) - msgThread.start() - - xray.startFromJSON(json) - finally: - for stream in fileStream: - stream.close() - - xrayPythonwProduceMsg() - - -class XrayCore(Core): - class ExitCode: - ConfigurationError = 23 - # Windows: 4294967295. Darwin, Linux: 255 (-1) - ServerStartFailure = 4294967295 if PLATFORM == 'Windows' else 255 - # Windows shutting down - SystemShuttingDown = 0x40010004 - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - @staticmethod - def name(): - return 'Xray-core' - - @staticmethod - @functools.lru_cache(None) - def version(): - try: - import xray - - return xray.__version__ - except Exception: - # Any non-exit exceptions - - return '0.0.0' - - def start(self, json, **kwargs): - super().start(target=startXrayCore, args=(json, self._msgQueue), **kwargs) - - -def startHysteria1(json, rule, mmdb, msgQueue): - try: - import hysteria - except ImportError: - # Fake running process - while True: - pass - else: - if hysteria.__version__ <= '1.3.5': - redirect = False - else: - redirect = True - - StdoutRedirectHelper.launch( - msgQueue, lambda: hysteria.startFromJSON(json, rule, mmdb), redirect - ) - - -class Hysteria1(Core): - class ExitCode: - ConfigurationError = 23 - RemoteNetworkError = 3 - # Windows shutting down - SystemShuttingDown = 0x40010004 - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - @staticmethod - def rule(rulePath): - path = getAbsolutePath(str(rulePath)) - - try: - with open(path, 'rb') as file: - data = file.read() - - logger.info(f'hysteria1 rule \'{path}\' load success') - - return data - except Exception as ex: - # Any non-exit exceptions - - logger.error(f'hysteria1 rule \'{path}\' load failed. {ex}') - - return '' - - @staticmethod - def mmdb(mmdbPath): - path = getAbsolutePath(str(mmdbPath)) - - try: - with open(path, 'rb') as file: - data = file.read() - - logger.info(f'hysteria1 mmdb \'{path}\' load success') - - return data - except Exception as ex: - # Any non-exit exceptions - - logger.error(f'hysteria1 mmdb \'{path}\' load failed. {ex}') - - return '' - - @staticmethod - def name(): - return 'Hysteria1' - - @staticmethod - @functools.lru_cache(None) - def version(): - try: - import hysteria - - return hysteria.__version__ - except Exception: - # Any non-exit exceptions - - return '0.0.0' - - def start(self, json, rule, mmdb, **kwargs): - super().start( - target=startHysteria1, args=(json, rule, mmdb, self._msgQueue), **kwargs - ) - - -def startHysteria2(json, msgQueue): - try: - import hysteria2 - except ImportError: - # Fake running process - while True: - pass - else: - if hysteria2.__version__ <= '2.0.0.1': - redirect = False - else: - redirect = True - - StdoutRedirectHelper.launch( - msgQueue, lambda: hysteria2.startFromJSON(json), redirect - ) - - -class Hysteria2(Core): - class ExitCode: - ConfigurationError = 23 - # Windows: 4294967295. Darwin, Linux: 255 (-1) - ServerStartFailure = 4294967295 if PLATFORM == 'Windows' else 255 - # Windows shutting down - SystemShuttingDown = 0x40010004 - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - @staticmethod - def name(): - return 'Hysteria2' - - @staticmethod - @functools.lru_cache(None) - def version(): - try: - import hysteria2 - - return hysteria2.__version__ - except Exception: - # Any non-exit exceptions - - return '0.0.0' - - def start(self, json, **kwargs): - super().start(target=startHysteria2, args=(json, self._msgQueue), **kwargs) diff --git a/Furious/Core/CoreManager.py b/Furious/Core/CoreManager.py new file mode 100644 index 0000000..bc3aecc --- /dev/null +++ b/Furious/Core/CoreManager.py @@ -0,0 +1,429 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from __future__ import annotations + +from Furious.Interface import * +from Furious.PyFramework import * +from Furious.QtFramework import * +from Furious.Library import * +from Furious.Utility import * +from Furious.Core import * + +import uuid +import logging +import functools +import subprocess + +__all__ = ['CoreManager'] + +logger = logging.getLogger(__name__) + + +def fixLogObjectPath(config: ConfigurationFactory, attr: str, value: str, log=True): + try: + path = config['log'][attr] + except Exception: + # Any non-exit exceptions + + config['log'][attr] = path = '' + + if not isinstance(path, str) and not isinstance(path, bytes): + config['log'][attr] = path = '' + + if path == '': + if isPythonw() and StdoutRedirectHelper.TemporaryDir.isValid(): + # Redirect implementation for pythonw environment + config['log'][attr] = StdoutRedirectHelper.TemporaryDir.filePath(value) + else: + # Relative path fails if booting on start up + # on Windows, when packed using nuitka... + + # Fix relative path if needed. User cannot feel this operation. + config['log'][attr] = getAbsolutePath(path) + + result = config['log'][attr] + + if result: + try: + # Create a new file + with open(result, 'x'): + pass + except FileExistsError: + pass + except Exception: + # Any non-exit exceptions + + pass + + if log: + logger.info( + f'{XrayCore.name()}: {attr} log is specified as \'{path}\'. ' + f'Fixed to \'{result}\'' + ) + + +class CoreManager(SupportExitCleanup): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.coresPool = [] + + def start( + self, + config: ConfigurationFactory, + routing: str, + exitCallback=None, + msgCallback=None, + tunMsgCallback=None, + deepcopy=True, + proxyModeOnly=False, + log=True, + **kwargs, + ) -> bool: + if deepcopy: + copy = config.deepcopy() + else: + copy = config + + if isinstance(copy, ConfigurationXray): + if copy.get('log') is None or not isinstance(copy['log'], dict): + copy['log'] = { + 'access': '', + 'error': '', + 'loglevel': 'warning', + } + + logRedirectValue = str(uuid.uuid4()) + + # Fix logObject + for attr in ['access', 'error']: + fixLogObjectPath(copy, attr, logRedirectValue, log) + + if routing == 'Bypass Mainland China': + routingObject = { + 'domainStrategy': 'IPIfNonMatch', + 'domainMatcher': 'hybrid', + 'rules': [ + { + 'type': 'field', + 'domain': [ + 'geosite:category-ads-all', + ], + 'outboundTag': 'block', + }, + { + 'type': 'field', + 'domain': [ + 'geosite:cn', + ], + 'outboundTag': 'direct', + }, + { + 'type': 'field', + 'ip': [ + 'geoip:private', + 'geoip:cn', + ], + 'outboundTag': 'direct', + }, + { + 'type': 'field', + 'port': '0-65535', + 'outboundTag': 'proxy', + }, + ], + } + # elif routing == 'Bypass Iran': + # routingObject = { + # 'domainStrategy': 'IPIfNonMatch', + # 'domainMatcher': 'hybrid', + # 'rules': [ + # { + # 'type': 'field', + # 'domain': [ + # 'geosite:category-ads-all', + # 'iran:ads', + # ], + # 'outboundTag': 'block', + # }, + # { + # 'type': 'field', + # 'domain': [ + # 'iran:ir', + # 'iran:other', + # ], + # 'outboundTag': 'direct', + # }, + # { + # 'type': 'field', + # 'ip': [ + # 'geoip:private', + # 'geoip:ir', + # ], + # 'outboundTag': 'direct', + # }, + # { + # 'type': 'field', + # 'port': '0-65535', + # 'outboundTag': 'proxy', + # }, + # ], + # } + elif routing == 'Global': + routingObject = {} + elif routing == 'Custom': + routingObject = copy.get('routing', {}) + else: + routingObject = {} + + if log: + logger.info(f'core {XrayCore.name()} configured') + logger.info(f'routing is {routing}') + logger.info(f'RoutingObject: {routingObject}') + + copy['routing'] = routingObject + + core = XrayCore(exitCallback=exitCallback, msgCallback=msgCallback) + success = core.start(copy, **kwargs) + elif isinstance(copy, ConfigurationHysteria1): + if routing == 'Bypass Mainland China': + routingObject = { + 'rule': DATA_DIR / 'hysteria' / 'bypass-mainland-China.acl', + 'mmdb': DATA_DIR / 'hysteria' / 'country.mmdb', + } + # elif routing == 'Bypass Iran': + # routingObject = { + # 'rule': DATA_DIR / 'hysteria' / 'bypass-Iran.acl', + # 'mmdb': DATA_DIR / 'hysteria' / 'country.mmdb', + # } + elif routing == 'Global': + routingObject = { + 'rule': '', + 'mmdb': '', + } + elif routing == 'Custom': + routingObject = { + 'rule': copy.get('acl', ''), + 'mmdb': copy.get('mmdb', ''), + } + else: + routingObject = { + 'rule': '', + 'mmdb': '', + } + + if log: + logger.info(f'core {Hysteria1.name()} configured') + logger.info(f'routing is {routing}') + logger.info(f'RoutingObject: {routingObject}') + + core = Hysteria1(exitCallback=exitCallback, msgCallback=msgCallback) + success = core.start( + copy, + Hysteria1.rule(routingObject.get('rule', '')), + Hysteria1.mmdb(routingObject.get('mmdb', '')), + **kwargs, + ) + elif isinstance(copy, ConfigurationHysteria2): + if log: + logger.info(f'core {Hysteria2.name()} configured') + + core = Hysteria2(exitCallback=exitCallback, msgCallback=msgCallback) + success = core.start(copy, **kwargs) + else: + core = None + success = False + + if core is not None: + self.coresPool.append(core) + + if not success: + logger.error(f'core {core.name()} start failed') + + return success + + # VPN Mode handling + if not proxyModeOnly and isVPNMode(): + # Currently VPN Mode is only supported on Windows and macOS + if PLATFORM == 'Windows' or PLATFORM == 'Darwin': + if PLATFORM == 'Windows': + # cleanup first + SystemRoutingTable.delete( + '0.0.0.0', APPLICATION_TUN_GATEWAY_ADDRESS + ) + + defaultGateway = SystemRoutingTable.getDefaultGateway() + + if PLATFORM == 'Darwin': + # Need this? + defaultGateway = list( + # Filter TUN Gateway + filter( + lambda x: x != APPLICATION_TUN_GATEWAY_ADDRESS, + defaultGateway, + ) + ) + + if len(defaultGateway) != 1: + logger.error(f'bad default gateway: {defaultGateway}') + + return False + + if PLATFORM == 'Windows': + gateway, interfaceIP = defaultGateway[0] + else: + gateway, interfaceIP = defaultGateway[0], None + + tun = Tun2socks(exitCallback=exitCallback, msgCallback=tunMsgCallback) + self.coresPool.append(tun) + + if not tun.start( + APPLICATION_TUN_DEVICE_NAME, + APPLICATION_TUN_NETWORK_INTERFACE_NAME, + 'info', + f'socks5://{copy.socksProxyEndpoint()}', + '', + ): + return False + + address = copy.itemAddress + + if not isValidIPAddress(address): + error, resolved = DNSResolver.resolve( + address, *parseHostPort(copy.httpProxyEndpoint()) + ) + + if error: + logger.error(f'DNS resolution failed: {address}') + + SystemRoutingTable.Relations.clear() + + return False + else: + for address in resolved: + SystemRoutingTable.Relations.append([address, gateway]) + else: + SystemRoutingTable.Relations.append([address, gateway]) + + if PLATFORM == 'Windows': + foundDevice = False + + for counter in range(0, 10000, 100): + try: + result = runExternalCommand( + 'ipconfig', + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + ) + except Exception: + # Any non-exit exceptions + + break + else: + stdout = result.stdout.decode('utf-8', 'replace') + + if stdout.find(APPLICATION_TUN_DEVICE_NAME) >= 0: + foundDevice = True + + logger.info( + f'find TUN device \'{APPLICATION_TUN_DEVICE_NAME}\' success. ' + f'Counter: {counter}' + ) + + break + + PySide6LegacyEventLoopWait(100) + + if not foundDevice: + logger.error( + f'find TUN device \'{APPLICATION_TUN_DEVICE_NAME}\' failed' + ) + + return False + + alias = SystemRoutingTable.WIN32GetInterfaceAliasByIP(interfaceIP) + + if alias: + + def _windowsCleanup(_alias): + SystemRoutingTable.WIN32SetInterfaceDNS(_alias) + SystemRoutingTable.WIN32FlushDNSCache() + + tun.cleanup = functools.partial(_windowsCleanup, alias) + + SystemRoutingTable.WIN32SetInterfaceDNS( + alias, '127.0.0.1', False + ) + + SystemRoutingTable.addRelations() + SystemRoutingTable.WIN32SetInterfaceDNS( + APPLICATION_TUN_DEVICE_NAME, '8.8.8.8', False + ) + SystemRoutingTable.setDeviceGateway( + APPLICATION_TUN_DEVICE_NAME, + APPLICATION_TUN_IP_ADDRESS, + APPLICATION_TUN_GATEWAY_ADDRESS, + ) + SystemRoutingTable.WIN32FlushDNSCache() + + if PLATFORM == 'Darwin': + for source in [ + *list(f'{2 ** (8 - x)}.0.0.0/{x}' for x in range(8, 0, -1)), + '198.18.0.0/15', + ]: + SystemRoutingTable.Relations.append( + [source, APPLICATION_TUN_GATEWAY_ADDRESS] + ) + + servers = SystemRoutingTable.DarwinGetDNSServers() + + def _darwinCleanup(_servers): + for _service, _dnsserver in _servers: + SystemRoutingTable.DarwinSetDNSServers(_service, _dnsserver) + + tun.cleanup = functools.partial(_darwinCleanup, servers) + + for service, dnsserver in servers: + SystemRoutingTable.DarwinSetDNSServers(service, '8.8.8.8') + + SystemRoutingTable.setDeviceGateway( + APPLICATION_TUN_DEVICE_NAME, + APPLICATION_TUN_IP_ADDRESS, + APPLICATION_TUN_GATEWAY_ADDRESS, + ) + SystemRoutingTable.addRelations() + + return True + + def allRunning(self) -> bool: + return all(core.isRunning() for core in self.coresPool) + + def anyRunning(self) -> bool: + return any(core.isRunning() for core in self.coresPool) + + def stopAll(self): + if self.coresPool: + for core in self.coresPool: + if isinstance(core, CoreFactory): + core.stop() + + self.coresPool.clear() + + def cleanup(self): + self.stopAll() diff --git a/Furious/Core/Hysteria1.py b/Furious/Core/Hysteria1.py new file mode 100644 index 0000000..ffc0bb2 --- /dev/null +++ b/Furious/Core/Hysteria1.py @@ -0,0 +1,163 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from __future__ import annotations + +from Furious.Interface import * +from Furious.QtFramework import * +from Furious.Library import * +from Furious.Utility import * + +from typing import Union + +import time +import logging +import multiprocessing + +logger = logging.getLogger(__name__) + +__all__ = ['Hysteria1'] + + +def startHysteria1(jsonString, rule, mmdb, msgQueue: multiprocessing.Queue): + try: + import hysteria + except ImportError: + # Fake running process + while True: + time.sleep(1) + else: + if hysteria.__version__ <= '1.3.5': + redirect = False + else: + redirect = True + + StdoutRedirectHelper.launch( + msgQueue, lambda: hysteria.startFromJSON(jsonString, rule, mmdb), redirect + ) + + +class Hysteria1(CoreProcess): + class ExitCode: + ConfigurationError = 23 + RemoteNetworkError = 3 + # Windows shutting down + SystemShuttingDown = 0x40010004 + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + @staticmethod + def rule(rulePath): + if isinstance(rulePath, str) and rulePath == '': + return '' + + try: + path = getAbsolutePath(str(rulePath)) + except Exception: + # Any non-exit exceptions + + logger.error('invalid hysteria1 rule path. Fall back to empty') + + return '' + + try: + with open(path, 'rb') as file: + data = file.read() + + logger.info(f'hysteria1 rule \'{path}\' load success') + + return data + except Exception as ex: + # Any non-exit exceptions + + logger.error( + f'hysteria1 rule \'{path}\' load failed. {ex}. Fall back to empty' + ) + + return '' + + @staticmethod + def mmdb(mmdbPath): + if isinstance(mmdbPath, str) and mmdbPath == '': + return '' + + try: + path = getAbsolutePath(str(mmdbPath)) + except Exception: + # Any non-exit exceptions + + logger.error('invalid hysteria1 mmdb path. Fall back to empty') + + return '' + + try: + with open(path, 'rb') as file: + data = file.read() + + logger.info(f'hysteria1 mmdb \'{path}\' load success') + + return data + except Exception as ex: + # Any non-exit exceptions + + logger.error( + f'hysteria1 mmdb \'{path}\' load failed. {ex}. Fall back to empty' + ) + + return '' + + @staticmethod + def name() -> str: + return 'Hysteria1' + + @staticmethod + def version() -> str: + try: + import hysteria + + return hysteria.__version__ + except Exception: + # Any non-exit exceptions + + return '0.0.0' + + def startFromArgs(self, jsonString: str, rule, mmdb, **kwargs) -> bool: + self.registerCurrentJSONConfig(jsonString) + + return super().start( + target=startHysteria1, + args=(jsonString, rule, mmdb, self.msgQueue), + **kwargs, + ) + + def start(self, config: Union[str, dict], rule, mmdb, **kwargs) -> bool: + if isinstance(config, str): + return self.startFromArgs(config, rule, mmdb, **kwargs) + elif isinstance(config, ConfigurationFactory): + return self.startFromArgs(config.toJSONString(), rule, mmdb, **kwargs) + elif isinstance(config, dict): + try: + jsonString = UJSONEncoder.encode(config) + except Exception: + # Any non-exit exceptions + + return False + else: + return self.startFromArgs(jsonString, rule, mmdb, **kwargs) + else: + return False diff --git a/Furious/Core/Hysteria2.py b/Furious/Core/Hysteria2.py new file mode 100644 index 0000000..273182f --- /dev/null +++ b/Furious/Core/Hysteria2.py @@ -0,0 +1,102 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from __future__ import annotations + +from Furious.Interface import * +from Furious.QtFramework import * +from Furious.Library import * +from Furious.Utility import * + +from typing import Union + +import time +import logging +import multiprocessing + +logger = logging.getLogger(__name__) + +__all__ = ['Hysteria2'] + + +def startHysteria2(jsonString: str, msgQueue: multiprocessing.Queue): + try: + import hysteria2 + except ImportError: + # Fake running process + while True: + time.sleep(1) + else: + if hysteria2.__version__ <= '2.0.0.1': + redirect = False + else: + redirect = True + + StdoutRedirectHelper.launch( + msgQueue, lambda: hysteria2.startFromJSON(jsonString), redirect + ) + + +class Hysteria2(CoreProcess): + class ExitCode: + ConfigurationError = 23 + # Windows: 4294967295. Darwin, Linux: 255 (-1) + ServerStartFailure = 4294967295 if PLATFORM == 'Windows' else 255 + # Windows shutting down + SystemShuttingDown = 0x40010004 + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + @staticmethod + def name() -> str: + return 'Hysteria2' + + @staticmethod + def version() -> str: + try: + import hysteria2 + + return hysteria2.__version__ + except Exception: + # Any non-exit exceptions + + return '0.0.0' + + def startFromArgs(self, jsonString: str, **kwargs) -> bool: + self.registerCurrentJSONConfig(jsonString) + + return super().start( + target=startHysteria2, args=(jsonString, self.msgQueue), **kwargs + ) + + def start(self, config: Union[str, dict], **kwargs) -> bool: + if isinstance(config, str): + return self.startFromArgs(config, **kwargs) + elif isinstance(config, ConfigurationFactory): + return self.startFromArgs(config.toJSONString(), **kwargs) + elif isinstance(config, dict): + try: + jsonString = UJSONEncoder.encode(config) + except Exception: + # Any non-exit exceptions + + return False + else: + return self.startFromArgs(jsonString, **kwargs) + else: + return False diff --git a/Furious/Core/Intellisense.py b/Furious/Core/Intellisense.py deleted file mode 100644 index ad1d37c..0000000 --- a/Furious/Core/Intellisense.py +++ /dev/null @@ -1,213 +0,0 @@ -# Copyright (C) 2023 Loren Eteval -# -# This file is part of Furious. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -from Furious.Core.Core import XrayCore, Hysteria1, Hysteria2 -from Furious.Utility.Utility import Protocol, protocolRepr, parseHostPort - - -class Intellisense: - @staticmethod - def getCoreType(ob): - def hasField(field): - return ob.get(field) is not None - - if hasField('inbounds') or hasField('outbounds'): - # Assuming is Xray-Core - return XrayCore.name() - - if hasField('server'): - if ( - hasField('protocol') - or hasField('up_mbps') - or hasField('down_mbps') - or hasField('auth_str') - or hasField('alpn') - or hasField('server_name') - or hasField('insecure') - or hasField('recv_window_conn') - or hasField('recv_window') - or isinstance(ob.get('obfs'), str) - or hasField('fast_open') - or hasField('lazy_start') - ): - return Hysteria1.name() - if ( - hasField('tls') - or hasField('transport') - or hasField('quic') - or hasField('bandwidth') - or hasField('tcpForwarding') - or hasField('udpForwarding') - or hasField('tcpTProxy') - or hasField('udpTProxy') - or isinstance(ob.get('obfs'), dict) - or hasField('fastOpen') - or hasField('lazy') - ): - return Hysteria2.name() - - return '' - - @staticmethod - def getCoreProtocol(ob): - try: - if Intellisense.getCoreType(ob) == XrayCore.name(): - for outbound in ob['outbounds']: - if outbound['tag'] == 'proxy': - return protocolRepr(outbound['protocol']) - - return '' - - if Intellisense.getCoreType(ob) == Hysteria1.name(): - return Protocol.Hysteria1 - - if Intellisense.getCoreType(ob) == Hysteria2.name(): - return Protocol.Hysteria2 - - return '' - except Exception: - # Any non-exit exceptions - - return '' - - @staticmethod - def getCoreAddr(ob): - try: - if Intellisense.getCoreType(ob) == XrayCore.name(): - for outbound in ob['outbounds']: - if outbound['tag'] == 'proxy': - protocol = protocolRepr(outbound['protocol']) - - if protocol == Protocol.VMess or protocol == Protocol.VLESS: - return ';'.join( - list( - f'{server["address"]}' - for server in outbound['settings']['vnext'] - ) - ) - - if ( - protocol == Protocol.Shadowsocks - or protocol == Protocol.Trojan - ): - return ';'.join( - list( - f'{server["address"]}' - for server in outbound['settings']['servers'] - ) - ) - - return '' - - if ( - Intellisense.getCoreType(ob) == Hysteria1.name() - or Intellisense.getCoreType(ob) == Hysteria2.name() - ): - server = ob['server'] - - pos = server.rfind(':') - - if pos == -1: - return server - else: - return server[:pos] - - return '' - except Exception: - # Any non-exit exceptions - - return '' - - @staticmethod - def getCorePort(ob): - try: - if Intellisense.getCoreType(ob) == XrayCore.name(): - for outbound in ob['outbounds']: - if outbound['tag'] == 'proxy': - protocol = protocolRepr(outbound['protocol']) - - if protocol == Protocol.VMess or protocol == Protocol.VLESS: - return ';'.join( - list( - f'{server["port"]}' - for server in outbound['settings']['vnext'] - ) - ) - - if ( - protocol == Protocol.Shadowsocks - or protocol == Protocol.Trojan - ): - return ';'.join( - list( - f'{server["port"]}' - for server in outbound['settings']['servers'] - ) - ) - - return '' - - if ( - Intellisense.getCoreType(ob) == Hysteria1.name() - or Intellisense.getCoreType(ob) == Hysteria2.name() - ): - server = ob['server'] - - pos = server.rfind(':') - - if pos == -1: - return '' - else: - return server[pos + 1 :] - - return '' - except Exception: - # Any non-exit exceptions - - return '' - - @staticmethod - def getCoreTransport(ob): - try: - if Intellisense.getCoreType(ob) == XrayCore.name(): - for outbound in ob['outbounds']: - if outbound['tag'] == 'proxy': - return outbound['streamSettings']['network'] - - return '' - - return '' - except Exception: - # Any non-exit exceptions - - return '' - - @staticmethod - def getCoreTLS(ob): - try: - if Intellisense.getCoreType(ob) == XrayCore.name(): - for outbound in ob['outbounds']: - if outbound['tag'] == 'proxy': - return outbound['streamSettings']['security'] - - return '' - - return '' - except Exception: - # Any non-exit exceptions - - return '' diff --git a/Furious/Core/TorRelay.py b/Furious/Core/TorRelay.py deleted file mode 100644 index 7ce056a..0000000 --- a/Furious/Core/TorRelay.py +++ /dev/null @@ -1,190 +0,0 @@ -# Copyright (C) 2023 Loren Eteval -# -# This file is part of Furious. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -from Furious.Core.Core import Core -from Furious.Utility.Constants import ( - APP, - PLATFORM, - APPLICATION_NAME, - DEFAULT_TOR_SOCKS_PORT, - DEFAULT_TOR_HTTPS_PORT, - LogType, -) -from Furious.Utility.Utility import AsyncSubprocessMessage, runCommand - -from PySide6 import QtCore - -import os -import re -import signal -import logging -import tempfile -import threading -import functools -import subprocess - -logger = logging.getLogger(__name__) - - -class TorRelayStarter(AsyncSubprocessMessage): - BOOTSTRAP_STATUS = re.compile(r'Bootstrapped ([0-9]+)%') - TORDATA_TEMP_DIR = QtCore.QTemporaryDir() - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.connectTimeoutCallback(self.processLine) - - self.torRelay = None - - self.bootstrapPercentage = 0 - - @property - def torRelayStorageObj(self): - # Handy reference - return APP().TorRelayWidget.StorageObj - - @staticmethod - @functools.lru_cache(None) - def getTorDataTempFilepath(): - if TorRelayStarter.TORDATA_TEMP_DIR.isValid(): - return TorRelayStarter.TORDATA_TEMP_DIR.path() - else: - return os.path.join(tempfile.gettempdir(), APPLICATION_NAME, 'tordata') - - def launch(self, proxyServer): - if PLATFORM == 'Windows': - self.torRelay = subprocess.Popen( - ['tor', '-f', '-'], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - creationflags=subprocess.CREATE_NO_WINDOW, - ) - else: - self.torRelay = subprocess.Popen( - ['tor', '-f', '-'], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - - torDataTempFilepath = TorRelayStarter.getTorDataTempFilepath() - - logger.info(f'Tor CLI data directory is {torDataTempFilepath}') - - torConfig = ( - f'SocksPort {self.torRelayStorageObj.get("socksTunnelPort", DEFAULT_TOR_SOCKS_PORT)}\n' - f'HTTPTunnelPort {self.torRelayStorageObj.get("httpsTunnelPort", DEFAULT_TOR_HTTPS_PORT)}\n' - f'Log {self.torRelayStorageObj.get("logLevel", "notice")} stdout\n' - f'DataDirectory {torDataTempFilepath}\n' - ) - - if proxyServer: - logger.info(f'{TorRelay.name()} uses proxy server {proxyServer}') - - # Use proxy - self.torRelay.stdin.write(f'{torConfig}HTTPSProxy {proxyServer}\n'.encode()) - else: - self.torRelay.stdin.write(torConfig.encode()) - - self.torRelay.stdin.close() - - self.startDaemonThread(self.torRelay.stdout) - # Begin get message - self.startTimer() - - @QtCore.Slot() - def processLine(self): - line = self.getLineNoWait() - - if line: - APP().logViewerWidget.appendLog(LogType.Tor, line) - - match = TorRelayStarter.BOOTSTRAP_STATUS.search(line) - - if match: - percentage = int(match.group(1)) - - self.bootstrapPercentage = percentage - - logger.info(f'{TorRelay.name()} bootstrapped {percentage}%') - - -@functools.lru_cache(None) -def getTorRelayVersion(): - try: - result = runCommand( - ['tor', '--version'], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - check=True, - ) - - # First line, 3rd param... - return result.stdout.decode().split('\n')[0].split()[2] - except Exception: - # Any non-exit exceptions - - return TorRelay.VERSION_NOT_FOUND - - -class TorRelay(Core): - VERSION_NOT_FOUND = '0.0.0' - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.starter = TorRelayStarter() - - @staticmethod - @functools.lru_cache(None) - def checkIfExists(): - return TorRelay.version() != TorRelay.VERSION_NOT_FOUND - - @staticmethod - def name(): - return 'Tor Relay' - - @staticmethod - def version(): - return getTorRelayVersion() - - @property - def bootstrapPercentage(self): - return self.starter.bootstrapPercentage - - def start(self, *args, **kwargs): - logger.info(f'{self.name()} {self.version()} started') - - self.starter.launch(kwargs.get('proxyServer', '')) - - def stop(self): - if isinstance(self.starter.torRelay, subprocess.Popen): - # Stop timer - self.starter.stopTimer() - - # Terminated by signal SIGTERM - self.starter.torRelay.send_signal(signal.SIGTERM) - - exitcode = self.starter.torRelay.wait() - - logger.info(f'{self.name()} terminated with exitcode {exitcode}') - - self.starter.bootstrapPercentage = 0 - # Reset relay - self.starter.torRelay = None diff --git a/Furious/Core/Tun2socks.py b/Furious/Core/Tun2socks.py index 16dde31..1bfef80 100644 --- a/Furious/Core/Tun2socks.py +++ b/Furious/Core/Tun2socks.py @@ -1,4 +1,4 @@ -# Copyright (C) 2023 Loren Eteval +# Copyright (C) 2024 Loren Eteval # # This file is part of Furious. # @@ -15,38 +15,53 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from Furious.Core.Core import Core -from Furious.Utility.RoutingTable import RoutingTable +from __future__ import annotations -import functools +from Furious.Interface import * +from Furious.QtFramework import * +from Furious.Library import * +from Furious.Utility import * + +import time +import multiprocessing + +__all__ = ['Tun2socks'] -def startTun2socks(*args, **kwargs): +def startTun2socks(msgQueue: multiprocessing.Queue, *args): try: import tun2socks except ImportError: # Fake running process while True: - pass + time.sleep(1) else: - tun2socks.startFromArgs(*args, **kwargs) + if tun2socks.__version__ <= '2.5.1.1': + redirect = False + else: + redirect = True + + StdoutRedirectHelper.launch( + msgQueue, lambda: tun2socks.startFromArgs(*args), redirect + ) -class Tun2socks(Core): +class Tun2socks(CoreProcess): class ExitCode: # Windows shutting down SystemShuttingDown = 0x40010004 - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + def __init__(self, **kwargs): + super().__init__(**kwargs) + + self.cleanup = None @staticmethod - def name(): + def name() -> str: return 'Tun2socks' @staticmethod - @functools.lru_cache(None) - def version(): + def version() -> str: try: import tun2socks @@ -56,14 +71,25 @@ class Tun2socks(Core): return '0.0.0' - def start(self, device, networkInterface, logLevel, proxy, restAPI, **kwargs): - super().start( + def start( + self, + device: str, + networkInterface: str, + logLevel: str, + proxy: str, + restAPI: str, + **kwargs, + ) -> bool: + return super().start( target=startTun2socks, - args=(device, networkInterface, logLevel, proxy, restAPI), + args=(self.msgQueue, device, networkInterface, logLevel, proxy, restAPI), **kwargs, ) def stop(self): - RoutingTable.deleteRelations() + SystemRoutingTable.deleteRelations() + + if callable(self.cleanup): + self.cleanup() super().stop() diff --git a/Furious/Core/XrayCore.py b/Furious/Core/XrayCore.py new file mode 100644 index 0000000..f30df11 --- /dev/null +++ b/Furious/Core/XrayCore.py @@ -0,0 +1,167 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from __future__ import annotations + +from Furious.Interface import * +from Furious.QtFramework import * +from Furious.Library import * +from Furious.Utility import * + +from typing import Union + +import io +import time +import threading +import multiprocessing + +__all__ = ['XrayCore'] + + +def startXrayCore(jsonString: str, msgQueue: multiprocessing.Queue): + try: + import xray + except ImportError: + # Fake running process + while True: + time.sleep(1) + else: + if xray.__version__ <= '1.8.4': + redirect = False + else: + # Can be redirected + redirect = True + + if not isPythonw(): + return StdoutRedirectHelper.launch( + msgQueue, lambda: xray.startFromJSON(jsonString), redirect + ) + + if not redirect: + return xray.startFromJSON(jsonString) + + def xrayPythonwProduceMsg(): + try: + jsonObject = UJSONEncoder.decode(jsonString) + except Exception: + # Any non-exit exceptions + + xray.startFromJSON(jsonString) + else: + loggingPath = [] + fileStreams = [] + + for loggingAttr in ['access', 'error']: + try: + path = jsonObject['log'][loggingAttr] + except Exception: + # Any non-exit exceptions + + continue + + if path not in loggingPath: + loggingPath.append(path) + + try: + stream = open(path, 'rb') + except Exception: + # Any non-exit exceptions + + pass + else: + stream.seek(0, io.SEEK_END) + + fileStreams.append(stream) + + def produceMsg(): + while True: + for file in fileStreams: + for line in iter(file.readline, b''): + if line and not line.isspace(): + try: + msgQueue.put_nowait( + line.decode('utf-8', 'replace') + ) + except Exception: + # Any non-exit exceptions + + pass + + time.sleep(CoreProcess.MESG_PRODUCE_THRESHOLD / 1000) + + try: + if fileStreams: + msgThread = threading.Thread(target=produceMsg, daemon=True) + msgThread.start() + + xray.startFromJSON(jsonString) + finally: + for stream in fileStreams: + stream.close() + + xrayPythonwProduceMsg() + + +class XrayCore(CoreProcess): + class ExitCode: + ConfigurationError = 23 + # Windows: 4294967295. Darwin, Linux: 255 (-1) + ServerStartFailure = 4294967295 if PLATFORM == 'Windows' else 255 + # Windows shutting down + SystemShuttingDown = 0x40010004 + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + @staticmethod + def name() -> str: + return 'Xray-core' + + @staticmethod + def version() -> str: + try: + import xray + + return xray.__version__ + except Exception: + # Any non-exit exceptions + + return '0.0.0' + + def startFromArgs(self, jsonString: str, **kwargs) -> bool: + self.registerCurrentJSONConfig(jsonString) + + return super().start( + target=startXrayCore, args=(jsonString, self.msgQueue), **kwargs + ) + + def start(self, config: Union[str, dict], **kwargs) -> bool: + if isinstance(config, str): + return self.startFromArgs(config, **kwargs) + elif isinstance(config, ConfigurationFactory): + return self.startFromArgs(config.toJSONString(), **kwargs) + elif isinstance(config, dict): + try: + jsonString = UJSONEncoder.encode(config) + except Exception: + # Any non-exit exceptions + + return False + else: + return self.startFromArgs(jsonString, **kwargs) + else: + return False diff --git a/Furious/Core/__init__.py b/Furious/Core/__init__.py index 52b2594..66a3968 100644 --- a/Furious/Core/__init__.py +++ b/Furious/Core/__init__.py @@ -1,4 +1,4 @@ -# Copyright (C) 2023 Loren Eteval +# Copyright (C) 2024 Loren Eteval # # This file is part of Furious. # @@ -15,3 +15,8 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +from .XrayCore import * +from .Hysteria1 import * +from .Hysteria2 import * +from .Tun2socks import * +from .CoreManager import * diff --git a/Furious/Externals/GenTranslation.py b/Furious/Externals/GenTranslation.py new file mode 100644 index 0000000..f7e3276 --- /dev/null +++ b/Furious/Externals/GenTranslation.py @@ -0,0 +1,1025 @@ +TRANSLATION = { + "Delete": { + "source": [ + "Furious.QtFramework.QtWidgets", + "Furious.Widget.UserServersQTableWidget", + "Furious.Widget.UserSubsQTableWidget", + "Furious.Window.UserSubsWindow", + "Furious.Widget.XrayAssetViewerQListWidget" + ], + "ZH": "删除", + "isReviewed": "True" + }, + "Delete these items?": { + "source": [ + "Furious.QtFramework.QtWidgets" + ], + "ZH": "删除这些项目?", + "isReviewed": "True" + }, + "Delete this item?": { + "source": [ + "Furious.QtFramework.QtWidgets" + ], + "ZH": "删除此项目?", + "isReviewed": "True" + }, + "New changes will take effect next time": { + "source": [ + "Furious.QtFramework.QtWidgets" + ], + "ZH": "新的更改将于下次生效", + "isReviewed": "True" + }, + "New version available": { + "source": [ + "Furious.QtFramework.UpdatesManager" + ], + "ZH": "新版本可用", + "isReviewed": "True" + }, + "Check for updates failed": { + "source": [ + "Furious.QtFramework.UpdatesManager" + ], + "ZH": "检查更新失败", + "isReviewed": "True" + }, + "Go to download page?": { + "source": [ + "Furious.QtFramework.UpdatesManager" + ], + "ZH": "前往下载页面?", + "isReviewed": "True" + }, + "Furious is already the latest version": { + "source": [ + "Furious.QtFramework.UpdatesManager" + ], + "ZH": "Furious已是最新版本", + "isReviewed": "True" + }, + "Invalid data": { + "source": [ + "Furious.TrayActions.Import" + ], + "ZH": "无效的数据", + "isReviewed": "True" + }, + "Invalid data. The content of the clipboard is:": { + "source": [ + "Furious.TrayActions.Import" + ], + "ZH": "无效的数据。剪贴板内容为", + "isReviewed": "True" + }, + "Import": { + "source": [ + "Furious.TrayActions.Import", + "Furious.Widget.XrayAssetViewerQListWidget" + ], + "ZH": "导入", + "isReviewed": "True" + }, + "Import share link success": { + "source": [ + "Furious.TrayActions.Import" + ], + "ZH": "导入分享链接成功", + "isReviewed": "True" + }, + "Imported to row": { + "source": [ + "Furious.TrayActions.Import" + ], + "ZH": "导入至行", + "isReviewed": "True" + }, + "Import success": { + "source": [ + "Furious.TrayActions.Import" + ], + "ZH": "导入成功", + "isReviewed": "True" + }, + "Import From File...": { + "source": [ + "Furious.TrayActions.Import", + "Furious.Window.XrayAssetViewerWindow" + ], + "ZH": "从文件导入...", + "isReviewed": "True" + }, + "Import File": { + "source": [ + "Furious.TrayActions.Import", + "Furious.Window.XrayAssetViewerWindow" + ], + "ZH": "导入文件", + "isReviewed": "True" + }, + "Text files (*.json);;All files (*)": { + "source": [ + "Furious.TrayActions.Import", + "Furious.Window.TextEditorWindow" + ], + "ZH": "文本文件 (*.json);;所有文件 (*)", + "isReviewed": "True" + }, + "Error opening file": { + "source": [ + "Furious.TrayActions.Import" + ], + "ZH": "打开文件出错", + "isReviewed": "True" + }, + "Invalid configuration file": { + "source": [ + "Furious.TrayActions.Import" + ], + "ZH": "无效的配置文件", + "isReviewed": "True" + }, + "Import Share Link From Clipboard": { + "source": [ + "Furious.TrayActions.Import" + ], + "ZH": "从剪贴板导入分享链接", + "isReviewed": "True" + }, + "Import JSON Configuration From Clipboard": { + "source": [ + "Furious.TrayActions.Import" + ], + "ZH": "从剪贴板导入JSON配置", + "isReviewed": "True" + }, + "Set Indent": { + "source": [ + "Furious.Widget.IndentSpinBox" + ], + "ZH": "设置缩进", + "isReviewed": "True" + }, + "Indent:": { + "source": [ + "Furious.Widget.IndentSpinBox" + ], + "ZH": "缩进:", + "isReviewed": "True" + }, + "Cancel": { + "source": [ + "Furious.Widget.IndentSpinBox", + "Furious.Window.TextEditorWindow", + "Furious.Window.UserSubsWindow" + ], + "ZH": "取消", + "isReviewed": "True" + }, + "Save Changes": { + "source": [ + "Furious.Window.TextEditorWindow" + ], + "ZH": "保存更改", + "isReviewed": "True" + }, + "The content has been modified. Save changes?": { + "source": [ + "Furious.Window.TextEditorWindow" + ], + "ZH": "内容已修改。保存更改?", + "isReviewed": "True" + }, + "Save": { + "source": [ + "Furious.Window.TextEditorWindow" + ], + "ZH": "保存", + "isReviewed": "True" + }, + "Discard": { + "source": [ + "Furious.Window.TextEditorWindow" + ], + "ZH": "丢弃", + "isReviewed": "True" + }, + "Please check if the configuration is in valid JSON format": { + "source": [ + "Furious.Window.TextEditorWindow" + ], + "ZH": "请检查配置是否为有效的JSON格式", + "isReviewed": "True" + }, + "Save As...": { + "source": [ + "Furious.Window.TextEditorWindow", + "Furious.Window.LogViewerWindow" + ], + "ZH": "另存为...", + "isReviewed": "True" + }, + "Exit": { + "source": [ + "Furious.Window.TextEditorWindow", + "Furious.Window.LogViewerWindow", + "Furious.Window.XrayAssetViewerWindow", + "Furious.TrayActions.Exit" + ], + "ZH": "退出", + "isReviewed": "True" + }, + "File": { + "source": [ + "Furious.Window.TextEditorWindow", + "Furious.Window.LogViewerWindow", + "Furious.Window.XrayAssetViewerWindow" + ], + "ZH": "文件", + "isReviewed": "True" + }, + "Undo": { + "source": [ + "Furious.Window.TextEditorWindow" + ], + "ZH": "撤销", + "isReviewed": "True" + }, + "Redo": { + "source": [ + "Furious.Window.TextEditorWindow" + ], + "ZH": "重做", + "isReviewed": "True" + }, + "Cut": { + "source": [ + "Furious.Window.TextEditorWindow" + ], + "ZH": "剪切", + "isReviewed": "True" + }, + "Copy": { + "source": [ + "Furious.Window.TextEditorWindow", + "Furious.Window.LogViewerWindow" + ], + "ZH": "复制", + "isReviewed": "True" + }, + "Paste": { + "source": [ + "Furious.Window.TextEditorWindow" + ], + "ZH": "粘贴", + "isReviewed": "True" + }, + "Select All": { + "source": [ + "Furious.Window.TextEditorWindow", + "Furious.Widget.UserServersQTableWidget", + "Furious.Window.LogViewerWindow" + ], + "ZH": "全选", + "isReviewed": "True" + }, + "Indent...": { + "source": [ + "Furious.Window.TextEditorWindow" + ], + "ZH": "缩进...", + "isReviewed": "True" + }, + "Edit": { + "source": [ + "Furious.Window.TextEditorWindow", + "Furious.Window.LogViewerWindow" + ], + "ZH": "编辑", + "isReviewed": "True" + }, + "Zoom In": { + "source": [ + "Furious.Window.TextEditorWindow", + "Furious.Window.LogViewerWindow" + ], + "ZH": "变大", + "isReviewed": "True" + }, + "Zoom Out": { + "source": [ + "Furious.Window.TextEditorWindow", + "Furious.Window.LogViewerWindow" + ], + "ZH": "变小", + "isReviewed": "True" + }, + "View": { + "source": [ + "Furious.Window.TextEditorWindow", + "Furious.Window.LogViewerWindow" + ], + "ZH": "显示", + "isReviewed": "True" + }, + "Error saving configuration": { + "source": [ + "Furious.Window.TextEditorWindow" + ], + "ZH": "保存配置出错", + "isReviewed": "True" + }, + "Save File": { + "source": [ + "Furious.Window.TextEditorWindow", + "Furious.Window.LogViewerWindow" + ], + "ZH": "保存文件", + "isReviewed": "True" + }, + "Error Saving File": { + "source": [ + "Furious.Window.TextEditorWindow" + ], + "ZH": "保存文件出错", + "isReviewed": "True" + }, + "Invalid server configuration": { + "source": [ + "Furious.Window.TextEditorWindow", + "Furious.TrayActions.Connect" + ], + "ZH": "无效的服务器配置", + "isReviewed": "True" + }, + "Error setting indent": { + "source": [ + "Furious.Window.TextEditorWindow" + ], + "ZH": "设置缩进出错", + "isReviewed": "True" + }, + "Remark": { + "source": [ + "Furious.Widget.UserServersQTableWidget", + "Furious.Widget.UserSubsQTableWidget" + ], + "ZH": "别名", + "isReviewed": "True" + }, + "Protocol": { + "source": [ + "Furious.Widget.UserServersQTableWidget" + ], + "ZH": "协议", + "isReviewed": "True" + }, + "Address": { + "source": [ + "Furious.Widget.UserServersQTableWidget" + ], + "ZH": "地址", + "isReviewed": "True" + }, + "Port": { + "source": [ + "Furious.Widget.UserServersQTableWidget" + ], + "ZH": "端口", + "isReviewed": "True" + }, + "Transport": { + "source": [ + "Furious.Widget.UserServersQTableWidget" + ], + "ZH": "传输方式", + "isReviewed": "True" + }, + "TLS": { + "source": [ + "Furious.Widget.UserServersQTableWidget" + ], + "ZH": "TLS", + "isReviewed": "True" + }, + "Subscription": { + "source": [ + "Furious.Widget.UserServersQTableWidget", + "Furious.Window.AppMainWindow" + ], + "ZH": "订阅", + "isReviewed": "True" + }, + "Latency": { + "source": [ + "Furious.Widget.UserServersQTableWidget" + ], + "ZH": "延迟", + "isReviewed": "True" + }, + "Speed": { + "source": [ + "Furious.Widget.UserServersQTableWidget" + ], + "ZH": "速度", + "isReviewed": "True" + }, + "Edit Configuration...": { + "source": [ + "Furious.Widget.UserServersQTableWidget", + "Furious.TrayActions.EditConfiguration" + ], + "ZH": "编辑配置...", + "isReviewed": "True" + }, + "Move Up": { + "source": [ + "Furious.Widget.UserServersQTableWidget" + ], + "ZH": "上移", + "isReviewed": "True" + }, + "Move Down": { + "source": [ + "Furious.Widget.UserServersQTableWidget" + ], + "ZH": "下移", + "isReviewed": "True" + }, + "Duplicate": { + "source": [ + "Furious.Widget.UserServersQTableWidget" + ], + "ZH": "克隆", + "isReviewed": "True" + }, + "Scroll To Activated Server": { + "source": [ + "Furious.Widget.UserServersQTableWidget" + ], + "ZH": "滚动到激活的服务器", + "isReviewed": "True" + }, + "Test Ping Latency": { + "source": [ + "Furious.Widget.UserServersQTableWidget" + ], + "ZH": "测试Ping延迟", + "isReviewed": "True" + }, + "Test Download Speed": { + "source": [ + "Furious.Widget.UserServersQTableWidget" + ], + "ZH": "测试下行速度", + "isReviewed": "True" + }, + "Clear Test Results": { + "source": [ + "Furious.Widget.UserServersQTableWidget" + ], + "ZH": "清除测试结果", + "isReviewed": "True" + }, + "New Empty Configuration": { + "source": [ + "Furious.Widget.UserServersQTableWidget" + ], + "ZH": "新建空白配置", + "isReviewed": "True" + }, + "Export Share Link To Clipboard": { + "source": [ + "Furious.Widget.UserServersQTableWidget" + ], + "ZH": "导出分享链接至剪贴板", + "isReviewed": "True" + }, + "Export As QR Code": { + "source": [ + "Furious.Widget.UserServersQTableWidget" + ], + "ZH": "导出为二维码", + "isReviewed": "True" + }, + "Export JSON Configuration To Clipboard": { + "source": [ + "Furious.Widget.UserServersQTableWidget" + ], + "ZH": "导出JSON配置至剪贴板", + "isReviewed": "True" + }, + "Connecting": { + "source": [ + "Furious.Widget.UserServersQTableWidget", + "Furious.TrayActions.Connect" + ], + "ZH": "正在连接", + "isReviewed": "True" + }, + "Connecting. Please wait": { + "source": [ + "Furious.Widget.UserServersQTableWidget" + ], + "ZH": "正在连接。请稍候", + "isReviewed": "True" + }, + "Untitled": { + "source": [ + "Furious.Widget.UserServersQTableWidget" + ], + "ZH": "未命名", + "isReviewed": "True" + }, + "Add Subscription": { + "source": [ + "Furious.Window.UserSubsWindow" + ], + "ZH": "添加订阅", + "isReviewed": "True" + }, + "Enter subscription remark:": { + "source": [ + "Furious.Window.UserSubsWindow" + ], + "ZH": "输入订阅名称:", + "isReviewed": "True" + }, + "Enter subscription URL:": { + "source": [ + "Furious.Window.UserSubsWindow" + ], + "ZH": "输入订阅URL:", + "isReviewed": "True" + }, + "Edit Subscription": { + "source": [ + "Furious.Window.UserSubsWindow" + ], + "ZH": "编辑订阅", + "isReviewed": "True" + }, + "Subscription List": { + "source": [ + "Furious.Window.UserSubsWindow" + ], + "ZH": "订阅列表", + "isReviewed": "True" + }, + "Add": { + "source": [ + "Furious.Window.UserSubsWindow" + ], + "ZH": "添加", + "isReviewed": "True" + }, + "Unable to save log": { + "source": [ + "Furious.Window.LogViewerWindow" + ], + "ZH": "无法保存日志", + "isReviewed": "True" + }, + "Text files (*.txt);;All files (*)": { + "source": [ + "Furious.Window.LogViewerWindow" + ], + "ZH": "文本文件 (*.txt);;所有文件 (*)", + "isReviewed": "True" + }, + "Error saving log": { + "source": [ + "Furious.Window.LogViewerWindow" + ], + "ZH": "保存日志出错", + "isReviewed": "True" + }, + "Log Viewer": { + "source": [ + "Furious.Window.LogViewerWindow" + ], + "ZH": "日志查看器", + "isReviewed": "True" + }, + "Asset file already exists. Overwrite?": { + "source": [ + "Furious.Widget.XrayAssetViewerQListWidget" + ], + "ZH": "资源文件已存在。是否覆盖?", + "isReviewed": "True" + }, + "Error import asset file": { + "source": [ + "Furious.Widget.XrayAssetViewerQListWidget" + ], + "ZH": "导入资源文件出错", + "isReviewed": "True" + }, + "Import asset file success": { + "source": [ + "Furious.Widget.XrayAssetViewerQListWidget" + ], + "ZH": "导入资源文件成功", + "isReviewed": "True" + }, + "Xray-core Asset File": { + "source": [ + "Furious.Window.XrayAssetViewerWindow" + ], + "ZH": "Xray-core资源文件", + "isReviewed": "True" + }, + "Refresh": { + "source": [ + "Furious.Window.XrayAssetViewerWindow" + ], + "ZH": "刷新", + "isReviewed": "True" + }, + "Open Asset Directory": { + "source": [ + "Furious.Window.XrayAssetViewerWindow" + ], + "ZH": "打开资源目录", + "isReviewed": "True" + }, + "All files (*)": { + "source": [ + "Furious.Window.XrayAssetViewerWindow" + ], + "ZH": "所有文件 (*)", + "isReviewed": "True" + }, + "Server": { + "source": [ + "Furious.Window.AppMainWindow" + ], + "ZH": "服务器", + "isReviewed": "True" + }, + "Log": { + "source": [ + "Furious.Window.AppMainWindow" + ], + "ZH": "日志", + "isReviewed": "True" + }, + "Show Furious Log": { + "source": [ + "Furious.Window.AppMainWindow" + ], + "ZH": "显示Furious日志", + "isReviewed": "True" + }, + "Show Core Log": { + "source": [ + "Furious.Window.AppMainWindow" + ], + "ZH": "显示内核日志", + "isReviewed": "True" + }, + "Show Tun2socks Log": { + "source": [ + "Furious.Window.AppMainWindow" + ], + "ZH": "显示Tun2socks日志", + "isReviewed": "True" + }, + "Update Subscription (Use Current Proxy)": { + "source": [ + "Furious.Window.AppMainWindow" + ], + "ZH": "更新订阅(使用当前代理)", + "isReviewed": "True" + }, + "Update Subscription (Force Proxy)": { + "source": [ + "Furious.Window.AppMainWindow" + ], + "ZH": "更新订阅(强制代理)", + "isReviewed": "True" + }, + "Update Subscription (No Proxy)": { + "source": [ + "Furious.Window.AppMainWindow" + ], + "ZH": "更新订阅(无代理)", + "isReviewed": "True" + }, + "Edit Subscription...": { + "source": [ + "Furious.Window.AppMainWindow" + ], + "ZH": "编辑订阅...", + "isReviewed": "True" + }, + "Tools": { + "source": [ + "Furious.Window.AppMainWindow" + ], + "ZH": "工具", + "isReviewed": "True" + }, + "Manage Xray-core Asset File...": { + "source": [ + "Furious.Window.AppMainWindow" + ], + "ZH": "管理Xray-core资源文件...", + "isReviewed": "True" + }, + "Check For Updates": { + "source": [ + "Furious.Window.AppMainWindow" + ], + "ZH": "检查更新", + "isReviewed": "True" + }, + "About": { + "source": [ + "Furious.Window.AppMainWindow" + ], + "ZH": "关于", + "isReviewed": "True" + }, + "Already started": { + "source": [ + "Furious.Widget.Application" + ], + "ZH": "已启动", + "isReviewed": "True" + }, + "Furious Log": { + "source": [ + "Furious.Widget.Application" + ], + "ZH": "Furious日志", + "isReviewed": "True" + }, + "Core Log": { + "source": [ + "Furious.Widget.Application" + ], + "ZH": "内核日志", + "isReviewed": "True" + }, + "Tun2socks Log": { + "source": [ + "Furious.Widget.Application" + ], + "ZH": "Tun2socks日志", + "isReviewed": "True" + }, + "Connect": { + "source": [ + "Furious.TrayActions.Connect" + ], + "ZH": "连接", + "isReviewed": "True" + }, + "Disconnect": { + "source": [ + "Furious.TrayActions.Connect" + ], + "ZH": "断开连接", + "isReviewed": "True" + }, + "Unable to connect": { + "source": [ + "Furious.TrayActions.Connect" + ], + "ZH": "无法连接", + "isReviewed": "True" + }, + "Server configuration empty. Please configure your server first": { + "source": [ + "Furious.TrayActions.Connect" + ], + "ZH": "服务器配置为空。请先配置服务器", + "isReviewed": "True" + }, + "Select and double click to activate configuration and connect": { + "source": [ + "Furious.TrayActions.Connect" + ], + "ZH": "选择并双击以激活配置并连接", + "isReviewed": "True" + }, + "Connected": { + "source": [ + "Furious.TrayActions.Connect" + ], + "ZH": "已连接", + "isReviewed": "True" + }, + "Unknown error": { + "source": [ + "Furious.TrayActions.Connect" + ], + "ZH": "未知错误", + "isReviewed": "True" + }, + "Failed to start core": { + "source": [ + "Furious.TrayActions.Connect" + ], + "ZH": "内核启动失败", + "isReviewed": "True" + }, + "Connection to server has been lost": { + "source": [ + "Furious.TrayActions.Connect" + ], + "ZH": "与服务器的连接已丢失", + "isReviewed": "True" + }, + "Core terminated unexpectedly": { + "source": [ + "Furious.TrayActions.Connect" + ], + "ZH": "内核意外终止", + "isReviewed": "True" + }, + "Disconnected": { + "source": [ + "Furious.TrayActions.Connect" + ], + "ZH": "已断开", + "isReviewed": "True" + }, + "Language": { + "source": [ + "Furious.TrayActions.Language" + ], + "ZH": "语言", + "isReviewed": "True" + }, + "Routing": { + "source": [ + "Furious.TrayActions.Routing" + ], + "ZH": "路由", + "isReviewed": "True" + }, + "VPN Mode": { + "source": [ + "Furious.TrayActions.Settings" + ], + "ZH": "VPN模式", + "isReviewed": "True" + }, + "VPN Mode Disabled (Administrator)": { + "source": [ + "Furious.TrayActions.Settings" + ], + "ZH": "VPN模式已禁用(管理员)", + "isReviewed": "True" + }, + "VPN Mode Disabled (root)": { + "source": [ + "Furious.TrayActions.Settings" + ], + "ZH": "VPN模式已禁用(root)", + "isReviewed": "True" + }, + "Settings": { + "source": [ + "Furious.TrayActions.Settings" + ], + "ZH": "设置", + "isReviewed": "True" + }, + "Dark Mode": { + "source": [ + "Furious.TrayActions.Settings" + ], + "ZH": "深色模式", + "isReviewed": "True" + }, + "Startup On Boot": { + "source": [ + "Furious.TrayActions.Settings" + ], + "ZH": "开机启动", + "isReviewed": "True" + }, + "Show Progress Bar When Connecting": { + "source": [ + "Furious.TrayActions.Settings" + ], + "ZH": "连接时显示进度条", + "isReviewed": "True" + }, + "Show Tab And Spaces In Editor": { + "source": [ + "Furious.TrayActions.Settings" + ], + "ZH": "在编辑器中显示制表符和空格", + "isReviewed": "True" + }, + "System Proxy": { + "source": [ + "Furious.TrayActions.SystemProxy" + ], + "ZH": "系统代理", + "isReviewed": "True" + }, + "Automatically Configure System Proxy": { + "source": [ + "Furious.TrayActions.SystemProxy" + ], + "ZH": "自动配置系统代理", + "isReviewed": "True" + }, + "Do Not Change System Proxy": { + "source": [ + "Furious.TrayActions.SystemProxy" + ], + "ZH": "不修改系统代理", + "isReviewed": "True" + }, + "Furious is not able to run on this operating system": { + "source": [ + "Furious.__main__" + ], + "ZH": "Furious无法在此操作系统上运行", + "isReviewed": "True" + }, + "Operating system information": { + "source": [ + "Furious.__main__" + ], + "ZH": "操作系统信息", + "isReviewed": "True" + }, + "Furious encountered an internal error and needs to be stopped": { + "source": [ + "Furious.__main__" + ], + "ZH": "Furious遇到内部错误,需要终止", + "isReviewed": "True" + }, + "Furious stopped unexpectedly due to an unknown exception": { + "source": [ + "Furious.__main__" + ], + "ZH": "由于一个未知异常,Furious意外终止", + "isReviewed": "True" + }, + "Crash log has been saved to": { + "source": [ + "Furious.__main__" + ], + "ZH": "崩溃日志已保存到", + "isReviewed": "True" + }, + "Open crash log": { + "source": [ + "Furious.__main__" + ], + "ZH": "打开崩溃日志", + "isReviewed": "True" + }, + "Bypass Mainland China": { + "source": [ + "Furious.TrayActions.Routing" + ], + "ZH": "绕过中国大陆", + "isReviewed": "True" + }, + "Global": { + "source": [ + "Furious.TrayActions.Routing" + ], + "ZH": "全球", + "isReviewed": "True" + }, + "Custom": { + "source": [ + "Furious.TrayActions.Routing" + ], + "ZH": "自定义", + "isReviewed": "True" + }, + "Furious cannot find any valid http proxy endpoint in the configuration": { + "source": [ + "Furious.TrayActions.Connect" + ], + "ZH": "Furious在配置中无法找到任何有效的http代理端点", + "isReviewed": "True" + }, + "Please complete your server configuration": { + "source": [ + "Furious.TrayActions.Connect" + ], + "ZH": "请完善服务器配置", + "isReviewed": "True" + }, + "Help": { + "source": [ + "Furious.Window.AppMainWindow" + ], + "ZH": "帮助", + "isReviewed": "True" + } +} diff --git a/Furious/Action/__init__.py b/Furious/Externals/__init__.py similarity index 67% rename from Furious/Action/__init__.py rename to Furious/Externals/__init__.py index d184d9e..056e0f1 100644 --- a/Furious/Action/__init__.py +++ b/Furious/Externals/__init__.py @@ -1,4 +1,4 @@ -# Copyright (C) 2023 Loren Eteval +# Copyright (C) 2024 Loren Eteval # # This file is part of Furious. # @@ -15,10 +15,4 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from .Connect import ConnectAction -from .EditConfiguration import EditConfigurationAction -from .Exit import ExitAction -from .Import import ImportAction -from .Language import LanguageAction -from .Routing import RoutingAction -from .Settings import SettingsAction +from .GenTranslation import * diff --git a/Furious/Interface/Application.py b/Furious/Interface/Application.py new file mode 100644 index 0000000..d424684 --- /dev/null +++ b/Furious/Interface/Application.py @@ -0,0 +1,34 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from __future__ import annotations + +__all__ = ['ApplicationFactory'] + + +class ApplicationFactory: + class ExitCode: + ExitSuccess = 0 + UnknownException = 1 + PlatformNotSupported = 2 + AssertionError = 3 + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def run(self): + raise NotImplementedError diff --git a/Furious/Interface/ConfigurationFactory.py b/Furious/Interface/ConfigurationFactory.py new file mode 100644 index 0000000..deecdf6 --- /dev/null +++ b/Furious/Interface/ConfigurationFactory.py @@ -0,0 +1,216 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from __future__ import annotations + +from Furious.Interface.UserServersTableItem import * + +from PySide6.QtWidgets import QApplication + +from abc import ABC +from typing import Union + +import copy +import ujson + +__all__ = ['ConfigurationFactory'] + + +class ConfigurationFactory(UserServersTableItem, dict, ABC): + """ + ConfigurationFactory is how Furious sees the core config. + + It subclasses from dict and can be constructed from: + 1. dictionary -- from existing JSON object + 2. string -- from URI or (valid) JSON string + """ + + def __init__(self, config: Union[str, dict] = '', **kwargs): + """ + Constructs a ConfigurationFactory. The constructor + never throws exception + + :param config: The input configuration. Can be a string or dict + """ + + # Extra attributes + self.kwargs = kwargs + + if isinstance(config, str): + try: + jsonObject = ujson.loads(config) + except Exception: + # Any non-exit exceptions + + try: + self.fromURI(config) + except Exception: + # Any non-exit exceptions + + super().__init__() + else: + super().__init__(**jsonObject) + elif isinstance(config, dict): + super().__init__(**config) + else: + super().__init__() + + def __getitem__(self, item: str): + if not isinstance(item, str): + raise TypeError(f'Bad type {type(item)} for __getitem__ call') + + return super().__getitem__(item) + + def __setitem__(self, item: str, value): + if not isinstance(item, str): + raise TypeError(f'Bad type {type(item)} for __setitem__ call') + + return super().__setitem__(item, value) + + def deepcopy(self) -> ConfigurationFactory: + return copy.deepcopy(self) + + def coreName(self) -> str: + return 'Unknown' + + def isValid(self) -> bool: + return bool(self) + + def getExtras(self, item): + return self.kwargs.get(item, '') + + def setExtras(self, item, value): + self.kwargs[item] = value + + @property + def itemRemark(self) -> str: + return self.getExtras('remark') + + @property + def itemSubscription(self) -> str: + try: + app = QApplication.instance() + + if app is None: + return '' + else: + subsId = self.getExtras('subsId') + subsOb = app.userSubs.data().get(subsId, {}) + + return subsOb.get('remark', '') + except Exception: + # Any non-exit exceptions + + return '' + + @property + def itemLatency(self) -> str: + return self.getExtras('delayResult') + + @property + def itemSpeed(self) -> str: + return self.getExtras('speedResult') + + def toJSONString(self, **kwargs) -> str: + """ + Converts self to a JSON string + + :param kwargs: Keyword arguments for encoder + :return: JSON string + """ + + try: + ensure_ascii = kwargs.pop('ensure_ascii', False) + escape_forward_slashes = kwargs.pop('escape_forward_slashes', False) + indent = kwargs.pop('indent', 4) + + return ujson.dumps( + self, + ensure_ascii=ensure_ascii, + escape_forward_slashes=escape_forward_slashes, + indent=indent, + **kwargs, + ) + except Exception: + # Any non-exit exceptions + + # '' is invalid + return '' + + def toStorageObject(self) -> dict: + if self.kwargs.get('remark') is None: + # compatibility: remark field is mandatory in previous application version + self.kwargs['remark'] = '' + + # self.toJSONString() is used to maintain backward compatibility + return {'config': self.toJSONString(), **self.kwargs} + + def toURI(self, remark: str = '') -> str: + """ + Converts self to a URI string + + :param remark: Remark (fragment) + :return: URI string + """ + + return '' + + def fromURI(self, URI: str) -> bool: + """ + Constructs self from a URI string + + :param URI: URI string + :return: True on success, false otherwise + """ + + return False + + def httpProxyEndpoint(self) -> str: + """ + Get current http proxy endpoint + + :return: Http proxy endpoint string + """ + + return '' + + def socksProxyEndpoint(self) -> str: + """ + Get current socks proxy endpoint + + :return: Socks proxy endpoint string + """ + + return '' + + def setHttpProxyEndpoint(self, endpoint: str) -> bool: + """ + Set current http proxy endpoint + + :return: True on success, false otherwise + """ + + return False + + def setSocksProxyEndpoint(self, endpoint: str) -> bool: + """ + Set current socks proxy endpoint + + :return: True on success, false otherwise + """ + + return False diff --git a/Furious/Interface/CoreFactory.py b/Furious/Interface/CoreFactory.py new file mode 100644 index 0000000..bfd49b3 --- /dev/null +++ b/Furious/Interface/CoreFactory.py @@ -0,0 +1,96 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from __future__ import annotations + +from Furious.Utility.Constants import PLATFORM + +from abc import ABC +from typing import Callable, Union + +import ujson + +__all__ = ['CoreFactory'] + + +class CoreFactory(ABC): + class ExitCode: + ConfigurationError = 23 + # Windows: 4294967295. Darwin, Linux: 255 (-1) + ServerStartFailure = 4294967295 if PLATFORM == 'Windows' else 255 + # Windows shutting down + SystemShuttingDown = 0x40010004 + + def __init__(self, exitCallback: Callable[[CoreFactory, int], None] = None): + self._exitCallback = exitCallback + self._jsonConfig = '' + + def registerExitCallback( + self, exitCallback: Callable[[CoreFactory, int], None] + ) -> CoreFactory: + self._exitCallback = exitCallback + + return self + + def registerCurrentJSONConfig(self, config: Union[str, dict]) -> CoreFactory: + self._jsonConfig = config + + return self + + @staticmethod + def name() -> str: + raise NotImplementedError + + @staticmethod + def version() -> str: + raise NotImplementedError + + def jsonConfigString(self) -> str: + if isinstance(self._jsonConfig, str): + return self._jsonConfig + if isinstance(self._jsonConfig, dict): + try: + return ujson.dumps( + self._jsonConfig, + ensure_ascii=False, + escape_forward_slashes=False, + ) + except Exception: + # Any non-exit exceptions + + return '' + + return '' + + def jsonConfigDict(self) -> dict: + if isinstance(self._jsonConfig, str): + try: + return ujson.loads(self._jsonConfig) + except Exception: + # Any non-exit exceptions + + return {} + if isinstance(self._jsonConfig, dict): + return self._jsonConfig + + return {} + + def start(self, *args, **kwargs) -> bool: + raise NotImplementedError + + def stop(self): + raise NotImplementedError diff --git a/Furious/Interface/Encoder.py b/Furious/Interface/Encoder.py new file mode 100644 index 0000000..d648d3c --- /dev/null +++ b/Furious/Interface/Encoder.py @@ -0,0 +1,31 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from __future__ import annotations + +from abc import ABC +from typing import Any + +__all__ = ['Encoder'] + + +class Encoder(ABC): + def encode(self, data: Any, **kwargs) -> Any: + raise NotImplementedError + + def decode(self, data: Any, **kwargs) -> Any: + raise NotImplementedError diff --git a/Furious/Interface/ItemUpdateProtocol.py b/Furious/Interface/ItemUpdateProtocol.py new file mode 100644 index 0000000..f9fb1fe --- /dev/null +++ b/Furious/Interface/ItemUpdateProtocol.py @@ -0,0 +1,55 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from __future__ import annotations + +from typing import Any, Sequence + +__all__ = ['ItemUpdateProtocol'] + + +class ItemUpdateProtocol: + def __init__(self, sequence: Sequence, currentIndex: int, currentItem: Any): + self.sequence = sequence + self.currentIndex = currentIndex + self.currentItem = currentItem + + def currentItemDeleted(self, *args, **kwargs) -> bool: + return self.currentIndex < 0 or self.currentIndex >= len(self.sequence) + + def updateImpl(self, *args, **kwargs): + raise NotImplementedError + + def updateResult(self): + if self.currentItemDeleted(): + # Deleted. Do nothing + return + + if id(self.sequence[self.currentIndex]) == id(self.currentItem): + self.updateImpl() + else: + # Linear find and update + for index, item in enumerate(self.sequence): + if id(item) == id(self.currentItem): + # Found. Update index + self.currentIndex = index + self.updateImpl() + + # Updated + break + + # Not found. Do nothing diff --git a/Furious/Interface/Storage.py b/Furious/Interface/Storage.py new file mode 100644 index 0000000..3127ac9 --- /dev/null +++ b/Furious/Interface/Storage.py @@ -0,0 +1,31 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from __future__ import annotations + +from abc import ABC +from typing import Any + +__all__ = ['StorageFactory'] + + +class StorageFactory(ABC): + def sync(self): + raise NotImplementedError + + def data(self) -> Any: + raise NotImplementedError diff --git a/Furious/Interface/UserServersTableItem.py b/Furious/Interface/UserServersTableItem.py new file mode 100644 index 0000000..93e07c2 --- /dev/null +++ b/Furious/Interface/UserServersTableItem.py @@ -0,0 +1,63 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from __future__ import annotations + +from abc import ABC + +__all__ = ['UserServersTableItem'] + + +class UserServersTableItem(ABC): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + @property + def itemRemark(self) -> str: + return '' + + @property + def itemProtocol(self) -> str: + return '' + + @property + def itemAddress(self) -> str: + return '' + + @property + def itemPort(self) -> str: + return '' + + @property + def itemTransport(self) -> str: + return '' + + @property + def itemTLS(self) -> str: + return '' + + @property + def itemSubscription(self) -> str: + return '' + + @property + def itemLatency(self) -> str: + return '' + + @property + def itemSpeed(self) -> str: + return '' diff --git a/Furious/Interface/__init__.py b/Furious/Interface/__init__.py new file mode 100644 index 0000000..fff170f --- /dev/null +++ b/Furious/Interface/__init__.py @@ -0,0 +1,24 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from .Encoder import * +from .UserServersTableItem import * +from .ItemUpdateProtocol import * +from .Storage import * +from .Application import * +from .ConfigurationFactory import * +from .CoreFactory import * diff --git a/Furious/Library/Configuration.py b/Furious/Library/Configuration.py new file mode 100644 index 0000000..574a506 --- /dev/null +++ b/Furious/Library/Configuration.py @@ -0,0 +1,1654 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from __future__ import annotations + +from Furious.Interface import * +from Furious.Utility import * +from Furious.Library.Encoder import * + +from typing import Union, Tuple + +import functools +import urllib.parse + +quote = functools.partial(urllib.parse.quote, safe='') +unquote = functools.partial(urllib.parse.unquote) +parse_qsl = functools.partial(urllib.parse.parse_qsl) +urlparse = functools.partial(urllib.parse.urlparse) +urlunparse = functools.partial(urllib.parse.urlunparse) + + +__all__ = [ + 'ConfigurationXray', + 'ConfigurationHysteria1', + 'ConfigurationHysteria2', + 'constructFromDict', + 'constructFromAny', +] + + +class ConfigurationXrayProxyOutboundObjectV(dict): + def __init__( + self, + protocol, + remote_host, + remote_port, + uuid_, + encryption, + type_, + security, + **kwargs, + ): + networkObjectArgs, securityArgs, TLSObjectArgs = {}, {}, {} + + if type_ == 'h2': + networkKey = 'httpSettings' + else: + networkKey = f'{type_}Settings' + + networkObjectArgs[ + networkKey + ] = ConfigurationXray.kwargs2ProxyStreamSettingsNetworkObject( + type_, remote_host, kwargs + ) + + if security: + securityArgs['security'] = security + + if security != 'none': + TLSObjectArgs[ + # tlsSettings, realitySettings + f'{security}Settings' + ] = ConfigurationXray.kwargs2ProxyStreamSettingsTLSObject( + protocol, remote_host, security, kwargs + ) + + super().__init__( + **{ + 'tag': 'proxy', + 'protocol': protocol, + 'settings': { + 'vnext': [ + { + 'address': remote_host, + 'port': int(remote_port), + 'users': [ + ConfigurationXray.kwargs2ProxyUserObject( + protocol, uuid_, encryption, kwargs + ), + ], + }, + ] + }, + 'streamSettings': { + 'network': type_, + **networkObjectArgs, + **securityArgs, + **TLSObjectArgs, + }, + 'mux': { + 'enabled': False, + 'concurrency': -1, + }, + }, + ) + + +class ConfigurationXrayProxyOutboundObjectSS(dict): + def __init__(self, method, password, address, port): + super().__init__( + **{ + 'tag': 'proxy', + 'protocol': 'shadowsocks', + 'settings': { + 'servers': [ + { + 'address': address, + 'port': int(port), + 'method': method, + 'password': password, + 'email': PROXY_OUTBOUND_USER_EMAIL, + 'ota': False, + }, + ] + }, + 'streamSettings': { + 'network': 'tcp', + }, + 'mux': { + 'enabled': False, + 'concurrency': -1, + }, + } + ) + + +class ConfigurationXrayProxyOutboundObjectTrojan(dict): + def __init__(self, password, address, port, type_, security, **kwargs): + networkObjectArgs, securityArgs, TLSObjectArgs = {}, {}, {} + + if type_ == 'h2': + networkKey = 'httpSettings' + else: + networkKey = f'{type_}Settings' + + networkObjectArgs[ + networkKey + ] = ConfigurationXray.kwargs2ProxyStreamSettingsNetworkObject( + type_, address, kwargs + ) + + if security: + securityArgs['security'] = security + + if security != 'none': + TLSObjectArgs[ + # tlsSettings, realitySettings + f'{security}Settings' + ] = ConfigurationXray.kwargs2ProxyStreamSettingsTLSObject( + 'trojan', address, security, kwargs + ) + + super().__init__( + **{ + 'tag': 'proxy', + 'protocol': 'trojan', + 'settings': { + 'servers': [ + { + 'address': address, + 'port': int(port), + 'password': password, + 'email': PROXY_OUTBOUND_USER_EMAIL, + }, + ] + }, + 'streamSettings': { + 'network': type_, + **networkObjectArgs, + **securityArgs, + **TLSObjectArgs, + }, + 'mux': { + 'enabled': False, + 'concurrency': -1, + }, + }, + ) + + +class ConfigurationXray(ConfigurationFactory): + def __init__(self, config: Union[str, dict] = '', **kwargs): + super().__init__(config, **kwargs) + + def coreName(self): + return 'Xray-core' + + @property + def proxyOutboundObject(self) -> dict: + try: + for outboundObject in self['outbounds']: + if outboundObject['tag'] == 'proxy': + return outboundObject + + return {} + except Exception: + # Any non-exit exceptions + + return {} + + @property + def proxyProtocol(self) -> str: + try: + return self.proxyOutboundObject['protocol'] + except Exception: + # Any non-exit exceptions + + return '' + + @property + def proxyServerObject(self) -> dict: + try: + if ( + self.proxyProtocol.lower() == 'vmess' + or self.proxyProtocol.lower() == 'vless' + ): + return self.proxyOutboundObject['settings']['vnext'][0] + + if ( + self.proxyProtocol.lower() == 'shadowsocks' + or self.proxyProtocol.lower() == 'trojan' + ): + return self.proxyOutboundObject['settings']['servers'][0] + + return {} + except Exception: + # Any non-exit exceptions + + return {} + + @property + def proxyUserObject(self) -> dict: + try: + return self.proxyServerObject['users'][0] + except Exception: + # Any non-exit exceptions + + return {} + + @property + def proxyStreamSettingsObject(self) -> dict: + try: + return self.proxyOutboundObject['streamSettings'] + except Exception: + # Any non-exit exceptions + + return {} + + @property + def proxyStreamSettingsTLS(self) -> str: + try: + return self.proxyStreamSettingsObject.get('security', 'none') + except Exception: + # Any non-exit exceptions + + return '' + + @property + def proxyStreamSettingsTLSObject(self) -> dict: + try: + # tlsSettings, realitySettings + TLSKey = f'{self.proxyStreamSettingsTLS}Settings' + + return self.proxyStreamSettingsObject[TLSKey] + except Exception: + # Any non-exit exceptions + + return {} + + @property + def proxyStreamSettingsNetwork(self) -> str: + try: + return self.proxyStreamSettingsObject['network'] + except Exception: + # Any non-exit exceptions + + return '' + + @property + def proxyStreamSettingsNetworkObject(self) -> dict: + try: + if self.proxyStreamSettingsNetwork == 'h2': + networkKey = 'httpSettings' + else: + networkKey = f'{self.proxyStreamSettingsNetwork}Settings' + + return self.proxyStreamSettingsObject[networkKey] + except Exception: + # Any non-exit exceptions + + return {} + + @property + def kwargsFromVMessProxyStreamSettingsNetworkObject(self) -> dict: + kwargs = {} + + network = self.proxyStreamSettingsNetwork + networkObject = self.proxyStreamSettingsNetworkObject + + if not networkObject: + return kwargs + + def hasKey(key): + return networkObject.get(key) is not None + + if network == 'tcp': + try: + kwargs['type'] = networkObject['header']['type'] + except Exception: + # Any non-exit exceptions + + pass + + elif network == 'kcp': + try: + # Get order matters here + if hasKey('seed'): + kwargs['path'] = networkObject['seed'] + + kwargs['type'] = networkObject['header']['type'] + except Exception: + # Any non-exit exceptions + + pass + + elif network == 'ws': + try: + # Get order matters here + if hasKey('path'): + kwargs['path'] = quote(networkObject['path']) + + if networkObject['headers']['Host']: + kwargs['host'] = quote(networkObject['headers']['Host']) + except Exception: + # Any non-exit exceptions + + pass + + elif network == 'h2' or network == 'http': + try: + # Get order matters here + if hasKey('path'): + kwargs['path'] = quote(networkObject['path']) + + kwargs['host'] = quote(','.join(networkObject['host'])) + except Exception: + # Any non-exit exceptions + + pass + + elif network == 'quic': + try: + # Get order matters here + if hasKey('security'): + kwargs['host'] = networkObject['security'] + + if hasKey('key'): + kwargs['path'] = quote(networkObject['key']) + + kwargs['type'] = networkObject['header']['type'] + except Exception: + # Any non-exit exceptions + + pass + + elif network == 'grpc': + if hasKey('serviceName'): + kwargs['path'] = networkObject['serviceName'] + + return kwargs + + @property + def kwargsFromVLESSProxyStreamSettingsNetworkObject(self) -> dict: + kwargs = {} + + network = self.proxyStreamSettingsNetwork + networkObject = self.proxyStreamSettingsNetworkObject + + if not networkObject: + return kwargs + + def hasKey(key): + return networkObject.get(key) is not None + + if network == 'tcp': + try: + kwargs['headerType'] = networkObject['header']['type'] + except Exception: + # Any non-exit exceptions + + pass + + elif network == 'kcp': + try: + # Get order matters here + if hasKey('seed'): + kwargs['seed'] = networkObject['seed'] + + kwargs['headerType'] = networkObject['header']['type'] + except Exception: + # Any non-exit exceptions + + pass + + elif network == 'ws': + try: + # Get order matters here + if hasKey('path'): + kwargs['path'] = quote(networkObject['path']) + + if networkObject['headers']['Host']: + kwargs['host'] = quote(networkObject['headers']['Host']) + except Exception: + # Any non-exit exceptions + + pass + + elif network == 'h2' or network == 'http': + try: + # Get order matters here + if hasKey('path'): + kwargs['path'] = quote(networkObject['path']) + + kwargs['host'] = quote(','.join(networkObject['host'])) + except Exception: + # Any non-exit exceptions + + pass + + elif network == 'quic': + try: + # Get order matters here + if hasKey('security'): + kwargs['quicSecurity'] = networkObject['security'] + + if hasKey('key'): + kwargs['path'] = quote(networkObject['key']) + + kwargs['headerType'] = networkObject['header']['type'] + except Exception: + # Any non-exit exceptions + + pass + + elif network == 'grpc': + if hasKey('serviceName'): + kwargs['serviceName'] = networkObject['serviceName'] + + return kwargs + + @staticmethod + def kwargs2ProxyStreamSettingsNetworkObject(type_, remote_host, kwargs) -> dict: + # Note: + # v2rayN share standard doesn't require unquote. Still + # unquote value according to (VMess AEAD / VLESS) standard + + if type_ == 'tcp': + TcpObject = {} + + if kwargs.get('headerType', 'none'): + headerType = kwargs.get('headerType', 'none') + + # Some dumb share link set this value to 'auto'. Protect it + if headerType != 'auto': + TcpObject['header'] = { + 'type': headerType, + } + + # Request settings for HTTP + if headerType == 'http' and kwargs.get('host'): + TcpObject['header']['request'] = { + 'version': '1.1', + 'method': 'GET', + 'path': [kwargs.get('path', '/')], + 'headers': { + 'Host': [unquote(kwargs.get('host'))], + 'User-Agent': [ + ( + 'Mozilla/5.0 (Windows NT 10.0; WOW64) ' + 'AppleWebKit/537.36 (KHTML, like Gecko) ' + 'Chrome/53.0.2785.143 Safari/537.36' + ), + ( + 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_0_2 like Mac OS X) ' + 'AppleWebKit/601.1 (KHTML, like Gecko) ' + 'CriOS/53.0.2785.109 ' + 'Mobile/14A456 ' + 'Safari/601.1.46' + ), + ], + 'Accept-Encoding': ['gzip, deflate'], + 'Connection': ['keep-alive'], + 'Pragma': 'no-cache', + }, + } + + return TcpObject + + elif type_ == 'kcp': + KcpObject = { + # Extension. From v2rayN + 'uplinkCapacity': 12, + 'downlinkCapacity': 100, + } + + if kwargs.get('headerType', 'none'): + headerType = kwargs.get('headerType', 'none') + + # Some dumb share link set this value to 'auto'. Protect it + if headerType != 'auto': + KcpObject['header'] = { + 'type': headerType, + } + + if kwargs.get('seed'): + KcpObject['seed'] = kwargs.get('seed') + + return KcpObject + + elif type_ == 'ws': + WebSocketObject = {} + + if kwargs.get('path', '/'): + WebSocketObject['path'] = unquote(kwargs.get('path', '/')) + + if kwargs.get('host'): + WebSocketObject['headers'] = { + 'Host': unquote(kwargs.get('host')), + } + + return WebSocketObject + + elif type_ == 'h2' or type_ == 'http': + HttpObject = {} + + if kwargs.get('host', remote_host): + # Protect "xxx," format + HttpObject['host'] = list( + filter( + lambda x: x != '', + unquote(kwargs.get('host', remote_host)).split(','), + ) + ) + + if kwargs.get('path', '/'): + HttpObject['path'] = unquote(kwargs.get('path', '/')) + + return HttpObject + + elif type_ == 'quic': + QuicObject = {} + + if kwargs.get('quicSecurity', 'none'): + QuicObject['security'] = kwargs.get('quicSecurity', 'none') + + if kwargs.get('key'): + QuicObject['key'] = unquote(kwargs.get('key')) + + if kwargs.get('headerType', 'none'): + headerType = kwargs.get('headerType', 'none') + + # Some dumb share link set this value to 'auto'. Protect it + if headerType != 'auto': + QuicObject['header'] = { + 'type': headerType, + } + + return QuicObject + + elif type_ == 'grpc': + GRPCObject = {} + + if kwargs.get('serviceName'): + GRPCObject['serviceName'] = kwargs.get('serviceName') + + if kwargs.get('mode', 'gun'): + GRPCObject['multiMode'] = kwargs.get('mode', 'gun') == 'multi' + + return GRPCObject + + @property + def kwargsFromProxyStreamSettingsTLSObject(self) -> dict: + kwargs = {} + + TLS = self.proxyStreamSettingsTLS + TLSObject = self.proxyStreamSettingsTLSObject + + if TLS == '' or TLS == 'none': + return kwargs + + if TLS == 'reality' or TLS == 'tls': + if TLSObject.get('fingerprint'): + kwargs['fp'] = TLSObject['fingerprint'] + + if TLSObject.get('serverName'): + kwargs['sni'] = TLSObject['serverName'] + + if TLSObject.get('alpn'): + kwargs['alpn'] = quote(','.join(TLSObject['alpn'])) + + if TLS == 'reality': + # More kwargs for reality + if TLSObject.get('publicKey'): + kwargs['pbk'] = TLSObject['publicKey'] + + if TLSObject.get('shortId'): + kwargs['sid'] = TLSObject['shortId'] + + if TLSObject.get('spiderX'): + kwargs['spx'] = quote(TLSObject['spiderX']) + + return kwargs + + @staticmethod + def kwargs2ProxyStreamSettingsTLSObject( + protocol, remote_host, security, kwargs + ) -> dict: + TLSObject = {} + + if security == 'reality' or security == 'tls': + # Note: If specify default value 'chrome', some share link fails. + # Leave default value as empty + fp = kwargs.get('fp') + sni = kwargs.get('sni') + # Protect "xxx," format + alpn = list( + filter( + lambda x: x != '', + unquote(kwargs.get('alpn', '')).split(','), + ) + ) + + if fp: + TLSObject['fingerprint'] = fp + + if sni: + TLSObject['serverName'] = sni + else: + host = '' + + if protocol == 'vmess': + host = kwargs.get('host') + + if protocol == 'vless': + host = remote_host + + if host: + TLSObject['serverName'] = host + + if alpn: + TLSObject['alpn'] = alpn + + if security == 'reality': + # More args for reality + pbk = kwargs.get('pbk') + sid = kwargs.get('sid', '') + spx = kwargs.get('spx', '') + + if pbk: + TLSObject['publicKey'] = pbk + + TLSObject['shortId'] = sid + TLSObject['spiderX'] = unquote(spx) + + return TLSObject + + @staticmethod + def kwargs2ProxyUserObject(protocol, uuid_, encryption, kwargs) -> dict: + if protocol == 'vmess': + UserObject = { + 'id': uuid_, + 'security': encryption, + 'email': PROXY_OUTBOUND_USER_EMAIL, + } + + # For VMess(v2rayN share standard) only. + if kwargs.get('aid') is not None: + UserObject['alterId'] = kwargs.get('aid') + + return UserObject + + if protocol == 'vless': + UserObject = { + 'id': uuid_, + 'encryption': encryption, + 'email': PROXY_OUTBOUND_USER_EMAIL, + } + + if kwargs.get('flow'): + # flow is empty, TLS. Otherwise, XTLS. + UserObject['flow'] = kwargs.get('flow') + + return UserObject + + return {} + + @staticmethod + def URI2ProxyOutboundObjectVMess(URI: str) -> Tuple[str, dict]: + try: + myHead, myBody = URI.split('://') + except Exception: + # Any non-exit exceptions + + return '', {} + + try: + myData = PyBase64Encoder.decode(myBody) + myJSON = UJSONEncoder.decode(myData) + except Exception: + # Any non-exit exceptions + + return ConfigurationXray.URI2ProxyOutboundObjectVLESS(URI, protocol='vmess') + else: + + def getOrDefault(key, default=''): + return myJSON.get(key, default) + + remark = unquote(getOrDefault('ps')) + + return ( + remark, + # Ignore: v + ConfigurationXrayProxyOutboundObjectV( + 'vmess', + getOrDefault('add'), + int(getOrDefault('port', '0')), + getOrDefault('id'), + getOrDefault('scy', 'auto'), + getOrDefault('net', 'tcp'), + getOrDefault('tls', 'none'), + # kwargs. v2rayN share standard -> (VMess AEAD / VLESS) standard + aid=int(getOrDefault('aid', '0')), + headerType=getOrDefault('type', 'none'), + host=getOrDefault('host'), + quicSecurity=getOrDefault('host', 'none'), + path=getOrDefault('path'), + key=getOrDefault('path'), + seed=getOrDefault('path'), + serviceName=getOrDefault('path'), + sni=getOrDefault('sni'), + alpn=getOrDefault('alpn'), + # Note: If specify default value 'chrome', some share link fails. + # Leave default value as empty + fp=getOrDefault('fp'), + ), + ) + + @staticmethod + def URI2ProxyOutboundObjectVLESS(URI: str, **kwargs) -> Tuple[str, dict]: + result = urlparse(URI) + remark = unquote(result.fragment) + queryObject = {key: value for key, value in parse_qsl(result.query)} + + uuid_, server = result.netloc.split('@') + + remote_host, remote_port = parseHostPort(server) + + encryption = queryObject.pop('encryption', 'none') + type_ = queryObject.pop('type', 'tcp') + security = queryObject.pop('security', 'none') + + return ( + remark, + ConfigurationXrayProxyOutboundObjectV( + kwargs.pop('protocol', 'vless'), + remote_host, + int(remote_port), + uuid_, + encryption, + type_, + security, + # kwargs + **queryObject, + ), + ) + + @staticmethod + def URI2ProxyOutboundObjectSS(URI: str) -> Tuple[str, dict]: + try: + result = urlparse(URI) + remark = unquote(result.fragment) + except Exception: + # Any non-exit exceptions + + return '', {} + + def getSSParams(): + try: + # ss://base64...#fragment + userinfo, server = ( + PyBase64Encoder.decode(result.netloc).decode().split('@') + ) + + return [*userinfo.split(':', 1), *parseHostPort(server)] + except Exception: + # Any non-exit exceptions + + pass + + # Begin SIP002... + try: + # Try pack with 3 element + userinfo, server = result.netloc.split('@') + + # Some old SS share link doesn't add padding + # in base64 encoding. Add padding to userinfo + return [ + *PyBase64Encoder.decode(userinfo + '===').decode().split(':', 1), + *parseHostPort(server), + ] + except Exception: + # Any non-exit exceptions + + pass + + try: + # Try pack with 4 element + userinfo, server = result.netloc.split('@') + + return [*userinfo.split(':', 1), *parseHostPort(server)] + except Exception: + # Any non-exit exceptions + + pass + + raise ValueError(f'Invalid SS URI format {URI}') + + return ( + remark, + ConfigurationXrayProxyOutboundObjectSS(*getSSParams()), + ) + + @staticmethod + def URI2ProxyOutboundObjectTrojan(URI: str) -> Tuple[str, dict]: + result = urlparse(URI) + remark = unquote(result.fragment) + queryObject = {key: value for key, value in parse_qsl(result.query)} + + password, server = result.netloc.split('@') + + address, port = parseHostPort(server) + + type_ = queryObject.pop('type', 'tcp') + # For Trojan: Assign tls by default + security = queryObject.pop('security', 'tls') + + return ( + remark, + ConfigurationXrayProxyOutboundObjectTrojan( + password, address, port, type_, security, **queryObject + ), + ) + + @staticmethod + def URI2ProxyOutboundObject(URI: str) -> Tuple[str, dict]: + if URI.startswith('vmess://'): + return ConfigurationXray.URI2ProxyOutboundObjectVMess(URI) + + if URI.startswith('vless://'): + return ConfigurationXray.URI2ProxyOutboundObjectVLESS(URI) + + if URI.startswith('ss://'): + return ConfigurationXray.URI2ProxyOutboundObjectSS(URI) + + if URI.startswith('trojan://'): + return ConfigurationXray.URI2ProxyOutboundObjectTrojan(URI) + + raise ValueError(f'Unrecognized URI scheme {URI}') + + @property + def itemRemark(self) -> str: + return self.getExtras('remark') + + @property + def itemProtocol(self) -> str: + return protocolRepr(self.proxyProtocol) + + @property + def itemAddress(self) -> str: + addr = self.proxyServerObject.get('address', '') + + return str(addr) + + @property + def itemPort(self) -> str: + port = self.proxyServerObject.get('port', '') + + return str(port) + + @property + def itemTransport(self) -> str: + return self.proxyStreamSettingsNetwork + + @property + def itemTLS(self) -> str: + return self.proxyStreamSettingsTLS + + @property + def itemLatency(self) -> str: + # Backward compatibility + return self.getExtras('delayResult') + + @property + def itemSpeed(self) -> str: + # Backward compatibility + return self.getExtras('speedResult') + + def toJSONString(self, **kwargs) -> str: + indent = kwargs.pop('indent', 2) + + return super().toJSONString(indent=indent) + + def toURI(self, remark: str = '') -> str: + if remark == '': + override = self.itemRemark + else: + override = remark + + if self.proxyProtocol.lower() == 'vmess': + netloc = PyBase64Encoder.encode( + UJSONEncoder.encode( + { + 'v': '2', + 'ps': override, + **{ + key: self.proxyServerObject[value] + for key, value in { + 'add': 'address', + 'port': 'port', + }.items() + }, + **{ + key: self.proxyUserObject[value] + for key, value in { + 'id': 'id', + 'aid': 'alterId', + 'scy': 'security', + }.items() + }, + 'net': self.proxyStreamSettingsNetwork, + 'tls': self.proxyStreamSettingsTLS, + # kwargs + **self.kwargsFromVMessProxyStreamSettingsNetworkObject, + **self.kwargsFromProxyStreamSettingsTLSObject, + } + ).encode() + ).decode() + + return urlunparse(['vmess', netloc, '', '', {}, '']) + + if self.proxyProtocol.lower() == 'vless': + flowArg = {} + + if self.proxyUserObject.get('flow'): + flowArg['flow'] = self.proxyUserObject['flow'] + + netloc = ( + self.proxyUserObject['id'] + + '@' + + self.proxyServerObject['address'] + + ':' + + str(self.proxyServerObject['port']) + ) + + query = '&'.join( + f'{key}={value}' + for key, value in { + 'encryption': self.proxyUserObject['encryption'], + 'type': self.proxyStreamSettingsNetwork, + 'security': self.proxyStreamSettingsTLS, + # kwargs + **flowArg, + **self.kwargsFromVLESSProxyStreamSettingsNetworkObject, + **self.kwargsFromProxyStreamSettingsTLSObject, + }.items() + ) + + return urlunparse(['vless', netloc, '', '', query, quote(override)]) + + if self.proxyProtocol.lower() == 'shadowsocks': + method, password, address, port = list( + self.proxyServerObject[value] + for value in ['method', 'password', 'address', 'port'] + ) + + netloc = f'{quote(method)}:{quote(password)}@{address}:{port}' + + return urlunparse(['ss', netloc, '', '', '', quote(override)]) + + if self.proxyProtocol.lower() == 'trojan': + password, address, port = list( + self.proxyServerObject[value] + for value in ['password', 'address', 'port'] + ) + + netloc = f'{quote(password)}@{address}:{port}' + + query = '&'.join( + f'{key}={value}' + for key, value in { + 'type': self.proxyStreamSettingsNetwork, + 'security': self.proxyStreamSettingsTLS, + # kwargs + **self.kwargsFromVLESSProxyStreamSettingsNetworkObject, + **self.kwargsFromProxyStreamSettingsTLSObject, + }.items() + ) + + return urlunparse(['trojan', netloc, '', '', query, quote(override)]) + + # Unrecognized protocol + return '' + + def fromURI(self, URI: str) -> bool: + try: + remark, proxyOutboundObject = ConfigurationXray.URI2ProxyOutboundObject(URI) + + dict.__init__( + self, + **{ + # log + 'log': { + 'access': '', + 'error': '', + 'loglevel': 'warning', + }, + # inbounds + 'inbounds': [ + { + 'tag': 'socks', + 'port': 10808, + 'listen': '127.0.0.1', + 'protocol': 'socks', + 'sniffing': { + 'enabled': True, + 'destOverride': [ + 'http', + 'tls', + ], + }, + 'settings': { + 'auth': 'noauth', + 'udp': True, + 'allowTransparent': False, + }, + }, + { + 'tag': 'http', + 'port': 10809, + 'listen': '127.0.0.1', + 'protocol': 'http', + 'sniffing': { + 'enabled': True, + 'destOverride': [ + 'http', + 'tls', + ], + }, + 'settings': { + 'auth': 'noauth', + 'udp': True, + 'allowTransparent': False, + }, + }, + ], + # outbounds + 'outbounds': [ + # proxy + proxyOutboundObject, + # direct + { + 'tag': 'direct', + 'protocol': 'freedom', + 'settings': {}, + }, + # block + { + 'tag': 'block', + 'protocol': 'blackhole', + 'settings': { + 'response': { + 'type': 'http', + } + }, + }, + ], + # routing + 'routing': {}, + }, + ) + + self.setExtras('remark', remark) + + return True + except Exception: + # Any non-exit exceptions + + return False + + def httpProxyEndpoint(self) -> str: + try: + for inbound in self['inbounds']: + if inbound['protocol'] == 'http': + # Note: If there are multiple http inbounds + # satisfied, the first one will be chosen. + return str(inbound['listen']) + ':' + str(inbound['port']) + + return '' + except Exception: + # Any non-exit exceptions + + return '' + + def socksProxyEndpoint(self) -> str: + try: + for inbound in self['inbounds']: + if inbound['protocol'] == 'socks': + # Note: If there are multiple socks inbounds + # satisfied, the first one will be chosen. + return str(inbound['listen']) + ':' + str(inbound['port']) + + return '' + except Exception: + # Any non-exit exceptions + + return '' + + def setHttpProxyEndpoint(self, endpoint: str) -> bool: + try: + listen, port = parseHostPort(endpoint) + + if self.get('inbounds') is None: + self['inbounds'] = [] + + for inbound in self['inbounds']: + if inbound['protocol'] == 'http': + # Note: If there are multiple http inbounds + # satisfied, the first one will be chosen. + inbound['listen'], inbound['port'] = listen, int(port) + + return True + + # No entry. Add new one + self['inbounds'].append( + { + 'tag': 'http', + 'port': int(port), + 'listen': listen, + 'protocol': 'http', + 'sniffing': { + 'enabled': True, + 'destOverride': [ + 'http', + 'tls', + ], + }, + 'settings': { + 'auth': 'noauth', + 'udp': True, + 'allowTransparent': False, + }, + } + ) + + return True + except Exception: + # Any non-exit exceptions + + return False + + def setSocksProxyEndpoint(self, endpoint: str) -> bool: + try: + listen, port = parseHostPort(endpoint) + + if self.get('inbounds') is None: + self['inbounds'] = [] + + for inbound in self['inbounds']: + if inbound['protocol'] == 'socks': + # Note: If there are multiple socks inbounds + # satisfied, the first one will be chosen. + inbound['listen'], inbound['port'] = listen, int(port) + + return True + + # No entry. Add new one + self['inbounds'].append( + { + 'tag': 'socks', + 'port': int(port), + 'listen': listen, + 'protocol': 'socks', + 'sniffing': { + 'enabled': True, + 'destOverride': [ + 'http', + 'tls', + ], + }, + 'settings': { + 'auth': 'noauth', + 'udp': True, + 'allowTransparent': False, + }, + } + ) + + return True + except Exception: + # Any non-exit exceptions + + return False + + def logAccessPath(self) -> str: + try: + return self['log']['access'] + except Exception: + # Any non-exit exceptions + + return '' + + def logErrorPath(self) -> str: + try: + return self['log']['error'] + except Exception: + # Any non-exit exceptions + + return '' + + def setLogAccessPath(self, path) -> bool: + try: + if self.get('log') is None: + self['log'] = {} + + self['log']['access'] = path + + return True + except Exception: + # Any non-exit exceptions + + return False + + def setLogErrorPath(self, path) -> bool: + try: + if self.get('log') is None: + self['log'] = {} + + self['log']['error'] = path + + return True + except Exception: + # Any non-exit exceptions + + return False + + +class ConfigurationHysteria1(ConfigurationFactory): + def __init__(self, config: Union[str, dict] = '', **kwargs): + super().__init__(config, **kwargs) + + def coreName(self): + return 'Hysteria1' + + @property + def itemRemark(self) -> str: + return self.getExtras('remark') + + @property + def itemProtocol(self) -> str: + return Protocol.Hysteria1 + + @property + def itemAddress(self) -> str: + server = self.get('server', '') + + pos = server.rfind(':') + + if pos == -1: + return server + else: + return server[:pos] + + @property + def itemPort(self) -> str: + server = self.get('server', '') + + pos = server.rfind(':') + + if pos == -1: + return '' + else: + return server[pos + 1 :] + + @property + def itemTransport(self) -> str: + return '' + + @property + def itemTLS(self) -> str: + return '' + + @property + def itemLatency(self) -> str: + return self.getExtras('delayResult') + + @property + def itemSpeed(self) -> str: + return self.getExtras('speedResult') + + def toJSONString(self, **kwargs) -> str: + indent = kwargs.pop('indent', 4) + + return super().toJSONString(indent=indent) + + def toURI(self, remark: str = '') -> str: + return '' + + def fromURI(self, URI: str) -> bool: + return False + + def httpProxyEndpoint(self) -> str: + try: + return self['http']['listen'] + except Exception: + # Any non-exit exceptions + + return '' + + def socksProxyEndpoint(self) -> str: + try: + return self['socks5']['listen'] + except Exception: + # Any non-exit exceptions + + return '' + + def setHttpProxyEndpoint(self, endpoint: str) -> bool: + try: + if self.get('http') is None: + self['http'] = {} + + self['http']['listen'] = endpoint + + return True + except Exception: + # Any non-exit exceptions + + return False + + def setSocksProxyEndpoint(self, endpoint: str) -> bool: + try: + if self.get('socks5') is None: + self['socks5'] = {} + + self['socks5']['listen'] = endpoint + + return True + except Exception: + # Any non-exit exceptions + + return False + + +class ConfigurationHysteria2(ConfigurationFactory): + def __init__(self, config: Union[str, dict] = '', **kwargs): + super().__init__(config, **kwargs) + + def coreName(self): + return 'Hysteria2' + + @property + def itemRemark(self) -> str: + return self.getExtras('remark') + + @property + def itemProtocol(self) -> str: + return Protocol.Hysteria2 + + @property + def itemAddress(self) -> str: + server = self.get('server', '') + + pos = server.rfind(':') + + if pos == -1: + return server + else: + return server[:pos] + + @property + def itemPort(self) -> str: + server = self.get('server', '') + + pos = server.rfind(':') + + if pos == -1: + return '' + else: + return server[pos + 1 :] + + @property + def itemTransport(self) -> str: + return '' + + @property + def itemTLS(self) -> str: + return '' + + @property + def itemLatency(self) -> str: + return self.getExtras('delayResult') + + @property + def itemSpeed(self) -> str: + return self.getExtras('speedResult') + + def toJSONString(self, **kwargs) -> str: + indent = kwargs.pop('indent', 4) + + return super().toJSONString(indent=indent) + + def toURI(self, remark: str = '') -> str: + if remark == '': + override = self.itemRemark + else: + override = remark + + netloc = self['auth'] + '@' + self['server'] + + TLSArg, obfsArg = {}, {} + + if self.get('tls'): + if self['tls'].get('sni'): + TLSArg['sni'] = self['tls']['sni'] + + if self['tls'].get('insecure') is True: + TLSArg['insecure'] = '1' + else: + TLSArg['insecure'] = '0' + + if self['tls'].get('pinSHA256'): + TLSArg['pinSHA256'] = self['tls']['pinSHA256'] + + if self.get('obfs'): + obfsType = self['obfs'].get('type', 'salamander') + + obfsArg['obfs'] = obfsType + obfsArg['obfs-password'] = self['obfs'][obfsType]['password'] + + query = '&'.join( + f'{key}={value}' + for key, value in { + **TLSArg, + **obfsArg, + }.items() + ) + + return urlunparse(['hysteria2', netloc, '', '', query, quote(override)]) + + def fromURI(self, URI: str) -> bool: + try: + result = urlparse(URI) + remark = unquote(result.fragment) + queryObject = {key: value for key, value in parse_qsl(result.query)} + + if result.scheme != 'hysteria2' and result.scheme != 'hy2': + raise ValueError('Invalid hysteria2 URI scheme') + + auth, server = result.netloc.split('@') + + obfs = queryObject.get('obfs', '') + obfsPassword = queryObject.get('obfs-password', '') + sni = queryObject.get('sni', '') + + insecure = queryObject.get('insecure', False) + + if isinstance(insecure, bool): + pass + elif insecure == '1': + insecure = True + else: + insecure = False + + pinSHA256 = queryObject.get('pinSHA256', '') + + obfsArg = {} + pinSHA256Arg = {} + + if obfs and obfsPassword: + obfsArg['obfs'] = { + 'type': obfs, + obfs: { + 'password': obfsPassword, + }, + } + + if pinSHA256: + pinSHA256Arg['pinSHA256'] = pinSHA256 + + dict.__init__( + self, + **{ + 'server': server, + 'auth': auth, + 'tls': { + 'sni': sni, + 'insecure': insecure, + **pinSHA256Arg, + }, + **obfsArg, + 'socks5': { + 'listen': '127.0.0.1:10808', + }, + 'http': { + 'listen': '127.0.0.1:10809', + }, + }, + ) + + self.setExtras('remark', remark) + + return True + except Exception: + # Any non-exit exceptions + + return False + + def httpProxyEndpoint(self) -> str: + try: + return self['http']['listen'] + except Exception: + # Any non-exit exceptions + + return '' + + def socksProxyEndpoint(self) -> str: + try: + return self['socks5']['listen'] + except Exception: + # Any non-exit exceptions + + return '' + + def setHttpProxyEndpoint(self, endpoint: str) -> bool: + try: + if self.get('http') is None: + self['http'] = {} + + self['http']['listen'] = endpoint + + return True + except Exception: + # Any non-exit exceptions + + return False + + def setSocksProxyEndpoint(self, endpoint: str) -> bool: + try: + if self.get('socks5') is None: + self['socks5'] = {} + + self['socks5']['listen'] = endpoint + + return True + except Exception: + # Any non-exit exceptions + + return False + + +def constructFromDict(config: dict, **kwargs) -> ConfigurationFactory: + if not isinstance(config, dict): + return ConfigurationFactory() + + def hasField(field): + return config.get(field) is not None + + if hasField('inbounds') or hasField('outbounds'): + # Assuming is Xray-Core + return ConfigurationXray(config, **kwargs) + + if hasField('server'): + if ( + hasField('protocol') + or hasField('up_mbps') + or hasField('down_mbps') + or hasField('auth_str') + or hasField('alpn') + or hasField('server_name') + or hasField('insecure') + or hasField('recv_window_conn') + or hasField('recv_window') + or isinstance(config.get('obfs'), str) + or hasField('fast_open') + or hasField('lazy_start') + ): + return ConfigurationHysteria1(config, **kwargs) + if ( + hasField('tls') + or hasField('transport') + or hasField('quic') + or hasField('bandwidth') + or hasField('tcpForwarding') + or hasField('udpForwarding') + or hasField('tcpTProxy') + or hasField('udpTProxy') + or isinstance(config.get('obfs'), dict) + or hasField('fastOpen') + or hasField('lazy') + ): + return ConfigurationHysteria2(config, **kwargs) + + # Copied to factory unrecognized + return ConfigurationFactory(config, **kwargs) + + +def constructFromAny(config: Union[str, dict], **kwargs) -> ConfigurationFactory: + if isinstance(config, str): + if ( + config.startswith('vmess://') + or config.startswith('vless://') + or config.startswith('ss://') + or config.startswith('trojan://') + ): + return ConfigurationXray(config, **kwargs) + if config.startswith('h2://') or config.startswith('hysteria2://'): + return ConfigurationHysteria2(config, **kwargs) + + try: + # Try to construct from JSON string + return constructFromDict(UJSONEncoder.decode(config), **kwargs) + except Exception: + # Any non-exit exceptions + + return ConfigurationFactory(**kwargs) + + if isinstance(config, dict): + return constructFromDict(config, **kwargs) + + return ConfigurationFactory(**kwargs) diff --git a/Furious/Library/Encoder.py b/Furious/Library/Encoder.py new file mode 100644 index 0000000..6d2e8e9 --- /dev/null +++ b/Furious/Library/Encoder.py @@ -0,0 +1,81 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from __future__ import annotations + +from Furious.Interface import * + +from typing import Any, AnyStr + +import json +import ujson +import base64 +import pybase64 + +__all__ = ['JSONEncoder', 'UJSONEncoder', 'Base64Encoder', 'PyBase64Encoder'] + + +class _JSONEncoder(Encoder): + def encode(self, data: Any, **kwargs) -> str: + ensure_ascii = kwargs.pop('ensure_ascii', False) + + return json.dumps(data, ensure_ascii=ensure_ascii, **kwargs) + + def decode(self, data: AnyStr, **kwargs) -> Any: + return json.loads(data, **kwargs) + + +class _UJSONEncoder(Encoder): + def encode(self, data: Any, **kwargs) -> str: + ensure_ascii = kwargs.pop('ensure_ascii', False) + escape_forward_slashes = kwargs.pop('escape_forward_slashes', False) + + return ujson.dumps( + data, + ensure_ascii=ensure_ascii, + escape_forward_slashes=escape_forward_slashes, + **kwargs, + ) + + def decode(self, data: AnyStr, **kwargs) -> Any: + return ujson.loads(data, **kwargs) + + +class _Base64Encoder(Encoder): + def encode(self, data: Any, **kwargs) -> bytes: + return base64.b64encode(data, **kwargs) + + def decode(self, data: Any, **kwargs) -> bytes: + validate = kwargs.pop('validate', False) + + return base64.b64decode(data, validate=validate, **kwargs) + + +class _PyBase64Encoder(Encoder): + def encode(self, data: Any, **kwargs) -> bytes: + return pybase64.b64encode(data, **kwargs) + + def decode(self, data: Any, **kwargs) -> bytes: + validate = kwargs.pop('validate', False) + + return pybase64.b64decode(data, validate=validate, **kwargs) + + +JSONEncoder = _JSONEncoder() +UJSONEncoder = _UJSONEncoder() +Base64Encoder = _Base64Encoder() +PyBase64Encoder = _PyBase64Encoder() diff --git a/Furious/Library/__init__.py b/Furious/Library/__init__.py new file mode 100644 index 0000000..6dd6cbb --- /dev/null +++ b/Furious/Library/__init__.py @@ -0,0 +1,19 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from .Configuration import * +from .Encoder import * diff --git a/Furious/PyFramework/Ancestors.py b/Furious/PyFramework/Ancestors.py new file mode 100644 index 0000000..3d9c5f6 --- /dev/null +++ b/Furious/PyFramework/Ancestors.py @@ -0,0 +1,177 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from __future__ import annotations + +from PySide6.QtWidgets import QApplication + +__all__ = [ + 'Translatable', + 'SupportConnectedCallback', + 'SupportThemeChangedCallback', + 'SupportExitCleanup', + 'SupportImplicitReference', + 'FastItemDeletionSearch', +] + +import logging + +logger = logging.getLogger(__name__) + + +class Translatable: + ObjectsPool = list() + + def __init__(self, *args, **kwargs): + self.translatable = kwargs.pop('translatable', True) + + super().__init__(*args, **kwargs) + + Translatable.ObjectsPool.append(self) + + def retranslate(self): + raise NotImplementedError + + @staticmethod + def retranslateAll(): + for ob in Translatable.ObjectsPool: + assert isinstance(ob, Translatable) + + if ob.translatable: + ob.retranslate() + + +class SupportConnectedCallback: + ObjectsPool = list() + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + SupportConnectedCallback.ObjectsPool.append(self) + + def disconnectedCallback(self): + raise NotImplementedError + + def connectedCallback(self): + raise NotImplementedError + + @staticmethod + def callConnectedCallback(): + for ob in SupportConnectedCallback.ObjectsPool: + assert isinstance(ob, SupportConnectedCallback) + + ob.connectedCallback() + + @staticmethod + def callDisconnectedCallback(): + for ob in SupportConnectedCallback.ObjectsPool: + assert isinstance(ob, SupportConnectedCallback) + + ob.disconnectedCallback() + + +class SupportThemeChangedCallback: + ObjectsPool = list() + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + SupportThemeChangedCallback.ObjectsPool.append(self) + + def themeChangedCallback(self, theme: str): + raise NotImplementedError + + @staticmethod + def callThemeChangedCallbackUnchecked(theme: str): + for ob in SupportThemeChangedCallback.ObjectsPool: + assert isinstance(ob, SupportThemeChangedCallback) + + ob.themeChangedCallback(theme) + + @staticmethod + def callThemeChangedCallback(theme: str): + try: + app = QApplication.instance() + + if app is not None and app.isDarkModeEnabled(): + # Ignore application dark detect system + logger.info(f'ignore system theme \'{theme}\' changes in dark mode') + + return + except Exception: + # Any non-exit exceptions + + pass + + logger.info(f'system theme changed to \'{theme}\'') + + SupportThemeChangedCallback.callThemeChangedCallbackUnchecked(theme) + + +class SupportExitCleanup: + ObjectsPool = list() + VisitedType = dict() + + def __init__(self, *args, **kwargs): + self.uniqueCleanup = kwargs.pop('uniqueCleanup', True) + + super().__init__(*args, **kwargs) + + SupportExitCleanup.ObjectsPool.append(self) + + def cleanup(self): + raise NotImplementedError + + @staticmethod + def cleanupAll(): + for ob in SupportExitCleanup.ObjectsPool: + assert isinstance(ob, SupportExitCleanup) + + if ob.uniqueCleanup: + obtype = str(type(ob)) + + if not SupportExitCleanup.VisitedType.get(obtype, False): + ob.cleanup() + + SupportExitCleanup.VisitedType[obtype] = True + else: + pass + else: + ob.cleanup() + + +class SupportImplicitReference: + ObjectsPool = list() + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + SupportImplicitReference.ObjectsPool.append(self) + + +class FastItemDeletionSearch: + DeletedItem = list() + DeletedId = dict() + + @staticmethod + def moveToTrash(item): + FastItemDeletionSearch.DeletedItem.append(item) + FastItemDeletionSearch.DeletedId[id(item)] = True + + @staticmethod + def isInTrash(item) -> bool: + return id(item) in FastItemDeletionSearch.DeletedId diff --git a/Furious/Gui/__init__.py b/Furious/PyFramework/__init__.py similarity index 88% rename from Furious/Gui/__init__.py rename to Furious/PyFramework/__init__.py index 52b2594..2e546d6 100644 --- a/Furious/Gui/__init__.py +++ b/Furious/PyFramework/__init__.py @@ -1,4 +1,4 @@ -# Copyright (C) 2023 Loren Eteval +# Copyright (C) 2024 Loren Eteval # # This file is part of Furious. # @@ -15,3 +15,4 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +from .Ancestors import * diff --git a/Furious/QtFramework/Ancestors.py b/Furious/QtFramework/Ancestors.py new file mode 100644 index 0000000..e445f7d --- /dev/null +++ b/Furious/QtFramework/Ancestors.py @@ -0,0 +1,70 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from Furious.PyFramework import * + +__all__ = ['QBlockSignals', 'QTranslatable'] + + +class QProtection: + def __init__(self, qobject): + self.qobject = qobject + self.hasattr = hasattr(self.qobject, 'setDisabled') + + def __enter__(self): + if self.hasattr: + self.qobject.setDisabled(True) + + def __exit__(self, exceptionType, exceptionValue, tb): + if self.hasattr: + self.qobject.setDisabled(False) + + +class QBlockSignals: + def __init__(self, qobject): + self.qobject = qobject + self.hasattr = hasattr(self.qobject, 'blockSignals') + + def __enter__(self): + if self.hasattr: + self.qobject.blockSignals(True) + + def __exit__(self, exceptionType, exceptionValue, tb): + if self.hasattr: + self.qobject.blockSignals(False) + + +class QTranslatable(Translatable): + def __init__(self, *args, **kwargs): + self.useQProtection = kwargs.pop('useQProtection', True) + + super().__init__(*args, **kwargs) + + def retranslate(self): + raise NotImplementedError + + @staticmethod + def retranslateAll(): + for ob in QTranslatable.ObjectsPool: + assert isinstance(ob, QTranslatable) + + if ob.translatable: + if ob.useQProtection: + with QProtection(ob): + ob.retranslate() + else: + ob.retranslate() diff --git a/Furious/QtFramework/CoreProcess.py b/Furious/QtFramework/CoreProcess.py new file mode 100644 index 0000000..c12688b --- /dev/null +++ b/Furious/QtFramework/CoreProcess.py @@ -0,0 +1,207 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from __future__ import annotations + +from Furious.Interface import * +from Furious.Utility import * + +from PySide6 import QtCore + +from abc import ABC +from typing import Callable + +import os +import sys +import time +import uuid +import logging +import threading +import multiprocessing + +__all__ = ['CoreProcess', 'StdoutRedirectHelper'] + +logger = logging.getLogger(__name__) + + +class CoreProcess(CoreFactory, ABC): + MESG_PRODUCE_THRESHOLD = 250 + + def __init__(self, **kwargs): + exitCallback = kwargs.pop('exitCallback', None) + + super().__init__(exitCallback) + + self._process = None + self._msgQueue = multiprocessing.Queue() + self._msgCallback = kwargs.pop('msgCallback', None) + + @QtCore.Slot() + def handleMsgTimemout(): + msg = self.getMsgNoWait() + + if msg and not msg.isspace(): + if callable(self._msgCallback): + self._msgCallback(msg) + + self._msgTimer = QtCore.QTimer() + self._msgTimer.timeout.connect(handleMsgTimemout) + + @QtCore.Slot() + def handleDaemonTimemout(): + self.checkIsRunning() + + self._daemonTimer = QtCore.QTimer() + self._daemonTimer.timeout.connect(handleDaemonTimemout) + + @property + def msgQueue(self) -> multiprocessing.Queue: + return self._msgQueue + + def registerMsgCallback(self, msgCallback) -> CoreProcess: + self._msgCallback = msgCallback + + return self + + def isRunning(self) -> bool: + if isinstance(self._process, multiprocessing.Process): + return self._process.is_alive() + else: + return False + + def checkIsRunning(self) -> bool: + if isinstance(self._process, multiprocessing.Process): + if self._process.is_alive(): + return True + else: + logger.error( + f'{self.name()} stopped unexpectedly with exitcode {self._process.exitcode}' + ) + + self._msgTimer.stop() + self._daemonTimer.stop() + + if callable(self._exitCallback): + self._exitCallback(self, self._process.exitcode) + + # Reset internal process + self._process = None + + return False + else: + return False + + def start(self, **kwargs) -> bool: + daemon = kwargs.pop('daemon', True) + waitCore = kwargs.pop('waitCore', True) + waitTime = kwargs.pop('waitTime', 2500) + + self._process = multiprocessing.Process(**kwargs, daemon=daemon) + self._process.start() + + logger.info(f'{self.name()} {self.version()} started') + + self._msgTimer.start(self.MESG_PRODUCE_THRESHOLD) + + if waitCore: + # Wait for the core to start up completely + PySide6LegacyEventLoopWait(waitTime) + + if self.checkIsRunning(): + # Start core daemon + self._daemonTimer.start(CORE_CHECK_ALIVE_INTERVAL) + + return True + else: + return False + + def stop(self): + if self.isRunning(): + self._msgTimer.stop() + self._daemonTimer.stop() + + self._process.terminate() + self._process.join() + + logger.info( + f'{self.name()} terminated with exitcode {self._process.exitcode}' + ) + + def getMsgNoWait(self) -> str: + try: + return self._msgQueue.get_nowait() + except Exception: + # Any non-exit exceptions + + return '' + + +class StdoutRedirectHelper: + TemporaryDir = QtCore.QTemporaryDir() + + @staticmethod + def launch( + msgQueue: multiprocessing.Queue, entrypoint: Callable[[], None], redirect: bool + ): + if not callable(entrypoint): + return + + if ( + not StdoutRedirectHelper.TemporaryDir.isValid() + or not redirect + # pythonw.exe + or isPythonw() + ): + # Call entrypoint directly + entrypoint() + + return + + temporaryFile = StdoutRedirectHelper.TemporaryDir.filePath(str(uuid.uuid4())) + tmpFileStream = open(temporaryFile, 'w+b') + stdoutFileno_ = sys.stdout.fileno() + stderrFileno_ = sys.stderr.fileno() + + sys.stdout.close() + sys.stderr.close() + + # Redirect + os.dup2(tmpFileStream.fileno(), stdoutFileno_) + os.dup2(tmpFileStream.fileno(), stderrFileno_) + + sys.stdout = tmpFileStream + sys.stderr = tmpFileStream + + def produceMsg(): + with open(temporaryFile, 'rb') as file: + while True: + for line in iter(file.readline, b''): + if line and not line.isspace(): + try: + msgQueue.put_nowait(line.decode('utf-8', 'replace')) + except Exception: + # Any non-exit exceptions + + pass + + time.sleep(CoreProcess.MESG_PRODUCE_THRESHOLD / 1000) + + msgThread = threading.Thread(target=produceMsg, daemon=True) + msgThread.start() + + with tmpFileStream: + entrypoint() diff --git a/Furious/QtFramework/DNSResolver.py b/Furious/QtFramework/DNSResolver.py new file mode 100644 index 0000000..61240df --- /dev/null +++ b/Furious/QtFramework/DNSResolver.py @@ -0,0 +1,157 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from __future__ import annotations + +from Furious.Utility import * +from Furious.Library import * + +from PySide6 import QtCore +from PySide6.QtNetwork import * + +from typing import Tuple + +import logging + +__all__ = ['DNSResolver'] + +logger = logging.getLogger(__name__) + + +class DNSResolver: + Manager = QNetworkAccessManager() + + @staticmethod + def request(address) -> QNetworkRequest: + request = QNetworkRequest( + QtCore.QUrl(f'https://cloudflare-dns.com/dns-query?name={address}') + ) + request.setRawHeader('accept'.encode(), 'application/dns-json'.encode()) + + return request + + @staticmethod + def handleFinishedByNetworkReply(networkReply, domain, resultMap): + assert isinstance(networkReply, QNetworkReply) + + if networkReply.error() != QNetworkReply.NetworkError.NoError: + logger.error( + f'DNS resolution for \'{domain}\' failed. {networkReply.errorString()}' + ) + + resultMap['error'] = True + else: + logger.info(f'DNS resolution for \'{domain}\' success') + + # Unchecked? + replyObject = UJSONEncoder.decode(networkReply.readAll().data()) + + for record in replyObject['Answer']: + address = record['data'] + + logger.info(f'\'{domain}\' resolved to \'{address}\'') + + if isValidIPAddress(address): + resultMap['result'][address] = True + else: + resultMap['depth'] += 1 + + newNetworkReply = DNSResolver.Manager.get( + DNSResolver.request(address) + ) + newNetworkReply.finished.connect( + functools.partial( + DNSResolver.handleFinishedByNetworkReply, + newNetworkReply, + address, + resultMap, + ) + ) + + resultMap['reference'].append(newNetworkReply) + + resultMap['depth'] -= 1 + + @staticmethod + def resolve(domain, proxyHost=None, proxyPort=None) -> Tuple[bool, list[str]]: + if proxyHost is None or proxyPort is None: + DNSResolver.Manager.setProxy(QNetworkProxy.ProxyType.NoProxy) + else: + try: + DNSResolver.Manager.setProxy( + QNetworkProxy( + QNetworkProxy.ProxyType.HttpProxy, proxyHost, int(proxyPort) + ) + ) + + logger.info(f'DNS resolution uses proxy server {proxyHost}:{proxyPort}') + except Exception as ex: + # Any non-exit exceptions + + logger.error( + f'invalid proxy server {proxyHost}:{proxyPort}. {ex}. ' + f'DNS resolution uses no proxy' + ) + + DNSResolver.Manager.setProxy(QNetworkProxy.ProxyType.NoProxy) + + resultMap = { + 'depth': 0, + 'error': False, + 'reference': [], + 'result': {}, + } + + resultMap['depth'] += 1 + + networkReply = DNSResolver.Manager.get(DNSResolver.request(domain)) + networkReply.finished.connect( + functools.partial( + DNSResolver.handleFinishedByNetworkReply, + networkReply, + domain, + resultMap, + ) + ) + + resultMap['reference'].append(networkReply) + + DNSResolver.wait(resultMap) + + return resultMap['error'], list(resultMap['result'].keys()) + + @staticmethod + def wait(resultMap, startCounter=0, timeout=30000, step=100): + if resultMap['depth'] != 0: + logger.info('DNS resolution in progress. Wait') + else: + return + + while resultMap['depth'] != 0 and startCounter < timeout: + PySide6LegacyEventLoopWait(step) + + startCounter += step + + if resultMap['depth'] != 0: + logger.error('DNS resolution timeout') + + for networkReply in resultMap['reference']: + if ( + isinstance(networkReply, QNetworkReply) + and not networkReply.isFinished() + ): + networkReply.abort() diff --git a/Furious/QtFramework/DynamicTheme.py b/Furious/QtFramework/DynamicTheme.py new file mode 100644 index 0000000..f952ecc --- /dev/null +++ b/Furious/QtFramework/DynamicTheme.py @@ -0,0 +1,71 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from __future__ import annotations + +from Furious.QtFramework.QtGui import bootstrapIcon, AppQIcon +from Furious.Utility import * + +import functools + +__all__ = ['ColorRGB', 'AppHue'] + + +class ColorRGB: + LIGHT_BLUE = '#43ACED' + LIGHT_RED = '#FF7276' + LIGHT_PURPLE = '#DA70D6' + + +class AppHue: + @staticmethod + def disconnectedColor() -> str: + return ColorRGB.LIGHT_BLUE + + @staticmethod + def disconnectedWindowIcon() -> AppQIcon: + return bootstrapIcon('rocket-takeoff-window.svg') + + @staticmethod + @functools.lru_cache(None) + def connectedColor() -> str: + if not isAdministrator(): + return ColorRGB.LIGHT_RED + else: + return ColorRGB.LIGHT_PURPLE + + @staticmethod + @functools.lru_cache(None) + def connectedWindowIcon() -> AppQIcon: + if not isAdministrator(): + return bootstrapIcon('rocket-takeoff-connected-dark.svg') + else: + return bootstrapIcon('rocket-takeoff-admin-connected.svg') + + @staticmethod + def currentColor() -> str: + if APP().isSystemTrayConnected(): + return AppHue.connectedColor() + else: + return AppHue.disconnectedColor() + + @staticmethod + def currentWindowIcon() -> AppQIcon: + if APP().isSystemTrayConnected(): + return AppHue.connectedWindowIcon() + else: + return AppHue.disconnectedWindowIcon() diff --git a/Furious/QtFramework/DynamicTranslate.py b/Furious/QtFramework/DynamicTranslate.py new file mode 100644 index 0000000..9384524 --- /dev/null +++ b/Furious/QtFramework/DynamicTranslate.py @@ -0,0 +1,112 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from __future__ import annotations + +from Furious.Utility import * +from Furious.Library import * +from Furious.Externals import * + +import functools + +__all__ = [ + 'gettext', + 'needTransFn', + 'LANGUAGE_TO_ABBR', + 'ABBR_TO_LANGUAGE', + 'SUPPORTED_LANGUAGE', + 'TranslationPool', +] + + +class Translator: + def __init__(self): + super().__init__() + + self.translation = dict() + self.dictEnglish = dict() + + def install(self, translation): + self.translation = translation + + for key, value in translation.items(): + # English -> English + value['EN'] = key + + for lang, text in value.items(): + if lang.isupper(): + # is 'ZH', 'EN', etc. + self.dictEnglish[text] = key + + # English -> English + self.dictEnglish.update(dict(list((key, key) for key in translation.keys()))) + + def translate(self, source, locale): + try: + return self.translation[self.dictEnglish[source]][locale] + except Exception: + # Any non-exit exceptions + + # No changes + return source + + +translator = Translator() + + +def installTranslation(translation): + translator.install(translation) + + +def gettext(source, locale=None): + if locale is None: + assert APP() is not None + + return translator.translate(source, AppSettings.get('Language')) + else: + assert locale in SUPPORTED_LANGUAGE + + return translator.translate(source, locale) + + +# Register new translation type here +LANGUAGE_TO_ABBR = { + 'English': 'EN', + '简体中文': 'ZH', +} + +ABBR_TO_LANGUAGE = {value: key for key, value in LANGUAGE_TO_ABBR.items()} + +SUPPORTED_LANGUAGE = list(LANGUAGE_TO_ABBR.values()) + +installTranslation(TRANSLATION) + + +class TranslatorHelper: + TranslationPool = list() + + @staticmethod + def appendText(*texts, **kwargs): + source = kwargs.pop('source', '') + + for text in texts: + if isinstance(text, str): + TranslatorHelper.TranslationPool.append([text, source]) + + +needTransFn = functools.partial(TranslatorHelper.appendText) +TranslationPool = TranslatorHelper.TranslationPool diff --git a/Furious/QtFramework/NetworkStateManager.py b/Furious/QtFramework/NetworkStateManager.py new file mode 100644 index 0000000..c184198 --- /dev/null +++ b/Furious/QtFramework/NetworkStateManager.py @@ -0,0 +1,120 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from Furious.PyFramework import * +from Furious.QtFramework.QtNetwork import * +from Furious.Utility import * + +from PySide6 import QtCore +from PySide6.QtNetwork import * + +import logging +import functools + +__all__ = ['NetworkStateManager'] + +logger = logging.getLogger(__name__) + + +class NetworkStateManager(SupportConnectedCallback, AppQNetworkAccessManager): + MIN_JOB_INTERVAL = 2500 + MAX_JOB_INTERVAL = 2000000000 + + def __init__(self, parent=None): + super().__init__(parent) + + self.jobStatus = False + self.jobInterval = NetworkStateManager.MIN_JOB_INTERVAL + + self.jobTimeoutTimer = QtCore.QTimer() + self.jobArrangeTimer = QtCore.QTimer() + + self.jobArrangeTimer.timeout.connect(lambda: self.startSingleTest()) + + def successCallback(self): + raise NotImplementedError + + def errorCallback(self, errorString: str): + raise NotImplementedError + + def handleFinishedByNetworkReply(self, networkReply): + assert isinstance(networkReply, QNetworkReply) + + if networkReply.error() != QNetworkReply.NetworkError.NoError: + self.jobTimeoutTimer.stop() + + errorString = networkReply.errorString() + + logger.error(f'connection test failed. {errorString}') + + self.errorCallback(errorString) + + if self.jobStatus is False: + self.jobInterval *= 2 + else: + self.jobInterval = NetworkStateManager.MIN_JOB_INTERVAL + + self.jobStatus = False + else: + self.jobTimeoutTimer.stop() + + logger.info(f'connection test success') + + self.successCallback() + + if self.jobStatus is True: + self.jobInterval *= 2 + else: + self.jobInterval = NetworkStateManager.MIN_JOB_INTERVAL + + self.jobStatus = True + + if self.jobInterval >= NetworkStateManager.MAX_JOB_INTERVAL: + # Limited + self.jobInterval = NetworkStateManager.MAX_JOB_INTERVAL + + self.jobArrangeTimer.start(self.jobInterval) + + def startSingleTest(self): + networkReply = self.get(QNetworkRequest(QtCore.QUrl(NETWORK_STATE_TEST_URL))) + networkReply.finished.connect( + functools.partial( + self.handleFinishedByNetworkReply, + networkReply, + ) + ) + + def abort(_networkReply): + if isinstance(_networkReply, QNetworkReply): + _networkReply.abort() + + self.jobTimeoutTimer.timeout.connect(functools.partial(abort, networkReply)) + self.jobTimeoutTimer.start(NetworkStateManager.MIN_JOB_INTERVAL - 500) + + def startTest(self): + self.jobArrangeTimer.start(NetworkStateManager.MIN_JOB_INTERVAL) + + def stopTest(self): + self.jobArrangeTimer.stop() + + def connectedCallback(self): + self.jobInterval = NetworkStateManager.MIN_JOB_INTERVAL + + self.jobArrangeTimer.start(self.jobInterval) + + def disconnectedCallback(self): + self.jobArrangeTimer.stop() diff --git a/Furious/Gui/Action.py b/Furious/QtFramework/QtGui.py similarity index 64% rename from Furious/Gui/Action.py rename to Furious/QtFramework/QtGui.py index c980ef4..0eb1e36 100644 --- a/Furious/Gui/Action.py +++ b/Furious/QtFramework/QtGui.py @@ -1,4 +1,4 @@ -# Copyright (C) 2023 Loren Eteval +# Copyright (C) 2024 Loren Eteval # # This file is part of Furious. # @@ -15,17 +15,13 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from Furious.Utility.Constants import PLATFORM -from Furious.Utility.Utility import ( - SupportThemeChangedCallback, - bootstrapIcon, - bootstrapIconWhite, - getUbuntuRelease, -) -from Furious.Utility.Translator import Translatable, gettext as _ +from Furious.QtFramework.Ancestors import QTranslatable +from Furious.QtFramework.DynamicTranslate import gettext as _ +from Furious.PyFramework.Ancestors import * +from Furious.Utility import * from PySide6 import QtCore -from PySide6.QtGui import QAction, QActionGroup +from PySide6.QtGui import * import logging import functools @@ -33,8 +29,36 @@ import darkdetect logger = logging.getLogger(__name__) +__all__ = [ + 'bootstrapIcon', + 'bootstrapIconWhite', + 'AppQIcon', + 'AppQAction', + 'AppQActionGroup', + 'AppQSeperator', +] -class Action(Translatable, SupportThemeChangedCallback, QAction): + +class AppQIcon(QIcon): + def __init__(self, iconFileName: str): + super().__init__(iconFileName) + + self.iconFileName = iconFileName + + +def iconFn(prefix, name): + if name.startswith('rocket-takeoff'): + # Colorful. Use default + return AppQIcon(f':/Icons/bootstrap/{name}') + else: + return AppQIcon(f':/Icons/{prefix}/{name}') + + +bootstrapIcon = functools.partial(iconFn, 'bootstrap') +bootstrapIconWhite = functools.partial(iconFn, 'bootstrap/white') + + +class AppQAction(QTranslatable, SupportThemeChangedCallback, QAction): def __init__( self, text, @@ -43,13 +67,19 @@ class Action(Translatable, SupportThemeChangedCallback, QAction): useActionGroup=True, checkable=False, checked=False, + statusTip=None, callback=None, + shortcut=None, **kwargs, ): super().__init__(text=text, **kwargs) + # Do not use QProtection because it's been managed somewhere else!!! + self.useQProtection = False self.iconFileName = '' + self.setShortcutVisibleInContextMenu(True) + if icon is not None: self.setIcon(icon) @@ -64,7 +94,7 @@ class Action(Translatable, SupportThemeChangedCallback, QAction): if useActionGroup: # Create reference - self._actionGroup = ActionGroup(self, *menu.actions()) + self._actionGroup = AppQActionGroup(self, *menu.actions()) self.setActionGroup(self._actionGroup) else: @@ -76,9 +106,15 @@ class Action(Translatable, SupportThemeChangedCallback, QAction): self.setCheckable(checkable) self.setChecked(checked) + if statusTip is not None: + self.setStatusTip(statusTip) + # Handy callback to be able to link with lambda self.callback = callback + if shortcut is not None: + self.setShortcut(shortcut) + @QtCore.Slot(bool) def triggerSignal(paramChecked): logger.info(f'action is \'{self.textEnglish}\'. Checked is {paramChecked}') @@ -124,51 +160,47 @@ class Action(Translatable, SupportThemeChangedCallback, QAction): return '' - def setIcon(self, icon): + def setIconByTheme(self, theme): + if not self.iconFileName: + return + + if AppSettings.isStateON_('DarkMode'): + # Custom dark mode + super().setIcon(bootstrapIconWhite(self.iconFileName)) + + return + + if theme == 'Dark': + if PLATFORM == 'Windows': + # Windows. Always use black icon + super().setIcon(bootstrapIcon(self.iconFileName)) + else: + if getUbuntuRelease() == '20.04': + # Ubuntu 20.04 system dark theme does not change menu color. + # Make it go black always + super().setIcon(bootstrapIcon(self.iconFileName)) + else: + super().setIcon(bootstrapIconWhite(self.iconFileName)) + else: + super().setIcon(bootstrapIcon(self.iconFileName)) + + def setIcon(self, icon: AppQIcon): self.iconFileName = self.getIconFileName(icon.iconFileName) - if self.iconFileName: - if darkdetect.theme() == 'Dark': - if PLATFORM == 'Windows': - # Windows. Always use black icon - super().setIcon(bootstrapIcon(self.iconFileName)) - else: - if getUbuntuRelease() == '20.04': - # Ubuntu 20.04 system dark theme does not change menu color. - # Make it go black always - super().setIcon(bootstrapIcon(self.iconFileName)) - else: - super().setIcon(bootstrapIconWhite(self.iconFileName)) - else: - super().setIcon(bootstrapIcon(self.iconFileName)) - else: + if not self.iconFileName: # Fall back super().setIcon(icon) - - def triggeredCallback(self, checked): - # Not a mandatory re-implementation in child class - pass + else: + self.setIconByTheme(darkdetect.theme()) def themeChangedCallback(self, theme): - if self.iconFileName: - if theme == 'Dark': - if PLATFORM == 'Windows': - # Windows. Always use black icon - super().setIcon(bootstrapIcon(self.iconFileName)) - else: - if getUbuntuRelease() == '20.04': - # Ubuntu 20.04 system dark theme does not change menu color. - # Make it go black always - super().setIcon(bootstrapIcon(self.iconFileName)) - else: - super().setIcon(bootstrapIconWhite(self.iconFileName)) - else: - super().setIcon(bootstrapIcon(self.iconFileName)) + self.setIconByTheme(theme) def retranslate(self): def recursiveTranslate(action, memo): if action not in memo and not action.isSeparator() and action.translatable: action.setText(_(action.text())) + action.setStatusTip(_(action.statusTip())) memo[action] = True @@ -179,11 +211,14 @@ class Action(Translatable, SupportThemeChangedCallback, QAction): for childAction in action.menu().actions(): recursiveTranslate(childAction, memo) - # Do not use StateContext because it's been managed somewhere else!!! recursiveTranslate(self, dict()) + def triggeredCallback(self, checked): + # Not a mandatory re-implementation in child class + pass -class ActionGroup(QActionGroup): + +class AppQActionGroup(QActionGroup): def __init__(self, parent, *actions): super().__init__(parent) @@ -191,6 +226,6 @@ class ActionGroup(QActionGroup): self.addAction(action) -class Seperator(QAction): +class AppQSeperator(QAction): def __init__(self): super().__init__() diff --git a/Furious/QtFramework/QtNetwork.py b/Furious/QtFramework/QtNetwork.py new file mode 100644 index 0000000..77ea681 --- /dev/null +++ b/Furious/QtFramework/QtNetwork.py @@ -0,0 +1,55 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from __future__ import annotations + +from Furious.Utility import * + +from PySide6.QtNetwork import * + +from typing import Union + +__all__ = ['AppQNetworkAccessManager'] + + +class AppQNetworkAccessManager(QNetworkAccessManager): + def __init__(self, parent=None): + super().__init__(parent) + + def configureHttpProxy(self, httpProxy: Union[str, None]) -> bool: + if httpProxy is None: + useProxy = False + else: + try: + proxyHost, proxyPort = parseHostPort(httpProxy) + + self.setProxy( + QNetworkProxy( + QNetworkProxy.ProxyType.HttpProxy, proxyHost, int(proxyPort) + ) + ) + except Exception: + # Any non-exit exceptions + + useProxy = False + else: + useProxy = True + + if not useProxy: + self.setProxy(QNetworkProxy.ProxyType.NoProxy) + + return useProxy diff --git a/Furious/QtFramework/QtWidgets.py b/Furious/QtFramework/QtWidgets.py new file mode 100644 index 0000000..ab39098 --- /dev/null +++ b/Furious/QtFramework/QtWidgets.py @@ -0,0 +1,581 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from __future__ import annotations + +from Furious.QtFramework.Ancestors import * +from Furious.QtFramework.DynamicTheme import AppHue +from Furious.QtFramework.DynamicTranslate import gettext as _, needTransFn +from Furious.QtFramework.QtGui import AppQAction, AppQSeperator +from Furious.PyFramework import * +from Furious.Utility import * +from Furious.Library import * + +from PySide6 import QtCore +from PySide6.QtGui import * +from PySide6.QtWidgets import * + +import functools + +__all__ = [ + 'moveToCenter', + 'AppQDialog', + 'AppQGroupBox', + 'AppQHeaderView', + 'AppQLabel', + 'AppQListWidget', + 'AppQMainWindow', + 'AppQMenu', + 'AppQMenuBar', + 'AppQMessageBox', + 'AppQPushButton', + 'AppQStyledItemDelegate', + 'AppQTableWidget', + 'AppQTabWidget', + 'AppQToolBar', + 'QuestionDeleteMBox', + 'NewChangesNextTimeMBox', +] + +needTrans = functools.partial(needTransFn, source=__name__) + + +def moveToCenter(widget, parent=None): + geometry = widget.geometry() + + if parent is None: + center = QApplication.primaryScreen().availableGeometry().center() + else: + center = parent.geometry().center() + + geometry.moveCenter(center) + + widget.move(geometry.topLeft()) + + +class AppQDialog(QTranslatable, SupportConnectedCallback, QDialog): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self._firstShowCall = True + + if PLATFORM != 'Darwin': + self.setWidthAndHeight() + + self.setWindowIcon(AppHue.currentWindowIcon()) + + def setWidthAndHeight(self): + pass + + def exec(self): + self.show() + + return super().exec() + + def open(self): + self.show() + + return super().open() + + def show(self): + super().show() + + if PLATFORM == 'Darwin': + if self._firstShowCall: + APP().processEvents() + + self.setWidthAndHeight() + + self._firstShowCall = False + + moveToCenter(self) + + def retranslate(self): + pass + + def disconnectedCallback(self): + self.setWindowIcon(AppHue.disconnectedWindowIcon()) + + def connectedCallback(self): + self.setWindowIcon(AppHue.connectedWindowIcon()) + + +class AppQGroupBox(QTranslatable, QGroupBox): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def retranslate(self): + self.setTitle(_(self.title())) + + +class AppQHeaderView(SupportExitCleanup, SupportConnectedCallback, QHeaderView): + def sectionSizeSettingsEmpty(self): + return self.sectionSizeSettingsName == '' + + def __init__(self, *args, **kwargs): + self.sectionSizeSettingsName = kwargs.pop('sectionSizeSettingsName', '') + + super().__init__(*args, **kwargs) + + self.columnCount = self.parent().columnCount() + self.sectionSizeTable = {} + + self.setSectionsClickable(True) + self.setStyleSheet(self.getStyleSheet(AppHue.currentColor())) + self.setFont(QFont(APP().customFontName)) + + self.sectionResized.connect(self.handleSectionResized) + + def restoreSectionSize(self): + if self.sectionSizeSettingsEmpty(): + return + + try: + self.sectionSizeTable = UJSONEncoder.decode( + AppSettings.get(self.sectionSizeSettingsName) + ) + + # Fill missing value + for column in range(self.columnCount): + if self.sectionSizeTable.get(str(column)) is None: + self.sectionSizeTable[str(column)] = self.defaultSectionSize() + + with QBlockSignals(self): + for key, value in self.sectionSizeTable.items(): + self.resizeSection(int(key), value) + except Exception: + # Any non-exit exceptions + + # Leave keys as strings since they will be + # loaded as string from json + self.sectionSizeTable = { + str(column): self.defaultSectionSize() + for column in range(self.columnCount) + } + + def setCustomSectionResizeMode(self): + # Horizontal header resize mode + for index in range(self.columnCount): + if index < self.columnCount - 1: + self.setSectionResizeMode(index, AppQHeaderView.ResizeMode.Interactive) + else: + self.setSectionResizeMode(index, AppQHeaderView.ResizeMode.Stretch) + + @staticmethod + def getStyleSheet(color): + return f'QHeaderView::section:hover {{ background-color: {color}; }}' + + def disconnectedCallback(self): + self.setStyleSheet(self.getStyleSheet(AppHue.disconnectedColor())) + + def connectedCallback(self): + self.setStyleSheet(self.getStyleSheet(AppHue.connectedColor())) + + @QtCore.Slot(int, int, int) + def handleSectionResized(self, index: int, oldSize: int, newSize: int): + if self.sectionSizeSettingsEmpty(): + return + + # Keys are string when loaded from json + self.sectionSizeTable[str(index)] = newSize + + def cleanup(self): + if self.sectionSizeSettingsEmpty(): + return + + AppSettings.set( + self.sectionSizeSettingsName, + UJSONEncoder.encode(self.sectionSizeTable), + ) + + +class AppQLabel(QTranslatable, QLabel): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def retranslate(self): + self.setText(_(self.text())) + + +class AppQListWidget(SupportConnectedCallback, QListWidget): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.setSelectionColor(AppHue.disconnectedColor()) + + def setSelectionColor(self, color): + self.setStyleSheet( + f'QListWidget::item:selected {{' + f' background: {color};' + f'}}' + f'' + f'QListWidget::item:hover {{' + f' background: {color};' + f'}}' + ) + + @property + def selectedIndex(self): + return sorted(list(set(index.row() for index in self.selectedIndexes()))) + + def disconnectedCallback(self): + self.setSelectionColor(AppHue.disconnectedColor()) + + def connectedCallback(self): + self.setSelectionColor(AppHue.connectedColor()) + + +class AppQMainWindow( + QTranslatable, + SupportConnectedCallback, + SupportExitCleanup, + QMainWindow, +): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self._firstShowCall = True + + self.setWindowIcon(AppHue.currentWindowIcon()) + + self._menuBar = AppQMenuBar(parent=self) + self.setMenuBar(self._menuBar) + + if PLATFORM != 'Darwin': + self.setWidthAndHeight() + + def setWidthAndHeight(self): + pass + + def show(self): + super().show() + + if PLATFORM == 'Darwin': + if self._firstShowCall: + APP().processEvents() + + self.setWidthAndHeight() + + self._firstShowCall = False + + moveToCenter(self) + + def closeEvent(self, event): + event.ignore() + + self.hide() + + def retranslate(self): + self.setWindowTitle(_(self.windowTitle())) + + def disconnectedCallback(self): + self.setWindowIcon(AppHue.disconnectedWindowIcon()) + + def connectedCallback(self): + self.setWindowIcon(AppHue.connectedWindowIcon()) + + def cleanup(self): + pass + + +class AppQMenu(QTranslatable, SupportConnectedCallback, QMenu): + def __init__(self, *actions, **kwargs): + super().__init__(**kwargs) + + # In some old version PySide6, the self.actions() method + # does not return with seperators. _actions list append + # them all + self._actions = [] + + for action in actions: + if isinstance(action, AppQSeperator): + self._actions.append(action) + self.addSeparator() + elif isinstance(action, AppQAction): + self._actions.append(action) + self.addAction(action) + else: + # Do nothing + pass + + self.setStyleSheet(self.getStyleSheet(AppHue.currentColor())) + + @staticmethod + def getStyleSheet(color): + return ( + f'QMenu::item {{' + f' background-color: solid;' + f'}}' + f'' + f'QMenu::item:selected {{' + f' background-color: {color};' + f'}}' + ) + + def retranslate(self): + self.setTitle(_(self.title())) + + def disconnectedCallback(self): + self.setStyleSheet(self.getStyleSheet(AppHue.disconnectedColor())) + + def connectedCallback(self): + self.setStyleSheet(self.getStyleSheet(AppHue.connectedColor())) + + +class AppQMenuBar(SupportConnectedCallback, QMenuBar): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.setSelectionColor(AppHue.currentColor()) + + @staticmethod + def getStyleSheet(color): + return ( + f'QMenuBar::item:selected {{' + f' background: {color};' + f'}}' + f'' + f'QMenuBar::item:hover {{' + f' background: {color};' + f'}}' + ) + + def setSelectionColor(self, color): + self.setStyleSheet(self.getStyleSheet(color)) + + def disconnectedCallback(self): + self.setSelectionColor(AppHue.disconnectedColor()) + + def connectedCallback(self): + self.setSelectionColor(AppHue.connectedColor()) + + +class AppQMessageBox(QTranslatable, SupportConnectedCallback, QMessageBox): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.setWindowIcon(AppHue.currentWindowIcon()) + + def moveToCenter(self): + moveToCenter(self, self.parentWidget()) + + return self + + def exec(self): + self.show() + self.moveToCenter() + + return super().exec() + + def open(self): + self.show() + self.moveToCenter() + + return super().open() + + def retranslate(self): + self.setWindowTitle(_(self.windowTitle())) + self.setText(_(self.text())) + + try: + self.setInformativeText(_(self.informativeText())) + except KeyError: + # Any translatable informative text + pass + + for button in self.buttons(): + if button.text().find('OK') != -1: + # &OK... + pass + else: + button.setText(_(button.text())) + + self.moveToCenter() + + def disconnectedCallback(self): + self.setWindowIcon(AppHue.disconnectedWindowIcon()) + + def connectedCallback(self): + self.setWindowIcon(AppHue.connectedWindowIcon()) + + +class AppQPushButton(QTranslatable, QPushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def retranslate(self): + self.setText(_(self.text())) + + +class AppQStyledItemDelegate(QStyledItemDelegate): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def createEditor(self, parent, option, index): + editor = QLineEdit(parent) + editor.setFont(QFont(APP().customFontName)) + + return editor + + +class AppQTableWidget(SupportConnectedCallback, QTableWidget): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.setWordWrap(False) + + @property + def selectedIndex(self): + return sorted(list(set(index.row() for index in self.selectedIndexes()))) + + @staticmethod + def getStyleSheet(color): + return f'QTableWidget {{ selection-background-color: {color}; }}' + + def setSelectionColor(self, color): + self.setStyleSheet(self.getStyleSheet(color)) + + def activateItemByIndex(self, index, activate): + if activate: + for column in range(self.columnCount()): + item = self.item(int(index), column) + + if item is None: + # Do nothing + continue + + font = item.font() + font.setBold(True) + + item.setFont(font) + item.setForeground(QColor(AppHue.currentColor())) + else: + for column in range(self.columnCount()): + item = self.item(int(index), column) + + if item is None: + # Do nothing + continue + + font = item.font() + font.setBold(False) + + item.setFont(font) + item.setForeground(QBrush()) + + def selectMultipleRows(self, indexes: list[int], clearCurrentSelection: bool): + if clearCurrentSelection: + self.selectionModel().clearSelection() + + selection = self.selectionModel().selection() + + for index in indexes: + selection.select( + self.model().index(index, 0), + self.model().index(index, self.columnCount() - 1), + ) + + self.selectionModel().select( + selection, QtCore.QItemSelectionModel.SelectionFlag.Select + ) + + def disconnectedCallback(self): + self.setSelectionColor(AppHue.disconnectedColor()) + + def connectedCallback(self): + self.setSelectionColor(AppHue.connectedColor()) + + +class AppQTabWidget(QTranslatable, QTabWidget): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def retranslate(self): + for index in range(self.count()): + self.setTabText(index, _(self.tabText(index))) + + +class AppQToolBar(QTranslatable, QToolBar): + def __init__(self, *actions, **kwargs): + super().__init__(**kwargs) + + self._actions = [] + + for action in actions: + if isinstance(action, AppQSeperator): + self._actions.append(action) + self.addSeparator() + elif isinstance(action, AppQAction): + self._actions.append(action) + self.addAction(action) + else: + # Do nothing + pass + + self.setStyleSheet(self.getStyleSheet()) + + @staticmethod + def getStyleSheet(): + return f'QToolBar {{ spacing: 5px; }}' + + def retranslate(self): + self.setWindowTitle(_(self.windowTitle())) + + +needTrans( + 'Delete', + 'Delete these items?', + 'Delete this item?', +) + + +class QuestionDeleteMBox(AppQMessageBox): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.isMulti = False + self.possibleRemark = '' + + self.setWindowTitle(_('Delete')) + self.setStandardButtons( + AppQMessageBox.StandardButton.Yes | AppQMessageBox.StandardButton.No + ) + + def customText(self) -> str: + if self.isMulti: + return _('Delete these items?') + else: + return _('Delete this item?') + f'\n\n{self.possibleRemark}' + + def retranslate(self): + self.setWindowTitle(_(self.windowTitle())) + self.setText(self.customText()) + + # Ignore informative text, buttons + + self.moveToCenter() + + +needTrans('New changes will take effect next time') + + +class NewChangesNextTimeMBox(AppQMessageBox): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.setIcon(AppQMessageBox.Icon.Information) + self.setText(_('New changes will take effect next time')) diff --git a/Furious/QtFramework/TextEditor.py b/Furious/QtFramework/TextEditor.py new file mode 100644 index 0000000..0fe9341 --- /dev/null +++ b/Furious/QtFramework/TextEditor.py @@ -0,0 +1,322 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from __future__ import annotations + +from Furious.PyFramework import * +from Furious.QtFramework.TextEditorTheme import ( + DraculaEditorTheme, + DraculaJSONSyntaxHighlighter, +) +from Furious.Utility import * + +from PySide6 import QtCore +from PySide6.QtGui import * +from PySide6.QtWidgets import * + +from typing import Callable + +import functools + +__all__ = [ + 'AppQPlainTextEdit', + 'AppQTextBrowser', + 'DraculaTextEditor', + 'DraculaJSONTextEditor', + 'DraculaTextBrowser', +] + + +class SupportPointSizeSettings(SupportExitCleanup): + def pointSizeSettingsEmpty(self): + return self.pointSizeSettingsName == '' + + def __init__(self, *args, **kwargs): + self.pointSizeSettingsName = kwargs.pop('pointSizeSettingsName', '') + + super().__init__(*args, **kwargs) + + self.uniqueCleanup = False + + self.restorePointSize() + + def restorePointSize(self): + raise NotImplementedError + + def cleanup(self): + raise NotImplementedError + + +class AppQPlainTextEdit(SupportPointSizeSettings, QPlainTextEdit): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def restorePointSize(self): + if self.pointSizeSettingsEmpty(): + return + + try: + # Restore point size + font = self.font() + font.setPointSize(int(AppSettings.get(self.pointSizeSettingsName))) + + self.setFont(font) + except Exception: + # Any non-exit exceptions + + pass + + @staticmethod + @functools.lru_cache(128) + def getIndent(line): + indent = '' + + for char in line: + if char.isspace(): + indent += char + else: + break + + return indent + + def getPrevAndNextChar(self, cursor): + plainText = self.toPlainText() + + cursor.movePosition(QTextCursor.MoveOperation.Left) + + try: + prevChar = plainText[cursor.position()] + except Exception: + # Any non-exit exceptions + + prevChar = '' + + # Move the cursor to the next character position + cursor.movePosition(QTextCursor.MoveOperation.Right) + + try: + nextChar = plainText[cursor.position()] + except Exception: + # Any non-exit exceptions + + nextChar = '' + + return prevChar + nextChar + + def smartIndent(self, event): + cursor = self.textCursor() + indent = self.getIndent(cursor.block().text()) + + # Do newline action + super().keyPressEvent(event) + + # Add last line indent + cursor.insertText(indent) + + self.setTextCursor(cursor) + + def smartSymbolPair(self, event, pair): + plainText = self.toPlainText() + + cursor = self.textCursor() + + if ( + cursor.position() < len(plainText) + and plainText[cursor.position()] == pair[0] + ): + # Do pair0 action + super().keyPressEvent(event) + else: + # Do pair0 action + super().keyPressEvent(event) + + # Do pair1 action + cursor.insertText(pair[1]) + # Move to middle + cursor.movePosition(QTextCursor.MoveOperation.Left) + + self.setTextCursor(cursor) + + def smartBackspace(self, event): + cursor = self.textCursor() + chPair = self.getPrevAndNextChar(cursor) + + if chPair == '""' or chPair == '{}' or chPair == '[]': + cursor.deleteChar() + cursor.deletePreviousChar() + else: + super().keyPressEvent(event) + + def hideTabAndSpaces(self): + textOption = QTextOption() + + self.document().setDefaultTextOption(textOption) + # Reset. Set + self.setLineWrapMode(QPlainTextEdit.LineWrapMode.WidgetWidth) + self.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) + + def showTabAndSpaces(self): + textOption = QTextOption() + textOption.setFlags(QTextOption.Flag.ShowTabsAndSpaces) + + self.document().setDefaultTextOption(textOption) + # Reset. Set + self.setLineWrapMode(QPlainTextEdit.LineWrapMode.WidgetWidth) + self.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) + + def keyPressEvent(self, event): + if ( + event.key() == QtCore.Qt.Key.Key_Return + or event.key() == QtCore.Qt.Key.Key_Enter + ): + self.smartIndent(event) + elif event.key() == QtCore.Qt.Key.Key_Backspace: + self.smartBackspace(event) + elif event.key() == QtCore.Qt.Key.Key_QuoteDbl: + self.smartSymbolPair(event, '""') + elif event.key() == QtCore.Qt.Key.Key_BraceLeft: + self.smartSymbolPair(event, '{}') + elif event.key() == QtCore.Qt.Key.Key_BracketLeft: + self.smartSymbolPair(event, '[]') + else: + super().keyPressEvent(event) + + def wheelEvent(self, event): + if event.modifiers() == QtCore.Qt.KeyboardModifier.ControlModifier: + delta = event.angleDelta().y() + + if delta > 0: + self.zoomIn() + if delta < 0: + self.zoomOut() + else: + super().wheelEvent(event) + + def cleanup(self): + if self.pointSizeSettingsEmpty(): + return + + AppSettings.set(self.pointSizeSettingsName, str(self.font().pointSize())) + + +class AppQTextBrowser(SupportPointSizeSettings, QTextBrowser): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def appendLine(self, line: str): + hScrollBar = self.horizontalScrollBar() + vScrollBar = self.verticalScrollBar() + scrollEnds = vScrollBar.maximum() - vScrollBar.value() <= 10 + + # Fix insertPlainText bug if user cursor is present + self.append(line.rstrip()) + + if scrollEnds: + vScrollBar.setValue(vScrollBar.maximum()) # Scrolls to the bottom + hScrollBar.setValue(0) # scroll to the left + + def restorePointSize(self): + if self.pointSizeSettingsEmpty(): + return + + try: + # Restore point size + font = self.font() + font.setPointSize(int(AppSettings.get(self.pointSizeSettingsName))) + + self.setFont(font) + except Exception: + # Any non-exit exceptions + + pass + + def wheelEvent(self, event): + if event.modifiers() == QtCore.Qt.KeyboardModifier.ControlModifier: + delta = event.angleDelta().y() + + if delta > 0: + self.zoomIn() + if delta < 0: + self.zoomOut() + else: + super().wheelEvent(event) + + def cleanup(self): + if self.pointSizeSettingsEmpty(): + return + + AppSettings.set(self.pointSizeSettingsName, str(self.font().pointSize())) + + +class DraculaTextEditor(AppQPlainTextEdit): + def __init__(self, *args, **kwargs): + fontFamily = kwargs.pop('fontFamily', '') + + super().__init__(*args, **kwargs) + + self._modificationChangedCb = None + self._cursorPositionChangedCb = None + + # Theme + self.setStyleSheet( + DraculaEditorTheme.getStyleSheet( + widgetName='QPlainTextEdit', fontFamily=fontFamily + ) + ) + + @QtCore.Slot(bool) + def handleModificationChanged(changed): + if changed: + self.document().setModified(False) + + if callable(self._modificationChangedCb): + self._modificationChangedCb() + + @QtCore.Slot() + def handleCursorPositionChanged(): + if callable(self._cursorPositionChangedCb): + self._cursorPositionChangedCb(self.textCursor()) + + self.modificationChanged.connect(handleModificationChanged) + self.cursorPositionChanged.connect(handleCursorPositionChanged) + + def registerCursorPositionChangedCb(self, callback: Callable[[QTextCursor], None]): + self._cursorPositionChangedCb = callback + + def registerModificationChangedCb(self, callback: Callable[[], None]): + self._modificationChangedCb = callback + + +class DraculaJSONTextEditor(DraculaTextEditor): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self._syntaxHighlighter = DraculaJSONSyntaxHighlighter(self.document()) + + +class DraculaTextBrowser(AppQTextBrowser): + def __init__(self, *args, **kwargs): + fontFamily = kwargs.pop('fontFamily', '') + + super().__init__(*args, **kwargs) + + # Theme + self.setStyleSheet( + DraculaEditorTheme.getStyleSheet( + widgetName='QTextBrowser', fontFamily=fontFamily + ) + ) diff --git a/Furious/Utility/Theme.py b/Furious/QtFramework/TextEditorTheme.py similarity index 74% rename from Furious/Utility/Theme.py rename to Furious/QtFramework/TextEditorTheme.py index 953adb0..8dcb390 100644 --- a/Furious/Utility/Theme.py +++ b/Furious/QtFramework/TextEditorTheme.py @@ -1,4 +1,4 @@ -# Copyright (C) 2023 Loren Eteval +# Copyright (C) 2024 Loren Eteval # # This file is part of Furious. # @@ -16,10 +16,12 @@ # along with this program. If not, see . from PySide6 import QtCore -from PySide6.QtGui import QColor, QFont, QSyntaxHighlighter, QTextCharFormat +from PySide6.QtGui import * + +__all__ = ['DraculaEditorTheme', 'DraculaJSONSyntaxHighlighter'] -class HighlightRules: +class EditorHighlightRules: def __init__(self, regex, color, isBold=False, isJSONKey=False): self.regex = QtCore.QRegularExpression(regex) self.color = QColor(color) @@ -33,7 +35,7 @@ class HighlightRules: self.isJSONKey = isJSONKey -class Theme(QSyntaxHighlighter): +class EditorTheme: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -42,23 +44,40 @@ class Theme(QSyntaxHighlighter): raise NotImplementedError -class DraculaTheme(Theme): +class DraculaEditorTheme(EditorTheme): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + @staticmethod + def getStyleSheet(widgetName, fontFamily): + return ( + f'{widgetName} {{' + f' background-color: #282A36;' + f' color: #F8F8F2;' + f' font-family: \'{fontFamily}\';' + f'}}' + ) + + +class DraculaJSONSyntaxHighlighter(QSyntaxHighlighter): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.highlightRules = [ # Keywords: true, false, null. Pink. Bold - HighlightRules(r'\b(true|false|null)\b', '#FF79C6', isBold=True), + EditorHighlightRules(r'\b(true|false|null)\b', '#FF79C6', isBold=True), # Numbers. Purple - HighlightRules(r'[-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?', '#BD93F9'), + EditorHighlightRules(r'[-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?', '#BD93F9'), # Symbols: :, [, ], {, }. White - HighlightRules(r'[:,\[\]\{\}]', '#F8F8F2'), + EditorHighlightRules(r'[:,\[\]\{\}]', '#F8F8F2'), # Double-quoted strings. Yellow - HighlightRules(r'"[^"\\]*(\\.[^"\\]*)*"', '#F1FA8C'), + EditorHighlightRules(r'"[^"\\]*(\\.[^"\\]*)*"', '#F1FA8C'), # JSON keys. Green - HighlightRules(r'"([^"\\]*(\\.[^"\\]*)*)"\s*:', '#50FA7B', isJSONKey=True), + EditorHighlightRules( + r'"([^"\\]*(\\.[^"\\]*)*)"\s*:', '#50FA7B', isJSONKey=True + ), # Comments(only for hints on display). Grey - HighlightRules(r'^#.*', '#6272a4'), + EditorHighlightRules(r'^#.*', '#6272a4'), ] def highlightBlock(self, text): @@ -77,13 +96,3 @@ class DraculaTheme(Theme): self.setFormat( match.capturedStart(), capturedLength, highlightRule.rules ) - - @staticmethod - def getStyleSheet(widgetName, fontFamily): - return ( - f'{widgetName} {{' - f' background-color: #282A36;' - f' color: #F8F8F2;' - f' font-family: \'{fontFamily}\';' - f'}}' - ) diff --git a/Furious/QtFramework/UpdatesManager.py b/Furious/QtFramework/UpdatesManager.py new file mode 100644 index 0000000..ad92366 --- /dev/null +++ b/Furious/QtFramework/UpdatesManager.py @@ -0,0 +1,162 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from __future__ import annotations + +from Furious.QtFramework.QtNetwork import * +from Furious.QtFramework.QtWidgets import * +from Furious.QtFramework.DynamicTranslate import gettext as _, needTransFn +from Furious.Utility import * +from Furious.Library import * + +from PySide6 import QtCore +from PySide6.QtGui import * +from PySide6.QtNetwork import * + +from typing import Union + +import logging +import operator +import functools + +__all__ = ['UpdatesManager'] + +logger = logging.getLogger(__name__) + +needTrans = functools.partial(needTransFn, source=__name__) + + +def versionToNumber(version): + major_weight = 10000 + minor_weight = 100 + patch_weight = 1 + + return functools.reduce( + operator.add, + list( + int(ver) * weight + for ver, weight in zip( + version.split('.'), [major_weight, minor_weight, patch_weight] + ) + ), + ) + + +needTrans('New version available') + + +class QuestionUpdateMBox(AppQMessageBox): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.version = '0.0.0' + self.setWindowTitle(_(APPLICATION_NAME)) + self.setStandardButtons( + AppQMessageBox.StandardButton.Yes | AppQMessageBox.StandardButton.No + ) + + def customText(self): + return _('New version available') + f': {self.version}' + + def retranslate(self): + self.setText(self.customText()) + self.setWindowTitle(_(self.windowTitle())) + self.setInformativeText(_(self.informativeText())) + + # Ignore button text + + self.moveToCenter() + + +needTrans( + 'Check for updates failed', + 'Go to download page?', + f'{APPLICATION_NAME} is already the latest version', +) + + +class UpdatesManager(AppQNetworkAccessManager): + API_URL = ( + f'https://api.github.com/repos/' + f'{APPLICATION_REPO_OWNER_NAME}/{APPLICATION_REPO_NAME}/releases/latest' + ) + + def __init__(self, parent=None): + super().__init__(parent) + + @staticmethod + def handleFinishedByNetworkReply(networkReply): + assert isinstance(networkReply, QNetworkReply) + + if networkReply.error() != QNetworkReply.NetworkError.NoError: + logger.error(f'check for updates failed. {networkReply.errorString()}') + + mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Critical) + mbox.setWindowTitle(_(APPLICATION_NAME)) + mbox.setText(_('Check for updates failed')) + + # Show the MessageBox and wait for user to close it + mbox.exec() + else: + logger.info('check for updates success') + + # Unchecked? + info = UJSONEncoder.decode(networkReply.readAll().data()) + + if versionToNumber(info['tag_name']) > versionToNumber(APPLICATION_VERSION): + mbox = QuestionUpdateMBox(icon=AppQMessageBox.Icon.Information) + mbox.version = info['tag_name'] + mbox.setText(mbox.customText()) + mbox.setInformativeText(_('Go to download page?')) + + # Show the MessageBox and wait for user to close it + if mbox.exec() == PySide6LegacyEnumValueWrapper( + AppQMessageBox.StandardButton.Yes + ): + if QDesktopServices.openUrl(QtCore.QUrl(info['html_url'])): + logger.info('open download page success') + else: + logger.error('open download page failed') + else: + # Do nothing + pass + else: + mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Information) + mbox.setWindowTitle(_(APPLICATION_NAME)) + mbox.setText(_(f'{APPLICATION_NAME} is already the latest version')) + + # Show the MessageBox and wait for user to close it + mbox.exec() + + def configureHttpProxy(self, httpProxy: Union[str, None]) -> bool: + useProxy = super().configureHttpProxy(httpProxy) + + if useProxy: + logger.info(f'check for updates uses proxy server {httpProxy}') + else: + logger.info(f'check for updates uses no proxy') + + return useProxy + + def checkForUpdates(self): + networkReply = self.get(QNetworkRequest(QtCore.QUrl(self.API_URL))) + networkReply.finished.connect( + functools.partial( + self.handleFinishedByNetworkReply, + networkReply, + ) + ) diff --git a/Furious/QtFramework/__init__.py b/Furious/QtFramework/__init__.py new file mode 100644 index 0000000..bafa872 --- /dev/null +++ b/Furious/QtFramework/__init__.py @@ -0,0 +1,29 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from .Ancestors import * +from .DNSResolver import * +from .CoreProcess import * +from .DynamicTheme import * +from .DynamicTranslate import * +from .QtGui import * +from .QtWidgets import * +from .QtNetwork import * +from .UpdatesManager import * +from .NetworkStateManager import * +from .TextEditor import * +from .TextEditorTheme import * diff --git a/Furious/Storage/UserServers.py b/Furious/Storage/UserServers.py new file mode 100644 index 0000000..6a40ef3 --- /dev/null +++ b/Furious/Storage/UserServers.py @@ -0,0 +1,66 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from __future__ import annotations + +from Furious.Interface import * +from Furious.PyFramework import * +from Furious.Utility import * +from Furious.Library import * + +__all__ = ['UserServers'] + +registerAppSettings('Configuration') + + +class UserServers(SupportExitCleanup, StorageFactory): + # remark, config, subsId. (subsId corresponds to unique in user subscription) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def restore(): + try: + return UJSONEncoder.decode( + PyBase64Encoder.decode(AppSettings.get('Configuration')) + ) + except Exception: + # Any non-exit exceptions + + return {'model': []} + + self._data = restore() + self._list = list( + constructFromAny(model.pop('config', ''), **model) + for model in self._data['model'] + ) + + def sync(self): + AppSettings.set( + 'Configuration', + PyBase64Encoder.encode( + UJSONEncoder.encode( + {'model': list(factory.toStorageObject() for factory in self._list)} + ).encode() + ), + ) + + def data(self) -> list[ConfigurationFactory]: + # Shallow copy + return self._list + + def cleanup(self): + self.sync() diff --git a/Furious/Storage/UserSubs.py b/Furious/Storage/UserSubs.py new file mode 100644 index 0000000..583e150 --- /dev/null +++ b/Furious/Storage/UserSubs.py @@ -0,0 +1,60 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from __future__ import annotations + +from Furious.Interface import * +from Furious.PyFramework import * +from Furious.Utility import * +from Furious.Library import * + +__all__ = ['UserSubs'] + +registerAppSettings('CustomSubscription') + + +class UserSubs(SupportExitCleanup, StorageFactory): + # unique: remark, webURL + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def restore(): + try: + return UJSONEncoder.decode( + PyBase64Encoder.decode(AppSettings.get('CustomSubscription')) + ) + except Exception: + # Any non-exit exceptions + + return {} + + self._data = restore() + + def sync(self): + AppSettings.set( + 'CustomSubscription', + PyBase64Encoder.encode( + UJSONEncoder.encode(self._data).encode(), + ), + ) + + def data(self) -> dict[str, dict]: + # Shallow copy + return self._data + + def cleanup(self): + self.sync() diff --git a/Furious/Storage/__init__.py b/Furious/Storage/__init__.py new file mode 100644 index 0000000..454938c --- /dev/null +++ b/Furious/Storage/__init__.py @@ -0,0 +1,19 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from .UserServers import * +from .UserSubs import * diff --git a/Furious/TrayActions/Connect.py b/Furious/TrayActions/Connect.py new file mode 100644 index 0000000..b93d0a5 --- /dev/null +++ b/Furious/TrayActions/Connect.py @@ -0,0 +1,351 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from __future__ import annotations + +from Furious.Interface import * +from Furious.PyFramework import * +from Furious.QtFramework import * +from Furious.QtFramework import gettext as _ +from Furious.Utility import * +from Furious.Library import * +from Furious.Core import * +from Furious.Widget.ConnectProgressBar import ConnectProgressBar + +from PySide6 import QtCore + +import queue +import logging +import functools + +__all__ = ['ConnectAction'] + +logger = logging.getLogger(__name__) + +registerAppSettings('Connect', isBinary=True) + +needTrans = functools.partial(needTransFn, source=__name__) + +needTrans( + 'Connect', + 'Connecting', + 'Disconnect', + 'Unable to connect', + 'Server configuration empty. Please configure your server first', + 'Select and double click to activate configuration and connect', + f'{APPLICATION_NAME} cannot find any valid http proxy ' + f'endpoint in the configuration', + 'Please complete your server configuration', + 'Connected', + 'Unknown error', + 'Invalid server configuration', + 'Failed to start core', + 'Connection to server has been lost', + 'Core terminated unexpectedly', + 'Disconnected', +) + + +def validateProxyServer(server) -> bool: + try: + host, port = parseHostPort(server) + + if int(port) < 0 or int(port) > 65535: + raise ValueError + except Exception: + # Any non-exit exceptions + + return False + else: + return True + + +class ConnectAction(AppQAction): + def __init__(self): + super().__init__( + _('Connect'), + icon=bootstrapIcon('unlock-fill.svg'), + checkable=True, + ) + + self.actionQueue = queue.Queue() + self.coreManager = CoreManager() + self.progressBar = ConnectProgressBar() + + self.actionTimer = QtCore.QTimer() + self.actionTimer.timeout.connect(lambda: self.callActionFromQueue()) + + def reset(self): + self.hideProgressBar(True) + self.setText(_('Connect')) + self.setIcon(bootstrapIcon('unlock-fill.svg')) + self.setChecked(False) + + AppSettings.turnOFF('Connect') + + # Accept new action + self.setDisabledAction(False) + + def showProgressBar(self): + if AppSettings.isStateON_('ShowProgressBarWhenConnecting'): + self.progressBar.setValue(0) + # Update the progress bar every 50ms + self.progressBar.start(50) + self.progressBar.show() + + return self + + def hideProgressBar(self, done: bool): + if done: + self.progressBar.setValue(100) + + self.progressBar.hide() + self.progressBar.stop() + + return self + + def setDisabledAction(self, value): + self.setDisabled(value) + + APP().systemTray.RoutingAction.setDisabled(value) + APP().systemTray.SystemProxyAction.setDisabled(value) + + if isAdministrator(): + VPNModeAction = APP().systemTray.SettingsAction.getVPNModeAction() + + if VPNModeAction is not None: + VPNModeAction.setDisabled(value) + + def isConnected(self) -> bool: + return self.textCompare('Disconnect') + + def isConnecting(self): + return self.textCompare('Connecting') + + def doConnecting(self): + self.setText(_('Connecting')) + self.setIcon(bootstrapIcon('lock-fill.svg')) + # Do not accept new action + self.setDisabledAction(True) + self.showProgressBar() + + def doConnected(self): + self.hideProgressBar(True) + # Connected + self.setText(_('Disconnect')) + + AppSettings.turnON_('Connect') + + SupportConnectedCallback.callConnectedCallback() + + # Accept new action + self.setDisabledAction(False) + + def doDisconnect(self): + SystemProxy.off() + + self.actionTimer.stop() + + self.coreManager.stopAll() + self.reset() + + while not self.actionQueue.empty(): + try: + unused = self.actionQueue.get_nowait() + except Exception: + # Any non-exit exceptions + + pass + + SupportConnectedCallback.callDisconnectedCallback() + + def doDisconnectWithTrayMessage(self, message: str): + self.doDisconnect() + + APP().systemTray.showMessage(message) + + def doConnect(self): + # Connect action + assert self.textCompare('Connect') + + if not AS_UserServers(): + AppSettings.turnOFF('Connect') + + self.setChecked(False) + + mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Critical) + mbox.setWindowTitle(_('Unable to connect')) + mbox.setText( + _('Server configuration empty. Please configure your server first') + ) + + # Show the MessageBox and wait for user to close it + mbox.exec() + + return + + if AS_UserActivatedItemIndex() < 0: + AppSettings.turnOFF('Connect') + + self.setChecked(False) + + mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Critical) + mbox.setWindowTitle(_('Unable to connect')) + mbox.setText( + _('Select and double click to activate configuration and connect') + ) + + # Show the MessageBox and wait for user to close it + mbox.exec() + + return + + try: + config = AS_UserServers()[AS_UserActivatedItemIndex()] + except Exception: + # Any non-exit exceptions + + AppSettings.turnOFF('Connect') + + self.setChecked(False) + + return + + assert isinstance(config, ConfigurationFactory) + + if not validateProxyServer(config.httpProxyEndpoint()): + AppSettings.turnOFF('Connect') + + self.setChecked(False) + + mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Critical) + mbox.setWindowTitle(_('Unable to connect')) + mbox.setText( + _( + f'{APPLICATION_NAME} cannot find any valid http proxy ' + f'endpoint in the configuration' + ) + ) + mbox.setInformativeText(_('Please complete your server configuration')) + + # Show the MessageBox and wait for user to close it + mbox.exec() + + return + + self.doConnecting() + + # Clear previous log + APP().logViewerWindowCore.clear() + APP().logViewerWindowTun_.clear() + + success = self.coreManager.start( + config, + routing=AppSettings.get('Routing'), + exitCallback=self.coreExitCallback, + msgCallback=lambda line: APP().logViewerWindowCore.appendLine(line), + tunMsgCallback=lambda line: APP().logViewerWindowTun_.appendLine(line), + ) + + if self.actionQueue.empty(): + if success: + SystemProxy.set(config.httpProxyEndpoint(), PROXY_SERVER_BYPASS) + + self.doConnected() + + APP().systemTray.showMessage(f'{config.coreName()}: ' + _('Connected')) + + self.actionTimer.start(CORE_CHECK_ALIVE_INTERVAL) + else: + logger.error('failed to start core manager') + + self.coreManager.stopAll() + self.doDisconnectWithTrayMessage( + f'{config.coreName()}: ' + _('Unknown error') + ) + else: + while not self.actionQueue.empty(): + self.callActionFromQueue() + + def callActionFromQueue(self): + try: + action = self.actionQueue.get_nowait() + except queue.Empty: + # Queue is empty + + pass + except Exception: + # Any non-exit exceptions + + pass + else: + if callable(action): + action() + + def coreExitCallback(self, core: CoreFactory, exitcode: int): + def putItem(item): + try: + self.actionQueue.put_nowait(item) + except Exception: + # Any non-exit exceptions + + pass + + if exitcode == CoreFactory.ExitCode.SystemShuttingDown: + # System shutting down. Do nothing + return + + if exitcode == CoreFactory.ExitCode.ConfigurationError: + return putItem( + functools.partial( + self.doDisconnectWithTrayMessage, + f'{core.name()}: ' + _('Invalid server configuration'), + ) + ) + + if exitcode == CoreFactory.ExitCode.ServerStartFailure: + return putItem( + functools.partial( + self.doDisconnectWithTrayMessage, + f'{core.name()}: ' + _('Failed to start core'), + ) + ) + + if isinstance(core, Hysteria1): + if exitcode == Hysteria1.ExitCode.RemoteNetworkError: + return putItem( + functools.partial( + self.doDisconnectWithTrayMessage, + f'{core.name()}: ' + _('Connection to server has been lost'), + ) + ) + + return putItem( + functools.partial( + self.doDisconnectWithTrayMessage, + f'{core.name()}: ' + _('Core terminated unexpectedly'), + ) + ) + + def triggeredCallback(self, checked): + if checked: + self.doConnect() + else: + # Disconnect action + assert self.textCompare('Disconnect') + + self.doDisconnectWithTrayMessage(_('Disconnected')) diff --git a/Furious/Action/EditConfiguration.py b/Furious/TrayActions/EditConfiguration.py similarity index 68% rename from Furious/Action/EditConfiguration.py rename to Furious/TrayActions/EditConfiguration.py index 29ac632..a6e6830 100644 --- a/Furious/Action/EditConfiguration.py +++ b/Furious/TrayActions/EditConfiguration.py @@ -1,4 +1,4 @@ -# Copyright (C) 2023 Loren Eteval +# Copyright (C) 2024 Loren Eteval # # This file is part of Furious. # @@ -15,13 +15,20 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from Furious.Gui.Action import Action -from Furious.Utility.Constants import APP -from Furious.Utility.Utility import bootstrapIcon -from Furious.Utility.Translator import gettext as _ +from Furious.QtFramework import * +from Furious.QtFramework import gettext as _ +from Furious.Utility import * + +import functools + +__all__ = ['EditConfigurationAction'] + +needTrans = functools.partial(needTransFn, source=__name__) + +needTrans('Edit Configuration...') -class EditConfigurationAction(Action): +class EditConfigurationAction(AppQAction): def __init__(self): super().__init__( _('Edit Configuration...'), @@ -29,4 +36,4 @@ class EditConfigurationAction(Action): ) def triggeredCallback(self, checked): - APP().ServerWidget.show() + APP().mainWindow.show() diff --git a/Furious/Action/Exit.py b/Furious/TrayActions/Exit.py similarity index 73% rename from Furious/Action/Exit.py rename to Furious/TrayActions/Exit.py index 7c2c70c..9bb9ebd 100644 --- a/Furious/Action/Exit.py +++ b/Furious/TrayActions/Exit.py @@ -1,4 +1,4 @@ -# Copyright (C) 2023 Loren Eteval +# Copyright (C) 2024 Loren Eteval # # This file is part of Furious. # @@ -15,13 +15,20 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from Furious.Gui.Action import Action -from Furious.Utility.Constants import APP -from Furious.Utility.Utility import bootstrapIcon -from Furious.Utility.Translator import gettext as _ +from Furious.QtFramework import * +from Furious.QtFramework import gettext as _ +from Furious.Utility import * + +import functools + +__all__ = ['ExitAction'] + +needTrans = functools.partial(needTransFn, source=__name__) + +needTrans('Exit') -class ExitAction(Action): +class ExitAction(AppQAction): def __init__(self): super().__init__( _('Exit'), diff --git a/Furious/TrayActions/Import.py b/Furious/TrayActions/Import.py new file mode 100644 index 0000000..b74f65d --- /dev/null +++ b/Furious/TrayActions/Import.py @@ -0,0 +1,287 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from Furious.Interface import * +from Furious.QtFramework import * +from Furious.QtFramework import gettext as _ +from Furious.Library import * +from Furious.Utility import * + +from PySide6.QtWidgets import QApplication, QFileDialog + +import os +import logging +import functools + +__all__ = [ + 'ImportFromFileAction', + 'ImportURIFromClipboardAction', + 'ImportJSONFromClipboardAction', + 'ImportAction', +] + +logger = logging.getLogger(__name__) + +needTrans = functools.partial(needTransFn, source=__name__) + +needTrans( + 'Invalid data', + 'Invalid data. The content of the clipboard is:', +) + + +def showImportErrorMBox(clipboard: str): + mbox = ImportErrorMBox(icon=AppQMessageBox.Icon.Critical) + + if len(clipboard) > 1000: + # Limited + mbox.setText(_('Invalid data')) + mbox.setInformativeText('') + else: + mbox.setText(_('Invalid data. The content of the clipboard is:')) + mbox.setInformativeText(clipboard) + + mbox.exec() + + +def importItemFromClipboard(clipboard: str): + factory = constructFromAny(clipboard) + + if not factory.isValid(): + showImportErrorMBox(clipboard) + else: + APP().mainWindow.appendNewItemByFactory(factory) + + mbox = ImportSuccessMBox(icon=AppQMessageBox.Icon.Information) + mbox.remark = factory.getExtras('remark') + mbox.setText(mbox.customText()) + mbox.exec() + + +needTrans('Import') + + +class ImportErrorMBox(AppQMessageBox): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.setWindowTitle(_('Import')) + + def retranslate(self): + self.setWindowTitle(_(self.windowTitle())) + self.setText(_(self.text())) + + # Ignore informative text, buttons + + self.moveToCenter() + + +needTrans( + 'Import', + 'Import share link success', + 'Imported to row', +) + + +class ImportMultiSuccessMBox(AppQMessageBox): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.imported = [] + self.rowCount = 0 + + self.setWindowTitle(_('Import')) + self.setIcon(AppQMessageBox.Icon.Information) + + def customText(self): + text = ( + _('Import share link success') + + f'\n\n' + + '\n'.join( + list( + f'{index + 1} - {remark}. ' + + _('Imported to row') + + f' {self.rowCount + index + 1}' + for index, remark in enumerate(self.imported) + ) + ) + ) + + if len(text) <= 1000: + return text + else: + # Limited + return _('Import share link success') + f'\n\n...' + + +needTrans( + 'Import', + 'Import success', +) + + +class ImportSuccessMBox(AppQMessageBox): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.remark = '' + self.setWindowTitle(_('Import')) + + def customText(self): + if self.remark: + return _('Import success') + f': {self.remark}' + else: + return _('Import success') + + def retranslate(self): + self.setWindowTitle(_(self.windowTitle())) + self.setText(self.customText()) + + # Ignore informative text, buttons + + self.moveToCenter() + + +needTrans( + 'Import From File...', + 'Import File', + 'Text files (*.json);;All files (*)', + 'Error opening file', + 'Invalid configuration file', + 'Invalid data', +) + + +class ImportFromFileAction(AppQAction): + def __init__(self, **kwargs): + super().__init__( + _('Import From File...'), + icon=bootstrapIcon('folder2-open.svg'), + **kwargs, + ) + + def triggeredCallback(self, checked): + filename, selectedFilter = QFileDialog.getOpenFileName( + None, + _('Import File'), + filter=_('Text files (*.json);;All files (*)'), + ) + + if filename: + try: + with open(filename, 'r', encoding='utf-8') as file: + plainText = file.read() + except Exception as ex: + # Any non-exit exceptions + + mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Critical) + mbox.setWindowTitle(_('Error opening file')) + mbox.setText(_('Invalid configuration file')) + mbox.setInformativeText(str(ex)) + + # Show the MessageBox and wait for user to close it + mbox.exec() + else: + factory = constructFromAny(plainText, remark=os.path.basename(filename)) + + if factory.isValid(): + APP().mainWindow.appendNewItemByFactory(factory) + + mbox = ImportSuccessMBox(icon=AppQMessageBox.Icon.Information) + mbox.remark = factory.getExtras('remark') + mbox.setText(mbox.customText()) + mbox.exec() + else: + mbox = ImportErrorMBox(icon=AppQMessageBox.Icon.Critical) + mbox.setText(_('Invalid data')) + mbox.setInformativeText('') + mbox.show() + + +needTrans('Import Share Link From Clipboard') + + +class ImportURIFromClipboardAction(AppQAction): + def __init__(self, **kwargs): + super().__init__(_('Import Share Link From Clipboard'), **kwargs) + + def triggeredCallback(self, checked): + clipboard = QApplication.clipboard().text().strip() + + try: + split = clipboard.split('\n') + except Exception: + # Any non-exit exceptions + + importItemFromClipboard(clipboard) + else: + imported = list() + rowCount = len(AS_UserServers()) + + for uri in split: + factory = constructFromAny(uri) + + if factory.isValid(): + APP().mainWindow.appendNewItemByFactory(factory) + + imported.append(factory.getExtras('remark')) + + if len(imported) == 0: + showImportErrorMBox(clipboard) + else: + if len(imported) == 1: + # Fall back to single + mbox = ImportSuccessMBox(icon=AppQMessageBox.Icon.Information) + mbox.remark = imported[0] + mbox.setText(mbox.customText()) + mbox.exec() + else: + mbox = ImportMultiSuccessMBox(icon=AppQMessageBox.Icon.Information) + mbox.imported = imported + mbox.rowCount = rowCount + mbox.setText(mbox.customText()) + mbox.exec() + + +needTrans('Import JSON Configuration From Clipboard') + + +class ImportJSONFromClipboardAction(AppQAction): + def __init__(self, **kwargs): + super().__init__(_('Import JSON Configuration From Clipboard'), **kwargs) + + def triggeredCallback(self, checked): + clipboard = QApplication.clipboard().text().strip() + + importItemFromClipboard(clipboard) + + +needTrans('Import') + + +class ImportAction(AppQAction): + def __init__(self): + super().__init__( + _('Import'), + icon=bootstrapIcon('lightning-charge.svg'), + menu=AppQMenu( + ImportURIFromClipboardAction(), + ImportJSONFromClipboardAction(), + ), + useActionGroup=False, + checkable=True, + ) diff --git a/Furious/Action/Language.py b/Furious/TrayActions/Language.py similarity index 58% rename from Furious/Action/Language.py rename to Furious/TrayActions/Language.py index 9bc3b31..bfb9872 100644 --- a/Furious/Action/Language.py +++ b/Furious/TrayActions/Language.py @@ -1,4 +1,4 @@ -# Copyright (C) 2023 Loren Eteval +# Copyright (C) 2024 Loren Eteval # # This file is part of Furious. # @@ -15,51 +15,63 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from Furious.Gui.Action import Action -from Furious.Widget.Widget import Menu -from Furious.Utility.Constants import APP -from Furious.Utility.Utility import bootstrapIcon -from Furious.Utility.Translator import ( - Translatable, - gettext as _, - ABBR_TO_LANGUAGE, - LANGUAGE_TO_ABBR, -) +from Furious.QtFramework import * +from Furious.QtFramework import gettext as _ +from Furious.Utility import * import logging +import functools + +__all__ = ['LanguageAction'] logger = logging.getLogger(__name__) -SUPPORTED_LANGUAGE = tuple(LANGUAGE_TO_ABBR.values()) +# Handy stuff +SMART_CHOSEN_LANGUAGE = ( + SYSTEM_LANGUAGE if SYSTEM_LANGUAGE in SUPPORTED_LANGUAGE else 'EN' +) + +registerAppSettings( + 'Language', validRange=SUPPORTED_LANGUAGE, default=SMART_CHOSEN_LANGUAGE +) + +needTrans = functools.partial(needTransFn, source=__name__) -class LanguageChildAction(Action): +class LanguageChildAction(AppQAction): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def triggeredCallback(self, checked): - checkedLanguage = LANGUAGE_TO_ABBR[self.text()] + abbr = LANGUAGE_TO_ABBR[self.text()] - if APP().Language != checkedLanguage: + if AppSettings.get('Language') != abbr: logger.info(f'set language to \'{self.text()}\'') - APP().Language = checkedLanguage + AppSettings.set('Language', abbr) - Translatable.retranslateAll() + QTranslatable.retranslateAll() + + def retranslate(self): + # Nothing to do + pass -class LanguageAction(Action): +needTrans('Language') + + +class LanguageAction(AppQAction): def __init__(self): super().__init__( _('Language'), icon=bootstrapIcon('globe2.svg'), - menu=Menu( + menu=AppQMenu( *list( LanguageChildAction( # Language representation text, checkable=True, - checked=text == ABBR_TO_LANGUAGE[APP().Language], + checked=text == ABBR_TO_LANGUAGE[AppSettings.get('Language')], ) for text in list(LANGUAGE_TO_ABBR.keys()) ), diff --git a/Furious/TrayActions/Routing.py b/Furious/TrayActions/Routing.py new file mode 100644 index 0000000..18a3f47 --- /dev/null +++ b/Furious/TrayActions/Routing.py @@ -0,0 +1,72 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from Furious.QtFramework import * +from Furious.QtFramework import gettext as _ +from Furious.Utility import * + +import functools + +__all__ = ['RoutingAction'] + +BUILTIN_ROUTING = ['Bypass Mainland China', 'Global', 'Custom'] + +registerAppSettings('Routing', validRange=BUILTIN_ROUTING) + +needTrans = functools.partial(needTransFn, source=__name__) + +needTrans(*BUILTIN_ROUTING) + + +class RoutingChildAction(AppQAction): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def triggeredCallback(self, checked): + textEnglish = self.textEnglish + + if AppSettings.get('Routing') != textEnglish: + AppSettings.set('Routing', textEnglish) + + if APP().isSystemTrayConnected(): + APP().systemTray.ConnectAction.doDisconnect() + APP().systemTray.ConnectAction.trigger() + + +needTrans('Routing') + + +class RoutingAction(AppQAction): + def __init__(self): + if AppSettings.get('Routing') == 'Bypass': + # Update value for backward compatibility + AppSettings.set('Routing', 'Bypass Mainland China') + + super().__init__( + _('Routing'), + icon=bootstrapIcon('shuffle.svg'), + menu=AppQMenu( + *list( + RoutingChildAction( + _(routing), + checkable=True, + checked=AppSettings.get('Routing') == routing, + ) + for routing in BUILTIN_ROUTING + ), + ), + ) diff --git a/Furious/TrayActions/Settings.py b/Furious/TrayActions/Settings.py new file mode 100644 index 0000000..f9ded4d --- /dev/null +++ b/Furious/TrayActions/Settings.py @@ -0,0 +1,178 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from Furious.QtFramework import * +from Furious.QtFramework import gettext as _ +from Furious.Utility import * + +from typing import Union + +import functools + +__all__ = ['SettingsAction'] + +registerAppSettings('VPNMode', isBinary=True) +registerAppSettings('DarkMode', isBinary=True) +registerAppSettings('StartupOnBoot', isBinary=True, default=BinarySettings.ON_) +registerAppSettings( + 'ShowProgressBarWhenConnecting', isBinary=True, default=BinarySettings.ON_ +) +registerAppSettings('ShowTabAndSpacesInEditor', isBinary=True) + +needTrans = functools.partial(needTransFn, source=__name__) + +needTrans( + 'VPN Mode', + 'VPN Mode Disabled (Administrator)', + 'VPN Mode Disabled (root)', +) + + +class VPNModeAction(AppQAction): + def __init__(self, **kwargs): + if isAdministrator(): + super().__init__(_('VPN Mode'), **kwargs) + else: + super().__init__(_(f'VPN Mode Disabled ({ADMINISTRATOR_NAME})'), **kwargs) + + self.setDisabled(True) + + def triggeredCallback(self, checked): + assert isAdministrator() + + if checked: + AppSettings.turnON_('VPNMode') + else: + AppSettings.turnOFF('VPNMode') + + try: + if APP().isSystemTrayConnected(): + mbox = NewChangesNextTimeMBox() + mbox.exec() + except Exception: + # Any non-exit exceptions + + pass + + +class SettingsChildAction(AppQAction): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def triggeredCallback(self, checked): + if self.textCompare('Dark Mode'): + # Settings turn on/off order matters here + if checked: + AppSettings.turnON_('DarkMode') + + try: + APP().switchToDarkMode() + except Exception: + # Any non-exit exceptions + + pass + else: + AppSettings.turnOFF('DarkMode') + + try: + APP().switchToAutoMode() + except Exception: + # Any non-exit exceptions + + pass + if self.textCompare('Startup On Boot'): + if checked: + StartupOnBoot.on_() + + AppSettings.turnON_('StartupOnBoot') + else: + StartupOnBoot.off() + + AppSettings.turnOFF('StartupOnBoot') + if self.textCompare('Show Progress Bar When Connecting'): + if checked: + AppSettings.turnON_('ShowProgressBarWhenConnecting') + else: + AppSettings.turnOFF('ShowProgressBarWhenConnecting') + if self.textCompare('Show Tab And Spaces In Editor'): + if checked: + APP().mainWindow.showTabAndSpaces() + + AppSettings.turnON_('ShowTabAndSpacesInEditor') + else: + APP().mainWindow.hideTabAndSpaces() + + AppSettings.turnOFF('ShowTabAndSpacesInEditor') + + +needTrans( + 'Settings', + 'Dark Mode', + 'Startup On Boot', + 'Show Progress Bar When Connecting', + 'Show Tab And Spaces In Editor', +) + + +class SettingsAction(AppQAction): + def __init__(self): + if PLATFORM == 'Windows' or PLATFORM == 'Darwin': + extraActions = [ + VPNModeAction( + checkable=True, + checked=AppSettings.isStateON_('VPNMode'), + ), + AppQSeperator(), + ] + else: + extraActions = [None] + + super().__init__( + _('Settings'), + icon=bootstrapIcon('gear-wide-connected.svg'), + menu=AppQMenu( + *extraActions, + SettingsChildAction( + _('Dark Mode'), + checkable=True, + checked=AppSettings.isStateON_('DarkMode'), + ), + SettingsChildAction( + _('Startup On Boot'), + checkable=True, + checked=AppSettings.isStateON_('StartupOnBoot'), + ), + SettingsChildAction( + _('Show Progress Bar When Connecting'), + checkable=True, + checked=AppSettings.isStateON_('ShowProgressBarWhenConnecting'), + ), + SettingsChildAction( + _('Show Tab And Spaces In Editor'), + checkable=True, + checked=AppSettings.isStateON_('ShowTabAndSpacesInEditor'), + ), + ), + useActionGroup=False, + ) + + def getVPNModeAction(self) -> Union[AppQAction, None]: + if PLATFORM == 'Windows' or PLATFORM == 'Darwin': + # 1st action + return self._menu.actions()[0] + else: + return None diff --git a/Furious/TrayActions/SystemProxy.py b/Furious/TrayActions/SystemProxy.py new file mode 100644 index 0000000..2632d29 --- /dev/null +++ b/Furious/TrayActions/SystemProxy.py @@ -0,0 +1,68 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from Furious.QtFramework import * +from Furious.QtFramework import gettext as _ +from Furious.Utility import * + +import functools + +__all__ = ['SystemProxyAction'] + +BUILTIN_PROXY_MODE = ['Auto', 'NoChanges'] + +registerAppSettings('SystemProxyMode', validRange=BUILTIN_PROXY_MODE) + +needTrans = functools.partial(needTransFn, source=__name__) + + +class SystemProxyChildAction(AppQAction): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def triggeredCallback(self, checked): + if self.textCompare('Automatically Configure System Proxy'): + AppSettings.set('SystemProxyMode', 'Auto') + if self.textCompare('Do Not Change System Proxy'): + AppSettings.set('SystemProxyMode', 'NoChanges') + + +needTrans( + 'System Proxy', + 'Automatically Configure System Proxy', + 'Do Not Change System Proxy', +) + + +class SystemProxyAction(AppQAction): + def __init__(self): + super().__init__( + _('System Proxy'), + icon=bootstrapIcon('hdd-network.svg'), + menu=AppQMenu( + SystemProxyChildAction( + _('Automatically Configure System Proxy'), + checkable=True, + checked=AppSettings.get('SystemProxyMode') == 'Auto', + ), + SystemProxyChildAction( + _('Do Not Change System Proxy'), + checkable=True, + checked=AppSettings.get('SystemProxyMode') == 'NoChanges', + ), + ), + ) diff --git a/Furious/TrayActions/__init__.py b/Furious/TrayActions/__init__.py new file mode 100644 index 0000000..5825b6c --- /dev/null +++ b/Furious/TrayActions/__init__.py @@ -0,0 +1,25 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from .Connect import * +from .EditConfiguration import * +from .Exit import * +from .Import import * +from .Language import * +from .Routing import * +from .Settings import * +from .SystemProxy import * diff --git a/Furious/Utility/Process.py b/Furious/Utility/AppMainProcess.py similarity index 75% rename from Furious/Utility/Process.py rename to Furious/Utility/AppMainProcess.py index f94e6e6..ca0b1c8 100644 --- a/Furious/Utility/Process.py +++ b/Furious/Utility/AppMainProcess.py @@ -1,4 +1,4 @@ -# Copyright (C) 2023 Loren Eteval +# Copyright (C) 2024 Loren Eteval # # This file is part of Furious. # @@ -15,18 +15,24 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from Furious.Widget.Application import Application -from Furious.Utility.Constants import APP, PLATFORM, CRASH_LOG_DIR +from __future__ import annotations + +from Furious.Interface import * +from Furious.Utility.Constants import * + +from typing import Callable import os import sys import logging import datetime import operator -import traceback import functools +import traceback import multiprocessing +__all__ = ['AppMainProcess'] + logger = logging.getLogger(__name__) if PLATFORM == 'Windows': @@ -35,13 +41,15 @@ else: ProcessContext = multiprocessing.get_context('spawn') -class Process(ProcessContext.Process): - def __init__(self, **kwargs): +class AppMainProcess(ProcessContext.Process): + def __init__(self, loaderFn: Callable[[], ApplicationFactory], **kwargs): super().__init__(**kwargs) - self.startUpTime = str(datetime.datetime.now()).replace(':', '') - self.logFileName = f'{self.startUpTime}.log' + self.startupTime = str(datetime.datetime.now()).replace(':', '') + self.logFileName = f'{self.startupTime}.log' self.fileWritten = multiprocessing.Manager().Value('b', False) + + self.appLoaderFn = loaderFn self.application = None def exceptHook(self, exceptionType, exceptionValue, tb): @@ -49,24 +57,25 @@ class Process(ProcessContext.Process): traceback.print_exception(exceptionType, exceptionValue, tb) if isinstance(exceptionValue, AssertionError): - error = Application.ErrorCode.AssertionError + exitcode = ApplicationFactory.ExitCode.AssertionError else: - error = Application.ErrorCode.UnknownException + exitcode = ApplicationFactory.ExitCode.UnknownException - logger.error(f'stopped with exitcode {error}') + logger.error(f'stopped with exitcode {exitcode}') self.saveCrashLog(exceptionType, exceptionValue, tb) if APP() is not None: - APP().exit(error) + APP().exit(exitcode) else: - sys.exit(error) + sys.exit(exitcode) def saveCrashLog(self, exceptionType, exceptionValue, tb): try: os.mkdir(CRASH_LOG_DIR) except FileExistsError: # Directory already exists + pass except Exception: # Any non-exit exceptions @@ -96,6 +105,6 @@ class Process(ProcessContext.Process): def run(self): sys.excepthook = self.exceptHook - self.application = Application(sys.argv) + self.application = self.appLoaderFn() sys.exit(self.application.run()) diff --git a/Furious/Utility/Resources.py b/Furious/Utility/AppResources.py similarity index 73% rename from Furious/Utility/Resources.py rename to Furious/Utility/AppResources.py index 9176d47..6ba1c2b 100644 --- a/Furious/Utility/Resources.py +++ b/Furious/Utility/AppResources.py @@ -147,6 +147,110 @@ zM11.27 2.461c.1\ 77V4.09c.81-.03 \ 1.577-.13 2.282-\ .287z\x22/>\x0a\ +\x00\x00\x02S\ +<\ +svg xmlns=\x22http:\ +//www.w3.org/200\ +0/svg\x22 width=\x2216\ +\x22 height=\x2216\x22 fi\ +ll=\x22currentColor\ +\x22 class=\x22bi bi-h\ +dd-network\x22 view\ +Box=\x220 0 16 16\x22>\ +\x0a \x0a \x0a\ +\x00\x00\x03\xda\ +<\ +svg xmlns=\x22http:\ +//www.w3.org/200\ +0/svg\x22 width=\x2216\ +\x22 height=\x2216\x22 fi\ +ll=\x22currentColor\ +\x22 class=\x22bi bi-p\ +in-angle\x22 viewBo\ +x=\x220 0 16 16\x22>\x0a \ + \x0a\ \x00\x00\x01\x94\ <\ svg xmlns=\x22http:\ @@ -425,6 +529,66 @@ m-.431.355A4.984\ 4.322zm.344 7.6\ 46.087.065-.087-\ .065z\x22/>\x0a\ +\x00\x00\x03\x9a\ +<\ +svg xmlns=\x22http:\ +//www.w3.org/200\ +0/svg\x22 width=\x2216\ +\x22 height=\x2216\x22 fi\ +ll=\x22currentColor\ +\x22 class=\x22bi bi-t\ +ools\x22 viewBox=\x220\ + 0 16 16\x22>\x0a \x0a\ \x00\x00\x03\xfc\ <\ svg xmlns=\x22http:\ @@ -491,6 +655,93 @@ c-.918.093-1.944\ 5 0 0 1-3 0v-1a.\ 5.5 0 0 1 .5-.5Z\ \x22/>\x0d\x0a\ +\x00\x00\x03\x8f\ +<\ +svg xmlns=\x22http:\ +//www.w3.org/200\ +0/svg\x22 width=\x2216\ +\x22 height=\x2216\x22 fi\ +ll=\x22currentColor\ +\x22 class=\x22bi bi-w\ +ifi\x22 viewBox=\x220 \ +0 16 16\x22>\x0a \x0a \x0a\ +\x00\x00\x01\x95\ +<\ +svg xmlns=\x22http:\ +//www.w3.org/200\ +0/svg\x22 width=\x2216\ +\x22 height=\x2216\x22 fi\ +ll=\x22currentColor\ +\x22 class=\x22bi bi-c\ +ollection\x22 viewB\ +ox=\x220 0 16 16\x22>\x0a\ + \x0a\ \x00\x00\x01\x83\ <\ svg xmlns=\x22http:\ @@ -889,6 +1140,44 @@ A7.568 7.568 0 0\ 96 5.054-1.902Z\x22\ />\x0d\x0a \x0d\x0a\ +\x00\x00\x02:\ +<\ +svg xmlns=\x22http:\ +//www.w3.org/200\ +0/svg\x22 width=\x2216\ +\x22 height=\x2216\x22 fi\ +ll=\x22currentColor\ +\x22 class=\x22bi bi-d\ +isplay\x22 viewBox=\ +\x220 0 16 16\x22>\x0a <\ +path d=\x22M0 4s0-2\ + 2-2h12s2 0 2 2v\ +6s0 2-2 2h-4q0 1\ + .25 1.5H11a.5.5\ + 0 0 1 0 1H5a.5.\ +5 0 0 1 0-1h.75Q\ +6 13 6 12H2s-2 0\ +-2-2zm1.398-.855\ +a.76.76 0 0 0-.2\ +54.302A1.5 1.5 0\ + 0 0 1 4.01V10c0\ + .325.078.502.14\ +5.602q.105.156.3\ +02.254a1.5 1.5 0\ + 0 0 .538.143L2.\ +01 11H14c.325 0 \ +.502-.078.602-.1\ +45a.76.76 0 0 0 \ +.254-.302 1.5 1.\ +5 0 0 0 .143-.53\ +8L15 9.99V4c0-.3\ +25-.078-.502-.14\ +5-.602a.76.76 0 \ +0 0-.302-.254A1.\ +5 1.5 0 0 0 13.9\ +9 3H2c-.325 0-.5\ +02.078-.602.145\x22\ +/>\x0a\ \x00\x00\x010\ <\ svg xmlns=\x22http:\ @@ -1200,6 +1489,66 @@ v6a.5.5 0 0 1-.5\ 5A1.5 1.5 0 0 0 \ 1 2.5v11z\x22/>\x0d\x0a\ +\x00\x00\x01\xcc\ +<\ +svg xmlns=\x22http:\ +//www.w3.org/200\ +0/svg\x22 width=\x2216\ +\x22 height=\x2216\x22 fi\ +ll=\x22currentColor\ +\x22 class=\x22bi bi-i\ +nfo-circle\x22 view\ +Box=\x220 0 16 16\x22>\ +\x0a \x0a \x0a\ +\x00\x00\x01\xae\ +<\ +svg xmlns=\x22http:\ +//www.w3.org/200\ +0/svg\x22 width=\x2216\ +\x22 height=\x2216\x22 fi\ +ll=\x22currentColor\ +\x22 class=\x22bi bi-d\ +ownload\x22 viewBox\ +=\x220 0 16 16\x22>\x0d\x0a \ + \x0d\x0a \x0d\x0a\ \x00\x00\x07\xf7\ <\ svg xmlns=\x22http:\ @@ -1330,35 +1679,6 @@ c-1.102.992-1.96\ -.896 5.054-1.90\ 2Z\x22 />\x0d\x0a \x0d\x0a\ \ -\x00\x00\x01\xae\ -<\ -svg xmlns=\x22http:\ -//www.w3.org/200\ -0/svg\x22 width=\x2216\ -\x22 height=\x2216\x22 fi\ -ll=\x22currentColor\ -\x22 class=\x22bi bi-d\ -ownload\x22 viewBox\ -=\x220 0 16 16\x22>\x0d\x0a \ - \x0d\x0a \x0d\x0a\ \x00\x00\x01w\ <\ svg xmlns=\x22http:\ @@ -1385,6 +1705,68 @@ l-4.2-4.2a.5.5 0\ 14 6.8V2a.5.5 0\ 0 1 .5-.5z\x22/>\x0d\x0a\ \ +\x00\x00\x03\xb2\ +<\ +svg xmlns=\x22http:\ +//www.w3.org/200\ +0/svg\x22 width=\x2216\ +\x22 height=\x2216\x22 fi\ +ll=\x22currentColor\ +\x22 class=\x22bi bi-r\ +outer\x22 viewBox=\x22\ +0 0 16 16\x22>\x0a \x0a \x0a \ +\x0a \x0a\ \x00\x00\x07\xf4\ <\ svg xmlns=\x22http:\ @@ -1745,6 +2127,112 @@ a6.963 6.963 0 0\ 4.09c.81-.03 1.5\ 77-.13 2.282-.28\ 7z\x22/>\x0a\ +\x00\x00\x02m\ +<\ +svg xmlns=\x22http:\ +//www.w3.org/200\ +0/svg\x22 width=\x2216\ +\x22 height=\x2216\x22 fi\ +ll=\x22currentColor\ +\x22 class=\x22bi bi-h\ +dd-network\x22 view\ +Box=\x220 0 16 16\x22>\ +\x0a \x0a \x0a\ +\x00\x00\x03\xe7\ +<\ +svg xmlns=\x22http:\ +//www.w3.org/200\ +0/svg\x22 width=\x2216\ +\x22 height=\x2216\x22 fi\ +ll=\x22currentColor\ +\x22 class=\x22bi bi-p\ +in-angle\x22 viewBo\ +x=\x220 0 16 16\x22>\x0a \ + \x0a\ +\ \x00\x00\x01\xa1\ <\ svg xmlns=\x22http:\ @@ -1896,6 +2384,67 @@ l.205.211a.96.96\ 322zm.344 7.646.\ 087.065-.087-.06\ 5z\x22/>\x0a\ +\x00\x00\x03\xa7\ +<\ +svg xmlns=\x22http:\ +//www.w3.org/200\ +0/svg\x22 width=\x2216\ +\x22 height=\x2216\x22 fi\ +ll=\x22currentColor\ +\x22 class=\x22bi bi-t\ +ools\x22 viewBox=\x220\ + 0 16 16\x22>\x0a \x0a\ +\ \x00\x00\x04\x09\ <\ svg xmlns=\x22http:\ @@ -1963,6 +2512,96 @@ m.015 3.867a.25.\ 0 1-3 0v-1a.5.5\ 0 0 1 .5-.5Z\x22/>\ \x0d\x0a\ +\x00\x00\x03\xa9\ +<\ +svg xmlns=\x22http:\ +//www.w3.org/200\ +0/svg\x22 width=\x2216\ +\x22 height=\x2216\x22 fi\ +ll=\x22currentColor\ +\x22 class=\x22bi bi-w\ +ifi\x22 viewBox=\x220 \ +0 16 16\x22>\x0a \x0a \x0a\ +\x00\x00\x01\xa2\ +<\ +svg xmlns=\x22http:\ +//www.w3.org/200\ +0/svg\x22 width=\x2216\ +\x22 height=\x2216\x22 fi\ +ll=\x22currentColor\ +\x22 class=\x22bi bi-c\ +ollection\x22 viewB\ +ox=\x220 0 16 16\x22>\x0a\ + \x0a\ \x00\x00\x01\x90\ <\ svg xmlns=\x22http:\ @@ -2107,6 +2746,45 @@ H2.5a.5.5 0 0 0-\ 5.5 0 0 0 14.367\ 7H1.633z\x22/>\x0a\ +\x00\x00\x02G\ +<\ +svg xmlns=\x22http:\ +//www.w3.org/200\ +0/svg\x22 width=\x2216\ +\x22 height=\x2216\x22 fi\ +ll=\x22currentColor\ +\x22 class=\x22bi bi-d\ +isplay\x22 viewBox=\ +\x220 0 16 16\x22>\x0a <\ +path fill=\x22white\ +\x22 d=\x22M0 4s0-2 2-\ +2h12s2 0 2 2v6s0\ + 2-2 2h-4q0 1 .2\ +5 1.5H11a.5.5 0 \ +0 1 0 1H5a.5.5 0\ + 0 1 0-1h.75Q6 1\ +3 6 12H2s-2 0-2-\ +2zm1.398-.855a.7\ +6.76 0 0 0-.254.\ +302A1.5 1.5 0 0 \ +0 1 4.01V10c0 .3\ +25.078.502.145.6\ +02q.105.156.302.\ +254a1.5 1.5 0 0 \ +0 .538.143L2.01 \ +11H14c.325 0 .50\ +2-.078.602-.145a\ +.76.76 0 0 0 .25\ +4-.302 1.5 1.5 0\ + 0 0 .143-.538L1\ +5 9.99V4c0-.325-\ +.078-.502-.145-.\ +602a.76.76 0 0 0\ +-.302-.254A1.5 1\ +.5 0 0 0 13.99 3\ +H2c-.325 0-.502.\ +078-.602.145\x22/>\x0a\ +\ \x00\x00\x01=\ <\ svg xmlns=\x22http:\ @@ -2294,6 +2972,39 @@ a1.5 1.5 0 0 0 1\ 0 0-1H2.5A1.5 1.\ 5 0 0 0 1 2.5v11\ z\x22/>\x0a\ +\x00\x00\x01\xe6\ +<\ +svg xmlns=\x22http:\ +//www.w3.org/200\ +0/svg\x22 width=\x2216\ +\x22 height=\x2216\x22 fi\ +ll=\x22currentColor\ +\x22 class=\x22bi bi-i\ +nfo-circle\x22 view\ +Box=\x220 0 16 16\x22>\ +\x0a \x0a\ + \x0a<\ +/svg>\ \x00\x00\x01\xc8\ <\ svg xmlns=\x22http:\ @@ -2352,6 +3063,71 @@ L2.707 8.3H12.5A\ 6.8V2a.5.5 0 0 \ 1 .5-.5z\x22/>\x0d\x0a\ +\x00\x00\x03\xe6\ +<\ +svg xmlns=\x22http:\ +//www.w3.org/200\ +0/svg\x22 width=\x2216\ +\x22 height=\x2216\x22 fi\ +ll=\x22currentColor\ +\x22 class=\x22bi bi-r\ +outer\x22 viewBox=\x22\ +0 0 16 16\x22>\x0a \x0a \x0a \x0a <\ +path fill=\x22white\ +\x22 d=\x22M8.5 5.5a.5\ +.5 0 1 1-1 0 .5.\ +5 0 0 1 1 0\x22/>\x0a<\ +/svg>\ \x00\x00\x02B\ <\ svg xmlns=\x22http:\ @@ -2462,6 +3238,14 @@ qt_resource_name = b"\ \x08\x88\x9cg\ \x00g\ \x00l\x00o\x00b\x00e\x002\x00.\x00s\x00v\x00g\ +\x00\x0f\ +\x08\x06w\x87\ +\x00h\ +\x00d\x00d\x00-\x00n\x00e\x00t\x00w\x00o\x00r\x00k\x00.\x00s\x00v\x00g\ +\x00\x0d\ +\x04&+\x87\ +\x00p\ +\x00i\x00n\x00-\x00a\x00n\x00g\x00l\x00e\x00.\x00s\x00v\x00g\ \x00\x14\ \x0eq\x8c\xa7\ \x00l\ @@ -2482,10 +3266,22 @@ qt_resource_name = b"\ \x00g\ \x00e\x00a\x00r\x00-\x00w\x00i\x00d\x00e\x00-\x00c\x00o\x00n\x00n\x00e\x00c\x00t\ \x00e\x00d\x00.\x00s\x00v\x00g\ +\x00\x09\ +\x066\xac\x07\ +\x00t\ +\x00o\x00o\x00l\x00s\x00.\x00s\x00v\x00g\ \x00\x0d\ \x00&\xe7\xa7\ \x00i\ \x00n\x00c\x00o\x00g\x00n\x00i\x00t\x00o\x00.\x00s\x00v\x00g\ +\x00\x08\ +\x0f\xccUg\ +\x00w\ +\x00i\x00f\x00i\x00.\x00s\x00v\x00g\ +\x00\x0e\ +\x04D5\x07\ +\x00c\ +\x00o\x00l\x00l\x00e\x00c\x00t\x00i\x00o\x00n\x00.\x00s\x00v\x00g\ \x00\x09\ \x02\xc6\x80\xc7\ \x00f\ @@ -2513,6 +3309,10 @@ qt_resource_name = b"\ \x00o\x00c\x00k\x00e\x00t\x00-\x00t\x00a\x00k\x00e\x00o\x00f\x00f\x00-\x00c\x00o\ \x00n\x00n\x00e\x00c\x00t\x00e\x00d\x00-\x00d\x00a\x00r\x00k\x00.\x00s\x00v\x00g\ \ +\x00\x0b\ +\x02^N'\ +\x00d\ +\x00i\x00s\x00p\x00l\x00a\x00y\x00.\x00s\x00v\x00g\ \x00\x10\ \x08\xe2\x08\x07\ \x00f\ @@ -2543,21 +3343,29 @@ qt_resource_name = b"\ \x00p\ \x00e\x00n\x00c\x00i\x00l\x00-\x00s\x00q\x00u\x00a\x00r\x00e\x00.\x00s\x00v\x00g\ \ +\x00\x0f\ +\x04cZ\xe7\ +\x00i\ +\x00n\x00f\x00o\x00-\x00c\x00i\x00r\x00c\x00l\x00e\x00.\x00s\x00v\x00g\ +\x00\x0c\ +\x08\x1a\x90\xa7\ +\x00d\ +\x00o\x00w\x00n\x00l\x00o\x00a\x00d\x00.\x00s\x00v\x00g\ \x00\x22\ \x0b\x95\x94\x07\ \x00r\ \x00o\x00c\x00k\x00e\x00t\x00-\x00t\x00a\x00k\x00e\x00o\x00f\x00f\x00-\x00a\x00d\ \x00m\x00i\x00n\x00-\x00c\x00o\x00n\x00n\x00e\x00c\x00t\x00e\x00d\x00.\x00s\x00v\ \x00g\ -\x00\x0c\ -\x08\x1a\x90\xa7\ -\x00d\ -\x00o\x00w\x00n\x00l\x00o\x00a\x00d\x00.\x00s\x00v\x00g\ \x00\x15\ \x09\xef\x1dg\ \x00a\ \x00r\x00r\x00o\x00w\x00-\x00r\x00e\x00t\x00u\x00r\x00n\x00-\x00l\x00e\x00f\x00t\ \x00.\x00s\x00v\x00g\ +\x00\x0a\ +\x0a\xcawG\ +\x00r\ +\x00o\x00u\x00t\x00e\x00r\x00.\x00s\x00v\x00g\ \x00\x1c\ \x04\xa9\xe3g\ \x00r\ @@ -2583,102 +3391,134 @@ qt_resource_struct = b"\ \x00\x00\x00\x00\x00\x00\x00\x00\ \x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02\ \x00\x00\x00\x00\x00\x00\x00\x00\ -\x00\x00\x00\x10\x00\x02\x00\x00\x00\x1b\x00\x00\x00\x03\ +\x00\x00\x00\x10\x00\x02\x00\x00\x00#\x00\x00\x00\x03\ \x00\x00\x00\x00\x00\x00\x00\x00\ -\x00\x00\x01R\x00\x00\x00\x00\x00\x01\x00\x00\x19Z\ +\x00\x00\x01\xae\x00\x00\x00\x00\x00\x01\x00\x00#-\ \x00\x00\x01\x8akGd\x22\ -\x00\x00\x03\x0c\x00\x02\x00\x00\x00\x14\x00\x00\x00\x1e\ +\x00\x00\x03\xbc\x00\x02\x00\x00\x00\x1c\x00\x00\x00&\ \x00\x00\x00\x00\x00\x00\x00\x00\ -\x00\x00\x02\x14\x00\x00\x00\x00\x00\x01\x00\x00-^\ +\x00\x00\x02\xa8\x00\x00\x00\x00\x00\x01\x00\x00<]\ \x00\x00\x01\x89(\xfeb\x85\ -\x00\x00\x02\xa6\x00\x00\x00\x00\x00\x01\x00\x007\xac\ +\x00\x00\x03V\x00\x00\x00\x00\x00\x01\x00\x00H\xe9\ \x00\x00\x01\x89(\xfeb\x87\ -\x00\x00\x01r\x00\x00\x00\x00\x00\x01\x00\x00\x1dZ\ +\x00\x00\x02\xf0\x00\x00\x00\x00\x00\x01\x00\x00DU\ +\x00\x00\x01\x8dn|S\xe5\ +\x00\x00\x02\x06\x00\x00\x00\x00\x00\x01\x00\x00,Y\ \x00\x00\x01\x89q\xdb\xa0\xb9\ -\x00\x00\x01\xee\x00\x00\x00\x00\x00\x01\x00\x00*\xe3\ +\x00\x00\x02\x82\x00\x00\x00\x00\x00\x01\x00\x009\xe2\ \x00\x00\x01\x88j\xc4\x1f\xc9\ -\x00\x00\x03\xdc\x00\x00\x00\x00\x00\x01\x00\x00S\x22\ +\x00\x00\x00\x98\x00\x00\x00\x00\x00\x01\x00\x00\x0a\xe5\ +\x00\x00\x01\x8dZ\x0d\x14\xb2\ +\x00\x00\x01\xe4\x00\x00\x00\x00\x00\x01\x00\x00*\xc0\ +\x00\x00\x01\x8dK\x97\x88\xed\ +\x00\x00\x03\xf4\x00\x00\x00\x00\x00\x01\x00\x00Y7\ +\x00\x00\x01\x8dZ4O\xe8\ +\x00\x00\x04\xca\x00\x00\x00\x00\x00\x01\x00\x00i\xe5\ \x00\x00\x01\x87x\xc2\xafR\ -\x00\x00\x01\xa6\x00\x00\x00\x00\x00\x01\x00\x00!:\ +\x00\x00\x02:\x00\x00\x00\x00\x00\x01\x00\x0009\ \x00\x00\x01\x87x\xc2\xafS\ -\x00\x00\x01\x8a\x00\x00\x00\x00\x00\x01\x00\x00\x1e\xe1\ +\x00\x00\x02\x1e\x00\x00\x00\x00\x00\x01\x00\x00-\xe0\ \x00\x00\x01\x8akGd\x22\ -\x00\x00\x03\x1c\x00\x00\x00\x00\x00\x01\x00\x00E\xb1\ +\x00\x00\x01\x96\x00\x00\x00\x00\x00\x01\x00\x00\x1f\x8f\ +\x00\x00\x01\x8dc\xb0\xf9\xfd\ +\x00\x00\x03\xcc\x00\x00\x00\x00\x00\x01\x00\x00V\xee\ \x00\x00\x01\x89q\xe4\xd2~\ -\x00\x00\x03\x8e\x00\x00\x00\x00\x00\x01\x00\x00O\xf5\ +\x00\x00\x00t\x00\x00\x00\x00\x00\x01\x00\x00\x08\x8e\ +\x00\x00\x01\x8dn\x81\xcf\xd7\ +\x00\x00\x04\x18\x00\x00\x00\x00\x00\x01\x00\x00[\x07\ \x00\x00\x01\x8akGd\x22\ \x00\x00\x00Z\x00\x00\x00\x00\x00\x01\x00\x00\x01z\ \x00\x00\x01\x85a_\xd8\xe6\ -\x00\x00\x00\xa2\x00\x00\x00\x00\x00\x01\x00\x00\x0a&\ +\x00\x00\x00\xe6\x00\x00\x00\x00\x00\x01\x00\x00\x10[\ \x00\x00\x01\x8a\xb6\xf3\x1c:\ -\x00\x00\x04j\x00\x00\x00\x00\x00\x01\x00\x00^[\ +\x00\x00\x05X\x00\x00\x00\x00\x00\x01\x00\x00u\x1e\ \x00\x00\x01\x88j\xc3_y\ -\x00\x00\x02\x5c\x00\x00\x00\x00\x00\x01\x00\x005V\ +\x00\x00\x03\x0c\x00\x00\x00\x00\x00\x01\x00\x00F\x93\ \x00\x00\x01\x89\xdf:\xd3\xbe\ -\x00\x00\x01\xd0\x00\x00\x00\x00\x00\x01\x00\x00)4\ +\x00\x00\x02d\x00\x00\x00\x00\x00\x01\x00\x0083\ \x00\x00\x01\x89q\xdb\xa0\xb9\ -\x00\x00\x03\xac\x00\x00\x00\x00\x00\x01\x00\x00Q\xa7\ +\x00\x00\x04\x80\x00\x00\x00\x00\x00\x01\x00\x00d\xb4\ \x00\x00\x01\x89q\xdb\xa0\xb9\ -\x00\x00\x00\xe6\x00\x00\x00\x00\x00\x01\x00\x00\x0c<\ +\x00\x00\x01*\x00\x00\x00\x00\x00\x01\x00\x00\x12q\ \x00\x00\x01\x87\x09\x8a\x8e<\ -\x00\x00\x02\xf6\x00\x00\x00\x00\x00\x01\x00\x00C2\ +\x00\x00\x03\xa6\x00\x00\x00\x00\x00\x01\x00\x00To\ \x00\x00\x01\x8a\x97O\x0e\xd4\ -\x00\x00\x04J\x00\x00\x00\x00\x00\x01\x00\x00]F\ +\x00\x00\x058\x00\x00\x00\x00\x00\x01\x00\x00t\x09\ \x00\x00\x01\x85b\x0b\xe8\xa4\ -\x00\x00\x02\x82\x00\x00\x00\x00\x00\x01\x00\x006\x8a\ +\x00\x00\x032\x00\x00\x00\x00\x00\x01\x00\x00G\xc7\ \x00\x00\x01\x85b\x0b\xdc\xdc\ -\x00\x00\x04\x1a\x00\x00\x00\x00\x00\x01\x00\x00[\x1a\ +\x00\x00\x04\xb0\x00\x00\x00\x00\x00\x01\x00\x00f/\ +\x00\x00\x01\x8dnV\xcf6\ +\x00\x00\x05\x08\x00\x00\x00\x00\x00\x01\x00\x00q\xdd\ \x00\x00\x01\x85ae+\xea\ -\x00\x00\x03D\x00\x00\x00\x00\x00\x01\x00\x00G\xfa\ +\x00\x00\x046\x00\x00\x00\x00\x00\x01\x00\x00\x5c\xb9\ \x00\x00\x01\x8b\x9erg^\ -\x00\x00\x02\xda\x00\x00\x00\x00\x00\x01\x00\x00?\xa6\ +\x00\x00\x03\x8a\x00\x00\x00\x00\x00\x01\x00\x00P\xe3\ \x00\x00\x01\x88\xbf\xc3#\xed\ -\x00\x00\x01\x1e\x00\x00\x00\x00\x00\x01\x00\x00\x146\ +\x00\x00\x01b\x00\x00\x00\x00\x00\x01\x00\x00\x1ak\ \x00\x00\x01\x85ae\x0eD\ -\x00\x00\x00t\x00\x00\x00\x00\x00\x01\x00\x00\x08\x8e\ +\x00\x00\x00\xb8\x00\x00\x00\x00\x00\x01\x00\x00\x0e\xc3\ \x00\x00\x01\x88e~\x9cP\ \x00\x00\x00(\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\ \x00\x00\x01\x89q\xdb\xa0\xb9\ -\x00\x00\x01R\x00\x00\x00\x00\x00\x01\x00\x00q\xc4\ +\x00\x00\x01\xce\x00\x00\x00\x00\x00\x01\x00\x00'-\ +\x00\x00\x01\x8dn\x7f\x82l\ +\x00\x00\x01\xae\x00\x00\x00\x00\x00\x01\x00\x00\x92\x8e\ \x00\x00\x01\x8akGd#\ -\x00\x00\x01r\x00\x00\x00\x00\x00\x01\x00\x00u\xd1\ +\x00\x00\x02\xf0\x00\x00\x00\x00\x00\x01\x00\x00\xa4`\ +\x00\x00\x01\x8dn|\xdf\xb8\ +\x00\x00\x02\x06\x00\x00\x00\x00\x00\x01\x00\x00\x9b\xee\ \x00\x00\x01\x89q\xdb\xa0\xba\ -\x00\x00\x01\xee\x00\x00\x00\x00\x00\x01\x00\x00{\xbb\ +\x00\x00\x02\x82\x00\x00\x00\x00\x00\x01\x00\x00\xa1\xd8\ \x00\x00\x01\x893\xb5sJ\ -\x00\x00\x01\x8a\x00\x00\x00\x00\x00\x01\x00\x00we\ +\x00\x00\x00\x98\x00\x00\x00\x00\x00\x01\x00\x00\x81\xf2\ +\x00\x00\x01\x8dZ\x0d\xc0*\ +\x00\x00\x01\xe4\x00\x00\x00\x00\x00\x01\x00\x00\x9aH\ +\x00\x00\x01\x8dK\x98d\x1a\ +\x00\x00\x03\xf4\x00\x00\x00\x00\x00\x01\x00\x00\xb1\xad\ +\x00\x00\x01\x8d]\xad\xfa\x8a\ +\x00\x00\x02\x1e\x00\x00\x00\x00\x00\x01\x00\x00\x9d\x82\ \x00\x00\x01\x8akGd#\ -\x00\x00\x03\x1c\x00\x00\x00\x00\x00\x01\x00\x00\x86\xe5\ +\x00\x00\x01\x96\x00\x00\x00\x00\x00\x01\x00\x00\x8e\xe3\ +\x00\x00\x01\x8dc\xb1\x90K\ +\x00\x00\x03\xcc\x00\x00\x00\x00\x00\x01\x00\x00\xafM\ \x00\x00\x01\x893\xb5s^\ -\x00\x00\x03\x8e\x00\x00\x00\x00\x00\x01\x00\x00\x89E\ +\x00\x00\x00t\x00\x00\x00\x00\x00\x01\x00\x00\x7f\x81\ +\x00\x00\x01\x8dn\x83K\x08\ +\x00\x00\x04\x18\x00\x00\x00\x00\x00\x01\x00\x00\xb3\x97\ \x00\x00\x01\x8akGd\x22\ -\x00\x00\x00Z\x00\x00\x00\x00\x00\x01\x00\x00a\x9d\ +\x00\x00\x00Z\x00\x00\x00\x00\x00\x01\x00\x00x`\ \x00\x00\x01\x893\xb5sr\ -\x00\x00\x00\xa2\x00\x00\x00\x00\x00\x01\x00\x00jc\ +\x00\x00\x00\xe6\x00\x00\x00\x00\x00\x01\x00\x00\x87\x82\ \x00\x00\x01\x8a\xb6\xf3\x1c;\ -\x00\x00\x04j\x00\x00\x00\x00\x00\x01\x00\x00\x90\x01\ +\x00\x00\x05X\x00\x00\x00\x00\x00\x01\x00\x00\xbe=\ \x00\x00\x01\x893\xb5sE\ -\x00\x00\x02\x5c\x00\x00\x00\x00\x00\x01\x00\x00~C\ +\x00\x00\x03\x0c\x00\x00\x00\x00\x00\x01\x00\x00\xa6\xab\ \x00\x00\x01\x89\xe3\x00+b\ -\x00\x00\x01\xd0\x00\x00\x00\x00\x00\x01\x00\x00y\xff\ +\x00\x00\x02d\x00\x00\x00\x00\x00\x01\x00\x00\xa0\x1c\ \x00\x00\x01\x89q\xdb\xa0\xba\ -\x00\x00\x03\xac\x00\x00\x00\x00\x00\x01\x00\x00\x8b\x11\ +\x00\x00\x04\x80\x00\x00\x00\x00\x00\x01\x00\x00\xb5c\ \x00\x00\x01\x89q\xdb\xa0\xba\ -\x00\x00\x02\xf6\x00\x00\x00\x00\x00\x01\x00\x00\x84Y\ +\x00\x00\x03\xa6\x00\x00\x00\x00\x00\x01\x00\x00\xac\xc1\ \x00\x00\x01\x8a\x97O\xa6\xc0\ -\x00\x00\x04J\x00\x00\x00\x00\x00\x01\x00\x00\x8e\xdf\ +\x00\x00\x058\x00\x00\x00\x00\x00\x01\x00\x00\xbd\x1b\ \x00\x00\x01\x893\xb5sg\ -\x00\x00\x02\x82\x00\x00\x00\x00\x00\x01\x00\x00\x7f\x84\ +\x00\x00\x032\x00\x00\x00\x00\x00\x01\x00\x00\xa7\xec\ \x00\x00\x01\x893\xb5sZ\ -\x00\x00\x04\x1a\x00\x00\x00\x00\x00\x01\x00\x00\x8c\x99\ +\x00\x00\x04\xb0\x00\x00\x00\x00\x00\x01\x00\x00\xb6\xeb\ +\x00\x00\x01\x8dnW\xb0\xbd\ +\x00\x00\x05\x08\x00\x00\x00\x00\x00\x01\x00\x00\xba\xd5\ \x00\x00\x01\x893\xb5sv\ -\x00\x00\x02\xda\x00\x00\x00\x00\x00\x01\x00\x00\x80\xb3\ +\x00\x00\x03\x8a\x00\x00\x00\x00\x00\x01\x00\x00\xa9\x1b\ \x00\x00\x01\x893\xb5sP\ -\x00\x00\x01\x1e\x00\x00\x00\x00\x00\x01\x00\x00l\x93\ +\x00\x00\x01b\x00\x00\x00\x00\x00\x01\x00\x00\x89\xb2\ \x00\x00\x01\x893\xb5sU\ -\x00\x00\x00t\x00\x00\x00\x00\x00\x01\x00\x00h\xbe\ +\x00\x00\x00\xb8\x00\x00\x00\x00\x00\x01\x00\x00\x85\xdd\ \x00\x00\x01\x893\xb5sl\ -\x00\x00\x00(\x00\x00\x00\x00\x00\x01\x00\x00`\x16\ +\x00\x00\x00(\x00\x00\x00\x00\x00\x01\x00\x00v\xd9\ \x00\x00\x01\x89q\xdb\xa0\xba\ +\x00\x00\x01\xce\x00\x00\x00\x00\x00\x01\x00\x00\x96\x9b\ +\x00\x00\x01\x8dn\x80v\xb5\ " def qInitResources(): diff --git a/Furious/Utility/AppSettings.py b/Furious/Utility/AppSettings.py new file mode 100644 index 0000000..da4debb --- /dev/null +++ b/Furious/Utility/AppSettings.py @@ -0,0 +1,132 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from __future__ import annotations + +from Furious.Utility.Utility import BinarySettings + +from PySide6 import QtCore + +import logging + +__all__ = ['AppSettings', 'registerAppSettings'] + +logger = logging.getLogger(__name__) + + +class AppSettings: + SettingsPool: dict[str, AppSettings] = dict() + + def __init__( + self, + name: str, + isBinary=False, + validRange: list = None, + default=None, + ): + self.name = name + self.isBinary = isBinary + + if isBinary: + self.validRange = BinarySettings.RANGE + self.default = BinarySettings.OFF if default is None else default + else: + self.validRange = validRange + + if validRange is None: + self.default = default + else: + self.default = validRange[0] if default is None else default + + if name in AppSettings.SettingsPool: + raise ValueError(f'\'{name}\' already exists in AppSettings') + + def validate(self, value) -> bool: + if self.validRange is None: + return True + else: + return value in self.validRange + + @staticmethod + def get(key: str): + settings = AppSettings.SettingsPool.get(key) + + if settings is None: + raise AttributeError(f'AppSettings \'{key}\' not found') + + assert isinstance(settings, AppSettings) + + value = QtCore.QSettings().value(settings.name) + + if settings.validate(value): + return value + else: + logger.error( + f'settings \'{settings.name}\' has value \'{value}\', ' + f'which is not in valid range {settings.validRange}. ' + f'Set to default \'{settings.default}\'' + ) + + # Value not in valid range, set to default + QtCore.QSettings().setValue(settings.name, settings.default) + + return settings.default + + @staticmethod + def isStateON_(key: str) -> bool: + value = AppSettings.get(key) + + if value == BinarySettings.ON_: + return True + else: + return False + + @staticmethod + def isStateOFF(key: str) -> bool: + value = AppSettings.get(key) + + if value == BinarySettings.OFF: + return True + else: + return False + + @staticmethod + def set(key: str, value): + settings = AppSettings.SettingsPool.get(key) + + if settings is None: + raise AttributeError(f'AppSettings \'{key}\' not found') + + assert isinstance(settings, AppSettings) + + if settings.validate(value): + QtCore.QSettings().setValue(settings.name, value) + else: + # Value not in valid range, raise exception + raise ValueError(f'Invalid AppSettings value \'{value}\' for \'{key}\'') + + @staticmethod + def turnON_(key: str): + AppSettings.set(key, BinarySettings.ON_) + + @staticmethod + def turnOFF(key: str): + AppSettings.set(key, BinarySettings.OFF) + + +def registerAppSettings(name: str, *args, **kwargs): + AppSettings.SettingsPool[name] = AppSettings(name, *args, **kwargs) diff --git a/Furious/Utility/AppSettingsFn.py b/Furious/Utility/AppSettingsFn.py new file mode 100644 index 0000000..8b8a6d0 --- /dev/null +++ b/Furious/Utility/AppSettingsFn.py @@ -0,0 +1,57 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from __future__ import annotations + +from Furious.Utility.Constants import * +from Furious.Utility.AppSettings import * + +import functools + +__all___ = ['AS_UserActivatedItemIndex', 'AS_UserServers', 'AS_UserSubscription'] + + +def _activatedItemIndex() -> int: + try: + return int(AppSettings.get('ActivatedItemIndex')) + except Exception: + # Any non-exit exceptions + + return -1 + + +def _userServersData() -> list: + try: + return APP().userServers.data() + except Exception: + # Any non-exit exceptions + + return [] + + +def _userSubscriptionData() -> dict[str, dict]: + try: + return APP().userSubs.data() + except Exception: + # Any non-exit exceptions + + return {} + + +AS_UserActivatedItemIndex = functools.partial(_activatedItemIndex) +AS_UserServers = functools.partial(_userServersData) +AS_UserSubscription = functools.partial(_userSubscriptionData) diff --git a/Furious/Utility/Constants.py b/Furious/Utility/Constants.py index c68b2ec..801f7ee 100644 --- a/Furious/Utility/Constants.py +++ b/Furious/Utility/Constants.py @@ -1,4 +1,4 @@ -# Copyright (C) 2023 Loren Eteval +# Copyright (C) 2024 Loren Eteval # # This file is part of Furious. # @@ -17,11 +17,9 @@ from Furious.Version import __version__ -from PySide6 import QtCore +from PySide6 import QtCore, __version__ as _pyside6_version from PySide6.QtWidgets import QApplication -import PySide6 - import math import pathlib import platform @@ -40,7 +38,7 @@ ORGANIZATION_NAME = 'Furious' ORGANIZATION_DOMAIN = 'Furious.GUI' # Tested: Furious supports minimum PySide6 version 6.1.0 -PYSIDE6_VERSION = PySide6.__version__ +PYSIDE6_VERSION = _pyside6_version PLATFORM = platform.system() PLATFORM_RELEASE = platform.release() @@ -55,7 +53,9 @@ PROXY_OUTBOUND_USER_EMAIL = f'user@{ORGANIZATION_DOMAIN}' ROOT_DIR = pathlib.Path(__file__).resolve().parent.parent.parent DATA_DIR = ROOT_DIR / APPLICATION_NAME / 'Data' +XRAY_ASSET_DIR = DATA_DIR / 'xray' CRASH_LOG_DIR = ROOT_DIR / APPLICATION_NAME / 'CrashLog' +GEN_TRANSLATION_FILE = ROOT_DIR / APPLICATION_NAME / 'Externals' / 'GenTranslation.py' PROXY_SERVER_BYPASS = ( 'localhost;*.local;127.*;10.*;172.16.*;172.17.*;' @@ -63,6 +63,8 @@ PROXY_SERVER_BYPASS = ( '172.26.*;172.27.*;172.28.*;172.29.*;172.30.*;172.31.*;192.168.*' ) +CORE_CHECK_ALIVE_INTERVAL = 2000 + if PLATFORM == 'Windows': APPLICATION_TUN_DEVICE_NAME = APPLICATION_NAME elif PLATFORM == 'Darwin': @@ -78,25 +80,10 @@ if PLATFORM == 'Windows': else: APPLICATION_TUN_IP_ADDRESS = APPLICATION_TUN_GATEWAY_ADDRESS -TOR_FAQ_URL = 'https://support.torproject.org/faq/' -TOR_FAQ_LABEL = f'Tor FAQ: {TOR_FAQ_URL}' - -# Avoid standard Tor port in case of running Tor services -DEFAULT_TOR_SOCKS_PORT = 9048 -DEFAULT_TOR_HTTPS_PORT = 9047 -# 20s -DEFAULT_TOR_RELAY_ESTABLISH_TIMEOUT = 20 - ADMINISTRATOR_NAME = 'Administrator' if PLATFORM == 'Windows' else 'root' +NETWORK_STATE_TEST_URL = 'http://cp.cloudflare.com' -class LogType: - Core = 'Core' - App = 'App' - Tor = 'Tor' - - -class Color: - LIGHT_BLUE = '#43ACED' - LIGHT_RED_ = '#FF7276' - LIGHT_PURPLE = '#DA70D6' +UNICODE_LARGE_RED_CIRCLE = u'\U0001F534' +UNICODE_LARGE_GREEN_CIRCLE = u'\U0001F7E2' +UNICODE_LARGE_ORANGE_CIRCLE = u'\U0001F7E0' diff --git a/Furious/Utility/PySide6Legacy.py b/Furious/Utility/PySide6Legacy.py new file mode 100644 index 0000000..df00dc3 --- /dev/null +++ b/Furious/Utility/PySide6Legacy.py @@ -0,0 +1,46 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from Furious.Utility.Constants import * + +import time + +__all__ = ['PySide6LegacyEnumValueWrapper', 'PySide6LegacyEventLoopWait'] + + +def PySide6LegacyEnumValueWrapper(enum): + # Protect PySide6 enum wrapper behavior changes + if PYSIDE6_VERSION < '6.2.2': + return enum + else: + return enum.value + + +def PySide6LegacyEventLoopWait(ms): + # Protect qWait method does not exist in some + # old PySide6 version + if PYSIDE6_VERSION < '6.3.1': + for counter in range(0, ms, 10): + time.sleep(10 / 1000) + + APP().processEvents() + else: + from PySide6.QtTest import QTest + + QTest.qWait(ms) + + APP().processEvents() diff --git a/Furious/Utility/RoutingTable.py b/Furious/Utility/RoutingTable.py deleted file mode 100644 index eb74659..0000000 --- a/Furious/Utility/RoutingTable.py +++ /dev/null @@ -1,325 +0,0 @@ -# Copyright (C) 2023 Loren Eteval -# -# This file is part of Furious. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -from Furious.Utility.Constants import ( - PLATFORM, - SYSTEM_LANGUAGE, - APPLICATION_TUN_GATEWAY_ADDRESS, -) -from Furious.Utility.Utility import runCommand - -import re -import logging -import subprocess - -logger = logging.getLogger(__name__) - - -def getDictTuple(returncode, stdout, stderr): - return { - 'returncode': returncode, - 'stdout': stdout, - 'stderr': stderr, - } - - -class RoutingTable: - Relations = list() - - DEFAULT_GATEWAY_WINDOWS = re.compile( - r'0\.0\.0\.0.\s*0\.0\.0\.0.\s*(\S+)', - ) - DEFAULT_GATEWAY_DARWIN = re.compile( - r'gateway:\s*(\S+)', - ) - - @staticmethod - def add(source, destination): - def _add(): - if PLATFORM == 'Windows': - try: - result = runCommand( - ['route', 'add', source, destination, 'metric', '5'], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - except Exception: - # Any non-exit exceptions - - return -1, '', '' - else: - if SYSTEM_LANGUAGE == 'ZH': - return ( - result.returncode, - result.stdout.decode('gbk', 'replace').strip(), - result.stderr.decode('gbk', 'replace').strip(), - ) - else: - return ( - result.returncode, - result.stdout.decode('utf-8', 'replace').strip(), - result.stderr.decode('utf-8', 'replace').strip(), - ) - - if PLATFORM == 'Darwin': - try: - result = runCommand( - ['route', 'add', '-net', source, destination], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - except Exception: - # Any non-exit exceptions - - return -1, '', '' - else: - return ( - result.returncode, - result.stdout.decode('utf-8', 'replace'), - result.stderr.decode('utf-8', 'replace'), - ) - - try: - returncode, stdout, stderr = _add() - except Exception: - # Any non-exit exceptions - - logger.error(f'add rule {source}->{destination} to routing table failed') - else: - if returncode == 0: - logger.info( - f'add rule {source}->{destination} to routing table success. ' - f'{getDictTuple(returncode, stdout, stderr)}' - ) - else: - logger.error( - f'add rule {source}->{destination} to routing table failed. ' - f'{getDictTuple(returncode, stdout, stderr)}' - ) - - @staticmethod - def addRelations(): - for source, destination in RoutingTable.Relations: - RoutingTable.add(source, destination) - - @staticmethod - def getDefaultGatewayAddress(): - def _get(): - if PLATFORM == 'Windows': - try: - result = runCommand( - [ - 'route', - 'PRINT', - '0.0.0.0', - ], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - check=True, - ) - - return RoutingTable.DEFAULT_GATEWAY_WINDOWS.findall( - result.stdout.decode('utf-8', 'replace') - ) - except Exception: - # Any non-exit exceptions - - return [] - - if PLATFORM == 'Darwin': - try: - result = runCommand( - 'route get default'.split(), - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - check=True, - ) - - return RoutingTable.DEFAULT_GATEWAY_DARWIN.findall( - result.stdout.decode('utf-8', 'replace') - ) - except Exception: - # Any non-exit exceptions - - return [] - - defaultGateway = _get() - - if defaultGateway: - logger.info(f'get default gateway address success. {defaultGateway}') - else: - logger.error('get default gateway address failed') - - return defaultGateway - - @staticmethod - def setDeviceGatewayAddress(deviceName, ipAddress, gatewayAddress): - def _set(): - if PLATFORM == 'Windows': - try: - result = runCommand( - 'netsh interface ip set address'.split() - + [ - deviceName, - 'static', - ipAddress, - '255.255.255.0', - gatewayAddress, - '3', - ], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - except Exception: - # Any non-exit exceptions - - return -1, '', '' - else: - if SYSTEM_LANGUAGE == 'ZH': - return ( - result.returncode, - result.stdout.decode('gbk', 'replace').strip(), - result.stderr.decode('gbk', 'replace').strip(), - ) - else: - return ( - result.returncode, - result.stdout.decode('utf-8', 'replace').strip(), - result.stderr.decode('utf-8', 'replace').strip(), - ) - - if PLATFORM == 'Darwin': - try: - result = runCommand( - [ - 'ifconfig', - deviceName, - ipAddress, - gatewayAddress, - 'up', - ], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - except Exception: - # Any non-exit exceptions - - return -1, '', '' - else: - return ( - result.returncode, - result.stdout.decode('utf-8', 'replace'), - result.stderr.decode('utf-8', 'replace'), - ) - - try: - returncode, stdout, stderr = _set() - except Exception: - # Any non-exit exceptions - - logger.error( - f'set device \'{deviceName}\' gateway address \'{gatewayAddress}\' failed' - ) - else: - if returncode == 0: - logger.info( - f'set device \'{deviceName}\' gateway address \'{gatewayAddress}\' success. ' - f'{getDictTuple(returncode, stdout, stderr)}' - ) - else: - logger.error( - f'set device \'{deviceName}\' gateway address \'{gatewayAddress}\' failed. ' - f'{getDictTuple(returncode, stdout, stderr)}' - ) - - @staticmethod - def delete(source, destination): - def _delete(): - if PLATFORM == 'Windows': - try: - result = runCommand( - ['route', 'delete', source, destination], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - except Exception: - # Any non-exit exceptions - - return -1, '', '' - else: - if SYSTEM_LANGUAGE == 'ZH': - return ( - result.returncode, - result.stdout.decode('gbk', 'replace').strip(), - result.stderr.decode('gbk', 'replace').strip(), - ) - else: - return ( - result.returncode, - result.stdout.decode('utf-8', 'replace').strip(), - result.stderr.decode('utf-8', 'replace').strip(), - ) - - if PLATFORM == 'Darwin': - try: - result = runCommand( - ['route', 'delete', '-net', source, destination], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - except Exception: - # Any non-exit exceptions - - return -1, '', '' - else: - return ( - result.returncode, - result.stdout.decode('utf-8', 'replace'), - result.stderr.decode('utf-8', 'replace'), - ) - - try: - returncode, stdout, stderr = _delete() - except Exception: - # Any non-exit exceptions - - logger.error( - f'delete rule {source}->{destination} from routing table failed' - ) - else: - if returncode == 0: - logger.info( - f'delete rule {source}->{destination} from routing table success. ' - f'{getDictTuple(returncode, stdout, stderr)}' - ) - else: - logger.error( - f'delete rule {source}->{destination} from routing table failed. ' - f'{getDictTuple(returncode, stdout, stderr)}' - ) - - @staticmethod - def deleteRelations(clear=True): - if PLATFORM == 'Windows': - if len(RoutingTable.Relations): - RoutingTable.delete('0.0.0.0', APPLICATION_TUN_GATEWAY_ADDRESS) - - for source, destination in RoutingTable.Relations[::-1]: - RoutingTable.delete(source, destination) - - if clear: - RoutingTable.Relations.clear() diff --git a/Furious/Utility/Settings.py b/Furious/Utility/Settings.py deleted file mode 100644 index fb43163..0000000 --- a/Furious/Utility/Settings.py +++ /dev/null @@ -1,128 +0,0 @@ -# Copyright (C) 2023 Loren Eteval -# -# This file is part of Furious. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -from Furious.Action.Language import SUPPORTED_LANGUAGE -from Furious.Action.Routing import BUILTIN_ROUTING -from Furious.Utility.Constants import SYSTEM_LANGUAGE -from Furious.Utility.Utility import Switch - -from PySide6 import QtCore - - -class Settings: - def __init__(self, name, protectedRange=None, default=None): - self.name = name - self.protectedRange = protectedRange - - if protectedRange is None: - self.default = default - else: - self.default = protectedRange[0] if default is None else default - - @staticmethod - def get(key): - if key in Memo: - settings = Memo[key] - - value = QtCore.QSettings().value(settings.name) - - if value is not None and ( - settings.protectedRange is None or value in settings.protectedRange - ): - return value - - # Value not in protected range. Set to default - QtCore.QSettings().setValue(settings.name, settings.default) - - return settings.default - else: - raise AttributeError(f'Settings \'{key}\' not found') - - @staticmethod - def set(key, value): - if key in Memo: - settings = Memo[key] - - if settings.protectedRange is None or value in settings.protectedRange: - QtCore.QSettings().setValue(settings.name, value) - else: - # Value not in protected range. Raise exception - raise ValueError(f'Invalid settings value {value}') - else: - raise AttributeError(f'Settings \'{key}\' not found') - - -# Handy stuff -SMART_CHOSEN_LANGUAGE = ( - SYSTEM_LANGUAGE if SYSTEM_LANGUAGE in SUPPORTED_LANGUAGE else 'EN' -) - -SUPPORTED_SETTINGS = ( - # Connected last time or not - Settings('Connect', Switch.RANGE), - # User Routing option - Settings('Routing', default=BUILTIN_ROUTING[0]), - # User VPN Mode - Settings('VPNMode', Switch.RANGE), - # Hide editor or not - Settings('HideEditor', Switch.RANGE), - # User Custom Routing object - Settings('CustomRouting'), - # User Configuration - Settings('Configuration'), - # User Custom Subscription object - Settings('CustomSubscription'), - # User Activated Server Index - Settings('ActivatedItemIndex'), - # User Tor Relay Settings, - Settings('TorRelaySettings'), - # Server Widget Window Size - Settings('ServerWidgetWindowSize'), - # Routes Widget Window Size - Settings('RoutesWidgetWindowSize'), - # Subscription Widget Window Size - Settings('SubscriptionWidgetWindowSize'), - # Server Widget Section Size - Settings('ServerWidgetSectionSizeTable'), - # Routes Widget Section Size - Settings('RoutesWidgetSectionSizeTable'), - # Subscription Widget Section Size - Settings('SubscriptionWidgetSectionSizeTable'), - # Server Widget Font Point Size - Settings('ServerWidgetPointSize'), - # App Log Viewer Widget Font Point Size - Settings('AppLogViewerWidgetPointSize'), - # Core Log Viewer Widget Font Point Size - Settings('CoreLogViewerWidgetPointSize'), - # Tor Log Viewer Widget Font Point Size - Settings('TorLogViewerWidgetPointSize'), - # User selected language - Settings('Language', SUPPORTED_LANGUAGE, SMART_CHOSEN_LANGUAGE), - # Startup On Boot - Settings('StartupOnBoot', Switch.RANGE, Switch.ON_), # On by default - # Show Progressbar - Settings( - # For user experience: On by default - 'ShowProgressBarWhenConnecting', - Switch.RANGE, - Switch.ON_, - ), - # Show Tab and spaces - Settings('ShowTabAndSpacesInEditor', Switch.RANGE), -) - -Memo = {settings.name: settings for settings in SUPPORTED_SETTINGS} diff --git a/Furious/Utility/StartupOnBoot.py b/Furious/Utility/StartupOnBoot.py index b36dc22..0a1a523 100644 --- a/Furious/Utility/StartupOnBoot.py +++ b/Furious/Utility/StartupOnBoot.py @@ -1,4 +1,4 @@ -# Copyright (C) 2023 Loren Eteval +# Copyright (C) 2024 Loren Eteval # # This file is part of Furious. # @@ -15,13 +15,9 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from Furious.Utility.Constants import ( - APPLICATION_NAME, - APPLICATION_VERSION, - APPLICATION_MACOS_SIGNATURE, - PLATFORM, -) -from Furious.Utility.Utility import isScriptMode +from Furious.Utility.Constants import * +from Furious.Utility.AppSettings import * +from Furious.Utility.SystemRuntime import isScriptMode from PySide6 import QtCore @@ -30,16 +26,15 @@ import sys import logging import plistlib +__all__ = ['StartupOnBoot'] + logger = logging.getLogger(__name__) class StartupOnBoot: - def __init__(self): - super().__init__() - @staticmethod def on_(): - def _on(): + def _on_(): if isScriptMode(): # Script mode logger.info('ignore turn on StartupOnBoot in script mode') @@ -135,7 +130,7 @@ class StartupOnBoot: else: return True - if _on(): + if _on_(): logger.info('turn on StartupOnBoot success') else: logger.error('turn on StartupOnBoot failed') diff --git a/Furious/Utility/Proxy.py b/Furious/Utility/SystemProxy.py similarity index 82% rename from Furious/Utility/Proxy.py rename to Furious/Utility/SystemProxy.py index a97b8eb..2afed14 100644 --- a/Furious/Utility/Proxy.py +++ b/Furious/Utility/SystemProxy.py @@ -1,4 +1,4 @@ -# Copyright (C) 2023 Loren Eteval +# Copyright (C) 2024 Loren Eteval # # This file is part of Furious. # @@ -15,17 +15,37 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from Furious.Utility.Constants import PLATFORM -from Furious.Utility.Utility import runCommand, parseHostPort +from __future__ import annotations + +from Furious.Utility.Constants import * +from Furious.Utility.Utility import parseHostPort, runExternalCommand +from Furious.Utility.AppSettings import * import logging import subprocess +__all__ = ['SystemProxy'] + logger = logging.getLogger(__name__) +def handleAppSystemProxyMode() -> bool: + try: + if AppSettings.get('SystemProxyMode') == 'Auto': + # Automatically configure + return True + else: + # Do not change + return False + except Exception: + # Any non-exit exceptions + + # Automatically configure + return True + + def linuxProxyConfig(proxy_args, arg0, arg1): - runCommand( + runExternalCommand( [ 'gsettings', 'set', @@ -41,7 +61,7 @@ def linuxProxyConfig(proxy_args, arg0, arg1): def darwinProxyConfig(operation, *args): def getNetworkServices(): - command = runCommand( + command = runExternalCommand( ['networksetup', '-listallnetworkservices'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, @@ -54,7 +74,7 @@ def darwinProxyConfig(operation, *args): return service[1:] for serviceName in getNetworkServices(): - runCommand( + runExternalCommand( [ 'networksetup', f'-{operation}', @@ -64,10 +84,8 @@ def darwinProxyConfig(operation, *args): ) -class _Proxy: - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - +class _SystemProxy: + def __init__(self): self._daemonThread = None @staticmethod @@ -104,10 +122,15 @@ class _Proxy: else: return True + if not handleAppSystemProxyMode(): + logger.info(f'ignore proxy PAC \'{pac_url}\' request') + + return + if _pac(): - logger.info('set proxy PAC success') + logger.info(f'set proxy PAC \'{pac_url}\' success') else: - logger.error('set proxy PAC failed') + logger.error(f'set proxy PAC \'{pac_url}\' failed') @staticmethod def set(server, bypass): @@ -153,6 +176,11 @@ class _Proxy: else: return True + if not handleAppSystemProxyMode(): + logger.info(f'ignore proxy server {server} request') + + return + if _set(): logger.info(f'set proxy server {server} success') else: @@ -193,6 +221,11 @@ class _Proxy: else: return True + if not handleAppSystemProxyMode(): + logger.info('ignore turn off proxy request') + + return + if _off(): logger.info('turn off proxy success') else: @@ -219,6 +252,11 @@ class _Proxy: return False + if not handleAppSystemProxyMode(): + logger.info('ignore turn on proxy daemon request') + + return + if _daemonOn_(): logger.info('turn on proxy daemon success') else: @@ -239,7 +277,6 @@ class _Proxy: if sysproxy.daemon_off(): self._daemonThread.join() - # Reset it self._daemonThread = None @@ -255,10 +292,15 @@ class _Proxy: # turning on StartupOnBoot by default. This should be # friendly for most of the users + if not handleAppSystemProxyMode(): + logger.info('ignore turn off proxy daemon request') + + return + if _daemonOff(): logger.info('turn off proxy daemon success') else: logger.error('turn off proxy daemon failed') -Proxy = _Proxy() +SystemProxy = _SystemProxy() diff --git a/Furious/Utility/SystemRoutingTable.py b/Furious/Utility/SystemRoutingTable.py new file mode 100644 index 0000000..b52cb4c --- /dev/null +++ b/Furious/Utility/SystemRoutingTable.py @@ -0,0 +1,471 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from __future__ import annotations + +from Furious.Utility.Constants import * +from Furious.Utility.Utility import runExternalCommand + +import re +import logging +import subprocess + +__all__ = ['SystemRoutingTable'] + +logger = logging.getLogger(__name__) + +if PLATFORM == 'Windows': + if SYSTEM_LANGUAGE == 'ZH': + SYSTEM_PREFERRED_ENCODING = 'gbk' + else: + SYSTEM_PREFERRED_ENCODING = 'utf-8' +else: + SYSTEM_PREFERRED_ENCODING = 'utf-8' + + +def dictRepr(returncode, stdout, stderr): + return { + 'returncode': returncode, + 'stdout': stdout.decode(SYSTEM_PREFERRED_ENCODING, 'replace').strip(), + 'stderr': stderr.decode(SYSTEM_PREFERRED_ENCODING, 'replace').strip(), + } + + +class SystemRoutingTable: + Relations = list() + + DEFAULT_GATEWAY_WIN32 = re.compile( + r'0\.0\.0\.0.\s*0\.0\.0\.0.\s*(\S+)\s*(\S+)', + ) + DEFAULT_GATEWAY_MACOS = re.compile( + r'gateway:\s*(\S+)', + ) + + @staticmethod + def add(sourceIP, destinationIP): + def _add(): + if PLATFORM == 'Windows': + try: + result = runExternalCommand( + ['route', 'add', sourceIP, destinationIP, 'metric', '5'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + except Exception: + # Any non-exit exceptions + + raise + else: + return result.returncode, result.stdout, result.stderr + + if PLATFORM == 'Darwin': + try: + result = runExternalCommand( + ['route', 'add', '-net', sourceIP, destinationIP], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + except Exception: + # Any non-exit exceptions + + raise + else: + return result.returncode, result.stdout, result.stderr + + try: + returncode, stdout, stderr = _add() + except Exception as ex: + # Any non-exit exceptions + + logger.error( + f'add rule {sourceIP}->{destinationIP} to routing table failed. {ex}' + ) + else: + if returncode == 0: + logger.info( + f'add rule {sourceIP}->{destinationIP} to routing table success. ' + f'{dictRepr(returncode, stdout, stderr)}' + ) + else: + logger.error( + f'add rule {sourceIP}->{destinationIP} to routing table failed. ' + f'{dictRepr(returncode, stdout, stderr)}' + ) + + @staticmethod + def addRelations(): + for sourceIP, destinationIP in SystemRoutingTable.Relations: + SystemRoutingTable.add(sourceIP, destinationIP) + + @staticmethod + def WIN32GetInterfaceAliasByIP(ipaddress) -> str: + assert PLATFORM == 'Windows' + + try: + # Note: Does not work on Windows 7 due to old powershell version + + result = runExternalCommand( + f'powershell \"Get-NetIPAddress -IPAddress \'{ipaddress}\' | %{{$_.InterfaceAlias}};\"', + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + shell=True, + check=True, + ) + + alias = result.stdout.decode(SYSTEM_PREFERRED_ENCODING, 'strict').strip() + + logger.info(f'get \'{ipaddress}\' interface alias success: {alias}') + + return alias + except subprocess.CalledProcessError as err: + logger.error( + f'get \'{ipaddress}\' interface alias failed. ' + f'{dictRepr(err.returncode, err.stdout, err.stderr)}' + ) + + return '' + except Exception as ex: + # Any non-exit exceptions + + logger.error(f'get \'{ipaddress}\' interface alias failed. {ex}') + + return '' + + @staticmethod + def WIN32SetInterfaceDNS(name, address=None, dhcp=True): + assert PLATFORM == 'Windows' + + try: + if dhcp: + result = runExternalCommand( + 'netsh interface ip set dns'.split() + [f'name=\"{name}\"', 'dhcp'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + ) + else: + assert address is not None + + result = runExternalCommand( + 'netsh interface ip set dns'.split() + + [f'name=\"{name}\"', 'static', f'{address}'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + ) + except subprocess.CalledProcessError as err: + logger.error( + f'set interface \'{name}\' DNS failed. ' + f'{dictRepr(err.returncode, err.stdout, err.stderr)}' + ) + except Exception as ex: + # Any non-exit exceptions + + logger.error(f'set interface \'{name}\' DNS failed. {ex}') + else: + logger.info( + f'set interface \'{name}\' DNS success. address: {address}. dhcp: {dhcp}. ' + f'{dictRepr(result.returncode, result.stdout, result.stderr)}' + ) + + @staticmethod + def WIN32FlushDNSCache(): + assert PLATFORM == 'Windows' + + try: + result = runExternalCommand( + 'ipconfig /flushdns'.split(), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + ) + except subprocess.CalledProcessError as err: + logger.error( + f'flush system DNS cache failed. ' + f'{dictRepr(err.returncode, err.stdout, err.stderr)}' + ) + except Exception as ex: + # Any non-exit exceptions + + logger.error(f'flush system DNS cache failed. {ex}') + else: + logger.info( + f'flush system DNS cache success. ' + f'{dictRepr(result.returncode, result.stdout, result.stderr)}' + ) + + @staticmethod + def DarwinGetDNSServers() -> list: + def getNetworkServices(): + _command = runExternalCommand( + ['networksetup', '-listallnetworkservices'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + ) + + # Replace with command.stdout.decode('utf-8', 'replace')...? + _service = list( + filter(lambda x: x != '', _command.stdout.decode().split('\n')) + ) + + return _service[1:] + + assert PLATFORM == 'Darwin' + + try: + services = getNetworkServices() + dnsservers = [] + + for service in services: + result = runExternalCommand( + 'networksetup -getdnsservers'.split() + [service], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + ) + + dnsserver = result.stdout.decode().strip() + + if dnsserver.find('DNS Servers') >= 0: + # 'There aren't any DNS Servers set on ...' + dnsservers.append('') + else: + dnsservers.append(dnsserver) + + servers = list(zip(services, dnsservers)) + except Exception as ex: + # Any non-exit exceptions + + logger.error(f'get system DNS servers failed. {ex}') + + return [] + else: + logger.info(f'get system DNS servers success. {servers}') + + return servers + + @staticmethod + def DarwinSetDNSServers(service: str, dnsserver: str): + assert PLATFORM == 'Darwin' + + dnsserverRepr = [dnsserver] + + try: + if not dnsserver: + result = runExternalCommand( + 'networksetup -setdnsservers'.split() + [service, 'Empty'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + ) + else: + result = runExternalCommand( + 'networksetup -setdnsservers'.split() + + [service] + + dnsserver.split('\n'), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + ) + except subprocess.CalledProcessError as err: + logger.error( + f'set service \'{service}\' DNS server {dnsserverRepr} failed. ' + f'{dictRepr(err.returncode, err.stdout, err.stderr)}' + ) + except Exception as ex: + # Any non-exit exceptions + + logger.error( + f'set service \'{service}\' DNS server {dnsserverRepr} failed. {ex}' + ) + else: + logger.info( + f'set service \'{service}\' DNS server {dnsserverRepr} success. ' + f'{dictRepr(result.returncode, result.stdout, result.stderr)}' + ) + + @staticmethod + def getDefaultGateway() -> list: + def _get(): + if PLATFORM == 'Windows': + # Note: On Windows interface IP is also captured + + result = runExternalCommand( + 'route print 0.0.0.0'.split(), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + ) + + return SystemRoutingTable.DEFAULT_GATEWAY_WIN32.findall( + result.stdout.decode(SYSTEM_PREFERRED_ENCODING, 'replace') + ) + + if PLATFORM == 'Darwin': + result = runExternalCommand( + 'route get default'.split(), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + ) + + return SystemRoutingTable.DEFAULT_GATEWAY_MACOS.findall( + result.stdout.decode(SYSTEM_PREFERRED_ENCODING, 'replace') + ) + + try: + defaultGateway = _get() + except subprocess.CalledProcessError as err: + logger.error( + f'get default gateway failed. ' + f'{dictRepr(err.returncode, err.stdout, err.stderr)}' + ) + + return [] + except Exception as ex: + # Any non-exit exceptions + + logger.error(f'get default gateway failed. {ex}') + + return [] + else: + logger.info(f'get default gateway success. {defaultGateway}') + + return defaultGateway + + @staticmethod + def setDeviceGateway(deviceName, deviceIP, deviceGateway): + def _set(): + if PLATFORM == 'Windows': + try: + result = runExternalCommand( + 'netsh interface ip set address'.split() + + [ + deviceName, + 'static', + deviceIP, + '255.255.255.0', + deviceGateway, + '3', + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + except Exception: + # Any non-exit exceptions + + raise + else: + return result.returncode, result.stdout, result.stderr + + if PLATFORM == 'Darwin': + try: + result = runExternalCommand( + ['ifconfig', deviceName, deviceIP, deviceGateway, 'up'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + except Exception: + # Any non-exit exceptions + + raise + else: + return result.returncode, result.stdout, result.stderr + + try: + returncode, stdout, stderr = _set() + except Exception as ex: + # Any non-exit exceptions + + logger.error( + f'set device \'{deviceName}\' gateway \'{deviceGateway}\' failed. {ex}' + ) + else: + if returncode == 0: + logger.info( + f'set device \'{deviceName}\' gateway \'{deviceGateway}\' success. ' + f'{dictRepr(returncode, stdout, stderr)}' + ) + else: + logger.error( + f'set device \'{deviceName}\' gateway \'{deviceGateway}\' failed. ' + f'{dictRepr(returncode, stdout, stderr)}' + ) + + @staticmethod + def delete(sourceIP, destinationIP): + def _delete(): + if PLATFORM == 'Windows': + try: + result = runExternalCommand( + ['route', 'delete', sourceIP, destinationIP], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + except Exception: + # Any non-exit exceptions + + raise + else: + return result.returncode, result.stdout, result.stderr + + if PLATFORM == 'Darwin': + try: + result = runExternalCommand( + ['route', 'delete', '-net', sourceIP, destinationIP], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + except Exception: + # Any non-exit exceptions + + raise + else: + return result.returncode, result.stdout, result.stderr + + try: + returncode, stdout, stderr = _delete() + except Exception as ex: + # Any non-exit exceptions + + logger.error( + f'delete rule {sourceIP}->{destinationIP} from routing table failed. {ex}' + ) + else: + if returncode == 0: + logger.info( + f'delete rule {sourceIP}->{destinationIP} from routing table success. ' + f'{dictRepr(returncode, stdout, stderr)}' + ) + else: + logger.error( + f'delete rule {sourceIP}->{destinationIP} from routing table failed. ' + f'{dictRepr(returncode, stdout, stderr)}' + ) + + @staticmethod + def deleteRelations(clear=True): + if PLATFORM == 'Windows': + if len(SystemRoutingTable.Relations): + SystemRoutingTable.delete('0.0.0.0', APPLICATION_TUN_GATEWAY_ADDRESS) + + for sourceIP, destinationIP in SystemRoutingTable.Relations[::-1]: + SystemRoutingTable.delete(sourceIP, destinationIP) + + if clear: + SystemRoutingTable.Relations.clear() diff --git a/Furious/Utility/SystemRuntime.py b/Furious/Utility/SystemRuntime.py new file mode 100644 index 0000000..50ba213 --- /dev/null +++ b/Furious/Utility/SystemRuntime.py @@ -0,0 +1,107 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from __future__ import annotations + +from Furious.Utility.Constants import * +from Furious.Utility.AppSettings import AppSettings +from Furious.Utility.Utility import runExternalCommand + +import os +import sys +import ctypes +import functools +import subprocess + +__all__ = [ + 'getPythonVersion', + 'getUbuntuRelease', + 'isAdministrator', + 'isVPNMode', + 'isScriptMode', + 'isPythonw', + 'isWindows7', +] + + +def getPythonVersion(): + return '.'.join(str(info) for info in sys.version_info) + + +@functools.lru_cache(None) +def getUbuntuRelease() -> str: + try: + result = runExternalCommand( + ['cat', '/etc/lsb-release'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + ) + + values = dict( + list(line.split('=')) + for line in filter(lambda x: x != '', result.stdout.decode().split('\n')) + ) + + if values['DISTRIB_ID'] == 'Ubuntu': + return values['DISTRIB_RELEASE'] + else: + return '' + except Exception: + # Any non-exit exceptions + + return '' + + +@functools.lru_cache(None) +def isAdministrator() -> bool: + if PLATFORM == 'Windows': + return ctypes.windll.shell32.IsUserAnAdmin() == 1 + else: + return os.geteuid() == 0 + + +def isVPNMode() -> bool: + return isAdministrator() and AppSettings.isStateON_('VPNMode') + + +def isScriptMode() -> bool: + return sys.argv[0].endswith('.py') + + +def isPythonw() -> bool: + def isRealFile(file): + if not hasattr(file, 'fileno'): + return False + + try: + tmp = os.dup(file.fileno()) + except Exception: + # Any non-exit exceptions + + return False + else: + os.close(tmp) + + return True + + # pythonw.exe. Also applies to packed GUI application on Windows + return not isRealFile(sys.__stdout__) or not isRealFile(sys.__stderr__) + + +def isWindows7() -> bool: + return PLATFORM == 'Windows' and PLATFORM_RELEASE == '7' diff --git a/Furious/Utility/Translator.py b/Furious/Utility/Translator.py deleted file mode 100644 index 9502d8a..0000000 --- a/Furious/Utility/Translator.py +++ /dev/null @@ -1,1109 +0,0 @@ -# Copyright (C) 2023 Loren Eteval -# -# This file is part of Furious. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -from Furious.Core.Core import XrayCore, Hysteria1 -from Furious.Core.TorRelay import TorRelay -from Furious.Utility.Constants import APP, APPLICATION_NAME, TOR_FAQ_LABEL - - -class Translatable: - Object = list() - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.translatable = True - - Translatable.Object.append(self) - - def retranslate(self): - raise NotImplementedError - - @staticmethod - def retranslateAll(): - for ob in Translatable.Object: - assert isinstance(ob, Translatable) - - if ob.translatable: - ob.retranslate() - - -class Translator: - def __init__(self): - super().__init__() - - self.translation = dict() - self.dictEnglish = dict() - - def install(self, translation): - self.translation = translation - - for key, value in translation.items(): - # English -> English - value['EN'] = key - - for lang, text in value.items(): - self.dictEnglish[text] = key - - # English -> English - self.dictEnglish.update(dict(list((key, key) for key in translation.keys()))) - - def translate(self, source, locale): - if source in NO_TRANSLATION_DICT: - return source - else: - return self.translation[self.dictEnglish[source]][locale] - - -_trans = Translator() - - -def installTranslation(translation): - _trans.install(translation) - - -def gettext(source, locale=None): - if locale is None: - assert APP() is not None - - return _trans.translate(source, APP().Language) - else: - assert locale in list(LANGUAGE_TO_ABBR.values()) - - return _trans.translate(source, locale) - - -LANGUAGE_TO_ABBR = { - 'English': 'EN', - 'Español': 'ES', - '简体中文': 'ZH', - '繁體中文': 'TW', -} - -ABBR_TO_LANGUAGE = {value: key for key, value in LANGUAGE_TO_ABBR.items()} - -# DO-NOT-TRANSLATE text -NO_TRANSLATION = [ - '', - 'OK', - APPLICATION_NAME, - *LANGUAGE_TO_ABBR.keys(), - 'TLS', - XrayCore.name(), - Hysteria1.name(), - TOR_FAQ_LABEL, - 'socks', - 'http', - 'URL', -] - -NO_TRANSLATION_DICT = {key: True for key in NO_TRANSLATION} - -TRANSLATION = { - # Tray Actions - 'Connect': { - 'ES': 'Conectar', - 'ZH': '连接', - 'TW': '連接', - }, - 'Connecting': { - 'ES': 'Conectando', - 'ZH': '正在连接', - 'TW': '正在連接', - }, - 'Connected': { - 'ES': 'Conectado', - 'ZH': '已连接', - 'TW': '已連接', - }, - 'Disconnect': { - 'ES': 'Desconectar', - 'ZH': '断开连接', - 'TW': '斷開連接', - }, - 'Disconnected': { - 'ES': 'Desconectado', - 'ZH': '已断开', - 'TW': '已斷開', - }, - 'Routing': { - 'ES': 'Enrutamiento', - 'ZH': '路由', - 'TW': '路由', - }, - 'Bypass Mainland China': { - 'ES': 'Evitar China Continental', - 'ZH': '绕过中国大陆', - 'TW': '繞過中國大陸', - }, - 'Bypass Iran': { - 'ES': 'Evitar Irán', - 'ZH': '绕过伊朗', - 'TW': '繞過伊朗', - }, - 'Route My Traffic Through Tor': { - 'ES': 'Enrutar Mi Tráfico a Través de Tor', - 'ZH': '通过Tor路由我的流量', - 'TW': '通過Tor路由我的流量', - }, - 'Global': { - 'ES': 'Global', - 'ZH': '全球', - 'TW': '全球', - }, - 'Custom': { - 'ES': 'Personalizado', - 'ZH': '自定义', - 'TW': '自定義', - }, - 'Subscription': { - 'ES': 'Suscripción', - 'ZH': '订阅', - 'TW': '訂閲', - }, - 'Update Subscription (Use Current Proxy)': { - 'ES': 'Actualizar Suscripción (Usar Proxy Actual)', - 'ZH': '更新订阅(使用当前代理)', - 'TW': '更新訂閲(使用當前代理)', - }, - 'Update Subscription (Force Proxy)': { - 'ES': 'Actualizar Suscripción (Forzar Proxy)', - 'ZH': '更新订阅(强制代理)', - 'TW': '更新訂閲(强制代理)', - }, - 'Update Subscription (No Proxy)': { - 'ES': 'Actualizar Suscripción (Sin Proxy)', - 'ZH': '更新订阅(无代理)', - 'TW': '更新訂閲(無代理)', - }, - 'Edit Subscription...': { - 'ES': 'Editar Suscripción...', - 'ZH': '编辑订阅...', - 'TW': '編輯訂閲...', - }, - 'Import': { - 'ES': 'Importar', - 'ZH': '导入', - 'TW': '導入', - }, - 'Import JSON Configuration From Clipboard': { - 'ES': 'Importar Configuración JSON desde el Portapapeles', - 'ZH': '从剪贴板导入JSON配置', - 'TW': '從剪貼板導入JSON配置', - }, - 'Import Share Link From Clipboard': { - 'ES': 'Importar Enlace Compartido desde el Portapapeles', - 'ZH': '从剪贴板导入分享链接', - 'TW': '從剪貼板導入分享鏈接', - }, - 'Edit Configuration...': { - 'ES': 'Editar Configuración...', - 'ZH': '编辑配置...', - 'TW': '編輯配置...', - }, - 'Language': { - 'ES': 'Idioma', - 'ZH': '语言', - 'TW': '語言', - }, - 'Settings': { - 'ES': 'Ajustes', - 'ZH': '设置', - 'TW': '設置', - }, - 'Startup On Boot': { - 'ES': 'Inició en el Arranque', - 'ZH': '开机启动', - 'TW': '開機啓動', - }, - 'Show Progress Bar When Connecting': { - 'ES': 'Mostrar Barra de Progreso al Conectarse', - 'ZH': '连接时显示进度条', - 'TW': '連接時顯示進度條', - }, - 'Show Tab And Spaces In Editor': { - 'ES': 'Mostrar Pestaña y Espacios en el Editor', - 'ZH': '在编辑器中显示制表符和空格', - 'TW': '在編輯器中顯示制表符和空格', - }, - 'VPN Mode': { - 'ES': 'Modo VPN', - 'ZH': 'VPN模式', - 'TW': 'VPN模式', - }, - 'VPN Mode Disabled (Administrator)': { - 'ES': 'Modo VPN Deshabilitado (Administrador)', - 'ZH': 'VPN模式已禁用(管理员)', - 'TW': 'VPN模式已禁用(管理員)', - }, - 'VPN Mode Disabled (root)': { - 'ES': 'Modo VPN Deshabilitado (root)', - 'ZH': 'VPN模式已禁用(root)', - 'TW': 'VPN模式已禁用(root)', - }, - 'Routing Settings...': { - 'ES': 'Ajustes de Enrutamiento...', - 'ZH': '路由设置...', - 'TW': '路由設置...', - }, - 'Tor Relay Settings...': { - 'ES': 'Ajustes de Tor Relay...', - 'ZH': 'Tor Relay设置...', - 'TW': 'Tor Relay設置...', - }, - 'Help': { - 'ES': 'Ayuda', - 'ZH': '帮助', - 'TW': '幫助', - }, - 'Show Log...': { - 'ES': f'Mostrar Registro...', - 'ZH': f'显示日志...', - 'TW': f'顯示日志...', - }, - 'Core Log': { - 'ES': 'Registro de Núcleo', - 'ZH': '内核日志', - 'TW': '内核日志', - }, - f'{APPLICATION_NAME} Log': { - 'ES': f'Registro de {APPLICATION_NAME}', - 'ZH': f'{APPLICATION_NAME}日志', - 'TW': f'{APPLICATION_NAME}日志', - }, - 'Tor Log': { - 'ES': 'Registro de Tor', - 'ZH': 'Tor日志', - 'TW': 'Tor日志', - }, - 'Check For Updates': { - 'ES': 'Comprobar Actualizaciones', - 'ZH': '检查更新', - 'TW': '檢查更新', - }, - 'About': { - 'ES': 'Sobre', - 'ZH': '关于', - 'TW': '關於', - }, - 'Exit': { - 'ES': 'Salida', - 'ZH': '退出', - 'TW': '退出', - }, - # Server configuration empty - 'Unable to connect': { - 'ES': 'No se puede conectar', - 'ZH': '无法连接', - 'TW': '無法連接', - }, - 'Server configuration empty. Please configure your server first.': { - 'ES': 'Configuración del servidor vacía. Por favor, configure su servidor primero.', - 'ZH': '服务器配置为空。请先配置您的服务器。', - 'TW': '服務器配置爲空。請先配置您的服務器。', - }, - 'Select and double click to activate configuration and connect.': { - 'ES': 'Seleccione y haga doble clic para activar la configuración y conectarse.', - 'ZH': '选中并双击以激活配置并连接。', - 'TW': '選中并雙擊以激活配置并連接。', - }, - # Http Proxy Configuration Error - f'{APPLICATION_NAME} cannot find any valid http proxy ' - f'endpoint in your server configuration.': { - 'ES': f'{APPLICATION_NAME} no puede encontrar ningún extremo de ' - f'proxy http válido en la configuración de su servidor.', - 'ZH': f'{APPLICATION_NAME}在您的服务器配置中找不到任何有效的http代理端点。', - 'TW': f'{APPLICATION_NAME}在您的服務器配置中找不到任何有效的http代理端點。', - }, - # Socks Proxy Configuration Error - f'{APPLICATION_NAME} cannot find any valid socks proxy ' - f'endpoint in your server configuration.': { - 'ES': f'{APPLICATION_NAME} no puede encontrar ningún extremo de ' - f'proxy socks válido en la configuración de su servidor.', - 'ZH': f'{APPLICATION_NAME}在您的服务器配置中找不到任何有效的socks代理端点。', - 'TW': f'{APPLICATION_NAME}在您的服務器配置中找不到任何有效的socks代理端點。', - }, - 'Please complete your server configuration.': { - 'ES': 'Complete la configuración de su servidor.', - 'ZH': '请完善您的服务器配置。', - 'TW': '請完善您的服務器配置。', - }, - # Import Share Link From Clipboard MessageBox - 'Invalid share link.': { - 'ES': 'Enlace compartido no válidos.', - 'ZH': '无效的分享链接。', - 'TW': '無效的分享鏈接。', - }, - 'Invalid share link. The content of the clipboard is:': { - 'ES': 'Enlace compartido no válidos. El contenido del portapapeles es:', - 'ZH': '无效的分享链接。剪贴板的内容为:', - 'TW': '無效的分享鏈接。剪貼板的内容為:', - }, - 'Import share link success: ': { - 'ES': 'Importación de enlace compartido exitosa: ', - 'ZH': '导入分享链接成功:', - 'TW': '導入分享鏈接成功:', - }, - 'Imported to row': { - 'ES': 'Importado a la fila', - 'ZH': '导入至行', - 'TW': '導入至行', - }, - 'Import share link success.': { - 'ES': 'Importación de enlace compartido exitosa.', - 'ZH': '导入分享链接成功。', - 'TW': '導入分享鏈接成功。', - }, - 'Go to edit': { - 'ES': 'Ir a editar', - 'ZH': '前往编辑', - 'TW': '前往編輯', - }, - # Import JSON Configuration From Clipboard MessageBox - 'Invalid JSON data.': { - 'ES': 'Datos JSON no válidos.', - 'ZH': '无效的JSON数据。', - 'TW': '無效的JSON數據。', - }, - 'Invalid JSON data. The content of the clipboard is:': { - 'ES': 'Datos JSON no válidos. El contenido del portapapeles es:', - 'ZH': '无效的JSON数据。剪贴板的内容为:', - 'TW': '無效的JSON數據。剪貼板的内容為:', - }, - 'Import JSON configuration success.': { - 'ES': 'Importación de configuración JSON exitosa.', - 'ZH': '导入JSON配置成功。', - 'TW': '導入JSON配置成功。', - }, - # Export - 'Export': { - 'ES': 'Exportar', - 'ZH': '导出', - 'TW': '導出', - }, - 'Export Share Link To Clipboard': { - 'ES': 'Exportar Enlace Compartido al Portapapeles', - 'ZH': '导出分享链接至剪贴板', - 'TW': '導出分享鏈接至剪貼板', - }, - 'Export share link to clipboard failed:': { - 'ES': 'Exportar enlace compartido al portapapeles falló:', - 'ZH': '导出分享链接至剪贴板失败:', - 'TW': '導出分享鏈接至剪貼板失敗:', - }, - 'Export share link to clipboard success:': { - 'ES': 'Exportar enlace compartido al portapapeles con éxito:', - 'ZH': '导出分享链接至剪贴板成功:', - 'TW': '導出分享鏈接至剪貼板成功:', - }, - 'Export share link to clipboard partially success:': { - 'ES': 'Exportar enlace compartido al portapapeles parcialmente exitoso:', - 'ZH': '导出分享链接至剪贴板部分成功:', - 'TW': '導出分享鏈接至剪貼板部分成功:', - }, - 'Failed:': { - 'ES': 'Fallido:', - 'ZH': '失败:', - 'TW': '失敗:', - }, - 'Export As QR Code': { - 'ES': 'Exportar Como Código QR', - 'ZH': '导出为二维码', - 'TW': '導出為二維碼', - }, - 'Export as QR code failed:': { - 'ES': 'Exportar como código QR falló:', - 'ZH': '导出为二维码失败:', - 'TW': '導出為二維碼失敗:', - }, - 'Export as QR code success:': { - 'ES': 'Exportar como código QR con éxito:', - 'ZH': '导出为二维码成功:', - 'TW': '導出為二維碼成功:', - }, - 'Export as QR code partially success:': { - 'ES': 'Exportar como código QR parcialmente exitoso:', - 'ZH': '导出为二维码部分成功:', - 'TW': '導出為二維碼部分成功:', - }, - 'Export JSON Configuration To Clipboard': { - 'ES': 'Exportar Configuración JSON al Portapapeles', - 'ZH': '导出JSON配置至剪贴板', - 'TW': '導出JSON配置至剪貼板', - }, - 'Export JSON configuration to clipboard success.': { - 'ES': 'Exportar configuración JSON al portapapeles con éxito.', - 'ZH': '导出JSON配置至剪贴板成功。', - 'TW': '導出JSON配置至剪貼板成功。', - }, - # Edit Server Configuration Widget - 'Edit Server Configuration': { - 'ES': 'Editar Configuración del Servidor', - 'ZH': '编辑服务器配置', - 'TW': '編輯服務器配置', - }, - 'File': { - 'ES': 'Archivo', - 'ZH': '文件', - 'TW': '文件', - }, - 'New...': { - 'ES': 'Nueva...', - 'ZH': '新建...', - 'TW': '新建...', - }, - 'Untitled': { - 'ES': 'Sin Título', - 'ZH': '未命名', - 'TW': '未命名', - }, - 'Import From File...': { - 'ES': 'Importar desde Archivo...', - 'ZH': '从文件导入...', - 'TW': '從文件導入...', - }, - 'Import File': { - 'ES': 'Importar Archivo', - 'ZH': '导入文件', - 'TW': '導入文件', - }, - 'Save': { - 'ES': 'Guardar', - 'ZH': '保存', - 'TW': '保存', - }, - 'Save As...': { - 'ES': 'Guardar Como...', - 'ZH': '另存为...', - 'TW': '另存爲...', - }, - 'Save File': { - 'ES': 'Guardar Archivo', - 'ZH': '保存文件', - 'TW': '保存文件', - }, - 'Text files (*.json);;All files (*)': { - 'ES': 'Archivos de texto (*.json);;Todos los archivos (*)', - 'ZH': '文本文档 (*.json);;所有文件 (*)', - 'TW': '文本文檔 (*.json);;所有文件 (*)', - }, - 'Text files (*.txt);;All files (*)': { - 'ES': 'Archivos de texto (*.txt);;Todos los archivos (*)', - 'ZH': '文本文档 (*.txt);;所有文件 (*)', - 'TW': '文本文檔 (*.txt);;所有文件 (*)', - }, - 'Error opening file': { - 'ES': 'Error al abrir archivo', - 'ZH': '打开文件时出错', - 'TW': '打開文件時出錯', - }, - 'Error saving configuration': { - 'ES': 'Error al guardar la configuración', - 'ZH': '保存配置时出错', - 'TW': '保存配置時出錯', - }, - 'Error saving file': { - 'ES': 'Error al guardar archivo', - 'ZH': '保存文件时出错', - 'TW': '保存文件時出錯', - }, - 'Error saving log': { - 'ES': 'Error al guardar registro', - 'ZH': '保存日志时出错', - 'TW': '保存日志時出錯', - }, - 'Unable to save log.': { - 'ES': 'No se puede guardar el registro.', - 'ZH': '无法保存日志。', - 'TW': '無法保存日志。', - }, - 'Server configuration corrupted': { - 'ES': 'Configuración del servidor corrupta', - 'ZH': '服务器配置已损坏', - 'TW': '服務器配置已損壞', - }, - f'{APPLICATION_NAME} cannot restore your server configuration. ' - f'It may have been tampered with.': { - 'ES': f'{APPLICATION_NAME} no puede restaurar la configuración de su servidor. ' - f'Puede que haya sido manipulado.', - 'ZH': f'{APPLICATION_NAME}无法恢复您的服务器配置。它可能已被篡改。', - 'TW': f'{APPLICATION_NAME}無法恢復您的服務器配置。它可能已被篡改。', - }, - f'The configuration content has been cleared by {APPLICATION_NAME}.': { - 'ES': f'El contenido de la configuración ha sido borrado por {APPLICATION_NAME}.', - 'ZH': f'配置内容已被{APPLICATION_NAME}清空。', - 'TW': f'配置内容已被{APPLICATION_NAME}清空。', - }, - 'Invalid configuration file.': { - 'ES': 'Archivo de configuración inválido.', - 'ZH': '无效的配置文件。', - 'TW': '無效的配置文件。', - }, - 'Hint': { - 'ES': 'Pista', - 'ZH': '提示', - 'TW': '提示', - }, - f'{APPLICATION_NAME} is currently connected.\n\n' - f'The new configuration will take effect the next time you connect.': { - 'ES': f'{APPLICATION_NAME} está actualmente conectado.\n\n' - f'La nueva configuración entrará en vigor la próxima vez que te conectes.', - 'ZH': f'{APPLICATION_NAME}当前已连接。\n\n' f'新配置将在您下次连接时生效。', - 'TW': f'{APPLICATION_NAME}當前已連接。\n\n' f'新配置將在您下次連接時生效。', - }, - 'Reconnect': { - 'ES': 'Reconectar', - 'ZH': '重新连接', - 'TW': '重新連接', - }, - 'Invalid server configuration.': { - 'ES': 'Configuración de servidor no válida.', - 'ZH': '无效的服务器配置。', - 'TW': '無效的服務器配置。', - }, - 'Please check your configuration is in valid JSON format:': { - 'ES': 'Verifique que su configuración esté en formato JSON válido:', - 'ZH': '请检查您的配置是否为有效的JSON格式:', - 'TW': '請檢查您的配置是否為有效的JSON格式:', - }, - 'Save Changes': { - 'ES': 'Guardar Cambios', - 'ZH': '保存更改', - 'TW': '保存更改', - }, - 'The content has been modified. Do you want to save the changes?': { - 'ES': 'El contenido ha sido modificado. ¿Desea guardar los cambios?', - 'ZH': '内容已修改。您要保存更改吗?', - 'TW': '内容已修改。您要保存更改嗎?', - }, - 'Discard': { - 'ES': 'Descartar', - 'ZH': '不保存', - 'TW': '不保存', - }, - 'Cancel': { - 'ES': 'Cancelar', - 'ZH': '取消', - 'TW': '取消', - }, - 'Delete': { - 'ES': 'Eliminar', - 'ZH': '删除', - 'TW': '刪除', - }, - 'Delete 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': '正在连接,请稍候。', - 'TW': '正在連接,請稍候。', - }, - 'Please save any changes to continue.': { - 'ES': 'Guarde los cambios para continuar.', - 'ZH': '请保存所有更改以继续。', - 'TW': '請保存所有更改以繼續', - }, - 'Edit': { - 'ES': 'Editar', - 'ZH': '编辑', - 'TW': '編輯', - }, - 'Undo': { - 'ES': 'Deshacer', - 'ZH': '撤销', - 'TW': '撤銷', - }, - 'Redo': { - 'ES': 'Rehacer', - 'ZH': '重做', - 'TW': '重做', - }, - 'Cut': { - 'ES': 'Cortar', - 'ZH': '剪切', - 'TW': '剪切', - }, - 'Copy': { - 'ES': 'Copiar', - 'ZH': '拷贝', - 'TW': '拷貝', - }, - 'Paste': { - 'ES': 'Pegar', - 'ZH': '粘贴', - 'TW': '粘貼', - }, - 'Select All': { - 'ES': 'Seleccionar Todo', - 'ZH': '全选', - 'TW': '全選', - }, - 'Set Indent': { - 'ES': 'Establecer sangría', - 'ZH': '设置缩进', - 'TW': '設置縮進', - }, - 'Indent...': { - 'ES': 'Sangría...', - 'ZH': '缩进...', - 'TW': '縮進...', - }, - 'Indent:': { - 'ES': 'Sangría:', - 'ZH': '缩进:', - 'TW': '縮進:', - }, - 'Error setting indent': { - 'ES': 'Error al establecer sangría', - 'ZH': '设置缩进时出错', - 'TW': '設置縮進時出錯', - }, - 'View': { - 'ES': 'Vista', - 'ZH': '显示', - 'TW': '顯示', - }, - 'Zoom In': { - 'ES': 'Acercar', - 'ZH': '变大', - 'TW': '變大', - }, - 'Zoom Out': { - 'ES': 'Alejar', - 'ZH': '变小', - 'TW': '變小', - }, - 'Show/Hide Editor': { - 'ES': 'Mostrar/Ocultar Editor', - 'ZH': '显示/隐藏编辑器', - 'TW': '顯示/隱藏編輯器', - }, - 'Remark': { - 'ES': 'Observación', - 'ZH': '别名', - 'TW': '別名', - }, - 'Protocol': { - 'ES': 'Protocolo', - 'ZH': '协议', - 'TW': '協議', - }, - 'Address': { - 'ES': 'Dirección', - 'ZH': '地址', - 'TW': '地址', - }, - 'Port': { - 'ES': 'Puerto', - 'ZH': '端口', - 'TW': '端口', - }, - 'Transport': { - 'ES': 'Transporte', - 'ZH': '传输方式', - 'TW': '傳輸方式', - }, - 'Server': { - 'ES': 'Servidor', - 'ZH': '服务器', - 'TW': '服務器', - }, - 'Latency': { - 'ES': 'Latencia', - 'ZH': '延迟', - 'TW': '延遲', - }, - 'Speed': { - 'ES': 'Velocidad', - 'ZH': '速度', - 'TW': '速度', - }, - 'Test Ping Latency': { - 'ES': 'Probar La Latencia de Ping', - 'ZH': '测试Ping延迟', - 'TW': '測試Ping延遲', - }, - 'Test Download Speed': { - 'ES': 'Probar La Velocidad de Descarga', - 'ZH': '测试下行速度', - 'TW': '測試下行速度', - }, - 'Clear Test Results': { - 'ES': 'Resultados Claros de La Prueba', - 'ZH': '清除测试结果', - 'TW': '清除測試結果', - }, - # Server Widget buttons - 'Move Up': { - 'ES': 'Ascender', - 'ZH': '上移', - 'TW': '上移', - }, - 'Move Down': { - 'ES': 'Mover Hacia Abajo', - 'ZH': '下移', - 'TW': '下移', - }, - 'Duplicate': { - 'ES': 'Duplicado', - 'ZH': '克隆', - 'TW': '克隆', - }, - 'Scroll To Activated Server': { - 'ES': 'Desplácese Hasta el Servidor Activado', - 'ZH': '滚动到激活的服务器', - 'TW': '滾動到激活的服務器', - }, - # 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': '保存路由配置時出錯', - }, - **dict( - list( - [ - f'Edit {coreName} Routing Rules', - { - 'ES': f'Editar Reglas de Enrutamiento de {coreName}', - 'ZH': f'编辑{coreName}路由规则', - 'TW': f'編輯{coreName}路由規則', - }, - ] - ) - for coreName in [XrayCore.name(), Hysteria1.name()] - ), - f'{XrayCore.name()} Routing Rules': { - 'ES': f'Reglas de Enrutamiento de {XrayCore.name()}', - 'ZH': f'{XrayCore.name()}路由规则', - 'TW': f'{XrayCore.name()}路由規則', - }, - f'Note: If acl is empty or does not exist, {APPLICATION_NAME} ' - f'will fall back to proxy all traffic.': { - 'ES': f'NOTA: Si la ACL está vacía o no existe, ' - f'{APPLICATION_NAME} volverá a utilizar proxy para todo el tráfico.', - 'ZH': f'注意:如果acl为空或不存在,{APPLICATION_NAME}将回落到代理所有流量。', - 'TW': f'注意:如果acl為空或不存在,{APPLICATION_NAME}將回落到代理所有流量。', - }, - f'Note: "Custom" will use acl and mmdb defined in current user configuration.': { - 'ES': f'NOTA: "Personalizado" utilizará la acl y mmdb definidas en la configuración de usuario actual', - 'ZH': f'注意:"自定义"将使用当前用户配置中定义的acl和mmdb。', - 'TW': f'注意:"自定義"將使用當前用戶配置中定義的acl和mmdb。', - }, - # Edit Subscription Widget - 'Edit Subscription': { - 'ES': 'Editar Suscripción', - 'ZH': '编辑订阅', - 'TW': '編輯訂閲', - }, - 'Subscription List': { - 'ES': 'Lista de Suscripción', - 'ZH': '订阅列表', - 'TW': '訂閲列表', - }, - 'Add subscription': { - 'ES': 'Agregar suscripción', - 'ZH': '添加订阅', - 'TW': '添加訂閲', - }, - 'Enter subscription remark:': { - 'ES': 'Introducir comentario de suscripción:', - 'ZH': '输入订阅名称:', - 'TW': '輸入訂閲名稱:', - }, - 'Enter subscription URL:': { - 'ES': 'Introducir URL de suscripción:', - 'ZH': '输入订阅URL:', - 'TW': '輸入訂閲URL:', - }, - # 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', - 'ZH': '日志查看器', - 'TW': '日志查看器', - }, - # Tor Relay Settings - 'Tor Relay Settings': { - 'ES': 'Ajustes de Tor Relay', - 'ZH': 'Tor Relay设置', - 'TW': 'Tor Relay設置', - }, - 'Tunnel Port': { - 'ES': 'Puerto de Túnel', - 'ZH': '隧道端口', - 'TW': '隧道端口', - }, - 'Proxy': { - 'ES': 'Proxy', - 'ZH': '代理', - 'TW': '代理', - }, - 'Other': { - 'ES': 'Otro', - 'ZH': '其它', - 'TW': '其它', - }, - 'Use Proxy': { - 'ES': 'Usa Proxy', - 'ZH': '使用代理', - 'TW': '使用代理', - }, - 'Log Level': { - 'ES': 'Nivel de Registro', - 'ZH': '日志级别', - 'TW': '日志級別', - }, - 'Relay Establish Timeout (seconds)': { - 'ES': 'Establecer Tiempo de Espera de Relay (segundos)', - 'ZH': 'Relay建立超时(秒)', - 'TW': 'Relay建立超時(秒)', - }, - # Check For Updates - 'Check for updates failed.': { - 'ES': 'Error al comprobar actualizaciones.', - 'ZH': '检查更新失败。', - 'TW': '檢查更新失敗。', - }, - 'New version available: ': { - 'ES': 'Nueva versión disponible: ', - 'ZH': '新版本可用:', - 'TW': '新版本可用:', - }, - 'Go to download page?': { - 'ES': '¿Ir a la página de descarga?', - 'ZH': '是否前往下载页面?', - 'TW': '是否前往下載頁面?', - }, - f'{APPLICATION_NAME} is already the latest version.': { - 'ES': f'{APPLICATION_NAME} ya es la última versión.', - 'ZH': f'{APPLICATION_NAME}已是最新版本。', - 'TW': f'{APPLICATION_NAME}已是最新版本。', - }, - f'Thank you for using {APPLICATION_NAME}.': { - 'ES': f'Gracias por usar {APPLICATION_NAME}.', - 'ZH': f'感谢您使用{APPLICATION_NAME}。', - 'TW': f'感謝您使用{APPLICATION_NAME}。', - }, - # Tray - 'Unconfigured server': { - 'ES': 'Servidor no configurado', - 'ZH': '未配置的服务器', - 'TW': '未配置的服務器', - }, - 'Already started': { - 'ES': 'Ya ha comenzado', - 'ZH': '已启动', - 'TW': '已啓動', - }, - 'Invalid server configuration': { - 'ES': 'Configuración de servidor no válida', - 'ZH': '无效的服务器配置', - 'TW': '無效的服務器配置', - }, - 'Failed to start core': { - 'ES': 'No se pudo iniciar el núcleo', - 'ZH': '内核启动失败', - 'TW': '内核啓動失敗', - }, - 'Connection test failed': { - 'ES': 'Falló la prueba de conexión', - 'ZH': '连接测试失败', - 'TW': '連接測試失敗', - }, - 'Connection to server has been lost': { - 'ES': 'Se perdió la conexión con el servidor', - 'ZH': '与服务器的连接丢失', - 'TW': '與服務器的連接丟失', - }, - 'Core terminated unexpectedly': { - 'ES': 'Núcleo terminado inesperadamente', - 'ZH': '内核意外终止', - 'TW': '内核意外終止', - }, - 'Routing changed: ': { - 'ES': 'Ruta cambiada: ', - 'ZH': '路由已更改:', - 'TW': '路由已更改:', - }, - f'{TorRelay.name()} establish timeout': { - 'ES': f'Establecer tiempo de espera de {TorRelay.name()}', - 'ZH': f'{TorRelay.name()}建立超时', - 'TW': f'{TorRelay.name()}建立超時', - }, - 'Cannot find Tor CLI in PATH': { - 'ES': 'No se puede encontrar Tor CLI en PATH', - 'ZH': '在PATH中找不到Tor CLI', - 'TW': '在PATH中找不到Tor CLI', - }, - 'VPN mode started': { - 'ES': 'Modo VPN detenido', - 'ZH': 'VPN模式已启动', - 'TW': 'VPN模式已啓動', - }, - 'VPN mode stopped': { - 'ES': 'Modo VPN iniciado', - 'ZH': 'VPN模式已停止', - 'TW': 'VPN模式已停止', - }, - 'No gateway address found': { - 'ES': 'No se encontró ninguna dirección de puerta de enlace', - 'ZH': '找不到网关地址', - 'TW': '找不到網關地址', - }, - 'Multiple gateway addresses found': { - 'ES': 'Se encontraron varias direcciones de puerta de enlace', - 'ZH': '找到多个网关地址', - 'TW': '找到多個網關地址', - }, - 'Server address invalid': { - 'ES': 'Dirección del servidor no válida', - 'ZH': '服务器地址无效', - 'TW': '服務器地址無效', - }, - 'DNS resolution failed': { - 'ES': 'La resolución DNS falló', - 'ZH': 'DNS解析失败', - 'TW': 'DNS解析失敗', - }, - 'DNS resolution timeout': { - 'ES': 'Tiempo de espera de resolución DNS', - 'ZH': 'DNS解析超时', - 'TW': 'DNS解析超時', - }, - # Errors - f'{APPLICATION_NAME} is not able to run on this operating system.': { - 'ES': f'{APPLICATION_NAME} no puede ejecutarse en este sistema operativo.', - 'ZH': f'{APPLICATION_NAME}无法在此操作系统上运行。', - 'TW': f'{APPLICATION_NAME}無法在此操作系統上運行。', - }, - 'Operating system information:': { - 'ES': 'Información del sistema operativo:', - 'ZH': '操作系统信息:', - 'TW': '操作系統信息:', - }, - f'{APPLICATION_NAME} stopped unexpectedly due to an unknown exception.': { - 'ES': f'{APPLICATION_NAME} se detuvo inesperadamente debido a una excepción desconocida.', - 'ZH': f'由于一个未知异常,{APPLICATION_NAME}意外终止。', - 'TW': f'由於一個未知異常,{APPLICATION_NAME}意外終止。', - }, - f'{APPLICATION_NAME} encountered an internal error and needs to be stopped.': { - 'ES': f'{APPLICATION_NAME} encontró un error interno y debe detenerse', - 'ZH': f'{APPLICATION_NAME}遇到内部错误,需要終止。', - 'TW': f'{APPLICATION_NAME}遇到内部錯誤,需要終止。', - }, - 'Crash log has been saved to: ': { - 'ES': 'El registro de errores se ha guardado en: ', - 'ZH': '崩溃日志已保存到:', - 'TW': '崩潰日志已保存到:', - }, - 'Open crash log': { - 'ES': 'Abrir registro de fallas', - 'ZH': '打开崩溃日志', - 'TW': '打開崩潰日志', - }, -} - -installTranslation(TRANSLATION) diff --git a/Furious/Utility/Utility.py b/Furious/Utility/Utility.py index b7f82e6..db22cb4 100644 --- a/Furious/Utility/Utility.py +++ b/Furious/Utility/Utility.py @@ -1,70 +1,25 @@ -# Copyright (C) 2023 Loren Eteval -# -# This file is part of Furious. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . +from __future__ import annotations -from Furious.Gui.Icon import Icon -from Furious.Utility.Constants import ( - APP, - PLATFORM, - PLATFORM_RELEASE, - ROOT_DIR, - PYSIDE6_VERSION, - DEFAULT_TOR_SOCKS_PORT, - DEFAULT_TOR_HTTPS_PORT, - DEFAULT_TOR_RELAY_ESTABLISH_TIMEOUT, - Color, -) +from Furious.Utility.Constants import * -from PySide6 import QtCore -from PySide6.QtWidgets import QApplication -from PySide6.QtNetwork import ( - QNetworkAccessManager, - QNetworkReply, - QNetworkRequest, - QNetworkProxy, -) +from typing import AnyStr, Tuple import os -import sys -import time -import copy import ujson -import queue -import ctypes -import logging -import pybase64 -import threading import functools import ipaddress import subprocess import urllib.parse -logger = logging.getLogger(__name__) - - -class Base64Encoder: - @staticmethod - @functools.lru_cache(128) - def encode(text): - return pybase64.b64encode(text) - - @staticmethod - @functools.lru_cache(128) - def decode(text): - return pybase64.b64decode(text, validate=False) +__all__ = [ + 'Protocol', + 'BinarySettings', + 'protocolRepr', + 'isValidIPAddress', + 'parseHostPort', + 'runExternalCommand', + 'getAbsolutePath', +] class Protocol: @@ -76,375 +31,15 @@ class Protocol: Hysteria2 = 'hysteria2' -class StateContext: - def __init__(self, ob, *args, **kwargs): - super().__init__(*args, **kwargs) - - assert hasattr(ob, 'setDisabled') - - self._ob = ob - - def __enter__(self): - self._ob.setDisabled(True) - - def __exit__(self, exceptionType, exceptionValue, tb): - self._ob.setDisabled(False) - - -class Storage: - def __init__(self, emptyObject, getObjectFn, settingName): - self.emptyObject = emptyObject - self.getObjectFn = getObjectFn - self.settingName = settingName - - def init(self): - return copy.deepcopy(self.emptyObject) - - def sync(self, ob=None): - if ob is None: - # Object is up-to-date - setattr(APP(), self.settingName, Storage.toStorage(self.getObjectFn())) - else: - # Object is up-to-date - setattr(APP(), self.settingName, Storage.toStorage(ob)) - - def toObject(self, storage): - if not storage: - # Storage does not exist, or is empty - return self.init() - - return ujson.loads(Base64Encoder.decode(storage)) - - @staticmethod - def toStorage(ob): - return Base64Encoder.encode( - ujson.dumps(ob, ensure_ascii=False, escape_forward_slashes=False).encode() - ) - - def clear(self): - setattr(APP(), self.settingName, '') - - -class _ServerStorage(Storage): - # remark, config, subsId. (subsId corresponds to unique in Subscription Object) - EMPTY_OBJECT = {'model': []} - - def __init__(self): - super().__init__( - _ServerStorage.EMPTY_OBJECT, - lambda: APP().ServerWidget.StorageObj, - 'Configuration', - ) - - def clear(self): - super().clear() - - APP().ActivatedItemIndex = str(-1) - - -class _RoutesStorage(Storage): - # remark, corename, routes - EMPTY_OBJECT = {'model': []} - - def __init__(self): - super().__init__( - _RoutesStorage.EMPTY_OBJECT, - lambda: APP().RoutesWidget.StorageObj, - 'CustomRouting', - ) - - -class _SubscriptionStorage(Storage): - # unique: remark, webURL - EMPTY_OBJECT = {} - - def __init__(self): - super().__init__( - _SubscriptionStorage.EMPTY_OBJECT, - lambda: APP().SubscriptionWidget.StorageObj, - 'CustomSubscription', - ) - - -class _TorRelaySettingsStorage(Storage): - EMPTY_OBJECT = { - 'socksTunnelPort': DEFAULT_TOR_SOCKS_PORT, - 'httpsTunnelPort': DEFAULT_TOR_HTTPS_PORT, - 'useProxy': True, - 'logLevel': 'notice', - 'relayEstablishTimeout': DEFAULT_TOR_RELAY_ESTABLISH_TIMEOUT, - } - - def __init__(self): - super().__init__( - _TorRelaySettingsStorage.EMPTY_OBJECT, - lambda: APP().TorRelayWidget.StorageObj, - 'TorRelaySettings', - ) - - -ServerStorage = _ServerStorage() -RoutesStorage = _RoutesStorage() -SubscriptionStorage = _SubscriptionStorage() -TorRelaySettingsStorage = _TorRelaySettingsStorage() - - -class AsyncSubprocessMessage: - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.msgQueue = queue.Queue() - self.msgTimer = QtCore.QTimer() - - self.daemonThread = None - - def connectTimeoutCallback(self, callback): - self.msgTimer.timeout.connect(callback) - - def startDaemonThread(self, stdout): - def enqueue(msgQueue): - for line in iter(stdout.readline, b''): - msgQueue.put(line.decode('utf-8', 'replace').strip()) - - stdout.close() - - self.daemonThread = threading.Thread( - target=enqueue, args=(self.msgQueue,), daemon=True - ) - self.daemonThread.start() - - def startTimer(self, msec=1): - self.msgTimer.start(msec) - - def stopTimer(self): - self.msgTimer.stop() - - def getLineNoWait(self): - try: - return self.msgQueue.get_nowait() - except queue.Empty: - # Queue is empty - - return '' - - -class Switch: +class BinarySettings: OFF = '0' ON_ = '1' RANGE = [OFF, ON_] -class SupportConnectedCallback: - Object = list() - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - SupportConnectedCallback.Object.append(self) - - def disconnectedCallback(self): - raise NotImplementedError - - def connectedCallback(self): - raise NotImplementedError - - @staticmethod - def callConnectedCallback(): - for ob in SupportConnectedCallback.Object: - assert isinstance(ob, SupportConnectedCallback) - - ob.connectedCallback() - - @staticmethod - def callDisconnectedCallback(): - for ob in SupportConnectedCallback.Object: - assert isinstance(ob, SupportConnectedCallback) - - ob.disconnectedCallback() - - -class NeedSyncSettings: - Object = list() - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - NeedSyncSettings.Object.append(self) - - def syncSettings(self): - raise NotImplementedError - - @staticmethod - def syncAll(): - for ob in NeedSyncSettings.Object: - assert isinstance(ob, NeedSyncSettings) - - ob.syncSettings() - - -class SupportThemeChangedCallback: - Object = list() - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - SupportThemeChangedCallback.Object.append(self) - - def themeChangedCallback(self, theme): - raise NotImplementedError - - @staticmethod - def callThemeChangedCallback(theme): - logger.info(f'system theme changed to {theme}') - - for ob in SupportThemeChangedCallback.Object: - assert isinstance(ob, SupportThemeChangedCallback) - - ob.themeChangedCallback(theme) - - -class DNSResolver: - networkAccessManager = QNetworkAccessManager() - - @staticmethod - def request(address): - request = QNetworkRequest( - QtCore.QUrl(f'https://cloudflare-dns.com/dns-query?name={address}') - ) - request.setRawHeader('accept'.encode(), 'application/dns-json'.encode()) - - return request - - @staticmethod - def handleFinishedByNetworkReply(networkReply, domain, resultMap): - assert isinstance(networkReply, QNetworkReply) - - if networkReply.error() != QNetworkReply.NetworkError.NoError: - logger.error( - f'DNS resolution for \'{domain}\' failed. {networkReply.errorString()}' - ) - - resultMap['error'] = True - else: - logger.info(f'DNS resolution for \'{domain}\' success') - - # Unchecked? - replyObject = ujson.loads(networkReply.readAll().data()) - - for record in replyObject['Answer']: - address = record['data'] - - logger.info(f'\'{domain}\' resolved to \'{address}\'') - - if isValidIPAddress(address): - resultMap['result'][address] = True - else: - resultMap['depth'] += 1 - - newNetworkReply = DNSResolver.networkAccessManager.get( - DNSResolver.request(address) - ) - newNetworkReply.finished.connect( - functools.partial( - DNSResolver.handleFinishedByNetworkReply, - newNetworkReply, - address, - resultMap, - ) - ) - - resultMap['reference'].append(newNetworkReply) - - resultMap['depth'] -= 1 - - @staticmethod - def resolve(domain, proxyHost=None, proxyPort=None): - if proxyHost is None or proxyPort is None: - DNSResolver.networkAccessManager.setProxy(QNetworkProxy.ProxyType.NoProxy) - else: - try: - DNSResolver.networkAccessManager.setProxy( - QNetworkProxy( - QNetworkProxy.ProxyType.HttpProxy, proxyHost, int(proxyPort) - ) - ) - - logger.info(f'DNS resolution uses proxy server {proxyHost}:{proxyPort}') - except Exception as ex: - # Any non-exit exceptions - - logger.error( - f'invalid proxy server {proxyHost}:{proxyPort}. {ex}. ' - 'DNS resolution uses no proxy' - ) - - DNSResolver.networkAccessManager.setProxy( - QNetworkProxy.ProxyType.NoProxy - ) - - resultMap = { - 'depth': 0, - 'error': False, - 'reference': [], - 'result': {}, - } - - resultMap['depth'] += 1 - - networkReply = DNSResolver.networkAccessManager.get(DNSResolver.request(domain)) - networkReply.finished.connect( - functools.partial( - DNSResolver.handleFinishedByNetworkReply, - networkReply, - domain, - resultMap, - ) - ) - - resultMap['reference'].append(networkReply) - - DNSResolver.wait(resultMap) - - return resultMap['error'], list(resultMap['result'].keys()) - - @staticmethod - def wait(resultMap, startCounter=0, timeout=30000, step=100): - if resultMap['depth'] != 0: - logger.info('DNS resolution in progress. Wait') - else: - return - - while resultMap['depth'] != 0 and startCounter < timeout: - eventLoopWait(step) - - startCounter += step - - if resultMap['depth'] != 0: - logger.error('DNS resolution timeout') - - for networkReply in resultMap['reference']: - if ( - isinstance(networkReply, QNetworkReply) - and not networkReply.isFinished() - ): - networkReply.abort() - - -def icon(prefix, name): - if name.startswith('rocket-takeoff'): - # Colorful. Use default - return Icon(f':/Icons/bootstrap/{name}') - else: - return Icon(f':/Icons/{prefix}/{name}') - - -bootstrapIcon = functools.partial(icon, 'bootstrap') -bootstrapIconWhite = functools.partial(icon, 'bootstrap/white') - - @functools.lru_cache(None) -def protocolRepr(protocol): +def protocolRepr(protocol: str) -> str: if protocol.lower() == 'vmess': return Protocol.VMess @@ -461,7 +56,7 @@ def protocolRepr(protocol): @functools.lru_cache(None) -def isValidIPAddress(address): +def isValidIPAddress(address) -> bool: try: ipaddress.ip_address(address) except Exception: @@ -473,7 +68,7 @@ def isValidIPAddress(address): # Can throw exceptions -def parseHostPort(address): +def parseHostPort(address) -> Tuple[AnyStr | None, str]: if address.count('//') == 0: result = urllib.parse.urlsplit('//' + address) else: @@ -482,140 +77,14 @@ def parseHostPort(address): return result.hostname, str(result.port) -def enumValueWrapper(enum): - # Protect PySide6 enum wrapper behavior changes - if PYSIDE6_VERSION < '6.2.2': - return enum - else: - return enum.value - - -def eventLoopWait(ms): - # Protect qWait method does not exist in some - # old PySide6 version - if PYSIDE6_VERSION < '6.3.1': - for counter in range(0, ms, 10): - time.sleep(10 / 1000) - - APP().processEvents() - else: - from PySide6.QtTest import QTest - - QTest.qWait(ms) - - APP().processEvents() - - -def runCommand(*args, **kwargs): +def runExternalCommand(*args, **kwargs): if PLATFORM == 'Windows': - return subprocess.run( - *args, creationflags=subprocess.CREATE_NO_WINDOW, **kwargs - ) + creationflags = kwargs.pop('creationflags', subprocess.CREATE_NO_WINDOW) + + return subprocess.run(*args, creationflags=creationflags, **kwargs) else: return subprocess.run(*args, **kwargs) def getAbsolutePath(path): return path if os.path.isabs(path) else str(ROOT_DIR / path) - - -@functools.lru_cache(None) -def getUbuntuRelease(): - try: - result = runCommand( - ['cat', '/etc/lsb-release'], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - check=True, - ) - - values = dict( - list(line.split('=')) - for line in filter(lambda x: x != '', result.stdout.decode().split('\n')) - ) - - if values['DISTRIB_ID'] == 'Ubuntu': - return values['DISTRIB_RELEASE'] - else: - return '' - except Exception: - # Any non-exit exceptions - - return '' - - -@functools.lru_cache(None) -def getConnectedColor(): - if not isAdministrator(): - return Color.LIGHT_RED_ - else: - return Color.LIGHT_PURPLE - - -@functools.lru_cache(None) -def getConnectedWindowIcon(): - if not isAdministrator(): - return bootstrapIcon('rocket-takeoff-connected-dark.svg') - else: - return bootstrapIcon('rocket-takeoff-admin-connected.svg') - - -def swapListItem(listOrTuple, index0, index1): - swap = listOrTuple[index0] - - listOrTuple[index0] = listOrTuple[index1] - listOrTuple[index1] = swap - - -@functools.lru_cache(None) -def isAdministrator(): - if PLATFORM == 'Windows': - return ctypes.windll.shell32.IsUserAnAdmin() == 1 - else: - return os.geteuid() == 0 - - -def isVPNMode(): - return isAdministrator() and APP().VPNMode == Switch.ON_ - - -def isScriptMode(): - return sys.argv[0].endswith('.py') - - -def isRealFile(file): - if not hasattr(file, 'fileno'): - return False - - try: - tmp = os.dup(file.fileno()) - except Exception: - # Any non-exit exceptions - - return False - else: - os.close(tmp) - - return True - - -def isPythonw(): - # pythonw.exe. Also applies to packed GUI application on Windows - return not isRealFile(sys.__stdout__) or not isRealFile(sys.__stderr__) - - -def isWindows7(): - return PLATFORM == 'Windows' and PLATFORM_RELEASE == '7' - - -def moveToCenter(widget, parent=None): - geometry = widget.geometry() - - if parent is None: - center = QApplication.primaryScreen().availableGeometry().center() - else: - center = parent.geometry().center() - - geometry.moveCenter(center) - - widget.move(geometry.topLeft()) diff --git a/Furious/Utility/__init__.py b/Furious/Utility/__init__.py index 52b2594..899445a 100644 --- a/Furious/Utility/__init__.py +++ b/Furious/Utility/__init__.py @@ -1,4 +1,4 @@ -# Copyright (C) 2023 Loren Eteval +# Copyright (C) 2024 Loren Eteval # # This file is part of Furious. # @@ -15,3 +15,14 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +from .Constants import * +from .PySide6Legacy import * +from .StartupOnBoot import * +from .SystemProxy import * +from .SystemRoutingTable import * +from .SystemRuntime import * + +from .AppMainProcess import * +from .AppSettings import * +from .AppSettingsFn import * +from .Utility import * diff --git a/Furious/Version.py b/Furious/Version.py index 6df2b13..ae470fc 100644 --- a/Furious/Version.py +++ b/Furious/Version.py @@ -1,4 +1,4 @@ -# Copyright (C) 2023 Loren Eteval +# Copyright (C) 2024 Loren Eteval # # This file is part of Furious. # @@ -15,4 +15,4 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -__version__ = '0.2.13' +__version__ = '0.3.0' diff --git a/Furious/Widget/Application.py b/Furious/Widget/Application.py index 290327a..05f8c9d 100644 --- a/Furious/Widget/Application.py +++ b/Furious/Widget/Application.py @@ -1,4 +1,4 @@ -# Copyright (C) 2023 Loren Eteval +# Copyright (C) 2024 Loren Eteval # # This file is part of Furious. # @@ -15,54 +15,38 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from Furious.Widget.SystemTrayIcon import SystemTrayIcon -from Furious.Widget.EditConfiguration import EditConfigurationWidget -from Furious.Widget.EditRouting import EditRoutingWidget -from Furious.Widget.EditSubscription import EditSubscriptionWidget -from Furious.Widget.LogViewer import LogViewerWidget -from Furious.Widget.TorRelaySettings import TorRelaySettingsWidget -from Furious.Utility.Constants import ( - APPLICATION_NAME, - APPLICATION_VERSION, - ORGANIZATION_NAME, - ORGANIZATION_DOMAIN, - PYSIDE6_VERSION, - PLATFORM, - PLATFORM_RELEASE, - LOCAL_SERVER_NAME, - SYSTEM_LANGUAGE, - DATA_DIR, - LogType, -) -from Furious.Utility.Utility import ( - ServerStorage, - SupportThemeChangedCallback, - NeedSyncSettings, - isScriptMode, - isPythonw, -) -from Furious.Utility.Proxy import Proxy -from Furious.Utility.Settings import Settings -from Furious.Utility.Translator import gettext as _ +from Furious.Interface import * +from Furious.PyFramework import * +from Furious.QtFramework import * +from Furious.QtFramework import gettext as _ +from Furious.Utility import * +from Furious.Storage import * +from Furious.Widget.SystemTrayIcon import * +from Furious.Window.AppMainWindow import * +from Furious.Window.LogViewerWindow import * from PySide6 import QtCore -from PySide6.QtGui import QFontDatabase -from PySide6.QtWidgets import QApplication -from PySide6.QtNetwork import QLocalServer, QLocalSocket +from PySide6.QtGui import * +from PySide6.QtNetwork import * +from PySide6.QtWidgets import * import os import sys import time import logging -import traceback import threading +import traceback +import functools +import qdarkstyle import darkdetect logger = logging.getLogger(__name__) +registerAppSettings('AppLogViewerWidgetPointSize') +registerAppSettings('CoreLogViewerWidgetPointSize') +registerAppSettings('TunLogViewerWidgetPointSize') -def getPythonVersion(): - return '.'.join(str(info) for info in sys.version_info) +needTrans = functools.partial(needTransFn, source=__name__) def rateLimited(maxCallPerSecond): @@ -97,28 +81,41 @@ class SystemTrayUnavailable(Exception): pass -class AppLogViewerHandle(logging.Handler): - def __init__(self, textBrowser): +class AppLogHandler(logging.Handler): + def __init__(self, emitCallback): super().__init__() - self.textBrowser = textBrowser + self.emitCallback = emitCallback def emit(self, record): - self.textBrowser.append(self.format(record)) + if callable(self.emitCallback): + self.emitCallback(self.format(record)) -class SingletonApplication(QApplication): +class ApplicationExitHelper(QApplication): def __init__(self, argv): super().__init__(argv) # Exiting flag - self.exiting = False + self._exiting = False + + def setExitingFlag(self, value: bool): + self._exiting = value + + def isExiting(self) -> bool: + return self._exiting is True + + +class SingletonApplication(ApplicationExitHelper): + def __init__(self, argv): + super().__init__(argv) + self.serverName = LOCAL_SERVER_NAME self.socket = QLocalSocket(self) self.server = QLocalServer(self) - def checkForExistingApp(self): + def hasRunningApp(self) -> bool: self.socket.connectToServer(self.serverName) if self.socket.waitForConnected(1000): @@ -150,13 +147,15 @@ class ApplicationThemeDetector(QtCore.QObject): super().__init__(*args, **kwargs) -class Application(SingletonApplication): - class ErrorCode: - ExitSuccess = 0 - UnknownException = 1 - PlatformNotSupported = 2 - AssertionError = 3 +needTrans( + 'Already started', + 'Furious Log', + 'Core Log', + 'Tun2socks Log', +) + +class Application(ApplicationFactory, SingletonApplication): def __init__(self, argv): super().__init__(argv) @@ -165,70 +164,60 @@ class Application(SingletonApplication): self.setOrganizationName(ORGANIZATION_NAME) self.setOrganizationDomain(ORGANIZATION_DOMAIN) - self.tray = None - - # Whether server test has been performed - self.testPerformed = False + self.systemTray = None # Font self.customFontLoadMsg = '' self.customFontEnabled = False self.customFontName = '' - # Log Viewer Widget - self.logViewerWidget = None - # Log Handle - self.appLogViewerHandle = None - self.appLogStreamHandle = None - # Theme Detect self.currentTheme = None self.themeDetectTimer = None self.themeDetector = None self.themeListenerThread = None - # Main Widget - self.SubscriptionWidget = None - self.ServerWidget = None - self.RoutesWidget = None - self.TorRelayWidget = None - - def __getattr__(self, key): - try: - return Settings.get(key) - except AttributeError: - raise - - def __setattr__(self, key, value): - try: - Settings.set(key, value) - except AttributeError: - pass - - super().__setattr__(key, value) + # Initialize storage + self.userServers = UserServers() + self.userSubs = UserSubs() @rateLimited(maxCallPerSecond=2) @QtCore.Slot() def showExistingApp(self): - if isinstance(self.tray, SystemTrayIcon): + if isinstance(self.systemTray, SystemTrayIcon): logger.info('attempting to start multiple instance. Show tray message') - self.tray.showMessage(_('Already started')) + self.systemTray.showMessage(_('Already started')) else: # The tray hasn't been initialized. Do nothing pass def configureLogging(self): - self.logViewerWidget = LogViewerWidget() - self.appLogViewerHandle = AppLogViewerHandle( - self.logViewerWidget.textBrowser(LogType.App) + self.logViewerWindowApp_ = LogViewerWindow( + tabTitle=_('Furious Log'), + fontFamily=self.customFontName, + pointSizeSettingsName='AppLogViewerWidgetPointSize', + ) + self.logViewerWindowCore = LogViewerWindow( + tabTitle=_('Core Log'), + fontFamily=self.customFontName, + pointSizeSettingsName='CoreLogViewerWidgetPointSize', + ) + self.logViewerWindowTun_ = LogViewerWindow( + tabTitle=_('Tun2socks Log'), + fontFamily=self.customFontName, + pointSizeSettingsName='TunLogViewerWidgetPointSize', ) - self.appLogStreamHandle = logging.StreamHandler() logging.basicConfig( format='[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s', level=logging.INFO, - handlers=(self.appLogViewerHandle, self.appLogStreamHandle), + handlers=( + AppLogHandler( + lambda record: self.logViewerWindowApp_.appendLine(record) + ), + logging.StreamHandler(), + ), ) logging.raiseExceptions = False @@ -239,7 +228,6 @@ class Application(SingletonApplication): if QFontDatabase.addApplicationFont(fontFile) != -1: # Delayed self.customFontLoadMsg = f'custom font {fontName} load success' - self.customFontEnabled = True self.customFontName = fontName else: @@ -251,149 +239,149 @@ class Application(SingletonApplication): # Xray environment variables os.environ['XRAY_LOCATION_ASSET'] = str(DATA_DIR / 'xray') - def initTray(self): - if not SystemTrayIcon.isSystemTrayAvailable(): - raise SystemTrayUnavailable( - 'SystemTrayIcon is not available on this platform' - ) - - self.addEnviron() - self.addCustomFont() - self.configureLogging() - - logger.info(f'application version: {APPLICATION_VERSION}') - logger.info( - f'Qt version: {QtCore.qVersion()}. PySide6 version: {PYSIDE6_VERSION}' - ) - logger.info( - f'python version: {getPythonVersion()}. Platform: {PLATFORM}. ' - f'Platform release: {PLATFORM_RELEASE}' - ) - logger.info(f'system version: {sys.version}') - logger.info(f'sys.executable: {sys.executable}') - logger.info(f'sys.argv: {sys.argv}') - logger.info(f'appFilePath: {self.applicationFilePath()}') - logger.info(f'isPythonw: {isPythonw()}') - logger.info(f'system language is {SYSTEM_LANGUAGE}') - logger.info(self.customFontLoadMsg) - logger.info(f'current theme is {darkdetect.theme()}') - - if PLATFORM != 'Windows' and not isScriptMode(): - logger.info('theme detect method uses timer implementation') - - @QtCore.Slot() - def handleTimeout(): - currentTheme = darkdetect.theme() - - if self.currentTheme != currentTheme: - self.currentTheme = currentTheme - - SupportThemeChangedCallback.callThemeChangedCallback(currentTheme) - - self.currentTheme = darkdetect.theme() - self.themeDetectTimer = QtCore.QTimer() - self.themeDetectTimer.timeout.connect(handleTimeout) - self.themeDetectTimer.start(1000) + def isSystemTrayConnected(self): + if isinstance(self.systemTray, SystemTrayIcon): + return self.systemTray.ConnectAction.isConnected() else: - logger.info('theme detect method uses listener implementation') + return False - def listener(*args, **kwargs): - try: - darkdetect.listener(*args, **kwargs) - except NotImplementedError: - # Not supported by darkdetect. Ignore + @staticmethod + def isDarkModeEnabled(): + return AppSettings.isStateON_('DarkMode') - logger.error( - 'darkdetect listener is not implemented on this platform' - ) + def switchToDarkMode(self): + self.setStyleSheet(qdarkstyle.load_stylesheet_pyside6()) - pass + SupportThemeChangedCallback.callThemeChangedCallbackUnchecked('Dark') - self.themeDetector = ApplicationThemeDetector() - self.themeDetector.themeChanged.connect( - SupportThemeChangedCallback.callThemeChangedCallback - ) + def switchToAutoMode(self): + self.setStyleSheet('') - self.themeListenerThread = threading.Thread( - target=listener, - args=(self.themeDetector.themeChanged.emit,), - daemon=True, - ) - self.themeListenerThread.start() - - # Mandatory - self.setQuitOnLastWindowClosed(False) - - # Reset proxy - Proxy.off() - Proxy.daemonOn_() - - self.aboutToQuit.connect(self.cleanup) - - self.SubscriptionWidget = EditSubscriptionWidget() - self.RoutesWidget = EditRoutingWidget() - self.ServerWidget = EditConfigurationWidget() - self.TorRelayWidget = TorRelaySettingsWidget() - - self.tray = SystemTrayIcon() - self.tray.show() - self.tray.setApplicationToolTip() - self.tray.bootstrap() - - return self - - def isConnected(self): - return self.tray is not None and self.tray.ConnectAction.isConnected() + SupportThemeChangedCallback.callThemeChangedCallbackUnchecked( + darkdetect.theme() + ) @QtCore.Slot() def cleanup(self): - Proxy.off() - Proxy.daemonOff() + SystemProxy.off() + SystemProxy.daemonOff() - # Try to avoid unnecessary storage sync. - # Better way to do this? - if self.testPerformed: - ServerStorage.sync() + SupportExitCleanup.cleanupAll() - NeedSyncSettings.syncAll() - - if self.tray is not None: - self.tray.ConnectAction.stopCore() + logger.info('final cleanup done') def exit(self, exitcode=0): - if self.ServerWidget is not None: - if self.ServerWidget.questionSave(): - # Changes handled. Exit - self.ServerWidget.pingThreadPool.clear() - self.exiting = True + self.setExitingFlag(True) - super().exit(exitcode) - else: - self.exiting = True + QtCore.QThreadPool.globalInstance().clear() - super().exit(exitcode) - - def log(self): - if self.logViewerWidget is not None: - return self.logViewerWidget.log(LogType.App) - else: - return '' + super().exit(exitcode) def run(self): try: - if not self.checkForExistingApp(): - return self.initTray().exec() + if self.hasRunningApp(): + # See: https://github.com/python/cpython/issues/79908 + # sys.exit(None) in multiprocessing will produce + # exitcode 1 in some Python version, which is + # not what we want. + return ApplicationFactory.ExitCode.ExitSuccess - # See: https://github.com/python/cpython/issues/79908 - # sys.exit(None) in multiprocessing will produce - # exitcode 1 in some Python version, which is - # not what we want. - return Application.ErrorCode.ExitSuccess + if not SystemTrayIcon.isSystemTrayAvailable(): + raise SystemTrayUnavailable( + 'SystemTrayIcon is not available on this platform' + ) + + self.addEnviron() + self.addCustomFont() + self.configureLogging() + + logger.info(f'application version: {APPLICATION_VERSION}') + logger.info( + f'Qt version: {QtCore.qVersion()}. PySide6 version: {PYSIDE6_VERSION}' + ) + logger.info( + f'python version: {getPythonVersion()}. Platform: {PLATFORM}. ' + f'Platform release: {PLATFORM_RELEASE}' + ) + logger.info(f'system version: {sys.version}') + logger.info(f'sys.executable: {sys.executable}') + logger.info(f'sys.argv: {sys.argv}') + logger.info(f'appFilePath: {self.applicationFilePath()}') + logger.info(f'isPythonw: {isPythonw()}') + logger.info(f'system language is {SYSTEM_LANGUAGE}') + logger.info(self.customFontLoadMsg) + logger.info(f'current theme is {darkdetect.theme()}') + + if PLATFORM != 'Windows' and not isScriptMode(): + logger.info('theme detect method uses timer implementation') + + @QtCore.Slot() + def handleTimeout(): + currentTheme = darkdetect.theme() + + if self.currentTheme != currentTheme: + self.currentTheme = currentTheme + + SupportThemeChangedCallback.callThemeChangedCallback( + currentTheme + ) + + self.currentTheme = darkdetect.theme() + self.themeDetectTimer = QtCore.QTimer() + self.themeDetectTimer.timeout.connect(handleTimeout) + self.themeDetectTimer.start(1000) + else: + logger.info('theme detect method uses listener implementation') + + def listener(*args, **kwargs): + try: + darkdetect.listener(*args, **kwargs) + except NotImplementedError: + # Not supported by darkdetect. Ignore + + logger.error( + 'darkdetect listener is not implemented on this platform' + ) + + pass + + self.themeDetector = ApplicationThemeDetector() + self.themeDetector.themeChanged.connect( + SupportThemeChangedCallback.callThemeChangedCallback + ) + + self.themeListenerThread = threading.Thread( + target=listener, + args=(self.themeDetector.themeChanged.emit,), + daemon=True, + ) + self.themeListenerThread.start() + + # Mandatory + self.setQuitOnLastWindowClosed(False) + + # Reset proxy + SystemProxy.off() + SystemProxy.daemonOn_() + + self.aboutToQuit.connect(self.cleanup) + + self.mainWindow = AppMainWindow() + self.systemTray = SystemTrayIcon() + + if AppSettings.isStateON_('DarkMode'): + self.switchToDarkMode() + + self.systemTray.show() + self.systemTray.setCustomToolTip() + self.systemTray.bootstrap() + + return self.exec() except SystemTrayUnavailable: - return Application.ErrorCode.PlatformNotSupported + return ApplicationFactory.ExitCode.PlatformNotSupported except Exception: # Any non-exit exceptions traceback.print_exc() - return Application.ErrorCode.UnknownException + return ApplicationFactory.ExitCode.UnknownException diff --git a/Furious/Widget/AssetViewer.py b/Furious/Widget/AssetViewer.py deleted file mode 100644 index e1c7e0a..0000000 --- a/Furious/Widget/AssetViewer.py +++ /dev/null @@ -1,279 +0,0 @@ -# Copyright (C) 2023 Loren Eteval -# -# This file is part of Furious. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -from Furious.Gui.Action import Action, Seperator -from Furious.Widget.Widget import ListWidget, MainWindow, Menu, MessageBox -from Furious.Utility.Constants import APPLICATION_NAME, PLATFORM, GOLDEN_RATIO, ROOT_DIR -from Furious.Utility.Utility import ( - StateContext, - SupportConnectedCallback, - SupportThemeChangedCallback, - bootstrapIcon, - bootstrapIconWhite, - enumValueWrapper, - getUbuntuRelease, - moveToCenter, -) -from Furious.Utility.Translator import Translatable, gettext as _ - -from PySide6 import QtCore -from PySide6.QtGui import QFont -from PySide6.QtWidgets import QFileDialog, QListWidget, QListWidgetItem - -import os -import shutil -import logging -import darkdetect - -logger = logging.getLogger(__name__) - - -class QuestionDeleteBox(MessageBox): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.isMulti = False - self.possibleRemark = '' - - self.setWindowTitle(_('Delete')) - - self.setStandardButtons( - MessageBox.StandardButton.Yes | MessageBox.StandardButton.No - ) - - def getText(self): - if self.isMulti: - return _('Delete these asset files?') - else: - return _('Delete this asset file?') + f'\n\n{self.possibleRemark}' - - def retranslate(self): - with StateContext(self): - self.setWindowTitle(_(self.windowTitle())) - self.setText(self.getText()) - - # Ignore informative text, buttons - - self.moveToCenter() - - -class AssetExistsBox(MessageBox): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.setWindowTitle(_('Import')) - - self.setStandardButtons( - MessageBox.StandardButton.Yes | MessageBox.StandardButton.No - ) - - def retranslate(self): - with StateContext(self): - self.setWindowTitle(_(self.windowTitle())) - self.setText(_(self.text())) - - # Ignore informative text, buttons - - self.moveToCenter() - - -class ImportAction(Action): - def __init__(self, **kwargs): - super().__init__(_('Import From File...'), **kwargs) - - self.assetExistsBox = AssetExistsBox( - icon=MessageBox.Icon.Question, parent=self.parent() - ) - self.importErrorBox = MessageBox( - icon=MessageBox.Icon.Critical, parent=self.parent() - ) - self.importSuccessBox = MessageBox( - icon=MessageBox.Icon.Information, parent=self.parent() - ) - - def triggeredCallback(self, checked): - filename, selectedFilter = QFileDialog.getOpenFileName( - self.parent(), _('Import File'), filter=_('All files (*)') - ) - - if filename: - basename = os.path.basename(filename) - - if os.path.isfile(AssetViewerWidget.AssetDir / os.path.basename(filename)): - self.assetExistsBox.setWindowTitle(_('Import')) - self.assetExistsBox.setText(_('Asset file already exists. Overwrite?')) - self.assetExistsBox.setInformativeText(basename) - - if self.assetExistsBox.exec() == enumValueWrapper( - MessageBox.StandardButton.No - ): - # Do not overwrite - return - - try: - shutil.copy(filename, AssetViewerWidget.AssetDir) - except shutil.SameFileError: - # Same file imported. Do nothing - pass - except Exception as ex: - # Any non-exit exception - - self.importErrorBox.setWindowTitle(_('Import')) - self.importErrorBox.setText(_('Error import asset file.')) - self.importErrorBox.setInformativeText(str(ex)) - - # Show the MessageBox and wait for user to close it - self.importErrorBox.exec() - else: - self.parent().flushItem() - - self.importSuccessBox.setWindowTitle(_('Import')) - self.importSuccessBox.setText(_('Import asset file success.')) - - # Show the MessageBox and wait for user to close it - self.importSuccessBox.exec() - - -class ExitAction(Action): - def __init__(self, **kwargs): - super().__init__(_('Exit'), **kwargs) - - def triggeredCallback(self, checked): - self.parent().hide() - - -class AssetViewerWidget(SupportThemeChangedCallback, MainWindow): - AssetDir = ROOT_DIR / APPLICATION_NAME / 'Data' / 'xray' - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.setWindowTitle(_('Asset File')) - self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg')) - - self.questionDeleteBox = QuestionDeleteBox( - icon=MessageBox.Icon.Question, parent=self - ) - - self.listWidget = ListWidget(parent=self) - self.listWidget.setSelectionBehavior(QListWidget.SelectionBehavior.SelectRows) - self.listWidget.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection) - self.listWidget.setIconSize(QtCore.QSize(64, 64)) - - if PLATFORM == 'Linux' and getUbuntuRelease() == '20.04': - self.initialTheme = darkdetect.theme() - else: - self.initialTheme = None - - self.flushItem() - self.setCentralWidget(self.listWidget) - - logger.info(f'asset dir is \'{AssetViewerWidget.AssetDir}\'') - - contextMenuActions = [ - Action( - _('Delete'), callback=lambda: self.deleteSelectedItem(), parent=self - ), - ] - - self.contextMenu = Menu(*contextMenuActions) - - self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) - self.customContextMenuRequested.connect(self.handleCustomContextMenuRequested) - - fileMenuActions = [ - ImportAction(parent=self), - Seperator(), - ExitAction(parent=self), - ] - - for menu in (fileMenuActions,): - for action in menu: - if isinstance(action, Action): - if hasattr(self, f'{action}'): - logger.warning(f'{self} already has action {action}') - - setattr(self, f'{action}', action) - - self._fileMenu = Menu(*fileMenuActions, title=_('File'), parent=self) - self.menuBar().addMenu(self._fileMenu) - - def setWidthAndHeight(self): - self.setGeometry(100, 100, 360, 360 * GOLDEN_RATIO) - - @QtCore.Slot(QtCore.QPoint) - def handleCustomContextMenuRequested(self, point): - self.contextMenu.exec(self.mapToGlobal(point)) - - def deleteSelectedItem(self): - indexes = self.listWidget.selectedIndex - - if len(indexes) == 0: - # Nothing selected - return - - self.questionDeleteBox.isMulti = bool(len(indexes) > 1) - self.questionDeleteBox.possibleRemark = ( - f'{self.listWidget.item(indexes[0]).text()}' - ) - self.questionDeleteBox.setText(self.questionDeleteBox.getText()) - - if self.questionDeleteBox.exec() == enumValueWrapper( - MessageBox.StandardButton.No - ): - # Do not delete - return - - for index in indexes: - os.remove(AssetViewerWidget.AssetDir / self.listWidget.item(index).text()) - - self.flushItem() - - def flushItemByTheme(self, theme): - self.listWidget.clear() - - for filename in os.listdir(AssetViewerWidget.AssetDir): - if os.path.isfile(AssetViewerWidget.AssetDir / filename): - item = QListWidgetItem(filename) - - if theme == 'Dark': - if PLATFORM == 'Windows': - # Windows. Always use black icon - item.setIcon(bootstrapIcon('file-earmark.svg')) - else: - item.setIcon(bootstrapIconWhite('file-earmark.svg')) - else: - item.setIcon(bootstrapIcon('file-earmark.svg')) - - self.listWidget.addItem(item) - - def flushItem(self): - if PLATFORM == 'Linux' and getUbuntuRelease() == '20.04': - assert self.initialTheme is not None - - # Ubuntu 20.04. Flush by initial theme - self.flushItemByTheme(self.initialTheme) - else: - self.flushItemByTheme(darkdetect.theme()) - - def themeChangedCallback(self, theme): - if PLATFORM == 'Linux' and getUbuntuRelease() == '20.04': - # Ubuntu 20.04 system dark theme does not - # change menu color. Do nothing - pass - else: - self.flushItemByTheme(theme) diff --git a/Furious/Widget/ConnectingProgressBar.py b/Furious/Widget/ConnectProgressBar.py similarity index 52% rename from Furious/Widget/ConnectingProgressBar.py rename to Furious/Widget/ConnectProgressBar.py index dad759a..fe24728 100644 --- a/Furious/Widget/ConnectingProgressBar.py +++ b/Furious/Widget/ConnectProgressBar.py @@ -1,4 +1,4 @@ -# Copyright (C) 2023 Loren Eteval +# Copyright (C) 2024 Loren Eteval # # This file is part of Furious. # @@ -15,51 +15,57 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from Furious.Utility.Constants import APPLICATION_NAME, Color -from Furious.Utility.Utility import ( - bootstrapIcon, - StateContext, - SupportConnectedCallback, - getConnectedColor, - getConnectedWindowIcon, -) -from Furious.Utility.Translator import Translatable, gettext as _ +from Furious.PyFramework import * +from Furious.QtFramework import * +from Furious.QtFramework import gettext as _ +from Furious.Utility import * from PySide6 import QtCore from PySide6.QtWidgets import QHBoxLayout, QProgressBar, QWidget +__all__ = ['ConnectProgressBar'] -class ConnectingProgressBar(Translatable, SupportConnectedCallback, QWidget): + +class ConnectProgressBar(QTranslatable, SupportConnectedCallback, QWidget): def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle(_(APPLICATION_NAME)) - self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg')) + self.setWindowIcon(AppHue.currentWindowIcon()) self.setFixedSize(280, 61) @QtCore.Slot() def updateProgressBar(): # Update the progress bar value - if self.progressBar.value() < 90: - self.progressBar.setValue(self.progressBar.value() + 1) + if self._progressBar.value() < 90: + self._progressBar.setValue(self._progressBar.value() + 1) # Stop the timer when the progress bar reaches 100% - if self.progressBar.value() >= 100: - self.timer.stop() + if self._progressBar.value() >= 100: + self._timer.stop() # Create a progress bar widget - self.progressBar = QProgressBar(self) - self.progressBar.setRange(0, 100) - self.progressBar.setStyleSheet(self.getStyleSheet(Color.LIGHT_BLUE)) + self._progressBar = QProgressBar(self) + self._progressBar.setRange(0, 100) + self._progressBar.setStyleSheet(self.getStyleSheet(AppHue.disconnectedColor())) - # create a timer to update the progress bar - self.timer = QtCore.QTimer(self) - self.timer.timeout.connect(updateProgressBar) + # Create a timer to update the progress bar + self._timer = QtCore.QTimer(self) + self._timer.timeout.connect(updateProgressBar) - self.layout = QHBoxLayout() - self.layout.addWidget(self.progressBar) + self._layout = QHBoxLayout() + self._layout.addWidget(self._progressBar) - self.setLayout(self.layout) + self.setLayout(self._layout) + + def setValue(self, value: int): + self._progressBar.setValue(value) + + def start(self, msec: int): + self._timer.start(msec) + + def stop(self): + self._timer.stop() @staticmethod def getStyleSheet(color): @@ -81,14 +87,13 @@ class ConnectingProgressBar(Translatable, SupportConnectedCallback, QWidget): self.hide() - def connectedCallback(self): - self.setWindowIcon(getConnectedWindowIcon()) - self.progressBar.setStyleSheet(self.getStyleSheet(getConnectedColor())) - def disconnectedCallback(self): - self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg')) - self.progressBar.setStyleSheet(self.getStyleSheet(Color.LIGHT_BLUE)) + self.setWindowIcon(AppHue.disconnectedWindowIcon()) + self._progressBar.setStyleSheet(self.getStyleSheet(AppHue.disconnectedColor())) + + def connectedCallback(self): + self.setWindowIcon(AppHue.connectedWindowIcon()) + self._progressBar.setStyleSheet(self.getStyleSheet(AppHue.connectedColor())) def retranslate(self): - with StateContext(self): - self.setWindowTitle(_(self.windowTitle())) + self.setWindowTitle(_(self.windowTitle())) diff --git a/Furious/Widget/EditConfiguration.py b/Furious/Widget/EditConfiguration.py deleted file mode 100644 index d01d282..0000000 --- a/Furious/Widget/EditConfiguration.py +++ /dev/null @@ -1,2838 +0,0 @@ -# Copyright (C) 2023 Loren Eteval -# -# This file is part of Furious. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -from Furious.Core.Core import Core, XrayCore, Hysteria1, Hysteria2 -from Furious.Core.TorRelay import TorRelay -from Furious.Core.Configuration import Configuration -from Furious.Core.Intellisense import Intellisense -from Furious.Action.Import import ImportLinkAction, ImportJSONAction -from Furious.Action.Export import ExportLinkAction, ExportJSONAction, ExportQRCodeAction -from Furious.Gui.Action import Action, Seperator -from Furious.Widget.Widget import ( - HeaderView, - MainWindow, - Menu, - MessageBox, - PushButton, - StyledItemDelegate, - TableWidget, - TabWidget, - ZoomablePlainTextEdit, -) -from Furious.Widget.IndentSpinBox import IndentSpinBox -from Furious.Utility.Constants import ( - APP, - APPLICATION_NAME, - APPLICATION_VERSION, - APPLICATION_ABOUT_PAGE, - APPLICATION_REPO_OWNER_NAME, - APPLICATION_REPO_NAME, - PLATFORM, - Color, -) -from Furious.Utility.Utility import ( - Base64Encoder, - StateContext, - ServerStorage, - Switch, - SupportConnectedCallback, - bootstrapIcon, - enumValueWrapper, - parseHostPort, - eventLoopWait, - swapListItem, - moveToCenter, - getConnectedColor, -) -from Furious.Utility.Translator import Translatable, gettext as _ -from Furious.Utility.Theme import DraculaTheme - -from PySide6 import QtCore -from PySide6.QtGui import QDesktopServices, QFont, QTextOption -from PySide6.QtWidgets import ( - QAbstractItemView, - QDialog, - QFileDialog, - QGridLayout, - QHBoxLayout, - QHeaderView, - QLabel, - QPlainTextEdit, - QSplitter, - QTableWidget, - QTableWidgetItem, - QTabWidget, - QVBoxLayout, - QWidget, -) -from PySide6.QtNetwork import ( - QNetworkAccessManager, - QNetworkReply, - QNetworkRequest, - QNetworkProxy, -) - -import os -import copy -import queue -import ujson -import ping3 -import logging -import operator -import functools -import multiprocessing - -logger = logging.getLogger(__name__) - - -def needSaveChanges(func): - def decorator(*args, **kwargs): - if APP().ServerWidget is not None and APP().ServerWidget.modified: - # Need save changes - APP().ServerWidget.saveChangeFirst.exec() - - return - - # Call the actual function - func(*args, **kwargs) - - return decorator - - -class NewBlankServerAction(Action): - def __init__(self, **kwargs): - super().__init__(_('New...'), **kwargs) - - self.setShortcut( - QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier.ControlModifier, - QtCore.Qt.Key.Key_N, - ) - ) - - def triggeredCallback(self, checked): - self.parent().importServer(_('Untitled'), '', syncStorage=True) - - -class ImportFileAction(Action): - def __init__(self, **kwargs): - super().__init__( - _('Import From File...'), - icon=bootstrapIcon('folder2-open.svg'), - **kwargs, - ) - - self.openErrorBox = MessageBox( - icon=MessageBox.Icon.Critical, parent=self.parent() - ) - - def triggeredCallback(self, checked): - filename, selectedFilter = QFileDialog.getOpenFileName( - self.parent(), - _('Import File'), - filter=_('Text files (*.json);;All files (*)'), - ) - - if filename: - try: - with open(filename, 'r', encoding='utf-8') as file: - plainText = file.read() - except Exception as ex: - # Any non-exit exceptions - - 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() - else: - self.parent().importServer( - os.path.basename(filename), plainText, syncStorage=True - ) - - -class ImportLinkActionWithHotKey(ImportLinkAction): - def __init__(self, **kwargs): - # Cloned. With hot key - super().__init__(**kwargs) - - self.setShortcut( - QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier.ControlModifier - | QtCore.Qt.KeyboardModifier.ShiftModifier, - QtCore.Qt.Key.Key_V, - ) - ) - - -class ImportJSONActionWithHotKey(ImportJSONAction): - def __init__(self, **kwargs): - # Cloned. With hot key - super().__init__(**kwargs) - - self.setShortcut( - QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier.ControlModifier, - QtCore.Qt.Key.Key_J, - ) - ) - - -class ExportLinkActionWithHotKey(ExportLinkAction): - def __init__(self, **kwargs): - # Cloned. With hot key - super().__init__(**kwargs) - - self.setShortcut( - QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier.ControlModifier - | QtCore.Qt.KeyboardModifier.ShiftModifier, - QtCore.Qt.Key.Key_C, - ) - ) - - -class ExportQRCodeActionWithHotKey(ExportQRCodeAction): - def __init__(self, **kwargs): - # Cloned. With hot key - super().__init__(**kwargs) - - self.setShortcut( - QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier.ControlModifier - | QtCore.Qt.KeyboardModifier.ShiftModifier, - QtCore.Qt.Key.Key_Q, - ) - ) - - -class ExportJSONActionWithHotKey(ExportJSONAction): - def __init__(self, **kwargs): - # Cloned. With hot key - super().__init__(**kwargs) - - self.setShortcut( - QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier.ControlModifier - | QtCore.Qt.KeyboardModifier.ShiftModifier, - QtCore.Qt.Key.Key_J, - ) - ) - - -class SelectAllServerAction(Action): - def __init__(self, **kwargs): - super().__init__(_('Select All'), **kwargs) - - self.setShortcut( - QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier.ControlModifier - | QtCore.Qt.KeyboardModifier.ShiftModifier, - QtCore.Qt.Key.Key_A, - ) - ) - - -class TestPingLatencyAction(Action): - def __init__(self, **kwargs): - super().__init__(_('Test Ping Latency'), **kwargs) - - self.setShortcut( - QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier.ControlModifier, - QtCore.Qt.Key.Key_P, - ) - ) - - def triggeredCallback(self, checked): - self.parent().testSelectedItemDelay() - - -class TestDownloadSpeedAction(Action): - def __init__(self, **kwargs): - super().__init__(_('Test Download Speed'), **kwargs) - - self.setShortcut( - QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier.ControlModifier, - QtCore.Qt.Key.Key_T, - ) - ) - - def triggeredCallback(self, checked): - self.parent().testSelectedItemSpeed() - - -class ClearTestResultsAction(Action): - def __init__(self, **kwargs): - super().__init__(_('Clear Test Results'), **kwargs) - - self.setShortcut( - QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier.ControlModifier, - QtCore.Qt.Key.Key_R, - ) - ) - - def triggeredCallback(self, checked): - self.parent().clearSelectedItemTestResults() - - -class ScrollToActivatedServerAction(Action): - def __init__(self, **kwargs): - super().__init__(_('Scroll To Activated Server'), **kwargs) - - self.setShortcut( - QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier.ControlModifier, - QtCore.Qt.Key.Key_G, - ) - ) - - @needSaveChanges - def triggeredCallback(self, checked): - activatedItem = self.parent().activatedItem - - self.parent().setCurrentItem(activatedItem) - self.parent().scrollToItem(activatedItem) - - -class JSONErrorBox(MessageBox): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.decodeError = '' - - def getText(self): - return ( - _('Please check your configuration is in valid JSON format:') - + f'\n\n{self.decodeError}' - ) - - def retranslate(self): - with StateContext(self): - self.setWindowTitle(_(self.windowTitle())) - self.setText(self.getText()) - - # Ignore informative text, buttons - - self.moveToCenter() - - -class SaveConfInfo(MessageBox): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.addButton(_('Reconnect'), MessageBox.ButtonRole.AcceptRole) - self.addButton(_('OK'), MessageBox.ButtonRole.RejectRole) - - -def questionReconnect(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 == enumValueWrapper(MessageBox.ButtonRole.AcceptRole): - # Reconnect - APP().tray.ConnectAction.reconnectAction() - else: - # OK. Do nothing - pass - - -class SaveAsServerAction(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 save(self, successCallback=None): - def saveSuccess(server): - myServerList = self.parent().ServerList - myFocusIndex = self.parent().currentFocus - - # Assert to be valid - assert 0 <= myFocusIndex < len(myServerList) - - # Unchecked? - myServerList[myFocusIndex]['config'] = server - # Flush row - self.parent().serverWidget.flushRow( - myFocusIndex, myServerList[myFocusIndex] - ) - - # Sync it - ServerStorage.sync() - - self.parent().markAsSaved() - - if callable(successCallback): - successCallback() - - if myFocusIndex == self.parent().activatedItemIndex: - # Activated configuration modified - - if server and APP().isConnected(): - questionReconnect(self.saveConfInfo) - - return True - - if not self.parent().modified: - return True - - text = self.parent().plainTextEdit.toPlainText() - - if text == '': - # Text is empty. Allowed. - return saveSuccess('') - - try: - myJSON = Configuration.toJSON(text) - except ujson.JSONDecodeError as decodeError: - self.saveErrorBox.decodeError = str(decodeError) - self.saveErrorBox.setWindowTitle(_('Error saving configuration')) - self.saveErrorBox.setText(self.saveErrorBox.getText()) - - # Show the MessageBox and wait for user to close it - self.saveErrorBox.exec() - - return False - except Exception: - # Any non-exit exceptions - - self.saveErrorBox.setWindowTitle(_('Error saving configuration')) - self.saveErrorBox.setText(_('Invalid server configuration.')) - - # Show the MessageBox and wait for user to close it - self.saveErrorBox.exec() - - return False - else: - return saveSuccess(text) - - def triggeredCallback(self, checked): - self.save() - - -class SaveErrorBox(MessageBox): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.saveError = '' - - def getText(self): - if self.saveError: - return _('Invalid server configuration.') + f'\n\n{self.saveError}' - else: - return _('Invalid server configuration.') - - def retranslate(self): - with StateContext(self): - self.setWindowTitle(_(self.windowTitle())) - self.setText(self.getText()) - - # Ignore informative text, buttons - - self.moveToCenter() - - -class SaveAsFileAction(Action): - def __init__(self, **kwargs): - super().__init__(_('Save As...'), **kwargs) - - self.saveErrorBox = SaveErrorBox( - icon=MessageBox.Icon.Critical, parent=self.parent() - ) - - def triggeredCallback(self, checked): - filename, selectedFilter = QFileDialog.getSaveFileName( - self.parent(), - _('Save File'), - filter=_('Text files (*.json);;All files (*)'), - ) - - if filename: - try: - with open(filename, 'w', encoding='utf-8') as file: - file.write(self.parent().plainTextEdit.toPlainText()) - except Exception as ex: - # Any non-exit exceptions - - self.saveErrorBox.saveError = str(ex) - self.saveErrorBox.setWindowTitle(_('Error saving file')) - self.saveErrorBox.setText(self.saveErrorBox.getText()) - - # Show the MessageBox and wait for user to close it - self.saveErrorBox.exec() - - -class ExitAction(Action): - def __init__(self, **kwargs): - super().__init__(_('Exit'), **kwargs) - - def triggeredCallback(self, checked): - self.parent().syncSettings() - self.parent().questionSave() - - -class UndoAction(Action): - def __init__(self, **kwargs): - super().__init__( - _('Undo'), - icon=bootstrapIcon('arrow-return-left.svg'), - **kwargs, - ) - - self.setShortcut( - QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier.ControlModifier, QtCore.Qt.Key.Key_Z - ) - ) - - def triggeredCallback(self, checked): - if self.parent().editorTab.isHidden(): - return - - self.parent().plainTextEdit.undo() - - -class RedoAction(Action): - def __init__(self, **kwargs): - super().__init__( - _('Redo'), - icon=bootstrapIcon('arrow-return-right.svg'), - **kwargs, - ) - - self.setShortcut( - QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier.ControlModifier - | QtCore.Qt.KeyboardModifier.ShiftModifier, - QtCore.Qt.Key.Key_Z, - ) - ) - - def triggeredCallback(self, checked): - if self.parent().editorTab.isHidden(): - return - - self.parent().plainTextEdit.redo() - - -class CutAction(Action): - def __init__(self, **kwargs): - super().__init__(_('Cut'), icon=bootstrapIcon('scissors.svg'), **kwargs) - - self.setShortcut( - QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier.ControlModifier, - QtCore.Qt.Key.Key_X, - ) - ) - - def triggeredCallback(self, checked): - if self.parent().editorTab.isHidden(): - return - - self.parent().plainTextEdit.cut() - - -class CopyAction(Action): - def __init__(self, **kwargs): - super().__init__(_('Copy'), icon=bootstrapIcon('files.svg'), **kwargs) - - self.setShortcut( - QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier.ControlModifier, - QtCore.Qt.Key.Key_C, - ) - ) - - def triggeredCallback(self, checked): - if self.parent().editorTab.isHidden(): - return - - self.parent().plainTextEdit.copy() - - -class PasteAction(Action): - def __init__(self, **kwargs): - super().__init__(_('Paste'), **kwargs) - - self.setShortcut( - QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier.ControlModifier, - QtCore.Qt.Key.Key_V, - ) - ) - - def triggeredCallback(self, checked): - if self.parent().editorTab.isHidden(): - return - - self.parent().plainTextEdit.paste() - - -class SelectAllTextAction(Action): - def __init__(self, **kwargs): - super().__init__(_('Select All'), **kwargs) - - self.setShortcut( - QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier.ControlModifier, - QtCore.Qt.Key.Key_A, - ) - ) - - def triggeredCallback(self, checked): - if self.parent().editorTab.isHidden(): - return - - self.parent().plainTextEdit.selectAll() - - -class IndentAction(Action): - def __init__(self, **kwargs): - super().__init__(_('Indent...'), **kwargs) - - self.indentSpinBox = IndentSpinBox() - self.indentFailure = JSONErrorBox(icon=MessageBox.Icon.Critical) - - def triggeredCallback(self, checked): - if self.parent().editorTab.isHidden(): - return - - choice = self.indentSpinBox.exec() - - if choice == enumValueWrapper(QDialog.DialogCode.Accepted): - myText = self.parent().plainTextEdit.toPlainText() - - if myText == '': - # Empty. Do nothing - return - - currentFocus = self.parent().currentFocus - - if currentFocus >= 0: - self.parent().saveScrollBarValue(currentFocus) - - try: - myJSON = Configuration.toJSON(myText) - myText = ujson.dumps( - myJSON, - indent=self.indentSpinBox.value(), - ensure_ascii=False, - escape_forward_slashes=False, - ) - except ujson.JSONDecodeError as decodeError: - self.indentFailure.decodeError = str(decodeError) - self.indentFailure.setWindowTitle(_('Error setting indent')) - self.indentFailure.setText(self.indentFailure.getText()) - - # Show the MessageBox and wait for user to close it - self.indentFailure.exec() - except Exception: - # Any non-exit exceptions - - self.indentFailure.setWindowTitle(_('Error setting indent')) - self.indentFailure.setText(_('Invalid server configuration.')) - - # Show the MessageBox and wait for user to close it - self.indentFailure.exec() - else: - self.parent().plainTextEdit.setPlainText(myText) - - if currentFocus >= 0: - self.parent().restoreScrollBarValue(currentFocus) - else: - # Do nothing - pass - - -class ZoomInAction(Action): - def __init__(self, **kwargs): - super().__init__(_('Zoom In'), **kwargs) - - self.setShortcut( - QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier.ControlModifier, QtCore.Qt.Key.Key_Plus - ) - ) - - def triggeredCallback(self, checked): - self.parent().plainTextEdit.zoomIn() - - -class ZoomOutAction(Action): - def __init__(self, **kwargs): - super().__init__(_('Zoom Out'), **kwargs) - - self.setShortcut( - QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier.ControlModifier, QtCore.Qt.Key.Key_Minus - ) - ) - - def triggeredCallback(self, checked): - self.parent().plainTextEdit.zoomOut() - - -class ShowHideEditorAction(Action): - def __init__(self, **kwargs): - super().__init__( - _('Show/Hide Editor'), - icon=bootstrapIcon('layout-text-sidebar-reverse.svg'), - **kwargs, - ) - - # Reference - self.serverTab = self.parent().serverTab - self.editorTab = self.parent().editorTab - - self.setShortcut( - QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier.ControlModifier, QtCore.Qt.Key.Key_E - ) - ) - - def triggeredCallback(self, checked): - oldSize = self.serverTab.width() - - if APP().HideEditor == Switch.OFF: - self.editorTab.hide() - - APP().HideEditor = Switch.ON_ - else: - self.editorTab.show() - - APP().HideEditor = Switch.OFF - - APP().processEvents() - - newSize = self.serverTab.width() - - APP().ServerWidget.resizeHorizontalHeaderSection(oldSize, newSize) - - -def useProxyServerIfPossible(manager, loggerAction): - proxyServer = APP().tray.ConnectAction.httpsProxyServer - - if proxyServer: - logger.info(f'{loggerAction} uses proxy server {proxyServer}') - - # Checked. Should not throw exceptions - proxyHost, proxyPort = parseHostPort(proxyServer) - - # Checked. int(proxyPort) should not throw exceptions - manager.setProxy( - QNetworkProxy(QNetworkProxy.ProxyType.HttpProxy, proxyHost, int(proxyPort)) - ) - else: - logger.info(f'{loggerAction} uses no proxy') - - manager.setProxy(QNetworkProxy.ProxyType.NoProxy) - - return proxyServer - - -class UpdateSubsAction(Action): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.networkAccessManager = QNetworkAccessManager(parent=self) - - self.networkReplyTable = {} - - def handleFinishedByNetworkReply(self, networkReply): - unique, remark, webURL = ( - self.networkReplyTable[networkReply]['unique'], - self.networkReplyTable[networkReply]['remark'], - self.networkReplyTable[networkReply]['webURL'], - ) - - if networkReply.error() != QNetworkReply.NetworkError.NoError: - logger.error(f'update subs {webURL} failed. {networkReply.errorString()}') - else: - logger.info(f'update subs {webURL} success') - - try: - shareLinks = list( - filter( - lambda x: x != '', - Base64Encoder.decode(networkReply.readAll().data()) - .decode() - .split('\n'), - ) - ) - except Exception as ex: - # Any non-exit exceptions - - logger.error(f'parse share link failed: {ex}') - else: - # Delete old subscription by unique(subsId) - deletedNum = APP().ServerWidget.deleteItemByUnique( - unique, syncStorage=False - ) - - # Append subscription - for shareLink in shareLinks: - ImportLinkAction.parseShareLink(shareLink, {'subsId': unique}) - - if deletedNum or shareLinks: - # Deleted or imported. Sync it - ServerStorage.sync() - - # Done. Remove entry. Key should be found, but protect it anyway - self.networkReplyTable.pop(networkReply, None) - - @QtCore.Slot() - def handleFinished(self): - networkReply = self.sender() - - self.handleFinishedByNetworkReply(networkReply) - - def configureProxy(self): - raise NotImplementedError - - @needSaveChanges - def triggeredCallback(self, checked): - # Reference - subscriptionDict = APP().SubscriptionWidget.SubscriptionDict - - if not subscriptionDict: - # Empty. Nothing to do - return - - self.configureProxy() - - for key, value in subscriptionDict.items(): - networkReply = self.networkAccessManager.get( - QNetworkRequest(QtCore.QUrl(value['webURL'])) - ) - - self.networkReplyTable[networkReply] = { - 'unique': key, - 'remark': value['remark'], - 'webURL': value['webURL'], - } - - networkReply.finished.connect( - functools.partial(self.handleFinishedByNetworkReply, networkReply) - ) - - # networkReply.finished.connect(self.handleFinished) - - -class UpdateSubsUseCurrentProxyAction(UpdateSubsAction): - def __init__(self, **kwargs): - super().__init__(_('Update Subscription (Use Current Proxy)'), **kwargs) - - def configureProxy(self): - useProxyServerIfPossible(self.networkAccessManager, 'update subscription') - - -class UpdateSubsForceProxyAction(UpdateSubsAction): - FORCE_PROXY_HOST = '127.0.0.1' - FORCE_PROXY_PORT = 10809 - - FORCE_PROXY = f'{FORCE_PROXY_HOST}:{FORCE_PROXY_PORT}' - - def __init__(self, **kwargs): - super().__init__(_('Update Subscription (Force Proxy)'), **kwargs) - - def configureProxy(self): - logger.info( - f'update subscription uses proxy server {UpdateSubsForceProxyAction.FORCE_PROXY}' - ) - - self.networkAccessManager.setProxy( - QNetworkProxy( - QNetworkProxy.ProxyType.HttpProxy, - UpdateSubsForceProxyAction.FORCE_PROXY_HOST, - UpdateSubsForceProxyAction.FORCE_PROXY_PORT, - ) - ) - - -class UpdateSubsNoProxyAction(UpdateSubsAction): - def __init__(self, **kwargs): - super().__init__(_('Update Subscription (No Proxy)'), **kwargs) - - def configureProxy(self): - self.networkAccessManager.setProxy(QNetworkProxy.ProxyType.NoProxy) - - -class EditSubscriptionAction(Action): - def __init__(self, **kwargs): - super().__init__( - _('Edit Subscription...'), - icon=bootstrapIcon('star.svg'), - **kwargs, - ) - - def triggeredCallback(self, checked): - APP().SubscriptionWidget.show() - - -class ShowLogAction(Action): - def __init__(self, **kwargs): - super().__init__(_('Show Log...'), **kwargs) - - def triggeredCallback(self, checked): - APP().logViewerWidget.showMaximized() - - -class WhetherUpdateInfoBox(MessageBox): - def __init__(self, version='0.0.0', *args, **kwargs): - super().__init__(*args, **kwargs) - - self.version = version - - def retranslate(self): - with StateContext(self): - self.setText(_('New version available: ') + self.version) - self.setWindowTitle(_(self.windowTitle())) - self.setInformativeText(_(self.informativeText())) - - # Ignore button text - - self.moveToCenter() - - -def versionToNumber(version): - major_weight = 10000 - minor_weight = 100 - patch_weight = 1 - - return functools.reduce( - operator.add, - list( - int(ver) * weight - for ver, weight in zip( - version.split('.'), [major_weight, minor_weight, patch_weight] - ) - ), - ) - - -class CheckForUpdatesAction(Action): - API_URL = ( - f'https://api.github.com/repos/' - f'{APPLICATION_REPO_OWNER_NAME}/{APPLICATION_REPO_NAME}/releases/latest' - ) - - def __init__(self, **kwargs): - super().__init__( - _('Check For Updates'), - icon=bootstrapIcon('download.svg'), - **kwargs, - ) - - self.networkAccessManager = QNetworkAccessManager(parent=self) - - self.checkForUpdatesError = MessageBox( - icon=MessageBox.Icon.Critical, parent=self.parent() - ) - self.whetherUpdateInfoBox = WhetherUpdateInfoBox( - icon=MessageBox.Icon.Information, parent=self.parent() - ) - self.latestVersionInfoBox = MessageBox( - icon=MessageBox.Icon.Information, parent=self.parent() - ) - - def handleFinishedByNetworkReply(self, networkReply): - if networkReply.error() != QNetworkReply.NetworkError.NoError: - logger.error(f'check for updates failed. {networkReply.errorString()}') - - self.checkForUpdatesError.setWindowTitle(_(APPLICATION_NAME)) - self.checkForUpdatesError.setText(_('Check for updates failed.')) - - # Show the MessageBox and wait for user to close it - self.checkForUpdatesError.exec() - else: - logger.info('check for updates success') - - # Unchecked? - info = ujson.loads(networkReply.readAll().data()) - - if versionToNumber(info['tag_name']) > versionToNumber(APPLICATION_VERSION): - self.whetherUpdateInfoBox.version = info['tag_name'] - self.whetherUpdateInfoBox.setWindowTitle(_(APPLICATION_NAME)) - self.whetherUpdateInfoBox.setStandardButtons( - MessageBox.StandardButton.Yes | MessageBox.StandardButton.No - ) - self.whetherUpdateInfoBox.setText( - _('New version available: ') + info['tag_name'] - ) - self.whetherUpdateInfoBox.setInformativeText(_('Go to download page?')) - - # Show the MessageBox and wait for user to close it - if self.whetherUpdateInfoBox.exec() == enumValueWrapper( - MessageBox.StandardButton.Yes - ): - if QDesktopServices.openUrl(QtCore.QUrl(info['html_url'])): - logger.info('open download page success') - else: - logger.error('open download page failed') - else: - # Do nothing - pass - else: - self.latestVersionInfoBox.setWindowTitle(_(APPLICATION_NAME)) - self.latestVersionInfoBox.setText( - _(f'{APPLICATION_NAME} is already the latest version.') - ) - self.latestVersionInfoBox.setInformativeText( - _(f'Thank you for using {APPLICATION_NAME}.') - ) - - # Show the MessageBox and wait for user to close it - self.latestVersionInfoBox.exec() - - @QtCore.Slot() - def handleFinished(self): - networkReply = self.sender() - - self.handleFinishedByNetworkReply(networkReply) - - def triggeredCallback(self, checked): - useProxyServerIfPossible(self.networkAccessManager, 'check for updates') - - networkReply = self.networkAccessManager.get( - QNetworkRequest(QtCore.QUrl(CheckForUpdatesAction.API_URL)) - ) - - networkReply.finished.connect( - functools.partial(self.handleFinishedByNetworkReply, networkReply) - ) - - # networkReply.finished.connect(self.handleFinished) - - -class AboutAction(Action): - def __init__(self, **kwargs): - super().__init__(_('About'), **kwargs) - - def triggeredCallback(self, checked): - if QDesktopServices.openUrl(QtCore.QUrl(APPLICATION_ABOUT_PAGE)): - logger.info('open about page success') - else: - logger.error('open about page failed') - - -class QuestionSaveBox(MessageBox): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.setWindowTitle(_('Save Changes')) - self.setText( - _('The content has been modified. Do you want to save the changes?') - ) - - self.button0 = self.addButton( - _('Save'), - MessageBox.ButtonRole.AcceptRole, - ) - self.button1 = self.addButton( - _('Cancel'), - MessageBox.ButtonRole.RejectRole, - ) - self.button2 = self.addButton( - _('Discard'), - MessageBox.ButtonRole.DestructiveRole, - ) - - self.setDefaultButton(self.button0) - - -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 configuration?') - else: - return _('Delete this configuration?') + 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 ProgressWaitBox(MessageBox): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.setWindowTitle(_('Connecting')) - self.setText(_('Connecting. Please wait.')) - - def retranslate(self): - with StateContext(self): - self.setWindowTitle(_(self.windowTitle())) - self.setText(_(self.text())) - - # Ignore informative text, buttons - - self.moveToCenter() - - -class SaveChangeFirst(MessageBox): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.setWindowTitle(_('Hint')) - self.setText(_('Please save any changes to continue.')) - - def retranslate(self): - with StateContext(self): - self.setWindowTitle(_(self.windowTitle())) - self.setText(_(self.text())) - - # Ignore informative text, buttons - - self.moveToCenter() - - -class ServerWidgetHorizontalHeader(HeaderView): - class SortOrder: - Ascending_ = False - Descending = True - - def __init__(self, *args, **kwargs): - super().__init__(QtCore.Qt.Orientation.Horizontal, *args, **kwargs) - - self.sectionClicked.connect(self.handleSectionClicked) - self.sectionResized.connect(self.handleSectionResized) - - self.sortOrderTable = list( - ServerWidgetHorizontalHeader.SortOrder.Ascending_ - for i in range(self.parent().columnCount()) - ) - - @needSaveChanges - @QtCore.Slot(int) - def handleSectionClicked(self, clickedIndex): - activatedIndex = self.parent().activatedItemIndex - - if activatedIndex < 0: - activatedServerId = None - else: - # Object is activated - activatedServerId = id(self.parent().ServerList[activatedIndex]) - - # De-activated temporarily for sorting - self.parent().activateItemByIndex(activatedIndex, activate=False) - - # Assertions - assert len(self.parent().ServerList) == len(self.parent().ScrollList) - - # Save focus scroll bar value - currentFocus = self.parent().currentFocus - - if currentFocus >= 0: - self.parent().saveScrollBarValue(currentFocus) - else: - # No item selected. Save nothing - pass - - # Prepare for scroll bar value restoration - scrollBarValueTable = {} - - for index, server in enumerate(self.parent().ServerList): - # Object id -> v, h - scrollBarValueTable[id(server)] = self.parent().ScrollList[index] - - # Sort it - self.parent().ServerList.sort( - key=lambda x: ServerWidget.HEADER_LABEL_GET_FUNC[clickedIndex](x), - reverse=self.sortOrderTable[clickedIndex], - ) - - # Sync it - ServerStorage.sync() - - # Flush it - self.parent().flushAll() - - # Toggle. Sorting done - self.sortOrderTable[clickedIndex] = not self.sortOrderTable[clickedIndex] - - # Restore corresponding scroll bar value. Later clicking will use them - for index, server in enumerate(self.parent().ServerList): - # Get v, h by Object id - self.parent().ScrollList[index] = scrollBarValueTable[id(server)] - - if activatedServerId is not None: - foundActivatedItem = False - - for index, server in enumerate(self.parent().ServerList): - if activatedServerId == id(server): - foundActivatedItem = True - - # Restore scroll bar value. Later clicking will use it - if currentFocus >= 0: - self.parent().restoreScrollBarValue(currentFocus) - - # Focus on focus - self.parent().setCurrentItemByIndex(index) - self.parent().activateItemByIndex(index, activate=True) - - break - - # Object id should be found - assert foundActivatedItem - else: - # Restore scroll bar value. Later clicking will use it - if currentFocus >= 0: - self.parent().restoreScrollBarValue(currentFocus) - - # Focus on focus - self.parent().setCurrentItemByIndex(currentFocus) - - @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 ServerWidgetVerticalHeader(HeaderView): - def __init__(self, *args, **kwargs): - super().__init__(QtCore.Qt.Orientation.Vertical, *args, **kwargs) - - -def toJSONMayBeNull(text): - try: - return Configuration.toJSON(text) - except Exception: - # Any non-exit exceptions - - return {} - - -def getSubsRemarkFromUnique(server): - try: - # SubscriptionWidget must be initialized before ServerWidget - return APP().SubscriptionWidget.SubscriptionDict[server['subsId']]['remark'] - except Exception: - # Any non-exit exceptions - - return '' - - -class Worker: - def __init__(self, serverIdx, serverObj): - self.serverIdx = serverIdx - self.serverObj = serverObj - - # Reference - self.serverRef = APP().ServerWidget.ServerList - self.deletedId = APP().ServerWidget.deletedServerId - - def serverDeleted(self): - if ( - # Fast search for deletion - id(self.serverObj) in self.deletedId - or self.serverIdx < 0 - or self.serverIdx >= len(self.serverRef) - ): - # Deleted - return True - else: - return False - - def updateCallback(self, index, reference): - raise NotImplementedError - - def updateResult(self): - if self.serverDeleted(): - # Deleted. Do nothing - return - - if id(self.serverRef[self.serverIdx]) == id(self.serverObj): - # Quick update - self.updateCallback(self.serverIdx, self.serverObj) - else: - # Linear find and update - for index, server in enumerate(self.serverRef): - if id(server) == id(self.serverObj): - # Found. Refresh index - self.serverIdx = index - self.updateCallback(self.serverIdx, self.serverObj) - - # Updated - return - - # Should not reach here. - self.serverIdx = -1 - - -class PingDelayWorker(Worker, QtCore.QObject, QtCore.QRunnable): - updateTable = QtCore.Signal(int, dict) - - def __init__(self, serverIdx, serverObj): - super().__init__(serverIdx, serverObj) - - # Explictly called __init__ - QtCore.QObject.__init__(self) - QtCore.QRunnable.__init__(self) - - def updateCallback(self, index, reference): - self.updateTable.emit(index, reference) - - def run(self): - if self.serverDeleted(): - # Deleted. Do nothing - return - - try: - result = ping3.ping( - Intellisense.getCoreAddr( - Configuration.toJSON(self.serverObj['config']) - ), - timeout=1, - unit='ms', - ) - except Exception: - # Any non-exit exceptions - - self.serverObj['delayResult'] = 'Timeout' - self.updateResult() - - return - - if result is False: - self.serverObj['delayResult'] = 'Error' - elif result is None: - self.serverObj['delayResult'] = 'Timeout' - else: - self.serverObj['delayResult'] = f'{round(result)}ms' - - self.updateResult() - - -class DownSpeedWorker(Worker): - def __init__(self, serverIdx, serverObj): - super().__init__(serverIdx, serverObj) - - self.core = None - - self.speedValue = False - self.totalBytes = 0 - - self.testFinished = False - self.elapsedTimer = QtCore.QElapsedTimer() - - self.networkReply = None - self.networkAccessManager = QNetworkAccessManager() - - def updateCallback(self, index, reference): - APP().ServerWidget.flushServerSpeedResult(index, reference) - - def abort(self): - if self.networkReply is not None: - self.networkReply.abort() - else: - self.testFinished = True - - if self.core is not None: - self.core.stop() - - def invalidConfigCancelTest(self): - self.serverObj['speedResult'] = 'Invalid' - self.updateResult() - self.testFinished = True - - def isFinished(self): - return self.testFinished - - def run(self): - if self.serverDeleted(): - self.testFinished = True - - # Deleted. Do nothing - return - - config = self.serverObj.get('config', '') - - if not config: - self.invalidConfigCancelTest() - - return - - self.serverObj['speedResult'] = 'Starting' - self.updateResult() - - try: - coreJSON = Configuration.toJSON(config) - coreType = Intellisense.getCoreType(coreJSON) - except Exception: - # Any non-exit exceptions - - self.invalidConfigCancelTest() - - return - - def coreExitCallback(exitcode): - if exitcode == Core.ExitCode.ConfigurationError: - self.serverObj['speedResult'] = 'Invalid' - elif exitcode == Core.ExitCode.ServerStartFailure: - self.serverObj['speedResult'] = 'Start failed' - else: - self.serverObj['speedResult'] = f'Core exited {exitcode}' - - self.updateResult() - - if coreType == XrayCore.name(): - # Force redirect - coreJSON['inbounds'] = [ - { - 'tag': 'http', - 'port': 20809, - 'listen': '127.0.0.1', - 'protocol': 'http', - 'sniffing': { - 'enabled': True, - 'destOverride': [ - 'http', - 'tls', - ], - }, - 'settings': { - 'auth': 'noauth', - 'udp': True, - 'allowTransparent': False, - }, - }, - ] - - try: - for outboundObject in coreJSON['outbounds']: - if outboundObject['tag'] == 'proxy': - # Avoid confusion with potentially existing 'proxy' tag - outboundObject['tag'] = 'proxy20809' - except Exception: - # Any non-exit exceptions - - pass - - # No routing - coreJSON['routing'] = {} - - self.core = XrayCore() - self.core.registerExitCallback(coreExitCallback) - self.core.start( - ujson.dumps( - coreJSON, - ensure_ascii=False, - escape_forward_slashes=False, - ) - ) - elif coreType == Hysteria1.name() or coreType == Hysteria2.name(): - # Force redirect - coreJSON['http'] = { - 'listen': '127.0.0.1:20809', - 'timeout': 300, - 'disable_udp': False, - } - - # No socks inbounds - coreJSON.pop('socks5', '') - - if coreType == Hysteria1.name(): - # User acl and mmdb already ignored - - self.core = Hysteria1() - self.core.registerExitCallback(coreExitCallback) - self.core.start( - ujson.dumps( - coreJSON, - ensure_ascii=False, - escape_forward_slashes=False, - ), - '', - '', - ) - else: - self.core = Hysteria2() - self.core.registerExitCallback(coreExitCallback) - self.core.start( - ujson.dumps( - coreJSON, - ensure_ascii=False, - escape_forward_slashes=False, - ) - ) - else: - self.invalidConfigCancelTest() - - return - - if self.core.checkAlive(): - self.networkAccessManager.setProxy( - QNetworkProxy(QNetworkProxy.ProxyType.HttpProxy, '127.0.0.1', 20809) - ) - - self.networkReply = self.networkAccessManager.get( - QNetworkRequest( - QtCore.QUrl( - 'http://speed.cloudflare.com/__down?during=download&bytes=104857600' - ) - ) - ) - self.networkReply.readyRead.connect(self.handleReadyRead) - self.networkReply.finished.connect(self.handleFinished) - - self.elapsedTimer.start() - else: - self.testFinished = True - - @QtCore.Slot() - def handleReadyRead(self): - if self.core.isAlive(): - self.totalBytes += self.networkReply.readAll().length() - - # Convert to seconds - elapsedSecond = self.elapsedTimer.elapsed() / 1000 - downloadSpeed = self.totalBytes / elapsedSecond / 1024 / 1024 - - # Has speed value - self.speedValue = True - self.serverObj['speedResult'] = f'{downloadSpeed:.2f} M/s' - self.updateResult() - - @QtCore.Slot() - def handleFinished(self): - if self.networkReply.error() != QNetworkReply.NetworkError.NoError: - if not self.speedValue: - if self.core.isAlive(): - if ( - self.networkReply.error() - == QNetworkReply.NetworkError.OperationCanceledError - ): - # Canceled by application - self.serverObj['speedResult'] = 'Canceled' - else: - try: - error = self.networkReply.error().name - except Exception: - # Any non-exit exceptions - - error = 'Unknown Error' - - if isinstance(error, bytes): - # Some old version PySide6 returns it as bytes. - # Protect it. - errorString = error.decode() - elif isinstance(error, str): - errorString = error - else: - errorString = 'Unknown Error' - - if errorString.endswith('Error'): - self.serverObj['speedResult'] = errorString[:-5] - else: - self.serverObj['speedResult'] = errorString - else: - # Core ExitCallback has been called - self.testFinished = True - - return - else: - if self.core.isAlive(): - self.totalBytes += self.networkReply.readAll().length() - - # Convert to seconds - elapsedSecond = self.elapsedTimer.elapsed() / 1000 - downloadSpeed = self.totalBytes / elapsedSecond / 1024 / 1024 - - self.serverObj['speedResult'] = f'{downloadSpeed:.2f} M/s' - else: - self.serverObj['speedResult'] = 'Start failed' - - self.core.stop() - self.updateResult() - self.testFinished = True - - -class ServerWidget(Translatable, SupportConnectedCallback, TableWidget): - # Might be extended in the future - HEADER_LABEL = [ - 'Remark', - 'Protocol', - 'Address', - 'Port', - 'Transport', - 'TLS', - 'Subscription', - 'Latency', - 'Speed', - ] - - DELAY_INDEX = HEADER_LABEL.index('Latency') - SPEED_INDEX = HEADER_LABEL.index('Speed') - - # Corresponds to header label - HEADER_LABEL_GET_FUNC = [ - lambda server: server['remark'], - lambda server: Intellisense.getCoreProtocol(toJSONMayBeNull(server['config'])), - lambda server: Intellisense.getCoreAddr(toJSONMayBeNull(server['config'])), - lambda server: Intellisense.getCorePort(toJSONMayBeNull(server['config'])), - lambda server: Intellisense.getCoreTransport(toJSONMayBeNull(server['config'])), - lambda server: Intellisense.getCoreTLS(toJSONMayBeNull(server['config'])), - lambda server: getSubsRemarkFromUnique(server), - lambda server: server.get('delayResult', ''), - lambda server: server.get('speedResult', ''), - ] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # MessageBox - self.questionSaveBox = QuestionSaveBox( - icon=MessageBox.Icon.Question, parent=self.parent() - ) - self.questionDelete_ = QuestionDeleteBox( - icon=MessageBox.Icon.Question, parent=self.parent() - ) - self.saveConfInfoBox = SaveConfInfo( - icon=MessageBox.Icon.Information, parent=self.parent() - ) - self.progressWaitBox = ProgressWaitBox( - icon=MessageBox.Icon.Information, parent=self.parent() - ) - self.saveChangeFirst = SaveChangeFirst( - icon=MessageBox.Icon.Information, parent=None - ) - - # Shallow copy. Checked - self.ServerList = self.parent().ServerList - - # Quick delete search - self.deletedServerOb = [] - self.deletedServerId = {} - - # Handy reference - self.plainTextEdit = self.parent().plainTextEdit - - # Handy reference - self.editorTab = self.parent().editorTab - - # Ping Thread Pool - self.pingThreadPool = QtCore.QThreadPool() - self.downSpeedQueue = queue.Queue() - - self.downSpeedTimer = QtCore.QTimer() - self.downSpeedTimer.timeout.connect(self.downSpeedTimeoutCallback) - self.downSpeedTimer.start(100) - - # Delegate - self.delegate = StyledItemDelegate(parent=self) - self.setItemDelegate(self.delegate) - - # Scroll bar value list. v, h - self.ScrollList = list([0, 0] for i in range(len(self.ServerList))) - - # Must set before flush all - self.setColumnCount(len(ServerWidget.HEADER_LABEL)) - - # Flush all data to table - self.flushAll() - - # Install custom header - self.setHorizontalHeader(ServerWidgetHorizontalHeader(self)) - - # 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().ServerWidgetSectionSizeTable) - - # Fill missing value - for column in range(self.columnCount()): - if self.sectionSizeTable.get(str(column)) is None: - self.sectionSizeTable[ - str(column) - ] = self.horizontalHeader().defaultSectionSize() - - # Block resize callback - self.horizontalHeader().blockSignals(True) - - for key, value in self.sectionSizeTable.items(): - self.horizontalHeader().resizeSection(int(key), value) - - # Unblock resize callback - self.horizontalHeader().blockSignals(False) - except Exception: - # Any non-exit exceptions - - # Leave keys as strings since they will be - # loaded as string from json - self.sectionSizeTable = { - str(column): self.horizontalHeader().defaultSectionSize() - for column in range(self.columnCount()) - } - - self.setHorizontalHeaderLabels( - list(_(label) for label in ServerWidget.HEADER_LABEL) - ) - - for column in range(self.horizontalHeader().count()): - self.horizontalHeaderItem(column).setFont(QFont(APP().customFontName)) - - # Install custom header - self.setVerticalHeader(ServerWidgetVerticalHeader(self)) - - # Selection - self.setSelectionColor(Color.LIGHT_BLUE) - self.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) - self.setSelectionMode(QTableWidget.SelectionMode.ExtendedSelection) - - # No drag and drop - self.setDragEnabled(False) - self.setDragDropMode(QAbstractItemView.DragDropMode.NoDragDrop) - self.setDropIndicatorShown(False) - self.setDefaultDropAction(QtCore.Qt.DropAction.IgnoreAction) - - contextMenuActions = [ - Action( - _('Move Up'), - callback=lambda: self.moveUpSelectedItem(), - parent=self, - ), - Action( - _('Move Down'), - callback=lambda: self.moveDownSelectedItem(), - parent=self, - ), - Action( - _('Duplicate'), - callback=lambda: self.duplicateSelectedItem(), - parent=self, - ), - Action( - _('Delete'), - callback=lambda: self.deleteSelectedItem(), - parent=self, - ), - Seperator(), - SelectAllServerAction( - callback=lambda: self.selectAll(), - parent=self, - ), - Seperator(), - ScrollToActivatedServerAction(parent=self), - Seperator(), - TestPingLatencyAction(parent=self), - TestDownloadSpeedAction(parent=self), - ClearTestResultsAction(parent=self), - Seperator(), - ImportFileAction(parent=self), - ImportLinkActionWithHotKey(parent=self), - ImportJSONActionWithHotKey(parent=self), - Seperator(), - ExportLinkActionWithHotKey(parent=self), - ExportQRCodeActionWithHotKey(parent=self), - ExportJSONActionWithHotKey(parent=self), - ] - - self.contextMenu = Menu(*contextMenuActions) - - self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) - self.customContextMenuRequested.connect(self.handleCustomContextMenuRequested) - - # Signals - self.currentItemChanged.connect(self.handleCurrentItemChanged) - self.itemChanged.connect(self.handleItemChanged) - self.itemSelectionChanged.connect(self.handleItemSelectionChanged) - self.itemActivated.connect(self.handleItemActivated) - - self.doNotSwitchCallback = None - - if self.activatedItem is not None: - self.setCurrentItem(self.activatedItem) - # Activate it from settings - self.activateItemByIndex(self.activatedItemIndex, activate=True) - - @property - def activatedItemIndex(self): - try: - return int(APP().ActivatedItemIndex) - except Exception: - # Any non-exit exceptions - - return -1 - - @property - def activatedItem(self): - return self.item(self.activatedItemIndex, 0) - - @property - def currentFocus(self): - try: - # Get current index from tab - return int(self.editorTab.tabText(0).split(' - ')[0]) - 1 - except Exception: - # Any non-exit exceptions - - return -1 - - def resizeHorizontalHeaderSection(self, oldSize, newSize): - factor = newSize / oldSize - - for index in range(self.columnCount()): - self.horizontalHeader().resizeSection( - index, round(self.horizontalHeader().sectionSize(index) * factor) - ) - - def flushRow(self, row, server): - for column in range(self.columnCount()): - oldItem = self.item(row, column) - newItem = QTableWidgetItem( - ServerWidget.HEADER_LABEL_GET_FUNC[column](server) - ) - - if oldItem is None: - # Item does not exists - newItem.setFont(QFont(APP().customFontName)) - - if ( - ServerWidget.HEADER_LABEL[column] == 'Latency' - or ServerWidget.HEADER_LABEL[column] == 'Speed' - ): - # Test results. Align right and vcenter - newItem.setTextAlignment( - QtCore.Qt.AlignmentFlag.AlignRight - | QtCore.Qt.AlignmentFlag.AlignVCenter - ) - else: - # Use existing - newItem.setFont(oldItem.font()) - newItem.setForeground(oldItem.foreground()) - - if oldItem.textAlignment() != 0: - newItem.setTextAlignment(oldItem.textAlignment()) - - if ServerWidget.HEADER_LABEL[column] == 'Remark': - # Remark is editable - newItem.setFlags( - QtCore.Qt.ItemFlag.ItemIsEnabled - | QtCore.Qt.ItemFlag.ItemIsSelectable - | QtCore.Qt.ItemFlag.ItemIsEditable - ) - else: - newItem.setFlags( - QtCore.Qt.ItemFlag.ItemIsEnabled - | QtCore.Qt.ItemFlag.ItemIsSelectable - ) - - self.setItem(row, column, newItem) - - def flushAll(self): - if self.rowCount() == 0: - # Should insert row - for row, server in enumerate(self.ServerList): - self.insertRow(row) - self.flushRow(row, server) - else: - for row, server in enumerate(self.ServerList): - self.flushRow(row, server) - - def saveScrollBarValue(self, index): - assert len(self.ScrollList) == len(self.ServerList) - - if 0 <= index < len(self.ScrollList): - self.ScrollList[index] = [ - self.plainTextEdit.verticalScrollBar().value(), - self.plainTextEdit.horizontalScrollBar().value(), - ] - - def restoreScrollBarValue(self, index): - assert len(self.ScrollList) == len(self.ServerList) - - if 0 <= index < len(self.ScrollList): - self.plainTextEdit.verticalScrollBar().setValue(self.ScrollList[index][0]) - self.plainTextEdit.horizontalScrollBar().setValue(self.ScrollList[index][1]) - - def appendScrollBarEntry(self): - self.ScrollList.append([0, 0]) - - def setPlainText(self, text, block=False, disabled=False): - if block: - self.plainTextEdit.blockSignals(True) - self.plainTextEdit.setPlainText(text) - self.plainTextEdit.blockSignals(False) - else: - self.plainTextEdit.setPlainText(text) - - self.plainTextEdit.setDisabled(disabled) - - def syncRemarkByIndex(self, index): - assert self.rowCount() == len(self.ServerList) - - if self.item(index, 0) is None: - return - - if self.ServerList[index]['remark'] != self.item(index, 0).text(): - self.ServerList[index]['remark'] = self.item(index, 0).text() - - # Only one tab currently. Refresh tab title - self.editorTab.setTabText( - 0, f'{index + 1} - {self.ServerList[index]["remark"]}' - ) - - remarkModified = True - else: - remarkModified = False - - # Avoid unnecessary encode/write, etc. - if remarkModified: - ServerStorage.sync() - - def activateItemByIndex(self, index, activate=True): - super().activateItemByIndex(index, activate) - - if activate: - APP().ActivatedItemIndex = str(index) - - def setCurrentItemByIndex(self, index): - self.setCurrentItem(self.item(index, 0)) - - def swapItem(self, index0, index1): - swapListItem(self.ServerList, index0, index1) - swapListItem(self.ScrollList, index0, index1) - - # Server storage sync is done by caller!!! - - self.flushRow(index0, self.ServerList[index0]) - self.flushRow(index1, self.ServerList[index1]) - - if index0 == self.activatedItemIndex: - # De-activate - self.activateItemByIndex(index0, activate=False) - # Activate - self.activateItemByIndex(index1, activate=True) - elif index1 == self.activatedItemIndex: - # De-activate - self.activateItemByIndex(index1, activate=False) - # Activate - self.activateItemByIndex(index0, activate=True) - - def selectMultipleRow(self, indexes, clearCurrentSelection): - if clearCurrentSelection: - self.selectionModel().clearSelection() - - selection = self.selectionModel().selection() - - for index in indexes: - selection.select( - self.model().index(index, 0), - self.model().index(index, self.columnCount() - 1), - ) - - self.selectionModel().select( - selection, QtCore.QItemSelectionModel.SelectionFlag.Select - ) - - def moveUpItemByIndex(self, index): - if index <= 0 or index >= len(self.ServerList): - # The top item, or does not exist. Do nothing - return - - self.swapItem(index, index - 1) - - @needSaveChanges - def moveUpSelectedItem(self): - indexes = self.selectedIndex - - if len(indexes) == 0: - # Nothing selected. Do nothing - return - - currentFocus = self.currentFocus - - if currentFocus >= 0: - self.saveScrollBarValue(currentFocus) - - for index in indexes: - self.moveUpItemByIndex(index) - - # Sync it - ServerStorage.sync() - - # Don't use setCurrentItemXXX since it will - # trigger handleCurrentItemChanged. Just switch context - # and change index - self.switchContext(indexes[-1] - 1) - - self.blockSignals(True) - self.setCurrentIndex(self.indexFromItem(self.item(indexes[-1] - 1, 0))) - self.blockSignals(False) - - self.selectMultipleRow( - list(index - 1 for index in indexes), clearCurrentSelection=True - ) - - def moveDownItemByIndex(self, index): - if index < 0 or index >= len(self.ServerList) - 1: - # The bottom item, or does not exist. Do nothing - return - - self.swapItem(index, index + 1) - - @needSaveChanges - def moveDownSelectedItem(self): - indexes = self.selectedIndex - - if len(indexes) == 0: - # Nothing selected. Do nothing - return - - currentFocus = self.currentFocus - - if currentFocus >= 0: - self.saveScrollBarValue(currentFocus) - - for index in indexes[::-1]: - self.moveDownItemByIndex(index) - - # Sync it - ServerStorage.sync() - - # Don't use setCurrentItemXXX since it will - # trigger handleCurrentItemChanged. Just switch context - # and change index - self.switchContext(indexes[0] + 1) - - self.blockSignals(True) - self.setCurrentIndex(self.indexFromItem(self.item(indexes[0] + 1, 0))) - self.blockSignals(False) - - self.selectMultipleRow( - list(index + 1 for index in indexes), clearCurrentSelection=True - ) - - @needSaveChanges - def duplicateSelectedItem(self): - indexes = self.selectedIndex - - if len(indexes) == 0: - # Nothing selected. Do nothing - return - - for index in indexes: - if 0 <= index < len(self.ServerList): - # Do not clone subsId - APP().ServerWidget.importServer( - self.ServerList[index]['remark'], - self.ServerList[index]['config'], - ) - - # Sync it - ServerStorage.sync() - - def deleteItemByIndex(self, indexes, syncStorage=False): - if len(indexes) == 0: - # Nothing selected. Do nothing - return 0 - - if self.activatedItemIndex in indexes: - takedActivated = True - else: - takedActivated = False - - for i in range(len(indexes)): - takedRow = indexes[i] - i - - self.blockSignals(True) - self.removeRow(takedRow) - self.blockSignals(False) - - # Added for deletion search - self.deletedServerOb.append(self.ServerList[takedRow]) - self.deletedServerId[id(self.ServerList[takedRow])] = True - - self.ServerList.pop(takedRow) - self.ScrollList.pop(takedRow) - - if not takedActivated and takedRow < self.activatedItemIndex: - APP().ActivatedItemIndex = str(self.activatedItemIndex - 1) - - if syncStorage: - # Sync it - ServerStorage.sync() - - if takedActivated: - # Set invalid first - APP().ActivatedItemIndex = str(-1) - - if APP().isConnected(): - # Trigger disconnect - APP().tray.ConnectAction.trigger() - - # Don't use setCurrentItemXXX since the - # selection may not be changed. Just switch context - # and change index - - # Don't use currentFocus since it may have been deleted. Get - # current row from self - self.switchContext(self.currentRow()) - - self.blockSignals(True) - self.setCurrentIndex(self.indexFromItem(self.item(self.currentRow(), 0))) - self.blockSignals(False) - - return len(indexes) - - @needSaveChanges - def deleteSelectedItem(self): - indexes = self.selectedIndex - - if len(indexes) == 0: - # Nothing selected. Do nothing - return - - self.questionDelete_.isMulti = bool(len(indexes) > 1) - self.questionDelete_.possibleRemark = ( - f'{indexes[0] + 1} - {self.ServerList[indexes[0]]["remark"]}' - ) - self.questionDelete_.setText(self.questionDelete_.getText()) - - if self.questionDelete_.exec() == enumValueWrapper( - MessageBox.StandardButton.No - ): - # Do not delete - return - - self.deleteItemByIndex(indexes, syncStorage=True) - - def flushServerResultByColumn(self, row, column, server): - oldItem = self.item(row, column) - newItem = QTableWidgetItem(ServerWidget.HEADER_LABEL_GET_FUNC[column](server)) - - if oldItem is None: - # Item does not exists - newItem.setFont(QFont(APP().customFontName)) - # Test results. Align right and vcenter - newItem.setTextAlignment( - QtCore.Qt.AlignmentFlag.AlignRight - | QtCore.Qt.AlignmentFlag.AlignVCenter - ) - else: - # Use existing - newItem.setFont(oldItem.font()) - newItem.setForeground(oldItem.foreground()) - - if oldItem.textAlignment() != 0: - newItem.setTextAlignment(oldItem.textAlignment()) - - newItem.setFlags( - QtCore.Qt.ItemFlag.ItemIsEnabled | QtCore.Qt.ItemFlag.ItemIsSelectable - ) - - self.setItem(row, column, newItem) - - def flushServerDelayResult(self, row, server): - self.flushServerResultByColumn(row, ServerWidget.DELAY_INDEX, server) - - def flushServerSpeedResult(self, row, server): - self.flushServerResultByColumn(row, ServerWidget.SPEED_INDEX, server) - - def testPingDelayByReference(self, indexes, references): - for index, reference in zip(indexes, references): - worker = PingDelayWorker(index, reference) - worker.setAutoDelete(True) - worker.updateTable.connect(self.flushServerDelayResult) - - self.pingThreadPool.start(worker) - - @QtCore.Slot() - def downSpeedTimeoutCallback(self): - try: - index, server = self.downSpeedQueue.get_nowait() - except queue.Empty: - # Queue is empty - - return - - def testDownloadSpeed(counter=0, timeout=5000, step=100): - worker = DownSpeedWorker(index, server) - worker.run() - - while not APP().exiting and not worker.isFinished() and counter < timeout: - eventLoopWait(step) - - counter += step - - if not worker.isFinished(): - worker.abort() - - while not worker.isFinished(): - eventLoopWait(step) - - if APP().exiting: - # Stop timer - self.downSpeedTimer.stop() - - testDownloadSpeed() - - def testDownSpeedByReference(self, indexes, references): - for index, reference in zip(indexes, references): - # Served by FIFO - try: - self.downSpeedQueue.put_nowait((index, reference)) - except Exception: - # Any non-exit exceptions - - pass - - def testItemDelayByIndex(self, indexes): - references = list(self.ServerList[index] for index in indexes) - - self.testPingDelayByReference(indexes, references) - - def testItemSpeedByIndex(self, indexes): - references = list(self.ServerList[index] for index in indexes) - - self.testDownSpeedByReference(indexes, references) - - def testSelectedItemDelay(self): - indexes = self.selectedIndex - - if len(indexes) == 0: - # Nothing selected. Do nothing - return - - APP().testPerformed = True - - self.testItemDelayByIndex(indexes) - - def testSelectedItemSpeed(self): - indexes = self.selectedIndex - - if len(indexes) == 0: - # Nothing selected. Do nothing - return - - APP().testPerformed = True - - self.testItemSpeedByIndex(indexes) - - def clearSelectedItemTestResultsByIndex(self, indexes): - for index in indexes: - server = self.ServerList[index] - - server['delayResult'] = '' - server['speedResult'] = '' - - self.flushServerDelayResult(index, server) - self.flushServerSpeedResult(index, server) - - def clearSelectedItemTestResults(self): - indexes = self.selectedIndex - - if len(indexes) == 0: - # Nothing selected. Do nothing - return - - self.clearSelectedItemTestResultsByIndex(indexes) - - # Sync it - ServerStorage.sync() - - def setEmptyItem(self): - # Click empty. - self.setPlainText('', block=True, disabled=True) - # Only one tab currently. Clear tab title - self.editorTab.setTabText(0, '') - self.blockSignals(True) - self.setCurrentIndex(self.indexFromItem(None)) - self.blockSignals(False) - - def switchContext(self, index): - self.doNotSwitchCallback = None - - if index < 0 or index >= len(self.ServerList): - # Click empty. - self.setEmptyItem() - else: - self.setPlainText(self.ServerList[index]['config'], block=True) - # Only one tab currently. Refresh tab title - self.editorTab.setTabText( - 0, f'{index + 1} - {self.ServerList[index]["remark"]}' - ) - # Restore scroll bar - self.restoreScrollBarValue(index) - - @QtCore.Slot(QTableWidgetItem, QTableWidgetItem) - def handleCurrentItemChanged(self, currItem, prevItem): - currRow = -1 if currItem is None else currItem.row() - prevRow = -1 if prevItem is None else prevItem.row() - - def doNotSwitchCallback(tableWidgetItem): - self.blockSignals(True) - self.setCurrentIndex(self.indexFromItem(tableWidgetItem)) - self.blockSignals(False) - - def doNotSwitch(): - self.doNotSwitchCallback = functools.partial(doNotSwitchCallback, prevItem) - - self.saveScrollBarValue(prevRow) - - if APP().ServerWidget is not None and APP().ServerWidget.modified: - choice = self.questionSaveBox.exec() - - if choice == enumValueWrapper(MessageBox.ButtonRole.AcceptRole): - # Save - if APP().ServerWidget.SaveAsServerAction.save( - successCallback=lambda: self.switchContext(currRow), - ): - pass - else: - # Do not switch - doNotSwitch() - - elif choice == enumValueWrapper(MessageBox.ButtonRole.DestructiveRole): - # Discard - self.switchContext(currRow) - - APP().ServerWidget.markAsSaved() # Fake saved - - elif choice == enumValueWrapper(MessageBox.ButtonRole.RejectRole): - # Cancel. Do not switch - doNotSwitch() - else: - self.switchContext(currRow) - - @QtCore.Slot(QTableWidgetItem) - def handleItemChanged(self, item): - self.syncRemarkByIndex(item.row()) - - @QtCore.Slot() - def handleItemSelectionChanged(self): - if callable(self.doNotSwitchCallback): - self.doNotSwitchCallback() - - @QtCore.Slot(QTableWidgetItem) - def handleItemActivated(self, item): - if self.activatedItemIndex == item.row(): - # Same item activated. Do nothing - return - - if APP().ServerWidget is not None and APP().ServerWidget.modified: - self.saveChangeFirst.exec() - - return - - if APP().tray.ConnectAction.isConnecting(): - if PLATFORM != 'Darwin': - # Show the MessageBox asynchronously - self.progressWaitBox.open() - else: - # Show the MessageBox and wait for user to close it - self.progressWaitBox.exec() - - return - - if self.activatedItemIndex >= 0: - # De-activate - self.activateItemByIndex(self.activatedItemIndex, activate=False) - - newIndex = item.row() - - # Activate - self.activateItemByIndex(newIndex, activate=True) - - if APP().isConnected(): - APP().tray.ConnectAction.reconnectAction() - - @QtCore.Slot(QtCore.QPoint) - def handleCustomContextMenuRequested(self, point): - self.contextMenu.exec(self.mapToGlobal(point)) - - def keyPressEvent(self, event): - if event.key() == QtCore.Qt.Key.Key_Delete: - self.deleteSelectedItem() - else: - super().keyPressEvent(event) - - def connectedCallback(self): - self.setSelectionColor(getConnectedColor()) - # Reactivate with possible color - self.activateItemByIndex(self.activatedItemIndex) - - def disconnectedCallback(self): - self.setSelectionColor(Color.LIGHT_BLUE) - # Reactivate with possible color - self.activateItemByIndex(self.activatedItemIndex) - - def retranslate(self): - self.setHorizontalHeaderLabels( - list(_(label) for label in ServerWidget.HEADER_LABEL) - ) - - -class EditConfigurationWidget(MainWindow): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.setWindowTitle(_('Edit Server Configuration')) - self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg')) - - # MessageBox - self.questionSaveBox = QuestionSaveBox( - icon=MessageBox.Icon.Question, parent=self - ) - self.restoreErrorBox = MessageBox(parent=self) - - try: - self.StorageObj = ServerStorage.toObject(APP().Configuration) - # Check model. Shallow copy - self.ServerList = self.StorageObj['model'] - except Exception: - # Any non-exit exceptions - - self.StorageObj = ServerStorage.init() - # Shallow copy - self.ServerList = self.StorageObj['model'] - - # Clear it - ServerStorage.clear() - - Configuration.corruptedError(self.restoreErrorBox) - - # Modified flag - self.modified = False - self.modifiedMark = ' *' - - # Initialize PlainTextEdit - @QtCore.Slot(bool) - def handleModification(changed): - if changed: - self.plainTextEdit.document().setModified(False) - self.markAsModified() - - self.plainTextEdit = ZoomablePlainTextEdit(parent=self) - - self.plainTextEdit.modificationChanged.connect(handleModification) - self.plainTextEdit.setDisabled(True) - - self.showTabAndSpacesIfNecessary() - - self.lineColumnLabel = QLabel('1:1 0') - self.statusBar().addPermanentWidget(self.lineColumnLabel) - - @QtCore.Slot() - def handleCursorPositionChanged(): - cursor = self.plainTextEdit.textCursor() - - self.lineColumnLabel.setText( - f'{cursor.blockNumber() + 1}:{cursor.columnNumber() + 1} {cursor.position()}' - ) - - self.plainTextEdit.cursorPositionChanged.connect(handleCursorPositionChanged) - - # Theme - self.plainTextEdit.setStyleSheet( - DraculaTheme.getStyleSheet( - widgetName='QPlainTextEdit', - fontFamily=APP().customFontName, - ) - ) - - self.theme = DraculaTheme(self.plainTextEdit.document()) - - try: - font = self.plainTextEdit.font() - font.setPointSize(int(APP().ServerWidgetPointSize)) - - self.plainTextEdit.setFont(font) - except Exception: - # Any non-exit exceptions - - pass - - self.serverEditorTabWidget = QWidget() - self.serverEditorTabWidgetLayout = QVBoxLayout(self.serverEditorTabWidget) - self.serverEditorTabWidgetLayout.addWidget(self.plainTextEdit) - - # Advance for self.serverWidget - self.editorTab = QTabWidget(self) - # Note: Set tab text later - self.editorTab.addTab(self.serverEditorTabWidget, '') - - self.serverWidget = ServerWidget(parent=self) - - # Buttons - self.moveUpButton = PushButton(_('Move Up')) - self.moveUpButton.clicked.connect( - lambda: self.serverWidget.moveUpSelectedItem() - ) - self.moveDownButton = PushButton(_('Move Down')) - self.moveDownButton.clicked.connect( - lambda: self.serverWidget.moveDownSelectedItem() - ) - self.duplicateButton = PushButton(_('Duplicate')) - self.duplicateButton.clicked.connect( - lambda: self.serverWidget.duplicateSelectedItem() - ) - self.deleteButton = PushButton(_('Delete')) - self.deleteButton.clicked.connect( - lambda: self.serverWidget.deleteSelectedItem() - ) - - # Button Layout - self.buttonWidget = QWidget() - self.buttonWidgetLayout = QGridLayout(parent=self.buttonWidget) - self.buttonWidgetLayout.addWidget(self.moveUpButton, 0, 0) - self.buttonWidgetLayout.addWidget(self.moveDownButton, 0, 1) - self.buttonWidgetLayout.addWidget(self.duplicateButton, 1, 0) - self.buttonWidgetLayout.addWidget(self.deleteButton, 1, 1) - - self.serverTabWidget = QWidget() - self.serverTabWidgetLayout = QVBoxLayout(self.serverTabWidget) - self.serverTabWidgetLayout.addWidget(self.serverWidget) - self.serverTabWidgetLayout.addWidget(self.buttonWidget) - - self.serverTab = TabWidget(self) - self.serverTab.addTab(self.serverTabWidget, _('Server')) - - self.splitter = QSplitter(QtCore.Qt.Orientation.Horizontal) - self.splitter.addWidget(self.serverTab) - self.splitter.addWidget(self.editorTab) - self.splitter.setStretchFactor(0, 8) - self.splitter.setStretchFactor(1, 5) - - if APP().HideEditor == Switch.ON_: - APP().processEvents() - - self.editorTab.hide() - - self.fakeCentralWidget = QWidget() - - self.fakeCentralWidgetLayout = QHBoxLayout(self.fakeCentralWidget) - self.fakeCentralWidgetLayout.addWidget(self.splitter) - - self.setCentralWidget(self.fakeCentralWidget) - - # Menu actions - fileMenu = { - 'name': 'File', - 'actions': [ - NewBlankServerAction(parent=self), - Seperator(), - SelectAllServerAction( - callback=lambda: self.serverWidget.selectAll(), - parent=self, - ), - Seperator(), - ScrollToActivatedServerAction(parent=self.serverWidget), - Seperator(), - TestPingLatencyAction(parent=self.serverWidget), - TestDownloadSpeedAction(parent=self.serverWidget), - ClearTestResultsAction(parent=self.serverWidget), - Seperator(), - ImportFileAction(parent=self), - ImportLinkActionWithHotKey(parent=self), - ImportJSONActionWithHotKey(parent=self), - Seperator(), - ExportLinkActionWithHotKey(parent=self), - ExportQRCodeActionWithHotKey(parent=self), - ExportJSONActionWithHotKey(parent=self), - Seperator(), - SaveAsServerAction(parent=self), - SaveAsFileAction(parent=self), - Seperator(), - ExitAction(parent=self), - ], - } - - editMenu = { - 'name': 'Edit', - 'actions': [ - UndoAction(parent=self), - RedoAction(parent=self), - Seperator(), - CutAction(parent=self), - CopyAction(parent=self), - PasteAction(parent=self), - Seperator(), - SelectAllTextAction(parent=self), - Seperator(), - IndentAction(parent=self), - ], - } - - viewMenu = { - 'name': 'View', - 'actions': [ - ZoomInAction(parent=self), - ZoomOutAction(parent=self), - Seperator(), - ShowHideEditorAction(parent=self), - ], - } - - subsMenu = { - 'name': 'Subscription', - 'actions': [ - UpdateSubsUseCurrentProxyAction(parent=self), - UpdateSubsForceProxyAction(parent=self), - UpdateSubsNoProxyAction(parent=self), - Seperator(), - EditSubscriptionAction(parent=self), - ], - } - - helpMenu = { - 'name': 'Help', - 'actions': [ - ShowLogAction(parent=self), - Seperator(), - CheckForUpdatesAction(parent=self), - AboutAction(parent=self), - ], - } - - # Menus - for menuDict in (fileMenu, editMenu, viewMenu, subsMenu, 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 - setattr(self, menuObjName, menu) - - self.menuBar().addMenu(menu) - - def setWidthAndHeight(self): - try: - self.setGeometry( - 100, - 100, - *list(int(size) for size in APP().ServerWidgetWindowSize.split(',')), - ) - except Exception: - # Any non-exit exceptions - - self.setGeometry(100, 100, 1800, 960) - - def deleteItemByIndex(self, indexes, syncStorage=False): - return self.serverWidget.deleteItemByIndex(indexes, syncStorage) - - def deleteItemByUnique(self, unique, syncStorage=False): - return self.deleteItemByIndex( - list( - index - for index, server in enumerate(self.ServerList) - if server.get('subsId', '') == unique - ), - syncStorage=syncStorage, - ) - - def importServer(self, remark, config, subsId='', syncStorage=False): - server = { - 'remark': remark, - 'config': config, - 'subsId': subsId, - } - - self.ServerList.append(server) - - if syncStorage: - # Sync it - ServerStorage.sync() - - row = self.serverWidget.rowCount() - - self.serverWidget.insertRow(row) - self.serverWidget.appendScrollBarEntry() - self.serverWidget.flushRow(row, server) - - if len(self.ServerList) == 1: - # The first one. Click it - self.serverWidget.setCurrentItemByIndex(0) - - # Try to be user-friendly in some extreme cases - if not APP().isConnected(): - # Activate automatically - self.serverWidget.activateItemByIndex(0) - - def showTabAndSpacesIfNecessary(self): - textOption = QTextOption() - - if APP().ShowTabAndSpacesInEditor == Switch.ON_: - textOption.setFlags(QTextOption.Flag.ShowTabsAndSpaces) - - self.plainTextEdit.document().setDefaultTextOption(textOption) - # Reset. Set - self.plainTextEdit.setLineWrapMode(QPlainTextEdit.LineWrapMode.WidgetWidth) - self.plainTextEdit.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) - - @property - def activatedItemIndex(self): - return self.serverWidget.activatedItemIndex - - @property - def selectedIndex(self): - return self.serverWidget.selectedIndex - - @property - def rowCount(self): - return self.serverWidget.rowCount() - - @property - def currentFocus(self): - return self.serverWidget.currentFocus - - @property - def saveChangeFirst(self): - return self.serverWidget.saveChangeFirst - - @property - def deletedServerId(self): - return self.serverWidget.deletedServerId - - @property - def pingThreadPool(self): - return self.serverWidget.pingThreadPool - - def flushServerSpeedResult(self, row, server): - self.serverWidget.flushServerSpeedResult(row, server) - - def saveScrollBarValue(self, index): - self.serverWidget.saveScrollBarValue(index) - - def restoreScrollBarValue(self, index): - self.serverWidget.restoreScrollBarValue(index) - - def resizeHorizontalHeaderSection(self, oldSize, newSize): - self.serverWidget.resizeHorizontalHeaderSection(oldSize, newSize) - - def questionSave(self): - # Save current scroll bar value - currentFocus = self.currentFocus - - if currentFocus >= 0: - self.saveScrollBarValue(currentFocus) - else: - # No item selected. Save nothing - pass - - if self.modified: - choice = self.questionSaveBox.exec() - - if choice == enumValueWrapper(MessageBox.ButtonRole.AcceptRole): - # Save - # If save success, close the window. - return self.SaveAsServerAction.save(successCallback=lambda: self.hide()) - - if choice == enumValueWrapper(MessageBox.ButtonRole.DestructiveRole): - # Discard - self.hide() - self.serverWidget.setEmptyItem() - self.markAsSaved() # Fake saved - - return True - - if choice == enumValueWrapper(MessageBox.ButtonRole.RejectRole): - # Cancel. Do nothing - return False - else: - self.hide() - - return True - - def markAsModified(self): - self.modified = True - - self.setWindowTitle(_('Edit Server Configuration') + self.modifiedMark) - - def markAsSaved(self): - self.modified = False - - self.setWindowTitle(_('Edit Server Configuration')) - - def closeEvent(self, event): - event.ignore() - - self.syncSettings() - self.questionSave() - - def syncSettings(self): - APP().ServerWidgetWindowSize = ( - f'{self.geometry().width()},{self.geometry().height()}' - ) - APP().ServerWidgetSectionSizeTable = ujson.dumps( - self.serverWidget.sectionSizeTable, - ensure_ascii=False, - escape_forward_slashes=False, - ) - APP().ServerWidgetPointSize = str(self.plainTextEdit.font().pointSize()) - - def retranslate(self): - with StateContext(self): - if self.modified: - # Keep modified mark - self.setWindowTitle(_('Edit Server Configuration') + self.modifiedMark) - else: - self.setWindowTitle(_(self.windowTitle())) diff --git a/Furious/Widget/EditRouting.py b/Furious/Widget/EditRouting.py deleted file mode 100644 index b5c0927..0000000 --- a/Furious/Widget/EditRouting.py +++ /dev/null @@ -1,1374 +0,0 @@ -# Copyright (C) 2023 Loren Eteval -# -# This file is part of Furious. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -from Furious.Action.Routing import ( - BUILTIN_ROUTING_TABLE, - BUILTIN_ROUTING, - RoutingChildAction, - routingToIndex, -) -from Furious.Core.Core import XrayCore, Hysteria1 -from Furious.Gui.Action import Action, Seperator -from Furious.Widget.Widget import ( - Dialog, - HeaderView, - Menu, - MainWindow, - 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, - PLATFORM, - APPLICATION_NAME, - GOLDEN_RATIO, - DATA_DIR, - TOR_FAQ_URL, - TOR_FAQ_LABEL, - Color, -) -from Furious.Utility.Utility import ( - RoutesStorage, - StateContext, - SupportConnectedCallback, - bootstrapIcon, - enumValueWrapper, - moveToCenter, - getConnectedColor, - getConnectedWindowIcon, -) -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, - QDialog, - QDialogButtonBox, - QFileDialog, - QFormLayout, - QGridLayout, - QHBoxLayout, - QHeaderView, - QLabel, - QLineEdit, - QPlainTextEdit, - QPushButton, - QTableWidget, - QTableWidgetItem, - QTextBrowser, - QVBoxLayout, - QWidget, -) - -import ujson -import logging -import operator -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 send to destination port 0-65535, in\n' - f'# this 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, - ), - }, - '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 send to destination port 0-65535, in\n' - f'# this 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, - ), - }, - 'Route My Traffic Through Tor': { - XrayCore.name(): ( - f'# Xray-core Routing rules\n' - f'# \n' - f'# This rule is read-only.\n' - f'# \n' - f'# Use Tor to achieve better anonymity, but {APPLICATION_NAME}\n' - f'# never claims nor guarantees *FULL* anonymity.\n' - f'# \n' - f'# See more information:\n' - f'# \n' - f'# {TOR_FAQ_URL}\n\n' - ), - }, - '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 send to destination port 0-65535, in\n' - f'# this 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, - ), - }, - '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_ROUTING_HINT = ( - f'Note: If acl is empty or does not exist, {APPLICATION_NAME} ' - f'will fall back to proxy all traffic.' -) - - -ROUTING_HINT = { - 'Bypass Mainland China': { - Hysteria1.name(): '', - }, - 'Bypass Iran': { - Hysteria1.name(): '', - }, - 'Route My Traffic Through Tor': { - Hysteria1.name(): TOR_FAQ_LABEL, - }, - 'Global': { - Hysteria1.name(): HYSTERIA_ROUTING_HINT, - }, - 'Custom': { - Hysteria1.name(): ( - f'Note: "Custom" will use acl and mmdb defined in current user configuration.' - ), - }, -} - - -DEFAULT_USER_ROUTING = { - XrayCore.name(): { - 'domainStrategy': 'IPIfNonMatch', - 'domainMatcher': 'hybrid', - 'rules': [ - # Example direct domain - { - 'type': 'field', - 'outboundTag': 'direct', - 'domain': [ - 'domain:example-direct1.suffix', - 'domain:example-direct2.suffix', - ], - }, - # Example proxy domain - { - 'type': 'field', - 'outboundTag': 'proxy', - 'domain': [ - 'domain:example-proxy1.suffix', - 'domain:example-proxy2.suffix', - ], - }, - # Example Ads - { - 'type': 'field', - 'outboundTag': 'block', - 'domain': [ - 'geosite:category-ads-all', - ], - }, - # Proxy everything - { - 'type': 'field', - 'outboundTag': 'proxy', - 'port': '0-65535', - }, - ], - 'balancers': [], - }, - Hysteria1.name(): { - 'acl': '', - 'mmdb': (DATA_DIR / 'hysteria' / 'country.mmdb').as_posix(), - }, -} - - -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, - ), -} - - -class ImportAssetFileAction(Action): - def __init__(self, **kwargs): - super().__init__(_('Import Asset File...'), **kwargs) - - def triggeredCallback(self, checked): - APP().RoutesWidget.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 == enumValueWrapper(MessageBox.ButtonRole.AcceptRole): - # 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().RoutesWidget.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( - operator.add, - 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'{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): - # TODO: Better way to do this. - # Fix https://github.com/LorenEteval/Furious/issues/33 - if core.count(XrayCore.name()) > 0: - core = XrayCore.name() - - APP().RoutesWidget.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().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 EditRoutingAction(Action): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.saveConfInfo = SaveConfInfo(icon=MessageBox.Icon.Information) - - self.RoutesList = self.parent().RoutesList - - self.routingEditorRef = self.parent().routingEditorRef - self.routingDialogRef = self.parent().routingDialogRef - - def triggeredCallback(self, checked): - if self.textCompare(f'Edit {XrayCore.name()} Routing Rules'): - for index in self.parent().selectedIndex: - self.routingEditorRef[index].show() - - if self.textCompare(f'Edit {Hysteria1.name()} Routing Rules'): - for index in self.parent().selectedIndex: - dialog = self.routingDialogRef[index] - choice = dialog.exec() - - if dialog.isBuiltin: - # Built-in. Nothing to do - logger.info( - f'built-in routing dialog \'{BUILTIN_ROUTING[index]}\' called exec. Nothing to do' - ) - - continue - - assert index >= len(BUILTIN_ROUTING) - - parentIndex = index - len(BUILTIN_ROUTING) - - if choice == enumValueWrapper(QDialog.DialogCode.Accepted): - ruleValue = dialog.ruleValue() - mmdbValue = dialog.mmdbValue() - - self.RoutesList[parentIndex][Hysteria1.name()].update( - {'acl': ruleValue, 'mmdb': mmdbValue} - ) - - # Sync it - RoutesStorage.sync() - - if parentIndex == routingToIndex() - len(BUILTIN_ROUTING): - # Activated routing modified - - if APP().isConnected(): - questionFastReconnect(self.saveConfInfo) - else: - # Restore - - routingObject = self.RoutesList[parentIndex][Hysteria1.name()] - - dialog.setRuleValue(routingObject.get('acl', '')) - dialog.setMmdbValue(routingObject.get('mmdb', '')) - - -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(MainWindow): - def __init__(self, isBuiltin, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.isBuiltin = isBuiltin - self.title = '' - - if APP().isConnected(): - self.setWindowIcon(getConnectedWindowIcon()) - else: - self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg')) - - # MessageBox - self.questionSaveBox = QuestionSaveBox( - icon=MessageBox.Icon.Question, parent=self - ) - - # 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 setWidthAndHeight(self): - self.setGeometry(100, 100, 656, 856) - - 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, _(f'{core} Routing Rules')) - - 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 == enumValueWrapper(MessageBox.ButtonRole.AcceptRole): - # Save - # If save success, close the window. - return self.SaveAction.save(successCallback=lambda: self.hide()) - - if choice == enumValueWrapper(MessageBox.ButtonRole.DestructiveRole): - # 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 == enumValueWrapper(MessageBox.ButtonRole.RejectRole): - # Cancel. Do nothing - return False - else: - self.hide() - - return True - - def closeEvent(self, event): - event.ignore() - - self.questionSave() - - def retranslate(self): - if self.isBuiltin: - # Built-in routing is translatable - with StateContext(self): - self.setWindowTitle(_(self.windowTitle())) - - -class RoutingDialog(Dialog): - LINE_EDIT_FIXED_WIDTH = 500 - BUTTON_FIXED_WIDTH = 30 - - def __init__(self, isBuiltin, hint='', *args, **kwargs): - super().__init__(*args, **kwargs) - - self.isBuiltin = isBuiltin - - if APP().isConnected(): - self.setWindowIcon(getConnectedWindowIcon()) - else: - self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg')) - - self.ruleText = QLabel(f'{Hysteria1.name()} acl:') - - self.ruleEdit = QLineEdit() - self.ruleEdit.setFixedWidth(RoutingDialog.LINE_EDIT_FIXED_WIDTH) - - self.ruleFile = QPushButton('...') - self.ruleFile.setFixedWidth(RoutingDialog.BUTTON_FIXED_WIDTH) - - self.ruleHBox = QHBoxLayout() - self.ruleHBox.addWidget(self.ruleEdit) - self.ruleHBox.addWidget(self.ruleFile) - - self.mmdbText = QLabel(f'{Hysteria1.name()} mmdb:') - - self.mmdbEdit = QLineEdit() - self.mmdbEdit.setFixedWidth(RoutingDialog.LINE_EDIT_FIXED_WIDTH) - - self.mmdbFile = QPushButton('...') - self.mmdbFile.setFixedWidth(RoutingDialog.BUTTON_FIXED_WIDTH) - - self.mmdbHBox = QHBoxLayout() - self.mmdbHBox.addWidget(self.mmdbEdit) - self.mmdbHBox.addWidget(self.mmdbFile) - - self.ruleFile.clicked.connect(self.handleRuleFileClicked) - self.mmdbFile.clicked.connect(self.handleMmdbFileClicked) - - if self.isBuiltin: - self.ruleEdit.setReadOnly(True) - self.mmdbEdit.setReadOnly(True) - - self.dialogBtns = QDialogButtonBox(QtCore.Qt.Orientation.Horizontal) - - self.dialogBtns.addButton(_('OK'), QDialogButtonBox.ButtonRole.AcceptRole) - self.dialogBtns.addButton(_('Cancel'), QDialogButtonBox.ButtonRole.RejectRole) - self.dialogBtns.accepted.connect(self.accept) - self.dialogBtns.rejected.connect(self.reject) - - layout = QFormLayout() - - if hint: - self.hint = QLabel(hint) - self.hint.setTextFormat(QtCore.Qt.TextFormat.RichText) - self.hint.setTextInteractionFlags( - QtCore.Qt.TextInteractionFlag.TextBrowserInteraction - ) - self.hint.setOpenExternalLinks(True) - - layout.addRow(self.hint) - else: - self.hint = None - - layout.addRow(self.ruleText, self.ruleHBox) - layout.addRow(self.mmdbText, self.mmdbHBox) - layout.addRow(self.dialogBtns) - layout.setFormAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) - - self.setLayout(layout) - - @QtCore.Slot() - def handleRuleFileClicked(self): - filename, selectedFilter = QFileDialog.getOpenFileName( - self.parent(), _('Import File'), filter=_('All files (*)') - ) - - if not self.isBuiltin and filename: - self.ruleEdit.setText(filename) - - @QtCore.Slot() - def handleMmdbFileClicked(self): - filename, selectedFilter = QFileDialog.getOpenFileName( - self.parent(), _('Import File'), filter=_('All files (*)') - ) - - if not self.isBuiltin and filename: - self.mmdbEdit.setText(filename) - - def setRuleValue(self, text): - self.ruleEdit.setText(text) - - def setMmdbValue(self, text): - self.mmdbEdit.setText(text) - - def ruleValue(self): - return self.ruleEdit.text() - - def mmdbValue(self): - return self.mmdbEdit.text() - - def retranslate(self): - with StateContext(self): - if self.isBuiltin: - # Built-in window title is translatable - self.setWindowTitle(_(self.windowTitle())) - - if self.hint is not None: - self.hint.setText(_(self.hint.text())) - - for button in self.dialogBtns.buttons(): - button.setText(_(button.text())) - - -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.RoutesWidget = kwargs.get('parent') - - self.questionDeleteBox = QuestionDeleteBox( - icon=MessageBox.Icon.Question, parent=self.parent() - ) - - # Handy reference - self.RoutesList = self.RoutesWidget.RoutesList - - # Currently only has Xray-Core - self.routingEditorRef = [] - # Currently only has Hysteria1 - self.routingDialogRef = [] - - # 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) - - # Fill missing value - for column in range(self.columnCount()): - if self.sectionSizeTable.get(str(column)) is None: - self.sectionSizeTable[ - str(column) - ] = self.horizontalHeader().defaultSectionSize() - - # Block resize callback - self.horizontalHeader().blockSignals(True) - - for key, value in self.sectionSizeTable.items(): - self.horizontalHeader().resizeSection(int(key), value) - - # Unblock resize callback - self.horizontalHeader().blockSignals(False) - except Exception: - # Any non-exit exceptions - - # Leave keys as strings since they will be - # loaded as string from json - self.sectionSizeTable = { - str(row): self.horizontalHeader().defaultSectionSize() - for row in range(self.columnCount()) - } - - self.setHorizontalHeaderLabels( - list(_(label) for label in 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(Color.LIGHT_BLUE) - self.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) - self.setSelectionMode(QTableWidget.SelectionMode.ExtendedSelection) - - # No drag and drop - self.setDragEnabled(False) - self.setDragDropMode(QAbstractItemView.DragDropMode.NoDragDrop) - self.setDropIndicatorShown(False) - self.setDefaultDropAction(QtCore.Qt.DropAction.IgnoreAction) - - contextMenuActions = [ - EditRoutingAction(_(f'Edit {XrayCore.name()} Routing Rules'), parent=self), - EditRoutingAction(_(f'Edit {Hysteria1.name()} Routing Rules'), parent=self), - ] - - self.contextMenu = Menu(*contextMenuActions) - self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) - - # Signals - self.customContextMenuRequested.connect(self.handleCustomContextMenuRequested) - - def initTableFromData(self): - for row, routing in enumerate(BUILTIN_ROUTING): - self.appendDataByColumn( - lambda column: EditRoutingTableWidget.BUILTIN_ITEM_GET_FUNC[column](row) - ) - - text = self.item(row, 0).text() - - routingEditor = self.appendRoutingEditor(text, isBuiltin=True) - - for core in [XrayCore.name()]: - routingEditor.addTabWithData( - core, - BUILTIN_ROUTING_TEXT[BUILTIN_ROUTING[row]][core], - ) - - routingDialog = self.appendRoutingDialog( - text, hint=_(ROUTING_HINT[routing][Hysteria1.name()]), isBuiltin=True - ) - - routingDialog.ruleEdit.setText( - BUILTIN_ROUTING_TABLE[routing][Hysteria1.name()].get('acl', '') - ) - routingDialog.mmdbEdit.setText( - BUILTIN_ROUTING_TABLE[routing][Hysteria1.name()].get('mmdb', '') - ) - - 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) - ) - - text = self.item(row, 0).text() - - routingEditor = self.appendRoutingEditor(text, isBuiltin=False) - - for core in [XrayCore.name()]: - routingEditor.addTabWithData( - core, - ujson.dumps( - self.RoutesList[routesIndex][core], - indent=2, - ensure_ascii=False, - escape_forward_slashes=False, - ), - ) - - routingDialog = self.appendRoutingDialog( - text, hint=_(HYSTERIA_ROUTING_HINT), isBuiltin=False - ) - - # Added for backward compatibility - if self.RoutesList[routesIndex].get(Hysteria1.name()) is None: - self.RoutesList[routesIndex][Hysteria1.name()] = {'acl': '', 'mmdb': ''} - - routingDialog.ruleEdit.setText( - self.RoutesList[routesIndex][Hysteria1.name()].get('acl', '') - ) - routingDialog.mmdbEdit.setText( - self.RoutesList[routesIndex][Hysteria1.name()].get('mmdb', '') - ) - - @QtCore.Slot(QtCore.QPoint) - def handleCustomContextMenuRequested(self, point): - self.contextMenu.exec(self.mapToGlobal(point)) - - def appendRoutingEditor(self, title, isBuiltin=False): - routingEditor = RoutingEditor(isBuiltin) - routingEditor.title = title - routingEditor.setWindowTitle(title) - - moveToCenter(routingEditor) - - self.routingEditorRef.append(routingEditor) - - return routingEditor - - def appendRoutingDialog(self, title, hint='', isBuiltin=False): - routingDialog = RoutingDialog(isBuiltin, hint) - routingDialog.setWindowTitle(title) - - self.routingDialogRef.append(routingDialog) - - return routingDialog - - 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 = self.item(indexes[0], 0).text() - self.questionDeleteBox.setText(self.questionDeleteBox.getText()) - - if self.questionDeleteBox.exec() == enumValueWrapper( - MessageBox.StandardButton.No - ): - # 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.routingDialogRef.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(getConnectedColor()) - # Reactivate with possible color - self.activateItemByIndex(routingToIndex(), activate=True) - - def disconnectedCallback(self): - self.setSelectionColor(Color.LIGHT_BLUE) - # 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 AddRoutingDialog(Dialog): - def __init__(self, parent=None): - super().__init__(parent) - - self.setWindowTitle(_('Add routing')) - self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg')) - - self.remarkText = QLabel(_('Enter routing remark:')) - self.remarkEdit = QLineEdit() - - self.dialogBtns = QDialogButtonBox(QtCore.Qt.Orientation.Horizontal) - - self.dialogBtns.addButton(_('OK'), QDialogButtonBox.ButtonRole.AcceptRole) - self.dialogBtns.addButton(_('Cancel'), QDialogButtonBox.ButtonRole.RejectRole) - self.dialogBtns.accepted.connect(self.accept) - self.dialogBtns.rejected.connect(self.reject) - - layout = QFormLayout() - - layout.addRow(self.remarkText) - layout.addRow(self.remarkEdit) - layout.addRow(self.dialogBtns) - layout.setFormAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) - - self.setLayout(layout) - - def setWidthAndHeight(self): - self.setGeometry(100, 100, 276, 80) - - def routingRemark(self): - return self.remarkEdit.text() - - def retranslate(self): - with StateContext(self): - self.setWindowTitle(_(self.windowTitle())) - - self.remarkText.setText(_(self.remarkText.text())) - - for button in self.dialogBtns.buttons(): - button.setText(_(button.text())) - - moveToCenter(self) - - -class EditRoutingWidget(MainWindow): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.setWindowTitle(_('Edit Routing')) - self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg')) - - self.assetViewer = AssetViewerWidget() - self.addRoutingDialog = AddRoutingDialog() - - 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) - - def setWidthAndHeight(self): - try: - self.setGeometry( - 100, - 100, - *list(int(size) for size in APP().RoutesWidgetWindowSize.split(',')), - ) - except Exception: - # Any non-exit exceptions - - self.setGeometry(100, 100, 360 * GOLDEN_RATIO, 360) - - def addRoute(self): - choice = self.addRoutingDialog.exec() - - if choice == enumValueWrapper(QDialog.DialogCode.Accepted): - routingRemark = self.addRoutingDialog.routingRemark() - - if routingRemark: - self.editRoutingTableWidget.appendDataByColumn( - lambda column: EditRoutingTableWidget.USER_ITEM_GET_FUNC[column]( - routingRemark - ) - ) - - routingEditor = self.editRoutingTableWidget.appendRoutingEditor( - routingRemark, isBuiltin=False - ) - - for core in [XrayCore.name()]: - routingEditor.addTabWithData( - core, - USER_ROUTING_TEXT[core], - ) - - routingDialog = self.editRoutingTableWidget.appendRoutingDialog( - routingRemark, hint=_(HYSTERIA_ROUTING_HINT), isBuiltin=False - ) - - # Set default value - routingDialog.ruleEdit.setText( - DEFAULT_USER_ROUTING[Hysteria1.name()].get('acl', '') - ) - routingDialog.mmdbEdit.setText( - DEFAULT_USER_ROUTING[Hysteria1.name()].get('mmdb', '') - ) - - self.RoutesList.append( - {'remark': routingRemark, **DEFAULT_USER_ROUTING} - ) - - # Sync it - RoutesStorage.sync() - - routingAction = APP().tray.RoutingAction - # Add entry in routing menu - routingAction.addAction( - RoutingChildAction(routingRemark, checkable=True, checked=False) - ) - else: - # Do nothing - pass - - 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()}' - ) - - 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) diff --git a/Furious/Widget/EditSubscription.py b/Furious/Widget/EditSubscription.py deleted file mode 100644 index 2b66b8e..0000000 --- a/Furious/Widget/EditSubscription.py +++ /dev/null @@ -1,469 +0,0 @@ -# Copyright (C) 2023 Loren Eteval -# -# This file is part of Furious. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -from Furious.Gui.Action import Action -from Furious.Widget.Widget import ( - Dialog, - HeaderView, - MainWindow, - Menu, - MessageBox, - PushButton, - StyledItemDelegate, - TableWidget, - TabWidget, -) -from Furious.Widget.EditConfiguration import QuestionDeleteBox -from Furious.Utility.Constants import ( - APP, - PLATFORM, - GOLDEN_RATIO, - Color, -) -from Furious.Utility.Utility import ( - ServerStorage, - SubscriptionStorage, - StateContext, - SupportConnectedCallback, - bootstrapIcon, - enumValueWrapper, - moveToCenter, - getConnectedColor, -) -from Furious.Utility.Translator import Translatable, gettext as _ - -from PySide6 import QtCore -from PySide6.QtGui import QFont -from PySide6.QtWidgets import ( - QAbstractItemView, - QDialog, - QDialogButtonBox, - QFormLayout, - QGridLayout, - QHeaderView, - QLabel, - QLineEdit, - QTableWidget, - QTableWidgetItem, - QVBoxLayout, - QWidget, -) - -import uuid -import ujson -import logging - -logger = logging.getLogger(__name__) - - -class EditSubsHorizontalHeader(HeaderView): - def __init__(self, *args, **kwargs): - super().__init__(QtCore.Qt.Orientation.Horizontal, *args, **kwargs) - - self.sectionResized.connect(self.handleSectionResized) - - @QtCore.Slot(int, int, int) - def handleSectionResized(self, index, oldSize, newSize): - # Keys are string when loaded from json - self.parent().sectionSizeTable[str(index)] = newSize - - -class EditSubsVerticalHeader(HeaderView): - def __init__(self, *args, **kwargs): - super().__init__(QtCore.Qt.Orientation.Vertical, *args, **kwargs) - - -class EditSubsTableWidget(Translatable, SupportConnectedCallback, TableWidget): - # Might be extended in the future - HEADER_LABEL = [ - 'Remark', - 'URL', - ] - - # Corresponds to header label - HEADER_LABEL_GET_FUNC = [ - lambda subscription: subscription['remark'], - lambda subscription: subscription['webURL'], - ] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.SubscriptionWidget = kwargs.get('parent') - - self.questionDeleteBox = QuestionDeleteBox( - icon=MessageBox.Icon.Question, parent=self.parent() - ) - - # Handy reference - self.SubscriptionDict = self.SubscriptionWidget.SubscriptionDict - - # Column count - self.setColumnCount(len(EditSubsTableWidget.HEADER_LABEL)) - - # Delegate - self.delegate = StyledItemDelegate(parent=self) - self.setItemDelegate(self.delegate) - - # Install custom header - self.setHorizontalHeader(EditSubsHorizontalHeader(self)) - - self.initTableFromData() - - # Horizontal header resize mode - for index in range(self.columnCount()): - if index < self.columnCount() - 1: - self.horizontalHeader().setSectionResizeMode( - index, QHeaderView.ResizeMode.Interactive - ) - else: - self.horizontalHeader().setSectionResizeMode( - index, QHeaderView.ResizeMode.Stretch - ) - - try: - # Restore horizontal section size - self.sectionSizeTable = ujson.loads( - APP().SubscriptionWidgetSectionSizeTable - ) - - # Fill missing value - for column in range(self.columnCount()): - if self.sectionSizeTable.get(str(column)) is None: - self.sectionSizeTable[ - str(column) - ] = self.horizontalHeader().defaultSectionSize() - - # Block resize callback - self.horizontalHeader().blockSignals(True) - - for key, value in self.sectionSizeTable.items(): - self.horizontalHeader().resizeSection(int(key), value) - - # Unblock resize callback - self.horizontalHeader().blockSignals(False) - except Exception: - # Any non-exit exceptions - - # Leave keys as strings since they will be - # loaded as string from json - self.sectionSizeTable = { - str(row): self.horizontalHeader().defaultSectionSize() - for row in range(self.columnCount()) - } - - self.setHorizontalHeaderLabels( - list(_(label) for label in EditSubsTableWidget.HEADER_LABEL) - ) - - for column in range(self.horizontalHeader().count()): - self.horizontalHeaderItem(column).setFont(QFont(APP().customFontName)) - - # Install custom header - self.setVerticalHeader(EditSubsVerticalHeader(self)) - - # Selection - self.setSelectionColor(Color.LIGHT_BLUE) - self.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) - self.setSelectionMode(QTableWidget.SelectionMode.ExtendedSelection) - - # No drag and drop - self.setDragEnabled(False) - self.setDragDropMode(QAbstractItemView.DragDropMode.NoDragDrop) - self.setDropIndicatorShown(False) - self.setDefaultDropAction(QtCore.Qt.DropAction.IgnoreAction) - - contextMenuActions = [ - Action( - _('Delete'), - callback=lambda: self.deleteSelectedItem(), - parent=self, - ), - ] - - self.contextMenu = Menu(*contextMenuActions) - self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) - - # Signals - self.customContextMenuRequested.connect(self.handleCustomContextMenuRequested) - self.itemChanged.connect(self.handleItemChanged) - - @QtCore.Slot(QTableWidgetItem) - def handleItemChanged(self, item): - unique = self.getUniqueByIndex(item.row()) - - keyMap = ['remark', 'webURL'] - - if self.SubscriptionDict[unique][keyMap[item.column()]] != item.text(): - # Modified. Update value - self.SubscriptionDict[unique][keyMap[item.column()]] = item.text() - - # Sync it - SubscriptionStorage.sync() - - @QtCore.Slot(QtCore.QPoint) - def handleCustomContextMenuRequested(self, point): - self.contextMenu.exec(self.mapToGlobal(point)) - - def getUniqueByIndex(self, index): - return list(self.SubscriptionDict.keys())[index] - - def initTableFromData(self): - for key, value in self.SubscriptionDict.items(): - self.appendDataByColumn( - lambda column: EditSubsTableWidget.HEADER_LABEL_GET_FUNC[column](value) - ) - - def deleteSelectedItem(self): - indexes = self.selectedIndex - - if len(indexes) == 0: - # Nothing to do - return - - if APP().ServerWidget is not None and APP().ServerWidget.modified: - APP().ServerWidget.saveChangeFirst.exec() - - return - - self.questionDeleteBox.isMulti = bool(len(indexes) > 1) - self.questionDeleteBox.possibleRemark = self.item(indexes[0], 0).text() - self.questionDeleteBox.setText(self.questionDeleteBox.getText()) - - if self.questionDeleteBox.exec() == enumValueWrapper( - MessageBox.StandardButton.No - ): - # Do not delete - return - - deleted = 0 - - for i in range(len(indexes)): - takedRow = indexes[i] - i - takedUnique = self.getUniqueByIndex(takedRow) - - self.removeRow(takedRow) - self.SubscriptionDict.pop(takedUnique) - - deleted += APP().ServerWidget.deleteItemByUnique( - takedUnique, syncStorage=False - ) - - if deleted: - # At least on server has been deleted. Sync it - ServerStorage.sync() - - # Sync it - SubscriptionStorage.sync() - - def appendDataByColumn(self, func): - row = self.rowCount() - - self.insertRow(row) - - for column in range(self.columnCount()): - item = QTableWidgetItem(func(column)) - - item.setFont(QFont(APP().customFontName)) - item.setFlags( - QtCore.Qt.ItemFlag.ItemIsEnabled - | QtCore.Qt.ItemFlag.ItemIsSelectable - | QtCore.Qt.ItemFlag.ItemIsEditable - ) - - self.setItem(row, column, item) - - def connectedCallback(self): - self.setSelectionColor(getConnectedColor()) - - def disconnectedCallback(self): - self.setSelectionColor(Color.LIGHT_BLUE) - - def retranslate(self): - self.setHorizontalHeaderLabels( - list(_(label) for label in EditSubsTableWidget.HEADER_LABEL) - ) - - -class AddSubsDialog(Dialog): - def __init__(self, parent=None): - super().__init__(parent) - - self.setWindowTitle(_('Add subscription')) - self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg')) - - self.remarkText = QLabel(_('Enter subscription remark:')) - self.remarkEdit = QLineEdit() - - self.webURLText = QLabel(_('Enter subscription URL:')) - self.webURLEdit = QLineEdit() - - self.dialogBtns = QDialogButtonBox(QtCore.Qt.Orientation.Horizontal) - - self.dialogBtns.addButton(_('OK'), QDialogButtonBox.ButtonRole.AcceptRole) - self.dialogBtns.addButton(_('Cancel'), QDialogButtonBox.ButtonRole.RejectRole) - self.dialogBtns.accepted.connect(self.accept) - self.dialogBtns.rejected.connect(self.reject) - - layout = QFormLayout() - - layout.addRow(self.remarkText) - layout.addRow(self.remarkEdit) - layout.addRow(self.webURLText) - layout.addRow(self.webURLEdit) - layout.addRow(self.dialogBtns) - layout.setFormAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) - - self.setLayout(layout) - - def setWidthAndHeight(self): - self.setGeometry(100, 100, 456, 150) - - def subscriptionRemark(self): - return self.remarkEdit.text() - - def subscriptionWebURL(self): - return self.webURLEdit.text() - - def retranslate(self): - with StateContext(self): - self.setWindowTitle(_(self.windowTitle())) - - self.remarkText.setText(_(self.remarkText.text())) - self.webURLText.setText(_(self.webURLText.text())) - - for button in self.dialogBtns.buttons(): - button.setText(_(button.text())) - - moveToCenter(self) - - -class EditSubscriptionWidget(MainWindow): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.setWindowTitle(_('Edit Subscription')) - self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg')) - - self.addSubsDialog = AddSubsDialog() - - try: - self.StorageObj = SubscriptionStorage.toObject(APP().CustomSubscription) - # Shallow copy - self.SubscriptionDict = self.StorageObj - except Exception: - # Any non-exit exceptions - - self.StorageObj = SubscriptionStorage.init() - # Shallow copy - self.SubscriptionDict = self.StorageObj - - # Clear it - SubscriptionStorage.clear() - - self.editSubsTableWidget = EditSubsTableWidget(parent=self) - - self.editorTab = TabWidget(self) - self.editorTab.addTab(self.editSubsTableWidget, _('Subscription List')) - - # Buttons - self.addButton = PushButton(_('Add')) - self.addButton.clicked.connect(lambda: self.addSubscription()) - self.deleteButton = PushButton(_('Delete')) - self.deleteButton.clicked.connect(lambda: self.deleteSelectedItem()) - - # Button Layout - self.buttonWidget = QWidget() - self.buttonWidgetLayout = QGridLayout(parent=self.buttonWidget) - self.buttonWidgetLayout.addWidget(self.addButton, 0, 0) - self.buttonWidgetLayout.addWidget(self.deleteButton, 0, 1) - - self.fakeCentralWidget = QWidget() - self.fakeCentralWidgetLayout = QVBoxLayout(self.fakeCentralWidget) - self.fakeCentralWidgetLayout.addWidget(self.editorTab) - self.fakeCentralWidgetLayout.addWidget(self.buttonWidget) - - self.setCentralWidget(self.fakeCentralWidget) - - def setWidthAndHeight(self): - try: - self.setGeometry( - 100, - 100, - *list( - int(size) for size in APP().SubscriptionWidgetWindowSize.split(',') - ), - ) - except Exception: - # Any non-exit exceptions - - self.setGeometry(100, 100, 360 * GOLDEN_RATIO, 360) - - def addSubscription(self): - choice = self.addSubsDialog.exec() - - if choice == enumValueWrapper(QDialog.DialogCode.Accepted): - subscriptionRemark = self.addSubsDialog.subscriptionRemark() - subscriptionWebURL = self.addSubsDialog.subscriptionWebURL() - - if subscriptionRemark: - # Unique id. Used by display and deletion - unique = str(uuid.uuid4()) - - subscription = { - unique: { - 'remark': subscriptionRemark, - 'webURL': subscriptionWebURL, - } - } - - self.SubscriptionDict.update(subscription) - - self.appendDataByColumn( - lambda column: EditSubsTableWidget.HEADER_LABEL_GET_FUNC[column]( - subscription[unique] - ) - ) - - # Sync it - SubscriptionStorage.sync() - else: - # Do nothing - pass - - def appendDataByColumn(self, func): - self.editSubsTableWidget.appendDataByColumn(func) - - def deleteSelectedItem(self): - self.editSubsTableWidget.deleteSelectedItem() - - def syncSettings(self): - APP().SubscriptionWidgetWindowSize = ( - f'{self.geometry().width()},{self.geometry().height()}' - ) - - APP().SubscriptionWidgetSectionSizeTable = ujson.dumps( - self.editSubsTableWidget.sectionSizeTable, - ensure_ascii=False, - escape_forward_slashes=False, - ) - - def keyPressEvent(self, event): - if event.key() == QtCore.Qt.Key.Key_Delete: - self.deleteSelectedItem() - else: - super().keyPressEvent(event) diff --git a/Furious/Widget/ExportQRCode.py b/Furious/Widget/ExportQRCode.py deleted file mode 100644 index 50c2e1f..0000000 --- a/Furious/Widget/ExportQRCode.py +++ /dev/null @@ -1,68 +0,0 @@ -# Copyright (C) 2023 Loren Eteval -# -# This file is part of Furious. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -from Furious.Widget.Widget import MainWindow -from Furious.Utility.Constants import APPLICATION_NAME -from Furious.Utility.Utility import SupportConnectedCallback, bootstrapIcon -from Furious.Utility.Translator import gettext as _ - -from PySide6 import QtCore -from PySide6.QtGui import QPixmap -from PySide6.QtWidgets import QLabel, QTabWidget - -import io -import pyqrcode - - -class ExportQRCode(MainWindow): - def __init__(self, parent=None): - super().__init__(parent) - - self.setWindowTitle(_(APPLICATION_NAME)) - self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg')) - self.setFixedSize(640, 640) - - self.labelList = [] - - self.editorTab = QTabWidget(self) - self.editorTab.setTabsClosable(True) - self.editorTab.tabCloseRequested.connect(self.handleTabCloseRequested) - - self.setCentralWidget(self.editorTab) - - def initTabWithData(self, data): - for text, link in data: - qrdata = io.BytesIO() - - qrcode = pyqrcode.create(link) - qrcode.png(qrdata, scale=5) - - pixmap = QPixmap() - pixmap.loadFromData(qrdata.getvalue(), 'PNG') - - label = QLabel(parent=self.editorTab) - label.setPixmap(pixmap) - - self.labelList.append(label) - self.editorTab.addTab(label, text) - - @QtCore.Slot(int) - def handleTabCloseRequested(self, index): - self.editorTab.removeTab(index) - - if self.editorTab.count() == 0: - self.hide() diff --git a/Furious/Widget/IndentSpinBox.py b/Furious/Widget/IndentSpinBox.py index 1d3ce0a..b02efc0 100644 --- a/Furious/Widget/IndentSpinBox.py +++ b/Furious/Widget/IndentSpinBox.py @@ -1,4 +1,4 @@ -# Copyright (C) 2023 Loren Eteval +# Copyright (C) 2024 Loren Eteval # # This file is part of Furious. # @@ -15,29 +15,32 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from Furious.Widget.Widget import Dialog -from Furious.Utility.Utility import ( - StateContext, - SupportConnectedCallback, - bootstrapIcon, -) -from Furious.Utility.Translator import Translatable, gettext as _ +from __future__ import annotations -from PySide6 import QtCore -from PySide6.QtWidgets import ( - QDialogButtonBox, - QFormLayout, - QLabel, - QSpinBox, +from Furious.QtFramework import * +from Furious.QtFramework import gettext as _ +from Furious.Utility import * + +from PySide6.QtWidgets import * + +import functools + +__all__ = ['IndentSpinBox'] + +needTrans = functools.partial(needTransFn, source=__name__) + +needTrans( + 'Set Indent', + 'Indent:', + 'Cancel', ) -class IndentSpinBox(Dialog): +class IndentSpinBox(AppQDialog): def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle(_('Set Indent')) - self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg')) self.indentText = QLabel(_('Indent:')) @@ -64,10 +67,9 @@ class IndentSpinBox(Dialog): return self.indentSpin.value() def retranslate(self): - with StateContext(self): - self.setWindowTitle(_(self.windowTitle())) + self.setWindowTitle(_(self.windowTitle())) - self.indentText.setText(_(self.indentText.text())) + self.indentText.setText(_(self.indentText.text())) - for button in self.dialogBtns.buttons(): - button.setText(_(button.text())) + for button in self.dialogBtns.buttons(): + button.setText(_(button.text())) diff --git a/Furious/Widget/LogViewer.py b/Furious/Widget/LogViewer.py deleted file mode 100644 index 3b9b85e..0000000 --- a/Furious/Widget/LogViewer.py +++ /dev/null @@ -1,296 +0,0 @@ -# Copyright (C) 2023 Loren Eteval -# -# This file is part of Furious. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -from Furious.Gui.Action import Action, Seperator -from Furious.Widget.Widget import ( - MainWindow, - Menu, - MessageBox, - TabWidget, - ZoomableTextBrowser, -) -from Furious.Utility.Constants import APP, APPLICATION_NAME, LogType -from Furious.Utility.Utility import ( - StateContext, - SupportConnectedCallback, - bootstrapIcon, -) -from Furious.Utility.Translator import Translatable, gettext as _ -from Furious.Utility.Theme import DraculaTheme - -from PySide6 import QtCore -from PySide6.QtWidgets import QFileDialog, QTextBrowser, QVBoxLayout, QWidget - -import logging - -logger = logging.getLogger(__name__) - - -class SaveErrorBox(MessageBox): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.saveError = '' - - def getText(self): - if self.saveError: - return _('Unable to save log.') + f'\n\n{self.saveError}' - else: - return _('Unable to save log.') - - def retranslate(self): - with StateContext(self): - self.setWindowTitle(_(self.windowTitle())) - self.setText(self.getText()) - - # Ignore informative text, buttons - - self.moveToCenter() - - -class SaveAsFileAction(Action): - def __init__(self, **kwargs): - super().__init__(_('Save As...'), **kwargs) - - self.saveErrorBox = SaveErrorBox( - icon=MessageBox.Icon.Critical, parent=self.parent() - ) - - def triggeredCallback(self, checked): - filename, selectedFilter = QFileDialog.getSaveFileName( - self.parent(), _('Save File'), filter=_('Text files (*.txt);;All files (*)') - ) - - if filename: - try: - with open(filename, 'w', encoding='utf-8') as file: - file.write(self.parent().currentTextBrowser().toPlainText()) - except Exception as ex: - # Any non-exit exceptions - - self.saveErrorBox.saveError = str(ex) - self.saveErrorBox.setWindowTitle(_('Error saving log')) - self.saveErrorBox.setText(self.saveErrorBox.getText()) - - # Show the MessageBox and wait for user to close it - self.saveErrorBox.exec() - - -class ExitAction(Action): - def __init__(self, **kwargs): - super().__init__(_('Exit'), **kwargs) - - def triggeredCallback(self, checked): - self.parent().syncSettings() - self.parent().hide() - - -class CopyAction(Action): - def __init__(self, **kwargs): - super().__init__(_('Copy'), icon=bootstrapIcon('files.svg'), **kwargs) - - self.setShortcut( - QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier.ControlModifier, - QtCore.Qt.Key.Key_C, - ) - ) - - def triggeredCallback(self, checked): - self.parent().currentTextBrowser().copy() - - -class SelectAllAction(Action): - def __init__(self, **kwargs): - super().__init__(_('Select All'), **kwargs) - - self.setShortcut( - QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier.ControlModifier, - QtCore.Qt.Key.Key_A, - ) - ) - - def triggeredCallback(self, checked): - self.parent().currentTextBrowser().selectAll() - - -class ZoomInAction(Action): - def __init__(self, **kwargs): - super().__init__(_('Zoom In'), **kwargs) - - self.setShortcut( - QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier.ControlModifier, QtCore.Qt.Key.Key_Plus - ) - ) - - def triggeredCallback(self, checked): - self.parent().currentTextBrowser().zoomIn() - - -class ZoomOutAction(Action): - def __init__(self, **kwargs): - super().__init__(_('Zoom Out'), **kwargs) - - self.setShortcut( - QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier.ControlModifier, QtCore.Qt.Key.Key_Minus - ) - ) - - def triggeredCallback(self, checked): - self.parent().currentTextBrowser().zoomOut() - - -class AppLogViewerWidget(QWidget): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.textBrowser = ZoomableTextBrowser(parent=self) - self.textBrowser.setLineWrapMode(QTextBrowser.LineWrapMode.NoWrap) - self.textBrowser.setStyleSheet( - DraculaTheme.getStyleSheet( - widgetName='QTextBrowser', - fontFamily=APP().customFontName, - ) - ) - - self.restorePointSize() - - self.widgetLayout = QVBoxLayout() - self.widgetLayout.addWidget(self.textBrowser) - - self.setLayout(self.widgetLayout) - - def pointSizeSetting(self): - return f'{self.__class__.__name__}PointSize' - - def restorePointSize(self): - try: - # Restore point size - font = self.textBrowser.font() - font.setPointSize(int(getattr(APP(), self.pointSizeSetting()))) - - self.textBrowser.setFont(font) - except Exception: - # Any non-exit exceptions - - pass - - def syncSettings(self): - setattr( - APP(), self.pointSizeSetting(), str(self.textBrowser.font().pointSize()) - ) - - -class CoreLogViewerWidget(AppLogViewerWidget): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - -class TorLogViewerWidget(AppLogViewerWidget): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - -class LogViewerWidget(MainWindow): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.setWindowTitle(_('Log Viewer')) - self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg')) - - self.coreLogViewerWidget = CoreLogViewerWidget() - self.appLogViewerWidget = AppLogViewerWidget() - self.torLogViewerWidget = TorLogViewerWidget() - - self.tabWidget = TabWidget() - self.tabWidget.addTab(self.coreLogViewerWidget, _('Core Log')) - self.tabWidget.addTab(self.appLogViewerWidget, _(f'{APPLICATION_NAME} Log')) - self.tabWidget.addTab(self.torLogViewerWidget, _('Tor Log')) - - self.textBrowserMap = { - LogType.Core: self.coreLogViewerWidget.textBrowser, - LogType.App: self.appLogViewerWidget.textBrowser, - LogType.Tor: self.torLogViewerWidget.textBrowser, - } - - self.setCentralWidget(self.tabWidget) - - fileMenuActions = [ - SaveAsFileAction(parent=self), - Seperator(), - ExitAction(parent=self), - ] - - editMenuActions = [ - CopyAction(parent=self), - Seperator(), - SelectAllAction(parent=self), - ] - - viewMenuActions = [ - ZoomInAction(parent=self), - ZoomOutAction(parent=self), - ] - - for menu in (fileMenuActions, editMenuActions, viewMenuActions): - for action in menu: - if isinstance(action, Action): - if hasattr(self, f'{action}'): - logger.warning(f'{self} already has action {action}') - - setattr(self, f'{action}', action) - - self._fileMenu = Menu(*fileMenuActions, title=_('File'), parent=self) - self._editMenu = Menu(*editMenuActions, title=_('Edit'), parent=self) - self._viewMenu = Menu(*viewMenuActions, title=_('View'), parent=self) - - self.menuBar().addMenu(self._fileMenu) - self.menuBar().addMenu(self._editMenu) - self.menuBar().addMenu(self._viewMenu) - - def currentTextBrowser(self): - return self.tabWidget.currentWidget().textBrowser - - def textBrowser(self, logType): - return self.textBrowserMap[logType] - - def log(self, logType): - return self.textBrowser(logType).toPlainText() - - def appendLog(self, logType, line): - textBrowser = self.textBrowser(logType) - - hScrollBar = textBrowser.horizontalScrollBar() - vScrollBar = textBrowser.verticalScrollBar() - scrollEnds = vScrollBar.maximum() - vScrollBar.value() <= 10 - - # Fix insertPlainText bug if user cursor is present - textBrowser.append(line.rstrip()) - - if scrollEnds: - vScrollBar.setValue(vScrollBar.maximum()) # Scrolls to the bottom - hScrollBar.setValue(0) # scroll to the left - - def clear(self, logType): - self.textBrowser(logType).clear() - - def syncSettings(self): - for index in range(self.tabWidget.count()): - self.tabWidget.widget(index).syncSettings() diff --git a/Furious/Widget/SystemTrayIcon.py b/Furious/Widget/SystemTrayIcon.py index db87387..bf47093 100644 --- a/Furious/Widget/SystemTrayIcon.py +++ b/Furious/Widget/SystemTrayIcon.py @@ -1,4 +1,4 @@ -# Copyright (C) 2023 Loren Eteval +# Copyright (C) 2024 Loren Eteval # # This file is part of Furious. # @@ -15,33 +15,11 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from Furious.Action import ( - ConnectAction, - RoutingAction, - ImportAction, - EditConfigurationAction, - LanguageAction, - SettingsAction, - ExitAction, -) -from Furious.Gui.Action import Action, Seperator -from Furious.Widget.Widget import Menu -from Furious.Utility.Constants import ( - APP, - APPLICATION_NAME, - APPLICATION_VERSION, - PLATFORM, -) -from Furious.Utility.Utility import ( - bootstrapIcon, - StateContext, - Switch, - isAdministrator, - isWindows7, -) -from Furious.Utility.Translator import Translatable, gettext as _ -from Furious.Utility.Proxy import Proxy -from Furious.Utility.StartupOnBoot import StartupOnBoot +from Furious.PyFramework import * +from Furious.QtFramework import * +from Furious.QtFramework import gettext as _ +from Furious.Utility import * +from Furious.TrayActions import * from PySide6 import QtCore from PySide6.QtWidgets import QSystemTrayIcon @@ -50,49 +28,49 @@ import logging logger = logging.getLogger(__name__) +__all__ = ['SystemTrayIcon'] -class SystemTrayIcon(Translatable, QSystemTrayIcon): + +class SystemTrayIcon(QTranslatable, SupportConnectedCallback, QSystemTrayIcon): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setPlainIcon() - self.activated.connect(self.handleActivated) - actions = [ ConnectAction(), RoutingAction(), - Seperator(), + SystemProxyAction(), + AppQSeperator(), ImportAction(), EditConfigurationAction(), - Seperator(), + AppQSeperator(), LanguageAction(), SettingsAction(), - Seperator(), + AppQSeperator(), ExitAction(), ] # Some old version PySide6 does not have setMenu method # for QAction. Protect it. Currently only used in SystemTrayIcon - if hasattr(Action, 'setMenu'): + if hasattr(AppQAction, 'setMenu'): logger.info('contextMenu uses setMenu implementation') for action in actions: - if isinstance(action, Action): + if isinstance(action, AppQAction): if hasattr(self, f'{action}'): logger.warning(f'{self} already has action {action}') setattr(self, f'{action}', action) - self._menu = Menu(*actions) + self._menu = AppQMenu(*actions) self.setContextMenu(self._menu) else: logger.info('contextMenu uses addMenu implementation') self._refs = [] - self._menu = Menu() + self._menu = AppQMenu() for action in actions: - if isinstance(action, Action): + if isinstance(action, AppQAction): if hasattr(self, f'{action}'): logger.warning(f'{self} already has action {action}') @@ -101,7 +79,7 @@ class SystemTrayIcon(Translatable, QSystemTrayIcon): if action._menu is None: self._menu.addAction(action) else: - menu = Menu(*action._menu._actions, title=action.text()) + menu = AppQMenu(*action._menu._actions, title=action.text()) menu.setIcon(action.icon()) self._refs.append(menu) @@ -111,20 +89,22 @@ class SystemTrayIcon(Translatable, QSystemTrayIcon): self.setContextMenu(self._menu) + self.setDisconnectedIcon() + self.activated.connect(self.handleActivated) + def bootstrap(self): - if APP().StartupOnBoot == Switch.ON_: + if AppSettings.isStateON_('StartupOnBoot'): # Rrefresh startup application location StartupOnBoot.on_() - if APP().Connect == Switch.ON_: - # Trigger connect action + if AppSettings.isStateON_('Connect'): self.ConnectAction.trigger() - def showMessage(self, msg, *args, **kwargs): - if msg: - super().showMessage(_(APPLICATION_NAME), msg, *args, **kwargs) + def showMessage(self, message: str, *args, **kwargs): + if message: + super().showMessage(_(APPLICATION_NAME), message, *args, **kwargs) - def setPlainIcon(self): + def setDisconnectedIcon(self): if PLATFORM == 'Darwin' or isWindows7(): # Darker self.setIcon(bootstrapIcon('rocket-takeoff-dark.svg')) @@ -144,11 +124,17 @@ class SystemTrayIcon(Translatable, QSystemTrayIcon): @QtCore.Slot(QSystemTrayIcon.ActivationReason) def handleActivated(self, reason): if reason == QSystemTrayIcon.ActivationReason.DoubleClick: - APP().ServerWidget.show() + APP().mainWindow.show() - def setApplicationToolTip(self): + def setCustomToolTip(self): self.setToolTip(f'{_(APPLICATION_NAME)} {APPLICATION_VERSION}') + def disconnectedCallback(self): + self.setDisconnectedIcon() + + def connectedCallback(self): + self.setConnectedIcon() + def retranslate(self): # Nothing to do pass diff --git a/Furious/Widget/TorRelaySettings.py b/Furious/Widget/TorRelaySettings.py deleted file mode 100644 index 61fb1ac..0000000 --- a/Furious/Widget/TorRelaySettings.py +++ /dev/null @@ -1,223 +0,0 @@ -# Copyright (C) 2023 Loren Eteval -# -# This file is part of Furious. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -from Furious.Widget.Widget import Dialog, GroupBox, Label -from Furious.Utility.Constants import ( - APP, - DEFAULT_TOR_SOCKS_PORT, - DEFAULT_TOR_HTTPS_PORT, - DEFAULT_TOR_RELAY_ESTABLISH_TIMEOUT, -) -from Furious.Utility.Utility import ( - StateContext, - SupportConnectedCallback, - TorRelaySettingsStorage, - bootstrapIcon, - moveToCenter, -) -from Furious.Utility.Translator import Translatable, gettext as _ - -from PySide6 import QtCore -from PySide6.QtWidgets import ( - QCheckBox, - QComboBox, - QDialogButtonBox, - QFormLayout, - QGridLayout, - QSpinBox, - QWidget, -) - - -class TorRelaySettingsWidget(Dialog): - TOR_LOG_LEVEL = ['err', 'warn', 'notice', 'info', 'debug'] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.setWindowTitle(_('Tor Relay Settings')) - self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg')) - - try: - self.StorageObj = TorRelaySettingsStorage.toObject(APP().TorRelaySettings) - except Exception: - # Any non-exit exceptions - - self.StorageObj = TorRelaySettingsStorage.init() - - # Clear it - TorRelaySettingsStorage.clear() - - self.endpointGroupBox = GroupBox(_('Tunnel Port'), parent=self) - self.useProxyGroupBox = GroupBox(_('Proxy'), parent=self) - self.torOtherGroupBox = GroupBox(_('Other'), parent=self) - - # Begin endpoint groupbox - self.socksTunnelLabel = Label(_('socks'), parent=self) - self.httpsTunnelLabel = Label(_('http'), parent=self) - - self.socksTunnelSpinBox = QSpinBox(parent=self) - self.socksTunnelSpinBox.setMinimum(0) - self.socksTunnelSpinBox.setMaximum(65535) - - self.httpsTunnelSpinBox = QSpinBox(parent=self) - self.httpsTunnelSpinBox.setMinimum(0) - self.httpsTunnelSpinBox.setMaximum(65535) - - self.socksTunnelWidget = QWidget(parent=self) - self.socksTunnelLayout = QFormLayout(parent=self.socksTunnelWidget) - self.socksTunnelLayout.addRow(self.socksTunnelLabel, self.socksTunnelSpinBox) - self.socksTunnelWidget.setLayout(self.socksTunnelLayout) - - self.httpsTunnelWidget = QWidget(parent=self) - self.httpsTunnelLayout = QFormLayout(parent=self.httpsTunnelWidget) - self.httpsTunnelLayout.addRow(self.httpsTunnelLabel, self.httpsTunnelSpinBox) - self.httpsTunnelWidget.setLayout(self.httpsTunnelLayout) - - self.endpointGroupBoxLayout = QGridLayout(parent=self.endpointGroupBox) - self.endpointGroupBoxLayout.addWidget(self.socksTunnelWidget, 0, 0) - self.endpointGroupBoxLayout.addWidget(self.httpsTunnelWidget, 0, 1) - - self.endpointGroupBox.setLayout(self.endpointGroupBoxLayout) - # End endpoint groupbox - - # Begin useProxy groupbox - self.useProxyLabel = Label(_('Use Proxy'), parent=self) - - self.useProxyCheckBox = QCheckBox(parent=self) - - self.useProxyWidget = QWidget(parent=self) - self.useProxyLayout = QFormLayout(parent=self.useProxyWidget) - self.useProxyLayout.addRow(self.useProxyLabel, self.useProxyCheckBox) - self.useProxyWidget.setLayout(self.useProxyLayout) - - self.useProxyGroupBoxLayout = QGridLayout(parent=self.useProxyGroupBox) - self.useProxyGroupBoxLayout.addWidget(self.useProxyWidget, 0, 0) - - self.useProxyGroupBox.setLayout(self.useProxyGroupBoxLayout) - # End useProxy groupbox - - # Begin torOther groupbox - self.torLogLevelLabel = Label(_('Log Level'), parent=self) - - self.torLogLevelComboBox = QComboBox(parent=self) - self.torLogLevelComboBox.addItems(TorRelaySettingsWidget.TOR_LOG_LEVEL) - - self.torRelayTimeoutLabel = Label( - _('Relay Establish Timeout (seconds)'), parent=self - ) - - self.torRelayTimeoutSpinBox = QSpinBox(parent=self) - self.torRelayTimeoutSpinBox.setMinimum(0) - self.torRelayTimeoutSpinBox.setMaximum(3600) - - self.torLogLevelWidget = QWidget(parent=self) - self.torLogLevelLayout = QFormLayout(parent=self.torLogLevelWidget) - self.torLogLevelLayout.addRow(self.torLogLevelLabel, self.torLogLevelComboBox) - self.torLogLevelWidget.setLayout(self.torLogLevelLayout) - - self.torRelayTimeoutWidget = QWidget(parent=self) - self.torRelayTimeoutLayout = QFormLayout(parent=self.torRelayTimeoutWidget) - self.torRelayTimeoutLayout.addRow( - self.torRelayTimeoutLabel, self.torRelayTimeoutSpinBox - ) - self.torRelayTimeoutWidget.setLayout(self.torRelayTimeoutLayout) - - self.torOtherGroupBoxLayout = QGridLayout(parent=self.torOtherGroupBox) - self.torOtherGroupBoxLayout.addWidget(self.torLogLevelWidget, 0, 0) - self.torOtherGroupBoxLayout.addWidget(self.torRelayTimeoutWidget, 0, 1) - - self.torOtherGroupBox.setLayout(self.torOtherGroupBoxLayout) - # End torOther groupbox - - # Restore value - self.restoreValueFromObject() - - # Dialog buttons - self.dialogBtns = QDialogButtonBox( - QtCore.Qt.Orientation.Horizontal, parent=self - ) - - self.dialogBtns.addButton(_('OK'), QDialogButtonBox.ButtonRole.AcceptRole) - self.dialogBtns.addButton(_('Cancel'), QDialogButtonBox.ButtonRole.RejectRole) - self.dialogBtns.accepted.connect(self.handleAccepted) - self.dialogBtns.rejected.connect(self.handleRejected) - - # Central Widget - self.fakeCentralWidget = QWidget(parent=self) - - self.layout = QGridLayout(parent=self.fakeCentralWidget) - self.layout.addWidget(self.endpointGroupBox, 0, 0) - self.layout.addWidget(self.useProxyGroupBox, 1, 0) - self.layout.addWidget(self.torOtherGroupBox, 2, 0) - self.layout.addWidget(self.dialogBtns, 3, 0) - - self.setLayout(self.layout) - - @QtCore.Slot() - def handleAccepted(self): - # Sync object - self.StorageObj['socksTunnelPort'] = self.socksTunnelSpinBox.value() - self.StorageObj['httpsTunnelPort'] = self.httpsTunnelSpinBox.value() - self.StorageObj['useProxy'] = self.useProxyCheckBox.isChecked() - self.StorageObj['logLevel'] = self.torLogLevelComboBox.currentText() - self.StorageObj['relayEstablishTimeout'] = self.torRelayTimeoutSpinBox.value() - - # Sync it - TorRelaySettingsStorage.sync() - - self.hide() - - @QtCore.Slot() - def handleRejected(self): - self.restoreValueFromObject() - self.hide() - - def restoreValueFromObject(self): - # Restore value - self.socksTunnelSpinBox.setValue( - self.StorageObj.get('socksTunnelPort', DEFAULT_TOR_SOCKS_PORT) - ) - self.httpsTunnelSpinBox.setValue( - self.StorageObj.get('httpsTunnelPort', DEFAULT_TOR_HTTPS_PORT) - ) - - self.useProxyCheckBox.setChecked(self.StorageObj.get('useProxy', True)) - - self.torLogLevelComboBox.setCurrentText( - self.StorageObj.get('logLevel', 'notice') - ) - self.torRelayTimeoutSpinBox.setValue( - self.StorageObj.get( - 'relayEstablishTimeout', DEFAULT_TOR_RELAY_ESTABLISH_TIMEOUT - ) - ) - - def closeEvent(self, event): - event.ignore() - - self.restoreValueFromObject() - self.hide() - - def retranslate(self): - with StateContext(self): - self.setWindowTitle(_(self.windowTitle())) - - for button in self.dialogBtns.buttons(): - button.setText(_(button.text())) - - moveToCenter(self) diff --git a/Furious/Widget/UserServersQTableWidget.py b/Furious/Widget/UserServersQTableWidget.py new file mode 100644 index 0000000..4335de4 --- /dev/null +++ b/Furious/Widget/UserServersQTableWidget.py @@ -0,0 +1,1225 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from __future__ import annotations + +from Furious.Interface import * +from Furious.PyFramework import * +from Furious.QtFramework import * +from Furious.QtFramework import gettext as _ +from Furious.Utility import * +from Furious.Library import * +from Furious.Core import * +from Furious.TrayActions.Import import * +from Furious.Window.QRCodeWindow import * +from Furious.Window.TextEditorWindow import * + +from PySide6 import QtCore +from PySide6.QtGui import * +from PySide6.QtWidgets import * +from PySide6.QtNetwork import * + +from typing import Callable, Union, Sequence, MutableSequence + +import ping3 +import queue +import logging +import functools + +__all__ = ['UserServersQTableWidget'] + +logger = logging.getLogger(__name__) + +registerAppSettings('ActivatedItemIndex') +registerAppSettings('ServerWidgetSectionSizeTable') + +needTrans = functools.partial(needTransFn, source=__name__) + + +class SubscriptionManager(AppQNetworkAccessManager): + def __init__(self, parent): + super().__init__(parent) + + self.networkReplyTable = {} + + def handleFinishedByNetworkReply(self, networkReply): + assert isinstance(networkReply, QNetworkReply) + + unique, webURL = ( + self.networkReplyTable[networkReply]['unique'], + self.networkReplyTable[networkReply]['webURL'], + ) + + if networkReply.error() != QNetworkReply.NetworkError.NoError: + logger.error(f'update subs {webURL} failed. {networkReply.errorString()}') + else: + logger.info(f'update subs {webURL} success') + + try: + data = networkReply.readAll().data() + + uris = list( + filter( + lambda x: x != '', + PyBase64Encoder.decode(data).decode().split('\n'), + ) + ) + except Exception as ex: + # Any non-exit exceptions + + logger.error(f'parse share link failed: {ex}') + else: + parent = self.parent() + + if isinstance(parent, UserServersQTableWidget): + parent.deleteItemByIndex( + list( + index + for index, server in enumerate(AS_UserServers()) + if server.getExtras('subsId') == unique + ), + ) + + for uri in uris: + parent.appendNewItem(config=uri, subsId=unique) + + # Done. Remove entry. Key should be found, but protect it anyway + self.networkReplyTable.pop(networkReply, None) + + def configureHttpProxy(self, httpProxy: Union[str, None]) -> bool: + useProxy = super().configureHttpProxy(httpProxy) + + if useProxy: + logger.info(f'update subs uses proxy server {httpProxy}') + else: + logger.info(f'update subs uses no proxy') + + return useProxy + + def updateSubs(self, unique: str, webURL: str): + networkReply = self.get(QNetworkRequest(QtCore.QUrl(webURL))) + + self.networkReplyTable[networkReply] = { + 'unique': unique, + 'webURL': webURL, + } + + networkReply.finished.connect( + functools.partial( + self.handleFinishedByNetworkReply, + networkReply, + ) + ) + + +class TestPingLatencyWorker(ItemUpdateProtocol, QtCore.QObject, QtCore.QRunnable): + finished = QtCore.Signal(int, object) + + def __init__(self, sequence: Sequence, index: int, item: ConfigurationFactory): + super().__init__(sequence, index, item) + + # Explictly called __init__ + QtCore.QObject.__init__(self) + QtCore.QRunnable.__init__(self) + + def currentItemDeleted(self) -> bool: + return super().currentItemDeleted() or FastItemDeletionSearch.isInTrash( + self.currentItem + ) + + def updateImpl(self): + self.finished.emit(self.currentIndex, self.currentItem) + + def updateResult(self): + # Extra guard + if APP() is None or APP().isExiting(): + return + + super().updateResult() + + def run(self): + if self.currentItemDeleted(): + # Deleted. Do nothing + return + + assert isinstance(self.currentItem, ConfigurationFactory) + + try: + result = ping3.ping(self.currentItem.itemAddress, timeout=2, unit='ms') + except Exception: + # Any non-exit exceptions + + self.currentItem.setExtras('delayResult', 'Timeout') + self.updateResult() + else: + if result is False: + self.currentItem.setExtras('delayResult', 'Error') + elif result is None: + self.currentItem.setExtras('delayResult', 'Timeout') + else: + self.currentItem.setExtras('delayResult', f'{round(result)}ms') + + self.updateResult() + + +class TestDownloadSpeedWorker(ItemUpdateProtocol, QtCore.QObject): + progressed = QtCore.Signal(int, object) + + def __init__(self, sequence: Sequence, index: int, item: ConfigurationFactory): + super().__init__(sequence, index, item) + + # Explictly called __init__ + QtCore.QObject.__init__(self) + + self.hasSpeedResult = False + self.totalBytesRead = 0 + + self.coreManager = CoreManager() + + self.networkAccessManager = QNetworkAccessManager() + self.networkReply = None + self.elapsedTimer = QtCore.QElapsedTimer() + + def isFinished(self) -> bool: + if isinstance(self.networkReply, QNetworkReply): + return self.networkReply.isFinished() + else: + return True + + def abort(self): + if isinstance(self.networkReply, QNetworkReply): + self.networkReply.abort() + + def currentItemDeleted(self) -> bool: + return super().currentItemDeleted() or FastItemDeletionSearch.isInTrash( + self.currentItem + ) + + def updateImpl(self): + self.progressed.emit(self.currentIndex, self.currentItem) + + def updateResult(self): + # Extra guard + if APP() is None or APP().isExiting(): + return + + super().updateResult() + + def run(self): + if self.currentItemDeleted(): + # Deleted. Do nothing + return + + assert isinstance(self.currentItem, ConfigurationFactory) + + if not self.currentItem.isValid(): + self.currentItem.setExtras('speedResult', 'Invalid') + self.updateResult() + + return + + def coreExitCallback(config: ConfigurationFactory, exitcode: int): + if exitcode == CoreProcess.ExitCode.ConfigurationError: + self.currentItem.setExtras('speedResult', 'Invalid') + + return self.updateResult() + if exitcode == CoreProcess.ExitCode.ServerStartFailure: + self.currentItem.setExtras('speedResult', 'Start failed') + + return self.updateResult() + if exitcode == CoreProcess.ExitCode.SystemShuttingDown: + pass + else: + self.currentItem.setExtras('speedResult', f'Core exited {exitcode}') + + return self.updateResult() + + def msgCallback(line: str): + try: + APP().logViewerWindowCore.appendLine(line) + except Exception: + # Any non-exit exceptions + + pass + + self.currentItem.setExtras('speedResult', 'Starting') + self.updateResult() + + copy = self.currentItem.deepcopy() + + if isinstance(copy, ConfigurationXray): + # Force redirect + copy['inbounds'] = [ + { + 'tag': 'http', + 'port': 20809, + 'listen': '127.0.0.1', + 'protocol': 'http', + 'sniffing': { + 'enabled': True, + 'destOverride': [ + 'http', + 'tls', + ], + }, + 'settings': { + 'auth': 'noauth', + 'udp': True, + 'allowTransparent': False, + }, + }, + ] + + try: + for outboundObject in copy['outbounds']: + if outboundObject['tag'] == 'proxy': + # Avoid confusion with potentially existing 'proxy' tag + outboundObject['tag'] = 'proxy20809' + except Exception: + # Any non-exit exceptions + + pass + + self.coreManager.start( + copy, + 'Global', + coreExitCallback, + msgCallback=msgCallback, + deepcopy=False, + proxyModeOnly=True, + log=False, + ) + elif isinstance(copy, ConfigurationHysteria1) or isinstance( + copy, ConfigurationHysteria2 + ): + # Force redirect + copy['http'] = { + 'listen': '127.0.0.1:20809', + 'timeout': 300, + 'disable_udp': False, + } + + # No socks inbounds + copy.pop('socks5', '') + + self.coreManager.start( + copy, + 'Global', + coreExitCallback, + msgCallback=msgCallback, + deepcopy=False, + proxyModeOnly=True, + log=False, + ) + else: + self.currentItem.setExtras('speedResult', 'Invalid') + self.updateResult() + + return + + self.networkAccessManager.setProxy( + QNetworkProxy(QNetworkProxy.ProxyType.HttpProxy, '127.0.0.1', 20809) + ) + self.networkReply = self.networkAccessManager.get( + QNetworkRequest( + QtCore.QUrl( + 'http://speed.cloudflare.com/__down?during=download&bytes=104857600' + ) + ) + ) + self.networkReply.readyRead.connect(self.handleReadyRead) + self.networkReply.finished.connect(self.handleFinished) + self.elapsedTimer.start() + + @QtCore.Slot() + def handleReadyRead(self): + if self.coreManager.allRunning(): + self.totalBytesRead += self.networkReply.readAll().length() + + # Convert to seconds + elapsedSecond = self.elapsedTimer.elapsed() / 1000 + downloadSpeed = self.totalBytesRead / elapsedSecond / 1024 / 1024 + + # Has speed test result + self.hasSpeedResult = True + self.currentItem.setExtras('speedResult', f'{downloadSpeed:.2f} M/s') + self.updateResult() + + @QtCore.Slot() + def handleFinished(self): + if self.networkReply.error() != QNetworkReply.NetworkError.NoError: + if not self.hasSpeedResult: + if not self.coreManager.allRunning(): + # Core ExitCallback has been called + return + + if ( + self.networkReply.error() + == QNetworkReply.NetworkError.OperationCanceledError + ): + # Canceled by application + self.currentItem.setExtras('speedResult', 'Canceled') + else: + try: + error = self.networkReply.error().name + except Exception: + # Any non-exit exceptions + + error = 'Unknown Error' + + if isinstance(error, bytes): + # Some old version PySide6 returns it as bytes. Protect it. + errorString = error.decode('utf-8', 'replace') + elif isinstance(error, str): + errorString = error + else: + errorString = 'Unknown Error' + + if errorString.endswith('Error'): + self.currentItem.setExtras('speedResult', errorString[:-5]) + else: + self.currentItem.setExtras('speedResult', errorString) + else: + if self.coreManager.allRunning(): + self.totalBytesRead += self.networkReply.readAll().length() + + # Convert to seconds + elapsedSecond = self.elapsedTimer.elapsed() / 1000 + downloadSpeed = self.totalBytesRead / elapsedSecond / 1024 / 1024 + + self.currentItem.setExtras('speedResult', f'{downloadSpeed:.2f} M/s') + else: + self.currentItem.setExtras('speedResult', 'Start failed') + + self.coreManager.stopAll() + self.updateResult() + + +class UserServersQTableWidgetHorizontalHeader(AppQHeaderView): + class SortOrder: + Ascending_ = False + Descending = True + + @staticmethod + def emptyClickGuard(): + return False + + def __init__(self, *args, **kwargs): + self.clickGuardFn = kwargs.pop('clickGuardFn', self.emptyClickGuard) + self.customSortFn = kwargs.pop('customSortFn', None) + + super().__init__(QtCore.Qt.Orientation.Horizontal, *args, **kwargs) + + self.sortOrderTable = list( + self.SortOrder.Ascending_ for i in range(self.columnCount) + ) + + self.sectionClicked.connect(self.handleSectionClicked) + + def customSort(self, clickedIndex): + self.customSortFn(clickedIndex, reverse=self.sortOrderTable[clickedIndex]) + # Toggle + self.sortOrderTable[clickedIndex] = not self.sortOrderTable[clickedIndex] + + @QtCore.Slot(int) + def handleSectionClicked(self, clickedIndex: int): + if callable(self.clickGuardFn) and self.clickGuardFn(): + # Guarded. Do nothing + return + + if self.customSortFn is None: + # Sorting not supported + return + + parent = self.parent() + + if isinstance(parent, AppQTableWidget): + # Support item activation + activatedIndex = AS_UserActivatedItemIndex() + + if activatedIndex < 0: + activatedServerId = None + else: + activatedServerId = id(AS_UserServers()[activatedIndex]) + + # De-activated temporarily for sorting + parent.activateItemByIndex(activatedIndex, False) + + self.customSort(clickedIndex) + + if activatedServerId is not None: + foundActivatedItem = False + + for index, server in enumerate(AS_UserServers()): + if activatedServerId == id(server): + foundActivatedItem = True + + parent.setCurrentItem(parent.item(index, 0)) + parent.activateItemByIndex(index, True) + + break + + # Object id should be found + assert foundActivatedItem + + else: + # Not yet implemented + pass + + +class UserServersQTableWidgetVerticalHeader(AppQHeaderView): + def __init__(self, *args, **kwargs): + super().__init__(QtCore.Qt.Orientation.Vertical, *args, **kwargs) + + +class UserServersQTableWidgetHeaders: + def __init__(self, name: str, func: Callable[[ConfigurationFactory], str] = None): + self.name = name + self.func = func + + def __call__(self, item: ConfigurationFactory) -> str: + if callable(self.func): + return self.func(item) + else: + return getattr(item, f'item{self}') + + def __eq__(self, other): + return str(self) == str(other) + + def __str__(self): + return self.name + + +needTrans( + 'Remark', + 'Protocol', + 'Address', + 'Port', + 'Transport', + 'TLS', + 'Subscription', + 'Latency', + 'Speed', + 'Edit Configuration...', + 'Move Up', + 'Move Down', + 'Duplicate', + 'Delete', + 'Select All', + 'Scroll To Activated Server', + 'Test Ping Latency', + 'Test Download Speed', + 'Clear Test Results', + 'New Empty Configuration', + 'Export Share Link To Clipboard', + 'Export As QR Code', + 'Export JSON Configuration To Clipboard', + 'Connecting', + 'Connecting. Please wait', + 'Untitled', +) + + +class UserServersQTableWidget(QTranslatable, AppQTableWidget): + Headers = [ + UserServersQTableWidgetHeaders('Remark'), + UserServersQTableWidgetHeaders('Protocol'), + UserServersQTableWidgetHeaders('Address'), + UserServersQTableWidgetHeaders('Port'), + UserServersQTableWidgetHeaders('Transport'), + UserServersQTableWidgetHeaders('TLS'), + UserServersQTableWidgetHeaders('Subscription'), + UserServersQTableWidgetHeaders('Latency'), + UserServersQTableWidgetHeaders('Speed'), + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.subsManager = SubscriptionManager(parent=self) + + self.testDownloadSpeedQueue = queue.Queue() + self.testDownloadSpeedTimer = QtCore.QTimer() + self.testDownloadSpeedTimer.timeout.connect(self.handleTestDownloadSpeedJob) + self.testDownloadSpeedTimer.start(250) + + # Text Editor Window + self.textEditorWindow = TextEditorWindow() + + # Delegate + self._delegate = AppQStyledItemDelegate(parent=self) + self.setItemDelegate(self._delegate) + + # Must set before flush all + self.setColumnCount(len(self.Headers)) + + # Flush all data to table + self.flushAll() + + # Install custom header + self.setHorizontalHeader( + UserServersQTableWidgetHorizontalHeader( + parent=self, + clickGuardFn=lambda: False, + customSortFn=self.customSortFn, + sectionSizeSettingsName='ServerWidgetSectionSizeTable', + ) + ) + self.setVerticalHeader(UserServersQTableWidgetVerticalHeader(self)) + + self.horizontalHeader().setCustomSectionResizeMode() + self.horizontalHeader().restoreSectionSize() + + self.setHorizontalHeaderLabels(list(_(str(header)) for header in self.Headers)) + + # Selection + self.setSelectionColor(AppHue.disconnectedColor()) + self.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) + self.setSelectionMode(QTableWidget.SelectionMode.ExtendedSelection) + + # No drag and drop + self.setDragEnabled(False) + self.setDragDropMode(QAbstractItemView.DragDropMode.NoDragDrop) + self.setDropIndicatorShown(False) + self.setDefaultDropAction(QtCore.Qt.DropAction.IgnoreAction) + + self.editConfigActionRef = AppQAction( + _('Edit Configuration...'), + icon=bootstrapIcon('pencil-square.svg'), + callback=lambda: self.editSelectedItemConfiguration(), + shortcut=QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier.ControlModifier, + QtCore.Qt.Key.Key_E, + ), + ) + + contextMenuActions = [ + AppQAction( + _('Move Up'), + callback=lambda: self.moveUpSelectedItem(), + ), + AppQAction( + _('Move Down'), + callback=lambda: self.moveDownSelectedItem(), + ), + AppQAction( + _('Duplicate'), + callback=lambda: self.duplicateSelectedItem(), + ), + AppQAction( + _('Delete'), + callback=lambda: self.deleteSelectedItem(), + ), + AppQSeperator(), + self.editConfigActionRef, + AppQSeperator(), + AppQAction( + _('Select All'), + callback=lambda: self.selectAll(), + shortcut=QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier.ControlModifier, + QtCore.Qt.Key.Key_A, + ), + ), + AppQSeperator(), + AppQAction( + _('Scroll To Activated Server'), + callback=lambda: self.scrollToActivatedItem(), + shortcut=QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier.ControlModifier, + QtCore.Qt.Key.Key_G, + ), + ), + AppQSeperator(), + AppQAction( + _('Test Ping Latency'), + callback=lambda: self.testSelectedItemPingLatency(), + shortcut=QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier.ControlModifier, + QtCore.Qt.Key.Key_P, + ), + ), + AppQAction( + _('Test Download Speed'), + callback=lambda: self.testSelectedItemDownloadSpeed(), + shortcut=QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier.ControlModifier, + QtCore.Qt.Key.Key_T, + ), + ), + AppQAction( + _('Clear Test Results'), + callback=lambda: self.clearSelectedItemTestResult(), + shortcut=QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier.ControlModifier, + QtCore.Qt.Key.Key_R, + ), + ), + AppQSeperator(), + AppQAction( + _('New Empty Configuration'), + callback=lambda: self.newEmptyItem(), + shortcut=QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier.ControlModifier, + QtCore.Qt.Key.Key_N, + ), + ), + ImportFromFileAction(), + ImportURIFromClipboardAction( + shortcut=QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier.ControlModifier, + QtCore.Qt.Key.Key_V, + ), + ), + ImportJSONFromClipboardAction( + shortcut=QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier.ControlModifier + | QtCore.Qt.KeyboardModifier.ShiftModifier, + QtCore.Qt.Key.Key_J, + ), + ), + AppQSeperator(), + AppQAction( + _('Export Share Link To Clipboard'), + callback=lambda: self.exportSelectedItemURI(), + shortcut=QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier.ControlModifier, + QtCore.Qt.Key.Key_C, + ), + ), + AppQAction( + _('Export As QR Code'), + icon=bootstrapIcon('qr-code.svg'), + callback=lambda: self.exportSelectedItemQR(), + ), + AppQAction( + _('Export JSON Configuration To Clipboard'), + callback=lambda: self.exportSelectedItemJSON(), + shortcut=QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier.ControlModifier, + QtCore.Qt.Key.Key_J, + ), + ), + ] + + self.contextMenu = AppQMenu(*contextMenuActions) + + # Add actions to self in order to activate shortcuts + self.addActions(self.contextMenu.actions()) + self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) + self.customContextMenuRequested.connect(self.handleCustomContextMenuRequested) + + # Signals + self.itemChanged.connect(self.handleItemChanged) + self.itemSelectionChanged.connect(self.handleItemSelectionChanged) + self.itemActivated.connect(self.handleItemActivated) + self.itemDoubleClicked.connect(self.handleItemDoubleClicked) + + if self.activatedItem() is not None: + self.setCurrentItem(self.activatedItem()) + self.activateItemByIndex(AS_UserActivatedItemIndex(), True) + + @QtCore.Slot(QTableWidgetItem) + def handleItemChanged(self, item: QTableWidgetItem): + index = item.row() + + if self.item(index, 0) is None: + return + + itemText = self.item(index, 0).text() + + if AS_UserServers()[index].getExtras('remark') != itemText: + AS_UserServers()[index].setExtras('remark', itemText) + + @QtCore.Slot() + def handleItemSelectionChanged(self): + if len(self.selectedIndex) > 1: + self.editConfigActionRef.setDisabled(True) + else: + self.editConfigActionRef.setDisabled(False) + + @QtCore.Slot(QTableWidgetItem) + def handleItemActivated(self, item: QTableWidgetItem): + oldIndex = AS_UserActivatedItemIndex() + newIndex = item.row() + + if oldIndex == newIndex: + # Same item activated. Do nothing + return + + if APP().systemTray.ConnectAction.isConnecting(): + mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Information) + mbox.setWindowTitle(_('Connecting')) + mbox.setText(_('Connecting. Please wait')) + + if PLATFORM != 'Darwin': + # Show the MessageBox asynchronously + mbox.open() + else: + # Show the MessageBox and wait for user to close it + mbox.exec() + + return + + if oldIndex >= 0: + self.activateItemByIndex(oldIndex, False) + + self.activateItemByIndex(newIndex, True) + + if APP().isSystemTrayConnected(): + APP().systemTray.ConnectAction.doDisconnect() + APP().systemTray.ConnectAction.trigger() + + @QtCore.Slot(QTableWidgetItem) + def handleItemDoubleClicked(self, item: QTableWidgetItem): + pass + + @QtCore.Slot(QtCore.QPoint) + def handleCustomContextMenuRequested(self, point): + self.contextMenu.exec(self.mapToGlobal(point)) + + def customSortFn(self, clickedIndex, **kwargs): + AS_UserServers().sort( + key=lambda server: self.Headers[clickedIndex](server), **kwargs + ) + + self.flushAll() + + def activatedItem(self) -> QTableWidgetItem: + return self.item(AS_UserActivatedItemIndex(), 0) + + def activateItemByIndex(self, index, activate): + super().activateItemByIndex(index, activate) + + if activate: + AppSettings.set('ActivatedItemIndex', str(index)) + + def flushItem(self, row: int, column: int, item: ConfigurationFactory): + header = self.Headers[column] + + oldItem = self.item(row, column) + newItem = QTableWidgetItem(header(item)) + + if oldItem is None: + # Item does not exists + newItem.setFont(QFont(APP().customFontName)) + + if str(header) == 'Latency' or str(header) == 'Speed': + # Test results. Align right and vcenter + newItem.setTextAlignment( + QtCore.Qt.AlignmentFlag.AlignRight + | QtCore.Qt.AlignmentFlag.AlignVCenter + ) + else: + # Use existing + newItem.setFont(oldItem.font()) + newItem.setForeground(oldItem.foreground()) + + if oldItem.textAlignment() != 0: + newItem.setTextAlignment(oldItem.textAlignment()) + + if str(header) == 'Remark': + # Remark is editable + newItem.setFlags( + QtCore.Qt.ItemFlag.ItemIsEnabled + | QtCore.Qt.ItemFlag.ItemIsSelectable + | QtCore.Qt.ItemFlag.ItemIsEditable + ) + else: + newItem.setFlags( + QtCore.Qt.ItemFlag.ItemIsEnabled | QtCore.Qt.ItemFlag.ItemIsSelectable + ) + + self.setItem(row, column, newItem) + + def flushRow(self, row: int, item: ConfigurationFactory): + for column in list(range(self.columnCount())): + self.flushItem(row, column, item) + + def flushAll(self): + if self.rowCount() == 0: + # Should insert row + for index, item in enumerate(AS_UserServers()): + self.insertRow(index) + self.flushRow(index, item) + else: + for index, item in enumerate(AS_UserServers()): + self.flushRow(index, item) + + def swapItem(self, index0: int, index1: int): + def swapSequenceItem(sequence: MutableSequence, param0: int, param1: int): + swap = sequence[param0] + + sequence[param0] = sequence[param1] + sequence[param1] = swap + + swapSequenceItem(AS_UserServers(), index0, index1) + + self.flushRow(index0, AS_UserServers()[index0]) + self.flushRow(index1, AS_UserServers()[index1]) + + if index0 == AS_UserActivatedItemIndex(): + # De-activate + self.activateItemByIndex(index0, False) + # Activate + self.activateItemByIndex(index1, True) + elif index1 == AS_UserActivatedItemIndex(): + # De-activate + self.activateItemByIndex(index1, False) + # Activate + self.activateItemByIndex(index0, True) + + def newEmptyItem(self): + self.appendNewItem(remark=_('Untitled'), acceptInvalid=True) + + def moveUpItemByIndex(self, index): + if index <= 0 or index >= len(AS_UserServers()): + # The top item, or does not exist. Do nothing + return + + self.swapItem(index, index - 1) + + def moveUpSelectedItem(self): + indexes = self.selectedIndex + + if len(indexes) == 0: + # Nothing selected. Do nothing + return + + for index in indexes: + self.moveUpItemByIndex(index) + + with QBlockSignals(self): + self.setCurrentIndex(self.indexFromItem(self.item(indexes[-1] - 1, 0))) + + self.selectMultipleRows(list(index - 1 for index in indexes), True) + + def moveDownItemByIndex(self, index): + if index < 0 or index >= len(AS_UserServers()) - 1: + # The bottom item, or does not exist. Do nothing + return + + self.swapItem(index, index + 1) + + def moveDownSelectedItem(self): + indexes = self.selectedIndex + + if len(indexes) == 0: + # Nothing selected. Do nothing + return + + for index in indexes[::-1]: + self.moveDownItemByIndex(index) + + with QBlockSignals(self): + self.setCurrentIndex(self.indexFromItem(self.item(indexes[0] + 1, 0))) + + self.selectMultipleRows(list(index + 1 for index in indexes), True) + + def duplicateSelectedItem(self): + indexes = self.selectedIndex + + if len(indexes) == 0: + # Nothing selected. Do nothing + return + + for index in indexes: + if 0 <= index < len(AS_UserServers()): + # Do not clone subsId + self.appendNewItem( + remark=AS_UserServers()[index].getExtras('remark'), + config=AS_UserServers()[index], + ) + + def deleteItemByIndex(self, indexes) -> int: + if len(indexes) == 0: + # Nothing selected. Do nothing + return 0 + + if AS_UserActivatedItemIndex() in indexes: + deleteActivated = True + else: + deleteActivated = False + + for i in range(len(indexes)): + deleteIndex = indexes[i] - i + + with QBlockSignals(self): + self.removeRow(deleteIndex) + + FastItemDeletionSearch.moveToTrash(AS_UserServers()[deleteIndex]) + + AS_UserServers().pop(deleteIndex) + + if not deleteActivated and deleteIndex < AS_UserActivatedItemIndex(): + AppSettings.set( + 'ActivatedItemIndex', str(AS_UserActivatedItemIndex() - 1) + ) + + if deleteActivated: + # Set invalid first + AppSettings.set('ActivatedItemIndex', str(-1)) + + if APP().isSystemTrayConnected(): + # Trigger disconnect + APP().systemTray.ConnectAction.trigger() + + return len(indexes) + + def deleteSelectedItem(self): + indexes = self.selectedIndex + + if len(indexes) == 0: + # Nothing selected. Do nothing + return + + mbox = QuestionDeleteMBox(icon=AppQMessageBox.Icon.Question) + mbox.isMulti = bool(len(indexes) > 1) + mbox.possibleRemark = f'{indexes[0] + 1} - {self.item(indexes[0], 0).text()}' + mbox.setText(mbox.customText()) + + if mbox.exec() == PySide6LegacyEnumValueWrapper( + AppQMessageBox.StandardButton.No + ): + # Do not delete + return + + self.deleteItemByIndex(indexes) + + def editSelectedItemConfiguration(self): + indexes = self.selectedIndex + + if len(indexes) == 0: + # Nothing selected. Do nothing + return + + if len(indexes) != 1: + # Should not reach here + return + + index = indexes[0] + title = f'{index + 1} - ' + AS_UserServers()[index].getExtras('remark') + + self.textEditorWindow.currentIndex = index + self.textEditorWindow.customWindowTitle = title + self.textEditorWindow.setWindowTitle(title) + self.textEditorWindow.setPlainText(AS_UserServers()[index].toJSONString(), True) + self.textEditorWindow.show() + + def scrollToActivatedItem(self): + activatedItem = self.activatedItem() + + self.setCurrentItem(activatedItem) + self.scrollToItem(activatedItem) + + def testSelectedItemPingLatency(self): + indexes = self.selectedIndex + + if len(indexes) == 0: + # Nothing selected. Do nothing + return + + # Real selected factory + references = list(AS_UserServers()[index] for index in indexes) + + for index, reference in zip(indexes, references): + worker = TestPingLatencyWorker(AS_UserServers(), index, reference) + + worker.setAutoDelete(True) + worker.finished.connect( + lambda paramIndex, paramFactory: self.flushItem( + paramIndex, self.Headers.index('Latency'), paramFactory + ) + ) + + QtCore.QThreadPool.globalInstance().start(worker) + + @QtCore.Slot() + def handleTestDownloadSpeedJob(self): + try: + index, server = self.testDownloadSpeedQueue.get_nowait() + except queue.Empty: + # Queue is empty + + return + + def testDownloadSpeed(counter=0, timeout=5000, step=100): + worker = TestDownloadSpeedWorker(AS_UserServers(), index, server) + worker.progressed.connect( + lambda paramIndex, paramFactory: self.flushItem( + paramIndex, self.Headers.index('Speed'), paramFactory + ) + ) + worker.run() + + while ( + not APP().isExiting() and not worker.isFinished() and counter < timeout + ): + PySide6LegacyEventLoopWait(step) + + counter += step + + if not worker.isFinished(): + worker.abort() + + while not worker.isFinished(): + PySide6LegacyEventLoopWait(step) + + if APP().isExiting(): + # Stop timer + self.testDownloadSpeedTimer.stop() + + testDownloadSpeed() + + def testSelectedItemDownloadSpeed(self): + indexes = self.selectedIndex + + if len(indexes) == 0: + # Nothing selected. Do nothing + return + + # Real selected factory + references = list(AS_UserServers()[index] for index in indexes) + + for index, reference in zip(indexes, references): + try: + # Served by FIFO + self.testDownloadSpeedQueue.put_nowait((index, reference)) + except Exception: + # Any non-exit exceptions + + pass + + def clearSelectedItemTestResult(self): + indexes = self.selectedIndex + + if len(indexes) == 0: + # Nothing selected. Do nothing + return + + for index in indexes: + factory = AS_UserServers()[index] + factory.setExtras('delayResult', '') + factory.setExtras('speedResult', '') + + self.flushItem(index, self.Headers.index('Latency'), factory) + self.flushItem(index, self.Headers.index('Speed'), factory) + + def updateSubs(self, httpProxy: Union[str, None]): + self.subsManager.configureHttpProxy(httpProxy) + + for key, value in AS_UserSubscription().items(): + self.subsManager.updateSubs(key, value.get('webURL', '')) + + def appendNewItemByFactory(self, factory: ConfigurationFactory): + AS_UserServers().append(factory) + + row = self.rowCount() + + self.insertRow(row) + self.flushRow(row, factory) + + if len(AS_UserServers()) == 1: + # The first one. Click it + self.setCurrentItem(self.item(0, 0)) + + # Try to be user-friendly in some extreme cases + if not APP().isSystemTrayConnected(): + # Activate automatically + self.activateItemByIndex(0, True) + + def appendNewItem(self, **kwargs): + acceptInvalid = kwargs.pop('acceptInvalid', False) + + model = { + 'remark': kwargs.pop('remark', ''), + 'config': kwargs.pop('config', ''), + 'subsId': kwargs.pop('subsId', ''), + } + tostr = f'{model}' + + factory = constructFromAny(model.pop('config', ''), **model) + + if factory.isValid(): + self.appendNewItemByFactory(factory) + else: + if acceptInvalid: + self.appendNewItemByFactory(factory) + else: + logger.error(f'invalid item: {tostr}') + + def exportSelectedItemURI(self): + indexes = self.selectedIndex + + if len(indexes) == 0: + # Nothing selected. Do nothing + return + + # TODO: MessageBox? + QApplication.clipboard().setText( + '\n'.join(list(AS_UserServers()[index].toURI() for index in indexes)) + ) + + def exportSelectedItemQR(self): + indexes = self.selectedIndex + + if len(indexes) == 0: + # Nothing selected. Do nothing + return + + window = QRCodeWindow() + window.initTabByIndex(indexes) + + if window.tabCount() > 0: + window.show() + + def exportSelectedItemJSON(self): + indexes = self.selectedIndex + + if len(indexes) == 0: + # Nothing selected. Do nothing + return + + # TODO: MessageBox? + QApplication.clipboard().setText( + '\n'.join(list(AS_UserServers()[index].toJSONString() for index in indexes)) + ) + + def showTabAndSpaces(self): + self.textEditorWindow.showTabAndSpaces() + + def hideTabAndSpaces(self): + self.textEditorWindow.hideTabAndSpaces() + + def keyPressEvent(self, event): + if event.key() == QtCore.Qt.Key.Key_Delete: + self.deleteSelectedItem() + else: + super().keyPressEvent(event) + + def disconnectedCallback(self): + super().disconnectedCallback() + + self.activateItemByIndex(AS_UserActivatedItemIndex(), True) + + def connectedCallback(self): + super().connectedCallback() + + self.activateItemByIndex(AS_UserActivatedItemIndex(), True) + + def retranslate(self): + self.setHorizontalHeaderLabels(list(_(str(header)) for header in self.Headers)) diff --git a/Furious/Widget/UserSubsQTableWidget.py b/Furious/Widget/UserSubsQTableWidget.py new file mode 100644 index 0000000..e6c4db0 --- /dev/null +++ b/Furious/Widget/UserSubsQTableWidget.py @@ -0,0 +1,236 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from __future__ import annotations + +from Furious.Interface import * +from Furious.PyFramework import * +from Furious.QtFramework import * +from Furious.QtFramework import gettext as _ +from Furious.Utility import * +from Furious.Library import * + +from PySide6 import QtCore +from PySide6.QtGui import * +from PySide6.QtWidgets import * + +from typing import Callable + +import functools + +__all__ = ['UserSubsQTableWidget'] + +registerAppSettings('SubscriptionWidgetSectionSizeTable') + +needTrans = functools.partial(needTransFn, source=__name__) + + +class UserSubsQTableWidgetHorizontalHeader(AppQHeaderView): + def __init__(self, *args, **kwargs): + super().__init__(QtCore.Qt.Orientation.Horizontal, *args, **kwargs) + + +class UserSubsQTableWidgetVerticalHeader(AppQHeaderView): + def __init__(self, *args, **kwargs): + super().__init__(QtCore.Qt.Orientation.Vertical, *args, **kwargs) + + +class UserSubsQTableWidgetHeaders: + def __init__(self, name: str, func: Callable[[dict], str] = None): + self.name = name + self.func = func + + def __call__(self, item: dict) -> str: + if callable(self.func): + return self.func(item) + else: + return '' + + def __eq__(self, other): + return str(self) == str(other) + + def __str__(self): + return self.name + + +needTrans( + 'Remark', + 'Delete', +) + + +class UserSubsQTableWidget(QTranslatable, AppQTableWidget): + Headers = [ + UserSubsQTableWidgetHeaders('Remark', lambda item: item.get('remark', '')), + UserSubsQTableWidgetHeaders('URL', lambda item: item.get('webURL', '')), + ] + + def __init__(self, *args, **kwargs): + self.deleteUniqueCallback = kwargs.pop('deleteUniqueCallback', None) + + super().__init__(*args, **kwargs) + + # Delegate + self._delegate = AppQStyledItemDelegate(parent=self) + self.setItemDelegate(self._delegate) + + # Must set before flush all + self.setColumnCount(len(self.Headers)) + + # Flush all data to table + self.flushAll() + + # Install custom header + self.setHorizontalHeader( + UserSubsQTableWidgetHorizontalHeader( + parent=self, + sectionSizeSettingsName='SubscriptionWidgetSectionSizeTable', + ) + ) + self.setVerticalHeader(UserSubsQTableWidgetVerticalHeader(self)) + + self.horizontalHeader().setCustomSectionResizeMode() + self.horizontalHeader().restoreSectionSize() + + self.setHorizontalHeaderLabels(list(_(str(header)) for header in self.Headers)) + + # Selection + self.setSelectionColor(AppHue.disconnectedColor()) + self.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) + self.setSelectionMode(QTableWidget.SelectionMode.ExtendedSelection) + + # No drag and drop + self.setDragEnabled(False) + self.setDragDropMode(QAbstractItemView.DragDropMode.NoDragDrop) + self.setDropIndicatorShown(False) + self.setDefaultDropAction(QtCore.Qt.DropAction.IgnoreAction) + + contextMenuActions = [ + AppQAction( + _('Delete'), + callback=lambda: self.deleteSelectedItem(), + ), + ] + + self.contextMenu = AppQMenu(*contextMenuActions) + self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) + + # Signals + self.itemChanged.connect(self.handleItemChanged) + self.customContextMenuRequested.connect(self.handleCustomContextMenuRequested) + + @QtCore.Slot(QTableWidgetItem) + def handleItemChanged(self, item: QTableWidgetItem): + unique = list(AS_UserSubscription().keys())[item.row()] + keyMap = ['remark', 'webURL'] + + AS_UserSubscription()[unique][keyMap[item.column()]] = item.text() + + @QtCore.Slot(QtCore.QPoint) + def handleCustomContextMenuRequested(self, point): + self.contextMenu.exec(self.mapToGlobal(point)) + + def deleteSelectedItem(self): + indexes = self.selectedIndex + + if len(indexes) == 0: + # Nothing to do + return + + mbox = QuestionDeleteMBox(icon=AppQMessageBox.Icon.Question) + mbox.isMulti = bool(len(indexes) > 1) + mbox.possibleRemark = self.item(indexes[0], 0).text() + mbox.setText(mbox.customText()) + + if mbox.exec() == PySide6LegacyEnumValueWrapper( + AppQMessageBox.StandardButton.No + ): + # Do not delete + return + + for i in range(len(indexes)): + deleteIndex = indexes[i] - i + deleteUnique = list(AS_UserSubscription().keys())[deleteIndex] + + self.removeRow(deleteIndex) + + AS_UserSubscription().pop(deleteUnique) + + if callable(self.deleteUniqueCallback): + self.deleteUniqueCallback(deleteUnique) + + def flushItem(self, row, column, item): + header = self.Headers[column] + + oldItem = self.item(row, column) + newItem = QTableWidgetItem(header(item)) + + if oldItem is None: + # Item does not exists + newItem.setFont(QFont(APP().customFontName)) + else: + # Use existing + newItem.setFont(oldItem.font()) + newItem.setForeground(oldItem.foreground()) + + if oldItem.textAlignment() != 0: + newItem.setTextAlignment(oldItem.textAlignment()) + + # Editable + newItem.setFlags( + QtCore.Qt.ItemFlag.ItemIsEnabled + | QtCore.Qt.ItemFlag.ItemIsSelectable + | QtCore.Qt.ItemFlag.ItemIsEditable + ) + + self.setItem(row, column, newItem) + + def flushRow(self, row, item): + for column in list(range(self.columnCount())): + self.flushItem(row, column, item) + + def flushAll(self): + if self.rowCount() == 0: + # Should insert row + for index, key in enumerate(AS_UserSubscription()): + self.insertRow(index) + self.flushRow(index, AS_UserSubscription()[key]) + else: + for index, key in enumerate(AS_UserSubscription()): + self.flushRow(index, AS_UserSubscription()[key]) + + def appendNewItem(self, **kwargs): + unique = kwargs.pop('unique', '') + remark = kwargs.pop('remark', '') + webURL = kwargs.pop('webURL', '') + + subs = { + unique: { + 'remark': remark, + 'webURL': webURL, + } + } + + AS_UserSubscription().update(subs) + + row = self.rowCount() + + self.insertRow(row) + self.flushRow(row, subs[unique]) + + def retranslate(self): + self.setHorizontalHeaderLabels(list(_(str(header)) for header in self.Headers)) diff --git a/Furious/Widget/Widget.py b/Furious/Widget/Widget.py deleted file mode 100644 index 64d48b7..0000000 --- a/Furious/Widget/Widget.py +++ /dev/null @@ -1,530 +0,0 @@ -# Copyright (C) 2023 Loren Eteval -# -# This file is part of Furious. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -from Furious.Gui.Action import Action, Seperator -from Furious.Utility.Constants import APP, PLATFORM, Color -from Furious.Utility.Utility import ( - StateContext, - SupportConnectedCallback, - NeedSyncSettings, - bootstrapIcon, - moveToCenter, - getConnectedColor, - getConnectedWindowIcon, -) -from Furious.Utility.Translator import Translatable, gettext as _ - -from PySide6 import QtCore -from PySide6.QtGui import QBrush, QColor, QFont, QTextCursor -from PySide6.QtWidgets import ( - QDialog, - QGroupBox, - QHeaderView, - QLabel, - QLineEdit, - QListWidget, - QMainWindow, - QMenu, - QMenuBar, - QMessageBox, - QPlainTextEdit, - QPushButton, - QStyledItemDelegate, - QTableWidget, - QTabWidget, - QTextBrowser, -) - -import functools - - -class Dialog(Translatable, SupportConnectedCallback, QDialog): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - if PLATFORM != 'Darwin': - self.setWidthAndHeight() - - def setWidthAndHeight(self): - pass - - def exec(self): - self.show() - - return super().exec() - - def open(self): - self.show() - - return super().open() - - def show(self): - super().show() - - if PLATFORM == 'Darwin': - self.setWidthAndHeight() - - moveToCenter(self) - - def connectedCallback(self): - self.setWindowIcon(getConnectedWindowIcon()) - - def disconnectedCallback(self): - self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg')) - - def retranslate(self): - pass - - -class GroupBox(Translatable, QGroupBox): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - def retranslate(self): - with StateContext(self): - self.setTitle(_(self.title())) - - -class HeaderView(SupportConnectedCallback, QHeaderView): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.setSectionsClickable(True) - - self.setStyleSheet( - f'QHeaderView::section:hover {{ background-color: {Color.LIGHT_BLUE}; }}' - ) - - def connectedCallback(self): - self.setStyleSheet( - f'QHeaderView::section:hover {{ background-color: {getConnectedColor()}; }}' - ) - - def disconnectedCallback(self): - self.setStyleSheet( - f'QHeaderView::section:hover {{ background-color: {Color.LIGHT_BLUE}; }}' - ) - - -class Label(Translatable, QLabel): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - def retranslate(self): - with StateContext(self): - self.setText(_(self.text())) - - -class ListWidget(SupportConnectedCallback, QListWidget): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.setSelectionColor(Color.LIGHT_BLUE) - - def setSelectionColor(self, color): - self.setStyleSheet( - f'QListWidget::item:selected {{' - f' background: {color};' - f'}}' - f'' - f'QListWidget::item:hover {{' - f' background: {color};' - f'}}' - ) - - @property - def selectedIndex(self): - return sorted(list(set(index.row() for index in self.selectedIndexes()))) - - def connectedCallback(self): - self.setSelectionColor(getConnectedColor()) - - def disconnectedCallback(self): - self.setSelectionColor(Color.LIGHT_BLUE) - - -class MainWindow(Translatable, SupportConnectedCallback, NeedSyncSettings, QMainWindow): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self._menuBar = MenuBar(parent=self) - self.setMenuBar(self._menuBar) - - if PLATFORM != 'Darwin': - self.setWidthAndHeight() - - def syncSettings(self): - pass - - def setWidthAndHeight(self): - pass - - def show(self): - super().show() - - if PLATFORM == 'Darwin': - APP().processEvents() - - self.setWidthAndHeight() - - moveToCenter(self) - - def closeEvent(self, event): - event.ignore() - - # Sync partial - self.syncSettings() - self.hide() - - def connectedCallback(self): - self.setWindowIcon(getConnectedWindowIcon()) - - def disconnectedCallback(self): - self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg')) - - def retranslate(self): - with StateContext(self): - self.setWindowTitle(_(self.windowTitle())) - - -class Menu(Translatable, SupportConnectedCallback, QMenu): - def __init__(self, *actions, **kwargs): - super().__init__(**kwargs) - - # In some old version PySide6, the self.actions() method - # does not return with seperators. _actions list append - # them all - self._actions = [] - - for action in actions: - if isinstance(action, Seperator): - self._actions.append(action) - self.addSeparator() - elif isinstance(action, Action): - self._actions.append(action) - self.addAction(action) - else: - # Do nothing - pass - - if APP().isConnected(): - self.setStyleSheet(self.getStyleSheet(getConnectedColor())) - else: - self.setStyleSheet(self.getStyleSheet(Color.LIGHT_BLUE)) - - @staticmethod - def getStyleSheet(color): - return ( - f'QMenu::item {{' - f' background-color: solid;' - f'}}' - f'' - f'QMenu::item:selected {{' - f' background-color: {color};' - f'}}' - ) - - def connectedCallback(self): - self.setStyleSheet(self.getStyleSheet(getConnectedColor())) - - def disconnectedCallback(self): - self.setStyleSheet(self.getStyleSheet(Color.LIGHT_BLUE)) - - def retranslate(self): - with StateContext(self): - self.setTitle(_(self.title())) - - -class MenuBar(SupportConnectedCallback, QMenuBar): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - if APP().isConnected(): - self.setSelectionColor(getConnectedColor()) - else: - self.setSelectionColor(Color.LIGHT_BLUE) - - def setSelectionColor(self, color): - self.setStyleSheet( - f'QMenuBar::item:selected {{' - f' background: {color};' - f'}}' - f'' - f'QMenuBar::item:hover {{' - f' background: {color};' - f'}}' - ) - - def connectedCallback(self): - self.setSelectionColor(getConnectedColor()) - - def disconnectedCallback(self): - self.setSelectionColor(Color.LIGHT_BLUE) - - -class MessageBox(Translatable, SupportConnectedCallback, QMessageBox): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg')) - - def moveToCenter(self): - moveToCenter(self, self.parentWidget()) - - return self - - def connectedCallback(self): - self.setWindowIcon(getConnectedWindowIcon()) - - def disconnectedCallback(self): - self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg')) - - def retranslate(self): - with StateContext(self): - self.setWindowTitle(_(self.windowTitle())) - self.setText(_(self.text())) - - try: - self.setInformativeText(_(self.informativeText())) - except KeyError: - # Any translatable informative text - pass - - for button in self.buttons(): - if button.text().count('OK') > 0: - # &OK... - pass - else: - button.setText(_(button.text())) - - self.moveToCenter() - - def exec(self): - self.show() - self.moveToCenter() - - return super().exec() - - def open(self): - self.show() - self.moveToCenter() - - return super().open() - - -class PushButton(Translatable, QPushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - def retranslate(self): - self.setText(_(self.text())) - - -class StyledItemDelegate(QStyledItemDelegate): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - def createEditor(self, parent, option, index): - editor = QLineEdit(parent) - editor.setFont(QFont(APP().customFontName)) - - return editor - - -class TableWidget(QTableWidget): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.setWordWrap(False) - - @property - def selectedIndex(self): - return sorted(list(set(index.row() for index in self.selectedIndexes()))) - - def setSelectionColor(self, color): - self.setStyleSheet(f'QTableWidget {{ selection-background-color: {color}; }}') - - def activateItemByIndex(self, index, activate=True): - if activate: - for column in range(self.columnCount()): - item = self.item(int(index), column) - - if item is None: - # Do nothing - continue - - font = item.font() - font.setBold(True) - - item.setFont(font) - - if APP().isConnected(): - item.setForeground(QColor(getConnectedColor())) - else: - item.setForeground(QColor(Color.LIGHT_BLUE)) - else: - for column in range(self.columnCount()): - item = self.item(int(index), column) - - if item is None: - # Do nothing - continue - - font = item.font() - font.setBold(False) - - item.setFont(font) - item.setForeground(QBrush()) - - -class TabWidget(Translatable, QTabWidget): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - def retranslate(self): - for index in range(self.count()): - self.setTabText(index, _(self.tabText(index))) - - -class ZoomablePlainTextEdit(QPlainTextEdit): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - @staticmethod - @functools.lru_cache(128) - def getIndent(line): - indent = '' - - for char in line: - if char.isspace(): - indent += char - else: - break - - return indent - - def getPrevAndNextChar(self, cursor): - plainText = self.toPlainText() - - cursor.movePosition(QTextCursor.MoveOperation.Left) - - try: - prevChar = plainText[cursor.position()] - except Exception: - # Any non-exit exceptions - - prevChar = '' - - # Move the cursor to the next character position - cursor.movePosition(QTextCursor.MoveOperation.Right) - - try: - nextChar = plainText[cursor.position()] - except Exception: - # Any non-exit exceptions - - nextChar = '' - - return prevChar + nextChar - - def smartIndent(self, event): - cursor = self.textCursor() - indent = self.getIndent(cursor.block().text()) - - # Do newline action - super().keyPressEvent(event) - - # Add last line indent - cursor.insertText(indent) - - self.setTextCursor(cursor) - - def smartSymbolPair(self, event, pair): - plainText = self.toPlainText() - - cursor = self.textCursor() - - if ( - cursor.position() < len(plainText) - and plainText[cursor.position()] == pair[0] - ): - # Do pair0 action - super().keyPressEvent(event) - else: - # Do pair0 action - super().keyPressEvent(event) - - # Do pair1 action - cursor.insertText(pair[1]) - # Move to middle - cursor.movePosition(QTextCursor.MoveOperation.Left) - - self.setTextCursor(cursor) - - def smartBackspace(self, event): - cursor = self.textCursor() - chPair = self.getPrevAndNextChar(cursor) - - if chPair == '""' or chPair == '{}' or chPair == '[]': - cursor.deleteChar() - cursor.deletePreviousChar() - else: - super().keyPressEvent(event) - - def keyPressEvent(self, event): - if ( - event.key() == QtCore.Qt.Key.Key_Return - or event.key() == QtCore.Qt.Key.Key_Enter - ): - self.smartIndent(event) - elif event.key() == QtCore.Qt.Key.Key_Backspace: - self.smartBackspace(event) - elif event.key() == QtCore.Qt.Key.Key_QuoteDbl: - self.smartSymbolPair(event, '""') - elif event.key() == QtCore.Qt.Key.Key_BraceLeft: - self.smartSymbolPair(event, '{}') - elif event.key() == QtCore.Qt.Key.Key_BracketLeft: - self.smartSymbolPair(event, '[]') - else: - super().keyPressEvent(event) - - def wheelEvent(self, event): - if event.modifiers() == QtCore.Qt.KeyboardModifier.ControlModifier: - delta = event.angleDelta().y() - - if delta > 0: - self.zoomIn() - if delta < 0: - self.zoomOut() - else: - super().wheelEvent(event) - - -class ZoomableTextBrowser(QTextBrowser): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - def wheelEvent(self, event): - if event.modifiers() == QtCore.Qt.KeyboardModifier.ControlModifier: - delta = event.angleDelta().y() - - if delta > 0: - self.zoomIn() - if delta < 0: - self.zoomOut() - else: - super().wheelEvent(event) diff --git a/Furious/Widget/XrayAssetViewerQListWidget.py b/Furious/Widget/XrayAssetViewerQListWidget.py new file mode 100644 index 0000000..8b4139b --- /dev/null +++ b/Furious/Widget/XrayAssetViewerQListWidget.py @@ -0,0 +1,209 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from __future__ import annotations + +from Furious.PyFramework import * +from Furious.QtFramework import * +from Furious.QtFramework import gettext as _ +from Furious.Utility import * + +from PySide6 import QtCore +from PySide6.QtWidgets import * + +import os +import shutil +import logging +import functools +import darkdetect + +__all__ = ['XrayAssetViewerQListWidget'] + +logger = logging.getLogger(__name__) + +needTrans = functools.partial(needTransFn, source=__name__) + + +class AssetExistsMBox(AppQMessageBox): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.setStandardButtons( + AppQMessageBox.StandardButton.Yes | AppQMessageBox.StandardButton.No + ) + + def retranslate(self): + self.setWindowTitle(_(self.windowTitle())) + self.setText(_(self.text())) + + # Ignore informative text, buttons + + self.moveToCenter() + + +needTrans( + 'Delete', + 'Import', + 'Asset file already exists. Overwrite?', + 'Error import asset file', + 'Import asset file success', +) + + +class XrayAssetViewerQListWidget(SupportThemeChangedCallback, AppQListWidget): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.setSelectionBehavior(AppQListWidget.SelectionBehavior.SelectRows) + self.setSelectionMode(AppQListWidget.SelectionMode.ExtendedSelection) + self.setIconSize(QtCore.QSize(64, 64)) + + if PLATFORM == 'Linux' and getUbuntuRelease() == '20.04': + self.initialTheme = darkdetect.theme() + else: + self.initialTheme = None + + self.flushItem() + + logger.info(f'Xray-core asset dir is \'{XRAY_ASSET_DIR}\'') + + contextMenuActions = [ + AppQAction( + _('Delete'), + callback=lambda: self.deleteSelectedItem(), + ), + ] + + self.contextMenu = AppQMenu(*contextMenuActions) + + # Add actions to self in order to activate shortcuts + self.addActions(self.contextMenu.actions()) + + self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) + self.customContextMenuRequested.connect(self.handleCustomContextMenuRequested) + + @QtCore.Slot(QtCore.QPoint) + def handleCustomContextMenuRequested(self, point): + self.contextMenu.exec(self.mapToGlobal(point)) + + def flushItemByTheme(self, theme: str): + self.clear() + + for filename in os.listdir(XRAY_ASSET_DIR): + if os.path.isfile(XRAY_ASSET_DIR / filename): + item = QListWidgetItem(filename) + + if AppSettings.isStateON_('DarkMode'): + # Custom dark mode + item.setIcon(bootstrapIconWhite('file-earmark.svg')) + else: + if theme == 'Dark': + if PLATFORM == 'Windows': + # Windows. Always use black icon + item.setIcon(bootstrapIcon('file-earmark.svg')) + else: + item.setIcon(bootstrapIconWhite('file-earmark.svg')) + else: + item.setIcon(bootstrapIcon('file-earmark.svg')) + + self.addItem(item) + + def flushItem(self): + if PLATFORM == 'Linux' and getUbuntuRelease() == '20.04': + assert self.initialTheme is not None + + # Ubuntu 20.04. Flush by initial theme(Ubuntu 20.04 theme changes bug) + self.flushItemByTheme(self.initialTheme) + else: + self.flushItemByTheme(darkdetect.theme()) + + def appendNewItem(self, filename: str): + basename = os.path.basename(filename) + + if os.path.isfile(XRAY_ASSET_DIR / basename): + mbox = AssetExistsMBox(icon=AppQMessageBox.Icon.Question) + mbox.setWindowTitle(_('Import')) + mbox.setText(_('Asset file already exists. Overwrite?')) + mbox.setInformativeText(basename) + + if mbox.exec() == PySide6LegacyEnumValueWrapper( + AppQMessageBox.StandardButton.No + ): + # Do not overwrite + return + + try: + shutil.copy(filename, XRAY_ASSET_DIR) + except shutil.SameFileError: + # Same file imported. Do nothing + pass + except Exception as ex: + # Any non-exit exception + + mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Critical) + mbox.setWindowTitle(_('Import')) + mbox.setText(_('Error import asset file')) + mbox.setInformativeText(str(ex)) + + # Show the MessageBox and wait for user to close it + mbox.exec() + else: + self.flushItem() + + mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Information) + mbox.setWindowTitle(_('Import')) + mbox.setText(_('Import asset file success')) + + # Show the MessageBox and wait for user to close it + mbox.exec() + + def deleteSelectedItem(self): + indexes = self.selectedIndex + + if len(indexes) == 0: + # Nothing selected + return + + mbox = QuestionDeleteMBox(icon=AppQMessageBox.Icon.Question) + mbox.isMulti = bool(len(indexes) > 1) + mbox.possibleRemark = f'{self.item(indexes[0]).text()}' + mbox.setText(mbox.customText()) + + if mbox.exec() == PySide6LegacyEnumValueWrapper( + AppQMessageBox.StandardButton.No + ): + # Do not delete + return + + for index in indexes: + os.remove(XRAY_ASSET_DIR / self.item(index).text()) + + self.flushItem() + + def keyPressEvent(self, event): + if event.key() == QtCore.Qt.Key.Key_Delete: + self.deleteSelectedItem() + else: + super().keyPressEvent(event) + + def themeChangedCallback(self, theme): + if PLATFORM == 'Linux' and getUbuntuRelease() == '20.04': + # Ubuntu 20.04 system dark theme does not + # change menu color. Do nothing + pass + else: + self.flushItemByTheme(theme) diff --git a/Furious/Widget/__init__.py b/Furious/Widget/__init__.py index 52b2594..e913fa6 100644 --- a/Furious/Widget/__init__.py +++ b/Furious/Widget/__init__.py @@ -1,4 +1,4 @@ -# Copyright (C) 2023 Loren Eteval +# Copyright (C) 2024 Loren Eteval # # This file is part of Furious. # @@ -15,3 +15,10 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +from .Application import * +from .ConnectProgressBar import * +from .IndentSpinBox import * +from .SystemTrayIcon import * +from .UserServersQTableWidget import * +from .UserSubsQTableWidget import * +from .XrayAssetViewerQListWidget import * diff --git a/Furious/Window/AppMainWindow.py b/Furious/Window/AppMainWindow.py new file mode 100644 index 0000000..73608c9 --- /dev/null +++ b/Furious/Window/AppMainWindow.py @@ -0,0 +1,405 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from __future__ import annotations + +from Furious.Interface import * +from Furious.QtFramework import * +from Furious.QtFramework import gettext as _ +from Furious.Library import * +from Furious.Utility import * +from Furious.Widget.UserServersQTableWidget import * +from Furious.Window.UserSubsWindow import * +from Furious.Window.LogViewerWindow import * +from Furious.Window.XrayAssetViewerWindow import * + +from PySide6 import QtCore +from PySide6.QtGui import * +from PySide6.QtWidgets import * +from PySide6.QtNetwork import * + +from typing import Union + +import logging +import functools + +__all__ = ['AppMainWindow'] + +logger = logging.getLogger(__name__) + +registerAppSettings('ServerWidgetWindowSize') + +needTrans = functools.partial(needTransFn, source=__name__) + + +def connectedHttpProxyEndpoint() -> Union[str, None]: + try: + if APP().isSystemTrayConnected(): + index = AS_UserActivatedItemIndex() + + if index >= 0: + return AS_UserServers()[index].httpProxyEndpoint() + else: + # Should not reach here + return None + else: + return None + except Exception: + # Any non-exit exceptions + + return None + + +def connectedRemark() -> str: + try: + if APP().isSystemTrayConnected(): + index = AS_UserActivatedItemIndex() + + if index >= 0: + return f'{index + 1} - ' + AS_UserServers()[index].getExtras('remark') + else: + # Should not reach here + return '' + else: + return '' + except Exception: + # Any non-exit exceptions + + return '' + + +class AppNetworkStateManager(NetworkStateManager): + def __init__(self, parent=None): + super().__init__(parent) + + def successCallback(self): + parent = self.parent() + + if isinstance(parent, AppMainWindow): + parent.setNetworkState(True) + + def errorCallback(self, errorString: str): + parent = self.parent() + + if isinstance(parent, AppMainWindow): + parent.setNetworkState(False, errorString=errorString) + + def startSingleTest(self): + if not APP().isSystemTrayConnected(): + parent = self.parent() + + if isinstance(parent, AppMainWindow): + parent.resetNetworkState() + + self.stopTest() + + httpProxyEndpoint = connectedHttpProxyEndpoint() + + if httpProxyEndpoint is None: + parent = self.parent() + + if isinstance(parent, AppMainWindow): + parent.resetNetworkState() + else: + self.configureHttpProxy(httpProxyEndpoint) + + super().startSingleTest() + + def disconnectedCallback(self): + parent = self.parent() + + if isinstance(parent, AppMainWindow): + parent.resetNetworkState() + + self.stopTest() + + +needTrans( + 'Server', + 'Log', + 'Show Furious Log', + 'Show Core Log', + 'Show Tun2socks Log', + 'Subscription', + 'Update Subscription (Use Current Proxy)', + 'Update Subscription (Force Proxy)', + 'Update Subscription (No Proxy)', + 'Edit Subscription...', + 'Tools', + 'Manage Xray-core Asset File...', + 'Check For Updates', + 'About', + 'Help', +) + + +class AppMainWindow(AppQMainWindow): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.setWindowTitle(_(APPLICATION_NAME)) + + self.updatesManager = UpdatesManager() + self.networkStateManager = AppNetworkStateManager(parent=self) + + self.userServersQTableWidget = UserServersQTableWidget() + self.userSubsWindow = UserSubsWindow( + deleteUniqueCallback=lambda unique: self.userServersQTableWidget.deleteItemByIndex( + list( + index + for index, server in enumerate(AS_UserServers()) + if server.getExtras('subsId') == unique + ) + ) + ) + self.xrayAssetViewerWindow = XrayAssetViewerWindow() + + self.mainTab = AppQTabWidget() + self.mainTab.addTab(self.userServersQTableWidget, _('Server')) + + logActions = [ + AppQAction( + _('Show Furious Log'), + callback=lambda: APP().logViewerWindowApp_.showMaximized(), + shortcut=QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier.ControlModifier + | QtCore.Qt.KeyboardModifier.ShiftModifier, + QtCore.Qt.Key.Key_F, + ), + ), + AppQAction( + _('Show Core Log'), + callback=lambda: APP().logViewerWindowCore.showMaximized(), + shortcut=QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier.ControlModifier + | QtCore.Qt.KeyboardModifier.ShiftModifier, + QtCore.Qt.Key.Key_C, + ), + ), + AppQAction( + _('Show Tun2socks Log'), + callback=lambda: APP().logViewerWindowTun_.showMaximized(), + shortcut=QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier.ControlModifier + | QtCore.Qt.KeyboardModifier.ShiftModifier, + QtCore.Qt.Key.Key_T, + ), + ), + ] + + subsActions = [ + AppQAction( + _('Update Subscription (Use Current Proxy)'), + callback=lambda: self.userServersQTableWidget.updateSubs( + connectedHttpProxyEndpoint() + ), + ), + AppQAction( + _('Update Subscription (Force Proxy)'), + callback=lambda: self.userServersQTableWidget.updateSubs( + '127.0.0.1:10809' + ), + ), + AppQAction( + _('Update Subscription (No Proxy)'), + callback=lambda: self.userServersQTableWidget.updateSubs(None), + ), + AppQSeperator(), + AppQAction( + _('Edit Subscription...'), + icon=bootstrapIcon('star.svg'), + callback=lambda: self.userSubsWindow.show(), + ), + ] + + toolsActions = [ + AppQAction( + _('Manage Xray-core Asset File...'), + callback=lambda: self.xrayAssetViewerWindow.show(), + ), + ] + + if hasattr(AppQAction, 'setMenu'): + self.toolbar = AppQToolBar( + AppQAction( + _('Log'), + icon=bootstrapIcon('pin-angle.svg'), + menu=AppQMenu(*logActions), + useActionGroup=False, + checkable=False, + ), + AppQSeperator(), + AppQAction( + _('Subscription'), + icon=bootstrapIcon('collection.svg'), + menu=AppQMenu(*subsActions), + useActionGroup=False, + checkable=False, + ), + AppQSeperator(), + AppQAction( + _('Tools'), + icon=bootstrapIcon('tools.svg'), + menu=AppQMenu(*toolsActions), + useActionGroup=False, + checkable=False, + ), + AppQSeperator(), + AppQAction( + _('Check For Updates'), + icon=bootstrapIcon('download.svg'), + checkable=False, + callback=lambda: self.checkForUpdates(), + ), + AppQSeperator(), + AppQAction( + _('About'), + icon=bootstrapIcon('info-circle.svg'), + checkable=False, + callback=lambda: self.openAboutPage(), + ), + ) + self.toolbar.setIconSize(QtCore.QSize(64, 32)) + self.toolbar.setToolButtonStyle( + QtCore.Qt.ToolButtonStyle.ToolButtonTextUnderIcon + ) + self.addToolBar(self.toolbar) + else: + # Menu actions + logMenu = { + 'name': 'Log', + 'actions': [*logActions], + } + + subsMenu = { + 'name': 'Subscription', + 'actions': [*subsActions], + } + + toolsMenu = { + 'name': 'Tools', + 'actions': [*toolsActions], + } + + helpMenu = { + 'name': 'Help', + 'actions': [ + AppQAction( + _('Check For Updates'), + icon=bootstrapIcon('download.svg'), + checkable=False, + callback=lambda: self.checkForUpdates(), + ), + AppQSeperator(), + AppQAction( + _('About'), + icon=bootstrapIcon('info-circle.svg'), + checkable=False, + callback=lambda: self.openAboutPage(), + ), + ], + } + + # Menus + for menuDict in (logMenu, subsMenu, toolsMenu, helpMenu): + menuName = menuDict['name'] + menuObjName = f'_{menuName}Menu' + menu = AppQMenu( + *menuDict['actions'], title=_(menuName), parent=self.menuBar() + ) + + # Set reference + setattr(self, menuObjName, menu) + + self.menuBar().addMenu(menu) + + self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.NoContextMenu) + + # TODO: Custom status tip + # self.setStatusBar(QStatusBar(self)) + + self.networkState = AppQLabel(translatable=False) + + self.statusBar().addPermanentWidget(self.networkState) + + self.networkStateManager.startTest() + + self._widget = QWidget() + self._layout = QVBoxLayout(self._widget) + self._layout.addWidget(self.mainTab) + + self.setCentralWidget(self._widget) + + def appendNewItemByFactory(self, factory: ConfigurationFactory): + self.userServersQTableWidget.appendNewItemByFactory(factory) + + def flushRow(self, row: int, item: ConfigurationFactory): + self.userServersQTableWidget.flushRow(row, item) + + def showTabAndSpaces(self): + self.userServersQTableWidget.showTabAndSpaces() + + def hideTabAndSpaces(self): + self.userServersQTableWidget.hideTabAndSpaces() + + def checkForUpdates(self): + self.updatesManager.configureHttpProxy(connectedHttpProxyEndpoint()) + self.updatesManager.checkForUpdates() + + def resetNetworkState(self): + self.networkState.setText('') + + def setNetworkState(self, success: bool, **kwargs): + remark = connectedRemark() + + if success: + if remark: + self.networkState.setText(f'{remark} {UNICODE_LARGE_GREEN_CIRCLE}') + else: + self.resetNetworkState() + else: + errorString = kwargs.pop('errorString', '') + + if remark: + self.networkState.setText( + f'{remark} - {errorString} {UNICODE_LARGE_RED_CIRCLE}' + ) + else: + self.resetNetworkState() + + @staticmethod + def openAboutPage(): + if QDesktopServices.openUrl(QtCore.QUrl(APPLICATION_ABOUT_PAGE)): + logger.info('open about page success') + else: + logger.error('open about page failed') + + def setWidthAndHeight(self): + try: + windowSize = AppSettings.get('ServerWidgetWindowSize').split(',') + + self.setGeometry(100, 100, *list(int(size) for size in windowSize)) + except Exception: + # Any non-exit exceptions + + self.setGeometry(100, 100, 1800, 960) + + def cleanup(self): + AppSettings.set( + 'ServerWidgetWindowSize', + f'{self.geometry().width()},{self.geometry().height()}', + ) diff --git a/Furious/Window/LogViewerWindow.py b/Furious/Window/LogViewerWindow.py new file mode 100644 index 0000000..5e12d6a --- /dev/null +++ b/Furious/Window/LogViewerWindow.py @@ -0,0 +1,183 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from Furious.QtFramework import * +from Furious.QtFramework import gettext as _ + +from PySide6 import QtCore +from PySide6.QtWidgets import * + +import functools + +__all__ = ['LogViewerWindow'] + +needTrans = functools.partial(needTransFn, source=__name__) + +needTrans('Unable to save log') + + +class SaveErrorMBox(AppQMessageBox): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.saveError = '' + + def customText(self): + if self.saveError: + return _('Unable to save log') + f'\n\n{self.saveError}' + else: + return _('Unable to save log') + + def retranslate(self): + self.setWindowTitle(_(self.windowTitle())) + self.setText(self.customText()) + + # Ignore informative text, buttons + + self.moveToCenter() + + +needTrans( + 'Save File', + 'Text files (*.txt);;All files (*)', + 'Error saving log', +) + + +def saveAsFile(content: str): + filename, selectedFilter = QFileDialog.getSaveFileName( + None, _('Save File'), filter=_('Text files (*.txt);;All files (*)') + ) + + if filename: + try: + with open(filename, 'w', encoding='utf-8') as file: + file.write(content) + except Exception as ex: + # Any non-exit exceptions + + mbox = SaveErrorMBox(icon=AppQMessageBox.Icon.Critical) + mbox.saveError = str(ex) + mbox.setWindowTitle(_('Error saving log')) + mbox.setText(mbox.customText()) + + # Show the MessageBox and wait for user to close it + mbox.exec() + + +needTrans( + 'Log Viewer', + 'Save As...', + 'Exit', + 'File', + 'Copy', + 'Select All', + 'Edit', + 'Zoom In', + 'Zoom Out', + 'View', +) + + +class LogViewerWindow(AppQMainWindow): + def __init__(self, *args, **kwargs): + tabTitle = kwargs.pop('tabTitle', '') + fontFamily = kwargs.pop('fontFamily', '') + pointSizeSettingsName = kwargs.pop('pointSizeSettingsName', '') + + super().__init__(*args, **kwargs) + + self.setWindowTitle(_('Log Viewer')) + + self.textBrowser = DraculaTextBrowser( + fontFamily=fontFamily, + pointSizeSettingsName=pointSizeSettingsName, + ) + self.textBrowser.setLineWrapMode(DraculaTextBrowser.LineWrapMode.NoWrap) + + self.tabWidget = AppQTabWidget() + self.tabWidget.addTab(self.textBrowser, tabTitle) + + self.setCentralWidget(self.tabWidget) + + self._fileMenu = AppQMenu( + AppQAction( + _('Save As...'), + callback=lambda: saveAsFile(self.textBrowser.toPlainText()), + ), + AppQSeperator(), + AppQAction( + _('Exit'), + callback=lambda: self.hide(), + ), + title=_('File'), + parent=self, + ) + + self._editMenu = AppQMenu( + AppQAction( + _('Copy'), + icon=bootstrapIcon('files.svg'), + callback=lambda: self.textBrowser.copy(), + shortcut=QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier.ControlModifier, + QtCore.Qt.Key.Key_C, + ), + ), + AppQSeperator(), + AppQAction( + _('Select All'), + callback=lambda: self.textBrowser.selectAll(), + shortcut=QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier.ControlModifier, + QtCore.Qt.Key.Key_A, + ), + ), + title=_('Edit'), + parent=self, + ) + + self._viewMenu = AppQMenu( + AppQAction( + _('Zoom In'), + callback=lambda: self.textBrowser.zoomIn(), + shortcut=QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier.ControlModifier, + QtCore.Qt.Key.Key_Plus, + ), + ), + AppQAction( + _('Zoom Out'), + callback=lambda: self.textBrowser.zoomOut(), + shortcut=QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier.ControlModifier, + QtCore.Qt.Key.Key_Minus, + ), + ), + title=_('View'), + parent=self, + ) + + self.menuBar().addMenu(self._fileMenu) + self.menuBar().addMenu(self._editMenu) + self.menuBar().addMenu(self._viewMenu) + + def appendLine(self, line: str): + self.textBrowser.appendLine(line) + + def clear(self): + self.textBrowser.clear() diff --git a/Furious/Window/QRCodeWindow.py b/Furious/Window/QRCodeWindow.py new file mode 100644 index 0000000..321ca39 --- /dev/null +++ b/Furious/Window/QRCodeWindow.py @@ -0,0 +1,81 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from __future__ import annotations + +from Furious.Interface import * +from Furious.PyFramework import * +from Furious.QtFramework import * +from Furious.QtFramework import gettext as _ +from Furious.Utility import * + +from PySide6 import QtCore +from PySide6.QtGui import * +from PySide6.QtWidgets import * + +import io +import pyqrcode + +__all__ = ['QRCodeWindow'] + + +class QRCodeWindow(SupportImplicitReference, AppQMainWindow): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.setWindowTitle(_(APPLICATION_NAME)) + self.setFixedSize(640, 640) + + self.tabWidget = QTabWidget(self) + self.tabWidget.setTabsClosable(True) + self.tabWidget.tabCloseRequested.connect(self.handleTabCloseRequested) + + self.setCentralWidget(self.tabWidget) + + def tabCount(self) -> int: + return self.tabWidget.count() + + def initTabByIndex(self, indexes): + self.tabWidget.clear() + + for index in indexes: + qrdata = io.BytesIO() + + config = AS_UserServers()[index] + + uri = config.toURI() + + if uri: + qrcode = pyqrcode.create(uri) + qrcode.png(qrdata, scale=5) + + pixmap = QPixmap() + pixmap.loadFromData(qrdata.getvalue(), 'PNG') + + widget = QLabel(parent=self.tabWidget) + widget.setPixmap(pixmap) + + self.tabWidget.addTab( + widget, f'{index + 1} - ' + config.getExtras('remark') + ) + + @QtCore.Slot(int) + def handleTabCloseRequested(self, index): + self.tabWidget.removeTab(index) + + if self.tabWidget.count() == 0: + self.hide() diff --git a/Furious/Window/TextEditorWindow.py b/Furious/Window/TextEditorWindow.py new file mode 100644 index 0000000..28f31d1 --- /dev/null +++ b/Furious/Window/TextEditorWindow.py @@ -0,0 +1,425 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from __future__ import annotations + +from Furious.PyFramework import * +from Furious.QtFramework import * +from Furious.QtFramework import gettext as _ +from Furious.Utility import * +from Furious.Library import * +from Furious.Widget.IndentSpinBox import * + +from PySide6 import QtCore +from PySide6.QtGui import * +from PySide6.QtWidgets import * + +import functools + +__all__ = ['TextEditorWindow'] + +registerAppSettings('ServerWidgetPointSize') + +needTrans = functools.partial(needTransFn, source=__name__) + +needTrans( + 'Save Changes', + 'The content has been modified. Save changes?', + 'Save', + 'Cancel', + 'Discard', +) + + +class QuestionSaveMBox(AppQMessageBox): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.setWindowTitle(_('Save Changes')) + self.setText(_('The content has been modified. Save changes?')) + + self.button0 = self.addButton(_('Save'), AppQMessageBox.ButtonRole.AcceptRole) + self.button1 = self.addButton(_('Cancel'), AppQMessageBox.ButtonRole.RejectRole) + self.button2 = self.addButton( + _('Discard'), AppQMessageBox.ButtonRole.DestructiveRole + ) + + self.setDefaultButton(self.button0) + + +needTrans('Please check if the configuration is in valid JSON format') + + +class JSONDecodeErrorMBox(AppQMessageBox): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.error = '' + + def customText(self): + return ( + _('Please check if the configuration is in valid JSON format') + + f'\n\n{self.error}' + ) + + def retranslate(self): + self.setWindowTitle(_(self.windowTitle())) + self.setText(self.customText()) + + # Ignore informative text, buttons + + self.moveToCenter() + + +needTrans( + 'Save', + 'Save As...', + 'Exit', + 'File', + 'Undo', + 'Redo', + 'Cut', + 'Copy', + 'Paste', + 'Select All', + 'Indent...', + 'Edit', + 'Zoom In', + 'Zoom Out', + 'View', + 'Error saving configuration', + 'Save File', + 'Text files (*.json);;All files (*)', + 'Error Saving File', + 'Invalid server configuration', + 'Error setting indent', +) + + +class TextEditorWindow(AppQMainWindow): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.customWindowTitle = '' + self.setWindowModality(QtCore.Qt.WindowModality.ApplicationModal) + self.setFixedSize(470, int(470 * GOLDEN_RATIO)) + + self.indentSpinBox = IndentSpinBox(parent=self) + + # Current editing index + self.currentIndex = -1 + + self.modified = False + self.modifiedMark = ' *' + + self.lineColumnLabel = QLabel('1:1 0') + self.statusBar().addPermanentWidget(self.lineColumnLabel) + + def modificationCallback(): + self.markAsModified() + + def cursorChangedCallback(cursor: QTextCursor): + self.lineColumnLabel.setText( + f'{cursor.blockNumber() + 1}:{cursor.columnNumber() + 1} {cursor.position()}' + ) + + self.jsonEditor = DraculaJSONTextEditor( + fontFamily=APP().customFontName, + pointSizeSettingsName='ServerWidgetPointSize', + ) + self.jsonEditor.setLineWrapMode(DraculaJSONTextEditor.LineWrapMode.NoWrap) + self.jsonEditor.registerModificationChangedCb(modificationCallback) + self.jsonEditor.registerCursorPositionChangedCb(cursorChangedCallback) + + self.setCentralWidget(self.jsonEditor) + + self.fileMenu = AppQMenu( + AppQAction( + _('Save'), + icon=bootstrapIcon('save.svg'), + callback=lambda: self.save(), + shortcut=QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier.ControlModifier, + QtCore.Qt.Key.Key_S, + ), + ), + AppQAction( + _('Save As...'), + callback=lambda: self.saveAsFile(), + ), + AppQSeperator(), + AppQAction( + _('Exit'), + callback=lambda: self.questionSave(), + ), + title=_('File'), + parent=self.menuBar(), + ) + + self.editMenu = AppQMenu( + AppQAction( + _('Undo'), + icon=bootstrapIcon('arrow-return-left.svg'), + callback=lambda: self.jsonEditor.undo(), + shortcut=QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier.ControlModifier, + QtCore.Qt.Key.Key_Z, + ), + ), + AppQAction( + _('Redo'), + icon=bootstrapIcon('arrow-return-right.svg'), + callback=lambda: self.jsonEditor.redo(), + shortcut=QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier.ControlModifier + | QtCore.Qt.KeyboardModifier.ShiftModifier, + QtCore.Qt.Key.Key_Z, + ), + ), + AppQSeperator(), + AppQAction( + _('Cut'), + icon=bootstrapIcon('scissors.svg'), + callback=lambda: self.jsonEditor.cut(), + shortcut=QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier.ControlModifier, + QtCore.Qt.Key.Key_X, + ), + ), + AppQAction( + _('Copy'), + icon=bootstrapIcon('files.svg'), + callback=lambda: self.jsonEditor.copy(), + shortcut=QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier.ControlModifier, + QtCore.Qt.Key.Key_C, + ), + ), + AppQAction( + _('Paste'), + callback=lambda: self.jsonEditor.paste(), + shortcut=QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier.ControlModifier, + QtCore.Qt.Key.Key_V, + ), + ), + AppQSeperator(), + AppQAction( + _('Select All'), + callback=lambda: self.jsonEditor.selectAll(), + shortcut=QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier.ControlModifier, + QtCore.Qt.Key.Key_A, + ), + ), + AppQSeperator(), + AppQAction( + _('Indent...'), + callback=lambda: self.setIndent(), + ), + title=_('Edit'), + parent=self.menuBar(), + ) + + self.viewMenu = AppQMenu( + AppQAction( + _('Zoom In'), + callback=lambda: self.jsonEditor.zoomIn(), + shortcut=QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier.ControlModifier, + QtCore.Qt.Key.Key_Plus, + ), + ), + AppQAction( + _('Zoom Out'), + callback=lambda: self.jsonEditor.zoomOut(), + shortcut=QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier.ControlModifier, + QtCore.Qt.Key.Key_Minus, + ), + ), + title=_('View'), + parent=self.menuBar(), + ) + + self.menuBar().addMenu(self.fileMenu) + self.menuBar().addMenu(self.editMenu) + self.menuBar().addMenu(self.viewMenu) + + def markAsModified(self): + self.modified = True + self.setWindowTitle(self.customWindowTitle + self.modifiedMark) + + def markAsSaved(self): + self.modified = False + self.setWindowTitle(self.customWindowTitle) + + def questionSave(self): + if self.modified: + mbox = QuestionSaveMBox(icon=AppQMessageBox.Icon.Question) + code = mbox.exec() + + if code == PySide6LegacyEnumValueWrapper( + AppQMessageBox.ButtonRole.AcceptRole + ): + if self.save(): + self.hide() + if code == PySide6LegacyEnumValueWrapper( + AppQMessageBox.ButtonRole.DestructiveRole + ): + self.markAsSaved() + self.hide() + if code == PySide6LegacyEnumValueWrapper( + AppQMessageBox.ButtonRole.RejectRole + ): + # Cancel. Do nothing + pass + else: + self.hide() + + def setPlainText(self, text: str, blockSignals: bool): + if blockSignals: + with QBlockSignals(self.jsonEditor): + self.jsonEditor.setPlainText(text) + else: + self.jsonEditor.setPlainText(text) + + def save(self) -> bool: + index = self.currentIndex + + if index < 0: + # Should not reach here. Do nothing + self.markAsSaved() + + return True + + plain = self.jsonEditor.toPlainText() + + try: + jsonObject = JSONEncoder.decode(plain) + except Exception as ex: + # Any non-exit exceptions + + mbox = JSONDecodeErrorMBox(icon=AppQMessageBox.Icon.Critical) + mbox.error = str(ex) + mbox.setWindowTitle(_('Error saving configuration')) + mbox.setText(mbox.customText()) + + # Show the MessageBox and wait for user to close it + mbox.exec() + + return False + else: + old = AS_UserServers()[index] + new = constructFromDict(jsonObject, **old.kwargs) + + FastItemDeletionSearch.moveToTrash(old) + + AS_UserServers()[index] = new + + try: + APP().mainWindow.flushRow(index, new) + except Exception: + # Any non-exit exceptions + + pass + + if index == AS_UserActivatedItemIndex(): + try: + if APP().isSystemTrayConnected(): + mbox = NewChangesNextTimeMBox() + mbox.exec() + except Exception: + # Any non-exit exceptions + + pass + + self.markAsSaved() + + return True + + def saveAsFile(self): + filename, selectedFilter = QFileDialog.getSaveFileName( + None, _('Save File'), filter=_('Text files (*.json);;All files (*)') + ) + + if filename: + try: + with open(filename, 'w', encoding='utf-8') as file: + file.write(self.jsonEditor.toPlainText()) + except Exception as ex: + # Any non-exit exceptions + + mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Critical) + mbox.setWindowTitle(_('Error Saving File')) + mbox.setText(_('Invalid server configuration')) + mbox.setInformativeText(str(ex)) + + # Show the MessageBox and wait for user to close it + mbox.exec() + + def setIndent(self): + code = self.indentSpinBox.exec() + + if code == PySide6LegacyEnumValueWrapper(AppQDialog.DialogCode.Accepted): + plain = self.jsonEditor.toPlainText() + + try: + jsonObject = JSONEncoder.decode(plain) + except Exception as ex: + # Any non-exit exceptions + + mbox = JSONDecodeErrorMBox(icon=AppQMessageBox.Icon.Critical) + mbox.error = str(ex) + mbox.setWindowTitle(_('Error setting indent')) + mbox.setText(mbox.customText()) + + # Show the MessageBox and wait for user to close it + mbox.exec() + else: + text = JSONEncoder.encode(jsonObject, indent=self.indentSpinBox.value()) + + self.setPlainText(text, False) + else: + # Do nothing + pass + + def showTabAndSpaces(self): + textOption = QTextOption() + textOption.setFlags(QTextOption.Flag.ShowTabsAndSpaces) + + self.jsonEditor.document().setDefaultTextOption(textOption) + # Reset. Set + self.jsonEditor.setLineWrapMode(QPlainTextEdit.LineWrapMode.WidgetWidth) + self.jsonEditor.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) + + def hideTabAndSpaces(self): + textOption = QTextOption() + + self.jsonEditor.document().setDefaultTextOption(textOption) + # Reset. Set + self.jsonEditor.setLineWrapMode(QPlainTextEdit.LineWrapMode.WidgetWidth) + self.jsonEditor.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) + + def closeEvent(self, event: QtCore.QEvent): + event.ignore() + + self.questionSave() + + def retranslate(self): + # Do nothing + pass diff --git a/Furious/Window/UserSubsWindow.py b/Furious/Window/UserSubsWindow.py new file mode 100644 index 0000000..888fe13 --- /dev/null +++ b/Furious/Window/UserSubsWindow.py @@ -0,0 +1,177 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from Furious.QtFramework import * +from Furious.QtFramework import gettext as _ +from Furious.Library import * +from Furious.Utility import * +from Furious.Widget.UserSubsQTableWidget import * + +from PySide6 import QtWidgets +from PySide6.QtGui import * +from PySide6.QtWidgets import * +from PySide6.QtNetwork import * + +import uuid +import functools + +__all__ = ['UserSubsWindow'] + +registerAppSettings('SubscriptionWidgetWindowSize') + +needTrans = functools.partial(needTransFn, source=__name__) + +needTrans( + 'Add Subscription', + 'Enter subscription remark:', + 'Enter subscription URL:', + 'Cancel', +) + + +class AddSubsDialog(AppQDialog): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.setWindowTitle(_('Add Subscription')) + + self.remarkText = QLabel(_('Enter subscription remark:')) + self.remarkEdit = QLineEdit() + + self.webURLText = QLabel(_('Enter subscription URL:')) + self.webURLEdit = QLineEdit() + + self.dialogBtns = QDialogButtonBox(QtCore.Qt.Orientation.Horizontal) + self.dialogBtns.addButton(_('OK'), QDialogButtonBox.ButtonRole.AcceptRole) + self.dialogBtns.addButton(_('Cancel'), QDialogButtonBox.ButtonRole.RejectRole) + self.dialogBtns.accepted.connect(self.accept) + self.dialogBtns.rejected.connect(self.reject) + + layout = QFormLayout() + layout.addRow(self.remarkText) + layout.addRow(self.remarkEdit) + layout.addRow(self.webURLText) + layout.addRow(self.webURLEdit) + layout.addRow(self.dialogBtns) + layout.setFormAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + + self.setLayout(layout) + + def setWidthAndHeight(self): + self.setGeometry(100, 100, 456, 150) + + def subsRemark(self): + return self.remarkEdit.text() + + def subsWebURL(self): + return self.webURLEdit.text() + + def retranslate(self): + self.setWindowTitle(_(self.windowTitle())) + + self.remarkText.setText(_(self.remarkText.text())) + self.webURLText.setText(_(self.webURLText.text())) + + for button in self.dialogBtns.buttons(): + button.setText(_(button.text())) + + moveToCenter(self) + + +needTrans( + 'Edit Subscription', + 'Subscription List', + 'Add', + 'Delete', +) + + +class UserSubsWindow(AppQMainWindow): + def __init__(self, *args, **kwargs): + callback = kwargs.pop('deleteUniqueCallback', None) + + super().__init__(*args, **kwargs) + + self.setWindowTitle(_('Edit Subscription')) + + self.addSubsDialog = AddSubsDialog() + self.userSubsQTableWidget = UserSubsQTableWidget(deleteUniqueCallback=callback) + + self.userSubsTab = AppQTabWidget(self) + self.userSubsTab.addTab(self.userSubsQTableWidget, _('Subscription List')) + + # Buttons + self.addButton = AppQPushButton(_('Add')) + self.addButton.clicked.connect(lambda: self.addSubs()) + self.deleteButton = AppQPushButton(_('Delete')) + self.deleteButton.clicked.connect(lambda: self.deleteSelectedItem()) + + # Button Layout + self.buttonWidget = QWidget() + self.buttonWidgetLayout = QGridLayout(parent=self.buttonWidget) + self.buttonWidgetLayout.addWidget(self.addButton, 0, 0) + self.buttonWidgetLayout.addWidget(self.deleteButton, 0, 1) + + self.fakeCentralWidget = QWidget() + self.fakeCentralWidgetLayout = QVBoxLayout(self.fakeCentralWidget) + self.fakeCentralWidgetLayout.addWidget(self.userSubsTab) + self.fakeCentralWidgetLayout.addWidget(self.buttonWidget) + + self.setCentralWidget(self.fakeCentralWidget) + + def setWidthAndHeight(self): + try: + windowSize = AppSettings.get('SubscriptionWidgetWindowSize').split(',') + + self.setGeometry(100, 100, *list(int(size) for size in windowSize)) + except Exception: + # Any non-exit exceptions + + self.setGeometry(100, 100, 360 * GOLDEN_RATIO, 360) + + def addSubs(self): + choice = self.addSubsDialog.exec() + + if choice == PySide6LegacyEnumValueWrapper(QDialog.DialogCode.Accepted): + remark = self.addSubsDialog.subsRemark() + webURL = self.addSubsDialog.subsWebURL() + + if remark: + # Unique id. Used by display and deletion + unique = str(uuid.uuid4()) + + self.userSubsQTableWidget.appendNewItem( + unique=unique, remark=remark, webURL=webURL + ) + else: + # Do nothing + pass + + def deleteSelectedItem(self): + self.userSubsQTableWidget.deleteSelectedItem() + + def keyPressEvent(self, event): + if event.key() == QtCore.Qt.Key.Key_Delete: + self.deleteSelectedItem() + else: + super().keyPressEvent(event) + + def cleanup(self): + AppSettings.set( + 'SubscriptionWidgetWindowSize', + f'{self.geometry().width()},{self.geometry().height()}', + ) diff --git a/Furious/Window/XrayAssetViewerWindow.py b/Furious/Window/XrayAssetViewerWindow.py new file mode 100644 index 0000000..78dd74d --- /dev/null +++ b/Furious/Window/XrayAssetViewerWindow.py @@ -0,0 +1,112 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from __future__ import annotations + +from Furious.Interface import * +from Furious.QtFramework import * +from Furious.QtFramework import gettext as _ +from Furious.Library import * +from Furious.Utility import * +from Furious.Widget.XrayAssetViewerQListWidget import * + +from PySide6.QtGui import * +from PySide6.QtWidgets import * + +import logging +import functools + +__all__ = ['XrayAssetViewerWindow'] + +logger = logging.getLogger(__name__) + +needTrans = functools.partial(needTransFn, source=__name__) + +needTrans( + 'Xray-core Asset File', + 'Refresh', + 'Open Asset Directory', + 'Import From File...', + 'Exit', + 'File', + 'Import File', + 'All files (*)', +) + + +class XrayAssetViewerWindow(AppQMainWindow): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.setWindowTitle(_('Xray-core Asset File')) + + self.xrayAssetViewerWidget = XrayAssetViewerQListWidget() + self.setCentralWidget(self.xrayAssetViewerWidget) + + self.fileMenu = AppQMenu( + AppQAction( + _('Refresh'), + callback=lambda: self.flushItem(), + shortcut=QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier.ControlModifier, + QtCore.Qt.Key.Key_R, + ), + ), + AppQSeperator(), + AppQAction( + _('Open Asset Directory'), + callback=lambda: self.openAssetDirectory(), + shortcut=QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier.ControlModifier, + QtCore.Qt.Key.Key_O, + ), + ), + AppQAction( + _('Import From File...'), + callback=lambda: self.appendNewItem(), + ), + AppQSeperator(), + AppQAction( + _('Exit'), + callback=lambda: self.hide(), + ), + title=_('File'), + parent=self.menuBar(), + ) + + self.menuBar().addMenu(self.fileMenu) + + def setWidthAndHeight(self): + self.setGeometry(100, 100, 360, 360 * GOLDEN_RATIO) + + def flushItem(self): + self.xrayAssetViewerWidget.flushItem() + + @staticmethod + def openAssetDirectory(): + if QDesktopServices.openUrl(QtCore.QUrl.fromLocalFile(XRAY_ASSET_DIR)): + logger.info(f'open Xray-core asset dir success') + else: + logger.error(f'open Xray-core asset dir failed') + + def appendNewItem(self): + filename, selectedFilter = QFileDialog.getOpenFileName( + None, _('Import File'), filter=_('All files (*)') + ) + + if filename: + self.xrayAssetViewerWidget.appendNewItem(filename) diff --git a/Furious/Window/__init__.py b/Furious/Window/__init__.py new file mode 100644 index 0000000..895a518 --- /dev/null +++ b/Furious/Window/__init__.py @@ -0,0 +1,23 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from .AppMainWindow import * +from .LogViewerWindow import * +from .QRCodeWindow import * +from .TextEditorWindow import * +from .UserSubsWindow import * +from .XrayAssetViewerWindow import * diff --git a/Furious/__init__.py b/Furious/__init__.py index 6f48680..59fbd75 100644 --- a/Furious/__init__.py +++ b/Furious/__init__.py @@ -1,4 +1,4 @@ -# Copyright (C) 2023 Loren Eteval +# Copyright (C) 2024 Loren Eteval # # This file is part of Furious. # @@ -15,4 +15,4 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from .Utility import Resources +from .Utility import AppResources diff --git a/Furious/__main__.py b/Furious/__main__.py index abd557c..041d1f7 100644 --- a/Furious/__main__.py +++ b/Furious/__main__.py @@ -1,4 +1,4 @@ -# Copyright (C) 2023 Loren Eteval +# Copyright (C) 2024 Loren Eteval # # This file is part of Furious. # @@ -15,12 +15,11 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +from Furious.Interface import * +from Furious.QtFramework import * +from Furious.QtFramework import gettext as _ +from Furious.Utility import * from Furious.Widget.Application import Application -from Furious.Widget.Widget import MessageBox -from Furious.Utility.Constants import APPLICATION_NAME, CRASH_LOG_DIR -from Furious.Utility.Utility import bootstrapIcon, enumValueWrapper -from Furious.Utility.Process import Process -from Furious.Utility.Translator import gettext as _ from PySide6 import QtCore from PySide6.QtGui import QDesktopServices @@ -28,19 +27,33 @@ from PySide6.QtGui import QDesktopServices import os import sys import logging +import functools import traceback +__all__ = ['main'] + logger = logging.getLogger(__name__) +needTrans = functools.partial(needTransFn, source=__name__) + +needTrans( + f'{APPLICATION_NAME} is not able to run on this operating system', + 'Operating system information', + f'{APPLICATION_NAME} encountered an internal error and needs to be stopped', + f'{APPLICATION_NAME} stopped unexpectedly due to an unknown exception', + 'Crash log has been saved to', + 'Open crash log', +) + def main(): try: - process = Process() + appMainProcess = AppMainProcess(functools.partial(Application, sys.argv)) - process.start() - process.join() + appMainProcess.start() + appMainProcess.join() - exitcode = process.exitcode + exitcode = appMainProcess.exitcode if exitcode == 0: sys.exit(exitcode) @@ -48,18 +61,19 @@ def main(): # For Qt runtime. Not used _app = Application(sys.argv) - if exitcode == Application.ErrorCode.PlatformNotSupported: - messageBox = MessageBox(icon=MessageBox.Icon.Critical) + if exitcode == ApplicationFactory.ExitCode.PlatformNotSupported: + messageBox = AppQMessageBox(icon=AppQMessageBox.Icon.Critical) messageBox.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg')) messageBox.setWindowTitle(_(APPLICATION_NAME)) messageBox.setText( - _(f'{APPLICATION_NAME} is not able to run on this operating system.') + _(f'{APPLICATION_NAME} is not able to run on this operating system') ) if hasattr(os, 'uname'): messageBox.setInformativeText( - f'{_("Operating system information:")}\n' + _('Operating system information') + + '\n' + '\n'.join( list( f'{arg}: {val}' @@ -77,43 +91,44 @@ def main(): ) ) - # Show the MessageBox and wait for user to close it + # Show the AppQMessageBox and wait for user to close it messageBox.exec() else: - if exitcode == Application.ErrorCode.AssertionError: + if exitcode == ApplicationFactory.ExitCode.AssertionError: # Assertion error text = _( - f'{APPLICATION_NAME} encountered an internal error and needs to be stopped.' + f'{APPLICATION_NAME} encountered an internal error and needs to be stopped' ) else: # Unknown exception text = _( - f'{APPLICATION_NAME} stopped unexpectedly due to an unknown exception.' + f'{APPLICATION_NAME} stopped unexpectedly due to an unknown exception' ) - messageBox = MessageBox(icon=MessageBox.Icon.Critical) + messageBox = AppQMessageBox(icon=AppQMessageBox.Icon.Critical) messageBox.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg')) messageBox.setWindowTitle(_(APPLICATION_NAME)) messageBox.setText(text) - if process.fileWritten.value: + if appMainProcess.fileWritten.value: # Crash log saved - - crashLogFile = str(CRASH_LOG_DIR / process.logFileName) + crashLogFile = str(CRASH_LOG_DIR / appMainProcess.logFileName) messageBox.setInformativeText( - _('Crash log has been saved to: ') + f'{crashLogFile}' + _('Crash log has been saved to') + f' {crashLogFile}' ) messageBox.addButton( - _('Open crash log'), MessageBox.ButtonRole.AcceptRole + _('Open crash log'), AppQMessageBox.ButtonRole.AcceptRole ) - messageBox.addButton(_('OK'), MessageBox.ButtonRole.RejectRole) + messageBox.addButton(_('OK'), AppQMessageBox.ButtonRole.RejectRole) - # Show the MessageBox and wait for user to close it + # Show the AppQMessageBox and wait for user to close it choice = messageBox.exec() - if choice == enumValueWrapper(MessageBox.ButtonRole.AcceptRole): + if choice == PySide6LegacyEnumValueWrapper( + AppQMessageBox.ButtonRole.AcceptRole + ): # Open if QDesktopServices.openUrl( QtCore.QUrl.fromLocalFile(crashLogFile) @@ -127,7 +142,7 @@ def main(): else: # Crash log not saved - # Show the MessageBox and wait for user to close it + # Show the AppQMessageBox and wait for user to close it messageBox.exec() sys.exit(exitcode) diff --git a/Furious/Gui/Icon.py b/Main.py similarity index 74% rename from Furious/Gui/Icon.py rename to Main.py index 1bf0d2d..8c1f8d4 100644 --- a/Furious/Gui/Icon.py +++ b/Main.py @@ -1,4 +1,4 @@ -# Copyright (C) 2023 Loren Eteval +# Copyright (C) 2024 Loren Eteval # # This file is part of Furious. # @@ -15,11 +15,8 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from PySide6.QtGui import QIcon +from Furious.__main__ import main -class Icon(QIcon): - def __init__(self, iconFileName): - super().__init__(iconFileName) - - self.iconFileName = iconFileName +if __name__ == '__main__': + main() diff --git a/Translation.py b/Translation.py new file mode 100644 index 0000000..6f58203 --- /dev/null +++ b/Translation.py @@ -0,0 +1,140 @@ +# Copyright (C) 2024 Loren Eteval +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from __future__ import annotations + +from Furious.Core import * +from Furious.Interface import * +from Furious.Library import * +from Furious.Utility import * +from Furious.PyFramework import * +from Furious.QtFramework import * +from Furious.Storage import * +from Furious.TrayActions import * +from Furious.Widget import * +from Furious.Window import * +from Furious.__main__ import * +from Furious.Externals import * + +import copy +import deepl +import logging +import argparse + +logging.basicConfig( + format='[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s', + level=logging.INFO, +) +logging.raiseExceptions = False + +logger = logging.getLogger('Translation') + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('-k', '--key', help='DeepL auth key', required=True) + parser.add_argument( + '-t', '--target', help='Target translation language', required=True + ) + parser.add_argument( + '-i', '--ignore', action='store_true', help='If provided, ignore review value' + ) + parser.add_argument('-p', '--proxy', help='Proxy server used in API') + + args = parser.parse_args() + + target, proxy = args.target, args.proxy + + logger.info(f'target translation language: {target}') + logger.info(f'use proxy: {proxy}') + + translation = copy.deepcopy(TRANSLATION) + + for text, source in TranslationPool: + if text not in translation: + translation[text] = {'source': [source]} + else: + unit = translation[text] + + if source not in unit['source']: + unit['source'].append(source) + + translator = deepl.Translator(args.key, send_platform_info=False, proxy=args.proxy) + + for text in translation.keys(): + # Remove redundant EN translation + translation[text].pop('EN', '') + + targetText = translation[text].get(target, '') + isReviewed = translation[text].get('isReviewed', 'False') + + if targetText and isReviewed == 'True' and not args.ignore: + # Translation already reviewed. Skip + logger.info( + f'skip reviewed translation: \'{text}\' --{target}--> \'{targetText}\'' + ) + else: + result = translator.translate_text( + text, + source_lang='EN', + target_lang=target, + context=( + f'\'{APPLICATION_NAME}\' is application name. ' + 'Please do not translate this word' + ), + ) + + logger.info( + f'query translation: \'{text}\' --{target}--> \'{result.text}\'' + ) + + translation[text][target] = result.text + + if not targetText or translation[text].get('isReviewed') is None: + # Target translation does not exist, or does not have 'isReviewed' field. + # Set 'isReviewed' field to "False" + translation[text]['isReviewed'] = 'False' + + try: + # Write back to file + with open(GEN_TRANSLATION_FILE, 'w', encoding='utf-8') as file: + file.write(f'TRANSLATION = {UJSONEncoder.encode(translation, indent=4)}\n') + except Exception as ex: + # Any non-exit exceptions + + logger.error(f'flush result to \'{GEN_TRANSLATION_FILE}\' failed. {ex}') + else: + logger.info(f'flush result to \'{GEN_TRANSLATION_FILE}\' success') + + unreviewed = 0 + + for text in translation.keys(): + isReviewed = translation[text].get('isReviewed', 'False') + + if isReviewed == 'False': + unreviewed += 1 + + logger.warning(f'have unreviewed translations \'{text}\'') + + if unreviewed > 0: + logger.error(f'have {unreviewed} unreviewed translations') + else: + logger.info(f'all translations have been reviewed') + + +if __name__ == '__main__': + main() diff --git a/setup.py b/setup.py index 5c2c132..bff3621 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,4 @@ -# Copyright (C) 2023 Loren Eteval +# Copyright (C) 2024 Loren Eteval # # This file is part of Furious. # @@ -37,7 +37,6 @@ setup( include_package_data=True, install_requires=[ 'PySide6', - 'Xray-core', 'tun2socks > 2.5.1', 'ujson', 'pybase64', @@ -49,8 +48,8 @@ setup( 'darkdetect[macos-listener]; sys_platform == "darwin"', ], extras_require={ - 'go1.20': ['hysteria > 1.3.5', 'hysteria2 == 2.0.0.1'], - 'go1.21': ['hysteria2 > 2.0.4'], + 'go1.20': ['Xray-core < 1.8.5', 'hysteria > 1.3.5', 'hysteria2 == 2.0.0.1'], + 'go1.21': ['Xray-core > 1.8.5', 'hysteria2 > 2.0.4'], }, entry_points={ 'gui_scripts': [