Add proxy routing and sing-box support
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
import 'package:flutter_tor/flutter_tor.dart';
|
||||
import 'package:riverpod/riverpod.dart';
|
||||
|
||||
enum TorPhase { stopped, bootstrapping, ready, degraded }
|
||||
|
||||
extension TorStatusX on TorStatus {
|
||||
TorPhase get phase {
|
||||
if (!isRunning) {
|
||||
return bootstrapProgress > 0 ? TorPhase.bootstrapping : TorPhase.stopped;
|
||||
}
|
||||
if (bootstrapProgress < 100) {
|
||||
return TorPhase.bootstrapping;
|
||||
}
|
||||
return socksPort != null ? TorPhase.ready : TorPhase.degraded;
|
||||
}
|
||||
|
||||
bool get isReady => phase == TorPhase.ready;
|
||||
bool get isBusy => phase == TorPhase.bootstrapping;
|
||||
bool get isStopped => phase == TorPhase.stopped;
|
||||
int? get usableSocksPort => isReady ? socksPort : null;
|
||||
}
|
||||
|
||||
extension TorAsyncStatusX on AsyncValue<TorStatus> {
|
||||
TorPhase get phase {
|
||||
if (isLoading && !hasValue) return TorPhase.bootstrapping;
|
||||
return value?.phase ?? TorPhase.stopped;
|
||||
}
|
||||
|
||||
bool get isReady => !isLoading && (value?.isReady ?? false);
|
||||
bool get isBusy => isLoading || (value?.isBusy ?? false);
|
||||
bool get isStopped => !isLoading && (value?.isStopped ?? true);
|
||||
int? get usableSocksPort => isReady ? value?.socksPort : null;
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
/*
|
||||
* 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';
|
||||
|
||||
part 'tor_proxy.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class TorProxyRepository extends _$TorProxyRepository {
|
||||
final _service = GeckoContainerProxyService();
|
||||
final _serviceLock = Lock();
|
||||
|
||||
Future<void> setProxyPort(int port) {
|
||||
return _serviceLock.synchronized(() async {
|
||||
await _waitHealthcheck().timeout(const Duration(seconds: 30));
|
||||
|
||||
return _service.setProxyPort(port);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> addContainerProxy(String contextId) {
|
||||
return _serviceLock.synchronized(() async {
|
||||
await _waitHealthcheck().timeout(const Duration(seconds: 10));
|
||||
|
||||
return _service.addContainerProxy(contextId);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> removeContainerProxy(String contextId) {
|
||||
return _serviceLock.synchronized(() async {
|
||||
await _waitHealthcheck().timeout(const Duration(seconds: 10));
|
||||
|
||||
return _service.removeContainerProxy(contextId);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> setSiteAssignments(List<SiteAssignment> assignements) {
|
||||
return _serviceLock.synchronized(() async {
|
||||
await _waitHealthcheck().timeout(const Duration(seconds: 10));
|
||||
|
||||
return _service.setSiteAssignments(
|
||||
Map.fromEntries(
|
||||
assignements.map(
|
||||
(e) => MapEntry(
|
||||
e.assignedSite.origin,
|
||||
e.contextualIdentity ?? 'general',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _waitHealthcheck({
|
||||
Duration timeout = const Duration(seconds: 15),
|
||||
}) async {
|
||||
final startTime = DateTime.now();
|
||||
|
||||
var healthy = await _service.healthcheck();
|
||||
while (!healthy) {
|
||||
if (DateTime.now().difference(startTime) > timeout) {
|
||||
throw TimeoutException('Timed out waiting for proxy service');
|
||||
}
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 25));
|
||||
healthy = await _service.healthcheck();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void build() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'tor_proxy.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(TorProxyRepository)
|
||||
final torProxyRepositoryProvider = TorProxyRepositoryProvider._();
|
||||
|
||||
final class TorProxyRepositoryProvider
|
||||
extends $NotifierProvider<TorProxyRepository, void> {
|
||||
TorProxyRepositoryProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'torProxyRepositoryProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$torProxyRepositoryHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
TorProxyRepository create() => TorProxyRepository();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(void value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<void>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$torProxyRepositoryHash() =>
|
||||
r'83c2976750f3f7907274b1ae4f926cd1de89be83';
|
||||
|
||||
abstract class _$TorProxyRepository 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);
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import 'package:rxdart/rxdart.dart';
|
||||
import 'package:weblibre/core/logger.dart';
|
||||
import 'package:weblibre/features/tor/data/models/moat.dart';
|
||||
import 'package:weblibre/features/tor/data/services/moat_service.dart';
|
||||
import 'package:weblibre/features/tor/domain/extensions/tor_status_x.dart';
|
||||
import 'package:weblibre/features/tor/domain/repositories/builtin_bridges.dart';
|
||||
import 'package:weblibre/features/user/data/models/tor_settings.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/tor_settings.dart';
|
||||
@@ -43,9 +44,7 @@ class TorProxyService extends _$TorProxyService {
|
||||
}) async {
|
||||
final currentStatus = await _tor.getStatus();
|
||||
|
||||
if (!currentStatus.isRunning ||
|
||||
currentStatus.socksPort == null ||
|
||||
reconfigureIfRunning) {
|
||||
if (!currentStatus.isReady || reconfigureIfRunning) {
|
||||
state = const AsyncLoading();
|
||||
|
||||
final torSettings = await ref
|
||||
@@ -164,7 +163,7 @@ class TorProxyService extends _$TorProxyService {
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<TorStatus> build() {
|
||||
Stream<TorStatus> build() async* {
|
||||
_statusSyncController = StreamController();
|
||||
|
||||
ref.onDispose(() async {
|
||||
@@ -172,6 +171,13 @@ class TorProxyService extends _$TorProxyService {
|
||||
await _tor.stop();
|
||||
});
|
||||
|
||||
return MergeStream([_tor.statusStream, _statusSyncController.stream]);
|
||||
// Seed the provider with the current runtime state so screens that only
|
||||
// watch the stream do not stay stuck in AsyncLoading until a manual sync.
|
||||
yield await _tor.getStatus();
|
||||
yield* MergeStream([_tor.statusStream, _statusSyncController.stream]);
|
||||
}
|
||||
}
|
||||
|
||||
Stream<TorLogMessage> torLogStream(Ref ref) {
|
||||
return ref.watch(torProxyServiceProvider.notifier)._tor.logStream;
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ final class TorProxyServiceProvider
|
||||
TorProxyService create() => TorProxyService();
|
||||
}
|
||||
|
||||
String _$torProxyServiceHash() => r'7b430ca32fbfc9ebebb1e52271c0183efdf60971';
|
||||
String _$torProxyServiceHash() => r'69240e5e8c5f6bb6aac1fd6c1c1215753607e34a';
|
||||
|
||||
abstract class _$TorProxyService extends $StreamNotifier<TorStatus> {
|
||||
Stream<TorStatus> build();
|
||||
|
||||
@@ -23,67 +23,102 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:nullability/nullability.dart';
|
||||
import 'package:weblibre/core/design/app_colors.dart';
|
||||
import 'package:weblibre/core/routing/routes.dart';
|
||||
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
|
||||
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
|
||||
import 'package:weblibre/features/tor/domain/extensions/tor_status_x.dart';
|
||||
import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
|
||||
import 'package:weblibre/features/tor/presentation/screens/country_picker.dart';
|
||||
import 'package:weblibre/features/user/data/models/tor_settings.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/tor_settings.dart';
|
||||
import 'package:weblibre/presentation/hooks/on_initialization.dart';
|
||||
import 'package:weblibre/presentation/icons/tor_icons.dart';
|
||||
import 'package:weblibre/utils/ui_helper.dart';
|
||||
|
||||
const List<SettingsSectionDefinition> torProxySettingsSections = [
|
||||
SettingsSectionDefinition(
|
||||
title: 'Service',
|
||||
keywords: ['power', 'start', 'stop'],
|
||||
entries: [
|
||||
SettingsEntryDefinition(
|
||||
title: 'Tor™ Service',
|
||||
subtitle: 'Start or stop the Tor service',
|
||||
keywords: ['enable', 'connect'],
|
||||
child: _TorServiceTile(),
|
||||
),
|
||||
SettingsEntryDefinition(
|
||||
title: 'Request New Identity',
|
||||
subtitle: 'Use a fresh circuit for new connections',
|
||||
keywords: ['circuit'],
|
||||
child: _RequestNewIdentityTile(),
|
||||
),
|
||||
],
|
||||
),
|
||||
SettingsSectionDefinition(
|
||||
title: 'Circumvention',
|
||||
keywords: ['bridges', 'transport', 'obfs4', 'snowflake'],
|
||||
entries: [
|
||||
SettingsEntryDefinition(
|
||||
title: 'Auto Configure Transport',
|
||||
subtitle:
|
||||
'Pick the right pluggable transport for your network automatically',
|
||||
keywords: ['auto'],
|
||||
child: _AutoConfigureTransportTile(),
|
||||
),
|
||||
SettingsEntryDefinition(
|
||||
title: 'Transport',
|
||||
subtitle:
|
||||
'Choose how to reach the Tor network when not auto-configured',
|
||||
keywords: ['direct', 'obfs4', 'snowflake'],
|
||||
child: _TransportSection(),
|
||||
),
|
||||
],
|
||||
),
|
||||
SettingsSectionDefinition(
|
||||
title: 'Country Restrictions',
|
||||
keywords: ['entry', 'exit', 'country'],
|
||||
entries: [
|
||||
SettingsEntryDefinition(
|
||||
title: 'Entry Country',
|
||||
subtitle: 'Choose the country of the entry guard',
|
||||
keywords: ['guard'],
|
||||
child: _CountryPickerTile(role: _NodeRole.entry),
|
||||
),
|
||||
SettingsEntryDefinition(
|
||||
title: 'Exit Country',
|
||||
subtitle: 'Choose the country of the exit node',
|
||||
keywords: ['exit'],
|
||||
child: _CountryPickerTile(role: _NodeRole.exit),
|
||||
),
|
||||
],
|
||||
),
|
||||
SettingsSectionDefinition(
|
||||
title: 'About',
|
||||
keywords: ['trademark', 'legal'],
|
||||
entries: [
|
||||
SettingsEntryDefinition(
|
||||
title: 'Trademark',
|
||||
keywords: ['legal'],
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.info_outline),
|
||||
title: Text('Trademark'),
|
||||
subtitle: Text(
|
||||
'Tor is a trademark of The Tor Project; all rights reserved. '
|
||||
'WebLibre is not endorsed or sponsored by, or affiliated with, '
|
||||
'the Tor Project.',
|
||||
),
|
||||
isThreeLine: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
class TorProxyScreen extends HookConsumerWidget {
|
||||
const TorProxyScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final appColors = AppColors.of(context);
|
||||
final bootstrapProgress = ref.watch(
|
||||
torProxyServiceProvider.select(
|
||||
(value) => value.value?.bootstrapProgress ?? 0,
|
||||
),
|
||||
);
|
||||
|
||||
final torPendingRequest = useState<bool?>(null);
|
||||
ref.listen(torProxyServiceProvider, (previous, next) {
|
||||
if (next.hasValue && torPendingRequest.value != null) {
|
||||
if (next.requireValue.isRunning != previous?.value?.isRunning ||
|
||||
next.requireValue.bootstrapProgress !=
|
||||
previous?.value?.bootstrapProgress) {
|
||||
if (torPendingRequest.value == true) {
|
||||
if (next.requireValue.bootstrapProgress > 0) {
|
||||
torPendingRequest.value = null;
|
||||
}
|
||||
} else {
|
||||
torPendingRequest.value = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
final torIsRunning = ref.watch(
|
||||
torProxyServiceProvider.select(
|
||||
(value) => value.value?.isRunning ?? false,
|
||||
),
|
||||
);
|
||||
|
||||
final torIsBootstrapped = ref.watch(
|
||||
torProxyServiceProvider.select(
|
||||
(value) => value.value?.bootstrapProgress == 100,
|
||||
),
|
||||
);
|
||||
|
||||
final torIsBusy =
|
||||
torPendingRequest.value != null ||
|
||||
bootstrapProgress > 0 && bootstrapProgress < 100;
|
||||
|
||||
final torSettings = ref.watch(torSettingsWithDefaultsProvider);
|
||||
final showContainerUi = ref.watch(
|
||||
generalSettingsWithDefaultsProvider.select((s) => s.showContainerUi),
|
||||
);
|
||||
|
||||
useOnInitialization(() async {
|
||||
await ref.read(torProxyServiceProvider.notifier).requestSync();
|
||||
});
|
||||
@@ -97,485 +132,294 @@ class TorProxyScreen extends HookConsumerWidget {
|
||||
}
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
body: Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
listTileTheme: ListTileTheme.of(
|
||||
context,
|
||||
).copyWith(iconColor: Colors.white, textColor: Colors.white),
|
||||
switchTheme: SwitchTheme.of(context).copyWith(
|
||||
trackColor: WidgetStateProperty.resolveWith<Color?>((
|
||||
Set<WidgetState> states,
|
||||
) {
|
||||
if (states.isEmpty) {
|
||||
return appColors.torBackgroundGrey;
|
||||
}
|
||||
return null; // Use the default color.
|
||||
}),
|
||||
trackOutlineColor: WidgetStateProperty.resolveWith<Color?>((
|
||||
Set<WidgetState> states,
|
||||
) {
|
||||
if (states.isEmpty) {
|
||||
return Colors.white;
|
||||
}
|
||||
return null; // Use the default color.
|
||||
}),
|
||||
),
|
||||
radioTheme: RadioTheme.of(context).copyWith(
|
||||
fillColor: WidgetStateColor.resolveWith((states) {
|
||||
return Colors.white;
|
||||
}),
|
||||
),
|
||||
checkboxTheme: CheckboxTheme.of(context).copyWith(
|
||||
fillColor: WidgetStateColor.resolveWith((states) {
|
||||
return Colors.white;
|
||||
}),
|
||||
checkColor: WidgetStateProperty.all(appColors.torPurple),
|
||||
),
|
||||
iconTheme: const IconThemeData(color: Colors.white),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: ColoredBox(
|
||||
color: appColors.torPurple,
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
pinned: true,
|
||||
title: SwitchListTile.adaptive(
|
||||
inactiveThumbColor: Colors.white,
|
||||
activeThumbColor: appColors.torActiveGreen,
|
||||
thumbIcon: WidgetStateProperty.resolveWith<Icon?>((
|
||||
Set<WidgetState> states,
|
||||
) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return const Icon(MdiIcons.axisArrowLock);
|
||||
}
|
||||
return null; // Use the default color.
|
||||
}),
|
||||
value: torPendingRequest.value ?? torIsRunning,
|
||||
title: const Text('Tor™ Service'),
|
||||
secondary: const Icon(MdiIcons.power),
|
||||
onChanged: torIsBusy
|
||||
? null
|
||||
: (value) async {
|
||||
if (value) {
|
||||
torPendingRequest.value = true;
|
||||
|
||||
await ref
|
||||
.read(torProxyServiceProvider.notifier)
|
||||
.startOrReconfigure(
|
||||
reconfigureIfRunning: false,
|
||||
);
|
||||
} else {
|
||||
torPendingRequest.value = false;
|
||||
|
||||
await ref
|
||||
.read(torProxyServiceProvider.notifier)
|
||||
.disconnect();
|
||||
}
|
||||
},
|
||||
),
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(4 + 40 + 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (torPendingRequest.value != false && torIsBusy)
|
||||
LinearProgressIndicator(
|
||||
backgroundColor: appColors.torBackgroundGrey,
|
||||
color: appColors.torActiveGreen,
|
||||
value: bootstrapProgress / 100,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 4.0,
|
||||
right: 16,
|
||||
left: 16,
|
||||
bottom: 4,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed:
|
||||
torIsRunning &&
|
||||
torIsBootstrapped &&
|
||||
!torIsBusy
|
||||
? () async {
|
||||
await ref
|
||||
.read(
|
||||
torProxyServiceProvider.notifier,
|
||||
)
|
||||
.requestNewIdentity();
|
||||
|
||||
if (context.mounted) {
|
||||
showInfoMessage(
|
||||
context,
|
||||
'Requesting new Tor identity...',
|
||||
);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
icon: const Icon(MdiIcons.refresh),
|
||||
label: const Text('Request New Identity'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverList.list(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 24.0),
|
||||
child: Text(
|
||||
'Routing',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
RadioGroup(
|
||||
groupValue: torSettings.proxyRegularTabsMode,
|
||||
onChanged: (value) async {
|
||||
if (value != null) {
|
||||
await ref
|
||||
.read(saveTorSettingsControllerProvider.notifier)
|
||||
.save(
|
||||
(currentSettings) => currentSettings.copyWith
|
||||
.proxyRegularTabsMode(value),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
if (showContainerUi)
|
||||
const RadioListTile.adaptive(
|
||||
value: TorRegularTabProxyMode.container,
|
||||
title: Text('Container-Based Routing'),
|
||||
subtitle: Text(
|
||||
'Route only tabs in Tor containers through the Tor network. Private tabs remain unaffected.',
|
||||
),
|
||||
),
|
||||
const RadioListTile.adaptive(
|
||||
value: TorRegularTabProxyMode.all,
|
||||
title: Text('Global Routing'),
|
||||
subtitle: Text(
|
||||
'Route all regular tabs through the Tor network. Private tabs remain unaffected.',
|
||||
),
|
||||
),
|
||||
if (!showContainerUi &&
|
||||
torSettings.proxyRegularTabsMode ==
|
||||
TorRegularTabProxyMode.container)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 56,
|
||||
right: 24,
|
||||
top: 4,
|
||||
),
|
||||
child: Text(
|
||||
'Container-based routing is currently active but hidden because Container UI is disabled.',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
SwitchListTile.adaptive(
|
||||
inactiveThumbColor: Colors.white,
|
||||
activeThumbColor: appColors.torActiveGreen,
|
||||
thumbIcon: WidgetStateProperty.resolveWith<Icon?>((
|
||||
Set<WidgetState> states,
|
||||
) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return const Icon(MdiIcons.incognito);
|
||||
}
|
||||
return null; // Use the default color.
|
||||
}),
|
||||
value: torSettings.proxyPrivateTabsTor,
|
||||
title: const Text('Proxy Private Tabs'),
|
||||
subtitle: const Text(
|
||||
'When enabled, all Private Tabs will be tunneled through Tor',
|
||||
),
|
||||
secondary: const Icon(MdiIcons.incognito),
|
||||
onChanged: (value) async {
|
||||
await ref
|
||||
.read(saveTorSettingsControllerProvider.notifier)
|
||||
.save(
|
||||
(currentSettings) => currentSettings.copyWith
|
||||
.proxyPrivateTabsTor(value),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 24.0),
|
||||
child: Text(
|
||||
'Circumvention',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
SwitchListTile.adaptive(
|
||||
inactiveThumbColor: Colors.white,
|
||||
activeThumbColor: appColors.torActiveGreen,
|
||||
thumbIcon: WidgetStateProperty.resolveWith<Icon?>((
|
||||
Set<WidgetState> states,
|
||||
) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return const Icon(MdiIcons.arrowDecisionAuto);
|
||||
}
|
||||
return null; // Use the default color.
|
||||
}),
|
||||
value: torSettings.config == TorConnectionConfig.auto,
|
||||
title: const Text('Auto Configure Transport'),
|
||||
subtitle: const Text(
|
||||
'From some locations, it is necessary to use a pluggable transport to connect to Tor',
|
||||
),
|
||||
secondary: const Icon(MdiIcons.arrowDecisionAuto),
|
||||
onChanged: torIsBusy
|
||||
? null
|
||||
: (value) async {
|
||||
await ref
|
||||
.read(
|
||||
saveTorSettingsControllerProvider.notifier,
|
||||
)
|
||||
.save(
|
||||
(currentSettings) =>
|
||||
currentSettings.copyWith.config(
|
||||
value
|
||||
? TorConnectionConfig.auto
|
||||
: TorConnectionConfig.direct,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (torSettings.config == TorConnectionConfig.auto) ...[
|
||||
SwitchListTile.adaptive(
|
||||
inactiveThumbColor: Colors.white,
|
||||
activeThumbColor: appColors.torActiveGreen,
|
||||
value: torSettings.requireBridge,
|
||||
contentPadding: const EdgeInsets.only(
|
||||
left: 56,
|
||||
right: 24,
|
||||
),
|
||||
onChanged: torIsBusy
|
||||
? null
|
||||
: (value) async {
|
||||
await ref
|
||||
.read(
|
||||
saveTorSettingsControllerProvider
|
||||
.notifier,
|
||||
)
|
||||
.save(
|
||||
(currentSettings) => currentSettings
|
||||
.copyWith
|
||||
.requireBridge(value),
|
||||
);
|
||||
},
|
||||
title: const Text(
|
||||
"I'm sure I cannot connect without a bridge",
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
RadioGroup(
|
||||
groupValue: torSettings.config,
|
||||
onChanged: (value) async {
|
||||
if (value != null) {
|
||||
await ref
|
||||
.read(
|
||||
saveTorSettingsControllerProvider.notifier,
|
||||
)
|
||||
.save(
|
||||
(currentSettings) =>
|
||||
currentSettings.copyWith.config(value),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
RadioListTile.adaptive(
|
||||
value: TorConnectionConfig.direct,
|
||||
enabled: !torIsBusy,
|
||||
contentPadding: const EdgeInsets.only(
|
||||
left: 56,
|
||||
right: 24,
|
||||
),
|
||||
title: const Text('Direct Connection'),
|
||||
subtitle: const Text(
|
||||
'The best way to connect to Tor if Tor is not blocked',
|
||||
),
|
||||
),
|
||||
RadioListTile.adaptive(
|
||||
value: TorConnectionConfig.obfs4,
|
||||
enabled: !torIsBusy,
|
||||
contentPadding: const EdgeInsets.only(
|
||||
left: 56,
|
||||
right: 24,
|
||||
),
|
||||
title: const Text('obfs4'),
|
||||
subtitle: const Text(
|
||||
'Suitable for light censorship and high bandwidth needs',
|
||||
),
|
||||
),
|
||||
RadioListTile.adaptive(
|
||||
value: TorConnectionConfig.snowflake,
|
||||
enabled: !torIsBusy,
|
||||
contentPadding: const EdgeInsets.only(
|
||||
left: 56,
|
||||
right: 24,
|
||||
),
|
||||
title: const Text('Snowflake'),
|
||||
subtitle: const Text(
|
||||
'Suitable for heavy censorship',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
CheckboxListTile.adaptive(
|
||||
value: torSettings.fetchRemoteBridges,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
enabled:
|
||||
torSettings.config != TorConnectionConfig.direct,
|
||||
contentPadding: const EdgeInsets.only(
|
||||
left: 56,
|
||||
right: 24,
|
||||
),
|
||||
onChanged: torIsBusy
|
||||
? null
|
||||
: (value) async {
|
||||
if (value != null) {
|
||||
await ref
|
||||
.read(
|
||||
saveTorSettingsControllerProvider
|
||||
.notifier,
|
||||
)
|
||||
.save(
|
||||
(currentSettings) => currentSettings
|
||||
.copyWith
|
||||
.fetchRemoteBridges(value),
|
||||
);
|
||||
}
|
||||
},
|
||||
title: const Text(
|
||||
"Fetch fresh Bridges before connecting",
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 24.0),
|
||||
child: Text(
|
||||
'Country Restrictions',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
enabled: !torIsBusy,
|
||||
leading:
|
||||
torSettings.entryNodeCountry.mapNotNull(
|
||||
(code) => CountryFlag.fromCountryCode(
|
||||
code,
|
||||
theme: const EmojiTheme(size: 28),
|
||||
),
|
||||
) ??
|
||||
const Icon(Icons.public, color: Colors.white),
|
||||
title: const Text('Entry Country'),
|
||||
subtitle: Text(
|
||||
torSettings.entryNodeCountry ?? 'Automatic',
|
||||
),
|
||||
trailing: const Icon(
|
||||
MdiIcons.chevronRight,
|
||||
color: Colors.white,
|
||||
),
|
||||
onTap: () async {
|
||||
final result = await TorCountryPickerRoute(
|
||||
title: 'Entry Country',
|
||||
$extra: torSettings.entryNodeCountry,
|
||||
).push<String>(context);
|
||||
if (result == null) return;
|
||||
final value = result == automaticCountry
|
||||
? null
|
||||
: result;
|
||||
await ref
|
||||
.read(saveTorSettingsControllerProvider.notifier)
|
||||
.save(
|
||||
(currentSettings) => currentSettings.copyWith
|
||||
.entryNodeCountry(value),
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
enabled: !torIsBusy,
|
||||
leading:
|
||||
torSettings.exitNodeCountry.mapNotNull(
|
||||
(code) => CountryFlag.fromCountryCode(
|
||||
code,
|
||||
theme: const EmojiTheme(size: 28),
|
||||
),
|
||||
) ??
|
||||
const Icon(Icons.public, color: Colors.white),
|
||||
title: const Text('Exit Country'),
|
||||
subtitle: Text(
|
||||
torSettings.exitNodeCountry ?? 'Automatic',
|
||||
),
|
||||
trailing: const Icon(
|
||||
MdiIcons.chevronRight,
|
||||
color: Colors.white,
|
||||
),
|
||||
onTap: () async {
|
||||
final result = await TorCountryPickerRoute(
|
||||
title: 'Exit Country',
|
||||
$extra: torSettings.exitNodeCountry,
|
||||
).push<String>(context);
|
||||
if (result == null) return;
|
||||
final value = result == automaticCountry
|
||||
? null
|
||||
: result;
|
||||
await ref
|
||||
.read(saveTorSettingsControllerProvider.notifier)
|
||||
.save(
|
||||
(currentSettings) => currentSettings.copyWith
|
||||
.exitNodeCountry(value),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 32.0,
|
||||
right: 12.0,
|
||||
left: 12.0,
|
||||
bottom: 8.0,
|
||||
),
|
||||
child: Text(
|
||||
'Tor is a trademark of The Tor Project; all rights reserved. WebLibre is not endorsed or sponsored by, or affiliated with, the Tor Project.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
return const SettingsDetailScaffold(
|
||||
title: 'Tor™ Proxy',
|
||||
subtitle:
|
||||
'Onion routing, pluggable transports, bridges and country restrictions.',
|
||||
icon: TorIcons.onionAlt,
|
||||
sections: torProxySettingsSections,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TorServiceTile extends HookConsumerWidget {
|
||||
const _TorServiceTile();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final status = ref.watch(
|
||||
torProxyServiceProvider.select((value) => value.value),
|
||||
);
|
||||
final pendingRequest = useState<bool?>(null);
|
||||
|
||||
ref.listen(torProxyServiceProvider, (previous, next) {
|
||||
if (next.hasValue && pendingRequest.value != null) {
|
||||
final current = next.requireValue;
|
||||
final prev = previous?.value;
|
||||
if (current.isRunning != prev?.isRunning ||
|
||||
current.bootstrapProgress != prev?.bootstrapProgress) {
|
||||
if (pendingRequest.value == true) {
|
||||
if (current.bootstrapProgress > 0) {
|
||||
pendingRequest.value = null;
|
||||
}
|
||||
} else {
|
||||
pendingRequest.value = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
final isRunning = status?.isRunning ?? false;
|
||||
final progress = status?.bootstrapProgress ?? 0;
|
||||
final isBusy = pendingRequest.value != null || (status?.isBusy ?? false);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
SwitchListTile.adaptive(
|
||||
secondary: const Icon(MdiIcons.power),
|
||||
title: const Text('Tor™ Service'),
|
||||
subtitle: const Text('Start or stop the Tor service'),
|
||||
value: pendingRequest.value ?? isRunning,
|
||||
onChanged: isBusy
|
||||
? null
|
||||
: (value) async {
|
||||
if (value) {
|
||||
pendingRequest.value = true;
|
||||
await ref
|
||||
.read(torProxyServiceProvider.notifier)
|
||||
.startOrReconfigure(reconfigureIfRunning: false);
|
||||
} else {
|
||||
pendingRequest.value = false;
|
||||
await ref
|
||||
.read(torProxyServiceProvider.notifier)
|
||||
.disconnect();
|
||||
}
|
||||
},
|
||||
),
|
||||
if (pendingRequest.value != false && isBusy)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: LinearProgressIndicator(value: progress / 100),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RequestNewIdentityTile extends ConsumerWidget {
|
||||
const _RequestNewIdentityTile();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final status = ref.watch(
|
||||
torProxyServiceProvider.select((value) => value.value),
|
||||
);
|
||||
final enabled = status?.isReady ?? false;
|
||||
|
||||
return ListTile(
|
||||
enabled: enabled,
|
||||
leading: const Icon(MdiIcons.refresh),
|
||||
title: const Text('Request New Identity'),
|
||||
subtitle: const Text('Use a fresh circuit for new connections'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () async {
|
||||
await ref.read(torProxyServiceProvider.notifier).requestNewIdentity();
|
||||
if (context.mounted) {
|
||||
showInfoMessage(context, 'Requesting new Tor identity...');
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AutoConfigureTransportTile extends ConsumerWidget {
|
||||
const _AutoConfigureTransportTile();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final torSettings = ref.watch(torSettingsWithDefaultsProvider);
|
||||
final isBusy = ref.watch(
|
||||
torProxyServiceProvider.select((value) => value.isBusy),
|
||||
);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
SwitchListTile.adaptive(
|
||||
secondary: const Icon(MdiIcons.arrowDecisionAuto),
|
||||
title: const Text('Auto Configure Transport'),
|
||||
subtitle: const Text(
|
||||
'From some locations, it is necessary to use a pluggable transport to connect to Tor',
|
||||
),
|
||||
value: torSettings.config == TorConnectionConfig.auto,
|
||||
onChanged: isBusy
|
||||
? null
|
||||
: (value) async {
|
||||
await ref
|
||||
.read(saveTorSettingsControllerProvider.notifier)
|
||||
.save(
|
||||
(current) => current.copyWith.config(
|
||||
value
|
||||
? TorConnectionConfig.auto
|
||||
: TorConnectionConfig.direct,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (torSettings.config == TorConnectionConfig.auto)
|
||||
SwitchListTile.adaptive(
|
||||
contentPadding: const EdgeInsets.only(left: 56, right: 24),
|
||||
title: const Text("I'm sure I cannot connect without a bridge"),
|
||||
value: torSettings.requireBridge,
|
||||
onChanged: isBusy
|
||||
? null
|
||||
: (value) async {
|
||||
await ref
|
||||
.read(saveTorSettingsControllerProvider.notifier)
|
||||
.save(
|
||||
(current) => current.copyWith.requireBridge(value),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TransportSection extends ConsumerWidget {
|
||||
const _TransportSection();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final torSettings = ref.watch(torSettingsWithDefaultsProvider);
|
||||
final isBusy = ref.watch(
|
||||
torProxyServiceProvider.select((value) => value.isBusy),
|
||||
);
|
||||
|
||||
if (torSettings.config == TorConnectionConfig.auto) {
|
||||
return const ListTile(
|
||||
leading: Icon(Icons.info_outline),
|
||||
title: Text('Auto-configured'),
|
||||
subtitle: Text(
|
||||
'Disable auto-configure above to pick a transport manually.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
RadioGroup<TorConnectionConfig>(
|
||||
groupValue: torSettings.config,
|
||||
onChanged: (value) async {
|
||||
if (value != null) {
|
||||
await ref
|
||||
.read(saveTorSettingsControllerProvider.notifier)
|
||||
.save((current) => current.copyWith.config(value));
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
RadioListTile<TorConnectionConfig>.adaptive(
|
||||
value: TorConnectionConfig.direct,
|
||||
enabled: !isBusy,
|
||||
title: const Text('Direct Connection'),
|
||||
subtitle: const Text(
|
||||
'The best way to connect to Tor if Tor is not blocked',
|
||||
),
|
||||
),
|
||||
RadioListTile<TorConnectionConfig>.adaptive(
|
||||
value: TorConnectionConfig.obfs4,
|
||||
enabled: !isBusy,
|
||||
title: const Text('obfs4'),
|
||||
subtitle: const Text(
|
||||
'Suitable for light censorship and high bandwidth needs',
|
||||
),
|
||||
),
|
||||
RadioListTile<TorConnectionConfig>.adaptive(
|
||||
value: TorConnectionConfig.snowflake,
|
||||
enabled: !isBusy,
|
||||
title: const Text('Snowflake'),
|
||||
subtitle: const Text('Suitable for heavy censorship'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
CheckboxListTile.adaptive(
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
enabled: !isBusy && torSettings.config != TorConnectionConfig.direct,
|
||||
value: torSettings.fetchRemoteBridges,
|
||||
title: const Text('Fetch fresh Bridges before connecting'),
|
||||
onChanged: isBusy
|
||||
? null
|
||||
: (value) async {
|
||||
if (value != null) {
|
||||
await ref
|
||||
.read(saveTorSettingsControllerProvider.notifier)
|
||||
.save(
|
||||
(current) =>
|
||||
current.copyWith.fetchRemoteBridges(value),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _NodeRole {
|
||||
entry(title: 'Entry Country'),
|
||||
exit(title: 'Exit Country');
|
||||
|
||||
const _NodeRole({required this.title});
|
||||
|
||||
final String title;
|
||||
}
|
||||
|
||||
class _CountryPickerTile extends ConsumerWidget {
|
||||
const _CountryPickerTile({required this.role});
|
||||
|
||||
final _NodeRole role;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final torSettings = ref.watch(torSettingsWithDefaultsProvider);
|
||||
final isBusy = ref.watch(
|
||||
torProxyServiceProvider.select((value) => value.isBusy),
|
||||
);
|
||||
final country = switch (role) {
|
||||
_NodeRole.entry => torSettings.entryNodeCountry,
|
||||
_NodeRole.exit => torSettings.exitNodeCountry,
|
||||
};
|
||||
|
||||
return ListTile(
|
||||
enabled: !isBusy,
|
||||
leading:
|
||||
country.mapNotNull(
|
||||
(code) => CountryFlag.fromCountryCode(
|
||||
code,
|
||||
theme: const EmojiTheme(size: 28),
|
||||
),
|
||||
) ??
|
||||
const Icon(Icons.public),
|
||||
title: Text(role.title),
|
||||
subtitle: Text(country ?? 'Automatic'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () async {
|
||||
final result = await TorCountryPickerRoute(
|
||||
title: role.title,
|
||||
$extra: country,
|
||||
).push<String>(context);
|
||||
if (result == null) return;
|
||||
final value = result == automaticCountry ? null : result;
|
||||
await ref
|
||||
.read(saveTorSettingsControllerProvider.notifier)
|
||||
.save(
|
||||
(current) => switch (role) {
|
||||
_NodeRole.entry => current.copyWith.entryNodeCountry(value),
|
||||
_NodeRole.exit => current.copyWith.exitNodeCountry(value),
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/core/design/app_colors.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/controllers/overlay.dart';
|
||||
import 'package:weblibre/features/tor/domain/extensions/tor_status_x.dart';
|
||||
import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
|
||||
import 'package:weblibre/presentation/icons/tor_icons.dart';
|
||||
import 'package:weblibre/presentation/widgets/animate_gradient_shader.dart';
|
||||
@@ -33,10 +34,7 @@ class TorNotification extends HookConsumerWidget {
|
||||
final appColors = AppColors.of(context);
|
||||
|
||||
ref.listen(torProxyServiceProvider, (previous, next) {
|
||||
final status = next.value;
|
||||
if (status != null &&
|
||||
status.isRunning &&
|
||||
status.bootstrapProgress >= 100) {
|
||||
if (next.isReady) {
|
||||
ref.read(overlayControllerProvider.notifier).dismiss();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user