From 7275ac940067530ad16f90841037f056322843ca Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Wed, 27 May 2026 09:23:18 +0200 Subject: [PATCH] setting to exclude proxy from global routing --- .../geckoview/domain/providers/tab_state.dart | 13 ++ .../domain/providers/tab_state.g.dart | 2 +- .../services/proxy_settings_replication.dart | 143 ++++++++++++--- .../proxy_settings_replication.g.dart | 2 +- .../browser/presentation/screens/browser.dart | 40 +++- .../tabs/data/models/container_data.dart | 7 + .../tabs/data/models/container_data.g.dart | 16 ++ .../features/tabs/domain/providers.dart | 4 +- .../features/tabs/domain/providers.g.dart | 12 +- .../presentation/screens/container_edit.dart | 28 +++ .../presentation/screens/container_list.dart | 7 + .../screens/container_selection.dart | 7 + .../domain/repositories/container_proxy.dart | 10 + .../repositories/container_proxy.g.dart | 2 +- .../screens/proxy_routing_settings.dart | 2 +- .../controllers/website_title.dart | 95 +++++++++- .../controllers/website_title.g.dart | 2 +- .../proxy_settings_replication_test.dart | 171 +++++++++++++++++- .../tabs/data/models/container_data_test.dart | 3 + .../api/GeckoContainerProxyApiImpl.kt | 10 + .../pigeons/Gecko.g.kt | 20 ++ .../container_proxy/src/background/index.ts | 10 + .../container_proxy/src/store/Store.ts | 59 ++++-- .../container_proxy/test/unit/Store.test.ts | 30 +++ .../services/gecko_container_proxy.dart | 7 + .../lib/src/pigeons/gecko.g.dart | 23 +++ .../pigeons/gecko.dart | 1 + 27 files changed, 661 insertions(+), 65 deletions(-) diff --git a/apps/weblibre/lib/features/geckoview/domain/providers/tab_state.dart b/apps/weblibre/lib/features/geckoview/domain/providers/tab_state.dart index a95a3c48..16753ef3 100644 --- a/apps/weblibre/lib/features/geckoview/domain/providers/tab_state.dart +++ b/apps/weblibre/lib/features/geckoview/domain/providers/tab_state.dart @@ -396,6 +396,19 @@ Future 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; } } diff --git a/apps/weblibre/lib/features/geckoview/domain/providers/tab_state.g.dart b/apps/weblibre/lib/features/geckoview/domain/providers/tab_state.g.dart index c843a882..1563804c 100644 --- a/apps/weblibre/lib/features/geckoview/domain/providers/tab_state.g.dart +++ b/apps/weblibre/lib/features/geckoview/domain/providers/tab_state.g.dart @@ -258,7 +258,7 @@ final class IsTabTunneledProvider } } -String _$isTabTunneledHash() => r'cb3a3afe5b94f7e9c77d6a32266afbb8a225c259'; +String _$isTabTunneledHash() => r'd36607ff812f71870af32ce59c2dbe898a9c1917'; final class IsTabTunneledFamily extends $Family with $FunctionalFamilyOverride, String?> { diff --git a/apps/weblibre/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.dart b/apps/weblibre/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.dart index 0a8d497a..ef61d49f 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.dart @@ -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 get hashParameters => const ['inherit']; +} + +final class _DirectProxyAssignment extends _ProxyAssignment { + final String scopeId; + + _DirectProxyAssignment(this.scopeId); + + @override + List get hashParameters => [scopeId]; +} + +final class _ExplicitProxyAssignment extends _ProxyAssignment { + final String proxyId; + + _ExplicitProxyAssignment(this.proxyId); + + @override + List get hashParameters => [proxyId]; +} + @Riverpod(keepAlive: true) class ProxySettingsReplication extends _$ProxySettingsReplication { - var _isolatedProxyAssignments = {}; - var _appliedContainerProxies = {}; + var _isolatedProxyAssignments = {}; + var _appliedContainerProxies = {}; final _recomputeLock = Lock(); var _recomputeDirty = false; @@ -82,33 +117,71 @@ class ProxySettingsReplication extends _$ProxySettingsReplication { .read(containerRepositoryProvider.notifier) .getAllContainersWithCount(); - final containerProxyIds = { + final containerAssignments = { 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 = {}; + final newAssignments = {}; 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 = {}; + final desired = {}; 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.from(_appliedContainerProxies); + final nextApplied = Map.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 _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); + } + } } diff --git a/apps/weblibre/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.g.dart b/apps/weblibre/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.g.dart index 2ac625fa..01b6bf72 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.g.dart @@ -42,7 +42,7 @@ final class ProxySettingsReplicationProvider } String _$proxySettingsReplicationHash() => - r'42dcd4ec7bc5a00804f729178b99c56e8c917850'; + r'cdb624b7f7806a7ce4218da350253ed3d29986d0'; abstract class _$ProxySettingsReplication extends $Notifier { void build(); diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/screens/browser.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/screens/browser.dart index 378fd2cb..e15eb1f2 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/screens/browser.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/screens/browser.dart @@ -249,9 +249,9 @@ Future _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 _proxyConnectionIdForLoadError( if (contextProxyConnectionId != null) { return contextProxyConnectionId; } + if (contextContainer?.metadata.bypassGlobalProxy == true) { + return null; + } } final tabContainer = await ref @@ -271,6 +274,9 @@ Future _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 _proxyConnectionIdForLoadError( return null; } -Future _isolatedContextProxyConnectionId( +typedef _LoadErrorProxyAssignment = ({ + bool direct, + ProxyConnectionId? proxyConnectionId, +}); + +Future<_LoadErrorProxyAssignment?> _isolatedContextProxyAssignment( WidgetRef ref, String contextId, ) async { @@ -296,17 +307,32 @@ Future _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 = ({ diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.dart index 51455b8d..ff5ed465 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.dart @@ -50,6 +50,9 @@ class ContainerMetadata with FastEquatable { @JsonKey(defaultValue: false) final bool excludeFromIndex; + @JsonKey(defaultValue: false) + final bool bypassGlobalProxy; + final List? 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? 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, ]; } diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.g.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.g.dart index 541c1983..76fdef79 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.g.dart @@ -17,6 +17,8 @@ abstract class _$ContainerMetadataCWProxy { ContainerMetadata excludeFromIndex(bool excludeFromIndex); + ContainerMetadata bypassGlobalProxy(bool bypassGlobalProxy); + ContainerMetadata assignedSites(List? 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? 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? 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 json) => ), clearDataOnExit: json['clearDataOnExit'] as bool? ?? false, excludeFromIndex: json['excludeFromIndex'] as bool? ?? false, + bypassGlobalProxy: json['bypassGlobalProxy'] as bool? ?? false, assignedSites: (json['assignedSites'] as List?) ?.map((e) => Uri.parse(e as String)) .toList(), @@ -263,6 +278,7 @@ Map _$ContainerMetadataToJson( 'proxyConnectionId': _proxyConnectionIdToJson(instance.proxyConnectionId), 'clearDataOnExit': instance.clearDataOnExit, 'excludeFromIndex': instance.excludeFromIndex, + 'bypassGlobalProxy': instance.bypassGlobalProxy, 'assignedSites': instance.assignedSites?.map((e) => e.toString()).toList(), }; diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers.dart b/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers.dart index 2e110bc2..b1478672 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers.dart @@ -195,8 +195,8 @@ Stream> 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>> watchIsolatedContextContainerMap(Ref ref) { final db = ref.watch(tabDatabaseProvider); diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers.g.dart b/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers.g.dart index 1cca3310..1dcc180b 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers.g.dart @@ -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, diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart index 85924e07..8c2cb841 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart @@ -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( + groupValue: selectedProxyConnectionId, onChanged: (value) { Navigator.pop( context, diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_list.dart b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_list.dart index 3942213c..ab938f45 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_list.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_list.dart @@ -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, diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_selection.dart b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_selection.dart index ba1790d7..fd8904ca 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_selection.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_selection.dart @@ -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, diff --git a/apps/weblibre/lib/features/proxy/domain/repositories/container_proxy.dart b/apps/weblibre/lib/features/proxy/domain/repositories/container_proxy.dart index 91b5fdf6..fa74c659 100644 --- a/apps/weblibre/lib/features/proxy/domain/repositories/container_proxy.dart +++ b/apps/weblibre/lib/features/proxy/domain/repositories/container_proxy.dart @@ -83,6 +83,16 @@ class ContainerProxyRepository extends _$ContainerProxyRepository { ); } + Future setContainerDirectConnection( + String contextId, { + required String scopeId, + }) { + return _runLocked( + body: () => + _service.setContainerDirectConnection(contextId, scopeId: scopeId), + ); + } + Future clearContainerProxy(String contextId) { return _runLocked(body: () => _service.clearContainerProxy(contextId)); } diff --git a/apps/weblibre/lib/features/proxy/domain/repositories/container_proxy.g.dart b/apps/weblibre/lib/features/proxy/domain/repositories/container_proxy.g.dart index 137910ae..a8f810dd 100644 --- a/apps/weblibre/lib/features/proxy/domain/repositories/container_proxy.g.dart +++ b/apps/weblibre/lib/features/proxy/domain/repositories/container_proxy.g.dart @@ -42,7 +42,7 @@ final class ContainerProxyRepositoryProvider } String _$containerProxyRepositoryHash() => - r'08cd408a3ae96c859ed5c9d56d61cf7e6514415f'; + r'ec3ec41b45b991623518f0aceb6ee7b21abb9112'; abstract class _$ContainerProxyRepository extends $Notifier { void build(); diff --git a/apps/weblibre/lib/features/proxy/presentation/screens/proxy_routing_settings.dart b/apps/weblibre/lib/features/proxy/presentation/screens/proxy_routing_settings.dart index d7ae3c6a..77385bf4 100644 --- a/apps/weblibre/lib/features/proxy/presentation/screens/proxy_routing_settings.dart +++ b/apps/weblibre/lib/features/proxy/presentation/screens/proxy_routing_settings.dart @@ -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.', ), ), ], diff --git a/apps/weblibre/lib/presentation/controllers/website_title.dart b/apps/weblibre/lib/presentation/controllers/website_title.dart index a8a6d201..2cb58c65 100644 --- a/apps/weblibre/lib/presentation/controllers/website_title.dart +++ b/apps/weblibre/lib/presentation/controllers/website_title.dart @@ -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 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 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?>> websiteFeedProvider( Ref ref, diff --git a/apps/weblibre/lib/presentation/controllers/website_title.g.dart b/apps/weblibre/lib/presentation/controllers/website_title.g.dart index 086cb0a3..e8b8fe22 100644 --- a/apps/weblibre/lib/presentation/controllers/website_title.g.dart +++ b/apps/weblibre/lib/presentation/controllers/website_title.g.dart @@ -162,7 +162,7 @@ final class PageInfoProvider } } -String _$pageInfoHash() => r'1dedc23d2420c950c7cec57500e99c5c221ed6e2'; +String _$pageInfoHash() => r'680a8f0f0101c86946b4839d30d6f55307312cce'; final class PageInfoFamily extends $Family with diff --git a/apps/weblibre/test/features/geckoview/features/browser/domain/services/proxy_settings_replication_test.dart b/apps/weblibre/test/features/geckoview/features/browser/domain/services/proxy_settings_replication_test.dart index 9b685104..635f00e8 100644 --- a/apps/weblibre/test/features/geckoview/features/browser/domain/services/proxy_settings_replication_test.dart +++ b/apps/weblibre/test/features/geckoview/features/browser/domain/services/proxy_settings_replication_test.dart @@ -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 []), + ), + watchIsolatedContextContainerMapProvider.overrideWith( + (ref) => Stream.value(const >{}), + ), + ], + ); + addTearDown(() async { + container.dispose(); + await db.close(); + }); + + final subscription = container.listen( + 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 []), + ), + watchIsolatedContextContainerMapProvider.overrideWith( + (ref) => Stream.value(const { + 'isolation-a': {'container-direct', 'container-inherit'}, + }), + ), + ], + ); + addTearDown(() async { + container.dispose(); + await db.close(); + }); + + final subscription = container.listen( + 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 setTorProxyPort(int? port) async {} @@ -116,6 +277,14 @@ class _FakeContainerProxyRepository extends ContainerProxyRepository { setContainerProxyCalls.add((contextId, proxyId)); } + @override + Future setContainerDirectConnection( + String contextId, { + required String scopeId, + }) async { + setContainerDirectConnectionCalls.add((contextId, scopeId)); + } + @override Future clearContainerProxy(String contextId) async {} diff --git a/apps/weblibre/test/features/geckoview/features/tabs/data/models/container_data_test.dart b/apps/weblibre/test/features/geckoview/features/tabs/data/models/container_data_test.dart index 9b64cd50..69b1329c 100644 --- a/apps/weblibre/test/features/geckoview/features/tabs/data/models/container_data_test.dart +++ b/apps/weblibre/test/features/geckoview/features/tabs/data/models/container_data_test.dart @@ -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); }); diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoContainerProxyApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoContainerProxyApiImpl.kt index f758363d..73e023f6 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoContainerProxyApiImpl.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoContainerProxyApiImpl.kt @@ -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) } diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt index 04357acf..c73d2d6c 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt @@ -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) @@ -8535,6 +8536,25 @@ interface GeckoContainerProxyApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoContainerProxyApi.setContainerDirectConnection$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val contextIdArg = args[0] as String + val scopeIdArg = args[1] as String + val wrapped: List = try { + api.setContainerDirectConnection(contextIdArg, scopeIdArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } run { val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoContainerProxyApi.clearContainerProxy$separatedMessageChannelSuffix", codec) if (api != null) { diff --git a/packages/flutter_mozilla_components/javascript/container_proxy/src/background/index.ts b/packages/flutter_mozilla_components/javascript/container_proxy/src/background/index.ts index 362de3ba..723363ed 100644 --- a/packages/flutter_mozilla_components/javascript/container_proxy/src/background/index.ts +++ b/packages/flutter_mozilla_components/javascript/container_proxy/src/background/index.ts @@ -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) diff --git a/packages/flutter_mozilla_components/javascript/container_proxy/src/store/Store.ts b/packages/flutter_mozilla_components/javascript/container_proxy/src/store/Store.ts index 8560f867..52509c36 100644 --- a/packages/flutter_mozilla_components/javascript/container_proxy/src/store/Store.ts +++ b/packages/flutter_mozilla_components/javascript/container_proxy/src/store/Store.ts @@ -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 = new Map() 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 { diff --git a/packages/flutter_mozilla_components/javascript/container_proxy/test/unit/Store.test.ts b/packages/flutter_mozilla_components/javascript/container_proxy/test/unit/Store.test.ts index a7022d6c..8bb699dc 100644 --- a/packages/flutter_mozilla_components/javascript/container_proxy/test/unit/Store.test.ts +++ b/packages/flutter_mozilla_components/javascript/container_proxy/test/unit/Store.test.ts @@ -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) + }) }) }) diff --git a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_container_proxy.dart b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_container_proxy.dart index 6a17abd5..33dcd2f0 100644 --- a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_container_proxy.dart +++ b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_container_proxy.dart @@ -36,6 +36,13 @@ class GeckoContainerProxyService { return _apiInstance.setContainerProxy(contextId, proxyId); } + Future setContainerDirectConnection( + String contextId, { + required String scopeId, + }) { + return _apiInstance.setContainerDirectConnection(contextId, scopeId); + } + Future clearContainerProxy(String contextId) { return _apiInstance.clearContainerProxy(contextId); } diff --git a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart index 4e7cc454..18435536 100644 --- a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart +++ b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart @@ -8799,6 +8799,29 @@ class GeckoContainerProxyApi { ); } + Future setContainerDirectConnection( + String contextId, + String scopeId, + ) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.flutter_mozilla_components.GeckoContainerProxyApi.setContainerDirectConnection$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [contextId, scopeId], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ); + } + Future clearContainerProxy(String contextId) async { final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoContainerProxyApi.clearContainerProxy$pigeonVar_messageChannelSuffix'; diff --git a/packages/flutter_mozilla_components/pigeons/gecko.dart b/packages/flutter_mozilla_components/pigeons/gecko.dart index c21963e6..f2078909 100644 --- a/packages/flutter_mozilla_components/pigeons/gecko.dart +++ b/packages/flutter_mozilla_components/pigeons/gecko.dart @@ -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 assignments);