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."""