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 decodeJSONTask(String data) async { return compute(_decodeJSON, data); } Future _decodeJSON(String content) async { return json.decode(content); } Future encodeJSONTask(T data) async { return compute(_encodeJSON, data); } Future _encodeJSON(T content) async { return json.encode(content); } Future encodeYamlTask(T data) async { return compute(_encodeYaml, data); } Future _encodeYaml(T content) async { return yaml.encode(content); } Future encodeMD5Task(String data) async { return compute(_encodeMD5, data); } Future _encodeMD5(String content) async { return content.toMd5(); } /// Throws [FormatException] when the file is not UTF-8. Future 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> toGroupsTask(ComputeGroupsState data) async { return compute>(buildGroups, data); } @visibleForTesting Future> 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 = []; 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.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 clashConfigTask(Map data) async { return compute, ClashConfig>(buildClashConfig, data); } @visibleForTesting ClashConfig buildClashConfig(Map configMap) { final clashConfig = ClashConfig.fromJson(configMap); final proxyTypeMap = {}; 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( _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.from(rawConfig['sniffer'] as Map) : {}, 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 injected, ) { if (injected.isEmpty) { return; } final providers = rawConfig[section]; rawConfig[section] = { ...injected, if (providers is Map) ...providers.cast(), }; } 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.from(rawConfig['dns'] as Map) : {}; 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.from(rawConfig['ntp'] as Map) : {}; rawConfig['ntp'] = {...rawNtp, ...ntpOverride}; } if (appendSystemDns) { final List nameserver = List.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.from(rawNtp), NtpOverrideKey.writeToSystem.path: false, }; } } List rules = []; if (data.rules.isEmpty) { if (rawConfig['rules'] != null) { rules = List.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 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.from(rawConfig)); return (yaml: yaml, md5: yaml.toMd5()); } typedef ShakingStoreArgs = ({ Iterable profileIds, Iterable scriptIds, Iterable providerFileNames, }); Future> shakingProfileTask(ShakingStoreArgs data) async { return compute< ({ShakingStoreArgs args, RootIsolateToken token}), List >(_shakingProfileTask, (args: data, token: RootIsolateToken.instance!)); } Future> _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 shakeOrphanFiles({ required Iterable profileIds, required Iterable scriptIds, required Iterable providerFileNames, required String profilesDirPath, required String providersDirPath, required String providerCacheDirPath, required String scriptsDirPath, }) { final List 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 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 encodeLogsTask(List data) async { return compute, String>(_encodeLogsTask, data); } Future _encodeLogsTask(List data) async { final logsRaw = data.map((item) => item.toString()); final logsRawString = logsRaw.join('\n'); return logsRawString; } Future oldToNowTask(Map data) async { final homeDir = await appPath.homeDirPath; return compute< ({Map configMap, String sourcePath, String targetPath}), MigrationData >(_oldToNowTask, (configMap: data, sourcePath: homeDir, targetPath: homeDir)); } Future _oldToNowTask( ({Map configMap, String sourcePath, String targetPath}) data, ) { return migrateLegacyConfig( configMap: data.configMap, sourcePath: data.sourcePath, targetPath: data.targetPath, ); } Future migrateLegacyConfig({ required Map 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? ?? {}; appSettingProps['restoreStrategy'] = appSettingProps['recoveryStrategy']; configMap['appSettingProps'] = appSettingProps; configMap['proxiesStyleProps'] = configMap['proxiesStyle']; List rawScripts = configMap['scripts'] as List? ?? []; if (rawScripts.isEmpty) { final scriptPropsJson = configMap['scriptProps'] as Map?; if (scriptPropsJson != null) { rawScripts = scriptPropsJson['scripts'] as List? ?? []; } } final Map idMap = {}; final List