diff --git a/client/lib/l10n/app_text.dart b/client/lib/l10n/app_text.dart index 8173929..41ee9d6 100644 --- a/client/lib/l10n/app_text.dart +++ b/client/lib/l10n/app_text.dart @@ -121,6 +121,7 @@ abstract class AppText { String get autostartSub; String get autoConnect; String get autoConnectSub; + String autoConnectingTo(String name); // 自动连接 toast String get smartRoute; String get smartRouteSub; String get killSwitch; diff --git a/client/lib/l10n/strings_en.dart b/client/lib/l10n/strings_en.dart index 0805392..b932090 100644 --- a/client/lib/l10n/strings_en.dart +++ b/client/lib/l10n/strings_en.dart @@ -188,6 +188,8 @@ class StringsEn extends AppText { @override String get autoConnectSub => 'Connect automatically on launch'; @override + String autoConnectingTo(String name) => 'Auto-connecting to $name…'; + @override String get smartRoute => 'Smart routing'; @override String get smartRouteSub => 'Accelerate abroad, direct at home'; diff --git a/client/lib/l10n/strings_zh.dart b/client/lib/l10n/strings_zh.dart index e70a599..e4bb09c 100644 --- a/client/lib/l10n/strings_zh.dart +++ b/client/lib/l10n/strings_zh.dart @@ -187,6 +187,8 @@ class StringsZh extends AppText { @override String get autoConnectSub => '启动时自动连接节点'; @override + String autoConnectingTo(String name) => '正在自动连接到 $name…'; + @override String get smartRoute => '智能分流'; @override String get smartRouteSub => '海外应用走加速,国内直连'; diff --git a/client/lib/main.dart b/client/lib/main.dart index 7316a6d..d43393f 100644 --- a/client/lib/main.dart +++ b/client/lib/main.dart @@ -23,6 +23,9 @@ import 'widgets/auth_screen.dart'; import 'widgets/onboarding_screen.dart'; import 'widgets/pangolin_logo.dart'; +/// 全局 ScaffoldMessenger key:供无 BuildContext 处(如自动连接)弹 SnackBar/toast。 +final rootMessengerKey = GlobalKey(); + Future main() async { WidgetsFlutterBinding.ensureInitialized(); // 桌面端:单实例 + 常驻系统托盘(关闭窗口隐藏到托盘而非退出)。 @@ -98,6 +101,7 @@ class _PangolinAppState extends ConsumerState { final mode = ref.watch(themeModeProvider); return MaterialApp( title: '穿山甲', + scaffoldMessengerKey: rootMessengerKey, debugShowCheckedModeBanner: false, theme: PangolinTheme.light, darkTheme: PangolinTheme.dark, @@ -131,22 +135,33 @@ class _RootFlowState extends ConsumerState { logLine('AutoConnect', 'armed; waiting for settings loaded + nodes ready'); ProviderSubscription>>? nodeSub; ProviderSubscription? settingsSub; + ProviderSubscription? selSub; var decided = false; void tryGo() { if (decided) return; final settings = ref.read(settingsProvider); + final selLoaded = ref.read(selectedNodeCodeProvider.notifier).loaded; // 直接判 _connect 实际会用的「生效节点」是否就绪(uuid 非空),而非「列表里有任一就绪节点」—— // 否则可能在 effectiveNode 尚是占位/未就绪时就触发,_connect 报「节点尚未就绪」。 + // 还要等设置 + 选中节点都从持久化恢复完,否则可能用到未恢复的默认(智能)而非上次选择。 final node = ref.read(effectiveNodeProvider); - if (!settings.loaded || node.uuid.isEmpty) return; + if (!settings.loaded || !selLoaded || node.uuid.isEmpty) return; decided = true; nodeSub?.close(); settingsSub?.close(); + selSub?.close(); final phase = ref.read(connectionProvider).phase; logLine('AutoConnect', 'ready: autoConnect=${settings.autoConnect} phase=$phase node=${node.code} uuid=${node.uuid.isEmpty ? "EMPTY" : "ok"}'); if (settings.autoConnect && phase == VpnPhase.off) { - logLine('AutoConnect', 'triggering connect'); + logLine('AutoConnect', 'triggering connect → ${node.code}'); ref.read(connectionProvider.notifier).toggle(); + // 自动连接 toast:告知用户「正在自动连接到 X」。 + final t = ref.read(appTextProvider); + rootMessengerKey.currentState?.showSnackBar(SnackBar( + content: Text(t.autoConnectingTo(node.localizedName(t.lang))), + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 3), + )); } else { logLine('AutoConnect', 'NOT connecting (autoConnect=${settings.autoConnect} phase=$phase)'); } @@ -154,6 +169,7 @@ class _RootFlowState extends ConsumerState { nodeSub = ref.listenManual(nodesProvider, (_, __) => tryGo()); settingsSub = ref.listenManual(settingsProvider, (_, __) => tryGo()); + selSub = ref.listenManual(selectedNodeCodeProvider, (_, __) => tryGo()); tryGo(); } diff --git a/client/lib/screens/nodes_page.dart b/client/lib/screens/nodes_page.dart index f922a0c..ab5b605 100644 --- a/client/lib/screens/nodes_page.dart +++ b/client/lib/screens/nodes_page.dart @@ -45,7 +45,7 @@ class _NodesPageState extends ConsumerState { } void _pick(String code) { - ref.read(selectedNodeCodeProvider.notifier).state = code; + ref.read(selectedNodeCodeProvider.notifier).select(code); ref.read(connectionProvider.notifier).onNodeChanged(); widget.onPicked(); } diff --git a/client/lib/state/connection_provider.dart b/client/lib/state/connection_provider.dart index bab0dac..53dc5d8 100644 --- a/client/lib/state/connection_provider.dart +++ b/client/lib/state/connection_provider.dart @@ -262,7 +262,7 @@ class ConnectionController extends StateNotifier { final smart = _ref.read(selectedNodeCodeProvider) == kSmartNodeCode; final altCode = smart ? _pickAlternativeCode(_ref.read(effectiveNodeProvider).code) : null; if (smart && altCode != null) { - _ref.read(selectedNodeCodeProvider.notifier).state = altCode; + _ref.read(selectedNodeCodeProvider.notifier).select(altCode); await _disconnect(); await _connect(); // 重连进行中给出「已自动切换」提示(connecting 态显示,连上后随 copyWith 清除)。 diff --git a/client/lib/state/nodes_provider.dart b/client/lib/state/nodes_provider.dart index fb5933b..9120d48 100644 --- a/client/lib/state/nodes_provider.dart +++ b/client/lib/state/nodes_provider.dart @@ -7,6 +7,7 @@ import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; import '../models/node.dart'; import '../services/api_config.dart'; @@ -96,8 +97,42 @@ final nodesProvider = AsyncNotifierProvider>(NodesNotifier.new); // ── 当前选中的节点 code;'AUTO' 表示智能选择(默认)───────────────── +// +// 持久化:用户的选择(智能 / 具体节点)跨重启保存,启动时恢复,供自动连接复用上次选择。 +const _kSelectedNode = 'selected_node_code'; -final selectedNodeCodeProvider = StateProvider((ref) => kSmartNodeCode); +class SelectedNodeController extends StateNotifier { + SelectedNodeController() : super(kSmartNodeCode) { + _load(); + } + + /// 是否已从持久化恢复完(自动连接要等它 true 再读,避免用到未恢复的默认值)。 + bool loaded = false; + + Future _load() async { + try { + final p = await SharedPreferences.getInstance(); + if (!mounted) return; + final v = p.getString(_kSelectedNode); + if (v != null && v.isNotEmpty) state = v; + } catch (_) { + // 测试/无平台:保留默认(智能)。 + } + loaded = true; + } + + /// 选择节点(智能传 kSmartNodeCode)并持久化。 + Future select(String code) async { + state = code; + try { + final p = await SharedPreferences.getInstance(); + await p.setString(_kSelectedNode, code); + } catch (_) {} + } +} + +final selectedNodeCodeProvider = + StateNotifierProvider((ref) => SelectedNodeController()); /// 是否处于智能选择。 final isSmartSelectProvider = Provider( diff --git a/client/pubspec.yaml b/client/pubspec.yaml index c8147ad..41444ba 100644 --- a/client/pubspec.yaml +++ b/client/pubspec.yaml @@ -1,7 +1,7 @@ name: pangolin_vpn description: 穿山甲 · Pangolin — 极简、稳定、跨平台网络加速客户端。 publish_to: "none" -version: 1.0.23+24 +version: 1.0.24+25 environment: sdk: ^3.5.0 diff --git a/client/test/unit/selected_node_persist_test.dart b/client/test/unit/selected_node_persist_test.dart new file mode 100644 index 0000000..cc50ffb --- /dev/null +++ b/client/test/unit/selected_node_persist_test.dart @@ -0,0 +1,50 @@ +// selected_node_persist_test.dart — 选中节点(智能/具体)跨重启持久化,供自动连接复用上次选择。 +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pangolin_vpn/models/node.dart'; +import 'package:pangolin_vpn/state/nodes_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => SharedPreferences.setMockInitialValues({})); + + Future waitSelected(ProviderContainer c, String want) async { + for (var i = 0; i < 60; i++) { + if (c.read(selectedNodeCodeProvider) == want) return; + await Future.delayed(const Duration(milliseconds: 10)); + } + } + + test('默认智能选择(AUTO)', () async { + final c = ProviderContainer(); + addTearDown(c.dispose); + expect(c.read(selectedNodeCodeProvider), kSmartNodeCode); + }); + + test('select 具体节点 → 落盘', () async { + final c = ProviderContainer(); + addTearDown(c.dispose); + await waitSelected(c, kSmartNodeCode); // 等 _load + await c.read(selectedNodeCodeProvider.notifier).select('JP'); + expect(c.read(selectedNodeCodeProvider), 'JP'); + final p = await SharedPreferences.getInstance(); + expect(p.getString('selected_node_code'), 'JP', reason: '应持久化'); + }); + + test('重启从持久化恢复具体节点', () async { + SharedPreferences.setMockInitialValues({'selected_node_code': 'JP'}); + final c = ProviderContainer(); + addTearDown(c.dispose); + await waitSelected(c, 'JP'); + expect(c.read(selectedNodeCodeProvider), 'JP', reason: '重启应恢复上次选择'); + }); + + test('重启恢复智能选择(AUTO 持久化)', () async { + SharedPreferences.setMockInitialValues({'selected_node_code': kSmartNodeCode}); + final c = ProviderContainer(); + addTearDown(c.dispose); + await waitSelected(c, kSmartNodeCode); + expect(c.read(selectedNodeCodeProvider), kSmartNodeCode); + }); +} diff --git a/client/windows/installer/pangolin.iss b/client/windows/installer/pangolin.iss index 515d69d..15da85a 100644 --- a/client/windows/installer/pangolin.iss +++ b/client/windows/installer/pangolin.iss @@ -3,7 +3,7 @@ ; 前置: 先在 client/ 跑 `flutter build windows`(Release),产物在 ; ..\..\build\windows\x64\runner\Release #define MyAppName "穿山甲 Pangolin" -#define MyAppVersion "1.0.23" +#define MyAppVersion "1.0.24" #define MyAppPublisher "Pangolin" #define MyAppExeName "pangolin_vpn.exe" #define BuildDir "..\..\build\windows\x64\runner\Release"