diff --git a/Furious/Application/DesktopApplication.py b/Furious/Application/DesktopApplication.py index 836361b..3876e6c 100644 --- a/Furious/Application/DesktopApplication.py +++ b/Furious/Application/DesktopApplication.py @@ -633,7 +633,7 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): ) = ( ConnectionController(parent=self), RoutingController(parent=self), - SettingsController(), + SettingsController(parent=self), ) self.connectionController.interactionEnabledChanged.connect( @@ -656,7 +656,11 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): logger.exception('connection controller shutdown failed') - for controllerName in ('routingController', 'connectionController'): + for controllerName in ( + 'settingsController', + 'routingController', + 'connectionController', + ): controller = getattr(self, controllerName) if isinstance(controller, QtCore.QObject): @@ -664,8 +668,6 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): setattr(self, controllerName, None) - self.settingsController = None - def _initializeThemeDetection(self): """Start the one application-owned theme observer.""" logger.info('theme detect method uses timer implementation') diff --git a/Furious/Controllers/SettingsController.py b/Furious/Controllers/SettingsController.py index 14d1339..cff933d 100644 --- a/Furious/Controllers/SettingsController.py +++ b/Furious/Controllers/SettingsController.py @@ -35,6 +35,7 @@ __all__ = [ 'LOG_AUTO_SCROLL_DOWN_SETTING', 'LOG_AUTO_CLEAR_SETTING', 'PROXY_ENDPOINT_INFO_SETTING', + 'SYSTEM_PROXY_MODE_OPTIONS', 'SettingsController', ] @@ -43,6 +44,10 @@ APPLICATION_THEME_SETTING = 'ApplicationTheme' _LEGACY_DARK_MODE_SETTING = 'DarkMode' LOG_AUTO_SCROLL_DOWN_SETTING = 'LogAutoScrollDown' LOG_AUTO_CLEAR_SETTING = 'LogAutoClear' +SYSTEM_PROXY_MODE_OPTIONS = ( + ('Automatically Configure System Proxy', AppBuiltinProxyMode.Auto.value), + ('Do Not Change System Proxy', AppBuiltinProxyMode.NoChanges.value), +) registerAppSettings('VPNMode', isBinary=True) registerAppSettings( @@ -136,11 +141,16 @@ def _synchronizeLegacyDarkModeSetting(): ) -class SettingsController: +class SettingsController(QtCore.QObject): """Apply application settings independently from their presentation.""" - def __init__(self): + tunModeChanged = QtCore.Signal(bool) + systemProxyModeChanged = QtCore.Signal(str) + + def __init__(self, parent=None): """Synchronize theme persistence after Qt application metadata is ready.""" + super().__init__(parent) + _synchronizeLegacyDarkModeSetting() @staticmethod @@ -151,13 +161,26 @@ class SettingsController: else: AppSettings.turnOFF(settingName) - @classmethod - def setTUNMode(cls, enabled: bool): + @staticmethod + def tunModeAvailable() -> bool: + """Return whether this process can enable application-managed TUN.""" + if PLATFORM == 'Linux': + return not SystemRuntime.flatpakID() + + return SystemRuntime.isAdmin() + + def setTUNMode(self, enabled: bool): """Persist the global TUN mode and notify active workflows.""" if PLATFORM != 'Linux': assert SystemRuntime.isAdmin() - cls._setBinary('VPNMode', enabled) + enabled = bool(enabled) + + if AppSettings.isStateON_('VPNMode') == enabled: + return + + self._setBinary('VPNMode', enabled) + self.tunModeChanged.emit(enabled) showMBoxNewChangesNextTime() @@ -245,13 +268,16 @@ class SettingsController: showMBoxNewChangesNextTime() - @staticmethod - def setSystemProxyMode(mode: str): + def setSystemProxyMode(self, mode: str): """Persist how Furious manages the operating-system proxy.""" validModes = tuple(item.value for item in AppBuiltinProxyMode) - if mode in validModes: - AppSettings.set('SystemProxyMode', mode) + if mode not in validModes or AppSettings.get('SystemProxyMode') == mode: + return + + AppSettings.set('SystemProxyMode', mode) + + self.systemProxyModeChanged.emit(mode) @classmethod def setAutoUpdateAssets(cls, enabled: bool): diff --git a/Furious/Controllers/__init__.py b/Furious/Controllers/__init__.py index 894c1fd..1570bcf 100644 --- a/Furious/Controllers/__init__.py +++ b/Furious/Controllers/__init__.py @@ -28,6 +28,7 @@ from .SettingsController import ( LOG_AUTO_CLEAR_SETTING, LOG_AUTO_SCROLL_DOWN_SETTING, PROXY_ENDPOINT_INFO_SETTING, + SYSTEM_PROXY_MODE_OPTIONS, SettingsController, ) @@ -36,6 +37,7 @@ __all__ = [ 'LOG_AUTO_CLEAR_SETTING', 'LOG_AUTO_SCROLL_DOWN_SETTING', 'PROXY_ENDPOINT_INFO_SETTING', + 'SYSTEM_PROXY_MODE_OPTIONS', 'ConnectionController', 'ConnectionError', 'ConnectionState', diff --git a/Furious/Window/HomePage.py b/Furious/Window/HomePage.py index 5f26ffd..8789b79 100644 --- a/Furious/Window/HomePage.py +++ b/Furious/Window/HomePage.py @@ -19,6 +19,7 @@ from __future__ import annotations +from Furious.Controllers import SYSTEM_PROXY_MODE_OPTIONS from Furious.Frozenlib import * from Furious.Interface import * from Furious.Models import * @@ -737,6 +738,21 @@ class HomePage(Mixins.QTranslatable, QMainWindow): ) self.routingSelector = RoutingSelector(parent=self) + self.systemProxyComboBox = AppQComboBox(parent=self) + self.systemProxyComboBox.setContentWidthAdjustable() + self.systemProxyComboBox.setMinimumWidth(220) + self.systemProxyComboBox.setToolTip(_('System Proxy')) + + for label, mode in SYSTEM_PROXY_MODE_OPTIONS: + self.systemProxyComboBox.addItem(_(label), mode) + + self.tunModeLabel = AppQLabel(_('TUN Mode'), parent=self) + self.tunModeSwitch = AppQSwitch(parent=self) + self._tunModeAvailable = AppSettingsController().tunModeAvailable() + + self._syncSystemProxyMode(AppSettings.get('SystemProxyMode')) + self.tunModeSwitch.syncChecked(AppSettings.isStateON_('VPNMode')) + self.searchLineEdit = AppQLineEdit() self.searchLineEdit.setPlaceholderText( _( @@ -767,16 +783,25 @@ class HomePage(Mixins.QTranslatable, QMainWindow): self.headerLayout.addWidget(self.searchLineEdit, 4) self.headerLayout.addWidget(self.searchButton) + self.connectionLayout = QHBoxLayout() + self.connectionLayout.setContentsMargins(0, 0, 0, 0) + self.connectionLayout.setSpacing(8) + self.connectionLayout.addWidget(self.connectButton) + self.connectionLayout.addWidget(self.routingSelector) + self.connectionLayout.addWidget(self.systemProxyComboBox) + self.connectionLayout.addWidget(self.tunModeLabel) + self.connectionLayout.addWidget(self.tunModeSwitch) + self.connectionLayout.addStretch(1) + self.actionLayout = QHBoxLayout() self.actionLayout.setContentsMargins(0, 0, 0, 0) self.actionLayout.setSpacing(8) - self.actionLayout.addWidget(self.connectButton) - self.actionLayout.addWidget(self.routingSelector) self.actionLayout.addWidget(self.serverButton) self.actionLayout.addStretch(1) self.actionLayout.addWidget(self.subscriptionFilterComboBox) self._layout.addLayout(self.headerLayout) + self._layout.addLayout(self.connectionLayout) self._layout.addLayout(self.actionLayout) self._layout.addWidget(self.userServersQTableWidget, 1) @@ -792,6 +817,19 @@ class HomePage(Mixins.QTranslatable, QMainWindow): self.subscriptionFilterComboBox.currentIndexChanged.connect( self.handleSubscriptionFilterChanged ) + self.systemProxyComboBox.currentIndexChanged.connect( + self.handleSystemProxyModeChanged + ) + self.tunModeSwitch.toggled.connect(self.handleTUNModeChanged) + + AppSettingsController().systemProxyModeChanged.connect( + self._syncSystemProxyMode + ) + AppSettingsController().tunModeChanged.connect(self.tunModeSwitch.syncChecked) + + AppConnectionController().interactionEnabledChanged.connect( + self.setConnectionControlsEnabled + ) self.userServersQTableWidget.subsManager.subscriptionsChanged.connect( self.refreshSubscriptionFilter @@ -805,9 +843,37 @@ class HomePage(Mixins.QTranslatable, QMainWindow): self.refreshSubscriptionFilter() self.handleServerSelectionChanged() + self.setConnectionControlsEnabled(AppConnectionController().interactionEnabled) self.setCentralWidget(self._widget) + @QtCore.Slot(str) + def _syncSystemProxyMode(self, mode: str): + """Select the shared system-proxy preference without writing it again.""" + with Mixins.QBlockSignalContext(self.systemProxyComboBox): + index = self.systemProxyComboBox.findData(mode) + + self.systemProxyComboBox.setCurrentIndex(max(0, index)) + + @QtCore.Slot() + def handleSystemProxyModeChanged(self): + """Persist a system-proxy selection through the shared settings authority.""" + mode = self.systemProxyComboBox.currentData() + + if isinstance(mode, str): + AppSettingsController().setSystemProxyMode(mode) + + @QtCore.Slot(bool) + def handleTUNModeChanged(self, enabled: bool): + """Persist a TUN request through the shared settings authority.""" + AppSettingsController().setTUNMode(enabled) + + @QtCore.Slot(bool) + def setConnectionControlsEnabled(self, enabled: bool): + """Gate connection-sensitive Home controls during lifecycle transitions.""" + self.systemProxyComboBox.setEnabled(bool(enabled)) + self.tunModeSwitch.setEnabled(bool(enabled) and self._tunModeAvailable) + @QtCore.Slot() def handleServerSelectionChanged(self, *_args): """Apply Home's selection policy to the shared connection control.""" diff --git a/Furious/Window/SettingsPage.py b/Furious/Window/SettingsPage.py index 61953f3..e3724e2 100644 --- a/Furious/Window/SettingsPage.py +++ b/Furious/Window/SettingsPage.py @@ -19,7 +19,10 @@ from __future__ import annotations -from Furious.Controllers import APPLICATION_THEME_SETTING +from Furious.Controllers import ( + APPLICATION_THEME_SETTING, + SYSTEM_PROXY_MODE_OPTIONS, +) from Furious.Frozenlib import * from Furious.Plugins import ( CapabilityKind, @@ -509,10 +512,7 @@ class _ApplicationThemeSettingsCard(_SettingsCard): class _SystemProxySettingsCard(_SettingsCard): """Select how Furious manages the operating-system proxy.""" - Options = ( - ('Automatically Configure System Proxy', AppBuiltinProxyMode.Auto.value), - ('Do Not Change System Proxy', AppBuiltinProxyMode.NoChanges.value), - ) + Options = SYSTEM_PROXY_MODE_OPTIONS _TranslatableOptions = ( _('Automatically Configure System Proxy'), @@ -540,10 +540,13 @@ class _SystemProxySettingsCard(_SettingsCard): parent=parent, ) - def sync(self): + def sync(self, mode=None): """Select the persisted proxy mode without writing it again.""" blocker = QtCore.QSignalBlocker(self.comboBox) - index = self.comboBox.findData(AppSettings.get('SystemProxyMode')) + selectedMode = ( + mode if isinstance(mode, str) else AppSettings.get('SystemProxyMode') + ) + index = self.comboBox.findData(selectedMode) self.comboBox.setCurrentIndex(max(index, 0)) @@ -646,10 +649,7 @@ class SettingsPage(Mixins.QTranslatable, QMainWindow): self.pluginSections = [] self._pluginActions = [] - if PLATFORM == 'Linux': - self._tunModeAvailable = not SystemRuntime.flatpakID() - else: - self._tunModeAvailable = SystemRuntime.isAdmin() + self._tunModeAvailable = AppSettingsController().tunModeAvailable() self.tunModeCard = _ToggleSettingsCard( 'shield-check.svg', @@ -660,6 +660,10 @@ class SettingsPage(Mixins.QTranslatable, QMainWindow): ) self.tunModeCard.checkBox.setEnabled(self._tunModeAvailable) + AppSettingsController().tunModeChanged.connect( + self.tunModeCard.checkBox.syncChecked + ) + ( self.applicationThemeCard, self.languageCard, @@ -800,6 +804,10 @@ class SettingsPage(Mixins.QTranslatable, QMainWindow): ), ) + AppSettingsController().systemProxyModeChanged.connect( + self.systemProxyCard.sync + ) + if not SystemRuntime.flatpakID(): self.connectionSection.addCard(self.tunSettingsCard) diff --git a/tests/test_models_and_services.py b/tests/test_models_and_services.py index 159ad9a..42feef2 100644 --- a/tests/test_models_and_services.py +++ b/tests/test_models_and_services.py @@ -23,7 +23,12 @@ from Furious.Controllers.SettingsController import ( APPLICATION_THEME_SETTING, SettingsController, ) -from Furious.Frozenlib import AppBinarySettings, AppSettings, ApplicationTheme +from Furious.Frozenlib import ( + AppBinarySettings, + AppBuiltinProxyMode, + AppSettings, + ApplicationTheme, +) from Furious.Models import ( CoreConfiguration, ProfileMetadata, @@ -50,6 +55,7 @@ from collections import OrderedDict import threading import unittest import weakref +from unittest import mock from tests.support import application, isolatedSettings, processQtEvents @@ -376,6 +382,48 @@ class SettingsMigrationTest(unittest.TestCase): ApplicationTheme.Light.value, ) + def testSharedNetworkPreferencesEmitOnlyRealChanges(self): + """Synchronize multiple views without publishing duplicate mutations.""" + application() + + with ( + isolatedSettings(), + mock.patch( + 'Furious.Controllers.SettingsController.PLATFORM', + 'Linux', + ), + mock.patch( + 'Furious.Controllers.SettingsController.showMBoxNewChangesNextTime' + ), + ): + AppSettings.turnOFF('VPNMode') + AppSettings.set( + 'SystemProxyMode', + AppBuiltinProxyMode.Auto.value, + ) + controller = SettingsController() + tunStates = [] + proxyModes = [] + + controller.tunModeChanged.connect(tunStates.append) + controller.systemProxyModeChanged.connect(proxyModes.append) + + controller.setTUNMode(True) + controller.setTUNMode(True) + controller.setSystemProxyMode(AppBuiltinProxyMode.NoChanges.value) + controller.setSystemProxyMode(AppBuiltinProxyMode.NoChanges.value) + + self.assertEqual(tunStates, [True]) + self.assertEqual( + proxyModes, + [AppBuiltinProxyMode.NoChanges.value], + ) + self.assertTrue(AppSettings.isStateON_('VPNMode')) + self.assertEqual( + AppSettings.get('SystemProxyMode'), + AppBuiltinProxyMode.NoChanges.value, + ) + class LogManagerTest(unittest.TestCase): """Verify bounded, categorized, and thread-safe structured logging."""