Files
LorenEteval_Furious/Furious/Widget/UserServersQTableWidget.py
T
2025-06-04 16:15:50 +08:00

1832 lines
57 KiB
Python

# 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.QtFramework import *
from Furious.QtFramework import gettext as _
from Furious.Utility import *
from Furious.Library import *
from Furious.Core import *
from Furious.TrayActions.Import import *
from Furious.Widget.GuiHysteria1 import *
from Furious.Widget.GuiHysteria2 import *
from Furious.Widget.GuiShadowsocks import *
from Furious.Widget.GuiTrojan import *
from Furious.Widget.GuiVLESS import *
from Furious.Widget.GuiVMess import *
from Furious.Window.QRCodeWindow import *
from Furious.Window.TextEditorWindow import *
from PySide6 import QtCore
from PySide6.QtGui import *
from PySide6.QtWidgets import *
from PySide6.QtNetwork import *
from typing import Callable, Union, Sequence, MutableSequence
import queue
import logging
import icmplib
import functools
__all__ = ['UserServersQTableWidget']
logger = logging.getLogger(__name__)
registerAppSettings('ActivatedItemIndex')
# Migrate legacy settings
registerAppSettings('ServerWidgetSectionSizeTable')
registerAppSettings('UserServersHeaderViewState')
class UpdateSubsInfoMBox(AppQMessageBox):
def __init__(self, *args, **kwargs):
self.successArgs = kwargs.pop('successArgs', list())
self.failureArgs = kwargs.pop('failureArgs', list())
super().__init__(*args, **kwargs)
self.setWindowTitle(_(APPLICATION_NAME))
def customText(self):
if self.successArgs:
text = _('Update subscription completed') + '\n\n'
else:
text = _('Update subscription failed')
for param in self.successArgs:
remark, webURL = param['remark'], param['webURL']
text += (
f'\U00002705 {remark} - {webURL} '
+ _('Configuration has been updated')
+ '\n'
)
if self.successArgs and self.failureArgs:
text += '\n'
elif self.failureArgs:
text += '\n\n'
for param in self.failureArgs:
error, remark, webURL = param['error'], param['remark'], param['webURL']
# error is the specific failure reason. Not used
# for mbox elegant appearance
text += (
f'\U0000274C {remark} - {webURL} '
+ _('Configuration update failed')
+ '\n'
)
return text
def setColumnMinWidth(self):
if PLATFORM == 'Windows':
self.findChild(QGridLayout).setColumnMinimumWidth(
2,
max((len(row) + 10) for row in self.text().split('\n'))
* self.fontMetrics().averageCharWidth(),
)
def retranslate(self):
self.setWindowTitle(_(self.windowTitle()))
self.setText(self.customText())
self.setColumnMinWidth()
# Ignore informative text, buttons
self.moveToCenter()
class SubscriptionManager(WebGETManager):
# Collect unused references every 30 mins
GC_COLLECT_INTERVAL = 30 * 60 * 1000
def __init__(self, parent, **kwargs):
actionMessage = kwargs.pop('actionMessage', 'update subs')
super().__init__(parent, actionMessage=actionMessage)
def handleItemDeletionAndInsertion(self, **kwargs):
successArgs = kwargs.pop('successArgs', list())
failureArgs = kwargs.pop('failureArgs', list())
showMessageBox = kwargs.pop('showMessageBox', True)
for param in successArgs:
uris, unique = param['uris'], param['unique']
parent = self.parent()
if isinstance(parent, UserServersQTableWidget):
isConnected = APP().isSystemTrayConnected()
subsIndexes = list(
index
for index, server in enumerate(AS_UserServers())
if server.getExtras('subsId') == unique
)
subsGroupIndex = -1
activatedIndex = AS_UserActivatedItemIndex()
if activatedIndex in subsIndexes:
for index, server in enumerate(AS_UserServers()):
if index <= activatedIndex:
if server.getExtras('subsId') == unique:
subsGroupIndex += 1
else:
break
parent.deleteItemByIndex(
subsIndexes, showTrayMessage=bool(subsGroupIndex < 0)
)
remaining = len(AS_UserServers())
for uri in uris:
parent.appendNewItem(config=uri, subsId=unique)
if subsGroupIndex >= 0:
newIndex = remaining + subsGroupIndex
if newIndex < len(AS_UserServers()):
parent.activateItemByIndex(newIndex, True)
if isConnected and not APP().isSystemTrayConnected():
# Trigger connect
APP().systemTray.ConnectAction.trigger()
if not QGarbageCollector.isEnabled():
QGarbageCollector.collect(SubscriptionManager.GC_COLLECT_INTERVAL)
if showMessageBox:
mbox = UpdateSubsInfoMBox(
successArgs=successArgs,
failureArgs=failureArgs,
parent=kwargs.pop('parent', None),
)
if successArgs:
mbox.setIcon(AppQMessageBox.Icon.Information)
else:
mbox.setIcon(AppQMessageBox.Icon.Critical)
mbox.setText(mbox.customText())
mbox.setColumnMinWidth()
# Show the MessageBox asynchronously
mbox.open()
def successCallback(self, networkReply, **kwargs):
remark = kwargs.get('remark', '')
webURL = kwargs.get('webURL', '')
depthMap = kwargs.get('depthMap', {})
successArgs = kwargs.get('successArgs', list())
failureArgs = kwargs.get('failureArgs', list())
data = networkReply.readAll().data()
try:
uris = list(
filter(
lambda x: x != '',
PyBase64Encoder.decode(data).decode().split('\n'),
)
)
except Exception as ex:
# Any non-exit exceptions
def classname(ob) -> str:
return ob.__class__.__name__
logger.error(f'parse share link from \'{webURL}\' failed: {ex}')
failureArgs.append({'error': classname(ex), **kwargs})
else:
logger.info(
f'update subs ({remark}, {webURL}) success. Got {len(uris)} share link'
)
successArgs.append({'uris': uris, **kwargs})
depthMap['depth'] -= 1
if depthMap['depth'] == 0:
self.handleItemDeletionAndInsertion(**kwargs)
def failureCallback(self, networkReply, **kwargs):
remark = kwargs.get('remark', '')
webURL = kwargs.get('webURL', '')
depthMap = kwargs.get('depthMap', {})
successArgs = kwargs.get('successArgs', list())
failureArgs = kwargs.get('failureArgs', list())
logger.error(
f'update subs ({remark}, {webURL}) failed: {networkReply.errorString()}'
)
failureArgs.append({'error': networkReply.errorString(), **kwargs})
depthMap['depth'] -= 1
if depthMap['depth'] == 0:
self.handleItemDeletionAndInsertion(**kwargs)
def updateSubsByWebGET(self, **kwargs):
url = kwargs.get('webURL', '')
if not url:
# Has url empty check
return
logActionMessage = kwargs.pop('logActionMessage', False)
self.webGET(url, logActionMessage=logActionMessage, **kwargs)
def updateSubsByUnique(self, unique: str, **kwargs):
depthMap = kwargs.get('depthMap', {'depth': 1})
successArgs = kwargs.get('successArgs', list())
failureArgs = kwargs.get('failureArgs', list())
if kwargs.get('depthMap') is None:
kwargs['depthMap'] = depthMap
if kwargs.get('successArgs') is None:
kwargs['successArgs'] = successArgs
if kwargs.get('failureArgs') is None:
kwargs['failureArgs'] = failureArgs
subsob = AS_UserSubscription()[unique]
self.updateSubsByWebGET(unique=unique, **subsob, **kwargs)
def updateSubs(self, **kwargs):
depthMap = {'depth': len(AS_UserSubscription())}
successArgs = list()
failureArgs = list()
for key in AS_UserSubscription().keys():
self.updateSubsByUnique(
key,
depthMap=depthMap,
successArgs=successArgs,
failureArgs=failureArgs,
**kwargs,
)
class TestPingLatencyWorker(QtCore.QObject, QtCore.QRunnable):
finished = QtCore.Signal()
def __init__(self, factory: ConfigurationFactory):
# Explictly called __init__
QtCore.QObject.__init__(self)
QtCore.QRunnable.__init__(self)
self.factory = factory
def run(self):
index = self.factory.index()
if (
index < 0
or index >= len(AS_UserServers())
or QGarbageCollector.isTracked(self.factory)
):
# Invalid item. Do nothing
return
assert isinstance(self.factory, ConfigurationFactory)
try:
result = icmplib.ping(
self.factory.itemAddress,
count=1,
timeout=2,
interval=1,
)
except Exception as ex:
# Any non-exit exceptions
def classname(ob) -> str:
return ob.__class__.__name__
self.factory.setExtras('delayResult', classname(ex))
# Extra guard
if APP() is None or APP().isExiting():
return
else:
self.finished.emit()
else:
# Result address should not be empty
if result.address and result.is_alive:
self.factory.setExtras('delayResult', f'{round(result.avg_rtt)}ms')
else:
if result.packet_loss == 1:
self.factory.setExtras('delayResult', 'Timeout')
else:
self.factory.setExtras('delayResult', 'Error')
# Extra guard
if APP() is None or APP().isExiting():
return
else:
self.finished.emit()
class TestTcpingLatencyWorker(QtCore.QObject, QtCore.QRunnable):
finished = QtCore.Signal()
def __init__(self, factory: ConfigurationFactory):
# Explictly called __init__
QtCore.QObject.__init__(self)
QtCore.QRunnable.__init__(self)
self.factory = factory
def run(self):
index = self.factory.index()
if (
index < 0
or index >= len(AS_UserServers())
or QGarbageCollector.isTracked(self.factory)
):
# Invalid item. Do nothing
return
assert isinstance(self.factory, ConfigurationFactory)
try:
sent, rtts = tcping(
self.factory.itemAddress,
int(self.factory.itemPort.split(',')[0]),
count=1,
timeout=2,
interval=1,
)
except Exception as ex:
# Any non-exit exceptions
def classname(ob) -> str:
return ob.__class__.__name__
self.factory.setExtras('delayResult', classname(ex))
# Extra guard
if APP() is None or APP().isExiting():
return
else:
self.finished.emit()
else:
if rtts:
self.factory.setExtras('delayResult', f'{round(rtts[0] * 1000)}ms')
else:
self.factory.setExtras('delayResult', 'Timeout')
# Extra guard
if APP() is None or APP().isExiting():
return
else:
self.finished.emit()
class TestDownloadSpeedWorker(WebGETManager):
progressed = QtCore.Signal()
def __init__(self, factory: ConfigurationFactory, port: int, parent=None, **kwargs):
actionMessage = kwargs.pop('actionMessage', 'test download speed')
super().__init__(parent, actionMessage=actionMessage)
self.factory = factory
self.port = port
self.hasSpeedResult = False
self.totalBytesRead = 0
self.hasDataCounter = 0
self.coreManager = CoreManager()
self.networkReply = None
self.elapsedTimer = QtCore.QElapsedTimer()
def isFinished(self) -> bool:
if isinstance(self.networkReply, QNetworkReply):
return self.networkReply.isFinished()
else:
return True
def abort(self):
if isinstance(self.networkReply, QNetworkReply):
self.networkReply.abort()
def run(self):
index = self.factory.index()
if (
index < 0
or index >= len(AS_UserServers())
or QGarbageCollector.isTracked(self.factory)
):
# Invalid item. Do nothing
return
assert isinstance(self.factory, ConfigurationFactory)
if not self.factory.isValid():
self.factory.setExtras('speedResult', 'Invalid')
# Extra guard
if APP() is None or APP().isExiting():
return
else:
self.progressed.emit()
# Configuration is not valid. Do nothing
return
def coreExitCallback(config: ConfigurationFactory, exitcode: int):
if exitcode == CoreProcess.ExitCode.ConfigurationError:
self.factory.setExtras('speedResult', f'Invalid')
# Extra guard
if APP() is None or APP().isExiting():
return
else:
self.progressed.emit()
elif exitcode == CoreProcess.ExitCode.ServerStartFailure:
self.factory.setExtras('speedResult', f'Core start failed')
# Extra guard
if APP() is None or APP().isExiting():
return
else:
self.progressed.emit()
elif exitcode == CoreProcess.ExitCode.SystemShuttingDown:
pass
else:
self.factory.setExtras('speedResult', f'Core exited {exitcode}')
# Extra guard
if APP() is None or APP().isExiting():
return
else:
self.progressed.emit()
def msgCallback(line: str):
try:
loggerCore().appendLine(line)
except Exception:
# Any non-exit exceptions
pass
self.factory.setExtras('speedResult', 'Starting')
# Extra guard
if APP() is None or APP().isExiting():
return
else:
self.progressed.emit()
itemcopy = self.factory.deepcopy()
if isinstance(itemcopy, ConfigurationXray):
# Force redirect
itemcopy['inbounds'] = [
{
'tag': 'http',
'port': self.port,
'listen': '127.0.0.1',
'protocol': 'http',
'sniffing': {
'enabled': True,
'destOverride': [
'http',
'tls',
],
},
'settings': {
'auth': 'noauth',
'udp': True,
'allowTransparent': False,
},
},
]
try:
for outboundObject in itemcopy['outbounds']:
if outboundObject['tag'] == f'proxy':
# Avoid confusion with potentially existing 'proxy' tag
outboundObject['tag'] = f'proxy{self.port}'
except Exception:
# Any non-exit exceptions
pass
self.coreManager.start(
itemcopy,
'Global',
coreExitCallback,
msgCallbackCore=msgCallback,
deepcopy=False,
proxyModeOnly=True,
log=False,
)
elif isinstance(itemcopy, ConfigurationHysteria1) or isinstance(
itemcopy, ConfigurationHysteria2
):
# Force redirect
itemcopy['http'] = {
'listen': f'127.0.0.1:{self.port}',
'timeout': 300,
'disable_udp': False,
}
# No socks inbounds
itemcopy.pop('socks5', '')
self.coreManager.start(
itemcopy,
'Global',
coreExitCallback,
msgCallbackCore=msgCallback,
deepcopy=False,
proxyModeOnly=True,
log=False,
)
else:
self.factory.setExtras('speedResult', 'Invalid')
# Extra guard
if APP() is None or APP().isExiting():
return
else:
self.progressed.emit()
# Unrecognized core. Return
return
self.configureHttpProxy(f'127.0.0.1:{self.port}')
self.networkReply = self.webGET(NETWORK_SPEED_TEST_URL)
self.elapsedTimer.start()
def successCallback(self, networkReply, **kwargs):
if self.coreManager.allRunning():
self.totalBytesRead += networkReply.readAll().length()
# Convert to seconds
elapsedSecond = self.elapsedTimer.elapsed() / 1000
downloadSpeed = self.totalBytesRead / elapsedSecond / 1024 / 1024
self.factory.setExtras('speedResult', f'{downloadSpeed:.2f} MiB/s')
else:
self.factory.setExtras('speedResult', f'Core start failed')
self.coreManager.stopAll()
# Extra guard
if APP() is None or APP().isExiting():
return
else:
self.progressed.emit()
def hasDataCallback(self, networkReply, **kwargs):
self.hasDataCounter += 1
if self.coreManager.allRunning():
self.totalBytesRead += networkReply.readAll().length()
# Convert to seconds
elapsedSecond = self.elapsedTimer.elapsed() / 1000
downloadSpeed = self.totalBytesRead / elapsedSecond / 1024 / 1024
# Has speed test result
self.hasSpeedResult = True
self.factory.setExtras('speedResult', f'{downloadSpeed:.2f} MiB/s')
# Extra guard
if APP() is None or APP().isExiting():
return
else:
if self.hasDataCounter % 10 == 0:
# Limited to save CPU resources
self.progressed.emit()
def failureCallback(self, networkReply, **kwargs):
if not self.hasSpeedResult:
if not self.coreManager.allRunning():
# Core ExitCallback has been called
return
if (
networkReply.error()
== QNetworkReply.NetworkError.OperationCanceledError
):
# Canceled by application
self.factory.setExtras('speedResult', 'Canceled')
else:
try:
error = networkReply.error().name
except Exception:
# Any non-exit exceptions
error = 'UnknownError'
if isinstance(error, bytes):
# Some old version PySide6 returns it as bytes. Protect it.
errorString = error.decode('utf-8', 'replace')
elif isinstance(error, str):
errorString = error
else:
errorString = 'UnknownError'
if errorString != 'UnknownError' and errorString.endswith('Error'):
self.factory.setExtras('speedResult', errorString[:-5])
else:
self.factory.setExtras('speedResult', errorString)
self.coreManager.stopAll()
# Extra guard
if APP() is None or APP().isExiting():
return
else:
self.progressed.emit()
class UserServersQTableWidgetHorizontalHeader(AppQHeaderView):
class SortOrder:
Ascending_ = False
Descending = True
@staticmethod
def emptyClickGuard():
return False
def __init__(self, *args, **kwargs):
self.clickGuardFn = kwargs.pop('clickGuardFn', self.emptyClickGuard)
self.customSortFn = kwargs.pop('customSortFn', None)
super().__init__(QtCore.Qt.Orientation.Horizontal, *args, **kwargs)
self.sortOrderTable = list(
self.SortOrder.Ascending_ for i in range(self.columnCount)
)
self.sectionClicked.connect(self.handleSectionClicked)
def customSort(self, clickedIndex):
self.customSortFn(clickedIndex, reverse=self.sortOrderTable[clickedIndex])
# Toggle
self.sortOrderTable[clickedIndex] = not self.sortOrderTable[clickedIndex]
@QtCore.Slot(int)
def handleSectionClicked(self, clickedIndex: int):
if callable(self.clickGuardFn) and self.clickGuardFn():
# Guarded. Do nothing
return
if self.customSortFn is None:
# Sorting is not supported
return
parent = self.parent()
if isinstance(parent, AppQTableWidget):
# Support item activation
activatedIndex = AS_UserActivatedItemIndex()
if activatedIndex < 0:
activatedServerId = None
else:
activatedServerId = id(AS_UserServers()[activatedIndex])
# De-activated temporarily for sorting
parent.activateItemByIndex(activatedIndex, False)
self.customSort(clickedIndex)
if activatedServerId is not None:
foundActivatedItem = False
for index, server in enumerate(AS_UserServers()):
if activatedServerId == id(server):
foundActivatedItem = True
parent.setCurrentItem(parent.item(index, 0))
parent.activateItemByIndex(index, True)
break
# Object id should be found
assert foundActivatedItem
else:
# Not yet implemented
pass
class UserServersQTableWidgetVerticalHeader(AppQHeaderView):
def __init__(self, *args, **kwargs):
super().__init__(QtCore.Qt.Orientation.Vertical, *args, **kwargs)
class UserServersQTableWidgetHeaders:
def __init__(self, name: str, func: Callable[[ConfigurationFactory], str] = None):
self.name = name
self.func = func
def __call__(self, item: ConfigurationFactory) -> str:
if callable(self.func):
return self.func(item)
else:
return getattr(item, f'item{self}')
def __eq__(self, other):
return str(self) == str(other)
def __str__(self):
return self.name
# ALL Headers VALUE
TRANSLATABLE_HEADERS = [
_('Remark'),
_('Protocol'),
_('Address'),
_('Port'),
_('Transport'),
_('TLS'),
_('Subscription'),
_('Latency'),
_('Speed'),
]
class UserServersQTableWidget(QTranslatable, AppQTableWidget):
Headers = [
UserServersQTableWidgetHeaders('Remark'),
UserServersQTableWidgetHeaders('Protocol'),
UserServersQTableWidgetHeaders('Address'),
UserServersQTableWidgetHeaders('Port'),
UserServersQTableWidgetHeaders('Transport'),
UserServersQTableWidgetHeaders('TLS'),
UserServersQTableWidgetHeaders('Subscription'),
UserServersQTableWidgetHeaders('Latency'),
UserServersQTableWidgetHeaders('Speed'),
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.subsManager = SubscriptionManager(parent=self)
self.testDownloadSpeedQueue = queue.Queue()
self.testDownloadSpeedTimer = QtCore.QTimer()
self.testDownloadSpeedTimer.timeout.connect(self.handleTestDownloadSpeedJob)
# Text Editor Window
self.textEditorWindow = TextEditorWindow(parent=self.parent())
# Must set before flush all
self.setColumnCount(len(self.Headers))
# Flush all data to table
self.flushAll()
# Install custom header
self.setHorizontalHeader(
UserServersQTableWidgetHorizontalHeader(
parent=self,
clickGuardFn=lambda: False,
customSortFn=self.customSortFn,
legacySectionSizeSettingsName='ServerWidgetSectionSizeTable',
sectionSizeSettingsName='UserServersHeaderViewState',
)
)
self.setVerticalHeader(UserServersQTableWidgetVerticalHeader(self))
self.horizontalHeader().setCustomSectionResizeMode()
self.horizontalHeader().restoreSectionSize()
self.setHorizontalHeaderLabels(list(_(str(header)) for header in self.Headers))
# Selection
self.setSelectionColor(AppHue.disconnectedColor())
self.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self.setSelectionMode(QTableWidget.SelectionMode.ExtendedSelection)
# No drag and drop
self.setDragEnabled(False)
self.setDragDropMode(QAbstractItemView.DragDropMode.NoDragDrop)
self.setDropIndicatorShown(False)
self.setDefaultDropAction(QtCore.Qt.DropAction.IgnoreAction)
self.customizeJSONConfigActionRef = AppQAction(
_('Customize JSON Configuration...'),
icon=bootstrapIcon('pencil-square.svg'),
callback=lambda: self.editSelectedItemConfiguration(),
shortcut=QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier.ControlModifier,
QtCore.Qt.Key.Key_E,
),
)
self.advancedActionRef = AppQAction(
_('Advanced...'),
menu=AppQMenu(
self.customizeJSONConfigActionRef,
),
useActionGroup=False,
checkable=True,
)
contextMenuActions = [
AppQAction(
_('Move Up'),
callback=lambda: self.moveUpSelectedItem(),
shortcut=QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier.ControlModifier,
QtCore.Qt.Key.Key_Up,
),
),
AppQAction(
_('Move Down'),
callback=lambda: self.moveDownSelectedItem(),
shortcut=QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier.ControlModifier,
QtCore.Qt.Key.Key_Down,
),
),
AppQAction(
_('Duplicate'),
callback=lambda: self.duplicateSelectedItem(),
),
AppQAction(
_('Delete'),
callback=lambda: self.deleteSelectedItem(),
shortcut=QtCore.QKeyCombination(
QtCore.Qt.Key.Key_Delete,
),
),
AppQSeperator(),
AppQAction(
_('Select All'),
callback=lambda: self.selectAll(),
shortcut=QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier.ControlModifier,
QtCore.Qt.Key.Key_A,
),
),
AppQSeperator(),
AppQAction(
_('Scroll To Activated Server'),
callback=lambda: self.scrollToActivatedItem(),
shortcut=QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier.ControlModifier,
QtCore.Qt.Key.Key_G,
),
),
AppQSeperator(),
AppQAction(
_('Test Ping Latency'),
callback=lambda: self.testSelectedItemPingLatency(),
shortcut=QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier.ControlModifier,
QtCore.Qt.Key.Key_P,
),
),
AppQAction(
_('Test Tcping Latency'),
callback=lambda: self.testSelectedItemTcpingLatency(),
shortcut=QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier.ControlModifier,
QtCore.Qt.Key.Key_O,
),
),
AppQAction(
_('Test Download Speed'),
callback=lambda: self.testSelectedItemDownloadSpeed(),
shortcut=QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier.ControlModifier,
QtCore.Qt.Key.Key_T,
),
),
AppQAction(
_('Clear Test Results'),
callback=lambda: self.clearSelectedItemTestResult(),
shortcut=QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier.ControlModifier,
QtCore.Qt.Key.Key_R,
),
),
AppQSeperator(),
self.advancedActionRef,
AppQSeperator(),
AppQAction(
_('New Empty Configuration'),
callback=lambda: self.newEmptyItem(),
shortcut=QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier.ControlModifier,
QtCore.Qt.Key.Key_N,
),
),
ImportFromFileAction(),
ImportURIFromClipboardAction(
shortcut=QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier.ControlModifier,
QtCore.Qt.Key.Key_V,
),
),
ImportJSONFromClipboardAction(
shortcut=QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier.ControlModifier
| QtCore.Qt.KeyboardModifier.ShiftModifier,
QtCore.Qt.Key.Key_J,
),
),
AppQSeperator(),
AppQAction(
_('Export Share Link To Clipboard'),
callback=lambda: self.exportSelectedItemURI(),
shortcut=QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier.ControlModifier,
QtCore.Qt.Key.Key_C,
),
),
AppQAction(
_('Export As QR Code'),
icon=bootstrapIcon('qr-code.svg'),
callback=lambda: self.exportSelectedItemQR(),
),
AppQAction(
_('Export JSON Configuration To Clipboard'),
callback=lambda: self.exportSelectedItemJSON(),
shortcut=QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier.ControlModifier,
QtCore.Qt.Key.Key_J,
),
),
]
self.contextMenu = AppQMenu(*contextMenuActions)
# Add actions to self in order to activate shortcuts
self.addActions(self.contextMenu.actions())
self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu)
self.customContextMenuRequested.connect(self.handleCustomContextMenuRequested)
# Distinguish double-click and activated
self.doubleClickedFlag = False
# Signals
self.itemChanged.connect(self.handleItemChanged)
self.itemSelectionChanged.connect(self.handleItemSelectionChanged)
self.itemActivated.connect(self.handleItemActivated)
self.itemDoubleClicked.connect(self.handleItemDoubleClicked)
if self.activatedItem() is not None:
self.setCurrentItem(self.activatedItem())
self.activateItemByIndex(AS_UserActivatedItemIndex(), True)
@QtCore.Slot(QTableWidgetItem)
def handleItemChanged(self, item: QTableWidgetItem):
pass
# TODO: In use?
# index = item.row()
#
# if self.item(index, 0) is None:
# return
#
# itemText = self.item(index, 0).text()
#
# if AS_UserServers()[index].getExtras('remark') != itemText:
# AS_UserServers()[index].setExtras('remark', itemText)
@QtCore.Slot()
def handleItemSelectionChanged(self):
if len(self.selectedIndex) > 1:
self.customizeJSONConfigActionRef.setDisabled(True)
else:
self.customizeJSONConfigActionRef.setDisabled(False)
@QtCore.Slot(QTableWidgetItem)
def handleItemActivated(self, item: QTableWidgetItem):
if self.doubleClickedFlag:
# Ignore double-click
self.doubleClickedFlag = False
return
oldIndex = AS_UserActivatedItemIndex()
newIndex = item.row()
if oldIndex == newIndex:
# Same item activated. Do nothing
return
if APP().systemTray.ConnectAction.isConnecting():
mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Information)
mbox.setWindowTitle(_('Connecting'))
mbox.setText(_('Connecting. Please wait...'))
if PLATFORM != 'Darwin':
# Show the MessageBox asynchronously
mbox.open()
else:
# Show the MessageBox asynchronously
# TODO: Verify
mbox.open()
return
if oldIndex >= 0:
self.activateItemByIndex(oldIndex, False)
self.activateItemByIndex(newIndex, True)
if APP().isSystemTrayConnected():
APP().systemTray.ConnectAction.doDisconnect()
APP().systemTray.ConnectAction.trigger()
@functools.lru_cache(None)
def getGuiEditorByProtocol(self, protocol, **kwargs):
logger.debug(f'getGuiEditorByProtocol called with protocol {protocol}')
if protocolRepr(protocol) == Protocol.VMess:
return GuiVMess(parent=self, **kwargs)
if protocolRepr(protocol) == Protocol.VLESS:
return GuiVLESS(parent=self, **kwargs)
if protocolRepr(protocol) == Protocol.Shadowsocks:
return GuiShadowsocks(parent=self, **kwargs)
if protocolRepr(protocol) == Protocol.Trojan:
return GuiTrojan(parent=self, **kwargs)
if protocolRepr(protocol) == Protocol.Hysteria2:
return GuiHysteria2(parent=self, **kwargs)
if protocolRepr(protocol) == Protocol.Hysteria1:
return GuiHysteria1(parent=self, **kwargs)
return None
def getGuiEditorByFactory(self, factory, **kwargs):
if isinstance(factory, ConfigurationXray):
protocol = factory.proxyProtocol
guiEditor = self.getGuiEditorByProtocol(protocol, **kwargs)
elif isinstance(factory, ConfigurationHysteria2):
guiEditor = self.getGuiEditorByProtocol(Protocol.Hysteria2, **kwargs)
elif isinstance(factory, ConfigurationHysteria1):
guiEditor = self.getGuiEditorByProtocol(Protocol.Hysteria1, **kwargs)
else:
guiEditor = None
return guiEditor
@QtCore.Slot(QTableWidgetItem)
def handleItemDoubleClicked(self, item: QTableWidgetItem):
self.doubleClickedFlag = True
index = item.row()
factory = AS_UserServers()[index]
# Do not translate window title
guiEditor = self.getGuiEditorByFactory(factory, translatable=False)
if guiEditor is None:
# Unrecognized.
showUnrecognizedConfigMBox()
return
guiEditor.setWindowTitle(f'{index + 1} - ' + factory.getExtras('remark'))
try:
guiEditor.factoryToInput(factory)
except Exception as ex:
# Any non-exit exceptions
logger.error(f'error while converting factory to input: {ex}')
guiEditor.accepted.connect(
functools.partial(
self.handleGuiEditorAccepted,
guiEditor,
index,
factory,
)
)
guiEditor.rejected.connect(
functools.partial(
self.handleGuiEditorRejected,
guiEditor,
)
)
guiEditor.open()
def handleGuiEditorAccepted(
self,
editor: GuiEditorWidgetQDialog,
index: int,
factory: ConfigurationFactory,
):
logger.debug(f'guiEditor accepted with index {index}')
modified = editor.inputToFactory(factory)
# Still flush to row since remark may be modified
self.flushRow(index, factory)
if modified and index == AS_UserActivatedItemIndex():
showNewChangesNextTimeMBox()
editor.accepted.disconnect()
editor.rejected.disconnect()
@staticmethod
def handleGuiEditorRejected(editor: GuiEditorWidgetQDialog):
editor.accepted.disconnect()
editor.rejected.disconnect()
@QtCore.Slot(QtCore.QPoint)
def handleCustomContextMenuRequested(self, point):
self.contextMenu.exec(self.mapToGlobal(point))
def customSortFn(self, clickedIndex, **kwargs):
def keyFn(server):
data = self.Headers[clickedIndex](server)
if clickedIndex == self.Headers.index('Latency'):
if data.endswith('ms'):
# Strip value
data = data[:-2]
try:
return float(data)
except Exception:
# Any non-exit exceptions
return abs(hash(data)) + 2**20
if clickedIndex == self.Headers.index('Speed'):
if data.endswith(' MiB/s'):
# Strip value
data = data[:-6]
try:
return float(data)
except Exception:
# Any non-exit exceptions
return abs(hash(data)) + 2**20
return data
AS_UserServers().sort(key=keyFn, **kwargs)
# Index is refreshed by calling flushAll()
self.flushAll()
def activatedItem(self) -> QTableWidgetItem:
return self.item(AS_UserActivatedItemIndex(), 0)
def activateItemByIndex(self, index, activate):
super().activateItemByIndex(index, activate)
if activate:
AppSettings.set('ActivatedItemIndex', str(index))
def flushItem(self, row: int, column: int, item: ConfigurationFactory):
itemIndex = item.index()
if (
itemIndex < 0
or itemIndex >= len(AS_UserServers())
or QGarbageCollector.isTracked(item)
):
# Invalid item. Do nothing
return
def searchIndex(start, stop, step=1):
nonlocal itemIndex
for _index in range(start, stop, step):
if id(item) == id(AS_UserServers()[_index]):
itemIndex = _index
item.setIndex(itemIndex)
return True
return False
if id(item) != id(AS_UserServers()[itemIndex]):
# itemIndex doesn't match
if searchIndex(itemIndex - 1, -1, -1) or searchIndex(
itemIndex + 1, len(AS_UserServers())
):
pass
else:
# Item isn't found in user servers. Do nothing
return
if row != itemIndex:
# Adjust row value
row = itemIndex
else:
pass
header = self.Headers[column]
oldItem = self.item(row, column)
newItem = QTableWidgetItem(header(item))
if oldItem is None:
# Item does not exist
newItem.setFont(QFont(APP().customFontName))
if str(header) == 'Latency' or str(header) == 'Speed':
# Test results. Align right and vcenter
newItem.setTextAlignment(
QtCore.Qt.AlignmentFlag.AlignRight
| QtCore.Qt.AlignmentFlag.AlignVCenter
)
else:
# Use existing
newItem.setFont(oldItem.font())
newItem.setForeground(oldItem.foreground())
if oldItem.textAlignment() != 0:
newItem.setTextAlignment(oldItem.textAlignment())
#
# Legacy flags. Not used
#
# if str(header) == 'Remark':
# # Remark is editable
# newItem.setFlags(
# QtCore.Qt.ItemFlag.ItemIsEnabled
# | QtCore.Qt.ItemFlag.ItemIsSelectable
# | QtCore.Qt.ItemFlag.ItemIsEditable
# )
# else:
# newItem.setFlags(
# QtCore.Qt.ItemFlag.ItemIsEnabled | QtCore.Qt.ItemFlag.ItemIsSelectable
# )
# Remark is now editable via GUI window
newItem.setFlags(
QtCore.Qt.ItemFlag.ItemIsEnabled | QtCore.Qt.ItemFlag.ItemIsSelectable
)
self.setItem(row, column, newItem)
def addServerViaGui(
self,
protocol: str,
windowTitle: str = APPLICATION_NAME,
**kwargs,
):
factory = getEmptyFactory(protocol)
guiEditor = self.getGuiEditorByFactory(factory, **kwargs)
if guiEditor is None:
# Unrecognized. Do nothing
return
guiEditor.setWindowTitle(windowTitle)
try:
guiEditor.factoryToInput(factory)
except Exception as ex:
# Any non-exit exceptions
logger.error(f'error while converting factory to input: {ex}')
guiEditor.accepted.connect(
functools.partial(
self.handleAddServerViaGuiAccepted,
guiEditor,
factory,
)
)
guiEditor.rejected.connect(
functools.partial(
self.handleAddServerViaGuiRejected,
guiEditor,
)
)
guiEditor.open()
def handleAddServerViaGuiAccepted(
self,
editor: GuiEditorWidgetQDialog,
factory: ConfigurationFactory,
):
editor.inputToFactory(factory)
self.appendNewItemByFactory(factory)
editor.accepted.disconnect()
editor.rejected.disconnect()
def handleAddServerViaGuiRejected(self, editor: GuiEditorWidgetQDialog):
editor.accepted.disconnect()
editor.rejected.disconnect()
def flushRow(self, row: int, item: ConfigurationFactory):
for column in list(range(self.columnCount())):
self.flushItem(row, column, item)
def flushAll(self):
# Refresh index
for index, item in enumerate(AS_UserServers()):
item.setIndex(index)
if self.rowCount() == 0:
# Should insert row
for index, item in enumerate(AS_UserServers()):
self.insertRow(index)
self.flushRow(index, item)
else:
for index, item in enumerate(AS_UserServers()):
self.flushRow(index, item)
def swapItem(self, index0: int, index1: int):
def swapSequenceItem(sequence: MutableSequence, param0: int, param1: int):
swap = sequence[param0]
sequence[param0] = sequence[param1]
sequence[param1] = swap
swapSequenceItem(AS_UserServers(), index0, index1)
# Refresh index
AS_UserServers()[index0].setIndex(index1)
AS_UserServers()[index1].setIndex(index0)
self.flushRow(index0, AS_UserServers()[index0])
self.flushRow(index1, AS_UserServers()[index1])
if index0 == AS_UserActivatedItemIndex():
# De-activate
self.activateItemByIndex(index0, False)
# Activate
self.activateItemByIndex(index1, True)
elif index1 == AS_UserActivatedItemIndex():
# De-activate
self.activateItemByIndex(index1, False)
# Activate
self.activateItemByIndex(index0, True)
def newEmptyItem(self):
self.appendNewItem(remark=_('Untitled'), acceptInvalid=True)
def moveUpItemByIndex(self, index):
if index <= 0 or index >= len(AS_UserServers()):
# The top item, or does not exist. Do nothing
return
self.swapItem(index, index - 1)
def moveUpSelectedItem(self):
indexes = self.selectedIndex
if len(indexes) == 0:
# Nothing selected. Do nothing
return
for index in indexes:
self.moveUpItemByIndex(index)
with QBlockSignals(self):
self.setCurrentIndex(self.indexFromItem(self.item(indexes[-1] - 1, 0)))
self.selectMultipleRows(list(index - 1 for index in indexes), True)
def moveDownItemByIndex(self, index):
if index < 0 or index >= len(AS_UserServers()) - 1:
# The bottom item, or does not exist. Do nothing
return
self.swapItem(index, index + 1)
def moveDownSelectedItem(self):
indexes = self.selectedIndex
if len(indexes) == 0:
# Nothing selected. Do nothing
return
for index in indexes[::-1]:
self.moveDownItemByIndex(index)
with QBlockSignals(self):
self.setCurrentIndex(self.indexFromItem(self.item(indexes[0] + 1, 0)))
self.selectMultipleRows(list(index + 1 for index in indexes), True)
def duplicateSelectedItem(self):
indexes = self.selectedIndex
if len(indexes) == 0:
# Nothing selected. Do nothing
return
for index in indexes:
if 0 <= index < len(AS_UserServers()):
# Do not clone subsId
self.appendNewItem(
remark=AS_UserServers()[index].getExtras('remark'),
config=AS_UserServers()[index],
)
def deleteItemByIndex(self, indexes, showTrayMessage=True) -> int:
if len(indexes) == 0:
# Nothing selected. Do nothing
return 0
if AS_UserActivatedItemIndex() in indexes:
deleteActivated = True
else:
deleteActivated = False
# Note: param indexes must be sorted
for i in range(len(indexes)):
deleteIndex = indexes[i] - i
with QBlockSignals(self):
self.removeRow(deleteIndex)
QGarbageCollector.track(AS_UserServers()[deleteIndex])
AS_UserServers().pop(deleteIndex)
if not deleteActivated and deleteIndex < AS_UserActivatedItemIndex():
AppSettings.set(
'ActivatedItemIndex', str(AS_UserActivatedItemIndex() - 1)
)
if deleteActivated:
# Set invalid first
AppSettings.set('ActivatedItemIndex', str(-1))
if APP().isSystemTrayConnected():
if showTrayMessage:
# Trigger disconnect
APP().systemTray.ConnectAction.trigger()
else:
# Trigger disconnect silently
APP().systemTray.ConnectAction.doDisconnect()
return len(indexes)
def deleteSelectedItem(self):
indexes = self.selectedIndex
if len(indexes) == 0:
# Nothing selected. Do nothing
return
def handleResultCode(_indexes, code):
if code == PySide6LegacyEnumValueWrapper(AppQMessageBox.StandardButton.Yes):
self.deleteItemByIndex(_indexes)
else:
pass
if PLATFORM == 'Windows':
# Windows
mbox = QuestionDeleteMBox(icon=AppQMessageBox.Icon.Question)
else:
# macOS & linux
mbox = QuestionDeleteMBox(
icon=AppQMessageBox.Icon.Question, parent=self.parent()
)
mbox.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
mbox.isMulti = bool(len(indexes) > 1)
mbox.possibleRemark = f'{indexes[0] + 1} - {self.item(indexes[0], 0).text()}'
mbox.setText(mbox.customText())
mbox.finished.connect(functools.partial(handleResultCode, indexes))
# Show the MessageBox asynchronously
mbox.open()
def editSelectedItemConfiguration(self):
indexes = self.selectedIndex
if len(indexes) == 0:
# Nothing selected. Do nothing
return
if len(indexes) != 1:
# Should not reach here
return
index = indexes[0]
title = f'{index + 1} - ' + AS_UserServers()[index].getExtras('remark')
self.textEditorWindow.currentIndex = index
self.textEditorWindow.customWindowTitle = title
self.textEditorWindow.setWindowTitle(title)
self.textEditorWindow.setPlainText(AS_UserServers()[index].toJSONString(), True)
self.textEditorWindow.show()
def scrollToActivatedItem(self):
activatedItem = self.activatedItem()
self.setCurrentItem(activatedItem)
self.scrollToItem(activatedItem)
def testSelectedItemPingLatency(self):
indexes = self.selectedIndex
if len(indexes) == 0:
# Nothing selected. Do nothing
return
# Real selected factory
references = list(AS_UserServers()[index] for index in indexes)
for index, reference in zip(indexes, references):
worker = TestPingLatencyWorker(reference)
worker.setAutoDelete(True)
worker.finished.connect(
functools.partial(
self.flushItem,
index,
self.Headers.index('Latency'),
reference,
)
)
AppThreadPool().start(worker)
def testSelectedItemTcpingLatency(self):
indexes = self.selectedIndex
if len(indexes) == 0:
# Nothing selected. Do nothing
return
# Real selected factory
references = list(AS_UserServers()[index] for index in indexes)
for index, reference in zip(indexes, references):
worker = TestTcpingLatencyWorker(reference)
worker.setAutoDelete(True)
worker.finished.connect(
functools.partial(
self.flushItem,
index,
self.Headers.index('Latency'),
reference,
)
)
AppThreadPool().start(worker)
@QtCore.Slot()
def handleTestDownloadSpeedJob(self):
try:
index, factory, timeout = self.testDownloadSpeedQueue.get_nowait()
except queue.Empty:
# Queue is empty
# Power Optimization. Timer gets fired only when needed
self.testDownloadSpeedTimer.stop()
return
assert isinstance(factory, ConfigurationFactory)
def testDownloadSpeed(counter=0, step=100):
worker = TestDownloadSpeedWorker(
factory, port=20809, logActionMessage=False
)
worker.progressed.connect(
functools.partial(
self.flushItem,
index,
self.Headers.index('Speed'),
factory,
)
)
worker.run()
while (
not APP().isExiting() and not worker.isFinished() and counter < timeout
):
PySide6LegacyEventLoopWait(step)
counter += step
if not worker.isFinished():
worker.abort()
while not worker.isFinished():
PySide6LegacyEventLoopWait(step)
if APP().isExiting():
# Stop timer
self.testDownloadSpeedTimer.stop()
testDownloadSpeed()
def testSelectedItemDownloadSpeedWithTimeout(self, timeout: int):
indexes = self.selectedIndex
if len(indexes) == 0:
# Nothing selected. Do nothing
return
# Real selected factory
references = list(AS_UserServers()[index] for index in indexes)
for index, reference in zip(indexes, references):
try:
# Served by FIFO
self.testDownloadSpeedQueue.put_nowait((index, reference, timeout))
except Exception:
# Any non-exit exceptions
pass
# Power Optimization
if not self.testDownloadSpeedTimer.isActive():
self.testDownloadSpeedTimer.start(250)
def testSelectedItemDownloadSpeed(self):
self.testSelectedItemDownloadSpeedWithTimeout(5000)
def clearSelectedItemTestResult(self):
indexes = self.selectedIndex
if len(indexes) == 0:
# Nothing selected. Do nothing
return
for index in indexes:
factory = AS_UserServers()[index]
factory.setExtras('delayResult', '')
factory.setExtras('speedResult', '')
self.flushItem(index, self.Headers.index('Latency'), factory)
self.flushItem(index, self.Headers.index('Speed'), factory)
def updateSubsByUnique(self, unique: str, httpProxy: Union[str, None], **kwargs):
self.subsManager.configureHttpProxy(httpProxy)
self.subsManager.updateSubsByUnique(unique, **kwargs)
def updateSubs(self, httpProxy: Union[str, None], **kwargs):
self.subsManager.configureHttpProxy(httpProxy)
self.subsManager.updateSubs(**kwargs)
def appendNewItemByFactory(self, factory: ConfigurationFactory):
index = len(AS_UserServers())
# Set index
factory.setIndex(index)
AS_UserServers().append(factory)
row = self.rowCount()
assert index == row
self.insertRow(row)
self.flushRow(row, factory)
if index == 0:
# The first one. Click it
self.setCurrentItem(self.item(0, 0))
# Try to be user-friendly in some extreme cases
if not APP().isSystemTrayConnected():
# Activate automatically
self.activateItemByIndex(0, True)
def appendNewItem(self, **kwargs):
acceptInvalid = kwargs.pop('acceptInvalid', False)
model = {
'remark': kwargs.pop('remark', ''),
'config': kwargs.pop('config', ''),
'subsId': kwargs.pop('subsId', ''),
}
tostr = f'{model}'
factory = constructFromAny(model.pop('config', ''), **model)
if factory.isValid():
self.appendNewItemByFactory(factory)
else:
if acceptInvalid:
self.appendNewItemByFactory(factory)
else:
logger.error(f'invalid item: {tostr}')
def exportSelectedItemURI(self):
indexes = self.selectedIndex
if len(indexes) == 0:
# Nothing selected. Do nothing
return
def toURI(factory) -> str:
assert isinstance(factory, ConfigurationFactory)
try:
return factory.toURI()
except Exception:
# Any non-exit exceptions
return ''
# TODO: MessageBox?
QApplication.clipboard().setText(
'\n'.join(list(toURI(AS_UserServers()[index]) for index in indexes))
)
def exportSelectedItemQR(self):
indexes = self.selectedIndex
if len(indexes) == 0:
# Nothing selected. Do nothing
return
window = QRCodeWindow()
window.initTabByIndex(indexes)
if window.tabCount() > 0:
window.show()
def exportSelectedItemJSON(self):
indexes = self.selectedIndex
if len(indexes) == 0:
# Nothing selected. Do nothing
return
# TODO: MessageBox?
QApplication.clipboard().setText(
'\n'.join(list(AS_UserServers()[index].toJSONString() for index in indexes))
)
def showTabAndSpaces(self):
self.textEditorWindow.showTabAndSpaces()
def hideTabAndSpaces(self):
self.textEditorWindow.hideTabAndSpaces()
def keyPressEvent(self, event):
if event.key() == QtCore.Qt.Key.Key_Return:
if PLATFORM == 'Darwin':
# Activate by Enter key on macOS
self.itemActivated.emit(self.currentItem())
else:
super().keyPressEvent(event)
else:
super().keyPressEvent(event)
def disconnectedCallback(self):
super().disconnectedCallback()
self.activateItemByIndex(AS_UserActivatedItemIndex(), True)
def connectedCallback(self):
super().connectedCallback()
self.activateItemByIndex(AS_UserActivatedItemIndex(), True)
def retranslate(self):
self.setHorizontalHeaderLabels(list(_(str(header)) for header in self.Headers))