Reorganize application packages

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-08-12 12:20:38 +08:00
parent df97365b46
commit 219343a06a
67 changed files with 1066 additions and 396 deletions
+3 -3
View File
@@ -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']
@@ -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
+25
View File
@@ -0,0 +1,25 @@
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
#
# 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 <https://www.gnu.org/licenses/>.
"""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']
+20 -3
View File
@@ -15,9 +15,26 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""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',
]
+32 -32
View File
@@ -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": "关闭",
+3 -1
View File
@@ -19,4 +19,6 @@
from __future__ import annotations
from .GenTranslation import *
from .GenTranslation import TRANSLATION
__all__ = ['TRANSLATION']
+46 -1
View File
@@ -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'
+4 -4
View File
@@ -15,7 +15,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""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)
+5 -5
View File
@@ -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
+24
View File
@@ -0,0 +1,24 @@
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
#
# 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 <https://www.gnu.org/licenses/>.
"""Define the package version without importing higher application layers."""
from __future__ import annotations
__all__ = ['__version__']
__version__ = '0.7.0'
+143 -14
View File
@@ -15,20 +15,149 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""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',
]
+5 -6
View File
@@ -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)
+17 -8
View File
@@ -15,14 +15,23 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""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',
]
@@ -15,21 +15,28 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""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)
+1 -1
View File
@@ -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']
+26 -4
View File
@@ -15,11 +15,33 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""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',
]
-65
View File
@@ -1,65 +0,0 @@
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
#
# 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 <https://www.gnu.org/licenses/>.
"""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()
+6 -5
View File
@@ -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 {
+1 -1
View File
@@ -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
@@ -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 _
+8 -12
View File
@@ -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."""
@@ -19,4 +19,6 @@
from __future__ import annotations
from .Plugin import *
from .Plugin import Hysteria1Plugin
__all__ = ['Hysteria1Plugin']
+1 -1
View File
@@ -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
@@ -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 _
@@ -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)
+17 -34
View File
@@ -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 '
+22 -3
View File
@@ -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)
@@ -19,4 +19,6 @@
from __future__ import annotations
from .Plugin import *
from .Plugin import Hysteria2Plugin
__all__ = ['Hysteria2Plugin']
+2 -2
View File
@@ -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
@@ -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 *
+1 -4
View File
@@ -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 *
@@ -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)
+1 -4
View File
@@ -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 *
+1 -4
View File
@@ -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 *
+1 -4
View File
@@ -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 *
+1 -1
View File
@@ -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 *
@@ -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 *
+21 -38
View File
@@ -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')
+7 -1
View File
@@ -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'
+8
View File
@@ -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
@@ -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
@@ -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 *
+3 -1
View File
@@ -19,4 +19,6 @@
from __future__ import annotations
from .Plugin import *
from .Plugin import XrayPlugin
__all__ = ['XrayPlugin']
+70 -18
View File
@@ -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()
+18 -7
View File
@@ -15,14 +15,25 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""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',
]
+1
View File
@@ -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 *
+24 -11
View File
@@ -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(
@@ -1,3 +1,20 @@
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
#
# 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 <https://www.gnu.org/licenses/>.
"""
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
+147 -13
View File
@@ -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',
]
@@ -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 *
+24
View File
@@ -0,0 +1,24 @@
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
#
# 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 <https://www.gnu.org/licenses/>.
"""Expose application services that coordinate lower-level subsystems."""
from __future__ import annotations
from .CoreManager import CoreManager
__all__ = ['CoreManager']
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
+31 -8
View File
@@ -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',
]
+3 -1
View File
@@ -19,4 +19,6 @@
from __future__ import annotations
from .AppMainProcess import *
from .AppMainProcess import AppMainProcess
__all__ = ['AppMainProcess']
+4 -2
View File
@@ -15,8 +15,10 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""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__']
+2 -3
View File
@@ -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(
+12 -5
View File
@@ -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'),
]
+19 -10
View File
@@ -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',
]
+16
View File
@@ -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(
+5 -5
View File
@@ -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']
+2
View File
@@ -20,3 +20,5 @@
from __future__ import annotations
from .Frozenlib import AppResources
__all__ = ['AppResources']
+1 -1
View File
@@ -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