mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-09-22 23:08:08 +03:00
1134 lines
40 KiB
Python
1134 lines
40 KiB
Python
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
|
||
#
|
||
# This file is part of Furious.
|
||
#
|
||
# This program is free software: you can redistribute it and/or modify
|
||
# it under the terms of the GNU General Public License as published by
|
||
# the Free Software Foundation, either version 3 of the License, or
|
||
# (at your option) any later version.
|
||
#
|
||
# This program is distributed in the hope that it will be useful,
|
||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
# GNU General Public License for more details.
|
||
#
|
||
# You should have received a copy of the GNU General Public License
|
||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||
|
||
"""Provide the server-management home page."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from Furious.Controllers import SYSTEM_PROXY_MODE_OPTIONS
|
||
from Furious.Frozenlib import *
|
||
from Furious.Interface import *
|
||
from Furious.Models import *
|
||
from Furious.Repository import *
|
||
from Furious.Plugins import getPluginRegistry
|
||
from Furious.Qt import *
|
||
from Furious.Qt import gettext as _
|
||
from Furious.Qt.Signals import connectWeakly
|
||
from Furious.Service import (
|
||
ConnectivityManager,
|
||
TrafficStatsManager,
|
||
UpdateManager,
|
||
formatTrafficSpeed,
|
||
formatTrafficUsage,
|
||
)
|
||
from Furious.Actions.Import import (
|
||
ImportFromFileAction,
|
||
ImportJSONFromClipboardAction,
|
||
ImportQRCodeOnTheScreenAction,
|
||
ImportURIFromClipboardAction,
|
||
)
|
||
from Furious.Widget.ConnectionButton import ConnectionButton
|
||
from Furious.Widget.RoutingSelector import RoutingSelector
|
||
from Furious.Widget.ServerTableView import *
|
||
from Furious.Window.NetworkTestDialog import *
|
||
from Furious.Window.ProxyBypassDialog import *
|
||
from Furious.Window.QRCodeWindow import QRCodeWindow
|
||
from Furious.Window.TextEditorWindow import TextEditorWindow
|
||
from Furious.Window.TunSettingsDialog import *
|
||
|
||
from PySide6 import QtCore
|
||
from PySide6.QtGui import *
|
||
from PySide6.QtWidgets import *
|
||
|
||
from typing import Union
|
||
|
||
import os
|
||
import logging
|
||
import functools
|
||
|
||
__all__ = ['HomePage']
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
STATUS_ITEM_SEPARATOR = '•'
|
||
|
||
|
||
class AppConnectivityManager(ConnectivityManager):
|
||
"""Coordinate app network connectivity operations."""
|
||
|
||
def __init__(self, parent=None):
|
||
"""Initialize the application connectivity manager."""
|
||
super().__init__(parent)
|
||
|
||
def successCallback(self, networkReply, **kwargs):
|
||
"""Handle a successful network operation."""
|
||
parent = self.parent()
|
||
|
||
if isinstance(parent, HomePage):
|
||
parent.setNetworkState(True)
|
||
|
||
super().successCallback(networkReply, **kwargs)
|
||
|
||
def failureCallback(self, networkReply, **kwargs):
|
||
"""Handle a failed network operation."""
|
||
parent = self.parent()
|
||
|
||
if isinstance(parent, HomePage):
|
||
parent.setNetworkState(False, errorString=networkReply.errorString())
|
||
|
||
super().failureCallback(networkReply, **kwargs)
|
||
|
||
def startSingleTest(self):
|
||
"""Start single test."""
|
||
if not AppConnectionController().isConnected():
|
||
parent = self.parent()
|
||
|
||
if isinstance(parent, HomePage):
|
||
parent.resetNetworkState()
|
||
|
||
self.stopTest()
|
||
|
||
connectedHttpProxy = Storage.Extras.UserHttpProxy()
|
||
|
||
if connectedHttpProxy is None:
|
||
parent = self.parent()
|
||
|
||
if isinstance(parent, HomePage):
|
||
parent.resetNetworkState()
|
||
else:
|
||
self.configureHttpProxy(connectedHttpProxy)
|
||
|
||
super().startSingleTest()
|
||
|
||
def disconnectedCallback(self):
|
||
"""Update the app network connectivity manager for a disconnected state."""
|
||
parent = self.parent()
|
||
|
||
if isinstance(parent, HomePage):
|
||
parent.resetNetworkState()
|
||
|
||
super().disconnectedCallback()
|
||
|
||
|
||
class NetworkStateBadge(Mixins.QTranslatable, Mixins.ThemeAware, QFrame):
|
||
"""Present connection and connectivity state as one compact Fluent badge."""
|
||
|
||
layoutRequirementChanged = QtCore.Signal()
|
||
|
||
DefaultIconFileName = 'plug.svg'
|
||
StateIconFileName = {
|
||
'disconnected': 'plug.svg',
|
||
'connecting': 'arrow-repeat.svg',
|
||
'connected': 'reception-4.svg',
|
||
'disconnecting': 'hourglass-split.svg',
|
||
'success': 'reception-4.svg',
|
||
'failure': 'reception-0.svg',
|
||
}
|
||
ConnectionStates = frozenset(
|
||
('disconnected', 'connecting', 'connected', 'disconnecting')
|
||
)
|
||
ConnectivityStates = frozenset(('success', 'failure'))
|
||
IconSize = QtCore.QSize(16, 16)
|
||
|
||
def __init__(self, parent=None):
|
||
"""Initialize the NetworkStateBadge."""
|
||
super().__init__(parent)
|
||
|
||
self.currentIconFileName = self.DefaultIconFileName
|
||
self.currentConnectionState = 'disconnected'
|
||
self.currentState = ''
|
||
self.currentRemark = ''
|
||
self.currentErrorString = ''
|
||
self._profileDetailsVisible = False
|
||
|
||
self.setObjectName('NetworkStateBadge')
|
||
self.setProperty('networkState', self.currentConnectionState)
|
||
self.setSizePolicy(
|
||
QSizePolicy.Policy.Maximum,
|
||
QSizePolicy.Policy.Fixed,
|
||
)
|
||
|
||
self.iconLabel, self.stateLabel, self.separatorLabel, self.profileLabel = (
|
||
QLabel(parent=self),
|
||
AppQLabel(translatable=False, parent=self),
|
||
AppQLabel(
|
||
STATUS_ITEM_SEPARATOR,
|
||
translatable=False,
|
||
parent=self,
|
||
),
|
||
AppQLabel(translatable=False, parent=self),
|
||
)
|
||
|
||
self.iconLabel.setFixedSize(self.IconSize)
|
||
self.stateLabel.setSizePolicy(
|
||
QSizePolicy.Policy.Fixed,
|
||
QSizePolicy.Policy.Preferred,
|
||
)
|
||
self.separatorLabel.setSizePolicy(
|
||
QSizePolicy.Policy.Fixed,
|
||
QSizePolicy.Policy.Preferred,
|
||
)
|
||
self.profileLabel.setSizePolicy(
|
||
QSizePolicy.Policy.Fixed,
|
||
QSizePolicy.Policy.Preferred,
|
||
)
|
||
|
||
self.stateLabel.setObjectName('NetworkStateLabel')
|
||
self.separatorLabel.setObjectName('NetworkStatusSeparator')
|
||
self.profileLabel.setObjectName('NetworkProfileLabel')
|
||
|
||
self._layout = QHBoxLayout(self)
|
||
self._layout.setContentsMargins(8, 2, 8, 2)
|
||
self._layout.setSpacing(5)
|
||
self._layout.addWidget(self.iconLabel)
|
||
self._layout.addWidget(self.stateLabel)
|
||
self._layout.addWidget(self.separatorLabel)
|
||
self._layout.addWidget(self.profileLabel)
|
||
|
||
connectionController = AppConnectionController()
|
||
connectionController.stateChanged.connect(self.handleConnectionStateChanged)
|
||
connectionController.activeProfileChanged.connect(
|
||
self.handleActiveConfigurationChanged
|
||
)
|
||
|
||
self.handleConnectionStateChanged(connectionController.state)
|
||
|
||
def setIconByTheme(self, theme: str, iconFileName: Union[str, None] = None):
|
||
"""Set icon by theme."""
|
||
iconFileName = iconFileName or self.currentIconFileName
|
||
|
||
if theme == AppStyleSheet.Dark:
|
||
icon = bootstrapIconWhite(iconFileName)
|
||
else:
|
||
icon = bootstrapIcon(iconFileName)
|
||
|
||
self.iconLabel.setPixmap(icon.pixmap(self.IconSize))
|
||
|
||
def presentationState(self) -> str:
|
||
"""Return the most specific state currently available for display."""
|
||
if self.currentConnectionState == 'connected' and self.currentState:
|
||
return self.currentState
|
||
|
||
return self.currentConnectionState
|
||
|
||
@staticmethod
|
||
def activeProfileRemark() -> str:
|
||
"""Return the active profile remark without its presentation row index."""
|
||
profile = AppConnectionController().activeProfile
|
||
|
||
if isinstance(profile, ServerProfile):
|
||
return profile.itemRemark
|
||
|
||
return ''
|
||
|
||
def statusText(self) -> str:
|
||
"""Return the complete, unelided status text for tooltips."""
|
||
stateText = {
|
||
'disconnected': _('Disconnected'),
|
||
'connecting': _('Connecting'),
|
||
'connected': _('Connected'),
|
||
'disconnecting': _('Disconnecting'),
|
||
'success': _('Network OK'),
|
||
'failure': _('Network error'),
|
||
}[self.presentationState()]
|
||
|
||
remark = self.profileRemark()
|
||
|
||
if remark:
|
||
return f'{stateText} {STATUS_ITEM_SEPARATOR} {remark}'
|
||
|
||
return stateText
|
||
|
||
def profileRemark(self) -> str:
|
||
"""Return the full profile text appropriate for the current state."""
|
||
if self.currentConnectionState == 'disconnected':
|
||
return ''
|
||
|
||
return self.activeProfileRemark() or self.currentRemark
|
||
|
||
def widthWithProfile(self) -> int:
|
||
"""Return the width needed to show the complete profile remark."""
|
||
self.ensurePolished()
|
||
|
||
margins = self._layout.contentsMargins()
|
||
spacing = self._layout.spacing()
|
||
|
||
return (
|
||
self.frameWidth() * 2
|
||
+ margins.left()
|
||
+ margins.right()
|
||
+ self.iconLabel.sizeHint().width()
|
||
+ self.stateLabel.sizeHint().width()
|
||
+ self.separatorLabel.sizeHint().width()
|
||
+ self.profileLabel.sizeHint().width()
|
||
+ spacing * 3
|
||
)
|
||
|
||
def widthWithoutProfile(self) -> int:
|
||
"""Return the natural compact width for icon and state text only."""
|
||
self.ensurePolished()
|
||
|
||
margins = self._layout.contentsMargins()
|
||
|
||
return (
|
||
self.frameWidth() * 2
|
||
+ margins.left()
|
||
+ margins.right()
|
||
+ self.iconLabel.sizeHint().width()
|
||
+ self.stateLabel.sizeHint().width()
|
||
+ self._layout.spacing()
|
||
)
|
||
|
||
def _updateWidthConstraints(self):
|
||
"""Publish size constraints for the current responsive presentation."""
|
||
if self._profileDetailsVisible:
|
||
requiredWidth = self.widthWithProfile()
|
||
|
||
self.setMinimumWidth(requiredWidth)
|
||
self.setMaximumWidth(requiredWidth)
|
||
else:
|
||
self.setMinimumWidth(0)
|
||
self.setMaximumWidth(self.widthWithoutProfile())
|
||
|
||
def setProfileDetailsVisible(self, visible: bool):
|
||
"""Show profile details whenever an active profile is available."""
|
||
visible = bool(visible and self.profileRemark())
|
||
|
||
if visible == self._profileDetailsVisible:
|
||
return
|
||
|
||
self._profileDetailsVisible = visible
|
||
self.separatorLabel.setVisible(visible)
|
||
self.profileLabel.setVisible(visible)
|
||
self._updateWidthConstraints()
|
||
self._layout.invalidate()
|
||
self.updateGeometry()
|
||
|
||
def statusToolTip(self):
|
||
"""Return the status tool tip value used by the network state badge."""
|
||
tooltip = self.statusText()
|
||
|
||
if self.presentationState() == 'failure' and self.currentErrorString:
|
||
tooltip += f'\n{self.currentErrorString}'
|
||
|
||
return tooltip
|
||
|
||
def updateStatusText(self):
|
||
"""Update status text."""
|
||
fullText = self.statusText()
|
||
stateText, separator, remark = fullText.partition(f' {STATUS_ITEM_SEPARATOR} ')
|
||
|
||
tooltip = self.statusToolTip()
|
||
|
||
self.stateLabel.setText(stateText)
|
||
self.profileLabel.setText(remark)
|
||
self.separatorLabel.setVisible(bool(separator) and self._profileDetailsVisible)
|
||
self.profileLabel.setVisible(bool(remark) and self._profileDetailsVisible)
|
||
self._updateWidthConstraints()
|
||
self.setToolTip(tooltip)
|
||
self.iconLabel.setToolTip(tooltip)
|
||
self.stateLabel.setToolTip(tooltip)
|
||
self.separatorLabel.setToolTip(tooltip)
|
||
self.profileLabel.setToolTip(tooltip)
|
||
|
||
self.layoutRequirementChanged.emit()
|
||
|
||
def refreshPresentation(self):
|
||
"""Refresh text, icon, and semantic style without recreating widgets."""
|
||
presentationState = self.presentationState()
|
||
|
||
self.setProperty('networkState', presentationState)
|
||
self.currentIconFileName = self.StateIconFileName.get(
|
||
presentationState,
|
||
self.DefaultIconFileName,
|
||
)
|
||
|
||
self.updateStatusText()
|
||
self.setIconByTheme(APP().theme())
|
||
self.refreshStyle()
|
||
|
||
@QtCore.Slot(object)
|
||
def handleConnectionStateChanged(self, state):
|
||
"""Reflect the connection controller's current lifecycle state."""
|
||
stateName = getattr(state, 'name', '').lower()
|
||
|
||
if stateName not in self.ConnectionStates:
|
||
return
|
||
|
||
self.currentConnectionState = stateName
|
||
|
||
if stateName != 'connected':
|
||
self.currentState = ''
|
||
self.currentErrorString = ''
|
||
|
||
self.refreshPresentation()
|
||
|
||
@QtCore.Slot(object)
|
||
def handleActiveConfigurationChanged(self, _configuration):
|
||
"""Refresh the profile label after the active profile changes."""
|
||
self.refreshPresentation()
|
||
|
||
def setStatus(self, state: str, remark: str, errorString: str = ''):
|
||
"""Set status."""
|
||
if state not in self.ConnectivityStates:
|
||
return
|
||
|
||
self.currentState = state
|
||
self.currentRemark = remark
|
||
self.currentErrorString = errorString
|
||
|
||
self.refreshPresentation()
|
||
|
||
def clearStatus(self):
|
||
"""Clear status."""
|
||
self.currentState = ''
|
||
self.currentRemark = ''
|
||
self.currentErrorString = ''
|
||
|
||
self.refreshPresentation()
|
||
|
||
def refreshStyle(self):
|
||
"""Refresh style."""
|
||
for widget in [
|
||
self,
|
||
self.iconLabel,
|
||
self.stateLabel,
|
||
self.separatorLabel,
|
||
self.profileLabel,
|
||
]:
|
||
widget.style().unpolish(widget)
|
||
widget.style().polish(widget)
|
||
widget.update()
|
||
|
||
def themeChangedCallback(self, theme: str):
|
||
"""Update the network state badge for a theme change."""
|
||
self.setIconByTheme(theme)
|
||
|
||
def retranslate(self):
|
||
"""Refresh translated text for the network state badge."""
|
||
self.refreshPresentation()
|
||
|
||
|
||
class TrafficStatsBadge(Mixins.QTranslatable, Mixins.ThemeAware, QWidget):
|
||
"""Display compact Fluent direction groups for live network statistics."""
|
||
|
||
layoutRequirementChanged = QtCore.Signal()
|
||
|
||
UploadIconFileName = 'cloud-upload.svg'
|
||
DownloadIconFileName = 'cloud-download.svg'
|
||
IconSize = QtCore.QSize(16, 16)
|
||
|
||
def __init__(self, parent=None):
|
||
"""Initialize dedicated traffic-direction icons and labels."""
|
||
super().__init__(parent)
|
||
|
||
self.setObjectName('TrafficStatsBadge')
|
||
self.setVisible(False)
|
||
|
||
(
|
||
self.downloadIconLabel,
|
||
self.downloadTextLabel,
|
||
self.downloadUsageLabel,
|
||
self.uploadIconLabel,
|
||
self.uploadTextLabel,
|
||
self.uploadUsageLabel,
|
||
) = (
|
||
QLabel(parent=self),
|
||
AppQLabel(translatable=False, parent=self),
|
||
AppQLabel(translatable=False, parent=self),
|
||
QLabel(parent=self),
|
||
AppQLabel(translatable=False, parent=self),
|
||
AppQLabel(translatable=False, parent=self),
|
||
)
|
||
|
||
self.downloadTextLabel.setObjectName('TrafficSpeedLabel')
|
||
self.uploadTextLabel.setObjectName('TrafficSpeedLabel')
|
||
self.uploadUsageLabel.setObjectName('TrafficUsageLabel')
|
||
self.downloadUsageLabel.setObjectName('TrafficUsageLabel')
|
||
|
||
self.downloadBadge = QFrame(parent=self)
|
||
self.downloadBadge.setObjectName('TrafficDirectionBadge')
|
||
self.downloadBadge.setProperty('direction', 'download')
|
||
|
||
self.uploadBadge = QFrame(parent=self)
|
||
self.uploadBadge.setObjectName('TrafficDirectionBadge')
|
||
self.uploadBadge.setProperty('direction', 'upload')
|
||
|
||
downloadLayout = QHBoxLayout(self.downloadBadge)
|
||
downloadLayout.setContentsMargins(8, 2, 8, 2)
|
||
downloadLayout.setSpacing(5)
|
||
downloadLayout.addWidget(self.downloadIconLabel)
|
||
downloadLayout.addWidget(self.downloadTextLabel)
|
||
downloadLayout.addWidget(self.downloadUsageLabel)
|
||
|
||
uploadLayout = QHBoxLayout(self.uploadBadge)
|
||
uploadLayout.setContentsMargins(8, 2, 8, 2)
|
||
uploadLayout.setSpacing(5)
|
||
uploadLayout.addWidget(self.uploadIconLabel)
|
||
uploadLayout.addWidget(self.uploadTextLabel)
|
||
uploadLayout.addWidget(self.uploadUsageLabel)
|
||
|
||
self._layout = QHBoxLayout(self)
|
||
self._layout.setContentsMargins(0, 1, 0, 1)
|
||
self._layout.setSpacing(5)
|
||
self._layout.addWidget(self.downloadBadge)
|
||
self._layout.addWidget(self.uploadBadge)
|
||
|
||
self.setIconByTheme(APP().theme())
|
||
self.updateToolTips()
|
||
|
||
def updateToolTips(self):
|
||
"""Apply translated descriptions to each traffic statistic."""
|
||
uploadSpeed, downloadSpeed, uploadUsage, downloadUsage = (
|
||
_('Upload Speed'),
|
||
_('Download Speed'),
|
||
_('Upload Traffic Usage'),
|
||
_('Download Traffic Usage'),
|
||
)
|
||
|
||
self.uploadIconLabel.setToolTip(uploadSpeed)
|
||
self.uploadTextLabel.setToolTip(uploadSpeed)
|
||
self.uploadUsageLabel.setToolTip(uploadUsage)
|
||
self.downloadIconLabel.setToolTip(downloadSpeed)
|
||
self.downloadTextLabel.setToolTip(downloadSpeed)
|
||
self.downloadUsageLabel.setToolTip(downloadUsage)
|
||
|
||
def setIconByTheme(self, theme: str):
|
||
"""Apply theme-appropriate upload and download icons."""
|
||
iconFactory = (
|
||
bootstrapIconWhite if theme == AppStyleSheet.Dark else bootstrapIcon
|
||
)
|
||
|
||
self.uploadIconLabel.setPixmap(
|
||
iconFactory(self.UploadIconFileName).pixmap(self.IconSize)
|
||
)
|
||
self.downloadIconLabel.setPixmap(
|
||
iconFactory(self.DownloadIconFileName).pixmap(self.IconSize)
|
||
)
|
||
|
||
@QtCore.Slot(float, float)
|
||
def setSpeeds(self, upload: float, download: float):
|
||
"""Display formatted upload and download byte rates."""
|
||
self.uploadTextLabel.setText(formatTrafficSpeed(upload))
|
||
self.downloadTextLabel.setText(formatTrafficSpeed(download))
|
||
self.setVisible(True)
|
||
self.layoutRequirementChanged.emit()
|
||
|
||
@QtCore.Slot(object, object)
|
||
def setUsage(self, upload: int, download: int):
|
||
"""Display formatted cumulative upload and download usage."""
|
||
self.uploadUsageLabel.setText(
|
||
f'{STATUS_ITEM_SEPARATOR} {formatTrafficUsage(upload)}'
|
||
)
|
||
self.downloadUsageLabel.setText(
|
||
f'{STATUS_ITEM_SEPARATOR} {formatTrafficUsage(download)}'
|
||
)
|
||
|
||
self.setVisible(True)
|
||
self.layoutRequirementChanged.emit()
|
||
|
||
@QtCore.Slot()
|
||
def clearStatistics(self):
|
||
"""Hide stale speed and usage values when statistics are unavailable."""
|
||
self.uploadTextLabel.clear()
|
||
self.uploadUsageLabel.clear()
|
||
self.downloadTextLabel.clear()
|
||
self.downloadUsageLabel.clear()
|
||
self.setVisible(False)
|
||
self.layoutRequirementChanged.emit()
|
||
|
||
def themeChangedCallback(self, theme: str):
|
||
"""Refresh traffic-direction icons after a theme change."""
|
||
self.setIconByTheme(theme)
|
||
|
||
def retranslate(self):
|
||
"""Refresh translated traffic-statistic tooltips."""
|
||
self.updateToolTips()
|
||
|
||
|
||
class ConnectionStatusWidget(QWidget):
|
||
"""Compose traffic metrics and network state as one permanent status group."""
|
||
|
||
def __init__(self, parent=None):
|
||
"""Initialize coordinated network-state and traffic-metric components."""
|
||
super().__init__(parent)
|
||
|
||
self.setObjectName('ConnectionStatusWidget')
|
||
self.networkState = NetworkStateBadge(parent=self)
|
||
self.trafficStats = TrafficStatsBadge(parent=self)
|
||
|
||
self._layout = QHBoxLayout(self)
|
||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||
self._layout.setSpacing(5)
|
||
self._layout.addStretch(1)
|
||
self._layout.addWidget(self.trafficStats)
|
||
self._layout.addWidget(self.networkState)
|
||
|
||
self.networkState.layoutRequirementChanged.connect(self._updateResponsiveLayout)
|
||
self.trafficStats.layoutRequirementChanged.connect(self._updateResponsiveLayout)
|
||
|
||
self._updateResponsiveLayout()
|
||
|
||
def resizeEvent(self, event):
|
||
"""Preserve the complete active-profile presentation after resizing."""
|
||
super().resizeEvent(event)
|
||
|
||
self._updateResponsiveLayout()
|
||
|
||
@QtCore.Slot()
|
||
def _updateResponsiveLayout(self):
|
||
"""Keep the active profile's complete remark visible in the status group."""
|
||
self.networkState.setProfileDetailsVisible(True)
|
||
|
||
|
||
class SearchButton(AppQIconTextPushButton):
|
||
"""Represent search button."""
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
"""Initialize the SearchButton."""
|
||
super().__init__(*args, **kwargs)
|
||
|
||
self.setObjectName('SearchButton')
|
||
self.setSizePolicy(
|
||
QSizePolicy.Policy.Fixed,
|
||
QSizePolicy.Policy.Fixed,
|
||
)
|
||
self.setText(_('Search'))
|
||
self.setIcon(bootstrapIcon('search.svg'))
|
||
|
||
def retranslate(self):
|
||
"""Refresh translated text for the search button."""
|
||
self.setText(_('Search'))
|
||
|
||
|
||
class HomePage(Mixins.QTranslatable, QMainWindow):
|
||
"""Own server management, its actions, and connection presentation."""
|
||
|
||
@staticmethod
|
||
def serverImportActions():
|
||
"""Build actions for the Home import menu and server table shortcuts."""
|
||
return (
|
||
ImportFromFileAction(),
|
||
ImportURIFromClipboardAction(
|
||
shortcut=QtCore.QKeyCombination(
|
||
QtCore.Qt.KeyboardModifier.ControlModifier,
|
||
QtCore.Qt.Key.Key_V,
|
||
),
|
||
),
|
||
ImportJSONFromClipboardAction(
|
||
shortcut=QtCore.QKeyCombination(
|
||
QtCore.Qt.KeyboardModifier.ControlModifier
|
||
| QtCore.Qt.KeyboardModifier.ShiftModifier,
|
||
QtCore.Qt.Key.Key_J,
|
||
),
|
||
),
|
||
AppQSeparator(),
|
||
ImportQRCodeOnTheScreenAction(),
|
||
)
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
"""Initialize the server-management page."""
|
||
super().__init__(*args, **kwargs)
|
||
|
||
self.setFocusPolicy(QtCore.Qt.FocusPolicy.StrongFocus)
|
||
|
||
self.updatesManager = UpdateManager(parent=self)
|
||
self.networkConnectivityManager = AppConnectivityManager(parent=self)
|
||
|
||
self.userServersQTableWidget = ServerTableView(
|
||
parent=self,
|
||
configurationEditorFactory=lambda: TextEditorWindow(parent=self),
|
||
qrCodeWindowFactory=QRCodeWindow,
|
||
importActionsFactory=self.serverImportActions,
|
||
)
|
||
|
||
pluginRegistry = getPluginRegistry()
|
||
|
||
# These two settings dialogs intentionally remain parent-owned and are
|
||
# refreshed/reused by SettingsPage instead of being recreated.
|
||
self.customizeProxyBypassDialog = ProxyBypassDialog(parent=self)
|
||
self.customizeNetworkTestDialog = NetworkTestDialog(parent=self)
|
||
|
||
serverActions = []
|
||
|
||
for descriptor in pluginRegistry.protocolDescriptors():
|
||
if (
|
||
not descriptor.addActionText
|
||
or pluginRegistry.editorForProtocol(descriptor.id) is None
|
||
):
|
||
continue
|
||
|
||
if descriptor.separatorBefore and serverActions:
|
||
serverActions.append(AppQSeparator())
|
||
|
||
if descriptor.translatable:
|
||
actionText = _(descriptor.addActionText)
|
||
else:
|
||
actionText = descriptor.addActionText
|
||
|
||
serverActions.append(
|
||
AppQAction(
|
||
actionText,
|
||
callback=functools.partial(
|
||
self.userServersQTableWidget.addServerViaGui,
|
||
descriptor.id,
|
||
# By default, editor window title is translatable
|
||
windowTitle=_(descriptor.editorWindowTitle),
|
||
),
|
||
translatable=descriptor.translatable,
|
||
)
|
||
)
|
||
|
||
if serverActions:
|
||
serverActions.append(AppQSeparator())
|
||
|
||
serverActions.append(
|
||
AppQAction(
|
||
_('New Empty Configuration'),
|
||
callback=lambda: self.userServersQTableWidget.newEmptyItem(),
|
||
)
|
||
)
|
||
|
||
self.serverMenu = AppQMenu(*serverActions, parent=self)
|
||
self.serverButton = AppQMenuPushButton(
|
||
_('Server'),
|
||
icon=bootstrapIcon('server.svg'),
|
||
popupMenu=self.serverMenu,
|
||
)
|
||
|
||
self.importMenu = AppQMenu(
|
||
*self.userServersQTableWidget.importActions, parent=self
|
||
)
|
||
self.importButton = AppQMenuPushButton(
|
||
_('Import'),
|
||
icon=bootstrapIcon('lightning-charge.svg'),
|
||
popupMenu=self.importMenu,
|
||
parent=self,
|
||
)
|
||
self.importButton.setEnabled(bool(self.userServersQTableWidget.importActions))
|
||
|
||
self.testMenu = AppQMenu(*self.userServersQTableWidget.testActions, parent=self)
|
||
self.testButton = AppQMenuPushButton(
|
||
_('Tests'),
|
||
icon=bootstrapIcon('graph-up.svg'),
|
||
popupMenu=self.testMenu,
|
||
parent=self,
|
||
)
|
||
|
||
self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.NoContextMenu)
|
||
|
||
# TODO: Custom status tip
|
||
# self.setStatusBar(QStatusBar(self))
|
||
|
||
self.connectionStatus = ConnectionStatusWidget(parent=self)
|
||
self.networkState = self.connectionStatus.networkState
|
||
self.trafficStats = self.connectionStatus.trafficStats
|
||
self.trafficStatsManager = TrafficStatsManager(parent=self)
|
||
self.trafficStatsManager.speedChanged.connect(self.trafficStats.setSpeeds)
|
||
self.trafficStatsManager.usageChanged.connect(self.trafficStats.setUsage)
|
||
self.trafficStatsManager.statisticsUnavailable.connect(
|
||
self.trafficStats.clearStatistics
|
||
)
|
||
|
||
self.statusBar().addPermanentWidget(self.connectionStatus, 1)
|
||
|
||
self._widget = QWidget()
|
||
self._widget.setObjectName('HomePageContent')
|
||
self._layout = QVBoxLayout(self._widget)
|
||
self._layout.setContentsMargins(20, 18, 20, 20)
|
||
self._layout.setSpacing(12)
|
||
|
||
self.pageTitleLabel = AppQLabel(_('Server'))
|
||
self.pageTitleLabel.setObjectName('HomePageTitle')
|
||
|
||
self.connectButton = ConnectionButton(
|
||
self.activateSelectedServerForConnection,
|
||
parent=self,
|
||
)
|
||
|
||
self.routingSelector = RoutingSelector(parent=self)
|
||
|
||
self.systemProxyComboBox = AppQComboBox(parent=self)
|
||
self.systemProxyComboBox.setContentWidthAdjustable()
|
||
self.systemProxyComboBox.setMinimumWidth(220)
|
||
self.systemProxyComboBox.setToolTip(_('System Proxy'))
|
||
|
||
for label, mode in SYSTEM_PROXY_MODE_OPTIONS:
|
||
self.systemProxyComboBox.addItem(_(label), mode)
|
||
|
||
self.tunModeLabel = AppQLabel(tunModeTitle(), parent=self)
|
||
self.tunModeSwitch = AppQSwitch(parent=self)
|
||
self._tunModeAvailable = AppSettingsController().tunModeAvailable()
|
||
|
||
self._syncSystemProxyMode(AppSettings.get('SystemProxyMode'))
|
||
self.tunModeSwitch.syncChecked(AppSettings.isStateON_('VPNMode'))
|
||
|
||
self.searchLineEdit = AppQLineEdit()
|
||
self.searchLineEdit.setClearButtonEnabled(True)
|
||
self.searchLineEdit.setPlaceholderText(
|
||
_(
|
||
'Search servers with text or regex, e.g. trojan, hk|jp, ^vmess, (us|sg).*tls'
|
||
)
|
||
)
|
||
self.searchLineEdit.setMinimumWidth(280)
|
||
self.searchLineEdit.setMaximumWidth(700)
|
||
self.searchLineEdit.setSizePolicy(
|
||
QSizePolicy.Policy.Expanding,
|
||
QSizePolicy.Policy.Fixed,
|
||
)
|
||
|
||
self.searchButton = SearchButton()
|
||
|
||
self.subscriptionFilterComboBox = AppQComboBox()
|
||
self.subscriptionFilterComboBox.setContentWidthAdjustable()
|
||
self.subscriptionFilterComboBox.setMinimumWidth(190)
|
||
self.subscriptionFilterComboBox.setSizePolicy(
|
||
QSizePolicy.Policy.Preferred,
|
||
QSizePolicy.Policy.Fixed,
|
||
)
|
||
|
||
self.headerLayout = QHBoxLayout()
|
||
self.headerLayout.setContentsMargins(0, 0, 0, 0)
|
||
self.headerLayout.setSpacing(8)
|
||
self.headerLayout.addWidget(self.pageTitleLabel)
|
||
self.headerLayout.addStretch(1)
|
||
self.headerLayout.addWidget(self.searchLineEdit, 4)
|
||
self.headerLayout.addWidget(self.searchButton)
|
||
|
||
self.connectionLayout = QHBoxLayout()
|
||
self.connectionLayout.setContentsMargins(0, 0, 0, 0)
|
||
self.connectionLayout.setSpacing(8)
|
||
self.connectionLayout.addWidget(self.connectButton)
|
||
self.connectionLayout.addWidget(self.routingSelector)
|
||
self.connectionLayout.addWidget(self.systemProxyComboBox)
|
||
self.connectionLayout.addWidget(self.tunModeLabel)
|
||
self.connectionLayout.addWidget(self.tunModeSwitch)
|
||
self.connectionLayout.addStretch(1)
|
||
|
||
self.actionLayout = QHBoxLayout()
|
||
self.actionLayout.setContentsMargins(0, 0, 0, 0)
|
||
self.actionLayout.setSpacing(8)
|
||
self.actionLayout.addWidget(self.serverButton)
|
||
self.actionLayout.addWidget(self.importButton)
|
||
self.actionLayout.addWidget(self.testButton)
|
||
self.actionLayout.addStretch(1)
|
||
self.actionLayout.addWidget(self.subscriptionFilterComboBox)
|
||
|
||
self._layout.addLayout(self.headerLayout)
|
||
self._layout.addLayout(self.connectionLayout)
|
||
self._layout.addLayout(self.actionLayout)
|
||
|
||
self.emptyState = QWidget(parent=self)
|
||
emptyLayout = QHBoxLayout(self.emptyState)
|
||
emptyLayout.setContentsMargins(0, 0, 0, 0)
|
||
|
||
self.emptyStateLabel = AppQLabel(translatable=False, parent=self.emptyState)
|
||
self.emptyStateLabel.setWordWrap(True)
|
||
|
||
emptyLayout.addWidget(self.emptyStateLabel, 1)
|
||
|
||
self._layout.addWidget(self.emptyState)
|
||
self._layout.addWidget(self.userServersQTableWidget, 1)
|
||
|
||
for model in (
|
||
self.userServersQTableWidget.sourceModel,
|
||
self.userServersQTableWidget.proxyModel,
|
||
):
|
||
for signal in (
|
||
model.rowsInserted,
|
||
model.rowsRemoved,
|
||
model.modelReset,
|
||
model.layoutChanged,
|
||
):
|
||
connectWeakly(signal, self, 'refreshEmptyState', sender=model)
|
||
|
||
self.refreshEmptyState()
|
||
|
||
self.searchButton.clicked.connect(
|
||
lambda: self.userServersQTableWidget.search(self.searchLineEdit.text())
|
||
)
|
||
|
||
self.searchLineEdit.returnPressed.connect(
|
||
lambda: self.userServersQTableWidget.search(self.searchLineEdit.text())
|
||
)
|
||
self.searchLineEdit.textChanged.connect(self.handleUserServersSearchTextChanged)
|
||
|
||
self.subscriptionFilterComboBox.currentIndexChanged.connect(
|
||
self.handleSubscriptionFilterChanged
|
||
)
|
||
self.systemProxyComboBox.currentIndexChanged.connect(
|
||
self.handleSystemProxyModeChanged
|
||
)
|
||
self.tunModeSwitch.toggled.connect(self.handleTUNModeChanged)
|
||
|
||
AppSettingsController().systemProxyModeChanged.connect(
|
||
self._syncSystemProxyMode
|
||
)
|
||
AppSettingsController().tunModeChanged.connect(
|
||
self.tunModeSwitch.syncCheckedAnimated
|
||
)
|
||
|
||
AppConnectionController().interactionEnabledChanged.connect(
|
||
self.setConnectionControlsEnabled
|
||
)
|
||
|
||
self.userServersQTableWidget.subsManager.subscriptionsChanged.connect(
|
||
self.refreshSubscriptionFilter
|
||
)
|
||
self.userServersQTableWidget.selectionModel().selectionChanged.connect(
|
||
self.handleServerSelectionChanged
|
||
)
|
||
self.userServersQTableWidget.activeServerChanged.connect(
|
||
AppRoutingController().refresh
|
||
)
|
||
|
||
self.refreshSubscriptionFilter()
|
||
self.handleServerSelectionChanged()
|
||
self.setConnectionControlsEnabled(AppConnectionController().interactionEnabled)
|
||
|
||
self.setCentralWidget(self._widget)
|
||
|
||
@QtCore.Slot()
|
||
def refreshEmptyState(self, *_args):
|
||
"""Explain empty storage separately from an empty filtered view."""
|
||
table = self.userServersQTableWidget
|
||
empty = table.proxyModel.rowCount() == 0
|
||
|
||
self.emptyState.setVisible(empty)
|
||
if empty:
|
||
self.emptyStateLabel.setText(
|
||
_(
|
||
'No profiles yet. Use Server to add a profile, Import to load profiles, or Subscriptions to add a subscription.'
|
||
)
|
||
if table.sourceModel.rowCount() == 0
|
||
else _('No profiles match the current filters.')
|
||
)
|
||
|
||
@QtCore.Slot(str)
|
||
def _syncSystemProxyMode(self, mode: str):
|
||
"""Select the shared system-proxy preference without writing it again."""
|
||
with Mixins.QBlockSignalContext(self.systemProxyComboBox):
|
||
index = self.systemProxyComboBox.findData(mode)
|
||
|
||
self.systemProxyComboBox.setCurrentIndex(max(0, index))
|
||
|
||
@QtCore.Slot()
|
||
def handleSystemProxyModeChanged(self):
|
||
"""Persist a system-proxy selection through the shared settings authority."""
|
||
mode = self.systemProxyComboBox.currentData()
|
||
|
||
if isinstance(mode, str):
|
||
AppSettingsController().setSystemProxyMode(mode)
|
||
|
||
@QtCore.Slot(bool)
|
||
def handleTUNModeChanged(self, enabled: bool):
|
||
"""Persist a TUN request through the shared settings authority."""
|
||
AppSettingsController().setTUNMode(enabled)
|
||
|
||
@QtCore.Slot(bool)
|
||
def setConnectionControlsEnabled(self, enabled: bool):
|
||
"""Gate connection-sensitive Home controls during lifecycle transitions."""
|
||
self.systemProxyComboBox.setEnabled(bool(enabled))
|
||
self.tunModeSwitch.setEnabled(bool(enabled) and self._tunModeAvailable)
|
||
|
||
@QtCore.Slot()
|
||
def handleServerSelectionChanged(self, *_args):
|
||
"""Apply Home's selection policy to the shared connection control."""
|
||
self.connectButton.setSelectionCount(
|
||
len(self.userServersQTableWidget.selectedIndex)
|
||
)
|
||
|
||
def activateSelectedServerForConnection(self) -> bool:
|
||
"""Activate exactly one selected server before requesting connection."""
|
||
indexes = self.userServersQTableWidget.selectedIndex
|
||
|
||
if len(indexes) != 1:
|
||
return False
|
||
|
||
self.userServersQTableWidget.activateSelectedServer()
|
||
|
||
AppRoutingController().refresh()
|
||
|
||
return Storage.UserActivatedItemIndex() == indexes[0]
|
||
|
||
@QtCore.Slot(str)
|
||
def handleUserServersSearchTextChanged(self, text: str):
|
||
"""Handle user servers search text changed."""
|
||
if not text:
|
||
self.userServersQTableWidget.clearSearch()
|
||
|
||
@QtCore.Slot()
|
||
def refreshSubscriptionFilter(self):
|
||
"""Refresh the stable group selector without losing its selection."""
|
||
selected = self.subscriptionFilterComboBox.currentData()
|
||
|
||
with Mixins.QBlockSignalContext(self.subscriptionFilterComboBox):
|
||
self.subscriptionFilterComboBox.clear()
|
||
self.subscriptionFilterComboBox.addItem(_('All Profiles'), None)
|
||
self.subscriptionFilterComboBox.addItem(_('Manual Profiles'), '')
|
||
|
||
for group in Storage.SubscriptionGroups():
|
||
self.subscriptionFilterComboBox.addItem(
|
||
group.remark or group.webURL or group.id,
|
||
group.id,
|
||
)
|
||
|
||
index = self.subscriptionFilterComboBox.findData(selected)
|
||
|
||
self.subscriptionFilterComboBox.setCurrentIndex(max(0, index))
|
||
|
||
self.handleSubscriptionFilterChanged()
|
||
|
||
@QtCore.Slot()
|
||
def handleSubscriptionFilterChanged(self):
|
||
"""Apply the selected ownership group to the server table."""
|
||
self.userServersQTableWidget.filterBySubscription(
|
||
self.subscriptionFilterComboBox.currentData()
|
||
)
|
||
|
||
def showSubscriptionGroup(self, unique: str):
|
||
"""Select one group in the Home profile filter."""
|
||
index = self.subscriptionFilterComboBox.findData(unique)
|
||
|
||
if index >= 0:
|
||
self.subscriptionFilterComboBox.setCurrentIndex(index)
|
||
|
||
def updateSubsByUnique(self, unique: str, httpProxy: Union[str, None], **kwargs):
|
||
"""Update subs by unique."""
|
||
self.userServersQTableWidget.updateSubsByUnique(unique, httpProxy, **kwargs)
|
||
|
||
def appendNewItemByFactory(self, factory: CoreConfiguration | ServerProfile):
|
||
"""Append new item by factory."""
|
||
self.userServersQTableWidget.appendNewItemByFactory(factory)
|
||
|
||
def appendNewItemsByFactories(self, factories):
|
||
"""Forward a batch of prepared profiles to the server table."""
|
||
self.userServersQTableWidget.appendNewItemsByFactories(factories)
|
||
|
||
def flushRow(self, row: int, item: ServerProfile):
|
||
"""Refresh row."""
|
||
self.userServersQTableWidget.flushRow(row, item)
|
||
|
||
def showTabAndSpaces(self):
|
||
"""Show tab and spaces."""
|
||
self.userServersQTableWidget.showTabAndSpaces()
|
||
|
||
def hideTabAndSpaces(self):
|
||
"""Hide tab and spaces."""
|
||
self.userServersQTableWidget.hideTabAndSpaces()
|
||
|
||
def getGuiTUNSettings(self, **kwargs):
|
||
"""Create a transient TUN settings editor for the settings page."""
|
||
parent = kwargs.pop('parent', self)
|
||
|
||
guiTUNSettings = TunSettingsDialog(parent=parent, **kwargs)
|
||
guiTUNSettings.factoryToInput(Storage.UserTUNSettings())
|
||
|
||
return guiTUNSettings
|
||
|
||
def checkForUpdates(self, **kwargs):
|
||
"""Check for updates."""
|
||
self.updatesManager.configureHttpProxy(Storage.Extras.UserHttpProxy())
|
||
self.updatesManager.checkForUpdates(**kwargs)
|
||
|
||
def resetNetworkState(self):
|
||
"""Reset network state."""
|
||
self.networkState.clearStatus()
|
||
|
||
def setNetworkState(self, success: bool, **kwargs):
|
||
"""Set network state."""
|
||
profile = AppConnectionController().activeProfile
|
||
remark = profile.itemRemark if isinstance(profile, ServerProfile) else ''
|
||
|
||
if not remark:
|
||
self.resetNetworkState()
|
||
|
||
return
|
||
|
||
if success:
|
||
self.networkState.setStatus(
|
||
'success',
|
||
remark,
|
||
)
|
||
|
||
return
|
||
|
||
errorString = kwargs.pop('errorString', '')
|
||
|
||
self.networkState.setStatus(
|
||
'failure',
|
||
remark,
|
||
errorString,
|
||
)
|
||
|
||
@staticmethod
|
||
def openApplicationFolder():
|
||
"""Open application folder."""
|
||
appFolder = os.path.dirname(APP().applicationFilePath())
|
||
|
||
if QDesktopServices.openUrl(QtCore.QUrl(appFolder)):
|
||
logger.info(f'open application folder \'{appFolder}\' success')
|
||
else:
|
||
logger.error(f'open application folder \'{appFolder}\' failed')
|
||
|
||
@staticmethod
|
||
def restartAsAdmin():
|
||
"""Handle the home-page request to restart with elevated privileges."""
|
||
if not SystemRuntime.isScriptMode():
|
||
if not SystemRuntime.isAdmin():
|
||
if PLATFORM == 'Windows':
|
||
QtCore.QProcess.startDetached(
|
||
'powershell',
|
||
arguments=[
|
||
'-Command',
|
||
f'Start-Process \'{APP().applicationFilePath()}\' '
|
||
f'\'{AppBuiltinCommand.RunAs.value}\' -Verb runAs',
|
||
],
|
||
)
|
||
elif PLATFORM == 'Darwin':
|
||
QtCore.QProcess.startDetached(
|
||
'osascript',
|
||
arguments=[
|
||
'-e',
|
||
f'do shell script \"{APP().applicationFilePath()} '
|
||
f'{AppBuiltinCommand.RunAs.value}\" with administrator privileges',
|
||
],
|
||
)
|
||
else:
|
||
mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Information)
|
||
mbox.setWindowTitle(_(APPLICATION_NAME))
|
||
mbox.setText(_('I am the supreme authority 👑'))
|
||
|
||
# Show the MessageBox asynchronously
|
||
mbox.open()
|
||
|
||
logger.info('ignored request to restart as admin as already is')
|
||
else:
|
||
logger.info(f'ignored request to restart as admin in script mode')
|
||
|
||
@staticmethod
|
||
def openAboutPage():
|
||
"""Open about page."""
|
||
if QDesktopServices.openUrl(QtCore.QUrl(APPLICATION_ABOUT_PAGE)):
|
||
logger.info('open about page success')
|
||
else:
|
||
logger.error('open about page failed')
|
||
|
||
def retranslate(self):
|
||
"""Refresh text owned directly by the home page."""
|
||
self.refreshEmptyState()
|