Files
pangolin/client/lib/state/connection_provider.dart
T
wangjia 76dbdcfa83 test(client): L2 点连接能连上 流程 + ConnectApi 注入(支柱1)
- connection_provider:加 connectApiFactoryProvider(原内部 new ConnectApi 改成
  可注入工厂,支柱1 接缝即接口)——默认行为不变
- test/unit/flow_connect_test.dart:真 toggle → fetchConfig(注入 MockClient 回占位
  config)→ bridge.start → statusStream 确认 → on → 再 toggle → off;
  验证「非乐观翻转」红线(on 必由 bridge 确认)
- 全量 unit/widget 47 全过(重构不破坏)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:13:40 +08:00

226 lines
7.9 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 = _ref.read(connectApiFactoryProvider)(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,
);
/// 按 authToken 造 ConnectApi 的工厂(支柱 1:接缝即接口)。默认走真控制面;
/// 测试 override 注入 MockClient,即可驱动「连接成功」路径而不打真网络。
typedef ConnectApiFactory = ConnectApi Function(String authToken);
final connectApiFactoryProvider = Provider<ConnectApiFactory>(
(_) => (authToken) => ConnectApi(baseUrl: kApiBaseUrl, authToken: authToken),
);