Files
pangolin/client/lib/state/nodes_provider.dart
wangjia 7add0259b4 chore(client/macos): 清理延迟排查的诊断打点 + debug 注入(最终干净版)
延迟显示已修通。移除排查期的临时诊断:扩展侧 Log 客户端/klog 捕获、log.level=debug 注入、
diag 字段/setDiag;主 app StatsClient 每5帧的 dl/ul/urltest 打印;Dart 侧 [Stats]/[Ping] 打点。
保留全部修复:clash_api 注入取 urltest、sendProviderMessage 统计通道、/proxies history+active
缓存、共享广播流、_onStats 回退 effectiveNode、连接态跳过直连实测。

CURRENT_PROJECT_VERSION 52→53。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 18:03:30 +08:00

225 lines
9.0 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// nodes_provider.dart — 节点清单 + 当前选择 + 实测延迟
//
// 从 GET /v1/nodes 拉取真实节点(含 host/port);拉取后后台对各节点做 TCP 握手
// 实测 per-client 延迟并回填。未登录 / 失败返回空列表(不再伪造演示节点)。
import 'dart:async';
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import '../models/node.dart';
import '../services/api_config.dart';
import '../services/latency_probe.dart';
import 'auth_provider.dart';
import 'connection_provider.dart';
// ── 节点列表 AsyncNotifier ────────────────────────────────────────
/// /v1/nodes 返回 401:access token 过期。供 _fetchWithRefresh 捕获后续期重试。
class _NodesUnauthorized implements Exception {
const _NodesUnauthorized();
}
class NodesNotifier extends AsyncNotifier<List<Node>> {
bool _disposed = false;
DateTime? _lastFetch;
/// 刷新限流:距上次成功拉取不足此间隔则跳过远端(下拉/按钮/进页多次触发只打一次)。
static const _minInterval = Duration(seconds: 10);
@override
Future<List<Node>> build() async {
_disposed = false; // build 重跑表示 notifier 仍在用:必须复位,否则上一轮 onDispose 置的
// true 会让 setLivePing/_measure 永久静默(urltest 回写/延迟实测失效 → 延迟冻成 —)。
ref.onDispose(() => _disposed = true);
// 只关心「是否登录」:token 续期(accessToken 变)不应重建 nodesProvider、清空已测延迟。
final loggedIn = ref.watch(authProvider.select((s) => s.isLoggedIn));
if (!loggedIn) return const [];
try {
final list = await _fetchWithRefresh();
_lastFetch = DateTime.now();
unawaited(_measure(list));
return list;
} catch (_) {
// 首拉失败(网络/鉴权):返回空列表而非抛错;鉴权真过期由 authProvider 登出跳登录页。
return const [];
}
}
/// 刷新节点列表(下拉/按钮/进页调用)。10s 限流;不清空当前列表(后台拉、好了再换),
/// 避免进页/下拉时列表闪空;失败保留旧数据。
Future<void> refresh() async {
final auth = ref.read(authProvider);
if (!auth.isLoggedIn) {
state = const AsyncData([]);
return;
}
if (_lastFetch != null && DateTime.now().difference(_lastFetch!) < _minInterval) {
return; // 限流:10s 内不重复请求远端
}
final result = await AsyncValue.guard(() async {
// ping 是客户端实测(direct TCP / 连接态 urltest 回写),服务端列表不带它(渲染为 0)。
// 刷新时保留已有 ping,否则会把刚测/回写的延迟清成 0 → 连接页延迟闪成 —。
final list = _mergePings(await _fetchWithRefresh());
_lastFetch = DateTime.now();
unawaited(_measure(list));
return list;
});
if (_disposed) return;
// 仅成功才换列表;失败(401/5xx/网络)保留旧列表——绝不因一次刷新失败把节点清空。
if (result.hasValue) state = result;
}
/// 用当前 state 里各节点已有的 ping 回填新拉到的列表(按 uuid;已有>0 才覆盖占位 0)。
List<Node> _mergePings(List<Node> fresh) {
final cur = state.valueOrNull;
if (cur == null) return fresh;
final byUuid = {for (final n in cur) n.uuid: n.ping};
return [
for (final n in fresh)
(byUuid[n.uuid] ?? 0) > 0 ? n.copyWith(ping: byUuid[n.uuid]!) : n
];
}
/// 回写某节点的实测延迟(ms)。连接态由 ConnectionController 用内核 urltest 回填连接节点,
/// 让「连接页延迟」与「节点列表延迟」同源(都读 node.ping)、显示一致。ms<=0 忽略。
void setLivePing(String uuid, int ms) {
if (ms <= 0 || _disposed) return;
final list = state.valueOrNull;
if (list == null) return;
var changed = false;
final updated = [
for (final n in list)
if (n.uuid == uuid && n.ping != ms) (() { changed = true; return n.copyWith(ping: ms); })() else n
];
if (changed) state = AsyncData(updated);
}
/// 后台实测各节点延迟,完成后回填(notifier 未销毁才更新)。
/// 探测失败(返回 0,如连接态全局 TUN 把到节点的 TCP 握手接住)时**保留旧 ping**,
/// 不把已知延迟覆盖成空——否则连上后节点列表延迟会整列消失。
Future<void> _measure(List<Node> list) async {
if (list.isEmpty) return;
// 连接态:全局 TUN 把「到节点的直连 TCP 握手」在本地接住,connect() 几毫秒就返回
// (假值,非真实 RTT)。这会覆盖掉内核 urltest 经 setLivePing 回写的真实延迟(连接页
// 因此显示 ~3ms)。连接时延迟只认 urltest,跳过直连实测;断开后再恢复实测。
if (ref.read(connectionProvider).phase == VpnPhase.on) return;
final pings = await probeAll(
[for (final n in list) (uuid: n.uuid, host: n.host, port: n.port)],
);
if (_disposed) return;
final updated = [
for (final n in list)
n.copyWith(ping: (pings[n.uuid] ?? 0) > 0 ? pings[n.uuid]! : n.ping)
];
state = AsyncData(updated);
}
/// 拉节点;401 时用 refresh token 续期重试一次(自愈),续期失败由 authProvider 登出。
/// 非 200 一律抛异常 → 调用方(refresh)保留旧列表,不把列表清空。
Future<List<Node>> _fetchWithRefresh() async {
final token = ref.read(authProvider).accessToken ?? '';
try {
return await _fetchNodes(token);
} on _NodesUnauthorized {
final ok = await ref.read(authProvider.notifier).refresh();
if (!ok) rethrow; // 续期也失败 = 真过期,交给 authProvider 触发登出
return await _fetchNodes(ref.read(authProvider).accessToken ?? '');
}
}
static Future<List<Node>> _fetchNodes(String accessToken) async {
final uri = Uri.parse('$kApiBaseUrl/v1/nodes');
final resp = await http.get(uri, headers: {
'Authorization': 'Bearer $accessToken',
}).timeout(const Duration(seconds: 10));
// 非 200 抛异常(401 单独类型,供续期重试):绝不返回空列表覆盖掉已有节点。
if (resp.statusCode == 401) throw const _NodesUnauthorized();
if (resp.statusCode != 200) {
throw Exception('nodes fetch ${resp.statusCode}');
}
final body = jsonDecode(resp.body) as Map<String, dynamic>;
final rawList = body['nodes'] as List<dynamic>? ?? [];
return rawList.map((e) {
final m = e as Map<String, dynamic>;
return Node(
uuid: m['id'] as String? ?? '',
code: m['region'] as String? ?? '??',
nameZh: m['name_zh'] as String? ?? '',
nameEn: m['name_en'] as String? ?? '',
tier: m['tier'] as String? ?? 'free',
host: m['host'] as String? ?? '',
port: (m['port'] as num?)?.toInt() ?? 0,
status: m['status'] as String? ?? 'up',
ping: 0, // 由 _measure 实测回填
);
}).toList();
}
}
final nodesProvider =
AsyncNotifierProvider<NodesNotifier, List<Node>>(NodesNotifier.new);
// ── 当前选中的节点 code'AUTO' 表示智能选择(默认)─────────────────
//
// 持久化:用户的选择(智能 / 具体节点)跨重启保存,启动时恢复,供自动连接复用上次选择。
const _kSelectedNode = 'selected_node_code';
class SelectedNodeController extends StateNotifier<String> {
SelectedNodeController() : super(kSmartNodeCode) {
_load();
}
/// 是否已从持久化恢复完(自动连接要等它 true 再读,避免用到未恢复的默认值)。
bool loaded = false;
Future<void> _load() async {
try {
final p = await SharedPreferences.getInstance();
if (!mounted) return;
final v = p.getString(_kSelectedNode);
if (v != null && v.isNotEmpty) state = v;
} catch (_) {
// 测试/无平台:保留默认(智能)。
}
loaded = true;
}
/// 选择节点(智能传 kSmartNodeCode)并持久化。
Future<void> select(String code) async {
state = code;
try {
final p = await SharedPreferences.getInstance();
await p.setString(_kSelectedNode, code);
} catch (_) {}
}
}
final selectedNodeCodeProvider =
StateNotifierProvider<SelectedNodeController, String>((ref) => SelectedNodeController());
/// 是否处于智能选择。
final isSmartSelectProvider = Provider<bool>(
(ref) => ref.watch(selectedNodeCodeProvider) == kSmartNodeCode,
);
/// 实际生效的节点:列表为空时返回占位;智能选择取实测延迟最小者(未测得排后)。
final effectiveNodeProvider = Provider<Node>((ref) {
final nodes = ref.watch(nodesProvider).valueOrNull ?? const <Node>[];
if (nodes.isEmpty) return kPlaceholderNode;
final code = ref.watch(selectedNodeCodeProvider);
if (code == kSmartNodeCode) {
return nodes.reduce((a, b) {
final pa = a.ping > 0 ? a.ping : 1 << 30;
final pb = b.ping > 0 ? b.ping : 1 << 30;
return pa <= pb ? a : b;
});
}
return nodes.firstWhere((n) => n.code == code, orElse: () => nodes.first);
});