@@ -92,6 +92,8 @@ const _kUrltestStale = Duration(seconds: 45);
// stats 帧在此时长内到过即视为「在流」。超过(app 挂起/唤醒未恢复)时看门狗路径 A 不判活,
// 避免把 app 被挂起的空档错算成节点死。须 > 原生 stats 轮询间隔(~1s),留足余量。
const _kStatsLive = Duration ( seconds: 10 ) ;
// 无备用节点时,弱网抖动判「节点异常」先自动重连当前节点的最大次数;超过才真报错。#18
const _kMaxAutoReconnect = 3 ;
// ── 状态机 ───────────────────────────────────────────────────────
@@ -156,6 +158,9 @@ class ConnectionController extends StateNotifier<ConnectionState> {
ConnectApi ? _api ;
// 实际所连节点(连接时锁定):看门狗探测/判活针对它,而非会随 ping 漂移的 effectiveNode。
Node ? _connectedNode ;
// 无备用节点时「先自动重连当前节点」的连续尝试计数;连接恢复健康(urltest 成功)或
// 用户主动连接时清零,只有持续失败才耗尽额度后真报「节点异常」。#18
int _autoReconnectAttempts = 0 ;
// 免费版:连接时锁定的剩余额度(秒);连接期按「_freeRemainingSec − 已用秒」倒计时,
// 归零即自动切断(_onFreeQuotaExhausted)。null = 会员/额度不限,不倒计时。
int ? _freeRemainingSec ;
@@ -166,6 +171,7 @@ class ConnectionController extends StateNotifier<ConnectionState> {
void toggle ( ) {
switch ( state . phase ) {
case VpnPhase . off:
_autoReconnectAttempts = 0 ; // 用户主动连接:重置弱网自动重连额度(#18)
_connect ( ) ;
case VpnPhase . on:
_userDisconnect = true ; // 用户主动断开:其 kernel off 不当作节点异常
@@ -312,15 +318,19 @@ class ConnectionController extends StateNotifier<ConnectionState> {
// 必须排除 connecting 握手期的瞬态 off——macOS NE 连接序列是 off→connecting→off→
// connecting→on,connecting 期的 off 是握手抖动、不是异常(否则一连接就误报「节点异常」)。
final wasConnected = state . phase = = VpnPhase . on ;
_stopElapsed ( ) ;
_stopWatchdog ( ) ;
if ( ! _userDisconnect & & wasConnected & & _offNotice = = null ) {
_offNotice = _ref . read ( appTextProvider ) . nodeUnhealthyError ;
// 意外掉线(非用户主动、曾连上;弱网抖动最常见的就是这条 kernel off)。先自动重连
// **当前节点**(不换节点——弱网是本地网络问题,换节点无益还会来回横跳),重试用尽才
// 报「节点异常」。(wasConnected 闸:重连握手期再掉线时 phase 已非 on,不自我循环触发。)#18
logLine ( ' Watchdog ' , ' unexpected kernel ${ s . name } after connected → node interrupted ' ) ;
unawaited ( _ref . read ( nodesProvider . notifier ) . refresh ( ) ) ;
_userDisconnect = false ;
unawaited ( _handleUnexpectedOff ( ) ) ;
return ;
}
state = ConnectionState ( phase: VpnPhase . off , error: _offNotice ) ;
_userDisconnect = false ;
_stopElapsed ( ) ;
_stopWatchdog ( ) ;
_refreshQuota ( ) ; // 内核掉线也算会话结束 → 刷新「今日剩余」
}
}
@@ -344,6 +354,7 @@ class ConnectionController extends StateNotifier<ConnectionState> {
if ( ds . isEmpty ) return ;
// urltest 成功:经 proxy 出站(REALITY 到节点)真实可达 → 记录时刻(供路径 A 判活)+ 回写延迟。
_lastUrltestOk = now ;
_autoReconnectAttempts = 0 ; // 节点已恢复健康 → 清零自动重连计数,下次抖动重获满额重试。#18
final best = ds . reduce ( ( a , b ) = > a < b ? a : b ) ;
_ref . read ( nodesProvider . notifier ) . setLivePing ( node . uuid , best ) ;
}
@@ -414,12 +425,38 @@ class ConnectionController extends StateNotifier<ConnectionState> {
}
return ;
}
// 手动选定节点(或智能模式无其他可用节点):断开并提示,尊重用户选择、不自动换 。
// 提示走 _offNotice,由 _disconnect/_onKernelStatus 应用,避免被 kernel off 覆盖。
// 无备用节点(手动选定节点 / 智能但无其他可用):弱网抖动别一判死就断开+要用户手动重连 。
// 先自动重连**当前节点**,连续失败超过上限再真报「节点异常」。#18
if ( await _tryAutoReconnectCurrent ( ) ) return ;
// 重试用尽:断开并提示「节点异常」。提示走 _offNotice,由 _disconnect/_onKernelStatus 应用。
_offNotice = t . nodeUnhealthyError ;
await _disconnect ( ) ;
}
/// 弱网抖动:自动重连**当前节点**(不换节点、不改 selectedNode),bounded。
/// 返回 true = 已发起重连(额度内);false = 已用尽额度,调用方应改报「节点异常」。#18
/// urltest 成功(_onStats)或用户主动连接(toggle)会把计数清零 → 只有持续失败才耗尽。
Future < bool > _tryAutoReconnectCurrent ( ) async {
if ( _autoReconnectAttempts > = _kMaxAutoReconnect ) return false ;
_autoReconnectAttempts + + ;
final Node node = _connectedNode ? ? _ref . read ( effectiveNodeProvider ) ;
logLine ( ' Watchdog ' , ' auto-reconnect current node ${ node . code } (attempt $ _autoReconnectAttempts / $ _kMaxAutoReconnect ) ' ) ;
await _disconnect ( ) ;
await _connect ( ) ;
// 重连握手中给「网络波动,正在重连…」瞬态提示(连上后随 copyWith 清除)。
if ( mounted & & state . phase ! = VpnPhase . off ) {
state = ConnectionState ( phase: state . phase , error: _ref . read ( appTextProvider ) . nodeReconnecting ) ;
}
return true ;
}
/// 意外内核掉线(弱网)的处理:先自动重连当前节点,额度用尽才断开并报「节点异常」。#18
Future < void > _handleUnexpectedOff ( ) async {
if ( await _tryAutoReconnectCurrent ( ) ) return ;
_offNotice = _ref . read ( appTextProvider ) . nodeUnhealthyError ;
await _disconnect ( ) ;
}
/// 选延迟最优、可用(status up)、非当前节点的 code;无则 null。
String ? _pickAlternativeCode ( String excludeCode ) {
final nodes = ( _ref . read ( nodesProvider ) . valueOrNull ? ? const [ ] )