merge: 桌面端 PoC M1 — sing-box 子进程 TUN 接线 [tsk_SLCsjNgtmng3]
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
# Pangolin 桌面端 PoC M1 — 接线指南
|
||||
|
||||
**任务**: tsk_SLCsjNgtmng3 · M1 首发打通(macOS TUN + REALITY)
|
||||
|
||||
## 前置条件
|
||||
|
||||
### 1. 下载 sing-box 二进制
|
||||
|
||||
```bash
|
||||
# macOS Apple Silicon
|
||||
cd app/kernel
|
||||
./fetch-desktop-bin.sh darwin arm64
|
||||
|
||||
# macOS Intel
|
||||
./fetch-desktop-bin.sh darwin amd64
|
||||
```
|
||||
|
||||
产物: `app/kernel/dist/desktop/darwin-arm64/sing-box`
|
||||
|
||||
### 2. 配置 TUN 提权(macOS PoC)
|
||||
|
||||
sing-box TUN 模式需要创建 `utun` 接口,必须有 root 权限。PoC 阶段使用 `sudo`:
|
||||
|
||||
```bash
|
||||
# 方式 A: sudoers 免密白名单(推荐,避免每次输密码)
|
||||
SINGBOX_PATH="$(pwd)/app/kernel/dist/desktop/darwin-arm64/sing-box"
|
||||
echo "$(whoami) ALL=(root) NOPASSWD: ${SINGBOX_PATH}" | sudo tee /etc/sudoers.d/pangolin-singbox
|
||||
sudo chmod 440 /etc/sudoers.d/pangolin-singbox
|
||||
|
||||
# 方式 B: 用 setuid(不推荐用于正式版)
|
||||
sudo chown root "${SINGBOX_PATH}"
|
||||
sudo chmod u+s "${SINGBOX_PATH}"
|
||||
```
|
||||
|
||||
> **正式版说明**: macOS 正式版应使用 SMJobBless 注册特权 Helper Daemon,
|
||||
> 并完成 Apple 公证 (notarization)。此 PoC 路径记录在 BACKLOG-11D-HELPER。
|
||||
|
||||
### 3. 获取 REALITY 服务端参数
|
||||
|
||||
从部署节点的 EC2 拿 REALITY 公钥和 UUID:
|
||||
|
||||
```bash
|
||||
# EC2 上 Xray REALITY 密钥对(deploy/xray/secrets/ 或 Bitwarden)
|
||||
ssh ec2 "cat ~/pangolin/xray/secrets/reality_keys.json 2>/dev/null || echo '不存在,需手动生成'"
|
||||
|
||||
# 若未生成,在 EC2 上运行:
|
||||
# docker run --rm ghcr.io/xtls/xray-core x25519 | tee /tmp/reality_keys.txt
|
||||
```
|
||||
|
||||
### 4. 渲染 PoC 配置
|
||||
|
||||
```bash
|
||||
export SERVER_HOST="18.136.60.128" # EC2 公网 IP
|
||||
export SERVER_PORT="11443" # Xray VLESS 端口
|
||||
export REALITY_UUID="your-uuid-here"
|
||||
export REALITY_PUBLIC_KEY="your-x25519-public-key"
|
||||
export REALITY_SHORT_ID="your-short-id"
|
||||
export REALITY_SNI="www.apple.com"
|
||||
|
||||
./app/kernel/poc/gen-poc-config.sh --out /tmp/pangolin-poc-config.json
|
||||
```
|
||||
|
||||
## 验收测试
|
||||
|
||||
### M1 验收步骤
|
||||
|
||||
```bash
|
||||
# 1. 设置二进制路径
|
||||
export PANGOLIN_SINGBOX_BIN="$(pwd)/app/kernel/dist/desktop/darwin-arm64/sing-box"
|
||||
|
||||
# 2. 用渲染后的 config 启动(PoC 会自动 sudo)
|
||||
sudo "${PANGOLIN_SINGBOX_BIN}" run -c /tmp/pangolin-poc-config.json &
|
||||
KERNEL_PID=$!
|
||||
|
||||
# 3. 等待就绪(Clash API)
|
||||
sleep 3
|
||||
|
||||
# 4. 验证出口 IP
|
||||
curl -s https://ifconfig.me # 应显示节点 IP(18.136.60.128 或接近)
|
||||
|
||||
# 5. DNS 泄露检测
|
||||
dig @8.8.8.8 google.com # 通过 VPN 解析
|
||||
dig google.com # 应走 sing-box DNS(不泄露本地 ISP)
|
||||
|
||||
# 6. 流量统计(Clash API)
|
||||
CLASH_PORT="$(cat /tmp/pangolin-poc-config.json | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['experimental']['clash_api']['external_controller'].split(':')[1])")"
|
||||
CLASH_SECRET="$(cat /tmp/pangolin-poc-config.json | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['experimental']['clash_api']['secret'])")"
|
||||
|
||||
curl -s -H "Authorization: Bearer ${CLASH_SECRET}" "http://127.0.0.1:${CLASH_PORT}/connections"
|
||||
|
||||
# 7. 停止(还原路由/DNS)
|
||||
kill "${KERNEL_PID}"
|
||||
sleep 2
|
||||
|
||||
# 8. 验证还原
|
||||
netstat -rn | grep utun # 不应出现 pangolin TUN 条目
|
||||
scutil --dns | head -20 # DNS 应回到系统默认
|
||||
```
|
||||
|
||||
### Dart 集成测试
|
||||
|
||||
```bash
|
||||
cd client
|
||||
flutter test test/bridge/kernel_process_test.dart -v
|
||||
```
|
||||
|
||||
## 架构说明
|
||||
|
||||
```
|
||||
DesktopVpnBridge.start(configJson)
|
||||
│
|
||||
├─ _injectClashApi() 注入随机端口+secret 到 experimental.clash_api
|
||||
├─ _writeConfig() 写 ~/Library/Application Support/com.pangolin.vpn/kernel/
|
||||
│
|
||||
└─ DesktopKernelProcess.spawn(configPath)
|
||||
│
|
||||
├─ _resolveBinaryPath() PANGOLIN_SINGBOX_BIN / 开发目录 / /usr/local/bin
|
||||
├─ Process.start(['sudo', binPath, 'run', '-c', configPath])
|
||||
├─ _waitForClashApi() 轮询 GET /connections 直到就绪(20s 超时)
|
||||
├─ _startStatsPoll() 每秒轮询 /connections → VpnStatsEvent
|
||||
└─ emit VpnStatus.on
|
||||
```
|
||||
|
||||
## 已知限制(PoC 阶段)
|
||||
|
||||
| 限制 | 原因 | 正式版方案 |
|
||||
|------|------|------------|
|
||||
| sudo 提权 | TUN 需 root | SMJobBless Helper + 公证 (BACKLOG-11D-HELPER) |
|
||||
| 单 REALITY outbound | 节点选优未实现 | URLTest 多节点 (11G) |
|
||||
| 统计用 /connections 轮询 | SSE 流实现更复杂 | /traffic SSE 订阅 |
|
||||
| Windows Wintun 未测 | 主验收平台 macOS | M1 验收后补 |
|
||||
| 无 kill-switch | strict_route 做基础保护 | 11G |
|
||||
| Android/iOS | 归 11E/11F | NEPacket/VpnService |
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bash
|
||||
# gen-poc-config.sh — 渲染 REALITY 客户端 PoC 配置(tsk_SLCsjNgtmng3)
|
||||
#
|
||||
# 用法: ./gen-poc-config.sh [--out <output.json>]
|
||||
#
|
||||
# 从环境变量读取参数(须全部提供,无 --干跑 模式):
|
||||
# SERVER_HOST EC2 IP(如 18.136.60.128)
|
||||
# SERVER_PORT Xray VLESS 监听端口(如 11443)
|
||||
# REALITY_UUID 客户端 UUID
|
||||
# REALITY_PUBLIC_KEY REALITY 公钥
|
||||
# REALITY_SHORT_ID REALITY shortId
|
||||
# REALITY_SNI TLS SNI(如 www.apple.com)
|
||||
#
|
||||
# 可选:
|
||||
# CLASH_API_PORT Clash API 端口(默认随机 49152-65535)
|
||||
# CLASH_API_SECRET Clash API secret(默认随机 32 字节 hex)
|
||||
#
|
||||
# 产物默认写到 /tmp/pangolin-poc-config.json(权限 0600)。
|
||||
#
|
||||
# 注意:REALITY 私钥等参数绝不入库,仅在运行时从 EC2 secrets/ 或 Bitwarden 取。
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TMPL="${SCRIPT_DIR}/reality_client.config.json.tmpl"
|
||||
OUT="${1:---out}"
|
||||
if [[ "${OUT}" == "--out" ]]; then
|
||||
OUT="${2:-/tmp/pangolin-poc-config.json}"
|
||||
shift 2 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ── 参数检查 ──────────────────────────────────────────────────────
|
||||
|
||||
_require() {
|
||||
local name="$1"
|
||||
if [[ -z "${!name:-}" ]]; then
|
||||
printf '✗ 环境变量 %s 未设置\n' "$name" >&2
|
||||
printf ' 示例: export %s=<value>\n' "$name" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
_require SERVER_HOST
|
||||
_require SERVER_PORT
|
||||
_require REALITY_UUID
|
||||
_require REALITY_PUBLIC_KEY
|
||||
_require REALITY_SHORT_ID
|
||||
|
||||
REALITY_SNI="${REALITY_SNI:-www.apple.com}"
|
||||
|
||||
# 生成随机 Clash API 端口和 secret(若未指定)
|
||||
if [[ -z "${CLASH_API_PORT:-}" ]]; then
|
||||
CLASH_API_PORT="$(python3 -c 'import random; print(random.randint(49152,65535))')"
|
||||
fi
|
||||
if [[ -z "${CLASH_API_SECRET:-}" ]]; then
|
||||
CLASH_API_SECRET="$(python3 -c 'import secrets; print(secrets.token_hex(32))')"
|
||||
fi
|
||||
|
||||
# ── 渲染模板 ──────────────────────────────────────────────────────
|
||||
|
||||
printf '==> 渲染配置 → %s\n' "$OUT"
|
||||
|
||||
sed \
|
||||
-e "s|__SERVER_HOST__|${SERVER_HOST}|g" \
|
||||
-e "s|__SERVER_PORT__|${SERVER_PORT}|g" \
|
||||
-e "s|__UUID__|${REALITY_UUID}|g" \
|
||||
-e "s|__REALITY_PUBLIC_KEY__|${REALITY_PUBLIC_KEY}|g" \
|
||||
-e "s|__REALITY_SHORT_ID__|${REALITY_SHORT_ID}|g" \
|
||||
-e "s|__REALITY_SNI__|${REALITY_SNI}|g" \
|
||||
-e "s|__CLASH_API_PORT__|${CLASH_API_PORT}|g" \
|
||||
-e "s|__CLASH_API_SECRET__|${CLASH_API_SECRET}|g" \
|
||||
"${TMPL}" > "${OUT}"
|
||||
|
||||
chmod 600 "${OUT}"
|
||||
|
||||
printf '✓ 已写入: %s\n' "$OUT"
|
||||
printf ' Clash API: http://127.0.0.1:%s (secret len=%d)\n' \
|
||||
"$CLASH_API_PORT" "${#CLASH_API_SECRET}"
|
||||
printf ' Server: %s:%s\n' "$SERVER_HOST" "$SERVER_PORT"
|
||||
printf '\n提示: 使用此配置测试前,请确认 sing-box 有 TUN 权限:\n'
|
||||
printf ' sudo sh -c "echo \"%%(whoami)s ALL=(root) NOPASSWD: /path/to/sing-box\" >> /etc/sudoers.d/pangolin"\n'
|
||||
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"_comment": "sing-box 客户端配置模板 — VLESS+REALITY+TUN(PoC tsk_SLCsjNgtmng3)",
|
||||
"_usage": "渲染脚本: app/kernel/poc/gen-poc-config.sh;占位符一律 __UPPER_SNAKE__",
|
||||
|
||||
"log": {
|
||||
"level": "info",
|
||||
"timestamp": true
|
||||
},
|
||||
|
||||
"dns": {
|
||||
"servers": [
|
||||
{
|
||||
"tag": "dns-remote",
|
||||
"address": "tls://1.1.1.1",
|
||||
"address_resolver": "dns-local",
|
||||
"detour": "reality-out"
|
||||
},
|
||||
{
|
||||
"tag": "dns-local",
|
||||
"address": "local",
|
||||
"detour": "direct"
|
||||
},
|
||||
{
|
||||
"tag": "dns-block",
|
||||
"address": "rcode://success"
|
||||
}
|
||||
],
|
||||
"rules": [
|
||||
{ "outbound": "any", "server": "dns-local" },
|
||||
{ "geosite": "cn", "server": "dns-local" },
|
||||
{ "geoip": "private", "server": "dns-local" }
|
||||
],
|
||||
"final": "dns-remote",
|
||||
"independent_cache": true
|
||||
},
|
||||
|
||||
"inbounds": [
|
||||
{
|
||||
"type": "tun",
|
||||
"tag": "tun-in",
|
||||
"inet4_address": "172.19.0.1/30",
|
||||
"inet6_address": "fdfe:dcba:9876::1/126",
|
||||
"mtu": 1492,
|
||||
"auto_route": true,
|
||||
"strict_route": true,
|
||||
"stack": "system",
|
||||
"sniff": true,
|
||||
"sniff_override_destination": false
|
||||
}
|
||||
],
|
||||
|
||||
"outbounds": [
|
||||
{
|
||||
"type": "vless",
|
||||
"tag": "reality-out",
|
||||
"server": "__SERVER_HOST__",
|
||||
"server_port": __SERVER_PORT__,
|
||||
"uuid": "__UUID__",
|
||||
"flow": "xtls-rprx-vision",
|
||||
"tls": {
|
||||
"enabled": true,
|
||||
"server_name": "__REALITY_SNI__",
|
||||
"utls": {
|
||||
"enabled": true,
|
||||
"fingerprint": "chrome"
|
||||
},
|
||||
"reality": {
|
||||
"enabled": true,
|
||||
"public_key": "__REALITY_PUBLIC_KEY__",
|
||||
"short_id": "__REALITY_SHORT_ID__"
|
||||
}
|
||||
},
|
||||
"packet_encoding": "xudp"
|
||||
},
|
||||
{
|
||||
"type": "direct",
|
||||
"tag": "direct"
|
||||
},
|
||||
{
|
||||
"type": "block",
|
||||
"tag": "block"
|
||||
},
|
||||
{
|
||||
"type": "dns",
|
||||
"tag": "dns-out"
|
||||
}
|
||||
],
|
||||
|
||||
"route": {
|
||||
"rules": [
|
||||
{ "protocol": "dns", "outbound": "dns-out" },
|
||||
{ "geosite": "cn", "outbound": "direct" },
|
||||
{ "geoip": "cn", "outbound": "direct" },
|
||||
{ "geoip": "private", "outbound": "direct" }
|
||||
],
|
||||
"final": "reality-out",
|
||||
"auto_detect_interface": true
|
||||
},
|
||||
|
||||
"experimental": {
|
||||
"clash_api": {
|
||||
"external_controller": "127.0.0.1:__CLASH_API_PORT__",
|
||||
"secret": "__CLASH_API_SECRET__"
|
||||
},
|
||||
"cache_file": {
|
||||
"enabled": true,
|
||||
"path": "/tmp/pangolin-poc.db"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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 error(UI 可一键重连)
|
||||
//
|
||||
// 进程生命周期:
|
||||
// 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 Helper;Windows 直接以管理员启动。
|
||||
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 是 arm64,Intel 是 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 的随机高位端口(49152–65535)。
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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 49152–65535', () {
|
||||
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>>());
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user