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);
}
@@ -0,0 +1,60 @@
/*
* 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';
extension SingboxProxyProfileTypeExt on SingboxProxyProfileType {
/// Long human label for menus, dialogs, subtitles.
String get label => switch (this) {
SingboxProxyProfileType.socks => 'SOCKS',
SingboxProxyProfileType.http => 'HTTP',
SingboxProxyProfileType.shadowsocks => 'Shadowsocks',
SingboxProxyProfileType.vmess => 'VMess',
SingboxProxyProfileType.vless => 'VLESS',
SingboxProxyProfileType.trojan => 'Trojan',
SingboxProxyProfileType.naive => 'Naive',
SingboxProxyProfileType.hysteria => 'Hysteria',
SingboxProxyProfileType.hysteria2 => 'Hysteria2',
SingboxProxyProfileType.tuic => 'TUIC',
SingboxProxyProfileType.ssh => 'SSH',
SingboxProxyProfileType.wireguard => 'WireGuard',
SingboxProxyProfileType.shadowTls => 'ShadowTLS',
SingboxProxyProfileType.anyTls => 'AnyTLS',
SingboxProxyProfileType.customOutbound => 'Custom Outbound',
};
/// Short 2-5 char protocol abbreviation for the profile-list badge.
String get badge => switch (this) {
SingboxProxyProfileType.socks => 'SOCKS',
SingboxProxyProfileType.http => 'HTTP',
SingboxProxyProfileType.shadowsocks => 'SS',
SingboxProxyProfileType.vmess => 'VMESS',
SingboxProxyProfileType.vless => 'VLESS',
SingboxProxyProfileType.trojan => 'TRJ',
SingboxProxyProfileType.naive => 'NAIVE',
SingboxProxyProfileType.hysteria => 'HY1',
SingboxProxyProfileType.hysteria2 => 'HY2',
SingboxProxyProfileType.tuic => 'TUIC',
SingboxProxyProfileType.ssh => 'SSH',
SingboxProxyProfileType.wireguard => 'WG',
SingboxProxyProfileType.shadowTls => 'STLS',
SingboxProxyProfileType.anyTls => 'ATLS',
SingboxProxyProfileType.customOutbound => 'JSON',
};
}
@@ -0,0 +1,60 @@
/*
* 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:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
import 'package:weblibre/features/proxy/data/models/singbox_proxy_profile.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart';
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
show ProxyProfile;
import 'package:weblibre/features/user/data/models/proxy_routing_settings.dart';
import 'package:weblibre/features/user/domain/repositories/proxy_routing_settings.dart';
part 'assigned_proxy_profiles.g.dart';
/// Returns sing-box proxy profiles that have at least one routing assignment:
/// global regular-tab routing, private-tab routing, or any container.
@Riverpod(keepAlive: true)
List<ProxyProfile> assignedSingboxProxyProfiles(Ref ref) {
final profiles =
ref.watch(singboxProxyProfilesRepositoryProvider).value ?? const [];
if (profiles.isEmpty) return const [];
final routing = ref.watch(proxyRoutingSettingsWithDefaultsProvider);
final containers =
ref.watch(watchContainersWithCountProvider).value ?? const [];
final assignedConnectionIds = <String>{
if (routing.regularTabsMode == ProxyRegularTabRoutingMode.all &&
routing.regularTabsProxyConnectionId != null)
routing.regularTabsProxyConnectionId!.encode(),
if (routing.privateTabsProxyConnectionId != null)
routing.privateTabsProxyConnectionId!.encode(),
for (final container in containers)
if (container.metadata.proxyConnectionId != null)
container.metadata.proxyConnectionId!.encode(),
};
if (assignedConnectionIds.isEmpty) return const [];
return [
for (final profile in profiles)
if (assignedConnectionIds.contains(profile.proxyConnectionId)) profile,
];
}
@@ -0,0 +1,66 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'assigned_proxy_profiles.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Returns sing-box proxy profiles that have at least one routing assignment:
/// global regular-tab routing, private-tab routing, or any container.
@ProviderFor(assignedSingboxProxyProfiles)
final assignedSingboxProxyProfilesProvider =
AssignedSingboxProxyProfilesProvider._();
/// Returns sing-box proxy profiles that have at least one routing assignment:
/// global regular-tab routing, private-tab routing, or any container.
final class AssignedSingboxProxyProfilesProvider
extends
$FunctionalProvider<
List<ProxyProfile>,
List<ProxyProfile>,
List<ProxyProfile>
>
with $Provider<List<ProxyProfile>> {
/// Returns sing-box proxy profiles that have at least one routing assignment:
/// global regular-tab routing, private-tab routing, or any container.
AssignedSingboxProxyProfilesProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'assignedSingboxProxyProfilesProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$assignedSingboxProxyProfilesHash();
@$internal
@override
$ProviderElement<List<ProxyProfile>> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
List<ProxyProfile> create(Ref ref) {
return assignedSingboxProxyProfiles(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(List<ProxyProfile> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<List<ProxyProfile>>(value),
);
}
}
String _$assignedSingboxProxyProfilesHash() =>
r'dbc5c429a3b5e4bff902023d363e568c2f955fc4';
@@ -0,0 +1,85 @@
/*
* 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:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/proxy/data/models/singbox_proxy_profile.dart';
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
import 'package:weblibre/features/proxy/domain/extensions/singbox_proxy_profile_type_x.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart';
part 'proxy_connection_options.g.dart';
class ProxyConnectionOption with FastEquatable {
final ProxyConnectionId id;
final String title;
final String subtitle;
ProxyConnectionOption({
required this.id,
required this.title,
required this.subtitle,
});
@override
List<Object?> get hashParameters => [id, title, subtitle];
}
@Riverpod(keepAlive: true)
List<ProxyConnectionOption> proxyConnectionOptions(Ref ref) {
final profilesAsync = ref.watch(singboxProxyProfilesRepositoryProvider);
profilesAsync.whenOrNull(
error: (error, stackTrace) => logger.e(
'Failed to load sing-box proxy profiles for connection picker',
error: error,
stackTrace: stackTrace,
),
);
final singboxProfiles = profilesAsync.value ?? const [];
return [
ProxyConnectionOption(
id: const TorProxyConnectionId(),
title: 'Tor',
subtitle: 'Route through the Tor network',
),
for (final profile in singboxProfiles)
ProxyConnectionOption(
id: profile.proxyConnection,
title: profile.name,
subtitle: profile.type.label,
),
];
}
String proxyConnectionTitle(
List<ProxyConnectionOption> options,
ProxyConnectionId proxyConnectionId,
) {
for (final option in options) {
if (option.id == proxyConnectionId) {
return option.title;
}
}
return 'Unknown proxy';
}
@@ -0,0 +1,58 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'proxy_connection_options.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(proxyConnectionOptions)
final proxyConnectionOptionsProvider = ProxyConnectionOptionsProvider._();
final class ProxyConnectionOptionsProvider
extends
$FunctionalProvider<
List<ProxyConnectionOption>,
List<ProxyConnectionOption>,
List<ProxyConnectionOption>
>
with $Provider<List<ProxyConnectionOption>> {
ProxyConnectionOptionsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'proxyConnectionOptionsProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$proxyConnectionOptionsHash();
@$internal
@override
$ProviderElement<List<ProxyConnectionOption>> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
List<ProxyConnectionOption> create(Ref ref) {
return proxyConnectionOptions(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(List<ProxyConnectionOption> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<List<ProxyConnectionOption>>(value),
);
}
}
String _$proxyConnectionOptionsHash() =>
r'7832968f74acd98189110cf926208e694a20d55a';
@@ -0,0 +1,148 @@
/*
* 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:async';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:synchronized/synchronized.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/site_assignment.dart';
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
part 'container_proxy.g.dart';
/// Slower-than-default healthcheck timeout for the Tor SOCKS port. Tor's
/// bootstrap can stall when the service is starting up before the proxy
/// service is fully ready; we accept a longer wait here than for other ops.
const _torHealthcheckTimeout = Duration(seconds: 30);
/// Default healthcheck timeout for ordinary proxy CRUD operations.
const _defaultHealthcheckTimeout = Duration(seconds: 10);
/// Outer cap on a single healthcheck wait. Exceeded → [TimeoutException].
const _maxHealthcheckTimeout = Duration(seconds: 15);
const _healthcheckInitialDelay = Duration(milliseconds: 50);
const _healthcheckMaxDelay = Duration(milliseconds: 500);
@Riverpod(keepAlive: true)
class ContainerProxyRepository extends _$ContainerProxyRepository {
final _service = GeckoContainerProxyService();
final _serviceLock = Lock();
Future<void> setTorProxyPort(int? port) {
return _runLocked(
healthcheckTimeout: _torHealthcheckTimeout,
body: () {
if (port == null) {
return _service.removeProxy(const TorProxyConnectionId().encode());
}
return _service.upsertProxy(
GeckoProxySettings(
id: const TorProxyConnectionId().encode(),
title: 'Tor',
type: 'socks',
host: '127.0.0.1',
port: port,
proxyDNS: true,
doNotProxyLocal: true,
),
);
},
);
}
Future<void> upsertProxy(GeckoProxySettings proxy) {
return _runLocked(body: () => _service.upsertProxy(proxy));
}
Future<void> removeProxy(String proxyId) {
return _runLocked(body: () => _service.removeProxy(proxyId));
}
Future<void> setContainerProxy(String contextId, String proxyId) {
return _runLocked(
body: () => _service.setContainerProxy(contextId, proxyId),
);
}
Future<void> clearContainerProxy(String contextId) {
return _runLocked(body: () => _service.clearContainerProxy(contextId));
}
Future<void> removeContainerProxyRelation(String contextId, String proxyId) {
return _runLocked(
body: () => _service.removeContainerProxyRelation(contextId, proxyId),
);
}
Future<void> setSiteAssignments(List<SiteAssignment> assignements) {
return _runLocked(
body: () => _service.setSiteAssignments(
Map.fromEntries(
assignements.map(
(e) => MapEntry(
e.assignedSite.origin,
e.contextualIdentity ?? 'general',
),
),
),
),
);
}
/// Serialises [body] behind the service lock after the underlying Gecko
/// proxy service reports healthy. Replaces the per-method boilerplate that
/// each public mutator used to repeat.
Future<T> _runLocked<T>({
required Future<T> Function() body,
Duration healthcheckTimeout = _defaultHealthcheckTimeout,
}) {
return _serviceLock.synchronized(() async {
await _waitHealthcheck().timeout(healthcheckTimeout);
return body();
});
}
/// Polls the Gecko proxy service with exponential backoff (capped) until it
/// reports healthy. The previous implementation polled every 25ms, which
/// burns CPU during the cold-start window before the native plugin is ready.
Future<void> _waitHealthcheck({
Duration timeout = _maxHealthcheckTimeout,
}) async {
final startTime = DateTime.now();
var delay = _healthcheckInitialDelay;
while (!await _service.healthcheck()) {
if (DateTime.now().difference(startTime) > timeout) {
throw TimeoutException('Timed out waiting for proxy service');
}
await Future<void>.delayed(delay);
delay = delay * 2;
if (delay > _healthcheckMaxDelay) delay = _healthcheckMaxDelay;
}
}
@override
void build() {
return;
}
}
@@ -0,0 +1,63 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'container_proxy.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(ContainerProxyRepository)
final containerProxyRepositoryProvider = ContainerProxyRepositoryProvider._();
final class ContainerProxyRepositoryProvider
extends $NotifierProvider<ContainerProxyRepository, void> {
ContainerProxyRepositoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'containerProxyRepositoryProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$containerProxyRepositoryHash();
@$internal
@override
ContainerProxyRepository create() => ContainerProxyRepository();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(void value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<void>(value),
);
}
}
String _$containerProxyRepositoryHash() =>
r'08cd408a3ae96c859ed5c9d56d61cf7e6514415f';
abstract class _$ContainerProxyRepository extends $Notifier<void> {
void build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<void, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<void, void>,
void,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,53 @@
/*
* 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_secure_storage/flutter_secure_storage.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'singbox_proxy_credentials.g.dart';
@Riverpod(keepAlive: true)
class SingboxProxyCredentialsRepository
extends _$SingboxProxyCredentialsRepository {
static const _storage = FlutterSecureStorage();
String _secretKey(String profileId) => 'singbox_proxy.secret.$profileId';
Future<String?> readSecretJson(String profileId) {
return _storage.read(key: _secretKey(profileId));
}
Future<void> writeSecretJson(String profileId, String? secretJson) async {
if (secretJson == null || secretJson.trim().isEmpty) {
await deleteSecretJson(profileId);
return;
}
await _storage.write(key: _secretKey(profileId), value: secretJson);
}
Future<void> deleteSecretJson(String profileId) {
return _storage.delete(key: _secretKey(profileId));
}
@override
void build() {
return;
}
}
@@ -0,0 +1,66 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'singbox_proxy_credentials.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(SingboxProxyCredentialsRepository)
final singboxProxyCredentialsRepositoryProvider =
SingboxProxyCredentialsRepositoryProvider._();
final class SingboxProxyCredentialsRepositoryProvider
extends $NotifierProvider<SingboxProxyCredentialsRepository, void> {
SingboxProxyCredentialsRepositoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'singboxProxyCredentialsRepositoryProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() =>
_$singboxProxyCredentialsRepositoryHash();
@$internal
@override
SingboxProxyCredentialsRepository create() =>
SingboxProxyCredentialsRepository();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(void value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<void>(value),
);
}
}
String _$singboxProxyCredentialsRepositoryHash() =>
r'b4e11b001ccccfbf26417963adf6cb81e1b1e69f';
abstract class _$SingboxProxyCredentialsRepository extends $Notifier<void> {
void build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<void, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<void, void>,
void,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -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 'dart:async';
import 'dart:collection';
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
import 'package:flutter_tor/flutter_tor.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/proxy/data/models/proxy_log_message.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart';
import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
part 'singbox_proxy_logs.g.dart';
/// Cap to keep memory bounded. ~2KB per line × 2000 = ~4MB worst-case, which
/// is well within budget for a debugging surface.
const int _ringBufferCapacity = 2000;
/// Snapshot of buffered log entries. Most-recent-last (chronological).
@Riverpod(keepAlive: true)
class SingboxProxyLogs extends _$SingboxProxyLogs {
final _buffer = Queue<ProxyLogMessage>();
StreamSubscription<SingboxProxyLogMessage>? _singboxSubscription;
StreamSubscription<TorLogMessage>? _torSubscription;
void _append(ProxyLogMessage message) {
_buffer.add(message);
while (_buffer.length > _ringBufferCapacity) {
_buffer.removeFirst();
}
state = List.unmodifiable(_buffer);
}
void clear() {
_buffer.clear();
state = const [];
}
@override
List<ProxyLogMessage> build() {
final client = ref.watch(singboxProxyClientProvider);
final torLogs = torLogStream(ref);
_singboxSubscription = client.logStream.listen(
(message) => _append(ProxyLogMessage.fromSingbox(message)),
);
_torSubscription = torLogs.listen(
(message) => _append(ProxyLogMessage.fromTor(message)),
);
ref.onDispose(() {
unawaited(_singboxSubscription?.cancel());
unawaited(_torSubscription?.cancel());
});
return List.unmodifiable(_buffer);
}
}
@@ -0,0 +1,67 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'singbox_proxy_logs.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Snapshot of buffered log entries. Most-recent-last (chronological).
@ProviderFor(SingboxProxyLogs)
final singboxProxyLogsProvider = SingboxProxyLogsProvider._();
/// Snapshot of buffered log entries. Most-recent-last (chronological).
final class SingboxProxyLogsProvider
extends $NotifierProvider<SingboxProxyLogs, List<ProxyLogMessage>> {
/// Snapshot of buffered log entries. Most-recent-last (chronological).
SingboxProxyLogsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'singboxProxyLogsProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$singboxProxyLogsHash();
@$internal
@override
SingboxProxyLogs create() => SingboxProxyLogs();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(List<ProxyLogMessage> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<List<ProxyLogMessage>>(value),
);
}
}
String _$singboxProxyLogsHash() => r'9fa30201ed4c128335142227226d937022f79d47';
/// Snapshot of buffered log entries. Most-recent-last (chronological).
abstract class _$SingboxProxyLogs extends $Notifier<List<ProxyLogMessage>> {
List<ProxyLogMessage> build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<List<ProxyLogMessage>, List<ProxyLogMessage>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<List<ProxyLogMessage>, List<ProxyLogMessage>>,
List<ProxyLogMessage>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,112 @@
/*
* 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:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/uuid.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_credentials.dart';
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
show ProxyProfile;
import 'package:weblibre/features/user/data/providers.dart';
part 'singbox_proxy_profiles.g.dart';
@Riverpod(keepAlive: true)
class SingboxProxyProfilesRepository extends _$SingboxProxyProfilesRepository {
Future<List<ProxyProfile>> fetchProfiles() {
return ref.read(userDatabaseProvider).proxyProfileDao.fetchAll();
}
Future<ProxyProfile?> findProfile(String id) {
return ref.read(userDatabaseProvider).proxyProfileDao.findById(id);
}
Future<ProxyProfile> createProfile({
required String name,
required SingboxProxyProfileType type,
required String configJson,
String? secretJson,
String? dnsOverrideJson,
}) async {
final now = DateTime.now();
final profile = ProxyProfile(
id: uuid.v4(),
name: name,
type: type,
configJson: configJson,
dnsOverrideJson: dnsOverrideJson,
createdAt: now,
updatedAt: now,
);
await ref.read(userDatabaseProvider).proxyProfileDao.upsert(profile);
await ref
.read(singboxProxyCredentialsRepositoryProvider.notifier)
.writeSecretJson(profile.id, secretJson);
return profile;
}
/// Updates an existing profile, bumping `updatedAt` only when the persisted
/// row actually changed. Pass [secretJson] (possibly null) to replace the
/// stored secret; pass [updateSecret] = false to leave secrets untouched.
Future<void> updateProfile(ProxyProfile profile, {String? secretJson}) async {
final dao = ref.read(userDatabaseProvider).proxyProfileDao;
final existing = await dao.findById(profile.id);
final contentChanged =
existing == null ||
existing.name != profile.name ||
existing.type != profile.type ||
existing.configJson != profile.configJson ||
existing.dnsOverrideJson != profile.dnsOverrideJson;
if (contentChanged) {
await dao.upsert(
ProxyProfile(
id: profile.id,
name: profile.name,
type: profile.type,
configJson: profile.configJson,
dnsOverrideJson: profile.dnsOverrideJson,
createdAt: existing?.createdAt ?? profile.createdAt,
updatedAt: DateTime.now(),
),
);
}
if (secretJson != null) {
await ref
.read(singboxProxyCredentialsRepositoryProvider.notifier)
.writeSecretJson(profile.id, secretJson);
}
}
Future<void> deleteProfile(String profileId) async {
await ref.read(userDatabaseProvider).proxyProfileDao.deleteById(profileId);
await ref
.read(singboxProxyCredentialsRepositoryProvider.notifier)
.deleteSecretJson(profileId);
}
@override
Stream<List<ProxyProfile>> build() {
return ref.watch(userDatabaseProvider).proxyProfileDao.watch().watch();
}
}
@@ -0,0 +1,62 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'singbox_proxy_profiles.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(SingboxProxyProfilesRepository)
final singboxProxyProfilesRepositoryProvider =
SingboxProxyProfilesRepositoryProvider._();
final class SingboxProxyProfilesRepositoryProvider
extends
$StreamNotifierProvider<
SingboxProxyProfilesRepository,
List<ProxyProfile>
> {
SingboxProxyProfilesRepositoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'singboxProxyProfilesRepositoryProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$singboxProxyProfilesRepositoryHash();
@$internal
@override
SingboxProxyProfilesRepository create() => SingboxProxyProfilesRepository();
}
String _$singboxProxyProfilesRepositoryHash() =>
r'5250f2aafb7ec621b3b33a53ff5a00874e8a4b12';
abstract class _$SingboxProxyProfilesRepository
extends $StreamNotifier<List<ProxyProfile>> {
Stream<List<ProxyProfile>> build();
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref as $Ref<AsyncValue<List<ProxyProfile>>, List<ProxyProfile>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<List<ProxyProfile>>, List<ProxyProfile>>,
AsyncValue<List<ProxyProfile>>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,356 @@
/*
* 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:async';
import 'dart:convert';
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:synchronized/synchronized.dart';
import 'package:weblibre/features/proxy/data/models/singbox_proxy_profile.dart';
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_credentials.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart';
import 'package:weblibre/features/proxy/domain/services/dns_config_resolver.dart';
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
show ProxyProfile;
import 'package:weblibre/features/user/data/models/proxy_dns_override.dart';
import 'package:weblibre/features/user/domain/repositories/engine_settings.dart';
part 'singbox_proxy_runtime.g.dart';
abstract interface class SingboxProxyClient {
Stream<SingboxProxyRuntimeState> get stateStream;
Stream<SingboxProxyLogMessage> get logStream;
Future<String?> validateProfile(SingboxProxyProfile profile);
Future<SingboxProxyConfigResult> buildConfig(
List<SingboxProxyProfile> profiles, {
SingboxProxyRuntimeOptions? options,
});
Future<SingboxProxyRuntimeState> start(
List<SingboxProxyProfile> profiles, {
SingboxProxyRuntimeOptions? options,
});
Future<void> stop(List<String> profileIds);
Future<void> stopAll();
Future<SingboxProxyRuntimeState> getState();
Future<void> dispose();
}
class FlutterSingboxProxyClient implements SingboxProxyClient {
final _plugin = FlutterSingboxProxy();
@override
Stream<SingboxProxyRuntimeState> get stateStream => _plugin.stateStream;
@override
Stream<SingboxProxyLogMessage> get logStream => _plugin.logStream;
@override
Future<String?> validateProfile(SingboxProxyProfile profile) {
return _plugin.validateProfile(profile);
}
@override
Future<SingboxProxyConfigResult> buildConfig(
List<SingboxProxyProfile> profiles, {
SingboxProxyRuntimeOptions? options,
}) {
return _plugin.buildConfig(profiles, options: options);
}
@override
Future<SingboxProxyRuntimeState> start(
List<SingboxProxyProfile> profiles, {
SingboxProxyRuntimeOptions? options,
}) {
return _plugin.start(profiles, options: options);
}
@override
Future<void> stop(List<String> profileIds) => _plugin.stop(profileIds);
@override
Future<void> stopAll() => _plugin.stopAll();
@override
Future<SingboxProxyRuntimeState> getState() => _plugin.getState();
@override
Future<void> dispose() => _plugin.dispose();
}
@Riverpod(keepAlive: true)
SingboxProxyClient singboxProxyClient(Ref ref) {
return FlutterSingboxProxyClient();
}
@Riverpod(keepAlive: true)
class SingboxProxyRuntimeRepository extends _$SingboxProxyRuntimeRepository {
final _lock = Lock();
SingboxProxyClient get _plugin => ref.read(singboxProxyClientProvider);
Future<SingboxProxyRuntimeState> _stateSnapshotUnlocked() async {
final currentState = state.asData?.value;
if (currentState != null) return currentState;
final nextState = await _plugin.getState();
state = AsyncData(nextState);
return nextState;
}
Future<SingboxProxyRuntimeState> startProfile(
String profileId, {
SingboxProxyRuntimeOptions? options,
}) async {
return _lock.synchronized(() async {
final currentState = await _stateSnapshotUnlocked();
final activeProfileIds = _activeProfileIds(currentState);
return _startProfilesUnlocked(
{...activeProfileIds, profileId}.toList(),
options: options,
);
});
}
Future<void> ensureProxyConnectionAvailable(
SingboxProxyConnectionId connectionId,
) async {
await _lock.synchronized(() async {
final currentState = await _stateSnapshotUnlocked();
final isRunning = currentState.endpoints.any(
(endpoint) => endpoint.profileId == connectionId.encode(),
);
if (isRunning) return;
final activeProfileIds = _activeProfileIds(currentState);
await _startProfilesUnlocked(
{...activeProfileIds, connectionId.profileId}.toList(),
);
});
}
Set<String> _activeProfileIds(SingboxProxyRuntimeState runtimeState) {
return runtimeState.endpoints
.map((endpoint) => ProxyConnectionId.decode(endpoint.profileId))
.whereType<SingboxProxyConnectionId>()
.map((connectionId) => connectionId.profileId)
.toSet();
}
Future<SingboxProxyRuntimeState> startProfiles(
List<String> profileIds, {
SingboxProxyRuntimeOptions? options,
}) {
return _lock.synchronized(
() => _startProfilesUnlocked(profileIds, options: options),
);
}
Future<SingboxProxyRuntimeState> _startProfilesUnlocked(
List<String> profileIds, {
SingboxProxyRuntimeOptions? options,
}) async {
state = const AsyncLoading<SingboxProxyRuntimeState>();
try {
final profiles = await _runtimeProfiles(profileIds);
final resolvedOptions = await _buildRuntimeOptions(
options ?? SingboxProxyRuntimeOptions(),
profileIds: profileIds.toSet(),
);
final nextState = await _plugin.start(profiles, options: resolvedOptions);
state = AsyncData(nextState);
return nextState;
} catch (error, stackTrace) {
state = AsyncError(error, stackTrace);
rethrow;
}
}
Future<SingboxProxyRuntimeOptions> _buildRuntimeOptions(
SingboxProxyRuntimeOptions base, {
required Set<String> profileIds,
}) async {
// Don't overwrite a caller-supplied dnsConfig (e.g. tests or one-off
// overrides).
final engineSettings = await ref
.read(engineSettingsRepositoryProvider.notifier)
.fetchSettings();
final dohUrl = engineSettings.dohProviderUrl;
if (base.dnsConfig != null) {
return SingboxProxyRuntimeOptions(
preferredBasePort: base.preferredBasePort,
blockUnmatchedTraffic: base.blockUnmatchedTraffic,
dnsConfig: base.dnsConfig,
bootstrapDohUrl: base.bootstrapDohUrl ?? dohUrl,
);
}
final profiles = await ref
.read(singboxProxyProfilesRepositoryProvider.notifier)
.fetchProfiles();
final overrides = <String, ProxyDnsOverride?>{
for (final profile in profiles)
if (profileIds.contains(profile.id))
profile.id: _decodeOverride(profile.dnsOverrideJson),
};
final dnsConfig = buildDnsConfig(
overridesByProfileId: overrides,
runningProfileIds: profileIds,
browserDohUrl: dohUrl,
);
return SingboxProxyRuntimeOptions(
preferredBasePort: base.preferredBasePort,
blockUnmatchedTraffic: base.blockUnmatchedTraffic,
dnsConfig: dnsConfig,
bootstrapDohUrl: dohUrl,
);
}
ProxyDnsOverride? _decodeOverride(String? json) {
if (json == null || json.trim().isEmpty) return null;
try {
final decoded = jsonDecode(json);
if (decoded is! Map<String, dynamic>) return null;
return ProxyDnsOverride.fromJson(decoded);
} catch (_) {
return null;
}
}
Future<void> stopProfiles(List<String> profileIds) {
return _lock.synchronized(() async {
await _stopProfilesUnlocked(profileIds);
});
}
Future<void> deleteProfile(String profileId) {
return _lock.synchronized(() async {
await _stopProfilesUnlocked([profileId]);
await ref
.read(singboxProxyProfilesRepositoryProvider.notifier)
.deleteProfile(profileId);
});
}
Future<void> stopAll() {
return _lock.synchronized(() async {
await _plugin.stopAll();
final nextState = await _plugin.getState();
state = AsyncData(nextState);
});
}
Future<String?> validateProfile(ProxyProfile profile) async {
return _plugin.validateProfile(await _runtimeProfile(profile));
}
Future<void> _stopProfilesUnlocked(List<String> profileIds) async {
final proxyIds = profileIds
.map((profileId) => SingboxProxyConnectionId(profileId).encode())
.toList();
await _plugin.stop(proxyIds);
final nextState = await _plugin.getState();
state = AsyncData(nextState);
}
Future<String?> validateProfileDraft(
ProxyProfile profile, {
String? secretJson,
}) {
return _plugin.validateProfile(
profile.toRuntimeProfile(secretJson: secretJson),
);
}
Future<SingboxProxyConfigResult> buildConfig(
List<String> profileIds, {
SingboxProxyRuntimeOptions? options,
}) async {
final resolvedOptions = await _buildRuntimeOptions(
options ?? SingboxProxyRuntimeOptions(),
profileIds: profileIds.toSet(),
);
return _plugin.buildConfig(
await _runtimeProfiles(profileIds),
options: resolvedOptions,
);
}
Future<List<SingboxProxyProfile>> _runtimeProfiles(
List<String> profileIds,
) async {
final profiles = await ref
.read(singboxProxyProfilesRepositoryProvider.notifier)
.fetchProfiles();
final profileMap = {for (final profile in profiles) profile.id: profile};
return Future.wait(
profileIds.map((profileId) async {
final profile = profileMap[profileId];
if (profile == null) {
throw StateError('Unknown sing-box proxy profile: $profileId');
}
return _runtimeProfile(profile);
}),
);
}
Future<SingboxProxyProfile> _runtimeProfile(ProxyProfile profile) async {
final secretJson = await ref
.read(singboxProxyCredentialsRepositoryProvider.notifier)
.readSecretJson(profile.id);
return profile.toRuntimeProfile(secretJson: secretJson);
}
@override
Future<SingboxProxyRuntimeState> build() async {
final plugin = ref.watch(singboxProxyClientProvider);
final stateSubscription = plugin.stateStream.listen((nextState) {
state = AsyncData(nextState);
});
ref.onDispose(() async {
await stateSubscription.cancel();
await plugin.dispose();
});
return plugin.getState();
}
}
@@ -0,0 +1,117 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'singbox_proxy_runtime.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(singboxProxyClient)
final singboxProxyClientProvider = SingboxProxyClientProvider._();
final class SingboxProxyClientProvider
extends
$FunctionalProvider<
SingboxProxyClient,
SingboxProxyClient,
SingboxProxyClient
>
with $Provider<SingboxProxyClient> {
SingboxProxyClientProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'singboxProxyClientProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$singboxProxyClientHash();
@$internal
@override
$ProviderElement<SingboxProxyClient> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
SingboxProxyClient create(Ref ref) {
return singboxProxyClient(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(SingboxProxyClient value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<SingboxProxyClient>(value),
);
}
}
String _$singboxProxyClientHash() =>
r'3e56b277667e92a2d4941fd084dddd0ff25afd52';
@ProviderFor(SingboxProxyRuntimeRepository)
final singboxProxyRuntimeRepositoryProvider =
SingboxProxyRuntimeRepositoryProvider._();
final class SingboxProxyRuntimeRepositoryProvider
extends
$AsyncNotifierProvider<
SingboxProxyRuntimeRepository,
SingboxProxyRuntimeState
> {
SingboxProxyRuntimeRepositoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'singboxProxyRuntimeRepositoryProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$singboxProxyRuntimeRepositoryHash();
@$internal
@override
SingboxProxyRuntimeRepository create() => SingboxProxyRuntimeRepository();
}
String _$singboxProxyRuntimeRepositoryHash() =>
r'665908eaff34569f7a19dc5328a067711d1937f2';
abstract class _$SingboxProxyRuntimeRepository
extends $AsyncNotifier<SingboxProxyRuntimeState> {
FutureOr<SingboxProxyRuntimeState> build();
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref
as $Ref<
AsyncValue<SingboxProxyRuntimeState>,
SingboxProxyRuntimeState
>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<
AsyncValue<SingboxProxyRuntimeState>,
SingboxProxyRuntimeState
>,
AsyncValue<SingboxProxyRuntimeState>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,109 @@
/*
* 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_mozilla_components/flutter_mozilla_components.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart';
import 'package:weblibre/features/user/data/models/engine_settings.dart';
import 'package:weblibre/features/user/domain/repositories/engine_settings.dart';
part 'browser_dns_leak_guard.g.dart';
/// Watches the proxy runtime and **disables** GeckoView's TRR (sets
/// [DohSettingsMode.off]) while at least one profile is running.
///
/// Why "off" and not "max": TRR resolves URL hostnames over DoH directly via
/// the system network, *before* any SOCKS connection is established — so a
/// DoH lookup leaks the destination outside the proxy even when the data
/// itself goes through it. `max` (TRR-only) keeps that leak. `off` disables
/// TRR so GeckoView uses its native resolver, which — combined with
/// `proxyDNS: true` on our SOCKS proxy settings — sends hostnames through the
/// SOCKS inbound so sing-box can resolve them instead of GeckoView doing a
/// direct DoH lookup first.
///
/// The previous TRR mode is captured on engage and restored when all
/// profiles stop.
///
/// This is a side-effect-only provider: it must be `keepAlive: true` and is
/// explicitly listened-to from main.dart so the side effect runs without any
/// widget needing to depend on it.
@Riverpod(keepAlive: true)
class BrowserDnsLeakGuard extends _$BrowserDnsLeakGuard {
DohSettingsMode? _savedMode;
@override
Future<void> build() async {
final runtime = ref.watch(singboxProxyRuntimeRepositoryProvider);
// Skip while a start/stop is in flight. `startProfiles` resets the runtime
// state to `AsyncLoading` for the entire restart — `asData` is briefly
// null, which would otherwise look like "no profiles running" and trigger
// a premature DoH restore in the middle of e.g. starting a second profile
// while one is already active, opening a leak window during the transition.
if (runtime.isLoading) return;
final anyRunning = runtime.asData?.value.endpoints.isNotEmpty ?? false;
if (anyRunning) {
await _enforceOffMode();
} else if (_savedMode != null) {
await _restoreSavedMode();
}
}
Future<void> _enforceOffMode() async {
final engine = ref.read(engineSettingsRepositoryProvider.notifier);
try {
final current = await engine.fetchSettings();
if (current.dohSettingsMode == DohSettingsMode.off) {
// Already off — don't capture it as the "saved" value, otherwise we
// would restore it back to off on disengage instead of the user's
// real previous choice.
return;
}
_savedMode = current.dohSettingsMode;
await engine.updateSettings(
(current) => current.copyWith.dohSettingsMode(DohSettingsMode.off),
);
} catch (error, stack) {
logger.e(
'browser DNS leak guard failed to disable TRR',
error: error,
stackTrace: stack,
);
}
}
Future<void> _restoreSavedMode() async {
final saved = _savedMode;
_savedMode = null;
if (saved == null) return;
try {
await ref
.read(engineSettingsRepositoryProvider.notifier)
.updateSettings((current) => current.copyWith.dohSettingsMode(saved));
} catch (error, stack) {
logger.e(
'browser DNS leak guard failed to restore DoH mode',
error: error,
stackTrace: stack,
);
}
}
}
@@ -0,0 +1,128 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'browser_dns_leak_guard.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Watches the proxy runtime and **disables** GeckoView's TRR (sets
/// [DohSettingsMode.off]) while at least one profile is running.
///
/// Why "off" and not "max": TRR resolves URL hostnames over DoH directly via
/// the system network, *before* any SOCKS connection is established — so a
/// DoH lookup leaks the destination outside the proxy even when the data
/// itself goes through it. `max` (TRR-only) keeps that leak. `off` disables
/// TRR so GeckoView uses its native resolver, which — combined with
/// `proxyDNS: true` on our SOCKS proxy settings — sends hostnames through the
/// SOCKS inbound so sing-box can resolve them instead of GeckoView doing a
/// direct DoH lookup first.
///
/// The previous TRR mode is captured on engage and restored when all
/// profiles stop.
///
/// This is a side-effect-only provider: it must be `keepAlive: true` and is
/// explicitly listened-to from main.dart so the side effect runs without any
/// widget needing to depend on it.
@ProviderFor(BrowserDnsLeakGuard)
final browserDnsLeakGuardProvider = BrowserDnsLeakGuardProvider._();
/// Watches the proxy runtime and **disables** GeckoView's TRR (sets
/// [DohSettingsMode.off]) while at least one profile is running.
///
/// Why "off" and not "max": TRR resolves URL hostnames over DoH directly via
/// the system network, *before* any SOCKS connection is established — so a
/// DoH lookup leaks the destination outside the proxy even when the data
/// itself goes through it. `max` (TRR-only) keeps that leak. `off` disables
/// TRR so GeckoView uses its native resolver, which — combined with
/// `proxyDNS: true` on our SOCKS proxy settings — sends hostnames through the
/// SOCKS inbound so sing-box can resolve them instead of GeckoView doing a
/// direct DoH lookup first.
///
/// The previous TRR mode is captured on engage and restored when all
/// profiles stop.
///
/// This is a side-effect-only provider: it must be `keepAlive: true` and is
/// explicitly listened-to from main.dart so the side effect runs without any
/// widget needing to depend on it.
final class BrowserDnsLeakGuardProvider
extends $AsyncNotifierProvider<BrowserDnsLeakGuard, void> {
/// Watches the proxy runtime and **disables** GeckoView's TRR (sets
/// [DohSettingsMode.off]) while at least one profile is running.
///
/// Why "off" and not "max": TRR resolves URL hostnames over DoH directly via
/// the system network, *before* any SOCKS connection is established — so a
/// DoH lookup leaks the destination outside the proxy even when the data
/// itself goes through it. `max` (TRR-only) keeps that leak. `off` disables
/// TRR so GeckoView uses its native resolver, which — combined with
/// `proxyDNS: true` on our SOCKS proxy settings — sends hostnames through the
/// SOCKS inbound so sing-box can resolve them instead of GeckoView doing a
/// direct DoH lookup first.
///
/// The previous TRR mode is captured on engage and restored when all
/// profiles stop.
///
/// This is a side-effect-only provider: it must be `keepAlive: true` and is
/// explicitly listened-to from main.dart so the side effect runs without any
/// widget needing to depend on it.
BrowserDnsLeakGuardProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'browserDnsLeakGuardProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$browserDnsLeakGuardHash();
@$internal
@override
BrowserDnsLeakGuard create() => BrowserDnsLeakGuard();
}
String _$browserDnsLeakGuardHash() =>
r'366aaa6ae8c163449dca50147be0451c766ba765';
/// Watches the proxy runtime and **disables** GeckoView's TRR (sets
/// [DohSettingsMode.off]) while at least one profile is running.
///
/// Why "off" and not "max": TRR resolves URL hostnames over DoH directly via
/// the system network, *before* any SOCKS connection is established — so a
/// DoH lookup leaks the destination outside the proxy even when the data
/// itself goes through it. `max` (TRR-only) keeps that leak. `off` disables
/// TRR so GeckoView uses its native resolver, which — combined with
/// `proxyDNS: true` on our SOCKS proxy settings — sends hostnames through the
/// SOCKS inbound so sing-box can resolve them instead of GeckoView doing a
/// direct DoH lookup first.
///
/// The previous TRR mode is captured on engage and restored when all
/// profiles stop.
///
/// This is a side-effect-only provider: it must be `keepAlive: true` and is
/// explicitly listened-to from main.dart so the side effect runs without any
/// widget needing to depend on it.
abstract class _$BrowserDnsLeakGuard extends $AsyncNotifier<void> {
FutureOr<void> build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<AsyncValue<void>, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<void>, void>,
AsyncValue<void>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,135 @@
/*
* 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/models/proxy_dns_override.dart';
/// Builds a sing-box [SingboxProxyDnsConfig] from automatic browser-DNS
/// mirroring plus per-profile [overridesByProfileId].
///
/// Profiles without an override reuse the browser DoH URL, detoured through the
/// profile and scoped to that profile's SOCKS inbound. Profiles with an
/// override use their own resolver instead.
///
/// Returns null when there is nothing to configure, letting sing-box fall back
/// to its built-in resolver behaviour.
SingboxProxyDnsConfig? buildDnsConfig({
required Map<String, ProxyDnsOverride?> overridesByProfileId,
required Set<String> runningProfileIds,
required String? browserDohUrl,
}) {
final servers = <SingboxProxyDnsServerConfig>[];
final hasBrowserDoh = browserDohUrl != null && browserDohUrl.isNotEmpty;
if (hasBrowserDoh) {
servers.add(
SingboxProxyDnsServerConfig(tag: 'browser-doh', address: browserDohUrl),
);
// Mirror the browser DoH URL through each running profile unless that
// profile has an explicit override. Scope by inbound so endpoint bootstrap
// lookups do not route through the not-yet-ready outbound and deadlock.
for (final profileId in runningProfileIds) {
if (overridesByProfileId[profileId] != null) {
continue;
}
final outboundTag = _outboundTagForProfile(profileId);
final inboundTag = _inboundTagForProfile(profileId);
servers.add(
SingboxProxyDnsServerConfig(
tag: 'browser-doh-${singboxSanitizeTag(profileId)}',
address: browserDohUrl,
detourTag: outboundTag,
matchInbounds: [inboundTag],
),
);
}
}
overridesByProfileId.forEach((profileId, override) {
if (override == null || !runningProfileIds.contains(profileId)) {
return;
}
final outboundTag = _outboundTagForProfile(profileId);
final inboundTag = _inboundTagForProfile(profileId);
final address = override.remoteServerAddress;
if (address == null || address.isEmpty) {
return;
}
servers.add(
SingboxProxyDnsServerConfig(
tag: 'override-${singboxSanitizeTag(profileId)}',
address: address,
detourTag: outboundTag,
matchInbounds: [inboundTag],
),
);
});
if (servers.isEmpty) return null;
return SingboxProxyDnsConfig(
servers: servers,
finalServerTag: hasBrowserDoh ? 'browser-doh' : null,
domainStrategy: _domainStrategy(overridesByProfileId).singboxValue,
);
}
ProxyDnsDomainStrategy _domainStrategy(
Map<String, ProxyDnsOverride?> overridesByProfileId,
) {
for (final override in overridesByProfileId.values) {
if (override != null) return override.domainStrategy;
}
return ProxyDnsDomainStrategy.preferIpv4;
}
/// Must mirror Kotlin `SingboxTagFormat.outboundTag(profileId)`.
///
/// The Kotlin builder receives the *runtime* profile id (which Dart prefixes
/// with `singbox:` via [SingboxProxyConnectionId] in
/// `ProxyProfileX.toRuntimeProfile`), then sanitises it. We must therefore
/// apply the same prefix here so the detour tag we emit references the same
/// outbound the builder actually created.
///
/// The mirrored Kotlin test lives in `SingboxTagFormatTest.kt` — both must
/// update together if this format ever changes.
String _outboundTagForProfile(String profileId) {
return singboxOutboundTag(SingboxProxyConnectionId(profileId).encode());
}
/// Must mirror Kotlin `SingboxTagFormat.inboundTag(profileId)`.
String _inboundTagForProfile(String profileId) {
return singboxInboundTag(SingboxProxyConnectionId(profileId).encode());
}
/// Public so the format-contract test can assert it directly.
String singboxOutboundTag(String runtimeProfileId) =>
'out-${singboxSanitizeTag(runtimeProfileId)}';
String singboxInboundTag(String runtimeProfileId) =>
'in-${singboxSanitizeTag(runtimeProfileId)}';
String singboxSanitizeTag(String value) =>
value.replaceAll(RegExp('[^A-Za-z0-9_.-]'), '_');
@@ -0,0 +1,237 @@
/*
* 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 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/proxy/data/forms/singbox_form_specs.dart';
import 'package:weblibre/features/proxy/data/models/proxy_profile_seed.dart';
import 'package:weblibre/features/proxy/data/models/proxy_share.dart';
import 'package:weblibre/features/proxy/data/models/wireguard_config_import.dart';
import 'package:weblibre/features/proxy/data/parsers/singbox_proxy_uri.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart';
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
show ProxyProfile;
import 'package:weblibre/features/user/data/models/proxy_dns_override.dart';
part 'proxy_input_consumer.g.dart';
enum ProxyFileImportKind { wireguardConf, singboxOutboundJson }
sealed class ProxyInputOutcome {
const ProxyInputOutcome();
}
class ProxyInputImported extends ProxyInputOutcome {
final ProxyProfile created;
const ProxyInputImported(this.created);
}
class ProxyInputSeed extends ProxyInputOutcome {
final ProxyProfileSeed seed;
const ProxyInputSeed(this.seed);
}
class ProxyInputError extends ProxyInputOutcome {
final String message;
const ProxyInputError(this.message);
}
@Riverpod(keepAlive: true)
class ProxyInputConsumer extends _$ProxyInputConsumer {
Future<ProxyInputOutcome> consumeRawText(String raw) async {
final trimmed = raw.trim();
if (trimmed.startsWith('$weblibreProxyShareScheme://')) {
return _consumeShareUri(trimmed);
}
if (_looksLikeWireguardConf(trimmed)) {
return _seedOutcome(
() => _seedFromWireguardConf(trimmed, fileName: 'WireGuard'),
logMessage: 'Failed to parse WireGuard configuration',
);
}
if (_looksLikeJsonObject(trimmed)) {
return _seedOutcome(
() => _seedFromSingboxOutboundJson(trimmed, fileName: 'Outbound'),
logMessage: 'Failed to parse pasted sing-box outbound JSON',
);
}
return _seedOutcome(() {
final imported = importSingboxProxyUri(trimmed);
return ProxyProfileSeed(
type: imported.type,
name: imported.name,
values: imported.values,
);
}, logMessage: 'Failed to import proxy URI');
}
Future<ProxyInputOutcome> consumeFile(
ProxyFileImportKind kind,
PlatformFile file,
) async {
final String text;
try {
text = await _readFileText(file);
} catch (error, stackTrace) {
logger.e(
'Failed to read proxy import file ${file.name}',
error: error,
stackTrace: stackTrace,
);
return ProxyInputError('Failed to read file: $error');
}
return _seedOutcome(
() => switch (kind) {
ProxyFileImportKind.wireguardConf => _seedFromWireguardConf(
text,
fileName: file.name,
),
ProxyFileImportKind.singboxOutboundJson => _seedFromSingboxOutboundJson(
text,
fileName: file.name,
),
},
logMessage: 'Invalid proxy import file ${file.name} ($kind)',
);
}
Future<ProxyInputOutcome> _consumeShareUri(String text) async {
try {
final envelope = decodeProxyShareUri(text);
final created = await ref
.read(singboxProxyProfilesRepositoryProvider.notifier)
.createProfile(
name: envelope.name,
type: envelope.type,
configJson: envelope.configJson,
secretJson: envelope.secretJson,
dnsOverrideJson: envelope.dnsOverrideJson,
);
return ProxyInputImported(created);
} on FormatException catch (error, stackTrace) {
logger.e(
'Failed to decode WebLibre proxy share URI',
error: error,
stackTrace: stackTrace,
);
return ProxyInputError(error.message);
}
}
ProxyInputOutcome _seedOutcome(
ProxyProfileSeed Function() createSeed, {
required String logMessage,
}) {
try {
return ProxyInputSeed(createSeed());
} on FormatException catch (error, stackTrace) {
logger.e(logMessage, error: error, stackTrace: stackTrace);
return ProxyInputError(error.message);
}
}
Future<String> _readFileText(PlatformFile file) async {
final bytes = file.bytes;
if (bytes != null) {
return utf8.decode(bytes, allowMalformed: true);
}
final path = file.path;
if (path == null) {
throw const FormatException('Unable to read file contents.');
}
return File(path).readAsString();
}
@override
void build() {}
}
ProxyProfileSeed _seedFromWireguardConf(
String configText, {
required String fileName,
}) {
final imported = WireguardConfigImport.fromConfigText(configText);
final dnsAddress = imported.primaryDnsAddress;
final dnsOverrideJson = dnsAddress == null
? null
: jsonEncode(ProxyDnsOverride(remoteServerAddress: dnsAddress).toJson());
return ProxyProfileSeed(
type: SingboxProxyProfileType.wireguard,
name: _stripExtension(fileName),
values: imported.values,
dnsOverrideJson: dnsOverrideJson,
);
}
ProxyProfileSeed _seedFromSingboxOutboundJson(
String text, {
required String fileName,
}) {
final decoded = jsonDecode(text);
if (decoded is! Map<String, dynamic>) {
throw const FormatException('Expected a sing-box outbound JSON object.');
}
final outboundType = decoded['type'];
if (outboundType is! String) {
throw const FormatException(
'Outbound JSON is missing a top-level "type" field.',
);
}
final spec = singboxProxyFormSpecs.values.firstWhere(
(entry) => entry.outboundType == outboundType,
orElse: () => throw FormatException(
'No structured form for outbound type "$outboundType". '
'Use Custom Outbound JSON instead.',
),
);
final values = spec.valuesFromJson(configJson: jsonEncode(decoded));
return ProxyProfileSeed(
type: spec.type,
name: (decoded['tag'] as String?) ?? _stripExtension(fileName),
values: values,
);
}
String _stripExtension(String fileName) {
final dot = fileName.lastIndexOf('.');
if (dot <= 0) return fileName;
return fileName.substring(0, dot);
}
bool _looksLikeWireguardConf(String text) {
return text.contains('[Interface]') &&
(text.contains('PrivateKey') || text.contains('Address'));
}
bool _looksLikeJsonObject(String text) {
return text.startsWith('{') && text.endsWith('}');
}
@@ -0,0 +1,63 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'proxy_input_consumer.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(ProxyInputConsumer)
final proxyInputConsumerProvider = ProxyInputConsumerProvider._();
final class ProxyInputConsumerProvider
extends $NotifierProvider<ProxyInputConsumer, void> {
ProxyInputConsumerProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'proxyInputConsumerProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$proxyInputConsumerHash();
@$internal
@override
ProxyInputConsumer create() => ProxyInputConsumer();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(void value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<void>(value),
);
}
}
String _$proxyInputConsumerHash() =>
r'fed529f253a9bdf72d0b1c23a298765b681d07b8';
abstract class _$ProxyInputConsumer extends $Notifier<void> {
void build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<void, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<void, void>,
void,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,249 @@
/*
* 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:async';
import 'dart:convert';
import 'dart:io';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:socks5_proxy/socks_client.dart';
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart';
import 'package:weblibre/features/tor/domain/extensions/tor_status_x.dart';
import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
part 'proxy_latency_tester.g.dart';
/// Mullvad's connectivity check — returns JSON with the egress `ip` plus
/// geolocation. Single round trip gives both reachability and the IP we
/// surface in the chip. Mullvad has a no-logs policy, which fits a
/// privacy-focused browser better than funneling every probe through
/// Cloudflare.
const _probeUrl = 'https://am.i.mullvad.net/json';
const _testTimeout = Duration(seconds: 8);
class ProxyLatencyData with FastEquatable {
final Duration latency;
final int statusCode;
final String? egressIp;
ProxyLatencyData({
required this.latency,
required this.statusCode,
this.egressIp,
});
@override
List<Object?> get hashParameters => [latency, statusCode, egressIp];
}
/// Per-profile latency results, keyed by profile id. Holds the latest result
/// only; we don't keep history because the test is user-triggered and the user
/// is looking at the chip we render from it.
@Riverpod(keepAlive: true)
class ProxyLatencyResults extends _$ProxyLatencyResults {
@override
Map<ProxyConnectionId, AsyncValue<ProxyLatencyData>> build() => const {};
void _set(ProxyConnectionId id, AsyncValue<ProxyLatencyData> result) {
state = {...state, id: result};
}
void clear(ProxyConnectionId id) {
if (!state.containsKey(id)) return;
state = {
for (final entry in state.entries)
if (entry.key != id) entry.key: entry.value,
};
}
Future<void> _run(
ProxyConnectionId id,
SingboxProxyRuntimeEndpoint endpoint,
) async {
_set(id, const AsyncLoading());
final result = await AsyncValue.guard(
() => measureViaSocks(endpoint: endpoint, url: Uri.parse(_probeUrl)),
);
_set(id, result);
}
/// Runs a probe through the profile's local SOCKS endpoint and records the
/// result. Profile must already be running — the endpoint is read from the
/// live runtime state.
Future<void> test(String profileId) async {
final runtimeState = ref.read(singboxProxyRuntimeRepositoryProvider).value;
final endpoint = runtimeState?.endpoints.where((endpoint) {
final decoded = ProxyConnectionId.decode(endpoint.profileId);
return decoded is SingboxProxyConnectionId &&
decoded.profileId == profileId;
}).firstOrNull;
if (endpoint == null) {
_set(
SingboxProxyConnectionId(profileId),
AsyncError('Profile is not running', StackTrace.current),
);
return;
}
await _run(SingboxProxyConnectionId(profileId), endpoint);
}
/// Probes Tor's local SOCKS endpoint. Keyed by [TorProxyConnectionId] so the
/// chip and clear/retain logic share a code path with sing-box profiles.
Future<void> testTor() async {
final socksPort = ref.read(torProxyServiceProvider).value?.usableSocksPort;
if (socksPort == null) {
_set(
const TorProxyConnectionId(),
AsyncError('Tor is not ready', StackTrace.current),
);
return;
}
await _run(
const TorProxyConnectionId(),
SingboxProxyRuntimeEndpoint(
profileId: const TorProxyConnectionId().encode(),
host: '127.0.0.1',
port: socksPort,
username: '',
password: '',
),
);
}
/// Drop any cached results for profile ids that are no longer running.
void retainRunning(Set<ProxyConnectionId> runningIds) {
if (setEquals(runningIds, state.keys.toSet())) return;
state = {
for (final entry in state.entries)
if (runningIds.contains(entry.key)) entry.key: entry.value,
};
}
}
/// Runs a warmup probe (discarded) followed by [sampleCount] timed requests
/// through a single SOCKS5-bound [HttpClient] and reports the minimum RTT —
/// mirrors the speedtest-style "best RTT" reporting used by NekoBox/v2rayN/
/// clash for user-triggered URL tests. The cold path (TCP + SOCKS5 handshake +
/// upstream outbound warmup) skews the first sample, so we discard it. Reusing
/// the HttpClient lets later samples reuse the pooled SOCKS connection.
///
/// Throws on failure; the latest successful sample also yields the egress IP
/// parsed from the probe body.
Future<ProxyLatencyData> measureViaSocks({
required SingboxProxyRuntimeEndpoint endpoint,
required Uri url,
Duration timeout = _testTimeout,
int sampleCount = 3,
}) async {
final httpClient = HttpClient()..connectionTimeout = timeout;
SocksTCPClient.assignToHttpClient(httpClient, [
ProxySettings(
InternetAddress(endpoint.host),
endpoint.port,
username: endpoint.username,
password: endpoint.password,
),
]);
try {
// Warmup — result discarded for timing, but if it fails we surface the
// error rather than aggregating min of {failures}.
await _singleProbe(httpClient, url, timeout);
Duration? best;
var lastStatusCode = 0;
String? lastEgressIp;
Object? lastError;
StackTrace? lastStackTrace;
for (var i = 0; i < sampleCount; i++) {
try {
final probe = await _singleProbe(httpClient, url, timeout);
if (best == null || probe.latency < best) best = probe.latency;
lastStatusCode = probe.statusCode;
lastEgressIp = probe.egressIp ?? lastEgressIp;
} catch (error, stackTrace) {
lastError = error;
lastStackTrace = stackTrace;
}
}
if (best == null) {
if (lastError != null) {
Error.throwWithStackTrace(lastError, lastStackTrace!);
}
throw const SocketException('No samples completed');
}
return ProxyLatencyData(
latency: best,
statusCode: lastStatusCode,
egressIp: lastEgressIp,
);
} finally {
httpClient.close(force: true);
}
}
Future<ProxyLatencyData> _singleProbe(
HttpClient httpClient,
Uri url,
Duration timeout,
) async {
final stopwatch = Stopwatch()..start();
final request = await httpClient.getUrl(url).timeout(timeout);
final response = await request.close().timeout(timeout);
stopwatch.stop();
String? egressIp;
if (response.statusCode == 200) {
final body = await response.transform(utf8.decoder).join();
egressIp = _parseEgressIp(body);
} else {
await response.drain<void>();
}
return ProxyLatencyData(
latency: stopwatch.elapsed,
statusCode: response.statusCode,
egressIp: egressIp,
);
}
String? _parseEgressIp(String body) {
try {
final decoded = jsonDecode(body);
if (decoded is Map<String, dynamic>) {
final ip = decoded['ip'];
if (ip is String && ip.isNotEmpty) return ip;
}
} on FormatException {
// Not JSON — fall through.
}
return null;
}
@@ -0,0 +1,94 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'proxy_latency_tester.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Per-profile latency results, keyed by profile id. Holds the latest result
/// only; we don't keep history because the test is user-triggered and the user
/// is looking at the chip we render from it.
@ProviderFor(ProxyLatencyResults)
final proxyLatencyResultsProvider = ProxyLatencyResultsProvider._();
/// Per-profile latency results, keyed by profile id. Holds the latest result
/// only; we don't keep history because the test is user-triggered and the user
/// is looking at the chip we render from it.
final class ProxyLatencyResultsProvider
extends
$NotifierProvider<
ProxyLatencyResults,
Map<ProxyConnectionId, AsyncValue<ProxyLatencyData>>
> {
/// Per-profile latency results, keyed by profile id. Holds the latest result
/// only; we don't keep history because the test is user-triggered and the user
/// is looking at the chip we render from it.
ProxyLatencyResultsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'proxyLatencyResultsProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$proxyLatencyResultsHash();
@$internal
@override
ProxyLatencyResults create() => ProxyLatencyResults();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(
Map<ProxyConnectionId, AsyncValue<ProxyLatencyData>> value,
) {
return $ProviderOverride(
origin: this,
providerOverride:
$SyncValueProvider<
Map<ProxyConnectionId, AsyncValue<ProxyLatencyData>>
>(value),
);
}
}
String _$proxyLatencyResultsHash() =>
r'f86dba5c5f17d1f74fdf2b7ca6f5adaa4c42ff75';
/// Per-profile latency results, keyed by profile id. Holds the latest result
/// only; we don't keep history because the test is user-triggered and the user
/// is looking at the chip we render from it.
abstract class _$ProxyLatencyResults
extends $Notifier<Map<ProxyConnectionId, AsyncValue<ProxyLatencyData>>> {
Map<ProxyConnectionId, AsyncValue<ProxyLatencyData>> build();
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref
as $Ref<
Map<ProxyConnectionId, AsyncValue<ProxyLatencyData>>,
Map<ProxyConnectionId, AsyncValue<ProxyLatencyData>>
>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<
Map<ProxyConnectionId, AsyncValue<ProxyLatencyData>>,
Map<ProxyConnectionId, AsyncValue<ProxyLatencyData>>
>,
Map<ProxyConnectionId, AsyncValue<ProxyLatencyData>>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,117 @@
/*
* 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_mozilla_components/flutter_mozilla_components.dart';
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:synchronized/synchronized.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/proxy/data/models/singbox_proxy_profile.dart';
import 'package:weblibre/features/proxy/domain/repositories/container_proxy.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart';
part 'singbox_proxy_endpoint_sync.g.dart';
/// Mirrors the sing-box runtime's active SOCKS endpoints into Gecko's
/// container-proxy registry. Listens to [singboxProxyRuntimeRepositoryProvider]
/// and diffs the previously registered set against the current endpoints.
///
/// Extracted from the runtime repository so that:
/// - the runtime repo only owns process state (start/stop/validate), and
/// - sync is a single side-effect channel — every transition (start, stop,
/// stream-driven refresh, native crash) flows through the same listener,
/// eliminating the duplicate-sync-call hazard that the inline approach had.
///
/// This is a side-effect-only provider: it must be `keepAlive: true` and is
/// explicitly listened-to from main.dart so the side effect runs without any
/// widget needing to depend on it.
@Riverpod(keepAlive: true)
class SingboxProxyEndpointSync extends _$SingboxProxyEndpointSync {
/// Proxy connection ids most recently registered with Gecko. Used to compute
/// the unregister set on the next sync.
var _registeredProxyIds = <String>{};
/// Serialises [_sync] runs so that a fast start→stop→start sequence can't
/// interleave upserts/removals.
final _syncLock = Lock();
Future<void> _sync(SingboxProxyRuntimeState runtimeState) async {
await _syncLock.synchronized(() async {
final nextProxyIds = runtimeState.endpoints
.map((endpoint) => endpoint.profileId)
.toSet();
final containerProxy = ref.read(
containerProxyRepositoryProvider.notifier,
);
for (final proxyId in _registeredProxyIds.difference(nextProxyIds)) {
await containerProxy.removeProxy(proxyId);
}
if (runtimeState.endpoints.isNotEmpty) {
final profiles = await ref
.read(singboxProxyProfilesRepositoryProvider.notifier)
.fetchProfiles();
final profileNames = {
for (final profile in profiles)
profile.proxyConnectionId: profile.name,
};
for (final endpoint in runtimeState.endpoints) {
await containerProxy.upsertProxy(
GeckoProxySettings(
id: endpoint.profileId,
title: profileNames[endpoint.profileId] ?? endpoint.profileId,
type: 'socks',
host: endpoint.host,
port: endpoint.port,
username: endpoint.username,
password: endpoint.password,
proxyDNS: true,
doNotProxyLocal: true,
),
);
}
}
_registeredProxyIds = nextProxyIds;
});
}
@override
void build() {
ref.listen<AsyncValue<SingboxProxyRuntimeState>>(
singboxProxyRuntimeRepositoryProvider,
fireImmediately: true,
(previous, next) {
final runtimeState = next.value;
if (runtimeState == null) return;
_sync(runtimeState).catchError((Object error, StackTrace stackTrace) {
logger.e(
'Failed to sync sing-box proxy endpoints to Gecko',
error: error,
stackTrace: stackTrace,
);
});
},
);
}
}
@@ -0,0 +1,116 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'singbox_proxy_endpoint_sync.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Mirrors the sing-box runtime's active SOCKS endpoints into Gecko's
/// container-proxy registry. Listens to [singboxProxyRuntimeRepositoryProvider]
/// and diffs the previously registered set against the current endpoints.
///
/// Extracted from the runtime repository so that:
/// - the runtime repo only owns process state (start/stop/validate), and
/// - sync is a single side-effect channel — every transition (start, stop,
/// stream-driven refresh, native crash) flows through the same listener,
/// eliminating the duplicate-sync-call hazard that the inline approach had.
///
/// This is a side-effect-only provider: it must be `keepAlive: true` and is
/// explicitly listened-to from main.dart so the side effect runs without any
/// widget needing to depend on it.
@ProviderFor(SingboxProxyEndpointSync)
final singboxProxyEndpointSyncProvider = SingboxProxyEndpointSyncProvider._();
/// Mirrors the sing-box runtime's active SOCKS endpoints into Gecko's
/// container-proxy registry. Listens to [singboxProxyRuntimeRepositoryProvider]
/// and diffs the previously registered set against the current endpoints.
///
/// Extracted from the runtime repository so that:
/// - the runtime repo only owns process state (start/stop/validate), and
/// - sync is a single side-effect channel — every transition (start, stop,
/// stream-driven refresh, native crash) flows through the same listener,
/// eliminating the duplicate-sync-call hazard that the inline approach had.
///
/// This is a side-effect-only provider: it must be `keepAlive: true` and is
/// explicitly listened-to from main.dart so the side effect runs without any
/// widget needing to depend on it.
final class SingboxProxyEndpointSyncProvider
extends $NotifierProvider<SingboxProxyEndpointSync, void> {
/// Mirrors the sing-box runtime's active SOCKS endpoints into Gecko's
/// container-proxy registry. Listens to [singboxProxyRuntimeRepositoryProvider]
/// and diffs the previously registered set against the current endpoints.
///
/// Extracted from the runtime repository so that:
/// - the runtime repo only owns process state (start/stop/validate), and
/// - sync is a single side-effect channel — every transition (start, stop,
/// stream-driven refresh, native crash) flows through the same listener,
/// eliminating the duplicate-sync-call hazard that the inline approach had.
///
/// This is a side-effect-only provider: it must be `keepAlive: true` and is
/// explicitly listened-to from main.dart so the side effect runs without any
/// widget needing to depend on it.
SingboxProxyEndpointSyncProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'singboxProxyEndpointSyncProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$singboxProxyEndpointSyncHash();
@$internal
@override
SingboxProxyEndpointSync create() => SingboxProxyEndpointSync();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(void value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<void>(value),
);
}
}
String _$singboxProxyEndpointSyncHash() =>
r'2d6b0641db33638b0b339b510dd63cdddab7f7cf';
/// Mirrors the sing-box runtime's active SOCKS endpoints into Gecko's
/// container-proxy registry. Listens to [singboxProxyRuntimeRepositoryProvider]
/// and diffs the previously registered set against the current endpoints.
///
/// Extracted from the runtime repository so that:
/// - the runtime repo only owns process state (start/stop/validate), and
/// - sync is a single side-effect channel — every transition (start, stop,
/// stream-driven refresh, native crash) flows through the same listener,
/// eliminating the duplicate-sync-call hazard that the inline approach had.
///
/// This is a side-effect-only provider: it must be `keepAlive: true` and is
/// explicitly listened-to from main.dart so the side effect runs without any
/// widget needing to depend on it.
abstract class _$SingboxProxyEndpointSync extends $Notifier<void> {
void build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<void, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<void, void>,
void,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,128 @@
/*
* 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:http/http.dart' as http;
import 'package:weblibre/features/proxy/data/parsers/singbox_proxy_uri.dart';
/// One line in a subscription that we attempted to parse. Either a usable
/// [SingboxProxyUriImport] or the [FormatException]-style error explaining
/// why the line failed to parse, so the UI can show a per-line outcome
/// instead of silently dropping nodes.
sealed class SubscriptionImportEntry {
final String rawLine;
const SubscriptionImportEntry({required this.rawLine});
}
class SubscriptionEntrySuccess extends SubscriptionImportEntry {
final SingboxProxyUriImport imported;
const SubscriptionEntrySuccess({
required super.rawLine,
required this.imported,
});
}
class SubscriptionEntryFailure extends SubscriptionImportEntry {
final Object error;
const SubscriptionEntryFailure({required super.rawLine, required this.error});
}
class SubscriptionImportResult {
final List<SubscriptionImportEntry> entries;
const SubscriptionImportResult(this.entries);
Iterable<SubscriptionEntrySuccess> get successes =>
entries.whereType<SubscriptionEntrySuccess>();
Iterable<SubscriptionEntryFailure> get failures =>
entries.whereType<SubscriptionEntryFailure>();
}
/// Fetches a v2rayN-style subscription URL and parses its contents.
///
/// Most subscription servers serve a base64-encoded blob whose decoded body is
/// a newline-delimited list of `ss://`, `vless://`, etc. URIs. Some serve the
/// raw newline-delimited list. We try both: base64 first, then raw, and use
/// whichever produces parseable URIs.
Future<SubscriptionImportResult> fetchSubscription(
Uri url, {
http.Client? client,
}) async {
final ownsClient = client == null;
final actualClient = client ?? http.Client();
try {
final response = await actualClient
.get(url, headers: {'User-Agent': 'WebLibre/sing-box-subscriber'})
.timeout(const Duration(seconds: 30));
if (response.statusCode >= 400) {
throw http.ClientException(
'Subscription returned HTTP ${response.statusCode}.',
url,
);
}
return parseSubscriptionBody(response.body);
} finally {
if (ownsClient) actualClient.close();
}
}
/// Decodes a subscription body into individual proxy entries. Public so the
/// UI can preview a pasted body without making a network call.
SubscriptionImportResult parseSubscriptionBody(String body) {
final lines = _tryBase64Decode(body) ?? body;
final entries = <SubscriptionImportEntry>[];
for (final raw in const LineSplitter().convert(lines)) {
final line = raw.trim();
if (line.isEmpty || line.startsWith('#')) continue;
try {
entries.add(
SubscriptionEntrySuccess(
rawLine: line,
imported: importSingboxProxyUri(line),
),
);
} on FormatException catch (error) {
entries.add(SubscriptionEntryFailure(rawLine: line, error: error));
}
}
return SubscriptionImportResult(entries);
}
String? _tryBase64Decode(String body) {
// Subscription bodies are base64 (sometimes URL-safe) without padding.
final stripped = body.replaceAll(RegExp(r'\s'), '');
if (stripped.isEmpty) return null;
// Only attempt if the body looks like base64 — bail out if it contains
// characters never present in base64 alphabets.
if (!RegExp(r'^[A-Za-z0-9+/_=-]+$').hasMatch(stripped)) return null;
try {
return utf8.decode(base64.decode(base64.normalize(stripped)));
} catch (_) {
try {
return utf8.decode(base64Url.decode(base64Url.normalize(stripped)));
} catch (_) {
return null;
}
}
}
@@ -0,0 +1,180 @@
/*
* 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/material.dart';
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
import 'package:weblibre/features/proxy/domain/providers/proxy_connection_options.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart';
import 'package:weblibre/features/tor/presentation/controllers/start_tor_proxy.dart';
import 'package:weblibre/features/tor/presentation/widgets/tor_dialog.dart';
import 'package:weblibre/utils/ui_helper.dart';
/// Single entry point that prompts the user to start whichever proxy backend
/// a container is configured to use. No-op when the container has no proxy
/// assigned or when the relevant backend is already running.
Future<bool> ensureProxyStartedForContainer(
BuildContext context,
WidgetRef ref,
ContainerData container,
) async {
final proxyConnectionId = container.metadata.proxyConnectionId;
if (proxyConnectionId == null) return true;
if (proxyConnectionId is TorProxyConnectionId) {
return await _ensureTorStarted(context, ref);
}
if (proxyConnectionId is SingboxProxyConnectionId) {
return await _maybeStartSingboxProxyForContainer(context, ref, container);
}
return true;
}
Future<bool> _ensureTorStarted(BuildContext context, WidgetRef ref) async {
final shouldPrompt = await ref
.read(startProxyControllerProvider.notifier)
.shouldPromptProxyStart();
if (!context.mounted) return false;
if (!shouldPrompt) return true;
final dialogResult = await showDialog<bool>(
context: context,
builder: (context) => const TorDialog(),
);
if (dialogResult == true) {
await ref.read(startProxyControllerProvider.notifier).startProxy();
return true;
}
return false;
}
Future<bool> _maybeStartSingboxProxyForContainer(
BuildContext context,
WidgetRef ref,
ContainerData container,
) async {
final proxyConnectionId = container.metadata.proxyConnectionId;
if (proxyConnectionId == null) return true;
if (proxyConnectionId is! SingboxProxyConnectionId) return true;
// Await any start/stop in flight so we don't prompt the user a second time
// while a start they already triggered is still resolving. If the runtime
// provider is already in AsyncError, keep treating that as "not running" so
// the user can retry from the container entry point.
final runtimeState = ref.read(singboxProxyRuntimeRepositoryProvider);
final resolvedRuntimeState = await switch (runtimeState) {
AsyncData(:final value) => Future.value(value),
AsyncLoading() => _resolveRuntimeStateForPrompt(ref),
AsyncError(:final error, :final stackTrace) => Future.value(
_runtimeStateRetryFallback(
error: error,
stackTrace: stackTrace,
message:
'Singbox proxy runtime is in error state; continuing so the user can retry startup',
),
),
};
if (!context.mounted) return false;
final isRunning = resolvedRuntimeState.endpoints.any(
(endpoint) => endpoint.profileId == proxyConnectionId.encode(),
);
if (isRunning) return true;
final proxyTitle = proxyConnectionTitle(
ref.read(proxyConnectionOptionsProvider),
proxyConnectionId,
);
final shouldStart = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
icon: const Icon(Icons.route_outlined),
title: const Text('Start Proxy Connection?'),
content: Text(
'This container uses $proxyTitle, but that connection is not running. Start it now?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Start'),
),
],
),
);
if (shouldStart != true) return false;
try {
await ref
.read(singboxProxyRuntimeRepositoryProvider.notifier)
.startProfile(proxyConnectionId.profileId);
return true;
} catch (error, stackTrace) {
logger.e(
'Failed to start singbox proxy profile ${proxyConnectionId.profileId}',
error: error,
stackTrace: stackTrace,
);
if (context.mounted) {
showErrorMessage(context, 'Failed to start proxy: $error');
}
return false;
}
}
Future<SingboxProxyRuntimeState> _resolveRuntimeStateForPrompt(
WidgetRef ref,
) async {
try {
return await ref.read(singboxProxyRuntimeRepositoryProvider.future);
} catch (error, stackTrace) {
return _runtimeStateRetryFallback(
error: error,
stackTrace: stackTrace,
message:
'Singbox proxy runtime is unresolved; continuing so the user can retry startup',
);
}
}
SingboxProxyRuntimeState _runtimeStateRetryFallback({
required Object error,
required StackTrace stackTrace,
required String message,
}) {
logger.w(message, error: error, stackTrace: stackTrace);
return SingboxProxyRuntimeState(
status: SingboxProxyRuntimeStatus.error,
endpoints: const [],
message: error.toString(),
);
}
@@ -0,0 +1,391 @@
/*
* 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:async';
import 'dart:convert';
import 'package:copy_with_extension/copy_with_extension.dart';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/uuid.dart';
import 'package:weblibre/features/proxy/data/forms/singbox_form_specs.dart';
import 'package:weblibre/features/proxy/data/models/proxy_profile_seed.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_credentials.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart';
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
show ProxyProfile;
part 'proxy_profile_draft_controller.g.dart';
const defaultCustomOutboundConfigJson = '''
{
"type": "socks",
"server": "127.0.0.1",
"server_port": 1080
}''';
sealed class SaveOutcome {
const SaveOutcome();
}
class SaveSucceeded extends SaveOutcome {
const SaveSucceeded();
}
class SaveFailed extends SaveOutcome {
final String message;
const SaveFailed(this.message);
}
@CopyWith()
class ProxyProfileDraftState with FastEquatable {
final String? profileId;
final ProxyProfile? existingProfile;
final String? loadError;
final String name;
final SingboxProxyProfileType type;
final Map<String, String> values;
final String? dnsOverrideJson;
final String customConfigJson;
final String customSecretJson;
final bool isSaving;
final bool secretLoaded;
ProxyProfileDraftState({
required this.profileId,
required this.existingProfile,
required this.loadError,
required this.name,
required this.type,
required this.values,
required this.dnsOverrideJson,
required this.customConfigJson,
required this.customSecretJson,
required this.isSaving,
required this.secretLoaded,
});
factory ProxyProfileDraftState.newProfile({ProxyProfileSeed? seed}) {
final type = seed?.type ?? SingboxProxyProfileType.customOutbound;
return ProxyProfileDraftState(
profileId: null,
existingProfile: null,
loadError: null,
name: seed?.name ?? '',
type: type,
values: _initialValuesForType(type, overlay: seed?.values),
dnsOverrideJson: seed?.dnsOverrideJson,
customConfigJson: defaultCustomOutboundConfigJson,
customSecretJson: '',
isSaving: false,
secretLoaded: true,
);
}
factory ProxyProfileDraftState.loadingExisting(String profileId) {
return ProxyProfileDraftState(
profileId: profileId,
existingProfile: null,
loadError: null,
name: '',
type: SingboxProxyProfileType.customOutbound,
values: const {},
dnsOverrideJson: null,
customConfigJson: defaultCustomOutboundConfigJson,
customSecretJson: '',
isSaving: false,
secretLoaded: false,
);
}
bool get isEditing => profileId != null;
bool get isLoading =>
isEditing && existingProfile == null && loadError == null;
@override
List<Object?> get hashParameters => [
profileId,
existingProfile,
loadError,
name,
type,
values,
dnsOverrideJson,
customConfigJson,
customSecretJson,
isSaving,
secretLoaded,
];
}
@riverpod
class ProxyProfileDraft extends _$ProxyProfileDraft {
@override
ProxyProfileDraftState build({String? profileId, ProxyProfileSeed? seed}) {
if (profileId == null) {
return ProxyProfileDraftState.newProfile(seed: seed);
}
unawaited(_loadExistingProfile(profileId));
return ProxyProfileDraftState.loadingExisting(profileId);
}
Future<void> _loadExistingProfile(String profileId) async {
try {
final profile = await ref
.read(singboxProxyProfilesRepositoryProvider.notifier)
.findProfile(profileId);
if (!ref.mounted) return;
if (profile == null) {
state = state.copyWith(
loadError: 'Proxy profile not found.',
secretLoaded: true,
);
return;
}
final secretJson = await ref
.read(singboxProxyCredentialsRepositoryProvider.notifier)
.readSecretJson(profile.id);
if (!ref.mounted) return;
final spec = singboxProxyFormSpecs[profile.type];
state = state.copyWith(
existingProfile: profile,
name: profile.name,
type: profile.type,
values: spec == null
? const {}
: spec.valuesFromJson(
configJson: profile.configJson,
secretJson: secretJson,
),
dnsOverrideJson: profile.dnsOverrideJson,
customConfigJson: profile.type == SingboxProxyProfileType.customOutbound
? profile.configJson
: defaultCustomOutboundConfigJson,
customSecretJson: profile.type == SingboxProxyProfileType.customOutbound
? secretJson ?? ''
: '',
secretLoaded: true,
);
} catch (error) {
if (!ref.mounted) return;
state = state.copyWith(
loadError: 'Failed to load proxy profile: $error',
secretLoaded: true,
);
}
}
void setName(String name) {
state = state.copyWith(name: name);
}
void setType(SingboxProxyProfileType type) {
if (state.isEditing || state.type == type) return;
state = state.copyWith(
type: type,
values: _initialValuesForType(type),
customConfigJson: defaultCustomOutboundConfigJson,
customSecretJson: '',
secretLoaded: true,
);
}
void setFieldValue(String key, String value) {
state = state.copyWith(values: {...state.values, key: value});
}
void setDnsOverrideJson(String? json) {
state = state.copyWith.dnsOverrideJson(json);
}
void setCustomConfigJson(String json) {
state = state.copyWith(customConfigJson: json);
}
void setCustomSecretJson(String json) {
state = state.copyWith(customSecretJson: json);
}
Future<SaveOutcome> save() async {
if (state.isSaving) {
return const SaveFailed('Profile is already saving.');
}
final draft = state;
final trimmedName = draft.name.trim();
if (trimmedName.isEmpty) {
return const SaveFailed('Profile name is required.');
}
if (draft.isLoading) {
return const SaveFailed('Profile is still loading, please wait.');
}
if (draft.loadError != null) {
return SaveFailed(draft.loadError!);
}
final encoded = _encodeDraft(draft);
switch (encoded) {
case _DraftEncodeFailure(:final message):
return SaveFailed(message);
case _DraftEncodeSuccess(:final configJson, :final secretJson):
state = state.copyWith(isSaving: true);
try {
final existing = draft.existingProfile;
final profile = ProxyProfile(
id: existing?.id ?? uuid.v4(),
name: trimmedName,
type: draft.type,
configJson: configJson,
dnsOverrideJson: draft.dnsOverrideJson,
createdAt: existing?.createdAt ?? DateTime.now(),
updatedAt: DateTime.now(),
);
final validationMessage = await ref
.read(singboxProxyRuntimeRepositoryProvider.notifier)
.validateProfileDraft(profile, secretJson: secretJson);
if (validationMessage != null) {
return SaveFailed(validationMessage);
}
if (existing == null) {
await ref
.read(singboxProxyProfilesRepositoryProvider.notifier)
.createProfile(
name: trimmedName,
type: draft.type,
configJson: configJson,
secretJson: secretJson,
dnsOverrideJson: draft.dnsOverrideJson,
);
} else {
await ref
.read(singboxProxyProfilesRepositoryProvider.notifier)
.updateProfile(profile);
await ref
.read(singboxProxyCredentialsRepositoryProvider.notifier)
.writeSecretJson(profile.id, secretJson);
}
return const SaveSucceeded();
} catch (error) {
return SaveFailed('Failed to save proxy profile: $error');
} finally {
if (ref.mounted) {
state = state.copyWith(isSaving: false);
}
}
}
}
}
sealed class _DraftEncodeResult {
const _DraftEncodeResult();
}
class _DraftEncodeSuccess extends _DraftEncodeResult {
final String configJson;
final String? secretJson;
const _DraftEncodeSuccess({
required this.configJson,
required this.secretJson,
});
}
class _DraftEncodeFailure extends _DraftEncodeResult {
final String message;
const _DraftEncodeFailure(this.message);
}
/// Validates and encodes the draft into wire-format JSON in a single pass.
/// For custom-outbound profiles we only check JSON shape; for spec-driven
/// types the spec carries field-level validation.
_DraftEncodeResult _encodeDraft(ProxyProfileDraftState draft) {
if (draft.type == SingboxProxyProfileType.customOutbound) {
final normalizedConfigJson = _normalizeJsonObject(draft.customConfigJson);
if (normalizedConfigJson == null) {
return const _DraftEncodeFailure('Config must be a JSON object.');
}
final rawSecret = draft.customSecretJson.trim();
if (rawSecret.isEmpty) {
return _DraftEncodeSuccess(
configJson: normalizedConfigJson,
secretJson: null,
);
}
final normalizedSecretJson = _normalizeJsonObject(draft.customSecretJson);
if (normalizedSecretJson == null) {
return const _DraftEncodeFailure('Secrets must be a JSON object.');
}
return _DraftEncodeSuccess(
configJson: normalizedConfigJson,
secretJson: normalizedSecretJson,
);
}
final spec = singboxProxyFormSpecs[draft.type]!;
final validationMessage = spec.validate(draft.values);
if (validationMessage != null) {
return _DraftEncodeFailure(validationMessage);
}
return _DraftEncodeSuccess(
configJson: spec.toConfigJson(draft.values),
secretJson: spec.toSecretJson(draft.values),
);
}
String? _normalizeJsonObject(String rawJson) {
try {
final decoded = jsonDecode(rawJson) as Object?;
if (decoded is! Map<String, dynamic>) {
return null;
}
return const JsonEncoder.withIndent(' ').convert(decoded);
} catch (_) {
return null;
}
}
Map<String, String> _initialValuesForType(
SingboxProxyProfileType type, {
Map<String, String>? overlay,
}) {
final spec = singboxProxyFormSpecs[type];
if (spec == null) return overlay == null ? const {} : Map.of(overlay);
return {
for (final field in spec.fields)
if (field.defaultValue != null) field.key: field.defaultValue!,
if (overlay != null) ...overlay,
};
}
@@ -0,0 +1,296 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'proxy_profile_draft_controller.dart';
// **************************************************************************
// CopyWithGenerator
// **************************************************************************
abstract class _$ProxyProfileDraftStateCWProxy {
ProxyProfileDraftState profileId(String? profileId);
ProxyProfileDraftState existingProfile(ProxyProfile? existingProfile);
ProxyProfileDraftState loadError(String? loadError);
ProxyProfileDraftState name(String name);
ProxyProfileDraftState type(SingboxProxyProfileType type);
ProxyProfileDraftState values(Map<String, String> values);
ProxyProfileDraftState dnsOverrideJson(String? dnsOverrideJson);
ProxyProfileDraftState customConfigJson(String customConfigJson);
ProxyProfileDraftState customSecretJson(String customSecretJson);
ProxyProfileDraftState isSaving(bool isSaving);
ProxyProfileDraftState secretLoaded(bool secretLoaded);
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `ProxyProfileDraftState(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// ProxyProfileDraftState(...).copyWith(id: 12, name: "My name")
/// ```
ProxyProfileDraftState call({
String? profileId,
ProxyProfile? existingProfile,
String? loadError,
String name,
SingboxProxyProfileType type,
Map<String, String> values,
String? dnsOverrideJson,
String customConfigJson,
String customSecretJson,
bool isSaving,
bool secretLoaded,
});
}
/// Callable proxy for `copyWith` functionality.
/// Use as `instanceOfProxyProfileDraftState.copyWith(...)` or call `instanceOfProxyProfileDraftState.copyWith.fieldName(value)` for a single field.
class _$ProxyProfileDraftStateCWProxyImpl
implements _$ProxyProfileDraftStateCWProxy {
const _$ProxyProfileDraftStateCWProxyImpl(this._value);
final ProxyProfileDraftState _value;
@override
ProxyProfileDraftState profileId(String? profileId) =>
call(profileId: profileId);
@override
ProxyProfileDraftState existingProfile(ProxyProfile? existingProfile) =>
call(existingProfile: existingProfile);
@override
ProxyProfileDraftState loadError(String? loadError) =>
call(loadError: loadError);
@override
ProxyProfileDraftState name(String name) => call(name: name);
@override
ProxyProfileDraftState type(SingboxProxyProfileType type) => call(type: type);
@override
ProxyProfileDraftState values(Map<String, String> values) =>
call(values: values);
@override
ProxyProfileDraftState dnsOverrideJson(String? dnsOverrideJson) =>
call(dnsOverrideJson: dnsOverrideJson);
@override
ProxyProfileDraftState customConfigJson(String customConfigJson) =>
call(customConfigJson: customConfigJson);
@override
ProxyProfileDraftState customSecretJson(String customSecretJson) =>
call(customSecretJson: customSecretJson);
@override
ProxyProfileDraftState isSaving(bool isSaving) => call(isSaving: isSaving);
@override
ProxyProfileDraftState secretLoaded(bool secretLoaded) =>
call(secretLoaded: secretLoaded);
@override
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `ProxyProfileDraftState(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// ProxyProfileDraftState(...).copyWith(id: 12, name: "My name")
/// ```
ProxyProfileDraftState call({
Object? profileId = const $CopyWithPlaceholder(),
Object? existingProfile = const $CopyWithPlaceholder(),
Object? loadError = const $CopyWithPlaceholder(),
Object? name = const $CopyWithPlaceholder(),
Object? type = const $CopyWithPlaceholder(),
Object? values = const $CopyWithPlaceholder(),
Object? dnsOverrideJson = const $CopyWithPlaceholder(),
Object? customConfigJson = const $CopyWithPlaceholder(),
Object? customSecretJson = const $CopyWithPlaceholder(),
Object? isSaving = const $CopyWithPlaceholder(),
Object? secretLoaded = const $CopyWithPlaceholder(),
}) {
return ProxyProfileDraftState(
profileId: profileId == const $CopyWithPlaceholder()
? _value.profileId
// ignore: cast_nullable_to_non_nullable
: profileId as String?,
existingProfile: existingProfile == const $CopyWithPlaceholder()
? _value.existingProfile
// ignore: cast_nullable_to_non_nullable
: existingProfile as ProxyProfile?,
loadError: loadError == const $CopyWithPlaceholder()
? _value.loadError
// ignore: cast_nullable_to_non_nullable
: loadError as String?,
name: name == const $CopyWithPlaceholder() || name == null
? _value.name
// ignore: cast_nullable_to_non_nullable
: name as String,
type: type == const $CopyWithPlaceholder() || type == null
? _value.type
// ignore: cast_nullable_to_non_nullable
: type as SingboxProxyProfileType,
values: values == const $CopyWithPlaceholder() || values == null
? _value.values
// ignore: cast_nullable_to_non_nullable
: values as Map<String, String>,
dnsOverrideJson: dnsOverrideJson == const $CopyWithPlaceholder()
? _value.dnsOverrideJson
// ignore: cast_nullable_to_non_nullable
: dnsOverrideJson as String?,
customConfigJson:
customConfigJson == const $CopyWithPlaceholder() ||
customConfigJson == null
? _value.customConfigJson
// ignore: cast_nullable_to_non_nullable
: customConfigJson as String,
customSecretJson:
customSecretJson == const $CopyWithPlaceholder() ||
customSecretJson == null
? _value.customSecretJson
// ignore: cast_nullable_to_non_nullable
: customSecretJson as String,
isSaving: isSaving == const $CopyWithPlaceholder() || isSaving == null
? _value.isSaving
// ignore: cast_nullable_to_non_nullable
: isSaving as bool,
secretLoaded:
secretLoaded == const $CopyWithPlaceholder() || secretLoaded == null
? _value.secretLoaded
// ignore: cast_nullable_to_non_nullable
: secretLoaded as bool,
);
}
}
extension $ProxyProfileDraftStateCopyWith on ProxyProfileDraftState {
/// Returns a callable class used to build a new instance with modified fields.
/// Example: `instanceOfProxyProfileDraftState.copyWith(...)` or `instanceOfProxyProfileDraftState.copyWith.fieldName(...)`.
// ignore: library_private_types_in_public_api
_$ProxyProfileDraftStateCWProxy get copyWith =>
_$ProxyProfileDraftStateCWProxyImpl(this);
}
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(ProxyProfileDraft)
final proxyProfileDraftProvider = ProxyProfileDraftFamily._();
final class ProxyProfileDraftProvider
extends $NotifierProvider<ProxyProfileDraft, ProxyProfileDraftState> {
ProxyProfileDraftProvider._({
required ProxyProfileDraftFamily super.from,
required ({String? profileId, ProxyProfileSeed? seed}) super.argument,
}) : super(
retry: null,
name: r'proxyProfileDraftProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$proxyProfileDraftHash();
@override
String toString() {
return r'proxyProfileDraftProvider'
''
'$argument';
}
@$internal
@override
ProxyProfileDraft create() => ProxyProfileDraft();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(ProxyProfileDraftState value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<ProxyProfileDraftState>(value),
);
}
@override
bool operator ==(Object other) {
return other is ProxyProfileDraftProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$proxyProfileDraftHash() => r'88dccb74624a8257f77646ab6755dce4e0de465a';
final class ProxyProfileDraftFamily extends $Family
with
$ClassFamilyOverride<
ProxyProfileDraft,
ProxyProfileDraftState,
ProxyProfileDraftState,
ProxyProfileDraftState,
({String? profileId, ProxyProfileSeed? seed})
> {
ProxyProfileDraftFamily._()
: super(
retry: null,
name: r'proxyProfileDraftProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
ProxyProfileDraftProvider call({String? profileId, ProxyProfileSeed? seed}) =>
ProxyProfileDraftProvider._(
argument: (profileId: profileId, seed: seed),
from: this,
);
@override
String toString() => r'proxyProfileDraftProvider';
}
abstract class _$ProxyProfileDraft extends $Notifier<ProxyProfileDraftState> {
late final _$args = ref.$arg as ({String? profileId, ProxyProfileSeed? seed});
String? get profileId => _$args.profileId;
ProxyProfileSeed? get seed => _$args.seed;
ProxyProfileDraftState build({String? profileId, ProxyProfileSeed? seed});
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref as $Ref<ProxyProfileDraftState, ProxyProfileDraftState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<ProxyProfileDraftState, ProxyProfileDraftState>,
ProxyProfileDraftState,
Object?,
Object?
>;
element.handleCreate(
ref,
() => build(profileId: _$args.profileId, seed: _$args.seed),
);
}
}
@@ -0,0 +1,214 @@
/*
* 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/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
import 'package:weblibre/features/proxy/domain/providers/proxy_connection_options.dart';
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
import 'package:weblibre/features/user/data/models/proxy_routing_settings.dart';
import 'package:weblibre/features/user/domain/repositories/proxy_routing_settings.dart';
const List<SettingsSectionDefinition> proxyRoutingSettingsSections = [
SettingsSectionDefinition(
title: 'Regular Tabs',
keywords: ['routing'],
entries: [
SettingsEntryDefinition(
title: 'Regular Tabs Routing Mode',
subtitle: 'Choose how regular tabs are routed through proxies',
keywords: ['container', 'global'],
child: _RegularTabsModeSection(),
),
SettingsEntryDefinition(
title: 'Proxy for global routing',
subtitle: 'Selected proxy when global routing is enabled',
keywords: ['proxy'],
child: _GlobalRoutingProxySection(),
),
],
),
SettingsSectionDefinition(
title: 'Private Tabs',
keywords: ['private', 'incognito'],
entries: [
SettingsEntryDefinition(
title: 'Proxy for private tabs',
subtitle: 'Selected proxy that carries private-tab traffic',
keywords: ['proxy'],
child: _PrivateTabsProxySection(),
),
],
),
];
class ProxyRoutingSettingsScreen extends StatelessWidget {
const ProxyRoutingSettingsScreen({super.key});
@override
Widget build(BuildContext context) {
return const SettingsDetailScaffold(
title: 'Proxy Routing',
subtitle: 'Choose which proxy carries regular and private tab traffic.',
icon: Icons.route_outlined,
sections: proxyRoutingSettingsSections,
);
}
}
class _RegularTabsModeSection extends ConsumerWidget {
const _RegularTabsModeSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(proxyRoutingSettingsWithDefaultsProvider);
return RadioGroup<ProxyRegularTabRoutingMode>(
groupValue: settings.regularTabsMode,
onChanged: (value) async {
if (value != null) {
await ref
.read(proxyRoutingSettingsRepositoryProvider.notifier)
.updateSettings(
(current) => current.copyWith(regularTabsMode: value),
);
}
},
child: const Column(
children: [
RadioListTile<ProxyRegularTabRoutingMode>.adaptive(
value: ProxyRegularTabRoutingMode.container,
title: Text('Container-Based Routing'),
subtitle: Text(
'Only tabs in containers with a proxy assigned are routed.',
),
),
RadioListTile<ProxyRegularTabRoutingMode>.adaptive(
value: ProxyRegularTabRoutingMode.all,
title: Text('Global Routing'),
subtitle: Text(
'Route every regular tab through the selected proxy.',
),
),
],
),
);
}
}
class _GlobalRoutingProxySection extends ConsumerWidget {
const _GlobalRoutingProxySection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(proxyRoutingSettingsWithDefaultsProvider);
if (settings.regularTabsMode != ProxyRegularTabRoutingMode.all) {
return const ListTile(
leading: Icon(Icons.info_outline),
title: Text('Not used in container-based routing'),
subtitle: Text(
'Switch to global routing above to pick the proxy that carries every regular tab.',
),
);
}
final options = ref.watch(proxyConnectionOptionsProvider);
return _ProxyConnectionPicker(
options: options,
selectedId: settings.regularTabsProxyConnectionId,
onChanged: (id) => ref
.read(proxyRoutingSettingsRepositoryProvider.notifier)
.updateSettings(
(current) => current.copyWith(regularTabsProxyConnectionId: id),
),
);
}
}
class _PrivateTabsProxySection extends ConsumerWidget {
const _PrivateTabsProxySection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(proxyRoutingSettingsWithDefaultsProvider);
final options = ref.watch(proxyConnectionOptionsProvider);
return _ProxyConnectionPicker(
options: options,
selectedId: settings.privateTabsProxyConnectionId,
onChanged: (id) => ref
.read(proxyRoutingSettingsRepositoryProvider.notifier)
.updateSettings(
(current) => current.copyWith(privateTabsProxyConnectionId: id),
),
);
}
}
class _ProxyConnectionPicker extends StatelessWidget {
final List<ProxyConnectionOption> options;
final ProxyConnectionId? selectedId;
final ValueChanged<ProxyConnectionId?> onChanged;
const _ProxyConnectionPicker({
required this.options,
required this.selectedId,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final hasUnknownSelection =
selectedId != null && !options.any((option) => option.id == selectedId);
return RadioGroup<ProxyConnectionId?>(
groupValue: selectedId,
onChanged: onChanged,
child: Column(
children: [
const RadioListTile<ProxyConnectionId?>.adaptive(
value: null,
title: Text('None'),
subtitle: Text('Use the normal browser connection'),
secondary: Icon(Icons.public),
),
if (hasUnknownSelection)
ListTile(
leading: Icon(
Icons.warning_amber_outlined,
color: Theme.of(context).colorScheme.error,
),
title: const Text('Unknown proxy'),
subtitle: const Text('The selected proxy no longer exists.'),
trailing: TextButton(
onPressed: () => onChanged(null),
child: const Text('Clear'),
),
),
for (final option in options)
RadioListTile<ProxyConnectionId?>.adaptive(
value: option.id,
title: Text(option.title),
subtitle: Text(option.subtitle),
secondary: const Icon(Icons.route_outlined),
),
],
),
);
}
}
@@ -0,0 +1,231 @@
/*
* 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/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:share_plus/share_plus.dart';
import 'package:weblibre/features/proxy/data/models/proxy_log_message.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_logs.dart';
import 'package:weblibre/utils/ui_helper.dart';
class SingboxProxyLogsScreen extends HookConsumerWidget {
const SingboxProxyLogsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final logs = ref.watch(singboxProxyLogsProvider);
final filter = useState<String?>(null);
final autoScroll = useState(true);
final scrollController = useScrollController();
// Stick to the bottom when new lines arrive — unless the user scrolled up.
// We coalesce scroll-to-bottom across rapid bursts via a pending flag so a
// chatty proxy can't fight the user trying to scroll up.
final pendingAutoScroll = useRef(false);
useEffect(() {
if (!autoScroll.value) return null;
if (pendingAutoScroll.value) return null;
pendingAutoScroll.value = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
pendingAutoScroll.value = false;
if (!autoScroll.value) return;
if (scrollController.hasClients) {
scrollController.jumpTo(scrollController.position.maxScrollExtent);
}
});
return null;
}, [logs.length, autoScroll.value]);
useEffect(() {
void onScroll() {
if (!scrollController.hasClients) return;
final atBottom =
scrollController.position.pixels >=
scrollController.position.maxScrollExtent - 24;
if (autoScroll.value != atBottom) {
autoScroll.value = atBottom;
}
}
scrollController.addListener(onScroll);
return () => scrollController.removeListener(onScroll);
}, [scrollController]);
final filtered = filter.value == null
? logs
: logs.where((m) => m.level.toLowerCase() == filter.value).toList();
return Scaffold(
appBar: AppBar(
title: const Text('Proxy Logs'),
actions: [
PopupMenuButton<String?>(
tooltip: 'Filter by level',
icon: const Icon(Icons.filter_list),
onSelected: (value) => filter.value = value,
itemBuilder: (context) => const [
PopupMenuItem<String?>(child: Text('All levels')),
PopupMenuItem(value: 'error', child: Text('Error')),
PopupMenuItem(value: 'warn', child: Text('Warning')),
PopupMenuItem(value: 'info', child: Text('Info')),
PopupMenuItem(value: 'debug', child: Text('Debug')),
PopupMenuItem(value: 'trace', child: Text('Trace')),
],
),
IconButton(
tooltip: 'Copy all',
icon: const Icon(Icons.copy_all),
onPressed: filtered.isEmpty
? null
: () async {
await Clipboard.setData(
ClipboardData(text: _formatLogs(filtered)),
);
if (context.mounted) {
showInfoMessage(context, 'Copied to clipboard');
}
},
),
IconButton(
tooltip: 'Share',
icon: const Icon(Icons.share),
onPressed: filtered.isEmpty
? null
: () => SharePlus.instance.share(
ShareParams(
text: _formatLogs(filtered),
subject: 'proxy logs',
),
),
),
IconButton(
tooltip: 'Clear',
icon: const Icon(Icons.delete_outline),
onPressed: logs.isEmpty
? null
: () => ref.read(singboxProxyLogsProvider.notifier).clear(),
),
],
),
body: filtered.isEmpty
? _EmptyLogs(hasFilter: filter.value != null)
: ListView.builder(
controller: scrollController,
itemCount: filtered.length,
itemBuilder: (context, index) =>
_LogLine(message: filtered[index]),
),
);
}
}
class _LogLine extends StatelessWidget {
final ProxyLogMessage message;
const _LogLine({required this.message});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final color = switch (message.level.toLowerCase()) {
'error' || 'fatal' => scheme.error,
'warn' || 'warning' => scheme.tertiary,
_ => scheme.onSurface,
};
final time = DateFormat(
'HH:mm:ss',
).format(DateTime.fromMillisecondsSinceEpoch(message.timestamp));
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
child: SelectableText.rich(
TextSpan(
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: color,
),
children: [
TextSpan(
text: '$time ',
style: TextStyle(color: scheme.onSurfaceVariant),
),
TextSpan(
text: '[${_sourceLabel(message.source)}] ',
style: TextStyle(color: scheme.primary),
),
TextSpan(
text: '[${message.level}] ',
style: const TextStyle(fontWeight: FontWeight.w600),
),
if (message.profileId != null)
TextSpan(
text: '${message.profileId} ',
style: TextStyle(color: scheme.primary),
),
TextSpan(text: message.message),
],
),
),
);
}
}
class _EmptyLogs extends StatelessWidget {
final bool hasFilter;
const _EmptyLogs({required this.hasFilter});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
hasFilter
? 'No log lines match the current filter.'
: 'No log lines yet. Start a proxy or Tor to see output here.',
textAlign: TextAlign.center,
),
),
);
}
}
String _formatLogs(List<ProxyLogMessage> messages) {
final buffer = StringBuffer();
for (final m in messages) {
final time = DateTime.fromMillisecondsSinceEpoch(
m.timestamp,
).toIso8601String();
buffer.writeln(
'$time [${_sourceLabel(m.source)}] [${m.level}]${m.profileId == null ? '' : ' (${m.profileId})'} ${m.message}',
);
}
return buffer.toString();
}
String _sourceLabel(ProxyLogSource source) {
return switch (source) {
ProxyLogSource.singBox => 'sing-box',
ProxyLogSource.tor => 'tor',
};
}
@@ -0,0 +1,265 @@
/*
* 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:fading_scroll/fading_scroll.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/proxy/data/forms/singbox_form_specs.dart';
import 'package:weblibre/features/proxy/data/models/proxy_profile_seed.dart';
import 'package:weblibre/features/proxy/domain/extensions/singbox_proxy_profile_type_x.dart';
import 'package:weblibre/features/proxy/presentation/controllers/proxy_profile_draft_controller.dart';
import 'package:weblibre/features/proxy/presentation/widgets/profile_editor/custom_outbound_profile_form.dart';
import 'package:weblibre/features/proxy/presentation/widgets/profile_editor/profile_dns_override_section.dart';
import 'package:weblibre/features/proxy/presentation/widgets/profile_editor/profile_editor_section.dart';
import 'package:weblibre/features/proxy/presentation/widgets/profile_editor/structured_profile_form.dart';
import 'package:weblibre/presentation/widgets/button_spinner.dart';
import 'package:weblibre/utils/ui_helper.dart';
class SingboxProxyProfileEditorScreen extends ConsumerWidget {
final String? profileId;
final ProxyProfileSeed? seed;
const SingboxProxyProfileEditorScreen({super.key, this.profileId, this.seed});
@override
Widget build(BuildContext context, WidgetRef ref) {
final draftProvider = proxyProfileDraftProvider(
profileId: profileId,
seed: seed,
);
final draft = ref.watch(draftProvider);
if (draft.isLoading) {
return Scaffold(
appBar: AppBar(title: const Text('Edit Profile')),
body: const Center(child: CircularProgressIndicator()),
);
}
if (draft.loadError != null) {
return Scaffold(
appBar: AppBar(title: const Text('Edit Profile')),
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(draft.loadError!),
),
),
);
}
return _Editor(draftProvider: draftProvider, draft: draft);
}
}
class _Editor extends ConsumerWidget {
final ProxyProfileDraftProvider draftProvider;
final ProxyProfileDraftState draft;
const _Editor({required this.draftProvider, required this.draft});
@override
Widget build(BuildContext context, WidgetRef ref) {
Future<void> handleSave() async {
final outcome = await ref.read(draftProvider.notifier).save();
if (!context.mounted) return;
switch (outcome) {
case SaveSucceeded():
Navigator.pop(context);
case SaveFailed(:final message):
showErrorMessage(context, message);
}
}
final scheme = Theme.of(context).colorScheme;
return Scaffold(
bottomNavigationBar: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: FilledButton.icon(
onPressed: draft.isSaving ? null : handleSave,
icon: draft.isSaving
? const ButtonSpinner()
: const Icon(Icons.check),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
label: Text(draft.isEditing ? 'Save Changes' : 'Create Profile'),
),
),
),
body: SafeArea(
bottom: false,
child: FadingScroll(
fadingSize: 25,
builder: (context, controller) {
return CustomScrollView(
controller: controller,
slivers: [
SliverAppBar.large(
centerTitle: false,
title: Text(draft.isEditing ? 'Edit Profile' : 'New Profile'),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
sliver: SliverList(
delegate: SliverChildListDelegate.fixed([
ProfileEditorSection(
title: 'General',
child: Padding(
padding: const EdgeInsets.all(16),
child: _GeneralSection(
draftProvider: draftProvider,
draft: draft,
),
),
),
const SizedBox(height: 24),
_ProtocolForm(draftProvider: draftProvider, draft: draft),
const SizedBox(height: 24),
ProfileEditorSection(
title: 'DNS Override',
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
child: ProfileDnsOverrideSection(
draftProvider: draftProvider,
),
),
),
const SizedBox(height: 16),
if (!draft.isEditing)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text(
'Tip: use the add menu on the previous screen to '
'import from a file, paste a share link, or scan '
'a QR code.',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: scheme.onSurfaceVariant),
),
),
]),
),
),
],
);
},
),
),
);
}
}
class _GeneralSection extends HookConsumerWidget {
final ProxyProfileDraftProvider draftProvider;
final ProxyProfileDraftState draft;
const _GeneralSection({required this.draftProvider, required this.draft});
@override
Widget build(BuildContext context, WidgetRef ref) {
final nameController = useTextEditingController(text: draft.name);
useEffect(() {
if (nameController.text != draft.name) {
nameController.text = draft.name;
}
return null;
}, [draft.name]);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: nameController,
textInputAction: TextInputAction.next,
decoration: const InputDecoration(
labelText: 'Profile Name',
border: OutlineInputBorder(),
),
onChanged: ref.read(draftProvider.notifier).setName,
),
const SizedBox(height: 16),
if (draft.isEditing)
// Protocol is locked after creation: each type stores a different
// config/secret JSON shape, so switching mid-edit would silently
// rewrite the profile under a foreign schema. To change protocol,
// create a new profile.
InputDecorator(
decoration: const InputDecoration(
labelText: 'Protocol',
border: OutlineInputBorder(),
helperText: 'Protocol is fixed once a profile is created.',
),
child: Text(draft.type.label),
)
else
DropdownButtonFormField<SingboxProxyProfileType>(
key: ValueKey(draft.type),
initialValue: draft.type,
decoration: const InputDecoration(
labelText: 'Protocol',
border: OutlineInputBorder(),
),
items: [
for (final type in SingboxProxyProfileType.values)
DropdownMenuItem(value: type, child: Text(type.label)),
],
onChanged: (value) {
if (value != null) {
ref.read(draftProvider.notifier).setType(value);
}
},
),
],
);
}
}
class _ProtocolForm extends StatelessWidget {
final ProxyProfileDraftProvider draftProvider;
final ProxyProfileDraftState draft;
const _ProtocolForm({required this.draftProvider, required this.draft});
@override
Widget build(BuildContext context) {
final spec = singboxProxyFormSpecs[draft.type];
if (spec != null) {
return StructuredProfileForm(
key: ValueKey((draft.type, draft.profileId)),
spec: spec,
draftProvider: draftProvider,
draft: draft,
);
}
return CustomOutboundProfileForm(
key: ValueKey(('custom', draft.profileId)),
draftProvider: draftProvider,
draft: draft,
);
}
}
@@ -0,0 +1,303 @@
/*
* 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:async';
import 'package:fading_scroll/fading_scroll.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart';
import 'package:weblibre/features/proxy/domain/services/proxy_latency_tester.dart';
import 'package:weblibre/features/proxy/presentation/widgets/add_proxy_method_sheet.dart';
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/profile_tile.dart';
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/status_header.dart';
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/tor_tile.dart';
import 'package:weblibre/features/tor/domain/extensions/tor_status_x.dart';
import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
show ProxyProfile;
import 'package:weblibre/utils/ui_helper.dart';
class SingboxProxyProfilesScreen extends HookConsumerWidget {
const SingboxProxyProfilesScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final profilesAsync = ref.watch(singboxProxyProfilesRepositoryProvider);
final runtimeState = ref.watch(singboxProxyRuntimeRepositoryProvider);
final torState = ref.watch(torProxyServiceProvider);
final deletingProfileIds = useState(<String>{});
final activeProfileIds = _activeProfileIds(runtimeState);
final runtimeBusy = runtimeState.isLoading;
final torIsRunning = torState.value?.isRunning ?? false;
final torIsBusy = torState.isBusy;
// Drop cached latency results for profiles that are no longer running so a
// stale "120 ms" chip can't outlive its connection.
ref.listen(singboxProxyRuntimeRepositoryProvider, (_, _) {
_pruneLatencyCache(ref);
});
ref.listen(torProxyServiceProvider, (_, _) {
_pruneLatencyCache(ref);
});
return Scaffold(
floatingActionButton: FloatingActionButton.extended(
onPressed: () => unawaited(_showAddSheet(context)),
icon: const Icon(Icons.add),
label: const Text('Add Profile'),
),
body: SafeArea(
child: FadingScroll(
fadingSize: 25,
builder: (context, controller) {
return CustomScrollView(
controller: controller,
slivers: [
SliverAppBar.large(
centerTitle: false,
title: const Text('Proxy Connections'),
actions: [
IconButton(
tooltip: 'View logs',
icon: const Icon(Icons.subject),
onPressed: () =>
const SingboxProxyLogsRoute().push(context),
),
],
),
...profilesAsync.when(
data: (profiles) {
// Prune ids whose profile was deleted (or otherwise
// disappeared) so the set can't grow unbounded if a tile
// is unmounted while its delete is still in flight.
final liveProfileIds = {
for (final profile in profiles) profile.id,
};
final pruned = deletingProfileIds.value.intersection(
liveProfileIds,
);
if (pruned.length != deletingProfileIds.value.length) {
// Schedule for the next frame to avoid mutating state
// during build.
WidgetsBinding.instance.addPostFrameCallback((_) {
deletingProfileIds.value = pruned;
});
}
return [
_ProfileListBody(
profiles: profiles,
activeProfileIds: activeProfileIds,
deletingProfileIds: pruned,
runtimeBusy: runtimeBusy,
torIsRunning: torIsRunning,
torIsBusy: torIsBusy,
onDeletingChanged: (id, deleting) {
final next = {...deletingProfileIds.value};
if (deleting) {
next.add(id);
} else {
next.remove(id);
}
deletingProfileIds.value = next;
},
),
];
},
loading: () => const [
SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
),
],
error: (error, stackTrace) {
logger.e(
'Failed to load singbox proxy profiles',
error: error,
stackTrace: stackTrace,
);
return [
SliverFillRemaining(
child: Padding(
padding: const EdgeInsets.all(24),
child: Center(
child: Text(
'Failed to load proxy profiles:\n$error',
textAlign: TextAlign.center,
),
),
),
),
];
},
),
],
);
},
),
),
);
}
}
Set<ProxyConnectionId> _activeConnectionIds(
AsyncValue<SingboxProxyRuntimeState> runtimeState,
) {
return runtimeState.asData?.value.endpoints
.map((endpoint) => ProxyConnectionId.decode(endpoint.profileId))
.nonNulls
.toSet() ??
const <ProxyConnectionId>{};
}
Set<String> _activeProfileIds(
AsyncValue<SingboxProxyRuntimeState> runtimeState,
) {
return _activeConnectionIds(
runtimeState,
).whereType<SingboxProxyConnectionId>().map((id) => id.profileId).toSet();
}
void _pruneLatencyCache(WidgetRef ref) {
final runtimeState = ref.read(singboxProxyRuntimeRepositoryProvider);
final torRunning =
ref.read(torProxyServiceProvider).value?.isRunning ?? false;
ref.read(proxyLatencyResultsProvider.notifier).retainRunning({
..._activeConnectionIds(runtimeState),
if (torRunning) const TorProxyConnectionId(),
});
}
Future<void> _showAddSheet(BuildContext context) async {
final action = await showModalBottomSheet<AddProxyAction>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) => const AddProxyMethodSheet(),
);
if (action == null) return;
if (!context.mounted) return;
switch (action) {
case AddProxyManual():
await const SingboxProxyProfileEditorRoute().push(context);
case AddProxySubscription():
await const SubscriptionImportRoute().push(context);
case AddProxyWithSeed(:final seed):
await SingboxProxyProfileEditorRoute($extra: seed).push(context);
case AddProxyImported(:final message):
showInfoMessage(context, message);
}
}
class _ProfileListBody extends ConsumerWidget {
final List<ProxyProfile> profiles;
final Set<String> activeProfileIds;
final Set<String> deletingProfileIds;
final bool runtimeBusy;
final bool torIsRunning;
final bool torIsBusy;
final void Function(String id, bool deleting) onDeletingChanged;
const _ProfileListBody({
required this.profiles,
required this.activeProfileIds,
required this.deletingProfileIds,
required this.runtimeBusy,
required this.torIsRunning,
required this.torIsBusy,
required this.onDeletingChanged,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final sorted = [...profiles]
..sort((a, b) {
final aRunning = activeProfileIds.contains(a.id);
final bRunning = activeProfileIds.contains(b.id);
if (aRunning == bRunning) return 0;
return aRunning ? -1 : 1;
});
final scheme = Theme.of(context).colorScheme;
final totalRunning = activeProfileIds.length + (torIsRunning ? 1 : 0);
final totalCount = profiles.length + 1;
Future<void> stopAll() async {
await ref.read(singboxProxyRuntimeRepositoryProvider.notifier).stopAll();
ref.read(proxyLatencyResultsProvider.notifier).retainRunning(const {});
if (torIsRunning) {
await ref.read(torProxyServiceProvider.notifier).disconnect();
}
}
return SliverList.list(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: StatusHeader(
totalCount: totalCount,
runningCount: totalRunning,
isBusy: runtimeBusy || torIsBusy,
onStopAll: totalRunning == 0 ? null : stopAll,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 12),
child: Text(
'Profiles',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: scheme.primary,
fontWeight: FontWeight.w700,
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 96),
child: Card.filled(
margin: EdgeInsets.zero,
color: scheme.surfaceContainer,
clipBehavior: Clip.antiAlias,
child: Column(
children: [
TorProfileTile(isRunning: torIsRunning, isBusy: torIsBusy),
for (final profile in sorted) ...[
const Divider(height: 1),
ProfileTile(
profile: profile,
isRunning: activeProfileIds.contains(profile.id),
isDeleting: deletingProfileIds.contains(profile.id),
runtimeBusy: runtimeBusy,
onDeletingChanged: onDeletingChanged,
),
],
],
),
),
),
],
);
}
}
@@ -0,0 +1,286 @@
/*
* 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/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/proxy/data/forms/singbox_form_specs.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart';
import 'package:weblibre/features/proxy/domain/services/subscription_importer.dart';
import 'package:weblibre/presentation/widgets/button_spinner.dart';
import 'package:weblibre/utils/ui_helper.dart';
class SubscriptionImportScreen extends HookConsumerWidget {
const SubscriptionImportScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final urlController = useTextEditingController();
final hasUrl = useListenableSelector(
urlController,
() => urlController.text.trim().isNotEmpty,
);
final fetching = useState(false);
final result = useState<SubscriptionImportResult?>(null);
final selection = useState(<int>{});
final fetchError = useState<String?>(null);
final isImporting = useState(false);
Future<void> fetch() async {
final raw = urlController.text.trim();
if (raw.isEmpty) return;
final uri = Uri.tryParse(raw);
if (uri == null || !uri.hasScheme) {
fetchError.value = 'Enter a full https:// subscription URL.';
return;
}
fetching.value = true;
fetchError.value = null;
try {
final outcome = await fetchSubscription(uri);
result.value = outcome;
selection.value = {
for (final (index, entry) in outcome.entries.indexed)
if (entry is SubscriptionEntrySuccess) index,
};
} catch (error, stackTrace) {
logger.e(
'Failed to fetch subscription from $uri',
error: error,
stackTrace: stackTrace,
);
fetchError.value = error.toString();
result.value = null;
} finally {
if (context.mounted) fetching.value = false;
}
}
Future<void> importSelected() async {
final outcome = result.value;
if (outcome == null) return;
isImporting.value = true;
var imported = 0;
try {
final notifier = ref.read(
singboxProxyProfilesRepositoryProvider.notifier,
);
for (final (index, entry) in outcome.entries.indexed) {
if (!selection.value.contains(index)) continue;
if (entry is! SubscriptionEntrySuccess) continue;
final parsed = entry.imported;
final spec = singboxProxyFormSpecs[parsed.type];
if (spec == null) continue;
await notifier.createProfile(
name: parsed.name ?? 'Imported ${imported + 1}',
type: parsed.type,
configJson: spec.toConfigJson(parsed.values),
secretJson: spec.toSecretJson(parsed.values),
);
imported++;
}
} finally {
if (context.mounted) isImporting.value = false;
}
if (context.mounted) {
showInfoMessage(context, 'Imported $imported profile(s)');
Navigator.of(context).pop();
}
}
return Scaffold(
appBar: AppBar(title: const Text('Import Subscription')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
TextField(
controller: urlController,
keyboardType: TextInputType.url,
decoration: const InputDecoration(
labelText: 'Subscription URL',
hintText: 'https://example.com/sub',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 8),
Text(
'Supports the v2rayN-style format: a base64-encoded list of '
'ss://, vless://, vmess://, trojan://, hysteria2://, tuic:// '
'and similar URIs. Routing rules from the subscription are '
'ignored — only proxy nodes are imported.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: !hasUrl || fetching.value ? null : fetch,
icon: fetching.value
? const ButtonSpinner()
: const Icon(Icons.cloud_download_outlined),
label: const Text('Fetch'),
),
if (fetchError.value != null) ...[
const SizedBox(height: 12),
Text(
fetchError.value!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
if (result.value != null) ...[
const SizedBox(height: 24),
_ResultsSection(
result: result.value!,
selectedIndices: selection.value,
isImporting: isImporting.value,
onSelectionChanged: (next) => selection.value = next,
onImport: importSelected,
),
],
],
),
);
}
}
class _ResultsSection extends StatelessWidget {
final SubscriptionImportResult result;
final Set<int> selectedIndices;
final bool isImporting;
final ValueChanged<Set<int>> onSelectionChanged;
final Future<void> Function() onImport;
const _ResultsSection({
required this.result,
required this.selectedIndices,
required this.isImporting,
required this.onSelectionChanged,
required this.onImport,
});
@override
Widget build(BuildContext context) {
final successCount = result.successes.length;
final failureCount = result.failures.length;
void selectAll() {
onSelectionChanged({
for (final (index, entry) in result.entries.indexed)
if (entry is SubscriptionEntrySuccess) index,
});
}
void toggle(int index, bool selected) {
final next = {...selectedIndices};
if (selected) {
next.add(index);
} else {
next.remove(index);
}
onSelectionChanged(next);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Text(
'$successCount usable node(s)'
'${failureCount > 0 ? ', $failureCount failed' : ''}',
style: Theme.of(context).textTheme.titleSmall,
),
),
TextButton(
onPressed: successCount == 0 ? null : selectAll,
child: const Text('Select all'),
),
TextButton(
onPressed: () => onSelectionChanged(const {}),
child: const Text('Clear'),
),
],
),
const Divider(),
for (final (index, entry) in result.entries.indexed)
_EntryTile(
entry: entry,
selected: selectedIndices.contains(index),
onChanged: entry is SubscriptionEntrySuccess
? (checked) => toggle(index, checked ?? false)
: null,
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: isImporting || selectedIndices.isEmpty ? null : onImport,
icon: isImporting
? const ButtonSpinner()
: const Icon(Icons.download_done),
label: Text('Import ${selectedIndices.length} profile(s)'),
),
],
);
}
}
class _EntryTile extends StatelessWidget {
final SubscriptionImportEntry entry;
final bool selected;
final ValueChanged<bool?>? onChanged;
const _EntryTile({
required this.entry,
required this.selected,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return switch (entry) {
SubscriptionEntrySuccess(:final imported) => CheckboxListTile(
value: selected,
onChanged: onChanged,
title: Text(imported.name ?? entry.rawLine),
subtitle: Text(
imported.type.name,
style: Theme.of(context).textTheme.bodySmall,
),
dense: true,
),
SubscriptionEntryFailure(:final error) => ListTile(
leading: Icon(
Icons.error_outline,
color: Theme.of(context).colorScheme.error,
),
title: Text(
entry.rawLine,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
error is FormatException ? error.message : error.toString(),
),
dense: true,
),
};
}
}
@@ -0,0 +1,340 @@
/*
* 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:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:qr_code_scanner_plus/qr_code_scanner_plus.dart';
import 'package:weblibre/features/proxy/data/models/proxy_profile_seed.dart';
import 'package:weblibre/features/proxy/domain/services/proxy_input_consumer.dart';
import 'package:weblibre/features/qr_scanner/presentation/dialogs/qr_scanner_dialog.dart';
import 'package:weblibre/utils/ui_helper.dart';
/// Outcome of the add-proxy bottom sheet. The sheet itself does not navigate
/// or surface success messages: it pops with one of these so the caller can
/// drive navigation from a stable, non-deactivated context.
sealed class AddProxyAction {
const AddProxyAction();
}
class AddProxyManual extends AddProxyAction {
const AddProxyManual();
}
class AddProxySubscription extends AddProxyAction {
const AddProxySubscription();
}
class AddProxyWithSeed extends AddProxyAction {
final ProxyProfileSeed seed;
const AddProxyWithSeed(this.seed);
}
class AddProxyImported extends AddProxyAction {
final String message;
const AddProxyImported(this.message);
}
/// Guided bottom sheet shown when the user adds a new proxy profile. Each
/// method either pops with an [AddProxyAction] for the caller to apply or
/// stays open so the user can try another method on error.
class AddProxyMethodSheet extends ConsumerWidget {
const AddProxyMethodSheet({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final scheme = Theme.of(context).colorScheme;
void popWith(AddProxyAction action) {
if (!context.mounted) return;
Navigator.of(context).pop(action);
}
Future<void> scanQr() async {
final result = await showDialog<Barcode>(
context: context,
builder: (_) => const QrScannerDialog(),
);
final code = result?.code?.trim();
if (code == null || code.isEmpty) return;
if (!context.mounted) return;
final action = await _consumeRawText(context, ref, code);
if (action == null) return;
popWith(action);
}
Future<void> pasteClipboard() async {
final data = await Clipboard.getData(Clipboard.kTextPlain);
final text = data?.text?.trim();
if (text == null || text.isEmpty) {
if (context.mounted) {
showInfoMessage(context, 'Clipboard is empty.');
}
return;
}
if (!context.mounted) return;
final action = await _consumeRawText(context, ref, text);
if (action == null) return;
popWith(action);
}
Future<void> importFromFile() async {
final kind = await showModalBottomSheet<ProxyFileImportKind>(
context: context,
showDragHandle: true,
builder: (_) => const _FileKindPicker(),
);
if (kind == null) return;
if (!context.mounted) return;
final action = await _consumeFile(context, ref, kind);
if (action == null) return;
popWith(action);
}
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Align(
alignment: Alignment.centerLeft,
child: Text(
'Add Connection',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w600),
),
),
const SizedBox(height: 4),
Align(
alignment: Alignment.centerLeft,
child: Text(
'Choose how you want to add a proxy profile.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: scheme.onSurfaceVariant,
),
),
),
const SizedBox(height: 20),
GridView.count(
crossAxisCount: 2,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 1.25,
children: [
_MethodCard(
icon: Icons.content_paste,
title: 'Clipboard',
subtitle: 'Paste share link or URI',
onTap: pasteClipboard,
isPrimary: true,
),
_MethodCard(
icon: Icons.qr_code_scanner,
title: 'Scan QR',
subtitle: 'From another device',
onTap: scanQr,
),
_MethodCard(
icon: Icons.cloud_download_outlined,
title: 'Subscription',
subtitle: 'Fetch from URL',
onTap: () => popWith(const AddProxySubscription()),
),
_MethodCard(
icon: Icons.upload_file_outlined,
title: 'Import file',
subtitle: '.conf or sing-box JSON',
onTap: importFromFile,
),
],
),
const SizedBox(height: 12),
Center(
child: TextButton.icon(
onPressed: () => popWith(const AddProxyManual()),
icon: const Icon(Icons.edit_note),
label: const Text('Enter manually'),
),
),
],
),
),
);
}
}
class _MethodCard extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final VoidCallback onTap;
final bool isPrimary;
const _MethodCard({
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
this.isPrimary = false,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final background = isPrimary
? scheme.primaryContainer
: scheme.surfaceContainerHigh;
final iconColor = isPrimary ? scheme.onPrimaryContainer : scheme.primary;
final titleColor = isPrimary ? scheme.onPrimaryContainer : scheme.onSurface;
final subtitleColor = isPrimary
? scheme.onPrimaryContainer.withValues(alpha: 0.75)
: scheme.onSurfaceVariant;
return Material(
color: background,
borderRadius: BorderRadius.circular(20),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 28, color: iconColor),
const SizedBox(height: 8),
Text(
title,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: titleColor,
),
),
const SizedBox(height: 2),
Text(
subtitle,
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: subtitleColor),
),
],
),
),
),
);
}
}
class _FileKindPicker extends StatelessWidget {
const _FileKindPicker();
@override
Widget build(BuildContext context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'Import from file',
style: Theme.of(context).textTheme.titleMedium,
),
),
),
ListTile(
leading: const Icon(Icons.vpn_lock),
title: const Text('WireGuard config'),
subtitle: const Text('.conf file with [Interface]/[Peer]'),
onTap: () =>
Navigator.of(context).pop(ProxyFileImportKind.wireguardConf),
),
ListTile(
leading: const Icon(Icons.data_object),
title: const Text('Sing-box outbound JSON'),
subtitle: const Text(
'Shadowsocks, Trojan, VMess, VLESS, Hysteria, …',
),
onTap: () => Navigator.of(
context,
).pop(ProxyFileImportKind.singboxOutboundJson),
),
],
),
),
);
}
}
Future<AddProxyAction?> _consumeFile(
BuildContext context,
WidgetRef ref,
ProxyFileImportKind kind,
) async {
final result = await FilePicker.pickFiles(withData: true);
final picked = result?.files.singleOrNull;
if (picked == null) return null;
final outcome = await ref
.read(proxyInputConsumerProvider.notifier)
.consumeFile(kind, picked);
if (!context.mounted) return null;
return _actionFromOutcome(context, outcome);
}
Future<AddProxyAction?> _consumeRawText(
BuildContext context,
WidgetRef ref,
String rawText,
) async {
final outcome = await ref
.read(proxyInputConsumerProvider.notifier)
.consumeRawText(rawText);
if (!context.mounted) return null;
return _actionFromOutcome(context, outcome);
}
AddProxyAction? _actionFromOutcome(
BuildContext context,
ProxyInputOutcome outcome,
) {
switch (outcome) {
case ProxyInputImported(:final created):
return AddProxyImported('Imported profile "${created.name}"');
case ProxyInputSeed(:final seed):
return AddProxyWithSeed(seed);
case ProxyInputError(:final message):
showErrorMessage(context, message);
return null;
}
}
@@ -0,0 +1,108 @@
/*
* 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/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/proxy/presentation/controllers/proxy_profile_draft_controller.dart';
import 'package:weblibre/features/proxy/presentation/widgets/profile_editor/profile_editor_section.dart';
import 'package:weblibre/presentation/widgets/obscurable_text_field.dart';
class CustomOutboundProfileForm extends HookConsumerWidget {
final ProxyProfileDraftProvider draftProvider;
final ProxyProfileDraftState draft;
const CustomOutboundProfileForm({
super.key,
required this.draftProvider,
required this.draft,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final configController = useTextEditingController(
text: draft.customConfigJson,
);
final secretController = useTextEditingController(
text: draft.customSecretJson,
);
useEffect(() {
if (configController.text != draft.customConfigJson) {
configController.text = draft.customConfigJson;
}
return null;
}, [draft.customConfigJson]);
useEffect(() {
if (secretController.text != draft.customSecretJson) {
secretController.text = draft.customSecretJson;
}
return null;
}, [draft.customSecretJson]);
final notifier = ref.read(draftProvider.notifier);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ProfileEditorSection(
title: 'Outbound',
child: Padding(
padding: const EdgeInsets.all(16),
child: TextField(
controller: configController,
minLines: 10,
maxLines: 18,
keyboardType: TextInputType.multiline,
decoration: const InputDecoration(
alignLabelWithHint: true,
labelText: 'Outbound JSON',
helperText: 'Public sing-box outbound object.',
border: OutlineInputBorder(),
),
onChanged: notifier.setCustomConfigJson,
),
),
),
const SizedBox(height: 24),
ProfileEditorSection(
title: 'Secrets',
child: Padding(
padding: const EdgeInsets.all(16),
child: ObscurableTextField(
controller: secretController,
enabled: draft.secretLoaded,
revealedMinLines: 4,
revealedMaxLines: 10,
decoration: const InputDecoration(
alignLabelWithHint: true,
labelText: 'Secret JSON',
helperText:
'Optional values merged into the outbound at runtime.',
border: OutlineInputBorder(),
),
onChanged: notifier.setCustomSecretJson,
),
),
),
],
);
}
}
@@ -0,0 +1,118 @@
/*
* 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/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/proxy/presentation/controllers/proxy_profile_draft_controller.dart';
import 'package:weblibre/features/user/data/models/proxy_dns_override.dart';
/// Per-profile DNS override editor. Keeps the UI surface minimal: a switch to
/// opt in, plus the most common shape (single resolver routed through *this*
/// profile).
class ProfileDnsOverrideSection extends HookConsumerWidget {
final ProxyProfileDraftProvider draftProvider;
const ProfileDnsOverrideSection({super.key, required this.draftProvider});
@override
Widget build(BuildContext context, WidgetRef ref) {
final overrideJson = ref.watch(
draftProvider.select((state) => state.dnsOverrideJson),
);
final initialOverride = useMemoized(() {
if (overrideJson == null || overrideJson.trim().isEmpty) {
return null;
}
try {
return ProxyDnsOverride.fromJson(
jsonDecode(overrideJson) as Map<String, dynamic>,
);
} catch (_) {
return null;
}
}, [overrideJson]);
final enabled = useState(initialOverride != null);
final addressController = useTextEditingController(
text: initialOverride?.remoteServerAddress ?? '',
);
// Reseed controls when the parent passes a new override (e.g. the
// WireGuard form populating DNS from an imported `[Interface] DNS = …`).
useEffect(() {
enabled.value = initialOverride != null;
final next = initialOverride?.remoteServerAddress ?? '';
if (addressController.text != next) {
addressController.text = next;
}
return null;
}, [initialOverride]);
void emitChange() {
if (!enabled.value) {
ref.read(draftProvider.notifier).setDnsOverrideJson(null);
return;
}
final override = ProxyDnsOverride(
remoteServerAddress: addressController.text.trim().isEmpty
? null
: addressController.text.trim(),
);
ref
.read(draftProvider.notifier)
.setDnsOverrideJson(jsonEncode(override.toJson()));
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Resolve names through a server reachable inside this profile '
'(e.g. an internal DoH server behind a corporate WireGuard). '
'Leave off to use automatic DNS handling.',
style: Theme.of(context).textTheme.bodySmall,
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
value: enabled.value,
onChanged: (value) {
enabled.value = value;
emitChange();
},
title: const Text('Use a profile-specific resolver'),
),
if (enabled.value) ...[
const SizedBox(height: 8),
TextField(
controller: addressController,
decoration: const InputDecoration(
labelText: 'DNS server address',
hintText: 'https://10.0.0.1/dns-query',
border: OutlineInputBorder(),
),
onChanged: (_) => emitChange(),
),
],
],
);
}
}
@@ -0,0 +1,55 @@
/*
* 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/material.dart';
class ProfileEditorSection extends StatelessWidget {
final String title;
final Widget child;
const ProfileEditorSection({
super.key,
required this.title,
required this.child,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: scheme.primary,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 12),
Card.filled(
margin: EdgeInsets.zero,
color: scheme.surfaceContainer,
clipBehavior: Clip.antiAlias,
child: child,
),
],
);
}
}
@@ -0,0 +1,297 @@
/*
* 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/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/proxy/data/forms/singbox_form_field.dart';
import 'package:weblibre/features/proxy/data/forms/singbox_form_spec.dart';
import 'package:weblibre/features/proxy/presentation/controllers/proxy_profile_draft_controller.dart';
import 'package:weblibre/features/proxy/presentation/widgets/profile_editor/profile_editor_section.dart';
import 'package:weblibre/presentation/widgets/obscurable_text_field.dart';
class StructuredProfileForm extends HookConsumerWidget {
final SingboxProxyFormSpec spec;
final ProxyProfileDraftProvider draftProvider;
final ProxyProfileDraftState draft;
const StructuredProfileForm({
super.key,
required this.spec,
required this.draftProvider,
required this.draft,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final controllers = useMemoized(
() => {
for (final field in spec.fields) field.key: TextEditingController(),
},
[spec.type],
);
useEffect(() {
return () {
for (final controller in controllers.values) {
controller.dispose();
}
};
}, [controllers]);
useEffect(() {
_syncControllers(controllers, draft.values);
return null;
}, [controllers, draft.values]);
final sections = _structuredFieldSections(spec.fields);
final notifier = ref.read(draftProvider.notifier);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (sectionIndex, section) in sections.indexed) ...[
if (sectionIndex > 0) const SizedBox(height: 24),
ProfileEditorSection(
title: section.title,
child: Padding(
padding: const EdgeInsets.all(16),
child: _SectionFields(
fields: section.fields,
controllers: controllers,
secretLoaded: draft.secretLoaded,
onChanged: notifier.setFieldValue,
),
),
),
],
const SizedBox(height: 12),
Text(
'Advanced protocol options can still be entered with Custom Outbound JSON.',
style: Theme.of(context).textTheme.bodySmall,
),
],
);
}
}
class _SectionFields extends StatelessWidget {
final List<SingboxProxyFormField> fields;
final Map<String, TextEditingController> controllers;
final bool secretLoaded;
final void Function(String key, String value) onChanged;
const _SectionFields({
required this.fields,
required this.controllers,
required this.secretLoaded,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (index, field) in fields.indexed) ...[
if (index > 0) const SizedBox(height: 16),
if (field.isBoolean)
_BooleanField(
field: field,
controller: controllers[field.key]!,
onChanged: (value) => onChanged(field.key, value),
)
else if (field.isSecret)
ObscurableTextField(
controller: controllers[field.key],
enabled: secretLoaded,
keyboardType: field.isNumber
? TextInputType.number
: TextInputType.text,
textInputAction: index == fields.length - 1
? TextInputAction.done
: TextInputAction.next,
revealedMinLines: field.key == 'private_key' ? 4 : null,
revealedMaxLines: field.key == 'private_key' ? 8 : 1,
decoration: InputDecoration(
labelText: field.required ? '${field.label} *' : field.label,
helperText: field.helperText ?? 'Stored in secure storage.',
border: const OutlineInputBorder(),
),
onChanged: (value) => onChanged(field.key, value),
)
else
TextField(
controller: controllers[field.key],
keyboardType: field.isNumber
? TextInputType.number
: field.isStringList
? TextInputType.multiline
: TextInputType.text,
textInputAction: field.isStringList
? TextInputAction.newline
: index == fields.length - 1
? TextInputAction.done
: TextInputAction.next,
minLines: field.isStringList ? 2 : 1,
maxLines: field.isStringList ? 4 : 1,
decoration: InputDecoration(
labelText: field.required ? '${field.label} *' : field.label,
helperText: field.helperText,
border: const OutlineInputBorder(),
),
onChanged: (value) => onChanged(field.key, value),
),
],
],
);
}
}
({String title, List<SingboxProxyFormField> fields}) _section(
String title,
List<SingboxProxyFormField> fields,
) {
return (title: title, fields: fields);
}
List<({String title, List<SingboxProxyFormField> fields})>
_structuredFieldSections(List<SingboxProxyFormField> fields) {
final basic = <SingboxProxyFormField>[];
final tls = <SingboxProxyFormField>[];
final transport = <SingboxProxyFormField>[];
final multiplex = <SingboxProxyFormField>[];
final dial = <SingboxProxyFormField>[];
final secrets = <SingboxProxyFormField>[];
final protocol = <SingboxProxyFormField>[];
for (final field in fields) {
if (field.key.startsWith('tls.')) {
tls.add(field);
} else if (field.key.startsWith('transport.')) {
transport.add(field);
} else if (field.key.startsWith('multiplex.')) {
multiplex.add(field);
} else if (_dialFieldKeys.contains(field.key)) {
dial.add(field);
} else if (field.isSecret) {
secrets.add(field);
} else if (_basicFieldKeys.contains(field.key)) {
basic.add(field);
} else {
protocol.add(field);
}
}
return [
if (basic.isNotEmpty) _section('Connection', basic),
if (secrets.isNotEmpty) _section('Credentials', secrets),
if (protocol.isNotEmpty) _section('Protocol Options', protocol),
if (tls.isNotEmpty) _section('TLS', tls),
if (transport.isNotEmpty) _section('Transport', transport),
if (multiplex.isNotEmpty) _section('Multiplex', multiplex),
if (dial.isNotEmpty) _section('Dial', dial),
];
}
const _basicFieldKeys = {
'server',
'server_port',
'version',
'local_address',
'peer_public_key',
};
const _dialFieldKeys = {
'detour',
'bind_interface',
'routing_mark',
'domain_strategy',
'connect_timeout',
};
void _syncControllers(
Map<String, TextEditingController> controllers,
Map<String, String> values,
) {
for (final entry in controllers.entries) {
final next = values[entry.key] ?? '';
if (entry.value.text != next) {
entry.value.text = next;
}
}
}
class _BooleanField extends HookWidget {
final SingboxProxyFormField field;
final TextEditingController controller;
final ValueChanged<String> onChanged;
const _BooleanField({
required this.field,
required this.controller,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final value = useListenableSelector(
controller,
() => parseFormBool(controller.text),
);
return InputDecorator(
decoration: InputDecoration(
labelText: field.required ? '${field.label} *' : field.label,
helperText: field.helperText,
helperMaxLines: 3,
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
),
child: Row(
children: [
Expanded(
child: Text(
value == null
? 'Unset (uses default)'
: (value ? 'Enabled' : 'Disabled'),
style: Theme.of(context).textTheme.bodyMedium,
),
),
if (value != null)
IconButton(
tooltip: 'Clear',
icon: const Icon(Icons.clear, size: 18),
onPressed: () {
controller.text = '';
onChanged('');
},
),
Switch(
value: value ?? false,
onChanged: (next) {
final text = next ? 'true' : 'false';
controller.text = text;
onChanged(text);
},
),
],
),
);
}
}
@@ -0,0 +1,49 @@
/*
* 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/material.dart';
class IpChip extends StatelessWidget {
final String ip;
const IpChip({super.key, required this.ip});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Tooltip(
message: 'Egress IP $ip',
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: scheme.secondaryContainer,
borderRadius: BorderRadius.circular(10),
),
child: Text(
ip,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: scheme.onSecondaryContainer,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
);
}
}
@@ -0,0 +1,136 @@
/*
* 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/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/proxy/domain/services/proxy_latency_tester.dart';
class LatencyChip extends StatelessWidget {
final AsyncValue<ProxyLatencyData> result;
const LatencyChip({super.key, required this.result});
@override
Widget build(BuildContext context) {
return switch (result) {
AsyncLoading() => const _LatencyStatusChip.loading(),
AsyncError(:final error) => _LatencyStatusChip.error(error),
AsyncData(:final value) => _LatencySuccessChip(value: value),
};
}
}
class _LatencyStatusChip extends StatelessWidget {
final String label;
final String tooltip;
final bool isError;
const _LatencyStatusChip({
required this.label,
required this.tooltip,
required this.isError,
});
const _LatencyStatusChip.loading()
: this(
label: 'Testing...',
tooltip: 'Latency test running',
isError: false,
);
_LatencyStatusChip.error(Object error)
: this(label: 'Failed', tooltip: error.toString(), isError: true);
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return _LatencyChipContainer(
label: label,
tooltip: tooltip,
backgroundColor: isError
? scheme.errorContainer
: scheme.surfaceContainerHighest,
foregroundColor: isError
? scheme.onErrorContainer
: scheme.onSurfaceVariant,
);
}
}
class _LatencySuccessChip extends StatelessWidget {
final ProxyLatencyData value;
const _LatencySuccessChip({required this.value});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final (background, foreground) = _latencyColors(scheme, value.latency);
return _LatencyChipContainer(
label: '${value.latency.inMilliseconds} ms',
tooltip: 'HTTP ${value.statusCode} in ${value.latency.inMilliseconds} ms',
backgroundColor: background,
foregroundColor: foreground,
);
}
static (Color, Color) _latencyColors(ColorScheme scheme, Duration latency) {
final ms = latency.inMilliseconds;
if (ms < 500) return (scheme.primaryContainer, scheme.onPrimaryContainer);
if (ms < 1500) {
return (scheme.tertiaryContainer, scheme.onTertiaryContainer);
}
return (scheme.errorContainer, scheme.onErrorContainer);
}
}
class _LatencyChipContainer extends StatelessWidget {
final String label;
final String tooltip;
final Color backgroundColor;
final Color foregroundColor;
const _LatencyChipContainer({
required this.label,
required this.tooltip,
required this.backgroundColor,
required this.foregroundColor,
});
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(10),
),
child: Text(
label,
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: foregroundColor),
),
),
);
}
}
@@ -0,0 +1,35 @@
/*
* 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/material.dart';
class MenuRow extends StatelessWidget {
final IconData icon;
final String label;
const MenuRow({super.key, required this.icon, required this.label});
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [Icon(icon, size: 20), const SizedBox(width: 12), Text(label)],
);
}
}
@@ -0,0 +1,62 @@
/*
* 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/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/proxy/domain/services/proxy_latency_tester.dart';
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/ip_chip.dart';
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/latency_chip.dart';
class ProfileSubtitle extends StatelessWidget {
final String typeLabel;
final AsyncValue<ProxyLatencyData>? latency;
const ProfileSubtitle({
super.key,
required this.typeLabel,
required this.latency,
});
@override
Widget build(BuildContext context) {
final latency = this.latency;
if (latency == null) {
return Text(typeLabel);
}
final egressIp = latency.value?.egressIp;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(typeLabel),
const SizedBox(height: 4),
Wrap(
spacing: 6,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
LatencyChip(result: latency),
if (egressIp != null) IpChip(ip: egressIp),
],
),
],
);
}
}
@@ -0,0 +1,249 @@
/*
* 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/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/proxy/data/models/proxy_share.dart';
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
import 'package:weblibre/features/proxy/domain/extensions/singbox_proxy_profile_type_x.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_credentials.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart';
import 'package:weblibre/features/proxy/domain/services/proxy_latency_tester.dart';
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/menu_row.dart';
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/profile_subtitle.dart';
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/protocol_badge.dart';
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/run_switch.dart';
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/share_profile_dialog.dart';
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
show ProxyProfile;
import 'package:weblibre/utils/ui_helper.dart';
enum ProfileAction { edit, testLatency, share, delete }
class ProfileTile extends ConsumerWidget {
final ProxyProfile profile;
final bool isRunning;
final bool isDeleting;
final bool runtimeBusy;
final void Function(String profileId, bool deleting) onDeletingChanged;
const ProfileTile({
super.key,
required this.profile,
required this.isRunning,
required this.isDeleting,
required this.runtimeBusy,
required this.onDeletingChanged,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isBusy = runtimeBusy || isDeleting;
final latencyResult = ref.watch(
proxyLatencyResultsProvider.select(
(map) => map[SingboxProxyConnectionId(profile.id)],
),
);
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
leading: isDeleting
? const SizedBox.square(
dimension: 36,
child: Padding(
padding: EdgeInsets.all(6),
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: ProtocolBadge(type: profile.type, active: isRunning),
title: Text(
profile.name,
style: TextStyle(
fontWeight: isRunning ? FontWeight.w600 : FontWeight.w500,
),
),
subtitle: ProfileSubtitle(
typeLabel: profile.type.label,
latency: latencyResult,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
PopupMenuButton<ProfileAction>(
enabled: !isBusy,
onSelected: (action) => _onAction(context, ref, action),
itemBuilder: (context) => [
const PopupMenuItem(
value: ProfileAction.edit,
child: MenuRow(icon: Icons.edit_outlined, label: 'Edit'),
),
PopupMenuItem(
value: ProfileAction.testLatency,
enabled: isRunning,
child: MenuRow(
icon: latencyResult is AsyncLoading
? Icons.hourglass_bottom
: Icons.network_check,
label: 'Test connection',
),
),
const PopupMenuItem(
value: ProfileAction.share,
child: MenuRow(icon: Icons.share_outlined, label: 'Share'),
),
const PopupMenuItem(
value: ProfileAction.delete,
child: MenuRow(icon: Icons.delete_outline, label: 'Delete'),
),
],
),
RunSwitch(
isRunning: isRunning,
disabled: isBusy,
onTap: () => _toggleRunState(context, ref),
),
],
),
onTap: () =>
SingboxProxyProfileEditorRoute(profileId: profile.id).push(context),
);
}
Future<void> _toggleRunState(BuildContext context, WidgetRef ref) async {
try {
final notifier = ref.read(singboxProxyRuntimeRepositoryProvider.notifier);
if (isRunning) {
await notifier.stopProfiles([profile.id]);
ref
.read(proxyLatencyResultsProvider.notifier)
.clear(SingboxProxyConnectionId(profile.id));
} else {
await notifier.startProfile(profile.id);
}
} catch (error, stackTrace) {
logger.e(
'Failed to toggle singbox proxy run state for ${profile.id}',
error: error,
stackTrace: stackTrace,
);
if (context.mounted) {
showErrorMessage(
context,
isRunning
? 'Failed to stop proxy: $error'
: 'Failed to start proxy: $error',
);
}
}
}
Future<void> _onAction(
BuildContext context,
WidgetRef ref,
ProfileAction action,
) async {
switch (action) {
case ProfileAction.edit:
await _handleEdit(context);
case ProfileAction.testLatency:
await _handleTestLatency(ref);
case ProfileAction.share:
await _handleShare(context, ref);
case ProfileAction.delete:
await _handleDelete(context, ref);
}
}
Future<void> _handleEdit(BuildContext context) {
return SingboxProxyProfileEditorRoute(profileId: profile.id).push(context);
}
Future<void> _handleTestLatency(WidgetRef ref) {
return ref.read(proxyLatencyResultsProvider.notifier).test(profile.id);
}
Future<void> _handleShare(BuildContext context, WidgetRef ref) async {
final secret = await ref
.read(singboxProxyCredentialsRepositoryProvider.notifier)
.readSecretJson(profile.id);
final shareUri = encodeProxyShareUri(
ProxyShareEnvelope(
name: profile.name,
type: profile.type,
configJson: profile.configJson,
secretJson: secret,
dnsOverrideJson: profile.dnsOverrideJson,
),
);
if (!context.mounted) return;
await showDialog<void>(
context: context,
builder: (context) =>
ShareProfileDialog(profileName: profile.name, shareUri: shareUri),
);
}
Future<void> _handleDelete(BuildContext context, WidgetRef ref) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Profile?'),
content: Text(
isRunning
? 'Stop ${profile.name}, then delete it and its stored secrets? Tabs and containers assigned to this profile will be blocked until you choose another proxy or clear the assignment.'
: 'Delete ${profile.name} and its stored secrets? Tabs and containers assigned to this profile will be blocked until you choose another proxy or clear the assignment.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(isRunning ? 'Stop and Delete' : 'Delete'),
),
],
),
);
if (confirmed != true) return;
onDeletingChanged(profile.id, true);
try {
await ref
.read(singboxProxyRuntimeRepositoryProvider.notifier)
.deleteProfile(profile.id);
ref
.read(proxyLatencyResultsProvider.notifier)
.clear(SingboxProxyConnectionId(profile.id));
} catch (error, stackTrace) {
logger.e(
'Failed to delete singbox proxy profile ${profile.id}',
error: error,
stackTrace: stackTrace,
);
if (context.mounted) {
showErrorMessage(context, 'Failed to delete profile: $error');
}
} finally {
if (context.mounted) onDeletingChanged(profile.id, false);
}
}
}
@@ -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/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
import 'package:weblibre/features/proxy/domain/extensions/singbox_proxy_profile_type_x.dart';
class ProtocolBadge extends StatelessWidget {
final SingboxProxyProfileType type;
final bool active;
const ProtocolBadge({super.key, required this.type, required this.active});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final background = active
? scheme.primary.withValues(alpha: 0.15)
: scheme.surfaceContainerHighest;
final foreground = active ? scheme.primary : scheme.onSurfaceVariant;
return Container(
width: 44,
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(12),
),
child: Text(
type.badge,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: foreground,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
),
),
);
}
}
@@ -0,0 +1,55 @@
/*
* 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/material.dart';
class RunSwitch extends StatelessWidget {
final bool isRunning;
final bool disabled;
final VoidCallback onTap;
const RunSwitch({
super.key,
required this.isRunning,
required this.disabled,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final background = isRunning
? scheme.primary
: scheme.surfaceContainerHighest;
final foreground = isRunning ? scheme.onPrimary : scheme.onSurface;
return IconButton.filled(
tooltip: isRunning ? 'Stop' : 'Start',
onPressed: disabled ? null : onTap,
style: IconButton.styleFrom(
backgroundColor: background,
foregroundColor: foreground,
disabledBackgroundColor: scheme.surfaceContainerHighest.withValues(
alpha: 0.5,
),
),
icon: Icon(isRunning ? Icons.stop_rounded : Icons.play_arrow_rounded),
);
}
}
@@ -0,0 +1,114 @@
/*
* 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/material.dart';
import 'package:flutter/services.dart';
import 'package:share_plus/share_plus.dart';
import 'package:weblibre/utils/ui_helper.dart';
class ShareProfileDialog extends StatelessWidget {
final String profileName;
final String shareUri;
const ShareProfileDialog({
super.key,
required this.profileName,
required this.shareUri,
});
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('Share "$profileName"'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
Icons.warning_amber_outlined,
color: Theme.of(context).colorScheme.onErrorContainer,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'This link contains the full profile, including any '
'stored credentials. Share carefully.',
style: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer,
),
),
),
],
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(8),
constraints: const BoxConstraints(maxHeight: 160),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(8),
),
child: SingleChildScrollView(
child: SelectableText(
shareUri,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'),
),
),
),
],
),
actions: [
TextButton.icon(
icon: const Icon(Icons.copy),
label: const Text('Copy'),
onPressed: () async {
await Clipboard.setData(ClipboardData(text: shareUri));
if (context.mounted) {
showInfoMessage(context, 'Copied to clipboard');
}
},
),
TextButton.icon(
icon: const Icon(Icons.share),
label: const Text('Share'),
onPressed: () async {
await SharePlus.instance.share(
ShareParams(text: shareUri, subject: profileName),
);
},
),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
);
}
}
@@ -0,0 +1,104 @@
/*
* 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/material.dart';
class StatusHeader extends StatelessWidget {
final int totalCount;
final int runningCount;
final bool isBusy;
final VoidCallback? onStopAll;
const StatusHeader({
super.key,
required this.totalCount,
required this.runningCount,
required this.isBusy,
required this.onStopAll,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final isAnyRunning = runningCount > 0;
final background = isAnyRunning
? scheme.primaryContainer
: scheme.surfaceContainerHigh;
final onBackground = isAnyRunning
? scheme.onPrimaryContainer
: scheme.onSurfaceVariant;
return Container(
padding: const EdgeInsets.fromLTRB(16, 14, 12, 14),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(20),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: onBackground.withValues(alpha: 0.12),
shape: BoxShape.circle,
),
child: Icon(
isAnyRunning ? Icons.cloud_done : Icons.cloud_off_outlined,
color: onBackground,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isAnyRunning ? 'Active' : 'Disconnected',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: onBackground,
),
),
Text(
isAnyRunning
? '$runningCount of $totalCount routing traffic'
: 'Tap a profile to connect',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: onBackground.withValues(alpha: 0.8),
),
),
],
),
),
if (onStopAll != null)
IconButton.filled(
tooltip: 'Stop all',
onPressed: isBusy ? null : onStopAll,
style: IconButton.styleFrom(
backgroundColor: scheme.errorContainer,
foregroundColor: scheme.onErrorContainer,
),
icon: const Icon(Icons.stop_rounded),
),
],
),
);
}
}
@@ -0,0 +1,154 @@
/*
* 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/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
import 'package:weblibre/features/proxy/domain/services/proxy_latency_tester.dart';
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/menu_row.dart';
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/profile_subtitle.dart';
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/run_switch.dart';
import 'package:weblibre/features/tor/domain/extensions/tor_status_x.dart';
import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
import 'package:weblibre/presentation/icons/tor_icons.dart';
import 'package:weblibre/utils/ui_helper.dart';
enum TorAction { edit, testLatency }
class TorProfileTile extends ConsumerWidget {
final bool isRunning;
final bool isBusy;
const TorProfileTile({
super.key,
required this.isRunning,
required this.isBusy,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final scheme = Theme.of(context).colorScheme;
final background = isRunning
? scheme.primary.withValues(alpha: 0.15)
: scheme.surfaceContainerHighest;
final foreground = isRunning ? scheme.primary : scheme.onSurfaceVariant;
final latencyResult = ref.watch(
proxyLatencyResultsProvider.select(
(map) => map[const TorProxyConnectionId()],
),
);
final torReady = ref.watch(
torProxyServiceProvider.select((s) => s.isReady),
);
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
leading: Container(
width: 44,
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(12),
),
child: Icon(TorIcons.onionAlt, color: foreground, size: 24),
),
title: Text(
'Tor',
style: TextStyle(
fontWeight: isRunning ? FontWeight.w600 : FontWeight.w500,
),
),
subtitle: ProfileSubtitle(
typeLabel: 'Onion routing',
latency: latencyResult,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
PopupMenuButton<TorAction>(
enabled: !isBusy,
onSelected: (action) async {
switch (action) {
case TorAction.edit:
await const TorProxyRoute().push(context);
case TorAction.testLatency:
await ref
.read(proxyLatencyResultsProvider.notifier)
.testTor();
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: TorAction.edit,
child: MenuRow(icon: Icons.edit_outlined, label: 'Edit'),
),
PopupMenuItem(
value: TorAction.testLatency,
enabled: torReady,
child: MenuRow(
icon: latencyResult is AsyncLoading
? Icons.hourglass_bottom
: Icons.network_check,
label: 'Test connection',
),
),
],
),
RunSwitch(
isRunning: isRunning,
disabled: isBusy,
onTap: () => _toggle(context, ref),
),
],
),
onTap: () => const TorProxyRoute().push(context),
);
}
Future<void> _toggle(BuildContext context, WidgetRef ref) async {
try {
final service = ref.read(torProxyServiceProvider.notifier);
if (isRunning) {
await service.disconnect();
ref
.read(proxyLatencyResultsProvider.notifier)
.clear(const TorProxyConnectionId());
} else {
await service.startOrReconfigure(reconfigureIfRunning: false);
}
} catch (error, stackTrace) {
logger.e(
'Failed to toggle Tor proxy',
error: error,
stackTrace: stackTrace,
);
if (context.mounted) {
showErrorMessage(
context,
isRunning
? 'Failed to stop Tor: $error'
: 'Failed to start Tor: $error',
);
}
}
}
}