mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-09-22 23:08:08 +03:00
497 lines
16 KiB
Python
497 lines
16 KiB
Python
# 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 (
|
||
CORE_LOG_CATEGORY,
|
||
TUN2SOCKS_LOG_CATEGORY,
|
||
ConnectionManager,
|
||
UpdateManager,
|
||
)
|
||
|
||
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=logManager.callback(CORE_LOG_CATEGORY),
|
||
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
|