7add0259b4
延迟显示已修通。移除排查期的临时诊断:扩展侧 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>
225 lines
9.0 KiB
Dart
225 lines
9.0 KiB
Dart
// 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);
|
||
});
|