Add proxy routing and sing-box support

This commit is contained in:
Fabian Freund
2026-05-22 18:16:31 +02:00
parent 51289f1266
commit a5974617aa
262 changed files with 32003 additions and 3962 deletions
@@ -0,0 +1,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);
}
}