diff --git a/Furious/Backends/Xray/RoutingWindow.py b/Furious/Backends/Xray/RoutingWindow.py index 490de86..43c79ca 100644 --- a/Furious/Backends/Xray/RoutingWindow.py +++ b/Furious/Backends/Xray/RoutingWindow.py @@ -955,7 +955,7 @@ class RoutingRulesDialog(AppQTransientDialog): FIXED_DIALOG_SIZE = QtCore.QSize(760, 470) - def __init__(self, routing: dict, parent=None): + def __init__(self, routing: dict, parent=None, *, routingUnique=None): """Initialize the RoutingRulesDialog.""" super().__init__(parent) @@ -964,6 +964,8 @@ class RoutingRulesDialog(AppQTransientDialog): self.setWindowModality(QtCore.Qt.WindowModality.WindowModal) self.listView = RoutingRulesListView(self.routing, parent=self) + self.routingUnique = routingUnique + self.originalRules = copy.deepcopy(self.listView.rules()) connectWeakly(self.listView.editRequested, self, 'editRule') @@ -1240,16 +1242,53 @@ class UserRoutingTableView(Mixins.QTranslatable, AppQTableView): if row < 0 or row >= self.sourceModel.rowCount(): return - self.sourceModel.routingByRow(row)['domainStrategy'] = text + routing = self.sourceModel.routingByRow(row) + + if routing.get('domainStrategy', 'AsIs') == text: + return + + unique = self.routingUniqueByRow(row) + routing['domainStrategy'] = text + self.sourceModel.emitAllChanged() + self._routingChanged(unique) def setEnabled(self, row: int, state: str): """Set enabled.""" if row < 0 or row >= self.sourceModel.rowCount(): return - self.sourceModel.routingByRow(row)['enabled'] = state == 'Enabled' + routing = self.sourceModel.routingByRow(row) + enabled = state == 'Enabled' + + if routing.get('enabled', True) == enabled: + return + + unique = self.routingUniqueByRow(row) + routing['enabled'] = enabled + self.sourceModel.emitAllChanged() + self._routingChanged(unique, disabled=not enabled) + + def _routingChanged(self, unique, *, disabled=False): + """Reconcile routing availability and offer reconnection for active edits.""" + connectionController, routingController = ( + AppConnectionController(), + AppRoutingController(), + ) + + notify = ( + connectionController is not None + and connectionController.isConnected() + and routingController is not None + and routingController.routing == f'Custom:{unique}' + ) + + if disabled and routingController is not None: + routingController.invalidateRouting(f'Custom:{unique}') + + if notify: + showMBoxNewChangesNextTime(parent=self) def appendNewItem(self): """Append new item.""" @@ -1378,22 +1417,32 @@ class UserRoutingTableView(Mixins.QTranslatable, AppQTableView): routing = self.sourceModel.routingByRow(indexes[0]) - dialog = RoutingRulesDialog(routing, parent=self) + dialog = RoutingRulesDialog( + routing, parent=self, routingUnique=self.routingUniqueByRow(indexes[0]) + ) connectWeakly( dialog.finished, self, '_rulesDialogFinished', sender=dialog, + forwardSender=True, ) dialog.open() - @QtCore.Slot(int) - def _rulesDialogFinished(self, _code): - """Refresh routing presentation after the rules dialog finishes.""" + @QtCore.Slot(object, int) + def _rulesDialogFinished(self, dialog, _code): + """Refresh and report saved rule edits, including window-close dismissal.""" self.flushAll() + # Rules are edited live; a rejected/closed dialog does not discard them. + if ( + Storage.UserRoutings().get(dialog.routingUnique) is dialog.routing + and dialog.originalRules != dialog.listView.rules() + ): + self._routingChanged(dialog.routingUnique) + class XrayRoutingWindow(AppQMainWindow): """Present the user routing window.""" diff --git a/Furious/Controllers/AGENTS.md b/Furious/Controllers/AGENTS.md index 444162c..f6d05fb 100644 --- a/Furious/Controllers/AGENTS.md +++ b/Furious/Controllers/AGENTS.md @@ -28,7 +28,9 @@ compatibility paths. selected repository profile from the active-profile reference and the independent runtime document; changes use controlled reconnect, not mutation of the running document. Capability refresh prefers the active profile and otherwise the repository's activated profile. Normalizing the displayed option does not itself persist a new - preference; explicit selection owns that mutation. User-defined routing labels are not translatable UI literals. + preference; explicit selection or invalidation owns that mutation. Disabling the selected custom route persists + its supported fallback without reconnecting by itself; re-enabling restores availability, not selection. + User-defined routing labels are not translatable UI literals. - `SettingsController` is the shared policy path used by Home, Settings, tray, and platform integration. Startup registration persists only after host success; other preferences may apply immediately or on the next connection. Preserve each setting's actual application timing instead of imposing one transaction order on all preferences. diff --git a/Furious/Controllers/RoutingController.py b/Furious/Controllers/RoutingController.py index 62f9eff..7881a83 100644 --- a/Furious/Controllers/RoutingController.py +++ b/Furious/Controllers/RoutingController.py @@ -132,6 +132,27 @@ class RoutingController(QtCore.QObject): return self.state() + def invalidateRouting(self, routing: str) -> bool: + """Persist the fallback after an explicit removal or disabling of a choice.""" + if AppSettings.get('Routing') != routing: + return False + + profile = self.currentProfileForRouting() + + if profile is None: + return False + + fallback = getPluginRegistry().normalizeRouting(profile, routing) + + if fallback == routing: + return False + + AppSettings.set('Routing', fallback) + + self.refresh(force=True) + + return True + @QtCore.Slot(str) def selectRouting(self, routing: str) -> bool: """Persist a supported route and reconnect the active proxy if needed.""" diff --git a/tests/test_controllers.py b/tests/test_controllers.py index cf76e57..a31f5b5 100644 --- a/tests/test_controllers.py +++ b/tests/test_controllers.py @@ -698,6 +698,169 @@ class RoutingControllerTest(unittest.TestCase): self.connectedProfile, ) + def testDisabledCustomRoutingStaysOnFallbackAfterReenable(self): + """Exercise the real reconnect prompt, tray menu, and routing selector.""" + from Furious.Actions.Routing import RoutingAction + from Furious.Backends.Configuration import ConfigXray + from Furious.Backends.Xray.Plugin import XrayPlugin + from Furious.Backends.Xray.RoutingWindow import UserRoutingTableView + from Furious.Frozenlib import AppBuiltinRouting + from Furious.Plugins import PluginRegistry + from Furious.Qt import AppQMessageBox + from Furious.Qt.QtWidgets import MBoxNewChangesNextTime + from Furious.Repository import Storage + from Furious.Widget.RoutingSelector import RoutingSelector + + app = application() + with isolatedSettings(): + profile = ServerProfile.fromConfiguration( + ConfigXray( + { + 'inbounds': [ + {'protocol': 'http', 'listen': '127.0.0.1', 'port': 18080} + ], + 'outbounds': [{'protocol': 'freedom', 'tag': 'proxy'}], + } + ) + ) + routings = { + 'active': {'remark': 'My routing', 'enabled': True, 'rules': []} + } + registry = PluginRegistry() + registry.register(XrayPlugin()) + core = FixtureCoreManager() + launchedRoutes = [] + + def start(configuration, **kwargs): + launchedRoutes.append( + registry.normalizeRouting(configuration, kwargs['routing']) + ) + return True + + core.start = start + connection = ConnectionController( + coreManager=core, updatesManager=FixtureUpdatesManager() + ) + logManager = LogManager() + with ( + mock.patch.object(app, 'connectionController', connection), + mock.patch.object(app, 'logManager', logManager), + mock.patch.object(Storage, 'UserRoutings', return_value=routings), + mock.patch.object(Storage, 'UserServers', return_value=[profile]), + mock.patch.object(Storage, 'UserActivatedItemIndex', return_value=0), + mock.patch( + 'Furious.Controllers.RoutingController.getPluginRegistry', + return_value=registry, + ), + mock.patch('Furious.Controllers.ConnectionController.SystemProxy.set'), + mock.patch('Furious.Controllers.ConnectionController.SystemProxy.off'), + mock.patch.object(connection, '_runPostConnectTasksOnce'), + mock.patch('sys.excepthook') as exceptionHook, + ): + AppSettings.set('Routing', 'Custom:active') + controller = RoutingController() + with mock.patch.object(app, 'routingController', controller): + view = UserRoutingTableView() + tray = RoutingAction(parent=view) + selector = RoutingSelector(parent=view) + try: + self.assertTrue(connection.startConnection(profile)) + self.assertEqual(launchedRoutes, ['Custom:active']) + view.indexWidget(view.sourceModel.index(0, 2)).setCurrentIndex( + 1 + ) + prompt = view.findChild(MBoxNewChangesNextTime) + self.assertIsNotNone(prompt) + prompt.button(AppQMessageBox.StandardButton.Yes).click() + processQtEvents() + fallback = AppBuiltinRouting.BypassMainlandChina.value + self.assertEqual(launchedRoutes, ['Custom:active', fallback]) + tray.rebuildMenu() + self.assertEqual(controller.routing, fallback) + view.indexWidget(view.sourceModel.index(0, 2)).setCurrentIndex( + 0 + ) + tray.rebuildMenu() + self.assertEqual(controller.routing, fallback) + self.assertEqual(AppSettings.get('Routing'), fallback) + self.assertEqual(selector.currentData(), fallback) + checked = [ + action.routingValue + for action in tray._menu.actions() + if action.isChecked() + ] + self.assertEqual(checked, [fallback]) + self.assertEqual(len(launchedRoutes), 2) + self.assertEqual(view.findChildren(MBoxNewChangesNextTime), []) + + customAction = next( + action + for action in tray._menu.actions() + if getattr(action, 'routingValue', None) == 'Custom:active' + ) + customAction.trigger() + self.assertEqual( + launchedRoutes, ['Custom:active', fallback, 'Custom:active'] + ) + self.assertEqual(selector.currentData(), 'Custom:active') + view.indexWidget(view.sourceModel.index(0, 2)).setCurrentIndex( + 1 + ) + prompt = view.findChild(MBoxNewChangesNextTime) + prompt.button(AppQMessageBox.StandardButton.No).click() + processQtEvents() + self.assertEqual(len(launchedRoutes), 3) + view.indexWidget(view.sourceModel.index(0, 2)).setCurrentIndex( + 0 + ) + tray.rebuildMenu() + self.assertEqual(controller.routing, fallback) + self.assertEqual(AppSettings.get('Routing'), fallback) + self.assertEqual(len(launchedRoutes), 3) + exceptionHook.assert_not_called() + finally: + connection.startDisconnection() + view.deleteLater() + controller.deleteLater() + processQtEvents() + connection.deleteLater() + logManager.deleteLater() + registry.shutdown() + processQtEvents() + + def testOnlyExplicitInvalidationPersistsFallbackWithoutReconnecting(self): + """Capability refresh stays observational until a selected choice is revoked.""" + connection = mock.Mock(activeProfile=self.connectedProfile) + registry = mock.Mock() + registry.routingOptions.return_value = (SimpleNamespace(id='fallback'),) + registry.normalizeRouting.return_value = 'fallback' + with ( + isolatedSettings(), + mock.patch( + 'Furious.Controllers.RoutingController.AppConnectionController', + return_value=connection, + ), + mock.patch( + 'Furious.Controllers.RoutingController.getPluginRegistry', + return_value=registry, + ), + ): + AppSettings.set('Routing', 'unavailable') + controller = RoutingController() + try: + controller.refresh(force=True) + self.assertEqual(controller.routing, 'fallback') + self.assertEqual(AppSettings.get('Routing'), 'unavailable') + self.assertFalse(controller.invalidateRouting('other')) + self.assertEqual(AppSettings.get('Routing'), 'unavailable') + self.assertTrue(controller.invalidateRouting('unavailable')) + self.assertEqual(AppSettings.get('Routing'), 'fallback') + self.assertFalse(controller.invalidateRouting('fallback')) + connection.startReconnection.assert_not_called() + finally: + controller.deleteLater() + processQtEvents() + def testSelectionFallsBackToRepositoryAndReconnectsOnce(self): """Persist supported routing and restart the connected runtime once.""" connectionController = mock.Mock() diff --git a/tests/test_ui_behavior.py b/tests/test_ui_behavior.py index 9c3633d..419e89c 100644 --- a/tests/test_ui_behavior.py +++ b/tests/test_ui_behavior.py @@ -45,6 +45,7 @@ from Furious.Backends.Xray.RoutingWindow import ( RoutingRulesDialog, RoutingTextEdit, RoutingTextEditDialog, + UserRoutingTableView, routingObjectFromProfile, ) from Furious.Backends.Xray.Routing import customRoutingObjectFromSettings @@ -2471,6 +2472,179 @@ class UnifiedLogPageTest(unittest.TestCase): self.assertTrue(all(reference() is None for reference in references)) +class RoutingChangeNoticeTest(unittest.TestCase): + """Report only saved changes to the connected custom routing profile.""" + + def setUp(self): + app = application() + self.addCleanup(collectAtBoundary) + self.enterContext(isolatedSettings()) + self.routings = { + unique: { + 'remark': 'Same display name', + 'domainStrategy': 'AsIs', + 'enabled': True, + 'rules': [ + {'domain': ['a.test'], 'outboundTag': 'proxy'}, + {'domain': ['b.test'], 'outboundTag': 'direct'}, + ], + } + for unique in ('active', 'other') + } + self.connection = mock.Mock() + self.connection.isConnected.return_value = True + self.routing = mock.Mock(routing='Custom:active') + self.enterContext( + mock.patch.object(app, 'connectionController', self.connection) + ) + self.enterContext(mock.patch.object(app, 'routingController', self.routing)) + self.enterContext( + mock.patch.object(Storage, 'UserRoutings', return_value=self.routings) + ) + self.notice = self.enterContext( + mock.patch('Furious.Backends.Xray.RoutingWindow.showMBoxNewChangesNextTime') + ) + self.view = UserRoutingTableView() + self.addCleanup(self.disposeView) + + def disposeView(self): + from shiboken6 import isValid + + if isValid(self.view): + self.view.deleteLater() + + def openRules(self, row=0): + self.view.selectRow(row) + self.view.editSelectedRules() + return self.view.findChild(RoutingRulesDialog) + + def testInlineChangesOnlyNotifyForConnectedSelectedRouting(self): + """Real combo signals distinguish routing IDs and ignore no-op refreshes.""" + with mock.patch('sys.excepthook') as exceptionHook: + self.view.flushAll() + self.view.setDomainStrategy(0, 'AsIs') + self.view.setEnabled(0, 'Enabled') + self.notice.assert_not_called() + + strategy = self.view.indexWidget(self.view.sourceModel.index(0, 1)) + strategy.setCurrentText('IPOnDemand') + self.assertEqual(self.routings['active']['domainStrategy'], 'IPOnDemand') + self.notice.assert_called_once_with(parent=self.view) + self.notice.reset_mock() + + enabled = self.view.indexWidget(self.view.sourceModel.index(0, 2)) + enabled.setCurrentIndex(1) + self.assertFalse(self.routings['active']['enabled']) + self.notice.assert_called_once_with(parent=self.view) + self.notice.reset_mock() + + self.view.setDomainStrategy(1, 'IPIfNonMatch') + self.view.setEnabled(1, 'Disabled') + self.connection.isConnected.return_value = False + self.view.setDomainStrategy(0, 'AsIs') + self.view.setEnabled(0, 'Enabled') + self.connection.isConnected.return_value = True + self.routing.routing = 'Global' + self.view.setDomainStrategy(0, 'IPIfNonMatch') + self.notice.assert_not_called() + processQtEvents() + exceptionHook.assert_not_called() + + def testRuleChangesNotifyOnCloseWithoutRetargetingSelection(self): + """Closing commits live edits and identifies the edited route by ID.""" + for change in ('add', 'edit', 'delete', 'move'): + with self.subTest(change=change), mock.patch( + 'sys.excepthook' + ) as exceptionHook: + dialog = self.openRules() + view = dialog.listView + if change == 'add': + view.appendRule({'domain': ['new.test'], 'outboundTag': 'proxy'}) + elif change == 'edit': + view.setRule(0, {'domain': ['edited.test'], 'outboundTag': 'proxy'}) + elif change == 'delete': + view.deleteRules([0]) + else: + view.setCurrentIndex(view.rulesModel.index(0, 0)) + view.moveSelectedRules('down') + self.view.selectRow(1) + self.notice.assert_not_called() + dialog.closeWindowButton.click() + processQtEvents() + self.notice.assert_called_once_with(parent=self.view) + exceptionHook.assert_not_called() + self.notice.reset_mock() + + def testRuleNoticeSkipsUnchangedRevertedStaleAndInactiveEdits(self): + """An open editor does not keep authority over a replaced route or connection.""" + for case in ( + 'unchanged', + 'reverted', + 'disconnected', + 'switched', + 'replaced', + 'other', + ): + with self.subTest(case=case), mock.patch('sys.excepthook') as exceptionHook: + dialog = self.openRules(1 if case == 'other' else 0) + if case != 'unchanged': + dialog.listView.appendRule( + {'domain': ['new.test'], 'outboundTag': 'proxy'} + ) + if case == 'reverted': + dialog.listView.deleteRules([len(dialog.listView.rules()) - 1]) + elif case == 'disconnected': + self.connection.isConnected.return_value = False + elif case == 'switched': + self.routing.routing = 'Custom:other' + elif case == 'replaced': + self.routings['active'] = copy.deepcopy(self.routings['active']) + dialog.reject() + processQtEvents() + self.notice.assert_not_called() + exceptionHook.assert_not_called() + self.connection.isConnected.return_value = True + self.routing.routing = 'Custom:active' + + def testExistingNoticeReconnectsOnlyWhenAcceptedAndDiesWithOwner(self): + """Reuse the real asynchronous prompt without retaining the rules editor.""" + from Furious.Qt.QtWidgets import ( + MBoxNewChangesNextTime, + showMBoxNewChangesNextTime, + ) + from shiboken6 import delete as deleteQObject, isValid + + self.notice.side_effect = showMBoxNewChangesNextTime + for button in ( + AppQMessageBox.StandardButton.No, + AppQMessageBox.StandardButton.Yes, + ): + dialog = self.openRules() + dialog.listView.appendRule({'domain': ['new.test'], 'outboundTag': 'proxy'}) + dialog.closeWindowButton.click() + processQtEvents() + prompt = self.view.findChild(MBoxNewChangesNextTime) + self.assertIsNotNone(prompt) + self.assertTrue(prompt.isVisible()) + self.assertFalse(isValid(dialog)) + self.assertIn(prompt, AppQDialog._openDialogs.values()) + prompt.button(button).click() + processQtEvents() + self.assertFalse(isValid(prompt)) + self.assertNotIn(prompt, AppQDialog._openDialogs.values()) + if button == AppQMessageBox.StandardButton.No: + self.connection.startReconnection.assert_not_called() + else: + self.connection.startReconnection.assert_called_once_with() + + self.view.setDomainStrategy(0, 'IPOnDemand') + prompt = self.view.findChild(MBoxNewChangesNextTime) + deleteQObject(self.view) + processQtEvents() + self.assertFalse(isValid(prompt)) + self.assertNotIn(prompt, AppQDialog._openDialogs.values()) + + class DialogBehaviorTest(unittest.TestCase): """Exercise no-selection guards and QMessageBox-compatible results."""