Add proxy routing and sing-box support
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user