diff --git a/Furious/Externals/GenTranslation.py b/Furious/Externals/GenTranslation.py index a3e79f97..acf8be9d 100644 --- a/Furious/Externals/GenTranslation.py +++ b/Furious/Externals/GenTranslation.py @@ -3757,5 +3757,37 @@ TRANSLATION = { "RU": "Доступно, если для «Стек» выбрано go или «Автоматически».", "ZH": "协议栈设为go或自动时可用。", "isReviewed": "True" + }, + "Unsupported sing-tun settings fields": { + "source": [ + "Furious.Window.SingTUNSettingsDialog" + ], + "RU": "Неподдерживаемые поля настроек sing-tun", + "ZH": "不支持的sing-tun设置字段", + "isReviewed": "True" + }, + "Unsupported sing-tun stack": { + "source": [ + "Furious.Window.SingTUNSettingsDialog" + ], + "RU": "Неподдерживаемый сетевой стек sing-tun", + "ZH": "不支持的sing-tun协议栈", + "isReviewed": "True" + }, + "Unsupported or application-owned native option": { + "source": [ + "Furious.Window.SingTUNSettingsDialog" + ], + "RU": "Неподдерживаемый или управляемый приложением нативный параметр", + "ZH": "不支持的原生选项或由应用管理的选项", + "isReviewed": "True" + }, + "Unsupported sing-tun host options": { + "source": [ + "Furious.Window.SingTUNSettingsDialog" + ], + "RU": "Неподдерживаемые параметры хоста sing-tun", + "ZH": "不支持的sing-tun主机选项", + "isReviewed": "True" } } diff --git a/Furious/Models/SingTUN.py b/Furious/Models/SingTUN.py index 6e9b795f..87f0b4cb 100644 --- a/Furious/Models/SingTUN.py +++ b/Furious/Models/SingTUN.py @@ -19,6 +19,8 @@ from __future__ import annotations +from enum import Enum + import copy import functools import ipaddress @@ -81,6 +83,21 @@ _STACK_FIELDS = { } +class SingTUNUnsupportedSettingsError(ValueError): + """Identify unsupported settings independently of their diagnostic wording.""" + + class Reason(Enum): + SettingsFields = 'Unsupported sing-tun settings fields' + Stack = 'Unsupported sing-tun stack' + NativeOption = 'Unsupported or application-owned native option' + HostOptions = 'Unsupported sing-tun host options' + + def __init__(self, reason: Reason): + self.reason = reason + + super().__init__(reason.value) + + class SingTUNHostSettingsCallers: """Bind sing-tun's named host settings callers to a captured host document.""" @@ -156,7 +173,9 @@ def prepareSingTUNSettings(document, *, platform=None): raise ValueError('sing-tun settings must be an object') if set(document) - set(SING_TUN_DEFAULTS): - raise ValueError('Unsupported sing-tun settings fields') + raise SingTUNUnsupportedSettingsError( + SingTUNUnsupportedSettingsError.Reason.SettingsFields + ) result = copy.deepcopy(SING_TUN_DEFAULTS) @@ -170,7 +189,9 @@ def prepareSingTUNSettings(document, *, platform=None): result[key] = copy.deepcopy(value) if result['stack'] not in ('', 'go', 'gvisor', 'system', 'mixed'): - raise ValueError('Unsupported sing-tun stack') + raise SingTUNUnsupportedSettingsError( + SingTUNUnsupportedSettingsError.Reason.Stack + ) if result['log_level'] not in ('trace', 'debug', 'info', 'warn', 'error', 'silent'): raise ValueError('Invalid sing-tun log level') @@ -201,7 +222,9 @@ def prepareSingTUNSettings(document, *, platform=None): tun, stack = result['tun_options'], result['stack_options'] if set(tun) - _TUN_FIELDS or set(stack) - _STACK_FIELDS: - raise ValueError('Unsupported or application-owned native option') + raise SingTUNUnsupportedSettingsError( + SingTUNUnsupportedSettingsError.Reason.NativeOption + ) for key, expected in _OWNED.items(): if type(tun[key]) is not type(expected) or tun[key] != expected: @@ -303,7 +326,9 @@ def prepareSingHostSettings(document): raise ValueError('sing-tun host options must be an object') if set(document) - set(SING_TUN_HOST_DEFAULTS): - raise ValueError('Unsupported sing-tun host options') + raise SingTUNUnsupportedSettingsError( + SingTUNUnsupportedSettingsError.Reason.HostOptions + ) result = copy.deepcopy(SING_TUN_HOST_DEFAULTS) result.update(copy.deepcopy(document)) diff --git a/Furious/Window/SingTUNSettingsDialog.py b/Furious/Window/SingTUNSettingsDialog.py index eeee85f2..1bd3c233 100644 --- a/Furious/Window/SingTUNSettingsDialog.py +++ b/Furious/Window/SingTUNSettingsDialog.py @@ -22,6 +22,7 @@ from __future__ import annotations from Furious.Models.SingTUN import ( SING_TUN_DEFAULTS, SING_TUN_HOST_DEFAULTS, + SingTUNUnsupportedSettingsError, prepareSingTUNSettings, ) from Furious.Repository import Storage @@ -55,6 +56,18 @@ import copy import json +def _unsupportedSettingsErrorText(error: SingTUNUnsupportedSettingsError): + """Translate application validation categories at the presentation boundary.""" + reason = SingTUNUnsupportedSettingsError.Reason + + return { + reason.SettingsFields: _('Unsupported sing-tun settings fields'), + reason.Stack: _('Unsupported sing-tun stack'), + reason.NativeOption: _('Unsupported or application-owned native option'), + reason.HostOptions: _('Unsupported sing-tun host options'), + }[error.reason] + + class SingTUNSettingsDialog(AppQTransientDialog): """Never import the native engine or mutate storage while loading widgets.""" @@ -337,7 +350,7 @@ class SingTUNSettingsDialog(AppQTransientDialog): instructions.setWordWrap(True) advanced.addRow(instructions) - self.errorLabel = AppQLabel('') + self.errorLabel = AppQLabel('', translatable=False) self.errorLabel.setWordWrap(True) layout.addWidget(self.errorLabel) @@ -505,7 +518,14 @@ class SingTUNSettingsDialog(AppQTransientDialog): Storage.replaceSingTUNSettings(document) except (ValueError, TypeError, OSError) as ex: - self.errorLabel.setText(str(ex)) + self.errorLabel.translatable = isinstance( + ex, SingTUNUnsupportedSettingsError + ) + self.errorLabel.setText( + _unsupportedSettingsErrorText(ex) + if self.errorLabel.translatable + else str(ex) + ) return diff --git a/tests/test_sing_tun.py b/tests/test_sing_tun.py index 2dfbbbd5..a8495300 100644 --- a/tests/test_sing_tun.py +++ b/tests/test_sing_tun.py @@ -26,7 +26,11 @@ from Furious.Core.MultiprocessingRuntime import ( ) from Furious.Interface import CoreRuntime, RuntimeState, RuntimeExitReason from Furious.Frozenlib import AppSettings, Mixins -from Furious.Models.SingTUN import prepareSingTUNSettings, addSingTUNExclusions +from Furious.Models.SingTUN import ( + prepareSingTUNSettings, + addSingTUNExclusions, + SingTUNUnsupportedSettingsError, +) from Furious.Frozenlib.SocksProxy import socksURL from Furious.Repository import Storage from Furious.Repository.SingTUNSettings import UserSingTUNSettings @@ -594,6 +598,105 @@ class SingTUNUIAndStorageTest(unittest.TestCase): processQtEvents() self.assertFalse(isValid(dialog)) + def testUnsupportedSettingsMessagesAreLocalizedAndRetranslatable(self): + """Reject unknown options with localized text without committing settings.""" + cases = ( + ({'unknown': 1}, 'Unsupported sing-tun settings fields'), + ( + {'tun_options': {'unknown': True}}, + 'Unsupported or application-owned native option', + ), + ( + {'stack_options': {'unknown': True}}, + 'Unsupported or application-owned native option', + ), + ({'host_options': {'sadfasfd': 1}}, 'Unsupported sing-tun host options'), + ({}, 'Unsupported sing-tun stack'), + ) + + for advanced, source in cases: + with self.subTest(source=source, advanced=advanced), isolatedSettings(): + AppSettings.set('Language', 'ZH') + dialog = self._dialog({}) + dialog.advanced.setPlainText(json.dumps(advanced)) + + if source == 'Unsupported sing-tun stack': + dialog.fields['stack'].addItem('unknown', 'unknown') + dialog.fields['stack'].setCurrentIndex( + dialog.fields['stack'].count() - 1 + ) + + dialog.open() + + try: + with mock.patch.object(Storage, 'replaceSingTUNSettings') as commit: + dialog.accept() + + commit.assert_not_called() + self.assertTrue(dialog.isVisible()) + self.assertEqual(dialog.errorLabel.text(), _(source, 'ZH')) + + for language in ('ZH', 'RU', 'EN', 'ZH'): + AppSettings.set('Language', language) + Mixins.QTranslatable.retranslateAll() + processQtEvents() + + expected = _(source, language) + self.assertEqual(dialog.errorLabel.text(), expected) + + if language != 'EN': + self.assertNotEqual(expected, source) + finally: + dialog.reject() + processQtEvents() + + def testUnsupportedErrorCategoryDoesNotDependOnDiagnosticWording(self): + """Keep UI language keyed to the category rather than an English match.""" + with isolatedSettings(): + AppSettings.set('Language', 'ZH') + dialog = self._dialog({}) + error = SingTUNUnsupportedSettingsError( + SingTUNUnsupportedSettingsError.Reason.HostOptions + ) + error.args = ('changed diagnostic wording',) + + try: + with mock.patch.object(dialog, 'document', side_effect=error): + dialog.accept() + + self.assertEqual( + dialog.errorLabel.text(), + _('Unsupported sing-tun host options', 'ZH'), + ) + finally: + dialog.reject() + processQtEvents() + + def testOtherErrorsKeepTheirDiagnosticsAcrossLanguageChanges(self): + """Binding and persistence diagnostics do not become category whitelists.""" + for error in ( + ValueError('Unsupported sing-tun host options'), + TypeError('arbitrary diagnostic detail'), + OSError('unable to save settings'), + ): + with self.subTest(error=error), isolatedSettings(): + AppSettings.set('Language', 'ZH') + dialog = self._dialog({}) + + try: + with mock.patch.object(dialog, 'document', side_effect=error): + dialog.accept() + + for language in ('ZH', 'RU', 'EN'): + AppSettings.set('Language', language) + Mixins.QTranslatable.retranslateAll() + processQtEvents() + + self.assertEqual(dialog.errorLabel.text(), str(error)) + finally: + dialog.reject() + processQtEvents() + def testSelectiveRowsKeepInputsBoundedAndPreserveKeyboardOrderAndValues(self): pages = ( (