fix(core): keep very large profiles from crashing the app

Changelog: Large profiles no longer crash; downloads trust core CAs
This commit is contained in:
chen08209
2026-10-15 12:31:27 +08:00
parent a5b6d300f6
commit 5984c161c5
36 changed files with 2564 additions and 1140 deletions
+20
View File
@@ -5,6 +5,7 @@ import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/config.dart';
import 'package:fl_clash/providers/state.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class FlClashHttpOverrides extends HttpOverrides {
@@ -12,6 +13,25 @@ class FlClashHttpOverrides extends HttpOverrides {
FlClashHttpOverrides(this._container);
static const _coreRootsAsset =
'core/Clash.Meta/component/ca/ca-certificates.crt';
/// The bundle the core embeds; only release CI fills it.
static Future<void> trustCoreRoots() async {
try {
final data = await rootBundle.load(_coreRootsAsset);
if (data.lengthInBytes == 0) return;
SecurityContext.defaultContext.setTrustedCertificatesBytes(
data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes),
);
} catch (e) {
commonPrint.log(
'trustCoreRoots error ${compactError(e)}',
logLevel: LogLevel.warning,
);
}
}
static String findProxyFor(ProviderContainer container, Uri url) {
return findProxyForReader(container.read, url);
}
+34 -61
View File
@@ -17,14 +17,6 @@ Future<T> _decodeJSON<T>(String content) async {
return json.decode(content);
}
Future<String> encodeJSONTask<T>(T data) async {
return compute<T, String>(_encodeJSON, data);
}
Future<String> _encodeJSON<T>(T content) async {
return json.encode(content);
}
Future<String> encodeYamlTask<T>(T data) async {
return compute<T, String>(_encodeYaml, data);
}
@@ -33,14 +25,6 @@ Future<String> _encodeYaml<T>(T content) async {
return yaml.encode(content);
}
Future<String> encodeMD5Task(String data) async {
return compute<String, String>(_encodeMD5, data);
}
Future<String> _encodeMD5<T>(String content) async {
return content.toMd5();
}
/// Throws [FormatException] when the file is not UTF-8.
Future<String?> readTextFileTask(String path) {
return compute(_readTextFile, path);
@@ -90,34 +74,45 @@ Future<List<Group>> buildGroups(ComputeGroupsState state) async {
}
Future<ClashConfig> clashConfigTask(Map<String, dynamic> data) async {
return compute<Map<String, dynamic>, ClashConfig>(buildClashConfig, data);
return compute<Map<String, dynamic>, ClashConfig>(_buildClashConfig, data);
}
@visibleForTesting
ClashConfig buildClashConfig(Map<String, dynamic> configMap) {
final clashConfig = ClashConfig.fromJson(configMap);
final proxyTypeMap = <String, String>{};
for (final proxy in clashConfig.proxies) {
proxyTypeMap[proxy.name] = proxy.type;
}
for (final proxyGroup in clashConfig.proxyGroups) {
proxyTypeMap[proxyGroup.name] = proxyGroup.type.value;
}
return clashConfig.copyWith(proxyTypeMap: proxyTypeMap);
ClashConfig _buildClashConfig(Map<String, dynamic> configMap) {
return ClashConfig.fromJson(configMap);
}
Future<({String yaml, String md5})> makeRealProfileTask(
MakeRealProfileState data,
) async {
return compute<MakeRealProfileState, ({String yaml, String md5})>(
_makeRealProfileTask,
data,
typedef ConfigCounts = ({int groups, int proxies, int rules});
typedef RealProfile = ({String yaml, String md5, ConfigCounts counts});
/// Counts what the Core was handed, not what the profile file declares.
ConfigCounts configCountsOf(Map<Object?, Object?> config) {
int lengthOf(Object? value) => value is List ? value.length : 0;
return (
groups: lengthOf(config['proxy-groups']),
proxies: lengthOf(config['proxies']),
rules: lengthOf(config['rules']),
);
}
Future<({String yaml, String md5})> _makeRealProfileTask(
MakeRealProfileState data,
) async {
String? lastMatchTarget(List<String> rules) {
for (int i = rules.length - 1; i >= 0; i--) {
final parsed = Rule.parse(rules[i]);
if (parsed.ruleAction == RuleAction.MATCH) {
final target = parsed.ruleTarget;
if (target != null && target.isNotEmpty) {
return target;
}
}
}
return null;
}
Future<RealProfile> makeRealProfileTask(MakeRealProfileState data) async {
return compute<MakeRealProfileState, RealProfile>(_makeRealProfileTask, data);
}
Future<RealProfile> _makeRealProfileTask(MakeRealProfileState data) async {
final rawConfig = Map.from(data.rawConfig);
final realPatchConfig = data.realPatchConfig;
final profilesPath = data.profilesPath;
@@ -334,16 +329,7 @@ Future<({String yaml, String md5})> _makeRealProfileTask(
}
if (hasMatchPlaceholder && replacementTarget == null) {
for (int i = rules.length - 1; i >= 0; i--) {
final parsed = Rule.parse(rules[i]);
if (parsed.ruleAction == RuleAction.MATCH) {
final target = parsed.ruleTarget;
if (target != null && target.isNotEmpty) {
replacementTarget = target;
break;
}
}
}
replacementTarget = lastMatchTarget(rules);
}
final List<String> finalAddedRules;
@@ -374,7 +360,7 @@ Future<({String yaml, String md5})> _makeRealProfileTask(
}
rawConfig['rules'] = rules;
final yaml = await _encodeYaml(Map<String, dynamic>.from(rawConfig));
return (yaml: yaml, md5: yaml.toMd5());
return (yaml: yaml, md5: yaml.toMd5(), counts: configCountsOf(rawConfig));
}
typedef ShakingStoreArgs = ({
@@ -621,16 +607,3 @@ Future<MigrationData> migrateLegacyConfig({
disabledRules: disabledRules,
);
}
Future<List<T>> mapListTask<T, S>(List<S> results, T Function(S) mapper) async {
return compute<({List<S> results, T Function(S) mapper}), List<T>>(
_mapListTask,
(results: results, mapper: mapper),
);
}
Future<List<T>> _mapListTask<T, S>(
({List<S> results, T Function(S) mapper}) args,
) async {
return args.results.map((item) => args.mapper(item)).toList();
}
+18 -4
View File
@@ -4,6 +4,7 @@ import 'dart:io';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/core/core.dart';
import 'package:fl_clash/core/interface.dart';
import 'package:fl_clash/core/spill.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:flutter/foundation.dart' show visibleForTesting;
@@ -290,10 +291,6 @@ class CoreController {
return _readConfig(await appPath.getProfilePath(id.toString()));
}
Future<Map<String, dynamic>> getAppliedConfig() async {
return _readConfig(await appPath.configFilePath);
}
Future<Map<String, dynamic>> _readConfig(String path) async {
final data = Map<String, dynamic>.from(await _interface.getConfig(path));
data['rules'] = data['rule'];
@@ -301,6 +298,23 @@ class CoreController {
return data;
}
Future<Map<String, dynamic>> getProfileOutbounds(int id) async {
return _interface.getProfileOutbounds(
await appPath.getProfilePath(id.toString()),
);
}
Future<String> buildProfile(SetupBase base) async {
return readSpilledText(await _interface.buildProfile(base));
}
Future<({ProfileBase base, String path})> getProfileBase(int id) async {
final path = await appPath.getProfilePath(id.toString());
final base = await _interface.getProfileBase(path);
final config = Map<String, dynamic>.from(base.config)..remove('rule');
return (base: base.copyWith(config: config), path: path);
}
Future<Traffic> getTraffic(bool onlyStatisticsProxy) async {
return _interface.getTraffic(onlyStatisticsProxy);
}
+17
View File
@@ -5,6 +5,7 @@ import 'dart:typed_data';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/core/event.dart';
import 'package:fl_clash/core/method.dart';
import 'package:fl_clash/core/spill.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:flutter/foundation.dart' show visibleForTesting;
@@ -210,6 +211,22 @@ final class CoreRpcClient implements CoreRpcChannel {
final id = response.id;
final completer = id == null ? null : _pending.remove(id);
if (completer == null || completer.isCompleted) {
unawaited(discardSpilledResponse(response));
return;
}
if (response.file != null) {
unawaited(
resolveSpilledResponse(
response,
).then((resolved) => _complete(completer, resolved)),
);
return;
}
_complete(completer, response);
}
void _complete(Completer<Object?> completer, CoreMethodResponse response) {
if (completer.isCompleted) {
return;
}
final error = response.error;
+51
View File
@@ -38,6 +38,12 @@ mixin CoreInterface {
Future<Map<String, dynamic>> getConfig(String path);
Future<ProfileBase> getProfileBase(String path);
Future<Map<String, dynamic>> getProfileOutbounds(String path);
Future<String> buildProfile(SetupBase base);
Future<String> dumpRuleSet(String path);
Future<RuleSetInfo> compileRuleSet(String name, {String url = ''});
@@ -295,6 +301,51 @@ abstract class CoreHandlerInterface with CoreInterface {
return result;
}
@override
Future<ProfileBase> getProfileBase(String path) async {
final result = await _invokeMethod<Map<String, dynamic>>(
method: CoreMethod.getProfileBase,
arguments: path,
);
if (result == null) {
throw const CoreMethodException(
code: 'empty_result',
message: 'Core returned an empty profile base',
);
}
return ProfileBase.fromJson(result);
}
@override
Future<Map<String, dynamic>> getProfileOutbounds(String path) async {
final result = await _invokeMethod<Map<String, dynamic>>(
method: CoreMethod.getProfileOutbounds,
arguments: path,
);
if (result == null) {
throw const CoreMethodException(
code: 'empty_result',
message: 'Core returned no outbounds',
);
}
return result;
}
@override
Future<String> buildProfile(SetupBase base) async {
final result = await _invokeMethod<String>(
method: CoreMethod.buildProfile,
arguments: base.toJson(),
);
if (result == null || result.isEmpty) {
throw const CoreMethodException(
code: 'empty_result',
message: 'Core returned no built profile',
);
}
return result;
}
@override
Future<String> setupConfig(SetupParams setupParams) async {
return _invokeMessage(
+15 -9
View File
@@ -9,6 +9,7 @@ import 'package:flutter/foundation.dart' show visibleForTesting;
import 'desktop/model.dart';
import 'interface.dart';
import 'method.dart';
import 'spill.dart';
class CoreLib extends CoreHandlerInterface {
static CoreLib? _instance;
@@ -111,18 +112,19 @@ class CoreLib extends CoreHandlerInterface {
return _stop(allowClosed: true);
}
// The service opens and closes the Core's listeners itself and reports a run
// that ends, so once it takes the command the Core's own answer is moot.
@override
Future<bool> startListener() async {
final listenerStarted = await super.startListener();
final serviceStarted = await _service?.start() ?? false;
return listenerStarted && serviceStarted;
await super.startListener();
return await _service?.start() ?? false;
}
@override
Future<bool> stopListener() async {
final serviceStopped = await _service?.stop() ?? false;
final listenerStopped = await super.stopListener();
return serviceStopped && listenerStopped;
await super.stopListener();
return serviceStopped;
}
@override
@@ -131,10 +133,14 @@ class CoreLib extends CoreHandlerInterface {
Object? arguments,
Duration? timeout,
}) {
return _invokeMethod<T>(
method: method,
arguments: arguments,
).withTimeout(timeout: timeout, onTimeout: () => null);
final call = _invokeMethod<T>(method: method, arguments: arguments);
return call.withTimeout(
timeout: timeout,
onTimeout: () {
unawaited(call.then(discardSpilledFile, onError: (_) {}));
return null;
},
);
}
Future<T?> _invokeMethod<T>({
+9 -1
View File
@@ -17,6 +17,9 @@ enum CoreMethod {
decodeShareLinks,
updateConfig,
getConfig,
getProfileBase,
getProfileOutbounds,
buildProfile,
dumpRuleSet,
compileRuleSet,
getProxies,
@@ -103,7 +106,10 @@ class CoreMethodResponse {
final Object? result;
final CoreMethodError? error;
const CoreMethodResponse({this.id, this.result, this.error});
/// Where the Core wrote the whole response instead; see `spill.dart`.
final String? file;
const CoreMethodResponse({this.id, this.result, this.error, this.file});
factory CoreMethodResponse.fromJson(Map<String, Object?> json) {
final error = json['error'];
@@ -113,6 +119,7 @@ class CoreMethodResponse {
error: error is Map
? CoreMethodError.fromJson(Map<String, Object?>.from(error))
: null,
file: json['file'] as String?,
);
}
@@ -121,6 +128,7 @@ class CoreMethodResponse {
if (id != null) 'id': id,
'result': result,
if (error != null) 'error': error!.toJson(),
if (file != null) 'file': file,
};
}
+93
View File
@@ -0,0 +1,93 @@
import 'dart:convert';
import 'dart:io';
import 'package:fl_clash/common/common.dart';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart';
import 'method.dart';
/// Mirrors spilledResponseDirName in core/spill.go.
const coreSpillDirectoryName = 'responses';
Future<bool> _isSpilled(String file) async {
final directory = join(await appPath.homeDirPath, coreSpillDirectoryName);
return equals(dirname(file), directory);
}
Future<CoreMethodResponse> resolveSpilledResponse(
CoreMethodResponse response,
) async {
final file = response.file;
if (file == null) {
return response;
}
if (!await _isSpilled(file)) {
return CoreMethodResponse(
id: response.id,
error: CoreMethodError(
code: 'spill_error',
message: 'Core answered with a file outside its spill directory: $file',
),
);
}
try {
return CoreMethodResponse.fromJson(
await compute(_readSpilledResponse, file),
);
} catch (error) {
return CoreMethodResponse(
id: response.id,
error: CoreMethodError(code: 'spill_error', message: '$error'),
);
}
}
Map<String, Object?> _readSpilledResponse(String path) {
final file = File(path);
try {
final decoded = utf8.decoder
.fuse(json.decoder)
.convert(file.readAsBytesSync());
return Map<String, Object?>.from(decoded as Map);
} finally {
try {
file.deleteSync();
} on FileSystemException catch (_) {}
}
}
Future<String> readSpilledText(String path) async {
if (!await _isSpilled(path)) {
throw CoreMethodException(
code: 'spill_error',
message: 'Core wrote a file outside its spill directory: $path',
);
}
return compute(_readSpilledText, path);
}
String _readSpilledText(String path) {
final file = File(path);
try {
return file.readAsStringSync();
} finally {
try {
file.deleteSync();
} on FileSystemException catch (_) {}
}
}
Future<void> discardSpilledResponse(CoreMethodResponse response) async {
await discardSpilledFile(response.file);
await discardSpilledFile(response.result);
}
Future<void> discardSpilledFile(Object? file) async {
if (file is! String || !await _isSpilled(file)) {
return;
}
try {
await File(file).delete();
} on FileSystemException catch (_) {}
}
+1
View File
@@ -39,6 +39,7 @@ void main(List<String> args) {
await RustLib.init();
final version = await system.init();
final container = await bootstrap.init(version);
await FlClashHttpOverrides.trustCoreRoots();
HttpOverrides.global = FlClashHttpOverrides(container);
request.attach(container.read);
runApp(
+25 -31
View File
@@ -11,12 +11,37 @@ abstract class SetupParams with _$SetupParams {
@JsonKey(name: 'selected-map') required Map<String, String> selectedMap,
@JsonKey(name: 'test-url') required String testUrl,
@Default(false) @JsonKey(name: 'skip-cert-verify') bool skipCertVerify,
@JsonKey(includeIfNull: false) SetupBase? base,
}) = _SetupParams;
factory SetupParams.fromJson(Map<String, dynamic> json) =>
_$SetupParamsFromJson(json);
}
@freezed
abstract class SetupBase with _$SetupBase {
const factory SetupBase({required String path, required String overlay}) =
_SetupBase;
factory SetupBase.fromJson(Map<String, dynamic> json) =>
_$SetupBaseFromJson(json);
}
@freezed
abstract class ProfileBase with _$ProfileBase {
const factory ProfileBase({
required Map<String, dynamic> config,
required String digest,
@Default([]) @JsonKey(name: 'match-rules') List<String> matchRules,
@Default(0) int proxies,
@Default(0) @JsonKey(name: 'proxy-groups') int proxyGroups,
@Default(0) int rules,
}) = _ProfileBase;
factory ProfileBase.fromJson(Map<String, dynamic> json) =>
_$ProfileBaseFromJson(json);
}
@freezed
abstract class UpdateParams with _$UpdateParams {
const factory UpdateParams({
@@ -143,17 +168,6 @@ abstract class RouteSnapshot with _$RouteSnapshot {
_$RouteSnapshotFromJson(json);
}
@freezed
abstract class UpdateGeoDataParams with _$UpdateGeoDataParams {
const factory UpdateGeoDataParams({
@JsonKey(name: 'geo-type') required String geoType,
@JsonKey(name: 'geo-name') required String geoName,
}) = _UpdateGeoDataParams;
factory UpdateGeoDataParams.fromJson(Map<String, Object?> json) =>
_$UpdateGeoDataParamsFromJson(json);
}
@freezed
abstract class CoreEvent with _$CoreEvent {
const factory CoreEvent({required CoreEventType type, dynamic data}) =
@@ -278,26 +292,6 @@ extension CoreMemoryStatsExt on CoreMemoryStats {
int get runtimeTotal => heapInuse + heapIdle + stackInuse + runtimeOther;
}
@freezed
abstract class Now with _$Now {
const factory Now({required String name, required String value}) = _Now;
factory Now.fromJson(Map<String, Object?> json) => _$NowFromJson(json);
}
@freezed
abstract class ProviderSubscriptionInfo with _$ProviderSubscriptionInfo {
const factory ProviderSubscriptionInfo({
@JsonKey(name: 'UPLOAD') @Default(0) int upload,
@JsonKey(name: 'DOWNLOAD') @Default(0) int download,
@JsonKey(name: 'TOTAL') @Default(0) int total,
@JsonKey(name: 'EXPIRE') @Default(0) int expire,
}) = _ProviderSubscriptionInfo;
factory ProviderSubscriptionInfo.fromJson(Map<String, Object?> json) =>
_$ProviderSubscriptionInfoFromJson(json);
}
SubscriptionInfo? subscriptionInfoFormCore(Map<String, Object?>? json) {
if (json == null) return null;
return SubscriptionInfo(
File diff suppressed because it is too large Load Diff
+35 -39
View File
@@ -10,6 +10,9 @@ _SetupParams _$SetupParamsFromJson(Map<String, dynamic> json) => _SetupParams(
selectedMap: Map<String, String>.from(json['selected-map'] as Map),
testUrl: json['test-url'] as String,
skipCertVerify: json['skip-cert-verify'] as bool? ?? false,
base: json['base'] == null
? null
: SetupBase.fromJson(json['base'] as Map<String, dynamic>),
);
Map<String, dynamic> _$SetupParamsToJson(_SetupParams instance) =>
@@ -17,6 +20,38 @@ Map<String, dynamic> _$SetupParamsToJson(_SetupParams instance) =>
'selected-map': instance.selectedMap,
'test-url': instance.testUrl,
'skip-cert-verify': instance.skipCertVerify,
'base': ?instance.base,
};
_SetupBase _$SetupBaseFromJson(Map<String, dynamic> json) => _SetupBase(
path: json['path'] as String,
overlay: json['overlay'] as String,
);
Map<String, dynamic> _$SetupBaseToJson(_SetupBase instance) =>
<String, dynamic>{'path': instance.path, 'overlay': instance.overlay};
_ProfileBase _$ProfileBaseFromJson(Map<String, dynamic> json) => _ProfileBase(
config: json['config'] as Map<String, dynamic>,
digest: json['digest'] as String,
matchRules:
(json['match-rules'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
const [],
proxies: (json['proxies'] as num?)?.toInt() ?? 0,
proxyGroups: (json['proxy-groups'] as num?)?.toInt() ?? 0,
rules: (json['rules'] as num?)?.toInt() ?? 0,
);
Map<String, dynamic> _$ProfileBaseToJson(_ProfileBase instance) =>
<String, dynamic>{
'config': instance.config,
'digest': instance.digest,
'match-rules': instance.matchRules,
'proxies': instance.proxies,
'proxy-groups': instance.proxyGroups,
'rules': instance.rules,
};
_UpdateParams _$UpdateParamsFromJson(Map<String, dynamic> json) =>
@@ -187,19 +222,6 @@ Map<String, dynamic> _$RouteSnapshotToJson(_RouteSnapshot instance) =>
'picks': instance.picks,
};
_UpdateGeoDataParams _$UpdateGeoDataParamsFromJson(Map<String, dynamic> json) =>
_UpdateGeoDataParams(
geoType: json['geo-type'] as String,
geoName: json['geo-name'] as String,
);
Map<String, dynamic> _$UpdateGeoDataParamsToJson(
_UpdateGeoDataParams instance,
) => <String, dynamic>{
'geo-type': instance.geoType,
'geo-name': instance.geoName,
};
_CoreEvent _$CoreEventFromJson(Map<String, dynamic> json) => _CoreEvent(
type: $enumDecode(_$CoreEventTypeEnumMap, json['type']),
data: json['data'],
@@ -387,32 +409,6 @@ Map<String, dynamic> _$CoreMemoryStatsToJson(_CoreMemoryStats instance) =>
'runtimeOther': instance.runtimeOther,
};
_Now _$NowFromJson(Map<String, dynamic> json) =>
_Now(name: json['name'] as String, value: json['value'] as String);
Map<String, dynamic> _$NowToJson(_Now instance) => <String, dynamic>{
'name': instance.name,
'value': instance.value,
};
_ProviderSubscriptionInfo _$ProviderSubscriptionInfoFromJson(
Map<String, dynamic> json,
) => _ProviderSubscriptionInfo(
upload: (json['UPLOAD'] as num?)?.toInt() ?? 0,
download: (json['DOWNLOAD'] as num?)?.toInt() ?? 0,
total: (json['TOTAL'] as num?)?.toInt() ?? 0,
expire: (json['EXPIRE'] as num?)?.toInt() ?? 0,
);
Map<String, dynamic> _$ProviderSubscriptionInfoToJson(
_ProviderSubscriptionInfo instance,
) => <String, dynamic>{
'UPLOAD': instance.upload,
'DOWNLOAD': instance.download,
'TOTAL': instance.total,
'EXPIRE': instance.expire,
};
_ExternalProvider _$ExternalProviderFromJson(Map<String, dynamic> json) =>
_ExternalProvider(
name: json['name'] as String,
+96 -28
View File
@@ -2,7 +2,19 @@ part of '../action.dart';
enum _SetupTaskResult { completed, handoffToCoreRestart, failed }
typedef _SetupInputs = (SetupState, PatchClashConfig, NetworkProps);
typedef _SetupInputs = (
SetupState,
PatchClashConfig,
NetworkProps,
SetupParams,
);
typedef BuiltProfile = ({
String yaml,
String md5,
ConfigCounts counts,
SetupBase? base,
});
class _RunRequest {
final bool running;
@@ -56,6 +68,10 @@ class SetupAction extends _$SetupAction {
);
}
// The selection reaches the Core through changeProxy, not through an apply.
SetupParams get _appliedParams =>
_setupParams.copyWith(selectedMap: const {});
Future<bool> fullSetup() async {
if (!ref.read(initProvider)) return true;
ref.read(proxiesActionProvider.notifier).cancelDelayTests();
@@ -167,7 +183,7 @@ class SetupAction extends _$SetupAction {
try {
applied = await applyProfile(
force: true,
preloadInvoke: () => _setCoreRunning(request),
preloadInvoke: () => _startCoreRunning(request),
);
} catch (_) {
applied = false;
@@ -179,20 +195,28 @@ class SetupAction extends _$SetupAction {
}
try {
await _setCoreRunning(request);
await _startCoreRunning(request);
} catch (_) {
_rollbackRunning(request);
rethrow;
}
if (_isCurrent(request)) {
final inputsApplied = await _inputsApplied();
if (_isCurrent(request) && !inputsApplied) {
applyProfileDebounce(force: true, silence: true);
}
return true;
}
Future<void> _startCoreRunning(_RunRequest request) async {
if (!await _setCoreRunning(request) && _isCurrent(request)) {
throw _unanswered(CoreMethod.startListener);
}
}
Future<bool> _stop(_RunRequest request) async {
final bool answered;
try {
await _setCoreRunning(request);
answered = await _setCoreRunning(request);
} catch (_) {
_rollbackRunning(request);
rethrow;
@@ -203,32 +227,42 @@ class SetupAction extends _$SetupAction {
resetCoreTraffic();
ref.read(trafficsProvider.notifier).clear();
ref.read(totalTrafficProvider.notifier).value = const Traffic();
// A silent Core is gone or hung; running again would proxy traffic to it.
if (!answered) {
throw _unanswered(CoreMethod.stopListener);
}
return true;
}
Future<void> _setCoreRunning(_RunRequest request) {
Future<bool> _setCoreRunning(_RunRequest request) {
return _listenerScheduler.run(() async {
if (!_isCurrent(request)) {
return;
return true;
}
if (request.running && ref.read(suspendProvider)) {
return;
return true;
}
if (ref.read(safeModeProvider)) {
return;
return true;
}
await _setListening(request.running);
return _setListening(request.running);
});
}
Future<void> _setListening(bool running) async {
Future<bool> _setListening(bool running) async {
final vpnOptions = await ref.read(vpnOptionsProvider.future);
await setCoreRunning(running);
ref.read(runningVpnOptionsProvider.notifier).value = running
final answered = await setCoreRunning(running);
ref.read(runningVpnOptionsProvider.notifier).value = running && answered
? vpnOptions
: null;
return answered;
}
CoreMethodException _unanswered(CoreMethod method) => CoreMethodException(
code: 'no_response',
message: 'Core did not answer ${method.name}',
);
/// An excluded SSID parks the listener without ending the run.
void syncSuspend() {
debouncer.call(FunctionTag.suspend, () {
@@ -341,6 +375,7 @@ class SetupAction extends _$SetupAction {
setupState,
_effectivePatchConfig(ref.read(patchClashConfigProvider)),
ref.read(networkSettingProvider),
_appliedParams,
);
}
@@ -395,12 +430,15 @@ class SetupAction extends _$SetupAction {
}
}
Future<({String yaml, String md5})> getProfile({
/// With a base, [BuiltProfile.yaml] is only the overlay the Core applies.
Future<BuiltProfile> getProfile({
required SetupState setupState,
required PatchClashConfig patchConfig,
}) async {
final profileId = setupState.profileId;
if (profileId == null) return (yaml: '', md5: '');
if (profileId == null) {
return (yaml: '', md5: '', counts: configCountsOf(const {}), base: null);
}
final defaultUA = globalState.packageInfo.ua;
final networkSetting = ref.read(
networkSettingProvider.select(
@@ -413,6 +451,14 @@ class SetupAction extends _$SetupAction {
);
final appendSystemDns = networkSetting.appendSystemDns;
final isCustom = setupState.profileType == ProfileType.custom;
final scriptContent =
!isCustom && setupState.extendType == ExtendType.script
? await setupState.script?.content
: null;
final hasScript = scriptContent?.isNotEmpty == true;
final profileBase = !isCustom && !hasScript
? await _core.getProfileBase(profileId)
: null;
Map<String, dynamic> rawConfig = isCustom
? {
'proxies': appProxiesPayload(
@@ -421,14 +467,10 @@ class SetupAction extends _$SetupAction {
groups: setupState.proxyGroups,
),
}
: await _core.getConfig(profileId);
: profileBase?.base.config ?? await _core.getConfig(profileId);
final proxyGroups = isCustom ? setupState.proxyGroups : <ProxyGroup>[];
final rules = isCustom ? setupState.rules : <Rule>[];
final scriptContent =
!isCustom && setupState.extendType == ExtendType.script
? await setupState.script?.content
: null;
if (scriptContent?.isNotEmpty == true) {
if (hasScript) {
rawConfig = await handleEvaluate(scriptContent!, rawConfig);
}
final realPatchConfig = patchConfig.copyWith(
@@ -442,7 +484,7 @@ class SetupAction extends _$SetupAction {
proxyGroups: proxyGroups,
rules: rules,
);
final res = makeRealProfileTask(
final res = await makeRealProfileTask(
MakeRealProfileState(
rules: rules,
proxyGroups: proxyGroups,
@@ -458,11 +500,28 @@ class SetupAction extends _$SetupAction {
addedRules: setupState.addedRules,
defaultUA: defaultUA,
authentication: networkSetting.authentication.credentials,
matchTarget: setupState.matchTarget,
matchTarget:
profileBase == null ||
setupState.matchTarget?.trim().isNotEmpty == true
? setupState.matchTarget
: lastMatchTarget(profileBase.base.matchRules),
safeMode: ref.read(safeModeProvider),
),
);
return res;
if (profileBase == null) {
return (yaml: res.yaml, md5: res.md5, counts: res.counts, base: null);
}
final (:base, :path) = profileBase;
return (
yaml: res.yaml,
md5: '${res.md5}${base.digest}'.toMd5(),
counts: (
groups: res.counts.groups + base.proxyGroups,
proxies: res.counts.proxies + base.proxies,
rules: res.counts.rules + base.rules,
),
base: SetupBase(path: path, overlay: res.yaml),
);
}
/// Only what the custom profile actually references is injected, so an
@@ -523,7 +582,10 @@ class SetupAction extends _$SetupAction {
setupState: setupState,
patchConfig: patchClashConfig,
);
return res.yaml;
return switch (res.base) {
final base? => await _core.buildProfile(base),
null => res.yaml,
};
} catch (e) {
dialogs.showNotifier(e.toString(), level: MessageLevel.error);
}
@@ -641,11 +703,13 @@ class SetupAction extends _$SetupAction {
return getProfile(setupState: state, patchConfig: realPatchConfig);
}, title: 'build profile');
final profileFailed = realProfile == null;
final setupParams = _setupParams.copyWith(base: realProfile?.base);
final inputs = switch (setupState) {
final state? when !profileFailed => (
state,
realPatchConfig,
networkSetting,
_appliedParams,
),
_ => null,
};
@@ -669,14 +733,16 @@ class SetupAction extends _$SetupAction {
await globalState.loadingRun(
() async {
try {
final configFilePath = await appPath.configFilePath;
await File(configFilePath).writeAsStringAtomically(yamlString);
if (setupParams.base == null) {
final configFilePath = await appPath.configFilePath;
await File(configFilePath).writeAsStringAtomically(yamlString);
}
final profileId = profile?.id;
if (profileId != null) {
await appPath.ensureProviderDirs(profileId);
}
final message = await _core.setupConfig(
params: _setupParams,
params: setupParams,
preloadInvoke: preloadInvoke,
);
if (message.isNotEmpty) {
@@ -691,6 +757,8 @@ class SetupAction extends _$SetupAction {
rethrow;
}
globalState.lastConfigMd5 = yamlMd5;
globalState.lastConfigCounts =
realProfile?.counts ?? configCountsOf(const {});
_appliedInputs = inputs;
await onUpdated?.call();
},
+4 -2
View File
@@ -37,8 +37,10 @@ Future<ClashConfig> clashConfig(Ref ref, int profileId) async {
if (type == ProfileType.custom) {
return const ClashConfig();
}
final configMap = await ref.read(coreHandlerProvider).getConfig(profileId);
return clashConfigTask(configMap);
final outbounds = await ref
.read(coreHandlerProvider)
.getProfileOutbounds(profileId);
return clashConfigTask(outbounds);
}
@riverpod
+8 -9
View File
@@ -1,25 +1,23 @@
import 'dart:async';
import 'common/common.dart';
import 'enum/enum.dart';
import 'models/models.dart';
import 'package:fl_clash/common/theme.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/providers/config.dart';
import 'package:fl_clash/ui/ui.dart';
import 'package:flutter/foundation.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'common/common.dart';
import 'enum/enum.dart';
import 'models/models.dart';
class GlobalState {
static GlobalState? _instance;
GlobalKey<NavigatorState> get navigatorKey => rootNavigatorKey;
late final String appEnv;
late final PackageInfo packageInfo;
Function? updateCurrentDelayDebounce;
late String appEnv;
late PackageInfo packageInfo;
late Measure measure;
late CommonTheme theme;
late ProviderContainer container;
bool needInitStatus = true;
@@ -33,6 +31,7 @@ class GlobalState {
}
String? lastConfigMd5;
ConfigCounts? lastConfigCounts;
bool isAttach = false;
GlobalState._internal();