Files
chen08209 532ebf07e9 feat(providers): assemble custom profiles and import configs
Changelog: DNS, Hosts and ports apply without a restart
2026-10-11 12:28:52 +08:00

341 lines
8.3 KiB
Dart

import 'dart:async';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/core/method.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:flutter/foundation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'app.dart';
import 'core.dart';
import 'state.dart';
part 'generated/route_state.g.dart';
/// What a probe answer is judged against. Nothing is fresh until the Core's
/// counters have been read, otherwise every answer would fail the epoch
/// check and be asked again forever.
@immutable
class RouteState {
const RouteState({
this.coreEpoch = 0,
this.picksVersion = 0,
this.picks = const {},
this.hostEpoch = 0,
this.proxied = false,
this.synced = false,
this.live = false,
this.resumes = 0,
});
final int coreEpoch;
final int picksVersion;
final Map<String, String> picks;
final int hostEpoch;
final bool proxied;
final bool synced;
final bool live;
final int resumes;
RouteState copyWith({
int? coreEpoch,
int? picksVersion,
Map<String, String>? picks,
int? hostEpoch,
bool? proxied,
bool? synced,
bool? live,
int? resumes,
}) {
return RouteState(
coreEpoch: coreEpoch ?? this.coreEpoch,
picksVersion: picksVersion ?? this.picksVersion,
picks: picks ?? this.picks,
hostEpoch: hostEpoch ?? this.hostEpoch,
proxied: proxied ?? this.proxied,
synced: synced ?? this.synced,
live: live ?? this.live,
resumes: resumes ?? this.resumes,
);
}
/// A chain stays fresh while every group on it still picks the hop below
/// it; groups the picks do not name, load balancers among them, cannot
/// contradict it. An answer with no chain is tied to the selection version
/// it was made under instead.
bool isFresh(RouteStamp stamp) {
if (!synced ||
stamp.coreEpoch != coreEpoch ||
stamp.hostEpoch != hostEpoch ||
stamp.proxied != proxied) {
return false;
}
final chains = stamp.chains;
if (chains.isEmpty) {
return stamp.picksVersion == picksVersion;
}
for (var i = chains.length - 1; i >= 1; i--) {
final next = picks[chains[i]];
if (next != null && next != chains[i - 1]) {
return false;
}
}
return true;
}
bool isBehind(RouteStamp stamp) =>
stamp.coreEpoch > coreEpoch || stamp.picksVersion > picksVersion;
@override
bool operator ==(Object other) =>
other is RouteState &&
other.coreEpoch == coreEpoch &&
other.picksVersion == picksVersion &&
mapEquals(other.picks, picks) &&
other.hostEpoch == hostEpoch &&
other.proxied == proxied &&
other.synced == synced &&
other.live == live &&
other.resumes == resumes;
@override
int get hashCode => Object.hash(
coreEpoch,
picksVersion,
Object.hashAllUnordered(
picks.entries.map((entry) => Object.hash(entry.key, entry.value)),
),
hostEpoch,
proxied,
synced,
live,
resumes,
);
}
/// The route a probe answer was produced under: the Core's counters when it
/// started, the host's when it was sent, and the chain it went through.
@immutable
class RouteStamp {
const RouteStamp({
required this.coreEpoch,
required this.picksVersion,
required this.hostEpoch,
required this.proxied,
this.chains = const [],
});
final int coreEpoch;
final int picksVersion;
final int hostEpoch;
final bool proxied;
final List<String> chains;
@override
bool operator ==(Object other) =>
other is RouteStamp &&
other.coreEpoch == coreEpoch &&
other.picksVersion == picksVersion &&
other.hostEpoch == hostEpoch &&
other.proxied == proxied &&
listEquals(other.chains, chains);
@override
int get hashCode => Object.hash(
coreEpoch,
picksVersion,
hostEpoch,
proxied,
Object.hashAll(chains),
);
}
@Riverpod(keepAlive: true)
class RouteTracker extends _$RouteTracker {
static const _retryDelays = [
Duration(seconds: 1),
Duration(seconds: 2),
Duration(seconds: 5),
Duration(seconds: 10),
Duration(seconds: 30),
];
int _watchers = 0;
int _generation = 0;
bool _syncing = false;
bool _resubscribe = false;
Timer? _retry;
int _failures = 0;
@override
RouteState build() {
ref.onDispose(_cancelRetry);
ref.listen(isStartProvider, (_, _) => _scheduleSyncProxied());
ref.listen(suspendProvider, (_, _) => _scheduleSyncProxied());
ref.listen(initProvider, (_, _) => _subscribe());
ref.listen(appVisibleProvider, (_, visible) {
if (visible) {
_subscribe();
} else if (_watchers > 0) {
_unsubscribe();
}
});
ref.listen(coreStatusProvider, (_, status) {
if (status == CoreStatus.connected) {
_subscribe();
} else {
_dropSync();
}
});
return RouteState(proxied: _proxied);
}
bool get _proxied => ref.read(isStartProvider) && !ref.read(suspendProvider);
bool get _ready =>
_watchers > 0 &&
ref.read(appVisibleProvider) &&
ref.read(initProvider) &&
ref.read(coreStatusProvider) == CoreStatus.connected;
void attach() {
if (++_watchers == 1) {
_subscribe();
}
}
void detach() {
if (_watchers == 0) {
return;
}
if (--_watchers == 0) {
_unsubscribe();
}
}
/// Reads the Core's counters again; a cache asks for this when an answer
/// carries a newer epoch than the state, which means an event was missed.
void resync() => _subscribe();
void applySnapshot(RouteSnapshot snapshot) {
state = state.copyWith(
coreEpoch: snapshot.coreEpoch,
picksVersion: snapshot.picksVersion,
picks: snapshot.picks,
hostEpoch: state.synced ? state.hostEpoch : state.hostEpoch + 1,
synced: true,
live: true,
);
}
void bumpHostEpoch() {
state = state.copyWith(hostEpoch: state.hostEpoch + 1);
}
void markResumed() {
state = state.copyWith(resumes: state.resumes + 1);
}
/// Deferred because both are computed: unwatched, they go stale, and the
/// widget that next reads one recomputes it from initState or build.
void _scheduleSyncProxied() {
scheduleMicrotask(() {
if (ref.mounted) {
_syncProxied();
}
});
}
void _syncProxied() {
final proxied = _proxied;
if (proxied != state.proxied) {
state = state.copyWith(proxied: proxied);
}
}
Future<void> _subscribe() async {
_retry?.cancel();
if (!_ready) {
return;
}
if (_syncing) {
_resubscribe = true;
return;
}
_syncing = true;
_resubscribe = false;
final generation = ++_generation;
RouteSnapshot? snapshot;
try {
snapshot = await ref.read(coreHandlerProvider).watchRoute(true);
} catch (error) {
commonPrint.log(
'watchRoute failed: $error',
logLevel: coreFailureLogLevel(error),
);
} finally {
_syncing = false;
}
if (!ref.mounted) {
return;
}
if (generation == _generation) {
if (snapshot != null) {
_failures = 0;
applySnapshot(snapshot);
} else if (!_resubscribe) {
_retryLater();
}
}
if (_resubscribe) {
unawaited(_subscribe());
}
}
void _retryLater() {
final delay = _retryDelays[_failures.clamp(0, _retryDelays.length - 1)];
_failures++;
_retry = Timer(delay, () => unawaited(_subscribe()));
}
void _cancelRetry() {
_retry?.cancel();
_retry = null;
_failures = 0;
}
/// Deferred because a widget releases its target from dispose.
void _unsubscribe() {
_cancelRetry();
final generation = ++_generation;
scheduleMicrotask(() {
if (ref.mounted && generation == _generation && state.live) {
state = state.copyWith(live: false);
}
});
if (ref.read(coreStatusProvider) != CoreStatus.connected) {
return;
}
unawaited(
ref.read(coreHandlerProvider).watchRoute(false).catchError((
Object error,
) {
commonPrint.log(
'watchRoute release failed: $error',
logLevel: coreFailureLogLevel(error),
);
return null;
}),
);
}
void _dropSync() {
_cancelRetry();
_generation++;
if (state.synced || state.live) {
state = state.copyWith(synced: false, live: false);
}
}
}