setting to exclude proxy from global routing

This commit is contained in:
Fabian Freund
2026-05-27 09:23:18 +02:00
parent 2812c24f35
commit 7275ac9400
27 changed files with 661 additions and 65 deletions
@@ -396,6 +396,19 @@ Future<bool> isTabTunneled(Ref ref, String? tabId) async {
return containerData?.metadata.proxyConnectionId != null;
case ProxyRegularTabRoutingMode.all:
final containerData = await ref
.read(tabDataRepositoryProvider.notifier)
.getTabContainerData(tabState.id);
if (!ref.mounted) return false;
if (containerData?.metadata.proxyConnectionId != null) {
return true;
}
if (containerData?.metadata.bypassGlobalProxy == true) {
return false;
}
return proxyRoutingSettings.regularTabsProxyConnectionId != null;
}
}
@@ -258,7 +258,7 @@ final class IsTabTunneledProvider
}
}
String _$isTabTunneledHash() => r'cb3a3afe5b94f7e9c77d6a32266afbb8a225c259';
String _$isTabTunneledHash() => r'd36607ff812f71870af32ce59c2dbe898a9c1917';
final class IsTabTunneledFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<bool>, String?> {
@@ -33,10 +33,45 @@ import 'package:weblibre/features/user/domain/repositories/proxy_routing_setting
part 'proxy_settings_replication.g.dart';
sealed class _ProxyAssignment with FastEquatable {
_ProxyAssignment();
factory _ProxyAssignment.inherit() = _InheritProxyAssignment;
factory _ProxyAssignment.direct(String scopeId) = _DirectProxyAssignment;
factory _ProxyAssignment.explicit(String proxyId) = _ExplicitProxyAssignment;
}
final class _InheritProxyAssignment extends _ProxyAssignment {
_InheritProxyAssignment();
@override
List<Object?> get hashParameters => const ['inherit'];
}
final class _DirectProxyAssignment extends _ProxyAssignment {
final String scopeId;
_DirectProxyAssignment(this.scopeId);
@override
List<Object?> get hashParameters => [scopeId];
}
final class _ExplicitProxyAssignment extends _ProxyAssignment {
final String proxyId;
_ExplicitProxyAssignment(this.proxyId);
@override
List<Object?> get hashParameters => [proxyId];
}
@Riverpod(keepAlive: true)
class ProxySettingsReplication extends _$ProxySettingsReplication {
var _isolatedProxyAssignments = <String, String>{};
var _appliedContainerProxies = <String, String?>{};
var _isolatedProxyAssignments = <String, _ProxyAssignment>{};
var _appliedContainerProxies = <String, _ProxyAssignment>{};
final _recomputeLock = Lock();
var _recomputeDirty = false;
@@ -82,33 +117,71 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
.read(containerRepositoryProvider.notifier)
.getAllContainersWithCount();
final containerProxyIds = <String, String>{
final containerAssignments = <String, _ProxyAssignment>{
for (final c in containers)
if (c.metadata.proxyConnectionId case final proxyId?)
c.id: proxyId.encode(),
if (c.metadata.contextualIdentity case final contextId?)
c.id: switch (c.metadata.proxyConnectionId) {
final proxyId? => _ProxyAssignment.explicit(proxyId.encode()),
null when c.metadata.bypassGlobalProxy => _ProxyAssignment.direct(
contextId,
),
null => _ProxyAssignment.inherit(),
},
};
final newAssignments = <String, String>{};
final newAssignments = <String, _ProxyAssignment>{};
for (final entry in contextContainerMap.entries) {
final assignments = entry.value
.map((containerId) => containerAssignments[containerId])
.nonNulls
.toList();
if (assignments.isEmpty) continue;
final proxyIds =
entry.value
.map((containerId) => containerProxyIds[containerId])
.nonNulls
assignments
.whereType<_ExplicitProxyAssignment>()
.map((assignment) => assignment.proxyId)
.toSet()
.toList()
..sort();
final directScopeIds =
assignments
.whereType<_DirectProxyAssignment>()
.map((assignment) => assignment.scopeId)
.toSet()
.toList()
..sort();
final hasInheritedAssignment = assignments.any(
(assignment) => assignment is _InheritProxyAssignment,
);
if (proxyIds.isEmpty) continue;
final chosenAssignment = proxyIds.isNotEmpty
? _ProxyAssignment.explicit(proxyIds.first)
: directScopeIds.isNotEmpty && !hasInheritedAssignment
? _ProxyAssignment.direct(directScopeIds.first)
: _ProxyAssignment.inherit();
if (chosenAssignment is! _InheritProxyAssignment) {
newAssignments[entry.key] = chosenAssignment;
}
final chosenLabel = switch (chosenAssignment) {
_DirectProxyAssignment(:final scopeId) => 'direct:$scopeId',
_ExplicitProxyAssignment(:final proxyId) => proxyId,
_InheritProxyAssignment() => 'inherit',
};
newAssignments[entry.key] = proxyIds.first;
if (proxyIds.length > 1) {
final distinctAssignmentCount =
proxyIds.length +
directScopeIds.length +
(hasInheritedAssignment ? 1 : 0);
if (distinctAssignmentCount > 1) {
// Isolation contexts can hold multiple containers; if they disagree on
// a proxy connection the alias is forced to pick one. Surface this so
// the user can split the containers across isolation contexts.
// routing, the alias is forced to pick one. Surface this so the user
// can split the containers across isolation contexts.
logger.w(
'Isolation context ${entry.key} has containers with multiple '
'proxy connections (${proxyIds.join(', ')}); using ${proxyIds.first}',
'proxy routing assignments '
'(${[if (hasInheritedAssignment) 'inherit', ...directScopeIds.map((id) => 'direct:$id'), ...proxyIds].join(', ')}); using $chosenLabel',
);
}
}
@@ -126,9 +199,7 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
for (final MapEntry(:key, :value) in newAssignments.entries) {
if (_isolatedProxyAssignments[key] == value) continue;
await ref
.read(containerProxyRepositoryProvider.notifier)
.setContainerProxy(key, value);
await _applyProxyAssignment(key, value);
}
_isolatedProxyAssignments = newAssignments;
@@ -260,15 +331,22 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
) async {
if (containers == null) return;
final desired = <String, String?>{};
final desired = <String, _ProxyAssignment>{};
for (final container in containers) {
final contextId = container.metadata.contextualIdentity;
if (contextId == null || contextId.isEmpty) continue;
desired[contextId] = container.metadata.proxyConnectionId?.encode();
final proxyConnectionId = container.metadata.proxyConnectionId;
desired[contextId] = proxyConnectionId != null
? _ProxyAssignment.explicit(proxyConnectionId.encode())
: container.metadata.bypassGlobalProxy
? _ProxyAssignment.direct(contextId)
: _ProxyAssignment.inherit();
}
final repo = ref.read(containerProxyRepositoryProvider.notifier);
final nextApplied = Map<String, String?>.from(_appliedContainerProxies);
final nextApplied = Map<String, _ProxyAssignment>.from(
_appliedContainerProxies,
);
for (final contextId in _appliedContainerProxies.keys.toSet().difference(
desired.keys.toSet(),
@@ -291,11 +369,7 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
continue;
}
try {
if (value != null) {
await repo.setContainerProxy(key, value);
} else {
await repo.clearContainerProxy(key);
}
await _applyProxyAssignment(key, value);
nextApplied[key] = value;
} catch (error, stackTrace) {
logger.e(
@@ -310,4 +384,19 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
_appliedContainerProxies = nextApplied;
}
Future<void> _applyProxyAssignment(
String contextId,
_ProxyAssignment assignment,
) async {
final repo = ref.read(containerProxyRepositoryProvider.notifier);
switch (assignment) {
case _ExplicitProxyAssignment(:final proxyId):
await repo.setContainerProxy(contextId, proxyId);
case _DirectProxyAssignment(:final scopeId):
await repo.setContainerDirectConnection(contextId, scopeId: scopeId);
case _InheritProxyAssignment():
await repo.clearContainerProxy(contextId);
}
}
}
@@ -42,7 +42,7 @@ final class ProxySettingsReplicationProvider
}
String _$proxySettingsReplicationHash() =>
r'42dcd4ec7bc5a00804f729178b99c56e8c917850';
r'cdb624b7f7806a7ce4218da350253ed3d29986d0';
abstract class _$ProxySettingsReplication extends $Notifier<void> {
void build();
@@ -249,9 +249,9 @@ Future<ProxyConnectionId?> _proxyConnectionIdForLoadError(
if (contextId != null && contextId.isNotEmpty && contextId != 'general') {
final isolatedContextProxyConnectionId =
await _isolatedContextProxyConnectionId(ref, contextId);
await _isolatedContextProxyAssignment(ref, contextId);
if (isolatedContextProxyConnectionId != null) {
return isolatedContextProxyConnectionId;
return isolatedContextProxyConnectionId.proxyConnectionId;
}
final contextContainer = await ref
@@ -262,6 +262,9 @@ Future<ProxyConnectionId?> _proxyConnectionIdForLoadError(
if (contextProxyConnectionId != null) {
return contextProxyConnectionId;
}
if (contextContainer?.metadata.bypassGlobalProxy == true) {
return null;
}
}
final tabContainer = await ref
@@ -271,6 +274,9 @@ Future<ProxyConnectionId?> _proxyConnectionIdForLoadError(
if (tabProxyConnectionId != null) {
return tabProxyConnectionId;
}
if (tabContainer?.metadata.bypassGlobalProxy == true) {
return null;
}
if (routing.regularTabsMode == ProxyRegularTabRoutingMode.all) {
return routing.regularTabsProxyConnectionId;
@@ -279,7 +285,12 @@ Future<ProxyConnectionId?> _proxyConnectionIdForLoadError(
return null;
}
Future<ProxyConnectionId?> _isolatedContextProxyConnectionId(
typedef _LoadErrorProxyAssignment = ({
bool direct,
ProxyConnectionId? proxyConnectionId,
});
Future<_LoadErrorProxyAssignment?> _isolatedContextProxyAssignment(
WidgetRef ref,
String contextId,
) async {
@@ -296,17 +307,32 @@ Future<ProxyConnectionId?> _isolatedContextProxyConnectionId(
final containers = await ref
.read(containerRepositoryProvider.notifier)
.getAllContainersWithCount();
final matchedContainers = containers.where(
(container) => containerIds.contains(container.id),
);
final proxyIds =
containers
.where((container) => containerIds.contains(container.id))
matchedContainers
.map((container) => container.metadata.proxyConnectionId?.encode())
.nonNulls
.toSet()
.toList()
..sort();
if (proxyIds.isEmpty) return null;
return ProxyConnectionId.decode(proxyIds.first);
if (proxyIds.isNotEmpty) {
return (
direct: false,
proxyConnectionId: ProxyConnectionId.decode(proxyIds.first),
);
}
final matchedContainerList = matchedContainers.toList();
if (matchedContainerList.isNotEmpty &&
matchedContainerList.every(
(container) => container.metadata.bypassGlobalProxy,
)) {
return (direct: true, proxyConnectionId: null);
}
return null;
}
typedef _PendingProxyLoadError = ({
@@ -50,6 +50,9 @@ class ContainerMetadata with FastEquatable {
@JsonKey(defaultValue: false)
final bool excludeFromIndex;
@JsonKey(defaultValue: false)
final bool bypassGlobalProxy;
final List<Uri>? assignedSites;
ContainerMetadata({
@@ -58,6 +61,7 @@ class ContainerMetadata with FastEquatable {
required this.proxyConnectionId,
required this.clearDataOnExit,
required this.excludeFromIndex,
required this.bypassGlobalProxy,
required this.assignedSites,
});
@@ -67,6 +71,7 @@ class ContainerMetadata with FastEquatable {
ProxyConnectionId? proxyConnectionId,
bool? clearDataOnExit,
bool? excludeFromIndex,
bool? bypassGlobalProxy,
List<Uri>? assignedSites,
}) : this(
iconData: iconData,
@@ -74,6 +79,7 @@ class ContainerMetadata with FastEquatable {
proxyConnectionId: proxyConnectionId,
clearDataOnExit: clearDataOnExit ?? false,
excludeFromIndex: excludeFromIndex ?? false,
bypassGlobalProxy: bypassGlobalProxy ?? false,
assignedSites: assignedSites,
);
@@ -91,6 +97,7 @@ class ContainerMetadata with FastEquatable {
proxyConnectionId,
clearDataOnExit,
excludeFromIndex,
bypassGlobalProxy,
assignedSites,
];
}
@@ -17,6 +17,8 @@ abstract class _$ContainerMetadataCWProxy {
ContainerMetadata excludeFromIndex(bool excludeFromIndex);
ContainerMetadata bypassGlobalProxy(bool bypassGlobalProxy);
ContainerMetadata assignedSites(List<Uri>? assignedSites);
/// Creates a new instance with the provided field values.
@@ -32,6 +34,7 @@ abstract class _$ContainerMetadataCWProxy {
ProxyConnectionId? proxyConnectionId,
bool clearDataOnExit,
bool excludeFromIndex,
bool bypassGlobalProxy,
List<Uri>? assignedSites,
});
}
@@ -62,6 +65,10 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy {
ContainerMetadata excludeFromIndex(bool excludeFromIndex) =>
call(excludeFromIndex: excludeFromIndex);
@override
ContainerMetadata bypassGlobalProxy(bool bypassGlobalProxy) =>
call(bypassGlobalProxy: bypassGlobalProxy);
@override
ContainerMetadata assignedSites(List<Uri>? assignedSites) =>
call(assignedSites: assignedSites);
@@ -80,6 +87,7 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy {
Object? proxyConnectionId = const $CopyWithPlaceholder(),
Object? clearDataOnExit = const $CopyWithPlaceholder(),
Object? excludeFromIndex = const $CopyWithPlaceholder(),
Object? bypassGlobalProxy = const $CopyWithPlaceholder(),
Object? assignedSites = const $CopyWithPlaceholder(),
}) {
return ContainerMetadata(
@@ -107,6 +115,12 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy {
? _value.excludeFromIndex
// ignore: cast_nullable_to_non_nullable
: excludeFromIndex as bool,
bypassGlobalProxy:
bypassGlobalProxy == const $CopyWithPlaceholder() ||
bypassGlobalProxy == null
? _value.bypassGlobalProxy
// ignore: cast_nullable_to_non_nullable
: bypassGlobalProxy as bool,
assignedSites: assignedSites == const $CopyWithPlaceholder()
? _value.assignedSites
// ignore: cast_nullable_to_non_nullable
@@ -247,6 +261,7 @@ ContainerMetadata _$ContainerMetadataFromJson(Map<String, dynamic> json) =>
),
clearDataOnExit: json['clearDataOnExit'] as bool? ?? false,
excludeFromIndex: json['excludeFromIndex'] as bool? ?? false,
bypassGlobalProxy: json['bypassGlobalProxy'] as bool? ?? false,
assignedSites: (json['assignedSites'] as List<dynamic>?)
?.map((e) => Uri.parse(e as String))
.toList(),
@@ -263,6 +278,7 @@ Map<String, dynamic> _$ContainerMetadataToJson(
'proxyConnectionId': _proxyConnectionIdToJson(instance.proxyConnectionId),
'clearDataOnExit': instance.clearDataOnExit,
'excludeFromIndex': instance.excludeFromIndex,
'bypassGlobalProxy': instance.bypassGlobalProxy,
'assignedSites': instance.assignedSites?.map((e) => e.toString()).toList(),
};
@@ -195,8 +195,8 @@ Stream<List<SiteAssignment>> watchAllAssignedSites(Ref ref) {
/// aliases for isolated contexts.
///
/// Returns a map from isolation context ID to the set of container IDs it
/// appears in. An isolation context needs a proxy alias if ANY of its
/// associated containers has a proxy connection assigned.
/// appears in. An isolation context needs an explicit routing alias if any
/// associated container has a proxy connection or bypasses global routing.
@Riverpod(keepAlive: true)
Stream<Map<String, Set<String>>> watchIsolatedContextContainerMap(Ref ref) {
final db = ref.watch(tabDatabaseProvider);
@@ -1151,8 +1151,8 @@ String _$watchAllAssignedSitesHash() =>
/// aliases for isolated contexts.
///
/// Returns a map from isolation context ID to the set of container IDs it
/// appears in. An isolation context needs a proxy alias if ANY of its
/// associated containers has a proxy connection assigned.
/// appears in. An isolation context needs an explicit routing alias if any
/// associated container has a proxy connection or bypasses global routing.
@ProviderFor(watchIsolatedContextContainerMap)
final watchIsolatedContextContainerMapProvider =
@@ -1163,8 +1163,8 @@ final watchIsolatedContextContainerMapProvider =
/// aliases for isolated contexts.
///
/// Returns a map from isolation context ID to the set of container IDs it
/// appears in. An isolation context needs a proxy alias if ANY of its
/// associated containers has a proxy connection assigned.
/// appears in. An isolation context needs an explicit routing alias if any
/// associated container has a proxy connection or bypasses global routing.
final class WatchIsolatedContextContainerMapProvider
extends
@@ -1181,8 +1181,8 @@ final class WatchIsolatedContextContainerMapProvider
/// aliases for isolated contexts.
///
/// Returns a map from isolation context ID to the set of container IDs it
/// appears in. An isolation context needs a proxy alias if ANY of its
/// associated containers has a proxy connection assigned.
/// appears in. An isolation context needs an explicit routing alias if any
/// associated container has a proxy connection or bypasses global routing.
WatchIsolatedContextContainerMapProvider._()
: super(
from: null,
@@ -99,6 +99,9 @@ class ContainerEditScreen extends HookConsumerWidget {
final excludeFromIndex = useState(
initialContainer.metadata.excludeFromIndex,
);
final bypassGlobalProxy = useState(
initialContainer.metadata.bypassGlobalProxy,
);
final assignedSites = useState(initialContainer.metadata.assignedSites);
final isPinned = useState(initialContainer.isPinned);
@@ -122,6 +125,10 @@ class ContainerEditScreen extends HookConsumerWidget {
clearDataOnExit:
clearDataOnExit.value && contextualIdentity.value != null,
excludeFromIndex: excludeFromIndex.value,
bypassGlobalProxy:
contextualIdentity.value != null &&
proxyConnectionId.value == null &&
bypassGlobalProxy.value,
assignedSites: assignedSites.value,
),
);
@@ -242,6 +249,8 @@ class ContainerEditScreen extends HookConsumerWidget {
final assignedSiteCount = assignedSites.value?.length ?? 0;
final canPickProxy =
_mode == _DialogMode.create || contextualIdentity.value != null;
final canBypassGlobalProxy =
contextualIdentity.value != null && proxyConnectionId.value == null;
return PopScope(
canPop: container == comparison,
@@ -404,6 +413,7 @@ class ContainerEditScreen extends HookConsumerWidget {
if (!value) {
proxyConnectionId.value = null;
bypassGlobalProxy.value = false;
}
if (!value && clearDataOnExit.value) {
@@ -473,14 +483,31 @@ class ContainerEditScreen extends HookConsumerWidget {
proxyConnectionId.value = null;
if (createdTemporaryIdentity) {
contextualIdentity.value = null;
bypassGlobalProxy.value = false;
}
case _ProxyPickerSelected(:final id):
proxyConnectionId.value = id;
bypassGlobalProxy.value = false;
}
}
: null,
),
const Divider(height: 1, indent: 56),
SwitchListTile.adaptive(
value:
canBypassGlobalProxy && bypassGlobalProxy.value,
title: const Text('Bypass Global Proxy'),
subtitle: const Text(
'Use the normal connection for this container when global routing is enabled',
),
secondary: const Icon(Icons.public),
onChanged: canBypassGlobalProxy
? (value) {
bypassGlobalProxy.value = value;
}
: null,
),
const Divider(height: 1, indent: 56),
SwitchListTile.adaptive(
value: clearDataOnExit.value,
title: const Text('Clear Data on Exit'),
@@ -661,6 +688,7 @@ class _ProxyConnectionPickerSheet extends StatelessWidget {
return SafeArea(
child: RadioGroup<ProxyConnectionId?>(
groupValue: selectedProxyConnectionId,
onChanged: (value) {
Navigator.pop(
context,
@@ -266,6 +266,13 @@ class _ContainerCard extends HookConsumerWidget {
isLoading: proxyOptionsLoading,
),
),
if (container.metadata.proxyConnectionId ==
null &&
container.metadata.bypassGlobalProxy)
const _ContainerInfoChip(
icon: Icons.public,
label: 'Direct',
),
if (container.metadata.clearDataOnExit)
const _ContainerInfoChip(
icon: Icons.cleaning_services_outlined,
@@ -322,6 +322,13 @@ class _SelectionContainerCard extends ConsumerWidget {
isLoading: proxyOptionsLoading,
),
),
if (container.metadata.proxyConnectionId ==
null &&
container.metadata.bypassGlobalProxy)
const _SelectionInfoChip(
icon: Icons.public,
label: 'Direct',
),
if (container.metadata.clearDataOnExit)
const _SelectionInfoChip(
icon: Icons.cleaning_services_outlined,
@@ -83,6 +83,16 @@ class ContainerProxyRepository extends _$ContainerProxyRepository {
);
}
Future<void> setContainerDirectConnection(
String contextId, {
required String scopeId,
}) {
return _runLocked(
body: () =>
_service.setContainerDirectConnection(contextId, scopeId: scopeId),
);
}
Future<void> clearContainerProxy(String contextId) {
return _runLocked(body: () => _service.clearContainerProxy(contextId));
}
@@ -42,7 +42,7 @@ final class ContainerProxyRepositoryProvider
}
String _$containerProxyRepositoryHash() =>
r'08cd408a3ae96c859ed5c9d56d61cf7e6514415f';
r'ec3ec41b45b991623518f0aceb6ee7b21abb9112';
abstract class _$ContainerProxyRepository extends $Notifier<void> {
void build();
@@ -104,7 +104,7 @@ class _RegularTabsModeSection extends ConsumerWidget {
value: ProxyRegularTabRoutingMode.all,
title: Text('Global Routing'),
subtitle: Text(
'Route every regular tab through the selected proxy.',
'Route regular tabs through the selected proxy unless a container bypasses it.',
),
),
],
@@ -27,6 +27,8 @@ import 'package:weblibre/extensions/uri.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
@@ -35,6 +37,20 @@ import 'package:weblibre/features/user/domain/repositories/proxy_routing_setting
part 'website_title.g.dart';
sealed class _PageInfoProxyAssignment {
const _PageInfoProxyAssignment();
}
final class _PageInfoDirectAssignment extends _PageInfoProxyAssignment {
const _PageInfoDirectAssignment();
}
final class _PageInfoExplicitProxyAssignment extends _PageInfoProxyAssignment {
final ProxyConnectionId proxyConnectionId;
const _PageInfoExplicitProxyAssignment(this.proxyConnectionId);
}
@Riverpod()
class CompletePageInfo extends _$CompletePageInfo {
@override
@@ -91,12 +107,34 @@ Future<WebPageInfo> pageInfo(
proxyRoutingSettingsWithDefaultsProvider,
);
if (containerData?.metadata.proxyConnectionId is TorProxyConnectionId ||
(tabState.tabMode is! PrivateTabMode &&
proxyRoutingSettings.regularTabsMode ==
ProxyRegularTabRoutingMode.all &&
proxyRoutingSettings.regularTabsProxyConnectionId
is TorProxyConnectionId) ||
final isolatedAssignment = tabState.isolationContextId != null
? await _pageInfoAssignmentForIsolationContext(
ref,
tabState.isolationContextId!,
)
: null;
final effectiveContainerProxyConnectionId = switch (isolatedAssignment) {
_PageInfoExplicitProxyAssignment(:final proxyConnectionId) =>
proxyConnectionId,
_PageInfoDirectAssignment() => null,
null => containerData?.metadata.proxyConnectionId,
};
final bypassesGlobalProxy =
isolatedAssignment is _PageInfoDirectAssignment ||
(isolatedAssignment == null &&
containerData?.metadata.bypassGlobalProxy == true);
final usesGlobalRegularTor =
tabState.tabMode is! PrivateTabMode &&
effectiveContainerProxyConnectionId == null &&
!bypassesGlobalProxy &&
proxyRoutingSettings.regularTabsMode ==
ProxyRegularTabRoutingMode.all &&
proxyRoutingSettings.regularTabsProxyConnectionId
is TorProxyConnectionId;
if (effectiveContainerProxyConnectionId is TorProxyConnectionId ||
usesGlobalRegularTor ||
(tabState.tabMode is PrivateTabMode &&
proxyRoutingSettings.privateTabsProxyConnectionId
is TorProxyConnectionId)) {
@@ -125,6 +163,51 @@ Future<WebPageInfo> pageInfo(
return result.value;
}
Future<_PageInfoProxyAssignment?> _pageInfoAssignmentForIsolationContext(
Ref ref,
String contextId,
) async {
final db = ref.read(tabDatabaseProvider);
final pairs = await db.tabDao.isolatedContextContainerPairs().get();
final containerIds = pairs
.where((pair) => pair.isolationContextId == contextId)
.map((pair) => pair.containerId)
.nonNulls
.toSet();
if (containerIds.isEmpty) return null;
final containers = await ref
.read(containerRepositoryProvider.notifier)
.getAllContainersWithCount();
final matchedContainers = containers.where(
(container) => containerIds.contains(container.id),
);
final proxyIds =
matchedContainers
.map((container) => container.metadata.proxyConnectionId?.encode())
.nonNulls
.toSet()
.toList()
..sort();
if (proxyIds.isNotEmpty) {
final proxyConnectionId = ProxyConnectionId.decode(proxyIds.first);
return proxyConnectionId != null
? _PageInfoExplicitProxyAssignment(proxyConnectionId)
: null;
}
final matchedContainerList = matchedContainers.toList();
if (matchedContainerList.isNotEmpty &&
matchedContainerList.every(
(container) => container.metadata.bypassGlobalProxy,
)) {
return const _PageInfoDirectAssignment();
}
return null;
}
@Riverpod()
AsyncValue<EquatableValue<Set<Uri>?>> websiteFeedProvider(
Ref ref,
@@ -162,7 +162,7 @@ final class PageInfoProvider
}
}
String _$pageInfoHash() => r'1dedc23d2420c950c7cec57500e99c5c221ed6e2';
String _$pageInfoHash() => r'680a8f0f0101c86946b4839d30d6f55307312cce';
final class PageInfoFamily extends $Family
with
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:ui';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_tor/flutter_tor.dart';
@@ -9,6 +10,8 @@ import 'package:weblibre/data/database/functions/lexo_rank_functions.dart';
import 'package:weblibre/data/database/functions/url_functions.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/services/proxy_settings_replication.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/database/database.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_source.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/site_assignment.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart';
@@ -82,12 +85,168 @@ void main() {
]);
},
);
test('bypassed containers sync explicit direct routing', () async {
final bypassedContainer = _container(
id: 'container-1',
contextId: 'context-a',
bypassGlobalProxy: true,
);
final db = TabDatabase(
NativeDatabase.memory(
setup: (database) {
registerLexorankFunctions(database);
registerUrlFunctions(database);
},
),
);
final containerProxyRepository = _FakeContainerProxyRepository();
final containerRepository = _FakeContainerRepository([bypassedContainer]);
final container = ProviderContainer(
overrides: [
tabDatabaseProvider.overrideWith((ref) => db),
containerProxyRepositoryProvider.overrideWith(
() => containerProxyRepository,
),
containerRepositoryProvider.overrideWith(() => containerRepository),
torProxyServiceProvider.overrideWith(_FakeTorProxyService.new),
proxyRoutingSettingsWithDefaultsProvider.overrideWith(
(ref) => ProxyRoutingSettings.withDefaults(
regularTabsMode: ProxyRegularTabRoutingMode.all,
regularTabsProxyConnectionId: const TorProxyConnectionId(),
),
),
watchContainersWithCountProvider.overrideWith(
(ref) => Stream.value([bypassedContainer]),
),
watchAllAssignedSitesProvider.overrideWith(
(ref) => Stream.value(const <SiteAssignment>[]),
),
watchIsolatedContextContainerMapProvider.overrideWith(
(ref) => Stream.value(const <String, Set<String>>{}),
),
],
);
addTearDown(() async {
container.dispose();
await db.close();
});
final subscription = container.listen<void>(
proxySettingsReplicationProvider,
(previous, next) {},
fireImmediately: true,
);
addTearDown(subscription.close);
await pumpEventQueue();
expect(containerProxyRepository.setContainerDirectConnectionCalls, [
('context-a', 'context-a'),
]);
expect(containerProxyRepository.setContainerProxyCalls, [
('general', const TorProxyConnectionId().encode()),
]);
});
test(
'mixed bypassed and inherited isolated containers do not bypass globally',
() async {
final bypassedContainer = _container(
id: 'container-direct',
contextId: 'context-direct',
bypassGlobalProxy: true,
);
final inheritedContainer = _container(
id: 'container-inherit',
contextId: 'context-inherit',
);
final db = TabDatabase(
NativeDatabase.memory(
setup: (database) {
registerLexorankFunctions(database);
registerUrlFunctions(database);
},
),
);
await db.containerDao.addContainer(bypassedContainer);
await db.containerDao.addContainer(inheritedContainer);
await db.tabDao.insertTab(
'tab-direct',
source: TabSource.manual,
parentId: const Value(null),
containerId: const Value('container-direct'),
tabMode: Value(TabMode.isolated('isolation-a')),
);
await db.tabDao.insertTab(
'tab-inherit',
source: TabSource.manual,
parentId: const Value(null),
containerId: const Value('container-inherit'),
tabMode: Value(TabMode.isolated('isolation-a')),
);
final containerProxyRepository = _FakeContainerProxyRepository();
final containers = [bypassedContainer, inheritedContainer];
final containerRepository = _FakeContainerRepository(containers);
final container = ProviderContainer(
overrides: [
tabDatabaseProvider.overrideWith((ref) => db),
containerProxyRepositoryProvider.overrideWith(
() => containerProxyRepository,
),
containerRepositoryProvider.overrideWith(() => containerRepository),
torProxyServiceProvider.overrideWith(_FakeTorProxyService.new),
proxyRoutingSettingsWithDefaultsProvider.overrideWith(
(ref) => ProxyRoutingSettings.withDefaults(
regularTabsMode: ProxyRegularTabRoutingMode.all,
regularTabsProxyConnectionId: const TorProxyConnectionId(),
),
),
watchContainersWithCountProvider.overrideWith(
(ref) => Stream.value(containers),
),
watchAllAssignedSitesProvider.overrideWith(
(ref) => Stream.value(const <SiteAssignment>[]),
),
watchIsolatedContextContainerMapProvider.overrideWith(
(ref) => Stream.value(const {
'isolation-a': {'container-direct', 'container-inherit'},
}),
),
],
);
addTearDown(() async {
container.dispose();
await db.close();
});
final subscription = container.listen<void>(
proxySettingsReplicationProvider,
(previous, next) {},
fireImmediately: true,
);
addTearDown(subscription.close);
await pumpEventQueue();
expect(containerProxyRepository.setContainerDirectConnectionCalls, [
('context-direct', 'context-direct'),
]);
expect(
containerProxyRepository.setContainerProxyCalls,
contains(('general', const TorProxyConnectionId().encode())),
);
expect(
containerProxyRepository.setContainerDirectConnectionCalls,
isNot(contains(('isolation-a', 'context-direct'))),
);
},
);
}
ContainerDataWithCount _container({
required String id,
required String contextId,
required ProxyConnectionId proxyConnectionId,
ProxyConnectionId? proxyConnectionId,
bool bypassGlobalProxy = false,
}) {
return ContainerDataWithCount(
id: id,
@@ -97,6 +256,7 @@ ContainerDataWithCount _container({
metadata: ContainerMetadata.withDefaults(
contextualIdentity: contextId,
proxyConnectionId: proxyConnectionId,
bypassGlobalProxy: bypassGlobalProxy,
),
tabCount: 0,
);
@@ -104,6 +264,7 @@ ContainerDataWithCount _container({
class _FakeContainerProxyRepository extends ContainerProxyRepository {
final setContainerProxyCalls = <(String, String)>[];
final setContainerDirectConnectionCalls = <(String, String)>[];
@override
Future<void> setTorProxyPort(int? port) async {}
@@ -116,6 +277,14 @@ class _FakeContainerProxyRepository extends ContainerProxyRepository {
setContainerProxyCalls.add((contextId, proxyId));
}
@override
Future<void> setContainerDirectConnection(
String contextId, {
required String scopeId,
}) async {
setContainerDirectConnectionCalls.add((contextId, scopeId));
}
@override
Future<void> clearContainerProxy(String contextId) async {}
@@ -9,6 +9,7 @@ void main() {
final metadata = ContainerMetadata.withDefaults(
proxyConnectionId: const SingboxProxyConnectionId('profile-1'),
clearDataOnExit: true,
bypassGlobalProxy: true,
);
final json = metadata.toJson();
@@ -19,7 +20,9 @@ void main() {
const SingboxProxyConnectionId('profile-1').encode(),
);
expect(json['clearDataOnExit'], isTrue);
expect(json['bypassGlobalProxy'], isTrue);
expect(restored.proxyConnectionId, metadata.proxyConnectionId);
expect(restored.bypassGlobalProxy, isTrue);
expect(restored.usesTorProxy, isFalse);
});
@@ -44,6 +44,16 @@ class GeckoContainerProxyApiImpl : GeckoContainerProxyApi {
)
}
override fun setContainerDirectConnection(contextId: String, scopeId: String) {
ContainerProxyFeature.scheduleRequest(
"setContainerDirectConnection",
JSONObject().apply {
put("contextId", contextId)
put("scopeId", scopeId)
}
)
}
override fun clearContainerProxy(contextId: String) {
ContainerProxyFeature.scheduleRequest("clearContainerProxy", contextId)
}
@@ -8412,6 +8412,7 @@ interface GeckoContainerProxyApi {
fun upsertProxy(proxy: GeckoProxySettings)
fun removeProxy(proxyId: String)
fun setContainerProxy(contextId: String, proxyId: String)
fun setContainerDirectConnection(contextId: String, scopeId: String)
fun clearContainerProxy(contextId: String)
fun removeContainerProxyRelation(contextId: String, proxyId: String)
fun setSiteAssignments(assignments: Map<String, String>)
@@ -8535,6 +8536,25 @@ interface GeckoContainerProxyApi {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoContainerProxyApi.setContainerDirectConnection$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val contextIdArg = args[0] as String
val scopeIdArg = args[1] as String
val wrapped: List<Any?> = try {
api.setContainerDirectConnection(contextIdArg, scopeIdArg)
listOf(null)
} catch (exception: Throwable) {
GeckoPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoContainerProxyApi.clearContainerProxy$separatedMessageChannelSuffix", codec)
if (api != null) {
@@ -14,6 +14,7 @@ interface Message {
'upsertProxy' |
'removeProxy' |
'setContainerProxy' |
'setContainerDirectConnection' |
'clearContainerProxy' |
'removeContainerProxyRelation' |
'healthcheck' |
@@ -69,6 +70,15 @@ port.onMessage.addListener((raw: unknown): void => {
store.setContainerProxyRelation(message.args.contextId, message.args.proxyId)
console.log('set container relation ' + message.args.contextId + ' -> ' + message.args.proxyId)
break
case "setContainerDirectConnection":
if (typeof message.args === 'string') {
store.setContainerDirectRelation(message.args)
console.log('set container direct relation ' + message.args)
} else {
store.setContainerDirectRelation(message.args.contextId, message.args.scopeId)
console.log('set container direct relation ' + message.args.contextId + ' scoped to ' + message.args.scopeId)
}
break
case "clearContainerProxy":
store.clearContainerProxyRelation(message.args)
console.log('cleared container relation ' + message.args)
@@ -84,6 +84,7 @@ interface WildcardAssignment {
export class Store {
private proxies: ProxyDao[] = []
private relations: { [key: string]: string[] } = {}
private directRelationScopes: { [key: string]: string } = {}
private siteAssignments: Map<string, string> = new Map<string, string>()
private wildcardAssignments: WildcardAssignment[] = []
@@ -147,15 +148,35 @@ export class Store {
return this.lookupAssignment(uri) !== undefined
}
private hasRelation(contextId: string): boolean {
return Object.prototype.hasOwnProperty.call(this.relations, contextId)
}
/**
* Returns the effective proxy relation for a context ID, mirroring the
* fallback logic in getProxiesForContainer: explicit relation first,
* then 'general' for non-private contexts, then empty.
* Returns the effective proxy relation for a context ID, preserving the
* difference between "no explicit relation" (undefined, may inherit) and
* "explicit direct connection" ([]).
*/
private getEffectiveRelation(contextId: string): string[] {
return this.relations[contextId]
?? ((contextId !== 'private') ? this.relations['general'] : undefined)
?? [];
private getEffectiveRelation(contextId: string): string[] | undefined {
if (this.hasRelation(contextId)) {
return this.relations[contextId]
}
if (contextId !== 'private' && this.hasRelation('general')) {
return this.relations['general']
}
return undefined
}
private getEffectiveDirectScope(contextId: string): string | undefined {
if (this.hasRelation(contextId) && this.relations[contextId].length === 0) {
return this.directRelationScopes[contextId] ?? contextId
}
if (contextId !== 'private' &&
this.hasRelation('general') &&
this.relations['general'].length === 0) {
return this.directRelationScopes['general'] ?? 'general'
}
return undefined
}
isSiteOriginInSameContext(uri: URL, contextId: string): boolean {
@@ -169,10 +190,19 @@ export class Store {
const assignedRelation = this.getEffectiveRelation(assignedContextId);
const currentRelation = this.getEffectiveRelation(contextId);
// Only treat as equivalent if both have actual proxy relations —
// empty relations mean no proxy, and different non-proxied contexts
// should not be considered equivalent.
if (assignedRelation.length > 0 &&
const assignedDirectScope = this.getEffectiveDirectScope(assignedContextId)
const currentDirectScope = this.getEffectiveDirectScope(contextId)
if (assignedDirectScope !== undefined || currentDirectScope !== undefined) {
return assignedDirectScope !== undefined &&
assignedDirectScope === currentDirectScope
}
// Treat proxy-routed contexts as equivalent only when both resolve to an
// explicit non-direct relation. Direct relations are scoped above so two
// unrelated bypassed containers do not collapse into the same context.
if (assignedRelation !== undefined &&
currentRelation !== undefined &&
assignedRelation.length > 0 &&
assignedRelation.length === currentRelation.length &&
assignedRelation.every((id, i) => id === currentRelation[i])) {
return true;
@@ -222,10 +252,17 @@ export class Store {
setContainerProxyRelation(cookieStoreId: string, proxyId: string): void {
this.relations[cookieStoreId] = [proxyId]
delete this.directRelationScopes[cookieStoreId]
}
setContainerDirectRelation(cookieStoreId: string, scopeId: string = cookieStoreId): void {
this.relations[cookieStoreId] = []
this.directRelationScopes[cookieStoreId] = scopeId
}
clearContainerProxyRelation(cookieStoreId: string): void {
delete this.relations[cookieStoreId]
delete this.directRelationScopes[cookieStoreId]
}
removeContainerProxyRelation(cookieStoreId: string, proxyId: string): void {
@@ -172,6 +172,17 @@ describe('Store', () => {
expect(result).to.be.deep.equal([])
})
it('should let an explicit direct relation bypass the general relation', () => {
const isolatedStore = new Store()
isolatedStore.putProxy(someProxyWith('global-proxy'))
isolatedStore.setContainerProxyRelation('general', 'global-proxy')
isolatedStore.setContainerDirectRelation('container1')
const result = isolatedStore.getProxiesForContainer('container1')
expect(result).to.be.null
})
})
describe('wildcard site assignments', function () {
@@ -247,5 +258,24 @@ describe('Store', () => {
const result = store.isSiteOriginInSameContext(new URL('https://blocked.example/another'), 'iso1_blocked')
expect(result).to.be.equal(false)
})
it('should allow direct aliases scoped to the assigned container', async () => {
store.setSiteAssignments(new Map([['https://direct.example/path', 'container_direct']]))
await store.setContainerProxyRelation('general', 'proxy_global')
await store.setContainerDirectRelation('container_direct')
await store.setContainerDirectRelation('iso1_direct', 'container_direct')
const result = store.isSiteOriginInSameContext(new URL('https://direct.example/another'), 'iso1_direct')
expect(result).to.be.equal(true)
})
it('should not allow unrelated direct containers as equivalent', async () => {
store.setSiteAssignments(new Map([['https://direct.example/path', 'container_direct']]))
await store.setContainerDirectRelation('container_direct')
await store.setContainerDirectRelation('other_direct')
const result = store.isSiteOriginInSameContext(new URL('https://direct.example/another'), 'other_direct')
expect(result).to.be.equal(false)
})
})
})
@@ -36,6 +36,13 @@ class GeckoContainerProxyService {
return _apiInstance.setContainerProxy(contextId, proxyId);
}
Future<void> setContainerDirectConnection(
String contextId, {
required String scopeId,
}) {
return _apiInstance.setContainerDirectConnection(contextId, scopeId);
}
Future<void> clearContainerProxy(String contextId) {
return _apiInstance.clearContainerProxy(contextId);
}
@@ -8799,6 +8799,29 @@ class GeckoContainerProxyApi {
);
}
Future<void> setContainerDirectConnection(
String contextId,
String scopeId,
) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.flutter_mozilla_components.GeckoContainerProxyApi.setContainerDirectConnection$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
<Object?>[contextId, scopeId],
);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
_extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
);
}
Future<void> clearContainerProxy(String contextId) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.flutter_mozilla_components.GeckoContainerProxyApi.clearContainerProxy$pigeonVar_messageChannelSuffix';
@@ -1888,6 +1888,7 @@ abstract class GeckoContainerProxyApi {
void upsertProxy(GeckoProxySettings proxy);
void removeProxy(String proxyId);
void setContainerProxy(String contextId, String proxyId);
void setContainerDirectConnection(String contextId, String scopeId);
void clearContainerProxy(String contextId);
void removeContainerProxyRelation(String contextId, String proxyId);
void setSiteAssignments(Map<String, String> assignments);