From 8584f0459e35fe22e625ddb0ca3510f673742259 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 29 Jun 2026 17:46:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(client):=20=E8=87=AA=E5=8A=A8=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E5=BC=80=E5=85=B3=20+=20Windows=20=E5=BC=80=E6=9C=BA?= =?UTF-8?q?=E8=87=AA=E5=90=AF=E6=94=B9=E4=BB=BB=E5=8A=A1=E8=AE=A1=E5=88=92?= =?UTF-8?q?=E7=A8=8B=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增「自动连接」设置开关(默认关,持久化):启动 app 进登录态主界面后,等节点就绪 自动连一次(_RootFlowState 一次性 listenManual nodesProvider → toggle())。全平台生效。 - Windows 开机自启改用 schtasks /rl highest 登录触发任务:本应用 requireAdministrator, 原注册表 Run 键(非提权)开机会弹 UAC/起不来 → 任务计划静默提权拉起。macOS/Linux 仍用 launch_at_startup 登录项。 - 设置页功能组加「自动连接」行;新增 autoConnect/autoConnectSub 文案。 - _load 加 mounted 守卫(异步初始化期间已 dispose 不再写 state)。 - 测试:autoConnect 默认关 / _load 读持久化 / setAutoConnect 落盘。 注:移动端真·常连(Android Always-on + iOS/iPad On-Demand,无 UI 自连)是更大的原生活, 已单列 todo #14 后续做;本轮移动端只有「打开 app 自动连接」。 Co-Authored-By: Claude Opus 4.8 --- client/lib/l10n/app_text.dart | 2 + client/lib/l10n/strings_en.dart | 4 ++ client/lib/l10n/strings_zh.dart | 4 ++ client/lib/main.dart | 25 ++++++++ client/lib/screens/settings_page.dart | 1 + client/lib/state/settings_provider.dart | 47 ++++++++++++++- .../test/unit/settings_autoconnect_test.dart | 59 +++++++++++++++++++ 7 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 client/test/unit/settings_autoconnect_test.dart diff --git a/client/lib/l10n/app_text.dart b/client/lib/l10n/app_text.dart index 14d8e62..8173929 100644 --- a/client/lib/l10n/app_text.dart +++ b/client/lib/l10n/app_text.dart @@ -119,6 +119,8 @@ abstract class AppText { String get settingsTitle; String get autostart; String get autostartSub; + String get autoConnect; + String get autoConnectSub; 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 ba3de27..0805392 100644 --- a/client/lib/l10n/strings_en.dart +++ b/client/lib/l10n/strings_en.dart @@ -184,6 +184,10 @@ class StringsEn extends AppText { @override String get autostartSub => 'Run automatically on system start'; @override + String get autoConnect => 'Auto-connect'; + @override + String get autoConnectSub => 'Connect automatically on launch'; + @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 d0852bf..e70a599 100644 --- a/client/lib/l10n/strings_zh.dart +++ b/client/lib/l10n/strings_zh.dart @@ -183,6 +183,10 @@ class StringsZh extends AppText { @override String get autostartSub => '系统启动时自动运行'; @override + String get autoConnect => '自动连接'; + @override + String get autoConnectSub => '启动时自动连接节点'; + @override String get smartRoute => '智能分流'; @override String get smartRouteSub => '海外应用走加速,国内直连'; diff --git a/client/lib/main.dart b/client/lib/main.dart index 55af956..4810a99 100644 --- a/client/lib/main.dart +++ b/client/lib/main.dart @@ -13,6 +13,9 @@ import 'services/token_store.dart'; import 'shell/home_shell.dart'; import 'state/app_providers.dart'; import 'state/auth_provider.dart'; +import 'state/connection_provider.dart'; +import 'state/nodes_provider.dart'; +import 'state/settings_provider.dart'; import 'system_tray.dart'; import 'widgets/auth_screen.dart'; import 'widgets/onboarding_screen.dart'; @@ -113,6 +116,25 @@ class RootFlow extends ConsumerStatefulWidget { class _RootFlowState extends ConsumerState { final _store = const TokenStore(); bool _forceOnboarding = false; + bool _autoConnectArmed = false; // 自动连接每进程只尝试一次 + + // 进入登录态主界面后:若开了「自动连接」且当前未连接,等节点就绪后自动连一次。 + void _maybeAutoConnect() { + if (_autoConnectArmed) return; + _autoConnectArmed = true; + if (!ref.read(settingsProvider).autoConnect) return; + if (ref.read(connectionProvider).phase != VpnPhase.off) return; + // 节点列表异步加载,等出现可用(uuid 非空)节点再连,避免「节点未就绪」直接失败。 + late final ProviderSubscription sub; + sub = ref.listenManual(nodesProvider, (prev, next) { + final ready = (next.valueOrNull ?? const []).any((n) => n.uuid.isNotEmpty); + if (!ready) return; + sub.close(); + if (ref.read(connectionProvider).phase == VpnPhase.off) { + ref.read(connectionProvider.notifier).toggle(); + } + }, fireImmediately: true); + } // 登录/注册成功后:未看过引导才进引导,否则直接主界面。 Future _afterAuth() async { @@ -139,6 +161,9 @@ class _RootFlowState extends ConsumerState { if (_forceOnboarding) { return OnboardingScreen(t: t, onDone: () { _afterOnboarding(); }); } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _maybeAutoConnect(); + }); return const HomeShell(); } } diff --git a/client/lib/screens/settings_page.dart b/client/lib/screens/settings_page.dart index 858ad4e..2c80289 100644 --- a/client/lib/screens/settings_page.dart +++ b/client/lib/screens/settings_page.dart @@ -42,6 +42,7 @@ class SettingsPage extends ConsumerWidget { // 功能开关组 _Card(children: [ _Row(title: t.autostart, sub: t.autostartSub, right: sw(s.autostart, settings.setAutostart)), + _Row(title: t.autoConnect, sub: t.autoConnectSub, right: sw(s.autoConnect, settings.setAutoConnect)), _Row(title: t.smartRoute, sub: t.smartRouteSub, right: sw(s.smartRoute, settings.setSmartRoute)), _Row(title: t.killSwitch, sub: t.killSwitchSub, last: true, right: sw(s.killSwitch, settings.setKillSwitch)), ]), diff --git a/client/lib/state/settings_provider.dart b/client/lib/state/settings_provider.dart index 01ede93..584d240 100644 --- a/client/lib/state/settings_provider.dart +++ b/client/lib/state/settings_provider.dart @@ -14,22 +14,33 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../bridge/vpn_bridge_provider.dart'; class AppSettings { - const AppSettings({this.autostart = true, this.smartRoute = true, this.killSwitch = true}); + const AppSettings({ + this.autostart = true, + this.autoConnect = false, + this.smartRoute = true, + this.killSwitch = true, + }); final bool autostart; + final bool autoConnect; // 启动 app 时自动连接(opt-in,默认关) final bool smartRoute; final bool killSwitch; - AppSettings copyWith({bool? autostart, bool? smartRoute, bool? killSwitch}) => AppSettings( + AppSettings copyWith({bool? autostart, bool? autoConnect, bool? smartRoute, bool? killSwitch}) => AppSettings( autostart: autostart ?? this.autostart, + autoConnect: autoConnect ?? this.autoConnect, smartRoute: smartRoute ?? this.smartRoute, killSwitch: killSwitch ?? this.killSwitch, ); } const _kAutostart = 'set_autostart'; +const _kAutoConnect = 'set_auto_connect'; const _kSmartRoute = 'set_smart_route'; const _kKillSwitch = 'set_kill_switch'; +// Windows 任务计划名(开机自启用最高权限任务,绕开注册表 Run 起不来/弹 UAC)。 +const _kWinTaskName = 'PangolinAutostart'; + class SettingsController extends StateNotifier { SettingsController(this._ref) : super(const AppSettings()) { _load(); @@ -41,14 +52,17 @@ class SettingsController extends StateNotifier { Future _load() async { try { final p = await SharedPreferences.getInstance(); + if (!mounted) return; // 异步初始化期间已 dispose:别再写 state。 state = AppSettings( autostart: p.getBool(_kAutostart) ?? true, + autoConnect: p.getBool(_kAutoConnect) ?? false, smartRoute: p.getBool(_kSmartRoute) ?? true, killSwitch: p.getBool(_kKillSwitch) ?? true, ); } catch (_) { // 测试/无平台:保留默认值。 } + if (!mounted) return; _applyKillSwitch(state.killSwitch); if (_desktop) await _applyAutostart(state.autostart); } @@ -59,6 +73,11 @@ class SettingsController extends StateNotifier { if (_desktop) await _applyAutostart(v); } + Future setAutoConnect(bool v) async { + state = state.copyWith(autoConnect: v); + await _persist(_kAutoConnect, v); + } + Future setSmartRoute(bool v) async { state = state.copyWith(smartRoute: v); await _persist(_kSmartRoute, v); @@ -80,6 +99,13 @@ class SettingsController extends StateNotifier { Future _applyAutostart(bool v) async { try { + // Windows:本应用 requireAdministrator,注册表 Run 键(非提权)开机会弹 UAC/起不来, + // 改用任务计划程序 /rl highest 登录触发任务 → 静默提权自启。 + if (Platform.isWindows) { + await _applyWindowsAutostart(v); + return; + } + // macOS/Linux:登录项即可(无需管理员)。 final info = await PackageInfo.fromPlatform(); launchAtStartup.setup(appName: info.appName, appPath: Platform.resolvedExecutable); if (v) { @@ -92,6 +118,23 @@ class SettingsController extends StateNotifier { } } + // 用 schtasks 建/删最高权限登录触发任务。app 已是管理员 → 可建 /rl highest 任务, + // 开机由计划程序静默提权拉起、不弹 UAC。/tr 含空格路径须内嵌引号。 + Future _applyWindowsAutostart(bool v) async { + if (v) { + final exe = Platform.resolvedExecutable; + await Process.run('schtasks', [ + '/create', '/tn', _kWinTaskName, + '/tr', '"$exe"', + '/sc', 'onlogon', + '/rl', 'highest', + '/f', + ]); + } else { + await Process.run('schtasks', ['/delete', '/tn', _kWinTaskName, '/f']); + } + } + Future _persist(String key, bool v) async { final p = await SharedPreferences.getInstance(); await p.setBool(key, v); diff --git a/client/test/unit/settings_autoconnect_test.dart b/client/test/unit/settings_autoconnect_test.dart new file mode 100644 index 0000000..f854f0e --- /dev/null +++ b/client/test/unit/settings_autoconnect_test.dart @@ -0,0 +1,59 @@ +// settings_autoconnect_test.dart — 自动连接开关:默认关 + 改状态并持久化(重启恢复)。 +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:pangolin_vpn/bridge/vpn_bridge_mock.dart'; +import 'package:pangolin_vpn/bridge/vpn_bridge_provider.dart'; +import 'package:pangolin_vpn/state/settings_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() { + SharedPreferences.setMockInitialValues({}); + // 桌面(mac 测试机)构造时会跑 _applyAutostart → PackageInfo.fromPlatform; + // mock 掉避免无插件挂起、_load 拖到 dispose 之后报「used after dispose」。 + PackageInfo.setMockInitialValues( + appName: 'Pangolin', packageName: 'com.pangolin', + version: '1.0.0', buildNumber: '1', buildSignature: '', + ); + }); + + ProviderContainer makeContainer() => ProviderContainer(overrides: [ + vpnBridgeProvider.overrideWithValue(VpnBridgeMock()), + ]); + + // 轮询等 _load() 把持久化值读进 state(异步,首次 SharedPreferences 初始化可能偏慢)。 + Future waitAutoConnect(ProviderContainer c, bool want) async { + for (var i = 0; i < 60; i++) { + if (c.read(settingsProvider).autoConnect == want) return; + await Future.delayed(const Duration(milliseconds: 10)); + } + } + + test('autoConnect 默认关(const 默认 + 空存储加载)', () async { + expect(const AppSettings().autoConnect, isFalse); + final c = makeContainer(); + addTearDown(c.dispose); + await waitAutoConnect(c, false); + expect(c.read(settingsProvider).autoConnect, isFalse); + }); + + test('_load 从持久化恢复 autoConnect=true', () async { + SharedPreferences.setMockInitialValues({'set_auto_connect': true}); + final c = makeContainer(); + addTearDown(c.dispose); + await waitAutoConnect(c, true); + expect(c.read(settingsProvider).autoConnect, isTrue, reason: '应读出持久化值'); + }); + + test('setAutoConnect 写入持久化存储', () async { + final c = makeContainer(); + addTearDown(c.dispose); + await waitAutoConnect(c, false); // 等初次加载安定 + await c.read(settingsProvider.notifier).setAutoConnect(true); + // 校验落盘(状态可能被并发的初次 _load 覆盖,但存储一定被写入)。 + final p = await SharedPreferences.getInstance(); + expect(p.getBool('set_auto_connect'), isTrue, reason: '应持久化'); + }); +}