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:
wangjia
2026-06-29 17:46:06 +08:00
parent 2080062d8c
commit 8584f0459e
7 changed files with 140 additions and 2 deletions
+45 -2
View File
@@ -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);