prepare for multiple apps
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* 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:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'moat.g.dart';
|
||||
|
||||
enum MoatTransportType {
|
||||
obfs4('obfs4'),
|
||||
snowflake('snowflake'),
|
||||
meek('meek'),
|
||||
meekAzure('meek-azure'),
|
||||
webtunnel('webtunnel');
|
||||
|
||||
const MoatTransportType(this.value);
|
||||
final String value;
|
||||
|
||||
@override
|
||||
String toString() => value;
|
||||
|
||||
static MoatTransportType? fromString(String value) {
|
||||
for (final transport in MoatTransportType.values) {
|
||||
if (transport.value == value) return transport;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class SettingsRequest {
|
||||
final String? country;
|
||||
@JsonKey(toJson: _transportsToJson, fromJson: _transportsFromJson)
|
||||
final List<MoatTransportType> transports;
|
||||
|
||||
const SettingsRequest({
|
||||
this.country,
|
||||
this.transports = const [
|
||||
MoatTransportType.obfs4,
|
||||
MoatTransportType.snowflake,
|
||||
MoatTransportType.webtunnel,
|
||||
],
|
||||
});
|
||||
|
||||
static List<String> _transportsToJson(List<MoatTransportType> transports) =>
|
||||
transports.map((t) => t.value).toList();
|
||||
|
||||
static List<MoatTransportType> _transportsFromJson(List<dynamic> json) => json
|
||||
.cast<String>()
|
||||
.map((s) => MoatTransportType.fromString(s))
|
||||
.where((t) => t != null)
|
||||
.cast<MoatTransportType>()
|
||||
.toList();
|
||||
|
||||
factory SettingsRequest.fromJson(Map<String, dynamic> json) =>
|
||||
_$SettingsRequestFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$SettingsRequestToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class SettingsResponse {
|
||||
final List<Setting>? settings;
|
||||
final String? country;
|
||||
final List<MoatError>? errors;
|
||||
|
||||
const SettingsResponse({this.settings, this.country, this.errors});
|
||||
|
||||
factory SettingsResponse.fromJson(Map<String, dynamic> json) =>
|
||||
_$SettingsResponseFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$SettingsResponseToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class Setting {
|
||||
@JsonKey(name: 'bridges')
|
||||
final Bridge bridge;
|
||||
|
||||
const Setting({required this.bridge});
|
||||
|
||||
factory Setting.fromJson(Map<String, dynamic> json) =>
|
||||
_$SettingFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$SettingToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class Bridge {
|
||||
@JsonKey(toJson: _transportToJson, fromJson: _transportFromJson)
|
||||
final MoatTransportType type;
|
||||
final String source;
|
||||
@JsonKey(name: 'bridge_strings')
|
||||
final List<String>? bridges;
|
||||
|
||||
const Bridge({required this.type, required this.source, this.bridges});
|
||||
|
||||
static String _transportToJson(MoatTransportType transport) =>
|
||||
transport.value;
|
||||
|
||||
static MoatTransportType _transportFromJson(dynamic json) =>
|
||||
MoatTransportType.fromString(json as String)!;
|
||||
|
||||
factory Bridge.fromJson(Map<String, dynamic> json) => _$BridgeFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$BridgeToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class MoatError implements Exception {
|
||||
final String? id;
|
||||
final String? type;
|
||||
final String? version;
|
||||
final int? code;
|
||||
final String? status;
|
||||
final String? detail;
|
||||
|
||||
const MoatError({
|
||||
this.id,
|
||||
this.type,
|
||||
this.version,
|
||||
this.code,
|
||||
this.status,
|
||||
this.detail,
|
||||
});
|
||||
|
||||
factory MoatError.fromJson(Map<String, dynamic> json) =>
|
||||
_$MoatErrorFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$MoatErrorToJson(this);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
if (detail != null && detail!.isNotEmpty) {
|
||||
return detail!;
|
||||
}
|
||||
return '$code $status';
|
||||
}
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class BuiltInBridges {
|
||||
final List<String> meek;
|
||||
|
||||
@JsonKey(name: 'meek-azure')
|
||||
final List<String> meekAzure;
|
||||
|
||||
final List<String> obfs4;
|
||||
final List<String> snowflake;
|
||||
|
||||
const BuiltInBridges({
|
||||
required this.meek,
|
||||
required this.meekAzure,
|
||||
required this.obfs4,
|
||||
required this.snowflake,
|
||||
});
|
||||
|
||||
factory BuiltInBridges.fromJson(Map<String, dynamic> json) =>
|
||||
_$BuiltInBridgesFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$BuiltInBridgesToJson(this);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'moat.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
SettingsRequest _$SettingsRequestFromJson(Map<String, dynamic> json) =>
|
||||
SettingsRequest(
|
||||
country: json['country'] as String?,
|
||||
transports: json['transports'] == null
|
||||
? const [
|
||||
MoatTransportType.obfs4,
|
||||
MoatTransportType.snowflake,
|
||||
MoatTransportType.webtunnel,
|
||||
]
|
||||
: SettingsRequest._transportsFromJson(json['transports'] as List),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SettingsRequestToJson(SettingsRequest instance) =>
|
||||
<String, dynamic>{
|
||||
'country': instance.country,
|
||||
'transports': SettingsRequest._transportsToJson(instance.transports),
|
||||
};
|
||||
|
||||
SettingsResponse _$SettingsResponseFromJson(Map<String, dynamic> json) =>
|
||||
SettingsResponse(
|
||||
settings: (json['settings'] as List<dynamic>?)
|
||||
?.map((e) => Setting.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
country: json['country'] as String?,
|
||||
errors: (json['errors'] as List<dynamic>?)
|
||||
?.map((e) => MoatError.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SettingsResponseToJson(SettingsResponse instance) =>
|
||||
<String, dynamic>{
|
||||
'settings': instance.settings?.map((e) => e.toJson()).toList(),
|
||||
'country': instance.country,
|
||||
'errors': instance.errors?.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
|
||||
Setting _$SettingFromJson(Map<String, dynamic> json) =>
|
||||
Setting(bridge: Bridge.fromJson(json['bridges'] as Map<String, dynamic>));
|
||||
|
||||
Map<String, dynamic> _$SettingToJson(Setting instance) => <String, dynamic>{
|
||||
'bridges': instance.bridge.toJson(),
|
||||
};
|
||||
|
||||
Bridge _$BridgeFromJson(Map<String, dynamic> json) => Bridge(
|
||||
type: Bridge._transportFromJson(json['type']),
|
||||
source: json['source'] as String,
|
||||
bridges: (json['bridge_strings'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$BridgeToJson(Bridge instance) => <String, dynamic>{
|
||||
'type': Bridge._transportToJson(instance.type),
|
||||
'source': instance.source,
|
||||
'bridge_strings': instance.bridges,
|
||||
};
|
||||
|
||||
MoatError _$MoatErrorFromJson(Map<String, dynamic> json) => MoatError(
|
||||
id: json['id'] as String?,
|
||||
type: json['type'] as String?,
|
||||
version: json['version'] as String?,
|
||||
code: (json['code'] as num?)?.toInt(),
|
||||
status: json['status'] as String?,
|
||||
detail: json['detail'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$MoatErrorToJson(MoatError instance) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'type': instance.type,
|
||||
'version': instance.version,
|
||||
'code': instance.code,
|
||||
'status': instance.status,
|
||||
'detail': instance.detail,
|
||||
};
|
||||
|
||||
BuiltInBridges _$BuiltInBridgesFromJson(Map<String, dynamic> json) =>
|
||||
BuiltInBridges(
|
||||
meek: (json['meek'] as List<dynamic>).map((e) => e as String).toList(),
|
||||
meekAzure: (json['meek-azure'] as List<dynamic>)
|
||||
.map((e) => e as String)
|
||||
.toList(),
|
||||
obfs4: (json['obfs4'] as List<dynamic>).map((e) => e as String).toList(),
|
||||
snowflake: (json['snowflake'] as List<dynamic>)
|
||||
.map((e) => e as String)
|
||||
.toList(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$BuiltInBridgesToJson(BuiltInBridges instance) =>
|
||||
<String, dynamic>{
|
||||
'meek': instance.meek,
|
||||
'meek-azure': instance.meekAzure,
|
||||
'obfs4': instance.obfs4,
|
||||
'snowflake': instance.snowflake,
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* 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:flutter/services.dart' show rootBundle;
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart' as path_provider;
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/features/tor/data/models/moat.dart';
|
||||
|
||||
part 'builtin_bridges.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class BuiltinBridgesService extends _$BuiltinBridgesService {
|
||||
late final _bridgeFileFuture = path_provider
|
||||
.getApplicationSupportDirectory()
|
||||
.then((dir) => File(p.join(dir.path, 'builtin-bridges.json')));
|
||||
|
||||
Future<DateTime?> lastUpdate() async {
|
||||
final bridgeFile = await _bridgeFileFuture;
|
||||
if (!await bridgeFile.exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return bridgeFile.lastModified();
|
||||
}
|
||||
|
||||
Future<void> updateStoredBuiltinBridges(BuiltInBridges bridges) async {
|
||||
final bridgeFile = await _bridgeFileFuture;
|
||||
await bridgeFile.writeAsString(jsonEncode(bridges.toJson()), flush: true);
|
||||
}
|
||||
|
||||
Future<BuiltInBridges?> getStoredBuiltinBridges() async {
|
||||
final bridgeFile = await _bridgeFileFuture;
|
||||
if (!await bridgeFile.exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final content = await bridgeFile.readAsString();
|
||||
return BuiltInBridges.fromJson(jsonDecode(content) as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<BuiltInBridges> getBundledBuiltinBridges() async {
|
||||
final content = await rootBundle.loadString(
|
||||
'assets/preferences/builtin-bridges.json',
|
||||
);
|
||||
return BuiltInBridges.fromJson(jsonDecode(content) as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
@override
|
||||
void build() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'builtin_bridges.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(BuiltinBridgesService)
|
||||
final builtinBridgesServiceProvider = BuiltinBridgesServiceProvider._();
|
||||
|
||||
final class BuiltinBridgesServiceProvider
|
||||
extends $NotifierProvider<BuiltinBridgesService, void> {
|
||||
BuiltinBridgesServiceProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'builtinBridgesServiceProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$builtinBridgesServiceHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
BuiltinBridgesService create() => BuiltinBridgesService();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(void value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<void>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$builtinBridgesServiceHash() =>
|
||||
r'beacb3c9d8c5a7179c7c9cad8024d128b09241f5';
|
||||
|
||||
abstract class _$BuiltinBridgesService 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,99 @@
|
||||
/*
|
||||
* 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/tor/data/models/moat.dart';
|
||||
|
||||
class MoatApi {
|
||||
static const String _baseUrl =
|
||||
'https://bridges.torproject.org/moat/circumvention/';
|
||||
|
||||
final http.Client _client;
|
||||
|
||||
MoatApi(this._client);
|
||||
|
||||
Future<SettingsResponse> settings([SettingsRequest? request]) async {
|
||||
final response = await _post(
|
||||
'settings',
|
||||
request ?? const SettingsRequest(),
|
||||
);
|
||||
return SettingsResponse.fromJson(
|
||||
jsonDecode(response.body) as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
Future<SettingsResponse> defaults([SettingsRequest? request]) async {
|
||||
final response = await _post(
|
||||
'defaults',
|
||||
request ?? const SettingsRequest(),
|
||||
);
|
||||
return SettingsResponse.fromJson(
|
||||
jsonDecode(response.body) as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, SettingsResponse>> map() async {
|
||||
final response = await _get('map');
|
||||
final Map<String, dynamic> data =
|
||||
jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return data.map(
|
||||
(key, value) => MapEntry(
|
||||
key,
|
||||
SettingsResponse.fromJson(value as Map<String, dynamic>),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<BuiltInBridges> builtin() async {
|
||||
final response = await _get('builtin');
|
||||
return BuiltInBridges.fromJson(
|
||||
jsonDecode(response.body) as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<String>> countries() async {
|
||||
final response = await _get('countries');
|
||||
return List<String>.from(jsonDecode(response.body) as List);
|
||||
}
|
||||
|
||||
Future<http.Response> _get(String endpoint) async {
|
||||
final uri = Uri.parse('$_baseUrl$endpoint');
|
||||
return await _client
|
||||
.get(uri, headers: _headers)
|
||||
.timeout(const Duration(seconds: 15));
|
||||
}
|
||||
|
||||
Future<http.Response> _post(String endpoint, Object body) async {
|
||||
final uri = Uri.parse('$_baseUrl$endpoint');
|
||||
return await _client
|
||||
.post(uri, headers: _headers, body: jsonEncode(body))
|
||||
.timeout(const Duration(seconds: 15));
|
||||
}
|
||||
|
||||
Map<String, String> get _headers => {
|
||||
'Content-Type': 'application/vnd.api+json',
|
||||
'Accept': 'application/vnd.api+json',
|
||||
};
|
||||
|
||||
void dispose() {
|
||||
_client.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
* 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:io';
|
||||
|
||||
import 'package:flutter_tor/flutter_tor.dart';
|
||||
import 'package:http/io_client.dart';
|
||||
import 'package:socks5_proxy/socks_client.dart';
|
||||
import 'package:weblibre/features/tor/data/models/moat.dart';
|
||||
import 'package:weblibre/features/tor/data/services/moat_api.dart';
|
||||
|
||||
const String _meekParameters =
|
||||
'url=https://1723079976.rsc.cdn77.org;front=www.phpmyadmin.net';
|
||||
|
||||
const defaultBridges = [MoatTransportType.obfs4, MoatTransportType.snowflake];
|
||||
|
||||
/// Manages MOAT API connections with persistent proxy setup.
|
||||
class MoatService {
|
||||
MoatApi? _api;
|
||||
|
||||
/// Initializes the MeekLite proxy and MOAT API connection.
|
||||
/// Must be called before using other methods.
|
||||
Future<void> initialize() async {
|
||||
if (_api != null) return;
|
||||
|
||||
final port = await IPtProxyController().start(TransportType.meek, "");
|
||||
|
||||
final httpClient = HttpClient();
|
||||
SocksTCPClient.assignToHttpClient(httpClient, [
|
||||
ProxySettings(
|
||||
InternetAddress.loopbackIPv4,
|
||||
port,
|
||||
username: _meekParameters,
|
||||
password: '\u0000',
|
||||
),
|
||||
]);
|
||||
|
||||
_api = MoatApi(IOClient(httpClient));
|
||||
}
|
||||
|
||||
/// Disposes the API connection and stops the MeekLite proxy.
|
||||
/// Should be called when done using the client.
|
||||
Future<void> dispose() async {
|
||||
if (_api == null) return;
|
||||
|
||||
_api!.dispose();
|
||||
await IPtProxyController().stop(TransportType.meek);
|
||||
_api = null;
|
||||
}
|
||||
|
||||
/// Ensures the client is initialized before API calls.
|
||||
void _ensureInitialized() {
|
||||
if (_api == null) {
|
||||
throw StateError(
|
||||
'MoatClient must be initialized before use. Call initialize() first.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles MOAT API errors and determines if PT is required.
|
||||
bool _handleMoatErrors(List<MoatError>? errors) {
|
||||
if (errors == null || errors.isEmpty) return false;
|
||||
|
||||
final error = errors.first;
|
||||
if (error.code == 404 || error.code == 406) {
|
||||
// 404: Needs transport, but not the available ones
|
||||
// 406: No country from IP address
|
||||
return true;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
/// Converts BuiltInBridges to Settings for requested transports.
|
||||
static List<Setting> convertBuiltinToSettings(
|
||||
BuiltInBridges builtinBridges, {
|
||||
List<MoatTransportType> transports = defaultBridges,
|
||||
}) {
|
||||
final settings = <Setting>[];
|
||||
|
||||
for (final transport in transports) {
|
||||
final bridgeStrings = switch (transport) {
|
||||
MoatTransportType.obfs4 => builtinBridges.obfs4,
|
||||
MoatTransportType.snowflake => builtinBridges.snowflake,
|
||||
MoatTransportType.meek => builtinBridges.meek,
|
||||
MoatTransportType.meekAzure => builtinBridges.meekAzure,
|
||||
MoatTransportType.webtunnel => <String>[], // Not available in builtin
|
||||
};
|
||||
|
||||
if (bridgeStrings.isNotEmpty) {
|
||||
settings.add(
|
||||
Setting(
|
||||
bridge: Bridge(
|
||||
type: transport,
|
||||
source: 'builtin',
|
||||
bridges: bridgeStrings,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
/// Gets built-in bridges from the MOAT service endpoint.
|
||||
Future<BuiltInBridges> getBuiltinBridges({
|
||||
List<MoatTransportType> transports = defaultBridges,
|
||||
}) async {
|
||||
_ensureInitialized();
|
||||
|
||||
final builtinBridges = await _api!.builtin();
|
||||
return builtinBridges;
|
||||
}
|
||||
|
||||
/// Tries to automatically configure Pluggable Transports.
|
||||
Future<List<Setting>?> autoConf({
|
||||
String? country,
|
||||
bool cannotConnectWithoutPt = false,
|
||||
List<MoatTransportType> transports = defaultBridges,
|
||||
}) async {
|
||||
_ensureInitialized();
|
||||
|
||||
bool localCannotConnectWithoutPt = cannotConnectWithoutPt;
|
||||
final request = SettingsRequest(country: country, transports: transports);
|
||||
|
||||
var response = await _api!.settings(request);
|
||||
|
||||
if (_handleMoatErrors(response.errors)) {
|
||||
localCannotConnectWithoutPt = true;
|
||||
}
|
||||
|
||||
final hasSettings = response.settings?.isNotEmpty ?? false;
|
||||
if (!hasSettings && !localCannotConnectWithoutPt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hasSettings) {
|
||||
return response.settings;
|
||||
}
|
||||
|
||||
response = await _api!.defaults(SettingsRequest(transports: transports));
|
||||
return response.settings;
|
||||
}
|
||||
|
||||
Future<List<Setting>?> getDefaultBridges({
|
||||
List<MoatTransportType> transports = defaultBridges,
|
||||
}) async {
|
||||
_ensureInitialized();
|
||||
|
||||
final response = await _api!.defaults(
|
||||
SettingsRequest(transports: transports),
|
||||
);
|
||||
|
||||
return response.settings;
|
||||
}
|
||||
|
||||
/// Gets the list of supported countries from the MOAT service.
|
||||
Future<List<String>> getCountries() async {
|
||||
_ensureInitialized();
|
||||
return await _api!.countries();
|
||||
}
|
||||
|
||||
/// Gets the country-to-settings map from the MOAT service.
|
||||
Future<Map<String, SettingsResponse>> getMap() async {
|
||||
_ensureInitialized();
|
||||
return await _api!.map();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* 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/core/logger.dart';
|
||||
import 'package:weblibre/features/tor/data/models/moat.dart';
|
||||
import 'package:weblibre/features/tor/data/services/builtin_bridges.dart';
|
||||
import 'package:weblibre/features/tor/data/services/moat_service.dart';
|
||||
|
||||
part 'builtin_bridges.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class BuiltinBridgesRepository extends _$BuiltinBridgesRepository {
|
||||
Future<void> updateIfNecessary() async {
|
||||
final lastUpdate = await ref
|
||||
.read(builtinBridgesServiceProvider.notifier)
|
||||
.lastUpdate();
|
||||
|
||||
if (lastUpdate == null ||
|
||||
DateTime.now().difference(lastUpdate) > const Duration(days: 2)) {
|
||||
BuiltInBridges? remoteBridges;
|
||||
try {
|
||||
remoteBridges = await service.getBuiltinBridges();
|
||||
} catch (e, s) {
|
||||
logger.e('Failed fetching builtin bridges', error: e, stackTrace: s);
|
||||
}
|
||||
|
||||
if (remoteBridges != null) {
|
||||
await ref
|
||||
.read(builtinBridgesServiceProvider.notifier)
|
||||
.updateStoredBuiltinBridges(remoteBridges);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<BuiltInBridges> getBridges({bool tryUpdate = true}) async {
|
||||
if (tryUpdate) {
|
||||
await updateIfNecessary();
|
||||
}
|
||||
|
||||
final storedBridges = await ref
|
||||
.read(builtinBridgesServiceProvider.notifier)
|
||||
.getStoredBuiltinBridges();
|
||||
|
||||
return storedBridges ??
|
||||
await ref
|
||||
.read(builtinBridgesServiceProvider.notifier)
|
||||
.getBundledBuiltinBridges();
|
||||
}
|
||||
|
||||
@override
|
||||
void build(MoatService service) {}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'builtin_bridges.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(BuiltinBridgesRepository)
|
||||
final builtinBridgesRepositoryProvider = BuiltinBridgesRepositoryFamily._();
|
||||
|
||||
final class BuiltinBridgesRepositoryProvider
|
||||
extends $NotifierProvider<BuiltinBridgesRepository, void> {
|
||||
BuiltinBridgesRepositoryProvider._({
|
||||
required BuiltinBridgesRepositoryFamily super.from,
|
||||
required MoatService super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'builtinBridgesRepositoryProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$builtinBridgesRepositoryHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'builtinBridgesRepositoryProvider'
|
||||
''
|
||||
'($argument)';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
BuiltinBridgesRepository create() => BuiltinBridgesRepository();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(void value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<void>(value),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is BuiltinBridgesRepositoryProvider &&
|
||||
other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$builtinBridgesRepositoryHash() =>
|
||||
r'b06ff04774d6d5b0aa7c4f93469ca514536ad85e';
|
||||
|
||||
final class BuiltinBridgesRepositoryFamily extends $Family
|
||||
with
|
||||
$ClassFamilyOverride<
|
||||
BuiltinBridgesRepository,
|
||||
void,
|
||||
void,
|
||||
void,
|
||||
MoatService
|
||||
> {
|
||||
BuiltinBridgesRepositoryFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'builtinBridgesRepositoryProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: false,
|
||||
);
|
||||
|
||||
BuiltinBridgesRepositoryProvider call(MoatService service) =>
|
||||
BuiltinBridgesRepositoryProvider._(argument: service, from: this);
|
||||
|
||||
@override
|
||||
String toString() => r'builtinBridgesRepositoryProvider';
|
||||
}
|
||||
|
||||
abstract class _$BuiltinBridgesRepository extends $Notifier<void> {
|
||||
late final _$args = ref.$arg as MoatService;
|
||||
MoatService get service => _$args;
|
||||
|
||||
void build(MoatService service);
|
||||
@$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(_$args));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* 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:collection/collection.dart';
|
||||
import 'package:flutter_tor/flutter_tor.dart';
|
||||
import 'package:nullability/nullability.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
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/repositories/builtin_bridges.dart';
|
||||
import 'package:weblibre/features/user/data/models/tor_settings.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/tor_settings.dart';
|
||||
|
||||
part 'tor_proxy.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class TorProxyService extends _$TorProxyService {
|
||||
final _tor = FlutterTor();
|
||||
late StreamController<TorStatus> _statusSyncController;
|
||||
|
||||
Future<TorStatus> startOrReconfigure({
|
||||
required bool reconfigureIfRunning,
|
||||
}) async {
|
||||
final currentStatus = await _tor.getStatus();
|
||||
|
||||
if (!currentStatus.isRunning ||
|
||||
currentStatus.socksPort == null ||
|
||||
reconfigureIfRunning) {
|
||||
state = const AsyncLoading();
|
||||
|
||||
final torSettings = await ref
|
||||
.read(torSettingsRepositoryProvider.notifier)
|
||||
.fetchSettings();
|
||||
|
||||
Setting? setting;
|
||||
if (torSettings.config == TorConnectionConfig.auto) {
|
||||
List<Setting>? config;
|
||||
|
||||
final moat = MoatService();
|
||||
try {
|
||||
await moat.initialize();
|
||||
config = await moat.autoConf(
|
||||
cannotConnectWithoutPt: torSettings.requireBridge,
|
||||
);
|
||||
|
||||
if (config == null && torSettings.requireBridge) {
|
||||
config = MoatService.convertBuiltinToSettings(
|
||||
await ref
|
||||
.read(builtinBridgesRepositoryProvider(moat).notifier)
|
||||
.getBridges(),
|
||||
);
|
||||
}
|
||||
} catch (e, s) {
|
||||
logger.e('Failed auto configure bridges', error: e, stackTrace: s);
|
||||
} finally {
|
||||
await moat.dispose();
|
||||
}
|
||||
|
||||
setting = config.mapNotNull(
|
||||
(config) =>
|
||||
config.firstWhereOrNull(
|
||||
(setting) => setting.bridge.type == MoatTransportType.obfs4,
|
||||
) ??
|
||||
config.firstWhereOrNull(
|
||||
(setting) => setting.bridge.type == MoatTransportType.snowflake,
|
||||
),
|
||||
);
|
||||
} else if (torSettings.config != TorConnectionConfig.direct) {
|
||||
List<Setting>? config;
|
||||
|
||||
final moat = MoatService();
|
||||
try {
|
||||
await moat.initialize();
|
||||
if (torSettings.fetchRemoteBridges) {
|
||||
config = await moat.getDefaultBridges();
|
||||
}
|
||||
|
||||
if (config == null &&
|
||||
(torSettings.requireBridge || !torSettings.fetchRemoteBridges)) {
|
||||
config = MoatService.convertBuiltinToSettings(
|
||||
await ref
|
||||
.read(builtinBridgesRepositoryProvider(moat).notifier)
|
||||
.getBridges(tryUpdate: torSettings.fetchRemoteBridges),
|
||||
);
|
||||
}
|
||||
|
||||
setting = config.mapNotNull(
|
||||
(config) => config.firstWhereOrNull(
|
||||
(setting) =>
|
||||
setting.bridge.type ==
|
||||
switch (torSettings.config) {
|
||||
TorConnectionConfig.auto => throw UnimplementedError(
|
||||
'TorConnectionConfig.auto bridge type not supported',
|
||||
),
|
||||
TorConnectionConfig.direct => throw UnimplementedError(
|
||||
'TorConnectionConfig.direct does not use bridges',
|
||||
),
|
||||
TorConnectionConfig.obfs4 => MoatTransportType.obfs4,
|
||||
TorConnectionConfig.snowflake =>
|
||||
MoatTransportType.snowflake,
|
||||
},
|
||||
),
|
||||
);
|
||||
} catch (e, s) {
|
||||
logger.e('Failed auto configure bridges', error: e, stackTrace: s);
|
||||
} finally {
|
||||
await moat.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
final config = TorConfiguration(
|
||||
transport: switch (setting?.bridge.type) {
|
||||
MoatTransportType.obfs4 => TransportType.obfs4,
|
||||
MoatTransportType.snowflake => TransportType.snowflake,
|
||||
MoatTransportType.meek => TransportType.meek,
|
||||
MoatTransportType.meekAzure => TransportType.meekAzure,
|
||||
MoatTransportType.webtunnel => TransportType.webtunnel,
|
||||
null => TransportType.none,
|
||||
},
|
||||
bridgeLines: setting?.bridge.bridges ?? [],
|
||||
entryNodeCountries: torSettings.entryNodeCountry?.toLowerCase(),
|
||||
exitNodeCountries: torSettings.exitNodeCountry?.toLowerCase(),
|
||||
);
|
||||
|
||||
await _tor.start(config);
|
||||
return _tor.getStatus();
|
||||
}
|
||||
|
||||
return currentStatus;
|
||||
}
|
||||
|
||||
Future<TorStatus> requestSync() async {
|
||||
final status = await _tor.getStatus();
|
||||
_statusSyncController.add(status);
|
||||
return status;
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
await _tor.stop();
|
||||
}
|
||||
|
||||
Future<void> requestNewIdentity() async {
|
||||
await _tor.requestNewIdentity();
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<TorStatus> build() {
|
||||
_statusSyncController = StreamController();
|
||||
|
||||
ref.onDispose(() async {
|
||||
await _statusSyncController.close();
|
||||
await _tor.stop();
|
||||
});
|
||||
|
||||
return MergeStream([_tor.statusStream, _statusSyncController.stream]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// 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(TorProxyService)
|
||||
final torProxyServiceProvider = TorProxyServiceProvider._();
|
||||
|
||||
final class TorProxyServiceProvider
|
||||
extends $StreamNotifierProvider<TorProxyService, TorStatus> {
|
||||
TorProxyServiceProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'torProxyServiceProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$torProxyServiceHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
TorProxyService create() => TorProxyService();
|
||||
}
|
||||
|
||||
String _$torProxyServiceHash() => r'7b430ca32fbfc9ebebb1e52271c0183efdf60971';
|
||||
|
||||
abstract class _$TorProxyService extends $StreamNotifier<TorStatus> {
|
||||
Stream<TorStatus> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<AsyncValue<TorStatus>, TorStatus>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<AsyncValue<TorStatus>, TorStatus>,
|
||||
AsyncValue<TorStatus>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* 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/widgets.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/controllers/overlay.dart';
|
||||
import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
|
||||
import 'package:weblibre/features/tor/presentation/widgets/tor_notification.dart';
|
||||
|
||||
part 'start_tor_proxy.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class StartProxyController extends _$StartProxyController {
|
||||
Future<bool> shouldPromptProxyStart() async {
|
||||
final currentStatus = await ref
|
||||
.read(torProxyServiceProvider.notifier)
|
||||
.requestSync();
|
||||
|
||||
return !currentStatus.isRunning;
|
||||
}
|
||||
|
||||
Future<void> startProxy() async {
|
||||
if (state) return;
|
||||
|
||||
state = true;
|
||||
|
||||
try {
|
||||
final connection = ref
|
||||
.read(torProxyServiceProvider.notifier)
|
||||
.startOrReconfigure(reconfigureIfRunning: false);
|
||||
|
||||
ref
|
||||
.read(overlayControllerProvider.notifier)
|
||||
.show(
|
||||
(context) =>
|
||||
const Positioned(top: 0, left: 0, child: TorNotification()),
|
||||
);
|
||||
|
||||
await connection;
|
||||
} finally {
|
||||
state = false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool build() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'start_tor_proxy.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(StartProxyController)
|
||||
final startProxyControllerProvider = StartProxyControllerProvider._();
|
||||
|
||||
final class StartProxyControllerProvider
|
||||
extends $NotifierProvider<StartProxyController, bool> {
|
||||
StartProxyControllerProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'startProxyControllerProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$startProxyControllerHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
StartProxyController create() => StartProxyController();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(bool value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<bool>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$startProxyControllerHash() =>
|
||||
r'899b585bf7f220251ac92f11c79537cc07723241';
|
||||
|
||||
abstract class _$StartProxyController extends $Notifier<bool> {
|
||||
bool build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<bool, bool>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<bool, bool>,
|
||||
bool,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* 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:country_codes/country_codes.dart';
|
||||
import 'package:country_flags/country_flags.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
/// Sentinel value returned when the user selects "Automatic" (no country).
|
||||
/// Distinguished from `null` which means the user navigated back without
|
||||
/// making a selection.
|
||||
const automaticCountry = '';
|
||||
|
||||
class CountryPickerScreen extends HookWidget {
|
||||
const CountryPickerScreen({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.selectedCountryCode,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String? selectedCountryCode;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final searchController = useTextEditingController();
|
||||
final searchQuery = useState('');
|
||||
|
||||
final countries = useMemoized(() {
|
||||
return CountryCodes.countryCodes().map((country) {
|
||||
final label =
|
||||
country.localizedName ??
|
||||
country.name ??
|
||||
country.alpha2Code ??
|
||||
country.countryCode ??
|
||||
'Unnamed Country';
|
||||
return (alpha2Code: country.alpha2Code, label: label);
|
||||
}).toList()..sort((a, b) => a.label.compareTo(b.label));
|
||||
});
|
||||
|
||||
final filteredCountries = useMemoized(() {
|
||||
if (searchQuery.value.isEmpty) return countries;
|
||||
final query = searchQuery.value.toLowerCase();
|
||||
return countries
|
||||
.where((c) => c.label.toLowerCase().contains(query))
|
||||
.toList();
|
||||
}, [searchQuery.value, countries]);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(title),
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(56),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16.0,
|
||||
vertical: 8.0,
|
||||
),
|
||||
child: TextField(
|
||||
controller: searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search countries...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: searchQuery.value.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
searchController.clear();
|
||||
searchQuery.value = '';
|
||||
},
|
||||
)
|
||||
: null,
|
||||
filled: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
onChanged: (value) => searchQuery.value = value,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: ListView.builder(
|
||||
itemCount: filteredCountries.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) {
|
||||
final isSelected = selectedCountryCode == null;
|
||||
return ListTile(
|
||||
leading: const SizedBox(
|
||||
width: 32,
|
||||
height: 24,
|
||||
child: Center(child: Icon(Icons.public)),
|
||||
),
|
||||
title: const Text('Automatic'),
|
||||
trailing: isSelected ? const Icon(Icons.check) : null,
|
||||
onTap: () => context.pop(automaticCountry),
|
||||
);
|
||||
}
|
||||
|
||||
final country = filteredCountries[index - 1];
|
||||
final isSelected = country.alpha2Code == selectedCountryCode;
|
||||
|
||||
return ListTile(
|
||||
leading: country.alpha2Code != null
|
||||
? CountryFlag.fromCountryCode(
|
||||
country.alpha2Code!,
|
||||
theme: const EmojiTheme(size: 28),
|
||||
)
|
||||
: const SizedBox(width: 32),
|
||||
title: Text(country.label),
|
||||
trailing: isSelected ? const Icon(Icons.check) : null,
|
||||
onTap: () => context.pop(country.alpha2Code),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
/*
|
||||
* 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:country_flags/country_flags.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/core/routing/routes.dart';
|
||||
import 'package:weblibre/features/settings/presentation/controllers/save_settings.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/utils/ui_helper.dart';
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
ref.listen(torSettingsRepositoryProvider, (previous, next) async {
|
||||
final torService = ref.read(torProxyServiceProvider.notifier);
|
||||
final currentStatus = await torService.requestSync();
|
||||
|
||||
if (currentStatus.isRunning) {
|
||||
await torService.startOrReconfigure(reconfigureIfRunning: true);
|
||||
}
|
||||
});
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:weblibre/core/design/app_colors.dart';
|
||||
import 'package:weblibre/presentation/icons/tor_icons.dart';
|
||||
|
||||
class TorDialog extends StatelessWidget {
|
||||
const TorDialog({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appColors = AppColors.of(context);
|
||||
return AlertDialog(
|
||||
icon: Icon(TorIcons.onionAlt, color: appColors.torPurple),
|
||||
title: const Text('Tor™ Proxy'),
|
||||
content: const Text(
|
||||
'This container requires a Tor proxy for secure connections, which is not currently running.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.pop(false);
|
||||
},
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.pop(true);
|
||||
},
|
||||
child: const Text('Enable'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* 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/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/services/tor_proxy.dart';
|
||||
import 'package:weblibre/presentation/icons/tor_icons.dart';
|
||||
import 'package:weblibre/presentation/widgets/animate_gradient_shader.dart';
|
||||
|
||||
class TorNotification extends HookConsumerWidget {
|
||||
const TorNotification({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final appColors = AppColors.of(context);
|
||||
|
||||
ref.listen(torProxyServiceProvider, (previous, next) {
|
||||
if (next.hasValue & next.requireValue.isRunning &&
|
||||
next.requireValue.bootstrapProgress == 100) {
|
||||
ref.read(overlayControllerProvider.notifier).dismiss();
|
||||
}
|
||||
});
|
||||
|
||||
return SafeArea(
|
||||
child: ColoredBox(
|
||||
color: appColors.torPurple,
|
||||
child: SizedBox(
|
||||
height: 56 + 12,
|
||||
width: MediaQuery.of(context).size.width,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
AnimateGradientShader(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
primaryEnd: Alignment.bottomLeft,
|
||||
secondaryEnd: Alignment.topRight,
|
||||
primaryColors: [
|
||||
appColors.torActiveGreen,
|
||||
appColors.torActiveGreen,
|
||||
],
|
||||
secondaryColors: const [Colors.white, Colors.white],
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Icon(TorIcons.onionAlt),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Tor Proxy is connecting...',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(color: Colors.white),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
ref.read(overlayControllerProvider.notifier).dismiss();
|
||||
},
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final bootstrapProgress = ref.watch(
|
||||
torProxyServiceProvider.select(
|
||||
(value) => value.value?.bootstrapProgress ?? 0,
|
||||
),
|
||||
);
|
||||
|
||||
return LinearProgressIndicator(
|
||||
backgroundColor: AppColors.of(context).torBackgroundGrey,
|
||||
color: AppColors.of(context).torActiveGreen,
|
||||
value: bootstrapProgress / 100,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user