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 aee00658..2ac625fa 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'3a7f15c1e09e304d355dada2de80112dec0adbf4'; + r'42dcd4ec7bc5a00804f729178b99c56e8c917850'; abstract class _$ProxySettingsReplication extends $Notifier { void build(); 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 62e156c1..0cc14a4f 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 @@ -38,6 +38,7 @@ import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors import 'package:weblibre/features/geckoview/features/tabs/utils/container_icons.dart'; import 'package:weblibre/features/proxy/data/proxy_connection.dart'; import 'package:weblibre/features/proxy/domain/providers/proxy_connection_options.dart'; +import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart'; enum _DialogMode { create, edit } @@ -80,6 +81,11 @@ class ContainerEditScreen extends HookConsumerWidget { final colorScheme = theme.colorScheme; final proxyOptions = ref.watch(proxyConnectionOptionsProvider); + final proxyOptionsLoading = ref.watch( + singboxProxyProfilesRepositoryProvider.select( + (value) => value.isLoading && !value.hasValue, + ), + ); final selectedColor = useState(initialContainer.color); final selectedIcon = useState(initialContainer.metadata.iconData); @@ -383,7 +389,11 @@ class ContainerEditScreen extends HookConsumerWidget { leading: const Icon(Icons.route_outlined), title: const Text('Proxy Connection'), subtitle: Text(switch (proxyConnectionId.value) { - final id? => proxyConnectionTitle(proxyOptions, id), + final id? => proxyConnectionTitle( + proxyOptions, + id, + isLoading: proxyOptionsLoading, + ), null => 'None', }), trailing: const Icon(Icons.chevron_right), @@ -401,6 +411,12 @@ class ContainerEditScreen extends HookConsumerWidget { uuid.v4(); } + final optionsLoaded = ref + .read( + singboxProxyProfilesRepositoryProvider, + ) + .hasValue; + final outcome = await showModalBottomSheet< _ProxyPickerOutcome @@ -410,6 +426,7 @@ class ContainerEditScreen extends HookConsumerWidget { builder: (context) { return _ProxyConnectionPickerSheet( options: proxyOptions, + optionsLoaded: optionsLoaded, selectedProxyConnectionId: proxyConnectionId.value, ); @@ -586,10 +603,12 @@ class _ProxyPickerSelected extends _ProxyPickerOutcome { class _ProxyConnectionPickerSheet extends StatelessWidget { final List options; + final bool optionsLoaded; final ProxyConnectionId? selectedProxyConnectionId; const _ProxyConnectionPickerSheet({ required this.options, + required this.optionsLoaded, required this.selectedProxyConnectionId, }); @@ -597,7 +616,8 @@ class _ProxyConnectionPickerSheet extends StatelessWidget { Widget build(BuildContext context) { final hasUnknownSelectedProxy = selectedProxyConnectionId != null && - !options.any((option) => option.id == selectedProxyConnectionId); + optionsLoaded && + !proxyConnectionOptionExists(options, selectedProxyConnectionId!); return SafeArea( child: RadioGroup( 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 877b2b6c..adc56452 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 @@ -32,6 +32,7 @@ import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/c import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart'; import 'package:weblibre/features/geckoview/features/tabs/utils/container_icons.dart'; import 'package:weblibre/features/proxy/domain/providers/proxy_connection_options.dart'; +import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart'; import 'package:weblibre/features/proxy/presentation/controllers/ensure_proxy_started.dart'; import 'package:weblibre/presentation/widgets/failure_widget.dart'; @@ -183,6 +184,9 @@ class _ContainerCard extends HookConsumerWidget { final tabCount = container.tabCount ?? 0; final palette = ContainerColors.palette(context, containerColor); final proxyOptions = ref.watch(proxyConnectionOptionsProvider); + final proxyOptionsState = ref.watch(singboxProxyProfilesRepositoryProvider); + final proxyOptionsLoading = + proxyOptionsState.isLoading && !proxyOptionsState.hasValue; return AnimatedContainer( duration: const Duration(milliseconds: 200), @@ -253,6 +257,7 @@ class _ContainerCard extends HookConsumerWidget { label: proxyConnectionTitle( proxyOptions, container.metadata.proxyConnectionId!, + isLoading: proxyOptionsLoading, ), ), if (container.metadata.clearDataOnExit) 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 d41c7e39..ba1790d7 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 @@ -35,6 +35,7 @@ import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/c import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart'; import 'package:weblibre/features/geckoview/features/tabs/utils/container_icons.dart'; import 'package:weblibre/features/proxy/domain/providers/proxy_connection_options.dart'; +import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart'; import 'package:weblibre/presentation/widgets/failure_widget.dart'; class ContainerSelectionScreen extends HookConsumerWidget { @@ -242,6 +243,9 @@ class _SelectionContainerCard extends ConsumerWidget { final palette = ContainerColors.palette(context, containerColor); final proxyOptions = ref.watch(proxyConnectionOptionsProvider); + final proxyOptionsState = ref.watch(singboxProxyProfilesRepositoryProvider); + final proxyOptionsLoading = + proxyOptionsState.isLoading && !proxyOptionsState.hasValue; return AnimatedContainer( duration: const Duration(milliseconds: 200), @@ -315,6 +319,7 @@ class _SelectionContainerCard extends ConsumerWidget { label: proxyConnectionTitle( proxyOptions, container.metadata.proxyConnectionId!, + isLoading: proxyOptionsLoading, ), ), if (container.metadata.clearDataOnExit) diff --git a/apps/weblibre/lib/features/proxy/domain/providers/proxy_connection_options.dart b/apps/weblibre/lib/features/proxy/domain/providers/proxy_connection_options.dart index 7c9471fe..12762b82 100644 --- a/apps/weblibre/lib/features/proxy/domain/providers/proxy_connection_options.dart +++ b/apps/weblibre/lib/features/proxy/domain/providers/proxy_connection_options.dart @@ -27,6 +27,9 @@ import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profil part 'proxy_connection_options.g.dart'; +const loadingProxyTitle = 'Loading proxy...'; +const unknownProxyTitle = 'Unknown proxy'; + class ProxyConnectionOption with FastEquatable { final ProxyConnectionId id; final String title; @@ -72,13 +75,21 @@ List proxyConnectionOptions(Ref ref) { String proxyConnectionTitle( List options, - ProxyConnectionId proxyConnectionId, -) { + ProxyConnectionId proxyConnectionId, { + bool isLoading = false, +}) { for (final option in options) { if (option.id == proxyConnectionId) { return option.title; } } - return 'Unknown proxy'; + return isLoading ? loadingProxyTitle : unknownProxyTitle; +} + +bool proxyConnectionOptionExists( + List options, + ProxyConnectionId proxyConnectionId, +) { + return options.any((option) => option.id == proxyConnectionId); } diff --git a/apps/weblibre/lib/features/proxy/domain/repositories/singbox_proxy_runtime.g.dart b/apps/weblibre/lib/features/proxy/domain/repositories/singbox_proxy_runtime.g.dart index 52be74f3..42eb4664 100644 --- a/apps/weblibre/lib/features/proxy/domain/repositories/singbox_proxy_runtime.g.dart +++ b/apps/weblibre/lib/features/proxy/domain/repositories/singbox_proxy_runtime.g.dart @@ -87,7 +87,7 @@ final class SingboxProxyRuntimeRepositoryProvider } String _$singboxProxyRuntimeRepositoryHash() => - r'bb84ab57abd3b7261105da85d3e047cef1b31a44'; + r'660543fd3f82a5fcc5c3c85b11d0a98d472638af'; abstract class _$SingboxProxyRuntimeRepository extends $AsyncNotifier { diff --git a/apps/weblibre/lib/features/proxy/presentation/controllers/ensure_proxy_started.dart b/apps/weblibre/lib/features/proxy/presentation/controllers/ensure_proxy_started.dart index 09b5eae5..1c4b37df 100644 --- a/apps/weblibre/lib/features/proxy/presentation/controllers/ensure_proxy_started.dart +++ b/apps/weblibre/lib/features/proxy/presentation/controllers/ensure_proxy_started.dart @@ -24,6 +24,7 @@ import 'package:weblibre/core/logger.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; import 'package:weblibre/features/proxy/data/proxy_connection.dart'; import 'package:weblibre/features/proxy/domain/providers/proxy_connection_options.dart'; +import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart'; import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart'; import 'package:weblibre/features/tor/presentation/controllers/start_tor_proxy.dart'; import 'package:weblibre/features/tor/presentation/widgets/tor_dialog.dart'; @@ -112,8 +113,8 @@ Future _maybeStartSingboxProxy( if (isRunning) return true; - final proxyTitle = proxyConnectionTitle( - ref.read(proxyConnectionOptionsProvider), + final proxyTitle = await _proxyConnectionTitleForPrompt( + ref, proxyConnectionId, ); final shouldStart = await showDialog( @@ -157,6 +158,35 @@ Future _maybeStartSingboxProxy( } } +Future _proxyConnectionTitleForPrompt( + WidgetRef ref, + ProxyConnectionId proxyConnectionId, +) async { + final options = ref.read(proxyConnectionOptionsProvider); + final title = proxyConnectionTitle(options, proxyConnectionId); + + if (title != unknownProxyTitle) { + return title; + } + + if (proxyConnectionId is SingboxProxyConnectionId) { + try { + final profile = await ref + .read(singboxProxyProfilesRepositoryProvider.notifier) + .findProfile(proxyConnectionId.profileId); + if (profile != null) return profile.name; + } catch (error, stackTrace) { + logger.w( + 'Failed to resolve sing-box proxy profile ${proxyConnectionId.profileId} for prompt title', + error: error, + stackTrace: stackTrace, + ); + } + } + + return proxyConnectionTitle(options, proxyConnectionId); +} + Future _resolveRuntimeStateForPrompt( WidgetRef ref, ) async { 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 fb97c84b..d7ae3c6a 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 @@ -21,6 +21,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/features/proxy/data/proxy_connection.dart'; import 'package:weblibre/features/proxy/domain/providers/proxy_connection_options.dart'; +import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart'; import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart'; import 'package:weblibre/features/user/data/models/proxy_routing_settings.dart'; import 'package:weblibre/features/user/domain/repositories/proxy_routing_settings.dart'; @@ -129,8 +130,10 @@ class _GlobalRoutingProxySection extends ConsumerWidget { } final options = ref.watch(proxyConnectionOptionsProvider); + final optionsState = ref.watch(singboxProxyProfilesRepositoryProvider); return _ProxyConnectionPicker( options: options, + optionsLoaded: optionsState.hasValue, selectedId: settings.regularTabsProxyConnectionId, onChanged: (id) => ref .read(proxyRoutingSettingsRepositoryProvider.notifier) @@ -148,8 +151,10 @@ class _PrivateTabsProxySection extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final settings = ref.watch(proxyRoutingSettingsWithDefaultsProvider); final options = ref.watch(proxyConnectionOptionsProvider); + final optionsState = ref.watch(singboxProxyProfilesRepositoryProvider); return _ProxyConnectionPicker( options: options, + optionsLoaded: optionsState.hasValue, selectedId: settings.privateTabsProxyConnectionId, onChanged: (id) => ref .read(proxyRoutingSettingsRepositoryProvider.notifier) @@ -162,11 +167,13 @@ class _PrivateTabsProxySection extends ConsumerWidget { class _ProxyConnectionPicker extends StatelessWidget { final List options; + final bool optionsLoaded; final ProxyConnectionId? selectedId; final ValueChanged onChanged; const _ProxyConnectionPicker({ required this.options, + required this.optionsLoaded, required this.selectedId, required this.onChanged, }); @@ -174,7 +181,9 @@ class _ProxyConnectionPicker extends StatelessWidget { @override Widget build(BuildContext context) { final hasUnknownSelection = - selectedId != null && !options.any((option) => option.id == selectedId); + selectedId != null && + optionsLoaded && + !proxyConnectionOptionExists(options, selectedId!); return RadioGroup( groupValue: selectedId, diff --git a/apps/weblibre/test/features/geckoview/features/tabs/presentation/screens/container_edit_test.dart b/apps/weblibre/test/features/geckoview/features/tabs/presentation/screens/container_edit_test.dart index 388adb93..df718147 100644 --- a/apps/weblibre/test/features/geckoview/features/tabs/presentation/screens/container_edit_test.dart +++ b/apps/weblibre/test/features/geckoview/features/tabs/presentation/screens/container_edit_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -5,6 +7,9 @@ import 'package:weblibre/features/geckoview/features/tabs/data/models/container_ import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_edit.dart'; import 'package:weblibre/features/proxy/data/proxy_connection.dart'; import 'package:weblibre/features/proxy/domain/providers/proxy_connection_options.dart'; +import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart'; +import 'package:weblibre/features/user/data/database/definitions.drift.dart' + show ProxyProfile; void main() { testWidgets( @@ -16,6 +21,9 @@ void main() { proxyConnectionOptionsProvider.overrideWith( (ref) => const [], ), + singboxProxyProfilesRepositoryProvider.overrideWith( + () => _LoadedProfilesRepository(const []), + ), ], child: MaterialApp( home: ContainerEditScreen.create( @@ -24,7 +32,9 @@ void main() { color: Colors.blue, orderKey: 'a', metadata: ContainerMetadata.withDefaults( - proxyConnectionId: const SingboxProxyConnectionId('missing-proxy'), + proxyConnectionId: const SingboxProxyConnectionId( + 'missing-proxy', + ), ), ), ), @@ -56,6 +66,9 @@ void main() { proxyConnectionOptionsProvider.overrideWith( (ref) => const [], ), + singboxProxyProfilesRepositoryProvider.overrideWith( + () => _LoadedProfilesRepository(const []), + ), ], child: MaterialApp( home: ContainerEditScreen.create( @@ -83,4 +96,58 @@ void main() { expect(find.text('New Container'), findsOneWidget); }, ); + + testWidgets('does not offer to clear selected proxy while profiles load', ( + tester, + ) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + proxyConnectionOptionsProvider.overrideWith( + (ref) => const [], + ), + singboxProxyProfilesRepositoryProvider.overrideWith( + () => _LoadingProfilesRepository(), + ), + ], + child: MaterialApp( + home: ContainerEditScreen.create( + initialContainer: ContainerData( + id: 'container-1', + color: Colors.blue, + orderKey: 'a', + metadata: ContainerMetadata.withDefaults( + proxyConnectionId: const SingboxProxyConnectionId('profile-1'), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Loading proxy...'), findsOneWidget); + + await tester.tap(find.text('Proxy Connection')); + await tester.pumpAndSettle(); + + expect(find.text('Unknown proxy'), findsNothing); + expect(find.text('Clear'), findsNothing); + }); +} + +class _LoadedProfilesRepository extends SingboxProxyProfilesRepository { + final List profiles; + + _LoadedProfilesRepository(this.profiles); + + @override + Stream> build() => Stream.value(profiles); +} + +class _LoadingProfilesRepository extends SingboxProxyProfilesRepository { + @override + Stream> build() async* { + await Completer().future; + } } diff --git a/apps/weblibre/test/features/proxy/presentation/controllers/ensure_proxy_started_test.dart b/apps/weblibre/test/features/proxy/presentation/controllers/ensure_proxy_started_test.dart index e0979cac..f1896f17 100644 --- a/apps/weblibre/test/features/proxy/presentation/controllers/ensure_proxy_started_test.dart +++ b/apps/weblibre/test/features/proxy/presentation/controllers/ensure_proxy_started_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -5,8 +7,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; import 'package:weblibre/features/proxy/data/proxy_connection.dart'; import 'package:weblibre/features/proxy/domain/providers/proxy_connection_options.dart'; +import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart'; import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart'; import 'package:weblibre/features/proxy/presentation/controllers/ensure_proxy_started.dart'; +import 'package:weblibre/features/user/data/database/definitions.drift.dart' + show ProxyProfile; void main() { testWidgets( @@ -47,10 +52,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Start Proxy Connection?'), findsOneWidget); - expect( - find.textContaining('This tab needs Mullvad'), - findsOneWidget, - ); + expect(find.textContaining('This tab needs Mullvad'), findsOneWidget); await tester.tap(find.text('Start')); await tester.pumpAndSettle(); @@ -59,6 +61,56 @@ void main() { expect(find.text('result:true'), findsOneWidget); }, ); + + testWidgets('resolves sing-box prompt title while profile options load', ( + tester, + ) async { + final runtimeRepository = _ErrorRuntimeRepository(); + final profilesRepository = _LoadingProfilesRepository([ + _profile(id: 'profile-1', name: 'Mullvad'), + ]); + final container = ContainerData( + id: 'container-1', + color: Colors.blue, + orderKey: 'a', + metadata: ContainerMetadata.withDefaults( + proxyConnectionId: const SingboxProxyConnectionId('profile-1'), + ), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + singboxProxyRuntimeRepositoryProvider.overrideWith( + () => runtimeRepository, + ), + singboxProxyProfilesRepositoryProvider.overrideWith( + () => profilesRepository, + ), + ], + child: MaterialApp(home: _EnsureProxyHarness(container: container)), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open container')); + await tester.pumpAndSettle(); + + expect(find.textContaining('This tab needs Mullvad'), findsOneWidget); + expect(find.textContaining('Unknown proxy'), findsNothing); + }); +} + +ProxyProfile _profile({required String id, required String name}) { + final createdAt = DateTime(2026); + return ProxyProfile( + id: id, + name: name, + type: SingboxProxyProfileType.customOutbound, + configJson: '{"type":"socks"}', + createdAt: createdAt, + updatedAt: createdAt, + ); } class _EnsureProxyHarness extends ConsumerStatefulWidget { @@ -128,3 +180,22 @@ class _ErrorRuntimeRepository extends SingboxProxyRuntimeRepository { ); } } + +class _LoadingProfilesRepository extends SingboxProxyProfilesRepository { + final List profiles; + + _LoadingProfilesRepository(this.profiles); + + @override + Stream> build() async* { + await Completer().future; + } + + @override + Future findProfile(String id) async { + for (final profile in profiles) { + if (profile.id == id) return profile; + } + return null; + } +}