Add proxy routing and sing-box support

This commit is contained in:
Fabian Freund
2026-05-22 18:16:31 +02:00
parent 51289f1266
commit a5974617aa
262 changed files with 32003 additions and 3962 deletions
@@ -0,0 +1,81 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
enum SingboxFieldKind {
text,
secret,
integer,
integerList,
port,
boolean,
stringList,
}
class SingboxProxyFormField {
final String key;
final String label;
final String? helperText;
final String? defaultValue;
final bool required;
final SingboxFieldKind kind;
final int? exactListLength;
final int? minValue;
final int? maxValue;
const SingboxProxyFormField({
required this.key,
required this.label,
this.helperText,
this.defaultValue,
this.required = false,
this.kind = SingboxFieldKind.text,
this.exactListLength,
this.minValue,
this.maxValue,
});
bool get isSecret => kind == SingboxFieldKind.secret;
bool get isNumber =>
kind == SingboxFieldKind.integer || kind == SingboxFieldKind.port;
bool get isIntegerList => kind == SingboxFieldKind.integerList;
bool get isPort => kind == SingboxFieldKind.port;
bool get isBoolean => kind == SingboxFieldKind.boolean;
bool get isStringList => kind == SingboxFieldKind.stringList;
}
/// Shared parsing of boolean form values. Returns null when the text doesn't
/// look like a boolean (treated as "unset"), so callers can distinguish absent
/// from explicitly-false.
bool? parseFormBool(String value) {
return switch (value.trim().toLowerCase()) {
'true' || 'yes' || '1' || 'on' => true,
'false' || 'no' || '0' || 'off' => false,
_ => null,
};
}
/// Splits a multi-value form input on newlines or commas, trims, drops blanks.
List<String> splitFormStringList(String value) {
return value
.split(RegExp(r'[\n,]'))
.map((item) => item.trim())
.where((item) => item.isNotEmpty)
.toList();
}
@@ -0,0 +1,168 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:convert';
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
import 'package:weblibre/features/proxy/data/forms/singbox_form_field.dart';
class SingboxProxyFormSpec {
final SingboxProxyProfileType type;
final String outboundType;
final List<SingboxProxyFormField> fields;
const SingboxProxyFormSpec({
required this.type,
required this.outboundType,
required this.fields,
});
String? validate(Map<String, String> values) {
for (final field in fields) {
final value = values[field.key]?.trim() ?? '';
if (field.required && value.isEmpty) {
return '${field.label} is required.';
}
if (field.isNumber && value.isNotEmpty) {
final parsed = int.tryParse(value);
if (parsed == null || parsed < 1) {
return '${field.label} must be a positive number.';
}
if (field.isPort && parsed > 65535) {
return '${field.label} must be between 1 and 65535.';
}
}
if (field.isIntegerList && value.isNotEmpty) {
final items = splitFormStringList(value);
final exactListLength = field.exactListLength;
if (exactListLength != null && items.length != exactListLength) {
return '${field.label} must contain $exactListLength numbers.';
}
for (final item in items) {
final parsed = int.tryParse(item);
if (parsed == null) {
return '${field.label} must contain only numbers.';
}
final minValue = field.minValue;
if (minValue != null && parsed < minValue) {
return '${field.label} must contain numbers greater than or equal to $minValue.';
}
final maxValue = field.maxValue;
if (maxValue != null && parsed > maxValue) {
return '${field.label} must contain numbers less than or equal to $maxValue.';
}
}
}
if (field.isBoolean && value.isNotEmpty && parseFormBool(value) == null) {
return '${field.label} must be true or false.';
}
}
return null;
}
String toConfigJson(Map<String, String> values) {
final config = <String, dynamic>{'type': outboundType};
for (final field in fields.where((field) => !field.isSecret)) {
final value = values[field.key]?.trim() ?? '';
if (value.isEmpty) continue;
_setJsonValue(config, field.key, _fieldJsonValue(field, value));
}
return const JsonEncoder.withIndent(' ').convert(config);
}
String? toSecretJson(Map<String, String> values) {
final secrets = <String, dynamic>{};
for (final field in fields.where((field) => field.isSecret)) {
final value = values[field.key]?.trim() ?? '';
if (value.isEmpty) continue;
_setJsonValue(secrets, field.key, _fieldJsonValue(field, value));
}
if (secrets.isEmpty) return null;
return const JsonEncoder.withIndent(' ').convert(secrets);
}
Map<String, String> valuesFromJson({
required String configJson,
String? secretJson,
}) {
final config = _decodeJsonObject(configJson) ?? const <String, dynamic>{};
final secrets = secretJson == null
? const <String, dynamic>{}
: _decodeJsonObject(secretJson) ?? const <String, dynamic>{};
final merged = {...config, ...secrets};
return {
for (final field in fields)
field.key: _fieldTextValue(
_jsonValueAt(merged, field.key),
field.defaultValue ?? '',
),
};
}
}
Map<String, dynamic>? _decodeJsonObject(String rawJson) {
try {
final decoded = jsonDecode(rawJson) as Object?;
if (decoded is Map<String, dynamic>) return decoded;
} catch (_) {
return null;
}
return null;
}
Object _fieldJsonValue(SingboxProxyFormField field, String value) {
if (field.isNumber) return int.parse(value);
if (field.isIntegerList) {
return splitFormStringList(value).map(int.parse).toList();
}
if (field.isBoolean) return parseFormBool(value)!;
if (field.isStringList) return splitFormStringList(value);
return value;
}
String _fieldTextValue(Object? value, String fallback) {
if (value == null) return fallback;
if (value is List) return value.map((item) => item.toString()).join('\n');
return value.toString();
}
void _setJsonValue(Map<String, dynamic> target, String path, Object value) {
final segments = path.split('.');
var current = target;
for (final segment in segments.take(segments.length - 1)) {
current =
current.putIfAbsent(segment, () => <String, dynamic>{})
as Map<String, dynamic>;
}
current[segments.last] = value;
}
Object? _jsonValueAt(Map<String, dynamic> source, String path) {
Object? current = source;
for (final segment in path.split('.')) {
if (current is! Map<String, dynamic>) return null;
current = current[segment];
}
return current;
}
@@ -0,0 +1,415 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
import 'package:weblibre/features/proxy/data/forms/singbox_form_field.dart';
import 'package:weblibre/features/proxy/data/forms/singbox_form_spec.dart';
const _serverField = SingboxProxyFormField(
key: 'server',
label: 'Server Address',
required: true,
);
const _serverPortField = SingboxProxyFormField(
key: 'server_port',
label: 'Server Port',
required: true,
kind: SingboxFieldKind.port,
);
const _usernameField = SingboxProxyFormField(
key: 'username',
label: 'Username',
kind: SingboxFieldKind.secret,
);
const _passwordField = SingboxProxyFormField(
key: 'password',
label: 'Password',
required: true,
kind: SingboxFieldKind.secret,
);
const _optionalPasswordField = SingboxProxyFormField(
key: 'password',
label: 'Password',
kind: SingboxFieldKind.secret,
);
const _uuidField = SingboxProxyFormField(
key: 'uuid',
label: 'UUID',
required: true,
kind: SingboxFieldKind.secret,
);
const _tlsFields = [
SingboxProxyFormField(
key: 'tls.enabled',
label: 'TLS Enabled',
helperText: 'true or false.',
kind: SingboxFieldKind.boolean,
),
SingboxProxyFormField(key: 'tls.server_name', label: 'TLS Server Name'),
SingboxProxyFormField(
key: 'tls.insecure',
label: 'Allow Invalid TLS Certificates',
helperText: 'true or false.',
kind: SingboxFieldKind.boolean,
),
SingboxProxyFormField(
key: 'tls.alpn',
label: 'TLS ALPN',
helperText: 'Comma-separated or one value per line.',
kind: SingboxFieldKind.stringList,
),
];
const _transportFields = [
SingboxProxyFormField(
key: 'transport.type',
label: 'Transport Type',
helperText: 'For example ws, http, grpc, or quic.',
),
SingboxProxyFormField(key: 'transport.path', label: 'Transport Path'),
SingboxProxyFormField(
key: 'transport.service_name',
label: 'gRPC Service Name',
),
];
const _multiplexFields = [
SingboxProxyFormField(
key: 'multiplex.enabled',
label: 'Multiplex Enabled',
helperText: 'true or false.',
kind: SingboxFieldKind.boolean,
),
SingboxProxyFormField(key: 'multiplex.protocol', label: 'Multiplex Protocol'),
SingboxProxyFormField(
key: 'multiplex.max_connections',
label: 'Multiplex Max Connections',
kind: SingboxFieldKind.integer,
),
];
const _dialFields = [
SingboxProxyFormField(key: 'detour', label: 'Dial Detour'),
SingboxProxyFormField(key: 'bind_interface', label: 'Bind Interface'),
SingboxProxyFormField(
key: 'routing_mark',
label: 'Routing Mark',
kind: SingboxFieldKind.integer,
),
SingboxProxyFormField(
key: 'domain_strategy',
label: 'Domain Strategy',
helperText: 'For example prefer_ipv4 or prefer_ipv6.',
),
SingboxProxyFormField(
key: 'connect_timeout',
label: 'Connect Timeout',
helperText: 'For example 5s.',
),
];
const _v2rayAdvancedFields = [
..._tlsFields,
..._transportFields,
..._multiplexFields,
..._dialFields,
];
const _commonAdvancedFields = [..._multiplexFields, ..._dialFields];
const singboxProxyFormSpecs = <SingboxProxyProfileType, SingboxProxyFormSpec>{
SingboxProxyProfileType.socks: SingboxProxyFormSpec(
type: SingboxProxyProfileType.socks,
outboundType: 'socks',
fields: [
_serverField,
_serverPortField,
SingboxProxyFormField(
key: 'version',
label: 'SOCKS Version',
defaultValue: '5',
kind: SingboxFieldKind.integer,
),
_usernameField,
_optionalPasswordField,
..._commonAdvancedFields,
],
),
SingboxProxyProfileType.http: SingboxProxyFormSpec(
type: SingboxProxyProfileType.http,
outboundType: 'http',
fields: [
_serverField,
_serverPortField,
_usernameField,
_optionalPasswordField,
..._tlsFields,
..._commonAdvancedFields,
],
),
SingboxProxyProfileType.shadowsocks: SingboxProxyFormSpec(
type: SingboxProxyProfileType.shadowsocks,
outboundType: 'shadowsocks',
fields: [
_serverField,
_serverPortField,
SingboxProxyFormField(
key: 'method',
label: 'Method',
defaultValue: '2022-blake3-aes-128-gcm',
required: true,
),
_passwordField,
..._commonAdvancedFields,
],
),
SingboxProxyProfileType.vmess: SingboxProxyFormSpec(
type: SingboxProxyProfileType.vmess,
outboundType: 'vmess',
fields: [
_serverField,
_serverPortField,
_uuidField,
SingboxProxyFormField(
key: 'security',
label: 'Security',
defaultValue: 'auto',
),
SingboxProxyFormField(
key: 'alter_id',
label: 'Alter ID',
kind: SingboxFieldKind.integer,
),
..._v2rayAdvancedFields,
],
),
SingboxProxyProfileType.vless: SingboxProxyFormSpec(
type: SingboxProxyProfileType.vless,
outboundType: 'vless',
fields: [
_serverField,
_serverPortField,
_uuidField,
SingboxProxyFormField(key: 'flow', label: 'Flow'),
..._v2rayAdvancedFields,
],
),
SingboxProxyProfileType.trojan: SingboxProxyFormSpec(
type: SingboxProxyProfileType.trojan,
outboundType: 'trojan',
fields: [
_serverField,
_serverPortField,
_passwordField,
..._v2rayAdvancedFields,
],
),
SingboxProxyProfileType.naive: SingboxProxyFormSpec(
type: SingboxProxyProfileType.naive,
outboundType: 'naive',
fields: [
_serverField,
_serverPortField,
_usernameField,
_passwordField,
..._tlsFields,
..._commonAdvancedFields,
],
),
SingboxProxyProfileType.hysteria: SingboxProxyFormSpec(
type: SingboxProxyProfileType.hysteria,
outboundType: 'hysteria',
fields: [
_serverField,
_serverPortField,
SingboxProxyFormField(
key: 'auth_str',
label: 'Auth String',
kind: SingboxFieldKind.secret,
),
SingboxProxyFormField(key: 'up', label: 'Upload Bandwidth'),
SingboxProxyFormField(key: 'down', label: 'Download Bandwidth'),
SingboxProxyFormField(
key: 'obfs',
label: 'Obfuscation',
kind: SingboxFieldKind.secret,
),
SingboxProxyFormField(
key: 'recv_window_conn',
label: 'Receive Window Conn',
kind: SingboxFieldKind.integer,
),
SingboxProxyFormField(
key: 'recv_window',
label: 'Receive Window',
kind: SingboxFieldKind.integer,
),
SingboxProxyFormField(
key: 'disable_mtu_discovery',
label: 'Disable MTU Discovery',
helperText: 'true or false.',
kind: SingboxFieldKind.boolean,
),
..._tlsFields,
..._commonAdvancedFields,
],
),
SingboxProxyProfileType.hysteria2: SingboxProxyFormSpec(
type: SingboxProxyProfileType.hysteria2,
outboundType: 'hysteria2',
fields: [
_serverField,
_serverPortField,
_passwordField,
SingboxProxyFormField(
key: 'up_mbps',
label: 'Upload Mbps',
kind: SingboxFieldKind.integer,
),
SingboxProxyFormField(
key: 'down_mbps',
label: 'Download Mbps',
kind: SingboxFieldKind.integer,
),
SingboxProxyFormField(key: 'obfs.type', label: 'Obfuscation Type'),
SingboxProxyFormField(
key: 'obfs.password',
label: 'Obfuscation Password',
kind: SingboxFieldKind.secret,
),
..._tlsFields,
..._commonAdvancedFields,
],
),
SingboxProxyProfileType.tuic: SingboxProxyFormSpec(
type: SingboxProxyProfileType.tuic,
outboundType: 'tuic',
fields: [
_serverField,
_serverPortField,
_uuidField,
_passwordField,
SingboxProxyFormField(
key: 'congestion_control',
label: 'Congestion Control',
defaultValue: 'cubic',
),
SingboxProxyFormField(
key: 'udp_relay_mode',
label: 'UDP Relay Mode',
defaultValue: 'native',
),
SingboxProxyFormField(
key: 'zero_rtt_handshake',
label: 'Zero RTT Handshake',
helperText: 'true or false.',
kind: SingboxFieldKind.boolean,
),
..._tlsFields,
..._commonAdvancedFields,
],
),
SingboxProxyProfileType.ssh: SingboxProxyFormSpec(
type: SingboxProxyProfileType.ssh,
outboundType: 'ssh',
fields: [
_serverField,
_serverPortField,
SingboxProxyFormField(key: 'user', label: 'User', required: true),
_optionalPasswordField,
SingboxProxyFormField(
key: 'private_key',
label: 'Private Key',
kind: SingboxFieldKind.secret,
),
SingboxProxyFormField(
key: 'private_key_passphrase',
label: 'Private Key Passphrase',
kind: SingboxFieldKind.secret,
),
..._commonAdvancedFields,
],
),
SingboxProxyProfileType.wireguard: SingboxProxyFormSpec(
type: SingboxProxyProfileType.wireguard,
outboundType: 'wireguard',
fields: [
_serverField,
_serverPortField,
SingboxProxyFormField(
key: 'local_address',
label: 'Local Address',
helperText: 'One CIDR per line, for example 10.0.0.2/32.',
required: true,
kind: SingboxFieldKind.stringList,
),
SingboxProxyFormField(
key: 'peer_public_key',
label: 'Peer Public Key',
required: true,
),
SingboxProxyFormField(
key: 'private_key',
label: 'Private Key',
helperText: 'Stored in secure storage, not profile JSON.',
required: true,
kind: SingboxFieldKind.secret,
),
SingboxProxyFormField(
key: 'pre_shared_key',
label: 'Pre-shared Key',
helperText: 'Optional. Stored in secure storage.',
kind: SingboxFieldKind.secret,
),
SingboxProxyFormField(
key: 'mtu',
label: 'MTU',
helperText: 'Optional. sing-box uses 1408 by default.',
defaultValue: '1408',
kind: SingboxFieldKind.integer,
),
SingboxProxyFormField(
key: 'reserved',
label: 'Reserved Bytes',
helperText: 'Optional. Three comma-separated numbers, e.g. 0,0,0.',
kind: SingboxFieldKind.integerList,
exactListLength: 3,
minValue: 0,
maxValue: 255,
),
],
),
SingboxProxyProfileType.shadowTls: SingboxProxyFormSpec(
type: SingboxProxyProfileType.shadowTls,
outboundType: 'shadowtls',
fields: [
_serverField,
_serverPortField,
SingboxProxyFormField(
key: 'version',
label: 'Version',
defaultValue: '3',
kind: SingboxFieldKind.integer,
),
_passwordField,
..._tlsFields,
..._commonAdvancedFields,
],
),
SingboxProxyProfileType.anyTls: SingboxProxyFormSpec(
type: SingboxProxyProfileType.anyTls,
outboundType: 'anytls',
fields: [_serverField, _serverPortField, _passwordField, ..._dialFields],
),
};
@@ -0,0 +1,78 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
import 'package:flutter_tor/flutter_tor.dart';
enum ProxyLogSource { singBox, tor }
class ProxyLogMessage with FastEquatable {
final ProxyLogSource source;
final String level;
final String message;
final int timestamp;
final String? profileId;
ProxyLogMessage({
required this.source,
required this.level,
required this.message,
required this.timestamp,
this.profileId,
});
factory ProxyLogMessage.fromSingbox(SingboxProxyLogMessage message) {
return ProxyLogMessage(
source: ProxyLogSource.singBox,
level: message.level,
message: message.message,
timestamp: message.timestamp,
profileId: message.profileId,
);
}
factory ProxyLogMessage.fromTor(TorLogMessage message) {
return ProxyLogMessage(
source: ProxyLogSource.tor,
level: _torSeverityToLevel(message.severity),
message: message.message,
timestamp: message.timestamp,
);
}
@override
List<Object?> get hashParameters => [
source,
level,
message,
timestamp,
profileId,
];
}
String _torSeverityToLevel(String severity) {
return switch (severity.toUpperCase()) {
'ERR' => 'error',
'WARN' => 'warn',
'DEBUG' => 'debug',
'INFO' || 'NOTICE' => 'info',
_ => severity.toLowerCase(),
};
}
@@ -0,0 +1,44 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
/// Pre-filled state handed to the create-mode editor when the user reached it
/// through a guided method (file import, clipboard, QR, etc.).
///
/// Carries structured [values] for [SingboxProxyFormSpec]-driven types.
/// [dnsOverrideJson] is applied to the editor's DNS override section when
/// present.
class ProxyProfileSeed with FastEquatable {
final SingboxProxyProfileType type;
final String? name;
final Map<String, String> values;
final String? dnsOverrideJson;
ProxyProfileSeed({
required this.type,
this.name,
this.values = const {},
this.dnsOverrideJson,
});
@override
List<Object?> get hashParameters => [type, name, values, dnsOverrideJson];
}
@@ -0,0 +1,141 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:convert';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
/// Custom share-link scheme for round-tripping a WebLibre proxy profile
/// (including any secret config) between WebLibre installs. NOT cross-app
/// compatible — for that use the standard ss://, vless://, etc. URIs imported
/// from the editor (and exposed by future per-protocol exporters).
const weblibreProxyShareScheme = 'weblibre-proxy';
class ProxyShareEnvelope with FastEquatable {
/// Schema version — bumped if the wrapped JSON shape ever changes.
/// v1: name + type + config + secret.
/// v2: also carries dnsOverrideJson so per-profile DNS settings survive
/// share/import. Older v1 payloads stay readable (treated as no
/// override).
static const int currentVersion = 2;
final String name;
final SingboxProxyProfileType type;
final String configJson;
final String? secretJson;
final String? dnsOverrideJson;
ProxyShareEnvelope({
required this.name,
required this.type,
required this.configJson,
this.secretJson,
this.dnsOverrideJson,
});
@override
List<Object?> get hashParameters => [
name,
type,
configJson,
secretJson,
dnsOverrideJson,
];
}
/// Encodes the profile as `weblibre-proxy://<base64url(JSON)>`. The JSON body
/// is compact (no whitespace) to keep the URL short for QR rendering once
/// added.
String encodeProxyShareUri(ProxyShareEnvelope envelope) {
final body = <String, Object?>{
'v': ProxyShareEnvelope.currentVersion,
'name': envelope.name,
'type': envelope.type.name,
'config': jsonDecode(envelope.configJson),
if (envelope.secretJson != null && envelope.secretJson!.isNotEmpty)
'secret': jsonDecode(envelope.secretJson!),
if (envelope.dnsOverrideJson != null &&
envelope.dnsOverrideJson!.isNotEmpty)
'dnsOverride': jsonDecode(envelope.dnsOverrideJson!),
};
final encoded = base64UrlEncode(utf8.encode(jsonEncode(body)));
return '$weblibreProxyShareScheme://$encoded';
}
/// Decodes a `weblibre-proxy://...` share URI. Throws [FormatException] on
/// malformed input or version mismatch.
const _shareUriPrefix = '$weblibreProxyShareScheme://';
ProxyShareEnvelope decodeProxyShareUri(String rawUri) {
final trimmed = rawUri.trim();
if (!trimmed.startsWith(_shareUriPrefix)) {
throw const FormatException(
'Not a WebLibre proxy share URI (expected scheme $weblibreProxyShareScheme).',
);
}
final payload = trimmed.substring(_shareUriPrefix.length);
final List<int> bytes;
try {
bytes = base64Url.decode(base64Url.normalize(payload));
} on FormatException {
throw const FormatException('Share URI payload is not valid base64url.');
}
final dynamic decoded;
try {
decoded = jsonDecode(utf8.decode(bytes));
} on FormatException {
throw const FormatException('Share URI payload is not valid JSON.');
}
if (decoded is! Map<String, Object?>) {
throw const FormatException('Share URI payload must be a JSON object.');
}
final version = decoded['v'];
if (version is! int ||
version < 1 ||
version > ProxyShareEnvelope.currentVersion) {
throw FormatException('Unsupported share URI version: $version.');
}
final name = decoded['name'];
final typeName = decoded['type'];
final config = decoded['config'];
final secret = decoded['secret'];
final dnsOverride = decoded['dnsOverride'];
if (name is! String || typeName is! String || config == null) {
throw const FormatException('Share URI is missing required fields.');
}
final type = SingboxProxyProfileType.values
.where((value) => value.name == typeName)
.firstOrNull;
if (type == null) {
throw FormatException('Unknown profile type: $typeName.');
}
return ProxyShareEnvelope(
name: name,
type: type,
configJson: jsonEncode(config),
secretJson: secret == null ? null : jsonEncode(secret),
dnsOverrideJson: dnsOverride == null ? null : jsonEncode(dnsOverride),
);
}
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
show ProxyProfile;
extension ProxyProfileExt on ProxyProfile {
ProxyConnectionId get proxyConnection => SingboxProxyConnectionId(id);
String get proxyConnectionId => proxyConnection.encode();
SingboxProxyProfile toRuntimeProfile({String? secretJson}) {
return SingboxProxyProfile(
id: proxyConnectionId,
name: name,
type: type,
configJson: configJson,
secretJson: secretJson,
);
}
}
@@ -0,0 +1,153 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:convert';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:weblibre/features/proxy/data/parsers/host_port.dart';
class WireguardConfigImport with FastEquatable {
/// Values that seed the shared [SingboxProxyFormSpec]-driven WireGuard form.
final Map<String, String> values;
/// Newline-separated DNS servers parsed from the WireGuard `[Interface] DNS`
/// line. Not round-tripped through sing-box outbound JSON because DNS lives
/// in the top-level `dns` block and is surfaced as a per-profile override.
final String dns;
WireguardConfigImport({required this.values, this.dns = ''});
@override
List<Object?> get hashParameters => [values, dns];
factory WireguardConfigImport.fromConfigText(String configText) {
final sections = _parseWireguardConfig(configText);
final interface = sections['interface'];
final peer = sections['peer'];
if (interface == null || peer == null) {
throw const FormatException(
'WireGuard config must contain [Interface] and [Peer] sections.',
);
}
final rawEndpoint = (peer['endpoint'] ?? '').trim();
final endpoint = rawEndpoint.isEmpty
? (host: '', port: '')
: parseHostPort(
rawEndpoint,
invalidMessage: 'WireGuard endpoint must be host:port.',
);
final mtu = interface['mtu']?.trim();
return WireguardConfigImport(
values: {
'server': endpoint.host,
'server_port': endpoint.port,
'local_address': _wireguardListValue(interface['address']),
'private_key': interface['privatekey']?.trim() ?? '',
'peer_public_key': peer['publickey']?.trim() ?? '',
'pre_shared_key': peer['presharedkey']?.trim() ?? '',
'mtu': mtu == null || mtu.isEmpty ? '1408' : mtu,
},
dns: _wireguardListValue(interface['dns']),
);
}
/// First parsed DNS entry as a sing-box-compatible address, or null when
/// the imported config had no `DNS = …` line. Bare IPs become `udp://<ip>`
/// (sing-box's plain-UDP scheme); anything already containing `://` is kept
/// verbatim so users can paste `https://…/dns-query` etc.
String? get primaryDnsAddress {
final entries = _splitList(dns);
if (entries.isEmpty) return null;
final first = entries.first;
if (first.contains('://')) return first;
return 'udp://$first';
}
}
List<String> _splitList(String value) {
return value
.replaceAll('[', '')
.replaceAll(']', '')
.split(RegExp(r'[\n,]'))
.map((item) => item.trim())
.where((item) => item.isNotEmpty)
.toList();
}
Map<String, Map<String, String>> _parseWireguardConfig(String configText) {
final sections = <String, Map<String, String>>{};
String? currentSection;
for (final rawLine in const LineSplitter().convert(configText)) {
final line = _stripWireguardComment(rawLine).trim();
if (line.isEmpty) continue;
if (line.startsWith('[') && line.endsWith(']')) {
currentSection = line.substring(1, line.length - 1).trim().toLowerCase();
if (currentSection.isEmpty) {
throw const FormatException('WireGuard config contains empty section.');
}
sections.putIfAbsent(currentSection, () => <String, String>{});
continue;
}
if (currentSection == null) {
throw const FormatException(
'WireGuard config entries must be inside a section.',
);
}
final separatorIndex = line.indexOf('=');
if (separatorIndex < 1) {
throw FormatException('Invalid WireGuard config line: $rawLine');
}
final key = line.substring(0, separatorIndex).trim().toLowerCase();
final value = line.substring(separatorIndex + 1).trim();
if (key.isEmpty) {
throw FormatException('Invalid WireGuard config line: $rawLine');
}
sections[currentSection]![key] = value;
}
return sections;
}
String _stripWireguardComment(String line) {
final hashIndex = line.indexOf('#');
final semicolonIndex = line.indexOf(';');
final indexes = [
if (hashIndex >= 0) hashIndex,
if (semicolonIndex >= 0) semicolonIndex,
];
if (indexes.isEmpty) return line;
indexes.sort();
return line.substring(0, indexes.first);
}
String _wireguardListValue(String? value) {
if (value == null) return '';
return value
.split(',')
.map((item) => item.trim())
.where((item) => item.isNotEmpty)
.join('\n');
}
@@ -0,0 +1,30 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:convert';
/// Decodes URL-safe or standard base64 text, tolerating missing padding.
String decodeBase64Text(String value) {
final normalized = value.replaceAll('-', '+').replaceAll('_', '/');
final padded = normalized.padRight(
normalized.length + (4 - normalized.length % 4) % 4,
'=',
);
return utf8.decode(base64Decode(padded));
}
@@ -0,0 +1,56 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
typedef HostPort = ({String host, String port});
/// Parses a `host:port` endpoint. Accepts bracketed IPv6 syntax
/// (`[2001:db8::1]:443`). Throws [FormatException] on invalid input.
HostPort parseHostPort(
String rawEndpoint, {
String invalidMessage = 'Endpoint must be host:port.',
}) {
final endpoint = rawEndpoint.trim();
if (endpoint.startsWith('[')) {
final closingIndex = endpoint.indexOf(']');
if (closingIndex < 0 || closingIndex == endpoint.length - 1) {
throw FormatException(invalidMessage);
}
final remainder = endpoint.substring(closingIndex + 1);
if (!remainder.startsWith(':')) {
throw FormatException(invalidMessage);
}
return (
host: endpoint.substring(1, closingIndex),
port: remainder.substring(1),
);
}
final separatorIndex = endpoint.lastIndexOf(':');
if (separatorIndex <= 0 || separatorIndex == endpoint.length - 1) {
throw FormatException(invalidMessage);
}
final host = endpoint.substring(0, separatorIndex);
if (host.contains(':')) {
throw const FormatException(
'IPv6 endpoints must use [address]:port syntax.',
);
}
return (host: host, port: endpoint.substring(separatorIndex + 1));
}
@@ -0,0 +1,320 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:convert';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
import 'package:weblibre/features/proxy/data/parsers/base64_text.dart';
import 'package:weblibre/features/proxy/data/parsers/host_port.dart';
/// Form-spec-shaped result of parsing a single proxy URI (ss://, vless://, …).
class SingboxProxyUriImport with FastEquatable {
final SingboxProxyProfileType type;
final String? name;
final Map<String, String> values;
SingboxProxyUriImport({required this.type, required this.values, this.name});
@override
List<Object?> get hashParameters => [type, name, values];
}
/// Dispatches on the URI scheme and delegates to the matching importer.
/// Throws [FormatException] for unknown or malformed schemes.
SingboxProxyUriImport importSingboxProxyUri(String rawUri) {
final trimmedUri = rawUri.trim();
final schemeEnd = trimmedUri.indexOf('://');
if (schemeEnd <= 0) {
throw const FormatException('Proxy URI must include a scheme.');
}
final scheme = trimmedUri.substring(0, schemeEnd).toLowerCase();
return switch (scheme) {
'ss' => _importShadowsocksUri(trimmedUri),
'socks' || 'socks5' => _importSocksUri(trimmedUri),
'http' || 'https' => _importHttpUri(trimmedUri),
'trojan' => _importTrojanUri(trimmedUri),
'vless' => _importVlessUri(trimmedUri),
'vmess' => _importVmessUri(trimmedUri),
'hysteria2' || 'hy2' => _importHysteria2Uri(trimmedUri),
'tuic' => _importTuicUri(trimmedUri),
_ => throw FormatException('Unsupported proxy URI scheme: $scheme'),
};
}
SingboxProxyUriImport _importShadowsocksUri(String rawUri) {
final payload = rawUri.substring('ss://'.length);
final fragmentIndex = payload.indexOf('#');
final withoutFragment = fragmentIndex >= 0
? payload.substring(0, fragmentIndex)
: payload;
final name = fragmentIndex >= 0
? Uri.decodeComponent(payload.substring(fragmentIndex + 1))
: null;
final withoutQuery = withoutFragment.split('?').first;
final decodedPayload = withoutQuery.contains('@')
? withoutQuery
: decodeBase64Text(withoutQuery);
final atIndex = decodedPayload.lastIndexOf('@');
if (atIndex <= 0 || atIndex == decodedPayload.length - 1) {
throw const FormatException('Shadowsocks URI must include credentials.');
}
final rawCredentials = decodedPayload.substring(0, atIndex);
final credentials = rawCredentials.contains(':')
? rawCredentials
: decodeBase64Text(rawCredentials);
final credentialSeparator = credentials.indexOf(':');
if (credentialSeparator <= 0) {
throw const FormatException(
'Shadowsocks URI credentials must be method:password.',
);
}
final endpoint = parseHostPort(
decodedPayload.substring(atIndex + 1),
invalidMessage: 'Proxy URI endpoint must be host:port.',
);
return SingboxProxyUriImport(
type: SingboxProxyProfileType.shadowsocks,
name: _nonEmptyName(name),
values: {
'server': endpoint.host,
'server_port': endpoint.port,
'method': Uri.decodeComponent(
credentials.substring(0, credentialSeparator),
),
'password': Uri.decodeComponent(
credentials.substring(credentialSeparator + 1),
),
},
);
}
SingboxProxyUriImport _importTrojanUri(String rawUri) {
final uri = Uri.parse(rawUri);
final endpoint = _uriEndpoint(uri, 'Trojan');
return SingboxProxyUriImport(
type: SingboxProxyProfileType.trojan,
name: _uriName(uri),
values: {
'server': endpoint.host,
'server_port': endpoint.port,
'password': Uri.decodeComponent(uri.userInfo),
'tls.enabled': 'true',
..._tlsImportValues(uri),
},
);
}
SingboxProxyUriImport _importVlessUri(String rawUri) {
final uri = Uri.parse(rawUri);
final endpoint = _uriEndpoint(uri, 'VLESS');
return SingboxProxyUriImport(
type: SingboxProxyProfileType.vless,
name: _uriName(uri),
values: {
'server': endpoint.host,
'server_port': endpoint.port,
'uuid': Uri.decodeComponent(uri.userInfo),
'flow': uri.queryParameters['flow'] ?? '',
..._tlsImportValues(uri),
},
);
}
SingboxProxyUriImport _importSocksUri(String rawUri) {
final uri = Uri.parse(rawUri);
final endpoint = _uriHostPort(uri, 'SOCKS');
final userInfo = _splitUserInfo(uri.userInfo);
return SingboxProxyUriImport(
type: SingboxProxyProfileType.socks,
name: _uriName(uri),
values: {
'server': endpoint.host,
'server_port': endpoint.port,
'version': '5',
'username': userInfo.username,
'password': userInfo.password,
},
);
}
SingboxProxyUriImport _importHttpUri(String rawUri) {
final uri = Uri.parse(rawUri);
final endpoint = _uriHostPort(uri, 'HTTP');
final userInfo = _splitUserInfo(uri.userInfo);
return SingboxProxyUriImport(
type: SingboxProxyProfileType.http,
name: _uriName(uri),
values: {
'server': endpoint.host,
'server_port': endpoint.port,
'username': userInfo.username,
'password': userInfo.password,
if (uri.scheme.toLowerCase() == 'https') 'tls.enabled': 'true',
..._tlsImportValues(uri),
},
);
}
SingboxProxyUriImport _importHysteria2Uri(String rawUri) {
final uri = Uri.parse(rawUri);
final endpoint = _uriEndpoint(uri, 'Hysteria2');
final query = uri.queryParameters;
return SingboxProxyUriImport(
type: SingboxProxyProfileType.hysteria2,
name: _uriName(uri),
values: {
'server': endpoint.host,
'server_port': endpoint.port,
'password': Uri.decodeComponent(uri.userInfo),
'obfs.type': query['obfs'] ?? '',
'obfs.password': query['obfs-password'] ?? query['obfs_password'] ?? '',
'tls.enabled': 'true',
..._tlsImportValues(uri),
},
);
}
SingboxProxyUriImport _importTuicUri(String rawUri) {
final uri = Uri.parse(rawUri);
final endpoint = _uriEndpoint(uri, 'TUIC');
final userInfo = _splitUserInfo(uri.userInfo);
final query = uri.queryParameters;
return SingboxProxyUriImport(
type: SingboxProxyProfileType.tuic,
name: _uriName(uri),
values: {
'server': endpoint.host,
'server_port': endpoint.port,
'uuid': userInfo.username,
'password': userInfo.password,
'congestion_control':
query['congestion_control'] ?? query['congestion'] ?? 'cubic',
'udp_relay_mode': query['udp_relay_mode'] ?? 'native',
'tls.enabled': 'true',
..._tlsImportValues(uri),
},
);
}
SingboxProxyUriImport _importVmessUri(String rawUri) {
final payload = rawUri.substring('vmess://'.length).split('#').first.trim();
final decoded = jsonDecode(decodeBase64Text(payload)) as Object?;
if (decoded is! Map<String, dynamic>) {
throw const FormatException('VMess URI payload must be a JSON object.');
}
final server = _stringValue(decoded['add'], '');
final port = _stringValue(decoded['port'], '');
final uuid = _stringValue(decoded['id'], '');
if (server.isEmpty || port.isEmpty || uuid.isEmpty) {
throw const FormatException('VMess URI must include add, port, and id.');
}
return SingboxProxyUriImport(
type: SingboxProxyProfileType.vmess,
name: _nonEmptyName(_stringValue(decoded['ps'], '')),
values: {
'server': server,
'server_port': port,
'uuid': uuid,
'security': _stringValue(decoded['scy'], 'auto'),
'alter_id': _stringValue(decoded['aid'], ''),
'tls.enabled': _stringValue(decoded['tls'], '') == 'tls' ? 'true' : '',
'tls.server_name': _stringValue(decoded['sni'], ''),
'transport.type': _stringValue(decoded['net'], ''),
'transport.path': _stringValue(decoded['path'], ''),
},
);
}
HostPort _uriEndpoint(Uri uri, String label) {
final endpoint = _uriHostPort(uri, label);
if (uri.userInfo.isEmpty) {
throw FormatException('$label URI must include credentials.');
}
return endpoint;
}
HostPort _uriHostPort(Uri uri, String label) {
final hasExplicitOrDefaultPort = uri.hasPort || uri.port > 0;
if (uri.host.isEmpty || !hasExplicitOrDefaultPort) {
throw FormatException('$label URI must include host and port.');
}
return (host: uri.host, port: uri.port.toString());
}
String? _uriName(Uri uri) {
if (!uri.hasFragment) return null;
return _nonEmptyName(Uri.decodeComponent(uri.fragment));
}
String? _nonEmptyName(String? name) {
final trimmed = name?.trim();
if (trimmed == null || trimmed.isEmpty) return null;
return trimmed;
}
({String username, String password}) _splitUserInfo(String userInfo) {
if (userInfo.isEmpty) return (username: '', password: '');
final separatorIndex = userInfo.indexOf(':');
if (separatorIndex < 0) {
return (username: Uri.decodeComponent(userInfo), password: '');
}
return (
username: Uri.decodeComponent(userInfo.substring(0, separatorIndex)),
password: Uri.decodeComponent(userInfo.substring(separatorIndex + 1)),
);
}
Map<String, String> _tlsImportValues(Uri uri) {
final query = uri.queryParameters;
final values = <String, String>{};
final security = query['security'] ?? query['tls'];
if (security == 'tls' || security == '1' || security == 'true') {
values['tls.enabled'] = 'true';
}
final serverName = query['sni'] ?? query['peer'] ?? query['server_name'];
if (serverName != null && serverName.isNotEmpty) {
values['tls.server_name'] = serverName;
}
final insecure =
query['allowInsecure'] ?? query['allow_insecure'] ?? query['insecure'];
if (insecure != null && insecure.isNotEmpty) {
values['tls.insecure'] = insecure;
}
final alpn = query['alpn'];
if (alpn != null && alpn.isNotEmpty) values['tls.alpn'] = alpn;
return values;
}
String _stringValue(Object? value, String fallback) {
if (value == null) return fallback;
return value.toString();
}
@@ -0,0 +1,74 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
sealed class ProxyConnectionId {
const ProxyConnectionId();
String encode();
static ProxyConnectionId? decode(String? raw) {
if (raw == null) return null;
if (raw == TorProxyConnectionId.encoded) {
return const TorProxyConnectionId();
}
if (raw.startsWith(SingboxProxyConnectionId._prefix)) {
final profileId = raw.substring(SingboxProxyConnectionId._prefix.length);
if (profileId.isEmpty) return null;
return SingboxProxyConnectionId(profileId);
}
return null;
}
}
final class TorProxyConnectionId extends ProxyConnectionId {
static const String encoded = 'tor';
const TorProxyConnectionId();
@override
String encode() => encoded;
@override
bool operator ==(Object other) => other is TorProxyConnectionId;
@override
int get hashCode => encoded.hashCode;
}
final class SingboxProxyConnectionId extends ProxyConnectionId {
static const String _prefix = 'singbox:';
final String profileId;
const SingboxProxyConnectionId(this.profileId);
@override
String encode() => '$_prefix$profileId';
@override
bool operator ==(Object other) {
return other is SingboxProxyConnectionId && other.profileId == profileId;
}
@override
int get hashCode => Object.hash(_prefix, profileId);
}