fix proxy auto start issue

This commit is contained in:
Fabian Freund
2026-05-23 19:20:53 +02:00
parent 696ea84412
commit e34acf2777
20 changed files with 236 additions and 164 deletions
@@ -130,7 +130,7 @@ final class GenericWebsiteServiceProvider
}
String _$genericWebsiteServiceHash() =>
r'83c62a15492606a7ffe4a7990fc1f9bdbfee87cc';
r'189cbd17a06be382859c697221355cd623608123';
abstract class _$GenericWebsiteService extends $Notifier<void> {
void build();
@@ -1186,7 +1186,7 @@ final class GroupedTabListItemsProvider
}
String _$groupedTabListItemsHash() =>
r'15106bfa42146270245c7178928a834237042c1c';
r'dbb510f22c00858fcbadfb94547d46841221fca4';
/// Grouped flat-list rendering for the list and grid views.
///
@@ -26,9 +26,7 @@ import 'package:weblibre/features/geckoview/features/tabs/data/models/container_
import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
import 'package:weblibre/features/proxy/domain/repositories/container_proxy.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart';
import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
import 'package:weblibre/features/user/data/models/proxy_routing_settings.dart';
import 'package:weblibre/features/user/domain/repositories/proxy_routing_settings.dart';
@@ -43,25 +41,6 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
final _recomputeLock = Lock();
var _recomputeDirty = false;
Future<void> _ensureProxyConnectionAvailable(
ProxyConnectionId proxyConnectionId,
) async {
if (proxyConnectionId is! SingboxProxyConnectionId) return;
try {
await ref
.read(singboxProxyRuntimeRepositoryProvider.notifier)
.ensureProxyConnectionAvailable(proxyConnectionId);
} catch (error, stackTrace) {
logger.e(
'Failed to make proxy connection $proxyConnectionId available; '
'assigned traffic will remain blocked',
error: error,
stackTrace: stackTrace,
);
}
}
Future<void> _queueIsolatedProxyAliasesRecompute() async {
_recomputeDirty = true;
if (_recomputeLock.inLock) return;
@@ -187,7 +166,6 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
await containerProxy.clearContainerProxy('private');
} else {
await containerProxy.setContainerProxy('private', next.encode());
await _ensureProxyConnectionAvailable(next);
}
},
onError: (error, stackTrace) {
@@ -220,7 +198,6 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
'general',
proxyConnectionId.encode(),
);
await _ensureProxyConnectionAvailable(proxyConnectionId);
}
}
},
@@ -315,10 +292,6 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
}
try {
if (value != null) {
final proxyConnectionId = ProxyConnectionId.decode(value);
if (proxyConnectionId != null) {
await _ensureProxyConnectionAvailable(proxyConnectionId);
}
await repo.setContainerProxy(key, value);
} else {
await repo.clearContainerProxy(key);
@@ -58,14 +58,19 @@ import 'package:weblibre/features/geckoview/features/find_in_page/presentation/c
import 'package:weblibre/features/geckoview/features/find_in_page/presentation/widgets/find_in_page.dart';
import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart';
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_autofocus.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/proxy/presentation/controllers/ensure_proxy_started.dart';
import 'package:weblibre/features/small_web/presentation/controllers/small_web_mode_controller.dart';
import 'package:weblibre/features/small_web/presentation/widgets/small_web_browser_overlay.dart';
import 'package:weblibre/features/sync/domain/repositories/sync.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart';
import 'package:weblibre/features/user/data/models/proxy_routing_settings.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/features/user/domain/repositories/proxy_routing_settings.dart';
import 'package:weblibre/utils/move_to_background.dart';
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
@@ -230,6 +235,86 @@ class _TabBar extends HookConsumerWidget {
}
}
Future<ProxyConnectionId?> _proxyConnectionIdForLoadError(
WidgetRef ref, {
required String tabId,
required String? contextId,
}) async {
final routing = ref.read(proxyRoutingSettingsWithDefaultsProvider);
final tabState = ref.read(tabStateProvider(tabId));
if (contextId == 'private' || tabState?.tabMode is PrivateTabMode) {
return routing.privateTabsProxyConnectionId;
}
if (contextId != null && contextId.isNotEmpty && contextId != 'general') {
final isolatedContextProxyConnectionId =
await _isolatedContextProxyConnectionId(ref, contextId);
if (isolatedContextProxyConnectionId != null) {
return isolatedContextProxyConnectionId;
}
final contextContainer = await ref
.read(containerRepositoryProvider.notifier)
.getContainerByContextualIdentity(contextId);
final contextProxyConnectionId =
contextContainer?.metadata.proxyConnectionId;
if (contextProxyConnectionId != null) {
return contextProxyConnectionId;
}
}
final tabContainer = await ref
.read(tabDataRepositoryProvider.notifier)
.getTabContainerData(tabId);
final tabProxyConnectionId = tabContainer?.metadata.proxyConnectionId;
if (tabProxyConnectionId != null) {
return tabProxyConnectionId;
}
if (routing.regularTabsMode == ProxyRegularTabRoutingMode.all) {
return routing.regularTabsProxyConnectionId;
}
return null;
}
Future<ProxyConnectionId?> _isolatedContextProxyConnectionId(
WidgetRef 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 proxyIds =
containers
.where((container) => containerIds.contains(container.id))
.map((container) => container.metadata.proxyConnectionId?.encode())
.nonNulls
.toSet()
.toList()
..sort();
if (proxyIds.isEmpty) return null;
return ProxyConnectionId.decode(proxyIds.first);
}
typedef _PendingProxyLoadError = ({
String? contextId,
String errorType,
String? url,
});
class BrowserScreen extends HookConsumerWidget {
const BrowserScreen({super.key});
@@ -238,52 +323,89 @@ class BrowserScreen extends HookConsumerWidget {
final eventService = ref.watch(eventServiceProvider);
final viewportService = ref.watch(viewportServiceProvider);
final activeProxyPromptKeys = useRef(<String>{});
final pendingProxyLoadErrors = useRef(<String, _PendingProxyLoadError>{});
final selectedTabIdForProxyPrompt = ref.watch(selectedTabProvider);
Future<void> handleProxyLoadError({
required String tabId,
required String? contextId,
required String? url,
required String errorType,
}) async {
final promptKey = '$tabId:${url ?? errorType}';
if (!activeProxyPromptKeys.value.add(promptKey)) return;
try {
final proxyConnectionId = await _proxyConnectionIdForLoadError(
ref,
tabId: tabId,
contextId: contextId,
);
if (!context.mounted || proxyConnectionId == null) return;
final isProxyStarted = await ensureProxyStartedForConnection(
context,
ref,
proxyConnectionId,
);
if (isProxyStarted &&
context.mounted &&
ref.read(selectedTabProvider) == tabId) {
await ref.read(tabSessionProvider(tabId: tabId).notifier).reload();
}
} catch (error, stackTrace) {
logger.e(
'Failed to handle proxy load error',
error: error,
stackTrace: stackTrace,
);
} finally {
activeProxyPromptKeys.value.remove(promptKey);
}
}
useEffect(() {
final tabId = selectedTabIdForProxyPrompt;
if (tabId == null) return null;
final pending = pendingProxyLoadErrors.value.remove(tabId);
if (pending == null) return null;
unawaited(
handleProxyLoadError(
tabId: tabId,
contextId: pending.contextId,
url: pending.url,
errorType: pending.errorType,
),
);
return null;
}, [selectedTabIdForProxyPrompt]);
useOnStreamChange(
eventService.proxyLoadErrorEvents,
onData: (event) async {
final selectedTabId = ref.read(selectedTabProvider);
final tabId = event.tabId ?? selectedTabId;
if (tabId == null || tabId != selectedTabId) return;
if (tabId == null) return;
final promptKey = '$tabId:${event.url ?? event.errorType}';
if (!activeProxyPromptKeys.value.add(promptKey)) return;
try {
final contextId = event.contextId;
final contextContainer = contextId != null && contextId.isNotEmpty
? await ref
.read(containerRepositoryProvider.notifier)
.getContainerByContextualIdentity(contextId)
: null;
final container =
contextContainer ??
await ref
.read(tabDataRepositoryProvider.notifier)
.getTabContainerData(tabId);
if (!context.mounted || container == null) return;
final isProxyStarted = await ensureProxyStartedForContainer(
context,
ref,
container,
if (tabId != selectedTabId) {
pendingProxyLoadErrors.value[tabId] = (
contextId: event.contextId,
errorType: event.errorType,
url: event.url,
);
if (isProxyStarted &&
context.mounted &&
ref.read(selectedTabProvider) == tabId) {
await ref.read(tabSessionProvider(tabId: tabId).notifier).reload();
}
} catch (error, stackTrace) {
logger.e(
'Failed to handle proxy load error',
error: error,
stackTrace: stackTrace,
);
} finally {
activeProxyPromptKeys.value.remove(promptKey);
return;
}
await handleProxyLoadError(
tabId: tabId,
contextId: event.contextId,
url: event.url,
errorType: event.errorType,
);
},
);
@@ -272,7 +272,7 @@ final class InstallCurrentWebAppProvider
}
String _$installCurrentWebAppHash() =>
r'0e0013fac7b70441983f7af1e6db3c6470c23900';
r'6eeed094b9ea9ade946dab77c691f271c92f89e1';
/// Installs the current tab as a PWA, embedding profile and container context
/// in the shortcut intent so the PWA reopens with the same isolation.
@@ -460,7 +460,7 @@ final class InstallBasicShortcutProvider
}
String _$installBasicShortcutHash() =>
r'fcb5bec79f375b32a7a0953ad22859a0db222167';
r'7155266e635d0016e4d3ed407bc6d8af61434b0f';
/// Creates a basic bookmark shortcut on the home screen for the current tab.
@@ -139,23 +139,6 @@ class SingboxProxyRuntimeRepository extends _$SingboxProxyRuntimeRepository {
});
}
Future<void> ensureProxyConnectionAvailable(
SingboxProxyConnectionId connectionId,
) async {
await _lock.synchronized(() async {
final currentState = await _stateSnapshotUnlocked();
final isRunning = currentState.endpoints.any(
(endpoint) => endpoint.profileId == connectionId.encode(),
);
if (isRunning) return;
final activeProfileIds = _activeProfileIds(currentState);
await _startProfilesUnlocked(
{...activeProfileIds, connectionId.profileId}.toList(),
);
});
}
Set<String> _activeProfileIds(SingboxProxyRuntimeState runtimeState) {
return runtimeState.endpoints
.map((endpoint) => ProxyConnectionId.decode(endpoint.profileId))
@@ -87,7 +87,7 @@ final class SingboxProxyRuntimeRepositoryProvider
}
String _$singboxProxyRuntimeRepositoryHash() =>
r'665908eaff34569f7a19dc5328a067711d1937f2';
r'bb84ab57abd3b7261105da85d3e047cef1b31a44';
abstract class _$SingboxProxyRuntimeRepository
extends $AsyncNotifier<SingboxProxyRuntimeState> {
@@ -42,7 +42,7 @@ final class ProxyInputConsumerProvider
}
String _$proxyInputConsumerHash() =>
r'fed529f253a9bdf72d0b1c23a298765b681d07b8';
r'8958cb3ff089779c1991390a7d6062cef74b0588';
abstract class _$ProxyInputConsumer extends $Notifier<void> {
void build();
@@ -81,7 +81,7 @@ final class SingboxProxyEndpointSyncProvider
}
String _$singboxProxyEndpointSyncHash() =>
r'2d6b0641db33638b0b339b510dd63cdddab7f7cf';
r'8aeb4097d02c2f6ea37fdb2de26b399d2e6042c2';
/// Mirrors the sing-box runtime's active SOCKS endpoints into Gecko's
/// container-proxy registry. Listens to [singboxProxyRuntimeRepositoryProvider]
@@ -37,7 +37,18 @@ Future<bool> ensureProxyStartedForContainer(
WidgetRef ref,
ContainerData container,
) async {
final proxyConnectionId = container.metadata.proxyConnectionId;
return ensureProxyStartedForConnection(
context,
ref,
container.metadata.proxyConnectionId,
);
}
Future<bool> ensureProxyStartedForConnection(
BuildContext context,
WidgetRef ref,
ProxyConnectionId? proxyConnectionId,
) async {
if (proxyConnectionId == null) return true;
if (proxyConnectionId is TorProxyConnectionId) {
@@ -45,7 +56,7 @@ Future<bool> ensureProxyStartedForContainer(
}
if (proxyConnectionId is SingboxProxyConnectionId) {
return await _maybeStartSingboxProxyForContainer(context, ref, container);
return await _maybeStartSingboxProxy(context, ref, proxyConnectionId);
}
return true;
@@ -71,16 +82,11 @@ Future<bool> _ensureTorStarted(BuildContext context, WidgetRef ref) async {
return false;
}
Future<bool> _maybeStartSingboxProxyForContainer(
Future<bool> _maybeStartSingboxProxy(
BuildContext context,
WidgetRef ref,
ContainerData container,
SingboxProxyConnectionId proxyConnectionId,
) async {
final proxyConnectionId = container.metadata.proxyConnectionId;
if (proxyConnectionId == null) return true;
if (proxyConnectionId is! SingboxProxyConnectionId) return true;
// Await any start/stop in flight so we don't prompt the user a second time
// while a start they already triggered is still resolving. If the runtime
// provider is already in AsyncError, keep treating that as "not running" so
@@ -116,7 +122,7 @@ Future<bool> _maybeStartSingboxProxyForContainer(
icon: const Icon(Icons.route_outlined),
title: const Text('Start Proxy Connection?'),
content: Text(
'This container uses $proxyTitle, but that connection is not running. Start it now?',
'This tab needs $proxyTitle, but that connection is not running. Start it now?',
),
actions: [
TextButton(
@@ -54,7 +54,7 @@ final class SearchBackendEndpointsProvider
}
String _$searchBackendEndpointsHash() =>
r'f7249075bdf7a83271f7f9030dd12a3a9d2bd483';
r'e2f367530cac69a2833b75520d1ac66605822f80';
@ProviderFor(searchClientLogger)
final searchClientLoggerProvider = SearchClientLoggerProvider._();
@@ -110,7 +110,7 @@ final class SharingIntentStreamProvider
}
String _$sharingIntentStreamHash() =>
r'02cef0c00bcb9416f8daf420977f58764c8efaca';
r'62e9e54bfa168e7400c4c496d0aca0972ccdf4c1';
/// Stream of account callback handoff codes extracted from deep link intents.
@@ -374,7 +374,7 @@ final class SandboxCaptureErrorsProvider
}
String _$sandboxCaptureErrorsHash() =>
r'd6ef9ebba2515f8177a380b822f344128e4ea8c4';
r'da9021ba394d2769140408577983bbcf45b0e0fe';
/// Orchestrates sandbox capture browsing:
///
@@ -440,7 +440,7 @@ final class SandboxCaptureControllerProvider
}
String _$sandboxCaptureControllerHash() =>
r'fb582cb09504dafd1b9addc1bfc0e6f58fabc9b1';
r'6ddcb0df7194f3e35ac0eee3c182204a990f9651';
/// Orchestrates sandbox capture browsing:
///
@@ -42,7 +42,7 @@ final class MetaSearchControllerProvider
}
String _$metaSearchControllerHash() =>
r'afb6600f78717df124a6afdb80710eba74666d23';
r'74ea7c616213dae53d3ac18b2677b797f5f87d53';
abstract class _$MetaSearchController extends $Notifier<MetaSearchState> {
MetaSearchState build();
@@ -108,7 +108,7 @@ final class WebSearchScrollOffsetProvider
}
String _$webSearchScrollOffsetHash() =>
r'8d622d93aba7712b2bf0e6da7cbde6c90ee00c07';
r'404573c2e5e52a0f260d182b7947f4878df45820';
/// Persisted vertical scroll offset of the web-search results list. Kept
/// alive (like [MetaSearchController]) so returning to the search screen
@@ -12,6 +12,7 @@ import 'package:mockito/mockito.dart' as _i1;
import 'package:mockito/src/dummies.dart' as _i5;
// ignore_for_file: type=lint
// ignore_for_file: avoid_redundant_argument_values
// ignore_for_file: avoid_setters_without_getters
// ignore_for_file: comment_references
// ignore_for_file: deprecated_member_use
@@ -12,6 +12,7 @@ import 'package:mockito/mockito.dart' as _i1;
import 'package:mockito/src/dummies.dart' as _i5;
// ignore_for_file: type=lint
// ignore_for_file: avoid_redundant_argument_values
// ignore_for_file: avoid_setters_without_getters
// ignore_for_file: comment_references
// ignore_for_file: deprecated_member_use
@@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:ui';
import 'package:drift/native.dart';
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_tor/flutter_tor.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -17,7 +16,6 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
import 'package:weblibre/features/proxy/domain/repositories/container_proxy.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart';
import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
import 'package:weblibre/features/user/data/models/proxy_routing_settings.dart';
import 'package:weblibre/features/user/domain/repositories/proxy_routing_settings.dart';
@@ -26,7 +24,7 @@ void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test(
'container assignments ensure sing-box profiles are running before syncing',
'container assignments sync without starting stopped sing-box profiles',
() async {
const profileId = SingboxProxyConnectionId('profile-1');
final assignedContainer = _container(
@@ -44,7 +42,6 @@ void main() {
);
final containerProxyRepository = _FakeContainerProxyRepository();
final containerRepository = _FakeContainerRepository([assignedContainer]);
final runtimeRepository = _FakeSingboxProxyRuntimeRepository();
final container = ProviderContainer(
overrides: [
tabDatabaseProvider.overrideWith((ref) => db),
@@ -52,9 +49,6 @@ void main() {
() => containerProxyRepository,
),
containerRepositoryProvider.overrideWith(() => containerRepository),
singboxProxyRuntimeRepositoryProvider.overrideWith(
() => runtimeRepository,
),
torProxyServiceProvider.overrideWith(_FakeTorProxyService.new),
proxyRoutingSettingsWithDefaultsProvider.overrideWith(
(ref) => ProxyRoutingSettings.withDefaults(),
@@ -83,7 +77,6 @@ void main() {
addTearDown(subscription.close);
await pumpEventQueue();
expect(runtimeRepository.ensuredProxyConnectionIds, [profileId]);
expect(containerProxyRepository.setContainerProxyCalls, [
('context-a', profileId.encode()),
]);
@@ -144,25 +137,6 @@ class _FakeContainerRepository extends ContainerRepository {
void build() {}
}
class _FakeSingboxProxyRuntimeRepository extends SingboxProxyRuntimeRepository {
final ensuredProxyConnectionIds = <SingboxProxyConnectionId>[];
@override
Future<void> ensureProxyConnectionAvailable(
SingboxProxyConnectionId connectionId,
) async {
ensuredProxyConnectionIds.add(connectionId);
}
@override
Future<SingboxProxyRuntimeState> build() async {
return SingboxProxyRuntimeState(
status: SingboxProxyRuntimeStatus.stopped,
endpoints: [],
);
}
}
class _FakeTorProxyService extends TorProxyService {
@override
Stream<TorStatus> build() async* {
@@ -22,37 +22,6 @@ import 'package:weblibre/features/user/domain/repositories/proxy_routing_setting
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test(
'ensureProxyConnectionAvailable starts an assigned stopped profile',
() async {
final profile = _profile(id: 'profile-1', name: 'First');
final client = _FakeSingboxProxyClient(_state(const []));
final container = _container(
client: client,
profilesRepository: _FakeProfilesRepository([profile]),
);
addTearDown(container.dispose);
await container.read(singboxProxyRuntimeRepositoryProvider.future);
final repository = container.read(
singboxProxyRuntimeRepositoryProvider.notifier,
);
await repository.ensureProxyConnectionAvailable(
SingboxProxyConnectionId(profile.id),
);
await repository.ensureProxyConnectionAvailable(
SingboxProxyConnectionId(profile.id),
);
expect(client.startCalls, hasLength(1));
expect(
client.startCalls.single.map((runtimeProfile) => runtimeProfile.id),
[profile.proxyConnectionId],
);
},
);
test('startProfile preserves already-running profiles', () async {
final profile1 = _profile(id: 'profile-1', name: 'First');
final profile2 = _profile(id: 'profile-2', name: 'Second');
@@ -48,7 +48,7 @@ void main() {
expect(find.text('Start Proxy Connection?'), findsOneWidget);
expect(
find.textContaining('This container uses Mullvad'),
find.textContaining('This tab needs Mullvad'),
findsOneWidget,
);
@@ -17,8 +17,10 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/widgets.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/settings/domain/providers/pending_settings_highlight.dart';
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
void main() {
@@ -78,4 +80,45 @@ void main() {
);
});
});
testWidgets('clears a pending highlight after the target entry handles it', (
tester,
) async {
final container = ProviderContainer();
addTearDown(container.dispose);
container.read(pendingSettingsHighlightProvider.notifier).set('Theme');
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: MaterialApp(
home: SettingsDetailScaffold(
title: 'Appearance',
subtitle: 'Configure app appearance',
icon: Icons.palette,
sections: const [
SettingsSectionDefinition(
title: 'Display',
entries: [
SettingsEntryDefinition(
title: 'Theme',
child: SizedBox(height: 48, child: Text('Theme')),
),
],
),
],
),
),
),
);
await tester.pump();
expect(container.read(pendingSettingsHighlightProvider), isNull);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();
expect(container.read(pendingSettingsHighlightProvider), isNull);
});
}