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:
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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>({
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (_) {}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user