Add proxy routing and sing-box support

This commit is contained in:
Fabian Freund
2026-05-22 18:16:31 +02:00
parent 51289f1266
commit a5974617aa
262 changed files with 32003 additions and 3962 deletions
@@ -18,41 +18,66 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:fast_equatable/fast_equatable.dart';
import 'package:nullability/nullability.dart';
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:synchronized/synchronized.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
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/tor/domain/repositories/tor_proxy.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/tor_settings.dart';
import 'package:weblibre/features/user/domain/repositories/tor_settings.dart';
import 'package:weblibre/features/user/data/models/proxy_routing_settings.dart';
import 'package:weblibre/features/user/domain/repositories/proxy_routing_settings.dart';
part 'proxy_settings_replication.g.dart';
@Riverpod(keepAlive: true)
class ProxySettingsReplication extends _$ProxySettingsReplication {
var _proxiedIsolationContexts = <String>{};
var _isolatedProxyAssignments = <String, String>{};
var _appliedContainerProxies = <String, String?>{};
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 {
if (_recomputeLock.inLock) {
return;
}
_recomputeDirty = true;
if (_recomputeLock.inLock) return;
await _recomputeLock.synchronized(() async {
try {
await _recomputeIsolatedProxyAliases();
} catch (error, stackTrace) {
logger.e(
'Error recomputing isolated proxy aliases',
error: error,
stackTrace: stackTrace,
);
while (_recomputeDirty) {
_recomputeDirty = false;
try {
await _recomputeIsolatedProxyAliases();
} catch (error, stackTrace) {
logger.e(
'Error recomputing isolated proxy aliases',
error: error,
stackTrace: stackTrace,
);
}
}
});
}
@@ -78,37 +103,56 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
.read(containerRepositoryProvider.notifier)
.getAllContainersWithCount();
// Build set of container IDs that have useProxy enabled.
final proxiedContainerIds = <String>{
final containerProxyIds = <String, String>{
for (final c in containers)
if (c.metadata.useProxy) c.id,
if (c.metadata.proxyConnectionId case final proxyId?)
c.id: proxyId.encode(),
};
// Compute which isolation contexts need proxy aliases.
// A context needs an alias if ANY of its associated containers
// has useProxy enabled.
final newProxied = <String>{
for (final entry in contextContainerMap.entries)
if (entry.value.any(proxiedContainerIds.contains)) entry.key,
};
final newAssignments = <String, String>{};
for (final entry in contextContainerMap.entries) {
final proxyIds =
entry.value
.map((containerId) => containerProxyIds[containerId])
.nonNulls
.toSet()
.toList()
..sort();
// Remove aliases that are no longer needed.
final toRemove = _proxiedIsolationContexts.difference(newProxied);
if (proxyIds.isEmpty) continue;
newAssignments[entry.key] = proxyIds.first;
if (proxyIds.length > 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.
logger.w(
'Isolation context ${entry.key} has containers with multiple '
'proxy connections (${proxyIds.join(', ')}); using ${proxyIds.first}',
);
}
}
final previousContexts = _isolatedProxyAssignments.keys.toSet();
final nextContexts = newAssignments.keys.toSet();
final toRemove = previousContexts.difference(nextContexts);
for (final contextId in toRemove) {
await ref
.read(torProxyRepositoryProvider.notifier)
.removeContainerProxy(contextId);
.read(containerProxyRepositoryProvider.notifier)
.clearContainerProxy(contextId);
}
// Add aliases that are newly needed.
final toAdd = newProxied.difference(_proxiedIsolationContexts);
for (final contextId in toAdd) {
for (final MapEntry(:key, :value) in newAssignments.entries) {
if (_isolatedProxyAssignments[key] == value) continue;
await ref
.read(torProxyRepositoryProvider.notifier)
.addContainerProxy(contextId);
.read(containerProxyRepositoryProvider.notifier)
.setContainerProxy(key, value);
}
_proxiedIsolationContexts = newProxied;
_isolatedProxyAssignments = newAssignments;
}
@override
@@ -118,8 +162,8 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
torProxyServiceProvider.select((data) => data.value),
(previous, next) async {
await ref
.read(torProxyRepositoryProvider.notifier)
.setProxyPort(next?.socksPort ?? -1);
.read(containerProxyRepositoryProvider.notifier)
.setTorProxyPort(next?.socksPort);
},
onError: (error, stackTrace) {
logger.e(
@@ -132,51 +176,18 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
ref.listen(
fireImmediately: true,
watchContainersWithCountProvider.select(
(value) => EquatableValue(value.value),
proxyRoutingSettingsWithDefaultsProvider.select(
(value) => value.privateTabsProxyConnectionId,
),
(previous, next) async {
if (next.value != null) {
for (final container in next.value!) {
if (container.metadata.contextualIdentity.isNotEmpty) {
if (container.metadata.useProxy) {
await ref
.read(torProxyRepositoryProvider.notifier)
.addContainerProxy(container.metadata.contextualIdentity!);
} else {
await ref
.read(torProxyRepositoryProvider.notifier)
.removeContainerProxy(
container.metadata.contextualIdentity!,
);
}
}
}
}
},
onError: (error, stackTrace) {
logger.e(
'Error listening to containersWithCountProvider',
error: error,
stackTrace: stackTrace,
final containerProxy = ref.read(
containerProxyRepositoryProvider.notifier,
);
},
);
ref.listen(
fireImmediately: true,
torSettingsWithDefaultsProvider.select(
(value) => value.proxyPrivateTabsTor,
),
(previous, next) async {
if (next) {
await ref
.read(torProxyRepositoryProvider.notifier)
.addContainerProxy('private');
if (next == null) {
await containerProxy.clearContainerProxy('private');
} else {
await ref
.read(torProxyRepositoryProvider.notifier)
.removeContainerProxy('private');
await containerProxy.setContainerProxy('private', next.encode());
await _ensureProxyConnectionAvailable(next);
}
},
onError: (error, stackTrace) {
@@ -190,19 +201,27 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
ref.listen(
fireImmediately: true,
torSettingsWithDefaultsProvider.select(
(value) => value.proxyRegularTabsMode,
proxyRoutingSettingsWithDefaultsProvider.select(
(value) => (value.regularTabsMode, value.regularTabsProxyConnectionId),
),
(previous, next) async {
switch (next) {
case TorRegularTabProxyMode.container:
await ref
.read(torProxyRepositoryProvider.notifier)
.removeContainerProxy('general');
case TorRegularTabProxyMode.all:
await ref
.read(torProxyRepositoryProvider.notifier)
.addContainerProxy('general');
final (regularTabsMode, proxyConnectionId) = next;
final containerProxy = ref.read(
containerProxyRepositoryProvider.notifier,
);
switch (regularTabsMode) {
case ProxyRegularTabRoutingMode.container:
await containerProxy.clearContainerProxy('general');
case ProxyRegularTabRoutingMode.all:
if (proxyConnectionId == null) {
await containerProxy.clearContainerProxy('general');
} else {
await containerProxy.setContainerProxy(
'general',
proxyConnectionId.encode(),
);
await _ensureProxyConnectionAvailable(proxyConnectionId);
}
}
},
onError: (error, stackTrace) {
@@ -217,7 +236,7 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
ref.listen(watchAllAssignedSitesProvider, (previous, next) async {
if (next.hasValue) {
await ref
.read(torProxyRepositoryProvider.notifier)
.read(containerProxyRepositoryProvider.notifier)
.setSiteAssignments(next.requireValue);
}
});
@@ -242,14 +261,80 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
watchContainersWithCountProvider.select(
(value) => EquatableValue(value.value),
),
(previous, next) => _queueIsolatedProxyAliasesRecompute(),
(previous, next) async {
await _applyContainerProxies(next.value);
await _queueIsolatedProxyAliasesRecompute();
},
onError: (error, stackTrace) {
logger.e(
'Error listening to container proxy changes for isolated aliases',
'Error listening to containersWithCountProvider',
error: error,
stackTrace: stackTrace,
);
},
);
}
/// Push per-container proxy assignments to Gecko, reconciling against the
/// last applied state so a transient mid-loop failure surfaces as a retry
/// on the next event rather than leaving Gecko half-updated forever.
Future<void> _applyContainerProxies(
List<ContainerDataWithCount>? containers,
) async {
if (containers == null) return;
final desired = <String, String?>{};
for (final container in containers) {
final contextId = container.metadata.contextualIdentity;
if (contextId == null || contextId.isEmpty) continue;
desired[contextId] = container.metadata.proxyConnectionId?.encode();
}
final repo = ref.read(containerProxyRepositoryProvider.notifier);
final nextApplied = Map<String, String?>.from(_appliedContainerProxies);
for (final contextId in _appliedContainerProxies.keys.toSet().difference(
desired.keys.toSet(),
)) {
try {
await repo.clearContainerProxy(contextId);
nextApplied.remove(contextId);
} catch (error, stackTrace) {
logger.e(
'Failed to clear removed container proxy assignment for $contextId',
error: error,
stackTrace: stackTrace,
);
}
}
for (final MapEntry(:key, :value) in desired.entries) {
if (_appliedContainerProxies[key] == value &&
_appliedContainerProxies.containsKey(key)) {
continue;
}
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);
}
nextApplied[key] = value;
} catch (error, stackTrace) {
logger.e(
'Failed to push container proxy assignment for $key',
error: error,
stackTrace: stackTrace,
);
// Leave nextApplied[key] at its previous value so the next event
// sees it as out-of-sync and retries.
}
}
_appliedContainerProxies = nextApplied;
}
}
@@ -42,7 +42,7 @@ final class ProxySettingsReplicationProvider
}
String _$proxySettingsReplicationHash() =>
r'7f47a561441cc2d594e9380d62dcc31f4c1059d3';
r'3a7f15c1e09e304d355dada2de80112dec0adbf4';
abstract class _$ProxySettingsReplication extends $Notifier<void> {
void build();
@@ -877,13 +877,10 @@ Future<void> _adjustFontSize(
if (settings.automaticFontSizeAdjustment) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Disable automatic font size in settings to adjust manually',
),
duration: Duration(seconds: 2),
),
ui_helper.showInfoMessage(
context,
'Disable automatic font size in settings to adjust manually',
duration: const Duration(seconds: 2),
);
}
return;
@@ -58,6 +58,9 @@ 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/domain/repositories/container.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.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';
@@ -234,6 +237,55 @@ class BrowserScreen extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final eventService = ref.watch(eventServiceProvider);
final viewportService = ref.watch(viewportServiceProvider);
final activeProxyPromptKeys = useRef(<String>{});
useOnStreamChange(
eventService.proxyLoadErrorEvents,
onData: (event) async {
final selectedTabId = ref.read(selectedTabProvider);
final tabId = event.tabId ?? selectedTabId;
if (tabId == null || tabId != selectedTabId) 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 (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);
}
},
);
final tabInFullScreen = ref.watch(
selectedTabStateProvider.select((value) => value?.isFullScreen ?? false),
@@ -23,6 +23,7 @@ import 'dart:convert';
import 'dart:ui' as ui;
import 'package:fading_scroll/fading_scroll.dart';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -35,6 +36,7 @@ import 'package:nullability/nullability.dart';
import 'package:share_plus/share_plus.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:weblibre/core/design/app_colors.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/core/providers/persisted_bool.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
@@ -67,10 +69,14 @@ 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/geckoview/features/tabs/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/top_sites/domain/repositories/top_site_repository.dart';
import 'package:weblibre/features/proxy/data/models/singbox_proxy_profile.dart';
import 'package:weblibre/features/proxy/domain/providers/assigned_proxy_profiles.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart';
import 'package:weblibre/features/small_web/presentation/controllers/small_web_mode_controller.dart';
import 'package:weblibre/features/sync/domain/entities/sync_repository_state.dart';
import 'package:weblibre/features/sync/domain/repositories/sync.dart';
import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
import 'package:weblibre/features/tor/presentation/controllers/start_tor_proxy.dart';
import 'package:weblibre/features/user/domain/presentation/dialogs/quit_browser_dialog.dart';
import 'package:weblibre/features/user/domain/providers.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
@@ -1930,102 +1936,162 @@ class _QuickLinksGrid extends ConsumerWidget {
final isTorActive = ref.watch(
torProxyServiceProvider.select((value) => value.value?.isRunning == true),
);
final isTorBusy = ref.watch(startProxyControllerProvider);
return Column(
children: [
Row(
children: [
Expanded(
child: _buildGridItem(
context,
Icons.history,
'History',
() async {
Navigator.pop(context);
await const HistoryRoute().push(context);
},
),
),
const SizedBox(width: 8),
Expanded(
child: _buildGridItem(
context,
MdiIcons.bookmarkMultiple,
'Bookmarks',
() async {
Navigator.pop(context);
await BookmarkListRoute(
entryGuid: BookmarkRoot.root.id,
).push(context);
},
),
),
const SizedBox(width: 8),
Expanded(
child: _buildGridItem(
context,
MdiIcons.fileDownload,
'Downloads',
() async {
Navigator.pop(context);
await const HistoryDownloadsRoute().push(context);
},
),
),
const SizedBox(width: 8),
Expanded(
child: _buildGridItem(
context,
MdiIcons.exclamationThick,
'Bangs',
() async {
Navigator.pop(context);
await const BangMenuRoute().push(context);
},
),
),
],
final assignedProfiles = ref.watch(assignedSingboxProxyProfilesProvider);
final runtimeEndpointIds = ref
.watch(
singboxProxyRuntimeRepositoryProvider.select((value) {
final endpoints = value.value?.endpoints;
if (endpoints == null) {
return EquatableValue(const <String>{});
}
return EquatableValue({
for (final endpoint in endpoints) endpoint.profileId,
});
}),
)
.value;
final torActiveColor = AppColors.of(context).torActiveGreen;
final items = <_QuickLinkItem>[
_QuickLinkItem(
icon: Icons.history,
label: 'History',
onTap: () async {
Navigator.pop(context);
await const HistoryRoute().push(context);
},
),
_QuickLinkItem(
icon: MdiIcons.bookmarkMultiple,
label: 'Bookmarks',
onTap: () async {
Navigator.pop(context);
await BookmarkListRoute(
entryGuid: BookmarkRoot.root.id,
).push(context);
},
),
_QuickLinkItem(
icon: MdiIcons.fileDownload,
label: 'Downloads',
onTap: () async {
Navigator.pop(context);
await const HistoryDownloadsRoute().push(context);
},
),
_QuickLinkItem(
icon: MdiIcons.exclamationThick,
label: 'Bangs',
onTap: () async {
Navigator.pop(context);
await const BangMenuRoute().push(context);
},
),
_QuickLinkItem(
icon: Icons.rss_feed,
label: 'Feeds',
onTap: () async {
Navigator.pop(context);
await context.push(FeedListRoute().location);
},
),
_QuickLinkItem(
icon: Icons.explore,
label: 'Small Web',
onTap: () async {
Navigator.pop(context);
await ref.read(smallWebModeControllerProvider.notifier).enter();
},
),
_QuickLinkItem(
icon: TorIcons.onionAlt,
label: 'Tor\u2122 Proxy',
badge: isTorActive,
badgeColor: torActiveColor,
onTap: () async {
if (isTorBusy) return;
if (isTorActive) {
await ref.read(torProxyServiceProvider.notifier).disconnect();
} else {
await ref.read(startProxyControllerProvider.notifier).startProxy();
}
},
onLongPress: () async {
Navigator.pop(context);
await const TorProxyRoute().push(context);
},
),
for (final profile in assignedProfiles)
_QuickLinkItem(
icon: MdiIcons.lanConnect,
label: profile.name,
badge: runtimeEndpointIds.contains(profile.proxyConnectionId),
badgeColor: torActiveColor,
onTap: () async {
final runtime = ref.read(
singboxProxyRuntimeRepositoryProvider.notifier,
);
final isRunning = runtimeEndpointIds.contains(
profile.proxyConnectionId,
);
try {
if (isRunning) {
await runtime.stopProfiles([profile.id]);
} else {
await runtime.startProfile(profile.id);
}
} catch (error, stackTrace) {
logger.e(
'Failed to toggle proxy profile ${profile.id} from menu',
error: error,
stackTrace: stackTrace,
);
if (context.mounted) {
ui_helper.showErrorMessage(context, 'Proxy error: $error');
}
}
},
onLongPress: () async {
Navigator.pop(context);
await SingboxProxyProfileEditorRoute(
profileId: profile.id,
).push(context);
},
),
const SizedBox(height: 8),
Row(
];
const spacing = 8.0;
const itemsPerRow = 4;
return LayoutBuilder(
builder: (context, constraints) {
final width =
(constraints.maxWidth - spacing * (itemsPerRow - 1)) / itemsPerRow;
return Wrap(
spacing: spacing,
runSpacing: spacing,
children: [
Expanded(
child: _buildGridItem(
context,
TorIcons.onionAlt,
'Tor\u2122 Proxy',
() async {
Navigator.pop(context);
await const TorProxyRoute().push(context);
},
badge: isTorActive,
badgeColor: AppColors.of(context).torActiveGreen,
for (final item in items)
SizedBox(
width: width,
child: _buildGridItem(
context,
item.icon,
item.label,
item.onTap,
badge: item.badge,
badgeColor: item.badgeColor,
onLongPress: item.onLongPress,
),
),
),
const SizedBox(width: 8),
Expanded(
child: _buildGridItem(context, Icons.rss_feed, 'Feeds', () async {
Navigator.pop(context);
await context.push(FeedListRoute().location);
}),
),
const SizedBox(width: 8),
Expanded(
child: _buildGridItem(
context,
Icons.explore,
'Small Web',
() async {
Navigator.pop(context);
await ref
.read(smallWebModeControllerProvider.notifier)
.enter();
},
),
),
],
),
],
);
},
);
}
@@ -2036,6 +2102,7 @@ class _QuickLinksGrid extends ConsumerWidget {
VoidCallback onTap, {
bool badge = false,
Color? badgeColor,
VoidCallback? onLongPress,
}) {
final iconWidget = Icon(
icon,
@@ -2048,6 +2115,7 @@ class _QuickLinksGrid extends ConsumerWidget {
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
onLongPress: onLongPress,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
@@ -2075,6 +2143,24 @@ class _QuickLinksGrid extends ConsumerWidget {
}
}
class _QuickLinkItem {
final IconData icon;
final String label;
final VoidCallback onTap;
final VoidCallback? onLongPress;
final bool badge;
final Color? badgeColor;
const _QuickLinkItem({
required this.icon,
required this.label,
required this.onTap,
this.onLongPress,
this.badge = false,
this.badgeColor,
});
}
// ─── Profile Card ───
class _ProfileCard extends HookConsumerWidget {
@@ -50,10 +50,10 @@ import 'package:weblibre/features/geckoview/features/history/domain/repositories
import 'package:weblibre/features/geckoview/features/preferences/data/repositories/preference_observer.dart';
import 'package:weblibre/features/geckoview/features/pwa/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/services/local_index_pruner.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.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/geckoview/features/tabs/domain/services/local_index_pruner.dart';
import 'package:weblibre/features/intent_gatekeeper/domain/entities/intent_source_policy.dart';
import 'package:weblibre/features/intent_gatekeeper/domain/entities/pending_intent_decision.dart';
import 'package:weblibre/features/intent_gatekeeper/domain/services/intent_gatekeeper.dart';
@@ -636,15 +636,15 @@ class _BrowserViewState extends ConsumerState<BrowserView>
},
);
//Ensure tor events don't get dropped
// Keep-alive subscription for Tor status. The body is intentionally empty:
// the listener exists so the provider stays active while the browser view
// is mounted and Tor state events are not dropped. singboxProxyLogs is
// kept alive from main.dart so startup logs are captured even before the
// browser view mounts.
ref.listenManual(
fireImmediately: true,
torProxyServiceProvider,
(previous, next) {
if (next.hasValue) {
debugPrint(next.requireValue.toString());
}
},
(previous, next) {},
onError: (error, stackTrace) {
logger.e(
'Error listening to torProxyServiceProvider',
@@ -73,6 +73,9 @@ class ViewTabSheetWidget extends HookConsumerWidget {
useEffect(
() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted) return;
if (!draggableScrollableController.isAttached) return;
final diff =
((MediaQuery.of(context).viewInsets.bottom / 2) /
MediaQuery.of(context).size.height) -
@@ -51,9 +51,8 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selec
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab_search.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_chips.dart';
import 'package:weblibre/features/proxy/presentation/controllers/ensure_proxy_started.dart';
import 'package:weblibre/features/sync/domain/repositories/sync.dart';
import 'package:weblibre/features/tor/presentation/controllers/start_tor_proxy.dart';
import 'package:weblibre/features/tor/presentation/widgets/tor_dialog.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/presentation/hooks/menu_controller.dart';
import 'package:weblibre/presentation/widgets/speech_to_text_button.dart';
@@ -111,26 +110,8 @@ class _TabFilters extends ConsumerWidget {
.read(selectedContainerProvider.notifier)
.setContainerId(container.id);
if (context.mounted &&
result == SetContainerResult.successHasProxy) {
final shouldStartProxy = await ref
.read(startProxyControllerProvider.notifier)
.shouldPromptProxyStart();
if (!context.mounted || !shouldStartProxy) return;
final dialogResult = await showDialog<bool>(
context: context,
builder: (context) {
return const TorDialog();
},
);
if (dialogResult == true) {
await ref
.read(startProxyControllerProvider.notifier)
.startProxy();
}
if (context.mounted && result == SetContainerResult.success) {
await ensureProxyStartedForContainer(context, ref, container);
}
} else {
ref.read(selectedContainerProvider.notifier).clearContainer();
@@ -73,9 +73,9 @@ List<CombinedHistoryItem> combinedHistorySuggestions(Ref ref) {
final engineAsync = ref.watch(engineSuggestionsProvider);
final localAsync = ref.watch(historySearchRepositoryProvider);
final engineSuggestions =
engineAsync.value ?? const <GeckoSuggestion>[];
final localResults = localAsync.value?.results ?? const <HistoryQueryResult>[];
final engineSuggestions = engineAsync.value ?? const <GeckoSuggestion>[];
final localResults =
localAsync.value?.results ?? const <HistoryQueryResult>[];
// Index the local rows by canonical URL so engine items can pick up
// snippet/title-highlight without an N×M scan.
@@ -53,8 +53,8 @@ import 'package:weblibre/features/geckoview/features/search/presentation/widgets
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_field.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_module_reorder_view.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/bookmark_search.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/feed_search.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/combined_history_suggestions.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/feed_search.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/local_history_suggestions.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_providers_section.dart';
@@ -64,8 +64,7 @@ import 'package:weblibre/features/geckoview/features/tabs/data/entities/isolatio
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/compact_container_selector.dart';
import 'package:weblibre/features/tor/presentation/controllers/start_tor_proxy.dart';
import 'package:weblibre/features/tor/presentation/widgets/tor_dialog.dart';
import 'package:weblibre/features/proxy/presentation/controllers/ensure_proxy_started.dart';
import 'package:weblibre/features/search_credits/domain/repositories/web_search_settings.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/features/web_search/domain/controllers/search_controller.dart';
@@ -496,26 +495,11 @@ class SearchScreen extends HookConsumerWidget {
if (!context.mounted) return;
if (result == SetContainerResult.successHasProxy) {
final shouldStartProxy = await ref
.read(startProxyControllerProvider.notifier)
.shouldPromptProxyStart();
if (context.mounted && shouldStartProxy) {
final dialogResult = await showDialog<bool>(
context: context,
builder: (_) => const TorDialog(),
);
if (dialogResult == true) {
await ref
.read(startProxyControllerProvider.notifier)
.startProxy();
}
}
if (result == SetContainerResult.success) {
await ensureProxyStartedForContainer(context, ref, container);
}
if (context.mounted && result != SetContainerResult.failed) {
if (context.mounted && result == SetContainerResult.success) {
const TabViewRoute().go(context);
}
},
@@ -75,9 +75,7 @@ class CombinedHistorySuggestions extends HookConsumerWidget {
// which is the same constant the row builder scans for below.
// Don't override the defaults here unless those scan constants
// are kept in sync.
await ref
.read(historySearchRepositoryProvider.notifier)
.addQuery(text);
await ref.read(historySearchRepositoryProvider.notifier).addQuery(text);
},
);
@@ -97,8 +97,10 @@ class HistorySuggestions extends HookConsumerWidget {
),
subtitle:
uri.mapNotNull(
(uri) =>
UriBreadcrumb(uri: uri, showHttpScheme: false),
(uri) => UriBreadcrumb(
uri: uri,
showHttpScheme: false,
),
) ??
suggestion.description.mapNotNull(
(description) => Text(
@@ -83,12 +83,15 @@ class LocalHistorySuggestions extends HookConsumerWidget {
final uri = Uri.tryParse(result.urlCanonical);
final content =
(result.extractedContent?.contains(historyHighlightPrefix) ==
true)
? result.extractedContent
: result.fullContent;
(result.extractedContent?.contains(
historyHighlightPrefix,
) ==
true)
? result.extractedContent
: result.fullContent;
final titleHasMatch =
result.title?.contains(historyHighlightPrefix) ?? false;
result.title?.contains(historyHighlightPrefix) ??
false;
final bodyHasMatch =
content?.contains(historyHighlightPrefix) ?? false;
@@ -23,6 +23,7 @@ import 'package:flutter/widgets.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:weblibre/data/database/converters/color.dart';
import 'package:weblibre/data/database/converters/icon_data.dart';
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
part 'container_data.g.dart';
@@ -31,10 +32,14 @@ part 'container_data.g.dart';
class ContainerMetadata with FastEquatable {
@IconDataJsonConverter()
final IconData? iconData;
final String? contextualIdentity;
@JsonKey(defaultValue: false)
final bool useProxy;
@JsonKey(
fromJson: _proxyConnectionIdFromJson,
toJson: _proxyConnectionIdToJson,
)
final ProxyConnectionId? proxyConnectionId;
@JsonKey(defaultValue: false)
final bool clearDataOnExit;
@@ -44,7 +49,7 @@ class ContainerMetadata with FastEquatable {
ContainerMetadata({
required this.iconData,
required this.contextualIdentity,
required this.useProxy,
required this.proxyConnectionId,
required this.clearDataOnExit,
required this.assignedSites,
});
@@ -52,17 +57,19 @@ class ContainerMetadata with FastEquatable {
ContainerMetadata.withDefaults({
IconData? iconData,
String? contextualIdentity,
bool? useProxy,
ProxyConnectionId? proxyConnectionId,
bool? clearDataOnExit,
List<Uri>? assignedSites,
}) : this(
iconData: iconData,
contextualIdentity: contextualIdentity,
useProxy: useProxy ?? false,
proxyConnectionId: proxyConnectionId,
clearDataOnExit: clearDataOnExit ?? false,
assignedSites: assignedSites,
);
bool get usesTorProxy => proxyConnectionId is TorProxyConnectionId;
factory ContainerMetadata.fromJson(Map<String, dynamic> json) =>
_$ContainerMetadataFromJson(json);
@@ -72,7 +79,7 @@ class ContainerMetadata with FastEquatable {
List<Object?> get hashParameters => [
iconData,
contextualIdentity,
useProxy,
proxyConnectionId,
clearDataOnExit,
assignedSites,
];
@@ -141,3 +148,8 @@ class ContainerDataWithCount extends ContainerData {
@override
List<Object?> get hashParameters => [...super.hashParameters, tabCount];
}
ProxyConnectionId? _proxyConnectionIdFromJson(String? json) =>
ProxyConnectionId.decode(json);
String? _proxyConnectionIdToJson(ProxyConnectionId? object) => object?.encode();
@@ -11,7 +11,7 @@ abstract class _$ContainerMetadataCWProxy {
ContainerMetadata contextualIdentity(String? contextualIdentity);
ContainerMetadata useProxy(bool useProxy);
ContainerMetadata proxyConnectionId(ProxyConnectionId? proxyConnectionId);
ContainerMetadata clearDataOnExit(bool clearDataOnExit);
@@ -27,7 +27,7 @@ abstract class _$ContainerMetadataCWProxy {
ContainerMetadata call({
IconData? iconData,
String? contextualIdentity,
bool useProxy,
ProxyConnectionId? proxyConnectionId,
bool clearDataOnExit,
List<Uri>? assignedSites,
});
@@ -48,7 +48,8 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy {
call(contextualIdentity: contextualIdentity);
@override
ContainerMetadata useProxy(bool useProxy) => call(useProxy: useProxy);
ContainerMetadata proxyConnectionId(ProxyConnectionId? proxyConnectionId) =>
call(proxyConnectionId: proxyConnectionId);
@override
ContainerMetadata clearDataOnExit(bool clearDataOnExit) =>
@@ -69,7 +70,7 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy {
ContainerMetadata call({
Object? iconData = const $CopyWithPlaceholder(),
Object? contextualIdentity = const $CopyWithPlaceholder(),
Object? useProxy = const $CopyWithPlaceholder(),
Object? proxyConnectionId = const $CopyWithPlaceholder(),
Object? clearDataOnExit = const $CopyWithPlaceholder(),
Object? assignedSites = const $CopyWithPlaceholder(),
}) {
@@ -82,10 +83,10 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy {
? _value.contextualIdentity
// ignore: cast_nullable_to_non_nullable
: contextualIdentity as String?,
useProxy: useProxy == const $CopyWithPlaceholder() || useProxy == null
? _value.useProxy
proxyConnectionId: proxyConnectionId == const $CopyWithPlaceholder()
? _value.proxyConnectionId
// ignore: cast_nullable_to_non_nullable
: useProxy as bool,
: proxyConnectionId as ProxyConnectionId?,
clearDataOnExit:
clearDataOnExit == const $CopyWithPlaceholder() ||
clearDataOnExit == null
@@ -227,7 +228,9 @@ ContainerMetadata _$ContainerMetadataFromJson(Map<String, dynamic> json) =>
const IconDataJsonConverter().fromJson,
),
contextualIdentity: json['contextualIdentity'] as String?,
useProxy: json['useProxy'] as bool? ?? false,
proxyConnectionId: _proxyConnectionIdFromJson(
json['proxyConnectionId'] as String?,
),
clearDataOnExit: json['clearDataOnExit'] as bool? ?? false,
assignedSites: (json['assignedSites'] as List<dynamic>?)
?.map((e) => Uri.parse(e as String))
@@ -242,7 +245,7 @@ Map<String, dynamic> _$ContainerMetadataToJson(
const IconDataJsonConverter().toJson,
),
'contextualIdentity': instance.contextualIdentity,
'useProxy': instance.useProxy,
'proxyConnectionId': _proxyConnectionIdToJson(instance.proxyConnectionId),
'clearDataOnExit': instance.clearDataOnExit,
'assignedSites': instance.assignedSites?.map((e) => e.toString()).toList(),
};
@@ -190,7 +190,7 @@ Stream<List<SiteAssignment>> watchAllAssignedSites(Ref ref) {
///
/// 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 useProxy enabled.
/// associated containers has a proxy connection assigned.
@Riverpod(keepAlive: true)
Stream<Map<String, Set<String>>> watchIsolatedContextContainerMap(Ref ref) {
final db = ref.watch(tabDatabaseProvider);
@@ -1082,7 +1082,7 @@ String _$watchAllAssignedSitesHash() =>
///
/// 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 useProxy enabled.
/// associated containers has a proxy connection assigned.
@ProviderFor(watchIsolatedContextContainerMap)
final watchIsolatedContextContainerMapProvider =
@@ -1094,7 +1094,7 @@ final watchIsolatedContextContainerMapProvider =
///
/// 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 useProxy enabled.
/// associated containers has a proxy connection assigned.
final class WatchIsolatedContextContainerMapProvider
extends
@@ -1112,7 +1112,7 @@ final class WatchIsolatedContextContainerMapProvider
///
/// 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 useProxy enabled.
/// associated containers has a proxy connection assigned.
WatchIsolatedContextContainerMapProvider._()
: super(
from: null,
@@ -37,7 +37,7 @@ import 'package:weblibre/features/user/data/providers.dart';
part 'selected_container.g.dart';
enum SetContainerResult { failed, success, successHasProxy }
enum SetContainerResult { failed, success }
@Riverpod(keepAlive: true)
class SelectedContainer extends _$SelectedContainer {
@@ -62,13 +62,13 @@ class SelectedContainer extends _$SelectedContainer {
bool canApply() => shouldApply?.call() ?? true;
if (ref.mounted && container != null && canApply()) {
if (container.metadata.useProxy) {
if (container.metadata.proxyConnectionId != null) {
final proxyPluginHealthy = await GeckoContainerProxyService()
.healthcheck();
if (ref.mounted && proxyPluginHealthy && canApply()) {
state = id;
return SetContainerResult.successHasProxy;
return SetContainerResult.success;
}
} else if (canApply()) {
state = id;
@@ -41,7 +41,7 @@ final class SelectedContainerProvider
}
}
String _$selectedContainerHash() => r'1f0828ce1d3f8b88fd2731a7aae2602a7323092c';
String _$selectedContainerHash() => r'3c9ab093bce7cd3b84e0318095b1ada25bdf8a65';
abstract class _$SelectedContainer extends $Notifier<String?> {
String? build();
@@ -28,6 +28,7 @@ import 'package:weblibre/features/geckoview/features/tabs/data/models/site_assig
import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/tabs/utils/color_palette.dart';
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
part 'container.g.dart';
@@ -78,6 +79,28 @@ class ContainerRepository extends _$ContainerRepository {
.replaceContainer(container);
}
Future<void> clearProxyConnectionAssignments(
ProxyConnectionId proxyConnectionId,
) async {
final containers = await getAllContainersWithCount();
final affectedContainers = containers.where(
(container) => container.metadata.proxyConnectionId == proxyConnectionId,
);
for (final container in affectedContainers) {
await replaceContainer(
ContainerData(
id: container.id,
name: container.name,
color: container.color,
orderKey: container.orderKey,
isPinned: container.isPinned,
metadata: container.metadata.copyWith.proxyConnectionId(null),
),
);
}
}
Future<void> assignContainerOrderKey(String id, {required String orderKey}) {
return ref
.read(tabDatabaseProvider)
@@ -214,17 +237,25 @@ class ContainerRepository extends _$ContainerRepository {
.take(targetIndex)
.where((container) => container.isPinned == movingContainer.isPinned)
.length
.clamp(0, scopedContainers.length - 1)
.toInt();
.clamp(0, scopedContainers.length - 1);
if (scopedTargetIndex == scopedOldIndex) return;
final orderKey = await _orderKeyForReorder(
scopedContainers,
scopedOldIndex,
scopedTargetIndex,
movingContainer.isPinned,
);
await assignContainerOrderKey(movingContainer.id, orderKey: orderKey);
// Generate a new order key and assign it in the same transaction so a
// crash between the two cannot leave the moving container with a stale
// key that no longer matches the surrounding rows.
final db = ref.read(tabDatabaseProvider);
await db.transaction(() async {
final orderKey = await _orderKeyForReorder(
scopedContainers,
scopedOldIndex,
scopedTargetIndex,
movingContainer.isPinned,
);
await db.containerDao.assignOrderKey(
movingContainer.id,
orderKey: orderKey,
);
});
}
Future<String> _orderKeyForReorder(
@@ -42,7 +42,7 @@ final class ContainerRepositoryProvider
}
String _$containerRepositoryHash() =>
r'a708f298d7bd50f1823029e00f8ed295b9f81f4e';
r'8db0f2a08ee5a67123f9c110ef7edf4c574992c4';
abstract class _$ContainerRepository extends $Notifier<void> {
void build();
@@ -200,9 +200,7 @@ class HistorySearchRepository extends _$HistorySearchRepository {
// last time the engine observed *anything* about this URL.
final updatedAt = DateTime.fromMillisecondsSinceEpoch(metadata.updatedAt);
final ageDays = now.difference(updatedAt).inHours / 24.0;
final decay = math.exp(
-math.max(0.0, ageDays) / _recencyHalfLifeDays,
);
final decay = math.exp(-math.max(0.0, ageDays) / _recencyHalfLifeDays);
score -= _recencyRankWeight * decay;
}
@@ -55,14 +55,12 @@ class LocalIndexSettingsSync extends _$LocalIndexSettingsSync {
/// last click. The lock is fine-grained per provider instance.
final _writeLock = Lock();
Future<void> _push({
required bool enabled,
required bool indexPrivate,
}) => _writeLock.synchronized(() async {
final dao = ref.read(tabDatabaseProvider).historyDao;
await dao.upsertSetting(_kEnabledKey, enabled);
await dao.upsertSetting(_kIndexPrivateKey, indexPrivate);
});
Future<void> _push({required bool enabled, required bool indexPrivate}) =>
_writeLock.synchronized(() async {
final dao = ref.read(tabDatabaseProvider).historyDao;
await dao.upsertSetting(_kEnabledKey, enabled);
await dao.upsertSetting(_kIndexPrivateKey, indexPrivate);
});
@override
void build() {
@@ -77,7 +75,9 @@ class LocalIndexSettingsSync extends _$LocalIndexSettingsSync {
if (previous == next) return;
// Fire-and-forget: `_writeLock` ensures the second flip queues
// behind the first instead of racing it.
unawaited(_push(enabled: next.enabled, indexPrivate: next.indexPrivate));
unawaited(
_push(enabled: next.enabled, indexPrivate: next.indexPrivate),
);
},
fireImmediately: true,
);
@@ -23,7 +23,6 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.dart';
import 'package:weblibre/core/uuid.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
@@ -31,11 +30,12 @@ import 'package:weblibre/features/geckoview/features/tabs/presentation/controlle
import 'package:weblibre/features/geckoview/features/tabs/presentation/dialogs/delete_container_dialog.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/dialogs/discard_changes_dialog.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_sites.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_icon_picker_sheet.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/color_picker_dialog.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_icon_picker_sheet.dart';
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/presentation/icons/tor_icons.dart';
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
import 'package:weblibre/features/proxy/domain/providers/proxy_connection_options.dart';
enum _DialogMode { create, edit }
@@ -77,12 +77,16 @@ class ContainerEditScreen extends HookConsumerWidget {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final proxyOptions = ref.watch(proxyConnectionOptionsProvider);
final selectedColor = useState(initialContainer.color);
final selectedIcon = useState(initialContainer.metadata.iconData);
final contextualIdentity = useState(
initialContainer.metadata.contextualIdentity,
);
final useProxy = useState(initialContainer.metadata.useProxy);
final proxyConnectionId = useState<ProxyConnectionId?>(
initialContainer.metadata.proxyConnectionId,
);
final clearDataOnExit = useState(initialContainer.metadata.clearDataOnExit);
final assignedSites = useState(initialContainer.metadata.assignedSites);
@@ -99,7 +103,9 @@ class ContainerEditScreen extends HookConsumerWidget {
metadata: initialContainer.metadata.copyWith(
contextualIdentity: contextualIdentity.value,
iconData: selectedIcon.value,
useProxy: useProxy.value && contextualIdentity.value != null,
proxyConnectionId: contextualIdentity.value != null
? proxyConnectionId.value
: null,
clearDataOnExit:
clearDataOnExit.value && contextualIdentity.value != null,
assignedSites: assignedSites.value,
@@ -217,6 +223,8 @@ class ContainerEditScreen extends HookConsumerWidget {
selectedColor.value,
);
final assignedSiteCount = assignedSites.value?.length ?? 0;
final canPickProxy =
_mode == _DialogMode.create || contextualIdentity.value != null;
return PopScope(
canPop: container == comparison,
@@ -358,8 +366,8 @@ class ContainerEditScreen extends HookConsumerWidget {
uuid.v4()
: null;
if (!value && useProxy.value) {
useProxy.value = false;
if (!value) {
proxyConnectionId.value = null;
}
if (!value && clearDataOnExit.value) {
@@ -369,29 +377,61 @@ class ContainerEditScreen extends HookConsumerWidget {
: null,
),
const Divider(height: 1, indent: 56),
SwitchListTile.adaptive(
value: useProxy.value,
title: const Text('Use Tor™ Proxy'),
secondary: const Icon(TorIcons.onionAlt),
onChanged: switch (_mode) {
_DialogMode.create => (value) {
if (value && contextualIdentity.value == null) {
contextualIdentity.value =
initialContainer
.metadata
.contextualIdentity ??
uuid.v4();
}
ListTile(
leading: const Icon(Icons.route_outlined),
title: const Text('Proxy Connection'),
subtitle: Text(switch (proxyConnectionId.value) {
final id? => proxyConnectionTitle(proxyOptions, id),
null => 'None',
}),
trailing: const Icon(Icons.chevron_right),
enabled: canPickProxy,
onTap: canPickProxy
? () async {
final createdTemporaryIdentity =
contextualIdentity.value == null;
useProxy.value = value;
},
_DialogMode.edit =>
(contextualIdentity.value != null)
? (value) {
useProxy.value = value;
}
: null,
},
if (createdTemporaryIdentity) {
contextualIdentity.value =
initialContainer
.metadata
.contextualIdentity ??
uuid.v4();
}
final outcome =
await showModalBottomSheet<
_ProxyPickerOutcome
>(
context: context,
showDragHandle: true,
builder: (context) {
return _ProxyConnectionPickerSheet(
options: proxyOptions,
selectedProxyConnectionId:
proxyConnectionId.value,
);
},
);
switch (outcome) {
case null:
// Dismissed without selecting — leave
// existing value untouched, but undo any
// temporary identity we created.
if (createdTemporaryIdentity) {
contextualIdentity.value = null;
}
case _ProxyPickerCleared():
proxyConnectionId.value = null;
if (createdTemporaryIdentity) {
contextualIdentity.value = null;
}
case _ProxyPickerSelected(:final id):
proxyConnectionId.value = id;
}
}
: null,
),
const Divider(height: 1, indent: 56),
SwitchListTile.adaptive(
@@ -524,3 +564,90 @@ class ContainerEditScreen extends HookConsumerWidget {
);
}
}
/// Result of the proxy picker sheet. `null` (sheet dismissed) is distinct
/// from `_ProxyPickerCleared` (user explicitly picked None) so the caller can
/// avoid clobbering the previously-selected proxy on a stray swipe-down.
sealed class _ProxyPickerOutcome {
const _ProxyPickerOutcome();
}
class _ProxyPickerCleared extends _ProxyPickerOutcome {
const _ProxyPickerCleared();
}
class _ProxyPickerSelected extends _ProxyPickerOutcome {
final ProxyConnectionId id;
const _ProxyPickerSelected(this.id);
}
class _ProxyConnectionPickerSheet extends StatelessWidget {
final List<ProxyConnectionOption> options;
final ProxyConnectionId? selectedProxyConnectionId;
const _ProxyConnectionPickerSheet({
required this.options,
required this.selectedProxyConnectionId,
});
@override
Widget build(BuildContext context) {
final hasUnknownSelectedProxy =
selectedProxyConnectionId != null &&
!options.any((option) => option.id == selectedProxyConnectionId);
return SafeArea(
child: RadioGroup<ProxyConnectionId?>(
onChanged: (value) {
Navigator.pop(
context,
value == null
? const _ProxyPickerCleared()
: _ProxyPickerSelected(value),
);
},
child: ListView(
shrinkWrap: true,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 4, 24, 12),
child: Text(
'Proxy Connection',
style: Theme.of(context).textTheme.titleLarge,
),
),
const RadioListTile<ProxyConnectionId?>(
value: null,
title: Text('None'),
subtitle: Text('Use the normal browser connection'),
secondary: Icon(Icons.public),
),
if (hasUnknownSelectedProxy)
ListTile(
leading: Icon(
Icons.warning_amber_outlined,
color: Theme.of(context).colorScheme.error,
),
title: const Text('Unknown proxy'),
subtitle: const Text('This proxy profile no longer exists'),
trailing: TextButton(
onPressed: () =>
Navigator.pop(context, const _ProxyPickerCleared()),
child: const Text('Clear'),
),
),
for (final option in options)
RadioListTile<ProxyConnectionId?>(
value: option.id,
title: Text(option.title),
subtitle: Text(option.subtitle),
secondary: const Icon(Icons.route_outlined),
),
const SizedBox(height: 12),
],
),
),
);
}
}
@@ -31,8 +31,8 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/co
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_title.dart';
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/tor/presentation/controllers/start_tor_proxy.dart';
import 'package:weblibre/features/tor/presentation/widgets/tor_dialog.dart';
import 'package:weblibre/features/proxy/domain/providers/proxy_connection_options.dart';
import 'package:weblibre/features/proxy/presentation/controllers/ensure_proxy_started.dart';
import 'package:weblibre/presentation/widgets/failure_widget.dart';
class ContainerListScreen extends HookConsumerWidget {
@@ -49,25 +49,8 @@ class ContainerListScreen extends HookConsumerWidget {
.read(selectedContainerProvider.notifier)
.setContainerId(container.id);
if (context.mounted && result == SetContainerResult.successHasProxy) {
final shouldStartProxy = await ref
.read(startProxyControllerProvider.notifier)
.shouldPromptProxyStart();
if (!context.mounted || !shouldStartProxy) {
return;
}
final dialogResult = await showDialog<bool>(
context: context,
builder: (context) {
return const TorDialog();
},
);
if (dialogResult == true) {
await ref.read(startProxyControllerProvider.notifier).startProxy();
}
if (context.mounted && result == SetContainerResult.success) {
await ensureProxyStartedForContainer(context, ref, container);
}
}
@@ -202,6 +185,7 @@ class _ContainerCard extends HookConsumerWidget {
final containerColor = container.color;
final tabCount = container.tabCount ?? 0;
final palette = ContainerColors.palette(context, containerColor);
final proxyOptions = ref.watch(proxyConnectionOptionsProvider);
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
@@ -266,10 +250,13 @@ class _ContainerCard extends HookConsumerWidget {
icon: Icons.cookie_outlined,
label: 'Isolated',
),
if (container.metadata.useProxy)
const _ContainerInfoChip(
if (container.metadata.proxyConnectionId != null)
_ContainerInfoChip(
icon: Icons.route_outlined,
label: 'Proxy',
label: proxyConnectionTitle(
proxyOptions,
container.metadata.proxyConnectionId!,
),
),
if (container.metadata.clearDataOnExit)
const _ContainerInfoChip(
@@ -34,6 +34,7 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/co
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_title.dart';
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/presentation/widgets/failure_widget.dart';
class ContainerSelectionScreen extends HookConsumerWidget {
@@ -221,7 +222,7 @@ class _UnassignedSelectionCard extends StatelessWidget {
}
}
class _SelectionContainerCard extends StatelessWidget {
class _SelectionContainerCard extends ConsumerWidget {
const _SelectionContainerCard({
required this.container,
required this.isSelected,
@@ -233,13 +234,15 @@ class _SelectionContainerCard extends StatelessWidget {
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final containerColor = container.color;
final tabCount = container.tabCount ?? 0;
final palette = ContainerColors.palette(context, containerColor);
final proxyOptions = ref.watch(proxyConnectionOptionsProvider);
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
@@ -306,10 +309,13 @@ class _SelectionContainerCard extends StatelessWidget {
icon: Icons.cookie_outlined,
label: 'Isolated',
),
if (container.metadata.useProxy)
const _SelectionInfoChip(
if (container.metadata.proxyConnectionId != null)
_SelectionInfoChip(
icon: Icons.route_outlined,
label: 'Proxy',
label: proxyConnectionTitle(
proxyOptions,
container.metadata.proxyConnectionId!,
),
),
if (container.metadata.clearDataOnExit)
const _SelectionInfoChip(