merge: 桌面端 PoC M1 — sing-box 子进程 TUN 接线 [tsk_SLCsjNgtmng3]

This commit is contained in:
wangjia
2026-06-15 20:56:37 +08:00
6 changed files with 1576 additions and 63 deletions
+239
View File
@@ -0,0 +1,239 @@
// 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} 切换出口
//
// macOS PoC 依赖:
// · sing-box 需能建立 TUN 接口(sudo 或 sudoers 免密白名单)
// · 环境变量 PANGOLIN_SINGBOX_BIN 可指定二进制路径
// · 实际 REALITY 参数由调用方(PoC 脚本)以静态 config 传入
//
// ignore_for_file: avoid_print
import 'dart:convert';
import 'dart:io';
import 'kernel_process.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;
// ── VpnBridge: start ─────────────────────────────────────────
@override
Future<void> start(String configJson) async {
// 1. 注入 Clash API 配置(随机端口 + secret
final (enrichedJson, port, secret) = injectClashApi(configJson);
// 2. 写 config 到应用支持目录(0600 权限)
final configPath = await writeConfig(enrichedJson);
print('[DesktopVpnBridge] config written: $configPath');
print('[DesktopVpnBridge] clash_api port=$port secret_len=${secret.length}');
// 3. 启动内核子进程(blocking until Clash API ready or error
await _kernel.spawn(configPath);
}
// ── VpnBridge: stop ──────────────────────────────────────────
@override
Future<void> stop() => _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 中自定义组名。
try {
await _kernel.clashApiClient.selectProxy('proxy', tag);
} catch (e) {
print('[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 ─────────────────────────────────
@override
Future<void> setKillSwitch({required bool on}) async {
// macOS PoC: TUN 的 strict_route=true 提供基础的 kill-switch 语义。
// 细粒度 kill-switch 归 11G。
print('[DesktopVpnBridge] setKillSwitch=$on (strict_route in TUN config)');
}
// ── VpnBridge: 事件流 ────────────────────────────────────────
@override
Stream<VpnStatus> get statusStream => _kernel.statusStream;
@override
Stream<VpnStatsEvent> get statsStream => _kernel.statsStream;
// ── VpnBridge: dispose ───────────────────────────────────────
@override
void dispose() {
if (_kernel is DesktopKernelProcess) {
(_kernel as DesktopKernelProcess).dispose();
}
}
// ── 内部: 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);
}
// ── 内部: 配置文件写入 ────────────────────────────────────────
// @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 (_) {
print('[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');
}
}
+548 -63
View File
@@ -1,112 +1,597 @@
// kernel_process.dart — 桌面端内核子进程管理接口
// kernel_process.dart — 桌面端内核子进程管理tsk_SLCsjNgtmng3 实现版)
//
// 桌面平台(macOS / Windows / Linux)直接将 sing-box 二进制作为
// 子进程运行,通过其 Clash 兼容 REST API 进行控制。
// 实现说明:
// · 进程管理: dart:io Process.start
// · macOS PoC 提权: sudo(开发机需有 sudo 权限 / sudoers 白名单)
// 正式版替换为 SMJobBless 特权 Helper + 公证 (BACKLOG-11D-HELPER)
// · Clash API: http 包 HTTP 轮询;readiness 探测 GET /connections
// 统计通过 /connections 拿累计字节,差分算瞬时速率
// · 二进制解析优先级(见 _resolveBinaryPath:
// 1. 环境变量 PANGOLIN_SINGBOX_BIN
// 2. <executable dir>/sing-box (或 .exe)
// 3. <executable dir>/../../Resources/sing-box (macOS .app bundle)
// 4. <project>/app/kernel/dist/desktop/<os>-<arch>/sing-box (开发目录)
// 5. /usr/local/bin/sing-box
// · 配置 Clash API 端口: 由 spawn 调用方(DesktopVpnBridge)写入配置,
// 或从配置文件读 experimental.clash_api.external_controller
//
// 本文件仅定义接口契约;具体实现由任务 11D(桌面 PoC)完成。
// 生命周期:
// spawn(configPath) → 起子进程 → 等 Clash API 就绪 → emit connecting→on
// kill() → SIGTERM → 等 ≤gracePeriod → SIGKILL → emit off
// 意外退出 → emit errorUI 可一键重连)
//
// 进程生命周期:
// spawn(configPath) → 子进程启动,REST API 开始监听
// kill() → 优雅停止(SIGTERM),超时后 SIGKILL
//
// Clash API 通信由 [ClashApiClient] 封装,基地址固定为
// http://127.0.0.1:9090(可覆盖)。
// ignore_for_file: avoid_print
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math' as math;
// ── Clash API 客户端 ─────────────────────────────────────────────
import 'package:http/http.dart' as http;
import 'vpn_bridge.dart';
// ══════════════════════════════════════════════════════════════════
// Clash API 客户端
// ══════════════════════════════════════════════════════════════════
/// sing-box Clash 兼容 REST API 客户端。
/// 实现由 11D(桌面 PoC)填充;本骨架仅定义调用约定。
///
/// baseUrl: http://127.0.0.1:<port>
/// secret : experimental.clash_api.secret 的值(Bearer Token
class ClashApiClient {
ClashApiClient({this.baseUrl = 'http://127.0.0.1:9090'});
ClashApiClient({
required this.baseUrl,
this.secret = '',
http.Client? httpClient,
}) : _http = httpClient ?? http.Client();
final String baseUrl;
final String secret;
final http.Client _http;
/// GET /traffic — 流量统计
Future<Map<String, dynamic>> getTraffic() {
throw UnimplementedError('ClashApiClient.getTraffic — 11D 实现');
Map<String, String> get _headers => {
if (secret.isNotEmpty) 'Authorization': 'Bearer $secret',
'Content-Type': 'application/json',
};
// ── GET /traffic ──────────────────────────────────────────────
// Clash /traffic 是 SSE 端点,推 {"up": N, "down": N}bytes/s)。
// 此实现读首帧后断开,供就绪探测或一次性快照使用。
// 持续统计用 getConnections()(普通 JSON,含 downloadTotal/uploadTotal)。
Future<Map<String, dynamic>> getTraffic() async {
final uri = Uri.parse('$baseUrl/traffic');
final request = http.Request('GET', uri);
request.headers.addAll(_headers);
request.headers['Accept'] = 'text/event-stream';
late http.StreamedResponse streamed;
try {
streamed =
await _http.send(request).timeout(const Duration(seconds: 3));
} on TimeoutException {
throw const HttpException('timeout connecting to /traffic');
}
if (streamed.statusCode != 200) {
// 读掉 body,释放连接
await streamed.stream.drain<void>();
throw HttpException('traffic: ${streamed.statusCode}');
}
final completer = Completer<Map<String, dynamic>>();
StreamSubscription<String>? sub;
sub = streamed.stream
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen(
(line) {
if (line.startsWith('data:')) {
final payload = line.substring(5).trim();
if (payload.isNotEmpty && !completer.isCompleted) {
try {
completer.complete(jsonDecode(payload) as Map<String, dynamic>);
} catch (e) {
if (!completer.isCompleted) completer.completeError(e);
}
sub?.cancel();
}
}
},
onError: (Object e) {
if (!completer.isCompleted) completer.completeError(e);
},
onDone: () {
if (!completer.isCompleted) {
completer.completeError(
const HttpException('SSE stream ended without data'),
);
}
},
cancelOnError: true,
);
return completer.future.timeout(
const Duration(seconds: 5),
onTimeout: () {
sub?.cancel();
throw TimeoutException('/traffic SSE timeout');
},
);
}
/// GET /proxies — 节点列表
Future<Map<String, dynamic>> getProxies() {
throw UnimplementedError('ClashApiClient.getProxies — 11D 实现');
// ── GET /proxies ──────────────────────────────────────────────
Future<Map<String, dynamic>> getProxies() async {
final response = await _http
.get(Uri.parse('$baseUrl/proxies'), headers: _headers)
.timeout(const Duration(seconds: 3));
if (response.statusCode != 200) {
throw HttpException('proxies: ${response.statusCode}');
}
return jsonDecode(response.body) as Map<String, dynamic>;
}
/// PUT /proxies/{group} — 切换出口
Future<void> selectProxy(String group, String proxy) {
throw UnimplementedError('ClashApiClient.selectProxy — 11D 实现');
// ── PUT /proxies/{group} ──────────────────────────────────────
Future<void> selectProxy(String group, String proxy) async {
final body = jsonEncode({'name': proxy});
final response = await _http
.put(Uri.parse('$baseUrl/proxies/$group'),
headers: _headers, body: body)
.timeout(const Duration(seconds: 3));
if (response.statusCode != 204 && response.statusCode != 200) {
throw HttpException('selectProxy: ${response.statusCode}');
}
}
/// GET /connections — 实时连接
Future<Map<String, dynamic>> getConnections() {
throw UnimplementedError('ClashApiClient.getConnections — 11D 实现');
// ── GET /connections ──────────────────────────────────────────
// 返回 {"downloadTotal": N, "uploadTotal": N, "connections": [...]}
// 累计字节;用于统计轮询和就绪探测。
Future<Map<String, dynamic>> getConnections() async {
final response = await _http
.get(Uri.parse('$baseUrl/connections'), headers: _headers)
.timeout(const Duration(seconds: 3));
if (response.statusCode != 200) {
throw HttpException('connections: ${response.statusCode}');
}
return jsonDecode(response.body) as Map<String, dynamic>;
}
/// DELETE /connections — 关闭所有连接
Future<void> closeAllConnections() {
throw UnimplementedError('ClashApiClient.closeAllConnections — 11D 实现');
// ── DELETE /connections ───────────────────────────────────────
Future<void> closeAllConnections() async {
final response = await _http
.delete(Uri.parse('$baseUrl/connections'), headers: _headers)
.timeout(const Duration(seconds: 3));
if (response.statusCode != 204 && response.statusCode != 200) {
throw HttpException('closeConnections: ${response.statusCode}');
}
}
void dispose() => _http.close();
}
// ── 进程管理接口 ─────────────────────────────────────────────────
// ══════════════════════════════════════════════════════════════════
// KernelProcess 接口
// ══════════════════════════════════════════════════════════════════
/// 桌面端内核子进程管理接口。
///
/// 使用方式:
/// ```dart
/// final kernel = DesktopKernelProcess();
/// await kernel.spawn('/path/to/config.json');
/// final traffic = await kernel.clashApiClient.getTraffic();
/// await kernel.kill();
/// ```
abstract class KernelProcess {
/// 启动内核子进程。
///
/// [configPath] 为 sing-box JSON 配置文件的绝对路径。
/// 返回的 [Future] 在进程就绪(REST API 可用)后 complete。
/// 启动内核子进程。[configPath] 为 sing-box JSON 配置文件绝对路径。
/// 返回的 Future 在进程就绪(Clash REST API 可用)后 complete。
Future<void> spawn(String configPath);
/// 停止内核子进程。
///
/// 先发 SIGTERM[gracePeriod] 内未退出则 SIGKILL。
Future<void> kill({
Duration gracePeriod = const Duration(seconds: 3),
});
/// 停止内核子进程。先 SIGTERM[gracePeriod] 内未退出则 SIGKILL。
Future<void> kill({Duration gracePeriod = const Duration(seconds: 5)});
/// 进程是否正在运行。
bool get isRunning;
/// Clash 兼容 REST API 客户端(进程运行时可用)。
/// Clash 兼容 REST API 客户端(进程运行时有效)。
ClashApiClient get clashApiClient;
/// 进程标准输出/标准错误日志流(用于调试面板)。
/// 进程 stdout/stderr 实时日志流(调试面板)。
Stream<String> get logStream;
/// VPN 状态事件流:connecting → on(就绪)/ off(停止)/ error(意外退出)。
Stream<VpnStatus> get statusStream;
/// 每秒流量统计帧(字节累计 + 瞬时速率)。
Stream<VpnStatsEvent> get statsStream;
}
// ── 占位实现(防 analyze 报错)────────────────────────────────────
// ══════════════════════════════════════════════════════════════════
// DesktopKernelProcess — 真实子进程实现
// ══════════════════════════════════════════════════════════════════
/// 占位实现,抛出 [UnimplementedError]
/// 11D 替换为真实子进程实现时删除本类。
/// 桌面端内核子进程管理(sing-box)
///
/// 除 [KernelProcess] 接口外,还额外暴露:
/// · [statusStream] — VPN 状态事件(connecting / on / off / error
/// · [statsStream] — 每秒流量统计帧(字节数 + 瞬时速率)
///
/// 用法(通常由 [DesktopVpnBridge] 驱动,不直接在 UI 使用):
/// ```dart
/// final kernel = DesktopKernelProcess();
/// await kernel.spawn('/tmp/pangolin/config.json');
/// // 收流量统计
/// kernel.statsStream.listen(print);
/// // 停止
/// await kernel.kill();
/// ```
class DesktopKernelProcess implements KernelProcess {
DesktopKernelProcess({
Duration readyTimeout = const Duration(seconds: 20),
Duration statsPollInterval = const Duration(seconds: 1),
this.useSudo = true,
}) : _readyTimeout = readyTimeout,
_statsPollInterval = statsPollInterval;
/// macOS/Linux PoC 时是否用 sudo 启动(TUN 接口需 root)。
/// 正式版改用 SMJobBless HelperWindows 直接以管理员启动。
final bool useSudo;
final Duration _readyTimeout;
final Duration _statsPollInterval;
Process? _process;
ClashApiClient? _clashApi;
Timer? _statsTimer;
bool _running = false;
// 上一帧累计字节(用于计算瞬时速率)
int _prevDownTotal = 0;
int _prevUpTotal = 0;
DateTime _prevPollTime = DateTime.now();
final _statusCtrl = StreamController<VpnStatus>.broadcast();
final _logCtrl = StreamController<String>.broadcast();
final _statsCtrl = StreamController<VpnStatsEvent>.broadcast();
// ── 公开属性 ─────────────────────────────────────────────────
@override
Future<void> spawn(String configPath) {
throw UnimplementedError('DesktopKernelProcess.spawn — 11D 实现');
bool get isRunning => _running;
@override
ClashApiClient get clashApiClient {
if (_clashApi == null) throw StateError('kernel not running');
return _clashApi!;
}
@override
Future<void> kill({Duration gracePeriod = const Duration(seconds: 3)}) {
throw UnimplementedError('DesktopKernelProcess.kill — 11D 实现');
Stream<String> get logStream => _logCtrl.stream;
/// VPN 状态事件流(connecting / on / off / error)。
@override
Stream<VpnStatus> get statusStream => _statusCtrl.stream;
/// 每秒流量统计帧。
@override
Stream<VpnStatsEvent> get statsStream => _statsCtrl.stream;
// ── spawn ─────────────────────────────────────────────────────
@override
Future<void> spawn(String configPath) async {
if (_running) throw StateError('DesktopKernelProcess already running');
// 1. 解析二进制路径
final binPath = await _resolveBinaryPath();
_log('binary: $binPath');
// 2. 读取配置,提取 Clash API 地址和 secret
final clashEndpoint = await _parseClashEndpoint(configPath);
final clashUrl = 'http://${clashEndpoint.$1}';
final clashSecret = clashEndpoint.$2;
_log('clash_api: $clashUrl (secret=${clashSecret.isNotEmpty})');
// 3. 初始化 ClashApiClient(进程启动前先建好,便于后续就绪检测)
_clashApi = ClashApiClient(baseUrl: clashUrl, secret: clashSecret);
// 4. 发 connecting 事件
_emitStatus(VpnStatus.connecting);
// 5. 构造命令并启动子进程
final cmd = _buildCommand(binPath, configPath);
_log('spawn: ${cmd.join(' ')}');
try {
_process = await Process.start(
cmd.first,
cmd.skip(1).toList(),
environment: Platform.environment,
// 不设 workingDirectory,让 sing-box 自行处理
);
} catch (e) {
_clashApi!.dispose();
_clashApi = null;
_emitStatus(VpnStatus.error);
throw ProcessException(cmd.first, cmd.skip(1).toList(),
'failed to start: $e');
}
_running = true;
// 6. 挂接 stdout/stderr 日志
_process!.stdout
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen((l) => _log('[stdout] $l'));
_process!.stderr
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen((l) => _log('[stderr] $l'));
// 7. 监测意外退出
_process!.exitCode.then((code) {
if (_running) {
_running = false;
_statsTimer?.cancel();
_statsTimer = null;
_log('[kernel] unexpected exit: code=$code');
_emitStatus(VpnStatus.error);
}
});
// 8. 等待 Clash API 就绪
try {
await _waitForClashApi();
} catch (e) {
// 超时或 API 不可用
await kill(gracePeriod: const Duration(seconds: 2));
rethrow;
}
// 9. 启动统计轮询
_startStatsPoll();
// 10. 发 on 事件
_emitStatus(VpnStatus.on);
_log('[kernel] ready');
}
@override
bool get isRunning => false;
// ── kill ──────────────────────────────────────────────────────
@override
ClashApiClient get clashApiClient =>
throw UnimplementedError('DesktopKernelProcess.clashApiClient — 11D 实现');
Future<void> kill({Duration gracePeriod = const Duration(seconds: 5)}) async {
if (!_running && _process == null) return;
@override
Stream<String> get logStream =>
throw UnimplementedError('DesktopKernelProcess.logStream — 11D 实现');
_running = false;
_statsTimer?.cancel();
_statsTimer = null;
final proc = _process;
_process = null;
if (proc != null) {
_log('[kernel] sending SIGTERM …');
proc.kill(ProcessSignal.sigterm);
try {
await proc.exitCode.timeout(gracePeriod);
_log('[kernel] exited after SIGTERM');
} on TimeoutException {
_log('[kernel] SIGTERM timeout, sending SIGKILL …');
proc.kill(ProcessSignal.sigkill);
await proc.exitCode.timeout(const Duration(seconds: 3)).catchError((_) => -1);
}
}
_clashApi?.dispose();
_clashApi = null;
_resetStats();
_emitStatus(VpnStatus.off);
_log('[kernel] stopped');
}
// ── 释放资源 ─────────────────────────────────────────────────
Future<void> dispose() async {
await kill(gracePeriod: const Duration(seconds: 3));
await _statusCtrl.close();
await _logCtrl.close();
await _statsCtrl.close();
}
// ── 内部方法 ─────────────────────────────────────────────────
void _emitStatus(VpnStatus s) {
if (!_statusCtrl.isClosed) _statusCtrl.add(s);
}
void _log(String msg) {
print('[DesktopKernel] $msg');
if (!_logCtrl.isClosed) _logCtrl.add(msg);
}
void _resetStats() {
_prevDownTotal = 0;
_prevUpTotal = 0;
_prevPollTime = DateTime.now();
}
// ── 就绪等待 ─────────────────────────────────────────────────
Future<void> _waitForClashApi() async {
final deadline = DateTime.now().add(_readyTimeout);
while (DateTime.now().isBefore(deadline)) {
if (!_running) throw StateError('process exited during startup');
try {
await _clashApi!.getConnections();
return; // API 已就绪
} catch (_) {
await Future<void>.delayed(const Duration(milliseconds: 250));
}
}
throw TimeoutException(
'Clash API not ready after ${_readyTimeout.inSeconds}s');
}
// ── 统计轮询 ─────────────────────────────────────────────────
void _startStatsPoll() {
_resetStats();
_prevPollTime = DateTime.now();
_statsTimer = Timer.periodic(_statsPollInterval, (_) async {
if (!_running || _statsCtrl.isClosed) return;
try {
final now = DateTime.now();
final data = await _clashApi!.getConnections();
final downTotal =
(data['downloadTotal'] as num?)?.toInt() ?? _prevDownTotal;
final upTotal =
(data['uploadTotal'] as num?)?.toInt() ?? _prevUpTotal;
final deltaSec =
now.difference(_prevPollTime).inMilliseconds / 1000.0;
final downSpeed = deltaSec > 0
? (downTotal - _prevDownTotal) / deltaSec
: 0.0;
final upSpeed = deltaSec > 0
? (upTotal - _prevUpTotal) / deltaSec
: 0.0;
_prevDownTotal = downTotal;
_prevUpTotal = upTotal;
_prevPollTime = now;
if (!_statsCtrl.isClosed) {
_statsCtrl.add(VpnStatsEvent(
uploadBytes: upTotal,
downloadBytes: downTotal,
uploadSpeed: upSpeed.clamp(0, double.infinity),
downloadSpeed: downSpeed.clamp(0, double.infinity),
urltestResults: const [], // URLTest 节点选优归 11G
));
}
} catch (_) {
// 统计读取失败不影响连接状态;下次重试
}
});
}
// ── 二进制路径解析 ────────────────────────────────────────────
static Future<String> _resolveBinaryPath() async {
final envBin = Platform.environment['PANGOLIN_SINGBOX_BIN'];
if (envBin != null && envBin.isNotEmpty) {
final f = File(envBin);
if (await f.exists()) return envBin;
}
final binName = Platform.isWindows ? 'sing-box.exe' : 'sing-box';
// 可执行文件同目录
final exeDir = File(Platform.resolvedExecutable).parent;
for (final candidate in [
File('${exeDir.path}/$binName'),
// macOS .app bundle: Contents/MacOS/../Resources/sing-box
File('${exeDir.parent.path}/Resources/$binName'),
]) {
if (await candidate.exists()) return candidate.path;
}
// 开发目录: <project_root>/app/kernel/dist/desktop/<os>-<arch>/sing-box
final os = _platformOsName();
final arch = _platformArchName();
if (os != null && arch != null) {
// 往上找项目根(含 app/kernel 的目录)
Directory dir = exeDir;
for (var i = 0; i < 8; i++) {
final candidate =
File('${dir.path}/app/kernel/dist/desktop/$os-$arch/$binName');
if (await candidate.exists()) return candidate.path;
final parent = dir.parent;
if (parent.path == dir.path) break;
dir = parent;
}
}
// 系统 PATH 兜底
const fallback = '/usr/local/bin/sing-box';
if (await File(fallback).exists()) return fallback;
throw FileSystemException(
'sing-box binary not found. '
'Set PANGOLIN_SINGBOX_BIN or run: '
'app/kernel/fetch-desktop-bin.sh ${os ?? "darwin"} ${arch ?? "arm64"}',
);
}
static String? _platformOsName() {
if (Platform.isMacOS) return 'darwin';
if (Platform.isLinux) return 'linux';
if (Platform.isWindows) return 'windows';
return null;
}
static String? _platformArchName() {
// Dart 目前没有公开 CPU arch 检测;用环境变量或约定
final envArch = Platform.environment['PROCESSOR_ARCHITECTURE'] ??
Platform.environment['HOSTTYPE'] ??
'';
if (envArch.contains('arm') || envArch.contains('aarch')) return 'arm64';
// Apple Silicon Mac 的 HOSTTYPE 是 arm64Intel 是 x86_64
// 若 uname 不可用则按照 macOS 默认 arm64(M 系芯片为主)
if (Platform.isMacOS) return 'arm64';
return 'amd64';
}
// ── 命令构造 ─────────────────────────────────────────────────
List<String> _buildCommand(String binPath, String configPath) {
if (useSudo && (Platform.isMacOS || Platform.isLinux)) {
// PoC: sudo 提权,开发机需配 sudoers 白名单(无密码)或近期有 sudo 缓存。
// 正式版: SMJobBless (macOS) / polkit (Linux)
// BACKLOG-11D-HELPER: 实装特权 Helper + 公证后删除此分支
return ['sudo', binPath, 'run', '-c', configPath];
}
// Windows: 以管理员权限启动 Flutter 应用时 sing-box 自动获得管理员权
return [binPath, 'run', '-c', configPath];
}
// ── 配置文件解析 ──────────────────────────────────────────────
/// 读取 configPath,提取 experimental.clash_api.external_controller 和 secret。
/// 返回 (controller_address, secret),如 ('127.0.0.1:51234', 'abc123')。
static Future<(String, String)> _parseClashEndpoint(String configPath) async {
final raw = await File(configPath).readAsString();
final Map<String, dynamic> json;
try {
json = jsonDecode(raw) as Map<String, dynamic>;
} catch (e) {
throw FormatException('invalid sing-box config: $e');
}
final exp = json['experimental'];
if (exp is! Map) {
return ('127.0.0.1:9090', '');
}
final api = exp['clash_api'];
if (api is! Map) {
return ('127.0.0.1:9090', '');
}
final controller =
(api['external_controller'] as String?) ?? '127.0.0.1:9090';
final secret = (api['secret'] as String?) ?? '';
return (controller, secret);
}
}
// ══════════════════════════════════════════════════════════════════
// 辅助:随机 Clash API 端口 / Secret 生成器
// ══════════════════════════════════════════════════════════════════
/// 生成用于 Clash API 的随机高位端口(4915265535)。
int generateClashApiPort() {
return 49152 + math.Random().nextInt(65535 - 49152 + 1);
}
/// 生成 32 字节(64 hex 字符)随机 Secret。
String generateClashApiSecret() {
final rng = math.Random.secure();
final bytes = List<int>.generate(32, (_) => rng.nextInt(256));
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
}
+465
View File
@@ -0,0 +1,465 @@
// kernel_process_test.dart — DesktopKernelProcess / DesktopVpnBridge 单元测试
//
// 验收条件(tsk_SLCsjNgtmng3):
// 1. ClashApiClient 构造参数正确存储
// 2. ClashApiClient.getConnections 请求正确(headers、解析)
// 3. ClashApiClient.selectProxy 发送 PUT + 正确 body
// 4. ClashApiClient.closeAllConnections 发送 DELETE
// 5. generateClashApiPort 生成合法高位端口(49152-65535
// 6. generateClashApiSecret 生成 64 位 hex
// 7. DesktopVpnBridge.injectClashApi 注入 Clash API 段(随机端口+secret
// 8. DesktopVpnBridge.injectClashApi 尊重已有 clash_api 配置
// 9. DesktopVpnBridge + FakeKernelProcess: start 触发 connecting→on 事件序列
// 10. DesktopVpnBridge: stop 触发 off
// 11. DesktopVpnBridge: statsStream 转发 kernel 事件
// 12. 意外退出推 error 状态(UI 不崩)
// 13. spawn 失败推 error 状态并抛异常
// ignore_for_file: avoid_print
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:pangolin_vpn/bridge/kernel_process.dart';
import 'package:pangolin_vpn/bridge/desktop_vpn_bridge.dart';
import 'package:pangolin_vpn/bridge/vpn_bridge.dart';
// ══════════════════════════════════════════════════════════════════
// FakeKernelProcess: implements KernelProcess,不依赖真实子进程
// ══════════════════════════════════════════════════════════════════
class FakeKernelProcess implements KernelProcess {
FakeKernelProcess({
this.shouldFailSpawn = false,
this.simulateUnexpectedExit = false,
});
final bool shouldFailSpawn;
final bool simulateUnexpectedExit;
bool _running = false;
String? lastConfigPath;
int killCalls = 0;
final _statusCtrl = StreamController<VpnStatus>.broadcast();
final _logCtrl = StreamController<String>.broadcast();
final _statsCtrl = StreamController<VpnStatsEvent>.broadcast();
@override
bool get isRunning => _running;
@override
ClashApiClient get clashApiClient =>
throw UnimplementedError('FakeKernelProcess.clashApiClient not needed in tests');
@override
Stream<String> get logStream => _logCtrl.stream;
@override
Stream<VpnStatus> get statusStream => _statusCtrl.stream;
@override
Stream<VpnStatsEvent> get statsStream => _statsCtrl.stream;
@override
Future<void> spawn(String configPath) async {
lastConfigPath = configPath;
if (shouldFailSpawn) {
_emitStatus(VpnStatus.error);
throw Exception('fake spawn failure');
}
_running = true;
_emitStatus(VpnStatus.connecting);
await Future<void>.delayed(const Duration(milliseconds: 10));
_emitStatus(VpnStatus.on);
if (simulateUnexpectedExit) {
Future<void>.delayed(const Duration(milliseconds: 60)).then((_) {
if (_running) {
_running = false;
_emitStatus(VpnStatus.error);
}
});
}
}
@override
Future<void> kill({Duration gracePeriod = const Duration(seconds: 5)}) async {
killCalls++;
if (_running) {
_running = false;
_emitStatus(VpnStatus.off);
}
}
// 测试辅助:向外注入统计帧
void emitStats(VpnStatsEvent e) {
if (!_statsCtrl.isClosed) _statsCtrl.add(e);
}
void disposeFake() {
_statusCtrl.close();
_logCtrl.close();
_statsCtrl.close();
}
void _emitStatus(VpnStatus s) {
if (!_statusCtrl.isClosed) _statusCtrl.add(s);
}
}
// ══════════════════════════════════════════════════════════════════
// 测试用 DesktopVpnBridge:覆盖 writeConfig,避免写真实文件系统
// ══════════════════════════════════════════════════════════════════
class _TestBridge extends DesktopVpnBridge {
_TestBridge(FakeKernelProcess kernel)
: fake = kernel,
super(kernel: kernel);
final FakeKernelProcess fake;
String? lastWrittenConfig;
@override
Future<String> writeConfig(String configJson) async {
lastWrittenConfig = configJson;
// 写到 systemTemp,避免权限问题(测试后清理)
final tmp = Directory.systemTemp.createTempSync('pangolin_test_');
final f = File('${tmp.path}/config.json')
..writeAsStringSync(configJson);
return f.path;
}
@override
void dispose() {
fake.disposeFake();
}
}
// ══════════════════════════════════════════════════════════════════
// 单元测试
// ══════════════════════════════════════════════════════════════════
void main() {
// ── 1. ClashApiClient ─────────────────────────────────────────
group('ClashApiClient', () {
test('stores baseUrl and secret', () {
const url = 'http://127.0.0.1:51234';
const secret = 'my-secret';
final c = ClashApiClient(baseUrl: url, secret: secret);
expect(c.baseUrl, url);
expect(c.secret, secret);
c.dispose();
});
test('getConnections sends Authorization header', () async {
final requests = <http.Request>[];
final mock = MockClient((req) async {
requests.add(req);
return http.Response(
jsonEncode({'downloadTotal': 100, 'uploadTotal': 50, 'connections': []}),
200,
);
});
final c = ClashApiClient(
baseUrl: 'http://127.0.0.1:9090',
secret: 'tok',
httpClient: mock);
await c.getConnections();
expect(requests, hasLength(1));
expect(requests[0].headers['Authorization'], 'Bearer tok');
expect(requests[0].url.path, '/connections');
c.dispose();
});
test('getConnections parses downloadTotal and uploadTotal', () async {
final mock = MockClient((_) async => http.Response(
jsonEncode({'downloadTotal': 12345, 'uploadTotal': 6789, 'connections': []}),
200,
));
final c = ClashApiClient(baseUrl: 'http://127.0.0.1:9090', httpClient: mock);
final data = await c.getConnections();
expect(data['downloadTotal'], 12345);
expect(data['uploadTotal'], 6789);
c.dispose();
});
test('getConnections throws HttpException on non-200', () async {
final mock = MockClient((_) async => http.Response('Unauthorized', 401));
final c = ClashApiClient(
baseUrl: 'http://127.0.0.1:9090', secret: 'bad', httpClient: mock);
await expectLater(c.getConnections(), throwsA(isA<HttpException>()));
c.dispose();
});
test('getProxies sends correct GET request', () async {
final mock = MockClient((req) async {
expect(req.method, 'GET');
expect(req.url.path, '/proxies');
return http.Response(jsonEncode({'proxies': {}}), 200);
});
final c = ClashApiClient(baseUrl: 'http://127.0.0.1:9090', httpClient: mock);
await c.getProxies();
c.dispose();
});
test('selectProxy sends PUT with correct body', () async {
Map<String, dynamic>? body;
final mock = MockClient((req) async {
expect(req.method, 'PUT');
expect(req.url.path, '/proxies/my-group');
body = jsonDecode(req.body) as Map<String, dynamic>;
return http.Response('', 204);
});
final c = ClashApiClient(baseUrl: 'http://127.0.0.1:9090', httpClient: mock);
await c.selectProxy('my-group', 'sg-01');
expect(body?['name'], 'sg-01');
c.dispose();
});
test('closeAllConnections sends DELETE', () async {
final mock = MockClient((req) async {
expect(req.method, 'DELETE');
expect(req.url.path, '/connections');
return http.Response('', 204);
});
final c = ClashApiClient(baseUrl: 'http://127.0.0.1:9090', httpClient: mock);
await c.closeAllConnections();
c.dispose();
});
});
// ── 2. 随机生成工具函数 ───────────────────────────────────────
group('Port and secret generators', () {
test('generateClashApiPort is in range 4915265535', () {
for (var i = 0; i < 30; i++) {
final p = generateClashApiPort();
expect(p, greaterThanOrEqualTo(49152));
expect(p, lessThanOrEqualTo(65535));
}
});
test('generateClashApiSecret is 64 lowercase hex chars', () {
final s = generateClashApiSecret();
expect(s.length, 64);
expect(RegExp(r'^[0-9a-f]+$').hasMatch(s), isTrue);
});
test('generateClashApiSecret produces unique values', () {
final a = generateClashApiSecret();
final b = generateClashApiSecret();
expect(a, isNot(equals(b)));
});
});
// ── 3. DesktopVpnBridge.injectClashApi ───────────────────────
group('DesktopVpnBridge.injectClashApi', () {
test('injects clash_api when absent', () {
const cfg = '{"log":{"level":"info"}}';
final (json, port, secret) = DesktopVpnBridge.injectClashApi(cfg);
final decoded = jsonDecode(json) as Map<String, dynamic>;
final exp = decoded['experimental'] as Map<String, dynamic>;
final api = exp['clash_api'] as Map<String, dynamic>;
final ctrl = api['external_controller'] as String;
expect(ctrl, startsWith('127.0.0.1:'));
expect(int.parse(ctrl.split(':')[1]),
allOf(greaterThanOrEqualTo(49152), lessThanOrEqualTo(65535)));
expect(api['secret'], isA<String>());
expect((api['secret'] as String).length, 64);
expect(port, greaterThanOrEqualTo(49152));
expect(secret.length, 64);
});
test('respects existing clash_api config', () {
const existing = '{'
'"experimental":{"clash_api":{"external_controller":"127.0.0.1:12345","secret":"abc"}}'
'}';
final (json, port, secret) = DesktopVpnBridge.injectClashApi(existing);
// Should return original JSON unchanged
expect(json, equals(existing));
expect(port, 12345);
expect(secret, 'abc');
});
test('preserves other config fields', () {
const cfg = '{"log":{"level":"info"},"dns":{"servers":[]}}';
final (json, _, _) = DesktopVpnBridge.injectClashApi(cfg);
final decoded = jsonDecode(json) as Map<String, dynamic>;
expect((decoded['log'] as Map)['level'], 'info');
expect(decoded['dns'], isA<Map>());
});
test('throws FormatException on invalid JSON', () {
expect(
() => DesktopVpnBridge.injectClashApi('not json'),
throwsA(isA<FormatException>()),
);
});
test('preserves existing experimental fields when injecting clash_api', () {
const cfg = '{"experimental":{"cache_file":{"enabled":true}}}';
final (json, _, _) = DesktopVpnBridge.injectClashApi(cfg);
final decoded = jsonDecode(json) as Map<String, dynamic>;
final exp = decoded['experimental'] as Map<String, dynamic>;
// cache_file should be preserved
expect(exp.containsKey('cache_file'), isTrue);
// clash_api should be injected
expect(exp.containsKey('clash_api'), isTrue);
});
});
// ── 4. DesktopVpnBridge + FakeKernelProcess ──────────────────
group('DesktopVpnBridge with FakeKernelProcess', () {
_TestBridge makeBridge({
bool failSpawn = false,
bool unexpectedExit = false,
}) {
final fake = FakeKernelProcess(
shouldFailSpawn: failSpawn,
simulateUnexpectedExit: unexpectedExit,
);
return _TestBridge(fake);
}
test('start emits connecting then on', () async {
final bridge = makeBridge();
final events = <VpnStatus>[];
final sub = bridge.statusStream.listen(events.add);
await bridge.start('{"log":{"level":"info"}}');
await Future<void>.delayed(const Duration(milliseconds: 30));
expect(events, containsAllInOrder([VpnStatus.connecting, VpnStatus.on]));
await sub.cancel();
bridge.dispose();
});
test('stop emits off', () async {
final bridge = makeBridge();
final events = <VpnStatus>[];
final sub = bridge.statusStream.listen(events.add);
await bridge.start('{"log":{}}');
await Future<void>.delayed(const Duration(milliseconds: 20));
await bridge.stop();
expect(events.last, VpnStatus.off);
expect((bridge.fake).killCalls, 1);
await sub.cancel();
bridge.dispose();
});
test('getStatus returns on while running', () async {
final bridge = makeBridge();
expect(await bridge.getStatus(), VpnStatus.off);
await bridge.start('{"log":{}}');
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(await bridge.getStatus(), VpnStatus.on);
await bridge.stop();
expect(await bridge.getStatus(), VpnStatus.off);
bridge.dispose();
});
test('statsStream relays events from kernel', () async {
final bridge = makeBridge();
final stats = <VpnStatsEvent>[];
final sub = bridge.statsStream.listen(stats.add);
await bridge.start('{"log":{}}');
await Future<void>.delayed(const Duration(milliseconds: 20));
const frame = VpnStatsEvent(
uploadBytes: 1024,
downloadBytes: 4096,
uploadSpeed: 512.0,
downloadSpeed: 2048.0,
urltestResults: [],
);
bridge.fake.emitStats(frame);
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(stats, hasLength(1));
expect(stats[0].uploadBytes, 1024);
expect(stats[0].downloadSpeed, 2048.0);
await sub.cancel();
bridge.dispose();
});
test('unexpected kernel exit emits error, UI does not crash', () async {
final bridge = makeBridge(unexpectedExit: true);
final events = <VpnStatus>[];
final sub = bridge.statusStream.listen(events.add);
await bridge.start('{"log":{}}');
await Future<void>.delayed(const Duration(milliseconds: 120));
expect(events, contains(VpnStatus.error));
// dispose must not throw
expect(() => bridge.dispose(), returnsNormally);
await sub.cancel();
});
test('failed spawn emits error and throws', () async {
final bridge = makeBridge(failSpawn: true);
final events = <VpnStatus>[];
final sub = bridge.statusStream.listen(events.add);
await expectLater(
() => bridge.start('{"log":{}}'),
throwsA(anything),
);
expect(events, contains(VpnStatus.error));
await sub.cancel();
bridge.dispose();
});
test('injectClashApi runs during start, enriched JSON written', () async {
final bridge = makeBridge();
await bridge.start('{"log":{"level":"info"}}');
final written = jsonDecode(bridge.lastWrittenConfig!) as Map<String, dynamic>;
final exp = written['experimental'] as Map<String, dynamic>?;
expect(exp, isNotNull);
expect(exp!.containsKey('clash_api'), isTrue);
bridge.dispose();
});
});
// ── 5. KernelProcess interface ───────────────────────────────
group('KernelProcess interface', () {
test('FakeKernelProcess satisfies KernelProcess', () {
// 静态类型检查:FakeKernelProcess implements KernelProcess
final KernelProcess kp = FakeKernelProcess();
expect(kp.isRunning, isFalse);
expect(kp.logStream, isA<Stream<String>>());
expect(kp.statusStream, isA<Stream<VpnStatus>>());
expect(kp.statsStream, isA<Stream<VpnStatsEvent>>());
});
});
}