add isolated tab feature

This commit is contained in:
Fabian Freund
2026-02-27 08:29:50 +01:00
parent 3ef567cc19
commit d24675094d
84 changed files with 3783 additions and 513 deletions
@@ -27,6 +27,7 @@ import 'package:weblibre/features/geckoview/domain/entities/states/find_result.d
import 'package:weblibre/features/geckoview/domain/entities/states/history.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/readerable.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/security.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
part 'tab.g.dart';
@@ -61,7 +62,9 @@ class TabState extends WebPageInfo {
final int progress;
final bool isPrivate;
final TabMode tabMode;
String? get isolationContextId => tabMode.isolationContextId;
final bool isFullScreen;
final bool isLoading;
final bool showToolbarAsExpanded;
@@ -82,7 +85,7 @@ class TabState extends WebPageInfo {
required this.icon,
required this.thumbnail,
required this.progress,
required this.isPrivate,
this.tabMode = TabMode.regular,
required this.isFullScreen,
required this.isLoading,
required this.showToolbarAsExpanded,
@@ -101,7 +104,6 @@ class TabState extends WebPageInfo {
icon: null,
thumbnail: null,
progress: 0,
isPrivate: false,
isFullScreen: false,
isLoading: false,
showToolbarAsExpanded: false,
@@ -120,7 +122,7 @@ class TabState extends WebPageInfo {
icon,
thumbnail,
progress,
isPrivate,
tabMode,
isFullScreen,
isLoading,
showToolbarAsExpanded,
@@ -21,7 +21,7 @@ abstract class _$TabStateCWProxy {
TabState progress(int progress);
TabState isPrivate(bool isPrivate);
TabState tabMode(TabMode tabMode);
TabState isFullScreen(bool isFullScreen);
@@ -52,7 +52,7 @@ abstract class _$TabStateCWProxy {
EquatableImage? icon,
EquatableImage? thumbnail,
int progress,
bool isPrivate,
TabMode tabMode,
bool isFullScreen,
bool isLoading,
bool showToolbarAsExpanded,
@@ -92,7 +92,7 @@ class _$TabStateCWProxyImpl implements _$TabStateCWProxy {
TabState progress(int progress) => call(progress: progress);
@override
TabState isPrivate(bool isPrivate) => call(isPrivate: isPrivate);
TabState tabMode(TabMode tabMode) => call(tabMode: tabMode);
@override
TabState isFullScreen(bool isFullScreen) => call(isFullScreen: isFullScreen);
@@ -136,7 +136,7 @@ class _$TabStateCWProxyImpl implements _$TabStateCWProxy {
Object? icon = const $CopyWithPlaceholder(),
Object? thumbnail = const $CopyWithPlaceholder(),
Object? progress = const $CopyWithPlaceholder(),
Object? isPrivate = const $CopyWithPlaceholder(),
Object? tabMode = const $CopyWithPlaceholder(),
Object? isFullScreen = const $CopyWithPlaceholder(),
Object? isLoading = const $CopyWithPlaceholder(),
Object? showToolbarAsExpanded = const $CopyWithPlaceholder(),
@@ -175,10 +175,10 @@ class _$TabStateCWProxyImpl implements _$TabStateCWProxy {
? _value.progress
// ignore: cast_nullable_to_non_nullable
: progress as int,
isPrivate: isPrivate == const $CopyWithPlaceholder() || isPrivate == null
? _value.isPrivate
tabMode: tabMode == const $CopyWithPlaceholder() || tabMode == null
? _value.tabMode
// ignore: cast_nullable_to_non_nullable
: isPrivate as bool,
: tabMode as TabMode,
isFullScreen:
isFullScreen == const $CopyWithPlaceholder() || isFullScreen == null
? _value.isFullScreen
@@ -20,7 +20,6 @@
import 'dart:async';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:nullability/nullability.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:share_plus/share_plus.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -32,6 +31,7 @@ import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/find_in_page/domain/repositories/find_in_page.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
part 'providers.g.dart';
@@ -51,13 +51,10 @@ GeckoSelectionActionService selectionActionService(Ref ref) {
tabStatesProvider,
)[ref.read(selectedTabProvider)];
final isPrivate = selectedTabState?.isPrivate.mapNotNull(
(isCurrentPrivate) =>
isCurrentPrivate ? TabType.private : TabType.regular,
);
final selectedTabType = selectedTabState?.tabMode.toTabType();
final route = SearchRoute(
tabType: isPrivate ?? settings.defaultCreateTabType,
tabType: selectedTabType ?? settings.defaultCreateTabType,
searchText: text,
);
@@ -76,19 +73,20 @@ GeckoSelectionActionService selectionActionService(Ref ref) {
tabStatesProvider,
)[ref.read(selectedTabProvider)];
final isPrivate =
currentTab?.isPrivate ??
ref
.read(generalSettingsWithDefaultsProvider)
.defaultCreateTabType ==
TabType.private;
final tabMode =
currentTab?.tabMode ??
TabMode.fromTabType(
ref
.read(generalSettingsWithDefaultsProvider)
.defaultCreateTabType,
);
await ref
.read(tabRepositoryProvider.notifier)
.addTab(
url: defaultSearchBang.getTemplateUrl(text),
parentId: currentTab?.id,
private: isPrivate,
tabMode: tabMode,
selectTab: true,
);
} else {
@@ -55,7 +55,7 @@ final class SelectionActionServiceProvider
}
String _$selectionActionServiceHash() =>
r'75fa0fa770e74e49554d4098102fde9f9e531c4f';
r'75701bb0be3302b461d296a7b0b28e3a30a9d346';
@ProviderFor(eventService)
final eventServiceProvider = EventServiceProvider._();
@@ -34,6 +34,8 @@ import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
import 'package:weblibre/features/geckoview/domain/providers.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
import 'package:weblibre/features/geckoview/features/find_in_page/domain/repositories/find_in_page.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/isolation_context.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/gecko_inference.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
@@ -78,13 +80,23 @@ class TabStates extends _$TabStates {
resolvedTitle = contentState.title;
}
// Infer tabMode from context ID if not already set and context is isolated
final inferredTabMode = switch (current.tabMode) {
IsolatedTabMode() => current.tabMode,
_ when isIsolatedContextId(contentState.contextId) => TabMode.isolated(
contentState.contextId!,
),
_ when contentState.isPrivate => TabMode.private,
_ => TabMode.regular,
};
final newState = current.copyWith(
parentId: contentState.parentId,
contextId: contentState.contextId,
url: url,
title: resolvedTitle,
progress: contentState.progress,
isPrivate: contentState.isPrivate,
tabMode: inferredTabMode,
isFullScreen: contentState.isFullScreen,
isLoading: contentState.isLoading,
showToolbarAsExpanded: contentState.showToolbarAsExpanded,
@@ -373,7 +385,9 @@ Future<bool> isTabTunneled(Ref ref, String? tabId) async {
final torSettings = ref.watch(torSettingsWithDefaultsProvider);
if (tabState != null) {
if (tabState.isPrivate) {
// Isolated tabs follow the same proxy rules as regular tabs
// (container-based routing via proxy aliasing)
if (tabState.tabMode is PrivateTabMode) {
return torSettings.proxyPrivateTabsTor;
} else {
switch (torSettings.proxyRegularTabsMode) {
@@ -402,13 +416,9 @@ TabState? selectedTabState(Ref ref) {
@Riverpod()
TabType? selectedTabType(Ref ref) {
final isPrivate = ref.watch(
selectedTabStateProvider.select((value) => value?.isPrivate),
);
final selectedState = ref.watch(selectedTabStateProvider);
return isPrivate.mapNotNull(
(isCurrentPrivate) => isCurrentPrivate ? TabType.private : TabType.regular,
);
return selectedState?.tabMode.toTabType();
}
@Riverpod()
@@ -41,7 +41,7 @@ final class TabStatesProvider
}
}
String _$tabStatesHash() => r'975de1242f42e2017754dc560125788b83ad8b72';
String _$tabStatesHash() => r'0fd97193ff963006e1943436b25b025dd3885c65';
abstract class _$TabStates extends $Notifier<Map<String, TabState>> {
Map<String, TabState> build();
@@ -258,7 +258,7 @@ final class IsTabTunneledProvider
}
}
String _$isTabTunneledHash() => r'55a42ccb73d1921eed27d32f6da3a54680d4ac8c';
String _$isTabTunneledHash() => r'7265f5e6f32056846d3df38f48476d10c3a70bac';
final class IsTabTunneledFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<bool>, String?> {
@@ -358,7 +358,7 @@ final class SelectedTabTypeProvider
}
}
String _$selectedTabTypeHash() => r'53093fc6db8becde7500163661e087fb0a2eb955';
String _$selectedTabTypeHash() => r'fe14b5c9f81d8f5859ae9d758e440c42908e8a54';
@ProviderFor(selectedTabContainerId)
final selectedTabContainerIdProvider = SelectedTabContainerIdProvider._();
@@ -24,6 +24,7 @@ import 'package:drift/drift.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:nullability/nullability.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:synchronized/synchronized.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart';
@@ -31,12 +32,16 @@ import 'package:weblibre/features/geckoview/domain/providers.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/isolation_context.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_source.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/providers.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/tor/domain/repositories/tor_proxy.dart';
import 'package:weblibre/utils/debouncer.dart';
part 'tab.g.dart';
@@ -46,6 +51,8 @@ class TabRepository extends _$TabRepository {
final _tabsService = GeckoTabService();
final _tabFromIntent = <String>{};
final _closeLock = Lock();
final _pendingIsolationCleanup = <String>{};
bool hasLaunchedFromIntent(String? tabId) {
if (tabId == null) {
@@ -79,13 +86,13 @@ class TabRepository extends _$TabRepository {
}
Future<String> addTab({
required TabMode tabMode,
Uri? url,
required bool selectTab,
bool startLoading = true,
String? parentId,
LoadUrlFlags flags = LoadUrlFlags.NONE,
Source source = Internal.newTab,
required bool private,
HistoryMetadataKey? historyMetadata,
Map<String, String>? additionalHeaders,
TabContainerSelection containerSelection =
@@ -101,10 +108,20 @@ class TabRepository extends _$TabRepository {
SpecificContainerTabSelection(:final container) => container,
};
final validatedParentId = await _resolveParentIdForContext(
parentId: parentId,
targetContextId: assignedContainer?.metadata.contextualIdentity,
);
// For isolated tabs, skip parent context validation since
// isolated tabs use their own immutable context ID.
final validatedParentId = tabMode is IsolatedTabMode
? parentId
: await _resolveParentIdForContext(
parentId: parentId,
targetContextId: assignedContainer?.metadata.contextualIdentity,
);
final effectiveIsolationContextId = tabMode.isolationContextId;
final effectiveContextId = tabMode is IsolatedTabMode
? effectiveIsolationContextId
: assignedContainer?.metadata.contextualIdentity;
final newTabId = await tabDao.upsertTabTransactional(
() {
@@ -114,17 +131,17 @@ class TabRepository extends _$TabRepository {
startLoading: startLoading,
parentId: validatedParentId,
flags: flags,
contextId: assignedContainer?.metadata.contextualIdentity,
contextId: effectiveContextId,
source: source,
private: private,
private: tabMode is PrivateTabMode,
historyMetadata: historyMetadata,
additionalHeaders: additionalHeaders,
);
},
parentId: Value(validatedParentId),
containerId: Value(assignedContainer?.id),
isPrivate: Value(private),
url: Value(url),
tabMode: Value(tabMode),
);
if (launchedFromIntent) {
@@ -188,8 +205,14 @@ class TabRepository extends _$TabRepository {
parentId: Value(validatedParentId),
source: TabSource.manual,
containerId: Value(assignedContainer?.id),
isPrivate: Value(tab.private),
url: Value(Uri.tryParse(tab.url)),
tabMode: Value(
isIsolatedContextId(tab.contextId)
? TabMode.isolated(tab.contextId!)
: tab.private
? TabMode.private
: TabMode.regular,
),
);
}
@@ -204,19 +227,35 @@ class TabRepository extends _$TabRepository {
}) async {
final tabDao = ref.read(tabDatabaseProvider).tabDao;
final sourceTabMode =
await tabDao.getTabMode(selectTabId).getSingleOrNull() ??
TabMode.regular;
// Duplicating an isolated tab creates a new isolation group
final duplicateIsolationContextId = sourceTabMode is IsolatedTabMode
? newIsolatedContextId()
: null;
final duplicateTabMode = sourceTabMode is IsolatedTabMode
? TabMode.isolated(duplicateIsolationContextId!)
: sourceTabMode;
// Isolated tabs always use their isolation context ID
final effectiveContextId = sourceTabMode is IsolatedTabMode
? duplicateIsolationContextId
: containerData?.metadata.contextualIdentity;
return await tabDao.upsertTabTransactional(
() {
return _tabsService.duplicateTab(
selectTabId: selectTabId,
newContextId: containerData?.metadata.contextualIdentity,
newContextId: effectiveContextId,
selectNewTab: selectTab,
);
},
parentId: const Value.absent(),
containerId: Value(containerData?.id),
isPrivate: Value(
await tabDao.getTabIsPrivate(selectTabId).getSingleOrNull(),
),
tabMode: Value(duplicateTabMode),
);
}
@@ -402,27 +441,148 @@ class TabRepository extends _$TabRepository {
}
}
Future<void> closeTab(String tabId) async {
if (ref.read(selectedTabProvider) == tabId) {
await _selectNextTab(tabId);
}
Future<void> closeTab(String tabId) {
return _closeLock.synchronized(() async {
// Collect isolation context before close
final isolationContextId = ref
.read(tabStatesProvider)[tabId]
?.isolationContextId;
return _tabsService.removeTab(tabId: tabId);
if (ref.read(selectedTabProvider) == tabId) {
await _selectNextTab(tabId);
}
await _tabsService.removeTab(tabId: tabId);
// Queue isolation cleanup — actual cleanup runs after syncTabs
// deletes the DB row, so the count check is accurate.
if (isolationContextId != null) {
_pendingIsolationCleanup.add(isolationContextId);
}
});
}
Future<void> closeTabs(List<String> tabIds) async {
final selectedTab = ref.read(selectedTabProvider);
if (selectedTab.mapNotNull(tabIds.contains) ?? false) {
await _selectNextTab(selectedTab!);
Future<void> closeTabs(List<String> tabIds) {
return _closeLock.synchronized(() async {
// Collect isolation contexts from tabs being closed
for (final tabId in tabIds) {
final contextId = ref
.read(tabStatesProvider)[tabId]
?.isolationContextId;
if (contextId != null) {
_pendingIsolationCleanup.add(contextId);
}
}
final selectedTab = ref.read(selectedTabProvider);
if (selectedTab.mapNotNull(tabIds.contains) ?? false) {
await _selectNextTab(selectedTab!);
}
await _tabsService.removeTabs(ids: tabIds);
});
}
/// Clears Gecko browsing data and removes proxy alias for an isolation
/// context if no more tabs share it.
Future<void> _cleanupIsolationContextIfEmpty(String contextId) async {
final tabDao = ref.read(tabDatabaseProvider).tabDao;
// Re-verify count after close (handles concurrent close races)
final remaining = await tabDao.tabsInIsolationGroup(contextId).getSingle();
if (remaining > 0) return;
// Guard against debounced DB persistence: a sibling tab can already be
// active in-memory for this context before isolation_context_id is written.
final activeTabs = ref.read(tabListProvider).value;
final activeStates = ref.read(tabStatesProvider);
final hasActiveSibling = activeTabs.any((tabId) {
final state = activeStates[tabId];
if (state == null) return false;
return state.isolationContextId == contextId ||
state.contextId == contextId;
});
if (hasActiveSibling) {
logger.i(
'Skipping isolation cleanup for active context still in memory: $contextId',
);
return;
}
return _tabsService.removeTabs(ids: tabIds);
logger.i('Cleaning up isolation context: $contextId');
// Clear Gecko browsing data for this context
try {
await ref
.read(browserDataServiceProvider.notifier)
.clearDataForContext(contextId);
} catch (e, st) {
logger.e(
'Failed to clear data for isolation context $contextId',
error: e,
stackTrace: st,
);
}
// Best-effort: remove proxy alias (no-op if never set)
try {
await ref
.read(torProxyRepositoryProvider.notifier)
.removeContainerProxy(contextId);
} catch (e, st) {
logger.e(
'Failed to remove proxy for isolation context $contextId',
error: e,
stackTrace: st,
);
}
}
Future<void> undoClose() {
return _tabsService.undo();
}
/// Cleans up isolation contexts from previous crashed sessions.
/// Called once after tab list stabilizes on startup.
// Future<void> _cleanupOrphanedIsolationContexts() async {
// final tabDao = ref.read(tabDatabaseProvider).tabDao;
// try {
// await _closeLock.synchronized(() async {
// if (!ref.mounted) return;
// // Reconcile DB rows against the current engine tab snapshot, including
// // valid empty-tab sessions (retainTabIds can be empty here).
// final syncTabsResult = await tabDao.syncTabs(
// retainTabIds: ref.read(tabListProvider).value,
// );
// _pendingIsolationCleanup.addAll(
// syncTabsResult.deletedIsolationContextIds,
// );
// if (_pendingIsolationCleanup.isNotEmpty) {
// final pending = Set<String>.of(_pendingIsolationCleanup);
// _pendingIsolationCleanup.clear();
// for (final contextId in pending) {
// if (!ref.mounted) return;
// logger.i('Cleaning orphaned isolation context: $contextId');
// await _cleanupIsolationContextIfEmpty(contextId);
// }
// }
// });
// } catch (e, st) {
// logger.e(
// 'Error during orphan isolation context cleanup',
// error: e,
// stackTrace: st,
// );
// }
// }
@override
void build() {
final eventSerivce = ref.watch(eventServiceProvider);
@@ -438,7 +598,6 @@ class TabRepository extends _$TabRepository {
parentId: const Value.absent(),
source: TabSource.addedEvent,
containerId: Value(containerId),
isPrivate: const Value.absent(),
);
},
onError: (Object error, StackTrace stackTrace) {
@@ -475,7 +634,7 @@ class TabRepository extends _$TabRepository {
if (event.blocked || tabIsEmpty) {
await addTab(
url: uri,
private: tabState.isPrivate,
tabMode: tabState.tabMode,
containerSelection: TabContainerSelection.specific(
containerData,
),
@@ -567,12 +726,42 @@ class TabRepository extends _$TabRepository {
tabListProvider,
(previous, next) async {
//Only sync tabs if there has been a previous value or is not empty
final syncTabs =
final shouldSyncTabs =
next.value.isNotEmpty || (previous?.value.isNotEmpty ?? false);
if (syncTabs) {
await db.tabDao.syncTabs(retainTabIds: next.value);
if (shouldSyncTabs) {
final syncTabsResult = await db.tabDao.syncTabs(
retainTabIds: next.value,
);
// Capture isolation contexts from rows deleted by syncTabs
// (orphaned tabs from crashes, or tabs the engine dropped).
_pendingIsolationCleanup.addAll(
syncTabsResult.deletedIsolationContextIds,
);
}
// Process pending isolation context cleanups after syncTabs
// has deleted the rows, so the count check is accurate.
if (_pendingIsolationCleanup.isNotEmpty) {
final pending = Set<String>.of(_pendingIsolationCleanup);
_pendingIsolationCleanup.clear();
for (final contextId in pending) {
if (!ref.mounted) break;
await _cleanupIsolationContextIfEmpty(contextId);
}
}
// One-shot orphan cleanup after tab list stabilizes (5s debounce).
// Also runs for DB-only contexts whose rows were already deleted
// by syncTabs above (those are handled via _pendingIsolationCleanup).
// if (!orphanCleanupDone) {
// orphanCleanupTimer?.cancel();
// orphanCleanupTimer = Timer(const Duration(seconds: 5), () async {
// if (orphanCleanupDone || !ref.mounted) return;
// orphanCleanupDone = true;
// await _cleanupOrphanedIsolationContexts();
// });
// }
},
onError: (Object error, StackTrace stackTrace) {
logger.e(
@@ -41,7 +41,7 @@ final class TabRepositoryProvider
}
}
String _$tabRepositoryHash() => r'd77e4e74bb7ee1b466172bca245b05c6bf03196c';
String _$tabRepositoryHash() => r'a6953b50e4b9d0e7e297a058872b9ad1c7d55c1a';
abstract class _$TabRepository extends $Notifier<void> {
void build();