# 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) activeProfileChanged = QtCore.Signal(object) interactionEnabledChanged = QtCore.Signal(bool) runtimesChanged = 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._activeProfile = 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 activeProfile(self): """Return the profile owned by the current connection lifecycle.""" return self._activeProfile @property def runtimes(self): """Return an immutable snapshot of managed core runtimes.""" return tuple(self._coreManager.runtimes) @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 _setActiveProfile(self, profile): """Publish the profile owned by the current lifecycle.""" if profile is self._activeProfile: return self._activeProfile = profile self.activeProfileChanged.emit(profile) def _emitRuntimesChanged(self): """Publish a stable snapshot after managed runtimes change.""" self.runtimesChanged.emit(self.runtimes) 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 _failConnection(self, message: str, details: str = '') -> bool: """Clean partial runtime state, then publish one structured error.""" if self.state is not ConnectionState.Disconnected: self.startDisconnection() self._reportError(message, details) return False def _reset(self): """Restore disconnected state after all runtime resources stop.""" self.progressFinished.emit(True) self._setActiveProfile(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._setActiveProfile(configuration) self._startConnecting() logManager = AppLogManager() # Retain application diagnostics while starting a fresh runtime log. logManager.clear(runtimeOnly=True) startExceptionDetails = '' 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 startExceptionDetails = str(ex) self._emitRuntimesChanged() if not self._actionQueue.empty(): while not self._actionQueue.empty(): self._callActionFromQueue() return False if not success: logger.error('failed to start core manager') startError = getattr(self._coreManager, 'lastStartError', '') return self._failConnection( f'{configuration.coreName()}: ' + (_(startError) if startError else _('Unknown error')), startExceptionDetails, ) 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}' ) return self._failConnection( f'{configuration.coreName()}: ' + _('Unknown error'), str(ex) ) 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._emitRuntimesChanged() 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: CoreRuntime, 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 == CoreRuntime.ExitCode.SystemShuttingDown.value: return None if exitcode == CoreRuntime.ExitCode.ConfigurationError.value: message = f'{core.name()}: ' + _('Invalid server configuration') elif exitcode == CoreRuntime.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._failConnection, message)) return None