Files
FlClash/lib/common/input_entries.dart
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

284 lines
7.1 KiB
Dart
Raw Permalink 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:fl_clash/enum/enum.dart';
import 'string.dart';
enum InputIssueKind {
keyTooLong,
valueTooLong,
missingValue,
invalidUrl,
invalidValue,
}
class InputIssue {
final int line;
final String raw;
final InputIssueKind kind;
const InputIssue({required this.line, required this.raw, required this.kind});
@override
bool operator ==(Object other) =>
other is InputIssue &&
other.line == line &&
other.raw == raw &&
other.kind == kind;
@override
int get hashCode => Object.hash(line, raw, kind);
@override
String toString() => 'InputIssue(line: $line, raw: $raw, kind: $kind)';
}
class ParsedInput<T> {
final List<T> entries;
final int skippedExisting;
final List<InputIssue> issues;
const ParsedInput({
required this.entries,
required this.skippedExisting,
required this.issues,
});
bool get isValid => issues.isEmpty;
}
final _lineSeparator = RegExp(r'\r?\n');
final _listSeparator = RegExp(r'[,,]');
final _whitespace = RegExp(r'\s');
final _whitespaces = RegExp(r'\s+');
final _ruleTypes = {for (final action in RuleAction.values) action.value};
/// fake-ip-filter in rule mode takes whole rules such as
/// `DOMAIN-SUFFIX,lan,real-ip`, so their commas never split a line.
bool _isRule(String value) {
final comma = value.indexOf(',');
return comma > 0 &&
_ruleTypes.contains(value.substring(0, comma).trim().toUpperCase());
}
/// Lines copied out of a YAML sequence keep their `- ` marker and quotes.
String _stripLineMarkup(String line) {
var value = line.trim();
if (value.startsWith('- ')) {
value = value.substring(2).trim();
}
if (value.endsWith(',')) {
value = value.substring(0, value.length - 1).trim();
}
return value;
}
String _unquote(String value) {
if (value.length < 2) {
return value;
}
final first = value[0];
if ((first == '"' || first == "'") && value.endsWith(first)) {
return value.substring(1, value.length - 1).trim();
}
return value;
}
ParsedInput<String> parseListInput(
String text, {
Set<String> existing = const {},
int? maxLength,
bool Function(String value)? isValid,
}) {
final entries = <String>[];
final issues = <InputIssue>[];
final seen = <String>{};
var skippedExisting = 0;
final lines = text.split(_lineSeparator);
for (var index = 0; index < lines.length; index++) {
final line = _stripLineMarkup(lines[index]);
final whole = _unquote(line);
final values = _isRule(whole) ? [whole] : line.split(_listSeparator);
for (final raw in values) {
final value = _unquote(raw.trim());
if (value.isEmpty) {
continue;
}
if (maxLength != null && value.length > maxLength) {
issues.add(
InputIssue(
line: index + 1,
raw: value,
kind: InputIssueKind.valueTooLong,
),
);
continue;
}
if (isValid != null && !isValid(value)) {
issues.add(
InputIssue(
line: index + 1,
raw: value,
kind: InputIssueKind.invalidValue,
),
);
continue;
}
if (!seen.add(value)) {
continue;
}
if (existing.contains(value)) {
skippedExisting++;
continue;
}
entries.add(value);
}
}
return ParsedInput(
entries: entries,
skippedExisting: skippedExisting,
issues: issues,
);
}
/// A url never carries whitespace, so any run of it separates two.
ParsedInput<String> parseUrlInput(
String text, {
required bool Function(String url) isValid,
Set<String> existing = const {},
}) {
final entries = <String>[];
final issues = <InputIssue>[];
final seen = <String>{};
var skippedExisting = 0;
final lines = text.split(_lineSeparator);
for (var index = 0; index < lines.length; index++) {
final line = _stripLineMarkup(lines[index]);
for (final raw in line.isShareLink ? [line] : line.split(_whitespaces)) {
final url = _unquote(raw);
if (url.isEmpty) {
continue;
}
if (!isValid(url)) {
issues.add(
InputIssue(
line: index + 1,
raw: url,
kind: InputIssueKind.invalidUrl,
),
);
continue;
}
if (!seen.add(url)) {
continue;
}
if (existing.contains(url)) {
skippedExisting++;
continue;
}
entries.add(url);
}
}
return ParsedInput(
entries: entries,
skippedExisting: skippedExisting,
issues: issues,
);
}
/// Keys never carry whitespace and values may carry `:` (`geosite:cn`,
/// `tls://1.1.1.1:853`, IPv6), so the first run of whitespace splits a line
/// and a bare `:` never does. A key may end in `:` or `=` and a value may
/// start with `=` so YAML and `key=value` lines parse without cleanup.
MapEntry<String, String>? _splitMapLine(String line) {
final whitespaceIndex = line.indexOf(_whitespace);
String key;
String value;
if (whitespaceIndex == -1) {
final equalsIndex = line.indexOf('=');
if (equalsIndex == -1) {
return null;
}
key = line.substring(0, equalsIndex);
value = line.substring(equalsIndex + 1);
} else {
key = line.substring(0, whitespaceIndex);
value = line.substring(whitespaceIndex).trim();
if (value.startsWith('=')) {
value = value.substring(1).trim();
} else if (value.startsWith(':') && value.length > 1 && value[1] == ' ') {
value = value.substring(1).trim();
}
}
if (key.endsWith(':') || key.endsWith('=')) {
key = key.substring(0, key.length - 1);
}
key = _unquote(key.trim());
value = _unquote(value);
if (key.isEmpty || value.isEmpty) {
return null;
}
return MapEntry(key, value);
}
ParsedInput<MapEntry<String, String>> parseMapInput(
String text, {
Set<String> existingKeys = const {},
int? keyMaxLength,
int? valueMaxLength,
}) {
final entries = <MapEntry<String, String>>[];
final issues = <InputIssue>[];
final seen = <String>{};
var skippedExisting = 0;
final lines = text.split(_lineSeparator);
for (var index = 0; index < lines.length; index++) {
final line = _stripLineMarkup(lines[index]);
if (line.isEmpty) {
continue;
}
final entry = _splitMapLine(line);
if (entry == null) {
issues.add(
InputIssue(
line: index + 1,
raw: line,
kind: InputIssueKind.missingValue,
),
);
continue;
}
if (keyMaxLength != null && entry.key.length > keyMaxLength) {
issues.add(
InputIssue(
line: index + 1,
raw: entry.key,
kind: InputIssueKind.keyTooLong,
),
);
continue;
}
if (valueMaxLength != null && entry.value.length > valueMaxLength) {
issues.add(
InputIssue(
line: index + 1,
raw: entry.value,
kind: InputIssueKind.valueTooLong,
),
);
continue;
}
if (!seen.add(entry.key)) {
continue;
}
if (existingKeys.contains(entry.key)) {
skippedExisting++;
continue;
}
entries.add(entry);
}
return ParsedInput(
entries: entries,
skippedExisting: skippedExisting,
issues: issues,
);
}