From 0edddf983c9dd13f438f94e7d0bf3e1b55a2e9a3 Mon Sep 17 00:00:00 2001 From: LB7666 Date: Sun, 18 Oct 2026 01:48:21 +0800 Subject: [PATCH] fix: retain explicit process exclusions and disk diagnostics --- lib/l10n/app_en.arb | 2 +- lib/l10n/app_zh.arb | 2 +- lib/src/ffi/proxy_service.dart | 2 +- lib/src/models/node_model.dart | 2 +- lib/src/models/proxy_config.dart | 4 +- lib/src/providers/proxy_provider.dart | 9 +- lib/src/widgets/tun_process_dialog.dart | 38 ++----- test/app_language_test.dart | 2 +- test/bypass_selection_test.dart | 132 ++++++++++++++++++++++++ test/log_notification_test.dart | 47 ++++++++- test/proxy_config_test.dart | 2 +- test/tun_hot_switch_test.dart | 4 +- 12 files changed, 203 insertions(+), 43 deletions(-) create mode 100644 test/bypass_selection_test.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4ef1032..913a5d4 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -870,7 +870,7 @@ "secureTransport": "Use v3 transport", "secureTransportDescription": "Native proxy sessions are always encrypted. V3 uses separate direction keys without an extra handshake round trip; upgrade every node first. Off uses the encrypted legacy protocol.", "tunDnsServer": "TUN DNS resolver IP", - "tunDnsServerDescription": "Default: 8.8.8.8. The proxy server must be able to reach this resolver. Set your private resolver here for internal domains. Reconnect to apply.", + "tunDnsServerDescription": "Desktop DNS uses AliDNS and Tencent DNS over the physical interface, then adapter DNS and this fallback address. Mobile DNS uses this address through the proxy. Existing settings are preserved. Reconnect to apply.", "invalidTunDnsServer": "Enter a valid IPv4 or IPv6 DNS resolver address.", "tunFakeIp": "TUN Fake-IP DNS", "tunFakeIpDescription": "Off by default: DNS returns real addresses through the proxy. Enable to use virtual addresses for domain routing. Takes effect after reconnecting." diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 51e41cd..7b1711d 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -362,7 +362,7 @@ "secureTransport": "使用 v3 传输协议", "secureTransportDescription": "原生代理连接默认已全程加密。v3 使用独立方向密钥且不增加握手往返;启用前需升级全部节点。关闭时使用兼容旧节点的加密协议。", "tunDnsServer": "TUN DNS 解析器 IP", - "tunDnsServerDescription": "默认 8.8.8.8,需可从代理服务器访问。解析内网域名时请填写相应的私有 DNS 地址。重新连接后生效。", + "tunDnsServerDescription": "桌面端优先通过物理网卡查询阿里云、腾讯云 DNS,再回退到网卡 DNS 和此地址;移动端通过代理访问此地址。已有配置保留,重新连接后生效。", "invalidTunDnsServer": "请输入有效的 IPv4 或 IPv6 DNS 解析器地址。", "tunFakeIp": "TUN Fake-IP DNS", "tunFakeIpDescription": "默认关闭:通过代理查询真实 IP。开启后使用虚拟地址保留域名路由信息。重新连接后生效。" diff --git a/lib/src/ffi/proxy_service.dart b/lib/src/ffi/proxy_service.dart index 4d4b083..49c8231 100644 --- a/lib/src/ffi/proxy_service.dart +++ b/lib/src/ffi/proxy_service.dart @@ -251,7 +251,7 @@ class ProxyService { bool udpDirectFallback = true, bool tunEnabled = false, bool tunFakeIp = false, - String tunDnsServer = '8.8.8.8', + String tunDnsServer = '223.5.5.5', List tunBypassProcesses = const [], bool reverseGeo = false, bool secureTransport = false, diff --git a/lib/src/models/node_model.dart b/lib/src/models/node_model.dart index 42856fa..dbd676b 100644 --- a/lib/src/models/node_model.dart +++ b/lib/src/models/node_model.dart @@ -174,7 +174,7 @@ class NodeInfo { bool udpDirectFallback = true, bool tunEnabled = false, bool tunFakeIp = false, - String tunDnsServer = '8.8.8.8', + String tunDnsServer = '223.5.5.5', List tunBypassProcesses = const [], AndroidVpnRoutingMode androidVpnRoutingMode = AndroidVpnRoutingMode.all, List androidVpnPackages = const [], diff --git a/lib/src/models/proxy_config.dart b/lib/src/models/proxy_config.dart index daf6b7a..96d8f90 100644 --- a/lib/src/models/proxy_config.dart +++ b/lib/src/models/proxy_config.dart @@ -54,7 +54,7 @@ class ProxyConfigModel { this.udpDirectFallback = true, this.tunEnabled = false, this.tunFakeIp = false, - this.tunDnsServer = '8.8.8.8', + this.tunDnsServer = '223.5.5.5', List tunBypassProcesses = const [], this.androidVpnRoutingMode = AndroidVpnRoutingMode.all, List androidVpnPackages = const [], @@ -133,7 +133,7 @@ class ProxyConfigModel { // routes after an application upgrade. tunEnabled: _parseBool(json['tunEnabled'], false), tunFakeIp: _parseBool(json['tunFakeIp'], false), - tunDnsServer: (json['tunDnsServer'] ?? '8.8.8.8').toString().trim(), + tunDnsServer: (json['tunDnsServer'] ?? '223.5.5.5').toString().trim(), tunBypassProcesses: _parseStringList(json['tunBypassProcesses']), androidVpnRoutingMode: AndroidVpnRoutingMode.fromWireName( json['androidVpnRoutingMode'], diff --git a/lib/src/providers/proxy_provider.dart b/lib/src/providers/proxy_provider.dart index 460158d..118b42a 100644 --- a/lib/src/providers/proxy_provider.dart +++ b/lib/src/providers/proxy_provider.dart @@ -472,10 +472,10 @@ class ProxyState extends ChangeNotifier { ); } - /// Persist and, when TUN is active, immediately apply process exclusions. + /// Persist and apply exclusions to the running listener and TUN session. Future updateTunBypassProcesses(List processes) async { if (_isDisposed) return false; - if (_isRunning && _isTunRunning) { + if (_isRunning) { final result = _service.setTunBypassProcesses(processes); if (result != ProxyResult.ok) { _lastError = _service.lastError ?? ProxyResult.message(result); @@ -707,9 +707,8 @@ class ProxyState extends ChangeNotifier { void setMinLogLevel(int level) { _minLogLevel = LogLevel.normalize(level); _filteredLogs = null; - // Also raise the native floor so the levels the UI filters out are never - // shipped across the FFI boundary in the first place. - _service.setLogLevel(_minLogLevel); + // Disk diagnostics retain INFO even while the view only shows errors. + _service.setLogLevel(_minLogLevel.clamp(0, 2)); if (!_isDisposed) logRevision.value++; _safeNotifyListeners(); } diff --git a/lib/src/widgets/tun_process_dialog.dart b/lib/src/widgets/tun_process_dialog.dart index b7998fe..be52373 100644 --- a/lib/src/widgets/tun_process_dialog.dart +++ b/lib/src/widgets/tun_process_dialog.dart @@ -105,7 +105,6 @@ class _TunProcessDialogState extends State { setState(() { _processes = sorted; _selfProcess = selfProcess; - _removeInheritedSelections(forest); if (!_initializedExpansion) { _expanded.addAll(expandableTunProcessNames(forest)); _initializedExpansion = true; @@ -115,23 +114,6 @@ class _TunProcessDialogState extends State { }); } - void _removeInheritedSelections(List forest) { - void visit(TunProcessTreeNode node, bool ancestorSelected) { - if (ancestorSelected) { - _selected.remove(node.process.name); - } - final selectedHere = - ancestorSelected || _selected.contains(node.process.name); - for (final child in node.children) { - visit(child, selectedHere); - } - } - - for (final root in forest) { - visit(root, false); - } - } - void _expandSelectedPaths(List forest) { bool visit(TunProcessTreeNode node) { final containsSelection = @@ -188,11 +170,8 @@ class _TunProcessDialogState extends State { setState(() { if (selected) { _selected.add(node.process.name); - // Parent matching already includes every descendant. Removing redundant - // explicit names keeps the persisted policy stable across PID changes. - for (final descendant in node.descendants) { - _selected.remove(descendant.process.name); - } + // Explicit rules survive launcher exit/restart and changing PID trees. + // Inheritance only affects the display, never the stored selection. if (node.children.isNotEmpty) { _expanded.add(node.process.name); } @@ -425,7 +404,9 @@ class _TunProcessDialogState extends State { .withValues(alpha: .38) : Colors.transparent, child: InkWell( - onTap: isSelf || inherited ? null : () => _setSelected(node, !selected), + onTap: isSelf + ? null + : () => _setSelected(node, !_selected.contains(name)), child: Padding( padding: EdgeInsets.only(left: row.depth * 24.0, right: 8), child: SizedBox( @@ -453,10 +434,13 @@ class _TunProcessDialogState extends State { ), ), Checkbox( - value: selected, - onChanged: isSelf || inherited + tristate: true, + value: inherited && !_selected.contains(name) + ? null + : selected, + onChanged: isSelf ? null - : (value) => _setSelected(node, value ?? false), + : (_) => _setSelected(node, !_selected.contains(name)), ), const SizedBox(width: 6), _ProcessIcon(process: process), diff --git a/test/app_language_test.dart b/test/app_language_test.dart index b1c4e25..f19d408 100644 --- a/test/app_language_test.dart +++ b/test/app_language_test.dart @@ -186,7 +186,7 @@ class LanguageTestProxyService extends ProxyService { bool udpDirectFallback = true, bool tunEnabled = false, bool tunFakeIp = false, - String tunDnsServer = '8.8.8.8', + String tunDnsServer = '223.5.5.5', List tunBypassProcesses = const [], bool reverseGeo = false, bool secureTransport = false, diff --git a/test/bypass_selection_test.dart b/test/bypass_selection_test.dart new file mode 100644 index 0000000..de95c87 --- /dev/null +++ b/test/bypass_selection_test.dart @@ -0,0 +1,132 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:proxy_ui/l10n/app_language.dart'; +import 'package:proxy_ui/src/ffi/proxy_service.dart'; +import 'package:proxy_ui/src/models/proxy_config.dart'; +import 'package:proxy_ui/src/providers/proxy_provider.dart'; +import 'package:proxy_ui/src/services/desktop_log_service.dart'; +import 'package:proxy_ui/src/widgets/tun_process_dialog.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:toastification/toastification.dart'; + +void main() { + testWidgets( + 'refreshing a launcher tree preserves explicit child exclusions', + (tester) async { + SharedPreferences.setMockInitialValues({}); + tester.view.physicalSize = const Size(1200, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final service = _Service(); + final state = ProxyState( + service: service, + desktopLogService: DesktopLogService(enabled: false), + ); + await tester.runAsync(() async { + while (!state.isInitialized) { + await Future.delayed(const Duration(milliseconds: 1)); + } + }); + state.updateConfig( + ProxyConfigModel(tunBypassProcesses: ['wegame', 'leagueclient']), + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: state, + child: MaterialApp( + builder: (context, child) => ToastificationWrapper(child: child!), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () => showDialog( + context: context, + builder: (_) => ChangeNotifierProvider.value( + value: state, + child: const TunProcessDialog(), + ), + ), + child: const Text('open'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + // Selecting the parent again must also keep explicit child rules. + final parent = find.text('wegame${Platform.isWindows ? '.exe' : ''}'); + await tester.tap(parent); + await tester.pump(); + await tester.tap(parent); + await tester.pump(); + // Previously loading the parent+child snapshot removed leagueclient. + service.parentAlive = false; + await tester.tap(find.byIcon(Icons.refresh)); + await tester.pumpAndSettle(); + final child = find.ancestor( + of: find.text('leagueclient${Platform.isWindows ? '.exe' : ''}'), + matching: find.byType(InkWell), + ); + expect( + tester + .widget( + find.descendant(of: child, matching: find.byType(Checkbox)), + ) + .value, + isTrue, + ); + await tester.tap(find.byType(FilledButton)); + await tester.pumpAndSettle(); + expect(state.config.tunBypassProcesses, ['leagueclient', 'wegame']); + final saved = (await SharedPreferences.getInstance()).getString( + 'proxy_config', + ); + expect(saved, contains('leagueclient')); + toastification.dismissAll(delayForAnimation: false); + await tester.pumpAndSettle(); + await tester.pumpWidget(const SizedBox.shrink()); + state.dispose(); + }, + ); +} + +class _Service extends ProxyService { + bool parentAlive = true; + @override + void initLogging() {} + @override + bool restoreOrphanedSystemProxy() => false; + @override + bool get isRunning => false; + @override + bool get isTunRunning => false; + @override + String? get tunSelfProcess => 'cipherrelay'; + @override + List listTunProcessDetails() => [ + if (parentAlive) + const TunProcessInfo( + name: 'wegame', + pids: [100], + executablePaths: [], + instances: [TunProcessInstance(pid: 100)], + ), + TunProcessInfo( + name: 'leagueclient', + pids: const [101], + executablePaths: const [], + instances: [ + TunProcessInstance(pid: 101, parentPid: parentAlive ? 100 : null), + ], + ), + ]; + @override + void dispose() {} +} diff --git a/test/log_notification_test.dart b/test/log_notification_test.dart index 129de39..242d6a0 100644 --- a/test/log_notification_test.dart +++ b/test/log_notification_test.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter_test/flutter_test.dart'; import 'package:proxy_ui/src/ffi/proxy_service.dart'; import 'package:proxy_ui/src/providers/proxy_provider.dart'; @@ -14,6 +16,43 @@ void main() { SharedPreferences.setMockInitialValues({}); }); + test( + 'ERROR view still persists INFO diagnostics and keeps the native floor', + () async { + final directory = await Directory.systemTemp.createTemp( + 'proxy-view-log-', + ); + final disk = DesktopLogService( + enabled: true, + directoryProvider: () async => directory, + ); + final service = _SilentProxyService(); + final state = ProxyState(service: service, desktopLogService: disk); + await _waitUntilInitialized(state); + state.setMinLogLevel(4); + expect(service.nativeLevel, 2); + ProxyService.debugEmitLog( + LogEntry(level: 2, message: 'bypassing UDP game.exe via physical NIC'), + ); + ProxyService.debugEmitLog( + LogEntry(level: 4, message: 'failed destination example.test:443'), + ); + await _settle(); + expect(state.filteredLogs.map((entry) => entry.level), [4]); + state.dispose(); + await disk.dispose(); + final text = await directory + .list() + .where((file) => file is File) + .cast() + .asyncMap((file) => file.readAsString()) + .join(); + expect(text, contains('bypassing UDP game.exe via physical NIC')); + expect(text, contains('failed destination example.test:443')); + await directory.delete(recursive: true); + }, + ); + test('log arrivals notify only the log channel, and coalesce', () async { final state = ProxyState( service: _SilentProxyService(), @@ -123,11 +162,17 @@ Future _settle() => /// so log arrivals never trip the connection reconciliation that legitimately /// does notify listeners. class _SilentProxyService extends ProxyService { + int? nativeLevel; @override void initLogging() {} @override - void setLogLevel(int level) {} + void setLogLevel(int level) { + nativeLevel = level; + } + + @override + bool restoreOrphanedSystemProxy() => false; @override bool get isRunning => false; diff --git a/test/proxy_config_test.dart b/test/proxy_config_test.dart index 08e47be..5796542 100644 --- a/test/proxy_config_test.dart +++ b/test/proxy_config_test.dart @@ -23,7 +23,7 @@ void main() { } }); test('TUN resolver persists across imports and copies', () { - expect(ProxyConfigModel.fromJson({}).tunDnsServer, '8.8.8.8'); + expect(ProxyConfigModel.fromJson({}).tunDnsServer, '223.5.5.5'); final config = ProxyConfigModel.fromJson({'tunDnsServer': ' 10.20.30.53 '}); expect(config.tunDnsServer, '10.20.30.53'); expect( diff --git a/test/tun_hot_switch_test.dart b/test/tun_hot_switch_test.dart index 16f52a5..cc9554e 100644 --- a/test/tun_hot_switch_test.dart +++ b/test/tun_hot_switch_test.dart @@ -109,7 +109,7 @@ void main() { ); expect(await state.start(), isTrue); expect(service.lastTunFakeIp, isFalse); - expect(service.lastTunDnsServer, '8.8.8.8'); + expect(service.lastTunDnsServer, '223.5.5.5'); expect(await state.stop(), isTrue); state.updateConfig( state.config.copyWith(tunFakeIp: true, tunDnsServer: '10.20.30.53'), @@ -368,7 +368,7 @@ class _FakeProxyService extends ProxyService { bool udpDirectFallback = true, bool tunEnabled = false, bool tunFakeIp = false, - String tunDnsServer = '8.8.8.8', + String tunDnsServer = '223.5.5.5', List tunBypassProcesses = const [], bool reverseGeo = false, bool secureTransport = false,