mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-09-30 19:08:03 +03:00
Fix custom routing change handling
Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
@@ -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."""
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user