Centralize connection lifecycle control

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-08-12 12:24:03 +08:00
parent e5b1050004
commit f332f634ec
18 changed files with 698 additions and 539 deletions
+70 -430
View File
@@ -15,48 +15,23 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""Implement tray actions for connect."""
"""Present the shared connection controller as a tray action."""
from __future__ import annotations
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.Service import (
TUN2SOCKS_LOG_CATEGORY,
ConnectionManager,
UpdateManager,
coreLogCallback,
from Furious.Controllers.ConnectionController import (
ConnectionController,
ConnectionError,
ConnectionState,
)
from Furious.Frozenlib import APP, AppSettings
from Furious.Qt import AppQAction, AppQMessageBox, bootstrapIcon
from Furious.Qt import gettext as _
from Furious.Widget.ConnectionProgressWidget import ConnectionProgressWidget
from PySide6 import QtCore
from enum import Enum
import queue
import logging
import functools
__all__ = ['ConnectionState', 'ConnectAction']
logger = logging.getLogger(__name__)
registerAppSettings('Connect', isBinary=True)
class ConnectionState(Enum):
"""Describe the shared application connection lifecycle."""
Disconnected = 'Connect'
Connecting = 'Connecting'
Connected = 'Disconnect'
Disconnecting = 'Disconnecting'
__all__ = ['ConnectAction']
_TRANSLATABLE_CONNECTION_STATES = (
_('Connect'),
@@ -66,28 +41,13 @@ _TRANSLATABLE_CONNECTION_STATES = (
)
def validateProxyServer(server) -> bool:
"""Validate proxy server."""
try:
host, port = parseHostPort(server)
if int(port) < 0 or int(port) > 65535:
raise ValueError
except Exception:
# Any non-exit exceptions
return False
else:
return True
class ConnectAction(AppQAction):
"""Own connection operations and expose their state to every UI surface."""
"""Adapt connection state and operations to a tray QAction."""
stateChanged = QtCore.Signal(object)
def __init__(self, controller: ConnectionController, **kwargs):
"""Bind tray presentation to the shared connection controller."""
self.controller = controller
def __init__(self, **kwargs):
"""Initialize the ConnectAction."""
super().__init__(
_('Connect'),
icon=bootstrapIcon('unlock-fill.svg'),
@@ -95,30 +55,21 @@ class ConnectAction(AppQAction):
**kwargs,
)
self.actionQueue = queue.Queue()
self.coreManager = ConnectionManager()
self.progressBar = ConnectionProgressWidget()
self._state = ConnectionState.Disconnected
self._activeConfiguration = None
self.progressWidget = ConnectionProgressWidget()
self.actionTimer = QtCore.QTimer()
self.actionTimer.timeout.connect(lambda: self.callActionFromQueue())
self.controller.stateChanged.connect(self.syncPresentation)
self.controller.progressStarted.connect(self.showProgress)
self.controller.progressFinished.connect(self.hideProgress)
self.controller.notificationRequested.connect(self.showNotification)
self.controller.errorOccurred.connect(self.showError)
self.updatesManager = UpdateManager()
self.syncPresentation()
@property
def state(self) -> ConnectionState:
"""Return the current connection lifecycle state."""
return self._state
@QtCore.Slot()
def syncPresentation(self, *_args):
"""Render the controller's state through text, icon, and action state."""
state = self.controller.state
@property
def activeConfiguration(self):
"""Return the configuration owned by the current connection attempt."""
return self._activeConfiguration
def _applyStatePresentation(self):
"""Apply the tray action presentation for the shared lifecycle state."""
state = self.state
self.setText(_(state.value))
self.setChecked(
state
@@ -127,379 +78,68 @@ class ConnectAction(AppQAction):
ConnectionState.Connected,
)
)
if state in (ConnectionState.Connecting, ConnectionState.Connected):
self.setIcon(bootstrapIcon('lock-fill.svg'))
else:
self.setIcon(bootstrapIcon('unlock-fill.svg'))
self.setDisabledAction(
state in (ConnectionState.Connecting, ConnectionState.Disconnecting)
self.setIcon(
bootstrapIcon(
'lock-fill.svg'
if state
in (
ConnectionState.Connecting,
ConnectionState.Connected,
)
else 'unlock-fill.svg'
)
)
self.setEnabled(self.controller.interactionEnabled)
def _setState(self, state: ConnectionState):
"""Publish one atomic lifecycle transition and its action presentation."""
changed = state is not self._state
self._state = state
self._applyStatePresentation()
if changed:
self.stateChanged.emit(state)
def reset(self):
"""Restore the connect action to its initial state."""
self.hideProgressBar(True)
self._activeConfiguration = None
AppSettings.turnOFF('Connect')
self._setState(ConnectionState.Disconnected)
def showProgressBar(self):
"""Show progress bar."""
@QtCore.Slot()
def showProgress(self):
"""Show connection progress when the user preference allows it."""
if AppSettings.isStateON_('ShowProgressBarWhenConnecting'):
self.progressBar.setValue(0)
# Update the progress bar every 50ms
self.progressBar.start(50)
self.progressBar.show()
self.progressWidget.setValue(0)
self.progressWidget.start(50)
self.progressWidget.show()
return self
def hideProgressBar(self, done: bool):
"""Hide progress bar."""
@QtCore.Slot(bool)
def hideProgress(self, done: bool):
"""Stop and close the connection progress presentation."""
if done:
self.progressBar.setValue(100)
self.progressWidget.setValue(100)
self.progressBar.close()
self.progressBar.stop()
return self
def setDisabledAction(self, value):
"""Set disabled action."""
self.setDisabled(value)
self.progressWidget.close()
self.progressWidget.stop()
@staticmethod
@QtCore.Slot(str)
def showNotification(message: str):
"""Present a controller notification through the system tray."""
try:
APP().routingController.setInteractionEnabled(not value)
APP().systemTray.showMessage(message)
except (AttributeError, RuntimeError):
pass
try:
APP().mainWindow.settingsPage.setConnectionControlsEnabled(not value)
except (AttributeError, RuntimeError):
pass
def isConnected(self) -> bool:
"""Return whether connected."""
return self.state is ConnectionState.Connected
def isConnecting(self):
"""Return whether connecting."""
return self.state is ConnectionState.Connecting
def isDisconnecting(self):
"""Return whether disconnecting."""
return self.state is ConnectionState.Disconnecting
def doConnecting(self):
"""Handle do connecting for the connect action."""
self._setState(ConnectionState.Connecting)
self.showProgressBar()
def doConnected(self):
"""Handle do connected for the connect action."""
self.hideProgressBar(True)
AppSettings.turnON_('Connect')
self._setState(ConnectionState.Connected)
Mixins.ConnectionAware.callConnectedCallback()
def doDisconnect(self):
"""Handle do disconnect for the connect action."""
if self.state is ConnectionState.Disconnected:
@staticmethod
@QtCore.Slot(object)
def showError(error: ConnectionError):
"""Present a structured controller error asynchronously."""
if not isinstance(error, ConnectionError):
return
self._setState(ConnectionState.Disconnecting)
mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Critical)
mbox.setWindowTitle(error.title)
mbox.setText(error.message)
SystemProxy.off()
if error.details:
mbox.setInformativeText(error.details)
self.actionTimer.stop()
self.coreManager.stopAll()
self.reset()
while not self.actionQueue.empty():
try:
unused = self.actionQueue.get_nowait()
except Exception:
# Any non-exit exceptions
pass
Mixins.ConnectionAware.callDisconnectedCallback()
def doDisconnectWithTrayMessage(self, message: str):
"""Handle do disconnect with tray message for the connect action."""
self.doDisconnect()
APP().systemTray.showMessage(message)
def doReconnect(self, message=''):
"""Handle do reconnect for the connect action."""
self.doDisconnectWithTrayMessage(message)
self.trigger()
def doConnect(self):
# Connect action
"""Return the do connect value used by the connect action."""
assert self.state is ConnectionState.Disconnected
if not Storage.UserServers():
AppSettings.turnOFF('Connect')
self.setChecked(False)
mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Critical)
mbox.setWindowTitle(_('Unable to connect'))
mbox.setText(
_('Server configuration empty. Please configure your server first')
)
# Show the MessageBox asynchronously
mbox.open()
return
if Storage.UserActivatedItemIndex() < 0:
AppSettings.turnOFF('Connect')
self.setChecked(False)
mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Critical)
mbox.setWindowTitle(_('Unable to connect'))
mbox.setText(
_('Select and press Enter to activate configuration and connect')
)
# Show the MessageBox asynchronously
mbox.open()
return
try:
config = Storage.UserServers()[Storage.UserActivatedItemIndex()]
except Exception:
# Any non-exit exceptions
AppSettings.turnOFF('Connect')
self.setChecked(False)
else:
assert isinstance(config, ServerProfile)
@forceToLocalhostIfPossible()
def getHttpProxy() -> str:
"""Return HTTP proxy."""
return config.httpProxy()
httpProxy = getHttpProxy()
if not validateProxyServer(httpProxy):
# Proxy server is not valid. Do not connect
AppSettings.turnOFF('Connect')
self.setChecked(False)
mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Critical)
mbox.setWindowTitle(_('Unable to connect'))
mbox.setText(
_(
f'{APPLICATION_NAME} cannot find any valid http proxy endpoint in the configuration'
)
)
mbox.setInformativeText(_('Please complete your server configuration'))
# Show the MessageBox asynchronously
mbox.open()
return
self._activeConfiguration = config
self.doConnecting()
logManager = AppLogManager()
# Retain application diagnostics while starting a fresh runtime log.
logManager.clear(runtimeOnly=True)
success = self.coreManager.start(
config,
routing=AppSettings.get('Routing'),
exitCallback=self.coreExitCallback,
msgCallbackCore=coreLogCallback(logManager),
msgCallbackTUN_=logManager.callback(
TUN2SOCKS_LOG_CATEGORY,
source='Tun2socks',
),
)
if self.actionQueue.empty():
if success:
# Use custom proxy bypass address if possible
settings = AppSettings.get('CustomProxyBypass')
if isinstance(settings, str):
proxyServerBypass = settings
else:
proxyServerBypass = PROXY_SERVER_BYPASS
try:
SystemProxy.set(httpProxy, proxyServerBypass)
except Exception as ex:
# Any non-exit exceptions
logger.error(f'error while setting http proxy: {ex}')
logger.error(
f'failed to connect successfully. '
f'This may be due to improper proxy '
f'server bypass settings: {proxyServerBypass}'
)
self.coreManager.stopAll()
self.doDisconnectWithTrayMessage(
f'{config.coreName()}: ' + _('Unknown error')
)
else:
self.doConnected()
APP().systemTray.showMessage(
f'{config.coreName()}: ' + _('Connected')
)
if AppSettings.isStateON_('PowerSaveMode'):
# Power optimization
self.actionTimer.start(CORE_CHECK_ALIVE_INTERVAL * 2)
else:
self.actionTimer.start(CORE_CHECK_ALIVE_INTERVAL)
self.doConnectedCallOnceOnly()
else:
logger.error('failed to start core manager')
self.coreManager.stopAll()
self.doDisconnectWithTrayMessage(
f'{config.coreName()}: ' + _('Unknown error')
)
else:
while not self.actionQueue.empty():
self.callActionFromQueue()
@callOnceOnly
def doConnectedCallOnceOnly(self):
"""Handle do connected call once only for the connect action."""
def newVersionCallback(newVersion):
"""Handle the new version callback."""
APP().systemTray.showMessage(
f'{APPLICATION_NAME} {newVersion} ' + _('is available to download')
)
connectedHttpProxy = Storage.Extras.UserHttpProxy()
# Check for updates
self.updatesManager.configureHttpProxy(connectedHttpProxy)
self.updatesManager.checkForUpdates(
showMessageBox=False,
hasNewVersionCallback=newVersionCallback,
)
getPluginRegistry().afterConnected(connectedHttpProxy)
def callActionFromQueue(self):
"""Call action from queue."""
try:
action = self.actionQueue.get_nowait()
except queue.Empty:
# Queue is empty
pass
except Exception:
# Any non-exit exceptions
pass
else:
if callable(action):
action()
def coreExitCallback(self, core: CoreProcess, exitcode: int):
"""Handle the core exit callback."""
def putItem(item):
"""Handle put item for the connect action."""
try:
self.actionQueue.put_nowait(item)
except Exception:
# Any non-exit exceptions
pass
if exitcode == CoreProcess.ExitCode.SystemShuttingDown.value:
# System shutting down. Do nothing
return None
if exitcode == CoreProcess.ExitCode.ConfigurationError.value:
putItem(
functools.partial(
self.doDisconnectWithTrayMessage,
f'{core.name()}: ' + _('Invalid server configuration'),
)
)
return None
if exitcode == CoreProcess.ExitCode.ServerStartFailure.value:
putItem(
functools.partial(
self.doDisconnectWithTrayMessage,
f'{core.name()}: ' + _('Failed to start core'),
)
)
return None
pluginMessage = getPluginRegistry().coreExitMessage(core, exitcode)
if pluginMessage:
putItem(
functools.partial(
self.doDisconnectWithTrayMessage,
f'{core.name()}: ' + _(pluginMessage),
)
)
return None
putItem(
functools.partial(
self.doDisconnectWithTrayMessage,
f'{core.name()}: ' + _('Core terminated unexpectedly'),
)
)
return None
mbox.open()
def triggeredCallback(self, checked):
"""Handle activation of the action."""
if self.state is ConnectionState.Disconnected:
self.doConnect()
elif self.state is ConnectionState.Connected:
self.doDisconnectWithTrayMessage(_('Disconnected'))
else:
# A disabled transition action should not normally be triggered.
# Restore its checked presentation if code triggered it directly.
self._applyStatePresentation()
"""Delegate the requested operation to the shared controller."""
self.controller.toggle()
# QAction toggles before its callback. Restore controller-owned state
# when validation rejected the operation without a state transition.
self.syncPresentation()
def retranslate(self):
"""Refresh the state-derived action text."""
self._applyStatePresentation()
"""Refresh the state-derived action text and icon."""
self.syncPresentation()
+1 -2
View File
@@ -19,7 +19,7 @@
from __future__ import annotations
from .Connection import ConnectionState, ConnectAction
from .Connection import ConnectAction
from .Exit import ExitAction
from .Home import ShowHomePageAction
from .Import import (
@@ -33,7 +33,6 @@ from .Routing import RoutingAction
__all__ = [
'ConnectAction',
'ConnectionState',
'ExitAction',
'ImportAction',
'ImportFromFileAction',
+16 -13
View File
@@ -23,7 +23,7 @@ from Furious.Frozenlib import *
from Furious.Interface import *
from Furious.Core import Tun2socks
from Furious.Backends import OFFICIAL_PLUGIN_TYPES
from Furious.Controllers import RoutingController
from Furious.Controllers import ConnectionController, RoutingController
from Furious.Extensions import BUNDLED_EXTENSION_TYPES
from Furious.Plugins import getPluginRegistry, initializePluginRegistry
from Furious.Qt import AppStyleSheet
@@ -31,7 +31,6 @@ from Furious.Qt.TextEditorTheme import configureEditorLogMetadata
from Furious.Qt import gettext as _
from Furious.Repository import *
from Furious.Service import ApplicationLogHandler, LogManager
from Furious.Actions.Connection import ConnectAction
from Furious.Application.TrayIcon import *
from Furious.Window.LogPage import *
from Furious.Window.MainWindow import *
@@ -181,7 +180,7 @@ class DesktopApplication(ApplicationRunner, SingletonApplication):
self.mainWindow = None
self.systemTray = None
self.connectionAction = None
self.connectionController = None
self.routingController = None
# Unified logging service and presentation
@@ -297,10 +296,6 @@ class DesktopApplication(ApplicationRunner, SingletonApplication):
self._userSubs = Storage.UserSubs()
self._userTUNSettings = Storage.UserTUNSettings()
def isSystemTrayConnected(self):
"""Return whether system tray connected."""
return self.connectionAction is not None and self.connectionAction.isConnected()
def isDarkMode(self):
"""Return whether dark mode."""
backgroudColor = self.palette().color(QPalette.ColorRole.Window)
@@ -374,6 +369,11 @@ class DesktopApplication(ApplicationRunner, SingletonApplication):
@QtCore.Slot()
def cleanup():
"""Release resources owned by the application."""
controller = getattr(APP(), 'connectionController', None)
if controller is not None:
controller.shutdown()
getPluginRegistry().shutdown()
Mixins.CleanupOnExit.cleanupAll()
@@ -452,6 +452,13 @@ class DesktopApplication(ApplicationRunner, SingletonApplication):
self.addEnviron()
self.addStorage()
# Controllers exist before presentation services so even early
# widgets and plugin UI can observe a stable disconnected state.
self.connectionController = ConnectionController(parent=self)
self.routingController = RoutingController(parent=self)
self.connectionController.interactionEnabledChanged.connect(
self.routingController.setInteractionEnabled
)
self.addCustomFont()
# self.configureApplicationFont()
self.configureLogging()
@@ -546,11 +553,6 @@ class DesktopApplication(ApplicationRunner, SingletonApplication):
SystemProxy.off()
SystemProxy.daemonOn_()
# The application owns connection and routing operations. Home and
# tray bind to these same objects regardless of construction order.
self.routingController = RoutingController(parent=self)
self.connectionAction = ConnectAction(isTrayAction=True)
self.mainWindow = MainWindow()
self.systemTray = TrayIcon()
@@ -564,7 +566,7 @@ class DesktopApplication(ApplicationRunner, SingletonApplication):
if state == QtCore.Qt.ApplicationState.ApplicationActive:
if (
not self.mainWindow.isVisible()
and not self.systemTray.ConnectAction.isConnecting()
and not self.connectionController.isConnecting()
):
self.mainWindow.show()
@@ -579,6 +581,7 @@ class DesktopApplication(ApplicationRunner, SingletonApplication):
self.systemTray.show()
self.systemTray.setCustomToolTip()
self.systemTray.bootstrap()
self.connectionController.restoreStartupState()
return self.exec()
except SystemTrayUnavailable:
+3 -6
View File
@@ -54,7 +54,7 @@ class TrayIcon(
super().__init__(*args, **kwargs)
actions = [
APP().connectionAction,
ConnectAction(APP().connectionController, isTrayAction=True),
RoutingAction(APP().routingController, isTrayAction=True),
ImportAction(isTrayAction=True),
AppQSeperator(),
@@ -135,9 +135,6 @@ class TrayIcon(
# Rrefresh startup application location
StartupOnBoot.on_()
if AppSettings.isStateON_('Connect'):
self.ConnectAction.trigger()
def showMessage(self, message: str, *args, **kwargs):
"""Show message."""
if message:
@@ -176,7 +173,7 @@ class TrayIcon(
def setMonochromeIcon(self):
"""Set monochrome icon."""
if APP().isSystemTrayConnected():
if APP().connectionController.isConnected():
self.setConnectedMonochromeIcon()
else:
self.setDisconnectedMonochromeIcon()
@@ -268,7 +265,7 @@ class TrayIcon(
return
if APP().isSystemTrayConnected():
if APP().connectionController.isConnected():
self.setConnectedIcon()
else:
self.setDisconnectedIcon()
+484
View File
@@ -0,0 +1,484 @@
# 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/>.
"""Own the application connection lifecycle independently from its UI."""
from __future__ import annotations
from Furious.Frozenlib import *
from Furious.Interface import *
from Furious.Models import ServerProfile
from Furious.Plugins import getPluginRegistry
from Furious.Qt.DynamicTranslate import gettext as _
from Furious.Repository import Storage
from Furious.Service import (
TUN2SOCKS_LOG_CATEGORY,
ConnectionManager,
UpdateManager,
coreLogCallback,
)
from PySide6 import QtCore
from dataclasses import dataclass
from enum import Enum
import queue
import logging
import functools
__all__ = ['ConnectionController', 'ConnectionError', 'ConnectionState']
logger = logging.getLogger(__name__)
registerAppSettings('Connect', isBinary=True)
registerAppSettings('CustomProxyBypass')
class ConnectionState(Enum):
"""Describe the application connection lifecycle."""
Disconnected = 'Connect'
Connecting = 'Connecting'
Connected = 'Disconnect'
Disconnecting = 'Disconnecting'
@dataclass(frozen=True)
class ConnectionError:
"""Describe a user-facing connection error without presenting it."""
title: str
message: str
details: str = ''
def validateProxyServer(server) -> bool:
"""Return whether *server* is a valid host and TCP port pair."""
try:
_host, port = parseHostPort(server)
if int(port) < 0 or int(port) > 65535:
raise ValueError
except Exception:
return False
return True
class ConnectionController(QtCore.QObject):
"""Coordinate one connection lifecycle and publish observable state."""
stateChanged = QtCore.Signal(object)
activeConfigurationChanged = QtCore.Signal(object)
interactionEnabledChanged = QtCore.Signal(bool)
processesChanged = QtCore.Signal(object)
progressStarted = QtCore.Signal()
progressFinished = QtCore.Signal(bool)
notificationRequested = QtCore.Signal(str)
errorOccurred = QtCore.Signal(object)
def __init__(self, parent=None, *, coreManager=None, updatesManager=None):
"""Initialize reusable runtime services and a disconnected state."""
super().__init__(parent)
self._actionQueue = queue.Queue()
self._coreManager = coreManager or ConnectionManager()
self._updatesManager = updatesManager or UpdateManager()
self._state = ConnectionState.Disconnected
self._activeConfiguration = None
self._lastError = None
self._actionTimer = QtCore.QTimer(self)
self._actionTimer.timeout.connect(self._callActionFromQueue)
@property
def state(self) -> ConnectionState:
"""Return the current connection lifecycle state."""
return self._state
@property
def activeConfiguration(self):
"""Return the profile owned by the current connection lifecycle."""
return self._activeConfiguration
@property
def processes(self):
"""Return an immutable snapshot of managed core processes."""
return tuple(self._coreManager.processesPool)
@property
def lastError(self):
"""Return the last user-facing connection error, if any."""
return self._lastError
@property
def interactionEnabled(self) -> bool:
"""Return whether connection-dependent settings may be changed."""
return self.state not in (
ConnectionState.Connecting,
ConnectionState.Disconnecting,
)
def isConnected(self) -> bool:
"""Return whether the connection is established."""
return self.state is ConnectionState.Connected
def isConnecting(self) -> bool:
"""Return whether the connection is starting."""
return self.state is ConnectionState.Connecting
def isDisconnecting(self) -> bool:
"""Return whether the connection is stopping."""
return self.state is ConnectionState.Disconnecting
def _setState(self, state: ConnectionState):
"""Publish an atomic lifecycle transition."""
if state is self._state:
return
interactionWasEnabled = self.interactionEnabled
self._state = state
self.stateChanged.emit(state)
if self.interactionEnabled != interactionWasEnabled:
self.interactionEnabledChanged.emit(self.interactionEnabled)
def _setActiveConfiguration(self, configuration):
"""Publish the profile owned by the current lifecycle."""
if configuration is self._activeConfiguration:
return
self._activeConfiguration = configuration
self.activeConfigurationChanged.emit(configuration)
def _setProcessesChanged(self):
"""Publish a stable process snapshot after runtime changes."""
self.processesChanged.emit(self.processes)
def _reportError(self, message: str, details: str = ''):
"""Publish a user-facing error without choosing its presentation."""
self._lastError = ConnectionError(_('Unable to connect'), message, details)
self.errorOccurred.emit(self._lastError)
def _reset(self):
"""Restore disconnected state after all runtime resources stop."""
self.progressFinished.emit(True)
self._setActiveConfiguration(None)
AppSettings.turnOFF('Connect')
self._setState(ConnectionState.Disconnected)
def _startConnecting(self):
"""Enter the connecting state and request progress presentation."""
self._setState(ConnectionState.Connecting)
self.progressStarted.emit()
def _finishConnecting(self):
"""Enter the connected state and notify connection-aware consumers."""
self.progressFinished.emit(True)
AppSettings.turnON_('Connect')
self._setState(ConnectionState.Connected)
Mixins.ConnectionAware.callConnectedCallback()
def startConnection(self, configuration=None) -> bool:
"""Start *configuration* or the active repository profile."""
# QObject already exposes a legacy ``connect`` attribute in PySide.
# Using an explicit operation name avoids shadowing Qt signal plumbing.
if self.state is not ConnectionState.Disconnected:
return False
if configuration is None:
servers = Storage.UserServers()
if not servers:
AppSettings.turnOFF('Connect')
self._reportError(
_('Server configuration empty. Please configure your server first')
)
return False
activeIndex = Storage.UserActivatedItemIndex()
if activeIndex < 0 or activeIndex >= len(servers):
AppSettings.turnOFF('Connect')
self._reportError(
_('Select and press Enter to activate configuration and connect')
)
return False
configuration = servers[activeIndex]
if not isinstance(configuration, ServerProfile):
AppSettings.turnOFF('Connect')
self._reportError(_('Please complete your server configuration'))
return False
@forceToLocalhostIfPossible()
def getHttpProxy() -> str:
"""Return the system-proxy endpoint for this connection."""
return configuration.httpProxy()
httpProxy = getHttpProxy()
if not validateProxyServer(httpProxy):
AppSettings.turnOFF('Connect')
self._reportError(
_(
f'{APPLICATION_NAME} cannot find any valid http proxy endpoint in the configuration'
),
_('Please complete your server configuration'),
)
return False
self._lastError = None
self._setActiveConfiguration(configuration)
self._startConnecting()
logManager = AppLogManager()
# Retain application diagnostics while starting a fresh runtime log.
logManager.clear(runtimeOnly=True)
try:
success = self._coreManager.start(
configuration,
routing=AppSettings.get('Routing'),
exitCallback=self.coreExitCallback,
msgCallbackCore=coreLogCallback(logManager),
msgCallbackTUN_=logManager.callback(
TUN2SOCKS_LOG_CATEGORY,
source='Tun2socks',
),
)
except Exception as ex:
# Any non-exit exceptions
logger.error(f'failed to start core manager: {ex}')
success = False
self._setProcessesChanged()
if not self._actionQueue.empty():
while not self._actionQueue.empty():
self._callActionFromQueue()
return False
if not success:
logger.error('failed to start core manager')
self.startDisconnection(
f'{configuration.coreName()}: ' + _('Unknown error')
)
return False
settings = AppSettings.get('CustomProxyBypass')
proxyServerBypass = (
settings if isinstance(settings, str) else PROXY_SERVER_BYPASS
)
try:
SystemProxy.set(httpProxy, proxyServerBypass)
except Exception as ex:
# Any non-exit exceptions
logger.error(f'error while setting http proxy: {ex}')
logger.error(
f'failed to connect successfully. '
f'This may be due to improper proxy '
f'server bypass settings: {proxyServerBypass}'
)
self.startDisconnection(
f'{configuration.coreName()}: ' + _('Unknown error')
)
return False
self._finishConnecting()
self.notificationRequested.emit(
f'{configuration.coreName()}: ' + _('Connected')
)
interval = CORE_CHECK_ALIVE_INTERVAL
if AppSettings.isStateON_('PowerSaveMode'):
interval *= 2
self._actionTimer.start(interval)
self._runPostConnectTasksOnce()
return True
def startDisconnection(self, notification: str = '') -> bool:
"""Stop the active runtime and optionally request a notification."""
if self.state is ConnectionState.Disconnected:
return False
self._setState(ConnectionState.Disconnecting)
self._actionTimer.stop()
try:
SystemProxy.off()
except Exception as ex:
logger.error(f'failed to turn off system proxy: {ex}')
try:
self._coreManager.stopAll()
except Exception as ex:
# Always complete the state transition. A cleanup failure must not
# strand every connection UI in the disabled Disconnecting state.
logger.error(f'failed to stop connection runtime: {ex}')
self._setProcessesChanged()
self._reset()
while not self._actionQueue.empty():
try:
self._actionQueue.get_nowait()
except Exception:
pass
Mixins.ConnectionAware.callDisconnectedCallback()
if notification:
self.notificationRequested.emit(notification)
return True
def startReconnection(self, notification: str = '') -> bool:
"""Restart the active repository profile when lifecycle state permits."""
if self.state in (
ConnectionState.Connecting,
ConnectionState.Disconnecting,
):
return False
if self.isConnected():
self.startDisconnection(notification)
return self.startConnection()
def restoreStartupState(self) -> bool:
"""Restore the persisted connection preference during application startup."""
if not AppSettings.isStateON_('Connect'):
return False
return self.startConnection()
def shutdown(self):
"""Stop runtime resources without changing the next-start preference."""
reconnectOnStartup = AppSettings.isStateON_('Connect')
if self.state is not ConnectionState.Disconnected:
self.startDisconnection()
if reconnectOnStartup:
AppSettings.turnON_('Connect')
def toggle(self) -> bool:
"""Perform the operation represented by the current stable state."""
if self.state is ConnectionState.Disconnected:
return self.startConnection()
if self.state is ConnectionState.Connected:
return self.startDisconnection(_('Disconnected'))
return False
@callOnceOnly
def _runPostConnectTasksOnce(self):
"""Run application update and plugin maintenance after first connect."""
def newVersionCallback(newVersion):
"""Request a notification when a newer version is available."""
self.notificationRequested.emit(
f'{APPLICATION_NAME} {newVersion} ' + _('is available to download')
)
connectedHttpProxy = Storage.Extras.UserHttpProxy()
self._updatesManager.configureHttpProxy(connectedHttpProxy)
self._updatesManager.checkForUpdates(
showMessageBox=False,
hasNewVersionCallback=newVersionCallback,
)
getPluginRegistry().afterConnected(connectedHttpProxy)
@QtCore.Slot()
def _callActionFromQueue(self):
"""Run one core-thread action on the controller's Qt thread."""
try:
action = self._actionQueue.get_nowait()
except queue.Empty:
return
except Exception:
# Any non-exit exceptions
return
if callable(action):
action()
def coreExitCallback(self, core: CoreProcess, exitcode: int):
"""Translate a core exit into a queued lifecycle operation."""
def putItem(item):
"""Queue an operation without allowing worker failures to escape."""
try:
self._actionQueue.put_nowait(item)
except Exception:
# Any non-exit exceptions
pass
if exitcode == CoreProcess.ExitCode.SystemShuttingDown.value:
return None
if exitcode == CoreProcess.ExitCode.ConfigurationError.value:
message = f'{core.name()}: ' + _('Invalid server configuration')
elif exitcode == CoreProcess.ExitCode.ServerStartFailure.value:
message = f'{core.name()}: ' + _('Failed to start core')
else:
pluginMessage = getPluginRegistry().coreExitMessage(core, exitcode)
message = (
f'{core.name()}: ' + _(pluginMessage)
if pluginMessage
else f'{core.name()}: ' + _('Core terminated unexpectedly')
)
putItem(functools.partial(self.startDisconnection, message))
return None
+2 -2
View File
@@ -136,8 +136,8 @@ class RoutingController(QtCore.QObject):
app = APP()
if app is not None and app.isSystemTrayConnected():
app.connectionAction.doReconnect()
if app is not None and app.connectionController.isConnected():
app.connectionController.startReconnection()
return True
+7 -1
View File
@@ -74,6 +74,7 @@ class SettingsController:
assert SystemRuntime.isAdmin()
cls._setBinary('VPNMode', enabled)
showMBoxNewChangesNextTime()
@classmethod
@@ -87,6 +88,8 @@ class SettingsController:
else:
APP().switchToAutoMode()
except Exception:
# Any non-exit exceptions
# The controller can be exercised before the full desktop UI exists.
pass
@@ -98,6 +101,7 @@ class SettingsController:
if AppSettings.get('Language') != language:
AppSettings.set('Language', language)
Mixins.QTranslatable.retranslateAll()
@classmethod
@@ -108,7 +112,7 @@ class SettingsController:
try:
if enabled:
APP().systemTray.setMonochromeIcon()
elif APP().isSystemTrayConnected():
elif APP().connectionController.isConnected():
APP().systemTray.setConnectedIcon()
else:
APP().systemTray.setDisconnectedIcon()
@@ -139,12 +143,14 @@ class SettingsController:
def setPowerSaveMode(cls, enabled: bool):
"""Persist power-saving behavior for the next connection."""
cls._setBinary('PowerSaveMode', enabled)
showMBoxNewChangesNextTime()
@classmethod
def setForceLocalProxy(cls, enabled: bool):
"""Persist local system-proxy address normalization."""
cls._setBinary('ForceToLocalhostWhenSettingLocalProxy', enabled)
showMBoxNewChangesNextTime()
@staticmethod
+12 -1
View File
@@ -17,7 +17,18 @@
"""Expose presentation-independent application controllers."""
from .ConnectionController import (
ConnectionController,
ConnectionError,
ConnectionState,
)
from .RoutingController import RoutingController
from .SettingsController import SettingsController
__all__ = ['RoutingController', 'SettingsController']
__all__ = [
'ConnectionController',
'ConnectionError',
'ConnectionState',
'RoutingController',
'SettingsController',
]
+2 -2
View File
@@ -64,7 +64,7 @@ class AppHue:
@staticmethod
def currentColor() -> str:
"""Return the current color value."""
if APP().isSystemTrayConnected():
if APP().connectionController.isConnected():
return AppHue.connectedColor()
else:
return AppHue.disconnectedColor()
@@ -72,7 +72,7 @@ class AppHue:
@staticmethod
def currentWindowIcon() -> AppQIcon:
"""Return the current window icon value."""
if APP().isSystemTrayConnected():
if APP().connectionController.isConnected():
return AppHue.connectedWindowIcon()
else:
return AppHue.disconnectedWindowIcon()
+4 -4
View File
@@ -1066,7 +1066,7 @@ def showMBoxNewChangesNextTime(**kwargs):
def handleResultCode(code):
"""Handle result code."""
if code == PySide6Legacy.enumValueWrapper(AppQMessageBox.StandardButton.Yes):
APP().systemTray.ConnectAction.doReconnect()
APP().connectionController.startReconnection()
else:
# Do nothing
pass
@@ -1074,7 +1074,7 @@ def showMBoxNewChangesNextTime(**kwargs):
try:
method = kwargs.pop('method', 'open')
if APP().isSystemTrayConnected():
if APP().connectionController.isConnected():
mbox = MBoxNewChangesNextTime(**kwargs)
if isinstance(mbox.parent(), QMainWindow):
@@ -1139,7 +1139,7 @@ def showMBoxDirectRulesNotAllowed(**kwargs):
controller, wasConnected = (
app.routingController,
app.isSystemTrayConnected(),
app.connectionController.isConnected(),
)
changed = controller.selectRouting(AppBuiltinRouting.Global.value)
@@ -1148,7 +1148,7 @@ def showMBoxDirectRulesNotAllowed(**kwargs):
# Selecting a route reconnects an established connection itself.
# During an initial connection attempt, resume that attempt here.
if not wasConnected:
app.connectionAction.trigger()
app.connectionController.startConnection()
else:
# Do nothing
pass
+25 -23
View File
@@ -127,19 +127,15 @@ class Storage:
def UserHttpProxy() -> Union[str, None]:
"""Return the user HTTP proxy value."""
try:
if APP().isSystemTrayConnected():
index, servers = (
Storage.UserActivatedItemIndex(),
Storage.UserServers(),
)
controller = APP().connectionController
configuration = controller.activeConfiguration
if index >= 0:
return servers[index].httpProxy()
else:
# Should not reach here
return None
else:
return None
if controller.isConnected() and isinstance(
configuration, ServerProfile
):
return configuration.httpProxy()
return None
except Exception:
# Any non-exit exceptions
@@ -149,19 +145,25 @@ class Storage:
def UserServerRemark() -> Union[str, None]:
"""Return the user server remark value."""
try:
if APP().isSystemTrayConnected():
index, servers = (
Storage.UserActivatedItemIndex(),
Storage.UserServers(),
)
controller = APP().connectionController
configuration = controller.activeConfiguration
if index >= 0:
return f'{index + 1} - ' + servers[index].itemRemark
else:
# Should not reach here
return ''
else:
if not controller.isConnected() or not isinstance(
configuration, ServerProfile
):
return ''
index = next(
(
index
for index, server in enumerate(Storage.UserServers())
if server is configuration
),
-1,
)
prefix = f'{index + 1} - ' if index >= 0 else ''
return prefix + configuration.itemRemark
except Exception:
# Any non-exit exceptions
+3 -4
View File
@@ -38,11 +38,10 @@ logger = logging.getLogger(__name__)
def isCoreActive(coreType) -> bool:
"""Return whether the active connection owns a core of ``coreType``."""
try:
connectAction = APP().systemTray.ConnectAction
controller = APP().connectionController
return connectAction.isConnected() and any(
isinstance(process, coreType)
for process in connectAction.coreManager.processesPool
return controller.isConnected() and any(
isinstance(process, coreType) for process in controller.processes
)
except (AttributeError, RuntimeError):
return False
+2 -2
View File
@@ -237,9 +237,9 @@ class TrafficStatsManager(
@staticmethod
def _activeProcesses():
"""Return the processes owned by the active tray connection."""
"""Return the processes owned by the active connection controller."""
try:
return tuple(APP().systemTray.ConnectAction.coreManager.processesPool)
return APP().connectionController.processes
except (AttributeError, RuntimeError):
return tuple()
+38 -24
View File
@@ -15,44 +15,46 @@
# 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 connection controls backed by the shared connection action."""
"""Provide a Home connection control backed by ConnectionController."""
from __future__ import annotations
from Furious.Actions.Connection import ConnectionState
from Furious.Controllers import ConnectionController, ConnectionState
from Furious.Frozenlib import Mixins
from Furious.Qt import bootstrapIcon, AppQPushButton
from Furious.Qt import gettext as _
from PySide6 import QtCore
from PySide6.QtWidgets import QPushButton, QSizePolicy
from PySide6.QtWidgets import QSizePolicy
from typing import Callable
__all__ = ['ConnectionButton']
class ConnectionButton(QPushButton):
"""Present a shared connection action with a local selection policy."""
class ConnectionButton(AppQPushButton):
"""Present connection state while retaining Home's selection policy."""
def __init__(
self,
connectionAction,
controller: ConnectionController,
activateSelected: Callable[[], bool],
parent=None,
):
"""Bind to one connection action without duplicating its state machine."""
super().__init__(parent)
self.connectionAction = connectionAction
"""Bind Home presentation and selection policy to the controller."""
self.controller = controller
self.activateSelected = activateSelected
self._selectionCount = 0
super().__init__(parent, useQSetDisabled=False)
self.setObjectName('ConnectionButton')
self.setMinimumWidth(124)
self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
self.setIconSize(QtCore.QSize(18, 18))
self.clicked.connect(self._handleClicked)
self.connectionAction.changed.connect(self.syncPresentation)
self.connectionAction.stateChanged.connect(self.syncPresentation)
self.controller.stateChanged.connect(self.syncPresentation)
self.syncPresentation()
@@ -63,19 +65,28 @@ class ConnectionButton(QPushButton):
@QtCore.Slot()
def syncPresentation(self, *_args):
"""Mirror text/icon/state while applying only Home's selection rule."""
action = self.connectionAction
state = action.state
"""Render controller state while applying only Home's selection rule."""
state = self.controller.state
self.setText(action.text())
self.setIcon(action.icon())
self.setText(_(state.value))
self.setIcon(
bootstrapIcon(
'lock-fill.svg'
if state
in (
ConnectionState.Connecting,
ConnectionState.Connected,
)
else 'unlock-fill.svg'
)
)
if state is ConnectionState.Disconnected:
# Zero-selection remains intentionally clickable but is a no-op.
enabled = self._selectionCount <= 1 and action.isEnabled()
enabled = self._selectionCount <= 1
elif state is ConnectionState.Connected:
# Disconnect always targets the active connection, not selection.
enabled = action.isEnabled()
enabled = True
else:
enabled = False
@@ -83,14 +94,17 @@ class ConnectionButton(QPushButton):
@QtCore.Slot()
def _handleClicked(self):
"""Delegate connect/disconnect to the shared action."""
state = self.connectionAction.state
"""Apply Home selection policy, then delegate the lifecycle operation."""
state = self.controller.state
if state is ConnectionState.Disconnected:
if self._selectionCount != 1 or not self.activateSelected():
return
if self.connectionAction.state is ConnectionState.Disconnected:
self.connectionAction.trigger()
self.controller.toggle()
elif state is ConnectionState.Connected:
self.connectionAction.trigger()
self.controller.toggle()
def retranslate(self):
"""Refresh state-derived Home connection text and icon."""
self.syncPresentation()
+19 -21
View File
@@ -1043,13 +1043,12 @@ class DeleteServersProgressDialog(AppQDialog):
self.table.activeServerChanged.emit()
if APP().isSystemTrayConnected():
if self.showTrayMessage:
# Trigger disconnect
APP().systemTray.ConnectAction.trigger()
else:
# Trigger disconnect silently
APP().systemTray.ConnectAction.doDisconnect()
controller = APP().connectionController
if controller.isConnected():
controller.startDisconnection(
_('Disconnected') if self.showTrayMessage else ''
)
self.accept()
@@ -1818,7 +1817,7 @@ class ServerTableView(
# Same item activated. Do nothing
return
if APP().systemTray.ConnectAction.isConnecting():
if APP().connectionController.isConnecting():
mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Information)
mbox.setWindowTitle(_('Connecting'))
mbox.setText(_('Connecting. Please wait...'))
@@ -1838,8 +1837,8 @@ class ServerTableView(
self.activateItemByIndex(newIndex, True)
if APP().isSystemTrayConnected():
APP().systemTray.ConnectAction.doReconnect()
if APP().connectionController.isConnected():
APP().connectionController.startReconnection()
@functools.lru_cache(None)
def getGuiEditorByProtocol(self, protocol, **kwargs):
@@ -2264,13 +2263,12 @@ class ServerTableView(
self.activeServerChanged.emit()
if APP().isSystemTrayConnected():
if showTrayMessage:
# Trigger disconnect
APP().systemTray.ConnectAction.trigger()
else:
# Trigger disconnect silently
APP().systemTray.ConnectAction.doDisconnect()
controller = APP().connectionController
if controller.isConnected():
controller.startDisconnection(
_('Disconnected') if showTrayMessage else ''
)
return len(indexes)
@@ -2567,7 +2565,7 @@ class ServerTableView(
active.itemSubscription == unique and active.itemSubscriptionManaged
)
wasConnected = APP().isSystemTrayConnected()
wasConnected = APP().connectionController.isConnected()
self.sourceModel.beginResetModel()
@@ -2594,9 +2592,9 @@ class ServerTableView(
if wasConnected and activeProfileId:
if newActivatedIndex < 0 and activeWasManagedByGroup:
APP().systemTray.ConnectAction.doDisconnect()
APP().connectionController.startDisconnection()
elif activeProfileId in result.changedProfileIds:
APP().systemTray.ConnectAction.doReconnect()
APP().connectionController.startReconnection()
return result
@@ -2622,7 +2620,7 @@ class ServerTableView(
self.setCurrentIndex(self.proxyIndexFromSourceRow(0))
# Try to be user-friendly in some extreme cases
if not APP().isSystemTrayConnected():
if not APP().connectionController.isConnected():
# Activate automatically
self.activateItemByIndex(0, True)
+2 -2
View File
@@ -90,7 +90,7 @@ class AppConnectivityManager(ConnectivityManager):
def startSingleTest(self):
"""Start single test."""
if not APP().isSystemTrayConnected():
if not APP().connectionController.isConnected():
parent = self.parent()
if isinstance(parent, HomePage):
@@ -512,7 +512,7 @@ class HomePage(Mixins.QTranslatable, QMainWindow):
self.pageTitleLabel.setObjectName('HomePageTitle')
self.connectButton = ConnectionButton(
APP().connectionAction,
APP().connectionController,
self.activateSelectedServerForConnection,
parent=self,
)
+8
View File
@@ -101,6 +101,14 @@ class MainWindow(AppQMainWindow):
parent=self.navigationView,
)
APP().connectionController.interactionEnabledChanged.connect(
self.settingsPage.setConnectionControlsEnabled
)
self.settingsPage.setConnectionControlsEnabled(
APP().connectionController.interactionEnabled
)
if not isinstance(self.logPage, LogPage):
raise TypeError('application log page must be a LogPage')
-2
View File
@@ -33,8 +33,6 @@ __all__ = ['ProxyBypassDialog']
logger = logging.getLogger(__name__)
registerAppSettings('CustomProxyBypass')
class ProxyBypassDialog(AppQDialog):
"""Present the GUI customize proxy bypass dialog."""