feat(client): 自动连接开关 + Windows 开机自启改任务计划程序
- 新增「自动连接」设置开关(默认关,持久化):启动 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 => '海外应用走加速,国内直连';
|
||||
|
||||
@@ -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<RootFlow> {
|
||||
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<void> _afterAuth() async {
|
||||
@@ -139,6 +161,9 @@ class _RootFlowState extends ConsumerState<RootFlow> {
|
||||
if (_forceOnboarding) {
|
||||
return OnboardingScreen(t: t, onDone: () { _afterOnboarding(); });
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _maybeAutoConnect();
|
||||
});
|
||||
return const HomeShell();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
]),
|
||||
|
||||
@@ -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<AppSettings> {
|
||||
SettingsController(this._ref) : super(const AppSettings()) {
|
||||
_load();
|
||||
@@ -41,14 +52,17 @@ class SettingsController extends StateNotifier<AppSettings> {
|
||||
Future<void> _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<AppSettings> {
|
||||
if (_desktop) await _applyAutostart(v);
|
||||
}
|
||||
|
||||
Future<void> setAutoConnect(bool v) async {
|
||||
state = state.copyWith(autoConnect: v);
|
||||
await _persist(_kAutoConnect, v);
|
||||
}
|
||||
|
||||
Future<void> setSmartRoute(bool v) async {
|
||||
state = state.copyWith(smartRoute: v);
|
||||
await _persist(_kSmartRoute, v);
|
||||
@@ -80,6 +99,13 @@ class SettingsController extends StateNotifier<AppSettings> {
|
||||
|
||||
Future<void> _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<AppSettings> {
|
||||
}
|
||||
}
|
||||
|
||||
// 用 schtasks 建/删最高权限登录触发任务。app 已是管理员 → 可建 /rl highest 任务,
|
||||
// 开机由计划程序静默提权拉起、不弹 UAC。/tr 含空格路径须内嵌引号。
|
||||
Future<void> _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<void> _persist(String key, bool v) async {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
await p.setBool(key, v);
|
||||
|
||||
@@ -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<void> waitAutoConnect(ProviderContainer c, bool want) async {
|
||||
for (var i = 0; i < 60; i++) {
|
||||
if (c.read(settingsProvider).autoConnect == want) return;
|
||||
await Future<void>.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: '应持久化');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user