# Copyright (C) 2024–present Loren Eteval & contributors # # This file is part of Furious. # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """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(WebGETManager): """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, mustCallOnce=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 mustCall(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(WebGETManager): """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 mustCall(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.must() def coreExitCallback(self, config: ConfigFactory, exitcode: int): """Handle the core exit callback.""" try: if exitcode == CoreProcess.ExitCode.ConfigurationError.value: self.factory.metadata.speed = 'Invalid' self.sync() elif exitcode == CoreProcess.ExitCode.ServerStartFailure.value: self.factory.metadata.speed = 'Core start failed' self.sync() elif exitcode == CoreProcess.ExitCode.SystemShuttingDown.value: pass else: self.factory.metadata.speed = f'Core exited {exitcode}' self.sync() finally: self.must() def _startKernel(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._startKernel(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.must() 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.must() 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[[ConfigFactory], 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, ), ), AppQSeperator(), AppQAction( _('Select All'), callback=lambda: self.selectAll(), shortcut=QtCore.QKeyCombination( QtCore.Qt.KeyboardModifier.ControlModifier, QtCore.Qt.Key.Key_A, ), ), AppQSeperator(), self.activateSelectedServerActionRef, 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 (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, ), ), AppQSeperator(), self.advancedActionRef, AppQSeperator(), AppQAction( _('New Empty Configuration'), callback=lambda: self.newEmptyItem(), shortcut=QtCore.QKeyCombination( QtCore.Qt.KeyboardModifier.ControlModifier, QtCore.Qt.Key.Key_N, ), ), *importActionsFactory(), 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.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: ConfigFactory | 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, )