app links initial

This commit is contained in:
Fabian Freund
2026-07-30 03:58:46 +02:00
parent 1b0c2b0d06
commit 4bc267969b
97 changed files with 9138 additions and 1054 deletions
@@ -1267,18 +1267,3 @@ class _TabGroupRecord {
required this.dateKey,
});
}
@Riverpod()
class AppLinksModeNotifier extends _$AppLinksModeNotifier {
final _service = GeckoEngineSettingsService();
Future<void> setMode(AppLinksMode mode) async {
await _service.setAppLinksMode(mode);
ref.invalidateSelf();
}
@override
Future<AppLinksMode> build() {
return _service.getAppLinksMode();
}
}
@@ -1253,48 +1253,3 @@ final class GroupedTabListItemsFamily extends $Family
@override
String toString() => r'groupedTabListItemsProvider';
}
@ProviderFor(AppLinksModeNotifier)
final appLinksModeProvider = AppLinksModeNotifierProvider._();
final class AppLinksModeNotifierProvider
extends $AsyncNotifierProvider<AppLinksModeNotifier, AppLinksMode> {
AppLinksModeNotifierProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'appLinksModeProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$appLinksModeNotifierHash();
@$internal
@override
AppLinksModeNotifier create() => AppLinksModeNotifier();
}
String _$appLinksModeNotifierHash() =>
r'2643b7d2799870fd444f7db204ea452d975368a3';
abstract class _$AppLinksModeNotifier extends $AsyncNotifier<AppLinksMode> {
FutureOr<AppLinksMode> build();
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref = this.ref as $Ref<AsyncValue<AppLinksMode>, AppLinksMode>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<AppLinksMode>, AppLinksMode>,
AsyncValue<AppLinksMode>,
Object?,
Object?
>;
return element.handleCreate(ref, build);
}
}
@@ -42,7 +42,7 @@ final class BrowserDataServiceProvider
}
String _$browserDataServiceHash() =>
r'2df2f652342efc3e16606b92fdef6062b02f72df';
r'5df7ca0b61a5f34e69280311777e98fc31907269';
abstract class _$BrowserDataService extends $Notifier<void> {
void build();
@@ -22,6 +22,7 @@ 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/app_links/domain/services/effective_routing.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';
@@ -33,45 +34,10 @@ import 'package:weblibre/features/user/domain/repositories/proxy_routing_setting
part 'proxy_settings_replication.g.dart';
sealed class _ProxyAssignment with FastEquatable {
_ProxyAssignment();
factory _ProxyAssignment.inherit() = _InheritProxyAssignment;
factory _ProxyAssignment.direct(String scopeId) = _DirectProxyAssignment;
factory _ProxyAssignment.explicit(String proxyId) = _ExplicitProxyAssignment;
}
final class _InheritProxyAssignment extends _ProxyAssignment {
_InheritProxyAssignment();
@override
List<Object?> get hashParameters => const ['inherit'];
}
final class _DirectProxyAssignment extends _ProxyAssignment {
final String scopeId;
_DirectProxyAssignment(this.scopeId);
@override
List<Object?> get hashParameters => [scopeId];
}
final class _ExplicitProxyAssignment extends _ProxyAssignment {
final String proxyId;
_ExplicitProxyAssignment(this.proxyId);
@override
List<Object?> get hashParameters => [proxyId];
}
@Riverpod(keepAlive: true)
class ProxySettingsReplication extends _$ProxySettingsReplication {
var _isolatedProxyAssignments = <String, _ProxyAssignment>{};
var _appliedContainerProxies = <String, _ProxyAssignment>{};
var _isolatedProxyAssignments = <String, ProxyAssignment>{};
var _appliedContainerProxies = <String, ProxyAssignment>{};
final _recomputeLock = Lock();
var _recomputeDirty = false;
@@ -117,19 +83,17 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
.read(containerRepositoryProvider.notifier)
.getAllContainersWithCount();
final containerAssignments = <String, _ProxyAssignment>{
final containerAssignments = <String, ProxyAssignment>{
for (final c in containers)
if (c.metadata.contextualIdentity case final contextId?)
c.id: switch (c.metadata.proxyConnectionId) {
final proxyId? => _ProxyAssignment.explicit(proxyId.encode()),
null when c.metadata.bypassGlobalProxy => _ProxyAssignment.direct(
contextId,
),
null => _ProxyAssignment.inherit(),
},
c.id: resolveContainerAssignment(
contextId: contextId,
proxyConnectionId: c.metadata.proxyConnectionId,
bypassGlobalProxy: c.metadata.bypassGlobalProxy,
),
};
final newAssignments = <String, _ProxyAssignment>{};
final newAssignments = <String, ProxyAssignment>{};
for (final entry in contextContainerMap.entries) {
final assignments = entry.value
.map((containerId) => containerAssignments[containerId])
@@ -138,50 +102,19 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
if (assignments.isEmpty) continue;
final proxyIds =
assignments
.whereType<_ExplicitProxyAssignment>()
.map((assignment) => assignment.proxyId)
.toSet()
.toList()
..sort();
final directScopeIds =
assignments
.whereType<_DirectProxyAssignment>()
.map((assignment) => assignment.scopeId)
.toSet()
.toList()
..sort();
final hasInheritedAssignment = assignments.any(
(assignment) => assignment is _InheritProxyAssignment,
);
final chosenAssignment = proxyIds.isNotEmpty
? _ProxyAssignment.explicit(proxyIds.first)
: directScopeIds.isNotEmpty && !hasInheritedAssignment
? _ProxyAssignment.direct(directScopeIds.first)
: _ProxyAssignment.inherit();
if (chosenAssignment is! _InheritProxyAssignment) {
newAssignments[entry.key] = chosenAssignment;
final routing = resolveIsolationContextRouting(assignments);
if (routing.chosen is! InheritProxyAssignment) {
newAssignments[entry.key] = routing.chosen;
}
final chosenLabel = switch (chosenAssignment) {
_DirectProxyAssignment(:final scopeId) => 'direct:$scopeId',
_ExplicitProxyAssignment(:final proxyId) => proxyId,
_InheritProxyAssignment() => 'inherit',
};
final distinctAssignmentCount =
proxyIds.length +
directScopeIds.length +
(hasInheritedAssignment ? 1 : 0);
if (distinctAssignmentCount > 1) {
if (routing.distinctAssignmentCount > 1) {
// Isolation contexts can hold multiple containers; if they disagree on
// routing, the alias is forced to pick one. Surface this so the user
// can split the containers across isolation contexts.
logger.w(
'Isolation context ${entry.key} has containers with multiple '
'proxy routing assignments '
'(${[if (hasInheritedAssignment) 'inherit', ...directScopeIds.map((id) => 'direct:$id'), ...proxyIds].join(', ')}); using $chosenLabel',
'(${routing.assignmentLabels.join(', ')}); using ${routing.chosenLabel}',
);
}
}
@@ -339,20 +272,19 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
) async {
if (containers == null) return;
final desired = <String, _ProxyAssignment>{};
final desired = <String, ProxyAssignment>{};
for (final container in containers) {
final contextId = container.metadata.contextualIdentity;
if (contextId == null || contextId.isEmpty) continue;
final proxyConnectionId = container.metadata.proxyConnectionId;
desired[contextId] = proxyConnectionId != null
? _ProxyAssignment.explicit(proxyConnectionId.encode())
: container.metadata.bypassGlobalProxy
? _ProxyAssignment.direct(contextId)
: _ProxyAssignment.inherit();
desired[contextId] = resolveContainerAssignment(
contextId: contextId,
proxyConnectionId: container.metadata.proxyConnectionId,
bypassGlobalProxy: container.metadata.bypassGlobalProxy,
);
}
final repo = ref.read(containerProxyRepositoryProvider.notifier);
final nextApplied = Map<String, _ProxyAssignment>.from(
final nextApplied = Map<String, ProxyAssignment>.from(
_appliedContainerProxies,
);
@@ -395,15 +327,15 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
Future<void> _applyProxyAssignment(
String contextId,
_ProxyAssignment assignment,
ProxyAssignment assignment,
) async {
final repo = ref.read(containerProxyRepositoryProvider.notifier);
switch (assignment) {
case _ExplicitProxyAssignment(:final proxyId):
case ExplicitProxyAssignment(:final proxyId):
await repo.setContainerProxy(contextId, proxyId);
case _DirectProxyAssignment(:final scopeId):
case DirectProxyAssignment(:final scopeId):
await repo.setContainerDirectConnection(contextId, scopeId: scopeId);
case _InheritProxyAssignment():
case InheritProxyAssignment():
await repo.clearContainerProxy(contextId);
}
}
@@ -42,7 +42,7 @@ final class ProxySettingsReplicationProvider
}
String _$proxySettingsReplicationHash() =>
r'69787c85c94ff165e3eeb0f0a3f3fc83e88a1b83';
r'bea07ab165545a6bef8a72ddf0503e0cd135eb8a';
abstract class _$ProxySettingsReplication extends $Notifier<void> {
void build();
@@ -44,7 +44,7 @@ List<ToolbarButtonConfig> _buildDefaultToolbarButtonConfigs({
return ToolbarButtonConfig(
buttonId: spec.id.name,
orderKey: key,
isVisible: allHidden ? false : spec.defaultVisible,
isVisible: !allHidden && spec.defaultVisible,
fallbackId: allHidden ? null : spec.defaultFallback?.name,
);
}).toList();
@@ -29,6 +29,7 @@ import 'package:weblibre/core/providers/global_drop.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/data/models/drag_data.dart';
import 'package:weblibre/extensions/media_query.dart';
import 'package:weblibre/features/app_links/presentation/widgets/app_link_prompt_host.dart';
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
import 'package:weblibre/features/geckoview/domain/controllers/overlay.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
@@ -1276,6 +1277,39 @@ class BrowserScreen extends HookConsumerWidget {
},
),
),
// Layer 7: App-link prompt banner (§2.6). Anchored above the bottom app
// bar / keyboard exactly like find-in-page, so it is never hidden behind
// the toolbar. Custom Tab sessions are prompted natively instead; this is
// the browser-tab surface only.
Consumer(
builder: (context, ref, child) {
final toolbarState = ref.watch(
toolbarVisibilityControllerProvider(selectedTabId),
);
final visible =
sheetDisplayed ||
(!tabInFullScreen &&
toolbarState == ToolbarVisibility.visible);
return Positioned(
left: (tabBarPosition == TabBarPosition.left && visible)
? sideRailTotalWidth
: 0.0,
right: (tabBarPosition == TabBarPosition.right && visible)
? sideRailTotalWidth
: 0.0,
bottom: math.max(
isRail
? bottomSafeArea
: (visible
? bottomAppBarTotalHeight
: bottomSafeArea),
MediaQuery.viewInsetsOf(context).bottom,
),
child: const AppLinkPromptHost(),
);
},
),
],
),
),
@@ -1575,22 +1575,25 @@ class _OpenInAppTile extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final tabState = ref.watch(tabStateProvider(selectedTabId));
final url = tabState?.url;
final hasExternalApp = useCachedFuture(
() => url != null ? _service.hasExternalApp(url) : Future.value(false),
final appLink = useCachedFuture(
() => url != null ? _service.resolveAppLink(url) : Future.value(null),
[url],
);
if (hasExternalApp.data != true) return const SizedBox.shrink();
final target = appLink.data;
if (target == null) return const SizedBox.shrink();
final appName = target.appName;
return Column(
children: [
_buildDivider(),
ListTile(
leading: const Icon(Icons.open_in_new),
title: const Text('Open in App'),
title: Text(appName != null ? 'Open in $appName' : 'Open in App'),
onTap: () async {
if (url == null) return;
final success = await _service.openAppLink(url);
final success = await _service.launchAppLink(url);
if (success && context.mounted) Navigator.pop(context);
},
),
@@ -30,6 +30,7 @@ import 'package:weblibre/core/logger.dart';
import 'package:weblibre/core/providers/device_info.dart';
import 'package:weblibre/core/providers/router.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/app_links/domain/services/app_link_policy_replication.dart';
import 'package:weblibre/features/bangs/data/models/web_search_bang.dart';
import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
import 'package:weblibre/features/bangs/domain/services/search_history_cleanup.dart';
@@ -676,6 +677,19 @@ class _BrowserViewState extends ConsumerState<BrowserView>
},
);
ref.listenManual(
fireImmediately: true,
appLinkPolicyReplicationProvider,
(previous, next) {},
onError: (error, stackTrace) {
logger.e(
'Error listening to appLinkPolicyReplicationProvider',
error: error,
stackTrace: stackTrace,
);
},
);
ref.listenManual(
fireImmediately: true,
historyExclusionReplicationProvider,
@@ -329,24 +329,27 @@ class OpenInAppMenuItemButton extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final tabState = ref.watch(tabStateProvider(selectedTabId));
final url = tabState?.url;
final hasExternalApp = useCachedFuture(
final appLink = useCachedFuture(
// ignore: discarded_futures useFuture
() => url != null ? _service.hasExternalApp(url) : Future.value(false),
() => url != null ? _service.resolveAppLink(url) : Future.value(null),
[url],
);
if (hasExternalApp.data != true) {
final target = appLink.data;
if (target == null) {
return const SizedBox.shrink();
}
final appName = target.appName;
return MenuItemButton(
leadingIcon: const Icon(Icons.open_in_new),
closeOnActivate: false,
child: const Text('Open in App'),
child: Text(appName != null ? 'Open in $appName' : 'Open in App'),
onPressed: () async {
if (url == null) return;
final success = await _service.openAppLink(url);
final success = await _service.launchAppLink(url);
if (success && context.mounted) {
MenuController.maybeOf(context)?.close();
@@ -357,19 +357,22 @@ class _OpenInAppTile extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final tabState = ref.watch(tabStateProvider(selectedTabId));
final url = tabState?.url;
final hasExternalApp = useCachedFuture(
() => url != null ? _service.hasExternalApp(url) : Future.value(false),
final appLink = useCachedFuture(
() => url != null ? _service.resolveAppLink(url) : Future.value(null),
[url],
);
if (hasExternalApp.data != true) return const SizedBox.shrink();
final target = appLink.data;
if (target == null) return const SizedBox.shrink();
final appName = target.appName;
return ListTile(
leading: const Icon(Icons.open_in_new),
title: const Text('Open in App'),
title: Text(appName != null ? 'Open in $appName' : 'Open in App'),
onTap: () async {
if (url == null) return;
final success = await _service.openAppLink(url);
final success = await _service.launchAppLink(url);
if (success && context.mounted) Navigator.pop(context);
},
);
@@ -0,0 +1,238 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* 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/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
show AppLinkTarget, AppLinksMode, GeckoAppLinksService;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart';
import 'package:weblibre/features/app_links/domain/entities/context_app_link_policy.dart';
import 'package:weblibre/features/app_links/domain/services/effective_app_link_policy.dart';
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart';
final _appLinkTargetProvider = FutureProvider.autoDispose
.family<AppLinkTarget?, Uri>((ref, url) {
return GeckoAppLinksService().resolveAppLink(url);
});
enum _SiteRuleChoice { followDefault, alwaysOpen, neverOpen }
/// Section widget showing the app-link rule for the current tab's site. Edits
/// the effective bucket — the owning container's override when it has isolated
/// app-link settings, otherwise the global rules — but does not expose the
/// global/container default from this site-specific sheet.
class AppLinkSection extends HookConsumerWidget {
final Uri url;
/// The tab's live contextId (`TabState.contextId`): the container base
/// contextId for a regular tab, the isolation contextId for an isolated tab.
final String? contextId;
const AppLinkSection({required this.url, required this.contextId, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final policy = ref.watch(effectiveAppLinkPolicyProvider(contextId));
final target = ref.watch(_appLinkTargetProvider(url));
final isLoadingTarget = target.isLoading && !target.hasValue;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Text(
'App Links',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
),
if (policy == null || isLoadingTarget)
const Skeletonizer(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: Icon(Icons.link),
title: Text('Open links for this site'),
subtitle: Text('Follows the default'),
),
],
),
)
else
_SiteRuleTile(
policy: policy,
target: target.hasValue ? target.value : null,
),
],
);
}
}
class _SiteRuleTile extends ConsumerWidget {
final EffectiveAppLinkPolicy policy;
final AppLinkTarget? target;
const _SiteRuleTile({required this.policy, required this.target});
@override
Widget build(BuildContext context, WidgetRef ref) {
final scope = target?.scopeKey;
final rule = (scope != null && scope.isNotEmpty)
? policy.rules[scope]
: null;
final choice = switch (rule?.decision) {
AppLinkRuleDecision.alwaysOpen => _SiteRuleChoice.alwaysOpen,
AppLinkRuleDecision.neverOpen => _SiteRuleChoice.neverOpen,
null => _SiteRuleChoice.followDefault,
};
final canAlwaysOpen = _alwaysOpenRuleFor(target) != null;
final colorScheme = Theme.of(context).colorScheme;
final (IconData icon, Color color) = switch (choice) {
_SiteRuleChoice.alwaysOpen => (MdiIcons.openInApp, colorScheme.primary),
_SiteRuleChoice.neverOpen => (Icons.public, colorScheme.primary),
_SiteRuleChoice.followDefault => (
Icons.link,
colorScheme.onSurfaceVariant,
),
};
return ListTile(
leading: Icon(icon, color: color),
title: const Text('Open links for this site'),
subtitle: Text(_subtitle(scope, rule, choice, canAlwaysOpen)),
trailing: DropdownButton<_SiteRuleChoice>(
value: choice,
underline: const SizedBox(),
items: [
const DropdownMenuItem(
value: _SiteRuleChoice.followDefault,
child: Text('Follow default'),
),
DropdownMenuItem(
value: _SiteRuleChoice.alwaysOpen,
enabled: canAlwaysOpen || choice == _SiteRuleChoice.alwaysOpen,
child: const Text('Open in app'),
),
const DropdownMenuItem(
value: _SiteRuleChoice.neverOpen,
child: Text('Keep in browser'),
),
],
onChanged: scope == null || scope.isEmpty
? null
: (value) async {
if (value != null && value != choice) {
await _setSiteRule(ref, scope, target, value);
}
},
),
);
}
String _subtitle(
String? scope,
PersistedAppLinkRule? rule,
_SiteRuleChoice choice,
bool canAlwaysOpen,
) {
if (scope == null || scope.isEmpty) return 'No app found for this site';
return switch (choice) {
_SiteRuleChoice.alwaysOpen =>
'Always opens in ${rule!.packageName ?? 'the app'}',
_SiteRuleChoice.neverOpen => 'Always stays in the browser',
_SiteRuleChoice.followDefault => switch (policy.mode) {
AppLinksMode.always =>
canAlwaysOpen
? 'Follows the default: opens in apps'
: 'Follows the default: no app found',
AppLinksMode.ask => 'Follows the default: asks first',
AppLinksMode.never => 'Follows the default: stays in the browser',
},
};
}
Future<void> _setSiteRule(
WidgetRef ref,
String scope,
AppLinkTarget? target,
_SiteRuleChoice choice,
) async {
Map<String, PersistedAppLinkRule> updateRules(
Map<String, PersistedAppLinkRule> rules,
) {
final next = {...rules};
switch (choice) {
case _SiteRuleChoice.followDefault:
next.remove(scope);
case _SiteRuleChoice.neverOpen:
next[scope] = PersistedAppLinkRule(
decision: AppLinkRuleDecision.neverOpen,
scope: scope,
);
case _SiteRuleChoice.alwaysOpen:
final rule = _alwaysOpenRuleFor(target);
if (rule != null) next[scope] = rule;
}
return next;
}
final overrideKey = policy.overrideKey;
await ref.read(saveGeneralSettingsControllerProvider.notifier).save((
current,
) {
if (overrideKey == null) {
return current.copyWith.appLinkRules(updateRules(current.appLinkRules));
}
final existing =
current.appLinkContextOverrides[overrideKey] ??
ContextAppLinkPolicy.blank();
return current.copyWith.appLinkContextOverrides({
...current.appLinkContextOverrides,
overrideKey: existing.copyWith.rules(updateRules(existing.rules)),
});
});
}
}
PersistedAppLinkRule? _alwaysOpenRuleFor(AppLinkTarget? target) {
final packageName = target?.packageName;
final scope = target?.scopeKey;
if (target == null ||
target.isAmbiguous ||
packageName == null ||
packageName.isEmpty ||
scope == null ||
scope.isEmpty) {
return null;
}
return PersistedAppLinkRule(
decision: AppLinkRuleDecision.alwaysOpen,
scope: scope,
packageName: packageName,
);
}
@@ -26,6 +26,7 @@ import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/certificate_tile.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/draggable_scrollable_header.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/app_link_section.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/clear_site_data_section.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/desktop_mode_section.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/gesture_exclusion_section.dart';
@@ -164,6 +165,12 @@ class ViewTabSheetWidget extends HookConsumerWidget {
url: initialTabState.url,
),
const Divider(),
// App Link Section
AppLinkSection(
url: initialTabState.url,
contextId: initialTabState.contextId,
),
const Divider(),
// Permissions Section
PermissionsSection(
origin: initialTabState.url.origin,
@@ -23,6 +23,7 @@ import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.dart';
import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart';
import 'package:weblibre/presentation/hooks/cached_future.dart';
class LaunchExternal extends HookConsumerWidget {
final HitResult hitResult;
@@ -33,19 +34,26 @@ class LaunchExternal extends HookConsumerWidget {
static Future<bool> isSupported(HitResult hitResult) async {
return hitResult.tryGetLink().mapNotNull(
(url) => _service.hasExternalApp(url),
(url) async => (await _service.resolveAppLink(url)) != null,
) ??
false;
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final url = hitResult.tryGetLink();
final appLink = useCachedFuture(
() => url != null ? _service.resolveAppLink(url) : Future.value(null),
[url],
);
final appName = appLink.data?.appName;
return ListTile(
leading: const Icon(Icons.open_in_new),
title: const Text('Open in App'),
title: Text(appName != null ? 'Open in $appName' : 'Open in App'),
onTap: () async {
await hitResult.tryGetLink().mapNotNull((url) async {
final success = await _service.openAppLink(url);
final success = await _service.launchAppLink(url);
if (success && context.mounted) {
context.pop();
@@ -184,11 +184,11 @@ class OpenSharedContent extends HookConsumerWidget {
};
}, [containerMode, contextId, selectionUrlKey, globalSelectedContainer]);
final hasExternalApp = useCachedFuture(
final appLink = useCachedFuture(
// ignore: discarded_futures useFuture
() => parsedDebouncedUrl != null
? _appLinksService.hasExternalApp(parsedDebouncedUrl)
: Future.value(false),
? _appLinksService.resolveAppLink(parsedDebouncedUrl)
: Future.value(null),
[parsedDebouncedUrl],
);
@@ -288,7 +288,7 @@ class OpenSharedContent extends HookConsumerWidget {
final uri = parseValidatedUrl(textController.text, eagerParsing: false);
if (uri == null) return;
final success = await _appLinksService.openAppLink(uri);
final success = await _appLinksService.launchAppLink(uri);
if (success && context.mounted) {
context.pop(true);
@@ -429,9 +429,11 @@ class OpenSharedContent extends HookConsumerWidget {
},
),
],
if (hasExternalApp.data == true)
if (appLink.data != null)
_OpenActionTile(
title: 'Open in App',
title: appLink.data?.appName != null
? 'Open in ${appLink.data!.appName}'
: 'Open in App',
subtitle: 'Open in an installed app',
icon: Icons.open_in_new,
onTap: openInApp,
@@ -19,11 +19,8 @@
*/
import 'package:drift/drift.dart';
import 'package:drift/internal/versioned_schema.dart';
import 'package:drift_dev/api/migrations_native.dart';
import 'package:flutter/foundation.dart';
import 'package:lexo_rank/lexo_rank.dart';
import 'package:weblibre/data/database/functions/lexo_rank_functions.dart';
import 'package:weblibre/data/database/functions/url_functions.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/capture_tab.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/container.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/history.dart';
@@ -83,6 +83,16 @@ class ContainerMetadata with FastEquatable {
@JsonKey(defaultValue: false)
final bool strictMode;
// When true, this container has its own app-link policy (open-in-app mode +
// remembered per-site rules) that fully replaces the global one for its tabs.
// The override itself lives in `GeneralSettings.appLinkContextOverrides` keyed
// by [contextualIdentity]; this flag only gates whether that override is
// consulted. Requires a Gecko contextId — the native interceptor keys the
// override on the tab's contextId, so it is normalized to false when
// [contextualIdentity] is null (mirrors [strictMode]/[excludeFromHistory]).
@JsonKey(defaultValue: false)
final bool isolatedAppLinkSettings;
ContainerMetadata({
required this.iconData,
required this.contextualIdentity,
@@ -94,6 +104,7 @@ class ContainerMetadata with FastEquatable {
required this.useCustomColor,
required this.assignedSites,
required this.strictMode,
required this.isolatedAppLinkSettings,
});
ContainerMetadata.withDefaults({
@@ -107,6 +118,7 @@ class ContainerMetadata with FastEquatable {
bool? useCustomColor,
List<Uri>? assignedSites,
bool? strictMode,
bool? isolatedAppLinkSettings,
}) : this(
iconData: iconData,
contextualIdentity: contextualIdentity,
@@ -128,6 +140,11 @@ class ContainerMetadata with FastEquatable {
// normalize away the invalid combination on read, and writers re-apply
// it via [sanitized].
strictMode: (strictMode ?? false) && contextualIdentity != null,
// Isolated app-link settings need a contextId — the native interceptor
// keys the override on the tab's contextId. Normalize the invalid
// combination on read; writers re-apply it via [sanitized].
isolatedAppLinkSettings:
(isolatedAppLinkSettings ?? false) && contextualIdentity != null,
);
/// Enforce the [excludeFromHistory] invariant before persistence: it can only
@@ -145,6 +162,11 @@ class ContainerMetadata with FastEquatable {
if (result.strictMode && result.contextualIdentity == null) {
result = result.copyWith(strictMode: false);
}
// Isolated app-link settings need a contextId: the interceptor keys the
// override on the tab's contextId.
if (result.isolatedAppLinkSettings && result.contextualIdentity == null) {
result = result.copyWith(isolatedAppLinkSettings: false);
}
return result;
}
@@ -167,6 +189,7 @@ class ContainerMetadata with FastEquatable {
useCustomColor,
assignedSites,
strictMode,
isolatedAppLinkSettings,
];
}
@@ -27,6 +27,8 @@ abstract class _$ContainerMetadataCWProxy {
ContainerMetadata strictMode(bool strictMode);
ContainerMetadata isolatedAppLinkSettings(bool isolatedAppLinkSettings);
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `ContainerMetadata(...).copyWith.fieldName(value)`.
///
@@ -45,6 +47,7 @@ abstract class _$ContainerMetadataCWProxy {
bool useCustomColor,
List<Uri>? assignedSites,
bool strictMode,
bool isolatedAppLinkSettings,
});
}
@@ -93,6 +96,10 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy {
@override
ContainerMetadata strictMode(bool strictMode) => call(strictMode: strictMode);
@override
ContainerMetadata isolatedAppLinkSettings(bool isolatedAppLinkSettings) =>
call(isolatedAppLinkSettings: isolatedAppLinkSettings);
@override
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `ContainerMetadata(...).copyWith.fieldName(value)`.
@@ -112,6 +119,7 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy {
Object? useCustomColor = const $CopyWithPlaceholder(),
Object? assignedSites = const $CopyWithPlaceholder(),
Object? strictMode = const $CopyWithPlaceholder(),
Object? isolatedAppLinkSettings = const $CopyWithPlaceholder(),
}) {
return ContainerMetadata(
iconData: iconData == const $CopyWithPlaceholder()
@@ -165,6 +173,12 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy {
? _value.strictMode
// ignore: cast_nullable_to_non_nullable
: strictMode as bool,
isolatedAppLinkSettings:
isolatedAppLinkSettings == const $CopyWithPlaceholder() ||
isolatedAppLinkSettings == null
? _value.isolatedAppLinkSettings
// ignore: cast_nullable_to_non_nullable
: isolatedAppLinkSettings as bool,
);
}
}
@@ -308,6 +322,8 @@ ContainerMetadata _$ContainerMetadataFromJson(Map<String, dynamic> json) =>
?.map((e) => Uri.parse(e as String))
.toList(),
strictMode: json['strictMode'] as bool? ?? false,
isolatedAppLinkSettings:
json['isolatedAppLinkSettings'] as bool? ?? false,
);
Map<String, dynamic> _$ContainerMetadataToJson(
@@ -326,6 +342,7 @@ Map<String, dynamic> _$ContainerMetadataToJson(
'useCustomColor': instance.useCustomColor,
'assignedSites': instance.assignedSites?.map((e) => e.toString()).toList(),
'strictMode': instance.strictMode,
'isolatedAppLinkSettings': instance.isolatedAppLinkSettings,
};
Value? _$JsonConverterFromJson<Json, Value>(
@@ -103,12 +103,10 @@ class TabDataRepository extends _$TabDataRepository {
),
// parentId defaults to null - breaks parent chain when changing contextual identity
selectTab: selectedTabId == tabState.id,
// Assignment-driven navigation to an assigned site: bypass the
// app-links delegate so cancelling an "open in app" prompt does
// not re-trigger it on the recreated tab's load.
flags: replacementUrl != null
? LoadUrlFlags.LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE
: LoadUrlFlags.NONE,
// Assignment-driven navigation is classified in its assigned context
// like any other load; the app-links fallback re-entry map (§2.7)
// covers the redirect loop the old delegate bypass used to guard.
flags: LoadUrlFlags.NONE,
);
}
}
@@ -41,7 +41,7 @@ final class TabDataRepositoryProvider
}
}
String _$tabDataRepositoryHash() => r'adc1c664b492e41a96a0310d92252dbbacc1a089';
String _$tabDataRepositoryHash() => r'd4eb49e25077aea6de479ea738ec92a213b71f78';
abstract class _$TabDataRepository extends $Notifier<void> {
void build();
@@ -26,6 +26,7 @@ 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:weblibre/core/uuid.dart';
import 'package:weblibre/features/app_links/presentation/widgets/container_app_link_settings_dialog.dart';
import 'package:weblibre/features/geckoview/features/history/domain/repositories/container_history.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
@@ -40,9 +41,32 @@ import 'package:weblibre/features/geckoview/features/tabs/utils/container_icons.
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
import 'package:weblibre/features/proxy/domain/providers/proxy_connection_options.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
enum _DialogMode { create, edit }
/// Remove any per-container app-link overrides (§ container isolation) stored for
/// [contextIds] in GeneralSettings. Null ids are ignored; a no-op when none are
/// present. Keeps overrides from lingering after a container drops isolation or
/// is deleted.
Future<void> _removeAppLinkOverrides(
WidgetRef ref,
Set<String?> contextIds,
) async {
final ids = contextIds.nonNulls.toSet();
if (ids.isEmpty) return;
await ref.read(generalSettingsRepositoryProvider.notifier).updateSettings((
current,
) {
if (!ids.any(current.appLinkContextOverrides.containsKey)) return current;
return current.copyWith.appLinkContextOverrides(
{...current.appLinkContextOverrides}..removeWhere((key, _) => ids.contains(key)),
);
});
}
class ContainerEditScreen extends HookConsumerWidget {
final _DialogMode _mode;
@@ -109,6 +133,9 @@ class ContainerEditScreen extends HookConsumerWidget {
);
final assignedSites = useState(initialContainer.metadata.assignedSites);
final strictMode = useState(initialContainer.metadata.strictMode);
final isolatedAppLinkSettings = useState(
initialContainer.metadata.isolatedAppLinkSettings,
);
final isPinned = useState(initialContainer.isPinned);
final textController = useTextEditingController(
@@ -147,6 +174,12 @@ class ContainerEditScreen extends HookConsumerWidget {
// strictness on the tab's cookieStoreId). sanitized() enforces the
// same invariant defensively on write.
strictMode: strictMode.value && contextualIdentity.value != null,
// Isolated app-link settings require a Gecko contextId (the
// interceptor keys the override on the tab's contextId).
// sanitized() enforces the same invariant defensively on write.
isolatedAppLinkSettings:
isolatedAppLinkSettings.value &&
contextualIdentity.value != null,
)
.sanitized(),
);
@@ -167,6 +200,15 @@ class ContainerEditScreen extends HookConsumerWidget {
isPinned: isPinned.value,
);
}
// Keep the per-container app-link override in step with the isolation
// toggle: drop it when the container is no longer isolated (or lost its
// contextId) so it can't linger orphaned in GeneralSettings.
if (!container.metadata.isolatedAppLinkSettings) {
await _removeAppLinkOverrides(ref, {
initialContainer.metadata.contextualIdentity,
container.metadata.contextualIdentity,
});
}
return container;
}
@@ -264,6 +306,11 @@ class ContainerEditScreen extends HookConsumerWidget {
.read(containerRepositoryProvider.notifier)
.deleteContainer(initialContainer.id);
// Drop the container's app-link override so it doesn't outlive it.
await _removeAppLinkOverrides(ref, {
initialContainer.metadata.contextualIdentity,
});
if (context.mounted) {
context.pop();
}
@@ -654,6 +701,79 @@ class ContainerEditScreen extends HookConsumerWidget {
],
),
),
const SizedBox(height: 24),
Text(
'App Links',
style: theme.textTheme.titleSmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 12),
Card.filled(
margin: EdgeInsets.zero,
color: colorScheme.surfaceContainer,
clipBehavior: Clip.antiAlias,
child: Column(
children: [
SwitchListTile.adaptive(
value:
contextualIdentity.value != null &&
isolatedAppLinkSettings.value,
title: const Text('Isolated App Link Settings'),
subtitle: Text(
contextualIdentity.value != null
? 'Use a separate open-in-app mode and remembered '
'site rules for this container instead of the '
'global settings'
: 'Requires cookie isolation to be enabled',
),
secondary: const Icon(MdiIcons.openInApp),
onChanged: (contextualIdentity.value != null)
? (value) {
isolatedAppLinkSettings.value = value;
}
: null,
),
// The per-container mode + rules live in GeneralSettings
// (keyed by the persisted contextId) and are edited live,
// like the global app-link settings. Only offered in edit
// mode against the saved, immutable contextId — a create
// draft's contextId can still churn (cookie-isolation
// toggling regenerates it), which would orphan overrides.
if (_mode == _DialogMode.edit &&
initialContainer.metadata.contextualIdentity !=
null &&
isolatedAppLinkSettings.value) ...[
const Divider(height: 1, indent: 56),
ListTile(
leading: const Icon(Icons.tune),
title: const Text('App Link Behavior'),
subtitle: const Text(
"Configure this container's open-in-app mode and "
'remembered sites',
),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await showDialog<void>(
context: context,
builder: (context) =>
ContainerAppLinkSettingsDialog(
contextId: initialContainer
.metadata
.contextualIdentity!,
containerName:
textController.text.trim().isNotEmpty
? textController.text.trim()
: initialContainer.name,
),
);
},
),
],
],
),
),
],
),
),