Files
FlClash/lib/common/task.dart
T
chen08209 491fae10f5 feat(views)!: set DNS, NTP, sniffer and TUN key by key
BREAKING CHANGE: DNS and NTP override toggles are removed; picked keys apply to every profile
Changelog: Per-key DNS, NTP, sniffer and TUN settings
2026-10-12 10:34:14 +08:00

637 lines
21 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:path/path.dart';
Future<T> decodeJSONTask<T>(String data) async {
return compute<String, T>(_decodeJSON, data);
}
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);
}
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);
}
String? _readTextFile(String path) {
final file = File(path);
if (!file.existsSync()) {
return null;
}
return utf8.decode(file.readAsBytesSync());
}
Future<List<Group>> toGroupsTask(ComputeGroupsState data) async {
return compute<ComputeGroupsState, List<Group>>(buildGroups, data);
}
@visibleForTesting
Future<List<Group>> buildGroups(ComputeGroupsState state) async {
final proxiesData = state.proxiesData;
final all = proxiesData.all;
final sortType = state.sortType;
final delayMap = state.delayMap;
final selectedMap = state.selectedMap;
final defaultTestUrl = state.defaultTestUrl;
final proxies = proxiesData.proxies;
if (proxies.isEmpty) return [];
final groups = <Group>[];
for (final groupName in all) {
final raw = proxies[groupName];
if (raw is! Map) continue;
if (!GroupTypeExtension.valueList.contains(raw['type'])) continue;
final memberNames = raw['all'];
final group = Map<String, dynamic>.from(raw);
group['all'] = memberNames is List
? memberNames.map((name) => proxies[name]).nonNulls.toList()
: const [];
groups.add(Group.fromJson(group));
}
return computeSort(
groups: groups,
sortType: sortType,
delayMap: delayMap,
selectedMap: selectedMap,
defaultTestUrl: defaultTestUrl,
);
}
Future<ClashConfig> clashConfigTask(Map<String, dynamic> data) async {
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);
}
Future<({String yaml, String md5})> makeRealProfileTask(
MakeRealProfileState data,
) async {
return compute<MakeRealProfileState, ({String yaml, String md5})>(
_makeRealProfileTask,
data,
);
}
Future<({String yaml, String md5})> _makeRealProfileTask(
MakeRealProfileState data,
) async {
final rawConfig = Map.from(data.rawConfig);
final realPatchConfig = data.realPatchConfig;
final profilesPath = data.profilesPath;
final profileId = data.profileId;
final overrides = data.overrides;
final addedRules = data.addedRules;
final appendSystemDns = data.appendSystemDns;
final defaultUA = data.defaultUA;
String getProvidersFilePathInner(String type, String key) {
return join(
profilesPath,
providersDirectoryName,
profileId.toString(),
type,
key.toMd5(),
);
}
void confineProviders(String section, String type) {
final providers = rawConfig[section];
if (providers is! Map) {
return;
}
for (final name in providers.keys) {
final provider = providers[name];
if (provider is! Map || provider['type'] == 'inline') {
continue;
}
// Two providers may share a URL and differ only by header.
final url = provider['url'];
final hasUrl = url is String && url.isNotEmpty;
final path = getProvidersFilePathInner(
type,
hasUrl ? '$name@$url' : '$section/$name',
);
if (hasUrl) {
_migrateLegacyProviderFile(
legacyPath: getProvidersFilePathInner(type, url),
newPath: path,
);
}
provider['path'] = path;
}
}
rawConfig['external-controller'] =
data.safeMode || realPatchConfig.secret.isEmpty
? ''
: realPatchConfig.externalController.value;
rawConfig['secret'] = realPatchConfig.secret;
// mihomo serves the unix and pipe controllers without a secret, and the unix
// one unlinks whatever sits at its path before opening it to everyone.
rawConfig['external-controller-tls'] = '';
rawConfig['external-controller-unix'] = '';
rawConfig['external-controller-pipe'] = '';
final iptables = rawConfig['iptables'];
if (iptables is Map) {
final inboundInterface = iptables['inbound-interface'];
if (inboundInterface is String && inboundInterface.contains(' ')) {
rawConfig['iptables'] = {'enable': false};
}
}
rawConfig['external-ui'] = '';
switch (realPatchConfig.interfaceNameMode) {
case InterfaceNameMode.clear:
rawConfig['interface-name'] = '';
case InterfaceNameMode.follow:
break;
case InterfaceNameMode.custom:
rawConfig['interface-name'] = realPatchConfig.interfaceName;
}
rawConfig['external-ui-url'] = '';
rawConfig['tcp-concurrent'] = realPatchConfig.tcpConcurrent;
rawConfig['unified-delay'] = realPatchConfig.unifiedDelay;
rawConfig['ipv6'] = realPatchConfig.ipv6;
rawConfig['log-level'] = realPatchConfig.logLevel.name;
rawConfig['port'] = 0;
rawConfig['socks-port'] = 0;
rawConfig['keep-alive-interval'] = realPatchConfig.keepAliveInterval;
rawConfig['keep-alive-idle'] = realPatchConfig.keepAliveIdle;
rawConfig['disable-keep-alive'] = realPatchConfig.disableKeepAlive;
rawConfig['routing-mark'] = realPatchConfig.routingMark;
rawConfig['mixed-port'] = realPatchConfig.mixedPort;
rawConfig['port'] = realPatchConfig.port;
rawConfig['socks-port'] = realPatchConfig.socksPort;
rawConfig['redir-port'] = realPatchConfig.redirPort;
rawConfig['tproxy-port'] = realPatchConfig.tproxyPort;
rawConfig['find-process-mode'] = realPatchConfig.findProcessMode.name;
rawConfig['allow-lan'] = realPatchConfig.allowLan;
// The app owns local inbound authentication; a profile-provided
// skip-auth-prefixes could silently exempt loopback and defeat it.
rawConfig['authentication'] = data.authentication;
rawConfig['skip-auth-prefixes'] = [];
rawConfig['mode'] = realPatchConfig.mode.name;
rawConfig['tun'] = {
if (rawConfig['tun'] is Map) ...rawConfig['tun'] as Map,
...overrides.tun.overrideJson(overrides.tunOverrideKeys),
...realPatchConfig.tun.toJson(),
};
rawConfig['geodata-loader'] = realPatchConfig.geodataLoader.name;
rawConfig['geo-auto-update'] = realPatchConfig.geoAutoUpdate;
rawConfig['geo-update-interval'] = realPatchConfig.geoUpdateInterval;
final snifferOverride = overrides.sniffer.overrideJson(
overrides.snifferOverrideKeys,
);
if (snifferOverride.isNotEmpty) {
rawConfig['sniffer'] = mergeSnifferOverride(
rawConfig['sniffer'] is Map
? Map<String, dynamic>.from(rawConfig['sniffer'] as Map)
: <String, dynamic>{},
snifferOverride,
);
}
if (rawConfig['sniffer']?['sniff'] != null) {
for (final value in (rawConfig['sniffer']?['sniff'] as Map).values) {
if (value['ports'] != null && value['ports'] is List) {
value['ports'] =
value['ports']?.map((item) => item.toString()).toList() ?? [];
}
}
}
if (rawConfig['profile'] == null) {
rawConfig['profile'] = {};
}
void injectUnconfinedProviders(
String section,
Map<String, dynamic> injected,
) {
if (injected.isEmpty) {
return;
}
final providers = rawConfig[section];
rawConfig[section] = {
...injected,
if (providers is Map) ...providers.cast<String, dynamic>(),
};
}
confineProviders('proxy-providers', proxiesProviderDirectoryName);
confineProviders('rule-providers', rulesProviderDirectoryName);
injectUnconfinedProviders('proxy-providers', data.injectedProxyProviders);
injectUnconfinedProviders('rule-providers', data.injectedRuleProviders);
rawConfig['profile']['store-selected'] = false;
rawConfig['geox-url'] = realPatchConfig.geoXUrl.raw;
rawConfig['global-ua'] = realPatchConfig.globalUa ?? defaultUA;
if (rawConfig['hosts'] == null) {
rawConfig['hosts'] = {};
}
for (final host in realPatchConfig.hosts.entries) {
rawConfig['hosts'][host.key] = host.value.splitByMultipleSeparators;
}
var rawDns = rawConfig['dns'] is Map
? Map<String, dynamic>.from(rawConfig['dns'] as Map)
: <String, dynamic>{};
final isEnableDns = rawDns['enable'] == true;
const systemDns = 'system://';
if (!isEnableDns) {
rawDns = mergeDnsOverride(
rawDns,
baselineDns.overrideJson(baselineDnsOverrideKeys),
);
}
rawDns = mergeDnsOverride(
rawDns,
overrides.dns.overrideJson(
overrides.dnsOverrideKeys,
keepEmpty: data.keepEmptyDnsOverrides,
),
);
rawConfig['dns'] = rawDns;
final ntpOverride = overrides.ntp.overrideJson(overrides.ntpOverrideKeys);
if (ntpOverride.isNotEmpty) {
final rawNtp = rawConfig['ntp'] is Map
? Map<String, dynamic>.from(rawConfig['ntp'] as Map)
: <String, dynamic>{};
rawConfig['ntp'] = {...rawNtp, ...ntpOverride};
}
if (appendSystemDns) {
final List<String> nameserver = List<String>.from(
rawConfig['dns']['nameserver'] ?? [],
);
if (!nameserver.contains(systemDns)) {
rawConfig['dns']['nameserver'] = [...nameserver, systemDns];
}
}
if (data.safeMode) {
rawConfig['listeners'] = [];
rawConfig['tunnels'] = [];
rawConfig['ss-config'] = '';
rawConfig['vmess-config'] = '';
rawConfig['tuic-server'] = {'enable': false};
rawConfig['iptables'] = {'enable': false};
rawConfig['dns']['listen'] = '';
final rawNtp = rawConfig['ntp'];
if (rawNtp is Map) {
rawConfig['ntp'] = {
...Map<String, dynamic>.from(rawNtp),
NtpOverrideKey.writeToSystem.path: false,
};
}
}
List<String> rules = [];
if (data.rules.isEmpty) {
if (rawConfig['rules'] != null) {
rules = List<String>.from(rawConfig['rules']);
}
if (addedRules.isNotEmpty) {
final hasMatchPlaceholder = addedRules.any(
(item) => item.ruleTarget?.toUpperCase() == 'MATCH',
);
String? replacementTarget = data.matchTarget?.trim();
if (replacementTarget?.isEmpty == true) {
replacementTarget = null;
}
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;
}
}
}
}
final List<String> finalAddedRules;
if (replacementTarget?.isNotEmpty == true) {
finalAddedRules = [];
for (int i = 0; i < addedRules.length; i++) {
final parsed = addedRules[i];
if (parsed.ruleTarget?.toUpperCase() == 'MATCH') {
finalAddedRules.add(
parsed.copyWith(ruleTarget: replacementTarget).rawValue,
);
} else {
finalAddedRules.add(addedRules[i].rawValue);
}
}
} else {
finalAddedRules = addedRules.map((e) => e.rawValue).toList();
}
rules = [...finalAddedRules, ...rules];
}
} else {
rules = data.rules.map((item) => item.rawValue).toList();
}
if (data.proxyGroups.isNotEmpty) {
rawConfig['proxy-groups'] = data.proxyGroups
.map((item) => item.definition)
.toList();
}
rawConfig['rules'] = rules;
final yaml = await _encodeYaml(Map<String, dynamic>.from(rawConfig));
return (yaml: yaml, md5: yaml.toMd5());
}
typedef ShakingStoreArgs = ({
Iterable<int> profileIds,
Iterable<int> scriptIds,
Iterable<String> providerFileNames,
});
Future<List<String>> shakingProfileTask(ShakingStoreArgs data) async {
return compute<
({ShakingStoreArgs args, RootIsolateToken token}),
List<String>
>(_shakingProfileTask, (args: data, token: RootIsolateToken.instance!));
}
Future<List<String>> _shakingProfileTask(
({ShakingStoreArgs args, RootIsolateToken token}) data,
) async {
BackgroundIsolateBinaryMessenger.ensureInitialized(data.token);
return shakeOrphanFiles(
profileIds: data.args.profileIds,
scriptIds: data.args.scriptIds,
providerFileNames: data.args.providerFileNames,
profilesDirPath: await appPath.profilesPath,
providersDirPath: await appPath.getProvidersRootPath(),
providerCacheDirPath: await appPath.providerCacheRootPath,
scriptsDirPath: await appPath.scriptsDirPath,
);
}
@visibleForTesting
List<String> shakeOrphanFiles({
required Iterable<int> profileIds,
required Iterable<int> scriptIds,
required Iterable<String> providerFileNames,
required String profilesDirPath,
required String providersDirPath,
required String providerCacheDirPath,
required String scriptsDirPath,
}) {
final List<String> targets = [];
void scanDirectory(
Directory dir,
bool Function(String baseName) isLive, {
bool includeDirectories = false,
}) {
if (!dir.existsSync()) return;
final entities = dir.listSync(recursive: false, followLinks: false);
for (final entity in entities) {
final selected =
entity is File || (includeDirectories && entity is Directory);
if (!selected) {
continue;
}
if (!isLive(basenameWithoutExtension(entity.path))) {
targets.add(entity.path);
}
}
}
bool Function(String) isLiveId(Iterable<int> ids) =>
(baseName) => ids.contains(int.tryParse(baseName));
scanDirectory(Directory(profilesDirPath), isLiveId(profileIds));
scanDirectory(
Directory(providersDirPath),
isLiveId(profileIds),
includeDirectories: true,
);
scanDirectory(
Directory(join(providerCacheDirPath, rulesProviderDirectoryName)),
providerFileNames.toSet().contains,
);
scanDirectory(Directory(scriptsDirPath), isLiveId(scriptIds));
return targets;
}
// Best-effort: a legacy url shared by two providers, or any rename failure,
// just leaves the file to be re-downloaded under the new path.
void _migrateLegacyProviderFile({
required String legacyPath,
required String newPath,
}) {
if (legacyPath == newPath || File(newPath).existsSync()) {
return;
}
try {
final legacyFile = File(legacyPath);
if (legacyFile.existsSync()) {
Directory(dirname(newPath)).createSync(recursive: true);
legacyFile.renameSync(newPath);
}
} catch (_) {}
}
Future<String> encodeLogsTask(List<Log> data) async {
return compute<List<Log>, String>(_encodeLogsTask, data);
}
Future<String> _encodeLogsTask(List<Log> data) async {
final logsRaw = data.map((item) => item.toString());
final logsRawString = logsRaw.join('\n');
return logsRawString;
}
Future<MigrationData> oldToNowTask(Map<String, Object?> data) async {
final homeDir = await appPath.homeDirPath;
return compute<
({Map<String, Object?> configMap, String sourcePath, String targetPath}),
MigrationData
>(_oldToNowTask, (configMap: data, sourcePath: homeDir, targetPath: homeDir));
}
Future<MigrationData> _oldToNowTask(
({Map<String, Object?> configMap, String sourcePath, String targetPath}) data,
) {
return migrateLegacyConfig(
configMap: data.configMap,
sourcePath: data.sourcePath,
targetPath: data.targetPath,
);
}
Future<MigrationData> migrateLegacyConfig({
required Map<String, Object?> configMap,
required String sourcePath,
required String targetPath,
}) async {
final accessControlMap = configMap['accessControl'];
final isAccessControl = configMap['isAccessControl'];
if (accessControlMap != null) {
(accessControlMap as Map)['enable'] = isAccessControl;
if (configMap['vpnProps'] != null) {
final vpnPropsRaw = configMap['vpnProps'] as Map;
vpnPropsRaw['accessControl'] = accessControlMap;
}
}
if (configMap['vpnProps'] != null) {
final vpnPropsRaw = configMap['vpnProps'] as Map;
vpnPropsRaw['accessControlProps'] = vpnPropsRaw['accessControl'];
}
configMap['davProps'] = configMap['dav'];
final appSettingProps =
configMap['appSetting'] as Map<String, dynamic>? ?? {};
appSettingProps['restoreStrategy'] = appSettingProps['recoveryStrategy'];
configMap['appSettingProps'] = appSettingProps;
configMap['proxiesStyleProps'] = configMap['proxiesStyle'];
List rawScripts = configMap['scripts'] as List<dynamic>? ?? [];
if (rawScripts.isEmpty) {
final scriptPropsJson = configMap['scriptProps'] as Map<String, dynamic>?;
if (scriptPropsJson != null) {
rawScripts = scriptPropsJson['scripts'] as List<dynamic>? ?? [];
}
}
final Map<String, int> idMap = {};
final List<Script> scripts = [];
for (final rawScript in rawScripts) {
final id = rawScript['id'] as String?;
final content = rawScript['content'] as String?;
final label = rawScript['label'] as String?;
if (id == null || content == null || label == null) {
continue;
}
final newId = idMap.updateCacheValue(rawScript['id'], () => snowflake.id);
final path = BackupEntries.resolve(targetPath, BackupEntries.script(newId));
final file = File(path);
await file.safeWriteAsString(content);
scripts.add(
Script(id: newId, label: label, lastUpdateTime: DateTime.now()),
);
}
final List rawRules = configMap['rules'] as List<dynamic>? ?? [];
final List<Rule> rules = [];
final List<DisabledRule> disabledRules = [];
for (final rawRule in rawRules) {
final id = idMap.updateCacheValue(rawRule['id'], () => snowflake.id);
rawRule['id'] = id;
final value = rawRule['value'] ?? '';
rules.add(Rule.parse(value, id: id));
}
final List rawProfiles = configMap['profiles'] as List<dynamic>? ?? [];
final List<Profile> profiles = [];
for (final rawProfile in rawProfiles) {
final rawId = rawProfile['id'] as String?;
if (rawId == null) {
continue;
}
final profileId = idMap.updateCacheValue(rawId, () => snowflake.id);
rawProfile['id'] = profileId;
final overwrite = rawProfile['overwrite'] as Map?;
if (overwrite != null) {
final standardOverwrite = overwrite['standardOverwrite'] as Map?;
if (standardOverwrite != null) {
final addedRules = standardOverwrite['addedRules'] as List? ?? [];
for (final addRule in addedRules) {
final id = idMap.updateCacheValue(addRule['id'], () => snowflake.id);
final value = addRule['value'] ?? '';
rules.add(Rule.parse(value, id: id).copyWith(profileId: profileId));
}
final disabledRuleIds = standardOverwrite['disabledRuleIds'] as List?;
if (disabledRuleIds != null) {
for (final disabledRuleId in disabledRuleIds) {
final newDisabledRuleId = idMap[disabledRuleId];
if (newDisabledRuleId != null) {
disabledRules.add(
DisabledRule(profileId: profileId, ruleId: newDisabledRuleId),
);
}
}
}
}
final scriptOverwrite = overwrite['scriptOverwrite'] as Map?;
if (scriptOverwrite != null) {
final scriptId = scriptOverwrite['scriptId'] as String?;
rawProfile['scriptId'] = scriptId != null ? idMap[scriptId] : null;
}
rawProfile['extendType'] = overwrite['type'] == ExtendType.script.name
? ExtendType.script.name
: ExtendType.standard.name;
}
final rawUrl = rawProfile['url'];
rawProfile['type'] = rawUrl is String && rawUrl.isNotEmpty ? 'url' : 'file';
final sourceFile = File(
BackupEntries.resolve(sourcePath, BackupEntries.profile(rawId)),
);
final targetFilePath = BackupEntries.resolve(
targetPath,
BackupEntries.profile(profileId),
);
await sourceFile.safeCopy(targetFilePath);
profiles.add(Profile.fromJson(rawProfile));
}
final currentProfileId = configMap['currentProfileId'];
configMap['currentProfileId'] = currentProfileId != null
? idMap[currentProfileId]
: null;
return MigrationData(
configMap: configMap,
profiles: profiles,
rules: rules,
scripts: scripts,
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();
}