Files
LorenEteval_Furious/Furious/Window/HomePage.py
T
2026-09-09 01:00:02 +08:00

1134 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Copyright (C) 2024present Loren Eteval & contributors <loren.eteval@proton.me>
#
# This file is part of Furious.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""Provide 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()