diff --git a/Furious/Backends/Hysteria1/Editor.py b/Furious/Backends/Hysteria1/Editor.py index 4a744ec..c0f53af 100644 --- a/Furious/Backends/Hysteria1/Editor.py +++ b/Furious/Backends/Hysteria1/Editor.py @@ -83,6 +83,8 @@ class GuiHy1ItemBasicProtocol(GuiEditorItemTextComboBox): def __init__(self, *args, **kwargs): """Initialize the GuiHy1ItemBasicProtocol.""" + kwargs.setdefault('preserveUnknownValues', True) + super().__init__(*args, **kwargs) self.addItems(['', 'udp', 'wechat-video', 'faketcp']) diff --git a/Furious/Backends/Hysteria2/Editor.py b/Furious/Backends/Hysteria2/Editor.py index cddae5e..84d373d 100644 --- a/Furious/Backends/Hysteria2/Editor.py +++ b/Furious/Backends/Hysteria2/Editor.py @@ -280,62 +280,6 @@ class GuiHy2FormBindings(EditorWidgetBinding): binding.factoryToInput(config) -class GuiHy2ColumnBindings(EditorWidgetBinding): - """Arrange independent compact form fields in equal-width columns.""" - - def __init__(self, *bindings: EditorWidgetBinding, **kwargs): - """Create one persistent row of equal-width one-field forms.""" - super().__init__(**kwargs) - - self.bindings = tuple(bindings) - self._widget = QWidget() - - layout = QHBoxLayout(self._widget) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(18) - - columns = [] - - for binding in self.bindings: - column = QWidget(self._widget) - - columnLayout = QFormLayout(column) - columnLayout.setContentsMargins(0, 0, 0, 0) - columnLayout.setFormAlignment(QtCore.Qt.AlignmentFlag.AlignLeft) - columnLayout.setFieldGrowthPolicy( - QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow - ) - columnLayout.addRow(*binding.widgets()) - - layout.addWidget(column, 1) - columns.append(column) - - minimumColumnWidth = max( - (column.minimumSizeHint().width() for column in columns), default=0 - ) - - for column in columns: - column.setMinimumWidth(minimumColumnWidth) - - def widgets(self): - """Return the composed full-width column row.""" - return (self._widget,) - - def inputToFactory(self, config: CoreConfiguration) -> bool: - """Apply every binding in the row to the same configuration.""" - modified = False - - for binding in self.bindings: - modified |= binding.inputToFactory(config) - - return modified - - def factoryToInput(self, config: CoreConfiguration): - """Load every binding in the row from the same configuration.""" - for binding in self.bindings: - binding.factoryToInput(config) - - class GuiHy2ItemBasicServer(GuiEditorItemTextInput): """Represent GUI hy2 item basic server.""" @@ -716,6 +660,12 @@ class GuiHy2PageObfsGecko(GuiHy2PageObfsXXX): translatable=False, ) + self.packetSizeRow = GuiHy2InlineBindings( + self.minPacketSizeItem, + self.maxPacketSizeItem, + expandInputs=False, + ) + return [ GuiHy2ItemObfsType(title='obfs-type', translatable=False), GuiHy2ItemObfsPassword( @@ -723,44 +673,9 @@ class GuiHy2PageObfsGecko(GuiHy2PageObfsXXX): obfsType='gecko', translatable=False, ), - self.minPacketSizeItem, - self.maxPacketSizeItem, + self.packetSizeRow, ] - def setupLayout(self): - """Align packet sizes with the full-width obfuscation fields.""" - layout = QGridLayout() - layout.setColumnStretch(1, 1) - layout.setColumnStretch(3, 1) - layout.setHorizontalSpacing(18) - - def addFullRow(index: int, row: int): - label, inputWidget = self._containers[index].widgets() - - layout.addWidget(label, row, 0) - layout.addWidget(inputWidget, row, 1, 1, 3) - - def addPair(index: int, column: int): - label, inputWidget = self._containers[index].widgets() - - layout.addWidget(label, 2, column) - layout.addWidget(inputWidget, 2, column + 1) - - packetSizeLabelWidth = max( - self.minPacketSizeItem._title.sizeHint().width(), - self.maxPacketSizeItem._title.sizeHint().width(), - ) - - layout.setColumnMinimumWidth(0, packetSizeLabelWidth) - layout.setColumnMinimumWidth(2, packetSizeLabelWidth) - - addFullRow(0, 0) - addFullRow(1, 1) - addPair(2, 0) - addPair(3, 2) - - self.setLayout(layout) - def handleMinPacketSizeChanged(self, value: int): """Handle min packet size changed.""" self.maxPacketSizeItem._input.setMinimum(value) @@ -808,7 +723,7 @@ class GuiHy2GroupBoxBasic(GuiEditorWidgetQGroupBox): GuiEditorItemBasicRemark(title=_('Remark')), GuiHy2ItemBasicServer(title=_('Server')), GuiHy2ItemBasicAuth(title='auth', translatable=False), - GuiHy2ColumnBindings( + GuiHy2InlineBindings( GuiHy2ItemBasicCongestionComboBox( title='congestion-type', key='type', @@ -819,6 +734,7 @@ class GuiHy2GroupBoxBasic(GuiEditorWidgetQGroupBox): key='bbrProfile', translatable=False, ), + expandInputs=False, ), ] @@ -1056,15 +972,16 @@ class GuiHy2GroupBoxAdvanced(GuiEditorWidgetQGroupBox): ), ) - self.toggleRow = GuiHy2ColumnBindings( + self.optionsRow = GuiHy2InlineBindings( + self.ipModeItem, self.chromeParrotItem, self.mimicEnabledItem, + expandInputs=False, ) return [ self.obfsItem, - self.ipModeItem, - self.toggleRow, + self.optionsRow, ] def setupPageLayout(self): diff --git a/Furious/Backends/Xray/TlsEditor.py b/Furious/Backends/Xray/TlsEditor.py index c13f24f..e28ad52 100644 --- a/Furious/Backends/Xray/TlsEditor.py +++ b/Furious/Backends/Xray/TlsEditor.py @@ -44,6 +44,8 @@ class GuiVTLSItemSecurity(GuiEditorItemTextComboBox): def __init__(self, *args, **kwargs): """Initialize the GuiVTLSItemSecurity.""" + kwargs.setdefault('preserveUnknownValues', True) + super().__init__(*args, **kwargs) self.addItems(STREAM_SECURITY) @@ -594,20 +596,6 @@ class GuiVTLSPageTLS(GuiVTLSPageXXX): basicLayout.setColumnStretch(1, 1) basicLayout.setColumnStretch(3, 1) - def addPair(index: int, row: int, column: int): - """Add pair.""" - label, inputWidget = self._containers[index].widgets() - - basicLayout.addWidget(label, row, column) - basicLayout.addWidget(inputWidget, row, column + 1) - - def addBasicFullRow(index: int, row: int): - """Add basic full row.""" - label, inputWidget = self._containers[index].widgets() - - basicLayout.addWidget(label, row, 0) - basicLayout.addWidget(inputWidget, row, 1, 1, 3) - def addFullRow(index: int): """Add full row.""" widgets = self._containers[index].widgets() @@ -619,11 +607,11 @@ class GuiVTLSPageTLS(GuiVTLSPageXXX): layout.addRow(label, inputWidget) - addPair(0, 0, 0) - addPair(2, 0, 2) - addPair(1, 1, 0) - addPair(3, 1, 2) - addBasicFullRow(4, 2) + addEditorGridBinding(basicLayout, self._containers[0], 0, 0) + addEditorGridBinding(basicLayout, self._containers[2], 0, 2) + addEditorGridBinding(basicLayout, self._containers[1], 1, 0) + addEditorGridBinding(basicLayout, self._containers[3], 1, 2) + addEditorGridFullRow(basicLayout, self._containers[4], 2) layout.addRow(basicLayout) @@ -673,27 +661,13 @@ class GuiVTLSPageReality(GuiVTLSPageXXX): layout.setColumnStretch(3, 1) layout.setHorizontalSpacing(12) - def addPair(index: int, row: int, column: int): - """Add pair.""" - label, inputWidget = self._containers[index].widgets() - - layout.addWidget(label, row, column) - layout.addWidget(inputWidget, row, column + 1) - - def addFullRow(index: int, row: int): - """Add full row.""" - label, inputWidget = self._containers[index].widgets() - - layout.addWidget(label, row, 0) - layout.addWidget(inputWidget, row, 1, 1, 3) - - addPair(0, 0, 0) - addPair(2, 0, 2) - addPair(1, 1, 0) - addPair(4, 1, 2) - addFullRow(3, 2) - addFullRow(5, 3) - addFullRow(6, 4) + addEditorGridBinding(layout, self._containers[0], 0, 0) + addEditorGridBinding(layout, self._containers[2], 0, 2) + addEditorGridBinding(layout, self._containers[1], 1, 0) + addEditorGridBinding(layout, self._containers[4], 1, 2) + addEditorGridFullRow(layout, self._containers[3], 2) + addEditorGridFullRow(layout, self._containers[5], 3) + addEditorGridFullRow(layout, self._containers[6], 4) self.setLayout(layout) diff --git a/Furious/Backends/Xray/TransportEditor.py b/Furious/Backends/Xray/TransportEditor.py index 48abb70..0d59852 100644 --- a/Furious/Backends/Xray/TransportEditor.py +++ b/Furious/Backends/Xray/TransportEditor.py @@ -51,6 +51,8 @@ class GuiVTransportItemNetwork(GuiEditorItemTextComboBox): def __init__(self, *args, **kwargs): """Initialize the GuiVTransportItemNetwork.""" + kwargs.setdefault('preserveUnknownValues', True) + super().__init__(*args, **kwargs) self.addItems(STREAM_NETWORK) @@ -1758,6 +1760,21 @@ class GuiVTransportPageXHttp(GuiVTransportPageXXX): GuiVTransportItemExtraXHttp(title='Extra', translatable=False), ] + def setupLayout(self): + """Place Host and Path together while preserving the remaining rows.""" + layout = QGridLayout() + layout.setColumnStretch(1, 1) + layout.setColumnStretch(3, 2) + + addEditorGridBinding(layout, self._containers[0], 0, 0) + addEditorGridFullRow(layout, self._containers[1], 1) + addEditorGridBinding(layout, self._containers[2], 2, 0) + addEditorGridBinding(layout, self._containers[3], 2, 2) + addEditorGridBinding(layout, self._containers[4], 3, 0) + addEditorGridFullRow(layout, self._containers[5], 4) + + self.setLayout(layout) + class GuiVTransportPageH2(GuiVTransportPageXXX): """Represent GUI v transport page h2.""" diff --git a/Furious/Backends/Xray/VlessEditor.py b/Furious/Backends/Xray/VlessEditor.py index d30b83f..d4ddf4a 100644 --- a/Furious/Backends/Xray/VlessEditor.py +++ b/Furious/Backends/Xray/VlessEditor.py @@ -238,6 +238,8 @@ class GuiVLESSItemBasicFlow(GuiEditorItemTextComboBox): def __init__(self, *args, **kwargs): """Initialize the GuiVLESSItemBasicFlow.""" + kwargs.setdefault('preserveUnknownValues', True) + super().__init__(*args, **kwargs) self.addItems( @@ -301,32 +303,16 @@ class GuiVLESSGroupBoxBasic(GuiEditorWidgetQGroupBox): widget.sizePolicy().verticalPolicy(), ) - def addPair(index: int, row: int, column: int): - """Add pair.""" - label, inputWidget = self._containers[index].widgets() + keepSpinBoxCompact(self._containers[2].widgets()[1]) - keepSpinBoxCompact(inputWidget) + addEditorGridFullRow(layout, self._containers[0], 0) + addEditorGridBinding(layout, self._containers[1], 1, 0) + addEditorGridBinding(layout, self._containers[2], 1, 2) + addEditorGridFullRow(layout, self._containers[3], 2) + addEditorGridBinding(layout, self._containers[4], 3, 0) + addEditorGridBinding(layout, self._containers[5], 3, 2) - layout.addWidget(label, row, column) - layout.addWidget(inputWidget, row, column + 1) - - def addFullRow(index: int, row: int): - """Add full row.""" - label, inputWidget = self._containers[index].widgets() - - keepSpinBoxCompact(inputWidget) - - layout.addWidget(label, row, 0) - layout.addWidget(inputWidget, row, 1, 1, 3) - - addFullRow(0, 0) - addFullRow(1, 1) - addFullRow(2, 2) - addFullRow(3, 3) - addPair(4, 4, 0) - addPair(5, 4, 2) - - layout.setRowStretch(5, 1) + layout.setRowStretch(4, 1) return layout diff --git a/Furious/Backends/Xray/VmessEditor.py b/Furious/Backends/Xray/VmessEditor.py index 46ece2d..1a1a982 100644 --- a/Furious/Backends/Xray/VmessEditor.py +++ b/Furious/Backends/Xray/VmessEditor.py @@ -237,6 +237,8 @@ class GuiVMessItemBasicSecurity(GuiEditorItemTextComboBox): def __init__(self, *args, **kwargs): """Initialize the GuiVMessItemBasicSecurity.""" + kwargs.setdefault('preserveUnknownValues', True) + super().__init__(*args, **kwargs) self.addItems( @@ -291,7 +293,8 @@ class GuiVMessGroupBoxBasic(GuiEditorWidgetQGroupBox): def setupPageLayout(self): """Set up page layout.""" layout = QGridLayout() - layout.setColumnStretch(4, 1) + layout.setColumnStretch(1, 1) + layout.setColumnStretch(3, 1) def keepSpinBoxCompact(widget: QWidget): """Handle keep spin box compact for the GUI v mess group box basic.""" @@ -309,33 +312,18 @@ class GuiVMessGroupBoxBasic(GuiEditorWidgetQGroupBox): widget.sizePolicy().verticalPolicy(), ) - def addPair(index: int, row: int, column: int): - """Add pair.""" - label, inputWidget = self._containers[index].widgets() - - keepSpinBoxCompact(inputWidget) - - layout.addWidget(label, row, column) - layout.addWidget(inputWidget, row, column + 1) - - def addFullRow(index: int, row: int): - """Add full row.""" - label, inputWidget = self._containers[index].widgets() - - keepSpinBoxCompact(inputWidget) - - layout.addWidget(label, row, 0) - layout.addWidget(inputWidget, row, 1, 1, 4) - - addFullRow(0, 0) - addFullRow(1, 1) - addPair(2, 2, 0) - addPair(4, 2, 2) - addFullRow(3, 3) + keepSpinBoxCompact(self._containers[2].widgets()[1]) + keepSpinBoxCompact(self._containers[4].widgets()[1]) keepComboBoxCompact(self._containers[5].widgets()[1]) - addFullRow(5, 4) - layout.setRowStretch(5, 1) + addEditorGridFullRow(layout, self._containers[0], 0) + addEditorGridBinding(layout, self._containers[1], 1, 0) + addEditorGridBinding(layout, self._containers[2], 1, 2) + addEditorGridFullRow(layout, self._containers[3], 2) + addEditorGridBinding(layout, self._containers[5], 3, 0) + addEditorGridBinding(layout, self._containers[4], 3, 2) + + layout.setRowStretch(4, 1) return layout diff --git a/Furious/Qt/EditorWidgets.py b/Furious/Qt/EditorWidgets.py index f87c95a..8517394 100644 --- a/Furious/Qt/EditorWidgets.py +++ b/Furious/Qt/EditorWidgets.py @@ -31,6 +31,8 @@ from PySide6.QtWidgets import * from typing import Callable, Sequence __all__ = [ + 'addEditorGridBinding', + 'addEditorGridFullRow', 'GuiEditorItemTextInput', 'GuiEditorItemTextSpinBox', 'GuiEditorItemTextComboBox', @@ -44,6 +46,31 @@ __all__ = [ ] +def addEditorGridBinding( + layout: QGridLayout, + binding: EditorWidgetBinding, + row: int, + column: int, +): + """Add one labeled editor binding to a two-field grid row.""" + label, inputWidget = binding.widgets() + + layout.addWidget(label, row, column) + layout.addWidget(inputWidget, row, column + 1) + + +def addEditorGridFullRow( + layout: QGridLayout, + binding: EditorWidgetBinding, + row: int, +): + """Add one labeled editor binding across both fields of a grid row.""" + label, inputWidget = binding.widgets() + + layout.addWidget(label, row, 0) + layout.addWidget(inputWidget, row, 1, 1, 3) + + class GuiEditorItemTextInput(EditorWidgetBinding): """Represent GUI editor item text input.""" @@ -113,6 +140,8 @@ class GuiEditorItemTextComboBox(EditorWidgetBinding): def __init__(self, *args, **kwargs): """Initialize the GuiEditorItemTextComboBox.""" + self.preserveUnknownValues = kwargs.pop('preserveUnknownValues', False) + title = kwargs.pop('title', '') translatable = kwargs.pop('translatable', True) parent = kwargs.pop('parent', None) @@ -127,11 +156,26 @@ class GuiEditorItemTextComboBox(EditorWidgetBinding): def text(self) -> str: """Return the text value.""" + if self.preserveUnknownValues and self._input.currentIndex() < 0: + return self._input.placeholderText() + return self._input.currentText() def setText(self, text: str): """Set text.""" - self._input.setCurrentText(text) + if not self.preserveUnknownValues: + self._input.setCurrentText(text) + + return + + index = self._input.findText(text) + + if index >= 0: + self._input.setPlaceholderText('') + self._input.setCurrentIndex(index) + else: + self._input.setPlaceholderText(text) + self._input.setCurrentIndex(-1) def addItems(self, texts: Sequence[str]): """Add items.""" diff --git a/Furious/Qt/__init__.py b/Furious/Qt/__init__.py index 0fdbde4..5dbe418 100644 --- a/Furious/Qt/__init__.py +++ b/Furious/Qt/__init__.py @@ -28,6 +28,8 @@ from .DynamicTranslate import ( gettext, ) from .EditorWidgets import ( + addEditorGridBinding, + addEditorGridFullRow, GuiEditorItemBasicRemark, GuiEditorItemProxyHttp, GuiEditorItemProxySocks, @@ -99,6 +101,8 @@ from .TextEditorTheme import ( from .HttpGetManager import HttpGetManager __all__ = [ + 'addEditorGridBinding', + 'addEditorGridFullRow', 'ABBR_TO_LANGUAGE', 'AppHue', 'AppQAction', diff --git a/tests/test_hysteria2_compatibility.py b/tests/test_hysteria2_compatibility.py index 41091c8..87c6a1d 100644 --- a/tests/test_hysteria2_compatibility.py +++ b/tests/test_hysteria2_compatibility.py @@ -216,8 +216,8 @@ class Hysteria2CompatibilityTest(unittest.TestCase): ('bandwidth.up', 'bandwidth.down'), ) self.assertEqual( - tuple(binding._title.text() for binding in advanced.toggleRow.bindings), - ('quic.disableChromeParrot', 'mimic.enabled'), + tuple(binding._title.text() for binding in advanced.optionsRow.bindings), + ('ipMode', 'quic.disableChromeParrot', 'mimic.enabled'), ) self.assertEqual( proxyBandwidth.bandwidthFields._widget.layout().contentsMargins().top(), @@ -248,73 +248,27 @@ class Hysteria2CompatibilityTest(unittest.TestCase): geckoPage = advanced.obfsItem.page(HY2_OBFS_TYPES.index('gecko')) advanced.obfsItem.setCurrentIndex(HY2_OBFS_TYPES.index('gecko')) application().processEvents() - obfsInputPositions = tuple( - binding._input.mapTo(advanced, binding._input.rect().topLeft()).x() - for binding in geckoPage._containers[:2] - ) - packetSizePositions = tuple( - binding._input.mapTo(advanced, binding._input.rect().topLeft()) - for binding in ( + self.assertEqual( + geckoPage.packetSizeRow.bindings, + ( geckoPage.minPacketSizeItem, geckoPage.maxPacketSizeItem, - ) - ) - alignedLabelPositions = tuple( - label.mapTo(advanced, label.rect().topLeft()).x() - for label in ( - geckoPage._containers[0]._title, - advanced.ipModeItem._title, - advanced.chromeParrotItem._title, - ) - ) - togglePositions = tuple( - binding._input.mapTo(advanced, binding._input.rect().topLeft()) - for binding in advanced.toggleRow.bindings + ), ) + self.assertIs(geckoPage._containers[-1], geckoPage.packetSizeRow) + self.assertIs(advanced._containers[-1], advanced.optionsRow) + self.assertEqual(len(advanced._containers), 2) - self.assertEqual(len(set(obfsInputPositions)), 1) - self.assertEqual(packetSizePositions[0].y(), packetSizePositions[1].y()) - self.assertEqual(obfsInputPositions[1], packetSizePositions[0].x()) - packetPairWidths = tuple( - binding._input.mapTo( - geckoPage, - binding._input.rect().topRight(), - ).x() - - binding._title.mapTo( - geckoPage, - binding._title.rect().topLeft(), - ).x() - + 1 - for binding in ( - geckoPage.minPacketSizeItem, - geckoPage.maxPacketSizeItem, + for row in ( + basic._containers[-1], + geckoPage.packetSizeRow, + advanced.optionsRow, + ): + rowLayout = row._widget.layout() + + self.assertTrue( + all(rowLayout.stretch(index) == 0 for index in range(rowLayout.count())) ) - ) - self.assertLessEqual(abs(packetPairWidths[0] - packetPairWidths[1]), 1) - congestionColumns = tuple( - basic._containers[-1]._widget.layout().itemAt(index).widget().width() - for index in range(2) - ) - self.assertLessEqual(abs(congestionColumns[0] - congestionColumns[1]), 1) - self.assertEqual(len(set(alignedLabelPositions)), 1) - ipModeInputPosition = advanced.ipModeItem._input.mapTo( - advanced, - advanced.ipModeItem._input.rect().topLeft(), - ).x() - self.assertNotEqual(obfsInputPositions[0], ipModeInputPosition) - self.assertEqual(togglePositions[0].y(), togglePositions[1].y()) - packetSizeBottom = geckoPage.maxPacketSizeItem._input.mapTo( - advanced, - geckoPage.maxPacketSizeItem._input.rect().bottomLeft(), - ).y() - ipModeTop = advanced.ipModeItem._input.mapTo( - advanced, - advanced.ipModeItem._input.rect().topLeft(), - ).y() - self.assertLessEqual( - ipModeTop - packetSizeBottom, - advanced.fontMetrics().height(), - ) editor.close() diff --git a/tests/test_qt_lifetime.py b/tests/test_qt_lifetime.py index 220d792..50e36b6 100644 --- a/tests/test_qt_lifetime.py +++ b/tests/test_qt_lifetime.py @@ -291,12 +291,12 @@ class QtLifetimeTest(unittest.TestCase): factories = ( ('external-core', ExternalCoreEditor, 35), ('socks-protocol', SocksEditor, 35), - ('vless-protocol', VlessEditor, 12), - ('vmess-protocol', VmessEditor, 12), + ('vless-protocol', VlessEditor, 25), + ('vmess-protocol', VmessEditor, 25), ('trojan-protocol', TrojanEditor, 12), ('shadowsocks-protocol', ShadowsocksEditor, 12), ('hysteria1-protocol', Hysteria1Editor, 12), - ('hysteria2-protocol', Hysteria2Editor, 12), + ('hysteria2-protocol', Hysteria2Editor, 25), ('xray-tun-settings', XrayTunSettingsDialog, 25), ('hysteria2-tun-settings', Hysteria2TunSettingsDialog, 25), ( diff --git a/tests/test_ui_behavior.py b/tests/test_ui_behavior.py index b4ab4cd..4d5c874 100644 --- a/tests/test_ui_behavior.py +++ b/tests/test_ui_behavior.py @@ -19,6 +19,10 @@ from __future__ import annotations +from Furious.Backends.Configuration import ( + ConfigXray, + configXrayEmptyProxyOutboundObject, +) from Furious.Backends.ExternalCore.Configuration import ( BLANK_CONFIG_EXTERNAL_CORE, ConfigExternalCore, @@ -31,8 +35,19 @@ from Furious.Backends.Xray.RoutingWindow import RoutingRulesDialog from Furious.Backends.Xray.ShadowsocksEditor import ShadowsocksEditor from Furious.Backends.Xray.SocksEditor import SocksEditor from Furious.Backends.Xray.TrojanEditor import TrojanEditor -from Furious.Backends.Xray.VlessEditor import VlessEditor -from Furious.Backends.Xray.VmessEditor import VmessEditor +from Furious.Backends.Xray.TransportEditor import ( + GuiVTransportPageXHttp, + GuiVTransportQGroupBox, + STREAM_NETWORK, +) +from Furious.Backends.Xray.VlessEditor import ( + GuiVLESSGroupBoxBasic, + VlessEditor, +) +from Furious.Backends.Xray.VmessEditor import ( + GuiVMessGroupBoxBasic, + VmessEditor, +) from Furious.Actions.Connection import ConnectAction from Furious.Controllers.ConnectionController import ( ConnectionError, @@ -44,9 +59,11 @@ from Furious.Controllers.SettingsController import ( LOG_AUTO_SCROLL_DOWN_SETTING, ) from Furious.Frozenlib import APPLICATION_NAME, AppSettings, Mixins -from Furious.Models import ProfileMetadata, ServerProfile +from Furious.Models import ProfileMetadata, Protocol, ServerProfile +from Furious.Plugins.API import RoutingOption from Furious.Qt import ( AppHue, + AppQComboBox, AppQMessageBox, AppQSwitch, AppStyleSheet, @@ -61,9 +78,11 @@ from Furious.Service import ( from Furious.Window.LogPage import LogPage from Furious.Window.SubscriptionPage import _SubscriptionEditorDialog from Furious.Widget.ConnectionButton import ConnectionButton +from Furious.Widget.RoutingSelector import RoutingSelector from PySide6 import QtCore from PySide6.QtTest import QTest +from PySide6.QtWidgets import QHBoxLayout, QWidget from tests.support import ( application, @@ -94,6 +113,168 @@ class EditorMappingTest(unittest.TestCase): """Finish every deferred transient deletion between tests.""" collectAtBoundary() + @staticmethod + def xrayProfile(protocol: Protocol, user: dict) -> ServerProfile: + """Return one minimal Xray profile for a basic-editor round trip.""" + outbound = configXrayEmptyProxyOutboundObject(protocol) + server = outbound['settings']['vnext'][0] + + server['address'] = 'server.example.com' + server['port'] = 443 + server['users'][0].update(user) + + return ServerProfile.fromConfiguration( + ConfigXray({'outbounds': [outbound]}), + ProfileMetadata(displayName='Fixture profile'), + ) + + def assertBindingPosition( + self, + layout, + binding, + row: int, + labelColumn: int, + inputColumn: int, + inputColumnSpan: int = 1, + ): + """Assert one binding's logical grid placement without pixel geometry.""" + label, inputWidget = binding.widgets() + + self.assertEqual( + layout.getItemPosition(layout.indexOf(label)), + (row, labelColumn, 1, 1), + ) + self.assertEqual( + layout.getItemPosition(layout.indexOf(inputWidget)), + (row, inputColumn, 1, inputColumnSpan), + ) + + def testVlessBasicUsesTlsGridRowsWithoutChangingMapping(self): + """Pair address/port and encryption/flow around full-width fields.""" + profile = self.xrayProfile( + Protocol.VLESS, + { + 'id': 'fixture-vless-id', + 'encryption': 'none', + 'flow': 'future-vless-flow', + }, + ) + original = copy.deepcopy(profile.connection) + group = GuiVLESSGroupBoxBasic() + layout = group._widget.currentWidget().layout() + + group.factoryToInput(profile) + + self.assertFalse(group.inputToFactory(profile)) + self.assertEqual(profile.connection, original) + self.assertBindingPosition(layout, group._containers[0], 0, 0, 1, 3) + self.assertBindingPosition(layout, group._containers[1], 1, 0, 1) + self.assertBindingPosition(layout, group._containers[2], 1, 2, 3) + self.assertBindingPosition(layout, group._containers[3], 2, 0, 1, 3) + self.assertBindingPosition(layout, group._containers[4], 3, 0, 1) + self.assertBindingPosition(layout, group._containers[5], 3, 2, 3) + self.assertEqual(group._containers[4].text(), 'none') + self.assertEqual(group._containers[5].text(), 'future-vless-flow') + + group.deleteLater() + + def testVmessBasicUsesTlsGridRowsWithoutChangingMapping(self): + """Pair address/port and security/alterId around the UUID row.""" + profile = self.xrayProfile( + Protocol.VMess, + { + 'id': 'fixture-vmess-id', + 'security': 'future-vmess-security', + 'alterId': 17, + }, + ) + original = copy.deepcopy(profile.connection) + group = GuiVMessGroupBoxBasic() + layout = group._widget.currentWidget().layout() + + group.factoryToInput(profile) + + self.assertFalse(group.inputToFactory(profile)) + self.assertEqual(profile.connection, original) + self.assertBindingPosition(layout, group._containers[0], 0, 0, 1, 3) + self.assertBindingPosition(layout, group._containers[1], 1, 0, 1) + self.assertBindingPosition(layout, group._containers[2], 1, 2, 3) + self.assertBindingPosition(layout, group._containers[3], 2, 0, 1, 3) + self.assertBindingPosition(layout, group._containers[5], 3, 0, 1) + self.assertBindingPosition(layout, group._containers[4], 3, 2, 3) + self.assertEqual(group._containers[5].text(), 'future-vmess-security') + self.assertEqual(group._containers[4].value(), 17) + + group.deleteLater() + + def testXHttpHostAndPathShareOneRowWithoutChangingMapping(self): + """Pair xhttp Host/Path while preserving switching and configuration.""" + profile = self.xrayProfile( + Protocol.VLESS, + { + 'id': 'fixture-xhttp-id', + 'encryption': 'none', + 'flow': '', + }, + ) + streamSettings = ConfigXray.getProxyOutboundStream(profile.connection) + streamSettings.update( + { + 'network': 'xhttp', + 'xhttpSettings': { + 'host': 'cdn.example.com', + 'path': '/a/representative/xhttp/path', + 'mode': 'auto', + 'extra': {'noGRPCHeader': True}, + }, + } + ) + original = copy.deepcopy(profile.connection) + group = GuiVTransportQGroupBox() + + group.factoryToInput(profile.connection) + + xhttpIndex = STREAM_NETWORK.index('xhttp') + wsIndex = STREAM_NETWORK.index('ws') + page = group.page(xhttpIndex) + self.assertIsInstance(page, GuiVTransportPageXHttp) + layout = page.layout() + host = page._containers[2] + path = page._containers[3] + hostInput = host.widgets()[1] + pathInput = path.widgets()[1] + + self.assertBindingPosition(layout, page._containers[0], 0, 0, 1) + self.assertBindingPosition(layout, page._containers[1], 1, 0, 1, 3) + self.assertBindingPosition(layout, page._containers[4], 3, 0, 1) + self.assertBindingPosition(layout, page._containers[5], 4, 0, 1, 3) + self.assertBindingPosition(layout, host, 2, 0, 1) + self.assertBindingPosition(layout, path, 2, 2, 3) + self.assertEqual(host.text(), 'cdn.example.com') + self.assertEqual(path.text(), '/a/representative/xhttp/path') + self.assertEqual(profile.connection, original) + + group.handleActivated(wsIndex) + group.handleActivated(xhttpIndex) + + self.assertIs(group.page(xhttpIndex), page) + self.assertIs(host.widgets()[1], hostInput) + self.assertIs(path.widgets()[1], pathInput) + self.assertFalse(group.inputToFactory(profile.connection)) + self.assertEqual(profile.connection, original) + + host.setText('edge.example.net') + path.setText('/updated/path') + + self.assertTrue(group.inputToFactory(profile.connection)) + self.assertEqual( + streamSettings['xhttpSettings']['host'], + 'edge.example.net', + ) + self.assertEqual(streamSettings['xhttpSettings']['path'], '/updated/path') + + group.deleteLater() + def testExternalCoreEditorRoundTripsStructuredFields(self): """Keep arguments, environment, process paths, and TUN data distinct.""" configuration = ConfigExternalCore(copy.deepcopy(BLANK_CONFIG_EXTERNAL_CORE))