Files
pangolin/client/lib/state/connection_provider.dart
T
wangjia 447f3f494e feat(client/macos): P1 原生隧道——PacketTunnel 系统扩展可加载 + libbox 运行
经长链路排查(同机对照可工作的 Tailscale),修复 macOS 系统扩展 realize
失败(OSSystemExtensionErrorDomain code=4)与 libbox 运行时崩溃,使内嵌
sing-box 的系统扩展能在 macOS 15 上激活并启动隧道。

系统扩展 realize(三个叠加根因):
- 扩展自包含:PacketTunnel 加 OTHER_LDFLAGS="" 切断对项目级 CocoaPods 链接
  标志的继承(原会把 flutter_secure_storage 链进扩展);Libbox.xcframework
  改纯 Link(静态),从 Embed Frameworks 移除冗余内嵌
- bundle 名 = 标识符:PRODUCT_NAME 设为 com.pangolin.pangolin.PacketTunnel
- 扩展 Info.plist 补 NSSystemExtensionUsageDescription(网络扩展类别强制要求)
- App Group 改 macOS 原生格式 BYL4KQHMTN.com.pangolin.pangolin;NEMachServiceName
  以其为前缀;扩展补 network.client/server;get-task-allow=false + 签名加 --timestamp
- CFBundleVersion 随构建递增(否则 sysextd 视为同版本不更新)

libbox 运行时:
- startOrReloadService(options:) 传 nil 致空指针 SIGSEGV → 传 LibboxOverrideOptions()
- 默认接口监控阻塞到首个 path 更新再返回,修 "no available network interface"

配套:
- scripts/local_test.sh:build/sign/notarize/copy/run 一条龙(Developer ID + 公证)
- client/macos/sign_libbox.sh:构建期以 Developer ID 重签内嵌 Libbox
- VpnChannel:401 自动刷新 token、详尽 os_log;auth/api 统一走 kApiBaseUrl
- docs/macos-sysext-realize-troubleshooting.html:完整踩坑复盘

WIP / 临时(后续清理):
- 隧道运行时仍在排查:剥离远程 rule-set 后 sing-box 启动卡点未定位
- 含临时诊断代码:main.swift stderr 重定向、box.log 输出、rule-set 剥离、debug 日志
- api_config 仍指向联调节点,发版前还原

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JEHzjEcFzvGwgbxT6Wbt6c
2026-06-21 21:18:33 +08:00

218 lines
7.5 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// connection_provider.dart — 连接状态机(严格三态,状态由内核事件驱动)
//
// 设计约定:
// - UI 调用 toggle(),控制器内部读取有效节点 + 认证令牌,调用 ConnectApi
// 并启动 VpnBridge 子进程。
// - 严禁乐观翻转:VpnPhase.on 必须由 bridge.statusStream 确认后才置。
// - 状态来源:bridge.statusStream(来自内核回调),非 Timer 模拟。
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../bridge/vpn_bridge.dart';
import '../bridge/vpn_bridge_provider.dart';
import '../l10n/app_text.dart';
import '../services/api_config.dart';
import '../services/connect_api.dart';
import 'app_providers.dart';
import 'auth_provider.dart';
import 'nodes_provider.dart';
import 'settings_provider.dart';
// ── 设备 IDMVP 常量;后续由 device_info_plus 取真实 ID)──────────
const _kDeviceId = String.fromEnvironment(
'PANGOLIN_DEVICE_ID',
defaultValue: 'mac-001',
);
// API base URL 统一用 api_config.dart 的 kApiBaseUrl(单一来源,勿再重复声明)。
// ── 连接阶段枚举 ──────────────────────────────────────────────────
/// 连接阶段。严格三态,与设计稿一致。
enum VpnPhase { off, connecting, on }
// ── 连接状态快照 ──────────────────────────────────────────────────
class ConnectionState {
const ConnectionState({required this.phase, this.elapsed = Duration.zero, this.error});
final VpnPhase phase;
final Duration elapsed;
/// 连接失败原因(已本地化);null = 无错误。供 UI 提示,不再静默吞掉。
final String? error;
ConnectionState copyWith({VpnPhase? phase, Duration? elapsed}) =>
ConnectionState(phase: phase ?? this.phase, elapsed: elapsed ?? this.elapsed);
@override
bool operator ==(Object other) =>
other is ConnectionState &&
other.phase == phase &&
other.elapsed == elapsed &&
other.error == error;
@override
int get hashCode => Object.hash(phase, elapsed, error);
}
// ── 状态机 ───────────────────────────────────────────────────────
class ConnectionController extends StateNotifier<ConnectionState> {
ConnectionController(this._ref, this._bridge)
: super(const ConnectionState(phase: VpnPhase.off)) {
// 订阅桥状态流:状态由内核事件驱动,严禁 UI 乐观翻转。
_statusSub = _bridge.statusStream.listen(_onKernelStatus);
}
final Ref _ref;
final VpnBridge _bridge;
StreamSubscription<VpnStatus>? _statusSub;
Timer? _elapsed;
ConnectApi? _api;
// ── 公有 API ───────────────────────────────────────────────────
/// 用户点击连接键:按当前状态决定动作,握手中忽略(禁乐观)。
void toggle() {
switch (state.phase) {
case VpnPhase.off:
_connect();
case VpnPhase.on:
_disconnect();
case VpnPhase.connecting:
break; // 握手进行中,不响应
}
}
/// 节点切换时触发:已连接则重连。
void onNodeChanged() {
if (state.phase == VpnPhase.on || state.phase == VpnPhase.connecting) {
_disconnect().then((_) => _connect());
}
}
// ── 内部 ─────────────────────────────────────────────────────
Future<void> _connect() async {
state = const ConnectionState(phase: VpnPhase.connecting);
final node = _ref.read(effectiveNodeProvider);
final zh = _ref.read(localeProvider) == AppLang.zh;
// 节点未就绪(列表加载中/为空):不连接,提示用户。
if (node.uuid.isEmpty) {
if (mounted) {
state = ConnectionState(
phase: VpnPhase.off,
error: zh ? '节点尚未就绪,请稍候重试' : 'Nodes not ready, please retry',
);
}
return;
}
try {
final configJson = await _fetchConfigWithRefresh(node.uuid);
// bridge.start() 不阻塞至连接建立;on 状态由 statusStream 回调驱动。
await _bridge.start(configJson);
} on ConnectApiException catch (e) {
// 把后端/网络错误冒泡到 UI(原静默回 off,用户不知所以)。
if (mounted) state = ConnectionState(phase: VpnPhase.off, error: zh ? e.messageZh : e.messageEn);
} catch (e) {
if (mounted) {
state = ConnectionState(
phase: VpnPhase.off,
error: zh ? '连接失败,请重试' : 'Connection failed, please retry',
);
}
}
}
/// 取配置;access token 过期(401)时用 refresh token 续期后**重试一次**。
/// 续期失败(refresh 也过期 / 被拒)由 authProvider.refresh() 触发登出 → UI 回登录页。
Future<String> _fetchConfigWithRefresh(String nodeUuid) async {
Future<String> doFetch() {
final token = _ref.read(authProvider).accessToken ?? '';
_api?.dispose();
_api = ConnectApi(baseUrl: kApiBaseUrl, authToken: token);
return _api!.fetchConfig(
nodeId: nodeUuid,
deviceId: _kDeviceId,
// smartRoute 偏好 → 国内分流(#5):国内 IP/域名直连,不走隧道。
splitCN: _ref.read(settingsProvider).smartRoute,
);
}
try {
return await doFetch();
} on ConnectApiException catch (e) {
if (e.statusCode == 401) {
// token 过期 → 续期后重试一次;续期成功则用新 token 再取。
final ok = await _ref.read(authProvider.notifier).refresh();
if (ok) return await doFetch();
}
rethrow;
}
}
Future<void> _disconnect() async {
_stopElapsed();
try {
await _bridge.stop();
} catch (_) {}
if (mounted) state = const ConnectionState(phase: VpnPhase.off);
}
void _onKernelStatus(VpnStatus s) {
if (!mounted) return;
switch (s) {
case VpnStatus.on:
state = state.copyWith(phase: VpnPhase.on);
_startElapsed();
case VpnStatus.connecting:
state = state.copyWith(phase: VpnPhase.connecting);
_stopElapsed();
case VpnStatus.off:
case VpnStatus.error:
state = const ConnectionState(phase: VpnPhase.off);
_stopElapsed();
}
}
void _startElapsed() {
_elapsed?.cancel();
_elapsed = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted && state.phase == VpnPhase.on) {
state = state.copyWith(elapsed: state.elapsed + const Duration(seconds: 1));
}
});
}
void _stopElapsed() {
_elapsed?.cancel();
_elapsed = null;
}
@override
void dispose() {
_statusSub?.cancel();
_stopElapsed();
_api?.dispose();
super.dispose();
}
}
// ── Provider ──────────────────────────────────────────────────────
final connectionProvider =
StateNotifierProvider<ConnectionController, ConnectionState>(
(ref) => ConnectionController(ref, ref.watch(vpnBridgeProvider)),
);
/// 内核实时统计流(上/下行瞬时速率、字节数)。连接页速度行的真实数据源。
final vpnStatsProvider = StreamProvider<VpnStatsEvent>(
(ref) => ref.watch(vpnBridgeProvider).statsStream,
);