Files
FlClash/lib/models/clash_config.dart
T

2080 lines
64 KiB
Dart
Raw Normal View History

import 'package:collection/collection.dart';
2024-05-11 17:02:34 +08:00
import 'package:fl_clash/common/common.dart';
2025-05-02 02:24:12 +08:00
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/core.dart';
2024-05-11 17:02:34 +08:00
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:yaml/yaml.dart';
2024-04-30 23:38:49 +08:00
2024-05-11 17:02:34 +08:00
part 'generated/clash_config.freezed.dart';
2026-05-27 15:16:51 +08:00
2025-02-09 18:39:38 +08:00
part 'generated/clash_config.g.dart';
2024-04-30 23:38:49 +08:00
2026-02-02 10:14:13 +08:00
const defaultClashConfig = PatchClashConfig();
2024-04-30 23:38:49 +08:00
2025-02-09 18:39:38 +08:00
const defaultTun = Tun();
2024-09-26 14:29:04 +08:00
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'],
);
2026-05-27 15:16:51 +08:00
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',
};
2024-08-25 23:30:18 +08:00
2024-09-05 08:59:45 +08:00
const defaultMixedPort = 7890;
const defaultKeepAliveInterval = 30;
const defaultTunMtu = 9000;
const minTunMtu = 1280;
const maxTunMtu = 65535;
const defaultCongestionController = 'cubic';
// The congestion controls mipstack registers, cubic first as its default.
const congestionControllers = [
defaultCongestionController,
'reno',
'bbr',
'bbr3',
];
const defaultDnsHijack = ['any:53'];
const defaultTunUdpTimeout = 300;
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',
2024-11-09 20:17:57 +08:00
];
Object? _readIncludeAll(Map<dynamic, dynamic> json, String key) {
return json['include-all'] == true ? true : json[key];
}
2025-02-09 18:39:38 +08:00
@freezed
2025-07-31 17:09:18 +08:00
abstract class ProxyGroup with _$ProxyGroup {
2025-02-09 18:39:38 +08:00
const factory ProxyGroup({
2026-02-02 10:14:13 +08:00
int? profileId,
@JsonKey(fromJson: Snowflake.buildId) required int id,
2025-02-09 18:39:38 +08:00
required String name,
2026-02-02 10:14:13 +08:00
required GroupType type,
2025-02-09 18:39:38 +08:00
List<String>? proxies,
List<String>? use,
@JsonKey(name: 'use-position') Map<String, int>? usePosition,
2025-02-09 18:39:38 +08:00
int? interval,
bool? lazy,
2026-02-02 10:14:13 +08:00
@JsonKey(name: 'disable-udp') bool? disableUDP,
2025-02-09 18:39:38 +08:00
String? url,
int? timeout,
2025-06-07 01:48:34 +08:00
@JsonKey(name: 'max-failed-times') int? maxFailedTimes,
2025-02-09 18:39:38 +08:00
String? filter,
2026-02-02 10:14:13 +08:00
@JsonKey(name: 'exclude-filter') String? excludeFilter,
2025-06-07 01:48:34 +08:00
@JsonKey(name: 'exclude-type') String? excludeType,
2026-02-02 10:14:13 +08:00
@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-proxies', readValue: _readIncludeAll)
bool? includeAllProxies,
@JsonKey(name: 'include-all-providers', readValue: _readIncludeAll)
bool? includeAllProviders,
2025-02-09 18:39:38 +08:00
bool? hidden,
String? icon,
2026-02-02 10:14:13 +08:00
String? order,
/// Where the members list shows the filters among the proxies, which no
/// config carries, as `ProxyGroupMembersSliver` writes it.
List<String>? memberLayout,
2025-02-09 18:39:38 +08:00
}) = _ProxyGroup;
factory ProxyGroup.fromJson(Map<String, Object?> json) =>
_$ProxyGroupFromJson(json);
}
2024-04-30 23:38:49 +08:00
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 (includeAllProxies == true) 'include-all-proxies': true,
if (includeAllProviders == true) 'include-all-providers': true,
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',
];
2026-02-02 10:14:13 +08:00
@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',
];
2026-10-08 17:58:43 +08:00
/// 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];
2026-10-08 17:58:43 +08:00
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' ||
2026-10-08 17:58:43 +08:00
'openvpn' => const [ProxyField.username, ProxyField.password],
_ => const [],
};
2026-10-08 17:58:43 +08:00
/// 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 [],
};
2026-10-08 17:58:43 +08:00
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),
);
}
}
2026-10-08 17:58:43 +08:00
return next;
}
2026-10-08 17:58:43 +08:00
/// 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;
}
2026-02-02 10:14:13 +08:00
@freezed
abstract class CustomProfileData with _$CustomProfileData {
const factory CustomProfileData({
2026-02-02 10:14:13 +08:00
@Default([]) List<ProxyGroup> proxyGroups,
@Default({}) Set<String> proxyProviders,
@Default({}) Set<String> ruleProviders,
2026-02-02 10:14:13 +08:00
@Default({}) Set<String> ruleTargets,
@Default({}) Set<String> proxies,
@Default({}) Map<String, String> dialers,
}) = _CustomProfileData;
2026-02-02 10:14:13 +08:00
}
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) =>
2026-10-08 17:58:43 +08:00
ruleTargets.contains(name) ||
name == GroupName.GLOBAL.name ||
namedProxies.contains(name);
}
2025-05-02 02:24:12 +08:00
@freezed
2025-07-31 17:09:18 +08:00
abstract class Sniffer with _$Sniffer {
2025-05-02 02:24:12 +08:00
const factory Sniffer({
@Default(false) bool enable,
2025-06-07 01:48:34 +08:00
@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,
2025-06-07 01:48:34 +08:00
@Default([]) @JsonKey(name: 'force-domain') List<String> forceDomain,
@Default([]) @JsonKey(name: 'skip-domain') List<String> skipDomain,
2025-06-07 01:48:34 +08:00
@Default([]) @JsonKey(name: 'skip-src-address') List<String> skipSrcAddress,
@Default([]) @JsonKey(name: 'skip-dst-address') List<String> skipDstAddress,
2025-05-02 02:24:12 +08:00
@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);
}
2025-05-02 02:24:12 +08:00
}
List<String> _formJsonPorts(List? ports) {
return ports?.map((item) => item.toString()).toList() ?? [];
}
@freezed
2025-07-31 17:09:18 +08:00
abstract class SnifferConfig with _$SnifferConfig {
2025-05-02 02:24:12 +08:00
const factory SnifferConfig({
@Default([]) @JsonKey(fromJson: _formJsonPorts) List<String> ports,
@JsonKey(name: 'override-destination', includeIfNull: false)
bool? overrideDest,
2025-05-02 02:24:12 +08:00
}) = _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},
};
}
/// The desktop TUN takes only [TunExt.coreJson]; Android's VPN starts from all.
2025-02-09 18:39:38 +08:00
@freezed
2025-07-31 17:09:18 +08:00
abstract class Tun with _$Tun {
2025-02-09 18:39:38 +08:00
const factory Tun({
@Default(false) bool enable,
@Default(appName) String device,
@Default(TunStack.mips) TunStack stack,
2025-06-07 01:48:34 +08:00
@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: 'route-exclude-address')
@Default([])
List<String> routeExcludeAddress,
2025-02-09 18:39:38 +08:00
}) = _Tun;
2024-04-30 23:38:49 +08:00
2025-02-09 18:39:38 +08:00
factory Tun.fromJson(Map<String, Object?> json) => _$TunFromJson(json);
2024-04-30 23:38:49 +08:00
2025-02-09 18:39:38 +08:00
factory Tun.safeFormJson(Map<String, Object?>? json) {
if (json == null) {
return defaultTun;
2024-04-30 23:38:49 +08:00
}
return decodeSalvaging('tun config', json, Tun.fromJson);
2024-04-30 23:38:49 +08:00
}
2025-02-09 18:39:38 +08:00
}
2024-04-30 23:38:49 +08:00
int _mtuFromJson(int value) =>
value > 0 ? value.clamp(minTunMtu, maxTunMtu) : defaultTunMtu;
String _congestionControllerFromJson(String value) =>
value.isEmpty ? defaultCongestionController : value;
2025-06-12 10:47:02 +08:00
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);
2025-06-12 10:47:02 +08:00
}
Map<String, Object?> get coreJson {
final json = toJson();
return {
for (final key in const ['enable', 'device', 'stack']) key: json[key],
'auto-route': true,
};
}
2025-06-12 10:47:02 +08:00
}
2025-02-09 18:39:38 +08:00
@freezed
2025-07-31 17:09:18 +08:00
abstract class FallbackFilter with _$FallbackFilter {
2025-02-09 18:39:38 +08:00
const factory FallbackFilter({
@Default(true) bool geoip,
@Default('') @JsonKey(name: 'geoip-code') String geoipCode,
2026-02-02 10:14:13 +08:00
@Default([]) List<String> geosite,
@Default([]) List<String> ipcidr,
@Default([]) List<String> domain,
2025-02-09 18:39:38 +08:00
}) = _FallbackFilter;
2024-07-02 08:08:31 +08:00
2025-02-09 18:39:38 +08:00
factory FallbackFilter.fromJson(Map<String, Object?> json) =>
_$FallbackFilterFromJson(json);
}
2024-07-02 08:08:31 +08:00
2025-02-09 18:39:38 +08:00
@freezed
2025-07-31 17:09:18 +08:00
abstract class Dns with _$Dns {
2025-02-09 18:39:38 +08:00
const factory Dns({
@Default(false) bool enable,
@Default('') String listen,
@Default(0) @JsonKey(name: 'listen-routing-mark') int listenRoutingMark,
2025-06-07 01:48:34 +08:00
@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,
2025-02-09 18:39:38 +08:00
@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'])
2025-06-07 01:48:34 +08:00
@JsonKey(name: 'default-nameserver')
2025-02-09 18:39:38 +08:00
List<String> defaultNameserver,
@Default(DnsMode.redirHost)
2025-06-07 01:48:34 +08:00
@JsonKey(name: 'enhanced-mode')
2025-02-09 18:39:38 +08:00
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({})
2025-06-07 01:48:34 +08:00
@JsonKey(name: 'nameserver-policy')
2025-02-09 18:39:38 +08:00
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([])
2025-06-07 01:48:34 +08:00
@JsonKey(name: 'proxy-server-nameserver')
2025-02-09 18:39:38 +08:00
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,
2025-02-09 18:39:38 +08:00
@Default(FallbackFilter())
2025-06-07 01:48:34 +08:00
@JsonKey(name: 'fallback-filter')
2025-02-09 18:39:38 +08:00
FallbackFilter fallbackFilter,
}) = _Dns;
2024-07-02 08:08:31 +08:00
2025-02-09 18:39:38 +08:00
factory Dns.fromJson(Map<String, Object?> json) => _$DnsFromJson(json);
2024-07-08 17:34:14 +08:00
2025-02-09 18:39:38 +08:00
factory Dns.safeDnsFromJson(Map<String, Object?> json) {
return decodeSalvaging('dns config', json, Dns.fromJson);
2024-07-08 17:34:14 +08:00
}
2025-02-09 18:39:38 +08:00
}
2024-07-08 17:34:14 +08:00
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(defaultTunMtu) int mtu,
@Default(defaultCongestionController)
@JsonKey(name: 'congestion-controller')
String congestionController,
@Default(false) @JsonKey(name: 'strict-route') bool strictRoute,
@Default([]) @JsonKey(name: 'route-address') List<String> routeAddress,
@Default([])
@JsonKey(name: 'route-exclude-address')
List<String> routeExcludeAddress,
@Default(defaultDnsHijack)
@JsonKey(name: 'dns-hijack')
List<String> dnsHijack,
@Default(defaultTunUdpTimeout) @JsonKey(name: 'udp-timeout') int udpTimeout,
@Default(false)
@JsonKey(name: 'disable-icmp-forwarding')
bool disableIcmpForwarding,
@Default(false) bool gso,
@Default([])
@JsonKey(name: 'include-interface')
List<String> includeInterface,
@Default([])
@JsonKey(name: 'exclude-interface')
List<String> excludeInterface,
@Default([]) @JsonKey(name: 'include-uid') List<int> includeUid,
@Default([]) @JsonKey(name: 'exclude-uid') List<int> excludeUid,
@Default([]) @JsonKey(name: 'exclude-src-port') List<int> excludeSrcPort,
@Default([]) @JsonKey(name: 'exclude-dst-port') List<int> excludeDstPort,
}) = _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);
}
/// Network TUN values changed from their defaults become app-wide overrides.
Map<String, Object?> _withLegacyTunOverride(Map<String, Object?> json) {
final raw = json['tun'];
if (json.containsKey(_tunOverrideKeysJsonKey) || raw is! Map) {
return json;
}
final tun = Tun.safeFormJson(Map<String, Object?>.from(raw));
final override = ProfileTun(
mtu: tun.mtu,
congestionController: tun.congestionController,
strictRoute: raw['strict-route'] == true,
routeAddress: tun.routeAddress,
routeExcludeAddress: tun.routeExcludeAddress,
);
return {
...json,
'tun-override': override.toJson(),
_tunOverrideKeysJsonKey: [
if (tun.mtu != defaultTunMtu) TunOverrideKey.mtu.path,
if (tun.congestionController != defaultCongestionController)
TunOverrideKey.congestionController.path,
if (override.strictRoute) TunOverrideKey.strictRoute.path,
if (tun.routeAddress.isNotEmpty) TunOverrideKey.routeAddress.path,
if (tun.routeExcludeAddress.isNotEmpty)
TunOverrideKey.routeExcludeAddress.path,
],
};
}
@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;
ProfileOverrides overAppTun(ProfileTun tun, Set<TunOverrideKey> keys) {
final own = this.tun.toJson();
return copyWith(
tun: ProfileTun.fromJson({
...tun.toJson(),
for (final key in tunOverrideKeys) key.path: own[key.path],
}),
tunOverrideKeys: {...keys, ...tunOverrideKeys},
);
}
}
2025-03-12 17:15:31 +08:00
@freezed
2026-02-02 10:14:13 +08:00
abstract class Rule with _$Rule {
const factory Rule({
@Default(-1) int id,
@JsonKey(includeFromJson: false, includeToJson: false) int? profileId,
2026-02-02 10:14:13 +08:00
@Default(RuleAction.DOMAIN) RuleAction ruleAction,
2025-03-12 17:15:31 +08:00
String? content,
String? ruleTarget,
String? ruleProvider,
String? subRule,
@Default(false) bool noResolve,
@Default(false) bool src,
2026-02-02 10:14:13 +08:00
String? order,
}) = _Rule;
factory Rule.init() {
return Rule(
ruleAction: RuleAction.DOMAIN,
ruleTarget: RuleTarget.DIRECT.value,
2026-02-02 10:14:13 +08:00
);
}
// Mirrors mihomo's ParseRulePayload with needTarget set.
2026-02-02 10:14:13 +08:00
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) {
2026-02-02 10:14:13 +08:00
return Rule(
id: id,
ruleAction: RuleAction.DOMAIN,
ruleTarget: RuleTarget.DIRECT.value,
2026-02-02 10:14:13 +08:00
);
}
final action = RuleAction.values.firstWhere(
(item) => item.value == type,
2025-03-12 17:15:31 +08:00
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;
2025-03-12 17:15:31 +08:00
} else {
payload = rest.elementAtOrNull(0);
target = rest.elementAtOrNull(1);
params = rest.skip(2).toList();
2025-03-12 17:15:31 +08:00
}
payload = payload?.isNotEmpty == true ? payload : null;
target = target?.isNotEmpty == true ? target : null;
2025-03-12 17:15:31 +08:00
2026-02-02 10:14:13 +08:00
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'),
2025-03-12 17:15:31 +08:00
);
}
2026-02-02 10:14:13 +08:00
factory Rule.fromJson(Map<String, Object?> json) => _$RuleFromJson(json);
2025-03-12 17:15:31 +08:00
}
2026-02-02 10:14:13 +08:00
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,
2026-02-02 10:14:13 +08:00
};
}
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;
}
}
2026-02-02 10:14:13 +08:00
String get rawValue {
final content = realContent;
final target = realTarget;
2025-03-12 17:15:31 +08:00
return [
ruleAction.value,
if (content?.isNotEmpty == true) content!,
if (target?.isNotEmpty == true) target!,
2025-03-12 17:15:31 +08:00
if (ruleAction.hasParams) ...[
2025-06-07 01:48:34 +08:00
if (src) 'src',
if (noResolve) 'no-resolve',
2025-07-31 17:09:18 +08:00
],
2025-06-07 01:48:34 +08:00
].join(',');
2025-03-12 17:15:31 +08:00
}
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;
2025-03-12 17:15:31 +08:00
}
2026-02-02 10:14:13 +08:00
List<Rule> _genRules(List<dynamic>? rules) {
2025-03-12 17:15:31 +08:00
if (rules == null) {
return [];
}
2026-02-02 10:14:13 +08:00
return rules.map((item) => Rule.parse(item)).toList();
2025-03-12 17:15:31 +08:00
}
2026-02-02 10:14:13 +08:00
List<String> _genList(Map<String, dynamic> json) {
return json.entries.map((entry) => entry.key).toList();
2025-03-12 17:15:31 +08:00
}
2025-03-09 01:58:46 +08:00
@freezed
2026-02-02 10:14:13 +08:00
abstract class ClashConfig with _$ClashConfig {
const factory ClashConfig({
2025-06-07 01:48:34 +08:00
@Default([]) @JsonKey(name: 'proxy-groups') List<ProxyGroup> proxyGroups,
2026-02-02 10:14:13 +08:00
@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)
2025-03-12 17:15:31 +08:00
@Default([])
2026-02-02 10:14:13 +08:00
List<String> ruleProviders,
@JsonKey(name: 'sub-rules', fromJson: _genList)
2025-03-12 17:15:31 +08:00
@Default([])
2026-02-02 10:14:13 +08:00
List<String> subRules,
}) = _ClashConfig;
2025-03-09 01:58:46 +08:00
2026-02-02 10:14:13 +08:00
factory ClashConfig.fromJson(Map<String, Object?> json) =>
_$ClashConfigFromJson(json);
2025-03-09 01:58:46 +08:00
}
2026-05-27 15:16:51 +08:00
extension GeoResourceUrlMapExt on Map<GeoResource, String> {
Map<String, String> get raw =>
2026-08-05 10:57:11 +08:00
map((key, value) => MapEntry(key.configKey, value));
2026-05-27 15:16:51 +08:00
}
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;
}
2025-02-09 18:39:38 +08:00
@freezed
2026-02-02 10:14:13 +08:00
abstract class PatchClashConfig with _$PatchClashConfig {
const factory PatchClashConfig({
2025-06-07 01:48:34 +08:00
@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,
2025-02-09 18:39:38 +08:00
@Default(Mode.rule) Mode mode,
2025-06-07 01:48:34 +08:00
@Default(false) @JsonKey(name: 'allow-lan') bool allowLan,
@Default(LogLevel.error) @JsonKey(name: 'log-level') LogLevel logLevel,
2025-02-09 18:39:38 +08:00
@Default(false) bool ipv6,
@Default(FindProcessMode.off)
@JsonKey(name: 'find-process-mode', unknownEnumValue: FindProcessMode.off)
2025-02-09 18:39:38 +08:00
FindProcessMode findProcessMode,
@Default(InterfaceNameMode.clear)
@JsonKey(
name: 'interface-name-mode',
unknownEnumValue: InterfaceNameMode.clear,
)
InterfaceNameMode interfaceNameMode,
@Default('') @JsonKey(name: 'interface-name') String interfaceName,
2025-02-09 18:39:38 +08:00
@Default(defaultKeepAliveInterval)
2025-06-07 01:48:34 +08:00
@JsonKey(name: 'keep-alive-interval')
2025-02-09 18:39:38 +08:00
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,
2025-06-07 01:48:34 +08:00
@Default(true) @JsonKey(name: 'unified-delay') bool unifiedDelay,
@Default(true) @JsonKey(name: 'tcp-concurrent') bool tcpConcurrent,
2025-02-09 18:39:38 +08:00
@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(defaultProfileTun)
@JsonKey(name: 'tun-override', fromJson: ProfileTun.safeFromJson)
ProfileTun tunOverride,
@Default({})
@JsonKey(name: _tunOverrideKeysJsonKey, fromJson: _tunOverrideKeysFromJson)
Set<TunOverrideKey> tunOverrideKeys,
2025-02-09 18:39:38 +08:00
@Default(defaultGeoXUrl)
2026-05-27 15:16:51 +08:00
@JsonKey(
name: 'geox-url',
fromJson: _geoXUrlFromJson,
toJson: _geoXUrlToJson,
)
Map<GeoResource, String> geoXUrl,
2025-02-09 18:39:38 +08:00
@Default(GeodataLoader.memconservative)
2025-06-07 01:48:34 +08:00
@JsonKey(name: 'geodata-loader')
2025-02-09 18:39:38 +08:00
GeodataLoader geodataLoader,
2025-06-07 01:48:34 +08:00
@JsonKey(name: 'global-ua') String? globalUa,
2025-02-09 18:39:38 +08:00
@Default(ExternalControllerStatus.close)
2025-06-07 01:48:34 +08:00
@JsonKey(name: 'external-controller')
2025-02-09 18:39:38 +08:00
ExternalControllerStatus externalController,
@Default('') String secret,
2025-10-14 15:13:52 +08:00
@Default({}) Map<String, String> hosts,
2026-05-27 15:16:51 +08:00
@Default(false) @JsonKey(name: 'geo-auto-update') bool geoAutoUpdate,
@Default(24) @JsonKey(name: 'geo-update-interval') int geoUpdateInterval,
2026-02-02 10:14:13 +08:00
}) = _PatchClashConfig;
2025-02-09 18:39:38 +08:00
2026-02-02 10:14:13 +08:00
factory PatchClashConfig.fromJson(Map<String, Object?> json) =>
_$PatchClashConfigFromJson(
_withLegacyTunOverride(_withLegacyDnsOverrideKeys(json)),
);
2025-02-09 18:39:38 +08:00
2026-02-02 10:14:13 +08:00
factory PatchClashConfig.safeFormJson(Map<String, Object?>? json) {
2025-02-09 18:39:38 +08:00
if (json == null) {
return defaultClashConfig;
2024-11-09 20:17:57 +08:00
}
return decodeSalvaging('clash config', json, PatchClashConfig.fromJson);
2024-10-27 16:59:23 +08:00
}
2024-05-20 15:15:09 +08:00
}
extension PatchClashConfigExt on PatchClashConfig {
UpdateParams toUpdateParams({
required List<String> authentication,
required bool skipCertVerify,
}) {
return UpdateParams(
tun: tun,
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;
}
}