Files
FlClash/lib/core/desktop/helper_client.dart
T
chen08209 94a25f4827 fix(desktop): fix the system proxy, tray and Helper on Windows and Linux
Changelog: Windows and Linux desktop fixes
2026-10-11 12:28:52 +08:00

602 lines
17 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:dio/io.dart';
import 'package:fl_clash/common/constant.dart';
import 'package:fl_clash/common/print.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:path/path.dart' as p;
import 'package:win32_registry/win32_registry.dart';
import 'core_manifest.dart';
import 'launcher.dart';
import 'model.dart';
import 'process_probe.dart';
enum HelperReadiness { ready, notReady, manifestMissing }
final class HelperStartResponse {
final String sessionId;
final int pid;
const HelperStartResponse({required this.sessionId, required this.pid});
}
final class HelperStopResponse {
final String sessionId;
final bool stopped;
final String? reason;
const HelperStopResponse({
required this.sessionId,
required this.stopped,
this.reason,
});
}
final class HelperException implements Exception {
final String code;
final String message;
final Object? details;
const HelperException({
required this.code,
required this.message,
this.details,
});
@override
String toString() => 'HelperException($code, $message, $details)';
}
final class HelperClient {
final Dio _dio;
final String Function() _expectedHelperPath;
final Future<String> Function() _readCoreSha256;
final String Function() _baseUrl;
String? _coreSha256Cache;
HelperClient({
Dio? dio,
String Function()? expectedHelperPath,
Future<String> Function()? readCoreSha256,
String? baseUrl,
}) : _dio = dio ?? _createDio(),
_baseUrl = baseUrl == null ? _defaultBaseUrl : (() => baseUrl),
_expectedHelperPath = expectedHelperPath ?? _defaultHelperPath,
_readCoreSha256 = readCoreSha256 ?? _readBundledCoreSha256;
// The bundled manifest.json is a fixed build artifact; a usable value is read
// once. An empty result means it is unusable now, so the Helper is skipped.
Future<String> _readCoreSha256Once() async {
final cached = _coreSha256Cache;
if (cached != null) {
return cached;
}
String coreSha256;
try {
coreSha256 = await _readCoreSha256();
} catch (_) {
coreSha256 = '';
}
if (coreSha256.isNotEmpty) {
_coreSha256Cache = coreSha256;
}
return coreSha256;
}
// The Helper protocol never leaves the machine; never route it through a
// proxy. Linux carries it over a Unix socket the systemd unit owns, so the
// authority in the URL is a placeholder the connection factory ignores.
static Dio _createDio() {
return Dio()
..httpClientAdapter = IOHttpClientAdapter(
createHttpClient: () {
final client = HttpClient();
client.findProxy = (uri) => 'DIRECT';
if (Platform.isLinux) {
client.connectionFactory = (uri, proxyHost, proxyPort) {
return Socket.startConnect(
InternetAddress(
helperSocketPath,
type: InternetAddressType.unix,
),
0,
);
};
}
return client;
},
);
}
String get baseUrl => _baseUrl();
static String _defaultBaseUrl() {
return Platform.isLinux
? 'http://$appHelperService'
: 'http://$localhost:${_readHelperPort()}';
}
/// Helpers installed before the port was published listen on [helperPort].
static int _readHelperPort() {
if (!Platform.isWindows) {
return helperPort;
}
try {
final key = LOCAL_MACHINE.open(helperPortKey);
try {
return key.getInt(helperPortValue) ?? helperPort;
} finally {
key.close();
}
} catch (_) {
return helperPort;
}
}
static Future<String> _readBundledCoreSha256() async {
return (await CoreManifest.readCoreSha256()) ?? '';
}
static p.Context get _pathContext {
return p.Context(
style: Platform.isWindows ? p.Style.windows : p.Style.posix,
);
}
static String _defaultHelperPath() {
final context = _pathContext;
return context.join(
context.dirname(Platform.resolvedExecutable),
Platform.isWindows ? '$appHelperService.exe' : appHelperService,
);
}
Future<HelperReadiness> readiness({
Duration? timeout,
bool logFailure = true,
}) async {
if (timeout != null && timeout <= Duration.zero) {
return HelperReadiness.notReady;
}
final cancelToken = CancelToken();
final timeoutTimer = timeout == null
? null
: Timer(
timeout,
() => cancelToken.cancel('helper ping deadline exceeded'),
);
try {
final coreSha256 = await _readCoreSha256Once();
if (coreSha256.isEmpty) {
_logPingFailure('Core manifest is missing or invalid', logFailure);
return HelperReadiness.manifestMissing;
}
final response = await _dio.get<Object?>(
'$baseUrl/ping',
queryParameters: {'coreSha256': coreSha256},
cancelToken: cancelToken,
options: _options(ResponseType.plain, acceptAnyStatus: true),
);
return response.statusCode == HttpStatus.ok
? _readyFromOk(response, logFailure)
: _notReadyFromPing(response, logFailure);
} catch (error) {
_logPingFailure('helper ping failed: $error', logFailure);
return HelperReadiness.notReady;
} finally {
timeoutTimer?.cancel();
}
}
HelperReadiness _readyFromOk(Response<Object?> response, bool logFailure) {
final protocolVersion = response.headers.value(helperProtocolVersionHeader);
final helperPath = response.data;
if (helperPath is! String) {
_logPingFailure('helper ping returned invalid response', logFailure);
return HelperReadiness.notReady;
}
if (protocolVersion != helperProtocolVersion) {
_logPingFailure('helper protocol mismatch: $protocolVersion', logFailure);
return HelperReadiness.notReady;
}
final matches = _pathContext.equals(
helperPath.trim(),
_expectedHelperPath(),
);
if (!matches) {
_logPingFailure('helper executable path mismatch', logFailure);
return HelperReadiness.notReady;
}
return HelperReadiness.ready;
}
HelperReadiness _notReadyFromPing(
Response<Object?> response,
bool logFailure,
) {
final statusCode = response.statusCode;
final protocolVersion = response.headers.value(helperProtocolVersionHeader);
if (statusCode == HttpStatus.conflict) {
final code = _mapFrom(response.data)?['code'];
if (code == 'coreSha256Mismatch') {
_logPingFailure('Helper Core SHA256 mismatch', logFailure);
return HelperReadiness.notReady;
}
if (protocolVersion == helperProtocolVersion) {
_logPingFailure(
'helper could not access the Core executable',
logFailure,
);
return HelperReadiness.notReady;
}
_logPingFailure(
'helper returned an unrecognized conflict '
'(protocol $protocolVersion)',
logFailure,
);
return HelperReadiness.notReady;
}
_logPingFailure(
'helper ping returned HTTP $statusCode '
'(protocol $protocolVersion)',
logFailure,
);
return HelperReadiness.notReady;
}
void _logPingFailure(String message, bool enabled) {
if (enabled) {
commonPrint.log(message, logLevel: LogLevel.warning);
}
}
/// /start hashes the whole Core binary before spawning it, and Smart App
/// Control adds a cloud reputation lookup inside CreateProcess; the ping
/// budget cut those off as a transport error and hid the Helper's answer.
static const startTimeout = Duration(seconds: 15);
/// Covers the Helper's graceful-exit wait plus its forced kill, so a Core
/// slow to tear down its TUN reports as stopped rather than as a transport error.
static const stopTimeout = Duration(seconds: 6);
Future<HelperStartResponse> start({
required String address,
required String sessionId,
}) async {
_validateSessionId(sessionId);
try {
final response = await _dio.post<Object?>(
'$baseUrl/start',
data: {'address': address, 'sessionId': sessionId},
options: _options(ResponseType.json, receiveTimeout: startTimeout),
);
final data = _responseMap(response, operation: 'start');
final returnedSession = data['sessionId'];
final pid = data['pid'];
if (returnedSession != sessionId || pid is! int || pid <= 0) {
throw const HelperException(
code: 'invalidResponse',
message: 'Helper returned an invalid start response',
);
}
return HelperStartResponse(
sessionId: returnedSession as String,
pid: pid,
);
} on HelperException {
rethrow;
} on DioException catch (error) {
throw _mapDioException(error, operation: 'start');
} catch (error) {
throw HelperException(
code: 'transportError',
message: 'Unable to start Core through Helper',
details: error.toString(),
);
}
}
Future<HelperStopResponse> stop(String sessionId) async {
_validateSessionId(sessionId);
try {
final response = await _dio.post<Object?>(
'$baseUrl/stop',
data: {'sessionId': sessionId},
options: _options(ResponseType.json, receiveTimeout: stopTimeout),
);
return _parseStopResponse(response, sessionId);
} on HelperException {
rethrow;
} on DioException catch (error) {
final response = error.response;
if (response?.statusCode == HttpStatus.conflict) {
final data = _mapFrom(response?.data);
final reason = data?['reason'];
if (reason is String) {
throw HelperException(
code: reason,
message: 'Helper refused to stop the requested Core session',
details: data,
);
}
}
throw _mapDioException(error, operation: 'stop');
} catch (error) {
throw HelperException(
code: 'transportError',
message: 'Unable to stop Core through Helper',
details: error.toString(),
);
}
}
HelperStopResponse _parseStopResponse(
Response<Object?> response,
String sessionId,
) {
final data = _responseMap(response, operation: 'stop');
final returnedSession = data['sessionId'];
final stopped = data['stopped'];
final reason = data['reason'];
if (returnedSession != sessionId ||
stopped is! bool ||
(stopped && reason != null) ||
(!stopped && reason != 'notRunning')) {
throw const HelperException(
code: 'invalidResponse',
message: 'Helper returned an invalid stop response',
);
}
return HelperStopResponse(
sessionId: returnedSession as String,
stopped: stopped,
reason: reason as String?,
);
}
Map<String, Object?> _responseMap(
Response<Object?> response, {
required String operation,
}) {
if (response.statusCode != HttpStatus.ok) {
throw HelperException(
code: 'unexpectedStatus',
message: 'Helper $operation returned HTTP ${response.statusCode}',
details: response.data,
);
}
final data = _mapFrom(response.data);
if (data == null) {
throw HelperException(
code: 'invalidResponse',
message: 'Helper returned an invalid $operation response',
details: response.data,
);
}
return data;
}
HelperException _mapDioException(
DioException error, {
required String operation,
}) {
final data = _mapFrom(error.response?.data);
final code = data?['code'];
final message = data?['message'];
if (code is String && message is String) {
return HelperException(
code: code,
message: message,
details: data?['details'],
);
}
return HelperException(
code: 'transportError',
message: 'Helper $operation request failed',
details: error.toString(),
);
}
Map<String, Object?>? _mapFrom(Object? data) {
if (data is String) {
try {
data = jsonDecode(data);
} on FormatException {
return null;
}
}
if (data is! Map) {
return null;
}
return Map<String, Object?>.from(data);
}
void _validateSessionId(String sessionId) {
if (!RegExp(r'^[0-9a-f]{32}$').hasMatch(sessionId)) {
throw const HelperException(
code: 'invalidSessionId',
message: 'Core session ID must be 128-bit lowercase hexadecimal',
);
}
}
Options _options(
ResponseType responseType, {
bool acceptAnyStatus = false,
Duration receiveTimeout = const Duration(seconds: 2),
}) {
return Options(
responseType: responseType,
connectTimeout: const Duration(milliseconds: 300),
receiveTimeout: receiveTimeout,
// The Helper reports readiness via non-2xx (400, 409, ...), so only
// transport errors should surface as DioExceptions.
validateStatus: acceptAnyStatus ? (_) => true : null,
);
}
}
final class HelperLauncher implements CoreProcessLauncher {
final HelperClient client;
final ProcessLivenessProbe livenessProbe;
const HelperLauncher(this.client, {this.livenessProbe = isProcessAlive});
@override
Future<CoreProcessLease> start({
required String sessionId,
required String address,
}) async {
try {
final response = await client.start(
address: address,
sessionId: sessionId,
);
return HelperCoreLease(
sessionId: response.sessionId,
pid: response.pid,
client: client,
livenessProbe: livenessProbe,
);
} catch (error, stackTrace) {
try {
await client.stop(sessionId);
} catch (releaseError) {
commonPrint.log(
'Failed to release Helper session $sessionId after a start failure: '
'${compactError(releaseError)}',
logLevel: LogLevel.warning,
);
}
Error.throwWithStackTrace(error, stackTrace);
}
}
}
// The Helper reports these codes before it spawns a Core, and /start releases
// the previously managed Core first, so no Helper-managed Core is left behind
// and a direct launch is safe to retry.
const _preSpawnHelperErrors = {'coreVerificationFailed', 'processLaunchFailed'};
final class FallbackCoreLauncher implements CoreProcessLauncher {
final CoreProcessLauncher primary;
final CoreProcessLauncher fallback;
const FallbackCoreLauncher({required this.primary, required this.fallback});
@override
Future<CoreProcessLease> start({
required String sessionId,
required String address,
}) async {
try {
return await primary.start(sessionId: sessionId, address: address);
} on HelperException catch (error) {
if (!_preSpawnHelperErrors.contains(error.code)) rethrow;
commonPrint.log(
'Helper could not start the Core ($error); '
'falling back to direct Core',
logLevel: LogLevel.warning,
);
return fallback.start(sessionId: sessionId, address: address);
}
}
}
typedef HelperReadinessProbe = Future<HelperReadiness> Function();
final class HelperLauncherResolver implements DesktopCoreLauncherResolver {
final bool hasHelper;
final CoreProcessLauncher directLauncher;
final CoreProcessLauncher helperLauncher;
final HelperReadinessProbe helperReady;
const HelperLauncherResolver({
required this.hasHelper,
required this.directLauncher,
required this.helperLauncher,
required this.helperReady,
});
@override
Future<CoreProcessLauncher> resolve() async {
if (!hasHelper) return directLauncher;
final readiness = await helperReady();
if (readiness == HelperReadiness.ready) {
return FallbackCoreLauncher(
primary: helperLauncher,
fallback: directLauncher,
);
}
return directLauncher;
}
}
final class HelperCoreLease implements CoreProcessLease {
@override
final String sessionId;
@override
final int pid;
final HelperClient _client;
final ProcessLivenessProbe _livenessProbe;
Future<CoreProcessStopResult>? _stopOperation;
HelperCoreLease({
required this.sessionId,
required this.pid,
required HelperClient client,
ProcessLivenessProbe livenessProbe = isProcessAlive,
}) : _client = client,
_livenessProbe = livenessProbe;
@override
CoreProcessOwner get owner => CoreProcessOwner.helper;
@override
Future<CoreProcessStopResult> stop(Duration timeout) {
final stopOperation = _stopOperation;
if (stopOperation != null) {
return stopOperation;
}
final nextOperation = _stop().onError((
Object error,
StackTrace stackTrace,
) {
_stopOperation = null;
Error.throwWithStackTrace(error, stackTrace);
});
_stopOperation = nextOperation;
return nextOperation;
}
// A dead Helper takes its Core with it; the OS is then the only witness.
Future<CoreProcessStopResult> _stop() async {
final HelperStopResponse response;
try {
response = await _client.stop(sessionId);
} on HelperException catch (error) {
if (error.code != 'transportError' || await _livenessProbe(pid)) {
rethrow;
}
commonPrint.log(
'Helper is unreachable and Core $pid has already exited; '
'session $sessionId is released',
logLevel: LogLevel.warning,
);
return const CoreProcessStopResult(stopped: false, exitConfirmed: true);
}
return CoreProcessStopResult(
stopped: response.stopped,
exitConfirmed: true,
);
}
}
final helperClient = HelperClient();