Fix custom routing change handling

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-09-19 13:38:49 +08:00
parent 8fb02d3b59
commit 8645b412d5
5 changed files with 417 additions and 8 deletions
+56 -7
View File
@@ -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."""
+3 -1
View File
@@ -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.
+21
View File
@@ -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."""
+163
View File
@@ -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()
+174
View File
@@ -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."""