From 219343a06a2a63dee325a7c9bb16826c76229ae5 Mon Sep 17 00:00:00 2001 From: Loren Eteval Date: Tue, 4 Aug 2026 21:43:41 +0800 Subject: [PATCH] Reorganize application packages Signed-off-by: Loren Eteval --- Deploy.py | 6 +- .../{Widget => Application}/Application.py | 11 +- .../{Widget => Application}/SystemTrayIcon.py | 0 Furious/Application/__init__.py | 25 +++ Furious/Core/__init__.py | 23 ++- Furious/Externals/GenTranslation.py | 64 +++---- Furious/Externals/__init__.py | 4 +- Furious/Frozenlib/Constants.py | 47 ++++- Furious/Frozenlib/PySide6Legacy.py | 8 +- Furious/Frozenlib/Utility.py | 10 +- Furious/Frozenlib/Version.py | 24 +++ Furious/Frozenlib/__init__.py | 157 +++++++++++++++-- Furious/Interface/CoreProcessFactory.py | 11 +- Furious/Interface/__init__.py | 25 ++- .../Configuration.py} | 148 +++++++++++++--- Furious/Library/UserServers.py | 2 +- Furious/Library/__init__.py | 30 +++- Furious/Plugins/Configuration.py | 65 ------- Furious/Plugins/Official/Configuration.py | 11 +- Furious/Plugins/Official/Hysteria1/Core.py | 2 +- .../Official/Hysteria1/GuiHysteria1.py | 1 + Furious/Plugins/Official/Hysteria1/Plugin.py | 20 +-- .../Plugins/Official/Hysteria1/__init__.py | 4 +- Furious/Plugins/Official/Hysteria2/Core.py | 2 +- .../Official/Hysteria2/GuiHysteria2.py | 1 + .../Official/Hysteria2/GuiTUNSettings.py | 38 ++++- Furious/Plugins/Official/Hysteria2/Plugin.py | 51 ++---- Furious/Plugins/Official/Hysteria2/TUN.py | 25 ++- .../Plugins/Official/Hysteria2/__init__.py | 4 +- Furious/Plugins/Official/Xray/Core.py | 4 +- .../Plugins/Official/Xray/GuiShadowsocks.py | 5 +- Furious/Plugins/Official/Xray/GuiSocks.py | 5 +- .../Plugins/Official/Xray/GuiTUNSettings.py | 35 +++- Furious/Plugins/Official/Xray/GuiTrojan.py | 5 +- Furious/Plugins/Official/Xray/GuiVLESS.py | 5 +- Furious/Plugins/Official/Xray/GuiVMess.py | 5 +- Furious/Plugins/Official/Xray/GuiVTLS.py | 2 +- .../Plugins/Official/Xray/GuiVTransport.py | 2 +- Furious/Plugins/Official/Xray/Plugin.py | 59 +++---- Furious/Plugins/Official/Xray/Routing.py | 8 +- Furious/Plugins/Official/Xray/TUN.py | 8 + .../Official/Xray/XrayAssetDownloadManager.py | 2 +- .../Official/Xray/XrayAssetViewerWindow.py | 2 + Furious/Plugins/Official/Xray/__init__.py | 4 +- Furious/Plugins/Registry.py | 88 ++++++++-- Furious/Plugins/__init__.py | 25 ++- Furious/Qt/GuiEditorXXX.py | 1 + Furious/Qt/TextEditorTheme.py | 35 ++-- Furious/{Widget => Qt}/WaitingSpinner.py | 19 +++ Furious/Qt/__init__.py | 160 ++++++++++++++++-- Furious/{Core => Service}/CoreManager.py | 4 +- Furious/Service/__init__.py | 24 +++ Furious/TrayActions/Connect.py | 4 +- .../ConnectProgressBar.py | 0 Furious/TrayActions/Import.py | 2 +- Furious/TrayActions/__init__.py | 39 ++++- Furious/Utility/__init__.py | 4 +- Furious/Version.py | 6 +- Furious/Widget/GuiTUNSettings.py | 5 +- Furious/{Window => Widget}/QRCodeWindow.py | 0 .../{Window => Widget}/TextEditorWindow.py | 0 Furious/Widget/UserServersQTableView.py | 17 +- Furious/Widget/__init__.py | 29 ++-- Furious/Window/AppMainWindow.py | 16 ++ Furious/Window/__init__.py | 10 +- Furious/__init__.py | 2 + Furious/__main__.py | 2 +- 67 files changed, 1066 insertions(+), 396 deletions(-) rename Furious/{Widget => Application}/Application.py (98%) rename Furious/{Widget => Application}/SystemTrayIcon.py (100%) create mode 100644 Furious/Application/__init__.py create mode 100644 Furious/Frozenlib/Version.py rename Furious/{Interface/ConfigFactory.py => Library/Configuration.py} (62%) delete mode 100644 Furious/Plugins/Configuration.py rename Furious/{Widget => Qt}/WaitingSpinner.py (93%) rename Furious/{Core => Service}/CoreManager.py (99%) create mode 100644 Furious/Service/__init__.py rename Furious/{Widget => TrayActions}/ConnectProgressBar.py (100%) rename Furious/{Window => Widget}/QRCodeWindow.py (100%) rename Furious/{Window => Widget}/TextEditorWindow.py (100%) diff --git a/Deploy.py b/Deploy.py index f520fded..f3e6f6c0 100644 --- a/Deploy.py +++ b/Deploy.py @@ -60,7 +60,7 @@ NUITKA_BINARY_VERSION_OPTION = ( f'--product-version={APPLICATION_VERSION} ' ) -PLATFORM_MACHINE_LOWER = PLATFORM_MACHINE.lower() +PLATFORM_MACHINE_LOWER = PLATFORM_MACHINE.casefold() if PLATFORM == 'Windows': NUITKA_BINARY_VERSION_OPTION += f'--file-description=\"{APPLICATION_DESCRIPTION}\" ' @@ -117,9 +117,9 @@ else: if PLATFORM == 'Windows': if PLATFORM_RELEASE.endswith('Server'): # Windows server. Fixed to windows10 - winVerCompatible = f'{PLATFORM.lower()}10' + winVerCompatible = f'{PLATFORM.casefold()}10' else: - winVerCompatible = f'{PLATFORM.lower()}{PLATFORM_RELEASE}' + winVerCompatible = f'{PLATFORM.casefold()}{PLATFORM_RELEASE}' if os.environ.get('WIN_VER_COMPATIBLE', ''): winVerCompatible = os.environ['WIN_VER_COMPATIBLE'] diff --git a/Furious/Widget/Application.py b/Furious/Application/Application.py similarity index 98% rename from Furious/Widget/Application.py rename to Furious/Application/Application.py index 4d58939a..4af593f8 100644 --- a/Furious/Widget/Application.py +++ b/Furious/Application/Application.py @@ -21,11 +21,13 @@ from __future__ import annotations from Furious.Frozenlib import * from Furious.Interface import * +from Furious.Core import Tun2socks from Furious.Plugins import getPluginRegistry from Furious.Qt import AppStyleSheet +from Furious.Qt.TextEditorTheme import configureEditorLogMetadata from Furious.Qt import gettext as _ from Furious.Library import * -from Furious.Widget.SystemTrayIcon import * +from Furious.Application.SystemTrayIcon import * from Furious.Window.AppMainWindow import * from Furious.Window.LogViewerWindow import * @@ -296,7 +298,12 @@ class Application(ApplicationFactory, SingletonApplication): @staticmethod def addEnviron(): """Add environ.""" - getPluginRegistry().configureEnvironment() + pluginRegistry = getPluginRegistry() + configureEditorLogMetadata( + lambda: (*pluginRegistry.coreVersions(), Tun2socks.version()), + pluginRegistry.logTimestampPatterns, + ) + pluginRegistry.configureEnvironment() if SystemRuntime.flatpakID(): # https://github.com/flatpak/flatpak/issues/3438 diff --git a/Furious/Widget/SystemTrayIcon.py b/Furious/Application/SystemTrayIcon.py similarity index 100% rename from Furious/Widget/SystemTrayIcon.py rename to Furious/Application/SystemTrayIcon.py diff --git a/Furious/Application/__init__.py b/Furious/Application/__init__.py new file mode 100644 index 00000000..c221a7fe --- /dev/null +++ b/Furious/Application/__init__.py @@ -0,0 +1,25 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# 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 . + +"""Expose the GUI composition root for the Furious desktop application.""" + +from __future__ import annotations + +from .Application import Application +from .SystemTrayIcon import SystemTrayIcon + +__all__ = ['Application', 'SystemTrayIcon'] diff --git a/Furious/Core/__init__.py b/Furious/Core/__init__.py index 32a7bcbe..c7e645b8 100644 --- a/Furious/Core/__init__.py +++ b/Furious/Core/__init__.py @@ -15,9 +15,26 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -"""Expose the public core-process integration API.""" +"""Expose reusable proxy-process primitives without application orchestration.""" from __future__ import annotations -from .Tun2socks import * -from .CoreManager import * +from .CoreProcessWorker import ( + CoreLaunchSpec, + CoreProcessMonitor, + CoreProcessState, + CoreProcessWorker, + MsgQueue, + ProcessOutputRedirector, +) +from .Tun2socks import Tun2socks + +__all__ = [ + 'CoreLaunchSpec', + 'CoreProcessMonitor', + 'CoreProcessState', + 'CoreProcessWorker', + 'MsgQueue', + 'ProcessOutputRedirector', + 'Tun2socks', +] diff --git a/Furious/Externals/GenTranslation.py b/Furious/Externals/GenTranslation.py index b4e023ca..16feee0e 100644 --- a/Furious/Externals/GenTranslation.py +++ b/Furious/Externals/GenTranslation.py @@ -154,7 +154,7 @@ TRANSLATION = { "Text files (*.json);;All files (*)": { "source": [ "Furious.TrayActions.Import", - "Furious.Window.TextEditorWindow" + "Furious.Widget.TextEditorWindow" ], "RU": "Текст файлов (*.json);;Остальные файлы (*)", "ZH": "文本文件 (*.json);;所有文件 (*)", @@ -225,7 +225,7 @@ TRANSLATION = { "Furious.Widget.GuiCustomizeProxyBypass", "Furious.Widget.IndentSpinBox", "Furious.Widget.UserServersQTableView", - "Furious.Window.TextEditorWindow", + "Furious.Widget.TextEditorWindow", "Furious.Window.UserSubsWindow" ], "RU": "Отмена", @@ -234,7 +234,7 @@ TRANSLATION = { }, "Save Changes": { "source": [ - "Furious.Window.TextEditorWindow" + "Furious.Widget.TextEditorWindow" ], "RU": "Сохранить изменения", "ZH": "保存更改", @@ -242,7 +242,7 @@ TRANSLATION = { }, "The content has been modified. Save changes?": { "source": [ - "Furious.Window.TextEditorWindow" + "Furious.Widget.TextEditorWindow" ], "RU": "Содержимое было изменено. Сохранить изменения?", "ZH": "内容已修改。保存更改?", @@ -250,7 +250,7 @@ TRANSLATION = { }, "Save": { "source": [ - "Furious.Window.TextEditorWindow" + "Furious.Widget.TextEditorWindow" ], "RU": "Сохранить", "ZH": "保存", @@ -258,7 +258,7 @@ TRANSLATION = { }, "Discard": { "source": [ - "Furious.Window.TextEditorWindow" + "Furious.Widget.TextEditorWindow" ], "RU": "Отказаться", "ZH": "丢弃", @@ -266,7 +266,7 @@ TRANSLATION = { }, "Please check if the configuration is in valid JSON format": { "source": [ - "Furious.Window.TextEditorWindow" + "Furious.Widget.TextEditorWindow" ], "RU": "Проверьте, имеет ли конфигурация допустимый формат JSON", "ZH": "请检查配置是否为有效的JSON格式", @@ -275,7 +275,7 @@ TRANSLATION = { "Save As...": { "source": [ "Furious.Window.LogViewerWindow", - "Furious.Window.TextEditorWindow" + "Furious.Widget.TextEditorWindow" ], "RU": "Сохранить как...", "ZH": "另存为...", @@ -283,7 +283,7 @@ TRANSLATION = { }, "Close Window": { "source": [ - "Furious.Window.TextEditorWindow" + "Furious.Widget.TextEditorWindow" ], "RU": "Закрыть окно", "ZH": "关闭窗口", @@ -293,7 +293,7 @@ TRANSLATION = { "source": [ "Furious.Plugins.Official.Xray.XrayAssetViewerWindow", "Furious.Window.LogViewerWindow", - "Furious.Window.TextEditorWindow" + "Furious.Widget.TextEditorWindow" ], "RU": "Файл", "ZH": "文件", @@ -301,7 +301,7 @@ TRANSLATION = { }, "Undo": { "source": [ - "Furious.Window.TextEditorWindow" + "Furious.Widget.TextEditorWindow" ], "RU": "Отменить", "ZH": "撤销", @@ -309,7 +309,7 @@ TRANSLATION = { }, "Redo": { "source": [ - "Furious.Window.TextEditorWindow" + "Furious.Widget.TextEditorWindow" ], "RU": "Повторить", "ZH": "重做", @@ -317,7 +317,7 @@ TRANSLATION = { }, "Cut": { "source": [ - "Furious.Window.TextEditorWindow" + "Furious.Widget.TextEditorWindow" ], "RU": "Вырезать", "ZH": "剪切", @@ -326,7 +326,7 @@ TRANSLATION = { "Copy": { "source": [ "Furious.Window.LogViewerWindow", - "Furious.Window.TextEditorWindow" + "Furious.Widget.TextEditorWindow" ], "RU": "Копировать", "ZH": "复制", @@ -334,7 +334,7 @@ TRANSLATION = { }, "Paste": { "source": [ - "Furious.Window.TextEditorWindow" + "Furious.Widget.TextEditorWindow" ], "RU": "Вставить", "ZH": "粘贴", @@ -344,7 +344,7 @@ TRANSLATION = { "source": [ "Furious.Widget.UserServersQTableView", "Furious.Window.LogViewerWindow", - "Furious.Window.TextEditorWindow" + "Furious.Widget.TextEditorWindow" ], "RU": "Выбрать все", "ZH": "全选", @@ -352,7 +352,7 @@ TRANSLATION = { }, "Indent...": { "source": [ - "Furious.Window.TextEditorWindow" + "Furious.Widget.TextEditorWindow" ], "RU": "Настройка отступа...", "ZH": "缩进...", @@ -361,7 +361,7 @@ TRANSLATION = { "Edit": { "source": [ "Furious.Window.LogViewerWindow", - "Furious.Window.TextEditorWindow" + "Furious.Widget.TextEditorWindow" ], "RU": "Редактировать", "ZH": "编辑", @@ -370,7 +370,7 @@ TRANSLATION = { "Zoom In": { "source": [ "Furious.Window.LogViewerWindow", - "Furious.Window.TextEditorWindow" + "Furious.Widget.TextEditorWindow" ], "RU": "Увеличить маcштаб", "ZH": "变大", @@ -379,7 +379,7 @@ TRANSLATION = { "Zoom Out": { "source": [ "Furious.Window.LogViewerWindow", - "Furious.Window.TextEditorWindow" + "Furious.Widget.TextEditorWindow" ], "RU": "Уменьшить маcштаб", "ZH": "变小", @@ -388,7 +388,7 @@ TRANSLATION = { "View": { "source": [ "Furious.Window.LogViewerWindow", - "Furious.Window.TextEditorWindow" + "Furious.Widget.TextEditorWindow" ], "RU": "Вид", "ZH": "显示", @@ -396,7 +396,7 @@ TRANSLATION = { }, "Error saving configuration": { "source": [ - "Furious.Window.TextEditorWindow" + "Furious.Widget.TextEditorWindow" ], "RU": "Ошибка сохранения конфигурации", "ZH": "保存配置出错", @@ -405,7 +405,7 @@ TRANSLATION = { "Save File": { "source": [ "Furious.Window.LogViewerWindow", - "Furious.Window.TextEditorWindow" + "Furious.Widget.TextEditorWindow" ], "RU": "Сохранить файл", "ZH": "保存文件", @@ -413,7 +413,7 @@ TRANSLATION = { }, "Error Saving File": { "source": [ - "Furious.Window.TextEditorWindow" + "Furious.Widget.TextEditorWindow" ], "RU": "Ошибка сохранения файла", "ZH": "保存文件出错", @@ -422,7 +422,7 @@ TRANSLATION = { "Invalid server configuration": { "source": [ "Furious.TrayActions.Connect", - "Furious.Window.TextEditorWindow" + "Furious.Widget.TextEditorWindow" ], "RU": "Неверная конфигурация сервера", "ZH": "无效的服务器配置", @@ -430,7 +430,7 @@ TRANSLATION = { }, "Error setting indent": { "source": [ - "Furious.Window.TextEditorWindow" + "Furious.Widget.TextEditorWindow" ], "RU": "Ошибка при установке отступов", "ZH": "设置缩进出错", @@ -935,7 +935,7 @@ TRANSLATION = { }, "Already started": { "source": [ - "Furious.Widget.Application" + "Furious.Application.Application" ], "RU": "Активированный", "ZH": "已启动", @@ -943,7 +943,7 @@ TRANSLATION = { }, "Furious Log": { "source": [ - "Furious.Widget.Application" + "Furious.Application.Application" ], "RU": "Лог Furious", "ZH": "Furious日志", @@ -951,7 +951,7 @@ TRANSLATION = { }, "Core Log": { "source": [ - "Furious.Widget.Application" + "Furious.Application.Application" ], "RU": "Лог ядра", "ZH": "内核日志", @@ -959,7 +959,7 @@ TRANSLATION = { }, "Tun2socks Log": { "source": [ - "Furious.Widget.Application" + "Furious.Application.Application" ], "RU": "Лог Tun2socks", "ZH": "Tun2socks日志", @@ -1729,7 +1729,7 @@ TRANSLATION = { }, "Administrator": { "source": [ - "Furious.Widget.SystemTrayIcon" + "Furious.Application.SystemTrayIcon" ], "RU": "Administrator", "ZH": "管理员", @@ -2116,7 +2116,7 @@ TRANSLATION = { "Close": { "source": [ "Furious.Plugins.Official.Xray.UserRoutingWindow", - "Furious.Window.TextEditorWindow" + "Furious.Widget.TextEditorWindow" ], "RU": "Закрыть", "ZH": "关闭", diff --git a/Furious/Externals/__init__.py b/Furious/Externals/__init__.py index 029e0d63..5878f6fe 100644 --- a/Furious/Externals/__init__.py +++ b/Furious/Externals/__init__.py @@ -19,4 +19,6 @@ from __future__ import annotations -from .GenTranslation import * +from .GenTranslation import TRANSLATION + +__all__ = ['TRANSLATION'] diff --git a/Furious/Frozenlib/Constants.py b/Furious/Frozenlib/Constants.py index bdc04b3e..65bef82b 100644 --- a/Furious/Frozenlib/Constants.py +++ b/Furious/Frozenlib/Constants.py @@ -19,7 +19,7 @@ from __future__ import annotations -from Furious.Version import __version__ +from .Version import __version__ from PySide6 import QtCore, __version__ as _pyside6_version @@ -28,6 +28,51 @@ import math import pathlib import platform +__all__ = [ + 'ADMINISTRATOR_NAME', + 'APPLICATION_ABOUT_PAGE', + 'APPLICATION_AUTHOR_EMAIL', + 'APPLICATION_AUTHOR_NAME', + 'APPLICATION_DESCRIPTION', + 'APPLICATION_FLATPAK_ID', + 'APPLICATION_MACOS_SIGNATURE', + 'APPLICATION_NAME', + 'APPLICATION_REPO_NAME', + 'APPLICATION_REPO_OWNER_NAME', + 'APPLICATION_TUN2SOCKS_GATEWAY_ADDRESS', + 'APPLICATION_TUN2SOCKS_INTERFACE_DNS_ADDRESS', + 'APPLICATION_TUN2SOCKS_NETWORK_INTERFACE_NAME', + 'APPLICATION_VERSION', + 'CORE_CHECK_ALIVE_INTERVAL', + 'CRASH_LOG_DIR', + 'DATA_DIR', + 'GEN_TRANSLATION_FILE', + 'GOLDEN_RATIO', + 'LOCAL_SERVER_NAME', + 'NETWORK_CONNECTIVITY_TEST_URL', + 'NETWORK_SPEED_TEST_URL', + 'ORGANIZATION_DOMAIN', + 'ORGANIZATION_NAME', + 'OS_CPU_COUNT', + 'PACKAGE_DIR', + 'PLATFORM', + 'PLATFORM_MACHINE', + 'PLATFORM_PYTHON_VERSION', + 'PLATFORM_RELEASE', + 'PROXY_OUTBOUND_USER_EMAIL', + 'PROXY_SERVER_BYPASS', + 'PYSIDE6_VERSION', + 'ROOT_DIR', + 'SYSTEM_LANGUAGE', + 'URL_GEOIP', + 'URL_GEOIP_SHA256', + 'URL_GEOSITE', + 'URL_GEOSITE_SHA256', + 'XRAY_ASSET_DIR', + 'XRAY_ASSET_PATH_GEOIP', + 'XRAY_ASSET_PATH_GEOSITE', +] + APPLICATION_NAME = 'Furious' APPLICATION_VERSION = __version__ APPLICATION_AUTHOR_NAME = 'Loren Eteval' diff --git a/Furious/Frozenlib/PySide6Legacy.py b/Furious/Frozenlib/PySide6Legacy.py index 035603da..62328adc 100644 --- a/Furious/Frozenlib/PySide6Legacy.py +++ b/Furious/Frozenlib/PySide6Legacy.py @@ -15,7 +15,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -"""Provide bundled py side6 legacy.""" +"""Provide bundled PySide6 legacy.""" from __future__ import annotations @@ -29,12 +29,12 @@ __all__ = ['PySide6Legacy'] class PySide6Legacy: - """Represent py side6 legacy.""" + """Represent PySide6 legacy.""" @staticmethod def enumValueWrapper(enum) -> int: # Protect PySide6 enum wrapper behavior changes - """Return the enum value wrapper value used by the py side6 legacy.""" + """Return the enum value wrapper value used by the PySide6 legacy.""" if versionToValue(PYSIDE6_VERSION) < versionToValue('6.2.2'): return enum else: @@ -44,7 +44,7 @@ class PySide6Legacy: def eventLoopWait(ms) -> None: # Protect qWait method does not exist in some # old PySide6 version - """Handle event loop wait for the py side6 legacy.""" + """Handle event loop wait for the PySide6 legacy.""" if versionToValue(PYSIDE6_VERSION) < versionToValue('6.3.1'): for counter in range(0, ms, 10): time.sleep(10 / 1000) diff --git a/Furious/Frozenlib/Utility.py b/Furious/Frozenlib/Utility.py index 765fc08b..3b37ed58 100644 --- a/Furious/Frozenlib/Utility.py +++ b/Furious/Frozenlib/Utility.py @@ -67,19 +67,19 @@ class Protocol(Enum): if not isinstance(protocol, str): return Protocol.Unknown - if protocol.lower() == 'vmess': + if protocol.casefold() == 'vmess': return Protocol.VMess - if protocol.lower() == 'vless': + if protocol.casefold() == 'vless': return Protocol.VLESS - if protocol.lower() == 'shadowsocks': + if protocol.casefold() == 'shadowsocks': return Protocol.Shadowsocks - if protocol.lower() == 'socks': + if protocol.casefold() == 'socks': return Protocol.Socks - if protocol.lower() == 'trojan': + if protocol.casefold() == 'trojan': return Protocol.Trojan return Protocol.Unknown diff --git a/Furious/Frozenlib/Version.py b/Furious/Frozenlib/Version.py new file mode 100644 index 00000000..42c682b9 --- /dev/null +++ b/Furious/Frozenlib/Version.py @@ -0,0 +1,24 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# 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 . + +"""Define the package version without importing higher application layers.""" + +from __future__ import annotations + +__all__ = ['__version__'] + +__version__ = '0.7.0' diff --git a/Furious/Frozenlib/__init__.py b/Furious/Frozenlib/__init__.py index 3e8a1d52..9092e83d 100644 --- a/Furious/Frozenlib/__init__.py +++ b/Furious/Frozenlib/__init__.py @@ -15,20 +15,149 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -"""Expose bundled platform, settings, and compatibility helpers.""" +"""Expose the self-contained foundation API used by higher layers.""" from __future__ import annotations -from .AppSettings import * -from .Constants import * -from .Enum import * -from .Globals import * -from .Mixins import * -from .PySide6Legacy import * -from .StartupOnBoot import * -from .SystemProxy import * -from .SystemRoutingTable import * -from .SystemRuntime import * -from .Tcping import * -from .Utility import * -from .Win32Session import * +from . import AppResources +from .AppSettings import AppBinarySettings, AppSettings, registerAppSettings +from .Constants import ( + ADMINISTRATOR_NAME, + APPLICATION_ABOUT_PAGE, + APPLICATION_AUTHOR_EMAIL, + APPLICATION_AUTHOR_NAME, + APPLICATION_DESCRIPTION, + APPLICATION_FLATPAK_ID, + APPLICATION_MACOS_SIGNATURE, + APPLICATION_NAME, + APPLICATION_REPO_NAME, + APPLICATION_REPO_OWNER_NAME, + APPLICATION_TUN2SOCKS_GATEWAY_ADDRESS, + APPLICATION_TUN2SOCKS_INTERFACE_DNS_ADDRESS, + APPLICATION_TUN2SOCKS_NETWORK_INTERFACE_NAME, + APPLICATION_VERSION, + CORE_CHECK_ALIVE_INTERVAL, + CRASH_LOG_DIR, + DATA_DIR, + GEN_TRANSLATION_FILE, + GOLDEN_RATIO, + LOCAL_SERVER_NAME, + NETWORK_CONNECTIVITY_TEST_URL, + NETWORK_SPEED_TEST_URL, + ORGANIZATION_DOMAIN, + ORGANIZATION_NAME, + OS_CPU_COUNT, + PACKAGE_DIR, + PLATFORM, + PLATFORM_MACHINE, + PLATFORM_PYTHON_VERSION, + PLATFORM_RELEASE, + PROXY_OUTBOUND_USER_EMAIL, + PROXY_SERVER_BYPASS, + PYSIDE6_VERSION, + ROOT_DIR, + SYSTEM_LANGUAGE, + URL_GEOIP, + URL_GEOIP_SHA256, + URL_GEOSITE, + URL_GEOSITE_SHA256, + XRAY_ASSET_DIR, + XRAY_ASSET_PATH_GEOIP, + XRAY_ASSET_PATH_GEOSITE, +) +from .Enum import AppBuiltinCommand, AppBuiltinProxyMode, AppBuiltinRouting +from .Globals import APP, AppFontName, AppLoggerWindow, AppThreadPool +from .Mixins import Mixins +from .PySide6Legacy import PySide6Legacy +from .StartupOnBoot import StartupOnBoot +from .SystemProxy import SystemProxy +from .SystemRoutingTable import SystemRoutingTable +from .SystemRuntime import SystemRuntime +from .Tcping import tcping +from .Utility import ( + Protocol, + absolutePath, + callOnceOnly, + callRateLimited, + classname, + forceToLocalhostIfPossible, + isValidIPAddress, + parseHostPort, + runExternalCommand, + versionToValue, +) +from .Win32Session import Win32Session + +__all__ = [ + 'ADMINISTRATOR_NAME', + 'APP', + 'APPLICATION_ABOUT_PAGE', + 'APPLICATION_AUTHOR_EMAIL', + 'APPLICATION_AUTHOR_NAME', + 'APPLICATION_DESCRIPTION', + 'APPLICATION_FLATPAK_ID', + 'APPLICATION_MACOS_SIGNATURE', + 'APPLICATION_NAME', + 'APPLICATION_REPO_NAME', + 'APPLICATION_REPO_OWNER_NAME', + 'APPLICATION_TUN2SOCKS_GATEWAY_ADDRESS', + 'APPLICATION_TUN2SOCKS_INTERFACE_DNS_ADDRESS', + 'APPLICATION_TUN2SOCKS_NETWORK_INTERFACE_NAME', + 'APPLICATION_VERSION', + 'AppBinarySettings', + 'AppBuiltinCommand', + 'AppBuiltinProxyMode', + 'AppBuiltinRouting', + 'AppFontName', + 'AppLoggerWindow', + 'AppResources', + 'AppSettings', + 'AppThreadPool', + 'CORE_CHECK_ALIVE_INTERVAL', + 'CRASH_LOG_DIR', + 'DATA_DIR', + 'GEN_TRANSLATION_FILE', + 'GOLDEN_RATIO', + 'LOCAL_SERVER_NAME', + 'Mixins', + 'NETWORK_CONNECTIVITY_TEST_URL', + 'NETWORK_SPEED_TEST_URL', + 'ORGANIZATION_DOMAIN', + 'ORGANIZATION_NAME', + 'OS_CPU_COUNT', + 'PACKAGE_DIR', + 'PLATFORM', + 'PLATFORM_MACHINE', + 'PLATFORM_PYTHON_VERSION', + 'PLATFORM_RELEASE', + 'PROXY_OUTBOUND_USER_EMAIL', + 'PROXY_SERVER_BYPASS', + 'PYSIDE6_VERSION', + 'Protocol', + 'PySide6Legacy', + 'ROOT_DIR', + 'SYSTEM_LANGUAGE', + 'StartupOnBoot', + 'SystemProxy', + 'SystemRoutingTable', + 'SystemRuntime', + 'URL_GEOIP', + 'URL_GEOIP_SHA256', + 'URL_GEOSITE', + 'URL_GEOSITE_SHA256', + 'Win32Session', + 'XRAY_ASSET_DIR', + 'XRAY_ASSET_PATH_GEOIP', + 'XRAY_ASSET_PATH_GEOSITE', + 'absolutePath', + 'callOnceOnly', + 'callRateLimited', + 'classname', + 'forceToLocalhostIfPossible', + 'isValidIPAddress', + 'parseHostPort', + 'registerAppSettings', + 'runExternalCommand', + 'tcping', + 'versionToValue', +] diff --git a/Furious/Interface/CoreProcessFactory.py b/Furious/Interface/CoreProcessFactory.py index 70799fa8..0a4ea6a8 100644 --- a/Furious/Interface/CoreProcessFactory.py +++ b/Furious/Interface/CoreProcessFactory.py @@ -20,7 +20,6 @@ from __future__ import annotations from Furious.Frozenlib import * -from Furious.Interface.ConfigFactory import * from abc import ABC, abstractmethod from enum import Enum @@ -68,14 +67,14 @@ class CoreProcessFactory(ABC): """Handle the registered singledispatch variant.""" return config - @toJSONString.register(ConfigFactory) - def _(self, config, **kwargs) -> str: - """Handle the registered singledispatch variant.""" - return config.toJSONString(**kwargs) - @toJSONString.register(dict) def _(self, config, **kwargs) -> str: """Handle the registered singledispatch variant.""" + serializer = getattr(config, 'toJSONString', None) + + if callable(serializer): + return serializer(**kwargs) + try: ensure_ascii = kwargs.pop('ensure_ascii', False) escape_forward_slashes = kwargs.pop('escape_forward_slashes', False) diff --git a/Furious/Interface/__init__.py b/Furious/Interface/__init__.py index e0374aa1..d4c2cbad 100644 --- a/Furious/Interface/__init__.py +++ b/Furious/Interface/__init__.py @@ -15,14 +15,23 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -"""Expose the public application interface contracts.""" +"""Expose low-level contracts that do not depend on application services.""" from __future__ import annotations -from .ApplicationFactory import * -from .EncoderFactory import * -from .GuiEditorItemFactory import * -from .StorageFactory import * -from .UserServersTableItem import * -from .ConfigFactory import * -from .CoreProcessFactory import * +from .ApplicationFactory import ApplicationFactory +from .CoreProcessFactory import CoreProcessFactory +from .EncoderFactory import EncoderFactory +from .GuiEditorItemFactory import GuiEditorItemFactory, GuiEditorItemWidgetContainer +from .StorageFactory import StorageFactory +from .UserServersTableItem import UserServersTableItem + +__all__ = [ + 'ApplicationFactory', + 'CoreProcessFactory', + 'EncoderFactory', + 'GuiEditorItemFactory', + 'GuiEditorItemWidgetContainer', + 'StorageFactory', + 'UserServersTableItem', +] diff --git a/Furious/Interface/ConfigFactory.py b/Furious/Library/Configuration.py similarity index 62% rename from Furious/Interface/ConfigFactory.py rename to Furious/Library/Configuration.py index 968c78e6..a5941abc 100644 --- a/Furious/Interface/ConfigFactory.py +++ b/Furious/Library/Configuration.py @@ -15,21 +15,28 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -"""Define the shared configuration model and conversion interface.""" +"""Define configuration data and the registry used to construct it.""" from __future__ import annotations -from Furious.Interface.UserServersTableItem import * - -from PySide6.QtWidgets import QApplication +from Furious.Interface.UserServersTableItem import UserServersTableItem from typing import Union import copy -import ujson import functools +import threading +import ujson -__all__ = ['ConfigFactory'] +__all__ = [ + 'ConfigFactory', + 'ConfigurationRegistry', + 'configurationRegistry', + 'registerConfigurationProvider', + 'configFactoryFromDict', + 'configFactoryFromAny', + 'configFactoryBlank', +] class ConfigFactory(UserServersTableItem, dict): @@ -149,21 +156,8 @@ class ConfigFactory(UserServersTableItem, dict): @property def itemSubscription(self) -> str: - """Return the item subscription value.""" - try: - app = QApplication.instance() - - if app is None: - return '' - else: - subsId = self.getExtras('subsId') - subsOb = app._userSubs.get(subsId, {}) - - return subsOb.get('remark', '') - except Exception: - # Any non-exit exceptions - - return '' + """Return the persisted subscription identifier.""" + return self.getExtras('subsId') or '' @property def itemLatency(self) -> str: @@ -265,3 +259,115 @@ class ConfigFactory(UserServersTableItem, dict): """ return False + + +class ConfigurationRegistry: + """Construct configuration objects through registered providers. + + Providers are deliberately defined by behavior instead of by a plugin base + class. This keeps configuration and persistence independent from the + optional plugin system while allowing plugins to contribute parsers. + """ + + def __init__(self): + """Initialize an empty provider registry.""" + self._providers = [] + self._lock = threading.RLock() + + def register(self, provider): + """Register *provider* once and return it.""" + requiredMethods = ('configFromString', 'configFromDict', 'blankConfig') + + if not all(callable(getattr(provider, name, None)) for name in requiredMethods): + raise TypeError( + 'configuration providers must implement configFromString, ' + 'configFromDict, and blankConfig' + ) + + with self._lock: + if any(item is provider for item in self._providers): + raise ValueError('configuration provider is already registered') + + self._providers.append(provider) + + return provider + + def providers(self): + """Return registered providers in deterministic registration order.""" + with self._lock: + return tuple(self._providers) + + def fromString(self, config: str, **kwargs): + """Return the first provider result for textual *config*.""" + for provider in self.providers(): + factory = provider.configFromString(config, **kwargs) + + if factory is not None: + return factory + + return None + + def fromDict(self, config: dict, **kwargs): + """Return the first provider result for mapping *config*.""" + for provider in self.providers(): + factory = provider.configFromDict(config, **kwargs) + + if factory is not None: + return factory + + return None + + def blank(self, protocol, **kwargs): + """Return a blank configuration from the first matching provider.""" + for provider in self.providers(): + factory = provider.blankConfig(protocol, **kwargs) + + if factory is not None: + return factory + + return None + + +configurationRegistry = ConfigurationRegistry() + + +def registerConfigurationProvider(provider): + """Register a configuration provider with the process-wide registry.""" + return configurationRegistry.register(provider) + + +def configFactoryFromDict(config: dict, **kwargs) -> ConfigFactory: + """Construct a configuration mapping through registered providers.""" + if not isinstance(config, dict): + return ConfigFactory(**kwargs) + + factory = configurationRegistry.fromDict(config, **kwargs) + + return factory if factory is not None else ConfigFactory(config, **kwargs) + + +def configFactoryFromAny(config: Union[str, dict], **kwargs) -> ConfigFactory: + """Construct configuration data from text or a mapping.""" + if isinstance(config, str): + factory = configurationRegistry.fromString(config, **kwargs) + + if factory is not None: + return factory + + try: + return configFactoryFromDict(ujson.loads(config), **kwargs) + except Exception: + # Any non-exit exceptions + return ConfigFactory(**kwargs) + + if isinstance(config, dict): + return configFactoryFromDict(config, **kwargs) + + return ConfigFactory(**kwargs) + + +def configFactoryBlank(protocol, **kwargs) -> ConfigFactory: + """Construct a blank configuration through a registered provider.""" + factory = configurationRegistry.blank(protocol, **kwargs) + + return factory if factory is not None else ConfigFactory(**kwargs) diff --git a/Furious/Library/UserServers.py b/Furious/Library/UserServers.py index 039f7c89..76de033e 100644 --- a/Furious/Library/UserServers.py +++ b/Furious/Library/UserServers.py @@ -21,8 +21,8 @@ from __future__ import annotations from Furious.Frozenlib import * from Furious.Interface import * +from Furious.Library.Configuration import ConfigFactory, configFactoryFromAny from Furious.Library.Encoder import * -from Furious.Plugins.Configuration import configFactoryFromAny __all__ = ['UserServers'] diff --git a/Furious/Library/__init__.py b/Furious/Library/__init__.py index 418ec4ef..679113b7 100644 --- a/Furious/Library/__init__.py +++ b/Furious/Library/__init__.py @@ -15,11 +15,33 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -"""Expose configuration, encoding, and storage services.""" +"""Expose domain configuration, encoding, and persistent storage services.""" from __future__ import annotations -from Furious.Plugins.Configuration import * +from .Configuration import ( + ConfigFactory, + ConfigurationRegistry, + configFactoryBlank, + configFactoryFromAny, + configFactoryFromDict, + configurationRegistry, + registerConfigurationProvider, +) +from .Encoder import Base64Encoder, JSONEncoder, PyBase64Encoder, UJSONEncoder +from .Storage import Storage -from .Encoder import * -from .Storage import * +__all__ = [ + 'Base64Encoder', + 'ConfigFactory', + 'ConfigurationRegistry', + 'JSONEncoder', + 'PyBase64Encoder', + 'Storage', + 'UJSONEncoder', + 'configFactoryBlank', + 'configFactoryFromAny', + 'configFactoryFromDict', + 'configurationRegistry', + 'registerConfigurationProvider', +] diff --git a/Furious/Plugins/Configuration.py b/Furious/Plugins/Configuration.py deleted file mode 100644 index 70ad1047..00000000 --- a/Furious/Plugins/Configuration.py +++ /dev/null @@ -1,65 +0,0 @@ -# Copyright (C) 2024–present Loren Eteval & contributors -# -# 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 . - -"""Dispatch configuration creation through the process-wide plugin registry.""" - -from __future__ import annotations - -from Furious.Interface.ConfigFactory import ConfigFactory - -from .Registry import getPluginRegistry - -from typing import Union - -import ujson - -__all__ = ['configFactoryFromDict', 'configFactoryFromAny', 'configFactoryBlank'] - - -def configFactoryFromDict(config: dict, **kwargs) -> ConfigFactory: - """Construct a configuration mapping through the first matching plugin.""" - if not isinstance(config, dict): - return ConfigFactory() - - factory = getPluginRegistry().configFromDict(config, **kwargs) - - return factory if factory is not None else ConfigFactory(config, **kwargs) - - -def configFactoryFromAny(config: Union[str, dict], **kwargs) -> ConfigFactory: - """Construct configuration data through the first matching plugin.""" - if isinstance(config, str): - factory = getPluginRegistry().configFromString(config, **kwargs) - if factory is not None: - return factory - - try: - return configFactoryFromDict(ujson.loads(config), **kwargs) - except Exception: - return ConfigFactory(**kwargs) - - if isinstance(config, dict): - return configFactoryFromDict(config, **kwargs) - - return ConfigFactory(**kwargs) - - -def configFactoryBlank(protocol, **kwargs) -> ConfigFactory: - """Construct a blank configuration through the owning plugin.""" - factory = getPluginRegistry().blankConfig(protocol, **kwargs) - - return factory if factory is not None else ConfigFactory() diff --git a/Furious/Plugins/Official/Configuration.py b/Furious/Plugins/Official/Configuration.py index 5f4abfb6..838aab78 100644 --- a/Furious/Plugins/Official/Configuration.py +++ b/Furious/Plugins/Official/Configuration.py @@ -21,6 +21,7 @@ from __future__ import annotations from Furious.Frozenlib import * from Furious.Interface import * +from Furious.Library.Configuration import ConfigFactory from Furious.Library.Encoder import * from typing import Union, Tuple @@ -392,7 +393,7 @@ class ConfigXray(ConfigFactory): def getProxyOutboundServer(config: dict, protocol: Protocol, **kwargs) -> dict: """Return proxy outbound server.""" value, proxyOutbound = ( - protocol.value.lower(), + protocol.value.casefold(), ConfigXray.getProxyOutboundObject(config, **kwargs), ) @@ -458,7 +459,7 @@ class ConfigXray(ConfigFactory): def proxyServerObject(self) -> dict: """Return the proxy server object value.""" try: - proxyProtocol = self.proxyProtocol.lower() + proxyProtocol = self.proxyProtocol.casefold() if proxyProtocol == 'vmess' or proxyProtocol == 'vless': return self.proxyOutboundObject['settings']['vnext'][0] @@ -479,7 +480,7 @@ class ConfigXray(ConfigFactory): def proxyUserObject(self) -> dict: """Return the proxy user object value.""" try: - if self.proxyProtocol.lower() == 'socks': + if self.proxyProtocol.casefold() == 'socks': return self.proxyServerObject return self.proxyServerObject['users'][0] @@ -1416,7 +1417,7 @@ class ConfigXray(ConfigFactory): else: override = remark - protocol = self.proxyProtocol.lower() + protocol = self.proxyProtocol.casefold() if protocol == 'vmess': netloc = PyBase64Encoder.encode( @@ -2311,7 +2312,7 @@ class ConfigHysteria2(ConfigFactory): def configXrayEmptyProxyOutboundObject(protocol: Protocol) -> dict: """Return the config Xray empty proxy outbound object value used by the application.""" - value = protocol.value.lower() + value = protocol.value.casefold() if value == 'vless' or value == 'vmess': return { diff --git a/Furious/Plugins/Official/Hysteria1/Core.py b/Furious/Plugins/Official/Hysteria1/Core.py index 7efebc12..f0a396be 100644 --- a/Furious/Plugins/Official/Hysteria1/Core.py +++ b/Furious/Plugins/Official/Hysteria1/Core.py @@ -20,7 +20,7 @@ from __future__ import annotations from Furious.Frozenlib import * -from Furious.Core.CoreProcessWorker import * +from Furious.Core import * from enum import Enum from typing import Union diff --git a/Furious/Plugins/Official/Hysteria1/GuiHysteria1.py b/Furious/Plugins/Official/Hysteria1/GuiHysteria1.py index b2b75130..8de557f7 100644 --- a/Furious/Plugins/Official/Hysteria1/GuiHysteria1.py +++ b/Furious/Plugins/Official/Hysteria1/GuiHysteria1.py @@ -21,6 +21,7 @@ from __future__ import annotations from Furious.Frozenlib import * from Furious.Interface import * +from Furious.Library import ConfigFactory from Furious.Qt import * from Furious.Qt import gettext as _ diff --git a/Furious/Plugins/Official/Hysteria1/Plugin.py b/Furious/Plugins/Official/Hysteria1/Plugin.py index 0f6425ae..3595c958 100644 --- a/Furious/Plugins/Official/Hysteria1/Plugin.py +++ b/Furious/Plugins/Official/Hysteria1/Plugin.py @@ -21,17 +21,14 @@ from __future__ import annotations from Furious.Frozenlib import * from Furious.Qt.DynamicTranslate import gettext as _ -from Furious.Plugins.API import FuriousPlugin, PluginProtocol, PluginRouting -from Furious.Plugins.Official.Configuration import ( - BLANK_CONFIG_HYSTERIA1, - ConfigHysteria1, -) +from Furious.Plugins.API import * +from Furious.Plugins.Official.Configuration import * + +from .Core import * import copy import logging -from .Core import Hysteria1 - __all__ = ['Hysteria1Plugin'] logger = logging.getLogger(__name__) @@ -60,11 +57,10 @@ class Hysteria1Plugin(FuriousPlugin): def configFromString(self, config: str, **kwargs): """Parse a Hysteria 1 share URI.""" - return ( - ConfigHysteria1(config, **kwargs) - if config.startswith('hysteria://') - else None - ) + if config.startswith('hysteria://'): + return ConfigHysteria1(config, **kwargs) + + return None def configFromDict(self, config: dict, **kwargs): """Recognize Hysteria 1 configuration mappings.""" diff --git a/Furious/Plugins/Official/Hysteria1/__init__.py b/Furious/Plugins/Official/Hysteria1/__init__.py index 9b0aebab..e25b361a 100644 --- a/Furious/Plugins/Official/Hysteria1/__init__.py +++ b/Furious/Plugins/Official/Hysteria1/__init__.py @@ -19,4 +19,6 @@ from __future__ import annotations -from .Plugin import * +from .Plugin import Hysteria1Plugin + +__all__ = ['Hysteria1Plugin'] diff --git a/Furious/Plugins/Official/Hysteria2/Core.py b/Furious/Plugins/Official/Hysteria2/Core.py index ae0d474d..f7e3ab1c 100644 --- a/Furious/Plugins/Official/Hysteria2/Core.py +++ b/Furious/Plugins/Official/Hysteria2/Core.py @@ -20,7 +20,7 @@ from __future__ import annotations from Furious.Frozenlib import * -from Furious.Core.CoreProcessWorker import * +from Furious.Core import * from enum import Enum from typing import Union diff --git a/Furious/Plugins/Official/Hysteria2/GuiHysteria2.py b/Furious/Plugins/Official/Hysteria2/GuiHysteria2.py index 76d95bc9..01d80f02 100644 --- a/Furious/Plugins/Official/Hysteria2/GuiHysteria2.py +++ b/Furious/Plugins/Official/Hysteria2/GuiHysteria2.py @@ -21,6 +21,7 @@ from __future__ import annotations from Furious.Frozenlib import * from Furious.Interface import * +from Furious.Library import ConfigFactory from Furious.Qt import * from Furious.Qt import gettext as _ diff --git a/Furious/Plugins/Official/Hysteria2/GuiTUNSettings.py b/Furious/Plugins/Official/Hysteria2/GuiTUNSettings.py index 110dd06a..2f1089dc 100644 --- a/Furious/Plugins/Official/Hysteria2/GuiTUNSettings.py +++ b/Furious/Plugins/Official/Hysteria2/GuiTUNSettings.py @@ -23,15 +23,16 @@ from Furious.Frozenlib import * from Furious.Qt import * from Furious.Qt import gettext as _ -from PySide6.QtGui import QDesktopServices +from PySide6 import QtCore +from PySide6.QtGui import * from PySide6.QtWidgets import * -import copy -import functools -import logging +from .Plugin import * +from .TUN import * -from .Plugin import isHysteria2ConnectionActive -from .TUN import getHysteria2TUNSettings, saveHysteria2TUNSettings +import copy +import logging +import functools __all__ = ['GuiHysteria2TUNSettings'] @@ -41,6 +42,7 @@ logger = logging.getLogger(__name__) def _nestedValue(config: dict, path: tuple[str, ...], default): """Return a nested settings value or *default*.""" value = config + for key in path: if not isinstance(value, dict): return default @@ -53,8 +55,10 @@ def _nestedValue(config: dict, path: tuple[str, ...], default): def _setNestedValue(config: dict, path: tuple[str, ...], value): """Set one nested settings value.""" target = config + for key in path[:-1]: child = target.get(key) + if not isinstance(child, dict): child = {} target[key] = child @@ -70,7 +74,9 @@ class GuiHysteria2TUNItemText(GuiEditorItemTextInput): def __init__(self, *args, **kwargs): """Initialize a Hysteria 2 TUN string editor.""" self.path = tuple(kwargs.pop('path')) + maximumWidth = kwargs.pop('maximumWidth', None) + super().__init__(*args, **kwargs) if maximumWidth is not None: @@ -79,6 +85,7 @@ class GuiHysteria2TUNItemText(GuiEditorItemTextInput): def inputToFactory(self, config: dict) -> bool: """Store the current text in a Hysteria 2 TUN settings mapping.""" value = self.text().strip() + if _nestedValue(config, self.path, '') == value: return False @@ -89,6 +96,7 @@ class GuiHysteria2TUNItemText(GuiEditorItemTextInput): def factoryToInput(self, config: dict): """Load one Hysteria 2 TUN string setting into the editor.""" value = _nestedValue(config, self.path, '') + self.setText(value if isinstance(value, str) else '') @@ -98,6 +106,7 @@ class GuiHysteria2TUNItemList(GuiHysteria2TUNItemText): def inputToFactory(self, config: dict) -> bool: """Store comma-separated values in the settings mapping.""" value = [item.strip() for item in self.text().split(',') if item.strip()] + if _nestedValue(config, self.path, []) == value: return False @@ -108,6 +117,7 @@ class GuiHysteria2TUNItemList(GuiHysteria2TUNItemText): def factoryToInput(self, config: dict): """Load one list setting into the editor.""" value = _nestedValue(config, self.path, []) + self.setText(','.join(value) if isinstance(value, list) else '') @@ -117,11 +127,13 @@ class GuiHysteria2TUNItemMTU(GuiEditorItemTextSpinBox): def __init__(self, *args, **kwargs): """Initialize the MTU editor.""" super().__init__(*args, **kwargs) + self.setRange(1, 65535) def inputToFactory(self, config: dict) -> bool: """Store the MTU in the settings mapping.""" value = self.value() + if config.get('mtu', 1500) == value: return False @@ -132,6 +144,7 @@ class GuiHysteria2TUNItemMTU(GuiEditorItemTextSpinBox): def factoryToInput(self, config: dict): """Load the MTU into the editor.""" value = config.get('mtu', 1500) + self.setValue(value if isinstance(value, int) else 1500) @@ -213,6 +226,7 @@ class GuiHysteria2TUNSettingsGroupBoxNetwork(GuiEditorWidgetQGroupBox): row, field = divmod(index, 2) label, editor = container.widgets() column = field * 2 + layout.addWidget(label, row, column) layout.addWidget(editor, row, column + 1) @@ -253,32 +267,42 @@ class GuiHysteria2TUNSettings(GuiEditorWidgetQDialog): def __init__(self, *args, **kwargs): """Initialize the Hysteria 2 TUN settings dialog.""" + self._isConnectionActive = kwargs.pop('isConnectionActive', lambda: False) kwargs.setdefault('style', 'portrait') kwargs.setdefault('tabTranslatable', True) + super().__init__(*args, **kwargs) self.setTabText(_('Customize Hysteria2 TUN Settings')) self.setFixedSize(int(650 * GOLDEN_RATIO), 650) + self._settings = getHysteria2TUNSettings() + self.factoryToInput(self._settings) + self.accepted.connect(self.handleAccepted) self.layout().takeRow(self.layout().rowCount() - 1) + bottomLayout = QHBoxLayout() bottomLayout.addWidget(Hysteria2TUNDocumentationURL()) bottomLayout.addStretch(1) bottomLayout.addWidget(self.dialogBtns) + self.layout().addRow(bottomLayout) def handleAccepted(self): """Persist changed settings and offer to reconnect when connected.""" oldSettings = copy.deepcopy(self._settings) + self.inputToFactory(self._settings) + if self._settings == oldSettings: return saveHysteria2TUNSettings(self._settings) - if SystemRuntime.isTUNMode() and isHysteria2ConnectionActive(): + + if SystemRuntime.isTUNMode() and self._isConnectionActive(): showMBoxNewChangesNextTime() @functools.lru_cache(None) diff --git a/Furious/Plugins/Official/Hysteria2/Plugin.py b/Furious/Plugins/Official/Hysteria2/Plugin.py index 45d471d5..3e670672 100644 --- a/Furious/Plugins/Official/Hysteria2/Plugin.py +++ b/Furious/Plugins/Official/Hysteria2/Plugin.py @@ -19,51 +19,27 @@ from __future__ import annotations -from Furious.Frozenlib import APP, PLATFORM, SystemRuntime +from Furious.Frozenlib import * from Furious.Qt.DynamicTranslate import gettext as _ -from Furious.Plugins.API import FuriousPlugin, PluginProtocol -from Furious.Plugins.Official.Configuration import ( - BLANK_CONFIG_HYSTERIA2, - ConfigHysteria2, -) +from Furious.Plugins.API import * +from Furious.Plugins.Official.Configuration import * + +from .Core import Hysteria2 +from .TUN import * import copy import logging -from .Core import Hysteria2 -from .TUN import ( - buildHysteria2TUNConfig, - getHysteria2TUNSettings, - isHysteria2TUNEnabled, - resolveHysteria2ServerAddresses, - setHysteria2TUNEnabled, -) - -__all__ = ['Hysteria2Plugin', 'isHysteria2ConnectionActive'] +__all__ = ['Hysteria2Plugin'] logger = logging.getLogger(__name__) + _TRANSLATABLE_ACTION_TEXT = [ _('Add Hysteria2 Server...'), ] -def isHysteria2ConnectionActive() -> bool: - """Return whether the connected core process belongs to Hysteria 2.""" - try: - connectAction = APP().systemTray.ConnectAction - if not connectAction.isConnected(): - return False - - return any( - isinstance(process, Hysteria2) - for process in connectAction.coreManager.processesPool - ) - except Exception: - # Plugin actions can be created before the tray is fully initialized. - return False - - class Hysteria2Plugin(FuriousPlugin): """Provide official Hysteria 2 support.""" @@ -133,6 +109,8 @@ class Hysteria2Plugin(FuriousPlugin): def createManagementActions(self, parent=None, **kwargs): """Create Hysteria 2 native TUN management actions.""" + isCoreActive = kwargs.pop('isCoreActive', lambda coreType: False) + # These modules require a fully initialized Furious.Qt package. from Furious.Qt import AppQAction, showMBoxNewChangesNextTime from Furious.Qt import gettext as _ @@ -149,7 +127,7 @@ class Hysteria2Plugin(FuriousPlugin): """Persist the native Hysteria 2 TUN action state.""" setHysteria2TUNEnabled(useHysteria2TUNAction.isChecked()) - if SystemRuntime.isTUNMode() and isHysteria2ConnectionActive(): + if SystemRuntime.isTUNMode() and isCoreActive(Hysteria2): showMBoxNewChangesNextTime() useHysteria2TUNAction.callback = updateUseHysteria2TUN @@ -158,7 +136,10 @@ class Hysteria2Plugin(FuriousPlugin): useHysteria2TUNAction, AppQAction( _('Customize Hysteria2 TUN Settings...'), - callback=lambda: GuiHysteria2TUNSettings(parent=parent).open(), + callback=lambda: GuiHysteria2TUNSettings( + parent=parent, + isConnectionActive=lambda: isCoreActive(Hysteria2), + ).open(), ), ) @@ -176,6 +157,7 @@ class Hysteria2Plugin(FuriousPlugin): # own process, so it cannot use CoreManager's privileged helper. # Keep the existing external tun2socks path available instead. config.pop('tun', None) + logger.warning( 'Hysteria 2 native TUN requires superuser privileges on ' 'Linux; falling back to external tun2socks' @@ -190,6 +172,7 @@ class Hysteria2Plugin(FuriousPlugin): if not serverAddresses and not hasManualExclusions: config.pop('tun', None) + logger.error( 'Hysteria 2 native TUN disabled for this connection because ' 'the server address could not be resolved and no manual route ' diff --git a/Furious/Plugins/Official/Hysteria2/TUN.py b/Furious/Plugins/Official/Hysteria2/TUN.py index 6e65d1b7..de16b5f5 100644 --- a/Furious/Plugins/Official/Hysteria2/TUN.py +++ b/Furious/Plugins/Official/Hysteria2/TUN.py @@ -21,11 +21,11 @@ from __future__ import annotations from Furious.Frozenlib import * -import copy -import ipaddress import json -import logging +import copy import socket +import logging +import ipaddress __all__ = [ 'DEFAULT_HYSTERIA2_TUN_SETTINGS', @@ -84,29 +84,36 @@ def _normalizedStringList(value) -> list[str]: def _normalizedHysteria2TUNSettings(settings) -> dict: """Return validated Hysteria 2 TUN settings with safe defaults.""" result = copy.deepcopy(DEFAULT_HYSTERIA2_TUN_SETTINGS) + if not isinstance(settings, dict): return result for key in ('name', 'timeout'): value = settings.get(key) + if isinstance(value, str): result[key] = value.strip() mtu = settings.get('mtu') + if isinstance(mtu, int) and not isinstance(mtu, bool) and 1 <= mtu <= 65535: result['mtu'] = mtu address = settings.get('address') + if isinstance(address, dict): for key in ('ipv4', 'ipv6'): value = address.get(key) + if isinstance(value, str): result['address'][key] = value.strip() route = settings.get('route') + if isinstance(route, dict): for key in ('ipv4', 'ipv6', 'ipv4Exclude', 'ipv6Exclude'): value = route.get(key) + if isinstance(value, (list, tuple)): result['route'][key] = _normalizedStringList(value) @@ -118,6 +125,8 @@ def getHysteria2TUNSettings() -> dict: try: settings = json.loads(AppSettings.get('Hysteria2TUNSettings')) except Exception: + # Any non-exit exceptions + settings = None return _normalizedHysteria2TUNSettings(settings) @@ -134,18 +143,23 @@ def saveHysteria2TUNSettings(settings: dict): def _serverHost(config) -> str: """Return the Hysteria server host from a config or share-style server URI.""" server = config.get('server', '') + if not isinstance(server, str) or not server.strip(): return '' try: host, _port = parseHostPort(server.strip()) except Exception: + # Any non-exit exceptions + host = None if not host: try: host = config.itemAddress except Exception: + # Any non-exit exceptions + host = server.rsplit(':', 1)[0] if not isinstance(host, str): @@ -157,16 +171,19 @@ def _serverHost(config) -> str: def resolveHysteria2ServerAddresses(config) -> list[str]: """Resolve addresses that must bypass native TUN to prevent a route loop.""" host = _serverHost(config) + if not host: return [] unscopedHost = host.split('%', 1)[0] + try: return [str(ipaddress.ip_address(unscopedHost))] except ValueError: pass addresses = [] + try: results = socket.getaddrinfo( host, @@ -184,6 +201,7 @@ def resolveHysteria2ServerAddresses(config) -> list[str]: continue address = str(socketAddress[0]).split('%', 1)[0] + try: address = str(ipaddress.ip_address(address)) except ValueError: @@ -218,6 +236,7 @@ def buildHysteria2TUNConfig(settings=None, serverAddresses=()) -> dict: continue key, prefix = _hostPrefix(address) + if key is not None and prefix not in settings['route'][key]: settings['route'][key].append(prefix) diff --git a/Furious/Plugins/Official/Hysteria2/__init__.py b/Furious/Plugins/Official/Hysteria2/__init__.py index 0ed069d3..92c9a87a 100644 --- a/Furious/Plugins/Official/Hysteria2/__init__.py +++ b/Furious/Plugins/Official/Hysteria2/__init__.py @@ -19,4 +19,6 @@ from __future__ import annotations -from .Plugin import * +from .Plugin import Hysteria2Plugin + +__all__ = ['Hysteria2Plugin'] diff --git a/Furious/Plugins/Official/Xray/Core.py b/Furious/Plugins/Official/Xray/Core.py index 35397048..19d8aaec 100644 --- a/Furious/Plugins/Official/Xray/Core.py +++ b/Furious/Plugins/Official/Xray/Core.py @@ -21,8 +21,8 @@ from __future__ import annotations from Furious.Frozenlib import * from Furious.Interface import * -from Furious.Library.Encoder import UJSONEncoder -from Furious.Core.CoreProcessWorker import * +from Furious.Library import * +from Furious.Core import * from enum import Enum from typing import Union diff --git a/Furious/Plugins/Official/Xray/GuiShadowsocks.py b/Furious/Plugins/Official/Xray/GuiShadowsocks.py index 04a7265a..da989560 100644 --- a/Furious/Plugins/Official/Xray/GuiShadowsocks.py +++ b/Furious/Plugins/Official/Xray/GuiShadowsocks.py @@ -22,12 +22,9 @@ from __future__ import annotations from Furious.Frozenlib import * from Furious.Interface import * from Furious.Library import * -from Furious.Plugins.Official.Configuration import ( - ConfigXray, - configXrayEmptyProxyOutboundObject, -) from Furious.Qt import * from Furious.Qt import gettext as _ +from Furious.Plugins.Official.Configuration import * from Furious.Plugins.Official.Xray.GuiVTransport import * from Furious.Plugins.Official.Xray.GuiVTLS import * diff --git a/Furious/Plugins/Official/Xray/GuiSocks.py b/Furious/Plugins/Official/Xray/GuiSocks.py index 69eaef2a..47fbebbf 100644 --- a/Furious/Plugins/Official/Xray/GuiSocks.py +++ b/Furious/Plugins/Official/Xray/GuiSocks.py @@ -22,12 +22,9 @@ from __future__ import annotations from Furious.Frozenlib import * from Furious.Interface import * from Furious.Library import * -from Furious.Plugins.Official.Configuration import ( - ConfigXray, - configXrayEmptyProxyOutboundObject, -) from Furious.Qt import * from Furious.Qt import gettext as _ +from Furious.Plugins.Official.Configuration import * from Furious.Plugins.Official.Xray.GuiVTransport import * from Furious.Plugins.Official.Xray.GuiVTLS import * diff --git a/Furious/Plugins/Official/Xray/GuiTUNSettings.py b/Furious/Plugins/Official/Xray/GuiTUNSettings.py index f87f72ec..36c50f20 100644 --- a/Furious/Plugins/Official/Xray/GuiTUNSettings.py +++ b/Furious/Plugins/Official/Xray/GuiTUNSettings.py @@ -23,15 +23,16 @@ from Furious.Frozenlib import * from Furious.Qt import * from Furious.Qt import gettext as _ -from PySide6.QtGui import QDesktopServices +from PySide6 import QtCore +from PySide6.QtGui import * from PySide6.QtWidgets import * -import copy -import functools -import logging +from .Plugin import * +from .TUN import * -from .Plugin import isXrayConnectionActive -from .TUN import getXrayTUNSettings, saveXrayTUNSettings +import copy +import logging +import functools __all__ = ['GuiXrayTUNSettings'] @@ -44,7 +45,9 @@ class GuiXrayTUNItemText(GuiEditorItemTextInput): def __init__(self, *args, **kwargs): """Initialize an Xray TUN string editor.""" self.key = kwargs.pop('key') + maximumWidth = kwargs.pop('maximumWidth', None) + super().__init__(*args, **kwargs) if maximumWidth is not None: @@ -53,6 +56,7 @@ class GuiXrayTUNItemText(GuiEditorItemTextInput): def inputToFactory(self, config: dict) -> bool: """Store the current text in an Xray TUN settings mapping.""" value = self.text().strip() + if config.get(self.key, '') == value: return False @@ -71,6 +75,7 @@ class GuiXrayTUNItemList(GuiXrayTUNItemText): def inputToFactory(self, config: dict) -> bool: """Store comma-separated values in the settings mapping.""" value = [item.strip() for item in self.text().split(',') if item.strip()] + if config.get(self.key, []) == value: return False @@ -81,6 +86,7 @@ class GuiXrayTUNItemList(GuiXrayTUNItemText): def factoryToInput(self, config: dict): """Load one Xray TUN list setting into the editor.""" value = config.get(self.key, []) + self.setText(','.join(value) if isinstance(value, list) else '') @@ -91,14 +97,18 @@ class GuiXrayTUNItemNumber(GuiEditorItemTextSpinBox): """Initialize an Xray TUN numeric editor.""" self.key = kwargs.pop('key') self.default = kwargs.pop('default') + minimum = kwargs.pop('minimum') maximum = kwargs.pop('maximum') + super().__init__(*args, **kwargs) + self.setRange(minimum, maximum) def inputToFactory(self, config: dict) -> bool: """Store the current number in an Xray TUN settings mapping.""" value = self.value() + if config.get(self.key, self.default) == value: return False @@ -109,6 +119,7 @@ class GuiXrayTUNItemNumber(GuiEditorItemTextSpinBox): def factoryToInput(self, config: dict): """Load one Xray TUN numeric setting into the editor.""" value = config.get(self.key, self.default) + self.setValue(value if isinstance(value, int) else self.default) @@ -221,32 +232,42 @@ class GuiXrayTUNSettings(GuiEditorWidgetQDialog): def __init__(self, *args, **kwargs): """Initialize the Xray-core TUN settings dialog.""" + self._isConnectionActive = kwargs.pop('isConnectionActive', lambda: False) kwargs.setdefault('style', 'portrait') kwargs.setdefault('tabTranslatable', True) + super().__init__(*args, **kwargs) self.setTabText(_('Customize Xray-core TUN Settings')) self.setFixedSize(int(650 * GOLDEN_RATIO), 650) + self._settings = getXrayTUNSettings() + self.factoryToInput(self._settings) + self.accepted.connect(self.handleAccepted) self.layout().takeRow(self.layout().rowCount() - 1) + bottomLayout = QHBoxLayout() bottomLayout.addWidget(XrayTUNDocumentationURL()) bottomLayout.addStretch(1) bottomLayout.addWidget(self.dialogBtns) + self.layout().addRow(bottomLayout) def handleAccepted(self): """Persist changed settings and offer to reconnect when connected.""" oldSettings = copy.deepcopy(self._settings) + self.inputToFactory(self._settings) + if self._settings == oldSettings: return saveXrayTUNSettings(self._settings) - if SystemRuntime.isTUNMode() and isXrayConnectionActive(): + + if SystemRuntime.isTUNMode() and self._isConnectionActive(): showMBoxNewChangesNextTime() @functools.lru_cache(None) diff --git a/Furious/Plugins/Official/Xray/GuiTrojan.py b/Furious/Plugins/Official/Xray/GuiTrojan.py index 02303eec..b11f6504 100644 --- a/Furious/Plugins/Official/Xray/GuiTrojan.py +++ b/Furious/Plugins/Official/Xray/GuiTrojan.py @@ -22,12 +22,9 @@ from __future__ import annotations from Furious.Frozenlib import * from Furious.Interface import * from Furious.Library import * -from Furious.Plugins.Official.Configuration import ( - ConfigXray, - configXrayEmptyProxyOutboundObject, -) from Furious.Qt import * from Furious.Qt import gettext as _ +from Furious.Plugins.Official.Configuration import * from Furious.Plugins.Official.Xray.GuiVTransport import * from Furious.Plugins.Official.Xray.GuiVTLS import * diff --git a/Furious/Plugins/Official/Xray/GuiVLESS.py b/Furious/Plugins/Official/Xray/GuiVLESS.py index b7589121..1b4ce2b1 100644 --- a/Furious/Plugins/Official/Xray/GuiVLESS.py +++ b/Furious/Plugins/Official/Xray/GuiVLESS.py @@ -22,12 +22,9 @@ from __future__ import annotations from Furious.Frozenlib import * from Furious.Interface import * from Furious.Library import * -from Furious.Plugins.Official.Configuration import ( - ConfigXray, - configXrayEmptyProxyOutboundObject, -) from Furious.Qt import * from Furious.Qt import gettext as _ +from Furious.Plugins.Official.Configuration import * from Furious.Plugins.Official.Xray.GuiVTransport import * from Furious.Plugins.Official.Xray.GuiVTLS import * diff --git a/Furious/Plugins/Official/Xray/GuiVMess.py b/Furious/Plugins/Official/Xray/GuiVMess.py index 54b456e7..9741be72 100644 --- a/Furious/Plugins/Official/Xray/GuiVMess.py +++ b/Furious/Plugins/Official/Xray/GuiVMess.py @@ -22,12 +22,9 @@ from __future__ import annotations from Furious.Frozenlib import * from Furious.Interface import * from Furious.Library import * -from Furious.Plugins.Official.Configuration import ( - ConfigXray, - configXrayEmptyProxyOutboundObject, -) from Furious.Qt import * from Furious.Qt import gettext as _ +from Furious.Plugins.Official.Configuration import * from Furious.Plugins.Official.Xray.GuiVTransport import * from Furious.Plugins.Official.Xray.GuiVTLS import * diff --git a/Furious/Plugins/Official/Xray/GuiVTLS.py b/Furious/Plugins/Official/Xray/GuiVTLS.py index 27250a28..09dddc16 100644 --- a/Furious/Plugins/Official/Xray/GuiVTLS.py +++ b/Furious/Plugins/Official/Xray/GuiVTLS.py @@ -21,8 +21,8 @@ from __future__ import annotations from Furious.Interface import * from Furious.Library import * -from Furious.Plugins.Official.Configuration import ConfigXray from Furious.Qt import * +from Furious.Plugins.Official.Configuration import * from PySide6 import QtCore from PySide6.QtWidgets import * diff --git a/Furious/Plugins/Official/Xray/GuiVTransport.py b/Furious/Plugins/Official/Xray/GuiVTransport.py index 0224e55e..b6dd9616 100644 --- a/Furious/Plugins/Official/Xray/GuiVTransport.py +++ b/Furious/Plugins/Official/Xray/GuiVTransport.py @@ -21,9 +21,9 @@ from __future__ import annotations from Furious.Interface import * from Furious.Library import * -from Furious.Plugins.Official.Configuration import ConfigXray from Furious.Qt import * from Furious.Qt import gettext as _ +from Furious.Plugins.Official.Configuration import * from PySide6.QtWidgets import * diff --git a/Furious/Plugins/Official/Xray/Plugin.py b/Furious/Plugins/Official/Xray/Plugin.py index 13d3cbac..73485c99 100644 --- a/Furious/Plugins/Official/Xray/Plugin.py +++ b/Furious/Plugins/Official/Xray/Plugin.py @@ -19,32 +19,23 @@ from __future__ import annotations -from Furious.Core.CoreProcessWorker import ProcessOutputRedirector from Furious.Frozenlib import * from Furious.Qt.DynamicTranslate import gettext as _ -from Furious.Library.Storage import Storage -from Furious.Plugins.API import FuriousPlugin, PluginProtocol, PluginRouting -from Furious.Plugins.Official.Configuration import ( - BLANK_CONFIG_XRAY, - ConfigXray, - configXrayEmptyProxyOutboundObject, -) +from Furious.Core import * +from Furious.Library import * +from Furious.Plugins.API import * +from Furious.Plugins.Official.Configuration import * + +from .Core import * +from .Routing import * +from .TUN import * -import copy -import logging import os import uuid +import copy +import logging -from .Core import XrayCore -from .Routing import customRoutingObjectFromSettings -from .TUN import ( - buildXrayTUNInbound, - hasXrayTUNInbound, - isXrayTUNEnabled, - setXrayTUNEnabled, -) - -__all__ = ['XrayPlugin', 'isXrayConnectionActive'] +__all__ = ['XrayPlugin'] logger = logging.getLogger(__name__) @@ -96,22 +87,6 @@ _TRANSLATABLE_ACTION_TEXT = [ ] -def isXrayConnectionActive() -> bool: - """Return whether the connected core process belongs to Xray-core.""" - try: - connectAction = APP().systemTray.ConnectAction - if not connectAction.isConnected(): - return False - - return any( - isinstance(process, XrayCore) - for process in connectAction.coreManager.processesPool - ) - except Exception: - # Plugin actions can be created before the tray is fully initialized. - return False - - class XrayPlugin(FuriousPlugin): """Provide official Xray-core support.""" @@ -209,6 +184,8 @@ class XrayPlugin(FuriousPlugin): def createManagementActions(self, parent=None, **kwargs): """Create Xray routing, TUN, and asset-management actions.""" + isCoreActive = kwargs.pop('isCoreActive', lambda coreType: False) + # These modules require a fully initialized Furious.Qt package. from Furious.Qt import ( AppQAction, @@ -245,7 +222,7 @@ class XrayPlugin(FuriousPlugin): """Persist the native Xray TUN action state.""" setXrayTUNEnabled(useXrayTUNAction.isChecked()) - if SystemRuntime.isTUNMode() and isXrayConnectionActive(): + if SystemRuntime.isTUNMode() and isCoreActive(XrayCore): showMBoxNewChangesNextTime() useXrayTUNAction.callback = updateUseXrayTUN @@ -260,7 +237,10 @@ class XrayPlugin(FuriousPlugin): useXrayTUNAction, AppQAction( _('Customize Xray-core TUN Settings...'), - callback=lambda: GuiXrayTUNSettings(parent=parent).open(), + callback=lambda: GuiXrayTUNSettings( + parent=parent, + isConnectionActive=lambda: isCoreActive(XrayCore), + ).open(), ), AppQSeperator(), AppQAction( @@ -275,6 +255,7 @@ class XrayPlugin(FuriousPlugin): return False inbounds = config.get('inbounds') + if not isinstance(inbounds, list): inbounds = [] @@ -344,6 +325,7 @@ class XrayPlugin(FuriousPlugin): config['log'] = {'access': '', 'error': '', 'loglevel': 'warning'} logRedirectValue = str(uuid.uuid4()) + for attr in ['access', 'error']: fixLogObjectPath(config, attr, logRedirectValue, log) @@ -454,6 +436,7 @@ class XrayPlugin(FuriousPlugin): return logger.info(f'assets folder \'{XRAY_ASSET_DIR}\' is writable. Continue') + if not AppSettings.isStateON_('AutoUpdateAssetFiles'): logger.info('skipped auto assets update due to settings') diff --git a/Furious/Plugins/Official/Xray/Routing.py b/Furious/Plugins/Official/Xray/Routing.py index 8653a65d..49d34fdb 100644 --- a/Furious/Plugins/Official/Xray/Routing.py +++ b/Furious/Plugins/Official/Xray/Routing.py @@ -19,7 +19,7 @@ from __future__ import annotations -from Furious.Library.Storage import Storage +from Furious.Library import * __all__ = ['cleanRoutingRule', 'customRoutingObjectFromSettings'] @@ -45,8 +45,10 @@ def cleanRoutingRule(rule: dict): 'process', ]: value = rule.get(key, []) + if isinstance(value, list): value = list(str(item).strip() for item in value if str(item).strip()) + if value: result[key] = value @@ -60,6 +62,7 @@ def cleanRoutingRule(rule: dict): 'ruleTag', ]: value = str(rule.get(key, '')).strip() + if value: result[key] = value @@ -69,14 +72,17 @@ def cleanRoutingRule(rule: dict): def customRoutingObjectFromSettings(routing: str): """Load a named custom Xray routing object from persistent settings.""" prefix = 'Custom:' + if not isinstance(routing, str) or not routing.startswith(prefix): return None routingProfile = Storage.UserRoutings().get(routing[len(prefix) :]) + if not isinstance(routingProfile, dict) or not routingProfile.get('enabled', True): return None domainStrategy = routingProfile.get('domainStrategy', 'AsIs') + if domainStrategy not in ['AsIs', 'IPIfNonMatch', 'IPOnDemand']: domainStrategy = 'AsIs' diff --git a/Furious/Plugins/Official/Xray/TUN.py b/Furious/Plugins/Official/Xray/TUN.py index 8000acb4..19392057 100644 --- a/Furious/Plugins/Official/Xray/TUN.py +++ b/Furious/Plugins/Official/Xray/TUN.py @@ -66,26 +66,31 @@ def setXrayTUNEnabled(enabled: bool): def _normalizedXrayTUNSettings(settings) -> dict: """Return validated Xray TUN settings with defaults for invalid fields.""" result = copy.deepcopy(DEFAULT_XRAY_TUN_SETTINGS) + if not isinstance(settings, dict): return result for key in ('name', 'desc', 'autoOutboundsInterface'): value = settings.get(key) + if isinstance(value, str): result[key] = value.strip() for key in ('gateway', 'dns', 'autoSystemRoutingTable'): value = settings.get(key) + if isinstance(value, (list, tuple)): result[key] = [ item.strip() for item in value if isinstance(item, str) and item.strip() ] mtu = settings.get('mtu') + if isinstance(mtu, int) and not isinstance(mtu, bool) and mtu > 0: result['mtu'] = mtu userLevel = settings.get('userLevel') + if ( isinstance(userLevel, int) and not isinstance(userLevel, bool) @@ -101,6 +106,8 @@ def getXrayTUNSettings() -> dict: try: settings = json.loads(AppSettings.get('XrayTUNSettings')) except Exception: + # Any non-exit exceptions + settings = None return _normalizedXrayTUNSettings(settings) @@ -137,6 +144,7 @@ def buildXrayTUNInbound(settings=None) -> dict: def hasXrayTUNInbound(config) -> bool: """Return whether an Xray configuration contains a TUN inbound.""" inbounds = config.get('inbounds', []) + if not isinstance(inbounds, list): return False diff --git a/Furious/Plugins/Official/Xray/XrayAssetDownloadManager.py b/Furious/Plugins/Official/Xray/XrayAssetDownloadManager.py index 0006005a..fcf0f781 100644 --- a/Furious/Plugins/Official/Xray/XrayAssetDownloadManager.py +++ b/Furious/Plugins/Official/Xray/XrayAssetDownloadManager.py @@ -20,7 +20,7 @@ from __future__ import annotations from Furious.Frozenlib import * -from Furious.Qt.WebGETManager import * +from Furious.Qt import * from PySide6 import QtCore diff --git a/Furious/Plugins/Official/Xray/XrayAssetViewerWindow.py b/Furious/Plugins/Official/Xray/XrayAssetViewerWindow.py index c1f16dea..32eb5f9e 100644 --- a/Furious/Plugins/Official/Xray/XrayAssetViewerWindow.py +++ b/Furious/Plugins/Official/Xray/XrayAssetViewerWindow.py @@ -22,8 +22,10 @@ from __future__ import annotations from Furious.Frozenlib import * from Furious.Qt import * from Furious.Qt import gettext as _ + from .XrayAssetViewerQListWidget import * +from PySide6 import QtCore from PySide6.QtGui import * from PySide6.QtWidgets import * diff --git a/Furious/Plugins/Official/Xray/__init__.py b/Furious/Plugins/Official/Xray/__init__.py index 7308c6c0..a63dc22b 100644 --- a/Furious/Plugins/Official/Xray/__init__.py +++ b/Furious/Plugins/Official/Xray/__init__.py @@ -19,4 +19,6 @@ from __future__ import annotations -from .Plugin import * +from .Plugin import XrayPlugin + +__all__ = ['XrayPlugin'] diff --git a/Furious/Plugins/Registry.py b/Furious/Plugins/Registry.py index 95dd61cd..aceac1e5 100644 --- a/Furious/Plugins/Registry.py +++ b/Furious/Plugins/Registry.py @@ -19,12 +19,14 @@ from __future__ import annotations +from Furious.Library.Configuration import configurationRegistry + from importlib import metadata import logging import threading -from .API import PLUGIN_API_VERSION, FuriousPlugin, PluginProtocol, PluginRouting +from .API import * __all__ = [ 'PLUGIN_ENTRY_POINT_GROUP', @@ -48,12 +50,17 @@ def _normalizeProtocol(protocol) -> str: class PluginRegistry: """Store plugins and route host operations to the owning plugin.""" - def __init__(self): - """Initialize an empty plugin registry.""" + def __init__(self, configRegistry=None): + """Initialize an empty plugin registry. + + ``configRegistry`` is supplied only to the process-wide registry. This + keeps independently constructed registries isolated for tools and tests. + """ self._plugins = {} self._protocols = {} self._configurationTypes = {} self._coreTypes = {} + self._configRegistry = configRegistry def register(self, plugin: FuriousPlugin): """Register a plugin after validating all of its public identifiers.""" @@ -62,6 +69,7 @@ class PluginRegistry: if not isinstance(plugin, FuriousPlugin): raise TypeError('plugin must be a FuriousPlugin instance') + if plugin.apiVersion != PLUGIN_API_VERSION: raise ValueError( f'plugin API {plugin.apiVersion!r} is not supported; ' @@ -69,28 +77,35 @@ class PluginRegistry: ) pluginId = str(plugin.pluginId).strip() + if not pluginId: raise ValueError('plugin ID cannot be empty') + if pluginId in self._plugins: raise ValueError(f'plugin {pluginId!r} is already registered') protocolKeys = [] + for descriptor in plugin.protocols: if not isinstance(descriptor, PluginProtocol): raise TypeError('plugin protocols must contain PluginProtocol values') key = _normalizeProtocol(descriptor.id) + if not key: raise ValueError('plugin protocol ID cannot be empty') + if key in self._protocols or key in protocolKeys: raise ValueError(f'protocol {descriptor.id!r} is already registered') protocolKeys.append(key) configurationTypes = [] + for configType in plugin.configurationTypes: if not isinstance(configType, type): raise TypeError('plugin configuration types must be classes') + if any( issubclass(configType, registeredType) or issubclass(registeredType, configType) @@ -102,12 +117,15 @@ class PluginRegistry: raise ValueError( f'configuration type {configType.__name__!r} overlaps a registered type' ) + configurationTypes.append(configType) coreTypes = [] + for coreType in plugin.coreTypes: if not isinstance(coreType, type): raise TypeError('plugin core types must be classes') + if any( issubclass(coreType, registeredType) or issubclass(registeredType, coreType) @@ -119,8 +137,12 @@ class PluginRegistry: raise ValueError( f'core type {coreType.__name__!r} overlaps a registered type' ) + coreTypes.append(coreType) + if self._configRegistry is not None: + self._configRegistry.register(plugin) + self._plugins[pluginId] = plugin for key, descriptor in zip(protocolKeys, plugin.protocols): @@ -172,6 +194,7 @@ class PluginRegistry: """Ask plugins to parse textual configuration data.""" for plugin in self.plugins(): factory = plugin.configFromString(config, **kwargs) + if factory is not None: return factory @@ -181,6 +204,7 @@ class PluginRegistry: """Ask plugins to parse configuration mapping data.""" for plugin in self.plugins(): factory = plugin.configFromDict(config, **kwargs) + if factory is not None: return factory @@ -195,6 +219,7 @@ class PluginRegistry: def createEditorForProtocol(self, protocol, parent=None, **kwargs): """Construct a protocol editor through the owning plugin.""" plugin = self.pluginForProtocol(protocol) + if plugin is None: return None @@ -203,6 +228,7 @@ class PluginRegistry: def createEditorForConfig(self, config, parent=None, **kwargs): """Construct a configuration editor through the owning plugin.""" plugin = self.pluginForConfig(config) + if plugin is None: return None @@ -225,16 +251,20 @@ class PluginRegistry: def prepareTUN(self, config) -> bool: """Ask a configuration's plugin to prepare its native TUN support.""" plugin = self.pluginForConfig(config) + if plugin is None: return False try: handled = plugin.prepareTUN(config) + if not isinstance(handled, bool): raise TypeError('plugin TUN preparation result must be a boolean') return handled except Exception as ex: + # Any non-exit exceptions + logger.error(f'TUN preparation failed for {plugin.pluginId!r}: {ex}') return False @@ -242,12 +272,14 @@ class PluginRegistry: def routingOptions(self, config): """Return validated routing modes supported by a configuration's plugin.""" plugin = self.pluginForConfig(config) + if plugin is None: return tuple() try: options = tuple(plugin.routingOptions(config)) optionIds = set() + for option in options: if not isinstance(option, PluginRouting): raise TypeError( @@ -256,18 +288,22 @@ class PluginRegistry: if not isinstance(option.id, str): raise TypeError('plugin routing option ID must be a string') + if not option.id.strip(): raise ValueError('plugin routing option ID cannot be empty') + if not isinstance(option.displayName, str): raise TypeError( 'plugin routing option display name must be a string' ) + if not isinstance(option.translatable, bool): raise TypeError( 'plugin routing option translatable flag must be a boolean' ) optionId = option.id + if optionId in optionIds: raise ValueError( f'routing option {option.id!r} is already registered' @@ -277,6 +313,8 @@ class PluginRegistry: return options except Exception as ex: + # Any non-exit exceptions + logger.error( f'failed to obtain routing options for {plugin.pluginId!r}: {ex}' ) @@ -286,6 +324,7 @@ class PluginRegistry: def normalizeRouting(self, config, routing): """Return a supported routing value or the plugin's first option.""" options = self.routingOptions(config) + if not options: return routing @@ -299,15 +338,20 @@ class PluginRegistry: try: plugin.configureEnvironment() except Exception as ex: + # Any non-exit exceptions + logger.error(f'environment hook failed for {plugin.pluginId!r}: {ex}') def coreVersions(self): """Return version strings reported by every registered plugin core.""" versions = [] + for plugin in self.plugins(): try: versions.extend(plugin.coreVersions()) except Exception as ex: + # Any non-exit exceptions + logger.error( f'failed to obtain core versions for {plugin.pluginId!r}: {ex}' ) @@ -317,10 +361,13 @@ class PluginRegistry: def logTimestampPatterns(self): """Return timestamp expressions contributed by registered plugins.""" patterns = [] + for plugin in self.plugins(): try: patterns.extend(plugin.logTimestampPatterns()) except Exception as ex: + # Any non-exit exceptions + logger.error( f'failed to obtain log patterns for {plugin.pluginId!r}: {ex}' ) @@ -330,12 +377,15 @@ class PluginRegistry: def coreExitMessage(self, core, exitcode: int): """Return the owning plugin's special exit message, if any.""" plugin = self.pluginForCore(core) + if plugin is None: return None try: return plugin.coreExitMessage(core, exitcode) except Exception as ex: + # Any non-exit exceptions + logger.error(f'failed to interpret core exit for {plugin.pluginId!r}: {ex}') return None @@ -346,6 +396,8 @@ class PluginRegistry: try: plugin.afterConnected(httpProxy) except Exception as ex: + # Any non-exit exceptions + logger.error( f'post-connection hook failed for {plugin.pluginId!r}: {ex}' ) @@ -354,11 +406,14 @@ class PluginRegistry: """Load third-party plugins exposed through Python entry points.""" try: entryPoints = metadata.entry_points() + if hasattr(entryPoints, 'select'): entryPoints = entryPoints.select(group=PLUGIN_ENTRY_POINT_GROUP) else: entryPoints = entryPoints.get(PLUGIN_ENTRY_POINT_GROUP, tuple()) except Exception as ex: + # Any non-exit exceptions + logger.error(f'failed to enumerate Furious plugins: {ex}') return @@ -366,6 +421,7 @@ class PluginRegistry: for entryPoint in entryPoints: try: plugin = entryPoint.load() + if isinstance(plugin, type) and issubclass(plugin, FuriousPlugin): plugin = plugin() elif callable(plugin) and not isinstance(plugin, FuriousPlugin): @@ -377,24 +433,14 @@ class PluginRegistry: else: self.register(plugin) except Exception as ex: + # Any non-exit exceptions + logger.error(f'failed to load plugin {entryPoint.name!r}: {ex}') -_registry = PluginRegistry() +_registry = PluginRegistry(configurationRegistry) _registryLock = threading.RLock() _registryInitialized = False -_officialPluginTypes = tuple() - - -def _setOfficialPluginTypes(pluginTypes): - """Configure bundled plugin classes before registry initialization.""" - global _officialPluginTypes - - with _registryLock: - if _registryInitialized: - raise RuntimeError('plugin registry is already initialized') - - _officialPluginTypes = tuple(pluginTypes) def getPluginRegistry() -> PluginRegistry: @@ -403,8 +449,14 @@ def getPluginRegistry() -> PluginRegistry: with _registryLock: if not _registryInitialized: - registry = PluginRegistry() - for pluginType in _officialPluginTypes: + # Importing official plugins is an intentional extension-loading + # boundary. Keeping it here prevents package initialization from + # eagerly importing GUI and core implementations. + from .Official import OFFICIAL_PLUGIN_TYPES + + registry = PluginRegistry(configurationRegistry) + + for pluginType in OFFICIAL_PLUGIN_TYPES: registry.register(pluginType()) registry.discover() diff --git a/Furious/Plugins/__init__.py b/Furious/Plugins/__init__.py index 5f33d74b..18a1545c 100644 --- a/Furious/Plugins/__init__.py +++ b/Furious/Plugins/__init__.py @@ -15,14 +15,25 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -"""Expose the Furious plugin API and process-wide plugin registry.""" +"""Expose plugin contracts without eagerly loading plugin implementations.""" from __future__ import annotations -from .API import * -from .Registry import * -from .Registry import _setOfficialPluginTypes -from .Configuration import * -from .Official import OFFICIAL_PLUGIN_TYPES +from .API import PLUGIN_API_VERSION, FuriousPlugin, PluginProtocol, PluginRouting +from .Registry import ( + PLUGIN_ENTRY_POINT_GROUP, + PluginRegistry, + getPluginRegistry, + registerPlugin, +) -_setOfficialPluginTypes(OFFICIAL_PLUGIN_TYPES) +__all__ = [ + 'PLUGIN_API_VERSION', + 'PLUGIN_ENTRY_POINT_GROUP', + 'FuriousPlugin', + 'PluginProtocol', + 'PluginRegistry', + 'PluginRouting', + 'getPluginRegistry', + 'registerPlugin', +] diff --git a/Furious/Qt/GuiEditorXXX.py b/Furious/Qt/GuiEditorXXX.py index d43a42fe..b6dad18e 100644 --- a/Furious/Qt/GuiEditorXXX.py +++ b/Furious/Qt/GuiEditorXXX.py @@ -21,6 +21,7 @@ from __future__ import annotations from Furious.Frozenlib import * from Furious.Interface import * +from Furious.Library import ConfigFactory from Furious.Qt.DynamicTranslate import gettext as _ from Furious.Qt.QtWidgets import * diff --git a/Furious/Qt/TextEditorTheme.py b/Furious/Qt/TextEditorTheme.py index 72170069..fd2d59b4 100644 --- a/Furious/Qt/TextEditorTheme.py +++ b/Furious/Qt/TextEditorTheme.py @@ -19,9 +19,7 @@ from __future__ import annotations -from Furious.Core.Tun2socks import Tun2socks from Furious.Frozenlib.Mixins import Mixins -from Furious.Plugins import getPluginRegistry from Furious.Qt.AppStyleSheet import AppStyleSheet from PySide6 import QtCore @@ -32,8 +30,22 @@ __all__ = [ 'DraculaEditorTheme', 'DraculaJSONSyntaxHighlighter', 'DraculaLoggerSyntaxHighlighter', + 'configureEditorLogMetadata', ] +_coreVersionsProvider = lambda: tuple() +_logTimestampPatternsProvider = lambda: tuple() + + +def configureEditorLogMetadata(coreVersionsProvider, timestampPatternsProvider): + """Install application-owned metadata providers used for log highlighting.""" + if not callable(coreVersionsProvider) or not callable(timestampPatternsProvider): + raise TypeError('editor log metadata providers must be callable') + + global _coreVersionsProvider, _logTimestampPatternsProvider + _coreVersionsProvider = coreVersionsProvider + _logTimestampPatternsProvider = timestampPatternsProvider + def _currentTheme(): """Return the application's active theme without coupling to its concrete type.""" @@ -149,10 +161,7 @@ class AppQSyntaxHighlighter(Mixins.ThemeAware, QSyntaxHighlighter): captured = text[capturedStart : capturedStart + capturedLength] shouldHighlight = True - for version in [ - *getPluginRegistry().coreVersions(), - Tun2socks.version(), - ]: + for version in _coreVersionsProvider(): if captured == version: # These x.y.z.u version values are not IPv4 addresses. Do not highlight shouldHighlight = False @@ -261,11 +270,15 @@ class DraculaLoggerSyntaxHighlighter(AppQSyntaxHighlighter): ), EditorHighlightRules( # Application logging timestamp - r'\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3}\]' + r'|' - # Plugin-core logging timestamps - + r'|'.join(getPluginRegistry().logTimestampPatterns()) + r'|' - # tun2socks logging timestamp - + r'\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2}', + r'|'.join( + ( + # Application logging timestamp + r'\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3}\]', + *_logTimestampPatternsProvider(), + # tun2socks logging timestamp + r'\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2}', + ) + ), palette['editor_timestamp'], ), EditorHighlightRules( diff --git a/Furious/Widget/WaitingSpinner.py b/Furious/Qt/WaitingSpinner.py similarity index 93% rename from Furious/Widget/WaitingSpinner.py rename to Furious/Qt/WaitingSpinner.py index c437d432..40b649f1 100644 --- a/Furious/Widget/WaitingSpinner.py +++ b/Furious/Qt/WaitingSpinner.py @@ -1,3 +1,20 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# 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 . + """ The MIT License (MIT) @@ -26,6 +43,8 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +from __future__ import annotations + from PySide6.QtCore import QRect, Qt, QTimer from PySide6.QtGui import QColor, QPainter, QPaintEvent from PySide6.QtWidgets import QWidget diff --git a/Furious/Qt/__init__.py b/Furious/Qt/__init__.py index 9275004a..03086458 100644 --- a/Furious/Qt/__init__.py +++ b/Furious/Qt/__init__.py @@ -19,16 +19,150 @@ from __future__ import annotations -from .DNSResolver import * -from .AppStyleSheet import * -from .DynamicTheme import * -from .DynamicTranslate import * -from .GuiEditorXXX import * -from .NetworkConnectivityManager import * -from .QtGui import * -from .QtNetwork import * -from .QtWidgets import * -from .TextEditor import * -from .TextEditorTheme import * -from .UpdatesManager import * -from .WebGETManager import * +from .AppStyleSheet import AppStyleSheet +from .DNSResolver import DNSResolver +from .DynamicTheme import AppHue +from .DynamicTranslate import ( + ABBR_TO_LANGUAGE, + LANGUAGE_TO_ABBR, + SUPPORTED_LANGUAGE, + gettext, +) +from .GuiEditorXXX import ( + GuiEditorItemBasicRemark, + GuiEditorItemProxyHttp, + GuiEditorItemProxySocks, + GuiEditorItemTextCheckBox, + GuiEditorItemTextComboBox, + GuiEditorItemTextInput, + GuiEditorItemTextSpinBox, + GuiEditorWidgetQDialog, + GuiEditorWidgetQGroupBox, + GuiEditorWidgetQWidget, +) +from .NetworkConnectivityManager import NetworkConnectivityManager +from .QtGui import ( + AppQAction, + AppQActionGroup, + AppQIcon, + AppQSeperator, + bootstrapIcon, + bootstrapIconMask, + bootstrapIconWhite, + bootstrapIconWithOpacity, +) +from .QtNetwork import AppQNetworkAccessManager +from .QtWidgets import ( + AppQCheckBox, + AppQComboBox, + AppQDialog, + AppQDialogButtonBox, + AppQGroupBox, + AppQHeaderView, + AppQLabel, + AppQLineEdit, + AppQListWidget, + AppQMainWindow, + AppQMenu, + AppQMenuBar, + AppQMessageBox, + AppQPushButton, + AppQSpinBox, + AppQTableView, + AppQTableWidget, + AppQTabWidget, + AppQToolBar, + MBoxDirectRulesNotAllowed, + MBoxNewChangesNextTime, + MBoxQuestionDelete, + MBoxUnrecognizedConfig, + moveToCenter, + showMBoxDirectRulesNotAllowed, + showMBoxNewChangesNextTime, + showMBoxUnrecognizedConfig, +) +from .TextEditor import ( + AppQPlainTextEdit, + AppQTextBrowser, + DraculaJSONTextEditor, + DraculaTextBrowser, + DraculaTextEditor, +) +from .TextEditorTheme import ( + DraculaEditorTheme, + DraculaJSONSyntaxHighlighter, + DraculaLoggerSyntaxHighlighter, + configureEditorLogMetadata, +) +from .UpdatesManager import UpdatesManager +from .WaitingSpinner import WaitingSpinner +from .WebGETManager import WebGETManager + +__all__ = [ + 'ABBR_TO_LANGUAGE', + 'AppHue', + 'AppQAction', + 'AppQActionGroup', + 'AppQCheckBox', + 'AppQComboBox', + 'AppQDialog', + 'AppQDialogButtonBox', + 'AppQGroupBox', + 'AppQHeaderView', + 'AppQIcon', + 'AppQLabel', + 'AppQLineEdit', + 'AppQListWidget', + 'AppQMainWindow', + 'AppQMenu', + 'AppQMenuBar', + 'AppQMessageBox', + 'AppQNetworkAccessManager', + 'AppQPlainTextEdit', + 'AppQPushButton', + 'AppQSeperator', + 'AppQSpinBox', + 'AppQTabWidget', + 'AppQTableView', + 'AppQTableWidget', + 'AppQTextBrowser', + 'AppQToolBar', + 'AppStyleSheet', + 'DNSResolver', + 'DraculaEditorTheme', + 'DraculaJSONSyntaxHighlighter', + 'DraculaJSONTextEditor', + 'DraculaLoggerSyntaxHighlighter', + 'DraculaTextBrowser', + 'DraculaTextEditor', + 'GuiEditorItemBasicRemark', + 'GuiEditorItemProxyHttp', + 'GuiEditorItemProxySocks', + 'GuiEditorItemTextCheckBox', + 'GuiEditorItemTextComboBox', + 'GuiEditorItemTextInput', + 'GuiEditorItemTextSpinBox', + 'GuiEditorWidgetQDialog', + 'GuiEditorWidgetQGroupBox', + 'GuiEditorWidgetQWidget', + 'LANGUAGE_TO_ABBR', + 'MBoxDirectRulesNotAllowed', + 'MBoxNewChangesNextTime', + 'MBoxQuestionDelete', + 'MBoxUnrecognizedConfig', + 'NetworkConnectivityManager', + 'SUPPORTED_LANGUAGE', + 'UpdatesManager', + 'WaitingSpinner', + 'WebGETManager', + 'bootstrapIcon', + 'bootstrapIconMask', + 'bootstrapIconWhite', + 'bootstrapIconWithOpacity', + 'configureEditorLogMetadata', + 'gettext', + 'moveToCenter', + 'showMBoxDirectRulesNotAllowed', + 'showMBoxNewChangesNextTime', + 'showMBoxUnrecognizedConfig', +] diff --git a/Furious/Core/CoreManager.py b/Furious/Service/CoreManager.py similarity index 99% rename from Furious/Core/CoreManager.py rename to Furious/Service/CoreManager.py index 0f588dbe..df8c5458 100644 --- a/Furious/Core/CoreManager.py +++ b/Furious/Service/CoreManager.py @@ -21,8 +21,8 @@ from __future__ import annotations from Furious.Frozenlib import * from Furious.Interface import * -from Furious.Library.Storage import Storage -from Furious.Plugins import getPluginRegistry +from Furious.Library import * +from Furious.Plugins import * from Furious.Qt import * from Furious.Core.CoreProcessWorker import * from Furious.Core.Tun2socks import * diff --git a/Furious/Service/__init__.py b/Furious/Service/__init__.py new file mode 100644 index 00000000..e8f41048 --- /dev/null +++ b/Furious/Service/__init__.py @@ -0,0 +1,24 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# 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 . + +"""Expose application services that coordinate lower-level subsystems.""" + +from __future__ import annotations + +from .CoreManager import CoreManager + +__all__ = ['CoreManager'] diff --git a/Furious/TrayActions/Connect.py b/Furious/TrayActions/Connect.py index 941a7b1d..ba1f28f9 100644 --- a/Furious/TrayActions/Connect.py +++ b/Furious/TrayActions/Connect.py @@ -25,8 +25,8 @@ from Furious.Library import * from Furious.Plugins import getPluginRegistry from Furious.Qt import * from Furious.Qt import gettext as _ -from Furious.Core.CoreManager import CoreManager -from Furious.Widget.ConnectProgressBar import ConnectProgressBar +from Furious.Service import CoreManager +from Furious.TrayActions.ConnectProgressBar import ConnectProgressBar from PySide6 import QtCore diff --git a/Furious/Widget/ConnectProgressBar.py b/Furious/TrayActions/ConnectProgressBar.py similarity index 100% rename from Furious/Widget/ConnectProgressBar.py rename to Furious/TrayActions/ConnectProgressBar.py diff --git a/Furious/TrayActions/Import.py b/Furious/TrayActions/Import.py index ed67ef01..956da6c9 100644 --- a/Furious/TrayActions/Import.py +++ b/Furious/TrayActions/Import.py @@ -23,7 +23,7 @@ from Furious.Frozenlib import * from Furious.Library import * from Furious.Qt import * from Furious.Qt import gettext as _ -from Furious.Widget.WaitingSpinner import * +from Furious.Qt.WaitingSpinner import * from PySide6 import QtCore from PySide6.QtGui import QColor diff --git a/Furious/TrayActions/__init__.py b/Furious/TrayActions/__init__.py index 29f448cf..6c029e90 100644 --- a/Furious/TrayActions/__init__.py +++ b/Furious/TrayActions/__init__.py @@ -19,11 +19,34 @@ from __future__ import annotations -from .Connect import * -from .EditConfiguration import * -from .Exit import * -from .Import import * -from .Language import * -from .Routing import * -from .Settings import * -from .SystemProxy import * +from .Connect import ConnectAction +from .ConnectProgressBar import ConnectProgressBar +from .EditConfiguration import EditConfigurationAction +from .Exit import ExitAction +from .Import import ( + ImportAction, + ImportFromFileAction, + ImportJSONFromClipboardAction, + ImportQRCodeOnTheScreenAction, + ImportURIFromClipboardAction, +) +from .Language import LanguageAction +from .Routing import RoutingAction +from .Settings import SettingsAction +from .SystemProxy import SystemProxyAction + +__all__ = [ + 'ConnectAction', + 'ConnectProgressBar', + 'EditConfigurationAction', + 'ExitAction', + 'ImportAction', + 'ImportFromFileAction', + 'ImportJSONFromClipboardAction', + 'ImportQRCodeOnTheScreenAction', + 'ImportURIFromClipboardAction', + 'LanguageAction', + 'RoutingAction', + 'SettingsAction', + 'SystemProxyAction', +] diff --git a/Furious/Utility/__init__.py b/Furious/Utility/__init__.py index 0470e27b..41b6ac4d 100644 --- a/Furious/Utility/__init__.py +++ b/Furious/Utility/__init__.py @@ -19,4 +19,6 @@ from __future__ import annotations -from .AppMainProcess import * +from .AppMainProcess import AppMainProcess + +__all__ = ['AppMainProcess'] diff --git a/Furious/Version.py b/Furious/Version.py index 0db813f4..9016dc95 100644 --- a/Furious/Version.py +++ b/Furious/Version.py @@ -15,8 +15,10 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -"""Define the Furious package version.""" +"""Provide the stable public import path for the Furious package version.""" from __future__ import annotations -__version__ = '0.7.0' +from Furious.Frozenlib.Version import __version__ + +__all__ = ['__version__'] diff --git a/Furious/Widget/GuiTUNSettings.py b/Furious/Widget/GuiTUNSettings.py index 432e4396..5a805d8b 100644 --- a/Furious/Widget/GuiTUNSettings.py +++ b/Furious/Widget/GuiTUNSettings.py @@ -25,6 +25,7 @@ from Furious.Library import * from Furious.Qt import * from Furious.Qt import gettext as _ +from PySide6 import QtCore from PySide6.QtGui import * from PySide6.QtWidgets import * @@ -242,9 +243,7 @@ class GuiTUNSettingsGroupBoxBasic(GuiEditorWidgetQGroupBox): key='tunAdapterInterfaceDNS', ), GuiTUNSettingsItemXXX( - title=_( - 'Bypass Tun2socks Adapter Interface IP (separated by commas)' - ), + title=_('Bypass Tun2socks Adapter Interface IP (separated by commas)'), key='bypassTUNAdapterInterfaceIP', ), GuiTUNSettingsItemCheckBoxXXX( diff --git a/Furious/Window/QRCodeWindow.py b/Furious/Widget/QRCodeWindow.py similarity index 100% rename from Furious/Window/QRCodeWindow.py rename to Furious/Widget/QRCodeWindow.py diff --git a/Furious/Window/TextEditorWindow.py b/Furious/Widget/TextEditorWindow.py similarity index 100% rename from Furious/Window/TextEditorWindow.py rename to Furious/Widget/TextEditorWindow.py diff --git a/Furious/Widget/UserServersQTableView.py b/Furious/Widget/UserServersQTableView.py index c17d592a..fae7008f 100644 --- a/Furious/Widget/UserServersQTableView.py +++ b/Furious/Widget/UserServersQTableView.py @@ -25,11 +25,11 @@ from Furious.Library import * from Furious.Plugins import getPluginRegistry from Furious.Qt import * from Furious.Qt import gettext as _ -from Furious.Core.CoreManager import CoreManager +from Furious.Service import CoreManager from Furious.TrayActions.Import import * -from Furious.Widget.WaitingSpinner import * -from Furious.Window.QRCodeWindow import * -from Furious.Window.TextEditorWindow import * +from Furious.Qt.WaitingSpinner import * +from Furious.Widget.QRCodeWindow import * +from Furious.Widget.TextEditorWindow import * from PySide6 import QtCore from PySide6.QtGui import * @@ -1066,6 +1066,13 @@ class UserServersQTableViewHeaders: return self.name +def _subscriptionRemark(item: ConfigFactory) -> str: + """Resolve a persisted subscription ID to its user-visible remark.""" + subscription = Storage.UserSubs().get(item.itemSubscription, {}) + + return subscription.get('remark', '') + + class UserServersTableModel(QtCore.QAbstractTableModel): """Expose user servers table data through a Qt item model.""" @@ -1404,7 +1411,7 @@ class UserServersQTableView( UserServersQTableViewHeaders('Port'), UserServersQTableViewHeaders('Transport'), UserServersQTableViewHeaders('TLS'), - UserServersQTableViewHeaders('Subscription'), + UserServersQTableViewHeaders('Subscription', _subscriptionRemark), UserServersQTableViewHeaders('Latency'), UserServersQTableViewHeaders('Speed'), ] diff --git a/Furious/Widget/__init__.py b/Furious/Widget/__init__.py index 49afdc1b..99220a8d 100644 --- a/Furious/Widget/__init__.py +++ b/Furious/Widget/__init__.py @@ -19,13 +19,22 @@ from __future__ import annotations -from .Application import * -from .ConnectProgressBar import * -from .GuiCustomizeNetworkTest import * -from .GuiCustomizeProxyBypass import * -from .GuiTUNSettings import * -from .IndentSpinBox import * -from .SystemTrayIcon import * -from .UserServersQTableView import * -from .UserSubsQTableView import * -from .WaitingSpinner import * +from .GuiCustomizeNetworkTest import GuiCustomizeNetworkTestDialog +from .GuiCustomizeProxyBypass import GuiCustomizeProxyBypassDialog +from .GuiTUNSettings import GuiTUNSettings +from .IndentSpinBox import IndentSpinBox +from .QRCodeWindow import QRCodeWindow +from .TextEditorWindow import TextEditorWindow +from .UserServersQTableView import UserServersQTableView +from .UserSubsQTableView import UserSubsQTableView + +__all__ = [ + 'GuiCustomizeNetworkTestDialog', + 'GuiCustomizeProxyBypassDialog', + 'GuiTUNSettings', + 'IndentSpinBox', + 'QRCodeWindow', + 'TextEditorWindow', + 'UserServersQTableView', + 'UserSubsQTableView', +] diff --git a/Furious/Window/AppMainWindow.py b/Furious/Window/AppMainWindow.py index 1a7bd62e..e9fb5223 100644 --- a/Furious/Window/AppMainWindow.py +++ b/Furious/Window/AppMainWindow.py @@ -45,6 +45,21 @@ __all__ = ['AppMainWindow'] logger = logging.getLogger(__name__) + +def _isCoreActive(coreType) -> bool: + """Return whether the tray's active connection owns *coreType*.""" + try: + connectAction = APP().systemTray.ConnectAction + + return connectAction.isConnected() and any( + isinstance(process, coreType) + for process in connectAction.coreManager.processesPool + ) + except (AttributeError, RuntimeError): + # The main window is built before the system tray is attached. + return False + + # Migrate legacy settings registerAppSettings('ServerWidgetWindowSize') registerAppSettings('AppMainWindowGeometry') @@ -440,6 +455,7 @@ class AppMainWindow(AppQMainWindow): managementActions = pluginRegistry.managementActions( plugin, parent=self, + isCoreActive=_isCoreActive, ) if managementActions: corePluginActions.append( diff --git a/Furious/Window/__init__.py b/Furious/Window/__init__.py index 6ac73d5d..75bb168b 100644 --- a/Furious/Window/__init__.py +++ b/Furious/Window/__init__.py @@ -19,8 +19,8 @@ from __future__ import annotations -from .AppMainWindow import * -from .LogViewerWindow import * -from .QRCodeWindow import * -from .TextEditorWindow import * -from .UserSubsWindow import * +from .AppMainWindow import AppMainWindow +from .LogViewerWindow import LogViewerWindow +from .UserSubsWindow import UserSubsWindow + +__all__ = ['AppMainWindow', 'LogViewerWindow', 'UserSubsWindow'] diff --git a/Furious/__init__.py b/Furious/__init__.py index 106bcf22..0edf7ab8 100644 --- a/Furious/__init__.py +++ b/Furious/__init__.py @@ -20,3 +20,5 @@ from __future__ import annotations from .Frozenlib import AppResources + +__all__ = ['AppResources'] diff --git a/Furious/__main__.py b/Furious/__main__.py index 7e56971f..905357b1 100644 --- a/Furious/__main__.py +++ b/Furious/__main__.py @@ -24,7 +24,7 @@ from Furious.Interface import * from Furious.Qt import * from Furious.Qt import gettext as _ from Furious.Utility import * -from Furious.Widget.Application import Application +from Furious.Application import Application from PySide6 import QtCore from PySide6.QtGui import QDesktopServices