Files
FlClash/lib/models/clash_config.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

2015 lines
62 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:collection/collection.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/core.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:yaml/yaml.dart';
part 'generated/clash_config.freezed.dart';
part 'generated/clash_config.g.dart';
const defaultClashConfig = PatchClashConfig();
const defaultTun = Tun();
const defaultDns = Dns();
const defaultNtp = Ntp();
const defaultSniffer = Sniffer();
const defaultProfileTun = ProfileTun();
/// What a profile that brings no DNS section of its own is given, so the core
/// always resolves. The user's own override set starts empty.
const baselineDnsOverrideKeys = {
DnsOverrideKey.enable,
DnsOverrideKey.enhancedMode,
DnsOverrideKey.nameserver,
};
const baselineDns = Dns(
enable: true,
enhancedMode: DnsMode.fakeIp,
nameserver: ['https://doh.pub/dns-query', 'https://dns.alidns.com/dns-query'],
);
const defaultGeoXUrl = {
GeoResource.MMDB:
'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.metadb',
GeoResource.ASN:
'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/GeoLite2-ASN.mmdb',
GeoResource.GEOIP:
'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.dat',
GeoResource.GEOSITE:
'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geosite.dat',
};
const defaultMixedPort = 7890;
const defaultKeepAliveInterval = 30;
const defaultTunMtu = 9000;
const minTunMtu = 1280;
const maxTunMtu = 65535;
const defaultCongestionController = 'cubic';
const privateRouteAddress = [
'0.0.0.0/8',
'10.0.0.0/8',
'127.0.0.0/8',
'169.254.0.0/16',
'172.16.0.0/12',
'192.168.0.0/16',
'224.0.0.0/4',
'255.255.255.255/32',
'fc00::/7',
'fe80::/10',
'ff00::/8',
];
@freezed
abstract class ProxyGroup with _$ProxyGroup {
const factory ProxyGroup({
int? profileId,
@JsonKey(fromJson: Snowflake.buildId) required int id,
required String name,
required GroupType type,
List<String>? proxies,
List<String>? use,
@JsonKey(name: 'use-position') Map<String, int>? usePosition,
int? interval,
bool? lazy,
@JsonKey(name: 'disable-udp') bool? disableUDP,
String? url,
int? timeout,
@JsonKey(name: 'max-failed-times') int? maxFailedTimes,
String? filter,
@JsonKey(name: 'exclude-filter') String? excludeFilter,
@JsonKey(name: 'exclude-type') String? excludeType,
@JsonKey(name: 'expected-status') String? expectedStatus,
int? tolerance,
LoadBalanceStrategy? strategy,
@JsonKey(name: 'hash-key') String? hashKey,
@JsonKey(name: 'default-selected') String? defaultSelected,
@JsonKey(name: 'empty-fallback') String? emptyFallback,
@JsonKey(name: 'include-all') bool? includeAll,
@JsonKey(name: 'include-all-proxies') bool? includeAllProxies,
@JsonKey(name: 'include-all-providers') bool? includeAllProviders,
bool? hidden,
String? icon,
String? order,
/// Where the members list shows the filters among the proxies, which no
/// config carries, as `ProxyGroupMembersSliver` writes it.
List<String>? memberLayout,
}) = _ProxyGroup;
factory ProxyGroup.fromJson(Map<String, Object?> json) =>
_$ProxyGroupFromJson(json);
}
extension ProxyGroupExt on ProxyGroup {
/// Only what `GroupCommonOption` and the per-type option structs read, so the
/// generated config carries no FlClash bookkeeping and no null placeholders.
Map<String, dynamic> get definition {
return {
'name': name,
'type': type.value,
if (proxies?.isNotEmpty == true) 'proxies': proxies,
if (use?.isNotEmpty == true) 'use': use,
'use-position': ?effectiveUsePosition,
if (url?.isNotEmpty == true) 'url': url,
if (interval != null) 'interval': interval,
if (timeout != null) 'timeout': timeout,
if (maxFailedTimes != null) 'max-failed-times': maxFailedTimes,
if (emptyFallback?.isNotEmpty == true) 'empty-fallback': emptyFallback,
if (lazy != null) 'lazy': lazy,
if (disableUDP != null) 'disable-udp': disableUDP,
if (filter?.isNotEmpty == true) 'filter': filter,
if (excludeFilter?.isNotEmpty == true) 'exclude-filter': excludeFilter,
if (excludeType?.isNotEmpty == true) 'exclude-type': excludeType,
if (expectedStatus?.isNotEmpty == true) 'expected-status': expectedStatus,
if (includeAll != null) 'include-all': includeAll,
if (includeAllProxies != null) 'include-all-proxies': includeAllProxies,
if (includeAllProviders != null)
'include-all-providers': includeAllProviders,
if (hidden != null) 'hidden': hidden,
if (icon?.isNotEmpty == true) 'icon': icon,
if (type == GroupType.URLTest && tolerance != null)
'tolerance': tolerance,
if (type == GroupType.Selector && defaultSelected?.isNotEmpty == true)
'default-selected': defaultSelected,
if (type == GroupType.LoadBalance && strategy != null)
'strategy': strategy!.value,
// The core refuses a round-robin group that sets one.
if (type == GroupType.LoadBalance &&
strategy != LoadBalanceStrategy.roundRobin &&
hashKey?.isNotEmpty == true)
'hash-key': hashKey,
};
}
/// [usePosition] without the providers the core would list last anyway.
Map<String, int>? get effectiveUsePosition {
final count = proxies?.length ?? 0;
final positions = {
for (final name in use ?? const <String>[])
if (usePosition?[name] case final position? when position < count)
name: position < 0 ? 0 : position,
};
return positions.isEmpty ? null : positions;
}
String get definitionYaml => yaml.encode(definition);
/// What [definitionYaml] leaves out, such as an option added without a
/// value, was never there to remove, so it stays.
ProxyGroup withDefinitionYaml(String content) {
final document = _plainYaml(loadYaml(content));
if (document is! Map<String, Object?> ||
(document['name'] is! String && document['name'] is! num) ||
document['type'] is! String) {
throw const FormatException('Not a proxy group mapping');
}
final shown = definition;
final group = ProxyGroup.fromJson({
for (final MapEntry(:key, :value) in toJson().entries)
if (!shown.containsKey(key) && key != 'use-position') key: value,
...document,
for (final key in proxyGroupTextKeys)
if (document[key] case final value? when value is! String)
key: value.toString(),
for (final key in const ['proxies', 'use'])
if (document[key] case final List<Object?> names)
key: [for (final name in names) name.toString()],
if (document.containsKey('use-position'))
'use-position': usePositionOf(document['use-position']),
'id': id,
}).copyWith(profileId: profileId, order: order);
return group.copyWith(usePosition: group.effectiveUsePosition);
}
}
Map<String, int>? usePositionOf(
Object? value, [
Map<String, String> names = const {},
]) => switch (value) {
Map() => {
for (final MapEntry(:key, :value) in value.entries)
names['$key'] ?? '$key': ?int.tryParse('$value'),
},
_ => null,
};
const proxyGroupTextKeys = [
'name',
'url',
'filter',
'exclude-filter',
'exclude-type',
'expected-status',
'hash-key',
'default-selected',
'empty-fallback',
'icon',
];
@freezed
abstract class Proxy with _$Proxy {
const factory Proxy({
required String name,
required String type,
String? now,
}) = _Proxy;
factory Proxy.fromJson(Map<String, Object?> json) => _$ProxyFromJson(json);
}
extension ProxyExt on Proxy {
List<String> get searchFields => [name, type];
}
/// The `type` values `adapter.ParseProxy` accepts, in its own order.
const customProxyTypes = [
'ss',
'ssr',
'socks5',
'http',
'vmess',
'vless',
'snell',
'trojan',
'hysteria',
'hysteria2',
'wireguard',
'tuic',
'shadowquic',
'gost-relay',
'direct',
'dns',
'reject',
'rematch',
'ssh',
'mieru',
'anytls',
'sudoku',
'masque',
'trusttunnel',
'openvpn',
'tailscale',
'zerotier',
'easytier',
];
/// The standard methods of sing-shadowsocks2, which mihomo's ss adapter
/// creates its cipher with.
const _ssCiphers = [
'aes-128-gcm',
'aes-192-gcm',
'aes-256-gcm',
'chacha20-ietf-poly1305',
'xchacha20-ietf-poly1305',
'2022-blake3-aes-128-gcm',
'2022-blake3-aes-256-gcm',
'2022-blake3-chacha20-poly1305',
'aes-128-ctr',
'aes-192-ctr',
'aes-256-ctr',
'aes-128-cfb',
'aes-192-cfb',
'aes-256-cfb',
'rc4-md5',
'chacha20-ietf',
'xchacha20',
'chacha20',
'none',
];
/// ShadowsocksR takes stream ciphers only.
const _ssrCiphers = [
'aes-128-ctr',
'aes-192-ctr',
'aes-256-ctr',
'aes-128-cfb',
'aes-192-cfb',
'aes-256-cfb',
'rc4-md5',
'chacha20-ietf',
'xchacha20',
'chacha20',
'none',
];
const _choiceEquality = ListEquality<String>();
/// mihomo matches struct keys, like HTTP header names, in any case.
Object? _keyIn(Map map, String key) {
if (map.containsKey(key)) {
return key;
}
final lower = key.toLowerCase();
for (final candidate in map.keys) {
if (candidate.toString().toLowerCase() == lower) {
return candidate;
}
}
return null;
}
Map<String, dynamic> _withPath(Map map, List<String> path, Object? value) {
final [key, ...rest] = path;
final next = Map<String, dynamic>.from(map);
final existing = _keyIn(next, key) as String? ?? key;
final child = rest.isEmpty
? value
: _withPath(
switch (next[existing]) {
final Map map => map,
_ => const {},
},
rest,
value,
);
if (child == null || child == '' || child is Map && child.isEmpty) {
next.remove(existing);
} else {
next[existing] = child;
}
return next;
}
/// One app-level proxy, kept as the raw mapping the core parses, since each
/// proxy type reads its own set of keys.
@freezed
abstract class CustomProxy with _$CustomProxy {
const factory CustomProxy({
@JsonKey(fromJson: Snowflake.buildId) required int id,
@Default({}) Map<String, dynamic> definition,
String? order,
/// Options the form lists with no value yet, which [definition] cannot
/// hold since the core reads every key in it.
@Default({}) Set<ProxyField> emptyOptions,
}) = _CustomProxy;
factory CustomProxy.fromJson(Map<String, Object?> json) =>
_$CustomProxyFromJson(json);
factory CustomProxy.fromDefinition(Map definition, {int? id}) {
return CustomProxy(
id: id ?? snowflake.id,
definition: Map<String, dynamic>.from(definition),
);
}
}
extension CustomProxyExt on CustomProxy {
String get name => definition['name']?.toString() ?? '';
String get type => definition['type']?.toString() ?? '';
String? get server => definition['server']?.toString();
int? get port => switch (definition['port']) {
final int port => port,
final Object port => int.tryParse(port.toString()),
null => null,
};
String? get address {
final server = this.server;
if (server == null || server.isEmpty) {
return null;
}
final port = this.port;
return port == null ? server : '$server:$port';
}
List<String> get searchFields => [name, type, ?server];
Set<String> get requiredKeys => switch (type) {
'ss' => const {'server', 'port', 'password', 'cipher'},
'ssr' => const {'server', 'port', 'password', 'cipher', 'obfs', 'protocol'},
'vmess' => const {'server', 'port', 'uuid', 'alterId', 'cipher'},
'vless' => const {'server', 'port', 'uuid'},
'snell' => const {'server', 'port', 'psk'},
'trojan' || 'anytls' => const {'server', 'port', 'password'},
'hysteria' => const {'server', 'up', 'down'},
'hysteria2' => const {'server'},
'wireguard' => const {'private-key'},
'ssh' => const {'server', 'port', 'username'},
'mieru' => const {'server', 'transport', 'username', 'password'},
'sudoku' => const {'server', 'port', 'key'},
'masque' => const {'server', 'port', 'private-key', 'public-key'},
'openvpn' => const {'server', 'port', 'ca'},
'zerotier' => const {'network'},
'socks5' ||
'http' ||
'tuic' ||
'shadowquic' ||
'gost-relay' ||
'trusttunnel' => const {'server', 'port'},
_ => const {},
};
/// The rows the form always shows: the credentials and [requiredKeys].
List<ProxyField> get fields => switch (type) {
'ss' => const [ProxyField.cipher, ProxyField.password],
'ssr' => const [
ProxyField.cipher,
ProxyField.protocol,
ProxyField.obfs,
ProxyField.password,
],
'vmess' => const [ProxyField.cipher, ProxyField.alterId, ProxyField.uuid],
'vless' => const [ProxyField.uuid],
'tuic' => const [ProxyField.uuid, ProxyField.password],
'trojan' || 'anytls' || 'hysteria2' => const [ProxyField.password],
'hysteria' => const [ProxyField.up, ProxyField.down, ProxyField.authStr],
'snell' => const [ProxyField.psk],
'sudoku' => const [ProxyField.key],
'wireguard' ||
'masque' => const [ProxyField.privateKey, ProxyField.publicKey],
'mieru' => const [
ProxyField.transport,
ProxyField.username,
ProxyField.password,
],
'socks5' ||
'http' ||
'ssh' ||
'shadowquic' ||
'trusttunnel' ||
'gost-relay' ||
'openvpn' => const [ProxyField.username, ProxyField.password],
_ => const [],
};
/// The username, password and UUID among [fields], which tell one account
/// on a server from another.
List<ProxyField> get credentials => [
for (final field in fields)
if (const {
ProxyField.username,
ProxyField.password,
ProxyField.uuid,
}.contains(field))
field,
];
/// The keys the form adds one at a time, each left to the core's default
/// until then.
List<ProxyField> get options {
final fields = this.fields;
return [
for (final field in ProxyField.values)
if (!fields.contains(field) && _offers(field)) field,
];
}
bool _offers(ProxyField field) {
final type = this.type;
return switch (field) {
ProxyField.username ||
ProxyField.password ||
ProxyField.uuid ||
ProxyField.psk ||
ProxyField.key ||
ProxyField.publicKey ||
ProxyField.authStr ||
ProxyField.cipher ||
ProxyField.alterId ||
ProxyField.transport => false,
ProxyField.privateKey || ProxyField.privateKeyPassphrase => type == 'ssh',
ProxyField.preSharedKey || ProxyField.allowedIps => type == 'wireguard',
ProxyField.obfs ||
ProxyField.up ||
ProxyField.down => type == 'hysteria2',
ProxyField.obfsPassword =>
type == 'hysteria2' && valueOf(ProxyField.obfs) != null,
ProxyField.protocol || ProxyField.obfsSecret => type == 'hysteria',
ProxyField.network => choicesOf(field).isNotEmpty,
ProxyField.flow => type == 'vless',
ProxyField.wsPath || ProxyField.wsHost => _usesNetwork('ws'),
ProxyField.grpcServiceName => _usesNetwork('grpc'),
ProxyField.xhttpPath || ProxyField.xhttpHost => _usesNetwork('xhttp'),
ProxyField.tls => const {
'socks5',
'http',
'vmess',
'vless',
'gost-relay',
}.contains(type),
ProxyField.sni => const {
'http',
'trojan',
'hysteria',
'hysteria2',
'tuic',
'shadowquic',
'gost-relay',
'anytls',
'masque',
'trusttunnel',
}.contains(type),
ProxyField.servername => type == 'vmess' || type == 'vless',
ProxyField.alpn => const {
'vmess',
'vless',
'trojan',
'hysteria',
'hysteria2',
'tuic',
'shadowquic',
'anytls',
'trusttunnel',
}.contains(type),
ProxyField.skipCertVerify => const {
'socks5',
'http',
'vmess',
'vless',
'trojan',
'hysteria',
'hysteria2',
'tuic',
'gost-relay',
'anytls',
'masque',
'trusttunnel',
}.contains(type),
ProxyField.clientFingerprint => const {
'ss',
'vmess',
'vless',
'snell',
'trojan',
'gost-relay',
'anytls',
'trusttunnel',
}.contains(type),
ProxyField.realityPublicKey || ProxyField.realityShortId => const {
'vmess',
'vless',
'trojan',
}.contains(type),
ProxyField.plugin || ProxyField.udpOverTcp => type == 'ss',
ProxyField.pluginMode => type == 'ss' && _pluginReads('mode'),
ProxyField.pluginHost => type == 'ss' && _pluginReads('host'),
ProxyField.protocolParam || ProxyField.obfsParam => type == 'ssr',
ProxyField.obfsMode ||
ProxyField.obfsHost ||
ProxyField.version => type == 'snell',
ProxyField.ports ||
ProxyField.hopInterval => type == 'hysteria' || type == 'hysteria2',
ProxyField.congestionController => const {
'tuic',
'shadowquic',
'masque',
'trusttunnel',
}.contains(type),
ProxyField.udpRelayMode || ProxyField.reduceRtt => type == 'tuic',
ProxyField.ip ||
ProxyField.ipv6 ||
ProxyField.dns ||
ProxyField.remoteDnsResolve ||
ProxyField.mtu => type == 'wireguard' || type == 'masque',
ProxyField.tfo || ProxyField.mptcp => !const {
'dns',
'reject',
'rematch',
'hysteria',
'hysteria2',
'tuic',
'wireguard',
'shadowquic',
'masque',
'tailscale',
'zerotier',
'easytier',
}.contains(type),
ProxyField.interfaceName ||
ProxyField.routingMark ||
ProxyField.ipVersion => !const {
'dns',
'reject',
'rematch',
}.contains(type),
};
}
bool _pluginReads(String key) => switch (valueOf(ProxyField.plugin)) {
'obfs' || 'v2ray-plugin' || 'gost-plugin' => true,
'kcptun' => key == 'mode',
'shadow-tls' || 'restls' || 'jls' => key == 'host',
_ => false,
};
bool _usesNetwork(String network) =>
valueOf(ProxyField.network) == network &&
choicesOf(ProxyField.network).contains(network);
/// The values the core accepts, in the order the form lists them.
List<String> choicesOf(ProxyField field) => switch (field) {
ProxyField.cipher => switch (type) {
'ss' => _ssCiphers,
'ssr' => _ssrCiphers,
'vmess' => const [
'auto',
'aes-128-gcm',
'chacha20-poly1305',
'aes-128-cfb',
'none',
'zero',
],
_ => const [],
},
ProxyField.protocol => switch (type) {
'ssr' => const [
'origin',
'auth_sha1_v4',
'auth_aes128_md5',
'auth_aes128_sha1',
'auth_chain_a',
'auth_chain_b',
],
'hysteria' => const ['udp', 'wechat-video', 'faketcp'],
_ => const [],
},
ProxyField.obfs => switch (type) {
'ssr' => const [
'plain',
'http_simple',
'http_post',
'random_head',
'tls1.2_ticket_auth',
'tls1.2_ticket_fastauth',
],
'hysteria2' => const ['salamander', 'gecko'],
_ => const [],
},
ProxyField.transport => const ['TCP', 'UDP'],
ProxyField.network => switch (type) {
'vmess' => const ['tcp', 'ws', 'http', 'h2', 'grpc', 'mekya', 'mkcp'],
'vless' => const ['tcp', 'ws', 'http', 'h2', 'grpc', 'xhttp'],
'trojan' => const ['tcp', 'ws', 'grpc'],
_ => const [],
},
ProxyField.flow => const ['xtls-rprx-vision'],
ProxyField.clientFingerprint => const [
'chrome',
'firefox',
'safari',
'ios',
'android',
'edge',
'360',
'qq',
'random',
'chrome120',
'firefox120',
'safari16',
'none',
],
ProxyField.plugin => const [
'obfs',
'v2ray-plugin',
'gost-plugin',
'shadow-tls',
'restls',
'jls',
'kcptun',
],
ProxyField.obfsMode => const ['http', 'tls', 'shadow-tls', 'restls', 'jls'],
ProxyField.congestionController => const [
'cubic',
'new_reno',
'bbr',
'bbr_meta_v1',
'bbr_meta_v2',
],
ProxyField.udpRelayMode => const ['native', 'quic'],
ProxyField.ipVersion => [
for (final version in IpVersion.values) version.value,
],
_ => const [],
};
Object? valueOf(ProxyField field) {
Object? value = definition;
for (final key in field.path) {
if (value is! Map) {
return null;
}
value = value[_keyIn(value, key)];
}
return value;
}
/// Drops the key on an empty [value], with any map left empty and the
/// options only the old value made available.
CustomProxy withField(ProxyField field, Object? value) {
final offered = options;
var next = copyWith(definition: _withPath(definition, field.path, value));
final stillOffered = next.options;
for (final option in offered) {
if (!stillOffered.contains(option)) {
next = next.copyWith(
definition: _withPath(next.definition, option.path, null),
);
}
}
return next;
}
/// Keeps the values the new type reads alike and fills the ones nearly
/// every server of it takes.
CustomProxy withType(String type) {
if (type == this.type) {
return this;
}
var next = withValue('type', type);
for (final field in ProxyField.values) {
final value = next.valueOf(field);
if (value == null) {
continue;
}
final choices = next.choicesOf(field);
final readsAlike =
(next.fields.contains(field) || next.options.contains(field)) &&
(_choiceEquality.equals(choicesOf(field), choices) ||
choices.contains(value));
if (!readsAlike) {
next = next.withField(field, null);
}
}
final defaults = switch (type) {
'vmess' => const {ProxyField.cipher: 'auto', ProxyField.alterId: 0},
'mieru' => const {ProxyField.transport: 'TCP'},
_ => const <ProxyField, Object>{},
};
for (final MapEntry(:key, :value) in defaults.entries) {
if (next.valueOf(key) == null) {
next = next.withField(key, value);
}
}
return next;
}
List<String> stringsOf(String key) =>
switch (definition[_keyIn(definition, key)]) {
final List items => [for (final item in items) item.toString()],
final String item when item.isNotEmpty => [item],
_ => const [],
};
CustomProxy withValue(String key, Object? value) =>
copyWith(definition: _withPath(definition, [key], value));
}
/// Per profile, since [target] names a proxy or group that profile defines.
@freezed
abstract class ProxyDialer with _$ProxyDialer {
const factory ProxyDialer({
required int profileId,
required int proxyId,
required String target,
}) = _ProxyDialer;
}
@freezed
sealed class CustomIssue with _$CustomIssue {
const factory CustomIssue.emptyName() = EmptyNameIssue;
const factory CustomIssue.reservedName(String name) = ReservedNameIssue;
const factory CustomIssue.duplicateName(String name) = DuplicateNameIssue;
const factory CustomIssue.coreRejected(String message) = CoreRejectedIssue;
const factory CustomIssue.missingProxies(List<String> names) =
MissingProxiesIssue;
const factory CustomIssue.missingProviders(List<String> names) =
MissingProvidersIssue;
const factory CustomIssue.noProxySource() = NoProxySourceIssue;
const factory CustomIssue.groupLoop(List<String> names) = GroupLoopIssue;
const factory CustomIssue.invalidEmptyFallback(String name) =
InvalidEmptyFallbackIssue;
const factory CustomIssue.invalidFilter(String name, String message) =
InvalidFilterIssue;
const factory CustomIssue.invalidPayload(RulePayloadError error) =
InvalidPayloadIssue;
const factory CustomIssue.missingRuleSet(String name) = MissingRuleSetIssue;
const factory CustomIssue.missingSubRule(String name) = MissingSubRuleIssue;
const factory CustomIssue.missingTarget(String name) = MissingTargetIssue;
const factory CustomIssue.missingDialer(String name) = MissingDialerIssue;
const factory CustomIssue.dialerLoop(String proxy, String target) =
DialerLoopIssue;
}
@freezed
abstract class CustomProfileIssues with _$CustomProfileIssues {
const factory CustomProfileIssues({
@Default({}) Map<int, List<CustomIssue>> proxyGroups,
@Default({}) Map<int, List<CustomIssue>> rules,
@Default({}) Map<int, List<CustomIssue>> dialers,
@Default([]) List<CustomIssue> dns,
@Default([]) List<CustomIssue> ntp,
}) = _CustomProfileIssues;
}
@freezed
abstract class CustomProfileData with _$CustomProfileData {
const factory CustomProfileData({
@Default([]) List<ProxyGroup> proxyGroups,
@Default({}) Set<String> proxyProviders,
@Default({}) Set<String> ruleProviders,
@Default({}) Set<String> ruleTargets,
@Default({}) Set<String> proxies,
@Default({}) Map<String, String> dialers,
}) = _CustomProfileData;
}
final _namedProxies = Expando<Set<String>>();
extension CustomProfileDataExt on CustomProfileData {
Set<String> get namedProxies => _namedProxies[this] ??= Set.unmodifiable({
for (final group in proxyGroups)
for (final name in group.proxies ?? const <String>[])
if (proxies.contains(name)) name,
});
/// A local proxy reaches the config only when a group names it.
bool isRuleTarget(String? name) =>
ruleTargets.contains(name) ||
name == GroupName.GLOBAL.name ||
namedProxies.contains(name);
}
@freezed
abstract class RuleProvider with _$RuleProvider {
const factory RuleProvider({required String name}) = _RuleProvider;
factory RuleProvider.fromJson(Map<String, Object?> json) =>
_$RuleProviderFromJson(json);
}
@freezed
abstract class ProxyProvider with _$ProxyProvider {
const factory ProxyProvider({required String name}) = _ProxyProvider;
factory ProxyProvider.fromJson(Map<String, Object?> json) =>
_$ProxyProviderFromJson(json);
}
@freezed
abstract class Sniffer with _$Sniffer {
const factory Sniffer({
@Default(false) bool enable,
@Default(true) @JsonKey(name: 'override-destination') bool overrideDest,
@Default(true) @JsonKey(name: 'force-dns-mapping') bool forceDnsMapping,
@Default(true) @JsonKey(name: 'parse-pure-ip') bool parsePureIp,
@Default([]) @JsonKey(name: 'force-domain') List<String> forceDomain,
@Default([]) @JsonKey(name: 'skip-domain') List<String> skipDomain,
@Default([]) @JsonKey(name: 'skip-src-address') List<String> skipSrcAddress,
@Default([]) @JsonKey(name: 'skip-dst-address') List<String> skipDstAddress,
@Default({}) Map<String, SnifferConfig> sniff,
}) = _Sniffer;
factory Sniffer.fromJson(Map<String, Object?> json) =>
_$SnifferFromJson(json);
factory Sniffer.safeSnifferFromJson(Map<String, Object?> json) {
return decodeSalvaging('sniffer config', json, Sniffer.fromJson);
}
}
List<String> _formJsonPorts(List? ports) {
return ports?.map((item) => item.toString()).toList() ?? [];
}
@freezed
abstract class SnifferConfig with _$SnifferConfig {
const factory SnifferConfig({
@Default([]) @JsonKey(fromJson: _formJsonPorts) List<String> ports,
@JsonKey(name: 'override-destination', includeIfNull: false)
bool? overrideDest,
}) = _SnifferConfig;
factory SnifferConfig.fromJson(Map<String, Object?> json) =>
_$SnifferConfigFromJson(json);
}
const _snifferOverrideKeysJsonKey = 'sniffer-override-keys';
Set<SnifferOverrideKey> _snifferOverrideKeysFromJson(List<Object?> json) => {
for (final path in json)
?SnifferOverrideKey.values.firstWhereOrNull((key) => key.path == path),
};
extension SnifferOverrideExt on Sniffer {
SnifferConfig sniffOf(SnifferOverrideKey key) =>
sniff[key.field] ?? const SnifferConfig();
Sniffer withSniff(SnifferOverrideKey key, SnifferConfig config) =>
copyWith(sniff: {...sniff, key.field: config});
// toJson leaves nested models as objects; the fragment is read as plain maps.
Map<String, Object?> get _json {
final json = toJson();
json[SnifferOverrideKey.sniffSection] = {
for (final key in SnifferOverrideKey.values)
if (key.parent != null) key.field: sniffOf(key).toJson(),
};
return json;
}
Map<String, Object?> overrideJson(Set<SnifferOverrideKey> keys) =>
_overrideJson(_json, SnifferOverrideKey.values, keys);
String overrideYaml(Set<SnifferOverrideKey> keys) =>
yaml.encode(overrideJson(keys));
({Sniffer sniffer, Set<SnifferOverrideKey> keys}) applyOverrideYaml(
String content,
) {
final json = _json;
final keys = _applyOverrideYaml(
content,
json: json,
values: SnifferOverrideKey.values,
section: 'sniffer',
normalizeField: (protocol) => protocol.toUpperCase(),
);
if (keys == null) {
return (sniffer: this, keys: const {});
}
return (sniffer: Sniffer.fromJson(json), keys: keys);
}
}
/// mihomo matches sniff protocols case-insensitively, so an overridden
/// protocol replaces the profile's entry whatever its case.
Map<String, dynamic> mergeSnifferOverride(
Map<String, dynamic> raw,
Map<String, Object?> override,
) {
final merged = Map<String, dynamic>.from(raw);
for (final entry in override.entries) {
final value = entry.value;
if (entry.key != SnifferOverrideKey.sniffSection || value is! Map) {
merged[entry.key] = value;
continue;
}
final overridden = {for (final name in value.keys) '$name'.toUpperCase()};
merged[entry.key] = {
for (final current in _rawSniff(raw).entries)
if (!overridden.contains('${current.key}'.toUpperCase()))
'${current.key}': current.value,
...value,
};
}
return merged;
}
/// mihomo reads the deprecated `sniffing` list only while `sniff` is empty, so
/// its protocols are carried into `sniff` before an override fills it.
Map<Object?, Object?> _rawSniff(Map<String, dynamic> raw) {
final sniff = raw[SnifferOverrideKey.sniffSection];
if (sniff is Map && sniff.isNotEmpty) {
return sniff;
}
final sniffing = raw['sniffing'];
if (sniffing is! List) {
return const {};
}
final ports = raw['port-whitelist'];
return {
for (final name in sniffing)
'$name'.toUpperCase(): {if (ports is List) 'ports': ports},
};
}
@freezed
abstract class Tun with _$Tun {
const factory Tun({
@Default(false) bool enable,
@Default(appName) String device,
@JsonKey(name: 'auto-route') @Default(false) bool autoRoute,
@Default(TunStack.mips) TunStack stack,
@JsonKey(name: 'dns-hijack') @Default(['any:53']) List<String> dnsHijack,
@JsonKey(name: 'route-address') @Default([]) List<String> routeAddress,
@JsonKey(fromJson: _mtuFromJson) @Default(defaultTunMtu) int mtu,
@JsonKey(
name: 'congestion-controller',
fromJson: _congestionControllerFromJson,
)
@Default(defaultCongestionController)
String congestionController,
@JsonKey(name: 'strict-route') @Default(false) bool strictRoute,
@JsonKey(name: 'route-exclude-address')
@Default([])
List<String> routeExcludeAddress,
}) = _Tun;
factory Tun.fromJson(Map<String, Object?> json) => _$TunFromJson(json);
factory Tun.safeFormJson(Map<String, Object?>? json) {
if (json == null) {
return defaultTun;
}
return decodeSalvaging('tun config', json, Tun.fromJson);
}
}
int _mtuFromJson(int value) =>
value > 0 ? value.clamp(minTunMtu, maxTunMtu) : defaultTunMtu;
String _congestionControllerFromJson(String value) =>
value.isEmpty ? defaultCongestionController : value;
extension TunExt on Tun {
List<String> excludedRoutes({required bool bypassPrivateRoute}) => [
...routeExcludeAddress,
if (bypassPrivateRoute) ...privateRouteAddress,
];
Future<List<String>> vpnRouteAddress({
required bool bypassPrivateRoute,
}) async {
final excluded = excludedRoutes(bypassPrivateRoute: bypassPrivateRoute);
if (routeAddress.isEmpty && excluded.isEmpty) {
return const [];
}
return subtractCidrsTask(routeAddress, excluded, wholeWhenEmpty: true);
}
Tun getRealTun({required bool bypassPrivateRoute}) => copyWith(
autoRoute: true,
routeExcludeAddress: excludedRoutes(bypassPrivateRoute: bypassPrivateRoute),
);
}
@freezed
abstract class FallbackFilter with _$FallbackFilter {
const factory FallbackFilter({
@Default(true) bool geoip,
@Default('') @JsonKey(name: 'geoip-code') String geoipCode,
@Default([]) List<String> geosite,
@Default([]) List<String> ipcidr,
@Default([]) List<String> domain,
}) = _FallbackFilter;
factory FallbackFilter.fromJson(Map<String, Object?> json) =>
_$FallbackFilterFromJson(json);
}
@freezed
abstract class Dns with _$Dns {
const factory Dns({
@Default(false) bool enable,
@Default('') String listen,
@Default(0) @JsonKey(name: 'listen-routing-mark') int listenRoutingMark,
@Default(false) @JsonKey(name: 'prefer-h3') bool preferH3,
@Default(true) @JsonKey(name: 'use-hosts') bool useHosts,
@Default(true) @JsonKey(name: 'use-system-hosts') bool useSystemHosts,
@Default(false) @JsonKey(name: 'respect-rules') bool respectRules,
@Default(false) bool ipv6,
@Default(100) @JsonKey(name: 'ipv6-timeout') int ipv6Timeout,
@Default(DnsCacheAlgorithm.lru)
@JsonKey(name: 'cache-algorithm')
DnsCacheAlgorithm cacheAlgorithm,
@Default(4096) @JsonKey(name: 'cache-max-size') int cacheMaxSize,
@Default(['114.114.114.114', '223.5.5.5', '8.8.8.8', '1.0.0.1'])
@JsonKey(name: 'default-nameserver')
List<String> defaultNameserver,
@Default(DnsMode.redirHost)
@JsonKey(name: 'enhanced-mode')
DnsMode enhancedMode,
@Default('198.18.0.1/16')
@JsonKey(name: 'fake-ip-range')
String fakeIpRange,
@Default('') @JsonKey(name: 'fake-ip-range6') String fakeIpRange6,
@Default([
'dns.msftnsci.com',
'www.msftnsci.com',
'www.msftconnecttest.com',
])
@JsonKey(name: 'fake-ip-filter')
List<String> fakeIpFilter,
@Default(FakeIpFilterMode.blacklist)
@JsonKey(name: 'fake-ip-filter-mode')
FakeIpFilterMode fakeIpFilterMode,
@Default(1) @JsonKey(name: 'fake-ip-ttl') int fakeIpTtl,
@Default({})
@JsonKey(name: 'nameserver-policy')
Map<String, String> nameserverPolicy,
@Default(['https://doh.pub/dns-query', 'tls://223.5.5.5:853'])
List<String> nameserver,
@Default([]) List<String> fallback,
@Default(false)
@JsonKey(name: 'fallback-lazy-query')
bool fallbackLazyQuery,
@Default([])
@JsonKey(name: 'proxy-server-nameserver')
List<String> proxyServerNameserver,
@Default({})
@JsonKey(name: 'proxy-server-nameserver-policy')
Map<String, String> proxyServerNameserverPolicy,
@Default([])
@JsonKey(name: 'direct-nameserver')
List<String> directNameserver,
@Default(false)
@JsonKey(name: 'direct-nameserver-follow-policy')
bool directNameserverFollowPolicy,
@Default(FallbackFilter())
@JsonKey(name: 'fallback-filter')
FallbackFilter fallbackFilter,
}) = _Dns;
factory Dns.fromJson(Map<String, Object?> json) => _$DnsFromJson(json);
factory Dns.safeDnsFromJson(Map<String, Object?> json) {
return decodeSalvaging('dns config', json, Dns.fromJson);
}
}
const _dnsOverrideKeysJsonKey = 'dns-override-keys';
Set<DnsOverrideKey> _dnsOverrideKeysFromJson(List<Object?> json) => {
for (final path in json)
?DnsOverrideKey.values.firstWhereOrNull((key) => key.path == path),
};
/// Configs saved before the key set existed overrode the whole DNS section as
/// the model held it then.
const _legacyDnsOverrideKeys = {
DnsOverrideKey.enable,
DnsOverrideKey.listen,
DnsOverrideKey.useHosts,
DnsOverrideKey.useSystemHosts,
DnsOverrideKey.ipv6,
DnsOverrideKey.respectRules,
DnsOverrideKey.preferH3,
DnsOverrideKey.enhancedMode,
DnsOverrideKey.fakeIpRange,
DnsOverrideKey.fakeIpFilter,
DnsOverrideKey.defaultNameserver,
DnsOverrideKey.nameserverPolicy,
DnsOverrideKey.nameserver,
DnsOverrideKey.fallback,
DnsOverrideKey.proxyServerNameserver,
DnsOverrideKey.fallbackFilterGeoip,
DnsOverrideKey.fallbackFilterGeoipCode,
DnsOverrideKey.fallbackFilterGeosite,
DnsOverrideKey.fallbackFilterIpcidr,
DnsOverrideKey.fallbackFilterDomain,
};
Map<String, Object?> _withLegacyDnsOverrideKeys(Map<String, Object?> json) {
if (json.containsKey(_dnsOverrideKeysJsonKey) || !json.containsKey('dns')) {
return json;
}
return {
...json,
_dnsOverrideKeysJsonKey: [
for (final key in _legacyDnsOverrideKeys) key.path,
],
};
}
extension DnsOverrideExt on Dns {
// toJson leaves nested models as objects; the fragment is read as plain maps.
Map<String, Object?> get _json {
final json = toJson();
json[DnsOverrideKey.fallbackFilterSection] = fallbackFilter.toJson();
json[DnsOverrideKey.nameserverPolicy.path] = _splitPolicyServers(
nameserverPolicy,
);
json[DnsOverrideKey.proxyServerNameserverPolicy.path] = _splitPolicyServers(
proxyServerNameserverPolicy,
);
return json;
}
/// [keepEmpty] writes emptied keys, but the core refuses an empty nameserver.
Map<String, Object?> overrideJson(
Set<DnsOverrideKey> keys, {
bool keepEmpty = false,
}) => _overrideJson(
_json,
DnsOverrideKey.values,
keys,
keepEmpty: keepEmpty
? keys.difference({DnsOverrideKey.nameserver})
: const <DnsOverrideKey>{},
);
String overrideYaml(Set<DnsOverrideKey> keys) =>
yaml.encode(overrideJson(keys));
({Dns dns, Set<DnsOverrideKey> keys}) applyOverrideYaml(String content) {
final json = _json;
final keys = _applyOverrideYaml(
content,
json: json,
values: DnsOverrideKey.values,
section: 'DNS',
);
if (keys == null) {
return (dns: this, keys: const {});
}
for (final policy in _policyKeys) {
json[policy.path] = _joinPolicyServers(json[policy.path]);
}
return (dns: Dns.fromJson(json), keys: keys);
}
}
Object? _plainYaml(Object? node) => switch (node) {
YamlMap() => {
for (final entry in node.entries)
entry.key.toString(): _plainYaml(entry.value),
},
YamlList() => [for (final item in node) _plainYaml(item)],
_ => node,
};
Map<String, Object?> _overrideJson<K extends OverrideKey>(
Map<String, Object?> json,
List<K> values,
Set<K> keys, {
Set<K> keepEmpty = const {},
}) {
final result = <String, Object?>{};
for (final key in values) {
if (!keys.contains(key)) {
continue;
}
final parent = key.parent;
final value = parent == null
? json[key.path]
: (json[parent] as Map)[key.field];
if (_isUnset(value) && !keepEmpty.contains(key)) {
continue;
}
if (parent == null) {
result[key.path] = value;
continue;
}
final nested =
result.putIfAbsent(parent, () => <String, Object?>{})
as Map<String, Object?>;
nested[key.field] = value;
}
return result;
}
bool _isUnset(Object? value) => switch (value) {
String() => value.isEmpty,
Iterable() => value.isEmpty,
Map() => value.isEmpty,
_ => false,
};
/// Reads an edited override document into [json] and returns the keys it
/// names, which become the override set, or null for an empty document.
/// Throws on a key [values] does not hold; the caller's decode throws on a
/// value the model cannot hold. [normalizeField] spells a nested field the way
/// its key path does.
Set<K>? _applyOverrideYaml<K extends OverrideKey>(
String content, {
required Map<String, Object?> json,
required List<K> values,
required String section,
String Function(String field)? normalizeField,
}) {
final document = _plainYaml(loadYaml(content));
if (document == null) {
return null;
}
if (document is! Map) {
throw FormatException('The override must be a map of $section keys');
}
final nested = {
for (final parent in {for (final key in values) ?key.parent})
parent: Map<String, Object?>.from(json[parent] as Map),
};
final keys = <K>{};
void take(String path, Object? value) {
final key = values.firstWhereOrNull((key) => key.path == path);
if (key == null) {
throw FormatException('Unknown $section key: $path');
}
keys.add(key);
final parent = key.parent;
if (parent == null) {
json[path] = value;
} else {
nested[parent]![key.field] = value;
}
}
for (final entry in document.entries) {
final name = entry.key.toString();
if (!nested.containsKey(name)) {
take(name, entry.value);
continue;
}
if (entry.value is! Map) {
throw FormatException('$name must be a map');
}
for (final sub in (entry.value as Map).entries) {
final field = sub.key.toString();
take('$name.${normalizeField?.call(field) ?? field}', sub.value);
}
}
json.addAll(nested);
return keys;
}
const _policyKeys = [
DnsOverrideKey.nameserverPolicy,
DnsOverrideKey.proxyServerNameserverPolicy,
];
Map<String, Object> _splitPolicyServers(Map<String, String> policy) => {
for (final entry in policy.entries)
entry.key: entry.value.splitByMultipleSeparators,
};
Object? _joinPolicyServers(Object? value) => switch (value) {
Map() => {
for (final entry in value.entries)
entry.key.toString(): switch (entry.value) {
List() => (entry.value as List).join(', '),
final server => server.toString(),
},
},
_ => value,
};
Map<String, dynamic> mergeDnsOverride(
Map<String, dynamic> raw,
Map<String, Object?> override,
) {
final merged = Map<String, dynamic>.from(raw);
for (final entry in override.entries) {
final current = merged[entry.key];
final value = entry.value;
merged[entry.key] =
entry.key == DnsOverrideKey.fallbackFilterSection &&
current is Map &&
value is Map
? {...Map<String, dynamic>.from(current), ...value}
: value;
}
return merged;
}
@freezed
abstract class Ntp with _$Ntp {
const factory Ntp({
@Default(false) bool enable,
@Default('time.apple.com') String server,
@Default(123) int port,
@Default(30) int interval,
@Default('') @JsonKey(name: 'dialer-proxy') String dialerProxy,
@Default(false) @JsonKey(name: 'write-to-system') bool writeToSystem,
}) = _Ntp;
factory Ntp.fromJson(Map<String, Object?> json) => _$NtpFromJson(json);
factory Ntp.safeNtpFromJson(Map<String, Object?> json) {
return decodeSalvaging('ntp config', json, Ntp.fromJson);
}
}
const _ntpOverrideKeysJsonKey = 'ntp-override-keys';
Set<NtpOverrideKey> _ntpOverrideKeysFromJson(List<Object?> json) => {
for (final path in json)
?NtpOverrideKey.values.firstWhereOrNull((key) => key.path == path),
};
extension NtpOverrideExt on Ntp {
Map<String, Object?> overrideJson(Set<NtpOverrideKey> keys) =>
_overrideJson(toJson(), NtpOverrideKey.values, keys);
String overrideYaml(Set<NtpOverrideKey> keys) =>
yaml.encode(overrideJson(keys));
({Ntp ntp, Set<NtpOverrideKey> keys}) applyOverrideYaml(String content) {
final json = toJson();
final keys = _applyOverrideYaml(
content,
json: json,
values: NtpOverrideKey.values,
section: 'NTP',
);
if (keys == null) {
return (ntp: this, keys: const {});
}
return (ntp: Ntp.fromJson(json), keys: keys);
}
}
@freezed
abstract class ProfileTun with _$ProfileTun {
const factory ProfileTun({
@Default(false)
@JsonKey(name: 'disable-icmp-forwarding')
bool disableIcmpForwarding,
@Default([])
@JsonKey(name: 'exclude-interface')
List<String> excludeInterface,
}) = _ProfileTun;
factory ProfileTun.fromJson(Map<String, Object?> json) =>
_$ProfileTunFromJson(json);
factory ProfileTun.safeFromJson(Map<String, Object?> json) {
return decodeSalvaging('tun override', json, ProfileTun.fromJson);
}
}
const _tunOverrideKeysJsonKey = 'tun-override-keys';
Set<TunOverrideKey> _tunOverrideKeysFromJson(List<Object?> json) => {
for (final path in json)
?TunOverrideKey.values.firstWhereOrNull((key) => key.path == path),
};
extension TunOverrideExt on ProfileTun {
Map<String, Object?> overrideJson(Set<TunOverrideKey> keys) =>
_overrideJson(toJson(), TunOverrideKey.values, keys);
}
@freezed
abstract class ProfileOverrides with _$ProfileOverrides {
const factory ProfileOverrides({
@Default(defaultDns) @JsonKey(fromJson: Dns.safeDnsFromJson) Dns dns,
@Default({})
@JsonKey(name: _dnsOverrideKeysJsonKey, fromJson: _dnsOverrideKeysFromJson)
Set<DnsOverrideKey> dnsOverrideKeys,
@Default(defaultNtp) @JsonKey(fromJson: Ntp.safeNtpFromJson) Ntp ntp,
@Default({})
@JsonKey(name: _ntpOverrideKeysJsonKey, fromJson: _ntpOverrideKeysFromJson)
Set<NtpOverrideKey> ntpOverrideKeys,
@Default(defaultSniffer)
@JsonKey(fromJson: Sniffer.safeSnifferFromJson)
Sniffer sniffer,
@Default({})
@JsonKey(
name: _snifferOverrideKeysJsonKey,
fromJson: _snifferOverrideKeysFromJson,
)
Set<SnifferOverrideKey> snifferOverrideKeys,
@Default(defaultProfileTun)
@JsonKey(fromJson: ProfileTun.safeFromJson)
ProfileTun tun,
@Default({})
@JsonKey(name: _tunOverrideKeysJsonKey, fromJson: _tunOverrideKeysFromJson)
Set<TunOverrideKey> tunOverrideKeys,
@Default({})
@JsonKey(name: 'proxy-providers')
Map<String, ProxyProviderOptions> proxyProviders,
}) = _ProfileOverrides;
factory ProfileOverrides.fromJson(Map<String, Object?> json) =>
_$ProfileOverridesFromJson(json);
factory ProfileOverrides.safeFromJson(Map<String, Object?> json) {
return decodeSalvaging(
'profile overrides',
json,
ProfileOverrides.fromJson,
);
}
}
@freezed
abstract class ProviderHealthCheck with _$ProviderHealthCheck {
const factory ProviderHealthCheck({
String? url,
int? interval,
int? timeout,
bool? lazy,
@JsonKey(name: 'expected-status') String? expectedStatus,
}) = _ProviderHealthCheck;
factory ProviderHealthCheck.fromJson(Map<String, Object?> json) =>
_$ProviderHealthCheckFromJson(json);
}
@freezed
abstract class ProviderOverride with _$ProviderOverride {
const factory ProviderOverride({
@JsonKey(name: 'additional-prefix') String? additionalPrefix,
@JsonKey(name: 'additional-suffix') String? additionalSuffix,
bool? udp,
@JsonKey(name: 'skip-cert-verify') bool? skipCertVerify,
@JsonKey(name: 'ip-version') IpVersion? ipVersion,
}) = _ProviderOverride;
factory ProviderOverride.fromJson(Map<String, Object?> json) =>
_$ProviderOverrideFromJson(json);
}
/// Keyed in a custom profile's overrides by the provider's name. The update
/// interval stays with the app, which keeps the file the provider reads.
@freezed
abstract class ProxyProviderOptions with _$ProxyProviderOptions {
const factory ProxyProviderOptions({
@Default(ProviderHealthCheck())
@JsonKey(name: 'health-check')
ProviderHealthCheck healthCheck,
String? filter,
@JsonKey(name: 'exclude-filter') String? excludeFilter,
@Default(ProviderOverride())
@JsonKey(name: 'override')
ProviderOverride proxyOverride,
/// Where the filters list shows the exclude filters among the filters,
/// which no config carries, as `ProxyProviderFiltersSliver` writes it.
List<String>? memberLayout,
}) = _ProxyProviderOptions;
factory ProxyProviderOptions.fromJson(Map<String, Object?> json) =>
_$ProxyProviderOptionsFromJson(json);
}
extension ProxyProviderOptionsExt on ProxyProviderOptions {
/// A health check without a url never runs, so one always carries a url.
/// A text option added but left empty stays in the form and out of here.
Map<String, Object?> get definition {
String? text(String? value) => value?.isNotEmpty == true ? value : null;
final healthCheck = this.healthCheck.copyWith(
url: text(this.healthCheck.url),
expectedStatus: text(this.healthCheck.expectedStatus),
);
final proxyOverride = this.proxyOverride.copyWith(
additionalPrefix: text(this.proxyOverride.additionalPrefix),
additionalSuffix: text(this.proxyOverride.additionalSuffix),
);
return {
if (healthCheck != const ProviderHealthCheck())
'health-check': {
'enable': true,
'url': healthCheck.url ?? defaultTestUrl,
'interval': ?healthCheck.interval,
'timeout': ?healthCheck.timeout,
'lazy': ?healthCheck.lazy,
'expected-status': ?healthCheck.expectedStatus,
},
'filter': ?filter,
'exclude-filter': ?excludeFilter,
if (proxyOverride != const ProviderOverride())
'override': {
'additional-prefix': ?proxyOverride.additionalPrefix,
'additional-suffix': ?proxyOverride.additionalSuffix,
'udp': ?proxyOverride.udp,
'skip-cert-verify': ?proxyOverride.skipCertVerify,
'ip-version': ?proxyOverride.ipVersion?.value,
},
};
}
}
extension ProfileOverridesExt on ProfileOverrides {
/// A custom profile brings no DNS section of its own, so it runs on
/// [baselineDnsOverrideKeys], which turn DNS on, unless its own `enable`
/// turns it off.
bool get customDnsEnabled =>
!dnsOverrideKeys.contains(DnsOverrideKey.enable) || dns.enable;
bool get customNtpEnabled =>
ntpOverrideKeys.contains(NtpOverrideKey.enable) && ntp.enable;
bool get customSnifferEnabled =>
snifferOverrideKeys.contains(SnifferOverrideKey.enable) && sniffer.enable;
}
@freezed
abstract class Rule with _$Rule {
const factory Rule({
@Default(-1) int id,
@JsonKey(includeFromJson: false, includeToJson: false) int? profileId,
@Default(RuleAction.DOMAIN) RuleAction ruleAction,
String? content,
String? ruleTarget,
String? ruleProvider,
String? subRule,
@Default(false) bool noResolve,
@Default(false) bool src,
String? order,
}) = _Rule;
factory Rule.init() {
return Rule(
ruleAction: RuleAction.DOMAIN,
ruleTarget: RuleTarget.DIRECT.value,
);
}
// Mirrors mihomo's ParseRulePayload with needTarget set.
factory Rule.parse(String value, {int? id}) {
id ??= snowflake.id;
final fields = value.split(',').map((item) => item.trim()).toList();
final type = fields.first.toUpperCase();
if (type.isEmpty) {
return Rule(
id: id,
ruleAction: RuleAction.DOMAIN,
ruleTarget: RuleTarget.DIRECT.value,
);
}
final action = RuleAction.values.firstWhere(
(item) => item.value == type,
orElse: () => RuleAction.DOMAIN,
);
final rest = fields.sublist(1);
String? payload;
String? target;
var params = const <String>[];
if (action == RuleAction.MATCH) {
target = rest.firstOrNull;
} else if (action.hasCommaPayload) {
target = rest.lastOrNull;
payload = rest.length > 1
? rest.sublist(0, rest.length - 1).join(',')
: null;
} else {
payload = rest.elementAtOrNull(0);
target = rest.elementAtOrNull(1);
params = rest.skip(2).toList();
}
payload = payload?.isNotEmpty == true ? payload : null;
target = target?.isNotEmpty == true ? target : null;
return Rule(
id: id,
ruleAction: action,
content: action == RuleAction.RULE_SET ? null : payload,
ruleProvider: action == RuleAction.RULE_SET ? payload : null,
ruleTarget: action == RuleAction.SUB_RULE ? null : target,
subRule: action == RuleAction.SUB_RULE ? target : null,
src: params.contains('src'),
noResolve: params.contains('no-resolve'),
);
}
factory Rule.fromJson(Map<String, Object?> json) => _$RuleFromJson(json);
}
extension RuleExt on Rule {
Rule autoOrder(Rule rule, String? a, String? b) {
final newRule = rule.order?.isNotEmpty != true
? rule.copyWith(order: indexing.generateKeyBetween(a, b))
: rule;
return newRule;
}
String? get realContent {
return switch (ruleAction) {
RuleAction.MATCH => null,
RuleAction.RULE_SET => ruleProvider,
_ => content,
};
}
String? get realTarget {
return switch (ruleAction == RuleAction.SUB_RULE) {
true => subRule,
false => ruleTarget,
};
}
String? targetErrorTip(String invalidSubRuleTip, String invalidPolicyTip) {
return switch (ruleAction == RuleAction.SUB_RULE) {
true => invalidSubRuleTip,
false => invalidPolicyTip,
};
}
/// The core rejects the whole config over one of these, taking the profile
/// down with it, so they are caught before the rule can be saved.
RulePayloadError? get payloadError {
final payload = realContent?.trim() ?? '';
if (payload.isEmpty) {
return null;
}
switch (ruleAction) {
case RuleAction.NETWORK:
return const ['tcp', 'udp'].contains(payload.toLowerCase())
? null
: RulePayloadError.network;
case RuleAction.DST_PORT:
case RuleAction.SRC_PORT:
case RuleAction.IN_PORT:
case RuleAction.UID:
return _parseRanges(payload) == null
? RulePayloadError.numberRange
: null;
case RuleAction.DSCP:
final bounds = _parseRanges(payload);
if (bounds == null) {
return RulePayloadError.numberRange;
}
return bounds.every((item) => item <= 63)
? null
: RulePayloadError.dscpRange;
default:
return null;
}
}
String get rawValue {
final content = realContent;
final target = realTarget;
return [
ruleAction.value,
if (content?.isNotEmpty == true) content!,
if (target?.isNotEmpty == true) target!,
if (ruleAction.hasParams) ...[
if (src) 'src',
if (noResolve) 'no-resolve',
],
].join(',');
}
List<String> get searchFields => [ruleAction.name, rawValue];
}
final class RuleLineException implements Exception {
final int line;
const RuleLineException(this.line);
}
String encodeRuleList(Iterable<Rule> rules) {
return rules.map((rule) => '- ${_ruleScalar(rule.rawValue)}\n').join();
}
String _ruleScalar(String value) {
if (!value.contains(': ') && !value.contains(' #') && !value.endsWith(':')) {
return value;
}
return "'${value.replaceAll("'", "''")}'";
}
final _rulesHeader = RegExp(r'^rules\s*[::]$', caseSensitive: false);
List<({int line, Rule rule})> decodeRuleList(
String content, {
Iterable<Rule> previous = const [],
}) {
final unusedIds = <String, List<int>>{};
for (final rule in previous) {
unusedIds.putIfAbsent(rule.rawValue, () => []).add(rule.id);
}
return [
for (final (index, line) in content.split('\n').indexed)
if (line.trim() case final text
when text.isNotEmpty &&
!text.startsWith('#') &&
!_rulesHeader.hasMatch(text))
(line: index + 1, rule: _ruleAt(_ruleText(text), index + 1, unusedIds)),
];
}
String _ruleText(String line) {
final text = line.startsWith('-') ? line.substring(1).trimLeft() : line;
if (text.startsWith("'") || text.startsWith('"')) {
return switch (_loadScalar(text)) {
final String value => value,
_ => text,
};
}
final comment = text.indexOf(' #');
return comment < 0 ? text : text.substring(0, comment).trimRight();
}
Object? _loadScalar(String text) {
try {
return loadYaml(text);
} on YamlException {
return null;
}
}
Rule _ruleAt(String value, int line, Map<String, List<int>> unusedIds) {
final type = value.split(',').first.trim().toUpperCase();
if (!RuleAction.values.any((action) => action.value == type)) {
throw RuleLineException(line);
}
final rule = Rule.parse(value);
final ids = unusedIds[rule.rawValue];
return ids == null || ids.isEmpty ? rule : rule.copyWith(id: ids.removeAt(0));
}
/// Mirrors mihomo's newIntRanges: `80`, `80-90`, and `/` or `,` between them.
List<int>? _parseRanges(String payload) {
if (payload == '*') {
return null;
}
final segments = payload.replaceAll(',', '/').split('/');
if (segments.length > 28) {
return null;
}
final bounds = <int>[];
for (final segment in segments) {
final trimmed = segment.trim();
if (trimmed.isEmpty) {
continue;
}
final parts = trimmed.split('-');
if (parts.length > 2) {
return null;
}
for (final part in parts) {
final bound = int.tryParse(part.replaceAll(RegExp(r'[\[\] ]'), ''));
if (bound == null || bound < 0) {
return null;
}
bounds.add(bound);
}
}
return bounds.isEmpty ? null : bounds;
}
List<Rule> _genRules(List<dynamic>? rules) {
if (rules == null) {
return [];
}
return rules.map((item) => Rule.parse(item)).toList();
}
List<String> _genList(Map<String, dynamic> json) {
return json.entries.map((entry) => entry.key).toList();
}
@freezed
abstract class ClashConfig with _$ClashConfig {
const factory ClashConfig({
@Default([]) @JsonKey(name: 'proxy-groups') List<ProxyGroup> proxyGroups,
@JsonKey(fromJson: _genRules) @Default([]) List<Rule> rules,
@Default([]) List<Proxy> proxies,
@JsonKey(name: 'proxy-providers', fromJson: _genList)
@Default([])
List<String> proxyProviders,
@JsonKey(name: 'rule-providers', fromJson: _genList)
@Default([])
List<String> ruleProviders,
@JsonKey(name: 'sub-rules', fromJson: _genList)
@Default([])
List<String> subRules,
@Default({}) Map<String, String> proxyTypeMap,
}) = _ClashConfig;
factory ClashConfig.fromJson(Map<String, Object?> json) =>
_$ClashConfigFromJson(json);
}
extension GeoResourceUrlMapExt on Map<GeoResource, String> {
Map<String, String> get raw =>
map((key, value) => MapEntry(key.configKey, value));
}
Map<GeoResource, String> _geoXUrlFromJson(Map<String, Object?>? json) {
if (json == null) {
return defaultGeoXUrl;
}
return json.map(
(key, value) => MapEntry(GeoResource.fromJson(key), value as String),
);
}
Map<String, String> _geoXUrlToJson(Map<GeoResource, String> value) {
return value.raw;
}
@freezed
abstract class PatchClashConfig with _$PatchClashConfig {
const factory PatchClashConfig({
@Default(defaultMixedPort) @JsonKey(name: 'mixed-port') int mixedPort,
@Default(0) @JsonKey(name: 'socks-port') int socksPort,
@Default(0) @JsonKey(name: 'port') int port,
@Default(0) @JsonKey(name: 'redir-port') int redirPort,
@Default(0) @JsonKey(name: 'tproxy-port') int tproxyPort,
@Default(Mode.rule) Mode mode,
@Default(false) @JsonKey(name: 'allow-lan') bool allowLan,
@Default(LogLevel.error) @JsonKey(name: 'log-level') LogLevel logLevel,
@Default(false) bool ipv6,
@Default(FindProcessMode.off)
@JsonKey(name: 'find-process-mode', unknownEnumValue: FindProcessMode.off)
FindProcessMode findProcessMode,
@Default(InterfaceNameMode.clear)
@JsonKey(
name: 'interface-name-mode',
unknownEnumValue: InterfaceNameMode.clear,
)
InterfaceNameMode interfaceNameMode,
@Default('') @JsonKey(name: 'interface-name') String interfaceName,
@Default(defaultKeepAliveInterval)
@JsonKey(name: 'keep-alive-interval')
int keepAliveInterval,
@Default(15) @JsonKey(name: 'keep-alive-idle') int keepAliveIdle,
@Default(false) @JsonKey(name: 'disable-keep-alive') bool disableKeepAlive,
@Default(0) @JsonKey(name: 'routing-mark') int routingMark,
@Default(true) @JsonKey(name: 'unified-delay') bool unifiedDelay,
@Default(true) @JsonKey(name: 'tcp-concurrent') bool tcpConcurrent,
@Default(defaultTun) @JsonKey(fromJson: Tun.safeFormJson) Tun tun,
@Default(defaultDns) @JsonKey(fromJson: Dns.safeDnsFromJson) Dns dns,
@Default({})
@JsonKey(name: _dnsOverrideKeysJsonKey, fromJson: _dnsOverrideKeysFromJson)
Set<DnsOverrideKey> dnsOverrideKeys,
@Default(defaultGeoXUrl)
@JsonKey(
name: 'geox-url',
fromJson: _geoXUrlFromJson,
toJson: _geoXUrlToJson,
)
Map<GeoResource, String> geoXUrl,
@Default(GeodataLoader.memconservative)
@JsonKey(name: 'geodata-loader')
GeodataLoader geodataLoader,
@JsonKey(name: 'global-ua') String? globalUa,
@Default(ExternalControllerStatus.close)
@JsonKey(name: 'external-controller')
ExternalControllerStatus externalController,
@Default('') String secret,
@Default({}) Map<String, String> hosts,
@Default(false) @JsonKey(name: 'geo-auto-update') bool geoAutoUpdate,
@Default(24) @JsonKey(name: 'geo-update-interval') int geoUpdateInterval,
}) = _PatchClashConfig;
factory PatchClashConfig.fromJson(Map<String, Object?> json) =>
_$PatchClashConfigFromJson(_withLegacyDnsOverrideKeys(json));
factory PatchClashConfig.safeFormJson(Map<String, Object?>? json) {
if (json == null) {
return defaultClashConfig;
}
return decodeSalvaging('clash config', json, PatchClashConfig.fromJson);
}
}
extension PatchClashConfigExt on PatchClashConfig {
UpdateParams toUpdateParams({
required bool bypassPrivateRoute,
required List<String> authentication,
required bool skipCertVerify,
}) {
return UpdateParams(
tun: tun.getRealTun(bypassPrivateRoute: bypassPrivateRoute),
authentication: authentication,
allowLan: allowLan,
findProcessMode: findProcessMode,
mode: mode,
logLevel: logLevel,
ipv6: ipv6,
tcpConcurrent: tcpConcurrent,
externalController: externalController,
secret: secret,
unifiedDelay: unifiedDelay,
mixedPort: mixedPort,
geoAutoUpdate: geoAutoUpdate,
geoUpdateInterval: geoUpdateInterval,
geoXUrl: geoXUrl.raw,
skipCertVerify: skipCertVerify,
);
}
/// Resets every field [toUpdateParams] patches live, leaving what only a
/// full setup hands the Core.
PatchClashConfig get setupOnly => copyWith(
tun: defaultClashConfig.tun,
mixedPort: defaultClashConfig.mixedPort,
allowLan: defaultClashConfig.allowLan,
findProcessMode: defaultClashConfig.findProcessMode,
mode: defaultClashConfig.mode,
logLevel: defaultClashConfig.logLevel,
ipv6: defaultClashConfig.ipv6,
tcpConcurrent: defaultClashConfig.tcpConcurrent,
externalController: defaultClashConfig.externalController,
secret: defaultClashConfig.secret,
unifiedDelay: defaultClashConfig.unifiedDelay,
geoAutoUpdate: defaultClashConfig.geoAutoUpdate,
geoUpdateInterval: defaultClashConfig.geoUpdateInterval,
geoXUrl: defaultClashConfig.geoXUrl,
);
PatchClashConfig ensureControllerSecret() {
if (externalController == ExternalControllerStatus.close) {
return secret.isEmpty ? this : copyWith(secret: '');
}
return secret.isEmpty ? copyWith(secret: generateRandomSecret(32)) : this;
}
}