Prepare the 0.5.0 release workload

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2025-05-19 19:43:12 +08:00
parent 41f1f04139
commit e0ae6d5a63
47 changed files with 2610 additions and 896 deletions
+3 -14
View File
@@ -119,7 +119,7 @@ else:
ARTIFACT_NAME = ''
def downloadXrayAssets(url, filename):
def downloadXrayAssets(url, filepath):
try:
import requests
except ImportError:
@@ -130,9 +130,6 @@ def downloadXrayAssets(url, filename):
if not os.path.exists(XRAY_ASSET_DIR):
os.makedirs(XRAY_ASSET_DIR)
# Full path where the file will be saved
filepath = os.path.join(XRAY_ASSET_DIR, filename)
# Send an HTTP GET request to the URL
response = requests.get(url)
response.raise_for_status() # Check if the request was successful
@@ -213,18 +210,10 @@ def cleanup():
def download():
# URLs of geosite and geoip assets
url_geosite = 'https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat'
url_geoip = 'https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat'
# Xray assets names
filename_geosite = 'geosite.dat'
filename_geoip = 'geoip.dat'
return all(
[
downloadXrayAssets(url_geosite, filename_geosite),
downloadXrayAssets(url_geoip, filename_geoip),
downloadXrayAssets(URL_GEOSITE, XRAY_ASSET_PATH_GEOSITE),
downloadXrayAssets(URL_GEOIP, XRAY_ASSET_PATH_GEOIP),
]
)
+172 -41
View File
@@ -78,6 +78,30 @@ def fixLogObjectPath(config: ConfigurationFactory, attr: str, value: str, log=Tr
)
def getUserTUNSettings(*args, **kwargs):
return AS_UserTUNSettings().get(*args, **kwargs)
userPrimaryAdapterInterfaceName = functools.partial(
getUserTUNSettings, 'primaryAdapterInterfaceName', ''
)
userPrimaryAdapterInterfaceIP = functools.partial(
getUserTUNSettings, 'primaryAdapterInterfaceIP', ''
)
userDefaultPrimaryGatewayIP = functools.partial(
getUserTUNSettings, 'defaultPrimaryGatewayIP', ''
)
userTunAdapterInterfaceDNS = functools.partial(
getUserTUNSettings, 'tunAdapterInterfaceDNS', ''
)
userBypassTUNAdapterInterfaceIP = functools.partial(
getUserTUNSettings, 'bypassTUNAdapterInterfaceIP', ''
)
userDisablePrimaryAdapterInterfaceDNS = functools.partial(
getUserTUNSettings, 'disablePrimaryAdapterInterfaceDNS', 'True'
)
class CoreManager(SupportExitCleanup):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -116,12 +140,12 @@ class CoreManager(SupportExitCleanup):
fixLogObjectPath(configcopy, attr, logRedirectValue, log)
if routing == 'Bypass Mainland China':
# VPN Mode handling
if not proxyModeOnly and isVPNMode():
# TUN Mode handling
if not proxyModeOnly and isTUNMode():
mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Critical)
mbox.setWindowTitle(_('Unable to connect'))
mbox.setText(
_('Routing option with direct rules is not allowed in VPN mode')
_('Routing option with direct rules is not allowed in TUN mode')
)
# Show the MessageBox asynchronously
@@ -218,12 +242,12 @@ class CoreManager(SupportExitCleanup):
success = coreProcess.start(configcopy, **kwargs)
elif isinstance(configcopy, ConfigurationHysteria1):
if routing == 'Bypass Mainland China':
# VPN Mode handling
if not proxyModeOnly and isVPNMode():
# TUN Mode handling
if not proxyModeOnly and isTUNMode():
mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Critical)
mbox.setWindowTitle(_('Unable to connect'))
mbox.setText(
_('Routing option with direct rules is not allowed in VPN mode')
_('Routing option with direct rules is not allowed in TUN mode')
)
# Show the MessageBox asynchronously
@@ -290,9 +314,9 @@ class CoreManager(SupportExitCleanup):
return success
# VPN Mode handling
if not proxyModeOnly and isVPNMode():
# Currently VPN Mode is only supported on Windows and macOS
# TUN Mode handling
if not proxyModeOnly and isTUNMode():
# Currently TUN Mode is only supported on Windows and macOS
if PLATFORM == 'Windows' or PLATFORM == 'Darwin':
if PLATFORM == 'Windows':
# cleanup first
@@ -300,27 +324,47 @@ class CoreManager(SupportExitCleanup):
'0.0.0.0', APPLICATION_TUN_GATEWAY_ADDRESS
)
defaultGateway = SystemRoutingTable.getDefaultGateway()
# Handle user defined settings
userGateway, userInterfaceIP = (
userDefaultPrimaryGatewayIP(),
userPrimaryAdapterInterfaceIP(),
)
if PLATFORM == 'Darwin':
# Need this?
defaultGateway = list(
# Filter TUN Gateway
filter(
lambda x: x != APPLICATION_TUN_GATEWAY_ADDRESS,
defaultGateway,
)
if userGateway and userInterfaceIP:
logger.info(
f'got user defined TUN settings. '
f'\'DefaultPrimaryGatewayIP\': {userGateway}. '
f'\'PrimaryAdapterInterfaceIP\': {userInterfaceIP}'
)
if len(defaultGateway) != 1:
logger.error(f'bad default gateway: {defaultGateway}')
return False
if PLATFORM == 'Windows':
gateway, interfaceIP = defaultGateway[0]
gateway, interfaceIP = userGateway, userInterfaceIP
else:
gateway, interfaceIP = defaultGateway[0], None
logger.info(
f'automatically fetching TUN settings: '
f'\'DefaultPrimaryGatewayIP\' and \'PrimaryAdapterInterfaceIP\''
)
defaultGateway = SystemRoutingTable.getDefaultGateway()
if PLATFORM == 'Darwin':
# Need this?
defaultGateway = list(
# Filter TUN Gateway
filter(
lambda x: x != APPLICATION_TUN_GATEWAY_ADDRESS,
defaultGateway,
)
)
if len(defaultGateway) != 1:
logger.error(f'bad default gateway: {defaultGateway}')
return False
if PLATFORM == 'Windows':
gateway, interfaceIP = defaultGateway[0]
else:
gateway, interfaceIP = defaultGateway[0], None
tunProcess = Tun2socks(
exitCallback=exitCallback, msgCallback=msgCallbackTun_
@@ -336,24 +380,61 @@ class CoreManager(SupportExitCleanup):
):
return False
address = configcopy.itemAddress
# Handle user defined settings
bypassTUN = userBypassTUNAdapterInterfaceIP()
if not isValidIPAddress(address):
error, resolved = DNSResolver.resolve(
address, *parseHostPort(configcopy.httpProxyEndpoint())
)
if bypassTUN:
try:
bypassSplit = bypassTUN.split(',')
except Exception as ex:
# Any non-exit exceptions
if error:
logger.error(f'DNS resolution failed: {address}')
logger.error(
f'error when processing ' f'user TUN bypass Settings: {ex}'
)
SystemRoutingTable.Relations.clear()
return False
else:
for address in resolved:
SystemRoutingTable.Relations.append([address, gateway])
for bypass in bypassSplit:
if isValidIPAddress(bypass):
logger.info(f'processing user TUN bypass IP: {bypass}')
SystemRoutingTable.Relations.append([bypass, gateway])
else:
logger.error(
f'invalid IP address when processing '
f'user TUN bypass Settings: {bypass}'
)
SystemRoutingTable.Relations.clear()
return False
else:
SystemRoutingTable.Relations.append([address, gateway])
logger.info(
f'automatically fetching TUN settings: '
f'\'BypassTUNAdapterInterfaceIP\''
)
address = configcopy.itemAddress
if not isValidIPAddress(address):
DNSResolver.configureHttpProxy(configcopy.httpProxyEndpoint())
error, resolved = DNSResolver.resolve(address)
if error:
logger.error(f'DNS resolution failed: {address}')
SystemRoutingTable.Relations.clear()
return False
else:
for address in resolved:
SystemRoutingTable.Relations.append([address, gateway])
else:
SystemRoutingTable.Relations.append([address, gateway])
if PLATFORM == 'Windows':
foundDevice = False
@@ -392,7 +473,25 @@ class CoreManager(SupportExitCleanup):
return False
alias = SystemRoutingTable.WIN32GetInterfaceAliasByIP(interfaceIP)
# Handle user defined settings
userInterfaceName = userPrimaryAdapterInterfaceName()
if userInterfaceName:
logger.info(
f'got user defined TUN settings. '
f'\'PrimaryAdapterInterfaceName\': {userInterfaceName}'
)
alias = userInterfaceName
else:
logger.info(
f'automatically fetching TUN settings: '
f'\'PrimaryAdapterInterfaceName\''
)
alias = SystemRoutingTable.WIN32GetInterfaceAliasByIP(
interfaceIP
)
if alias:
@@ -402,14 +501,35 @@ class CoreManager(SupportExitCleanup):
tunProcess.cleanup = functools.partial(_windowsCleanup, alias)
SystemRoutingTable.WIN32SetInterfaceDNS(
alias, '127.0.0.1', False
# Handle user defined settings
userDisableInterfaceDNS = (
userDisablePrimaryAdapterInterfaceDNS()
)
logger.info(
f'DisablePrimaryInterfaceDNS: {userDisableInterfaceDNS}'
)
if userDisableInterfaceDNS != 'False':
SystemRoutingTable.WIN32SetInterfaceDNS(
alias, '127.0.0.1', False
)
# Handle user defined settings
userTunInterfaceDNS = userTunAdapterInterfaceDNS()
if userTunInterfaceDNS == '':
userTunInterfaceDNS = APPLICATION_TUN_INTERFACE_DNS_ADDRESS
else:
logger.info(
f'got user defined TUN settings. '
f'TunAdapterInterfaceDNS: {userTunInterfaceDNS}'
)
SystemRoutingTable.addRelations()
SystemRoutingTable.WIN32SetInterfaceDNS(
APPLICATION_TUN_DEVICE_NAME,
APPLICATION_TUN_INTERFACE_DNS_ADDRESS,
userTunInterfaceDNS,
False,
)
SystemRoutingTable.setDeviceGateway(
@@ -436,10 +556,21 @@ class CoreManager(SupportExitCleanup):
tunProcess.cleanup = functools.partial(_darwinCleanup, servers)
# Handle user defined settings
userTunInterfaceDNS = userTunAdapterInterfaceDNS()
if userTunInterfaceDNS == '':
userTunInterfaceDNS = APPLICATION_TUN_INTERFACE_DNS_ADDRESS
else:
logger.info(
f'got user defined TUN settings. '
f'TunAdapterInterfaceDNS: {userTunInterfaceDNS}'
)
for service, dnsserver in servers:
SystemRoutingTable.DarwinSetDNSServers(
service,
APPLICATION_TUN_INTERFACE_DNS_ADDRESS,
userTunInterfaceDNS,
)
SystemRoutingTable.setDeviceGateway(
+287 -13
View File
@@ -422,7 +422,8 @@ TRANSLATION = {
"Furious.Widget.GuiTrojan",
"Furious.Widget.GuiVLESS",
"Furious.Widget.GuiVMess",
"Furious.Widget.UserServersQTableWidget"
"Furious.Widget.UserServersQTableWidget",
"Furious.Widget.UserSubsQTableWidget"
],
"RU": "Примечание",
"ZH": "别名",
@@ -1015,28 +1016,28 @@ TRANSLATION = {
"ZH": "路由",
"isReviewed": "True"
},
"VPN Mode": {
"TUN Mode": {
"source": [
"Furious.TrayActions.Settings"
],
"RU": "VPN режим",
"ZH": "VPN模式",
"RU": "TUN режим",
"ZH": "TUN模式",
"isReviewed": "True"
},
"VPN Mode Disabled (Administrator)": {
"TUN Mode Disabled (Administrator)": {
"source": [
"Furious.TrayActions.Settings"
],
"RU": "VPN режим отключен (Administrator)",
"ZH": "VPN模式已禁用(管理员)",
"RU": "TUN режим отключен (Administrator)",
"ZH": "TUN模式已禁用(管理员)",
"isReviewed": "True"
},
"VPN Mode Disabled (root)": {
"TUN Mode Disabled (root)": {
"source": [
"Furious.TrayActions.Settings"
],
"RU": "VPN режим отключен (root)",
"ZH": "VPN模式已禁用(root)",
"RU": "TUN режим отключен (root)",
"ZH": "TUN模式已禁用(root)",
"isReviewed": "True"
},
"Settings": {
@@ -1213,6 +1214,7 @@ TRANSLATION = {
"Furious.Widget.GuiHysteria2",
"Furious.Widget.GuiShadowsocks",
"Furious.Widget.GuiTrojan",
"Furious.Widget.GuiTUNSettings",
"Furious.Widget.GuiVLESS",
"Furious.Widget.GuiVMess"
],
@@ -1341,12 +1343,12 @@ TRANSLATION = {
"ZH": "添加Hysteria2服务器...",
"isReviewed": "True"
},
"Routing option with direct rules is not allowed in VPN mode": {
"Routing option with direct rules is not allowed in TUN mode": {
"source": [
"Furious.Core.CoreManager"
],
"RU": "Опция маршрутизации с прямыми правилами не разрешена в режиме VPN",
"ZH": "VPN模式下不允许使用直接规则的路由选项",
"RU": "Опция маршрутизации с прямыми правилами не разрешена в режиме TUN",
"ZH": "TUN模式下不允许使用直接规则的路由选项",
"isReviewed": "True"
},
"Select and press Enter to activate configuration and connect": {
@@ -1375,5 +1377,277 @@ TRANSLATION = {
"RU": "OK",
"ZH": "OK",
"isReviewed": "True"
},
"Advanced...": {
"source": [
"Furious.Widget.UserServersQTableWidget"
],
"RU": "Расширенные...",
"ZH": "高级...",
"isReviewed": "True"
},
"is available to download": {
"source": [
"Furious.TrayActions.Connect"
],
"RU": "доступен для скачивания",
"ZH": "可供下载",
"isReviewed": "True"
},
"Update subscription completed": {
"source": [
"Furious.Widget.UserServersQTableWidget"
],
"RU": "Подписка на обновление завершена",
"ZH": "更新订阅完成",
"isReviewed": "True"
},
"Update subscription failed": {
"source": [
"Furious.Widget.UserServersQTableWidget"
],
"RU": "Обновить подписку не удалось",
"ZH": "更新订阅失败",
"isReviewed": "True"
},
"Configuration has been updated": {
"source": [
"Furious.Widget.UserServersQTableWidget"
],
"RU": "Конфигурация обновлена",
"ZH": "配置已更新",
"isReviewed": "True"
},
"Configuration update failed": {
"source": [
"Furious.Widget.UserServersQTableWidget"
],
"RU": "Обновление конфигурации не удалось",
"ZH": "配置更新失败",
"isReviewed": "True"
},
"Auto Update": {
"source": [
"Furious.Widget.UserSubsQTableWidget"
],
"RU": "Авто обновление",
"ZH": "自动更新",
"isReviewed": "True"
},
"Auto Update Use Proxy": {
"source": [
"Furious.Widget.UserSubsQTableWidget"
],
"RU": "Авто обновление Использовать прокси",
"ZH": "自动更新使用代理",
"isReviewed": "True"
},
"Never": {
"source": [
"Furious.Widget.UserSubsQTableWidget"
],
"RU": "Никогда",
"ZH": "从不",
"isReviewed": "True"
},
"Every 5 mins": {
"source": [
"Furious.Widget.UserSubsQTableWidget"
],
"RU": "Каждые 5 минут",
"ZH": "每5分钟",
"isReviewed": "True"
},
"Every 10 mins": {
"source": [
"Furious.Widget.UserSubsQTableWidget"
],
"RU": "Каждые 10 минут",
"ZH": "每10分钟",
"isReviewed": "True"
},
"Every 15 mins": {
"source": [
"Furious.Widget.UserSubsQTableWidget"
],
"RU": "Каждые 15 минут",
"ZH": "每15分钟",
"isReviewed": "True"
},
"Every 30 mins": {
"source": [
"Furious.Widget.UserSubsQTableWidget"
],
"RU": "Каждые 30 минут",
"ZH": "每30分钟",
"isReviewed": "True"
},
"Every 45 mins": {
"source": [
"Furious.Widget.UserSubsQTableWidget"
],
"RU": "Каждые 45 минут",
"ZH": "每45分钟",
"isReviewed": "True"
},
"Every 1 hour": {
"source": [
"Furious.Widget.UserSubsQTableWidget"
],
"RU": "Каждый 1 час",
"ZH": "每1小时",
"isReviewed": "True"
},
"Every 2 hours": {
"source": [
"Furious.Widget.UserSubsQTableWidget"
],
"RU": "Каждые 2 часа",
"ZH": "每2小时",
"isReviewed": "True"
},
"Every 3 hours": {
"source": [
"Furious.Widget.UserSubsQTableWidget"
],
"RU": "Каждые 3 часа",
"ZH": "每3小时",
"isReviewed": "True"
},
"Every 6 hours": {
"source": [
"Furious.Widget.UserSubsQTableWidget"
],
"RU": "Каждые 6 часа",
"ZH": "每6小时",
"isReviewed": "True"
},
"Every 8 hours": {
"source": [
"Furious.Widget.UserSubsQTableWidget"
],
"RU": "Каждые 8 часа",
"ZH": "每8小时",
"isReviewed": "True"
},
"Every 10 hours": {
"source": [
"Furious.Widget.UserSubsQTableWidget"
],
"RU": "Каждые 10 часа",
"ZH": "每10小时",
"isReviewed": "True"
},
"Every 12 hours": {
"source": [
"Furious.Widget.UserSubsQTableWidget"
],
"RU": "Каждые 12 часа",
"ZH": "每12小时",
"isReviewed": "True"
},
"Every 24 hours": {
"source": [
"Furious.Widget.UserSubsQTableWidget"
],
"RU": "Каждые 24 часа",
"ZH": "每24小时",
"isReviewed": "True"
},
"Use current proxy": {
"source": [
"Furious.Widget.UserSubsQTableWidget"
],
"RU": "Использовать текущий прокси-сервер",
"ZH": "使用当前代理",
"isReviewed": "True"
},
"Force proxy": {
"source": [
"Furious.Widget.UserSubsQTableWidget"
],
"RU": "Обязательно через прокси-сервер",
"ZH": "强制代理",
"isReviewed": "True"
},
"No proxy": {
"source": [
"Furious.Widget.UserSubsQTableWidget"
],
"RU": "Не использовать прокси",
"ZH": "无代理",
"isReviewed": "True"
},
"Customize TUN Settings": {
"source": [
"Furious.Widget.GuiTUNSettings"
],
"RU": "Настройте параметры TUN",
"ZH": "自定义TUN设置",
"isReviewed": "True"
},
"Customize TUN Settings...": {
"source": [
"Furious.Window.AppMainWindow"
],
"RU": "Настройте параметры TUN...",
"ZH": "自定义TUN设置...",
"isReviewed": "True"
},
"Go to help page": {
"source": [
"Furious.Widget.GuiTUNSettings"
],
"RU": "Перейти на страницу помощи",
"ZH": "转到帮助页面",
"isReviewed": "True"
},
"Primary Adapter Interface Name": {
"source": [
"Furious.Widget.GuiTUNSettings"
],
"RU": "Имя основного сетевого адаптера",
"ZH": "主适配器接口名称",
"isReviewed": "True"
},
"Primary Adapter Interface IP": {
"source": [
"Furious.Widget.GuiTUNSettings"
],
"RU": "IP-адрес основного сетевого адаптера",
"ZH": "主适配器接口IP",
"isReviewed": "True"
},
"Default Primary Gateway IP": {
"source": [
"Furious.Widget.GuiTUNSettings"
],
"RU": "IP-адрес основного шлюза по умолчанию",
"ZH": "默认主网关IP",
"isReviewed": "True"
},
"TUN Adapter Interface DNS": {
"source": [
"Furious.Widget.GuiTUNSettings"
],
"RU": "DNS-адрес интерфейса адаптера TUN",
"ZH": "TUN适配器接口DNS",
"isReviewed": "True"
},
"Bypass TUN adapter interface IP (separated by commas)": {
"source": [
"Furious.Widget.GuiTUNSettings"
],
"RU": "Обход IP-адреса интерфейса адаптера TUN (через запятую)",
"ZH": "绕过TUN适配器接口IP(以逗号分割)",
"isReviewed": "True"
},
"Disable Primary Adapter Interface DNS (Mitigating DNS leaks on Windows)": {
"source": [
"Furious.Widget.GuiTUNSettings"
],
"RU": "Отключить DNS основного сетевого адаптера (для предотвращения утечек DNS в Windows)",
"ZH": "禁用主适配器接口DNS(在Windows上缓解DNS泄露)",
"isReviewed": "True"
}
}
@@ -47,6 +47,8 @@ class ConfigurationFactory(UserServersTableItem, dict, ABC):
:param config: The input configuration. Can be a string or dict
"""
self._index = kwargs.pop('index', 0)
# Extra attributes
self.kwargs = kwargs
@@ -96,6 +98,12 @@ class ConfigurationFactory(UserServersTableItem, dict, ABC):
def setExtras(self, item, value):
self.kwargs[item] = value
def index(self) -> int:
return self._index
def setIndex(self, index):
self._index = index
@property
def itemRemark(self) -> str:
return self.getExtras('remark')
+12 -27
View File
@@ -27,15 +27,7 @@ import ujson
__all__ = ['CoreFactory']
class CoreFactorySuper:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
ExitCallbackType = Callable[[CoreFactorySuper, int], None]
class CoreFactory(CoreFactorySuper, ABC):
class CoreFactory(ABC):
class ExitCode:
ConfigurationError = 23
# Windows: 4294967295. Darwin, Linux: 255 (-1)
@@ -43,21 +35,14 @@ class CoreFactory(CoreFactorySuper, ABC):
# Windows shutting down
SystemShuttingDown = 0x40010004
def __init__(self, exitCallback: ExitCallbackType = None):
def __init__(
self, exitCallback: Union[Callable[[CoreFactory, int], None], None] = None
):
super().__init__()
self._exitCallback = exitCallback
self._jsonConfig = ''
@property
def exitCallback(self) -> ExitCallbackType:
return self._exitCallback
def registerExitCallback(self, exitCallback: ExitCallbackType) -> CoreFactory:
self._exitCallback = exitCallback
return self
def callExitCallback(self, exitcode: int):
if callable(self._exitCallback):
self._exitCallback(self, exitcode)
@@ -67,14 +52,6 @@ class CoreFactory(CoreFactorySuper, ABC):
return self
@staticmethod
def name() -> str:
raise NotImplementedError
@staticmethod
def version() -> str:
raise NotImplementedError
def configJSONString(self) -> str:
if isinstance(self._jsonConfig, str):
return self._jsonConfig
@@ -105,6 +82,14 @@ class CoreFactory(CoreFactorySuper, ABC):
return {}
@staticmethod
def name() -> str:
raise NotImplementedError
@staticmethod
def version() -> str:
raise NotImplementedError
def start(self, *args, **kwargs) -> bool:
raise NotImplementedError
+2 -4
View File
@@ -17,8 +17,6 @@
from __future__ import annotations
from Furious.Interface.ConfigurationFactory import ConfigurationFactory
from typing import Any
__all__ = ['GuiEditorItemFactory', 'GuiEditorItemWidgetContainer']
@@ -28,10 +26,10 @@ class GuiEditorItemFactory:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def inputToFactory(self, config: ConfigurationFactory) -> Any:
def inputToFactory(self, *args, **kwargs) -> Any:
raise NotImplementedError
def factoryToInput(self, config: ConfigurationFactory) -> Any:
def factoryToInput(self, *args, **kwargs) -> Any:
raise NotImplementedError
-55
View File
@@ -1,55 +0,0 @@
# Copyright (C) 2024 Loren Eteval <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/>.
from __future__ import annotations
from typing import Any, Sequence
__all__ = ['ItemUpdateProtocol']
class ItemUpdateProtocol:
def __init__(self, sequence: Sequence, currentIndex: int, currentItem: Any):
self.sequence = sequence
self.currentIndex = currentIndex
self.currentItem = currentItem
def currentItemDeleted(self, *args, **kwargs) -> bool:
return self.currentIndex < 0 or self.currentIndex >= len(self.sequence)
def updateImpl(self, *args, **kwargs):
raise NotImplementedError
def updateResult(self):
if self.currentItemDeleted():
# Deleted. Do nothing
return
if id(self.sequence[self.currentIndex]) == id(self.currentItem):
self.updateImpl()
else:
# Linear find and update
for index, item in enumerate(self.sequence):
if id(item) == id(self.currentItem):
# Found. Update index
self.currentIndex = index
self.updateImpl()
# Updated
break
# Not found. Do nothing
-1
View File
@@ -18,7 +18,6 @@
from .Encoder import *
from .GuiEditorItemFactory import *
from .UserServersTableItem import *
from .ItemUpdateProtocol import *
from .Storage import *
from .Application import *
from .ConfigurationFactory import *
+39 -41
View File
@@ -19,19 +19,20 @@ from __future__ import annotations
from PySide6.QtWidgets import QApplication
from typing import Any
import logging
logger = logging.getLogger(__name__)
__all__ = [
'Translatable',
'SupportConnectedCallback',
'SupportThemeChangedCallback',
'SupportExitCleanup',
'SupportImplicitReference',
'FastItemDeletionSearch',
'GarbageCollector',
]
import logging
logger = logging.getLogger(__name__)
class Translatable:
ObjectsPool = list()
@@ -48,11 +49,11 @@ class Translatable:
@staticmethod
def retranslateAll():
for ob in Translatable.ObjectsPool:
assert isinstance(ob, Translatable)
for object_ in Translatable.ObjectsPool:
assert isinstance(object_, Translatable)
if ob.translatable:
ob.retranslate()
if object_.translatable:
object_.retranslate()
class SupportConnectedCallback:
@@ -71,17 +72,17 @@ class SupportConnectedCallback:
@staticmethod
def callConnectedCallback():
for ob in SupportConnectedCallback.ObjectsPool:
assert isinstance(ob, SupportConnectedCallback)
for object_ in SupportConnectedCallback.ObjectsPool:
assert isinstance(object_, SupportConnectedCallback)
ob.connectedCallback()
object_.connectedCallback()
@staticmethod
def callDisconnectedCallback():
for ob in SupportConnectedCallback.ObjectsPool:
assert isinstance(ob, SupportConnectedCallback)
for object_ in SupportConnectedCallback.ObjectsPool:
assert isinstance(object_, SupportConnectedCallback)
ob.disconnectedCallback()
object_.disconnectedCallback()
class SupportThemeChangedCallback:
@@ -97,10 +98,10 @@ class SupportThemeChangedCallback:
@staticmethod
def callThemeChangedCallbackUnchecked(theme: str):
for ob in SupportThemeChangedCallback.ObjectsPool:
assert isinstance(ob, SupportThemeChangedCallback)
for object_ in SupportThemeChangedCallback.ObjectsPool:
assert isinstance(object_, SupportThemeChangedCallback)
ob.themeChangedCallback(theme)
object_.themeChangedCallback(theme)
@staticmethod
def callThemeChangedCallback(theme: str):
@@ -138,40 +139,37 @@ class SupportExitCleanup:
@staticmethod
def cleanupAll():
for ob in SupportExitCleanup.ObjectsPool:
assert isinstance(ob, SupportExitCleanup)
for object_ in SupportExitCleanup.ObjectsPool:
assert isinstance(object_, SupportExitCleanup)
if ob.uniqueCleanup:
obtype = str(type(ob))
if object_.uniqueCleanup:
obtype = str(type(object_))
if not SupportExitCleanup.VisitedType.get(obtype, False):
ob.cleanup()
object_.cleanup()
SupportExitCleanup.VisitedType[obtype] = True
else:
pass
else:
ob.cleanup()
object_.cleanup()
class SupportImplicitReference:
class GarbageCollector:
ObjectsPool = list()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
SupportImplicitReference.ObjectsPool.append(self)
class FastItemDeletionSearch:
DeletedItem = list()
DeletedId = dict()
# ObjectsId is used to speed up searching
ObjectsId = dict()
@staticmethod
def moveToTrash(item):
FastItemDeletionSearch.DeletedItem.append(item)
FastItemDeletionSearch.DeletedId[id(item)] = True
def track(object_: Any):
GarbageCollector.ObjectsPool.append(object_)
GarbageCollector.ObjectsId[id(object_)] = True
@staticmethod
def isInTrash(item) -> bool:
return id(item) in FastItemDeletionSearch.DeletedId
def isTracked(object_: Any) -> bool:
return id(object_) in GarbageCollector.ObjectsId
@staticmethod
def clear():
GarbageCollector.ObjectsPool.clear()
GarbageCollector.ObjectsId.clear()
+26 -9
View File
@@ -15,36 +15,38 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from Furious.PyFramework import *
__all__ = ['QBlockSignals', 'QTranslatable']
from PySide6 import QtCore
__all__ = ['QBlockSignals', 'QTranslatable', 'QGarbageCollector']
class QProtection:
def __init__(self, qobject):
def __init__(self, qobject: QtCore.QObject):
self.qobject = qobject
self.hasattr = hasattr(self.qobject, 'setDisabled')
def __enter__(self):
if self.hasattr:
if hasattr(self.qobject, 'setDisabled'):
self.qobject.setDisabled(True)
def __exit__(self, exceptionType, exceptionValue, tb):
if self.hasattr:
if hasattr(self.qobject, 'setDisabled'):
self.qobject.setDisabled(False)
class QBlockSignals:
def __init__(self, qobject):
def __init__(self, qobject: QtCore.QObject):
self.qobject = qobject
self.hasattr = hasattr(self.qobject, 'blockSignals')
def __enter__(self):
if self.hasattr:
if hasattr(self.qobject, 'blockSignals'):
self.qobject.blockSignals(True)
def __exit__(self, exceptionType, exceptionValue, tb):
if self.hasattr:
if hasattr(self.qobject, 'blockSignals'):
self.qobject.blockSignals(False)
@@ -64,7 +66,22 @@ class QTranslatable(Translatable):
if ob.translatable:
if ob.useQProtection:
assert isinstance(ob, QtCore.QObject)
with QProtection(ob):
ob.retranslate()
else:
ob.retranslate()
class QGarbageCollector(GarbageCollector):
Timer = QtCore.QTimer()
@staticmethod
def isEnabled():
return QGarbageCollector.Timer.isActive()
@staticmethod
def collect(timeout: int):
QGarbageCollector.Timer.timeout.connect(QGarbageCollector.clear)
QGarbageCollector.Timer.start(timeout)
+48 -51
View File
@@ -17,6 +17,7 @@
from __future__ import annotations
from Furious.QtFramework.WebGETManager import *
from Furious.Utility import *
from Furious.Library import *
@@ -32,8 +33,11 @@ __all__ = ['DNSResolver']
logger = logging.getLogger(__name__)
class DNSResolver:
Manager = QNetworkAccessManager()
class _DNSResolver(WebGETManager):
def __init__(self, parent=None, **kwargs):
actionMessage = kwargs.pop('actionMessage', 'DNS resolution')
super().__init__(parent, actionMessage=actionMessage)
@staticmethod
def request(address) -> QNetworkRequest:
@@ -44,22 +48,23 @@ class DNSResolver:
return request
@staticmethod
def handleFinishedByNetworkReply(networkReply, domain, resultMap):
assert isinstance(networkReply, QNetworkReply)
def successCallback(self, networkReply, **kwargs):
domain = kwargs.pop('domain', '')
resultMap = kwargs.pop('resultMap', {})
if networkReply.error() != QNetworkReply.NetworkError.NoError:
data = networkReply.readAll().data()
try:
replyObject = UJSONEncoder.decode(data)
except Exception as ex:
logger.error(
f'DNS resolution for \'{domain}\' failed. {networkReply.errorString()}'
f'bad network reply while resolving DNS for \'{domain}\'. {ex}'
)
resultMap['error'] = True
else:
logger.info(f'DNS resolution for \'{domain}\' success')
# Unchecked?
replyObject = UJSONEncoder.decode(networkReply.readAll().data())
for record in replyObject['Answer']:
address = record['data']
@@ -70,46 +75,31 @@ class DNSResolver:
else:
resultMap['depth'] += 1
newNetworkReply = DNSResolver.Manager.get(
DNSResolver.request(address)
)
newNetworkReply.finished.connect(
functools.partial(
DNSResolver.handleFinishedByNetworkReply,
newNetworkReply,
address,
resultMap,
)
newNetworkReply = self.webGET(
self.request(address),
logActionMessage=False,
domain=address,
resultMap=resultMap,
)
resultMap['reference'].append(newNetworkReply)
resultMap['depth'] -= 1
@staticmethod
def resolve(domain, proxyHost=None, proxyPort=None) -> Tuple[bool, list[str]]:
if proxyHost is None or proxyPort is None:
DNSResolver.Manager.setProxy(QNetworkProxy.ProxyType.NoProxy)
else:
try:
DNSResolver.Manager.setProxy(
QNetworkProxy(
QNetworkProxy.ProxyType.HttpProxy, proxyHost, int(proxyPort)
)
)
def failureCallback(self, networkReply: QNetworkReply, **kwargs):
domain = kwargs.pop('domain', '')
resultMap = kwargs.pop('resultMap', {})
logger.info(f'DNS resolution uses proxy server {proxyHost}:{proxyPort}')
except Exception as ex:
# Any non-exit exceptions
logger.error(
f'DNS resolution for \'{domain}\' failed. {networkReply.errorString()}'
)
logger.error(
f'invalid proxy server {proxyHost}:{proxyPort}. {ex}. '
f'DNS resolution uses no proxy'
)
DNSResolver.Manager.setProxy(QNetworkProxy.ProxyType.NoProxy)
resultMap['error'] = True
resultMap['depth'] -= 1
def resolve(self, domain, timeout=30000) -> Tuple[bool, list[str]]:
resultMap = {
'domain': domain,
'depth': 0,
'error': False,
'reference': [],
@@ -118,26 +108,28 @@ class DNSResolver:
resultMap['depth'] += 1
networkReply = DNSResolver.Manager.get(DNSResolver.request(domain))
networkReply.finished.connect(
functools.partial(
DNSResolver.handleFinishedByNetworkReply,
networkReply,
domain,
resultMap,
)
networkReply = self.webGET(
self.request(domain),
logActionMessage=False,
domain=domain,
resultMap=resultMap,
)
resultMap['reference'].append(networkReply)
DNSResolver.wait(resultMap)
self.wait(resultMap, timeout=timeout)
return resultMap['error'], list(resultMap['result'].keys())
@staticmethod
def wait(resultMap, startCounter=0, timeout=30000, step=100):
domain = resultMap.get('domain', '')
if not domain:
return
if resultMap['depth'] != 0:
logger.info('DNS resolution in progress. Wait')
logger.info(f'DNS resolution for \'{domain}\' in progress. Wait')
else:
return
@@ -147,7 +139,9 @@ class DNSResolver:
startCounter += step
if resultMap['depth'] != 0:
logger.error('DNS resolution timeout')
logger.error(
f'DNS resolution for \'{domain}\' reached timeout {timeout // 1000}s'
)
for networkReply in resultMap['reference']:
if (
@@ -155,3 +149,6 @@ class DNSResolver:
and not networkReply.isFinished()
):
networkReply.abort()
DNSResolver = _DNSResolver()
+1 -1
View File
@@ -95,7 +95,7 @@ class GuiEditorItemTextComboBox(GuiEditorItemWidgetContainer):
super().__init__(*args, **kwargs)
self._title = AppQLabel(_(title), translatable=translatable, parent=parent)
self._input = QComboBox(parent=parent)
self._input = AppQComboBox(parent=parent, translatable=False)
def text(self) -> str:
return self._input.currentText()
+27 -48
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/>.
from __future__ import annotations
from Furious.PyFramework import *
from Furious.QtFramework.QtNetwork import *
from Furious.QtFramework.WebGETManager import *
from Furious.Utility import *
from PySide6 import QtCore
@@ -30,12 +32,14 @@ __all__ = ['NetworkStateManager']
logger = logging.getLogger(__name__)
class NetworkStateManager(SupportConnectedCallback, AppQNetworkAccessManager):
class NetworkStateManager(SupportConnectedCallback, WebGETManager):
MIN_JOB_INTERVAL = 2500
MAX_JOB_INTERVAL = 2000000000
def __init__(self, parent=None):
super().__init__(parent)
def __init__(self, parent=None, **kwargs):
actionMessage = kwargs.pop('actionMessage', 'test network state')
super().__init__(parent, actionMessage=actionMessage)
self.jobStatus = False
self.jobInterval = NetworkStateManager.MIN_JOB_INTERVAL
@@ -45,58 +49,32 @@ class NetworkStateManager(SupportConnectedCallback, AppQNetworkAccessManager):
self.jobArrangeTimer.timeout.connect(lambda: self.startSingleTest())
def successCallback(self):
raise NotImplementedError
def recalculateJobInterval(self, jobStatus: bool) -> int:
assert isinstance(jobStatus, bool)
def errorCallback(self, errorString: str):
raise NotImplementedError
def handleFinishedByNetworkReply(self, networkReply):
assert isinstance(networkReply, QNetworkReply)
if networkReply.error() != QNetworkReply.NetworkError.NoError:
self.jobTimeoutTimer.stop()
errorString = networkReply.errorString()
logger.error(f'connection test failed. {errorString}')
self.errorCallback(errorString)
if self.jobStatus is False:
self.jobInterval *= 2
else:
self.jobInterval = NetworkStateManager.MIN_JOB_INTERVAL
self.jobStatus = False
if self.jobStatus is jobStatus:
self.jobInterval *= 2
else:
self.jobTimeoutTimer.stop()
self.jobInterval = NetworkStateManager.MIN_JOB_INTERVAL
logger.info(f'connection test success')
self.successCallback()
if self.jobStatus is True:
self.jobInterval *= 2
else:
self.jobInterval = NetworkStateManager.MIN_JOB_INTERVAL
self.jobStatus = True
self.jobStatus = jobStatus
if self.jobInterval >= NetworkStateManager.MAX_JOB_INTERVAL:
# Limited
self.jobInterval = NetworkStateManager.MAX_JOB_INTERVAL
self.jobArrangeTimer.start(self.jobInterval)
return self.jobInterval
def successCallback(self, networkReply, **kwargs):
self.jobTimeoutTimer.stop()
self.jobArrangeTimer.start(self.recalculateJobInterval(jobStatus=True))
def failureCallback(self, networkReply, **kwargs):
self.jobTimeoutTimer.stop()
self.jobArrangeTimer.start(self.recalculateJobInterval(jobStatus=False))
def startSingleTest(self):
networkReply = self.get(QNetworkRequest(QtCore.QUrl(NETWORK_STATE_TEST_URL)))
networkReply.finished.connect(
functools.partial(
self.handleFinishedByNetworkReply,
networkReply,
)
)
networkReply = self.webGET(NETWORK_STATE_TEST_URL)
def abort(_networkReply):
if isinstance(_networkReply, QNetworkReply):
@@ -107,17 +85,18 @@ class NetworkStateManager(SupportConnectedCallback, AppQNetworkAccessManager):
def stopTest(self):
self.jobArrangeTimer.stop()
self.jobTimeoutTimer.stop()
def connectedCallback(self):
if AppSettings.isStateON_('PowerSaveMode'):
# Power optimization
logger.info('no job for network state manager in power save mode')
self.jobArrangeTimer.stop()
self.stopTest()
else:
self.jobInterval = NetworkStateManager.MIN_JOB_INTERVAL
self.jobArrangeTimer.start(self.jobInterval)
def disconnectedCallback(self):
self.jobArrangeTimer.stop()
self.stopTest()
+13 -6
View File
@@ -34,6 +34,7 @@ import functools
__all__ = [
'moveToCenter',
'AppQCheckBox',
'AppQComboBox',
'AppQDialog',
'AppQDialogButtonBox',
'AppQGroupBox',
@@ -77,6 +78,15 @@ class AppQCheckBox(QTranslatable, QCheckBox):
self.setText(_(self.text()))
class AppQComboBox(QTranslatable, QComboBox):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def retranslate(self):
for index in range(self.count()):
self.setItemText(index, _(self.itemText(index)))
class AppQDialog(QTranslatable, SupportConnectedCallback, QDialog):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -176,7 +186,7 @@ class AppQHeaderView(SupportExitCleanup, SupportConnectedCallback, QHeaderView):
self.sectionSizeTable[str(column)] = self.defaultSectionSize()
with QBlockSignals(self):
for key, value in self.sectionSizeTable.items():
for key, value in reversed(self.sectionSizeTable.items()):
self.resizeSection(int(key), value)
except Exception:
# Any non-exit exceptions
@@ -293,15 +303,12 @@ class AppQMainWindow(
moveToCenter(self)
APP().processEvents()
if PLATFORM == 'Darwin':
self.activateWindow()
self.raise_()
def closeEvent(self, event):
event.ignore()
self.hide()
def retranslate(self):
self.setWindowTitle(_(self.windowTitle()))
+3 -4
View File
@@ -18,10 +18,7 @@
from __future__ import annotations
from Furious.PyFramework import *
from Furious.QtFramework.TextEditorTheme import (
DraculaEditorTheme,
DraculaJSONSyntaxHighlighter,
)
from Furious.QtFramework.TextEditorTheme import *
from Furious.Utility import *
from PySide6 import QtCore
@@ -314,6 +311,8 @@ class DraculaTextBrowser(AppQTextBrowser):
super().__init__(*args, **kwargs)
self._syntaxHighlighter = DraculaLoggerSyntaxHighlighter(self.document())
# Theme
self.setStyleSheet(
DraculaEditorTheme.getStyleSheet(
+111 -17
View File
@@ -15,14 +15,22 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from Furious.Core import *
from PySide6 import QtCore
from PySide6.QtGui import *
__all__ = ['DraculaEditorTheme', 'DraculaJSONSyntaxHighlighter']
__all__ = [
'DraculaEditorTheme',
'DraculaJSONSyntaxHighlighter',
'DraculaLoggerSyntaxHighlighter',
]
class EditorHighlightRules:
def __init__(self, regex, color, isBold=False, isJSONKey=False):
def __init__(self, regex, color, isBold=False, isItalic=False, isJSONKey=False):
self.regex = QtCore.QRegularExpression(regex)
self.color = QColor(color)
@@ -32,6 +40,9 @@ class EditorHighlightRules:
if isBold:
self.rules.setFontWeight(QFont.Weight.Bold)
if isItalic:
self.rules.setFontItalic(True)
self.isJSONKey = isJSONKey
@@ -59,7 +70,46 @@ class DraculaEditorTheme(EditorTheme):
)
class DraculaJSONSyntaxHighlighter(QSyntaxHighlighter):
class AppQSyntaxHighlighter(QSyntaxHighlighter):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.highlightRules = list()
def highlightBlock(self, text):
for highlightRule in self.highlightRules:
iterator = highlightRule.regex.globalMatch(text)
while iterator.hasNext():
match = iterator.next()
if highlightRule.isJSONKey:
# JSON keys. Ignore trailing :
capturedLength = match.capturedLength() - 1
else:
capturedLength = match.capturedLength()
capturedStart = match.capturedStart()
captured = text[capturedStart : capturedStart + capturedLength]
shouldHighlight = True
for version in [
XrayCore.version(),
Hysteria1.version(),
Hysteria2.version(),
Tun2socks.version(),
]:
if captured == version:
# These x.y.z.u version values are not IPv4 addresses. Do not highlight
shouldHighlight = False
break
if shouldHighlight:
self.setFormat(capturedStart, capturedLength, highlightRule.rules)
class DraculaJSONSyntaxHighlighter(AppQSyntaxHighlighter):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -77,22 +127,66 @@ class DraculaJSONSyntaxHighlighter(QSyntaxHighlighter):
r'"([^"\\]*(\\.[^"\\]*)*)"\s*:', '#50FA7B', isJSONKey=True
),
# Comments(only for hints on display). Grey
EditorHighlightRules(r'^#.*', '#6272a4'),
EditorHighlightRules(r'^#.*', '#6272A4'),
]
def highlightBlock(self, text):
for highlightRule in self.highlightRules:
iterator = highlightRule.regex.globalMatch(text)
while iterator.hasNext():
match = iterator.next()
class DraculaLoggerSyntaxHighlighter(AppQSyntaxHighlighter):
# https://ihateregex.io/expr/ip/
IPV4_REGEX = (
r'(\b25[0-5]|\b2[0-4][0-9]|\b[01]?[0-9][0-9]?)'
r'(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}(?::\d{1,5})?\b'
)
# https://vernon.mauery.com/content/2008/04/21/ipv6-regex/
IPV6_REGEX = (
r'(A([0-9a-f]{1,4}:){1,1}(:[0-9a-f]{1,4}){1,6}Z)|'
r'(A([0-9a-f]{1,4}:){1,2}(:[0-9a-f]{1,4}){1,5}Z)|'
r'(A([0-9a-f]{1,4}:){1,3}(:[0-9a-f]{1,4}){1,4}Z)|'
r'(A([0-9a-f]{1,4}:){1,4}(:[0-9a-f]{1,4}){1,3}Z)|'
r'(A([0-9a-f]{1,4}:){1,5}(:[0-9a-f]{1,4}){1,2}Z)|'
r'(A([0-9a-f]{1,4}:){1,6}(:[0-9a-f]{1,4}){1,1}Z)|'
r'(A(([0-9a-f]{1,4}:){1,7}|:):Z)|'
r'(A:(:[0-9a-f]{1,4}){1,7}Z)|'
r'(A((([0-9a-f]{1,4}:){6})(25[0-5]|2[0-4]d|[0-1]?d?d)(.(25[0-5]|2[0-4]d|[0-1]?d?d)){3})Z)|'
r'(A(([0-9a-f]{1,4}:){5}[0-9a-f]{1,4}:(25[0-5]|2[0-4]d|[0-1]?d?d)(.(25[0-5]|2[0-4]d|[0-1]?d?d)){3})Z)|'
r'(A([0-9a-f]{1,4}:){5}:[0-9a-f]{1,4}:(25[0-5]|2[0-4]d|[0-1]?d?d)(.(25[0-5]|2[0-4]d|[0-1]?d?d)){3}Z)|'
r'(A([0-9a-f]{1,4}:){1,1}(:[0-9a-f]{1,4}){1,4}:(25[0-5]|2[0-4]d|[0-1]?d?d)(.(25[0-5]|2[0-4]d|[0-1]?d?d)){3}Z)|'
r'(A([0-9a-f]{1,4}:){1,2}(:[0-9a-f]{1,4}){1,3}:(25[0-5]|2[0-4]d|[0-1]?d?d)(.(25[0-5]|2[0-4]d|[0-1]?d?d)){3}Z)|'
r'(A([0-9a-f]{1,4}:){1,3}(:[0-9a-f]{1,4}){1,2}:(25[0-5]|2[0-4]d|[0-1]?d?d)(.(25[0-5]|2[0-4]d|[0-1]?d?d)){3}Z)|'
r'(A([0-9a-f]{1,4}:){1,4}(:[0-9a-f]{1,4}){1,1}:(25[0-5]|2[0-4]d|[0-1]?d?d)(.(25[0-5]|2[0-4]d|[0-1]?d?d)){3}Z)|'
r'(A(([0-9a-f]{1,4}:){1,5}|:):(25[0-5]|2[0-4]d|[0-1]?d?d)(.(25[0-5]|2[0-4]d|[0-1]?d?d)){3}Z)|'
r'(A:(:[0-9a-f]{1,4}){1,5}:(25[0-5]|2[0-4]d|[0-1]?d?d)(.(25[0-5]|2[0-4]d|[0-1]?d?d)){3}Z)(?::\d{1,5})?'
)
if highlightRule.isJSONKey:
# JSON keys. Ignore trailing :
capturedLength = match.capturedLength() - 1
else:
capturedLength = match.capturedLength()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setFormat(
match.capturedStart(), capturedLength, highlightRule.rules
)
self.highlightRules = [
# IP addresses (IPv4 & IPv6)
EditorHighlightRules(
# https://ihateregex.io/expr/ip/
DraculaLoggerSyntaxHighlighter.IPV4_REGEX
+ r'|'
+ DraculaLoggerSyntaxHighlighter.IPV6_REGEX,
'#B4FFFF',
),
# URLs
EditorHighlightRules(r'(https?:)?//[^\s,\'"\]\)\(<>]+', '#FFDD88'),
# Timestamps
EditorHighlightRules(
r'\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3}\]', '#7F7F7F'
),
# Logger name: [X.Y.Z]
EditorHighlightRules(
r'\[[A-Za-z0-9_]+\.[A-Za-z0-9_]+\.[A-Za-z0-9_]+\]', '#FFB46E'
),
# Log levels
EditorHighlightRules(r'\[INFO\]', '#A0C882', isBold=True),
EditorHighlightRules(r'\[DEBUG\]', '#82B4FF', isBold=True),
EditorHighlightRules(r'\[WARNING\]', '#FFD75A', isBold=True),
EditorHighlightRules(r'\[ERROR\]', '#FF7878', isBold=True),
EditorHighlightRules(r'\[CRITICAL\]', '#FF5050', isBold=True),
# Quoted strings (single or double)
EditorHighlightRules(r"'[^']*'", '#A0FFA0'),
EditorHighlightRules(r'"[^"]*"', '#A0FFA0'),
]
+58 -47
View File
@@ -20,6 +20,7 @@ from __future__ import annotations
from Furious.QtFramework.QtNetwork import *
from Furious.QtFramework.QtWidgets import *
from Furious.QtFramework.DynamicTranslate import gettext as _
from Furious.QtFramework.WebGETManager import *
from Furious.Utility import *
from Furious.Library import *
@@ -27,10 +28,9 @@ from PySide6 import QtCore
from PySide6.QtGui import *
from PySide6.QtNetwork import *
from typing import Union
from typing import Union, Callable
import logging
import operator
import functools
__all__ = ['UpdatesManager']
@@ -61,34 +61,36 @@ class QuestionUpdateMBox(AppQMessageBox):
self.moveToCenter()
class UpdatesManager(AppQNetworkAccessManager):
class UpdatesManager(WebGETManager):
API_URL = (
f'https://api.github.com/repos/'
f'{APPLICATION_REPO_OWNER_NAME}/{APPLICATION_REPO_NAME}/releases/latest'
)
def __init__(self, parent=None):
super().__init__(parent)
def __init__(self, parent=None, **kwargs):
actionMessage = kwargs.pop('actionMessage', 'check for updates')
def handleFinishedByNetworkReply(self, networkReply):
assert isinstance(networkReply, QNetworkReply)
super().__init__(parent, actionMessage=actionMessage)
if networkReply.error() != QNetworkReply.NetworkError.NoError:
logger.error(f'check for updates failed. {networkReply.errorString()}')
def successCallback(self, networkReply, **kwargs):
showMessageBox = kwargs.pop('showMessageBox', True)
hasNewVersionCallback = kwargs.pop('hasNewVersionCallback', None)
mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Critical)
mbox.setWindowTitle(_(APPLICATION_NAME))
mbox.setText(_('Check for updates failed'))
data = networkReply.readAll().data()
# Show the MessageBox asynchronously
mbox.open()
try:
info = UJSONEncoder.decode(data)
except Exception as ex:
# Any non-exit exceptions
logger.error(f'bad network reply while checking for updates. {ex}')
if showMessageBox:
self.showErrorMessageBox()
else:
logger.info('check for updates success')
newVersion = info['tag_name']
# Unchecked?
info = UJSONEncoder.decode(networkReply.readAll().data())
if versionToValue(info['tag_name']) > versionToValue(APPLICATION_VERSION):
if versionToValue(newVersion) > versionToValue(APPLICATION_VERSION):
def handleResultCode(code):
if code == PySide6LegacyEnumValueWrapper(
@@ -102,40 +104,49 @@ class UpdatesManager(AppQNetworkAccessManager):
# Do nothing
pass
mbox = QuestionUpdateMBox(icon=AppQMessageBox.Icon.Information)
mbox.version = info['tag_name']
mbox.setText(mbox.customText())
mbox.setInformativeText(_('Go to download page?'))
mbox.finished.connect(functools.partial(handleResultCode))
if callable(hasNewVersionCallback):
hasNewVersionCallback(newVersion)
# dummy ref
setattr(self, '_questionUpdateMBox', mbox)
if showMessageBox:
mbox = QuestionUpdateMBox(icon=AppQMessageBox.Icon.Information)
mbox.version = newVersion
mbox.setText(mbox.customText())
mbox.setInformativeText(_('Go to download page?'))
mbox.finished.connect(functools.partial(handleResultCode))
# Show the MessageBox asynchronously
mbox.open()
# Show the MessageBox asynchronously
mbox.open()
else:
mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Information)
mbox.setWindowTitle(_(APPLICATION_NAME))
mbox.setText(_(f'{APPLICATION_NAME} is already the latest version'))
if showMessageBox:
mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Information)
mbox.setWindowTitle(_(APPLICATION_NAME))
mbox.setText(_(f'{APPLICATION_NAME} is already the latest version'))
# Show the MessageBox asynchronously
mbox.open()
# Show the MessageBox asynchronously
mbox.open()
def configureHttpProxy(self, httpProxy: Union[str, None]) -> bool:
useProxy = super().configureHttpProxy(httpProxy)
@staticmethod
def showErrorMessageBox():
mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Critical)
mbox.setWindowTitle(_(APPLICATION_NAME))
mbox.setText(_('Check for updates failed'))
if useProxy:
logger.info(f'check for updates uses proxy server {httpProxy}')
else:
logger.info(f'check for updates uses no proxy')
# Show the MessageBox asynchronously
mbox.open()
return useProxy
def failureCallback(self, networkReply, **kwargs):
showMessageBox = kwargs.pop('showMessageBox', True)
def checkForUpdates(self):
networkReply = self.get(QNetworkRequest(QtCore.QUrl(self.API_URL)))
networkReply.finished.connect(
functools.partial(
self.handleFinishedByNetworkReply,
networkReply,
)
if showMessageBox:
self.showErrorMessageBox()
def checkForUpdates(
self,
showMessageBox=True,
hasNewVersionCallback: Callable[[str], None] = None,
):
self.webGET(
self.API_URL,
showMessageBox=showMessageBox,
hasNewVersionCallback=hasNewVersionCallback,
)
+112
View File
@@ -0,0 +1,112 @@
# Copyright (C) 2024 Loren Eteval <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/>.
from __future__ import annotations
from Furious.QtFramework.QtNetwork import *
from Furious.QtFramework.QtWidgets import *
from Furious.QtFramework.DynamicTranslate import gettext as _
from Furious.Utility import *
from Furious.Library import *
from PySide6 import QtCore
from PySide6.QtGui import *
from PySide6.QtNetwork import *
from typing import AnyStr, Union, Callable
import logging
import functools
__all__ = ['WebGETManager']
logger = logging.getLogger(__name__)
class WebGETManager(AppQNetworkAccessManager):
def __init__(self, parent=None, actionMessage='web GET'):
super().__init__(parent)
self.actionMessage = actionMessage
def successCallback(self, networkReply: QNetworkReply, **kwargs):
pass
def hasDataCallback(self, networkReply: QNetworkReply, **kwargs):
pass
def failureCallback(self, networkReply: QNetworkReply, **kwargs):
pass
def handleReadyReadByNetworkReply(self, networkReply: QNetworkReply, **kwargs):
self.hasDataCallback(networkReply, **kwargs)
def handleFinishedByNetworkReply(self, networkReply: QNetworkReply, **kwargs):
if not isinstance(networkReply, QNetworkReply):
# Some PySide6 version does not have networkReply as
# QNetworkReply instance, so assertion is not used here
logger.error(f'QNetworkReply error in PySide6 {PYSIDE6_VERSION} version')
return
logActionMessage = kwargs.pop('logActionMessage', True)
if networkReply.error() != QNetworkReply.NetworkError.NoError:
if logActionMessage:
logger.error(
f'{self.actionMessage} failed. {networkReply.errorString()}'
)
self.failureCallback(networkReply, **kwargs)
else:
if logActionMessage:
logger.info(f'{self.actionMessage} success')
self.successCallback(networkReply, **kwargs)
def configureHttpProxy(self, httpProxy: Union[str, None]) -> bool:
useProxy = super().configureHttpProxy(httpProxy)
if useProxy:
logger.info(f'{self.actionMessage} uses proxy server {httpProxy}')
else:
logger.info(f'{self.actionMessage} uses no proxy')
return useProxy
def webGET(self, request: Union[QNetworkRequest, str], **kwargs) -> QNetworkReply:
if isinstance(request, QNetworkRequest):
networkReply = self.get(request)
else:
networkReply = self.get(QNetworkRequest(QtCore.QUrl(request)))
networkReply.readyRead.connect(
functools.partial(
self.handleReadyReadByNetworkReply,
networkReply,
**kwargs,
)
)
networkReply.finished.connect(
functools.partial(
self.handleFinishedByNetworkReply,
networkReply,
**kwargs,
)
)
return networkReply
@@ -0,0 +1,210 @@
# Copyright (C) 2024 Loren Eteval <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/>.
from __future__ import annotations
from Furious.QtFramework.QtNetwork import *
from Furious.QtFramework.QtWidgets import *
from Furious.QtFramework.DynamicTranslate import gettext as _
from Furious.QtFramework.WebGETManager import *
from Furious.Utility import *
from Furious.Library import *
from PySide6 import QtCore
from PySide6.QtGui import *
from PySide6.QtNetwork import *
from typing import AnyStr, Union, Callable
import os
import logging
import hashlib
import functools
__all__ = ['XrayAssetDownloadManager']
logger = logging.getLogger(__name__)
class SHA256Worker(QtCore.QObject, QtCore.QRunnable):
finished = QtCore.Signal(str)
def __init__(self, string=b''):
# Explictly called __init__
QtCore.QObject.__init__(self)
QtCore.QRunnable.__init__(self)
self.string = string
def run(self):
self.finished.emit(hashlib.sha256(self.string).hexdigest())
class XrayAssetSHA256DownloadManager(WebGETManager):
def __init__(self, parent=None, **kwargs):
actionMessage = kwargs.pop('actionMessage', 'download sha256')
super().__init__(parent, actionMessage=actionMessage)
@staticmethod
def fileContent(filepath, mode='rb') -> AnyStr:
try:
with open(filepath, mode) as file:
return file.read()
except Exception:
# Any non-exit exceptions
return b''
def successCallback(self, networkReply, **kwargs):
filepath = kwargs.pop('filepath', '')
downloadCallback = kwargs.pop('downloadCallback', None)
data = networkReply.readAll().data()
if isinstance(data, bytes):
datastr = data.decode('utf-8', 'replace')
else:
datastr = str(data)
logger.debug(f'data: {data}')
# 6068a73edfa08b63080b8d362bd4c5b069689a3548f413f43cfa58227a201571 geosite.dat
# 3a12beaa33c81b6751b45833a0a942b9462420d91182e7fa1d768b418e604049 geoip.dat
value = datastr.split()[0]
basename = os.path.basename(filepath)
def handleFinished(_digest, _value=''):
logger.debug(
f'computed digest is \'{_digest}\' while repo digest is \'{_value}\''
)
if _digest != _value:
logger.info(f'digest not equal for {basename}. Start downloading asset')
if callable(downloadCallback):
downloadCallback()
else:
logger.info(f'digest equal for {basename}. Nothing to do')
worker = SHA256Worker(self.fileContent(filepath))
worker.setAutoDelete(True)
worker.finished.connect(functools.partial(handleFinished, _value=value))
AppThreadPool().start(worker)
def download(self, url, filepath, downloadCallback: Callable[[], None]):
self.webGET(
url,
filepath=str(filepath),
downloadCallback=downloadCallback,
)
class XrayAssetAssetsDownloadManager(WebGETManager):
def __init__(self, parent=None, **kwargs):
actionMessage = kwargs.pop('actionMessage', 'download assets')
super().__init__(parent, actionMessage=actionMessage)
def successCallback(self, networkReply, **kwargs):
filepath = kwargs.pop('filepath', '')
saveFile = QtCore.QSaveFile(filepath)
data = networkReply.readAll().data()
if not saveFile.open(QtCore.QSaveFile.OpenModeFlag.WriteOnly):
logger.error(f'failed to open \'{filepath}\' for writing')
else:
saveFile.write(data)
if not saveFile.commit():
logger.error(f'save file to \'{filepath}\' failed')
else:
logger.info(f'save file to \'{filepath}\' success')
def download(self, url, filepath):
self.webGET(url, filepath=str(filepath))
class XrayAssetPairDownloadHelper:
def __init__(self, *args, **kwargs):
sha256ActionMessage = kwargs.pop('sha256ActionMessage', 'download sha256')
assetsActionMessage = kwargs.pop('assetsActionMessage', 'download assets')
super().__init__(*args, **kwargs)
self.sha256Downloader = XrayAssetSHA256DownloadManager(
actionMessage=sha256ActionMessage
)
self.assetsDownloader = XrayAssetAssetsDownloadManager(
actionMessage=assetsActionMessage
)
def configureHttpProxy(self, httpProxy: Union[str, None]) -> bool:
return all(
[
self.sha256Downloader.configureHttpProxy(httpProxy),
self.assetsDownloader.configureHttpProxy(httpProxy),
]
)
def download(self, sha256URL, assetsURL, filepath):
self.sha256Downloader.download(
url=sha256URL,
filepath=filepath,
downloadCallback=functools.partial(
self.assetsDownloader.download, assetsURL, filepath
),
)
class XrayAssetDownloadManager:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.downloadHelperGeosite = XrayAssetPairDownloadHelper(
sha256ActionMessage='download geosite sha256',
assetsActionMessage='download geosite assets',
)
self.downloadHelperGeoip = XrayAssetPairDownloadHelper(
sha256ActionMessage='download geoip sha256',
assetsActionMessage='download geoip assets',
)
def configureHttpProxy(self, httpProxy: Union[str, None]) -> bool:
return all(
[
self.downloadHelperGeosite.configureHttpProxy(httpProxy),
self.downloadHelperGeoip.configureHttpProxy(httpProxy),
]
)
def download(self):
self.downloadHelperGeosite.download(
sha256URL=URL_GEOSITE_SHA256,
assetsURL=URL_GEOSITE,
filepath=XRAY_ASSET_PATH_GEOSITE,
)
self.downloadHelperGeoip.download(
sha256URL=URL_GEOIP_SHA256,
assetsURL=URL_GEOIP,
filepath=XRAY_ASSET_PATH_GEOIP,
)
+2
View File
@@ -28,3 +28,5 @@ from .NetworkStateManager import *
from .TextEditor import *
from .TextEditorTheme import *
from .GuiEditorXXX import *
from .WebGETManager import *
from .XrayAssetDownloadManager import *
+8 -2
View File
@@ -27,6 +27,12 @@ __all__ = ['UserServers']
registerAppSettings('Configuration')
class UserServer:
remark: str
config: str
subsId: str
class UserServers(SupportExitCleanup, StorageFactory):
# remark, config, subsId. (subsId corresponds to unique in user subscription)
def __init__(self, *args, **kwargs):
@@ -44,8 +50,8 @@ class UserServers(SupportExitCleanup, StorageFactory):
self._data = restore()
self._list = list(
constructFromAny(model.pop('config', ''), **model)
for model in self._data['model']
constructFromAny(model.pop('config', ''), index=index, **model)
for index, model in enumerate(self._data['model'])
)
def sync(self):
+10 -1
View File
@@ -27,8 +27,17 @@ __all__ = ['UserSubs']
registerAppSettings('CustomSubscription')
class UserSubEntry:
remark: str
webURL: str
class UserSub:
unique: dict[str, dict]
class UserSubs(SupportExitCleanup, StorageFactory):
# unique: remark, webURL
# unique: { remark, webURL }
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
+59
View File
@@ -0,0 +1,59 @@
# Copyright (C) 2024 Loren Eteval <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/>.
from __future__ import annotations
from Furious.Interface import *
from Furious.PyFramework import *
from Furious.Utility import *
from Furious.Library import *
__all__ = ['UserTUNSettings']
registerAppSettings('CustomTUNSettings')
class UserTUNSettings(SupportExitCleanup, StorageFactory):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def restore():
try:
return UJSONEncoder.decode(
PyBase64Encoder.decode(AppSettings.get('CustomTUNSettings'))
)
except Exception:
# Any non-exit exceptions
return {}
self._data = restore()
def sync(self):
AppSettings.set(
'CustomTUNSettings',
PyBase64Encoder.encode(
UJSONEncoder.encode(self._data).encode(),
),
)
def data(self) -> dict[str, str]:
# Shallow copy
return self._data
def cleanup(self):
self.sync()
+1
View File
@@ -17,3 +17,4 @@
from .UserServers import *
from .UserSubs import *
from .UserTUNSettings import *
+81 -54
View File
@@ -69,6 +69,9 @@ class ConnectAction(AppQAction):
self.actionTimer = QtCore.QTimer()
self.actionTimer.timeout.connect(lambda: self.callActionFromQueue())
self.updatesManager = UpdatesManager()
self.assetDownloadManager = XrayAssetDownloadManager()
def reset(self):
self.hideProgressBar(True)
self.setText(_('Connect'))
@@ -93,7 +96,7 @@ class ConnectAction(AppQAction):
if done:
self.progressBar.setValue(100)
self.progressBar.hide()
self.progressBar.close()
self.progressBar.stop()
return self
@@ -105,10 +108,10 @@ class ConnectAction(AppQAction):
APP().systemTray.SystemProxyAction.setDisabled(value)
if isAdministrator():
VPNModeAction = APP().systemTray.SettingsAction.getVPNModeAction()
TUNModeAction = APP().systemTray.SettingsAction.getTUNModeAction()
if VPNModeAction is not None:
VPNModeAction.setDisabled(value)
if TUNModeAction is not None:
TUNModeAction.setDisabled(value)
def isConnected(self) -> bool:
return self.textCompare('Disconnect')
@@ -202,69 +205,93 @@ class ConnectAction(AppQAction):
AppSettings.turnOFF('Connect')
self.setChecked(False)
else:
assert isinstance(config, ConfigurationFactory)
return
if not validateProxyServer(config.httpProxyEndpoint()):
# Proxy server is not valid. Do not connect
assert isinstance(config, ConfigurationFactory)
AppSettings.turnOFF('Connect')
if not validateProxyServer(config.httpProxyEndpoint()):
AppSettings.turnOFF('Connect')
self.setChecked(False)
self.setChecked(False)
mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Critical)
mbox.setWindowTitle(_('Unable to connect'))
mbox.setText(
_(
f'{APPLICATION_NAME} cannot find any valid http proxy endpoint in the configuration'
mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Critical)
mbox.setWindowTitle(_('Unable to connect'))
mbox.setText(
_(
f'{APPLICATION_NAME} cannot find any valid http proxy endpoint in the configuration'
)
)
mbox.setInformativeText(_('Please complete your server configuration'))
# Show the MessageBox asynchronously
mbox.open()
return
self.doConnecting()
# Clear previous log
loggerCore().clear()
loggerTun_().clear()
success = self.coreManager.start(
config,
routing=AppSettings.get('Routing'),
exitCallback=self.coreExitCallback,
msgCallbackCore=lambda line: loggerCore().appendLine(line),
msgCallbackTun_=lambda line: loggerTun_().appendLine(line),
)
mbox.setInformativeText(_('Please complete your server configuration'))
# Show the MessageBox asynchronously
mbox.open()
if self.actionQueue.empty():
if success:
SystemProxy.set(config.httpProxyEndpoint(), PROXY_SERVER_BYPASS)
return
self.doConnected()
self.doConnecting()
APP().systemTray.showMessage(
f'{config.coreName()}: ' + _('Connected')
)
# Clear previous log
loggerCore().clear()
loggerTun_().clear()
if AppSettings.isStateON_('PowerSaveMode'):
# Power optimization
logger.info(f'no action queue in power save mode')
success = self.coreManager.start(
config,
routing=AppSettings.get('Routing'),
exitCallback=self.coreExitCallback,
msgCallbackCore=lambda line: loggerCore().appendLine(line),
msgCallbackTun_=lambda line: loggerTun_().appendLine(line),
self.actionTimer.stop()
else:
self.actionTimer.start(CORE_CHECK_ALIVE_INTERVAL)
self.doConnectedCallOnceOnly()
else:
logger.error('failed to start core manager')
self.coreManager.stopAll()
self.doDisconnectWithTrayMessage(
f'{config.coreName()}: ' + _('Unknown error')
)
else:
while not self.actionQueue.empty():
self.callActionFromQueue()
@callOnceOnly
def doConnectedCallOnceOnly(self):
def newVersionCallback(newVersion):
APP().systemTray.showMessage(
f'{APPLICATION_NAME} {newVersion} ' + _('is available to download')
)
connectedHttpProxy = connectedHttpProxyEndpoint()
# Check for updates
self.updatesManager.configureHttpProxy(connectedHttpProxy)
self.updatesManager.checkForUpdates(
showMessageBox=False,
hasNewVersionCallback=newVersionCallback,
)
if self.actionQueue.empty():
if success:
SystemProxy.set(config.httpProxyEndpoint(), PROXY_SERVER_BYPASS)
self.doConnected()
APP().systemTray.showMessage(f'{config.coreName()}: ' + _('Connected'))
if AppSettings.isStateON_('PowerSaveMode'):
# Power optimization
logger.info(f'no action queue in power save mode')
self.actionTimer.stop()
else:
self.actionTimer.start(CORE_CHECK_ALIVE_INTERVAL)
else:
logger.error('failed to start core manager')
self.coreManager.stopAll()
self.doDisconnectWithTrayMessage(
f'{config.coreName()}: ' + _('Unknown error')
)
else:
while not self.actionQueue.empty():
self.callActionFromQueue()
# Automatically update assets
self.assetDownloadManager.configureHttpProxy(connectedHttpProxy)
self.assetDownloadManager.download()
def callActionFromQueue(self):
try:
+1 -1
View File
@@ -96,7 +96,7 @@ class ImportMultiSuccessMBox(AppQMessageBox):
def customText(self):
text = (
_('Import share link success')
+ f'\n\n'
+ '\n\n'
+ '\n'.join(
list(
f'{index + 1} - {remark}. '
+14 -8
View File
@@ -36,18 +36,18 @@ registerAppSettings(
registerAppSettings('ShowTabAndSpacesInEditor', isBinary=True)
# Administrator, root
TRANSLATABLE_VPN_MODE = [
_('VPN Mode Disabled (Administrator)'),
_('VPN Mode Disabled (root)'),
TRANSLATABLE_TUN_MODE = [
_('TUN Mode Disabled (Administrator)'),
_('TUN Mode Disabled (root)'),
]
class VPNModeAction(AppQAction):
class TUNModeAction(AppQAction):
def __init__(self, **kwargs):
if isAdministrator():
super().__init__(_('VPN Mode'), **kwargs)
super().__init__(_('TUN Mode'), **kwargs)
else:
super().__init__(_(f'VPN Mode Disabled ({ADMINISTRATOR_NAME})'), **kwargs)
super().__init__(_(f'TUN Mode Disabled ({ADMINISTRATOR_NAME})'), **kwargs)
self.setDisabled(True)
@@ -136,10 +136,16 @@ class SettingsAction(AppQAction):
def __init__(self, **kwargs):
if PLATFORM == 'Windows' or PLATFORM == 'Darwin':
extraActions = [
VPNModeAction(
TUNModeAction(
checkable=True,
checked=AppSettings.isStateON_('VPNMode'),
),
AppQAction(
_('Customize TUN Settings...'),
icon=bootstrapIcon('diagram-3.svg'),
checkable=False,
callback=lambda: APP().mainWindow.getGuiTUNSettings().open(),
),
AppQSeperator(),
]
else:
@@ -187,7 +193,7 @@ class SettingsAction(AppQAction):
**kwargs,
)
def getVPNModeAction(self) -> Union[AppQAction, None]:
def getTUNModeAction(self) -> Union[AppQAction, None]:
if PLATFORM == 'Windows' or PLATFORM == 'Darwin':
# 1st action
return self._menu.actions()[0]
+56 -1
View File
@@ -20,9 +20,18 @@ from __future__ import annotations
from Furious.Utility.Constants import *
from Furious.Utility.AppSettings import *
from typing import Union
import functools
__all___ = ['AS_UserActivatedItemIndex', 'AS_UserServers', 'AS_UserSubscription']
__all___ = [
'AS_UserActivatedItemIndex',
'AS_UserServers',
'AS_UserSubscription',
'AS_UserTUNSettings',
'connectedHttpProxyEndpoint',
'connectedServerRemark',
]
def _activatedItemIndex() -> int:
@@ -52,6 +61,52 @@ def _userSubscriptionData() -> dict[str, dict]:
return {}
def _userTUNSettingsData() -> dict[str, str]:
try:
return APP().userTUNSettings.data()
except Exception:
# Any non-exit exceptions
return {}
AS_UserActivatedItemIndex = functools.partial(_activatedItemIndex)
AS_UserServers = functools.partial(_userServersData)
AS_UserSubscription = functools.partial(_userSubscriptionData)
AS_UserTUNSettings = functools.partial(_userTUNSettingsData)
def connectedHttpProxyEndpoint() -> Union[str, None]:
try:
if APP().isSystemTrayConnected():
index = AS_UserActivatedItemIndex()
if index >= 0:
return AS_UserServers()[index].httpProxyEndpoint()
else:
# Should not reach here
return None
else:
return None
except Exception:
# Any non-exit exceptions
return None
def connectedServerRemark() -> str:
try:
if APP().isSystemTrayConnected():
index = AS_UserActivatedItemIndex()
if index >= 0:
return f'{index + 1} - ' + AS_UserServers()[index].getExtras('remark')
else:
# Should not reach here
return ''
else:
return ''
except Exception:
# Any non-exit exceptions
return ''
+13
View File
@@ -59,6 +59,16 @@ XRAY_ASSET_DIR = DATA_DIR / 'xray'
CRASH_LOG_DIR = PACKAGE_DIR / 'CrashLog'
GEN_TRANSLATION_FILE = PACKAGE_DIR / 'Externals' / 'GenTranslation.py'
URL_GEOSITE = 'https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat'
URL_GEOIP = (
'https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat'
)
URL_GEOSITE_SHA256 = 'https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat.sha256sum'
URL_GEOIP_SHA256 = 'https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat.sha256sum'
XRAY_ASSET_PATH_GEOSITE = XRAY_ASSET_DIR / 'geosite.dat'
XRAY_ASSET_PATH_GEOIP = XRAY_ASSET_DIR / 'geoip.dat'
PROXY_SERVER_BYPASS = (
'localhost;*.local;127.*;10.*;172.16.*;172.17.*;'
'172.18.*;172.19.*;172.20.*;172.21.*;172.22.*;172.23.*;172.24.*;172.25.*;'
@@ -92,6 +102,9 @@ APPLICATION_TUN_INTERFACE_DNS_ADDRESS = '1.1.1.1'
ADMINISTRATOR_NAME = 'Administrator' if PLATFORM == 'Windows' else 'root'
NETWORK_STATE_TEST_URL = 'http://cp.cloudflare.com'
NETWORK_SPEED_TEST_URL = (
'http://speed.cloudflare.com/__down?during=download&bytes=104857600'
)
UNICODE_LARGE_RED_CIRCLE = u'\U0001F534'
UNICODE_LARGE_GREEN_CIRCLE = u'\U0001F7E2'
+1 -1
View File
@@ -288,7 +288,7 @@ class _SystemProxy:
return False
# Note: try to resolve non-Windows proxy daemon issue by
# Note: try to solve non-Windows proxy daemon issue by
# turning on StartupOnBoot by default. This should be
# friendly for most of the users
+1 -1
View File
@@ -356,7 +356,7 @@ class SystemRoutingTable:
result = runExternalCommand(
'netsh interface ip set address'.split()
+ [
deviceName,
f'\"{deviceName}\"',
'static',
deviceIP,
'255.255.255.0',
+2 -2
View File
@@ -31,7 +31,7 @@ __all__ = [
'getPythonVersion',
'getUbuntuRelease',
'isAdministrator',
'isVPNMode',
'isTUNMode',
'isScriptMode',
'isPythonw',
'isWindows7',
@@ -79,7 +79,7 @@ def isAdministrator() -> bool:
return os.geteuid() == 0
def isVPNMode() -> bool:
def isTUNMode() -> bool:
return isAdministrator() and AppSettings.isStateON_('VPNMode')
+29
View File
@@ -30,8 +30,10 @@ import subprocess
import urllib.parse
__all__ = [
'AppThreadPool',
'Protocol',
'BinarySettings',
'callOnceOnly',
'protocolRepr',
'isValidIPAddress',
'parseHostPort',
@@ -46,6 +48,13 @@ __all__ = [
]
def _appThreadPool() -> QtCore.QThreadPool:
return APP().threadPool
AppThreadPool = functools.partial(_appThreadPool)
class Protocol:
VMess = 'VMess'
VLESS = 'VLESS'
@@ -62,6 +71,26 @@ class BinarySettings:
RANGE = [OFF, ON_]
def callOnceOnly(func):
"""
Decorator that ensures a function is only called once.
Later calls return the cached result from the first invocation.
"""
result = None
called = False
def wrapper(*args, **kwargs):
nonlocal result, called
if not called:
result = func(*args, **kwargs)
called = True
return result
return wrapper
@functools.lru_cache(None)
def protocolRepr(protocol: str) -> str:
if not isinstance(protocol, str):
+1
View File
@@ -181,6 +181,7 @@ class Application(ApplicationFactory, SingletonApplication):
# Initialize storage
self.userServers = UserServers()
self.userSubs = UserSubs()
self.userTUNSettings = UserTUNSettings()
# ThreadPool
self.threadPool = QtCore.QThreadPool()
-5
View File
@@ -85,11 +85,6 @@ class ConnectProgressBar(QTranslatable, SupportConnectedCallback, QWidget):
f'}}'
)
def closeEvent(self, event):
event.ignore()
self.hide()
def disconnectedCallback(self):
self.setWindowIcon(AppHue.disconnectedWindowIcon())
self._progressBar.setStyleSheet(self.getStyleSheet(AppHue.disconnectedColor()))
+207
View File
@@ -0,0 +1,207 @@
# Copyright (C) 2024 Loren Eteval <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/>.
from __future__ import annotations
from Furious.Interface import *
from Furious.QtFramework import *
from Furious.QtFramework import gettext as _
from Furious.Utility import *
from PySide6.QtGui import *
import logging
__all__ = ['GuiTUNSettings']
logger = logging.getLogger(__name__)
class GuiTUNSettingsItemXXX(GuiEditorItemTextInput):
def __init__(self, *args, **kwargs):
self.key = kwargs.pop('key', '')
super().__init__(*args, **kwargs)
def inputToFactory(self, config: dict) -> bool:
oldItem = config.get(self.key, '')
newItem = self.text()
if newItem != oldItem:
config[self.key] = newItem
# Modified
return True
else:
# Not modified
return False
def factoryToInput(self, config: dict):
self.setText(config.get(self.key, ''))
class GuiTUNSettingsItemDisablePrimaryAdapterInterfaceDNS(GuiEditorItemTextCheckBox):
def __init__(self, *args, **kwargs):
self.key = kwargs.pop('key', '')
super().__init__(*args, **kwargs)
def inputToFactory(self, config: dict) -> bool:
oldItem = config.get(self.key, 'True')
newItem = self.isChecked()
if oldItem not in ['False', 'True']:
# Invalid value. Set to default
oldItem = 'True'
if str(newItem) != oldItem:
config[self.key] = str(newItem)
# Modified
return True
else:
# Not modified
return False
def factoryToInput(self, config: dict):
self.setChecked(config.get(self.key, 'True') != 'False')
class AppQLabelHelpPage(AppQLabel):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setWebsiteURL()
self.linkActivated.connect(self.handleLinkActivated)
def setWebsiteURL(self):
self.setText(
'<html><head/><body><p>'
'<a href=\"https://github.com/LorenEteval/Furious/wiki/TUN-Mode\">'
'<span style=\" text-decoration: underline; color:#007ad6;\">'
+ _('Go to help page')
+ '</span></a></p></body></html>'
)
@staticmethod
def handleLinkActivated(link: str):
if QDesktopServices.openUrl(QtCore.QUrl(link)):
logger.info(f'open link \'{link}\' success')
else:
logger.error(f'open link \'{link}\' failed')
def retranslate(self):
self.setWebsiteURL()
class GuiTUNSettingsItemHelpPage(GuiEditorItemWidgetContainer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._page = AppQLabelHelpPage()
def widgets(self):
return [self._page]
class GuiTUNSettingsGroupBox(GuiEditorWidgetQGroupBox):
def __init__(self, **kwargs):
super().__init__(_('Basic Configuration'), **kwargs)
def containerSequence(self):
return [
GuiTUNSettingsItemXXX(
title=_('Primary Adapter Interface Name'),
key='primaryAdapterInterfaceName',
),
GuiTUNSettingsItemXXX(
title=_('Primary Adapter Interface IP'),
key='primaryAdapterInterfaceIP',
),
GuiTUNSettingsItemXXX(
title=_('Default Primary Gateway IP'),
key='defaultPrimaryGatewayIP',
),
GuiTUNSettingsItemXXX(
title=_('TUN Adapter Interface DNS'),
key='tunAdapterInterfaceDNS',
),
GuiTUNSettingsItemXXX(
title=_('Bypass TUN adapter interface IP (separated by commas)'),
key='bypassTUNAdapterInterfaceIP',
),
GuiTUNSettingsItemDisablePrimaryAdapterInterfaceDNS(
title=_(
'Disable Primary Adapter Interface DNS (Mitigating DNS leaks on Windows)'
),
key='disablePrimaryAdapterInterfaceDNS',
),
GuiTUNSettingsItemHelpPage(),
]
class GuiTUNSettings(GuiEditorWidgetQDialog):
def __init__(self, *args, **kwargs):
tabTranslatable = kwargs.pop('tabTranslatable', True)
super().__init__(*args, tabTranslatable=tabTranslatable, **kwargs)
self.setTabText(_('Customize TUN Settings'))
self.setFixedSize(int(460 * GOLDEN_RATIO), int(460))
# Shallow copy
config = AS_UserTUNSettings()
try:
self.factoryToInput(config)
except Exception as ex:
# Any non-exit exceptions
logger.error(f'error while converting factory to input: {ex}')
self.accepted.connect(functools.partial(self.handleAccepted, config))
self.rejected.connect(functools.partial(self.handleRejected))
def handleAccepted(self, config: dict):
modified = self.inputToFactory(config)
if modified and isTUNMode():
showNewChangesNextTimeMBox()
self.accepted.disconnect()
self.rejected.disconnect()
def handleRejected(self):
self.accepted.disconnect()
self.rejected.disconnect()
def inputToFactory(self, config: dict) -> bool:
modified = False
for groupBox in self.groupBoxes:
modified |= groupBox.inputToFactory(config)
return modified
def factoryToInput(self, config: dict):
for groupBox in self.groupBoxes:
groupBox.factoryToInput(config)
def groupBoxSequence(self):
return [
GuiTUNSettingsGroupBox(),
]
File diff suppressed because it is too large Load Diff
+311 -30
View File
@@ -28,10 +28,13 @@ from PySide6 import QtCore
from PySide6.QtGui import *
from PySide6.QtWidgets import *
from typing import Callable
from typing import Union, Callable
import logging
import functools
logger = logging.getLogger(__name__)
__all__ = ['UserSubsQTableWidget']
registerAppSettings('SubscriptionWidgetSectionSizeTable')
@@ -65,10 +68,76 @@ class UserSubsQTableWidgetHeaders:
return self.name
class UserSubsAppQComboBox(AppQComboBox):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def retranslate(self):
# Do not emit 'currentTextChanged' when retranslate
with QBlockSignals(self):
super().retranslate()
# Headers VALUE
TRANSLATABLE_HEADERS = [
_('Never'),
_('Every 5 mins'),
_('Every 10 mins'),
_('Every 15 mins'),
_('Every 30 mins'),
_('Every 45 mins'),
_('Every 1 hour'),
_('Every 2 hours'),
_('Every 3 hours'),
_('Every 6 hours'),
_('Every 8 hours'),
_('Every 10 hours'),
_('Every 12 hours'),
_('Every 24 hours'),
_('Use current proxy'),
_('Force proxy'),
_('No proxy'),
_('Remark'),
_('Auto Update'),
_('Auto Update Use Proxy'),
]
class UserSubsQTableWidget(QTranslatable, AppQTableWidget):
AutoUpdateOptions = {
'': None,
'Never': None,
'Every 5 mins': 5 * 60 * 1000,
'Every 10 mins': 10 * 60 * 1000,
'Every 15 mins': 15 * 60 * 1000,
'Every 30 mins': 30 * 60 * 1000,
'Every 45 mins': 45 * 60 * 1000,
'Every 1 hour': 1 * 60 * 60 * 1000,
'Every 2 hours': 2 * 60 * 60 * 1000,
'Every 3 hours': 3 * 60 * 60 * 1000,
'Every 6 hours': 6 * 60 * 60 * 1000,
'Every 8 hours': 8 * 60 * 60 * 1000,
'Every 10 hours': 10 * 60 * 60 * 1000,
'Every 12 hours': 12 * 60 * 60 * 1000,
'Every 24 hours': 24 * 60 * 60 * 1000,
}
ProxyOptions = {
'': lambda: None,
'Use current proxy': lambda: connectedHttpProxyEndpoint(),
'Force proxy': lambda: '127.0.0.1:10809',
'No proxy': lambda: None,
}
Headers = [
UserSubsQTableWidgetHeaders('Remark', lambda item: item.get('remark', '')),
UserSubsQTableWidgetHeaders('URL', lambda item: item.get('webURL', '')),
UserSubsQTableWidgetHeaders(
'Auto Update', lambda item: item.get('autoupdate', '')
),
UserSubsQTableWidgetHeaders(
'Auto Update Use Proxy', lambda item: item.get('proxy', '')
),
]
def __init__(self, *args, **kwargs):
@@ -76,6 +145,8 @@ class UserSubsQTableWidget(QTranslatable, AppQTableWidget):
super().__init__(*args, **kwargs)
self.timers = list(QtCore.QTimer() for i in range(len(AS_UserSubscription())))
# Must set before flush all
self.setColumnCount(len(self.Headers))
@@ -124,7 +195,8 @@ class UserSubsQTableWidget(QTranslatable, AppQTableWidget):
@QtCore.Slot(QTableWidgetItem)
def handleItemChanged(self, item: QTableWidgetItem):
unique = list(AS_UserSubscription().keys())[item.row()]
keyMap = ['remark', 'webURL']
# 'autoupdate', 'proxy' is not triggered here
keyMap = ['remark', 'webURL', 'autoupdate', 'proxy']
AS_UserSubscription()[unique][keyMap[item.column()]] = item.text()
@@ -149,6 +221,25 @@ class UserSubsQTableWidget(QTranslatable, AppQTableWidget):
AS_UserSubscription().pop(deleteUnique)
# Begin timer cleanup
qtimer = self.timers[deleteIndex]
assert isinstance(qtimer, QtCore.QTimer)
try:
qtimer.timeout.disconnect()
except Exception as ex:
# Any non-exit exceptions
# Disconnect all previous signals if possible
pass
qtimer.stop()
# End timer cleanup
# Remove timer from list
self.timers.pop(deleteIndex)
if callable(self.deleteUniqueCallback):
self.deleteUniqueCallback(deleteUnique)
else:
@@ -170,37 +261,189 @@ class UserSubsQTableWidget(QTranslatable, AppQTableWidget):
mbox.setText(mbox.customText())
mbox.finished.connect(functools.partial(handleResultCode, indexes))
# dummy ref
setattr(self, '_questionDeleteMBox', mbox)
# Show the MessageBox asynchronously
mbox.open()
def flushItem(self, row, column, item):
header = self.Headers[column]
def handleAutoUpdateComboBoxCurrentTextChanged(self, text: str, row: int):
textEnglish = _(text, 'EN')
oldItem = self.item(row, column)
newItem = QTableWidgetItem(header(item))
unique = list(AS_UserSubscription().keys())[row]
subsob = AS_UserSubscription()[unique]
if oldItem is None:
# Item does not exists
newItem.setFont(QFont(APP().customFontName))
else:
# Use existing
newItem.setFont(oldItem.font())
newItem.setForeground(oldItem.foreground())
remark, webURL = subsob['remark'], subsob['webURL']
if oldItem.textAlignment() != 0:
newItem.setTextAlignment(oldItem.textAlignment())
try:
timems = self.AutoUpdateOptions[textEnglish]
except KeyError:
# Invalid key. Reset
logger.error(f'\'{textEnglish}\' is not in auto update options. Reset')
# Editable
newItem.setFlags(
QtCore.Qt.ItemFlag.ItemIsEnabled
| QtCore.Qt.ItemFlag.ItemIsSelectable
| QtCore.Qt.ItemFlag.ItemIsEditable
textEnglish = ''
# Write to subs object
subsob['autoupdate'] = textEnglish
timems = self.AutoUpdateOptions[textEnglish]
qtimer = self.timers[row]
assert isinstance(qtimer, QtCore.QTimer)
if timems is not None:
logger.info(
f'start auto update job for subscription ({remark}, {webURL}). '
f'Interval is {timems // (60 * 1000)} mins'
)
self.setItem(row, column, newItem)
def getHttpProxy(_subsob):
return self.ProxyOptions[_subsob.get('proxy', '')]()
try:
qtimer.timeout.disconnect()
except Exception as ex:
# Any non-exit exceptions
# Disconnect all previous signals if possible
pass
qtimer.timeout.connect(
functools.partial(
self.updateSubsByUnique,
unique=unique,
httpProxy=functools.partial(getHttpProxy, subsob),
)
)
qtimer.start(timems)
else:
logger.info(f'stop auto update job for subscription ({remark}, {webURL})')
try:
qtimer.timeout.disconnect()
except Exception as ex:
# Any non-exit exceptions
# Disconnect all previous signals if possible
pass
qtimer.stop()
# Write to subs object
subsob['autoupdate'] = textEnglish
# Return potentially fixed value
return textEnglish
def handleProxyComboBoxCurrentTextChanged(self, text: str, row: int):
textEnglish = _(text, 'EN')
unique = list(AS_UserSubscription().keys())[row]
subsob = AS_UserSubscription()[unique]
try:
proxyFn = self.ProxyOptions[textEnglish]
except KeyError:
# Invalid key. Reset
logger.error(f'\'{textEnglish}\' is not in proxy options. Reset')
textEnglish = ''
# Write to subs object
subsob['proxy'] = textEnglish
proxyFn = self.ProxyOptions[textEnglish]
assert callable(proxyFn)
# Write to subs object
subsob['proxy'] = textEnglish
# Return potentially fixed value
return textEnglish
def flushItem(self, row, column, item):
if column == self.Headers.index('Auto Update'):
header = self.Headers[column]
oldItem = self.item(row, column)
newItem = UserSubsAppQComboBox()
newItem.addItems(list(_(key) for key in self.AutoUpdateOptions.keys()))
if oldItem is None:
# Item does not exist
newItem.setFont(QFont(APP().customFontName))
else:
# Use existing
newItem.setFont(oldItem.font())
index = newItem.findText(
_(self.handleAutoUpdateComboBoxCurrentTextChanged(header(item), row))
)
if index >= 0:
newItem.setCurrentIndex(index)
newItem.currentTextChanged.connect(
functools.partial(
self.handleAutoUpdateComboBoxCurrentTextChanged,
row=row,
)
)
self.setCellWidget(row, column, newItem)
elif column == self.Headers.index('Auto Update Use Proxy'):
header = self.Headers[column]
oldItem = self.item(row, column)
newItem = UserSubsAppQComboBox()
newItem.addItems(list(_(key) for key in self.ProxyOptions.keys()))
if oldItem is None:
# Item does not exist
newItem.setFont(QFont(APP().customFontName))
else:
# Use existing
newItem.setFont(oldItem.font())
index = newItem.findText(
_(self.handleProxyComboBoxCurrentTextChanged(header(item), row))
)
if index >= 0:
newItem.setCurrentIndex(index)
newItem.currentTextChanged.connect(
functools.partial(
self.handleProxyComboBoxCurrentTextChanged,
row=row,
)
)
self.setCellWidget(row, column, newItem)
else:
header = self.Headers[column]
oldItem = self.item(row, column)
newItem = QTableWidgetItem(header(item))
if oldItem is None:
# Item does not exist
newItem.setFont(QFont(APP().customFontName))
else:
# Use existing
newItem.setFont(oldItem.font())
newItem.setForeground(oldItem.foreground())
if oldItem.textAlignment() != 0:
newItem.setTextAlignment(oldItem.textAlignment())
# Editable
newItem.setFlags(
QtCore.Qt.ItemFlag.ItemIsEnabled
| QtCore.Qt.ItemFlag.ItemIsSelectable
| QtCore.Qt.ItemFlag.ItemIsEditable
)
self.setItem(row, column, newItem)
def flushRow(self, row, item):
for column in list(range(self.columnCount())):
@@ -217,24 +460,62 @@ class UserSubsQTableWidget(QTranslatable, AppQTableWidget):
self.flushRow(index, AS_UserSubscription()[key])
def appendNewItem(self, **kwargs):
unique = kwargs.pop('unique', '')
remark = kwargs.pop('remark', '')
webURL = kwargs.pop('webURL', '')
unique, remark, webURL, autoupdate, proxy = (
kwargs.pop('unique', ''),
kwargs.pop('remark', ''),
kwargs.pop('webURL', ''),
# Below values are not filled when adding item via Gui
kwargs.pop('autoupdate', ''),
kwargs.pop('proxy', ''),
)
# The internal subs object
subs = {
subsob = {
unique: {
'remark': remark,
'webURL': webURL,
'autoupdate': autoupdate,
'proxy': proxy,
}
}
AS_UserSubscription().update(subs)
# 'unique' should be unique in subscription storage,
# but protect it anyway.
if unique not in AS_UserSubscription():
# Add timer
self.timers.append(QtCore.QTimer())
AS_UserSubscription().update(subsob)
row = self.rowCount()
self.insertRow(row)
self.flushRow(row, subs[unique])
self.flushRow(row, subsob[unique])
@staticmethod
def updateSubsByUnique(
unique: str, httpProxy: Union[str, Callable, None], **kwargs
):
showMessageBox = kwargs.pop('showMessageBox', False)
if callable(httpProxy):
try:
realHttpProxy = httpProxy()
except Exception as ex:
# Any non-exit exceptions
logger.error(
f'error while configuring http proxy: {ex}. '
f'Update subscription uses no proxy'
)
realHttpProxy = None
else:
realHttpProxy = httpProxy
APP().mainWindow.updateSubsByUnique(
unique, realHttpProxy, showMessageBox=showMessageBox, **kwargs
)
def retranslate(self):
self.setHorizontalHeaderLabels(list(_(str(header)) for header in self.Headers))
@@ -195,9 +195,6 @@ class XrayAssetViewerQListWidget(SupportThemeChangedCallback, AppQListWidget):
mbox.setInformativeText(basename)
mbox.finished.connect(functools.partial(handleResultCode, filename))
# dummy ref
setattr(self, '_assetExistsMBox', mbox)
# Show the MessageBox asynchronously
mbox.open()
else:
@@ -235,9 +232,6 @@ class XrayAssetViewerQListWidget(SupportThemeChangedCallback, AppQListWidget):
mbox.setText(mbox.customText())
mbox.finished.connect(functools.partial(handleResultCode, indexes))
# dummy ref
setattr(self, '_questionDeleteMBox', mbox)
# Show the MessageBox asynchronously
mbox.open()
+1
View File
@@ -21,6 +21,7 @@ from .GuiHysteria1 import *
from .GuiHysteria2 import *
from .GuiShadowsocks import *
from .GuiTrojan import *
from .GuiTUNSettings import *
from .GuiVLESS import *
from .GuiVMess import *
from .IndentSpinBox import *
+44 -47
View File
@@ -23,6 +23,7 @@ from Furious.QtFramework import gettext as _
from Furious.Library import *
from Furious.Utility import *
from Furious.Widget.UserServersQTableWidget import *
from Furious.Widget.GuiTUNSettings import *
from Furious.Window.UserSubsWindow import *
from Furious.Window.LogViewerWindow import *
from Furious.Window.XrayAssetViewerWindow import *
@@ -45,57 +46,25 @@ logger = logging.getLogger(__name__)
registerAppSettings('ServerWidgetWindowSize')
def connectedHttpProxyEndpoint() -> Union[str, None]:
try:
if APP().isSystemTrayConnected():
index = AS_UserActivatedItemIndex()
if index >= 0:
return AS_UserServers()[index].httpProxyEndpoint()
else:
# Should not reach here
return None
else:
return None
except Exception:
# Any non-exit exceptions
return None
def connectedRemark() -> str:
try:
if APP().isSystemTrayConnected():
index = AS_UserActivatedItemIndex()
if index >= 0:
return f'{index + 1} - ' + AS_UserServers()[index].getExtras('remark')
else:
# Should not reach here
return ''
else:
return ''
except Exception:
# Any non-exit exceptions
return ''
class AppNetworkStateManager(NetworkStateManager):
def __init__(self, parent=None):
super().__init__(parent)
def successCallback(self):
def successCallback(self, networkReply, **kwargs):
parent = self.parent()
if isinstance(parent, AppMainWindow):
parent.setNetworkState(True)
def errorCallback(self, errorString: str):
super().successCallback(networkReply, **kwargs)
def failureCallback(self, networkReply, **kwargs):
parent = self.parent()
if isinstance(parent, AppMainWindow):
parent.setNetworkState(False, errorString=errorString)
parent.setNetworkState(False, errorString=networkReply.errorString())
super().failureCallback(networkReply, **kwargs)
def startSingleTest(self):
if not APP().isSystemTrayConnected():
@@ -106,15 +75,15 @@ class AppNetworkStateManager(NetworkStateManager):
self.stopTest()
httpProxyEndpoint = connectedHttpProxyEndpoint()
connectedHttpProxy = connectedHttpProxyEndpoint()
if httpProxyEndpoint is None:
if connectedHttpProxy is None:
parent = self.parent()
if isinstance(parent, AppMainWindow):
parent.resetNetworkState()
else:
self.configureHttpProxy(httpProxyEndpoint)
self.configureHttpProxy(connectedHttpProxy)
super().startSingleTest()
@@ -124,7 +93,7 @@ class AppNetworkStateManager(NetworkStateManager):
if isinstance(parent, AppMainWindow):
parent.resetNetworkState()
self.stopTest()
super().disconnectedCallback()
class AppMainWindow(AppQMainWindow):
@@ -132,7 +101,8 @@ class AppMainWindow(AppQMainWindow):
super().__init__(*args, **kwargs)
self.setWindowTitle(_(APPLICATION_NAME))
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_ShowWithoutActivating)
# TODO: Need this?
# self.setAttribute(QtCore.Qt.WidgetAttribute.WA_ShowWithoutActivating)
self.updatesManager = UpdatesManager()
self.networkStateManager = AppNetworkStateManager(parent=self)
@@ -263,7 +233,21 @@ class AppMainWindow(AppQMainWindow):
),
]
if PLATFORM == 'Windows' or PLATFORM == 'Darwin':
customizeTUNSettingsAction = [
AppQAction(
_('Customize TUN Settings...'),
icon=bootstrapIcon('diagram-3.svg'),
checkable=False,
callback=lambda: self.getGuiTUNSettings().open(),
),
AppQSeperator(),
]
else:
customizeTUNSettingsAction = []
toolsActions = [
*customizeTUNSettingsAction,
AppQAction(
_('Manage Xray-core Asset File...'),
callback=lambda: self.xrayAssetViewerWindow.show(),
@@ -402,6 +386,9 @@ class AppMainWindow(AppQMainWindow):
self.setCentralWidget(self._widget)
def updateSubsByUnique(self, unique: str, httpProxy: Union[str, None], **kwargs):
self.userServersQTableWidget.updateSubsByUnique(unique, httpProxy, **kwargs)
def appendNewItemByFactory(self, factory: ConfigurationFactory):
self.userServersQTableWidget.appendNewItemByFactory(factory)
@@ -414,15 +401,25 @@ class AppMainWindow(AppQMainWindow):
def hideTabAndSpaces(self):
self.userServersQTableWidget.hideTabAndSpaces()
def checkForUpdates(self):
def getGuiTUNSettings(self, **kwargs):
@functools.lru_cache(None)
def cachedGuiTUNSettings():
return GuiTUNSettings(self, **kwargs)
guiTUNSettings = cachedGuiTUNSettings()
guiTUNSettings.factoryToInput(AS_UserTUNSettings())
return guiTUNSettings
def checkForUpdates(self, **kwargs):
self.updatesManager.configureHttpProxy(connectedHttpProxyEndpoint())
self.updatesManager.checkForUpdates()
self.updatesManager.checkForUpdates(**kwargs)
def resetNetworkState(self):
self.networkState.setText('')
def setNetworkState(self, success: bool, **kwargs):
remark = connectedRemark()
remark = connectedServerRemark()
if success:
if remark:
+1 -1
View File
@@ -99,7 +99,7 @@ class LogViewerWindow(AppQMainWindow):
AppQSeperator(),
AppQAction(
_('Exit'),
callback=lambda: self.hide(),
callback=lambda: self.close(),
),
title=_('File'),
parent=self,
+2 -2
View File
@@ -33,7 +33,7 @@ import pyqrcode
__all__ = ['QRCodeWindow']
class QRCodeWindow(SupportImplicitReference, AppQMainWindow):
class QRCodeWindow(AppQMainWindow):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -83,4 +83,4 @@ class QRCodeWindow(SupportImplicitReference, AppQMainWindow):
self.tabWidget.removeTab(index)
if self.tabWidget.count() == 0:
self.hide()
self.close()
+31 -39
View File
@@ -124,7 +124,7 @@ class TextEditorWindow(AppQMainWindow):
AppQSeperator(),
AppQAction(
_('Close Window'),
callback=lambda: self.questionSave(),
callback=lambda: self.close(),
),
title=_('File'),
parent=self.menuBar(),
@@ -228,38 +228,6 @@ class TextEditorWindow(AppQMainWindow):
self.modified = False
self.setWindowTitle(self.customWindowTitle)
def questionSave(self):
if self.modified:
def handleButtonClicked(saveMBox, button):
assert isinstance(saveMBox, QuestionSaveMBox)
if saveMBox.button0 == button:
# Save
if self.save():
saveMBox.close()
self.hide()
elif saveMBox.button1 == button:
# Discard
self.markAsSaved()
saveMBox.close()
self.hide()
elif saveMBox.button2 == button:
# Cancel. Do nothing
pass
mbox = QuestionSaveMBox(icon=AppQMessageBox.Icon.Question, parent=self)
mbox.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
mbox.buttonClicked.connect(functools.partial(handleButtonClicked, mbox))
# dummy ref
setattr(self, '_questionSaveMBox', mbox)
# Show the MessageBox asynchronously
mbox.open()
else:
self.hide()
def setPlainText(self, text: str, blockSignals: bool):
if blockSignals:
with QBlockSignals(self.jsonEditor):
@@ -297,7 +265,7 @@ class TextEditorWindow(AppQMainWindow):
old = AS_UserServers()[index]
new = constructFromDict(jsonObject, **old.kwargs)
FastItemDeletionSearch.moveToTrash(old)
QGarbageCollector.track(old)
AS_UserServers()[index] = new
@@ -369,9 +337,6 @@ class TextEditorWindow(AppQMainWindow):
functools.partial(handleResultCode, indentSpinBox)
)
# dummy ref
setattr(self, '_IndentSpinBox', indentSpinBox)
# Show the MessageBox asynchronously
indentSpinBox.open()
@@ -393,9 +358,36 @@ class TextEditorWindow(AppQMainWindow):
self.jsonEditor.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
def closeEvent(self, event: QtCore.QEvent):
event.ignore()
if self.modified:
mbox = QuestionSaveMBox(icon=AppQMessageBox.Icon.Question, parent=self)
mbox.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
self.questionSave()
def handleButtonClicked(button):
if button == mbox.button0:
# Save
if self.save():
mbox.close()
event.accept()
else:
event.ignore()
elif button == mbox.button1:
# Discard
self.markAsSaved()
mbox.close()
event.accept()
elif button == mbox.button2:
# Cancel. Do nothing
event.ignore()
mbox.buttonClicked.connect(functools.partial(handleButtonClicked))
# Show the MessageBox and wait for the user to close it
mbox.exec()
else:
event.accept()
def retranslate(self):
# Do nothing
+1 -4
View File
@@ -116,7 +116,7 @@ class UserSubsWindow(AppQMainWindow):
except Exception:
# Any non-exit exceptions
self.setGeometry(100, 100, 360 * GOLDEN_RATIO, 360)
self.setGeometry(100, 100, 500 * GOLDEN_RATIO, 500)
def addSubs(self):
def handleResultCode(_addSubsDialog, code):
@@ -141,9 +141,6 @@ class UserSubsWindow(AppQMainWindow):
functools.partial(handleResultCode, addSubsDialog)
)
# dummy ref
setattr(self, '_addSubsDialog', addSubsDialog)
# Show the MessageBox asynchronously
addSubsDialog.open()
+1 -1
View File
@@ -77,7 +77,7 @@ class XrayAssetViewerWindow(AppQMainWindow):
AppQSeperator(),
AppQAction(
_('Exit'),
callback=lambda: self.hide(),
callback=lambda: self.close(),
),
title=_('File'),
parent=self.menuBar(),
+33 -23
View File
@@ -36,12 +36,12 @@ logger = logging.getLogger(__name__)
def runAppMain():
appMainProcess = AppMainProcess(functools.partial(Application, sys.argv))
process = AppMainProcess(functools.partial(Application, sys.argv))
appMainProcess.start()
appMainProcess.join()
process.start()
process.join()
exitcode = appMainProcess.exitcode
exitcode = process.exitcode
if exitcode == 0:
sys.exit(exitcode)
@@ -76,7 +76,7 @@ def runAppMain():
)
)
# Show the MessageBox and wait for user to close it
# Show the MessageBox and wait for the user to close it
messageBox.exec()
else:
if exitcode == ApplicationFactory.ExitCode.AssertionError:
@@ -96,34 +96,44 @@ def runAppMain():
messageBox.setWindowTitle(_(APPLICATION_NAME))
messageBox.setText(text)
if appMainProcess.fileWritten.value:
if process.fileWritten.value:
# Crash log saved
crashLogFile = str(CRASH_LOG_DIR / appMainProcess.logFileName)
crashLogFile = str(CRASH_LOG_DIR / process.logFileName)
logger.info(f'crash log has been saved to {crashLogFile}')
messageBox.setInformativeText(
_('Crash log has been saved to') + f' {crashLogFile}'
)
messageBox.addButton(
button0 = messageBox.addButton(
_('Open crash log'), AppQMessageBox.ButtonRole.AcceptRole
)
messageBox.addButton(_('OK'), AppQMessageBox.ButtonRole.RejectRole)
button1 = messageBox.addButton(
_('OK'), AppQMessageBox.ButtonRole.RejectRole
)
# Show the MessageBox and wait for user to close it
if messageBox.exec() == PySide6LegacyEnumValueWrapper(
AppQMessageBox.ButtonRole.AcceptRole
):
# Open
if QDesktopServices.openUrl(QtCore.QUrl.fromLocalFile(crashLogFile)):
logger.info(f'open crash log {crashLogFile} success')
def handleButtonClicked(button):
if button == button0:
# Open
if QDesktopServices.openUrl(
QtCore.QUrl.fromLocalFile(crashLogFile)
):
logger.info(f'open crash log {crashLogFile} success')
else:
logger.error(f'open crash log {crashLogFile} failed')
else:
logger.error(f'open crash log {crashLogFile} failed')
else:
# OK. Do nothing
pass
else:
# Crash log not saved
# OK. Do nothing
pass
# Show the MessageBox and wait for user to close it
messageBox.buttonClicked.connect(handleButtonClicked)
# Show the MessageBox and wait for the user to close it
messageBox.exec()
else:
# Crash log wasn't saved
logger.info(f'crash log was not saved')
# Show the MessageBox and wait for the user to close it
messageBox.exec()
sys.exit(exitcode)