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,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;
}
}
}