app links initial
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ final class BrowserDataServiceProvider
|
||||
}
|
||||
|
||||
String _$browserDataServiceHash() =>
|
||||
r'2df2f652342efc3e16606b92fdef6062b02f72df';
|
||||
r'5df7ca0b61a5f34e69280311777e98fc31907269';
|
||||
|
||||
abstract class _$BrowserDataService extends $Notifier<void> {
|
||||
void build();
|
||||
|
||||
+26
-94
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ final class ProxySettingsReplicationProvider
|
||||
}
|
||||
|
||||
String _$proxySettingsReplicationHash() =>
|
||||
r'69787c85c94ff165e3eeb0f0a3f3fc83e88a1b83';
|
||||
r'bea07ab165545a6bef8a72ddf0503e0cd135eb8a';
|
||||
|
||||
abstract class _$ProxySettingsReplication extends $Notifier<void> {
|
||||
void build();
|
||||
|
||||
+1
-1
@@ -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();
|
||||
|
||||
+34
@@ -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(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
+8
-5
@@ -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);
|
||||
},
|
||||
),
|
||||
|
||||
+14
@@ -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,
|
||||
|
||||
+8
-5
@@ -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();
|
||||
|
||||
+8
-5
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
+238
@@ -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,
|
||||
);
|
||||
}
|
||||
+7
@@ -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,
|
||||
|
||||
+11
-3
@@ -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();
|
||||
|
||||
+8
-6
@@ -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();
|
||||
|
||||
+120
@@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user