added support for tor entry/exit countries

This commit is contained in:
Fabian Freund
2026-01-08 18:42:03 +01:00
parent f23c1fa04f
commit 2741876ed5
9 changed files with 276 additions and 35 deletions
@@ -136,6 +136,8 @@ class TorProxyService extends _$TorProxyService {
null => TransportType.none,
},
bridgeLines: setting?.bridge.bridges ?? [],
entryNodeCountries: torSettings.entryNodeCountry?.toLowerCase(),
exitNodeCountries: torSettings.exitNodeCountry?.toLowerCase(),
);
await _tor.start(config);
@@ -155,6 +157,10 @@ class TorProxyService extends _$TorProxyService {
await _tor.stop();
}
Future<void> requestNewIdentity() async {
await _tor.requestNewIdentity();
}
@override
Stream<TorStatus> build() {
_statusSyncController = StreamController();
@@ -33,7 +33,7 @@ final class TorProxyServiceProvider
TorProxyService create() => TorProxyService();
}
String _$torProxyServiceHash() => r'be69327bdef8feaa2a43bd83f20168988dcdd228';
String _$torProxyServiceHash() => r'0bf00b61bf7e739fe8c77696cf1b2a151d137671';
abstract class _$TorProxyService extends $StreamNotifier<TorStatus> {
Stream<TorStatus> build();
@@ -17,11 +17,14 @@
* 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:country_codes/country_codes.dart';
import 'package:country_flags/country_flags.dart';
import 'package:fading_scroll/fading_scroll.dart';
import 'package:flutter/material.dart';
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/features/settings/presentation/controllers/save_settings.dart';
import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
@@ -29,6 +32,7 @@ import 'package:weblibre/features/user/data/models/tor_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';
class TorProxyScreen extends HookConsumerWidget {
const TorProxyScreen();
@@ -89,6 +93,39 @@ class TorProxyScreen extends HookConsumerWidget {
}
});
final countryDropdownEntries = useMemoized(
() => [
const DropdownMenuEntry(
value: null,
label: 'Automatic',
leadingIcon: Icon(MdiIcons.lightbulbAuto),
),
...CountryCodes.countryCodes().map((country) {
final label =
country.localizedName ??
country.name ??
country.alpha2Code ??
country.countryCode ??
'Unnamed Country';
return DropdownMenuEntry(
value: country.alpha2Code,
label: label,
leadingIcon: country.alpha2Code.mapNotNull(
(code) => CountryFlag.fromCountryCode(
code,
theme: const ImageTheme(width: 24, height: 16),
),
),
labelWidget: Text(
label,
style: const TextStyle(color: Colors.white),
),
);
}),
],
);
return Scaffold(
appBar: AppBar(title: const Text('Tor™ Proxy')),
body: Theme(
@@ -139,39 +176,72 @@ class TorProxyScreen extends HookConsumerWidget {
border: Border.all(color: Colors.white),
borderRadius: BorderRadius.circular(16),
),
child: 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™ Proxy'),
secondary: const Icon(MdiIcons.power),
onChanged: torIsBusy
? null
: (value) async {
if (value) {
torPendingRequest.value = true;
child: Column(
children: [
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™ Proxy'),
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)
.startOrReconfigure(
reconfigureIfRunning: false,
);
} else {
torPendingRequest.value = false;
await ref
.read(torProxyServiceProvider.notifier)
.disconnect();
}
},
await ref
.read(torProxyServiceProvider.notifier)
.disconnect();
}
},
),
Padding(
padding: const EdgeInsets.only(
right: 16.0,
left: 16,
bottom: 16,
),
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'),
),
),
),
],
),
),
const SizedBox(height: 16),
@@ -447,6 +517,123 @@ class TorProxyScreen extends HookConsumerWidget {
),
),
],
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,
),
),
),
Padding(
padding: const EdgeInsets.only(
left: 16.0,
right: 24.0,
top: 8.0,
),
child: DropdownMenu(
initialSelection:
torSettings.entryNodeCountry,
menuHeight: 400,
requestFocusOnTap: true,
label: const Text('Entry Country'),
textStyle: const TextStyle(
color: Colors.white,
),
inputDecorationTheme:
const InputDecorationTheme(
labelStyle: TextStyle(
color: Colors.white,
),
iconColor: Colors.white,
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(
color: Colors.white,
),
),
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(
color: Colors.white,
),
),
),
// menuStyle: MenuStyle(
// backgroundColor:
// WidgetStateProperty.all<Color>(
// AppColors.torPurple,
// ),
// ),
dropdownMenuEntries: countryDropdownEntries,
onSelected: (value) async {
await ref
.read(
saveTorSettingsControllerProvider
.notifier,
)
.save(
(currentSettings) => currentSettings
.copyWith
.entryNodeCountry(value),
);
},
),
),
Padding(
padding: const EdgeInsets.only(
left: 16.0,
right: 24.0,
top: 8.0,
),
child: DropdownMenu(
initialSelection: torSettings.exitNodeCountry,
menuHeight: 400,
requestFocusOnTap: true,
label: const Text('Exit Country'),
textStyle: const TextStyle(
color: Colors.white,
),
inputDecorationTheme:
const InputDecorationTheme(
labelStyle: TextStyle(
color: Colors.white,
),
iconColor: Colors.white,
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(
color: Colors.white,
),
),
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(
color: Colors.white,
),
),
),
// menuStyle: MenuStyle(
// backgroundColor:
// WidgetStateProperty.all<Color>(
// AppColors.torPurple,
// ),
// ),
dropdownMenuEntries: countryDropdownEntries,
onSelected: (value) async {
await ref
.read(
saveTorSettingsControllerProvider
.notifier,
)
.save(
(currentSettings) => currentSettings
.copyWith
.exitNodeCountry(value),
);
},
),
),
],
);
},
@@ -503,8 +690,8 @@ class TorProxyScreen extends HookConsumerWidget {
),
Padding(
padding: const EdgeInsets.only(
right: 8.0,
left: 8.0,
right: 12.0,
left: 12.0,
bottom: 8.0,
),
child: Text(
@@ -35,6 +35,8 @@ class TorSettings with FastEquatable {
final TorConnectionConfig config;
final bool requireBridge;
final bool fetchRemoteBridges;
final String? entryNodeCountry;
final String? exitNodeCountry;
TorSettings({
required this.proxyRegularTabsMode,
@@ -42,6 +44,8 @@ class TorSettings with FastEquatable {
required this.config,
required this.requireBridge,
required this.fetchRemoteBridges,
required this.entryNodeCountry,
required this.exitNodeCountry,
});
TorSettings.withDefaults({
@@ -50,6 +54,8 @@ class TorSettings with FastEquatable {
TorConnectionConfig? config,
bool? requireBridge,
bool? fetchRemoteBridges,
this.entryNodeCountry,
this.exitNodeCountry,
}) : proxyRegularTabsMode =
proxyRegularTabsMode ?? TorRegularTabProxyMode.container,
proxyPrivateTabsTor = proxyPrivateTabsTor ?? false,
@@ -69,5 +75,7 @@ class TorSettings with FastEquatable {
config,
requireBridge,
fetchRemoteBridges,
entryNodeCountry,
exitNodeCountry,
];
}
@@ -17,6 +17,10 @@ abstract class _$TorSettingsCWProxy {
TorSettings fetchRemoteBridges(bool fetchRemoteBridges);
TorSettings entryNodeCountry(String? entryNodeCountry);
TorSettings exitNodeCountry(String? exitNodeCountry);
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `TorSettings(...).copyWith.fieldName(value)`.
///
@@ -30,6 +34,8 @@ abstract class _$TorSettingsCWProxy {
TorConnectionConfig config,
bool requireBridge,
bool fetchRemoteBridges,
String? entryNodeCountry,
String? exitNodeCountry,
});
}
@@ -60,6 +66,14 @@ class _$TorSettingsCWProxyImpl implements _$TorSettingsCWProxy {
TorSettings fetchRemoteBridges(bool fetchRemoteBridges) =>
call(fetchRemoteBridges: fetchRemoteBridges);
@override
TorSettings entryNodeCountry(String? entryNodeCountry) =>
call(entryNodeCountry: entryNodeCountry);
@override
TorSettings exitNodeCountry(String? exitNodeCountry) =>
call(exitNodeCountry: exitNodeCountry);
@override
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `TorSettings(...).copyWith.fieldName(value)`.
@@ -74,6 +88,8 @@ class _$TorSettingsCWProxyImpl implements _$TorSettingsCWProxy {
Object? config = const $CopyWithPlaceholder(),
Object? requireBridge = const $CopyWithPlaceholder(),
Object? fetchRemoteBridges = const $CopyWithPlaceholder(),
Object? entryNodeCountry = const $CopyWithPlaceholder(),
Object? exitNodeCountry = const $CopyWithPlaceholder(),
}) {
return TorSettings(
proxyRegularTabsMode:
@@ -103,6 +119,14 @@ class _$TorSettingsCWProxyImpl implements _$TorSettingsCWProxy {
? _value.fetchRemoteBridges
// ignore: cast_nullable_to_non_nullable
: fetchRemoteBridges as bool,
entryNodeCountry: entryNodeCountry == const $CopyWithPlaceholder()
? _value.entryNodeCountry
// ignore: cast_nullable_to_non_nullable
: entryNodeCountry as String?,
exitNodeCountry: exitNodeCountry == const $CopyWithPlaceholder()
? _value.exitNodeCountry
// ignore: cast_nullable_to_non_nullable
: exitNodeCountry as String?,
);
}
}
@@ -128,6 +152,8 @@ TorSettings _$TorSettingsFromJson(Map<String, dynamic> json) =>
config: $enumDecodeNullable(_$TorConnectionConfigEnumMap, json['config']),
requireBridge: json['requireBridge'] as bool?,
fetchRemoteBridges: json['fetchRemoteBridges'] as bool?,
entryNodeCountry: json['entryNodeCountry'] as String?,
exitNodeCountry: json['exitNodeCountry'] as String?,
);
Map<String, dynamic> _$TorSettingsToJson(TorSettings instance) =>
@@ -138,6 +164,8 @@ Map<String, dynamic> _$TorSettingsToJson(TorSettings instance) =>
'config': _$TorConnectionConfigEnumMap[instance.config]!,
'requireBridge': instance.requireBridge,
'fetchRemoteBridges': instance.fetchRemoteBridges,
'entryNodeCountry': instance.entryNodeCountry,
'exitNodeCountry': instance.exitNodeCountry,
};
const _$TorRegularTabProxyModeEnumMap = {
@@ -55,6 +55,14 @@ class TorSettingsRepository extends _$TorSettingsRepository {
DriftSqlType.bool,
db.typeMapping,
),
'entryNodeCountry': settings['entryNodeCountry']?.readAs(
DriftSqlType.string,
db.typeMapping,
),
'exitNodeCountry': settings['exitNodeCountry']?.readAs(
DriftSqlType.string,
db.typeMapping,
),
});
}
@@ -34,7 +34,7 @@ final class TorSettingsRepositoryProvider
}
String _$torSettingsRepositoryHash() =>
r'341730ed58b49ab3d721583e0f2d5ac650018918';
r'f771f23f17903bd192b24604bab522fb20571ffd';
abstract class _$TorSettingsRepository extends $StreamNotifier<TorSettings> {
Stream<TorSettings> build();
+3
View File
@@ -20,6 +20,7 @@
import 'dart:developer';
import 'package:background_fetch/background_fetch.dart';
import 'package:country_codes/country_codes.dart';
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@@ -74,6 +75,8 @@ class _MainWidget extends HookConsumerWidget {
);
useOnInitialization(() async {
await CountryCodes.init();
final engineSettings = await ref
.read(engineSettingsRepositoryProvider.notifier)
.fetchSettings();
+1
View File
@@ -13,6 +13,7 @@ dependencies:
collection: ^1.19.1
convert: ^3.1.2
copy_with_extension: ^10.0.1
country_codes: ^3.3.0
country_flags: ^4.1.1
cryptography_flutter: ^2.3.4
drift: ^2.30.0