mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-10-06 13:58:01 +03:00
Centralize connection lifecycle control
Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
+70
-430
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -33,8 +33,6 @@ __all__ = ['ProxyBypassDialog']
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
registerAppSettings('CustomProxyBypass')
|
||||
|
||||
|
||||
class ProxyBypassDialog(AppQDialog):
|
||||
"""Present the GUI customize proxy bypass dialog."""
|
||||
|
||||
Reference in New Issue
Block a user