diff --git a/Furious/Actions/Connection.py b/Furious/Actions/Connection.py
index a65d08f..8381a6e 100644
--- a/Furious/Actions/Connection.py
+++ b/Furious/Actions/Connection.py
@@ -15,48 +15,23 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
-"""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()
diff --git a/Furious/Actions/__init__.py b/Furious/Actions/__init__.py
index 0fe86b6..3ce0461 100644
--- a/Furious/Actions/__init__.py
+++ b/Furious/Actions/__init__.py
@@ -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',
diff --git a/Furious/Application/DesktopApplication.py b/Furious/Application/DesktopApplication.py
index b791415..1dcb936 100644
--- a/Furious/Application/DesktopApplication.py
+++ b/Furious/Application/DesktopApplication.py
@@ -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:
diff --git a/Furious/Application/TrayIcon.py b/Furious/Application/TrayIcon.py
index f893d5d..b54cec8 100644
--- a/Furious/Application/TrayIcon.py
+++ b/Furious/Application/TrayIcon.py
@@ -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()
diff --git a/Furious/Controllers/ConnectionController.py b/Furious/Controllers/ConnectionController.py
new file mode 100644
index 0000000..f906304
--- /dev/null
+++ b/Furious/Controllers/ConnectionController.py
@@ -0,0 +1,484 @@
+# Copyright (C) 2024–present Loren Eteval & contributors
+#
+# This file is part of Furious.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+"""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
diff --git a/Furious/Controllers/RoutingController.py b/Furious/Controllers/RoutingController.py
index e6bebdf..5222280 100644
--- a/Furious/Controllers/RoutingController.py
+++ b/Furious/Controllers/RoutingController.py
@@ -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
diff --git a/Furious/Controllers/SettingsController.py b/Furious/Controllers/SettingsController.py
index 34d8ee2..e64e607 100644
--- a/Furious/Controllers/SettingsController.py
+++ b/Furious/Controllers/SettingsController.py
@@ -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
diff --git a/Furious/Controllers/__init__.py b/Furious/Controllers/__init__.py
index 4944a1e..c195c25 100644
--- a/Furious/Controllers/__init__.py
+++ b/Furious/Controllers/__init__.py
@@ -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',
+]
diff --git a/Furious/Qt/DynamicTheme.py b/Furious/Qt/DynamicTheme.py
index 87a851c..7aba51c 100644
--- a/Furious/Qt/DynamicTheme.py
+++ b/Furious/Qt/DynamicTheme.py
@@ -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()
diff --git a/Furious/Qt/QtWidgets.py b/Furious/Qt/QtWidgets.py
index 1b62754..be82686 100644
--- a/Furious/Qt/QtWidgets.py
+++ b/Furious/Qt/QtWidgets.py
@@ -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
diff --git a/Furious/Repository/Storage.py b/Furious/Repository/Storage.py
index 73577b7..78e6b33 100644
--- a/Furious/Repository/Storage.py
+++ b/Furious/Repository/Storage.py
@@ -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
diff --git a/Furious/Service/PluginUIManager.py b/Furious/Service/PluginUIManager.py
index a4f1599..df93dba 100644
--- a/Furious/Service/PluginUIManager.py
+++ b/Furious/Service/PluginUIManager.py
@@ -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
diff --git a/Furious/Service/TrafficStatsManager.py b/Furious/Service/TrafficStatsManager.py
index c7ca9af..9bdf2f8 100644
--- a/Furious/Service/TrafficStatsManager.py
+++ b/Furious/Service/TrafficStatsManager.py
@@ -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()
diff --git a/Furious/Widget/ConnectionButton.py b/Furious/Widget/ConnectionButton.py
index 0e8a6ce..f21bf91 100644
--- a/Furious/Widget/ConnectionButton.py
+++ b/Furious/Widget/ConnectionButton.py
@@ -15,44 +15,46 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
-"""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()
diff --git a/Furious/Widget/ServerTableView.py b/Furious/Widget/ServerTableView.py
index 1c6bfcb..0c71870 100644
--- a/Furious/Widget/ServerTableView.py
+++ b/Furious/Widget/ServerTableView.py
@@ -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)
diff --git a/Furious/Window/HomePage.py b/Furious/Window/HomePage.py
index aafbc43..929ddb9 100644
--- a/Furious/Window/HomePage.py
+++ b/Furious/Window/HomePage.py
@@ -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,
)
diff --git a/Furious/Window/MainWindow.py b/Furious/Window/MainWindow.py
index ca5181a..0de9c3d 100644
--- a/Furious/Window/MainWindow.py
+++ b/Furious/Window/MainWindow.py
@@ -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')
diff --git a/Furious/Window/ProxyBypassDialog.py b/Furious/Window/ProxyBypassDialog.py
index e9db69f..87e004f 100644
--- a/Furious/Window/ProxyBypassDialog.py
+++ b/Furious/Window/ProxyBypassDialog.py
@@ -33,8 +33,6 @@ __all__ = ['ProxyBypassDialog']
logger = logging.getLogger(__name__)
-registerAppSettings('CustomProxyBypass')
-
class ProxyBypassDialog(AppQDialog):
"""Present the GUI customize proxy bypass dialog."""