Files
pangolin/client/lib/bridge/desktop_vpn_bridge.dart
wangjia 322d806876 fix+build(client/windows): 复查修缮——一键构建脚本 + 修 README dart-define 漏项 + 清 lint
- 新增 client/windows/build.ps1:拉内核→带 --dart-define 构建→出安装包一键串联,
  规避 Release 漏 --dart-define 导致默认连 localhost、登录「网络请求失败」的坑。
- README:Release 命令补回 --dart-define 并加显式警告,指向一键脚本。
- 清 4 个 lint warning:desktop_vpn_bridge 多余 cast→pattern match、去多余非空断言;
  nav_sidebar 删未用 import;vpn_bridge_mock 加 killSwitchEnabled getter。
- 同步 window_manager/tray_manager/screen_retriever 插件注册生成文件(win+macos)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:17:13 +08:00

377 lines
14 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// desktop_vpn_bridge.dart — 桌面端 VpnBridge 实现(tsk_SLCsjNgtmng3
//
// 实现 VpnBridge 接口,底层驱动 KernelProcess(默认使用 DesktopKernelProcess)。
//
// 关键职责:
// 1. start(configJson):
// - 注入 experimental.clash_api(随机高位端口 + 随机 secret)
// - 写 config 到 <AppSupport>/pangolin/kernel/config_<ts>.json(权限 0600
// - 调 kernel.spawn(configPath)
// 2. stop(): kernel.kill() + 取消自动重连
// 3. statusStream / statsStream: 代理 KernelProcess 事件流
// 4. selectOutbound: 通过 Clash API PUT /proxies/{group} 切换出口(M4
// 5. setKillSwitch: 修改 TUN strict_route 并重载内核(M5
// 6. 自动退避重连: 内核崩溃后 1s/2s/4s/8s/16s/30s 上限自动重试(M5
//
// macOS PoC 依赖:
// · sing-box 需能建立 TUN 接口(sudo 或 sudoers 免密白名单)
// · 环境变量 PANGOLIN_SINGBOX_BIN 可指定二进制路径
// · 实际 REALITY 参数由调用方(PoC 脚本)以静态 config 传入
//
// ignore_for_file: avoid_print
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'kernel_process.dart';
import 'log.dart';
import 'vpn_bridge.dart';
/// 桌面端 VPN 桥接实现(macOS / Linux / Windows PoC)。
///
/// 使用示例(PoC 命令行测试):
/// ```dart
/// final bridge = DesktopVpnBridge();
/// bridge.statusStream.listen(print);
/// bridge.statsStream.listen(print);
/// await bridge.start(jsonEncode(myConfig));
/// // ...
/// await bridge.stop();
/// bridge.dispose();
/// ```
class DesktopVpnBridge implements VpnBridge {
/// 构造函数。
///
/// [kernel] 可注入假实现,用于测试。默认创建 [DesktopKernelProcess]。
/// [configDirOverride] 仅供测试使用,覆盖配置写入目录(否则写到应用支持目录)。
DesktopVpnBridge({
KernelProcess? kernel,
bool useSudo = true,
String? configDirOverride,
}) : _kernel = kernel ??
DesktopKernelProcess(
readyTimeout: const Duration(seconds: 20),
statsPollInterval: const Duration(seconds: 1),
useSudo: useSudo,
),
_configDirOverride = configDirOverride;
final KernelProcess _kernel;
final String? _configDirOverride;
// ── M5: Kill-switch 状态 ──────────────────────────────────────
bool _killSwitchEnabled = false;
// ── M5: 自动退避重连状态 ─────────────────────────────────────
// 记录用户最初传入的 config(未注入 Clash API 之前的原始字符串),
// 重连时重用,保持每次重连都有新 port+secret。
String? _lastUserConfigJson;
bool _shouldAutoReconnect = false;
int _reconnectAttempt = 0;
Timer? _reconnectTimer;
StreamSubscription<VpnStatus>? _kernelStatusSub;
// ── 退避延迟序列(秒):1/2/4/8/16/30/30/…
static const _kRetryDelaysSec = [1, 2, 4, 8, 16, 30];
// ── VpnBridge: start ─────────────────────────────────────────
@override
Future<void> start(String configJson) async {
// 保存原始 config(未注入 Clash API),用于重连
_lastUserConfigJson = configJson;
_shouldAutoReconnect = true;
// 注意:不在此重置 _reconnectAttempt,由 stop() 和重连成功回调负责重置,
// 确保连续 start 失败时退避延迟单调递增。
// 订阅内核状态流:内核崩溃时自动触发退避重连
_kernelStatusSub?.cancel();
_kernelStatusSub = _kernel.statusStream.listen((status) {
if (status == VpnStatus.error && _shouldAutoReconnect) {
_scheduleReconnect();
}
});
// 1. 将 kill-switch 偏好写入 configstrict_route 字段)
final configWithKs = applyKillSwitchToConfig(configJson, _killSwitchEnabled);
// 2. 注入 Clash API 配置(随机端口 + secret
final (enrichedJson, port, secret) = injectClashApi(configWithKs);
// 3. 写 config 到应用支持目录(0600 权限)
final configPath = await writeConfig(enrichedJson);
logLine('DesktopVpnBridge', 'config written: $configPath');
logLine('DesktopVpnBridge', 'clash_api port=$port secret_len=${secret.length}');
logLine('DesktopVpnBridge', 'killSwitch=$_killSwitchEnabled');
// 4. 启动内核子进程(blocking until Clash API ready or error
await _kernel.spawn(configPath);
}
// ── VpnBridge: stop ──────────────────────────────────────────
@override
Future<void> stop() async {
// 取消自动重连,防止 kill 后再次被重连定时器触发
_shouldAutoReconnect = false;
_reconnectTimer?.cancel();
_reconnectTimer = null;
_kernelStatusSub?.cancel();
_kernelStatusSub = null;
_reconnectAttempt = 0;
await _kernel.kill(gracePeriod: const Duration(seconds: 5));
}
// ── VpnBridge: getStatus ─────────────────────────────────────
@override
Future<VpnStatus> getStatus() async {
return _kernel.isRunning ? VpnStatus.on : VpnStatus.off;
}
// ── VpnBridge: selectOutbound ─────────────────────────────────
@override
Future<void> selectOutbound(String tag) async {
if (!_kernel.isRunning) {
throw StateError('kernel not running; cannot selectOutbound');
}
// sing-box Selector 出口组名固定为 "proxy"(见 config 模板 outbounds[].tag)。
// tag 可以是单节点 tag(如 "reality-out")或 urltest 组 tag(如 "auto-select")。
try {
await _kernel.clashApiClient.selectProxy('proxy', tag);
} catch (e) {
logLine('DesktopVpnBridge', 'selectOutbound(tag=$tag) error: $e');
rethrow;
}
}
// ── VpnBridge: getActiveOutbound ─────────────────────────────
@override
Future<String> getActiveOutbound() async {
if (!_kernel.isRunning) return 'auto';
try {
final data = await _kernel.clashApiClient.getProxies();
final proxies = data['proxies'];
if (proxies is Map) {
final proxy = proxies['proxy'];
if (proxy is Map) {
return (proxy['now'] as String?) ?? 'auto';
}
}
} catch (_) {}
return 'auto';
}
// ── VpnBridge: setKillSwitch ─────────────────────────────────
/// 设置 Kill-switch 开关。
///
/// Kill-switch 通过修改 TUN inbound 的 `strict_route` 字段实现:
/// - `on=true`: `strict_route: true` → 内核停止时流量被系统丢弃
/// - `on=false`: `strict_route: false` → 内核停止时流量走正常路由
///
/// 若内核当前正在运行,会触发一次静默重载(先 kill,再以新 config 重启)。
@override
Future<void> setKillSwitch({required bool on}) async {
if (_killSwitchEnabled == on) return;
_killSwitchEnabled = on;
logLine('DesktopVpnBridge', 'setKillSwitch=$on');
// 若内核正在运行,用新设置重载(会有约 1~2s 重连)
if (_kernel.isRunning && _lastUserConfigJson != null) {
logLine('DesktopVpnBridge', 'reloading kernel for killSwitch change');
// 暂停自动重连,避免 kill 触发重连定时器
final wasAutoReconnect = _shouldAutoReconnect;
_shouldAutoReconnect = false;
_reconnectTimer?.cancel();
_kernelStatusSub?.cancel();
_kernelStatusSub = null;
await _kernel.kill(gracePeriod: const Duration(seconds: 3));
_shouldAutoReconnect = wasAutoReconnect;
// start() 会重新订阅 _kernelStatusSub
await start(_lastUserConfigJson!);
}
}
// ── VpnBridge: 事件流 ────────────────────────────────────────
@override
Stream<VpnStatus> get statusStream => _kernel.statusStream;
@override
Stream<VpnStatsEvent> get statsStream => _kernel.statsStream;
// ── VpnBridge: dispose ───────────────────────────────────────
@override
void dispose() {
_shouldAutoReconnect = false;
_reconnectTimer?.cancel();
_reconnectTimer = null;
_kernelStatusSub?.cancel();
_kernelStatusSub = null;
if (_kernel case final DesktopKernelProcess k) {
k.dispose();
}
}
// ── M5: 退避重连调度 ─────────────────────────────────────────
/// 安排下一次退避重连。
/// 延迟序列:1s → 2s → 4s → 8s → 16s → 30s → 30s → …
void _scheduleReconnect() {
if (!_shouldAutoReconnect || _lastUserConfigJson == null) return;
_reconnectTimer?.cancel();
final delaySec = _kRetryDelaysSec[
_reconnectAttempt.clamp(0, _kRetryDelaysSec.length - 1)];
_reconnectAttempt++;
logLine('DesktopVpnBridge',
'auto-reconnect in ${delaySec}s (attempt $_reconnectAttempt)');
_reconnectTimer = Timer(Duration(seconds: delaySec), () async {
if (!_shouldAutoReconnect || _lastUserConfigJson == null) return;
try {
logLine('DesktopVpnBridge', 'auto-reconnect: attempting start...');
await start(_lastUserConfigJson!);
_reconnectAttempt = 0; // 成功后重置退避计数
logLine('DesktopVpnBridge', 'auto-reconnect: success');
} catch (e) {
logLine('DesktopVpnBridge', 'auto-reconnect: start failed: $e');
// start() 失败(内核未能启动)→ 手动调度下一次重试
if (_shouldAutoReconnect) _scheduleReconnect();
}
});
}
// ── 内部: Clash API 注入(@visibleForTesting)─────────────────
/// 检查 configJson 中是否有 experimental.clash_api;若无则注入随机端口+secret。
/// 返回 (修改后 JSON, 端口, secret)。
// @visibleForTesting
static (String, int, String) injectClashApi(String configJson) {
late Map<String, dynamic> cfg;
try {
cfg = jsonDecode(configJson) as Map<String, dynamic>;
} catch (e) {
throw FormatException('invalid configJson: $e');
}
// 若已有 clash_api,尊重现有值
final rawExp = cfg['experimental'];
final experimental = (rawExp is Map)
? rawExp.cast<String, dynamic>()
: <String, dynamic>{};
if (experimental.containsKey('clash_api')) {
final api =
(experimental['clash_api'] as Map).cast<String, dynamic>();
final ctrl =
(api['external_controller'] as String?) ?? '127.0.0.1:9090';
final secret = (api['secret'] as String?) ?? '';
final port = int.tryParse(ctrl.split(':').last) ?? 9090;
return (configJson, port, secret);
}
// 注入随机 port + secret
final port = generateClashApiPort();
final secret = generateClashApiSecret();
final updated = {
...cfg,
'experimental': {
...experimental,
'clash_api': {
'external_controller': '127.0.0.1:$port',
'secret': secret,
},
},
};
return (jsonEncode(updated), port, secret);
}
// ── 内部: Kill-switch 注入(@visibleForTesting)──────────────
/// 将 kill-switch 偏好写入 configJson 的 TUN inbound `strict_route` 字段。
///
/// - `killSwitch=true`: `strict_route: true`(所有流量绑定 TUN,内核停止即断流)
/// - `killSwitch=false`: `strict_route: false`(内核停止后流量走默认路由)
///
/// 若配置中没有 TUN inbound,原样返回(不修改)。
// @visibleForTesting
static String applyKillSwitchToConfig(String configJson, bool killSwitch) {
late Map<String, dynamic> cfg;
try {
cfg = jsonDecode(configJson) as Map<String, dynamic>;
} catch (_) {
return configJson; // 解析失败,原样返回
}
final inbounds = cfg['inbounds'];
if (inbounds is! List) return configJson;
bool changed = false;
final updatedInbounds = inbounds.map((ib) {
if (ib is Map && ib['type'] == 'tun') {
changed = true;
return <String, dynamic>{...Map<String, dynamic>.from(ib), 'strict_route': killSwitch};
}
return ib;
}).toList();
if (!changed) return configJson;
return jsonEncode(<String, dynamic>{...cfg, 'inbounds': updatedInbounds});
}
// ── 内部: 配置文件写入 ────────────────────────────────────────
// @visibleForTesting
Future<String> writeConfig(String configJson) async {
final dir = await _resolveConfigDir();
await dir.create(recursive: true);
final ts = DateTime.now().millisecondsSinceEpoch;
final file = File('${dir.path}/config_$ts.json');
await file.writeAsString(configJson, flush: true);
// 0600 权限(仅当前用户可读写)
if (!Platform.isWindows) {
try {
await Process.run('chmod', ['600', file.path]);
} catch (_) {
logLine('DesktopVpnBridge', 'warning: chmod 600 failed for ${file.path}');
}
}
return file.path;
}
Future<Directory> _resolveConfigDir() async {
if (_configDirOverride != null) {
return Directory(_configDirOverride);
}
return _defaultConfigDir();
}
static Future<Directory> _defaultConfigDir() async {
if (Platform.isMacOS) {
final home = Platform.environment['HOME'] ?? '/tmp';
return Directory(
'$home/Library/Application Support/com.pangolin.vpn/kernel');
}
if (Platform.isLinux) {
final base = Platform.environment['XDG_DATA_HOME'] ??
'${Platform.environment['HOME'] ?? '/tmp'}/.local/share';
return Directory('$base/pangolin-vpn/kernel');
}
if (Platform.isWindows) {
final appData =
Platform.environment['LOCALAPPDATA'] ?? r'C:\ProgramData';
return Directory('$appData\\Pangolin\\kernel');
}
return Directory('/tmp/pangolin/kernel');
}
}