Unify Home and tray routing controls

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-08-12 12:24:03 +08:00
parent 4d88940434
commit e5b1050004
12 changed files with 409 additions and 67 deletions
+1 -1
View File
@@ -180,7 +180,7 @@ class ConnectAction(AppQAction):
self.setDisabled(value)
try:
APP().systemTray.RoutingAction.setDisabled(value)
APP().routingController.setInteractionEnabled(not value)
except (AttributeError, RuntimeError):
pass
+38 -58
View File
@@ -15,19 +15,17 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""Implement tray actions for routing."""
"""Present shared routing state through the tray action menu."""
from __future__ import annotations
from Furious.Frozenlib import *
from Furious.Repository import *
from Furious.Plugins import getPluginRegistry
from Furious.Controllers import RoutingController
from Furious.Qt import *
from Furious.Qt import gettext as _
__all__ = ['RoutingAction']
from PySide6 import QtCore
registerAppSettings('Routing', default=AppBuiltinRouting.BypassMainlandChina.value)
__all__ = ['RoutingAction']
# ALL BUILTIN ROUTING VALUE
_TRANSLATABLE_BUILTIN_ROUTING = [
@@ -38,33 +36,32 @@ _TRANSLATABLE_BUILTIN_ROUTING = [
class RoutingChildAction(AppQAction):
"""Handle the routing child action."""
"""Forward one tray route choice to the shared controller."""
def __init__(self, *args, **kwargs):
def __init__(
self,
*args,
controller: RoutingController,
routingValue: str,
**kwargs,
):
"""Initialize the RoutingChildAction."""
self.routingValue = kwargs.pop('routingValue', None)
self.controller = controller
self.routingValue = routingValue
super().__init__(*args, **kwargs)
def triggeredCallback(self, checked):
"""Handle activation of the action."""
textEnglish = self.routingValue or self.textEnglish
if AppSettings.get('Routing') != textEnglish:
AppSettings.set('Routing', textEnglish)
if APP().isSystemTrayConnected():
APP().systemTray.ConnectAction.doReconnect()
self.controller.selectRouting(self.routingValue)
class RoutingAction(AppQAction):
"""Handle the routing action."""
"""Render application routing options as a synchronized tray menu."""
def __init__(self, **kwargs):
def __init__(self, controller: RoutingController, **kwargs):
"""Initialize the RoutingAction."""
if AppSettings.get('Routing') == 'Bypass':
# Update value for backward compatibility
AppSettings.set('Routing', AppBuiltinRouting.BypassMainlandChina.value)
self.controller = controller
super().__init__(
_('Routing'),
@@ -73,17 +70,13 @@ class RoutingAction(AppQAction):
**kwargs,
)
self.rebuildMenu()
self.controller.stateChanged.connect(self._applyState)
self.controller.interactionEnabledChanged.connect(self.setEnabled)
def routingActions(self):
"""Return routing actions supported by the active core plugin."""
config = self.activeConfig()
if config is None:
return list()
self._applyState(*self.controller.state())
pluginRegistry = getPluginRegistry()
options = pluginRegistry.routingOptions(config)
routing = pluginRegistry.normalizeRouting(config, AppSettings.get('Routing'))
def routingActions(self, options, routing: str):
"""Build tray actions from one shared controller snapshot."""
actions = list()
for option in options:
@@ -97,6 +90,7 @@ class RoutingAction(AppQAction):
if option.translatable
else option.displayName
),
controller=self.controller,
routingValue=option.id,
checkable=True,
checked=routing == option.id,
@@ -105,27 +99,20 @@ class RoutingAction(AppQAction):
return actions
@staticmethod
def activeConfig():
"""Return the currently selected server configuration, if any."""
try:
index = Storage.UserActivatedItemIndex()
servers = Storage.UserServers()
@QtCore.Slot(object, str)
def _applyState(self, options, routing: str):
"""Rebuild the tray menu from one controller state snapshot."""
oldActionGroup = getattr(self, '_actionGroup', None)
if 0 <= index < len(servers):
return servers[index]
except Exception:
# The tray may be built before persistent storage is available.
pass
return None
def rebuildMenu(self):
"""Handle rebuild menu for the routing action."""
self._menu.clear()
self._menu._actions.clear()
if oldActionGroup is not None:
oldActionGroup.deleteLater()
self._actionGroup = AppQActionGroup(self)
actions = self.routingActions()
actions = self.routingActions(options, routing)
for action in actions:
if isinstance(action, AppQSeperator):
@@ -137,15 +124,8 @@ class RoutingAction(AppQAction):
self._actionGroup.addAction(action)
self.setVisible(bool(actions))
self.setEnabled(self.controller.interactionEnabled)
def getGlobalAction(self):
"""Return the active plugin's global routing action, if supported."""
return next(
(
action
for action in self._menu._actions
if isinstance(action, RoutingChildAction)
and action.routingValue == AppBuiltinRouting.Global.value
),
None,
)
def rebuildMenu(self):
"""Refresh plugin options immediately before the tray menu opens."""
self.controller.refresh(force=True)
+5 -2
View File
@@ -23,6 +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.Extensions import BUNDLED_EXTENSION_TYPES
from Furious.Plugins import getPluginRegistry, initializePluginRegistry
from Furious.Qt import AppStyleSheet
@@ -181,6 +182,7 @@ class DesktopApplication(ApplicationRunner, SingletonApplication):
self.mainWindow = None
self.systemTray = None
self.connectionAction = None
self.routingController = None
# Unified logging service and presentation
self.logManager = None
@@ -544,8 +546,9 @@ class DesktopApplication(ApplicationRunner, SingletonApplication):
SystemProxy.off()
SystemProxy.daemonOn_()
# The application owns the connection operation. Home and tray bind
# to this same action, regardless of their construction order.
# 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()
+1 -1
View File
@@ -55,7 +55,7 @@ class TrayIcon(
actions = [
APP().connectionAction,
RoutingAction(isTrayAction=True),
RoutingAction(APP().routingController, isTrayAction=True),
ImportAction(isTrayAction=True),
AppQSeperator(),
ShowHomePageAction(isTrayAction=True),
+153
View File
@@ -0,0 +1,153 @@
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
#
# 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 <https://www.gnu.org/licenses/>.
"""Own application routing selection independently from its UI controls."""
from __future__ import annotations
from Furious.Frozenlib import (
APP,
AppBuiltinRouting,
AppSettings,
registerAppSettings,
)
from Furious.Plugins import getPluginRegistry
from Furious.Repository import Storage
from PySide6 import QtCore
__all__ = ['RoutingController']
registerAppSettings('Routing', default=AppBuiltinRouting.BypassMainlandChina.value)
class RoutingController(QtCore.QObject):
"""Expose one routing state shared by every application surface."""
stateChanged = QtCore.Signal(object, str)
optionsChanged = QtCore.Signal(object)
routingChanged = QtCore.Signal(str)
interactionEnabledChanged = QtCore.Signal(bool)
def __init__(self, parent=None):
"""Initialize persisted routing state and plugin-provided options."""
super().__init__(parent)
if AppSettings.get('Routing') == 'Bypass':
# Update the value used by older Furious releases.
AppSettings.set('Routing', AppBuiltinRouting.BypassMainlandChina.value)
self._options = tuple()
self._routing = str(AppSettings.get('Routing'))
self._interactionEnabled = True
self.refresh()
@staticmethod
def activeConfiguration():
"""Return the active server configuration, if one is available."""
try:
index = Storage.UserActivatedItemIndex()
servers = Storage.UserServers()
if 0 <= index < len(servers):
return servers[index]
except Exception:
# The controller can exist before persistent storage is ready.
pass
return None
@property
def options(self):
"""Return routing options supported by the active configuration."""
return self._options
@property
def routing(self) -> str:
"""Return the normalized route shown by routing controls."""
return self._routing
@property
def interactionEnabled(self) -> bool:
"""Return whether users may change routing at this lifecycle state."""
return self._interactionEnabled
def state(self):
"""Return the current immutable option and selection snapshot."""
return self.options, self.routing
def refresh(self, *, force=False):
"""Refresh routing capabilities from the active proxy-core plugin."""
config = self.activeConfiguration()
registry = getPluginRegistry()
options = registry.routingOptions(config) if config is not None else tuple()
routing = (
registry.normalizeRouting(config, AppSettings.get('Routing'))
if config is not None
else str(AppSettings.get('Routing'))
)
optionsChanged = options != self._options
routingChanged = routing != self._routing
self._options = options
self._routing = routing
if optionsChanged:
self.optionsChanged.emit(options)
if routingChanged:
self.routingChanged.emit(routing)
if force or optionsChanged or routingChanged:
self.stateChanged.emit(options, routing)
return self.state()
@QtCore.Slot(str)
def selectRouting(self, routing: str) -> bool:
"""Persist a supported route and reconnect the active proxy if needed."""
options, _current = self.refresh()
if routing not in tuple(option.id for option in options):
return False
if AppSettings.get('Routing') == routing:
return False
AppSettings.set('Routing', routing)
self.refresh(force=True)
app = APP()
if app is not None and app.isSystemTrayConnected():
app.connectionAction.doReconnect()
return True
@QtCore.Slot(bool)
def setInteractionEnabled(self, enabled: bool):
"""Publish whether routing controls may accept user interaction."""
enabled = bool(enabled)
if enabled == self._interactionEnabled:
return
self._interactionEnabled = enabled
self.interactionEnabledChanged.emit(enabled)
+2 -1
View File
@@ -17,6 +17,7 @@
"""Expose presentation-independent application controllers."""
from .RoutingController import RoutingController
from .SettingsController import SettingsController
__all__ = ['SettingsController']
__all__ = ['RoutingController', 'SettingsController']
+76 -4
View File
@@ -38,6 +38,7 @@ __all__ = [
'moveToCenter',
'AppQCheckBox',
'AppQComboBox',
'AppQComboBoxSeparatorDelegate',
'AppQDialog',
'AppQDialogButtonBox',
'AppQGroupBox',
@@ -96,6 +97,55 @@ class AppQCheckBox(Mixins.QTranslatable, QCheckBox):
self.setText(_(self.text()))
class AppQComboBoxSeparatorDelegate(Mixins.ThemeAware, QAbstractItemDelegate):
"""Paint real combo-box separators with application theme tokens."""
SeparatorRoleValue = 'separator'
HorizontalMargin = 8
def __init__(self, wrappedDelegate, comboBox):
"""Wrap Qt's native delegate without changing normal item painting."""
self.wrappedDelegate = wrappedDelegate
self.comboBox = comboBox
super().__init__(comboBox)
@staticmethod
def isSeparator(index) -> bool:
"""Return whether *index* was created by QComboBox.insertSeparator()."""
return (
index.data(QtCore.Qt.ItemDataRole.AccessibleDescriptionRole)
== AppQComboBoxSeparatorDelegate.SeparatorRoleValue
)
def paint(self, painter, option, index):
"""Paint separators and preserve Qt's native rendering for all items."""
if not self.isSeparator(index):
self.wrappedDelegate.paint(painter, option, index)
return
try:
color = QColor(
AppStyleSheet.paletteForTheme(APP().theme())['border_strong']
)
except (AttributeError, RuntimeError):
color = option.palette.color(QPalette.ColorRole.Mid)
line = option.rect.adjusted(self.HorizontalMargin, 0, -self.HorizontalMargin, 0)
y = line.center().y()
painter.fillRect(line.left(), y, max(0, line.width()), 1, color)
def sizeHint(self, option, index):
"""Keep the native popup's item and separator geometry unchanged."""
return self.wrappedDelegate.sizeHint(option, index)
def themeChangedCallback(self, theme: str):
"""Repaint the popup viewport after an application theme change."""
self.comboBox.view().viewport().update()
class AppQComboBox(Mixins.QTranslatable, QComboBox):
"""Represent app q combo box."""
@@ -103,6 +153,18 @@ class AppQComboBox(Mixins.QTranslatable, QComboBox):
"""Initialize the AppQComboBox."""
super().__init__(*args, **kwargs)
self._themedSeparatorDelegate = None
def enableThemedSeparators(self):
"""Theme real insertSeparator() rows while preserving native items."""
if self._themedSeparatorDelegate is not None:
return
self._themedSeparatorDelegate = AppQComboBoxSeparatorDelegate(
self.itemDelegate(), self
)
self.setItemDelegate(self._themedSeparatorDelegate)
def retranslate(self):
"""Refresh translated text for the app q combo box."""
for index in range(self.count()):
@@ -1073,10 +1135,20 @@ def showMBoxDirectRulesNotAllowed(**kwargs):
def handleResultCode(code):
"""Handle result code."""
if code == PySide6Legacy.enumValueWrapper(AppQMessageBox.StandardButton.Yes):
globalAction = APP().systemTray.RoutingAction.getGlobalAction()
if globalAction is not None:
globalAction.trigger()
APP().systemTray.ConnectAction.trigger()
app = APP()
controller, wasConnected = (
app.routingController,
app.isSystemTrayConnected(),
)
changed = controller.selectRouting(AppBuiltinRouting.Global.value)
if changed or controller.routing == AppBuiltinRouting.Global.value:
# Selecting a route reconnects an established connection itself.
# During an initial connection attempt, resume that attempt here.
if not wasConnected:
app.connectionAction.trigger()
else:
# Do nothing
pass
+2
View File
@@ -53,6 +53,7 @@ from .QtNetwork import AppQNetworkAccessManager
from .QtWidgets import (
AppQCheckBox,
AppQComboBox,
AppQComboBoxSeparatorDelegate,
AppQDialog,
AppQDialogButtonBox,
AppQGroupBox,
@@ -102,6 +103,7 @@ __all__ = [
'AppQActionGroup',
'AppQCheckBox',
'AppQComboBox',
'AppQComboBoxSeparatorDelegate',
'AppQDialog',
'AppQDialogButtonBox',
'AppQGroupBox',
+106
View File
@@ -0,0 +1,106 @@
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
#
# 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 <https://www.gnu.org/licenses/>.
"""Present the shared routing state as a compact Fluent-style selector."""
from __future__ import annotations
from Furious.Controllers import RoutingController
from Furious.Frozenlib import Mixins
from Furious.Qt import AppQComboBox
from Furious.Qt import gettext as _
from PySide6 import QtCore
from PySide6.QtWidgets import QSizePolicy
__all__ = ['RoutingSelector']
class RoutingSelector(AppQComboBox):
"""Select routes through the application-wide routing controller."""
def __init__(self, controller: RoutingController, parent=None):
"""Bind the selector to one shared routing controller."""
self.controller = controller
# This widget restores its enabled state from the shared controller in
# retranslate(), so the generic translation disable/enable wrapper is
# neither needed nor safe during connection transitions.
super().__init__(parent, useQSetDisabled=False)
self.setObjectName('HomeRoutingSelector')
self.enableThemedSeparators()
self.setMinimumWidth(180)
self.setMaximumWidth(280)
self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
self.setAccessibleName(_('Routing'))
self.setToolTip(_('Routing'))
self.currentIndexChanged.connect(self._handleCurrentIndexChanged)
self.controller.stateChanged.connect(self._applyState)
self.controller.interactionEnabledChanged.connect(self._applyInteractionEnabled)
self._applyState(*self.controller.state())
@staticmethod
def _optionText(option) -> str:
"""Return an option's translated or literal display name."""
return _(option.displayName) if option.translatable else option.displayName
@QtCore.Slot(object, str)
def _applyState(self, options, routing: str):
"""Replace items atomically from one controller state snapshot."""
with Mixins.QBlockSignalContext(self):
self.clear()
for option in options:
if option.separatorBefore and self.count():
self.insertSeparator(self.count())
self.addItem(self._optionText(option), option.id)
index = self.findData(routing)
self.setCurrentIndex(index if index >= 0 else 0)
self.setVisible(bool(options))
self._applyInteractionEnabled(self.controller.interactionEnabled)
@QtCore.Slot(bool)
def _applyInteractionEnabled(self, enabled: bool):
"""Enable selection only when options and connection state allow it."""
self.setEnabled(bool(enabled) and self.count() > 0)
@QtCore.Slot(int)
def _handleCurrentIndexChanged(self, index: int):
"""Forward user selection without retaining independent state."""
routing = self.itemData(index)
if isinstance(routing, str):
self.controller.selectRouting(routing)
def showPopup(self):
"""Refresh plugin capabilities immediately before showing options."""
self.controller.refresh(force=True)
super().showPopup()
def retranslate(self):
"""Refresh option text and routing accessibility metadata."""
self._applyState(*self.controller.state())
self.setAccessibleName(_('Routing'))
self.setToolTip(_('Routing'))
+15
View File
@@ -1041,6 +1041,8 @@ class DeleteServersProgressDialog(AppQDialog):
# Set invalid first
AppSettings.set('ActivatedItemIndex', str(-1))
self.table.activeServerChanged.emit()
if APP().isSystemTrayConnected():
if self.showTrayMessage:
# Trigger disconnect
@@ -1462,6 +1464,8 @@ class ServerTableView(
):
"""Represent user servers Qt table view."""
activeServerChanged = QtCore.Signal()
RowHeight = 42
Headers = [
@@ -1953,6 +1957,7 @@ class ServerTableView(
def activateItemByIndex(self, index, activate):
"""Activate item by index."""
oldIndex = Storage.UserActivatedItemIndex()
changed = activate and oldIndex != int(index)
if activate:
AppSettings.set('ActivatedItemIndex', str(index))
@@ -1960,6 +1965,9 @@ class ServerTableView(
self.sourceModel.emitRowChanged(oldIndex)
self.sourceModel.emitRowChanged(index)
if changed:
self.activeServerChanged.emit()
def flushItem(self, row: int, column: int, item: ServerProfile):
"""Refresh item."""
itemIndex = item.index
@@ -2092,6 +2100,9 @@ class ServerTableView(
self.sourceModel.emitRowChanged(row)
if row == Storage.UserActivatedItemIndex():
self.activeServerChanged.emit()
def flushAll(self):
# Refresh index
"""Refresh all."""
@@ -2251,6 +2262,8 @@ class ServerTableView(
# Set invalid first
AppSettings.set('ActivatedItemIndex', str(-1))
self.activeServerChanged.emit()
if APP().isSystemTrayConnected():
if showTrayMessage:
# Trigger disconnect
@@ -2574,6 +2587,8 @@ class ServerTableView(
AppSettings.set('ActivatedItemIndex', str(newActivatedIndex))
self.activeServerChanged.emit()
self.sourceModel.refreshIndexes()
self.proxyModel.invalidate()
+2
View File
@@ -22,6 +22,7 @@ from __future__ import annotations
from .ConnectionProgressWidget import ConnectionProgressWidget
from .MetricsGraph import MetricsGraphWidget
from .NavigationView import NavigationView
from .RoutingSelector import RoutingSelector
from .ServerTableView import ServerTableView
from .SubscriptionTableView import SubscriptionTableView
from .WaitingSpinner import WaitingSpinner
@@ -30,6 +31,7 @@ __all__ = [
'ConnectionProgressWidget',
'MetricsGraphWidget',
'NavigationView',
'RoutingSelector',
'ServerTableView',
'SubscriptionTableView',
'WaitingSpinner',
+8
View File
@@ -40,6 +40,7 @@ from Furious.Actions.Import import (
ImportURIFromClipboardAction,
)
from Furious.Widget.ConnectionButton import ConnectionButton
from Furious.Widget.RoutingSelector import RoutingSelector
from Furious.Widget.ServerTableView import *
from Furious.Window.NetworkTestDialog import *
from Furious.Window.ProxyBypassDialog import *
@@ -515,6 +516,8 @@ class HomePage(Mixins.QTranslatable, QMainWindow):
self.activateSelectedServerForConnection,
parent=self,
)
self.routingController = APP().routingController
self.routingSelector = RoutingSelector(self.routingController, parent=self)
self.searchLineEdit = AppQLineEdit()
self.searchLineEdit.setPlaceholderText(
@@ -550,6 +553,7 @@ class HomePage(Mixins.QTranslatable, QMainWindow):
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)
@@ -577,6 +581,9 @@ class HomePage(Mixins.QTranslatable, QMainWindow):
self.userServersQTableWidget.selectionModel().selectionChanged.connect(
self.handleServerSelectionChanged
)
self.userServersQTableWidget.activeServerChanged.connect(
self.routingController.refresh
)
self.refreshSubscriptionFilter()
self.handleServerSelectionChanged()
@@ -607,6 +614,7 @@ class HomePage(Mixins.QTranslatable, QMainWindow):
return False
self.userServersQTableWidget.activateSelectedServer()
self.routingController.refresh()
return Storage.UserActivatedItemIndex() == indexes[0]