Add sing-tun settings UI

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-10-06 00:37:54 +08:00
parent 0dc11b6755
commit 251ec3cec6
11 changed files with 2534 additions and 40 deletions
+2 -2
View File
@@ -309,7 +309,7 @@ class ExternalCoreApplicationTun2socksInput(GuiEditorItemTextSwitch):
def __init__(self):
"""Initialize the application tun2socks opt-in control."""
super().__init__(title=_('Use Application Tun2socks'))
super().__init__(title=_('Use Application TUN'))
def connectToggled(self, receiver, methodName: str):
"""Connect a field-state callback without retaining its binding."""
@@ -536,7 +536,7 @@ class ExternalCoreEditor(GuiEditorWidgetQDialog):
and not self._tunRemoteAddressInput.text()
):
validationMessage = _(
'TUN remote address is required when application tun2socks is enabled'
'TUN remote address is required when application TUN is enabled'
)
if validationMessage:
+285 -32
View File
@@ -1425,6 +1425,7 @@ TRANSLATION = {
},
"Primary Adapter Interface Name": {
"source": [
"Furious.Window.SingTUNSettingsDialog",
"Furious.Window.TunSettingsDialog"
],
"RU": "Имя основного сетевого адаптера",
@@ -1433,6 +1434,7 @@ TRANSLATION = {
},
"Primary Adapter Interface IP": {
"source": [
"Furious.Window.SingTUNSettingsDialog",
"Furious.Window.TunSettingsDialog"
],
"RU": "IP-адрес основного сетевого адаптера",
@@ -1447,24 +1449,9 @@ TRANSLATION = {
"ZH": "默认主网关IP",
"isReviewed": "True"
},
"Tun2socks Adapter Interface DNS": {
"source": [
"Furious.Window.TunSettingsDialog"
],
"RU": "DNS-адрес интерфейса адаптера Tun2socks",
"ZH": "Tun2socks适配器接口DNS",
"isReviewed": "True"
},
"Bypass Tun2socks Adapter Interface IP (separated by commas)": {
"source": [
"Furious.Window.TunSettingsDialog"
],
"RU": "Обход IP-адреса интерфейса адаптера Tun2socks (через запятую)",
"ZH": "绕过Tun2socks适配器接口IP(以逗号分隔)",
"isReviewed": "True"
},
"Disable Primary Adapter Interface DNS (Mitigating DNS leaks on Windows)": {
"source": [
"Furious.Window.SingTUNSettingsDialog",
"Furious.Window.TunSettingsDialog"
],
"RU": "Отключить DNS основного сетевого адаптера (для предотвращения утечек DNS в Windows)",
@@ -2760,14 +2747,6 @@ TRANSLATION = {
"ZH": "SOCKS代理",
"isReviewed": "True"
},
"Use Application Tun2socks": {
"source": [
"Furious.Backends.ExternalCore.Editor"
],
"RU": "Использовать Tun2socks приложения",
"ZH": "使用应用程序的Tun2socks",
"isReviewed": "True"
},
"TUN Remote Address": {
"source": [
"Furious.Backends.ExternalCore.Editor"
@@ -2936,14 +2915,6 @@ TRANSLATION = {
"ZH": "代理出口信息与隐私",
"isReviewed": "True"
},
"TUN remote address is required when application tun2socks is enabled": {
"source": [
"Furious.Backends.ExternalCore.Editor"
],
"RU": "При включённом Tun2socks приложения необходимо указать удалённый адрес TUN",
"ZH": "启用应用程序的Tun2socks时必须填写TUN远程地址",
"isReviewed": "True"
},
"Data usage": {
"source": [
"Furious.Window.SettingsPage"
@@ -3375,5 +3346,287 @@ TRANSLATION = {
"RU": "Тесты",
"ZH": "测试",
"isReviewed": "True"
},
"Application TUN Engine": {
"source": [
"Furious.Window.SettingsPage"
],
"RU": "Движок TUN приложения",
"ZH": "应用 TUN 引擎",
"isReviewed": "True"
},
"Native proxy-core TUN takes priority. This engine is used when Furious provides application TUN. Changes apply to the next connection.": {
"source": [
"Furious.Window.SettingsPage"
],
"RU": "Встроенный TUN прокси-ядра имеет приоритет. Этот движок используется, когда TUN предоставляет Furious. Изменения применяются при следующем подключении.",
"ZH": "代理内核的原生 TUN 优先。由 Furious 提供应用 TUN 时才使用此引擎。更改将在下次连接时生效。",
"isReviewed": "True"
},
"Customize sing-tun Settings...": {
"source": [
"Furious.Window.SettingsPage"
],
"RU": "Настроить sing-tun...",
"ZH": "自定义 sing-tun 设置...",
"isReviewed": "True"
},
"Customize sing-tun Settings": {
"source": [
"Furious.Window.SingTUNSettingsDialog"
],
"RU": "Настройка sing-tun",
"ZH": "自定义 sing-tun 设置",
"isReviewed": "True"
},
"SOCKS Transit": {
"source": [
"Furious.Window.SingTUNSettingsDialog"
],
"RU": "Передача через SOCKS",
"ZH": "SOCKS 转发",
"isReviewed": "True"
},
"Stack": {
"source": [
"Furious.Window.SingTUNSettingsDialog"
],
"RU": "Сетевой стек",
"ZH": "网络协议栈",
"isReviewed": "True"
},
"Log Level": {
"source": [
"Furious.Window.SingTUNSettingsDialog"
],
"RU": "Уровень журналирования",
"ZH": "日志级别",
"isReviewed": "True"
},
"SOCKS Interface Binding (empty for automatic)": {
"source": [
"Furious.Window.SingTUNSettingsDialog"
],
"RU": "Интерфейс SOCKS (пусто — автоматически)",
"ZH": "SOCKS 绑定接口(留空自动选择)",
"isReviewed": "True"
},
"Maximum Sessions": {
"source": [
"Furious.Window.SingTUNSettingsDialog"
],
"RU": "Максимум сеансов",
"ZH": "最大会话数",
"isReviewed": "True"
},
"Interface and Stack": {
"source": [
"Furious.Window.SingTUNSettingsDialog"
],
"RU": "Интерфейс и сетевой стек",
"ZH": "接口与协议栈",
"isReviewed": "True"
},
"Device Name (empty for automatic)": {
"source": [
"Furious.Window.SingTUNSettingsDialog"
],
"RU": "Имя устройства (пусто — автоматически)",
"ZH": "设备名称(留空自动选择)",
"isReviewed": "True"
},
"MTU (bytes)": {
"source": [
"Furious.Window.SingTUNSettingsDialog"
],
"RU": "MTU (байты)",
"ZH": "MTU(字节)",
"isReviewed": "True"
},
"IPv4 Address Prefixes (comma separated)": {
"source": [
"Furious.Window.SingTUNSettingsDialog"
],
"RU": "Префиксы IPv4 (через запятую)",
"ZH": "IPv4 地址前缀(以逗号分隔)",
"isReviewed": "True"
},
"IPv6 Address Prefixes (comma separated)": {
"source": [
"Furious.Window.SingTUNSettingsDialog"
],
"RU": "Префиксы IPv6 (через запятую)",
"ZH": "IPv6 地址前缀(以逗号分隔)",
"isReviewed": "True"
},
"Native UDP Timeout (Go duration, e.g. 1m)": {
"source": [
"Furious.Window.SingTUNSettingsDialog"
],
"RU": "Тайм-аут UDP стека (длительность Go, например 1m)",
"ZH": "原生 UDP 超时(Go 时长,例如 1m)",
"isReviewed": "True"
},
"Native ICMP Timeout (Go duration, 0 for default)": {
"source": [
"Furious.Window.SingTUNSettingsDialog"
],
"RU": "Тайм-аут ICMP стека (длительность Go, 0 — по умолчанию)",
"ZH": "原生 ICMP 超时(Go 时长,0 使用默认值)",
"isReviewed": "True"
},
"Native UDP NAT Limit (0 for default)": {
"source": [
"Furious.Window.SingTUNSettingsDialog"
],
"RU": "Лимит UDP NAT стека (0 — по умолчанию)",
"ZH": "原生 UDP NAT 上限(0 使用默认值)",
"isReviewed": "True"
},
"Advanced Options": {
"source": [
"Furious.Window.SingTUNSettingsDialog"
],
"RU": "Дополнительные параметры",
"ZH": "高级选项",
"isReviewed": "True"
},
"Use exact native JSON field names. Supported: route address/exclusion lists, StrictRoute, InterfaceScope, ForwarderBindInterface and IncludeAllNetworks. AutoRoute, external configuration and DNS ownership are managed. Unsupported fields are rejected.": {
"source": [
"Furious.Window.SingTUNSettingsDialog"
],
"RU": "Используйте точные имена полей JSON. Поддерживаются списки маршрутов и исключений, StrictRoute, InterfaceScope, ForwarderBindInterface и IncludeAllNetworks. AutoRoute, внешняя настройка и управление DNS контролируются приложением. Неподдерживаемые поля отклоняются.",
"ZH": "请使用准确的原生 JSON 字段名。支持路由地址及排除列表、StrictRoute、InterfaceScope、ForwarderBindInterface 和 IncludeAllNetworks。AutoRoute、外部配置模式和 DNS 归属由应用管理。不支持的字段会被拒绝。",
"isReviewed": "True"
},
"Connect Timeout (seconds)": {
"source": [
"Furious.Window.SingTUNSettingsDialog"
],
"RU": "Тайм-аут подключения (секунды)",
"ZH": "连接超时(秒)",
"isReviewed": "True"
},
"SOCKS UDP Timeout (seconds)": {
"source": [
"Furious.Window.SingTUNSettingsDialog"
],
"RU": "Тайм-аут UDP SOCKS (секунды)",
"ZH": "SOCKS UDP 超时(秒)",
"isReviewed": "True"
},
"TCP Idle Timeout (seconds)": {
"source": [
"Furious.Window.SingTUNSettingsDialog"
],
"RU": "Тайм-аут простоя TCP (секунды)",
"ZH": "TCP 空闲超时(秒)",
"isReviewed": "True"
},
"UDP Mapping": {
"source": [
"Furious.Window.SingTUNSettingsDialog"
],
"RU": "Сопоставление UDP",
"ZH": "UDP 映射方式",
"isReviewed": "True"
},
"UDP Filtering": {
"source": [
"Furious.Window.SingTUNSettingsDialog"
],
"RU": "Фильтрация UDP",
"ZH": "UDP 过滤方式",
"isReviewed": "True"
},
"Automatic": {
"source": [
"Furious.Window.SingTUNSettingsDialog"
],
"RU": "Автоматический выбор",
"ZH": "自动选择",
"isReviewed": "True"
},
"Endpoint Independent": {
"source": [
"Furious.Window.SingTUNSettingsDialog"
],
"RU": "Независимо от конечной точки",
"ZH": "与端点无关",
"isReviewed": "True"
},
"Address Dependent": {
"source": [
"Furious.Window.SingTUNSettingsDialog"
],
"RU": "Зависит от адреса",
"ZH": "依赖地址",
"isReviewed": "True"
},
"Address and Port Dependent": {
"source": [
"Furious.Window.SingTUNSettingsDialog"
],
"RU": "Зависит от адреса и порта",
"ZH": "依赖地址和端口",
"isReviewed": "True"
},
"TUN remote address is required when application TUN is enabled": {
"source": [
"Furious.Backends.ExternalCore.Editor"
],
"RU": "При включённом TUN приложения требуется адрес удалённого сервера",
"ZH": "启用应用程序 TUN 时必须填写 TUN 远程地址",
"isReviewed": "True"
},
"Use Application TUN": {
"source": [
"Furious.Backends.ExternalCore.Editor"
],
"RU": "Использовать TUN приложения",
"ZH": "使用应用程序 TUN",
"isReviewed": "True"
},
"Configure sing-tun interface, stack, SOCKS transit and host settings.": {
"source": [
"Furious.Window.SettingsPage"
],
"RU": "Настройте интерфейс, стек, SOCKS-передачу и параметры хоста sing-tun.",
"ZH": "配置 sing-tun 的接口、协议栈、SOCKS 转发和主机设置。",
"isReviewed": "True"
},
"Host Settings": {
"source": [
"Furious.Window.SingTUNSettingsDialog"
],
"RU": "Настройки хоста",
"ZH": "主机设置",
"isReviewed": "True"
},
"TCP Congestion Control (Go stack)": {
"source": [
"Furious.Window.SingTUNSettingsDialog"
],
"RU": "Управление перегрузкой TCP (стек Go)",
"ZH": "TCP 拥塞控制(Go 协议栈)",
"isReviewed": "True"
},
"TUN Adapter Interface DNS": {
"source": [
"Furious.Window.SingTUNSettingsDialog",
"Furious.Window.TunSettingsDialog"
],
"RU": "DNS-адрес интерфейса адаптера TUN",
"ZH": "TUN适配器接口DNS",
"isReviewed": "True"
},
"Bypass TUN Adapter Interface IP (separated by commas)": {
"source": [
"Furious.Window.SingTUNSettingsDialog",
"Furious.Window.TunSettingsDialog"
],
"RU": "IP-адреса в обход интерфейса TUN (через запятую)",
"ZH": "绕过TUN适配器接口的IP地址(以逗号分隔)",
"isReviewed": "True"
}
}
+5
View File
@@ -49,6 +49,7 @@ from Furious.Window.ProxyBypassDialog import *
from Furious.Window.QRCodeWindow import QRCodeWindow
from Furious.Window.TextEditorWindow import TextEditorWindow
from Furious.Window.TunSettingsDialog import *
from Furious.Window.SingTUNSettingsDialog import SingTUNSettingsDialog
from PySide6 import QtCore
from PySide6.QtGui import *
@@ -1130,6 +1131,10 @@ class HomePage(Mixins.QTranslatable, QMainWindow):
self.updatesManager.configureHttpProxy(Storage.Extras.UserHttpProxy())
self.updatesManager.checkForUpdates(**kwargs)
def getGuiSingTUNSettings(self, **kwargs):
"""Transfer a fresh managed transient editor to the Settings page."""
return SingTUNSettingsDialog(parent=kwargs.pop('parent', self), **kwargs)
def resetNetworkState(self):
"""Reset network state."""
self.networkState.clearStatus()
+1
View File
@@ -94,6 +94,7 @@ class MainWindow(AppQMainWindow):
self.logPage = AppLogPage()
self.settingsPage = SettingsPage(
tunSettingsDialogFactory=self.homePage.getGuiTUNSettings,
singTunSettingsDialogFactory=self.homePage.getGuiSingTUNSettings,
proxyBypassDialog=self.homePage.customizeProxyBypassDialog,
networkTestDialog=self.homePage.customizeNetworkTestDialog,
checkForUpdates=self.homePage.checkForUpdates,
+66 -3
View File
@@ -39,6 +39,7 @@ from Furious.Service.TrafficStatsManager import (
METRICS_COLLECTION_SETTING,
)
from Furious.Window.TunSettingsDialog import tunModeTitle
from Furious.Qt.Signals import connectWeakly
from PySide6 import QtCore
from PySide6.QtWidgets import *
@@ -555,6 +556,44 @@ class _SystemProxySettingsCard(_SettingsCard):
AppSettingsController().setSystemProxyMode(mode)
class _TUNBackendSettingsCard(_SettingsCard):
"""Select an application fallback without changing native-core policy."""
def __init__(self):
self.comboBox = AppQComboBox()
self.comboBox.setObjectName('SettingsComboBox')
self.comboBox.addItem('sing-tun', 'sing-tun')
self.comboBox.addItem('tun2socks', 'tun2socks')
super().__init__(
'diagram-3.svg',
self.comboBox,
_('Application TUN Engine'),
_(
'Native proxy-core TUN takes priority. This engine is used when Furious provides application TUN. Changes apply to the next connection.'
),
)
self.sync()
connectWeakly(self.comboBox.currentIndexChanged, self, '_selectionChanged')
connectWeakly(AppSettingsController().tunBackendChanged, self, 'sync')
def sync(self, backend=None):
blocker = QtCore.QSignalBlocker(self.comboBox)
value = (
backend
if isinstance(backend, str)
else AppSettings.get(APPLICATION_TUN_BACKEND)
)
self.comboBox.setCurrentIndex(max(self.comboBox.findData(value), 0))
del blocker
def _selectionChanged(self, _index):
AppSettingsController().setTUNBackend(self.comboBox.currentData())
class _SettingsSection(QWidget):
"""Group a translated heading and a stack of settings cards."""
@@ -601,6 +640,7 @@ class SettingsPage(Mixins.QTranslatable, QMainWindow):
self,
*,
tunSettingsDialogFactory,
singTunSettingsDialogFactory=None,
proxyBypassDialog,
networkTestDialog,
checkForUpdates,
@@ -613,6 +653,7 @@ class SettingsPage(Mixins.QTranslatable, QMainWindow):
super().__init__(parent)
self.setObjectName('SettingsPage')
self._singTunSettingsDialogFactory = singTunSettingsDialogFactory
(
self._tunSettingsDialogFactory,
@@ -636,6 +677,7 @@ class SettingsPage(Mixins.QTranslatable, QMainWindow):
self.pageTitleLabel.setObjectName('SettingsPageTitle')
self.generalSection = _SettingsSection(_('General'))
self.tunSection = _SettingsSection('TUN', translatable=False)
self.connectionSection = _SettingsSection(_('Connection and Interface'))
self.applicationSection = _SettingsSection(_('Application'))
self.pluginSettingsTitleLabel = AppQLabel(_('Plugin Settings'))
@@ -644,6 +686,14 @@ class SettingsPage(Mixins.QTranslatable, QMainWindow):
self._pluginActions = []
self._tunModeAvailable = AppSettingsController().tunModeAvailable()
self.tunBackendCard = _TUNBackendSettingsCard()
self.singTunSettingsCard = _ActionSettingsCard(
None,
self._openSingTUNSettings,
_('Customize sing-tun Settings...'),
_('Configure sing-tun interface, stack, SOCKS transit and host settings.'),
_('Open'),
)
self.tunModeCard = _ToggleSettingsCard(
'shield-check.svg',
@@ -729,7 +779,7 @@ class SettingsPage(Mixins.QTranslatable, QMainWindow):
self.editorWhitespaceCard,
) = (
_ActionSettingsCard(
'diagram-3.svg',
None,
self._openTUNSettings,
_('Customize Tun2socks Settings...'),
_('Configure the external Tun2socks network interface and routing.'),
@@ -802,8 +852,10 @@ class SettingsPage(Mixins.QTranslatable, QMainWindow):
self.systemProxyCard.sync
)
if not SystemRuntime.flatpakID():
self.connectionSection.addCard(self.tunSettingsCard)
self.tunSection.addCard(self.tunBackendCard)
self.tunSection.addCard(self.singTunSettingsCard)
self.tunSection.addCard(self.tunSettingsCard)
self.tunSection.setVisible(not SystemRuntime.flatpakID())
self.connectionSection.addCard(self.systemProxyCard)
self.connectionSection.addCard(self.proxyBypassCard)
@@ -884,6 +936,7 @@ class SettingsPage(Mixins.QTranslatable, QMainWindow):
contentLayout.setSpacing(22)
contentLayout.addWidget(self.pageTitleLabel)
contentLayout.addWidget(self.generalSection)
contentLayout.addWidget(self.tunSection)
contentLayout.addWidget(self.connectionSection)
if self.pluginSections:
@@ -1124,10 +1177,19 @@ class SettingsPage(Mixins.QTranslatable, QMainWindow):
"""Show the end-user disclosure for the active endpoint providers."""
_endpointPrivacyMessageBox(self).open()
def _openSingTUNSettings(self):
if self._singTunSettingsDialogFactory is not None:
self._singTunSettingsDialogFactory(parent=self).open()
def setConnectionControlsEnabled(self, enabled: bool):
"""Disable connection-sensitive settings during a transition."""
self.tunModeCard.setEnabled(bool(enabled) and self._tunModeAvailable)
self.systemProxyCard.comboBox.setEnabled(bool(enabled))
self.tunBackendCard.setEnabled(bool(enabled) and not SystemRuntime.flatpakID())
self.tunSettingsCard.setEnabled(bool(enabled))
self.singTunSettingsCard.setEnabled(
bool(enabled) and self._singTunSettingsDialogFactory is not None
)
def showEvent(self, event):
"""Synchronize controls in case a legacy action changed a setting."""
@@ -1135,6 +1197,7 @@ class SettingsPage(Mixins.QTranslatable, QMainWindow):
for section in (
self.generalSection,
self.tunSection,
self.connectionSection,
self.applicationSection,
*self.pluginSections,
+424
View File
@@ -0,0 +1,424 @@
# 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/>.
"""A staged, transient editor for sing-tun's native and host configuration."""
from __future__ import annotations
from Furious.Models.SingTUN import (
SING_TUN_DEFAULTS,
SING_TUN_HOST_DEFAULTS,
prepareSingTUNSettings,
)
from Furious.Repository import Storage
from Furious.Frozenlib import PLATFORM, GOLDEN_RATIO, AppFontName
from Furious.Qt import (
AppQTransientDialog,
AppQLabel,
AppQLineEdit,
AppQComboBox,
AppQSpinBox,
AppQSwitch,
AppQTabWidget,
AppQDialogButtonBox,
DraculaJSONTextEditor,
)
from Furious.Qt.Signals import connectWeakly
from Furious.Qt import gettext as _
from PySide6 import QtCore
from PySide6.QtWidgets import (
QFormLayout,
QHBoxLayout,
QVBoxLayout,
QWidget,
QScrollArea,
)
import copy
import json
class SingTUNSettingsDialog(AppQTransientDialog):
"""Never import the native engine or mutate storage while loading widgets."""
FIXED_DIALOG_SIZE = (
QtCore.QSize(int(690 * GOLDEN_RATIO), 690)
if PLATFORM in ('Darwin', 'Linux')
else QtCore.QSize(int(620 * GOLDEN_RATIO), 620)
)
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle(_('Customize sing-tun Settings'))
self._original = copy.deepcopy(Storage.UserSingTUNSettings())
self.fields = {}
self.tabs = AppQTabWidget(self)
layout = QVBoxLayout(self)
layout.addWidget(self.tabs)
common = copy.deepcopy(SING_TUN_DEFAULTS)
for key, value in self._original.items():
if key in ('tun_options', 'stack_options', 'host_options') and isinstance(
value, dict
):
common[key].update(value)
elif key in common:
common[key] = value
bridge = self._page(_('SOCKS Transit'))
self._choice(
bridge,
'stack',
_('Stack'),
[
('go', 'go'),
('gvisor', 'gvisor'),
('system', 'system'),
('mixed', 'mixed'),
(_('Automatic'), ''),
],
common.get('stack'),
)
self._choice(
bridge,
'log_level',
_('Log Level'),
[
(value, value)
for value in ('trace', 'debug', 'info', 'warn', 'error', 'silent')
],
common.get('log_level'),
)
self._text(
bridge,
'network_interface',
_('SOCKS Interface Binding (empty for automatic)'),
common.get('network_interface'),
)
self._number(
bridge,
'max_sessions',
_('Maximum Sessions'),
common.get('max_sessions'),
1,
16384,
)
for key, label in (
('connect_timeout', _('Connect Timeout (seconds)')),
('udp_timeout', _('SOCKS UDP Timeout (seconds)')),
('tcp_idle_timeout', _('TCP Idle Timeout (seconds)')),
):
self._text(bridge, key, label, common.get(key))
native = self._page(_('Interface and Stack'))
tun = (
common['tun_options']
if isinstance(common.get('tun_options'), dict)
else SING_TUN_DEFAULTS['tun_options']
)
self._text(
native, 'Name', _('Device Name (empty for automatic)'), tun.get('Name', '')
)
self._number(native, 'MTU', _('MTU (bytes)'), tun.get('MTU', 1500), 68, 65535)
self._text(
native,
'Inet4Address',
_('IPv4 Address Prefixes (comma separated)'),
(
', '.join(str(value) for value in tun.get('Inet4Address', []))
if isinstance(tun.get('Inet4Address'), list)
else ''
),
)
self._text(
native,
'Inet6Address',
_('IPv6 Address Prefixes (comma separated)'),
(
', '.join(str(value) for value in tun.get('Inet6Address', []))
if isinstance(tun.get('Inet6Address'), list)
else ''
),
)
stack = (
common['stack_options']
if isinstance(common.get('stack_options'), dict)
else {}
)
self._choice(
native,
'TCPCongestionControl',
_('TCP Congestion Control (Go stack)'),
[
(_('Automatic'), ''),
('cubic', 'cubic'),
('reno', 'reno'),
('bbr', 'bbr'),
],
stack.get('TCPCongestionControl', ''),
)
self._text(
native,
'UDPTimeout',
_('Native UDP Timeout (Go duration, e.g. 1m)'),
stack.get('UDPTimeout', '1m'),
)
self._text(
native,
'ICMPTimeout',
_('Native ICMP Timeout (Go duration, 0 for default)'),
stack.get('ICMPTimeout', '0'),
)
self._text(
native,
'UDPNATMax',
_('Native UDP NAT Limit (0 for default)'),
stack.get('UDPNATMax', 0),
)
for key, label in (
('UDPMapping', _('UDP Mapping')),
('UDPFiltering', _('UDP Filtering')),
):
self._choice(
native,
key,
label,
[
(_('Endpoint Independent'), 0),
(_('Address Dependent'), 1),
(_('Address and Port Dependent'), 2),
],
stack.get(key, 0),
)
host = (
common['host_options']
if isinstance(common.get('host_options'), dict)
else SING_TUN_HOST_DEFAULTS
)
hostForm = self._page(_('Host Settings'))
for key, label in (
('primaryAdapterInterfaceName', _('Primary Adapter Interface Name')),
('primaryAdapterInterfaceIP', _('Primary Adapter Interface IP')),
('tunAdapterInterfaceDNS', _('TUN Adapter Interface DNS')),
(
'bypassTUNAdapterInterfaceIP',
_('Bypass TUN Adapter Interface IP (separated by commas)'),
),
):
self._text(hostForm, key, label, host.get(key, ''))
self.disableDNS = AppQSwitch()
self.disableDNS.setChecked(
host.get('disablePrimaryAdapterInterfaceDNS', True) is True
)
disableDNSRow = QHBoxLayout()
disableDNSRow.setContentsMargins(0, 0, 0, 0)
disableDNSRow.addWidget(
AppQLabel(
_(
'Disable Primary Adapter Interface DNS (Mitigating DNS leaks on Windows)'
)
)
)
disableDNSRow.addWidget(self.disableDNS)
disableDNSRow.addStretch()
hostForm.addRow(disableDNSRow)
advanced = self._page(_('Advanced Options'))
self.advanced = DraculaJSONTextEditor(fontFamily=AppFontName())
self.advanced.setLineWrapMode(DraculaJSONTextEditor.LineWrapMode.NoWrap)
extra = copy.deepcopy(self._original)
for key in SING_TUN_DEFAULTS:
if key not in ('tun_options', 'stack_options', 'host_options'):
extra.pop(key, None)
for key, represented in (
('tun_options', ('Name', 'MTU', 'Inet4Address', 'Inet6Address')),
('host_options', tuple(SING_TUN_HOST_DEFAULTS)),
(
'stack_options',
(
'UDPTimeout',
'ICMPTimeout',
'UDPNATMax',
'UDPMapping',
'UDPFiltering',
'TCPCongestionControl',
),
),
):
if isinstance(extra.get(key), dict):
for field in represented:
extra[key].pop(field, None)
self.advanced.setPlainText(json.dumps(extra, indent=2, ensure_ascii=False))
advanced.addRow(self.advanced)
instructions = AppQLabel(
_(
'Use exact native JSON field names. Supported: route address/exclusion lists, StrictRoute, InterfaceScope, ForwarderBindInterface and IncludeAllNetworks. AutoRoute, external configuration and DNS ownership are managed. Unsupported fields are rejected.'
)
)
instructions.setWordWrap(True)
advanced.addRow(instructions)
self.errorLabel = AppQLabel('')
self.errorLabel.setWordWrap(True)
layout.addWidget(self.errorLabel)
buttons = AppQDialogButtonBox(
AppQDialogButtonBox.StandardButton.Ok
| AppQDialogButtonBox.StandardButton.Cancel
)
connectWeakly(buttons.accepted, self, 'accept')
connectWeakly(buttons.rejected, self, 'reject')
layout.addWidget(buttons)
def _page(self, title):
page = QWidget()
form = QFormLayout(page)
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setWidget(page)
self.tabs.addTab(scroll, title)
return form
def _text(self, form, key, label, value):
widget = AppQLineEdit()
widget.setText(str(value) if value is not None else '')
self.fields[key] = widget
form.addRow(AppQLabel(label), widget)
def _number(self, form, key, label, value, minimum, maximum):
widget = AppQSpinBox()
widget.setRange(minimum, maximum)
widget.setValue(value if type(value) is int else minimum)
self.fields[key] = widget
form.addRow(AppQLabel(label), widget)
def _choice(self, form, key, label, choices, value):
widget = AppQComboBox()
for text, data in choices:
widget.addItem(text, data)
widget.setCurrentIndex(max(widget.findData(value), 0))
self.fields[key] = widget
form.addRow(AppQLabel(label), widget)
def document(self):
"""Prepare sing-tun's complete candidate before its live collection changes."""
document = json.loads(self.advanced.toPlainText())
if not isinstance(document, dict):
raise ValueError('Advanced options must be a JSON object')
tun = document.setdefault('tun_options', {})
stack = document.setdefault('stack_options', {})
if not isinstance(tun, dict) or not isinstance(stack, dict):
raise ValueError('Native options must be JSON objects')
for key in ('stack', 'log_level'):
document[key] = self.fields[key].currentData()
document['network_interface'] = self.fields['network_interface'].text().strip()
document['max_sessions'] = self.fields['max_sessions'].value()
for key in ('connect_timeout', 'udp_timeout', 'tcp_idle_timeout'):
document[key] = float(self.fields[key].text())
tun['Name'], tun['MTU'] = (
self.fields['Name'].text().strip(),
self.fields['MTU'].value(),
)
for key in ('Inet4Address', 'Inet6Address'):
tun[key] = [
value.strip()
for value in self.fields[key].text().split(',')
if value.strip()
]
for key in ('UDPTimeout', 'ICMPTimeout'):
value = self.fields[key].text().strip()
stack[key] = int(value) if value.isdigit() else value
stack['UDPNATMax'] = int(self.fields['UDPNATMax'].text())
for key in ('UDPMapping', 'UDPFiltering', 'TCPCongestionControl'):
stack[key] = self.fields[key].currentData()
host = document.setdefault('host_options', {})
if not isinstance(host, dict):
raise ValueError('sing-tun host options must be a JSON object')
for key in (
'primaryAdapterInterfaceName',
'primaryAdapterInterfaceIP',
'tunAdapterInterfaceDNS',
'bypassTUNAdapterInterfaceIP',
):
host[key] = self.fields[key].text().strip()
host['disablePrimaryAdapterInterfaceDNS'] = self.disableDNS.isChecked()
prepareSingTUNSettings(document, platform=PLATFORM)
return document
def accept(self):
try:
document = self.document()
Storage.replaceSingTUNSettings(document)
except (ValueError, TypeError, OSError) as ex:
self.errorLabel.setText(str(ex))
return
super().accept()
+2 -2
View File
@@ -264,11 +264,11 @@ class GuiTUNSettingsGroupBoxBasic(GuiEditorWidgetQGroupBox):
key='defaultPrimaryGatewayIP',
),
GuiTUNSettingsItemXXX(
title=_('Tun2socks Adapter Interface DNS'),
title=_('TUN Adapter Interface DNS'),
key='tunAdapterInterfaceDNS',
),
GuiTUNSettingsItemXXX(
title=_('Bypass Tun2socks Adapter Interface IP (separated by commas)'),
title=_('Bypass TUN Adapter Interface IP (separated by commas)'),
key='bypassTUNAdapterInterfaceIP',
),
GuiTUNSettingsItemSwitchXXX(
+2 -1
View File
@@ -94,6 +94,7 @@ worker. Choose tests by the changed contract rather than by filename alone.
| [test_external_core.py](test_external_core.py) | Harmless real process launch/output/shutdown, partial thread-start rollback, non-finite timeout rejection, failed reap/thread-join retry, readiness/TUN metadata, Windows paths with spaces, subscription rejection of executable profiles, bounded DNS references. |
| [test_frozenlib.py](test_frozenlib.py) | Nested state guards, cleanup isolation, bounded caches/throttling, dual-stack probe selection, mocked proxy/DNS/routes/startup/session boundaries and failure handling. |
| [test_native_tun_semantics.py](test_native_tun_semantics.py) | Xray/Hysteria2 runtime-copy TUN preservation/replacement, managed-TUN failures, download-test stripping, prevention of a second tun2socks owner. |
| [test_sing_tun.py](test_sing_tun.py) | Engine choice/Go default, development-stack and congestion-control compatibility, independent backend settings and host defaults, selected-only snapshots, captured TUN log sources in both startup paths, staged settings and malformed storage, SOCKS authentication/UDP, harmless real spawn/status/stop, native readiness, cancellation, synchronous compatibility, mocked DNS/routing ownership including development IPv6 rule ordering and refused cleanup. |
| [test_subscription_sync.py](test_subscription_sync.py) | Group-local preparation/commit, stable duplicate identity, atomic failure, preservation of newer local metadata, rejection of changed source state. |
| [test_subscription_manager.py](test_subscription_manager.py) | Stable request generations, provider metadata, batch/partial failures, timer policy, targeted updates, real Stop Updates input, worker responsiveness, cancellation and synchronous shutdown ownership. |
| [test_subscription_scalability.py](test_subscription_scalability.py) | Deterministic 1/3/8-group preparation and commit with 1,500 profiles per group and bounded workers; uses the offline benchmark helper. |
@@ -140,7 +141,7 @@ regular cases run by default while its heavy class remains gated.
python -m unittest tests.test_interface tests.test_models_and_services tests.test_repository_contracts tests.test_plugin_architecture tests.test_public_api tests.test_hysteria1_protocol tests.test_hysteria2_compatibility tests.test_backend_editor_contract tests.test_shadowsocks_uri tests.test_socks_uri -v
# Application, connection, runtime, and mocked host boundaries
python -m unittest tests.test_application_process tests.test_architecture_refactors tests.test_connection_startup_async tests.test_controllers tests.test_runtime_lifecycle tests.test_external_core tests.test_frozenlib tests.test_native_tun_semantics -v
python -m unittest tests.test_application_process tests.test_architecture_refactors tests.test_connection_startup_async tests.test_controllers tests.test_runtime_lifecycle tests.test_external_core tests.test_frozenlib tests.test_native_tun_semantics tests.test_sing_tun -v
# Subscription, profile tests, HTTP/assets, endpoint information, metrics, and logs
python -m unittest tests.test_subscription_sync tests.test_subscription_manager tests.test_subscription_scalability tests.test_profile_test_jobs tests.test_service_runtime tests.test_xray_asset_download tests.test_endpoint_info tests.test_metrics_behavior tests.test_log_manager_generation tests.test_log_manager_benchmark -v
+116
View File
@@ -0,0 +1,116 @@
# 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/>.
"""Harmless source/Nuitka probe for the new transient settings editor."""
from __future__ import annotations
from Furious.Qt import AppQDialog
from Furious.Repository import Storage
from Furious.Window.SingTUNSettingsDialog import SingTUNSettingsDialog
from PySide6.QtWidgets import QWidget
from shiboken6 import isValid
from tests.support import application, isolatedSettings, processQtEvents
import argparse
import copy
import gc
import json
import sys
import weakref
import importlib
def run(iterations, method):
application()
destroyed = []
references = []
errors = []
originalHook = sys.excepthook
sys.excepthook = lambda kind, error, traceback: errors.append(
(kind.__name__, str(error))
)
singGetter, replace = (
Storage.UserSingTUNSettings,
Storage.replaceSingTUNSettings,
)
sing = {}
Storage.UserSingTUNSettings = staticmethod(lambda: copy.deepcopy(sing))
Storage.replaceSingTUNSettings = staticmethod(lambda document: None)
baseline = len(AppQDialog._openDialogs)
try:
with isolatedSettings():
for index in range(iterations):
owner = QWidget()
dialog = SingTUNSettingsDialog(owner)
dialog.destroyed.connect(lambda *_args: destroyed.append(True))
references.append(weakref.ref(dialog))
dialog.open()
if method == 'owner':
owner.deleteLater()
else:
getattr(dialog, method)()
processQtEvents()
assert not isValid(dialog), index
if isValid(owner):
owner.deleteLater()
processQtEvents()
del dialog, owner
gc.collect()
processQtEvents()
assert len(destroyed) == iterations
assert all(reference() is None for reference in references)
assert len(AppQDialog._openDialogs) == baseline
assert not errors, errors
finally:
sys.excepthook = originalHook
Storage.UserSingTUNSettings = staticmethod(singGetter)
Storage.replaceSingTUNSettings = staticmethod(replace)
return {
'method': method,
'destroyed': len(destroyed),
'wrapperReleased': iterations,
'callbackErrors': len(errors),
'compiledDialog': hasattr(
importlib.import_module('Furious.Window.SingTUNSettingsDialog'),
'__compiled__',
),
'compiledSignals': hasattr(
importlib.import_module('Furious.Qt.Signals'), '__compiled__'
),
}
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--iterations', type=int, default=20)
parser.add_argument(
'--method', choices=('accept', 'reject', 'close', 'owner'), default='reject'
)
arguments = parser.parse_args()
print(json.dumps(run(arguments.iterations, arguments.method)))
File diff suppressed because it is too large Load Diff
+28
View File
@@ -426,6 +426,7 @@ class SettingsPageOrganizationTest(unittest.TestCase):
)
page = SettingsPage(
tunSettingsDialogFactory=mock.Mock(),
singTunSettingsDialogFactory=mock.Mock(),
proxyBypassDialog=proxyBypassDialog,
networkTestDialog=networkTestDialog,
**callbacks,
@@ -449,6 +450,20 @@ class SettingsPageOrganizationTest(unittest.TestCase):
]
self.assertEqual(page.generalSection.cards, expectedGeneralCards)
expectedTUNCards = [
page.tunBackendCard,
page.singTunSettingsCard,
page.tunSettingsCard,
]
self.assertEqual(page.tunSection.titleLabel.text(), 'TUN')
self.assertEqual(page.tunSection.cards, expectedTUNCards)
self.assertFalse(page.tunSection.isHidden())
self.assertTrue(
all(card.parent() is page.tunSection for card in expectedTUNCards)
)
self.assertTrue(
all(card not in page.connectionSection.cards for card in expectedTUNCards)
)
self.assertEqual(
page.applicationSection.cards,
[page.updateCard, page.aboutCard],
@@ -473,11 +488,17 @@ class SettingsPageOrganizationTest(unittest.TestCase):
page.restartCard.button.click()
page.openFolderCard.button.click()
page.tunSettingsCard.button.click()
page.singTunSettingsCard.button.click()
callbacks['restartAsAdmin'].assert_called_once_with()
callbacks['openApplicationFolder'].assert_called_once_with()
callbacks['checkForUpdates'].assert_not_called()
callbacks['openAboutPage'].assert_not_called()
page._tunSettingsDialogFactory.assert_called_once_with(parent=page)
page._tunSettingsDialogFactory.return_value.open.assert_called_once_with()
page._singTunSettingsDialogFactory.assert_called_once_with(parent=page)
page._singTunSettingsDialogFactory.return_value.open.assert_called_once_with()
page.close()
page.deleteLater()
@@ -494,6 +515,13 @@ class SettingsPageOrganizationTest(unittest.TestCase):
self.assertIn(page.tunModeCard, page.generalSection.cards)
self.assertFalse(page.tunModeCard.checkBox.isEnabled())
self.assertTrue(page.tunSection.isHidden())
self.assertTrue(
all(
card.parent() is page.tunSection
for card in page.tunSection.cards
)
)
page.setConnectionControlsEnabled(True)
self.assertFalse(page.tunModeCard.checkBox.isEnabled())