Files
LorenEteval_Furious/Furious/Widget/ServerTableView.py
T
2026-08-19 15:03:55 +08:00

2736 lines
85 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Copyright (C) 2024present Loren Eteval & contributors <loren.eteval@proton.me>
#
# This file is part of Furious.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""Provide widgets for user servers Qt table view."""
from __future__ import annotations
from Furious.Frozenlib import *
from Furious.Interface import *
from Furious.Models import *
from Furious.Repository import *
from Furious.Plugins import (
blankProfile,
exportConfiguration,
getPluginRegistry,
profileFromAny,
)
from Furious.Qt import *
from Furious.Qt import gettext as _
from Furious.Service import (
ConnectionManager,
SubscriptionImportService,
SubscriptionSource,
SubscriptionSynchronizer,
coreLogCallback,
)
from Furious.Widget.WaitingSpinner import *
from PySide6 import QtCore
from PySide6.QtGui import *
from PySide6.QtWidgets import *
from PySide6.QtNetwork import *
from typing import Callable, Union, MutableSequence
import re
import logging
import icmplib
import functools
import collections
import datetime
__all__ = ['ServerTableView']
logger = logging.getLogger(__name__)
registerAppSettings('ActivatedItemIndex')
# Migrate legacy settings
registerAppSettings('ServerWidgetSectionSizeTable')
registerAppSettings('UserServersHeaderViewState')
def appIsExiting() -> bool:
"""Return the app is exiting value used by the application."""
app = APP()
if app is None:
return True
else:
isExiting = getattr(app, 'isExiting', None)
if callable(isExiting):
return isExiting()
else:
return True
class MBoxUpdateSubsInfo(AppQMessageBox):
"""Represent m box update subs info."""
def __init__(self, *args, **kwargs):
"""Initialize the MBoxUpdateSubsInfo."""
self.successArgs = kwargs.pop('successArgs', list())
self.failureArgs = kwargs.pop('failureArgs', list())
super().__init__(*args, **kwargs)
self.setWindowTitle(_(APPLICATION_NAME))
def customText(self):
"""Return the user-facing message text for the m box update subs info."""
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):
"""Keep long subscription summaries readable in the Fluent dialog."""
if PLATFORM == 'Windows':
self.setContentMinimumWidth(
max((len(row) + 10) for row in self.text().split('\n'))
* self.fontMetrics().averageCharWidth(),
)
def retranslate(self):
"""Refresh translated text for the m box update subs info."""
self.setWindowTitle(_(self.windowTitle()))
self.setText(self.customText())
self.setColumnMinWidth()
# Ignore informative text, buttons
self.moveToCenter()
class SubscriptionManager(HttpGetManager):
"""Coordinate subscription operations."""
subscriptionsChanged = QtCore.Signal()
def __init__(self, parent, **kwargs):
"""Initialize the SubscriptionManager."""
actionMessage = kwargs.pop('actionMessage', 'update subs')
super().__init__(parent, actionMessage=actionMessage, completionRunsOnce=False)
self.importer = SubscriptionImportService()
self.synchronizer = SubscriptionSynchronizer()
def handleSynchronizationResults(self, **kwargs):
"""Commit successful group-scoped synchronization results."""
successArgs = kwargs.pop('successArgs', list())
failureArgs = kwargs.pop('failureArgs', list())
showMessageBox = kwargs.pop('showMessageBox', True)
for param in successArgs:
profiles, unique = param['profiles'], param['unique']
parent = self.parent()
if isinstance(parent, ServerTableView):
result = parent.synchronizeSubscriptionProfiles(
unique,
profiles,
self.synchronizer,
)
param['syncResult'] = result
group = Storage.SubscriptionGroup(unique)
if group is not None:
group.lastDecoderId = param.get('decoderId', '')
group.lastSyncStatus = 'success'
group.lastSyncError = ''
group.profileCount = len(result.profileIds)
Storage.upsertSubscriptionGroup(group)
if showMessageBox:
mbox = MBoxUpdateSubsInfo(
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()
self.subscriptionsChanged.emit()
def completionCallback(self, **kwargs):
"""Perform the required completion hook."""
depthMap = kwargs.get('depthMap', {})
depthMap['depth'] -= 1
if depthMap['depth'] == 0:
self.handleSynchronizationResults(**kwargs)
def successCallback(self, networkReply, **kwargs):
"""Handle a successful network operation."""
remark = kwargs.get('remark', '')
webURL = kwargs.get('webURL', '')
successArgs = kwargs.get('successArgs', list())
failureArgs = kwargs.get('failureArgs', list())
data = bytes(networkReply.readAll().data())
source = SubscriptionSource(
kwargs.get('unique', ''),
webURL,
remark,
kwargs.get('decoderId'),
)
result = self.importer.importPayload(data, source)
profileFilter = str(kwargs.get('filter', '')).strip()
if result is not None and profileFilter:
try:
pattern = re.compile(profileFilter, re.IGNORECASE)
except re.error as ex:
logger.error(
f'invalid subscription filter for {remark!r}: {ex}. '
f'Importing all profiles'
)
else:
result = type(result)(
result.decoderId,
tuple(
profile
for profile in result.profiles
if pattern.search(str(getattr(profile, 'itemRemark', '')))
),
result.rejectedItems,
)
if result is None or not result.profiles:
failureArgs.append({'error': 'UnsupportedSubscriptionFormat', **kwargs})
group = Storage.SubscriptionGroup(kwargs.get('unique', ''))
if group is not None:
group.lastSyncStatus = 'error'
group.lastSyncError = 'UnsupportedSubscriptionFormat'
Storage.upsertSubscriptionGroup(group)
else:
logger.info(
f'update subs ({remark}, {webURL}) success. '
f'Got {len(result.profiles)} profiles from {result.decoderId!r}; '
f'rejected {result.rejectedItems}'
)
successArgs.append(
{
**kwargs,
'profiles': result.profiles,
'decoderId': result.decoderId,
}
)
unique = kwargs.get('unique', '')
if unique in Storage.UserSubs():
group = Storage.SubscriptionGroup(unique)
if group is not None:
group.lastUpdated = (
datetime.datetime.now()
.astimezone()
.isoformat(timespec='seconds')
)
group.lastDecoderId = result.decoderId
Storage.upsertSubscriptionGroup(group)
def failureCallback(self, networkReply, **kwargs):
"""Handle a failed network operation."""
remark = kwargs.get('remark', '')
webURL = kwargs.get('webURL', '')
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})
group = Storage.SubscriptionGroup(kwargs.get('unique', ''))
if group is not None:
group.lastSyncStatus = 'error'
group.lastSyncError = networkReply.errorString()
Storage.upsertSubscriptionGroup(group)
def updateSubsByWebGET(self, **kwargs):
"""Update subs by web get."""
url = kwargs.get('webURL', '')
if not url:
# Has url empty check
return
logActionMessage = kwargs.pop('logActionMessage', False)
# Some providers reject the default Qt User-Agent (e.g. with 503),
# so identify ourselves explicitly.
request = QNetworkRequest(QtCore.QUrl(url))
userAgent = str(kwargs.get('userAgent', '')).strip()
request.setRawHeader(
b'User-Agent',
(userAgent or f'{APPLICATION_NAME}/{APPLICATION_VERSION}').encode(),
)
self.webGET(request, logActionMessage=logActionMessage, **kwargs)
def updateSubsByUnique(self, unique: str, **kwargs):
"""Update subs by unique."""
subscription = Storage.UserSubs().get(unique)
if (
not subscription
or not subscription.get('enabled', True)
or not subscription.get('webURL')
):
return
group = Storage.SubscriptionGroup(unique)
if group is not None:
group.lastSyncStatus = 'syncing'
group.lastSyncError = ''
Storage.upsertSubscriptionGroup(group)
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
self.updateSubsByWebGET(unique=unique, **Storage.UserSubs()[unique], **kwargs)
def updateSubs(self, **kwargs):
"""Update subs."""
enabledKeys = tuple(
key
for key, subscription in Storage.UserSubs().items()
if subscription.get('enabled', True) and subscription.get('webURL')
)
if not enabledKeys:
return
depthMap = {'depth': len(enabledKeys)}
successArgs = list()
failureArgs = list()
for key in enabledKeys:
self.updateSubsByUnique(
key,
depthMap=depthMap,
successArgs=successArgs,
failureArgs=failureArgs,
**kwargs,
)
class TestPingLatencyWorker(QtCore.QObject, QtCore.QRunnable):
"""Run test ping latency work in the background."""
finished = QtCore.Signal()
def __init__(self, factory: ServerProfile):
# Explictly called __init__
"""Initialize the TestPingLatencyWorker."""
QtCore.QObject.__init__(self)
QtCore.QRunnable.__init__(self)
self.factory = factory
def run(self):
"""Run the test ping latency worker task."""
index = self.factory.index
if self.factory.deleted or index < 0 or index >= len(Storage.UserServers()):
# Invalid item. Do nothing
return
assert isinstance(self.factory, ServerProfile)
try:
result = icmplib.ping(
self.factory.itemAddress,
count=1,
timeout=2,
interval=1,
)
except Exception as ex:
# Any non-exit exceptions
self.factory.metadata.latency = classname(ex)
else:
# Result address should not be empty
if result.address and result.is_alive:
self.factory.metadata.latency = f'{round(result.avg_rtt)}ms'
else:
if result.packet_loss == 1:
self.factory.metadata.latency = 'Timeout'
else:
self.factory.metadata.latency = 'Error'
finally:
# Extra guard
if not appIsExiting():
self.finished.emit()
class TestTcpingLatencyWorker(QtCore.QObject, QtCore.QRunnable):
"""Run test tcping latency work in the background."""
finished = QtCore.Signal()
def __init__(self, factory: ServerProfile):
# Explictly called __init__
"""Initialize the TestTcpingLatencyWorker."""
QtCore.QObject.__init__(self)
QtCore.QRunnable.__init__(self)
self.factory = factory
def run(self):
"""Run the test tcping latency worker task."""
index = self.factory.index
if self.factory.deleted or index < 0 or index >= len(Storage.UserServers()):
# Invalid item. Do nothing
return
assert isinstance(self.factory, ServerProfile)
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
self.factory.metadata.latency = classname(ex)
else:
if rtts:
self.factory.metadata.latency = f'{round(rtts[0] * 1000)}ms'
else:
self.factory.metadata.latency = 'Timeout'
finally:
# Extra guard
if not appIsExiting():
self.finished.emit()
class TestDownloadSpeedWorker(HttpGetManager):
"""Run test download speed work in the background."""
progressed = QtCore.Signal()
finished = QtCore.Signal(object)
def __init__(
self,
factory: ServerProfile,
port: int,
timeout: int,
parent=None,
**kwargs,
):
"""Initialize the TestDownloadSpeedWorker."""
actionMessage = kwargs.pop('actionMessage', 'test download speed')
super().__init__(parent, actionMessage=actionMessage)
self.factory = factory
self.port = port
self.timeout = timeout
self.kwargs = kwargs
self.hasSpeedResult = False
self.totalBytesRead = 0
self.hasDataCounter = 0
self.coreManager = ConnectionManager()
self.networkReply = None
self.elapsedTimer = QtCore.QElapsedTimer()
self.timeoutTimer = QtCore.QTimer(self)
self.timeoutTimer.setSingleShot(True)
self.timeoutTimer.timeout.connect(self.handleTimeout)
def completionCallback(self, **kwargs):
"""Perform the required completion hook."""
self.timeoutTimer.stop()
self.finished.emit(self)
def sync(self):
# Extra guard
"""Persist the current test download speed worker data."""
if not appIsExiting():
self.progressed.emit()
def isFinished(self) -> bool:
"""Return whether finished."""
if isinstance(self.networkReply, QNetworkReply):
return self.networkReply.isFinished()
else:
return True
def abort(self):
"""Cancel the active test download speed worker operation."""
if isinstance(self.networkReply, QNetworkReply):
self.networkReply.abort()
def handleTimeout(self):
"""Handle timeout."""
try:
if not self.isFinished():
self.abort()
finally:
self.runCompletionCallback()
def coreExitCallback(self, config: CoreConfiguration, exitcode: int):
"""Handle the core exit callback."""
try:
if exitcode == CoreRuntime.ExitCode.ConfigurationError.value:
self.factory.metadata.speed = 'Invalid'
self.sync()
elif exitcode == CoreRuntime.ExitCode.ServerStartFailure.value:
self.factory.metadata.speed = 'Core start failed'
self.sync()
elif exitcode == CoreRuntime.ExitCode.SystemShuttingDown.value:
pass
else:
self.factory.metadata.speed = f'Core exited {exitcode}'
self.sync()
finally:
self.runCompletionCallback()
def _startCoreRuntime(self, config) -> bool:
"""Prepare and start a download test through its runtime factory."""
configcopy = getPluginRegistry().prepareDownloadTest(config, self.port)
if configcopy is None:
self.factory.metadata.speed = 'Invalid'
self.sync()
return False
self.factory.metadata.speed = 'Starting'
self.sync()
return self.coreManager.start(
configcopy,
AppBuiltinRouting.Global.value,
self.coreExitCallback,
msgCallbackCore=coreLogCallback(AppLogManager()),
deepcopy=False,
proxyModeOnly=True,
log=False,
)
def start(self):
"""Start the test download speed worker."""
try:
if appIsExiting():
raise
index = self.factory.index
if self.factory.deleted or index < 0 or index >= len(Storage.UserServers()):
# Invalid item. Do nothing
return
assert isinstance(self.factory, ServerProfile)
if not self.factory.isValid():
# Configuration is invalid
self.factory.metadata.speed = 'Invalid'
self.sync()
else:
if not self._startCoreRuntime(self.factory) or appIsExiting():
return
self.configureHttpProxy(f'127.0.0.1:{self.port}')
# Use custom network speed test URL if possible
settings = AppSettings.get('CustomNetworkSpeedTestURL')
if isinstance(settings, str):
url = settings
else:
url = NETWORK_SPEED_TEST_URL
self.networkReply = self.webGET(url, **self.kwargs)
self.elapsedTimer.start()
self.timeoutTimer.start(self.timeout)
finally:
if self.networkReply is None:
self.runCompletionCallback()
def successCallback(self, networkReply, **kwargs):
"""Handle a successful network operation."""
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.metadata.speed = f'{downloadSpeed:.2f} MiB/s'
else:
self.factory.metadata.speed = 'Core start failed'
self.coreManager.stopAll()
self.sync()
def hasDataCallback(self, networkReply, **kwargs):
"""Handle newly available network response data."""
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.metadata.speed = f'{downloadSpeed:.2f} MiB/s'
# Limited to save CPU resources
if self.hasDataCounter % 25 == 0:
self.sync()
def failureCallback(self, networkReply, **kwargs):
"""Handle a failed network operation."""
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.metadata.speed = '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.
error = error.decode('utf-8', 'replace')
elif isinstance(error, str):
pass
else:
error = 'UnknownError'
if error != 'UnknownError' and error.endswith('Error'):
self.factory.metadata.speed = error[:-5]
else:
self.factory.metadata.speed = error
self.coreManager.stopAll()
self.sync()
class DownloadSpeedTestJob:
"""Represent download speed test job."""
def __init__(
self,
index: int,
factory: ServerProfile,
timeout: int,
logActionMessage=False,
):
"""Initialize the DownloadSpeedTestJob."""
super().__init__()
self.index = index
self.factory = factory
self.timeout = timeout
self.logActionMessage = logActionMessage
class DownloadSpeedTestScheduler(QtCore.QObject):
"""Schedule and coordinate download speed test jobs."""
SinglePort = 20809
MultiPortStart = 30000
MultiPortStop = 40000
def __init__(self, table, isMulti: bool, parent=None):
"""Initialize the DownloadSpeedTestScheduler."""
super().__init__(parent)
self.table = table
self.isMulti = isMulti
self.maxConcurrency = max(OS_CPU_COUNT // 2, 1) if isMulti else 1
self.queue = collections.deque()
self.activeJobs = {}
self.activePorts = set()
self.nextMultiPort = self.MultiPortStart
self.drainScheduled = False
def enqueue(
self,
index: int,
factory: ServerProfile,
timeout: int,
logActionMessage=False,
):
"""Handle enqueue for the download speed test scheduler."""
self.queue.append(
DownloadSpeedTestJob(index, factory, timeout, logActionMessage)
)
self.scheduleDrain()
def enqueueMany(self, jobs: list[DownloadSpeedTestJob]):
"""Handle enqueue many for the download speed test scheduler."""
self.queue.extend(jobs)
self.scheduleDrain()
def cancelAll(self):
"""Return whether cel all."""
self.queue.clear()
for worker, _, _ in list(self.activeJobs.values()):
assert isinstance(worker, TestDownloadSpeedWorker)
if not worker.isFinished():
worker.abort()
worker.coreManager.stopAll()
worker.runCompletionCallback()
def scheduleDrain(self):
"""Handle schedule drain for the download speed test scheduler."""
if self.drainScheduled:
return
self.drainScheduled = True
QtCore.QTimer.singleShot(0, self.drain)
def drain(self):
"""Handle drain for the download speed test scheduler."""
self.drainScheduled = False
if appIsExiting():
self.cancelAll()
return
while self.queue and len(self.activeJobs) < self.maxConcurrency:
job = self.queue.popleft()
assert isinstance(job.factory, ServerProfile)
if job.factory.deleted:
continue
port = self.allocatePort()
if port is None:
self.queue.appendleft(job)
break
self.startJob(job, port)
def allocatePort(self) -> Union[int, None]:
"""Return whether allocate port."""
if not self.isMulti:
if self.activeJobs:
return None
return self.SinglePort
portRange = self.MultiPortStop - self.MultiPortStart
for _ in range(portRange):
port = self.nextMultiPort
self.nextMultiPort += 1
if self.nextMultiPort >= self.MultiPortStop:
self.nextMultiPort = self.MultiPortStart
if port not in self.activePorts:
self.activePorts.add(port)
return port
return None
def releasePort(self, port: int):
"""Handle release port for the download speed test scheduler."""
self.activePorts.discard(port)
def startJob(self, job: DownloadSpeedTestJob, port: int):
"""Start job."""
worker = TestDownloadSpeedWorker(
job.factory,
port,
job.timeout,
parent=self,
logActionMessage=job.logActionMessage,
)
worker.progressed.connect(
functools.partial(
self.table.flushDownloadSpeedItem,
job.index,
job.factory,
)
)
self.activeJobs[id(worker)] = (worker, job, port)
worker.finished.connect(self.handleWorkerFinished)
worker.start()
@QtCore.Slot(object)
def handleWorkerFinished(self, worker):
"""Handle worker finished."""
workerId = id(worker)
try:
_, _, port = self.activeJobs.pop(workerId)
except KeyError:
return
self.releasePort(port)
# Completed workers are children of the long-lived scheduler. Merely
# removing the Python dictionary entry would leave every worker (and
# its network/timer children) in the scheduler's QObject tree.
worker.deleteLater()
self.scheduleDrain()
class DeleteServersProgressDialog(AppQTransientDialog):
"""Present progress and cancellation controls for delete servers."""
def __init__(self, table, indexes, showTrayMessage=True, parent=None):
"""Initialize the DeleteServersProgressDialog."""
super().__init__(parent)
self.table = table
self.indexes = list(indexes)
self.showTrayMessage = showTrayMessage
self.total = len(self.indexes)
self.nextIndex = 0
self.deletedCount = 0
self.deletedActivated = False
self.canceled = False
self.finishedDeletion = False
self.currentRemark = ''
self.setWindowTitle(_('Delete'))
self.setWindowModality(QtCore.Qt.WindowModality.ApplicationModal)
self.spinner = WaitingSpinner(
self,
center_on_parent=False,
lines=12,
line_length=7,
line_width=3,
radius=7,
color=QColor(96, 160, 255),
)
self.statusLabel = AppQLabel()
self.detailLabel = AppQLabel()
self.detailLabel.setWordWrap(True)
self.cancelButton = AppQPushButton(_('Cancel'))
self.cancelButton.clicked.connect(self.cancel)
statusLayout = QHBoxLayout()
statusLayout.addWidget(self.spinner)
statusLayout.addWidget(self.statusLabel, 1)
layout = QVBoxLayout()
layout.addLayout(statusLayout)
layout.addWidget(self.detailLabel)
layout.addWidget(self.cancelButton)
self.setLayout(layout)
self.updateStatus()
def setWidthAndHeight(self):
"""Apply the default size for the delete servers progress dialog."""
self.resize(420, 150)
def open(self):
"""Open the delete servers progress dialog asynchronously."""
self.spinner.start()
QtCore.QTimer.singleShot(0, self.deleteNext)
return super().open()
def reject(self):
"""Reject the current delete servers progress dialog values."""
self.cancel()
def cancel(self):
"""Cancel the delete servers progress dialog operation."""
self.canceled = True
self.cancelButton.setEnabled(False)
self.updateStatus()
def updateStatus(self):
"""Update status."""
if self.canceled:
self.statusLabel.setText(
_('Canceling delete') + f'... {self.deletedCount}/{self.total}'
)
else:
self.statusLabel.setText(
_('Deleting') + f'... {self.deletedCount}/{self.total}'
)
if self.currentRemark:
self.detailLabel.setText(_('Current') + f': {self.currentRemark}')
else:
self.detailLabel.setText('')
@staticmethod
def limitedRemark(remark: str) -> str:
"""Return the limited remark value used by the delete servers progress dialog."""
remark = str(remark).strip()
if len(remark) <= 120:
return remark
return remark[:117] + '...'
def deleteNext(self):
"""Delete next."""
if self.canceled or self.nextIndex >= self.total:
self.finishDeletion()
return
originalIndex = self.indexes[self.nextIndex]
self.nextIndex += 1
deleteIndex = originalIndex - self.deletedCount
if deleteIndex < 0 or deleteIndex >= len(Storage.UserServers()):
self.updateStatus()
QtCore.QTimer.singleShot(0, self.deleteNext)
return
factory = Storage.UserServers()[deleteIndex]
self.currentRemark = self.limitedRemark(factory.itemRemark)
if originalIndex == Storage.UserActivatedItemIndex():
self.deletedActivated = True
self.table.sourceModel.beginRemoveRows(
QtCore.QModelIndex(),
deleteIndex,
deleteIndex,
)
factory.deleted = True
Storage.UserServers().pop(deleteIndex)
self.table.sourceModel.endRemoveRows()
if not self.deletedActivated and deleteIndex < Storage.UserActivatedItemIndex():
AppSettings.set(
'ActivatedItemIndex', str(Storage.UserActivatedItemIndex() - 1)
)
self.deletedCount += 1
self.updateStatus()
QtCore.QTimer.singleShot(0, self.deleteNext)
def finishDeletion(self):
"""Handle finish deletion for the delete servers progress dialog."""
if self.finishedDeletion:
return
self.finishedDeletion = True
self.spinner.stop()
self.table.sourceModel.refreshIndexes()
self.table.sourceModel.emitAllChanged()
if self.deletedActivated:
# Set invalid first
AppSettings.set('ActivatedItemIndex', str(-1))
self.table.activeServerChanged.emit()
controller = AppConnectionController()
if controller.isConnected():
controller.startDisconnection(
_('Disconnected') if self.showTrayMessage else ''
)
self.accept()
def retranslate(self):
"""Refresh translated text for the delete servers progress dialog."""
self.setWindowTitle(_(self.windowTitle()))
self.cancelButton.setText(_(self.cancelButton.text()))
self.updateStatus()
class ServerTableHorizontalHeader(AppQHeaderView):
"""Provide the user servers Qt table view horizontal table header."""
def __init__(self, *args, **kwargs):
"""Initialize the ServerTableHorizontalHeader."""
super().__init__(QtCore.Qt.Orientation.Horizontal, *args, **kwargs)
class ServerTableVerticalHeader(AppQHeaderView):
"""Provide the user servers Qt table view vertical table header."""
def __init__(self, *args, **kwargs):
"""Initialize the ServerTableVerticalHeader."""
super().__init__(QtCore.Qt.Orientation.Vertical, *args, **kwargs)
class ServerTableColumn:
"""Describe and render user servers Qt table view table columns."""
def __init__(self, name: str, func: Callable[[CoreConfiguration], str] = None):
"""Initialize the ServerTableColumn."""
self.name = name
self.func = func
def __call__(self, item: ServerProfile) -> str:
"""Invoke the user servers Qt table view headers as a callable."""
if callable(self.func):
return self.func(item)
else:
return getattr(item, f'item{self}')
def __eq__(self, other):
"""Compare the user servers Qt table view headers with another value."""
return str(self) == str(other)
def __str__(self):
"""Return the display text for the user servers Qt table view headers."""
return self.name
def _subscriptionRemark(item: ServerProfile) -> str:
"""Resolve a persisted subscription ID to its user-visible remark."""
if not item.itemSubscriptionManaged:
return ''
subscription = Storage.UserSubs().get(item.itemSubscription, {})
if not subscription:
return _('Unknown Subscription')
remark = subscription.get('remark', '') or item.itemSubscription
return (
remark if subscription.get('enabled', True) else f'{remark} ({_("Disabled")})'
)
class UserServersTableModel(QtCore.QAbstractTableModel):
"""Expose user servers table data through a Qt item model."""
SortRole = QtCore.Qt.ItemDataRole.UserRole + 1
def __init__(self, headers: list[ServerTableColumn], parent=None):
"""Initialize the UserServersTableModel."""
super().__init__(parent)
self.headers = headers
def rowCount(self, parent=QtCore.QModelIndex()) -> int:
"""Return the number of rows exposed by the model."""
if parent.isValid():
return 0
return len(Storage.UserServers())
def columnCount(self, parent=QtCore.QModelIndex()) -> int:
"""Return the number of columns exposed by the model."""
if parent.isValid():
return 0
return len(self.headers)
def flags(self, index):
"""Return the Qt item flags for a model index."""
if not index.isValid():
return QtCore.Qt.ItemFlag.NoItemFlags
return QtCore.Qt.ItemFlag.ItemIsEnabled | QtCore.Qt.ItemFlag.ItemIsSelectable
def data(self, index, role=QtCore.Qt.ItemDataRole.DisplayRole):
"""Return the data managed by the user servers table model."""
if not index.isValid():
return None
row = index.row()
column = index.column()
if row < 0 or row >= len(Storage.UserServers()):
return None
if column < 0 or column >= len(self.headers):
return None
server = Storage.UserServers()[row]
header = self.headers[column]
text = header(server)
if (
role == QtCore.Qt.ItemDataRole.DisplayRole
or role == QtCore.Qt.ItemDataRole.ToolTipRole
):
return text
if role == QtCore.Qt.ItemDataRole.FontRole:
font = QFont(AppFontName())
if row == Storage.UserActivatedItemIndex():
font.setBold(True)
return font
if role == QtCore.Qt.ItemDataRole.ForegroundRole:
if row == Storage.UserActivatedItemIndex():
return QColor(AppHue.currentColor())
return None
if role == QtCore.Qt.ItemDataRole.TextAlignmentRole:
if str(header) == 'Latency' or str(header) == 'Speed':
return (
QtCore.Qt.AlignmentFlag.AlignRight
| QtCore.Qt.AlignmentFlag.AlignVCenter
)
return None
if role == self.SortRole:
if str(header) == 'Latency':
return self.testResultSortValue(text, 'ms')
if str(header) == 'Speed':
return self.testResultSortValue(text, ' MiB/s')
return text
return None
def headerData(
self,
section: int,
orientation: QtCore.Qt.Orientation,
role=QtCore.Qt.ItemDataRole.DisplayRole,
):
"""Return display data for a table header section."""
if role != QtCore.Qt.ItemDataRole.DisplayRole:
return None
if orientation == QtCore.Qt.Orientation.Horizontal:
if 0 <= section < len(self.headers):
return _(str(self.headers[section]))
return None
return section + 1
@staticmethod
def testResultSortValue(text: str, suffix: str):
"""Return the test result sort value value used by the user servers table model."""
if text.endswith(suffix):
text = text[: -len(suffix)]
try:
return float(text)
except Exception:
# Any non-exit exceptions
return abs(hash(text)) + 2**20
def emitRowChanged(self, row: int, column: Union[int, None] = None):
"""Handle emit row changed for the user servers table model."""
if row < 0 or row >= self.rowCount():
return
if column is None:
left = self.index(row, 0)
right = self.index(row, self.columnCount() - 1)
else:
left = self.index(row, column)
right = left
self.dataChanged.emit(left, right, [])
def emitAllChanged(self):
"""Handle emit all changed for the user servers table model."""
if self.rowCount() == 0 or self.columnCount() == 0:
return
self.dataChanged.emit(
self.index(0, 0),
self.index(self.rowCount() - 1, self.columnCount() - 1),
[],
)
@staticmethod
def refreshIndexes():
"""Refresh indexes."""
for index, item in enumerate(Storage.UserServers()):
item.index = index
def sort(
self,
column: int,
order: QtCore.Qt.SortOrder = QtCore.Qt.SortOrder.AscendingOrder,
):
"""Sort the user servers table model."""
if column < 0 or column >= self.columnCount():
return
activatedIndex = Storage.UserActivatedItemIndex()
if 0 <= activatedIndex < len(Storage.UserServers()):
activatedServerId = id(Storage.UserServers()[activatedIndex])
else:
activatedServerId = None
header = self.headers[column]
def keyFn(factory: ServerProfile):
"""Return the key fn value used by the user servers table model."""
data = header(factory)
if str(header) == 'Latency':
return self.testResultSortValue(data, 'ms')
if str(header) == 'Speed':
return self.testResultSortValue(data, ' MiB/s')
return data
self.layoutAboutToBeChanged.emit()
Storage.UserServers().sort(
key=keyFn,
reverse=order == QtCore.Qt.SortOrder.DescendingOrder,
)
self.refreshIndexes()
if activatedServerId is not None:
for index, server in enumerate(Storage.UserServers()):
if id(server) == activatedServerId:
AppSettings.set('ActivatedItemIndex', str(index))
break
self.layoutChanged.emit()
class UserServersSortFilterProxyModel(QtCore.QSortFilterProxyModel):
"""Filter and sort user servers sort filter data."""
def __init__(self, parent=None):
"""Initialize the UserServersSortFilterProxyModel."""
super().__init__(parent)
self.searchPattern = ''
self.searchCaseSensitive = False
self.searchUseRegex = True
self.searchRegex = None
self.subscriptionFilter = None
self.sortSuspended = False
self.setSortRole(UserServersTableModel.SortRole)
self.setDynamicSortFilter(True)
def sort(
self,
column: int,
order: QtCore.Qt.SortOrder = QtCore.Qt.SortOrder.AscendingOrder,
):
"""Sort the user servers sort filter proxy model."""
if self.sortSuspended:
super().sort(-1, order)
return
if column < 0:
super().sort(column, order)
return
model = self.sourceModel()
if model is not None:
model.sort(column, order)
self.invalidate()
def setSearchPattern(
self,
pattern: str,
*,
caseSensitive: bool = False,
regex: bool = True,
):
"""Set search pattern."""
self.searchPattern = str(pattern or '')
self.searchCaseSensitive = caseSensitive
self.searchUseRegex = regex
self.searchRegex = None
if self.searchPattern:
flags = 0 if caseSensitive else re.IGNORECASE
regexPattern = (
self.searchPattern if regex else re.escape(self.searchPattern)
)
try:
self.searchRegex = re.compile(regexPattern, flags)
except re.error as ex:
logger.error(
f'invalid user servers search regex: {ex}. '
f'Fall back to literal matching'
)
self.searchRegex = re.compile(re.escape(self.searchPattern), flags)
self.invalidateFilter()
def setSubscriptionFilter(self, unique: str | None):
"""Limit rows to manual profiles or one subscription group."""
self.subscriptionFilter = unique
self.invalidateFilter()
def filterAcceptsRow(self, sourceRow: int, sourceParent) -> bool:
"""Filter accepts row."""
model = self.sourceModel()
if model is None:
return True
if 0 <= sourceRow < len(Storage.UserServers()):
profile = Storage.UserServers()[sourceRow]
if self.subscriptionFilter == '':
if profile.itemSubscriptionManaged:
return False
elif (
self.subscriptionFilter is not None
and profile.itemSubscription != self.subscriptionFilter
):
return False
if not self.searchPattern or self.searchRegex is None:
return True
searchableText = '\n'.join(
str(
model.data(
model.index(sourceRow, column, sourceParent),
QtCore.Qt.ItemDataRole.DisplayRole,
)
or ''
)
for column in range(model.columnCount(sourceParent))
)
return self.searchRegex.search(searchableText) is not None
def headerData(
self,
section: int,
orientation: QtCore.Qt.Orientation,
role=QtCore.Qt.ItemDataRole.DisplayRole,
):
"""Return display data for a table header section."""
if (
orientation == QtCore.Qt.Orientation.Vertical
and role == QtCore.Qt.ItemDataRole.DisplayRole
):
return section + 1
return super().headerData(section, orientation, role)
# ALL Headers VALUE
_TRANSLATABLE_HEADERS = [
_('Remark'),
_('Protocol'),
_('Address'),
_('Port'),
_('Transport'),
_('TLS'),
_('Subscription'),
_('Latency'),
_('Speed'),
]
class ServerTableView(
Mixins.QTranslatable,
Mixins.CleanupOnExit,
AppQTableView,
):
"""Represent user servers Qt table view."""
activeServerChanged = QtCore.Signal()
RowHeight = 42
Headers = [
ServerTableColumn('Remark'),
ServerTableColumn('Protocol'),
ServerTableColumn('Address'),
ServerTableColumn('Port'),
ServerTableColumn('Transport'),
ServerTableColumn('TLS'),
ServerTableColumn('Subscription', _subscriptionRemark),
ServerTableColumn('Latency'),
ServerTableColumn('Speed'),
]
def __init__(self, *args, **kwargs):
"""Initialize the server table view."""
configurationEditorFactory = kwargs.pop('configurationEditorFactory')
self.qrCodeWindowFactory = kwargs.pop('qrCodeWindowFactory')
importActionsFactory = kwargs.pop('importActionsFactory')
super().__init__(*args, **kwargs)
self.sourceModel = UserServersTableModel(self.Headers, parent=self)
self.proxyModel = UserServersSortFilterProxyModel(parent=self)
self.proxyModel.setSourceModel(self.sourceModel)
self.setModel(self.proxyModel)
self.subsManager = SubscriptionManager(parent=self)
self.subsManager.subscriptionsChanged.connect(self.flushAll)
self.downloadSpeedScheduler = DownloadSpeedTestScheduler(
self,
isMulti=False,
parent=self,
)
self.downloadSpeedMultiScheduler = DownloadSpeedTestScheduler(
self,
isMulti=True,
parent=self,
)
self.configurationEditor = configurationEditorFactory()
# Install custom header
self.setHorizontalHeader(
ServerTableHorizontalHeader(
parent=self,
legacySectionSizeSettingsName='ServerWidgetSectionSizeTable',
sectionSizeSettingsName='UserServersHeaderViewState',
)
)
self.setVerticalHeader(ServerTableVerticalHeader(self))
self.setDefaultRowHeight(self.RowHeight)
self.horizontalHeader().setCustomSectionResizeMode()
self.horizontalHeader().restoreSectionSize()
self.proxyModel.sortSuspended = True
self.setSortingEnabled(True)
self.proxyModel.sortSuspended = False
self.proxyModel.sort(-1)
# Selection
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.setSelectionMode(QAbstractItemView.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('braces-asterisk.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=False,
)
self.activateSelectedServerActionRef = AppQAction(
_('Activate Selected Server'),
callback=lambda: self.activateSelectedServer(),
shortcut=QtCore.QKeyCombination(
QtCore.Qt.Key.Key_Enter,
),
)
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,
),
),
AppQSeparator(),
AppQAction(
_('Select All'),
callback=lambda: self.selectAll(),
shortcut=QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier.ControlModifier,
QtCore.Qt.Key.Key_A,
),
),
AppQSeparator(),
self.activateSelectedServerActionRef,
AppQAction(
_('Scroll To Activated Server'),
callback=lambda: self.scrollToActivatedItem(),
shortcut=QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier.ControlModifier,
QtCore.Qt.Key.Key_G,
),
),
AppQSeparator(),
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 (Multithreaded)'),
callback=lambda: self.testSelectedItemDownloadSpeedMulti(),
shortcut=QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier.ControlModifier,
QtCore.Qt.Key.Key_M,
),
),
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,
),
),
AppQSeparator(),
self.advancedActionRef,
AppQSeparator(),
AppQAction(
_('New Empty Configuration'),
callback=lambda: self.newEmptyItem(),
shortcut=QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier.ControlModifier,
QtCore.Qt.Key.Key_N,
),
),
*importActionsFactory(),
AppQSeparator(),
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.selectionModel().selectionChanged.connect(self.handleItemSelectionChanged)
self.activated.connect(self.handleItemActivated)
self.doubleClicked.connect(self.handleItemDoubleClicked)
self.flushAll()
if self.activatedIndex().isValid():
self.setCurrentIndex(self.activatedIndex())
self.activateItemByIndex(Storage.UserActivatedItemIndex(), True)
@property
def selectedIndex(self):
"""Return the selected index value."""
indexes = list(
self.sourceRowFromProxyIndex(index)
for index in self.selectionModel().selectedRows()
)
return sorted(list(set(index for index in indexes if index >= 0)))
def sourceIndexFromProxyIndex(self, index: QtCore.QModelIndex):
"""Return the source index from proxy index value used by the user servers Qt table view."""
if not index.isValid():
return QtCore.QModelIndex()
return self.proxyModel.mapToSource(index)
def proxyIndexFromSourceIndex(self, index: QtCore.QModelIndex):
"""Return the proxy index from source index value used by the user servers Qt table view."""
if not index.isValid():
return QtCore.QModelIndex()
return self.proxyModel.mapFromSource(index)
def sourceRowFromProxyIndex(self, index: QtCore.QModelIndex) -> int:
"""Return the source row from proxy index value used by the user servers Qt table view."""
sourceIndex = self.sourceIndexFromProxyIndex(index)
if sourceIndex.isValid():
return sourceIndex.row()
return -1
def sourceRowFromProxyRow(self, row: int) -> int:
"""Return the source row from proxy row value used by the user servers Qt table view."""
return self.sourceRowFromProxyIndex(self.proxyModel.index(row, 0))
def proxyIndexFromSourceRow(self, row: int, column: int = 0):
"""Return the proxy index from source row value used by the user servers Qt table view."""
if row < 0 or row >= self.sourceModel.rowCount():
return QtCore.QModelIndex()
return self.proxyIndexFromSourceIndex(self.sourceModel.index(row, column))
def selectMultipleRows(self, indexes: list[int], clearCurrentSelection: bool):
"""Select multiple rows."""
if clearCurrentSelection:
self.selectionModel().clearSelection()
selection = self.selectionModel().selection()
for index in indexes:
proxyIndex0 = self.proxyIndexFromSourceRow(index, 0)
proxyIndex1 = self.proxyIndexFromSourceRow(index, len(self.Headers) - 1)
if proxyIndex0.isValid() and proxyIndex1.isValid():
selection.select(proxyIndex0, proxyIndex1)
self.selectionModel().select(
selection, QtCore.QItemSelectionModel.SelectionFlag.Select
)
def disconnectedCallback(self):
"""Update the user servers Qt table view for a disconnected state."""
super().disconnectedCallback()
self.activateItemByIndex(Storage.UserActivatedItemIndex(), True)
def connectedCallback(self):
"""Update the user servers Qt table view for a connected state."""
super().connectedCallback()
self.activateItemByIndex(Storage.UserActivatedItemIndex(), True)
def handleItemSelectionChanged(self, *args):
"""Handle item selection changed."""
if len(self.selectedIndex) > 1:
for action in [
self.customizeJSONConfigActionRef,
self.activateSelectedServerActionRef,
]:
action.setDisabled(True)
else:
for action in [
self.customizeJSONConfigActionRef,
self.activateSelectedServerActionRef,
]:
action.setDisabled(False)
@QtCore.Slot(QtCore.QModelIndex)
def handleItemActivated(self, index: QtCore.QModelIndex):
"""Handle item activated."""
if self.doubleClickedFlag:
# Ignore double-click
self.doubleClickedFlag = False
return
oldIndex = Storage.UserActivatedItemIndex()
newIndex = self.sourceRowFromProxyIndex(index)
if newIndex < 0:
return
if oldIndex == newIndex:
# Same item activated. Do nothing
return
if AppConnectionController().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 AppConnectionController().isConnected():
AppConnectionController().startReconnection()
def getGuiEditorByFactory(
self, factory, **kwargs
) -> Union[GuiEditorWidgetQDialog, None]:
"""Return GUI editor by factory."""
editor = getPluginRegistry().createEditorForConfig(
factory, parent=self, **kwargs
)
if editor is not None:
# These editors are created for a single add/edit operation. A Qt
# parent alone would keep every closed native widget tree alive for
# the lifetime of the server table.
editor.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose, True)
return editor
@QtCore.Slot(QtCore.QModelIndex)
def handleItemDoubleClicked(self, modelIndex: QtCore.QModelIndex):
"""Handle item double clicked."""
self.doubleClickedFlag = True
index = self.sourceRowFromProxyIndex(modelIndex)
if index < 0:
return
factory = Storage.UserServers()[index]
# Do not translate window title
guiEditor = self.getGuiEditorByFactory(factory, translatable=False)
if guiEditor is None:
# Unrecognized.
showMBoxUnrecognizedConfig()
return
guiEditor.setWindowTitle(f'{index + 1} - ' + factory.itemRemark)
try:
guiEditor.factoryToInput(factory)
except Exception as ex:
# Any non-exit exceptions
logger.error(f'error while converting factory to input: {ex}')
# Keep operation metadata on the transient editor and use QObject-bound
# slots. Partials stored by PySide otherwise retain both the complete
# editor tree and this application-lifetime table until disconnection.
guiEditor._modContext = (index, factory)
guiEditor.accepted.connect(self.handleGuiEditorAccepted)
guiEditor.rejected.connect(self.handleGuiEditorRejected)
guiEditor.open()
@QtCore.Slot()
def handleGuiEditorAccepted(self):
"""Handle GUI editor accepted."""
editor = self.sender()
if not isinstance(editor, GuiEditorWidgetQDialog):
return
index, factory = editor._modContext
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 == Storage.UserActivatedItemIndex():
showMBoxNewChangesNextTime()
editor.accepted.disconnect()
editor.rejected.disconnect()
del editor._modContext
@QtCore.Slot()
def handleGuiEditorRejected(self):
"""Handle GUI editor rejected."""
editor = self.sender()
if not isinstance(editor, GuiEditorWidgetQDialog):
return
editor.accepted.disconnect()
editor.rejected.disconnect()
if hasattr(editor, '_modContext'):
del editor._modContext
@QtCore.Slot(QtCore.QPoint)
def handleCustomContextMenuRequested(self, point):
"""Handle custom context menu requested."""
self.contextMenu.exec(self.viewport().mapToGlobal(point))
def customSortFn(self, clickedIndex, **kwargs):
"""Handle custom sort fn for the user servers Qt table view."""
order = (
QtCore.Qt.SortOrder.DescendingOrder
if kwargs.get('reverse', False)
else QtCore.Qt.SortOrder.AscendingOrder
)
self.sortByColumn(clickedIndex, order)
def activatedIndex(self):
"""Activate d index."""
return self.proxyIndexFromSourceRow(Storage.UserActivatedItemIndex(), 0)
def activateItemByIndex(self, index, activate):
"""Activate item by index."""
oldIndex = Storage.UserActivatedItemIndex()
changed = activate and oldIndex != int(index)
if activate:
AppSettings.set('ActivatedItemIndex', str(index))
self.sourceModel.emitRowChanged(oldIndex)
self.sourceModel.emitRowChanged(index)
if changed:
self.activeServerChanged.emit()
def flushItem(self, row: int, column: int, item: ServerProfile):
"""Refresh item."""
itemIndex = item.index
if item.deleted or itemIndex < 0 or itemIndex >= len(Storage.UserServers()):
# Invalid item. Do nothing
return
def searchIndex(start, stop, step=1):
"""Search index."""
nonlocal itemIndex
for _index in range(start, stop, step):
if id(item) == id(Storage.UserServers()[_index]):
itemIndex = _index
item.index = itemIndex
return True
return False
if id(item) != id(Storage.UserServers()[itemIndex]):
# itemIndex doesn't match
if searchIndex(itemIndex - 1, -1, -1) or searchIndex(
itemIndex + 1, len(Storage.UserServers())
):
pass
else:
# Item isn't found in user servers. Do nothing
return
if row != itemIndex:
# Adjust row value
row = itemIndex
else:
pass
self.sourceModel.emitRowChanged(row, column)
def search(
self,
pattern: str,
*,
caseSensitive: bool = False,
regex: bool = True,
):
"""Search the user servers Qt table view."""
self.proxyModel.setSearchPattern(
pattern,
caseSensitive=caseSensitive,
regex=regex,
)
def clearSearch(self):
"""Clear search."""
self.search('')
def filterBySubscription(self, unique: str | None):
"""Show all, manual, or one subscription group's profiles."""
self.proxyModel.setSubscriptionFilter(unique)
def addServerViaGui(
self,
protocol,
windowTitle: str = APPLICATION_NAME,
**kwargs,
):
"""Add server via GUI."""
factory = blankProfile(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._addContext = factory
guiEditor.accepted.connect(self.handleAddServerViaGuiAccepted)
guiEditor.rejected.connect(self.handleAddServerViaGuiRejected)
guiEditor.open()
@QtCore.Slot()
def handleAddServerViaGuiAccepted(self):
"""Handle add server via GUI accepted."""
editor = self.sender()
if not isinstance(editor, GuiEditorWidgetQDialog):
return
factory = editor._addContext
editor.inputToFactory(factory)
self.appendNewItemByFactory(factory)
editor.accepted.disconnect()
editor.rejected.disconnect()
del editor._addContext
@QtCore.Slot()
def handleAddServerViaGuiRejected(self):
"""Handle add server via GUI rejected."""
editor = self.sender()
if not isinstance(editor, GuiEditorWidgetQDialog):
return
editor.accepted.disconnect()
editor.rejected.disconnect()
if hasattr(editor, '_addContext'):
del editor._addContext
def flushRow(self, row: int, item: ServerProfile):
"""Refresh row."""
itemIndex = item.index
if item.deleted or itemIndex < 0 or itemIndex >= len(Storage.UserServers()):
# Invalid item. Do nothing
return
if row != itemIndex:
row = itemIndex
self.sourceModel.emitRowChanged(row)
if row == Storage.UserActivatedItemIndex():
self.activeServerChanged.emit()
def flushAll(self):
# Refresh index
"""Refresh all."""
self.sourceModel.refreshIndexes()
self.sourceModel.emitAllChanged()
def swapItem(self, index0: int, index1: int):
"""Handle swap item for the user servers Qt table view."""
def swapSequenceItem(sequence: MutableSequence, param0: int, param1: int):
"""Handle swap sequence item for the user servers Qt table view."""
swap = sequence[param0]
sequence[param0] = sequence[param1]
sequence[param1] = swap
activatedIndex = Storage.UserActivatedItemIndex()
self.sourceModel.layoutAboutToBeChanged.emit()
swapSequenceItem(Storage.UserServers(), index0, index1)
# Refresh index
self.sourceModel.refreshIndexes()
self.sourceModel.layoutChanged.emit()
if index0 == activatedIndex:
# Activate
self.activateItemByIndex(index1, True)
elif index1 == activatedIndex:
# Activate
self.activateItemByIndex(index0, True)
def newEmptyItem(self):
"""Handle new empty item for the user servers Qt table view."""
self.appendNewItem(remark=_('Untitled'), acceptInvalid=True)
def moveUpItemByIndex(self, index):
"""Move up item by index."""
if index <= 0 or index >= len(Storage.UserServers()):
# The top item, or does not exist. Do nothing
return
self.swapItem(index, index - 1)
def moveUpSelectedItem(self):
"""Move up selected item."""
indexes = self.selectedIndex
if len(indexes) == 0:
# Nothing selected. Do nothing
return
for index in indexes:
self.moveUpItemByIndex(index)
with Mixins.QBlockSignalContext(self):
self.setCurrentIndex(self.proxyIndexFromSourceRow(indexes[-1] - 1))
self.selectMultipleRows(list(index - 1 for index in indexes), True)
def moveDownItemByIndex(self, index):
"""Move down item by index."""
if index < 0 or index >= len(Storage.UserServers()) - 1:
# The bottom item, or does not exist. Do nothing
return
self.swapItem(index, index + 1)
def moveDownSelectedItem(self):
"""Move down selected item."""
indexes = self.selectedIndex
if len(indexes) == 0:
# Nothing selected. Do nothing
return
for index in indexes[::-1]:
self.moveDownItemByIndex(index)
with Mixins.QBlockSignalContext(self):
self.setCurrentIndex(self.proxyIndexFromSourceRow(indexes[0] + 1))
self.selectMultipleRows(list(index + 1 for index in indexes), True)
def duplicateSelectedItem(self):
"""Handle duplicate selected item for the user servers Qt table view."""
indexes = self.selectedIndex
if len(indexes) == 0:
# Nothing selected. Do nothing
return
for index in indexes:
if 0 <= index < len(Storage.UserServers()):
deepcopy = Storage.UserServers()[index].independentCopy()
# A duplicate is a new manual profile, not another profile
# managed by the source subscription.
self.appendNewItem(
remark=deepcopy.itemRemark,
config=deepcopy,
)
def deleteItemByIndex(
self, indexes, showTrayMessage=True, showProgress=True
) -> int:
"""Delete item by index."""
indexes = sorted(set(indexes))
if len(indexes) == 0:
# Nothing selected. Do nothing
return 0
if showProgress and len(indexes) > 1:
dialog = DeleteServersProgressDialog(
self,
indexes,
showTrayMessage=showTrayMessage,
parent=self.window(),
)
dialog.open()
return 0
if Storage.UserActivatedItemIndex() in indexes:
deleteActivated = True
else:
deleteActivated = False
# Note: param indexes must be sorted
for i in range(len(indexes)):
deleteIndex = indexes[i] - i
self.sourceModel.beginRemoveRows(
QtCore.QModelIndex(),
deleteIndex,
deleteIndex,
)
Storage.UserServers()[deleteIndex].deleted = True
Storage.UserServers().pop(deleteIndex)
self.sourceModel.endRemoveRows()
if not deleteActivated and deleteIndex < Storage.UserActivatedItemIndex():
AppSettings.set(
'ActivatedItemIndex', str(Storage.UserActivatedItemIndex() - 1)
)
# Refresh index
self.sourceModel.refreshIndexes()
self.sourceModel.emitAllChanged()
if deleteActivated:
# Set invalid first
AppSettings.set('ActivatedItemIndex', str(-1))
self.activeServerChanged.emit()
controller = AppConnectionController()
if controller.isConnected():
controller.startDisconnection(
_('Disconnected') if showTrayMessage else ''
)
return len(indexes)
def deleteSelectedItem(self):
"""Delete selected item."""
indexes = self.selectedIndex
if len(indexes) == 0:
# Nothing selected. Do nothing
return
def handleResultCode(_indexes, code):
"""Handle result code."""
if code == PySide6Legacy.enumValueWrapper(
AppQMessageBox.StandardButton.Yes
):
self.deleteItemByIndex(_indexes)
else:
pass
if PLATFORM == 'Windows':
# Windows
mbox = MBoxQuestionDelete(icon=AppQMessageBox.Icon.Question)
else:
# macOS & linux
mbox = MBoxQuestionDelete(
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} - ' + Storage.UserServers()[indexes[0]].itemRemark
)
mbox.setText(mbox.customText())
mbox.finished.connect(functools.partial(handleResultCode, indexes))
# Show the MessageBox asynchronously
mbox.open()
def editSelectedItemConfiguration(self):
"""Handle edit selected item configuration for the user servers Qt table view."""
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} - ' + Storage.UserServers()[index].itemRemark
self.configurationEditor.currentIndex = index
self.configurationEditor.customWindowTitle = title
self.configurationEditor.setWindowTitle(title)
self.configurationEditor.setPlainText(
Storage.UserServers()[index].toJSONString(), True
)
self.configurationEditor.show()
def activateSelectedServer(self):
"""Activate selected server."""
indexes = self.selectedIndex
if len(indexes) == 0:
# Nothing selected. Do nothing
return
if len(indexes) != 1:
# Should not reach here
return
item = self.proxyIndexFromSourceRow(indexes[0])
if item.isValid():
self.handleItemActivated(item)
def scrollToActivatedItem(self):
"""Handle scroll to activated item for the user servers Qt table view."""
activatedItem = self.activatedIndex()
if activatedItem.isValid():
self.setCurrentIndex(activatedItem)
self.scrollTo(activatedItem)
def rowFromFactory(self, fallbackIndex: int, factory: ServerProfile) -> int:
"""Return the row from factory value."""
if (
0 <= factory.index < len(Storage.UserServers())
and Storage.UserServers()[factory.index] is factory
):
return factory.index
if (
0 <= fallbackIndex < len(Storage.UserServers())
and Storage.UserServers()[fallbackIndex] is factory
):
return fallbackIndex
for index, item in enumerate(Storage.UserServers()):
if item is factory:
return index
return -1
def flushDownloadSpeedItem(self, fallbackIndex: int, factory: ServerProfile):
"""Refresh download speed item."""
index = self.rowFromFactory(fallbackIndex, factory)
if index < 0:
return
self.flushItem(index, self.Headers.index('Speed'), factory)
def testSelectedItemPingLatency(self):
"""Handle test selected item ping latency for the user servers Qt table view."""
indexes = self.selectedIndex
if len(indexes) == 0:
# Nothing selected. Do nothing
return
# Real selected factory
references = list(Storage.UserServers()[index] for index in indexes)
for index, reference in zip(indexes, references):
if appIsExiting():
break
assert isinstance(reference, ServerProfile)
if reference.deleted:
continue
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):
"""Handle test selected item tcping latency for the user servers Qt table view."""
indexes = self.selectedIndex
if len(indexes) == 0:
# Nothing selected. Do nothing
return
# Real selected factory
references = list(Storage.UserServers()[index] for index in indexes)
for index, reference in zip(indexes, references):
if appIsExiting():
break
assert isinstance(reference, ServerProfile)
if reference.deleted:
continue
worker = TestTcpingLatencyWorker(reference)
worker.setAutoDelete(True)
worker.finished.connect(
functools.partial(
self.flushItem,
index,
self.Headers.index('Latency'),
reference,
)
)
AppThreadPool().start(worker)
def testDownloadSpeedByFactory(
self,
index: int,
factory: ServerProfile,
port: int,
timeout: int,
isMulti: bool,
counter=0,
step=100,
logActionMessage=False,
):
"""Handle test download speed by factory for the user servers Qt table view."""
scheduler = (
self.downloadSpeedMultiScheduler if isMulti else self.downloadSpeedScheduler
)
scheduler.enqueue(index, factory, timeout, logActionMessage)
def testSelectedItemDownloadSpeedWithTimeoutXXX(
self,
scheduler: DownloadSpeedTestScheduler,
timeout: int,
):
"""Handle test selected item download speed with timeout xxx for the user servers Qt table view."""
indexes = self.selectedIndex
if len(indexes) == 0:
# Nothing selected. Do nothing
return
# Real selected factory
references = list(Storage.UserServers()[index] for index in indexes)
jobs = list()
for index, reference in zip(indexes, references):
jobs.append(DownloadSpeedTestJob(index, reference, timeout))
scheduler.enqueueMany(jobs)
def testSelectedItemDownloadSpeedWithTimeout(self, timeout: int):
"""Handle test selected item download speed with timeout for the user servers Qt table view."""
self.testSelectedItemDownloadSpeedWithTimeoutXXX(
self.downloadSpeedScheduler,
timeout,
)
def testSelectedItemDownloadSpeedWithTimeoutMulti(self, timeout: int):
"""Handle test selected item download speed with timeout multi for the user servers Qt table view."""
self.testSelectedItemDownloadSpeedWithTimeoutXXX(
self.downloadSpeedMultiScheduler,
timeout,
)
def testSelectedItemDownloadSpeed(self):
"""Handle test selected item download speed for the user servers Qt table view."""
self.testSelectedItemDownloadSpeedWithTimeout(5000)
def testSelectedItemDownloadSpeedMulti(self):
"""Handle test selected item download speed multi for the user servers Qt table view."""
self.testSelectedItemDownloadSpeedWithTimeoutMulti(5000)
def clearSelectedItemTestResult(self):
"""Clear selected item test result."""
indexes = self.selectedIndex
if len(indexes) == 0:
# Nothing selected. Do nothing
return
for index in indexes:
factory = Storage.UserServers()[index]
factory.metadata.latency = ''
factory.metadata.speed = ''
self.flushItem(index, self.Headers.index('Latency'), factory)
self.flushItem(index, self.Headers.index('Speed'), factory)
def cleanup(self):
"""Release resources owned by the user servers Qt table view."""
self.downloadSpeedScheduler.cancelAll()
self.downloadSpeedMultiScheduler.cancelAll()
def updateSubsByUnique(self, unique: str, httpProxy: Union[str, None], **kwargs):
"""Update subs by unique."""
self.subsManager.configureHttpProxy(httpProxy)
self.subsManager.updateSubsByUnique(unique, **kwargs)
def updateSubs(self, httpProxy: Union[str, None], **kwargs):
"""Update subs."""
self.selectionModel().clearSelection()
self.subsManager.configureHttpProxy(httpProxy)
self.subsManager.updateSubs(**kwargs)
def synchronizeSubscriptionProfiles(
self,
unique: str,
profiles,
synchronizer: SubscriptionSynchronizer | None = None,
):
"""Apply one group update without replacing unrelated profiles."""
synchronizer = synchronizer or SubscriptionSynchronizer()
servers = Storage.UserServers()
activatedIndex = Storage.UserActivatedItemIndex()
activeProfileId = ''
activeWasManagedByGroup = False
if 0 <= activatedIndex < len(servers):
active = servers[activatedIndex]
activeProfileId = active.metadata.profileId
activeWasManagedByGroup = (
active.itemSubscription == unique and active.itemSubscriptionManaged
)
wasConnected = AppConnectionController().isConnected()
self.sourceModel.beginResetModel()
try:
result = synchronizer.reconcile(servers, profiles, unique)
finally:
self.sourceModel.endResetModel()
newActivatedIndex = next(
(
index
for index, profile in enumerate(servers)
if profile.metadata.profileId == activeProfileId
),
-1,
)
AppSettings.set('ActivatedItemIndex', str(newActivatedIndex))
self.activeServerChanged.emit()
self.sourceModel.refreshIndexes()
self.proxyModel.invalidate()
if wasConnected and activeProfileId:
if newActivatedIndex < 0 and activeWasManagedByGroup:
AppConnectionController().startDisconnection()
elif activeProfileId in result.changedProfileIds:
AppConnectionController().startReconnection()
return result
def appendNewItemByFactory(self, factory: CoreConfiguration | ServerProfile):
"""Append new item by factory."""
factory = ensureProfile(factory)
index = len(Storage.UserServers())
# Set index
factory.index = index
self.sourceModel.beginInsertRows(QtCore.QModelIndex(), index, index)
Storage.UserServers().append(factory)
self.sourceModel.endInsertRows()
self.sourceModel.refreshIndexes()
self.flushRow(index, factory)
if index == 0:
# The first one. Click it
self.setCurrentIndex(self.proxyIndexFromSourceRow(0))
# Try to be user-friendly in some extreme cases
if not AppConnectionController().isConnected():
# Activate automatically
self.activateItemByIndex(0, True)
def appendNewItem(self, **kwargs):
"""Append new item."""
acceptInvalid = kwargs.pop('acceptInvalid', False)
model = {
'remark': kwargs.pop('remark', ''),
'config': kwargs.pop('config', ''),
'subsId': kwargs.pop('subsId', ''),
}
tostr = f'{model}'
factory = profileFromAny(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):
"""Export selected item URI."""
indexes = self.selectedIndex
if len(indexes) == 0:
# Nothing selected. Do nothing
return
def toURI(factory) -> str:
"""Export the configuration as a share URI."""
assert isinstance(factory, ServerProfile)
try:
return exportConfiguration(factory)
except Exception:
# Any non-exit exceptions
return ''
# TODO: MessageBox?
QApplication.clipboard().setText(
'\n'.join(list(toURI(Storage.UserServers()[index]) for index in indexes))
)
def exportSelectedItemQR(self):
"""Export selected item QR."""
indexes = self.selectedIndex
if len(indexes) == 0:
# Nothing selected. Do nothing
return
window = self.qrCodeWindowFactory()
window.initTabByIndex(indexes)
if window.tabCount() > 0:
window.show()
def exportSelectedItemJSON(self):
"""Export selected item JSON."""
indexes = self.selectedIndex
if len(indexes) == 0:
# Nothing selected. Do nothing
return
# TODO: MessageBox?
QApplication.clipboard().setText(
'\n'.join(
list(Storage.UserServers()[index].toJSONString() for index in indexes)
)
)
def showTabAndSpaces(self):
"""Show tab and spaces."""
self.configurationEditor.showTabAndSpaces()
def hideTabAndSpaces(self):
"""Hide tab and spaces."""
self.configurationEditor.hideTabAndSpaces()
def keyPressEvent(self, event):
"""Handle a key press for the user servers Qt table view."""
if event.key() == QtCore.Qt.Key.Key_Return:
if PLATFORM == 'Darwin':
# Activate by Enter key on macOS
self.handleItemActivated(self.currentIndex())
else:
super().keyPressEvent(event)
else:
super().keyPressEvent(event)
def retranslate(self):
"""Refresh translated text for the user servers Qt table view."""
self.sourceModel.headerDataChanged.emit(
QtCore.Qt.Orientation.Horizontal,
0,
len(self.Headers) - 1,
)