From e0ae6d5a63ba20fbc36ed9ae3fec5001e9e17682 Mon Sep 17 00:00:00 2001 From: Loren Eteval Date: Mon, 19 May 2025 19:37:20 +0800 Subject: [PATCH] Prepare the 0.5.0 release workload Signed-off-by: Loren Eteval --- Deploy.py | 17 +- Furious/Core/CoreManager.py | 213 ++++- Furious/Externals/GenTranslation.py | 300 +++++- Furious/Interface/ConfigurationFactory.py | 8 + Furious/Interface/CoreFactory.py | 39 +- Furious/Interface/GuiEditorItemFactory.py | 6 +- Furious/Interface/ItemUpdateProtocol.py | 55 -- Furious/Interface/__init__.py | 1 - Furious/PyFramework/Ancestors.py | 80 +- Furious/QtFramework/Ancestors.py | 35 +- Furious/QtFramework/DNSResolver.py | 99 +- Furious/QtFramework/GuiEditorXXX.py | 2 +- Furious/QtFramework/NetworkStateManager.py | 75 +- Furious/QtFramework/QtWidgets.py | 19 +- Furious/QtFramework/TextEditor.py | 7 +- Furious/QtFramework/TextEditorTheme.py | 128 ++- Furious/QtFramework/UpdatesManager.py | 105 ++- Furious/QtFramework/WebGETManager.py | 112 +++ .../QtFramework/XrayAssetDownloadManager.py | 210 +++++ Furious/QtFramework/__init__.py | 2 + Furious/Storage/UserServers.py | 10 +- Furious/Storage/UserSubs.py | 11 +- Furious/Storage/UserTUNSettings.py | 59 ++ Furious/Storage/__init__.py | 1 + Furious/TrayActions/Connect.py | 135 +-- Furious/TrayActions/Import.py | 2 +- Furious/TrayActions/Settings.py | 22 +- Furious/Utility/AppSettingsFn.py | 57 +- Furious/Utility/Constants.py | 13 + Furious/Utility/SystemProxy.py | 2 +- Furious/Utility/SystemRoutingTable.py | 2 +- Furious/Utility/SystemRuntime.py | 4 +- Furious/Utility/Utility.py | 29 + Furious/Widget/Application.py | 1 + Furious/Widget/ConnectProgressBar.py | 5 - Furious/Widget/GuiTUNSettings.py | 207 +++++ Furious/Widget/UserServersQTableWidget.py | 855 ++++++++++++------ Furious/Widget/UserSubsQTableWidget.py | 341 ++++++- Furious/Widget/XrayAssetViewerQListWidget.py | 6 - Furious/Widget/__init__.py | 1 + Furious/Window/AppMainWindow.py | 91 +- Furious/Window/LogViewerWindow.py | 2 +- Furious/Window/QRCodeWindow.py | 4 +- Furious/Window/TextEditorWindow.py | 70 +- Furious/Window/UserSubsWindow.py | 5 +- Furious/Window/XrayAssetViewerWindow.py | 2 +- Furious/__main__.py | 56 +- 47 files changed, 2610 insertions(+), 896 deletions(-) delete mode 100644 Furious/Interface/ItemUpdateProtocol.py create mode 100644 Furious/QtFramework/WebGETManager.py create mode 100644 Furious/QtFramework/XrayAssetDownloadManager.py create mode 100644 Furious/Storage/UserTUNSettings.py create mode 100644 Furious/Widget/GuiTUNSettings.py diff --git a/Deploy.py b/Deploy.py index 48453e8c..53c93b16 100644 --- a/Deploy.py +++ b/Deploy.py @@ -119,7 +119,7 @@ else: ARTIFACT_NAME = '' -def downloadXrayAssets(url, filename): +def downloadXrayAssets(url, filepath): try: import requests except ImportError: @@ -130,9 +130,6 @@ def downloadXrayAssets(url, filename): if not os.path.exists(XRAY_ASSET_DIR): os.makedirs(XRAY_ASSET_DIR) - # Full path where the file will be saved - filepath = os.path.join(XRAY_ASSET_DIR, filename) - # Send an HTTP GET request to the URL response = requests.get(url) response.raise_for_status() # Check if the request was successful @@ -213,18 +210,10 @@ def cleanup(): def download(): - # URLs of geosite and geoip assets - url_geosite = 'https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat' - url_geoip = 'https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat' - - # Xray assets names - filename_geosite = 'geosite.dat' - filename_geoip = 'geoip.dat' - return all( [ - downloadXrayAssets(url_geosite, filename_geosite), - downloadXrayAssets(url_geoip, filename_geoip), + downloadXrayAssets(URL_GEOSITE, XRAY_ASSET_PATH_GEOSITE), + downloadXrayAssets(URL_GEOIP, XRAY_ASSET_PATH_GEOIP), ] ) diff --git a/Furious/Core/CoreManager.py b/Furious/Core/CoreManager.py index 8534f811..9f9a30a2 100644 --- a/Furious/Core/CoreManager.py +++ b/Furious/Core/CoreManager.py @@ -78,6 +78,30 @@ def fixLogObjectPath(config: ConfigurationFactory, attr: str, value: str, log=Tr ) +def getUserTUNSettings(*args, **kwargs): + return AS_UserTUNSettings().get(*args, **kwargs) + + +userPrimaryAdapterInterfaceName = functools.partial( + getUserTUNSettings, 'primaryAdapterInterfaceName', '' +) +userPrimaryAdapterInterfaceIP = functools.partial( + getUserTUNSettings, 'primaryAdapterInterfaceIP', '' +) +userDefaultPrimaryGatewayIP = functools.partial( + getUserTUNSettings, 'defaultPrimaryGatewayIP', '' +) +userTunAdapterInterfaceDNS = functools.partial( + getUserTUNSettings, 'tunAdapterInterfaceDNS', '' +) +userBypassTUNAdapterInterfaceIP = functools.partial( + getUserTUNSettings, 'bypassTUNAdapterInterfaceIP', '' +) +userDisablePrimaryAdapterInterfaceDNS = functools.partial( + getUserTUNSettings, 'disablePrimaryAdapterInterfaceDNS', 'True' +) + + class CoreManager(SupportExitCleanup): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -116,12 +140,12 @@ class CoreManager(SupportExitCleanup): fixLogObjectPath(configcopy, attr, logRedirectValue, log) if routing == 'Bypass Mainland China': - # VPN Mode handling - if not proxyModeOnly and isVPNMode(): + # TUN Mode handling + if not proxyModeOnly and isTUNMode(): mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Critical) mbox.setWindowTitle(_('Unable to connect')) mbox.setText( - _('Routing option with direct rules is not allowed in VPN mode') + _('Routing option with direct rules is not allowed in TUN mode') ) # Show the MessageBox asynchronously @@ -218,12 +242,12 @@ class CoreManager(SupportExitCleanup): success = coreProcess.start(configcopy, **kwargs) elif isinstance(configcopy, ConfigurationHysteria1): if routing == 'Bypass Mainland China': - # VPN Mode handling - if not proxyModeOnly and isVPNMode(): + # TUN Mode handling + if not proxyModeOnly and isTUNMode(): mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Critical) mbox.setWindowTitle(_('Unable to connect')) mbox.setText( - _('Routing option with direct rules is not allowed in VPN mode') + _('Routing option with direct rules is not allowed in TUN mode') ) # Show the MessageBox asynchronously @@ -290,9 +314,9 @@ class CoreManager(SupportExitCleanup): return success - # VPN Mode handling - if not proxyModeOnly and isVPNMode(): - # Currently VPN Mode is only supported on Windows and macOS + # TUN Mode handling + if not proxyModeOnly and isTUNMode(): + # Currently TUN Mode is only supported on Windows and macOS if PLATFORM == 'Windows' or PLATFORM == 'Darwin': if PLATFORM == 'Windows': # cleanup first @@ -300,27 +324,47 @@ class CoreManager(SupportExitCleanup): '0.0.0.0', APPLICATION_TUN_GATEWAY_ADDRESS ) - defaultGateway = SystemRoutingTable.getDefaultGateway() + # Handle user defined settings + userGateway, userInterfaceIP = ( + userDefaultPrimaryGatewayIP(), + userPrimaryAdapterInterfaceIP(), + ) - if PLATFORM == 'Darwin': - # Need this? - defaultGateway = list( - # Filter TUN Gateway - filter( - lambda x: x != APPLICATION_TUN_GATEWAY_ADDRESS, - defaultGateway, - ) + if userGateway and userInterfaceIP: + logger.info( + f'got user defined TUN settings. ' + f'\'DefaultPrimaryGatewayIP\': {userGateway}. ' + f'\'PrimaryAdapterInterfaceIP\': {userInterfaceIP}' ) - if len(defaultGateway) != 1: - logger.error(f'bad default gateway: {defaultGateway}') - - return False - - if PLATFORM == 'Windows': - gateway, interfaceIP = defaultGateway[0] + gateway, interfaceIP = userGateway, userInterfaceIP else: - gateway, interfaceIP = defaultGateway[0], None + logger.info( + f'automatically fetching TUN settings: ' + f'\'DefaultPrimaryGatewayIP\' and \'PrimaryAdapterInterfaceIP\'' + ) + + 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 tunProcess = Tun2socks( exitCallback=exitCallback, msgCallback=msgCallbackTun_ @@ -336,24 +380,61 @@ class CoreManager(SupportExitCleanup): ): return False - address = configcopy.itemAddress + # Handle user defined settings + bypassTUN = userBypassTUNAdapterInterfaceIP() - if not isValidIPAddress(address): - error, resolved = DNSResolver.resolve( - address, *parseHostPort(configcopy.httpProxyEndpoint()) - ) + if bypassTUN: + try: + bypassSplit = bypassTUN.split(',') + except Exception as ex: + # Any non-exit exceptions - if error: - logger.error(f'DNS resolution failed: {address}') + logger.error( + f'error when processing ' f'user TUN bypass Settings: {ex}' + ) SystemRoutingTable.Relations.clear() return False else: - for address in resolved: - SystemRoutingTable.Relations.append([address, gateway]) + for bypass in bypassSplit: + if isValidIPAddress(bypass): + logger.info(f'processing user TUN bypass IP: {bypass}') + + SystemRoutingTable.Relations.append([bypass, gateway]) + else: + logger.error( + f'invalid IP address when processing ' + f'user TUN bypass Settings: {bypass}' + ) + + SystemRoutingTable.Relations.clear() + + return False else: - SystemRoutingTable.Relations.append([address, gateway]) + logger.info( + f'automatically fetching TUN settings: ' + f'\'BypassTUNAdapterInterfaceIP\'' + ) + + address = configcopy.itemAddress + + if not isValidIPAddress(address): + DNSResolver.configureHttpProxy(configcopy.httpProxyEndpoint()) + + error, resolved = DNSResolver.resolve(address) + + 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 @@ -392,7 +473,25 @@ class CoreManager(SupportExitCleanup): return False - alias = SystemRoutingTable.WIN32GetInterfaceAliasByIP(interfaceIP) + # Handle user defined settings + userInterfaceName = userPrimaryAdapterInterfaceName() + + if userInterfaceName: + logger.info( + f'got user defined TUN settings. ' + f'\'PrimaryAdapterInterfaceName\': {userInterfaceName}' + ) + + alias = userInterfaceName + else: + logger.info( + f'automatically fetching TUN settings: ' + f'\'PrimaryAdapterInterfaceName\'' + ) + + alias = SystemRoutingTable.WIN32GetInterfaceAliasByIP( + interfaceIP + ) if alias: @@ -402,14 +501,35 @@ class CoreManager(SupportExitCleanup): tunProcess.cleanup = functools.partial(_windowsCleanup, alias) - SystemRoutingTable.WIN32SetInterfaceDNS( - alias, '127.0.0.1', False + # Handle user defined settings + userDisableInterfaceDNS = ( + userDisablePrimaryAdapterInterfaceDNS() + ) + + logger.info( + f'DisablePrimaryInterfaceDNS: {userDisableInterfaceDNS}' + ) + + if userDisableInterfaceDNS != 'False': + SystemRoutingTable.WIN32SetInterfaceDNS( + alias, '127.0.0.1', False + ) + + # Handle user defined settings + userTunInterfaceDNS = userTunAdapterInterfaceDNS() + + if userTunInterfaceDNS == '': + userTunInterfaceDNS = APPLICATION_TUN_INTERFACE_DNS_ADDRESS + else: + logger.info( + f'got user defined TUN settings. ' + f'TunAdapterInterfaceDNS: {userTunInterfaceDNS}' ) SystemRoutingTable.addRelations() SystemRoutingTable.WIN32SetInterfaceDNS( APPLICATION_TUN_DEVICE_NAME, - APPLICATION_TUN_INTERFACE_DNS_ADDRESS, + userTunInterfaceDNS, False, ) SystemRoutingTable.setDeviceGateway( @@ -436,10 +556,21 @@ class CoreManager(SupportExitCleanup): tunProcess.cleanup = functools.partial(_darwinCleanup, servers) + # Handle user defined settings + userTunInterfaceDNS = userTunAdapterInterfaceDNS() + + if userTunInterfaceDNS == '': + userTunInterfaceDNS = APPLICATION_TUN_INTERFACE_DNS_ADDRESS + else: + logger.info( + f'got user defined TUN settings. ' + f'TunAdapterInterfaceDNS: {userTunInterfaceDNS}' + ) + for service, dnsserver in servers: SystemRoutingTable.DarwinSetDNSServers( service, - APPLICATION_TUN_INTERFACE_DNS_ADDRESS, + userTunInterfaceDNS, ) SystemRoutingTable.setDeviceGateway( diff --git a/Furious/Externals/GenTranslation.py b/Furious/Externals/GenTranslation.py index b2ebe5b9..b89d9aa4 100644 --- a/Furious/Externals/GenTranslation.py +++ b/Furious/Externals/GenTranslation.py @@ -422,7 +422,8 @@ TRANSLATION = { "Furious.Widget.GuiTrojan", "Furious.Widget.GuiVLESS", "Furious.Widget.GuiVMess", - "Furious.Widget.UserServersQTableWidget" + "Furious.Widget.UserServersQTableWidget", + "Furious.Widget.UserSubsQTableWidget" ], "RU": "Примечание", "ZH": "别名", @@ -1015,28 +1016,28 @@ TRANSLATION = { "ZH": "路由", "isReviewed": "True" }, - "VPN Mode": { + "TUN Mode": { "source": [ "Furious.TrayActions.Settings" ], - "RU": "VPN режим", - "ZH": "VPN模式", + "RU": "TUN режим", + "ZH": "TUN模式", "isReviewed": "True" }, - "VPN Mode Disabled (Administrator)": { + "TUN Mode Disabled (Administrator)": { "source": [ "Furious.TrayActions.Settings" ], - "RU": "VPN режим отключен (Administrator)", - "ZH": "VPN模式已禁用(管理员)", + "RU": "TUN режим отключен (Administrator)", + "ZH": "TUN模式已禁用(管理员)", "isReviewed": "True" }, - "VPN Mode Disabled (root)": { + "TUN Mode Disabled (root)": { "source": [ "Furious.TrayActions.Settings" ], - "RU": "VPN режим отключен (root)", - "ZH": "VPN模式已禁用(root)", + "RU": "TUN режим отключен (root)", + "ZH": "TUN模式已禁用(root)", "isReviewed": "True" }, "Settings": { @@ -1213,6 +1214,7 @@ TRANSLATION = { "Furious.Widget.GuiHysteria2", "Furious.Widget.GuiShadowsocks", "Furious.Widget.GuiTrojan", + "Furious.Widget.GuiTUNSettings", "Furious.Widget.GuiVLESS", "Furious.Widget.GuiVMess" ], @@ -1341,12 +1343,12 @@ TRANSLATION = { "ZH": "添加Hysteria2服务器...", "isReviewed": "True" }, - "Routing option with direct rules is not allowed in VPN mode": { + "Routing option with direct rules is not allowed in TUN mode": { "source": [ "Furious.Core.CoreManager" ], - "RU": "Опция маршрутизации с прямыми правилами не разрешена в режиме VPN", - "ZH": "VPN模式下不允许使用直接规则的路由选项", + "RU": "Опция маршрутизации с прямыми правилами не разрешена в режиме TUN", + "ZH": "TUN模式下不允许使用直接规则的路由选项", "isReviewed": "True" }, "Select and press Enter to activate configuration and connect": { @@ -1375,5 +1377,277 @@ TRANSLATION = { "RU": "OK", "ZH": "OK", "isReviewed": "True" + }, + "Advanced...": { + "source": [ + "Furious.Widget.UserServersQTableWidget" + ], + "RU": "Расширенные...", + "ZH": "高级...", + "isReviewed": "True" + }, + "is available to download": { + "source": [ + "Furious.TrayActions.Connect" + ], + "RU": "доступен для скачивания", + "ZH": "可供下载", + "isReviewed": "True" + }, + "Update subscription completed": { + "source": [ + "Furious.Widget.UserServersQTableWidget" + ], + "RU": "Подписка на обновление завершена", + "ZH": "更新订阅完成", + "isReviewed": "True" + }, + "Update subscription failed": { + "source": [ + "Furious.Widget.UserServersQTableWidget" + ], + "RU": "Обновить подписку не удалось", + "ZH": "更新订阅失败", + "isReviewed": "True" + }, + "Configuration has been updated": { + "source": [ + "Furious.Widget.UserServersQTableWidget" + ], + "RU": "Конфигурация обновлена", + "ZH": "配置已更新", + "isReviewed": "True" + }, + "Configuration update failed": { + "source": [ + "Furious.Widget.UserServersQTableWidget" + ], + "RU": "Обновление конфигурации не удалось", + "ZH": "配置更新失败", + "isReviewed": "True" + }, + "Auto Update": { + "source": [ + "Furious.Widget.UserSubsQTableWidget" + ], + "RU": "Авто обновление", + "ZH": "自动更新", + "isReviewed": "True" + }, + "Auto Update Use Proxy": { + "source": [ + "Furious.Widget.UserSubsQTableWidget" + ], + "RU": "Авто обновление Использовать прокси", + "ZH": "自动更新使用代理", + "isReviewed": "True" + }, + "Never": { + "source": [ + "Furious.Widget.UserSubsQTableWidget" + ], + "RU": "Никогда", + "ZH": "从不", + "isReviewed": "True" + }, + "Every 5 mins": { + "source": [ + "Furious.Widget.UserSubsQTableWidget" + ], + "RU": "Каждые 5 минут", + "ZH": "每5分钟", + "isReviewed": "True" + }, + "Every 10 mins": { + "source": [ + "Furious.Widget.UserSubsQTableWidget" + ], + "RU": "Каждые 10 минут", + "ZH": "每10分钟", + "isReviewed": "True" + }, + "Every 15 mins": { + "source": [ + "Furious.Widget.UserSubsQTableWidget" + ], + "RU": "Каждые 15 минут", + "ZH": "每15分钟", + "isReviewed": "True" + }, + "Every 30 mins": { + "source": [ + "Furious.Widget.UserSubsQTableWidget" + ], + "RU": "Каждые 30 минут", + "ZH": "每30分钟", + "isReviewed": "True" + }, + "Every 45 mins": { + "source": [ + "Furious.Widget.UserSubsQTableWidget" + ], + "RU": "Каждые 45 минут", + "ZH": "每45分钟", + "isReviewed": "True" + }, + "Every 1 hour": { + "source": [ + "Furious.Widget.UserSubsQTableWidget" + ], + "RU": "Каждый 1 час", + "ZH": "每1小时", + "isReviewed": "True" + }, + "Every 2 hours": { + "source": [ + "Furious.Widget.UserSubsQTableWidget" + ], + "RU": "Каждые 2 часа", + "ZH": "每2小时", + "isReviewed": "True" + }, + "Every 3 hours": { + "source": [ + "Furious.Widget.UserSubsQTableWidget" + ], + "RU": "Каждые 3 часа", + "ZH": "每3小时", + "isReviewed": "True" + }, + "Every 6 hours": { + "source": [ + "Furious.Widget.UserSubsQTableWidget" + ], + "RU": "Каждые 6 часа", + "ZH": "每6小时", + "isReviewed": "True" + }, + "Every 8 hours": { + "source": [ + "Furious.Widget.UserSubsQTableWidget" + ], + "RU": "Каждые 8 часа", + "ZH": "每8小时", + "isReviewed": "True" + }, + "Every 10 hours": { + "source": [ + "Furious.Widget.UserSubsQTableWidget" + ], + "RU": "Каждые 10 часа", + "ZH": "每10小时", + "isReviewed": "True" + }, + "Every 12 hours": { + "source": [ + "Furious.Widget.UserSubsQTableWidget" + ], + "RU": "Каждые 12 часа", + "ZH": "每12小时", + "isReviewed": "True" + }, + "Every 24 hours": { + "source": [ + "Furious.Widget.UserSubsQTableWidget" + ], + "RU": "Каждые 24 часа", + "ZH": "每24小时", + "isReviewed": "True" + }, + "Use current proxy": { + "source": [ + "Furious.Widget.UserSubsQTableWidget" + ], + "RU": "Использовать текущий прокси-сервер", + "ZH": "使用当前代理", + "isReviewed": "True" + }, + "Force proxy": { + "source": [ + "Furious.Widget.UserSubsQTableWidget" + ], + "RU": "Обязательно через прокси-сервер", + "ZH": "强制代理", + "isReviewed": "True" + }, + "No proxy": { + "source": [ + "Furious.Widget.UserSubsQTableWidget" + ], + "RU": "Не использовать прокси", + "ZH": "无代理", + "isReviewed": "True" + }, + "Customize TUN Settings": { + "source": [ + "Furious.Widget.GuiTUNSettings" + ], + "RU": "Настройте параметры TUN", + "ZH": "自定义TUN设置", + "isReviewed": "True" + }, + "Customize TUN Settings...": { + "source": [ + "Furious.Window.AppMainWindow" + ], + "RU": "Настройте параметры TUN...", + "ZH": "自定义TUN设置...", + "isReviewed": "True" + }, + "Go to help page": { + "source": [ + "Furious.Widget.GuiTUNSettings" + ], + "RU": "Перейти на страницу помощи", + "ZH": "转到帮助页面", + "isReviewed": "True" + }, + "Primary Adapter Interface Name": { + "source": [ + "Furious.Widget.GuiTUNSettings" + ], + "RU": "Имя основного сетевого адаптера", + "ZH": "主适配器接口名称", + "isReviewed": "True" + }, + "Primary Adapter Interface IP": { + "source": [ + "Furious.Widget.GuiTUNSettings" + ], + "RU": "IP-адрес основного сетевого адаптера", + "ZH": "主适配器接口IP", + "isReviewed": "True" + }, + "Default Primary Gateway IP": { + "source": [ + "Furious.Widget.GuiTUNSettings" + ], + "RU": "IP-адрес основного шлюза по умолчанию", + "ZH": "默认主网关IP", + "isReviewed": "True" + }, + "TUN Adapter Interface DNS": { + "source": [ + "Furious.Widget.GuiTUNSettings" + ], + "RU": "DNS-адрес интерфейса адаптера TUN", + "ZH": "TUN适配器接口DNS", + "isReviewed": "True" + }, + "Bypass TUN adapter interface IP (separated by commas)": { + "source": [ + "Furious.Widget.GuiTUNSettings" + ], + "RU": "Обход IP-адреса интерфейса адаптера TUN (через запятую)", + "ZH": "绕过TUN适配器接口IP(以逗号分割)", + "isReviewed": "True" + }, + "Disable Primary Adapter Interface DNS (Mitigating DNS leaks on Windows)": { + "source": [ + "Furious.Widget.GuiTUNSettings" + ], + "RU": "Отключить DNS основного сетевого адаптера (для предотвращения утечек DNS в Windows)", + "ZH": "禁用主适配器接口DNS(在Windows上缓解DNS泄露)", + "isReviewed": "True" } } diff --git a/Furious/Interface/ConfigurationFactory.py b/Furious/Interface/ConfigurationFactory.py index deecdf66..efe4f874 100644 --- a/Furious/Interface/ConfigurationFactory.py +++ b/Furious/Interface/ConfigurationFactory.py @@ -47,6 +47,8 @@ class ConfigurationFactory(UserServersTableItem, dict, ABC): :param config: The input configuration. Can be a string or dict """ + self._index = kwargs.pop('index', 0) + # Extra attributes self.kwargs = kwargs @@ -96,6 +98,12 @@ class ConfigurationFactory(UserServersTableItem, dict, ABC): def setExtras(self, item, value): self.kwargs[item] = value + def index(self) -> int: + return self._index + + def setIndex(self, index): + self._index = index + @property def itemRemark(self) -> str: return self.getExtras('remark') diff --git a/Furious/Interface/CoreFactory.py b/Furious/Interface/CoreFactory.py index e2093dcf..da6303f0 100644 --- a/Furious/Interface/CoreFactory.py +++ b/Furious/Interface/CoreFactory.py @@ -27,15 +27,7 @@ import ujson __all__ = ['CoreFactory'] -class CoreFactorySuper: - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - -ExitCallbackType = Callable[[CoreFactorySuper, int], None] - - -class CoreFactory(CoreFactorySuper, ABC): +class CoreFactory(ABC): class ExitCode: ConfigurationError = 23 # Windows: 4294967295. Darwin, Linux: 255 (-1) @@ -43,21 +35,14 @@ class CoreFactory(CoreFactorySuper, ABC): # Windows shutting down SystemShuttingDown = 0x40010004 - def __init__(self, exitCallback: ExitCallbackType = None): + def __init__( + self, exitCallback: Union[Callable[[CoreFactory, int], None], None] = None + ): super().__init__() self._exitCallback = exitCallback self._jsonConfig = '' - @property - def exitCallback(self) -> ExitCallbackType: - return self._exitCallback - - def registerExitCallback(self, exitCallback: ExitCallbackType) -> CoreFactory: - self._exitCallback = exitCallback - - return self - def callExitCallback(self, exitcode: int): if callable(self._exitCallback): self._exitCallback(self, exitcode) @@ -67,14 +52,6 @@ class CoreFactory(CoreFactorySuper, ABC): return self - @staticmethod - def name() -> str: - raise NotImplementedError - - @staticmethod - def version() -> str: - raise NotImplementedError - def configJSONString(self) -> str: if isinstance(self._jsonConfig, str): return self._jsonConfig @@ -105,6 +82,14 @@ class CoreFactory(CoreFactorySuper, ABC): return {} + @staticmethod + def name() -> str: + raise NotImplementedError + + @staticmethod + def version() -> str: + raise NotImplementedError + def start(self, *args, **kwargs) -> bool: raise NotImplementedError diff --git a/Furious/Interface/GuiEditorItemFactory.py b/Furious/Interface/GuiEditorItemFactory.py index 1b684eb2..e1d2c07f 100644 --- a/Furious/Interface/GuiEditorItemFactory.py +++ b/Furious/Interface/GuiEditorItemFactory.py @@ -17,8 +17,6 @@ from __future__ import annotations -from Furious.Interface.ConfigurationFactory import ConfigurationFactory - from typing import Any __all__ = ['GuiEditorItemFactory', 'GuiEditorItemWidgetContainer'] @@ -28,10 +26,10 @@ class GuiEditorItemFactory: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - def inputToFactory(self, config: ConfigurationFactory) -> Any: + def inputToFactory(self, *args, **kwargs) -> Any: raise NotImplementedError - def factoryToInput(self, config: ConfigurationFactory) -> Any: + def factoryToInput(self, *args, **kwargs) -> Any: raise NotImplementedError diff --git a/Furious/Interface/ItemUpdateProtocol.py b/Furious/Interface/ItemUpdateProtocol.py deleted file mode 100644 index f9fb1fea..00000000 --- a/Furious/Interface/ItemUpdateProtocol.py +++ /dev/null @@ -1,55 +0,0 @@ -# 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/__init__.py b/Furious/Interface/__init__.py index d67fd161..8dde45c5 100644 --- a/Furious/Interface/__init__.py +++ b/Furious/Interface/__init__.py @@ -18,7 +18,6 @@ from .Encoder import * from .GuiEditorItemFactory import * from .UserServersTableItem import * -from .ItemUpdateProtocol import * from .Storage import * from .Application import * from .ConfigurationFactory import * diff --git a/Furious/PyFramework/Ancestors.py b/Furious/PyFramework/Ancestors.py index 3d9c5f6d..6d3a08d6 100644 --- a/Furious/PyFramework/Ancestors.py +++ b/Furious/PyFramework/Ancestors.py @@ -19,19 +19,20 @@ from __future__ import annotations from PySide6.QtWidgets import QApplication +from typing import Any + +import logging + +logger = logging.getLogger(__name__) + __all__ = [ 'Translatable', 'SupportConnectedCallback', 'SupportThemeChangedCallback', 'SupportExitCleanup', - 'SupportImplicitReference', - 'FastItemDeletionSearch', + 'GarbageCollector', ] -import logging - -logger = logging.getLogger(__name__) - class Translatable: ObjectsPool = list() @@ -48,11 +49,11 @@ class Translatable: @staticmethod def retranslateAll(): - for ob in Translatable.ObjectsPool: - assert isinstance(ob, Translatable) + for object_ in Translatable.ObjectsPool: + assert isinstance(object_, Translatable) - if ob.translatable: - ob.retranslate() + if object_.translatable: + object_.retranslate() class SupportConnectedCallback: @@ -71,17 +72,17 @@ class SupportConnectedCallback: @staticmethod def callConnectedCallback(): - for ob in SupportConnectedCallback.ObjectsPool: - assert isinstance(ob, SupportConnectedCallback) + for object_ in SupportConnectedCallback.ObjectsPool: + assert isinstance(object_, SupportConnectedCallback) - ob.connectedCallback() + object_.connectedCallback() @staticmethod def callDisconnectedCallback(): - for ob in SupportConnectedCallback.ObjectsPool: - assert isinstance(ob, SupportConnectedCallback) + for object_ in SupportConnectedCallback.ObjectsPool: + assert isinstance(object_, SupportConnectedCallback) - ob.disconnectedCallback() + object_.disconnectedCallback() class SupportThemeChangedCallback: @@ -97,10 +98,10 @@ class SupportThemeChangedCallback: @staticmethod def callThemeChangedCallbackUnchecked(theme: str): - for ob in SupportThemeChangedCallback.ObjectsPool: - assert isinstance(ob, SupportThemeChangedCallback) + for object_ in SupportThemeChangedCallback.ObjectsPool: + assert isinstance(object_, SupportThemeChangedCallback) - ob.themeChangedCallback(theme) + object_.themeChangedCallback(theme) @staticmethod def callThemeChangedCallback(theme: str): @@ -138,40 +139,37 @@ class SupportExitCleanup: @staticmethod def cleanupAll(): - for ob in SupportExitCleanup.ObjectsPool: - assert isinstance(ob, SupportExitCleanup) + for object_ in SupportExitCleanup.ObjectsPool: + assert isinstance(object_, SupportExitCleanup) - if ob.uniqueCleanup: - obtype = str(type(ob)) + if object_.uniqueCleanup: + obtype = str(type(object_)) if not SupportExitCleanup.VisitedType.get(obtype, False): - ob.cleanup() + object_.cleanup() SupportExitCleanup.VisitedType[obtype] = True else: pass else: - ob.cleanup() + object_.cleanup() -class SupportImplicitReference: +class GarbageCollector: ObjectsPool = list() - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - SupportImplicitReference.ObjectsPool.append(self) - - -class FastItemDeletionSearch: - DeletedItem = list() - DeletedId = dict() + # ObjectsId is used to speed up searching + ObjectsId = dict() @staticmethod - def moveToTrash(item): - FastItemDeletionSearch.DeletedItem.append(item) - FastItemDeletionSearch.DeletedId[id(item)] = True + def track(object_: Any): + GarbageCollector.ObjectsPool.append(object_) + GarbageCollector.ObjectsId[id(object_)] = True @staticmethod - def isInTrash(item) -> bool: - return id(item) in FastItemDeletionSearch.DeletedId + def isTracked(object_: Any) -> bool: + return id(object_) in GarbageCollector.ObjectsId + + @staticmethod + def clear(): + GarbageCollector.ObjectsPool.clear() + GarbageCollector.ObjectsId.clear() diff --git a/Furious/QtFramework/Ancestors.py b/Furious/QtFramework/Ancestors.py index e445f7d7..30739db0 100644 --- a/Furious/QtFramework/Ancestors.py +++ b/Furious/QtFramework/Ancestors.py @@ -15,36 +15,38 @@ # 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 * -__all__ = ['QBlockSignals', 'QTranslatable'] +from PySide6 import QtCore + +__all__ = ['QBlockSignals', 'QTranslatable', 'QGarbageCollector'] class QProtection: - def __init__(self, qobject): + def __init__(self, qobject: QtCore.QObject): self.qobject = qobject - self.hasattr = hasattr(self.qobject, 'setDisabled') def __enter__(self): - if self.hasattr: + if hasattr(self.qobject, 'setDisabled'): self.qobject.setDisabled(True) def __exit__(self, exceptionType, exceptionValue, tb): - if self.hasattr: + if hasattr(self.qobject, 'setDisabled'): self.qobject.setDisabled(False) class QBlockSignals: - def __init__(self, qobject): + def __init__(self, qobject: QtCore.QObject): self.qobject = qobject - self.hasattr = hasattr(self.qobject, 'blockSignals') def __enter__(self): - if self.hasattr: + if hasattr(self.qobject, 'blockSignals'): self.qobject.blockSignals(True) def __exit__(self, exceptionType, exceptionValue, tb): - if self.hasattr: + if hasattr(self.qobject, 'blockSignals'): self.qobject.blockSignals(False) @@ -64,7 +66,22 @@ class QTranslatable(Translatable): if ob.translatable: if ob.useQProtection: + assert isinstance(ob, QtCore.QObject) + with QProtection(ob): ob.retranslate() else: ob.retranslate() + + +class QGarbageCollector(GarbageCollector): + Timer = QtCore.QTimer() + + @staticmethod + def isEnabled(): + return QGarbageCollector.Timer.isActive() + + @staticmethod + def collect(timeout: int): + QGarbageCollector.Timer.timeout.connect(QGarbageCollector.clear) + QGarbageCollector.Timer.start(timeout) diff --git a/Furious/QtFramework/DNSResolver.py b/Furious/QtFramework/DNSResolver.py index 61240df2..bf550d43 100644 --- a/Furious/QtFramework/DNSResolver.py +++ b/Furious/QtFramework/DNSResolver.py @@ -17,6 +17,7 @@ from __future__ import annotations +from Furious.QtFramework.WebGETManager import * from Furious.Utility import * from Furious.Library import * @@ -32,8 +33,11 @@ __all__ = ['DNSResolver'] logger = logging.getLogger(__name__) -class DNSResolver: - Manager = QNetworkAccessManager() +class _DNSResolver(WebGETManager): + def __init__(self, parent=None, **kwargs): + actionMessage = kwargs.pop('actionMessage', 'DNS resolution') + + super().__init__(parent, actionMessage=actionMessage) @staticmethod def request(address) -> QNetworkRequest: @@ -44,22 +48,23 @@ class DNSResolver: return request - @staticmethod - def handleFinishedByNetworkReply(networkReply, domain, resultMap): - assert isinstance(networkReply, QNetworkReply) + def successCallback(self, networkReply, **kwargs): + domain = kwargs.pop('domain', '') + resultMap = kwargs.pop('resultMap', {}) - if networkReply.error() != QNetworkReply.NetworkError.NoError: + data = networkReply.readAll().data() + + try: + replyObject = UJSONEncoder.decode(data) + except Exception as ex: logger.error( - f'DNS resolution for \'{domain}\' failed. {networkReply.errorString()}' + f'bad network reply while resolving DNS for \'{domain}\'. {ex}' ) 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'] @@ -70,46 +75,31 @@ class DNSResolver: else: resultMap['depth'] += 1 - newNetworkReply = DNSResolver.Manager.get( - DNSResolver.request(address) - ) - newNetworkReply.finished.connect( - functools.partial( - DNSResolver.handleFinishedByNetworkReply, - newNetworkReply, - address, - resultMap, - ) + newNetworkReply = self.webGET( + self.request(address), + logActionMessage=False, + domain=address, + resultMap=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) - ) - ) + def failureCallback(self, networkReply: QNetworkReply, **kwargs): + domain = kwargs.pop('domain', '') + resultMap = kwargs.pop('resultMap', {}) - logger.info(f'DNS resolution uses proxy server {proxyHost}:{proxyPort}') - except Exception as ex: - # Any non-exit exceptions + logger.error( + f'DNS resolution for \'{domain}\' failed. {networkReply.errorString()}' + ) - logger.error( - f'invalid proxy server {proxyHost}:{proxyPort}. {ex}. ' - f'DNS resolution uses no proxy' - ) - - DNSResolver.Manager.setProxy(QNetworkProxy.ProxyType.NoProxy) + resultMap['error'] = True + resultMap['depth'] -= 1 + def resolve(self, domain, timeout=30000) -> Tuple[bool, list[str]]: resultMap = { + 'domain': domain, 'depth': 0, 'error': False, 'reference': [], @@ -118,26 +108,28 @@ class DNSResolver: resultMap['depth'] += 1 - networkReply = DNSResolver.Manager.get(DNSResolver.request(domain)) - networkReply.finished.connect( - functools.partial( - DNSResolver.handleFinishedByNetworkReply, - networkReply, - domain, - resultMap, - ) + networkReply = self.webGET( + self.request(domain), + logActionMessage=False, + domain=domain, + resultMap=resultMap, ) resultMap['reference'].append(networkReply) - DNSResolver.wait(resultMap) + self.wait(resultMap, timeout=timeout) return resultMap['error'], list(resultMap['result'].keys()) @staticmethod def wait(resultMap, startCounter=0, timeout=30000, step=100): + domain = resultMap.get('domain', '') + + if not domain: + return + if resultMap['depth'] != 0: - logger.info('DNS resolution in progress. Wait') + logger.info(f'DNS resolution for \'{domain}\' in progress. Wait') else: return @@ -147,7 +139,9 @@ class DNSResolver: startCounter += step if resultMap['depth'] != 0: - logger.error('DNS resolution timeout') + logger.error( + f'DNS resolution for \'{domain}\' reached timeout {timeout // 1000}s' + ) for networkReply in resultMap['reference']: if ( @@ -155,3 +149,6 @@ class DNSResolver: and not networkReply.isFinished() ): networkReply.abort() + + +DNSResolver = _DNSResolver() diff --git a/Furious/QtFramework/GuiEditorXXX.py b/Furious/QtFramework/GuiEditorXXX.py index be3dd58b..0c9138f1 100644 --- a/Furious/QtFramework/GuiEditorXXX.py +++ b/Furious/QtFramework/GuiEditorXXX.py @@ -95,7 +95,7 @@ class GuiEditorItemTextComboBox(GuiEditorItemWidgetContainer): super().__init__(*args, **kwargs) self._title = AppQLabel(_(title), translatable=translatable, parent=parent) - self._input = QComboBox(parent=parent) + self._input = AppQComboBox(parent=parent, translatable=False) def text(self) -> str: return self._input.currentText() diff --git a/Furious/QtFramework/NetworkStateManager.py b/Furious/QtFramework/NetworkStateManager.py index 04c2f78c..704aa1f4 100644 --- a/Furious/QtFramework/NetworkStateManager.py +++ b/Furious/QtFramework/NetworkStateManager.py @@ -15,8 +15,10 @@ # 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.QtNetwork import * +from Furious.QtFramework.WebGETManager import * from Furious.Utility import * from PySide6 import QtCore @@ -30,12 +32,14 @@ __all__ = ['NetworkStateManager'] logger = logging.getLogger(__name__) -class NetworkStateManager(SupportConnectedCallback, AppQNetworkAccessManager): +class NetworkStateManager(SupportConnectedCallback, WebGETManager): MIN_JOB_INTERVAL = 2500 MAX_JOB_INTERVAL = 2000000000 - def __init__(self, parent=None): - super().__init__(parent) + def __init__(self, parent=None, **kwargs): + actionMessage = kwargs.pop('actionMessage', 'test network state') + + super().__init__(parent, actionMessage=actionMessage) self.jobStatus = False self.jobInterval = NetworkStateManager.MIN_JOB_INTERVAL @@ -45,58 +49,32 @@ class NetworkStateManager(SupportConnectedCallback, AppQNetworkAccessManager): self.jobArrangeTimer.timeout.connect(lambda: self.startSingleTest()) - def successCallback(self): - raise NotImplementedError + def recalculateJobInterval(self, jobStatus: bool) -> int: + assert isinstance(jobStatus, bool) - 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 + if self.jobStatus is jobStatus: + self.jobInterval *= 2 else: - self.jobTimeoutTimer.stop() + self.jobInterval = NetworkStateManager.MIN_JOB_INTERVAL - 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 + self.jobStatus = jobStatus if self.jobInterval >= NetworkStateManager.MAX_JOB_INTERVAL: # Limited self.jobInterval = NetworkStateManager.MAX_JOB_INTERVAL - self.jobArrangeTimer.start(self.jobInterval) + return self.jobInterval + + def successCallback(self, networkReply, **kwargs): + self.jobTimeoutTimer.stop() + self.jobArrangeTimer.start(self.recalculateJobInterval(jobStatus=True)) + + def failureCallback(self, networkReply, **kwargs): + self.jobTimeoutTimer.stop() + self.jobArrangeTimer.start(self.recalculateJobInterval(jobStatus=False)) def startSingleTest(self): - networkReply = self.get(QNetworkRequest(QtCore.QUrl(NETWORK_STATE_TEST_URL))) - networkReply.finished.connect( - functools.partial( - self.handleFinishedByNetworkReply, - networkReply, - ) - ) + networkReply = self.webGET(NETWORK_STATE_TEST_URL) def abort(_networkReply): if isinstance(_networkReply, QNetworkReply): @@ -107,17 +85,18 @@ class NetworkStateManager(SupportConnectedCallback, AppQNetworkAccessManager): def stopTest(self): self.jobArrangeTimer.stop() + self.jobTimeoutTimer.stop() def connectedCallback(self): if AppSettings.isStateON_('PowerSaveMode'): # Power optimization logger.info('no job for network state manager in power save mode') - self.jobArrangeTimer.stop() + self.stopTest() else: self.jobInterval = NetworkStateManager.MIN_JOB_INTERVAL self.jobArrangeTimer.start(self.jobInterval) def disconnectedCallback(self): - self.jobArrangeTimer.stop() + self.stopTest() diff --git a/Furious/QtFramework/QtWidgets.py b/Furious/QtFramework/QtWidgets.py index c7a56d0e..5191f2a7 100644 --- a/Furious/QtFramework/QtWidgets.py +++ b/Furious/QtFramework/QtWidgets.py @@ -34,6 +34,7 @@ import functools __all__ = [ 'moveToCenter', 'AppQCheckBox', + 'AppQComboBox', 'AppQDialog', 'AppQDialogButtonBox', 'AppQGroupBox', @@ -77,6 +78,15 @@ class AppQCheckBox(QTranslatable, QCheckBox): self.setText(_(self.text())) +class AppQComboBox(QTranslatable, QComboBox): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def retranslate(self): + for index in range(self.count()): + self.setItemText(index, _(self.itemText(index))) + + class AppQDialog(QTranslatable, SupportConnectedCallback, QDialog): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -176,7 +186,7 @@ class AppQHeaderView(SupportExitCleanup, SupportConnectedCallback, QHeaderView): self.sectionSizeTable[str(column)] = self.defaultSectionSize() with QBlockSignals(self): - for key, value in self.sectionSizeTable.items(): + for key, value in reversed(self.sectionSizeTable.items()): self.resizeSection(int(key), value) except Exception: # Any non-exit exceptions @@ -293,15 +303,12 @@ class AppQMainWindow( moveToCenter(self) + APP().processEvents() + if PLATFORM == 'Darwin': self.activateWindow() self.raise_() - def closeEvent(self, event): - event.ignore() - - self.hide() - def retranslate(self): self.setWindowTitle(_(self.windowTitle())) diff --git a/Furious/QtFramework/TextEditor.py b/Furious/QtFramework/TextEditor.py index 0fe9341e..85985d61 100644 --- a/Furious/QtFramework/TextEditor.py +++ b/Furious/QtFramework/TextEditor.py @@ -18,10 +18,7 @@ from __future__ import annotations from Furious.PyFramework import * -from Furious.QtFramework.TextEditorTheme import ( - DraculaEditorTheme, - DraculaJSONSyntaxHighlighter, -) +from Furious.QtFramework.TextEditorTheme import * from Furious.Utility import * from PySide6 import QtCore @@ -314,6 +311,8 @@ class DraculaTextBrowser(AppQTextBrowser): super().__init__(*args, **kwargs) + self._syntaxHighlighter = DraculaLoggerSyntaxHighlighter(self.document()) + # Theme self.setStyleSheet( DraculaEditorTheme.getStyleSheet( diff --git a/Furious/QtFramework/TextEditorTheme.py b/Furious/QtFramework/TextEditorTheme.py index 8dcb3906..c095695f 100644 --- a/Furious/QtFramework/TextEditorTheme.py +++ b/Furious/QtFramework/TextEditorTheme.py @@ -15,14 +15,22 @@ # 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 PySide6 import QtCore from PySide6.QtGui import * -__all__ = ['DraculaEditorTheme', 'DraculaJSONSyntaxHighlighter'] +__all__ = [ + 'DraculaEditorTheme', + 'DraculaJSONSyntaxHighlighter', + 'DraculaLoggerSyntaxHighlighter', +] class EditorHighlightRules: - def __init__(self, regex, color, isBold=False, isJSONKey=False): + def __init__(self, regex, color, isBold=False, isItalic=False, isJSONKey=False): self.regex = QtCore.QRegularExpression(regex) self.color = QColor(color) @@ -32,6 +40,9 @@ class EditorHighlightRules: if isBold: self.rules.setFontWeight(QFont.Weight.Bold) + if isItalic: + self.rules.setFontItalic(True) + self.isJSONKey = isJSONKey @@ -59,7 +70,46 @@ class DraculaEditorTheme(EditorTheme): ) -class DraculaJSONSyntaxHighlighter(QSyntaxHighlighter): +class AppQSyntaxHighlighter(QSyntaxHighlighter): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.highlightRules = list() + + def highlightBlock(self, text): + for highlightRule in self.highlightRules: + iterator = highlightRule.regex.globalMatch(text) + + while iterator.hasNext(): + match = iterator.next() + + if highlightRule.isJSONKey: + # JSON keys. Ignore trailing : + capturedLength = match.capturedLength() - 1 + else: + capturedLength = match.capturedLength() + + capturedStart = match.capturedStart() + captured = text[capturedStart : capturedStart + capturedLength] + shouldHighlight = True + + for version in [ + XrayCore.version(), + Hysteria1.version(), + Hysteria2.version(), + Tun2socks.version(), + ]: + if captured == version: + # These x.y.z.u version values are not IPv4 addresses. Do not highlight + shouldHighlight = False + + break + + if shouldHighlight: + self.setFormat(capturedStart, capturedLength, highlightRule.rules) + + +class DraculaJSONSyntaxHighlighter(AppQSyntaxHighlighter): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -77,22 +127,66 @@ class DraculaJSONSyntaxHighlighter(QSyntaxHighlighter): r'"([^"\\]*(\\.[^"\\]*)*)"\s*:', '#50FA7B', isJSONKey=True ), # Comments(only for hints on display). Grey - EditorHighlightRules(r'^#.*', '#6272a4'), + EditorHighlightRules(r'^#.*', '#6272A4'), ] - def highlightBlock(self, text): - for highlightRule in self.highlightRules: - iterator = highlightRule.regex.globalMatch(text) - while iterator.hasNext(): - match = iterator.next() +class DraculaLoggerSyntaxHighlighter(AppQSyntaxHighlighter): + # https://ihateregex.io/expr/ip/ + IPV4_REGEX = ( + r'(\b25[0-5]|\b2[0-4][0-9]|\b[01]?[0-9][0-9]?)' + r'(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}(?::\d{1,5})?\b' + ) + # https://vernon.mauery.com/content/2008/04/21/ipv6-regex/ + IPV6_REGEX = ( + r'(A([0-9a-f]{1,4}:){1,1}(:[0-9a-f]{1,4}){1,6}Z)|' + r'(A([0-9a-f]{1,4}:){1,2}(:[0-9a-f]{1,4}){1,5}Z)|' + r'(A([0-9a-f]{1,4}:){1,3}(:[0-9a-f]{1,4}){1,4}Z)|' + r'(A([0-9a-f]{1,4}:){1,4}(:[0-9a-f]{1,4}){1,3}Z)|' + r'(A([0-9a-f]{1,4}:){1,5}(:[0-9a-f]{1,4}){1,2}Z)|' + r'(A([0-9a-f]{1,4}:){1,6}(:[0-9a-f]{1,4}){1,1}Z)|' + r'(A(([0-9a-f]{1,4}:){1,7}|:):Z)|' + r'(A:(:[0-9a-f]{1,4}){1,7}Z)|' + r'(A((([0-9a-f]{1,4}:){6})(25[0-5]|2[0-4]d|[0-1]?d?d)(.(25[0-5]|2[0-4]d|[0-1]?d?d)){3})Z)|' + r'(A(([0-9a-f]{1,4}:){5}[0-9a-f]{1,4}:(25[0-5]|2[0-4]d|[0-1]?d?d)(.(25[0-5]|2[0-4]d|[0-1]?d?d)){3})Z)|' + r'(A([0-9a-f]{1,4}:){5}:[0-9a-f]{1,4}:(25[0-5]|2[0-4]d|[0-1]?d?d)(.(25[0-5]|2[0-4]d|[0-1]?d?d)){3}Z)|' + r'(A([0-9a-f]{1,4}:){1,1}(:[0-9a-f]{1,4}){1,4}:(25[0-5]|2[0-4]d|[0-1]?d?d)(.(25[0-5]|2[0-4]d|[0-1]?d?d)){3}Z)|' + r'(A([0-9a-f]{1,4}:){1,2}(:[0-9a-f]{1,4}){1,3}:(25[0-5]|2[0-4]d|[0-1]?d?d)(.(25[0-5]|2[0-4]d|[0-1]?d?d)){3}Z)|' + r'(A([0-9a-f]{1,4}:){1,3}(:[0-9a-f]{1,4}){1,2}:(25[0-5]|2[0-4]d|[0-1]?d?d)(.(25[0-5]|2[0-4]d|[0-1]?d?d)){3}Z)|' + r'(A([0-9a-f]{1,4}:){1,4}(:[0-9a-f]{1,4}){1,1}:(25[0-5]|2[0-4]d|[0-1]?d?d)(.(25[0-5]|2[0-4]d|[0-1]?d?d)){3}Z)|' + r'(A(([0-9a-f]{1,4}:){1,5}|:):(25[0-5]|2[0-4]d|[0-1]?d?d)(.(25[0-5]|2[0-4]d|[0-1]?d?d)){3}Z)|' + r'(A:(:[0-9a-f]{1,4}){1,5}:(25[0-5]|2[0-4]d|[0-1]?d?d)(.(25[0-5]|2[0-4]d|[0-1]?d?d)){3}Z)(?::\d{1,5})?' + ) - if highlightRule.isJSONKey: - # JSON keys. Ignore trailing : - capturedLength = match.capturedLength() - 1 - else: - capturedLength = match.capturedLength() + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) - self.setFormat( - match.capturedStart(), capturedLength, highlightRule.rules - ) + self.highlightRules = [ + # IP addresses (IPv4 & IPv6) + EditorHighlightRules( + # https://ihateregex.io/expr/ip/ + DraculaLoggerSyntaxHighlighter.IPV4_REGEX + + r'|' + + DraculaLoggerSyntaxHighlighter.IPV6_REGEX, + '#B4FFFF', + ), + # URLs + EditorHighlightRules(r'(https?:)?//[^\s,\'"\]\)\(<>]+', '#FFDD88'), + # Timestamps + EditorHighlightRules( + r'\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3}\]', '#7F7F7F' + ), + # Logger name: [X.Y.Z] + EditorHighlightRules( + r'\[[A-Za-z0-9_]+\.[A-Za-z0-9_]+\.[A-Za-z0-9_]+\]', '#FFB46E' + ), + # Log levels + EditorHighlightRules(r'\[INFO\]', '#A0C882', isBold=True), + EditorHighlightRules(r'\[DEBUG\]', '#82B4FF', isBold=True), + EditorHighlightRules(r'\[WARNING\]', '#FFD75A', isBold=True), + EditorHighlightRules(r'\[ERROR\]', '#FF7878', isBold=True), + EditorHighlightRules(r'\[CRITICAL\]', '#FF5050', isBold=True), + # Quoted strings (single or double) + EditorHighlightRules(r"'[^']*'", '#A0FFA0'), + EditorHighlightRules(r'"[^"]*"', '#A0FFA0'), + ] diff --git a/Furious/QtFramework/UpdatesManager.py b/Furious/QtFramework/UpdatesManager.py index 7daa6153..34d1a9aa 100644 --- a/Furious/QtFramework/UpdatesManager.py +++ b/Furious/QtFramework/UpdatesManager.py @@ -20,6 +20,7 @@ from __future__ import annotations from Furious.QtFramework.QtNetwork import * from Furious.QtFramework.QtWidgets import * from Furious.QtFramework.DynamicTranslate import gettext as _ +from Furious.QtFramework.WebGETManager import * from Furious.Utility import * from Furious.Library import * @@ -27,10 +28,9 @@ from PySide6 import QtCore from PySide6.QtGui import * from PySide6.QtNetwork import * -from typing import Union +from typing import Union, Callable import logging -import operator import functools __all__ = ['UpdatesManager'] @@ -61,34 +61,36 @@ class QuestionUpdateMBox(AppQMessageBox): self.moveToCenter() -class UpdatesManager(AppQNetworkAccessManager): +class UpdatesManager(WebGETManager): 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) + def __init__(self, parent=None, **kwargs): + actionMessage = kwargs.pop('actionMessage', 'check for updates') - def handleFinishedByNetworkReply(self, networkReply): - assert isinstance(networkReply, QNetworkReply) + super().__init__(parent, actionMessage=actionMessage) - if networkReply.error() != QNetworkReply.NetworkError.NoError: - logger.error(f'check for updates failed. {networkReply.errorString()}') + def successCallback(self, networkReply, **kwargs): + showMessageBox = kwargs.pop('showMessageBox', True) + hasNewVersionCallback = kwargs.pop('hasNewVersionCallback', None) - mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Critical) - mbox.setWindowTitle(_(APPLICATION_NAME)) - mbox.setText(_('Check for updates failed')) + data = networkReply.readAll().data() - # Show the MessageBox asynchronously - mbox.open() + try: + info = UJSONEncoder.decode(data) + except Exception as ex: + # Any non-exit exceptions + + logger.error(f'bad network reply while checking for updates. {ex}') + + if showMessageBox: + self.showErrorMessageBox() else: - logger.info('check for updates success') + newVersion = info['tag_name'] - # Unchecked? - info = UJSONEncoder.decode(networkReply.readAll().data()) - - if versionToValue(info['tag_name']) > versionToValue(APPLICATION_VERSION): + if versionToValue(newVersion) > versionToValue(APPLICATION_VERSION): def handleResultCode(code): if code == PySide6LegacyEnumValueWrapper( @@ -102,40 +104,49 @@ class UpdatesManager(AppQNetworkAccessManager): # Do nothing pass - mbox = QuestionUpdateMBox(icon=AppQMessageBox.Icon.Information) - mbox.version = info['tag_name'] - mbox.setText(mbox.customText()) - mbox.setInformativeText(_('Go to download page?')) - mbox.finished.connect(functools.partial(handleResultCode)) + if callable(hasNewVersionCallback): + hasNewVersionCallback(newVersion) - # dummy ref - setattr(self, '_questionUpdateMBox', mbox) + if showMessageBox: + mbox = QuestionUpdateMBox(icon=AppQMessageBox.Icon.Information) + mbox.version = newVersion + mbox.setText(mbox.customText()) + mbox.setInformativeText(_('Go to download page?')) + mbox.finished.connect(functools.partial(handleResultCode)) - # Show the MessageBox asynchronously - mbox.open() + # Show the MessageBox asynchronously + mbox.open() else: - mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Information) - mbox.setWindowTitle(_(APPLICATION_NAME)) - mbox.setText(_(f'{APPLICATION_NAME} is already the latest version')) + if showMessageBox: + mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Information) + mbox.setWindowTitle(_(APPLICATION_NAME)) + mbox.setText(_(f'{APPLICATION_NAME} is already the latest version')) - # Show the MessageBox asynchronously - mbox.open() + # Show the MessageBox asynchronously + mbox.open() - def configureHttpProxy(self, httpProxy: Union[str, None]) -> bool: - useProxy = super().configureHttpProxy(httpProxy) + @staticmethod + def showErrorMessageBox(): + mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Critical) + mbox.setWindowTitle(_(APPLICATION_NAME)) + mbox.setText(_('Check for updates failed')) - if useProxy: - logger.info(f'check for updates uses proxy server {httpProxy}') - else: - logger.info(f'check for updates uses no proxy') + # Show the MessageBox asynchronously + mbox.open() - return useProxy + def failureCallback(self, networkReply, **kwargs): + showMessageBox = kwargs.pop('showMessageBox', True) - def checkForUpdates(self): - networkReply = self.get(QNetworkRequest(QtCore.QUrl(self.API_URL))) - networkReply.finished.connect( - functools.partial( - self.handleFinishedByNetworkReply, - networkReply, - ) + if showMessageBox: + self.showErrorMessageBox() + + def checkForUpdates( + self, + showMessageBox=True, + hasNewVersionCallback: Callable[[str], None] = None, + ): + self.webGET( + self.API_URL, + showMessageBox=showMessageBox, + hasNewVersionCallback=hasNewVersionCallback, ) diff --git a/Furious/QtFramework/WebGETManager.py b/Furious/QtFramework/WebGETManager.py new file mode 100644 index 00000000..404bfc0b --- /dev/null +++ b/Furious/QtFramework/WebGETManager.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.QtFramework.QtNetwork import * +from Furious.QtFramework.QtWidgets import * +from Furious.QtFramework.DynamicTranslate import gettext as _ +from Furious.Utility import * +from Furious.Library import * + +from PySide6 import QtCore +from PySide6.QtGui import * +from PySide6.QtNetwork import * + +from typing import AnyStr, Union, Callable + +import logging +import functools + +__all__ = ['WebGETManager'] + +logger = logging.getLogger(__name__) + + +class WebGETManager(AppQNetworkAccessManager): + def __init__(self, parent=None, actionMessage='web GET'): + super().__init__(parent) + + self.actionMessage = actionMessage + + def successCallback(self, networkReply: QNetworkReply, **kwargs): + pass + + def hasDataCallback(self, networkReply: QNetworkReply, **kwargs): + pass + + def failureCallback(self, networkReply: QNetworkReply, **kwargs): + pass + + def handleReadyReadByNetworkReply(self, networkReply: QNetworkReply, **kwargs): + self.hasDataCallback(networkReply, **kwargs) + + def handleFinishedByNetworkReply(self, networkReply: QNetworkReply, **kwargs): + if not isinstance(networkReply, QNetworkReply): + # Some PySide6 version does not have networkReply as + # QNetworkReply instance, so assertion is not used here + logger.error(f'QNetworkReply error in PySide6 {PYSIDE6_VERSION} version') + + return + + logActionMessage = kwargs.pop('logActionMessage', True) + + if networkReply.error() != QNetworkReply.NetworkError.NoError: + if logActionMessage: + logger.error( + f'{self.actionMessage} failed. {networkReply.errorString()}' + ) + + self.failureCallback(networkReply, **kwargs) + else: + if logActionMessage: + logger.info(f'{self.actionMessage} success') + + self.successCallback(networkReply, **kwargs) + + def configureHttpProxy(self, httpProxy: Union[str, None]) -> bool: + useProxy = super().configureHttpProxy(httpProxy) + + if useProxy: + logger.info(f'{self.actionMessage} uses proxy server {httpProxy}') + else: + logger.info(f'{self.actionMessage} uses no proxy') + + return useProxy + + def webGET(self, request: Union[QNetworkRequest, str], **kwargs) -> QNetworkReply: + if isinstance(request, QNetworkRequest): + networkReply = self.get(request) + else: + networkReply = self.get(QNetworkRequest(QtCore.QUrl(request))) + + networkReply.readyRead.connect( + functools.partial( + self.handleReadyReadByNetworkReply, + networkReply, + **kwargs, + ) + ) + networkReply.finished.connect( + functools.partial( + self.handleFinishedByNetworkReply, + networkReply, + **kwargs, + ) + ) + + return networkReply diff --git a/Furious/QtFramework/XrayAssetDownloadManager.py b/Furious/QtFramework/XrayAssetDownloadManager.py new file mode 100644 index 00000000..3efb2bec --- /dev/null +++ b/Furious/QtFramework/XrayAssetDownloadManager.py @@ -0,0 +1,210 @@ +# 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 _ +from Furious.QtFramework.WebGETManager import * +from Furious.Utility import * +from Furious.Library import * + +from PySide6 import QtCore +from PySide6.QtGui import * +from PySide6.QtNetwork import * + +from typing import AnyStr, Union, Callable + +import os +import logging +import hashlib +import functools + +__all__ = ['XrayAssetDownloadManager'] + +logger = logging.getLogger(__name__) + + +class SHA256Worker(QtCore.QObject, QtCore.QRunnable): + finished = QtCore.Signal(str) + + def __init__(self, string=b''): + # Explictly called __init__ + QtCore.QObject.__init__(self) + QtCore.QRunnable.__init__(self) + + self.string = string + + def run(self): + self.finished.emit(hashlib.sha256(self.string).hexdigest()) + + +class XrayAssetSHA256DownloadManager(WebGETManager): + def __init__(self, parent=None, **kwargs): + actionMessage = kwargs.pop('actionMessage', 'download sha256') + + super().__init__(parent, actionMessage=actionMessage) + + @staticmethod + def fileContent(filepath, mode='rb') -> AnyStr: + try: + with open(filepath, mode) as file: + return file.read() + except Exception: + # Any non-exit exceptions + + return b'' + + def successCallback(self, networkReply, **kwargs): + filepath = kwargs.pop('filepath', '') + downloadCallback = kwargs.pop('downloadCallback', None) + + data = networkReply.readAll().data() + + if isinstance(data, bytes): + datastr = data.decode('utf-8', 'replace') + else: + datastr = str(data) + + logger.debug(f'data: {data}') + + # 6068a73edfa08b63080b8d362bd4c5b069689a3548f413f43cfa58227a201571 geosite.dat + # 3a12beaa33c81b6751b45833a0a942b9462420d91182e7fa1d768b418e604049 geoip.dat + value = datastr.split()[0] + + basename = os.path.basename(filepath) + + def handleFinished(_digest, _value=''): + logger.debug( + f'computed digest is \'{_digest}\' while repo digest is \'{_value}\'' + ) + + if _digest != _value: + logger.info(f'digest not equal for {basename}. Start downloading asset') + + if callable(downloadCallback): + downloadCallback() + else: + logger.info(f'digest equal for {basename}. Nothing to do') + + worker = SHA256Worker(self.fileContent(filepath)) + + worker.setAutoDelete(True) + worker.finished.connect(functools.partial(handleFinished, _value=value)) + + AppThreadPool().start(worker) + + def download(self, url, filepath, downloadCallback: Callable[[], None]): + self.webGET( + url, + filepath=str(filepath), + downloadCallback=downloadCallback, + ) + + +class XrayAssetAssetsDownloadManager(WebGETManager): + def __init__(self, parent=None, **kwargs): + actionMessage = kwargs.pop('actionMessage', 'download assets') + + super().__init__(parent, actionMessage=actionMessage) + + def successCallback(self, networkReply, **kwargs): + filepath = kwargs.pop('filepath', '') + + saveFile = QtCore.QSaveFile(filepath) + + data = networkReply.readAll().data() + + if not saveFile.open(QtCore.QSaveFile.OpenModeFlag.WriteOnly): + logger.error(f'failed to open \'{filepath}\' for writing') + else: + saveFile.write(data) + + if not saveFile.commit(): + logger.error(f'save file to \'{filepath}\' failed') + else: + logger.info(f'save file to \'{filepath}\' success') + + def download(self, url, filepath): + self.webGET(url, filepath=str(filepath)) + + +class XrayAssetPairDownloadHelper: + def __init__(self, *args, **kwargs): + sha256ActionMessage = kwargs.pop('sha256ActionMessage', 'download sha256') + assetsActionMessage = kwargs.pop('assetsActionMessage', 'download assets') + + super().__init__(*args, **kwargs) + + self.sha256Downloader = XrayAssetSHA256DownloadManager( + actionMessage=sha256ActionMessage + ) + self.assetsDownloader = XrayAssetAssetsDownloadManager( + actionMessage=assetsActionMessage + ) + + def configureHttpProxy(self, httpProxy: Union[str, None]) -> bool: + return all( + [ + self.sha256Downloader.configureHttpProxy(httpProxy), + self.assetsDownloader.configureHttpProxy(httpProxy), + ] + ) + + def download(self, sha256URL, assetsURL, filepath): + self.sha256Downloader.download( + url=sha256URL, + filepath=filepath, + downloadCallback=functools.partial( + self.assetsDownloader.download, assetsURL, filepath + ), + ) + + +class XrayAssetDownloadManager: + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.downloadHelperGeosite = XrayAssetPairDownloadHelper( + sha256ActionMessage='download geosite sha256', + assetsActionMessage='download geosite assets', + ) + self.downloadHelperGeoip = XrayAssetPairDownloadHelper( + sha256ActionMessage='download geoip sha256', + assetsActionMessage='download geoip assets', + ) + + def configureHttpProxy(self, httpProxy: Union[str, None]) -> bool: + return all( + [ + self.downloadHelperGeosite.configureHttpProxy(httpProxy), + self.downloadHelperGeoip.configureHttpProxy(httpProxy), + ] + ) + + def download(self): + self.downloadHelperGeosite.download( + sha256URL=URL_GEOSITE_SHA256, + assetsURL=URL_GEOSITE, + filepath=XRAY_ASSET_PATH_GEOSITE, + ) + self.downloadHelperGeoip.download( + sha256URL=URL_GEOIP_SHA256, + assetsURL=URL_GEOIP, + filepath=XRAY_ASSET_PATH_GEOIP, + ) diff --git a/Furious/QtFramework/__init__.py b/Furious/QtFramework/__init__.py index 99768ad9..a7ffed5f 100644 --- a/Furious/QtFramework/__init__.py +++ b/Furious/QtFramework/__init__.py @@ -28,3 +28,5 @@ from .NetworkStateManager import * from .TextEditor import * from .TextEditorTheme import * from .GuiEditorXXX import * +from .WebGETManager import * +from .XrayAssetDownloadManager import * diff --git a/Furious/Storage/UserServers.py b/Furious/Storage/UserServers.py index 6a40ef3e..aa450fe1 100644 --- a/Furious/Storage/UserServers.py +++ b/Furious/Storage/UserServers.py @@ -27,6 +27,12 @@ __all__ = ['UserServers'] registerAppSettings('Configuration') +class UserServer: + remark: str + config: str + subsId: str + + class UserServers(SupportExitCleanup, StorageFactory): # remark, config, subsId. (subsId corresponds to unique in user subscription) def __init__(self, *args, **kwargs): @@ -44,8 +50,8 @@ class UserServers(SupportExitCleanup, StorageFactory): self._data = restore() self._list = list( - constructFromAny(model.pop('config', ''), **model) - for model in self._data['model'] + constructFromAny(model.pop('config', ''), index=index, **model) + for index, model in enumerate(self._data['model']) ) def sync(self): diff --git a/Furious/Storage/UserSubs.py b/Furious/Storage/UserSubs.py index 583e150d..9f7699fc 100644 --- a/Furious/Storage/UserSubs.py +++ b/Furious/Storage/UserSubs.py @@ -27,8 +27,17 @@ __all__ = ['UserSubs'] registerAppSettings('CustomSubscription') +class UserSubEntry: + remark: str + webURL: str + + +class UserSub: + unique: dict[str, dict] + + class UserSubs(SupportExitCleanup, StorageFactory): - # unique: remark, webURL + # unique: { remark, webURL } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/Furious/Storage/UserTUNSettings.py b/Furious/Storage/UserTUNSettings.py new file mode 100644 index 00000000..f4fefd41 --- /dev/null +++ b/Furious/Storage/UserTUNSettings.py @@ -0,0 +1,59 @@ +# 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__ = ['UserTUNSettings'] + +registerAppSettings('CustomTUNSettings') + + +class UserTUNSettings(SupportExitCleanup, StorageFactory): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def restore(): + try: + return UJSONEncoder.decode( + PyBase64Encoder.decode(AppSettings.get('CustomTUNSettings')) + ) + except Exception: + # Any non-exit exceptions + + return {} + + self._data = restore() + + def sync(self): + AppSettings.set( + 'CustomTUNSettings', + PyBase64Encoder.encode( + UJSONEncoder.encode(self._data).encode(), + ), + ) + + def data(self) -> dict[str, str]: + # Shallow copy + return self._data + + def cleanup(self): + self.sync() diff --git a/Furious/Storage/__init__.py b/Furious/Storage/__init__.py index 454938cc..df85a511 100644 --- a/Furious/Storage/__init__.py +++ b/Furious/Storage/__init__.py @@ -17,3 +17,4 @@ from .UserServers import * from .UserSubs import * +from .UserTUNSettings import * diff --git a/Furious/TrayActions/Connect.py b/Furious/TrayActions/Connect.py index c35b8de0..8cc17f48 100644 --- a/Furious/TrayActions/Connect.py +++ b/Furious/TrayActions/Connect.py @@ -69,6 +69,9 @@ class ConnectAction(AppQAction): self.actionTimer = QtCore.QTimer() self.actionTimer.timeout.connect(lambda: self.callActionFromQueue()) + self.updatesManager = UpdatesManager() + self.assetDownloadManager = XrayAssetDownloadManager() + def reset(self): self.hideProgressBar(True) self.setText(_('Connect')) @@ -93,7 +96,7 @@ class ConnectAction(AppQAction): if done: self.progressBar.setValue(100) - self.progressBar.hide() + self.progressBar.close() self.progressBar.stop() return self @@ -105,10 +108,10 @@ class ConnectAction(AppQAction): APP().systemTray.SystemProxyAction.setDisabled(value) if isAdministrator(): - VPNModeAction = APP().systemTray.SettingsAction.getVPNModeAction() + TUNModeAction = APP().systemTray.SettingsAction.getTUNModeAction() - if VPNModeAction is not None: - VPNModeAction.setDisabled(value) + if TUNModeAction is not None: + TUNModeAction.setDisabled(value) def isConnected(self) -> bool: return self.textCompare('Disconnect') @@ -202,69 +205,93 @@ class ConnectAction(AppQAction): AppSettings.turnOFF('Connect') self.setChecked(False) + else: + assert isinstance(config, ConfigurationFactory) - return + if not validateProxyServer(config.httpProxyEndpoint()): + # Proxy server is not valid. Do not connect - assert isinstance(config, ConfigurationFactory) + AppSettings.turnOFF('Connect') - if not validateProxyServer(config.httpProxyEndpoint()): - AppSettings.turnOFF('Connect') + self.setChecked(False) - 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 endpoint in the configuration' + mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Critical) + mbox.setWindowTitle(_('Unable to connect')) + mbox.setText( + _( + f'{APPLICATION_NAME} cannot find any valid http proxy endpoint in the configuration' + ) ) + mbox.setInformativeText(_('Please complete your server configuration')) + + # Show the MessageBox asynchronously + mbox.open() + + return + + self.doConnecting() + + # Clear previous log + loggerCore().clear() + loggerTun_().clear() + + success = self.coreManager.start( + config, + routing=AppSettings.get('Routing'), + exitCallback=self.coreExitCallback, + msgCallbackCore=lambda line: loggerCore().appendLine(line), + msgCallbackTun_=lambda line: loggerTun_().appendLine(line), ) - mbox.setInformativeText(_('Please complete your server configuration')) - # Show the MessageBox asynchronously - mbox.open() + if self.actionQueue.empty(): + if success: + SystemProxy.set(config.httpProxyEndpoint(), PROXY_SERVER_BYPASS) - return + self.doConnected() - self.doConnecting() + APP().systemTray.showMessage( + f'{config.coreName()}: ' + _('Connected') + ) - # Clear previous log - loggerCore().clear() - loggerTun_().clear() + if AppSettings.isStateON_('PowerSaveMode'): + # Power optimization + logger.info(f'no action queue in power save mode') - success = self.coreManager.start( - config, - routing=AppSettings.get('Routing'), - exitCallback=self.coreExitCallback, - msgCallbackCore=lambda line: loggerCore().appendLine(line), - msgCallbackTun_=lambda line: loggerTun_().appendLine(line), + self.actionTimer.stop() + else: + self.actionTimer.start(CORE_CHECK_ALIVE_INTERVAL) + + self.doConnectedCallOnceOnly() + 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() + + @callOnceOnly + def doConnectedCallOnceOnly(self): + def newVersionCallback(newVersion): + APP().systemTray.showMessage( + f'{APPLICATION_NAME} {newVersion} ' + _('is available to download') + ) + + connectedHttpProxy = connectedHttpProxyEndpoint() + + # Check for updates + self.updatesManager.configureHttpProxy(connectedHttpProxy) + self.updatesManager.checkForUpdates( + showMessageBox=False, + hasNewVersionCallback=newVersionCallback, ) - if self.actionQueue.empty(): - if success: - SystemProxy.set(config.httpProxyEndpoint(), PROXY_SERVER_BYPASS) - - self.doConnected() - - APP().systemTray.showMessage(f'{config.coreName()}: ' + _('Connected')) - - if AppSettings.isStateON_('PowerSaveMode'): - # Power optimization - logger.info(f'no action queue in power save mode') - - self.actionTimer.stop() - else: - 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() + # Automatically update assets + self.assetDownloadManager.configureHttpProxy(connectedHttpProxy) + self.assetDownloadManager.download() def callActionFromQueue(self): try: diff --git a/Furious/TrayActions/Import.py b/Furious/TrayActions/Import.py index 55ef5005..c024565a 100644 --- a/Furious/TrayActions/Import.py +++ b/Furious/TrayActions/Import.py @@ -96,7 +96,7 @@ class ImportMultiSuccessMBox(AppQMessageBox): def customText(self): text = ( _('Import share link success') - + f'\n\n' + + '\n\n' + '\n'.join( list( f'{index + 1} - {remark}. ' diff --git a/Furious/TrayActions/Settings.py b/Furious/TrayActions/Settings.py index 8f2ffd4c..b846150f 100644 --- a/Furious/TrayActions/Settings.py +++ b/Furious/TrayActions/Settings.py @@ -36,18 +36,18 @@ registerAppSettings( registerAppSettings('ShowTabAndSpacesInEditor', isBinary=True) # Administrator, root -TRANSLATABLE_VPN_MODE = [ - _('VPN Mode Disabled (Administrator)'), - _('VPN Mode Disabled (root)'), +TRANSLATABLE_TUN_MODE = [ + _('TUN Mode Disabled (Administrator)'), + _('TUN Mode Disabled (root)'), ] -class VPNModeAction(AppQAction): +class TUNModeAction(AppQAction): def __init__(self, **kwargs): if isAdministrator(): - super().__init__(_('VPN Mode'), **kwargs) + super().__init__(_('TUN Mode'), **kwargs) else: - super().__init__(_(f'VPN Mode Disabled ({ADMINISTRATOR_NAME})'), **kwargs) + super().__init__(_(f'TUN Mode Disabled ({ADMINISTRATOR_NAME})'), **kwargs) self.setDisabled(True) @@ -136,10 +136,16 @@ class SettingsAction(AppQAction): def __init__(self, **kwargs): if PLATFORM == 'Windows' or PLATFORM == 'Darwin': extraActions = [ - VPNModeAction( + TUNModeAction( checkable=True, checked=AppSettings.isStateON_('VPNMode'), ), + AppQAction( + _('Customize TUN Settings...'), + icon=bootstrapIcon('diagram-3.svg'), + checkable=False, + callback=lambda: APP().mainWindow.getGuiTUNSettings().open(), + ), AppQSeperator(), ] else: @@ -187,7 +193,7 @@ class SettingsAction(AppQAction): **kwargs, ) - def getVPNModeAction(self) -> Union[AppQAction, None]: + def getTUNModeAction(self) -> Union[AppQAction, None]: if PLATFORM == 'Windows' or PLATFORM == 'Darwin': # 1st action return self._menu.actions()[0] diff --git a/Furious/Utility/AppSettingsFn.py b/Furious/Utility/AppSettingsFn.py index 8b8a6d0c..0cb9ad8a 100644 --- a/Furious/Utility/AppSettingsFn.py +++ b/Furious/Utility/AppSettingsFn.py @@ -20,9 +20,18 @@ from __future__ import annotations from Furious.Utility.Constants import * from Furious.Utility.AppSettings import * +from typing import Union + import functools -__all___ = ['AS_UserActivatedItemIndex', 'AS_UserServers', 'AS_UserSubscription'] +__all___ = [ + 'AS_UserActivatedItemIndex', + 'AS_UserServers', + 'AS_UserSubscription', + 'AS_UserTUNSettings', + 'connectedHttpProxyEndpoint', + 'connectedServerRemark', +] def _activatedItemIndex() -> int: @@ -52,6 +61,52 @@ def _userSubscriptionData() -> dict[str, dict]: return {} +def _userTUNSettingsData() -> dict[str, str]: + try: + return APP().userTUNSettings.data() + except Exception: + # Any non-exit exceptions + + return {} + + AS_UserActivatedItemIndex = functools.partial(_activatedItemIndex) AS_UserServers = functools.partial(_userServersData) AS_UserSubscription = functools.partial(_userSubscriptionData) +AS_UserTUNSettings = functools.partial(_userTUNSettingsData) + + +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 connectedServerRemark() -> 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 '' diff --git a/Furious/Utility/Constants.py b/Furious/Utility/Constants.py index 666b0840..e780e38c 100644 --- a/Furious/Utility/Constants.py +++ b/Furious/Utility/Constants.py @@ -59,6 +59,16 @@ XRAY_ASSET_DIR = DATA_DIR / 'xray' CRASH_LOG_DIR = PACKAGE_DIR / 'CrashLog' GEN_TRANSLATION_FILE = PACKAGE_DIR / 'Externals' / 'GenTranslation.py' +URL_GEOSITE = 'https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat' +URL_GEOIP = ( + 'https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat' +) +URL_GEOSITE_SHA256 = 'https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat.sha256sum' +URL_GEOIP_SHA256 = 'https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat.sha256sum' + +XRAY_ASSET_PATH_GEOSITE = XRAY_ASSET_DIR / 'geosite.dat' +XRAY_ASSET_PATH_GEOIP = XRAY_ASSET_DIR / 'geoip.dat' + PROXY_SERVER_BYPASS = ( 'localhost;*.local;127.*;10.*;172.16.*;172.17.*;' '172.18.*;172.19.*;172.20.*;172.21.*;172.22.*;172.23.*;172.24.*;172.25.*;' @@ -92,6 +102,9 @@ APPLICATION_TUN_INTERFACE_DNS_ADDRESS = '1.1.1.1' ADMINISTRATOR_NAME = 'Administrator' if PLATFORM == 'Windows' else 'root' NETWORK_STATE_TEST_URL = 'http://cp.cloudflare.com' +NETWORK_SPEED_TEST_URL = ( + 'http://speed.cloudflare.com/__down?during=download&bytes=104857600' +) UNICODE_LARGE_RED_CIRCLE = u'\U0001F534' UNICODE_LARGE_GREEN_CIRCLE = u'\U0001F7E2' diff --git a/Furious/Utility/SystemProxy.py b/Furious/Utility/SystemProxy.py index 2afed14c..300030a4 100644 --- a/Furious/Utility/SystemProxy.py +++ b/Furious/Utility/SystemProxy.py @@ -288,7 +288,7 @@ class _SystemProxy: return False - # Note: try to resolve non-Windows proxy daemon issue by + # Note: try to solve non-Windows proxy daemon issue by # turning on StartupOnBoot by default. This should be # friendly for most of the users diff --git a/Furious/Utility/SystemRoutingTable.py b/Furious/Utility/SystemRoutingTable.py index b52cb4c6..b93a78de 100644 --- a/Furious/Utility/SystemRoutingTable.py +++ b/Furious/Utility/SystemRoutingTable.py @@ -356,7 +356,7 @@ class SystemRoutingTable: result = runExternalCommand( 'netsh interface ip set address'.split() + [ - deviceName, + f'\"{deviceName}\"', 'static', deviceIP, '255.255.255.0', diff --git a/Furious/Utility/SystemRuntime.py b/Furious/Utility/SystemRuntime.py index d1d1f07e..45a98a07 100644 --- a/Furious/Utility/SystemRuntime.py +++ b/Furious/Utility/SystemRuntime.py @@ -31,7 +31,7 @@ __all__ = [ 'getPythonVersion', 'getUbuntuRelease', 'isAdministrator', - 'isVPNMode', + 'isTUNMode', 'isScriptMode', 'isPythonw', 'isWindows7', @@ -79,7 +79,7 @@ def isAdministrator() -> bool: return os.geteuid() == 0 -def isVPNMode() -> bool: +def isTUNMode() -> bool: return isAdministrator() and AppSettings.isStateON_('VPNMode') diff --git a/Furious/Utility/Utility.py b/Furious/Utility/Utility.py index 67b888a8..3e0cb1cd 100644 --- a/Furious/Utility/Utility.py +++ b/Furious/Utility/Utility.py @@ -30,8 +30,10 @@ import subprocess import urllib.parse __all__ = [ + 'AppThreadPool', 'Protocol', 'BinarySettings', + 'callOnceOnly', 'protocolRepr', 'isValidIPAddress', 'parseHostPort', @@ -46,6 +48,13 @@ __all__ = [ ] +def _appThreadPool() -> QtCore.QThreadPool: + return APP().threadPool + + +AppThreadPool = functools.partial(_appThreadPool) + + class Protocol: VMess = 'VMess' VLESS = 'VLESS' @@ -62,6 +71,26 @@ class BinarySettings: RANGE = [OFF, ON_] +def callOnceOnly(func): + """ + Decorator that ensures a function is only called once. + Later calls return the cached result from the first invocation. + """ + result = None + called = False + + def wrapper(*args, **kwargs): + nonlocal result, called + + if not called: + result = func(*args, **kwargs) + called = True + + return result + + return wrapper + + @functools.lru_cache(None) def protocolRepr(protocol: str) -> str: if not isinstance(protocol, str): diff --git a/Furious/Widget/Application.py b/Furious/Widget/Application.py index bb70b2e2..461cf831 100644 --- a/Furious/Widget/Application.py +++ b/Furious/Widget/Application.py @@ -181,6 +181,7 @@ class Application(ApplicationFactory, SingletonApplication): # Initialize storage self.userServers = UserServers() self.userSubs = UserSubs() + self.userTUNSettings = UserTUNSettings() # ThreadPool self.threadPool = QtCore.QThreadPool() diff --git a/Furious/Widget/ConnectProgressBar.py b/Furious/Widget/ConnectProgressBar.py index c9d59f5b..ea688e5f 100644 --- a/Furious/Widget/ConnectProgressBar.py +++ b/Furious/Widget/ConnectProgressBar.py @@ -85,11 +85,6 @@ class ConnectProgressBar(QTranslatable, SupportConnectedCallback, QWidget): f'}}' ) - def closeEvent(self, event): - event.ignore() - - self.hide() - def disconnectedCallback(self): self.setWindowIcon(AppHue.disconnectedWindowIcon()) self._progressBar.setStyleSheet(self.getStyleSheet(AppHue.disconnectedColor())) diff --git a/Furious/Widget/GuiTUNSettings.py b/Furious/Widget/GuiTUNSettings.py new file mode 100644 index 00000000..2735ec1b --- /dev/null +++ b/Furious/Widget/GuiTUNSettings.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.QtFramework import * +from Furious.QtFramework import gettext as _ +from Furious.Utility import * + +from PySide6.QtGui import * + +import logging + +__all__ = ['GuiTUNSettings'] + +logger = logging.getLogger(__name__) + + +class GuiTUNSettingsItemXXX(GuiEditorItemTextInput): + def __init__(self, *args, **kwargs): + self.key = kwargs.pop('key', '') + + super().__init__(*args, **kwargs) + + def inputToFactory(self, config: dict) -> bool: + oldItem = config.get(self.key, '') + newItem = self.text() + + if newItem != oldItem: + config[self.key] = newItem + + # Modified + return True + else: + # Not modified + return False + + def factoryToInput(self, config: dict): + self.setText(config.get(self.key, '')) + + +class GuiTUNSettingsItemDisablePrimaryAdapterInterfaceDNS(GuiEditorItemTextCheckBox): + def __init__(self, *args, **kwargs): + self.key = kwargs.pop('key', '') + + super().__init__(*args, **kwargs) + + def inputToFactory(self, config: dict) -> bool: + oldItem = config.get(self.key, 'True') + newItem = self.isChecked() + + if oldItem not in ['False', 'True']: + # Invalid value. Set to default + oldItem = 'True' + + if str(newItem) != oldItem: + config[self.key] = str(newItem) + + # Modified + return True + else: + # Not modified + return False + + def factoryToInput(self, config: dict): + self.setChecked(config.get(self.key, 'True') != 'False') + + +class AppQLabelHelpPage(AppQLabel): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.setWebsiteURL() + self.linkActivated.connect(self.handleLinkActivated) + + def setWebsiteURL(self): + self.setText( + '

' + '' + '' + + _('Go to help page') + + '

' + ) + + @staticmethod + def handleLinkActivated(link: str): + if QDesktopServices.openUrl(QtCore.QUrl(link)): + logger.info(f'open link \'{link}\' success') + else: + logger.error(f'open link \'{link}\' failed') + + def retranslate(self): + self.setWebsiteURL() + + +class GuiTUNSettingsItemHelpPage(GuiEditorItemWidgetContainer): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self._page = AppQLabelHelpPage() + + def widgets(self): + return [self._page] + + +class GuiTUNSettingsGroupBox(GuiEditorWidgetQGroupBox): + def __init__(self, **kwargs): + super().__init__(_('Basic Configuration'), **kwargs) + + def containerSequence(self): + return [ + GuiTUNSettingsItemXXX( + title=_('Primary Adapter Interface Name'), + key='primaryAdapterInterfaceName', + ), + GuiTUNSettingsItemXXX( + title=_('Primary Adapter Interface IP'), + key='primaryAdapterInterfaceIP', + ), + GuiTUNSettingsItemXXX( + title=_('Default Primary Gateway IP'), + key='defaultPrimaryGatewayIP', + ), + GuiTUNSettingsItemXXX( + title=_('TUN Adapter Interface DNS'), + key='tunAdapterInterfaceDNS', + ), + GuiTUNSettingsItemXXX( + title=_('Bypass TUN adapter interface IP (separated by commas)'), + key='bypassTUNAdapterInterfaceIP', + ), + GuiTUNSettingsItemDisablePrimaryAdapterInterfaceDNS( + title=_( + 'Disable Primary Adapter Interface DNS (Mitigating DNS leaks on Windows)' + ), + key='disablePrimaryAdapterInterfaceDNS', + ), + GuiTUNSettingsItemHelpPage(), + ] + + +class GuiTUNSettings(GuiEditorWidgetQDialog): + def __init__(self, *args, **kwargs): + tabTranslatable = kwargs.pop('tabTranslatable', True) + + super().__init__(*args, tabTranslatable=tabTranslatable, **kwargs) + + self.setTabText(_('Customize TUN Settings')) + self.setFixedSize(int(460 * GOLDEN_RATIO), int(460)) + + # Shallow copy + config = AS_UserTUNSettings() + + try: + self.factoryToInput(config) + except Exception as ex: + # Any non-exit exceptions + + logger.error(f'error while converting factory to input: {ex}') + + self.accepted.connect(functools.partial(self.handleAccepted, config)) + self.rejected.connect(functools.partial(self.handleRejected)) + + def handleAccepted(self, config: dict): + modified = self.inputToFactory(config) + + if modified and isTUNMode(): + showNewChangesNextTimeMBox() + + self.accepted.disconnect() + self.rejected.disconnect() + + def handleRejected(self): + self.accepted.disconnect() + self.rejected.disconnect() + + def inputToFactory(self, config: dict) -> bool: + modified = False + + for groupBox in self.groupBoxes: + modified |= groupBox.inputToFactory(config) + + return modified + + def factoryToInput(self, config: dict): + for groupBox in self.groupBoxes: + groupBox.factoryToInput(config) + + def groupBoxSequence(self): + return [ + GuiTUNSettingsGroupBox(), + ] diff --git a/Furious/Widget/UserServersQTableWidget.py b/Furious/Widget/UserServersQTableWidget.py index b0f8d54d..d69f16e1 100644 --- a/Furious/Widget/UserServersQTableWidget.py +++ b/Furious/Widget/UserServersQTableWidget.py @@ -54,149 +54,270 @@ registerAppSettings('ActivatedItemIndex') registerAppSettings('ServerWidgetSectionSizeTable') -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): - isConnected = APP().isSystemTrayConnected() - - subsIndexes = list( - index - for index, server in enumerate(AS_UserServers()) - if server.getExtras('subsId') == unique - ) - - subsGroupIndex = -1 - activatedIndex = AS_UserActivatedItemIndex() - - if activatedIndex in subsIndexes: - for index, server in enumerate(AS_UserServers()): - if index <= activatedIndex: - if server.getExtras('subsId') == unique: - subsGroupIndex += 1 - else: - break - - parent.deleteItemByIndex( - subsIndexes, showTrayMessage=bool(subsGroupIndex < 0) - ) - - remaining = len(AS_UserServers()) - - for uri in uris: - parent.appendNewItem(config=uri, subsId=unique) - - if subsGroupIndex >= 0: - newIndex = remaining + subsGroupIndex - - if newIndex < len(AS_UserServers()): - parent.activateItemByIndex(newIndex, True) - - if isConnected and not APP().isSystemTrayConnected(): - # Trigger connect - APP().systemTray.ConnectAction.trigger() - - # 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 WorkerSequence(ItemUpdateProtocol): - def __init__(self, sequence: Sequence, index: int, item: ConfigurationFactory): - super().__init__(sequence, index, item) - - def currentItemDeleted(self) -> bool: - return super().currentItemDeleted() or FastItemDeletionSearch.isInTrash( - self.currentItem - ) - - def updateResult(self): - # Extra guard - if APP() is None or APP().isExiting(): - return - - super().updateResult() - - -class TestPingLatencyWorker(WorkerSequence, QtCore.QObject, QtCore.QRunnable): - finished = QtCore.Signal(int, object) - +class UpdateSubsInfoMBox(AppQMessageBox): def __init__(self, *args, **kwargs): + self.successArgs = kwargs.pop('successArgs', list()) + self.failureArgs = kwargs.pop('failureArgs', list()) + super().__init__(*args, **kwargs) + self.setWindowTitle(_(APPLICATION_NAME)) + + def customText(self): + if self.successArgs: + text = _('Update subscription completed') + '\n\n' + else: + text = _('Update subscription failed') + + for param in self.successArgs: + remark, webURL = param['remark'], param['webURL'] + + text += ( + f'\U00002705 {remark} - {webURL} ' + + _('Configuration has been updated') + + '\n' + ) + + if self.successArgs and self.failureArgs: + text += '\n' + elif self.failureArgs: + text += '\n\n' + + for param in self.failureArgs: + error, remark, webURL = param['error'], param['remark'], param['webURL'] + + # error is the specific failure reason. Not used + # for mbox elegant appearance + + text += ( + f'\U0000274C {remark} - {webURL} ' + + _('Configuration update failed') + + '\n' + ) + + return text + + def setColumnMinWidth(self): + self.findChild(QGridLayout).setColumnMinimumWidth( + 2, + max((len(row) + 10) for row in self.text().split('\n')) + * self.fontMetrics().averageCharWidth(), + ) + + def retranslate(self): + self.setWindowTitle(_(self.windowTitle())) + self.setText(self.customText()) + self.setColumnMinWidth() + + # Ignore informative text, buttons + + self.moveToCenter() + + +class SubscriptionManager(WebGETManager): + # Collect unused references every 30 mins + GC_COLLECT_INTERVAL = 30 * 60 * 1000 + + def __init__(self, parent, **kwargs): + actionMessage = kwargs.pop('actionMessage', 'update subs') + + super().__init__(parent, actionMessage=actionMessage) + + def handleItemDeletionAndInsertion(self, **kwargs): + successArgs = kwargs.pop('successArgs', list()) + failureArgs = kwargs.pop('failureArgs', list()) + + showMessageBox = kwargs.pop('showMessageBox', True) + + for param in successArgs: + uris, unique = param['uris'], param['unique'] + + parent = self.parent() + + if isinstance(parent, UserServersQTableWidget): + isConnected = APP().isSystemTrayConnected() + + subsIndexes = list( + index + for index, server in enumerate(AS_UserServers()) + if server.getExtras('subsId') == unique + ) + + subsGroupIndex = -1 + activatedIndex = AS_UserActivatedItemIndex() + + if activatedIndex in subsIndexes: + for index, server in enumerate(AS_UserServers()): + if index <= activatedIndex: + if server.getExtras('subsId') == unique: + subsGroupIndex += 1 + else: + break + + parent.deleteItemByIndex( + subsIndexes, showTrayMessage=bool(subsGroupIndex < 0) + ) + + remaining = len(AS_UserServers()) + + for uri in uris: + parent.appendNewItem(config=uri, subsId=unique) + + if subsGroupIndex >= 0: + newIndex = remaining + subsGroupIndex + + if newIndex < len(AS_UserServers()): + parent.activateItemByIndex(newIndex, True) + + if isConnected and not APP().isSystemTrayConnected(): + # Trigger connect + APP().systemTray.ConnectAction.trigger() + + if not QGarbageCollector.isEnabled(): + QGarbageCollector.collect(SubscriptionManager.GC_COLLECT_INTERVAL) + + if showMessageBox: + mbox = UpdateSubsInfoMBox( + successArgs=successArgs, + failureArgs=failureArgs, + ) + + if successArgs: + mbox.setIcon(AppQMessageBox.Icon.Information) + else: + mbox.setIcon(AppQMessageBox.Icon.Critical) + + mbox.setText(mbox.customText()) + mbox.setColumnMinWidth() + + # Show the MessageBox asynchronously + mbox.open() + + def successCallback(self, networkReply, **kwargs): + remark = kwargs.get('remark', '') + webURL = kwargs.get('webURL', '') + depthMap = kwargs.get('depthMap', {}) + successArgs = kwargs.get('successArgs', list()) + failureArgs = kwargs.get('failureArgs', list()) + + data = networkReply.readAll().data() + + try: + uris = list( + filter( + lambda x: x != '', + PyBase64Encoder.decode(data).decode().split('\n'), + ) + ) + except Exception as ex: + # Any non-exit exceptions + + def classname(ob) -> str: + return ob.__class__.__name__ + + logger.error(f'parse share link from \'{webURL}\' failed: {ex}') + + failureArgs.append({'error': classname(ex), **kwargs}) + else: + logger.info( + f'update subs ({remark}, {webURL}) success. Got {len(uris)} share link' + ) + + successArgs.append({'uris': uris, **kwargs}) + + depthMap['depth'] -= 1 + + if depthMap['depth'] == 0: + self.handleItemDeletionAndInsertion(**kwargs) + + def failureCallback(self, networkReply, **kwargs): + remark = kwargs.get('remark', '') + webURL = kwargs.get('webURL', '') + depthMap = kwargs.get('depthMap', {}) + successArgs = kwargs.get('successArgs', list()) + failureArgs = kwargs.get('failureArgs', list()) + + logger.error( + f'update subs ({remark}, {webURL}) failed: {networkReply.errorString()}' + ) + + failureArgs.append({'error': networkReply.errorString(), **kwargs}) + + depthMap['depth'] -= 1 + + if depthMap['depth'] == 0: + self.handleItemDeletionAndInsertion(**kwargs) + + def updateSubsByWebGET(self, **kwargs): + url = kwargs.get('webURL', '') + + if not url: + # Has url empty check + return + + logActionMessage = kwargs.pop('logActionMessage', False) + + self.webGET(url, logActionMessage=logActionMessage, **kwargs) + + def updateSubsByUnique(self, unique: str, **kwargs): + depthMap = kwargs.get('depthMap', {'depth': 1}) + successArgs = kwargs.get('successArgs', list()) + failureArgs = kwargs.get('failureArgs', list()) + + if kwargs.get('depthMap') is None: + kwargs['depthMap'] = depthMap + + if kwargs.get('successArgs') is None: + kwargs['successArgs'] = successArgs + + if kwargs.get('failureArgs') is None: + kwargs['failureArgs'] = failureArgs + + subsob = AS_UserSubscription()[unique] + + self.updateSubsByWebGET(unique=unique, **subsob, **kwargs) + + def updateSubs(self, **kwargs): + depthMap = {'depth': len(AS_UserSubscription())} + successArgs = list() + failureArgs = list() + + for key in AS_UserSubscription().keys(): + self.updateSubsByUnique( + key, + depthMap=depthMap, + successArgs=successArgs, + failureArgs=failureArgs, + **kwargs, + ) + + +class TestPingLatencyWorker(QtCore.QObject, QtCore.QRunnable): + finished = QtCore.Signal() + + def __init__(self, factory: ConfigurationFactory): # Explictly called __init__ QtCore.QObject.__init__(self) QtCore.QRunnable.__init__(self) - def updateImpl(self): - self.finished.emit(self.currentIndex, self.currentItem) + self.factory = factory def run(self): - if self.currentItemDeleted(): - # Deleted. Do nothing + index = self.factory.index() + + if ( + index < 0 + or index >= len(AS_UserServers()) + or QGarbageCollector.isTracked(self.factory) + ): + # Invalid item. Do nothing return - assert isinstance(self.currentItem, ConfigurationFactory) + assert isinstance(self.factory, ConfigurationFactory) try: result = icmplib.ping( - self.currentItem.itemAddress, + self.factory.itemAddress, count=1, timeout=2, interval=1, @@ -207,45 +328,57 @@ class TestPingLatencyWorker(WorkerSequence, QtCore.QObject, QtCore.QRunnable): def classname(ob) -> str: return ob.__class__.__name__ - self.currentItem.setExtras('delayResult', classname(ex)) - self.updateResult() + self.factory.setExtras('delayResult', classname(ex)) + + # Extra guard + if APP() is None or APP().isExiting(): + return + else: + self.finished.emit() else: # Result address should not be empty if result.address and result.is_alive: - self.currentItem.setExtras('delayResult', f'{round(result.avg_rtt)}ms') + self.factory.setExtras('delayResult', f'{round(result.avg_rtt)}ms') else: if result.packet_loss == 1: - self.currentItem.setExtras('delayResult', 'Timeout') + self.factory.setExtras('delayResult', 'Timeout') else: - self.currentItem.setExtras('delayResult', 'Error') + self.factory.setExtras('delayResult', 'Error') - self.updateResult() + # Extra guard + if APP() is None or APP().isExiting(): + return + else: + self.finished.emit() -class TestTcpingLatencyWorker(WorkerSequence, QtCore.QObject, QtCore.QRunnable): - finished = QtCore.Signal(int, object) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) +class TestTcpingLatencyWorker(QtCore.QObject, QtCore.QRunnable): + finished = QtCore.Signal() + def __init__(self, factory: ConfigurationFactory): # Explictly called __init__ QtCore.QObject.__init__(self) QtCore.QRunnable.__init__(self) - def updateImpl(self): - self.finished.emit(self.currentIndex, self.currentItem) + self.factory = factory def run(self): - if self.currentItemDeleted(): - # Deleted. Do nothing + index = self.factory.index() + + if ( + index < 0 + or index >= len(AS_UserServers()) + or QGarbageCollector.isTracked(self.factory) + ): + # Invalid item. Do nothing return - assert isinstance(self.currentItem, ConfigurationFactory) + assert isinstance(self.factory, ConfigurationFactory) try: sent, rtts = tcping( - self.currentItem.itemAddress, - int(self.currentItem.itemPort.split(',')[0]), + self.factory.itemAddress, + int(self.factory.itemPort.split(',')[0]), count=1, timeout=2, interval=1, @@ -256,32 +389,44 @@ class TestTcpingLatencyWorker(WorkerSequence, QtCore.QObject, QtCore.QRunnable): def classname(ob) -> str: return ob.__class__.__name__ - self.currentItem.setExtras('delayResult', classname(ex)) - self.updateResult() + self.factory.setExtras('delayResult', classname(ex)) + + # Extra guard + if APP() is None or APP().isExiting(): + return + else: + self.finished.emit() else: if rtts: - self.currentItem.setExtras('delayResult', f'{round(rtts[0] * 1000)}ms') + self.factory.setExtras('delayResult', f'{round(rtts[0] * 1000)}ms') else: - self.currentItem.setExtras('delayResult', 'Timeout') + self.factory.setExtras('delayResult', 'Timeout') - self.updateResult() + # Extra guard + if APP() is None or APP().isExiting(): + return + else: + self.finished.emit() -class TestDownloadSpeedWorker(WorkerSequence, QtCore.QObject): - progressed = QtCore.Signal(int, object) +class TestDownloadSpeedWorker(WebGETManager): + progressed = QtCore.Signal() - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + def __init__(self, factory: ConfigurationFactory, port: int, parent=None, **kwargs): + actionMessage = kwargs.pop('actionMessage', 'test download speed') - # Explictly called __init__ - QtCore.QObject.__init__(self) + super().__init__(parent, actionMessage=actionMessage) + + self.factory = factory + self.port = port self.hasSpeedResult = False self.totalBytesRead = 0 + self.hasDataCounter = 0 + self.coreManager = CoreManager() - self.networkAccessManager = QNetworkAccessManager() self.networkReply = None self.elapsedTimer = QtCore.QElapsedTimer() @@ -295,37 +440,58 @@ class TestDownloadSpeedWorker(WorkerSequence, QtCore.QObject): if isinstance(self.networkReply, QNetworkReply): self.networkReply.abort() - def updateImpl(self): - self.progressed.emit(self.currentIndex, self.currentItem) - def run(self): - if self.currentItemDeleted(): - # Deleted. Do nothing + index = self.factory.index() + + if ( + index < 0 + or index >= len(AS_UserServers()) + or QGarbageCollector.isTracked(self.factory) + ): + # Invalid item. Do nothing return - assert isinstance(self.currentItem, ConfigurationFactory) + assert isinstance(self.factory, ConfigurationFactory) - if not self.currentItem.isValid(): - self.currentItem.setExtras('speedResult', 'Invalid') - self.updateResult() + if not self.factory.isValid(): + self.factory.setExtras('speedResult', 'Invalid') + # Extra guard + if APP() is None or APP().isExiting(): + return + else: + self.progressed.emit() + + # Configuration is not valid. Do nothing return def coreExitCallback(config: ConfigurationFactory, exitcode: int): if exitcode == CoreProcess.ExitCode.ConfigurationError: - self.currentItem.setExtras('speedResult', f'Invalid') + self.factory.setExtras('speedResult', f'Invalid') - return self.updateResult() - if exitcode == CoreProcess.ExitCode.ServerStartFailure: - self.currentItem.setExtras('speedResult', f'Core start failed') + # Extra guard + if APP() is None or APP().isExiting(): + return + else: + self.progressed.emit() + elif exitcode == CoreProcess.ExitCode.ServerStartFailure: + self.factory.setExtras('speedResult', f'Core start failed') - return self.updateResult() - if exitcode == CoreProcess.ExitCode.SystemShuttingDown: + # Extra guard + if APP() is None or APP().isExiting(): + return + else: + self.progressed.emit() + elif exitcode == CoreProcess.ExitCode.SystemShuttingDown: pass else: - self.currentItem.setExtras('speedResult', f'Core exited {exitcode}') + self.factory.setExtras('speedResult', f'Core exited {exitcode}') - return self.updateResult() + # Extra guard + if APP() is None or APP().isExiting(): + return + else: + self.progressed.emit() def msgCallback(line: str): try: @@ -335,17 +501,22 @@ class TestDownloadSpeedWorker(WorkerSequence, QtCore.QObject): pass - self.currentItem.setExtras('speedResult', 'Starting') - self.updateResult() + self.factory.setExtras('speedResult', 'Starting') - itemcopy = self.currentItem.deepcopy() + # Extra guard + if APP() is None or APP().isExiting(): + return + else: + self.progressed.emit() + + itemcopy = self.factory.deepcopy() if isinstance(itemcopy, ConfigurationXray): # Force redirect itemcopy['inbounds'] = [ { 'tag': 'http', - 'port': 20809, + 'port': self.port, 'listen': '127.0.0.1', 'protocol': 'http', 'sniffing': { @@ -365,9 +536,9 @@ class TestDownloadSpeedWorker(WorkerSequence, QtCore.QObject): try: for outboundObject in itemcopy['outbounds']: - if outboundObject['tag'] == 'proxy': + if outboundObject['tag'] == f'proxy': # Avoid confusion with potentially existing 'proxy' tag - outboundObject['tag'] = 'proxy20809' + outboundObject['tag'] = f'proxy{self.port}' except Exception: # Any non-exit exceptions @@ -387,7 +558,7 @@ class TestDownloadSpeedWorker(WorkerSequence, QtCore.QObject): ): # Force redirect itemcopy['http'] = { - 'listen': '127.0.0.1:20809', + 'listen': f'127.0.0.1:{self.port}', 'timeout': 300, 'disable_udp': False, } @@ -404,29 +575,47 @@ class TestDownloadSpeedWorker(WorkerSequence, QtCore.QObject): log=False, ) else: - self.currentItem.setExtras('speedResult', 'Invalid') - self.updateResult() + self.factory.setExtras('speedResult', 'Invalid') + # Extra guard + if APP() is None or APP().isExiting(): + return + else: + self.progressed.emit() + + # Unrecognized core. Return 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.configureHttpProxy(f'127.0.0.1:{self.port}') + + self.networkReply = self.webGET(NETWORK_SPEED_TEST_URL) self.elapsedTimer.start() - @QtCore.Slot() - def handleReadyRead(self): + def successCallback(self, networkReply, **kwargs): if self.coreManager.allRunning(): - self.totalBytesRead += self.networkReply.readAll().length() + self.totalBytesRead += networkReply.readAll().length() + + # Convert to seconds + elapsedSecond = self.elapsedTimer.elapsed() / 1000 + downloadSpeed = self.totalBytesRead / elapsedSecond / 1024 / 1024 + + self.factory.setExtras('speedResult', f'{downloadSpeed:.2f} MiB/s') + else: + self.factory.setExtras('speedResult', f'Core start failed') + + self.coreManager.stopAll() + + # Extra guard + if APP() is None or APP().isExiting(): + return + else: + self.progressed.emit() + + def hasDataCallback(self, networkReply, **kwargs): + self.hasDataCounter += 1 + + if self.coreManager.allRunning(): + self.totalBytesRead += networkReply.readAll().length() # Convert to seconds elapsedSecond = self.elapsedTimer.elapsed() / 1000 @@ -434,57 +623,56 @@ class TestDownloadSpeedWorker(WorkerSequence, QtCore.QObject): # Has speed test result self.hasSpeedResult = True - self.currentItem.setExtras('speedResult', f'{downloadSpeed:.2f} MiB/s') - self.updateResult() + self.factory.setExtras('speedResult', f'{downloadSpeed:.2f} MiB/s') - @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 = 'UnknownError' - - 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 = 'UnknownError' - - if errorString != 'UnknownError' and 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} MiB/s') + # Extra guard + if APP() is None or APP().isExiting(): + return else: - self.currentItem.setExtras('speedResult', f'Core start failed') + if self.hasDataCounter % 10 == 0: + # Limited to save CPU resources + self.progressed.emit() + + def failureCallback(self, networkReply, **kwargs): + if not self.hasSpeedResult: + if not self.coreManager.allRunning(): + # Core ExitCallback has been called + return + + if ( + networkReply.error() + == QNetworkReply.NetworkError.OperationCanceledError + ): + # Canceled by application + self.factory.setExtras('speedResult', 'Canceled') + else: + try: + error = networkReply.error().name + except Exception: + # Any non-exit exceptions + + error = 'UnknownError' + + 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 = 'UnknownError' + + if errorString != 'UnknownError' and errorString.endswith('Error'): + self.factory.setExtras('speedResult', errorString[:-5]) + else: + self.factory.setExtras('speedResult', errorString) self.coreManager.stopAll() - self.updateResult() + + # Extra guard + if APP() is None or APP().isExiting(): + return + else: + self.progressed.emit() class UserServersQTableWidgetHorizontalHeader(AppQHeaderView): @@ -520,7 +708,7 @@ class UserServersQTableWidgetHorizontalHeader(AppQHeaderView): return if self.customSortFn is None: - # Sorting not supported + # Sorting is not supported return parent = self.parent() @@ -654,7 +842,7 @@ class UserServersQTableWidget(QTranslatable, AppQTableWidget): self.setDropIndicatorShown(False) self.setDefaultDropAction(QtCore.Qt.DropAction.IgnoreAction) - self.editConfigActionRef = AppQAction( + self.customizeJSONConfigActionRef = AppQAction( _('Customize JSON Configuration...'), icon=bootstrapIcon('pencil-square.svg'), callback=lambda: self.editSelectedItemConfiguration(), @@ -664,14 +852,31 @@ class UserServersQTableWidget(QTranslatable, AppQTableWidget): ), ) + self.advancedActionRef = AppQAction( + _('Advanced...'), + menu=AppQMenu( + self.customizeJSONConfigActionRef, + ), + useActionGroup=False, + checkable=True, + ) + contextMenuActions = [ AppQAction( _('Move Up'), callback=lambda: self.moveUpSelectedItem(), + shortcut=QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier.ControlModifier, + QtCore.Qt.Key.Key_Up, + ), ), AppQAction( _('Move Down'), callback=lambda: self.moveDownSelectedItem(), + shortcut=QtCore.QKeyCombination( + QtCore.Qt.KeyboardModifier.ControlModifier, + QtCore.Qt.Key.Key_Down, + ), ), AppQAction( _('Duplicate'), @@ -680,10 +885,11 @@ class UserServersQTableWidget(QTranslatable, AppQTableWidget): AppQAction( _('Delete'), callback=lambda: self.deleteSelectedItem(), + shortcut=QtCore.QKeyCombination( + QtCore.Qt.Key.Key_Delete, + ), ), AppQSeperator(), - self.editConfigActionRef, - AppQSeperator(), AppQAction( _('Select All'), callback=lambda: self.selectAll(), @@ -735,6 +941,8 @@ class UserServersQTableWidget(QTranslatable, AppQTableWidget): ), ), AppQSeperator(), + self.advancedActionRef, + AppQSeperator(), AppQAction( _('New Empty Configuration'), callback=lambda: self.newEmptyItem(), @@ -803,22 +1011,25 @@ class UserServersQTableWidget(QTranslatable, AppQTableWidget): @QtCore.Slot(QTableWidgetItem) def handleItemChanged(self, item: QTableWidgetItem): - index = item.row() + pass - 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) + # TODO: In use? + # 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) + self.customizeJSONConfigActionRef.setDisabled(True) else: - self.editConfigActionRef.setDisabled(False) + self.customizeJSONConfigActionRef.setDisabled(False) @QtCore.Slot(QTableWidgetItem) def handleItemActivated(self, item: QTableWidgetItem): @@ -899,6 +1110,7 @@ class UserServersQTableWidget(QTranslatable, AppQTableWidget): index = item.row() factory = AS_UserServers()[index] + # Do not translate window title guiEditor = self.getGuiEditorByFactory(factory, translatable=False) if guiEditor is None: @@ -992,6 +1204,7 @@ class UserServersQTableWidget(QTranslatable, AppQTableWidget): AS_UserServers().sort(key=keyFn, **kwargs) + # Index is refreshed by calling flushAll() self.flushAll() def activatedItem(self) -> QTableWidgetItem: @@ -1004,6 +1217,45 @@ class UserServersQTableWidget(QTranslatable, AppQTableWidget): AppSettings.set('ActivatedItemIndex', str(index)) def flushItem(self, row: int, column: int, item: ConfigurationFactory): + itemIndex = item.index() + + if ( + itemIndex < 0 + or itemIndex >= len(AS_UserServers()) + or QGarbageCollector.isTracked(item) + ): + # Invalid item. Do nothing + return + + def searchIndex(start, stop, step=1): + nonlocal itemIndex + + for _index in range(start, stop, step): + if id(item) == id(AS_UserServers()[_index]): + itemIndex = _index + + item.setIndex(itemIndex) + + return True + + return False + + if id(item) != id(AS_UserServers()[itemIndex]): + # itemIndex doesn't match + if searchIndex(itemIndex - 1, -1, -1) or searchIndex( + itemIndex + 1, len(AS_UserServers()) + ): + pass + else: + # Item isn't found in user servers. Do nothing + return + + if row != itemIndex: + # Adjust row value + row = itemIndex + else: + pass + header = self.Headers[column] oldItem = self.item(row, column) @@ -1108,6 +1360,10 @@ class UserServersQTableWidget(QTranslatable, AppQTableWidget): self.flushItem(row, column, item) def flushAll(self): + # Refresh index + for index, item in enumerate(AS_UserServers()): + item.setIndex(index) + if self.rowCount() == 0: # Should insert row for index, item in enumerate(AS_UserServers()): @@ -1126,6 +1382,10 @@ class UserServersQTableWidget(QTranslatable, AppQTableWidget): swapSequenceItem(AS_UserServers(), index0, index1) + # Refresh index + AS_UserServers()[index0].setIndex(index1) + AS_UserServers()[index1].setIndex(index0) + self.flushRow(index0, AS_UserServers()[index0]) self.flushRow(index1, AS_UserServers()[index1]) @@ -1212,13 +1472,14 @@ class UserServersQTableWidget(QTranslatable, AppQTableWidget): else: deleteActivated = False + # Note: param indexes must be sorted for i in range(len(indexes)): deleteIndex = indexes[i] - i with QBlockSignals(self): self.removeRow(deleteIndex) - FastItemDeletionSearch.moveToTrash(AS_UserServers()[deleteIndex]) + QGarbageCollector.track(AS_UserServers()[deleteIndex]) AS_UserServers().pop(deleteIndex) @@ -1269,9 +1530,6 @@ class UserServersQTableWidget(QTranslatable, AppQTableWidget): mbox.setText(mbox.customText()) mbox.finished.connect(functools.partial(handleResultCode, indexes)) - # dummy ref - setattr(self, '_questionDeleteMBox', mbox) - # Show the MessageBox asynchronously mbox.open() @@ -1312,16 +1570,19 @@ class UserServersQTableWidget(QTranslatable, AppQTableWidget): references = list(AS_UserServers()[index] for index in indexes) for index, reference in zip(indexes, references): - worker = TestPingLatencyWorker(AS_UserServers(), index, reference) + worker = TestPingLatencyWorker(reference) worker.setAutoDelete(True) worker.finished.connect( - lambda paramIndex, paramFactory: self.flushItem( - paramIndex, self.Headers.index('Latency'), paramFactory + functools.partial( + self.flushItem, + index, + self.Headers.index('Latency'), + reference, ) ) - APP().threadPool.start(worker) + AppThreadPool().start(worker) def testSelectedItemTcpingLatency(self): indexes = self.selectedIndex @@ -1334,21 +1595,24 @@ class UserServersQTableWidget(QTranslatable, AppQTableWidget): references = list(AS_UserServers()[index] for index in indexes) for index, reference in zip(indexes, references): - worker = TestTcpingLatencyWorker(AS_UserServers(), index, reference) + worker = TestTcpingLatencyWorker(reference) worker.setAutoDelete(True) worker.finished.connect( - lambda paramIndex, paramFactory: self.flushItem( - paramIndex, self.Headers.index('Latency'), paramFactory + functools.partial( + self.flushItem, + index, + self.Headers.index('Latency'), + reference, ) ) - APP().threadPool.start(worker) + AppThreadPool().start(worker) @QtCore.Slot() def handleTestDownloadSpeedJob(self): try: - index, server = self.testDownloadSpeedQueue.get_nowait() + index, factory, timeout = self.testDownloadSpeedQueue.get_nowait() except queue.Empty: # Queue is empty @@ -1357,13 +1621,18 @@ class UserServersQTableWidget(QTranslatable, AppQTableWidget): return - assert isinstance(server, ConfigurationFactory) + assert isinstance(factory, ConfigurationFactory) - def testDownloadSpeed(counter=0, timeout=5000, step=100): - worker = TestDownloadSpeedWorker(AS_UserServers(), index, server) + def testDownloadSpeed(counter=0, step=100): + worker = TestDownloadSpeedWorker( + factory, port=20809, logActionMessage=False + ) worker.progressed.connect( - lambda paramIndex, paramFactory: self.flushItem( - paramIndex, self.Headers.index('Speed'), paramFactory + functools.partial( + self.flushItem, + index, + self.Headers.index('Speed'), + factory, ) ) worker.run() @@ -1387,7 +1656,7 @@ class UserServersQTableWidget(QTranslatable, AppQTableWidget): testDownloadSpeed() - def testSelectedItemDownloadSpeed(self): + def testSelectedItemDownloadSpeedWithTimeout(self, timeout: int): indexes = self.selectedIndex if len(indexes) == 0: @@ -1400,7 +1669,7 @@ class UserServersQTableWidget(QTranslatable, AppQTableWidget): for index, reference in zip(indexes, references): try: # Served by FIFO - self.testDownloadSpeedQueue.put_nowait((index, reference)) + self.testDownloadSpeedQueue.put_nowait((index, reference, timeout)) except Exception: # Any non-exit exceptions @@ -1410,6 +1679,9 @@ class UserServersQTableWidget(QTranslatable, AppQTableWidget): if not self.testDownloadSpeedTimer.isActive(): self.testDownloadSpeedTimer.start(250) + def testSelectedItemDownloadSpeed(self): + self.testSelectedItemDownloadSpeedWithTimeout(5000) + def clearSelectedItemTestResult(self): indexes = self.selectedIndex @@ -1425,21 +1697,30 @@ class UserServersQTableWidget(QTranslatable, AppQTableWidget): self.flushItem(index, self.Headers.index('Latency'), factory) self.flushItem(index, self.Headers.index('Speed'), factory) - def updateSubs(self, httpProxy: Union[str, None]): + def updateSubsByUnique(self, unique: str, httpProxy: Union[str, None], **kwargs): self.subsManager.configureHttpProxy(httpProxy) + self.subsManager.updateSubsByUnique(unique, **kwargs) - for key, value in AS_UserSubscription().items(): - self.subsManager.updateSubs(key, value.get('webURL', '')) + def updateSubs(self, httpProxy: Union[str, None], **kwargs): + self.subsManager.configureHttpProxy(httpProxy) + self.subsManager.updateSubs(**kwargs) def appendNewItemByFactory(self, factory: ConfigurationFactory): + index = len(AS_UserServers()) + + # Set index + factory.setIndex(index) + AS_UserServers().append(factory) row = self.rowCount() + assert index == row + self.insertRow(row) self.flushRow(row, factory) - if len(AS_UserServers()) == 1: + if index == 0: # The first one. Click it self.setCurrentItem(self.item(0, 0)) @@ -1522,9 +1803,7 @@ class UserServersQTableWidget(QTranslatable, AppQTableWidget): self.textEditorWindow.hideTabAndSpaces() def keyPressEvent(self, event): - if event.key() == QtCore.Qt.Key.Key_Delete: - self.deleteSelectedItem() - elif event.key() == QtCore.Qt.Key.Key_Return: + if event.key() == QtCore.Qt.Key.Key_Return: if PLATFORM == 'Darwin': # Activate by Enter key on macOS self.itemActivated.emit(self.currentItem()) diff --git a/Furious/Widget/UserSubsQTableWidget.py b/Furious/Widget/UserSubsQTableWidget.py index 2b539573..472455df 100644 --- a/Furious/Widget/UserSubsQTableWidget.py +++ b/Furious/Widget/UserSubsQTableWidget.py @@ -28,10 +28,13 @@ from PySide6 import QtCore from PySide6.QtGui import * from PySide6.QtWidgets import * -from typing import Callable +from typing import Union, Callable +import logging import functools +logger = logging.getLogger(__name__) + __all__ = ['UserSubsQTableWidget'] registerAppSettings('SubscriptionWidgetSectionSizeTable') @@ -65,10 +68,76 @@ class UserSubsQTableWidgetHeaders: return self.name +class UserSubsAppQComboBox(AppQComboBox): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def retranslate(self): + # Do not emit 'currentTextChanged' when retranslate + with QBlockSignals(self): + super().retranslate() + + +# Headers VALUE +TRANSLATABLE_HEADERS = [ + _('Never'), + _('Every 5 mins'), + _('Every 10 mins'), + _('Every 15 mins'), + _('Every 30 mins'), + _('Every 45 mins'), + _('Every 1 hour'), + _('Every 2 hours'), + _('Every 3 hours'), + _('Every 6 hours'), + _('Every 8 hours'), + _('Every 10 hours'), + _('Every 12 hours'), + _('Every 24 hours'), + _('Use current proxy'), + _('Force proxy'), + _('No proxy'), + _('Remark'), + _('Auto Update'), + _('Auto Update Use Proxy'), +] + + class UserSubsQTableWidget(QTranslatable, AppQTableWidget): + AutoUpdateOptions = { + '': None, + 'Never': None, + 'Every 5 mins': 5 * 60 * 1000, + 'Every 10 mins': 10 * 60 * 1000, + 'Every 15 mins': 15 * 60 * 1000, + 'Every 30 mins': 30 * 60 * 1000, + 'Every 45 mins': 45 * 60 * 1000, + 'Every 1 hour': 1 * 60 * 60 * 1000, + 'Every 2 hours': 2 * 60 * 60 * 1000, + 'Every 3 hours': 3 * 60 * 60 * 1000, + 'Every 6 hours': 6 * 60 * 60 * 1000, + 'Every 8 hours': 8 * 60 * 60 * 1000, + 'Every 10 hours': 10 * 60 * 60 * 1000, + 'Every 12 hours': 12 * 60 * 60 * 1000, + 'Every 24 hours': 24 * 60 * 60 * 1000, + } + + ProxyOptions = { + '': lambda: None, + 'Use current proxy': lambda: connectedHttpProxyEndpoint(), + 'Force proxy': lambda: '127.0.0.1:10809', + 'No proxy': lambda: None, + } + Headers = [ UserSubsQTableWidgetHeaders('Remark', lambda item: item.get('remark', '')), UserSubsQTableWidgetHeaders('URL', lambda item: item.get('webURL', '')), + UserSubsQTableWidgetHeaders( + 'Auto Update', lambda item: item.get('autoupdate', '') + ), + UserSubsQTableWidgetHeaders( + 'Auto Update Use Proxy', lambda item: item.get('proxy', '') + ), ] def __init__(self, *args, **kwargs): @@ -76,6 +145,8 @@ class UserSubsQTableWidget(QTranslatable, AppQTableWidget): super().__init__(*args, **kwargs) + self.timers = list(QtCore.QTimer() for i in range(len(AS_UserSubscription()))) + # Must set before flush all self.setColumnCount(len(self.Headers)) @@ -124,7 +195,8 @@ class UserSubsQTableWidget(QTranslatable, AppQTableWidget): @QtCore.Slot(QTableWidgetItem) def handleItemChanged(self, item: QTableWidgetItem): unique = list(AS_UserSubscription().keys())[item.row()] - keyMap = ['remark', 'webURL'] + # 'autoupdate', 'proxy' is not triggered here + keyMap = ['remark', 'webURL', 'autoupdate', 'proxy'] AS_UserSubscription()[unique][keyMap[item.column()]] = item.text() @@ -149,6 +221,25 @@ class UserSubsQTableWidget(QTranslatable, AppQTableWidget): AS_UserSubscription().pop(deleteUnique) + # Begin timer cleanup + qtimer = self.timers[deleteIndex] + + assert isinstance(qtimer, QtCore.QTimer) + + try: + qtimer.timeout.disconnect() + except Exception as ex: + # Any non-exit exceptions + + # Disconnect all previous signals if possible + pass + + qtimer.stop() + # End timer cleanup + + # Remove timer from list + self.timers.pop(deleteIndex) + if callable(self.deleteUniqueCallback): self.deleteUniqueCallback(deleteUnique) else: @@ -170,37 +261,189 @@ class UserSubsQTableWidget(QTranslatable, AppQTableWidget): mbox.setText(mbox.customText()) mbox.finished.connect(functools.partial(handleResultCode, indexes)) - # dummy ref - setattr(self, '_questionDeleteMBox', mbox) - # Show the MessageBox asynchronously mbox.open() - def flushItem(self, row, column, item): - header = self.Headers[column] + def handleAutoUpdateComboBoxCurrentTextChanged(self, text: str, row: int): + textEnglish = _(text, 'EN') - oldItem = self.item(row, column) - newItem = QTableWidgetItem(header(item)) + unique = list(AS_UserSubscription().keys())[row] + subsob = AS_UserSubscription()[unique] - if oldItem is None: - # Item does not exists - newItem.setFont(QFont(APP().customFontName)) - else: - # Use existing - newItem.setFont(oldItem.font()) - newItem.setForeground(oldItem.foreground()) + remark, webURL = subsob['remark'], subsob['webURL'] - if oldItem.textAlignment() != 0: - newItem.setTextAlignment(oldItem.textAlignment()) + try: + timems = self.AutoUpdateOptions[textEnglish] + except KeyError: + # Invalid key. Reset + logger.error(f'\'{textEnglish}\' is not in auto update options. Reset') - # Editable - newItem.setFlags( - QtCore.Qt.ItemFlag.ItemIsEnabled - | QtCore.Qt.ItemFlag.ItemIsSelectable - | QtCore.Qt.ItemFlag.ItemIsEditable + textEnglish = '' + + # Write to subs object + subsob['autoupdate'] = textEnglish + + timems = self.AutoUpdateOptions[textEnglish] + + qtimer = self.timers[row] + + assert isinstance(qtimer, QtCore.QTimer) + + if timems is not None: + logger.info( + f'start auto update job for subscription ({remark}, {webURL}). ' + f'Interval is {timems // (60 * 1000)} mins' ) - self.setItem(row, column, newItem) + def getHttpProxy(_subsob): + return self.ProxyOptions[_subsob.get('proxy', '')]() + + try: + qtimer.timeout.disconnect() + except Exception as ex: + # Any non-exit exceptions + + # Disconnect all previous signals if possible + pass + + qtimer.timeout.connect( + functools.partial( + self.updateSubsByUnique, + unique=unique, + httpProxy=functools.partial(getHttpProxy, subsob), + ) + ) + qtimer.start(timems) + else: + logger.info(f'stop auto update job for subscription ({remark}, {webURL})') + + try: + qtimer.timeout.disconnect() + except Exception as ex: + # Any non-exit exceptions + + # Disconnect all previous signals if possible + pass + + qtimer.stop() + + # Write to subs object + subsob['autoupdate'] = textEnglish + + # Return potentially fixed value + return textEnglish + + def handleProxyComboBoxCurrentTextChanged(self, text: str, row: int): + textEnglish = _(text, 'EN') + + unique = list(AS_UserSubscription().keys())[row] + subsob = AS_UserSubscription()[unique] + + try: + proxyFn = self.ProxyOptions[textEnglish] + except KeyError: + # Invalid key. Reset + logger.error(f'\'{textEnglish}\' is not in proxy options. Reset') + + textEnglish = '' + + # Write to subs object + subsob['proxy'] = textEnglish + + proxyFn = self.ProxyOptions[textEnglish] + + assert callable(proxyFn) + + # Write to subs object + subsob['proxy'] = textEnglish + + # Return potentially fixed value + return textEnglish + + def flushItem(self, row, column, item): + if column == self.Headers.index('Auto Update'): + header = self.Headers[column] + + oldItem = self.item(row, column) + newItem = UserSubsAppQComboBox() + newItem.addItems(list(_(key) for key in self.AutoUpdateOptions.keys())) + + if oldItem is None: + # Item does not exist + newItem.setFont(QFont(APP().customFontName)) + else: + # Use existing + newItem.setFont(oldItem.font()) + + index = newItem.findText( + _(self.handleAutoUpdateComboBoxCurrentTextChanged(header(item), row)) + ) + + if index >= 0: + newItem.setCurrentIndex(index) + + newItem.currentTextChanged.connect( + functools.partial( + self.handleAutoUpdateComboBoxCurrentTextChanged, + row=row, + ) + ) + + self.setCellWidget(row, column, newItem) + elif column == self.Headers.index('Auto Update Use Proxy'): + header = self.Headers[column] + + oldItem = self.item(row, column) + newItem = UserSubsAppQComboBox() + newItem.addItems(list(_(key) for key in self.ProxyOptions.keys())) + + if oldItem is None: + # Item does not exist + newItem.setFont(QFont(APP().customFontName)) + else: + # Use existing + newItem.setFont(oldItem.font()) + + index = newItem.findText( + _(self.handleProxyComboBoxCurrentTextChanged(header(item), row)) + ) + + if index >= 0: + newItem.setCurrentIndex(index) + + newItem.currentTextChanged.connect( + functools.partial( + self.handleProxyComboBoxCurrentTextChanged, + row=row, + ) + ) + + self.setCellWidget(row, column, newItem) + else: + header = self.Headers[column] + + oldItem = self.item(row, column) + newItem = QTableWidgetItem(header(item)) + + if oldItem is None: + # Item does not exist + 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())): @@ -217,24 +460,62 @@ class UserSubsQTableWidget(QTranslatable, AppQTableWidget): self.flushRow(index, AS_UserSubscription()[key]) def appendNewItem(self, **kwargs): - unique = kwargs.pop('unique', '') - remark = kwargs.pop('remark', '') - webURL = kwargs.pop('webURL', '') + unique, remark, webURL, autoupdate, proxy = ( + kwargs.pop('unique', ''), + kwargs.pop('remark', ''), + kwargs.pop('webURL', ''), + # Below values are not filled when adding item via Gui + kwargs.pop('autoupdate', ''), + kwargs.pop('proxy', ''), + ) # The internal subs object - subs = { + subsob = { unique: { 'remark': remark, 'webURL': webURL, + 'autoupdate': autoupdate, + 'proxy': proxy, } } - AS_UserSubscription().update(subs) + # 'unique' should be unique in subscription storage, + # but protect it anyway. + if unique not in AS_UserSubscription(): + # Add timer + self.timers.append(QtCore.QTimer()) + + AS_UserSubscription().update(subsob) row = self.rowCount() self.insertRow(row) - self.flushRow(row, subs[unique]) + self.flushRow(row, subsob[unique]) + + @staticmethod + def updateSubsByUnique( + unique: str, httpProxy: Union[str, Callable, None], **kwargs + ): + showMessageBox = kwargs.pop('showMessageBox', False) + + if callable(httpProxy): + try: + realHttpProxy = httpProxy() + except Exception as ex: + # Any non-exit exceptions + + logger.error( + f'error while configuring http proxy: {ex}. ' + f'Update subscription uses no proxy' + ) + + realHttpProxy = None + else: + realHttpProxy = httpProxy + + APP().mainWindow.updateSubsByUnique( + unique, realHttpProxy, showMessageBox=showMessageBox, **kwargs + ) def retranslate(self): self.setHorizontalHeaderLabels(list(_(str(header)) for header in self.Headers)) diff --git a/Furious/Widget/XrayAssetViewerQListWidget.py b/Furious/Widget/XrayAssetViewerQListWidget.py index 5ab927c7..65ba2e91 100644 --- a/Furious/Widget/XrayAssetViewerQListWidget.py +++ b/Furious/Widget/XrayAssetViewerQListWidget.py @@ -195,9 +195,6 @@ class XrayAssetViewerQListWidget(SupportThemeChangedCallback, AppQListWidget): mbox.setInformativeText(basename) mbox.finished.connect(functools.partial(handleResultCode, filename)) - # dummy ref - setattr(self, '_assetExistsMBox', mbox) - # Show the MessageBox asynchronously mbox.open() else: @@ -235,9 +232,6 @@ class XrayAssetViewerQListWidget(SupportThemeChangedCallback, AppQListWidget): mbox.setText(mbox.customText()) mbox.finished.connect(functools.partial(handleResultCode, indexes)) - # dummy ref - setattr(self, '_questionDeleteMBox', mbox) - # Show the MessageBox asynchronously mbox.open() diff --git a/Furious/Widget/__init__.py b/Furious/Widget/__init__.py index b79c069a..af8ecf8c 100644 --- a/Furious/Widget/__init__.py +++ b/Furious/Widget/__init__.py @@ -21,6 +21,7 @@ from .GuiHysteria1 import * from .GuiHysteria2 import * from .GuiShadowsocks import * from .GuiTrojan import * +from .GuiTUNSettings import * from .GuiVLESS import * from .GuiVMess import * from .IndentSpinBox import * diff --git a/Furious/Window/AppMainWindow.py b/Furious/Window/AppMainWindow.py index 5e2cfc99..8875e6b4 100644 --- a/Furious/Window/AppMainWindow.py +++ b/Furious/Window/AppMainWindow.py @@ -23,6 +23,7 @@ from Furious.QtFramework import gettext as _ from Furious.Library import * from Furious.Utility import * from Furious.Widget.UserServersQTableWidget import * +from Furious.Widget.GuiTUNSettings import * from Furious.Window.UserSubsWindow import * from Furious.Window.LogViewerWindow import * from Furious.Window.XrayAssetViewerWindow import * @@ -45,57 +46,25 @@ logger = logging.getLogger(__name__) registerAppSettings('ServerWidgetWindowSize') -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): + def successCallback(self, networkReply, **kwargs): parent = self.parent() if isinstance(parent, AppMainWindow): parent.setNetworkState(True) - def errorCallback(self, errorString: str): + super().successCallback(networkReply, **kwargs) + + def failureCallback(self, networkReply, **kwargs): parent = self.parent() if isinstance(parent, AppMainWindow): - parent.setNetworkState(False, errorString=errorString) + parent.setNetworkState(False, errorString=networkReply.errorString()) + + super().failureCallback(networkReply, **kwargs) def startSingleTest(self): if not APP().isSystemTrayConnected(): @@ -106,15 +75,15 @@ class AppNetworkStateManager(NetworkStateManager): self.stopTest() - httpProxyEndpoint = connectedHttpProxyEndpoint() + connectedHttpProxy = connectedHttpProxyEndpoint() - if httpProxyEndpoint is None: + if connectedHttpProxy is None: parent = self.parent() if isinstance(parent, AppMainWindow): parent.resetNetworkState() else: - self.configureHttpProxy(httpProxyEndpoint) + self.configureHttpProxy(connectedHttpProxy) super().startSingleTest() @@ -124,7 +93,7 @@ class AppNetworkStateManager(NetworkStateManager): if isinstance(parent, AppMainWindow): parent.resetNetworkState() - self.stopTest() + super().disconnectedCallback() class AppMainWindow(AppQMainWindow): @@ -132,7 +101,8 @@ class AppMainWindow(AppQMainWindow): super().__init__(*args, **kwargs) self.setWindowTitle(_(APPLICATION_NAME)) - self.setAttribute(QtCore.Qt.WidgetAttribute.WA_ShowWithoutActivating) + # TODO: Need this? + # self.setAttribute(QtCore.Qt.WidgetAttribute.WA_ShowWithoutActivating) self.updatesManager = UpdatesManager() self.networkStateManager = AppNetworkStateManager(parent=self) @@ -263,7 +233,21 @@ class AppMainWindow(AppQMainWindow): ), ] + if PLATFORM == 'Windows' or PLATFORM == 'Darwin': + customizeTUNSettingsAction = [ + AppQAction( + _('Customize TUN Settings...'), + icon=bootstrapIcon('diagram-3.svg'), + checkable=False, + callback=lambda: self.getGuiTUNSettings().open(), + ), + AppQSeperator(), + ] + else: + customizeTUNSettingsAction = [] + toolsActions = [ + *customizeTUNSettingsAction, AppQAction( _('Manage Xray-core Asset File...'), callback=lambda: self.xrayAssetViewerWindow.show(), @@ -402,6 +386,9 @@ class AppMainWindow(AppQMainWindow): self.setCentralWidget(self._widget) + def updateSubsByUnique(self, unique: str, httpProxy: Union[str, None], **kwargs): + self.userServersQTableWidget.updateSubsByUnique(unique, httpProxy, **kwargs) + def appendNewItemByFactory(self, factory: ConfigurationFactory): self.userServersQTableWidget.appendNewItemByFactory(factory) @@ -414,15 +401,25 @@ class AppMainWindow(AppQMainWindow): def hideTabAndSpaces(self): self.userServersQTableWidget.hideTabAndSpaces() - def checkForUpdates(self): + def getGuiTUNSettings(self, **kwargs): + @functools.lru_cache(None) + def cachedGuiTUNSettings(): + return GuiTUNSettings(self, **kwargs) + + guiTUNSettings = cachedGuiTUNSettings() + guiTUNSettings.factoryToInput(AS_UserTUNSettings()) + + return guiTUNSettings + + def checkForUpdates(self, **kwargs): self.updatesManager.configureHttpProxy(connectedHttpProxyEndpoint()) - self.updatesManager.checkForUpdates() + self.updatesManager.checkForUpdates(**kwargs) def resetNetworkState(self): self.networkState.setText('') def setNetworkState(self, success: bool, **kwargs): - remark = connectedRemark() + remark = connectedServerRemark() if success: if remark: diff --git a/Furious/Window/LogViewerWindow.py b/Furious/Window/LogViewerWindow.py index 9a42f258..ac002124 100644 --- a/Furious/Window/LogViewerWindow.py +++ b/Furious/Window/LogViewerWindow.py @@ -99,7 +99,7 @@ class LogViewerWindow(AppQMainWindow): AppQSeperator(), AppQAction( _('Exit'), - callback=lambda: self.hide(), + callback=lambda: self.close(), ), title=_('File'), parent=self, diff --git a/Furious/Window/QRCodeWindow.py b/Furious/Window/QRCodeWindow.py index 0fb94929..761386bb 100644 --- a/Furious/Window/QRCodeWindow.py +++ b/Furious/Window/QRCodeWindow.py @@ -33,7 +33,7 @@ import pyqrcode __all__ = ['QRCodeWindow'] -class QRCodeWindow(SupportImplicitReference, AppQMainWindow): +class QRCodeWindow(AppQMainWindow): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -83,4 +83,4 @@ class QRCodeWindow(SupportImplicitReference, AppQMainWindow): self.tabWidget.removeTab(index) if self.tabWidget.count() == 0: - self.hide() + self.close() diff --git a/Furious/Window/TextEditorWindow.py b/Furious/Window/TextEditorWindow.py index 567d3bc1..e9dd539c 100644 --- a/Furious/Window/TextEditorWindow.py +++ b/Furious/Window/TextEditorWindow.py @@ -124,7 +124,7 @@ class TextEditorWindow(AppQMainWindow): AppQSeperator(), AppQAction( _('Close Window'), - callback=lambda: self.questionSave(), + callback=lambda: self.close(), ), title=_('File'), parent=self.menuBar(), @@ -228,38 +228,6 @@ class TextEditorWindow(AppQMainWindow): self.modified = False self.setWindowTitle(self.customWindowTitle) - def questionSave(self): - if self.modified: - - def handleButtonClicked(saveMBox, button): - assert isinstance(saveMBox, QuestionSaveMBox) - - if saveMBox.button0 == button: - # Save - if self.save(): - saveMBox.close() - self.hide() - elif saveMBox.button1 == button: - # Discard - self.markAsSaved() - saveMBox.close() - self.hide() - elif saveMBox.button2 == button: - # Cancel. Do nothing - pass - - mbox = QuestionSaveMBox(icon=AppQMessageBox.Icon.Question, parent=self) - mbox.setWindowModality(QtCore.Qt.WindowModality.WindowModal) - mbox.buttonClicked.connect(functools.partial(handleButtonClicked, mbox)) - - # dummy ref - setattr(self, '_questionSaveMBox', mbox) - - # Show the MessageBox asynchronously - mbox.open() - else: - self.hide() - def setPlainText(self, text: str, blockSignals: bool): if blockSignals: with QBlockSignals(self.jsonEditor): @@ -297,7 +265,7 @@ class TextEditorWindow(AppQMainWindow): old = AS_UserServers()[index] new = constructFromDict(jsonObject, **old.kwargs) - FastItemDeletionSearch.moveToTrash(old) + QGarbageCollector.track(old) AS_UserServers()[index] = new @@ -369,9 +337,6 @@ class TextEditorWindow(AppQMainWindow): functools.partial(handleResultCode, indentSpinBox) ) - # dummy ref - setattr(self, '_IndentSpinBox', indentSpinBox) - # Show the MessageBox asynchronously indentSpinBox.open() @@ -393,9 +358,36 @@ class TextEditorWindow(AppQMainWindow): self.jsonEditor.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) def closeEvent(self, event: QtCore.QEvent): - event.ignore() + if self.modified: + mbox = QuestionSaveMBox(icon=AppQMessageBox.Icon.Question, parent=self) + mbox.setWindowModality(QtCore.Qt.WindowModality.WindowModal) - self.questionSave() + def handleButtonClicked(button): + if button == mbox.button0: + # Save + if self.save(): + mbox.close() + + event.accept() + else: + event.ignore() + elif button == mbox.button1: + # Discard + self.markAsSaved() + + mbox.close() + + event.accept() + elif button == mbox.button2: + # Cancel. Do nothing + event.ignore() + + mbox.buttonClicked.connect(functools.partial(handleButtonClicked)) + + # Show the MessageBox and wait for the user to close it + mbox.exec() + else: + event.accept() def retranslate(self): # Do nothing diff --git a/Furious/Window/UserSubsWindow.py b/Furious/Window/UserSubsWindow.py index b19bcf37..ab8cfaef 100644 --- a/Furious/Window/UserSubsWindow.py +++ b/Furious/Window/UserSubsWindow.py @@ -116,7 +116,7 @@ class UserSubsWindow(AppQMainWindow): except Exception: # Any non-exit exceptions - self.setGeometry(100, 100, 360 * GOLDEN_RATIO, 360) + self.setGeometry(100, 100, 500 * GOLDEN_RATIO, 500) def addSubs(self): def handleResultCode(_addSubsDialog, code): @@ -141,9 +141,6 @@ class UserSubsWindow(AppQMainWindow): functools.partial(handleResultCode, addSubsDialog) ) - # dummy ref - setattr(self, '_addSubsDialog', addSubsDialog) - # Show the MessageBox asynchronously addSubsDialog.open() diff --git a/Furious/Window/XrayAssetViewerWindow.py b/Furious/Window/XrayAssetViewerWindow.py index d80c1cff..2d3a08ff 100644 --- a/Furious/Window/XrayAssetViewerWindow.py +++ b/Furious/Window/XrayAssetViewerWindow.py @@ -77,7 +77,7 @@ class XrayAssetViewerWindow(AppQMainWindow): AppQSeperator(), AppQAction( _('Exit'), - callback=lambda: self.hide(), + callback=lambda: self.close(), ), title=_('File'), parent=self.menuBar(), diff --git a/Furious/__main__.py b/Furious/__main__.py index b4322a71..aa77b4e5 100644 --- a/Furious/__main__.py +++ b/Furious/__main__.py @@ -36,12 +36,12 @@ logger = logging.getLogger(__name__) def runAppMain(): - appMainProcess = AppMainProcess(functools.partial(Application, sys.argv)) + process = AppMainProcess(functools.partial(Application, sys.argv)) - appMainProcess.start() - appMainProcess.join() + process.start() + process.join() - exitcode = appMainProcess.exitcode + exitcode = process.exitcode if exitcode == 0: sys.exit(exitcode) @@ -76,7 +76,7 @@ def runAppMain(): ) ) - # Show the MessageBox and wait for user to close it + # Show the MessageBox and wait for the user to close it messageBox.exec() else: if exitcode == ApplicationFactory.ExitCode.AssertionError: @@ -96,34 +96,44 @@ def runAppMain(): messageBox.setWindowTitle(_(APPLICATION_NAME)) messageBox.setText(text) - if appMainProcess.fileWritten.value: + if process.fileWritten.value: # Crash log saved - crashLogFile = str(CRASH_LOG_DIR / appMainProcess.logFileName) + crashLogFile = str(CRASH_LOG_DIR / process.logFileName) + + logger.info(f'crash log has been saved to {crashLogFile}') messageBox.setInformativeText( _('Crash log has been saved to') + f' {crashLogFile}' ) - messageBox.addButton( + button0 = messageBox.addButton( _('Open crash log'), AppQMessageBox.ButtonRole.AcceptRole ) - messageBox.addButton(_('OK'), AppQMessageBox.ButtonRole.RejectRole) + button1 = messageBox.addButton( + _('OK'), AppQMessageBox.ButtonRole.RejectRole + ) - # Show the MessageBox and wait for user to close it - if messageBox.exec() == PySide6LegacyEnumValueWrapper( - AppQMessageBox.ButtonRole.AcceptRole - ): - # Open - if QDesktopServices.openUrl(QtCore.QUrl.fromLocalFile(crashLogFile)): - logger.info(f'open crash log {crashLogFile} success') + def handleButtonClicked(button): + if button == button0: + # Open + if QDesktopServices.openUrl( + QtCore.QUrl.fromLocalFile(crashLogFile) + ): + logger.info(f'open crash log {crashLogFile} success') + else: + logger.error(f'open crash log {crashLogFile} failed') else: - logger.error(f'open crash log {crashLogFile} failed') - else: - # OK. Do nothing - pass - else: - # Crash log not saved + # OK. Do nothing + pass - # Show the MessageBox and wait for user to close it + messageBox.buttonClicked.connect(handleButtonClicked) + + # Show the MessageBox and wait for the user to close it + messageBox.exec() + else: + # Crash log wasn't saved + logger.info(f'crash log was not saved') + + # Show the MessageBox and wait for the user to close it messageBox.exec() sys.exit(exitcode)