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
@@ -0,0 +1,273 @@
/*
* 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 'dart:async';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
hide ProtectedTargetPattern;
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
as pigeon
show ProtectedTargetPattern;
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/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_routing.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart';
import 'package:weblibre/features/user/data/models/proxy_routing_settings.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/features/user/domain/repositories/proxy_routing_settings.dart';
part 'app_link_policy_replication.g.dart';
/// Effective app-link protection (§2.3), recomputed whenever routing, strict
/// mode, contextual identities, or site assignments change.
@Riverpod(keepAlive: true)
AppLinkProtection appLinkProtection(Ref ref) {
final routing = ref.watch(proxyRoutingSettingsWithDefaultsProvider);
final protectGeneralContext =
routing.regularTabsMode == ProxyRegularTabRoutingMode.all &&
routing.regularTabsProxyConnectionId != null;
final containers =
ref.watch(watchContainersWithCountProvider).value ?? const [];
final isolationMap =
ref.watch(watchIsolatedContextContainerMapProvider).value ?? const {};
final strict =
ref.watch(watchStrictContextAssignmentsProvider).value ?? const {};
final sites = ref.watch(watchAllAssignedSitesProvider).value ?? const [];
return computeAppLinkProtection(
protectGeneralContext: protectGeneralContext,
containers: containers,
isolationContextContainerMap: isolationMap,
strictContextIds: strict.keys.toSet(),
siteAssignments: sites,
);
}
/// The complete policy snapshot to push, or null until the real persisted
/// settings have loaded (the `...WithDefaults` loading placeholder is not valid
/// input, §2.8). Combines the user-intent settings with computed protection.
@Riverpod(keepAlive: true)
AppLinkPolicySnapshot? appLinkPolicySnapshot(Ref ref) {
final settings = ref.watch(generalSettingsRepositoryProvider).value;
if (settings == null) return null;
// Don't push a snapshot until the protection/override inputs have actually
// loaded (§2.8). `appLinkProtection` and `_computeContextOverrides` fall back to
// empty collections while these streams are still loading; pushing that would
// briefly persist "no protected contexts / no overrides" to native and let a
// protected or isolated container's links leak out during startup. Native keeps
// last session's persisted snapshot until the real one is ready.
final containersLoaded = ref.watch(watchContainersWithCountProvider).hasValue;
final isolationLoaded = ref
.watch(watchIsolatedContextContainerMapProvider)
.hasValue;
final strictLoaded = ref.watch(watchStrictContextAssignmentsProvider).hasValue;
final sitesLoaded = ref.watch(watchAllAssignedSitesProvider).hasValue;
// The real proxy-routing settings drive `protectGeneralContext`; the
// `...WithDefaults` view silently substitutes defaults while the row loads,
// which would compute "general context not proxied" and let a globally-proxied
// setup auto-launch during startup. Wait for the actual value.
final routingLoaded = ref
.watch(proxyRoutingSettingsRepositoryProvider)
.hasValue;
if (!containersLoaded ||
!isolationLoaded ||
!strictLoaded ||
!sitesLoaded ||
!routingLoaded) {
return null;
}
final protection = ref.watch(appLinkProtectionProvider);
return AppLinkPolicySnapshot(
globalMode: settings.appLinksMode,
rules: {
for (final MapEntry(:key, :value) in settings.appLinkRules.entries)
key: _toNativeRule(value),
},
marketplaceFallbackEnabled: settings.appLinkMarketplaceFallback,
protectGeneralContext: protection.protectGeneralContext,
protectedContextIds: protection.protectedContextIds.toList(),
strictContextIds: protection.strictContextIds.toList(),
protectedTargetPatterns: protection.protectedTargetPatterns
.map(_toNativePattern)
.toList(),
contextOverrides: _computeContextOverrides(ref, settings),
);
}
/// Build the per-container override map (§ container isolation): one entry per
/// container whose "isolated app link settings" toggle is on and which has a
/// contextId. A freshly isolated container with no stored override still gets a
/// blank-slate entry so its "replace" behaviour takes effect immediately rather
/// than silently falling back to the global policy.
///
/// The override is published under the container's base contextId **and** under
/// every active isolation context id belonging to that container: isolated tabs
/// (`tab_mode = 2`) load under their own `isolation_context_id`, which is the
/// `session.contextId` the native interceptor keys the lookup on — so without the
/// fan-out isolated tabs would silently fall back to the global policy (mirrors
/// how `computeAppLinkProtection` expands protection to isolation contexts). When
/// an isolation context is shared by several isolated-app-link containers, the
/// container with the lowest sorted base contextId wins (deterministic).
Map<String, NativeContextAppLinkPolicy> _computeContextOverrides(
Ref ref,
GeneralSettings settings,
) {
final containers =
ref.watch(watchContainersWithCountProvider).value ?? const [];
final isolationMap =
ref.watch(watchIsolatedContextContainerMapProvider).value ?? const {};
NativeContextAppLinkPolicy toNative(ContextAppLinkPolicy policy) {
return NativeContextAppLinkPolicy(
mode: policy.mode,
rules: {
for (final MapEntry(:key, :value) in policy.rules.entries)
key: _toNativeRule(value),
},
);
}
// Base contextId -> native override, plus containerId -> base contextId for the
// isolation-context fan-out below (only isolated-app-link containers).
final overrideByBaseContextId = <String, NativeContextAppLinkPolicy>{};
final baseContextIdByContainerId = <String, String>{};
for (final container in containers) {
final contextId = container.metadata.contextualIdentity;
if (contextId == null || !container.metadata.isolatedAppLinkSettings) {
continue;
}
overrideByBaseContextId[contextId] = toNative(
settings.appLinkContextOverrides[contextId] ??
ContextAppLinkPolicy.blank(),
);
baseContextIdByContainerId[container.id] = contextId;
}
final overrides = <String, NativeContextAppLinkPolicy>{
...overrideByBaseContextId,
};
for (final MapEntry(key: isolationContextId, value: containerIds)
in isolationMap.entries) {
final baseIds =
containerIds
.map((id) => baseContextIdByContainerId[id])
.nonNulls
.toList()
..sort();
if (baseIds.isEmpty) continue;
// A base contextId never collides with an isolation context id, but guard
// so a real container's own entry always wins if one ever did.
overrides.putIfAbsent(
isolationContextId,
() => overrideByBaseContextId[baseIds.first]!,
);
}
return overrides;
}
NativeAppLinkRule _toNativeRule(PersistedAppLinkRule rule) {
return NativeAppLinkRule(
decision: switch (rule.decision) {
AppLinkRuleDecision.alwaysOpen => NativeAppLinkRuleDecision.alwaysOpen,
AppLinkRuleDecision.neverOpen => NativeAppLinkRuleDecision.neverOpen,
},
scope: rule.scope,
packageName: rule.packageName,
);
}
pigeon.ProtectedTargetPattern _toNativePattern(ProtectedTargetPattern pattern) {
return pigeon.ProtectedTargetPattern(
scheme: pattern.scheme,
hostOrSuffix: pattern.hostOrSuffix,
includeSubdomains: pattern.includeSubdomains,
port: pattern.port,
);
}
/// Single serialised writer that mirrors the Dart-owned app-link policy to the
/// native profile-scoped store (§2.8), the sole policy source consulted by the
/// interceptor. Structured like `ProxySettingsReplication`; mounted from app root
/// after initialisation.
@Riverpod(keepAlive: true)
class AppLinkPolicyReplication extends _$AppLinkPolicyReplication {
final _appLinks = GeckoAppLinksService();
final _pushLock = Lock();
// Coalesces the most recent snapshot while a push is in flight; genuinely
// nullable (no snapshot pushed yet).
// ignore: use_late_for_private_fields_and_variables
AppLinkPolicySnapshot? _latest;
var _pushDirty = false;
Future<void> _queuePush(AppLinkPolicySnapshot snapshot) async {
_latest = snapshot;
_pushDirty = true;
if (_pushLock.inLock) return;
await _pushLock.synchronized(() async {
while (_pushDirty) {
_pushDirty = false;
final pending = _latest!;
try {
await _appLinks.setAppLinkPolicy(pending);
} catch (error, stackTrace) {
// `setAppLinkPolicy` before a profile is bound is an error the
// replicator retries after initialisation (§2.8).
logger.w(
'Failed to push app-link policy; will retry',
error: error,
stackTrace: stackTrace,
);
_pushDirty = true;
await Future<void>.delayed(const Duration(seconds: 1));
}
}
});
}
@override
void build() {
ref.listen(
fireImmediately: true,
appLinkPolicySnapshotProvider,
(previous, next) {
if (next == null) return;
unawaited(_queuePush(next));
},
onError: (error, stackTrace) {
logger.e(
'Error computing app-link policy snapshot',
error: error,
stackTrace: stackTrace,
);
},
);
}
}
@@ -0,0 +1,194 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'app_link_policy_replication.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Effective app-link protection (§2.3), recomputed whenever routing, strict
/// mode, contextual identities, or site assignments change.
@ProviderFor(appLinkProtection)
final appLinkProtectionProvider = AppLinkProtectionProvider._();
/// Effective app-link protection (§2.3), recomputed whenever routing, strict
/// mode, contextual identities, or site assignments change.
final class AppLinkProtectionProvider
extends
$FunctionalProvider<
AppLinkProtection,
AppLinkProtection,
AppLinkProtection
>
with $Provider<AppLinkProtection> {
/// Effective app-link protection (§2.3), recomputed whenever routing, strict
/// mode, contextual identities, or site assignments change.
AppLinkProtectionProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'appLinkProtectionProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$appLinkProtectionHash();
@$internal
@override
$ProviderElement<AppLinkProtection> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
AppLinkProtection create(Ref ref) {
return appLinkProtection(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(AppLinkProtection value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<AppLinkProtection>(value),
);
}
}
String _$appLinkProtectionHash() => r'6aab203c9b7d2f2c8a73684ea0b624e09fe6179f';
/// The complete policy snapshot to push, or null until the real persisted
/// settings have loaded (the `...WithDefaults` loading placeholder is not valid
/// input, §2.8). Combines the user-intent settings with computed protection.
@ProviderFor(appLinkPolicySnapshot)
final appLinkPolicySnapshotProvider = AppLinkPolicySnapshotProvider._();
/// The complete policy snapshot to push, or null until the real persisted
/// settings have loaded (the `...WithDefaults` loading placeholder is not valid
/// input, §2.8). Combines the user-intent settings with computed protection.
final class AppLinkPolicySnapshotProvider
extends
$FunctionalProvider<
AppLinkPolicySnapshot?,
AppLinkPolicySnapshot?,
AppLinkPolicySnapshot?
>
with $Provider<AppLinkPolicySnapshot?> {
/// The complete policy snapshot to push, or null until the real persisted
/// settings have loaded (the `...WithDefaults` loading placeholder is not valid
/// input, §2.8). Combines the user-intent settings with computed protection.
AppLinkPolicySnapshotProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'appLinkPolicySnapshotProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$appLinkPolicySnapshotHash();
@$internal
@override
$ProviderElement<AppLinkPolicySnapshot?> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
AppLinkPolicySnapshot? create(Ref ref) {
return appLinkPolicySnapshot(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(AppLinkPolicySnapshot? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<AppLinkPolicySnapshot?>(value),
);
}
}
String _$appLinkPolicySnapshotHash() =>
r'7f700b67d3b7b0b435fe82a98de455c6e374a1a2';
/// Single serialised writer that mirrors the Dart-owned app-link policy to the
/// native profile-scoped store (§2.8), the sole policy source consulted by the
/// interceptor. Structured like `ProxySettingsReplication`; mounted from app root
/// after initialisation.
@ProviderFor(AppLinkPolicyReplication)
final appLinkPolicyReplicationProvider = AppLinkPolicyReplicationProvider._();
/// Single serialised writer that mirrors the Dart-owned app-link policy to the
/// native profile-scoped store (§2.8), the sole policy source consulted by the
/// interceptor. Structured like `ProxySettingsReplication`; mounted from app root
/// after initialisation.
final class AppLinkPolicyReplicationProvider
extends $NotifierProvider<AppLinkPolicyReplication, void> {
/// Single serialised writer that mirrors the Dart-owned app-link policy to the
/// native profile-scoped store (§2.8), the sole policy source consulted by the
/// interceptor. Structured like `ProxySettingsReplication`; mounted from app root
/// after initialisation.
AppLinkPolicyReplicationProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'appLinkPolicyReplicationProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$appLinkPolicyReplicationHash();
@$internal
@override
AppLinkPolicyReplication create() => AppLinkPolicyReplication();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(void value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<void>(value),
);
}
}
String _$appLinkPolicyReplicationHash() =>
r'866e749328bef9f65c2124585d8c03d798802563';
/// Single serialised writer that mirrors the Dart-owned app-link policy to the
/// native profile-scoped store (§2.8), the sole policy source consulted by the
/// interceptor. Structured like `ProxySettingsReplication`; mounted from app root
/// after initialisation.
abstract class _$AppLinkPolicyReplication extends $Notifier<void> {
void build();
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref = this.ref as $Ref<void, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<void, void>,
void,
Object?,
Object?
>;
return element.handleCreate(ref, build);
}
}
@@ -0,0 +1,165 @@
/*
* 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_mozilla_components/flutter_mozilla_components.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/logger.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/geckoview/features/tabs/domain/providers.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
part 'app_links_coordinator.g.dart';
/// Receives the native availability signal for Flutter-owned prompts. The event
/// is optimisation-only (no buffering/replay); the store query is authoritative.
class _AppLinkEventsReceiver extends GeckoAppLinkEvents {
_AppLinkEventsReceiver(this._onAvailable);
final void Function(AppLinkPromptOwner owner) _onAvailable;
@override
void onAppLinkPromptAvailable(int sequence, AppLinkPromptOwner owner) {
_onAvailable(owner);
}
}
/// Orchestrates Flutter-owned app-link prompts (§2.6): registers the availability
/// event handler, queries the native pending store on attach/resume/event, and
/// exposes resolution (including the remember-then-resolve flow). The presented
/// list is authoritative from the query and deduped by `requestId` — the event
/// is only a nudge to re-query.
@Riverpod(keepAlive: true)
class AppLinksCoordinator extends _$AppLinksCoordinator {
final _service = GeckoAppLinksService();
@override
List<AppLinkPromptRequest> build() {
final receiver = _AppLinkEventsReceiver((owner) {
if (owner == AppLinkPromptOwner.flutterBrowser) {
// ignore: discarded_futures
refresh();
}
});
GeckoAppLinkEvents.setUp(receiver);
ref.onDispose(() => GeckoAppLinkEvents.setUp(null));
// Initial query; the returned future updates state when it completes.
// ignore: discarded_futures
refresh();
return const [];
}
/// Re-query the native pending store (called on attach, lifecycle resume, and
/// when the availability event fires).
Future<void> refresh() async {
try {
final prompts = await _service.getPendingAppLinkPrompts(
AppLinkPromptOwner.flutterBrowser,
);
logger.i(
'app-link refresh -> ${prompts.length} prompt(s): '
'${prompts.map((p) => '${p.requestId}@${p.tabId}(${p.isModal ? 'modal' : 'banner'})').toList()}',
);
state = prompts;
} catch (error, stackTrace) {
logger.w(
'Failed to query pending app-link prompts',
error: error,
stackTrace: stackTrace,
);
}
}
/// Resolve a pending prompt and re-query.
Future<AppLinkResolutionResult> resolve(
int requestId,
AppLinkDecision decision,
) async {
final result = await _service.resolvePendingAppLink(requestId, decision);
await refresh();
return result;
}
/// Remember-then-resolve (§2.6): persist the rule to `GeneralSettings` first so
/// it is replicated to native, then resolve the still-pending request.
///
/// [contextId] is the source tab's live contextId (from the prompt request) —
/// the container's base contextId for a regular tab, or the tab's
/// `isolation_context_id` for an isolated tab. When it resolves to a container
/// with "isolated app link settings" enabled, the rule is written to that
/// container's own override bucket (`appLinkContextOverrides`, keyed by the
/// container's base contextId) rather than the global [GeneralSettings.appLinkRules],
/// keeping the two rule sets separate (replace semantics).
Future<AppLinkResolutionResult> resolveWithRule(
int requestId,
AppLinkDecision decision,
PersistedAppLinkRule rule, {
String? contextId,
}) async {
final overrideKey = await _overrideKeyForContext(contextId);
await ref.read(generalSettingsRepositoryProvider.notifier).updateSettings((
current,
) {
if (overrideKey != null) {
final existing =
current.appLinkContextOverrides[overrideKey] ??
ContextAppLinkPolicy.blank();
final updated = existing.copyWith.rules({
...existing.rules,
rule.scope: rule,
});
return current.copyWith.appLinkContextOverrides({
...current.appLinkContextOverrides,
overrideKey: updated,
});
}
return current.copyWith.appLinkRules({
...current.appLinkRules,
rule.scope: rule,
});
});
return resolve(requestId, decision);
}
/// Resolve the source tab's live [contextId] to the override storage key — the
/// base contextId of the owning isolated-app-link container — or null to write
/// globally. Handles both a regular tab (contextId is already the container
/// base) and an isolated tab (contextId is an `isolation_context_id` mapping to
/// its container). Delegates to [resolveAppLinkOverrideKey] so writes land in
/// the bucket that is published back to native.
Future<String?> _overrideKeyForContext(String? contextId) async {
if (contextId == null) return null;
final containers = await ref.read(watchContainersWithCountProvider.future);
final isolationMap = await ref.read(
watchIsolatedContextContainerMapProvider.future,
);
return resolveAppLinkOverrideKey(
liveContextId: contextId,
containers: containers,
isolationContextContainerMap: isolationMap,
);
}
}
@@ -0,0 +1,90 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'app_links_coordinator.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Orchestrates Flutter-owned app-link prompts (§2.6): registers the availability
/// event handler, queries the native pending store on attach/resume/event, and
/// exposes resolution (including the remember-then-resolve flow). The presented
/// list is authoritative from the query and deduped by `requestId` — the event
/// is only a nudge to re-query.
@ProviderFor(AppLinksCoordinator)
final appLinksCoordinatorProvider = AppLinksCoordinatorProvider._();
/// Orchestrates Flutter-owned app-link prompts (§2.6): registers the availability
/// event handler, queries the native pending store on attach/resume/event, and
/// exposes resolution (including the remember-then-resolve flow). The presented
/// list is authoritative from the query and deduped by `requestId` — the event
/// is only a nudge to re-query.
final class AppLinksCoordinatorProvider
extends $NotifierProvider<AppLinksCoordinator, List<AppLinkPromptRequest>> {
/// Orchestrates Flutter-owned app-link prompts (§2.6): registers the availability
/// event handler, queries the native pending store on attach/resume/event, and
/// exposes resolution (including the remember-then-resolve flow). The presented
/// list is authoritative from the query and deduped by `requestId` — the event
/// is only a nudge to re-query.
AppLinksCoordinatorProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'appLinksCoordinatorProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$appLinksCoordinatorHash();
@$internal
@override
AppLinksCoordinator create() => AppLinksCoordinator();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(List<AppLinkPromptRequest> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<List<AppLinkPromptRequest>>(value),
);
}
}
String _$appLinksCoordinatorHash() =>
r'183fc7ac1264a63c24b1d10f4a22cbfbf6046da7';
/// Orchestrates Flutter-owned app-link prompts (§2.6): registers the availability
/// event handler, queries the native pending store on attach/resume/event, and
/// exposes resolution (including the remember-then-resolve flow). The presented
/// list is authoritative from the query and deduped by `requestId` — the event
/// is only a nudge to re-query.
abstract class _$AppLinksCoordinator
extends $Notifier<List<AppLinkPromptRequest>> {
List<AppLinkPromptRequest> build();
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref =
this.ref
as $Ref<List<AppLinkPromptRequest>, List<AppLinkPromptRequest>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<
List<AppLinkPromptRequest>,
List<AppLinkPromptRequest>
>,
List<AppLinkPromptRequest>,
Object?,
Object?
>;
return element.handleCreate(ref, build);
}
}
@@ -0,0 +1,151 @@
/*
* 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:fast_equatable/fast_equatable.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
show AppLinksMode;
import 'package:riverpod_annotation/riverpod_annotation.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/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
part 'effective_app_link_policy.g.dart';
/// Resolve a tab's live contextId to the app-link override storage key — the
/// base contextId of the container with "isolated app link settings" enabled
/// that governs the tab — or null when the global mode + rules apply.
///
/// [liveContextId] is the container's base contextId for a regular tab, or the
/// tab's `isolation_context_id` for an isolated tab (i.e. `TabState.contextId`
/// as reported by the engine). Uses the same lowest-sorted-base tiebreak as the
/// snapshot builder so lookups land on the bucket that is published to native.
String? resolveAppLinkOverrideKey({
required String? liveContextId,
required List<ContainerDataWithCount> containers,
required Map<String, Set<String>> isolationContextContainerMap,
}) {
if (liveContextId == null) return null;
// Regular tab: liveContextId is a container's own base contextId.
for (final container in containers) {
if (container.metadata.contextualIdentity == liveContextId) {
return container.metadata.isolatedAppLinkSettings ? liveContextId : null;
}
}
// Isolated tab: liveContextId is an isolation context shared by one or more
// containers; pick the isolated-app-link one with the lowest base contextId.
final containerIds = isolationContextContainerMap[liveContextId];
if (containerIds == null || containerIds.isEmpty) return null;
final byId = {for (final container in containers) container.id: container};
final baseIds =
containerIds
.map((id) => byId[id])
.nonNulls
.where(
(container) =>
container.metadata.isolatedAppLinkSettings &&
container.metadata.contextualIdentity != null,
)
.map((container) => container.metadata.contextualIdentity!)
.toList()
..sort();
return baseIds.isEmpty ? null : baseIds.first;
}
/// The app-link policy effectively governing a tab: the global mode + rules,
/// or the owning container's override when it has isolated app-link settings
/// (replace semantics). Used by the site settings sheet to display and edit
/// the settings in the bucket that actually applies to the shown tab.
class EffectiveAppLinkPolicy with FastEquatable {
/// The override storage key (container base contextId), or null when the
/// global bucket governs the tab.
final String? overrideKey;
/// Display name of the governing container; null when global.
final String? containerName;
/// The effective open-links-in-apps mode.
final AppLinksMode mode;
/// The effective remembered rules, keyed by canonical scope
/// (`host:<host>` | `pkg:<package>`).
final Map<String, PersistedAppLinkRule> rules;
EffectiveAppLinkPolicy({
required this.overrideKey,
required this.containerName,
required this.mode,
required this.rules,
});
/// Whether the tab is governed by a container override (true) or the global
/// bucket (false).
bool get isContainerScoped => overrideKey != null;
@override
List<Object?> get hashParameters => [overrideKey, containerName, mode, rules];
}
/// Compute the [EffectiveAppLinkPolicy] for a tab's live contextId. Returns
/// null until the container/isolation inputs have loaded — resolving against
/// empty placeholders could misattribute an isolated container's tab to the
/// global bucket, so callers show a loading state instead.
@Riverpod()
EffectiveAppLinkPolicy? effectiveAppLinkPolicy(Ref ref, String? liveContextId) {
final settings = ref.watch(generalSettingsWithDefaultsProvider);
final containers = ref.watch(watchContainersWithCountProvider).value;
final isolationMap = ref
.watch(watchIsolatedContextContainerMapProvider)
.value;
if (containers == null || isolationMap == null) return null;
final overrideKey = resolveAppLinkOverrideKey(
liveContextId: liveContextId,
containers: containers,
isolationContextContainerMap: isolationMap,
);
if (overrideKey == null) {
return EffectiveAppLinkPolicy(
overrideKey: null,
containerName: null,
mode: settings.appLinksMode,
rules: settings.appLinkRules,
);
}
final override =
settings.appLinkContextOverrides[overrideKey] ??
ContextAppLinkPolicy.blank();
final containerName = containers
.where((c) => c.metadata.contextualIdentity == overrideKey)
.firstOrNull
?.name;
return EffectiveAppLinkPolicy(
overrideKey: overrideKey,
containerName: containerName,
mode: override.mode,
rules: override.rules,
);
}
@@ -0,0 +1,118 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'effective_app_link_policy.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Compute the [EffectiveAppLinkPolicy] for a tab's live contextId. Returns
/// null until the container/isolation inputs have loaded — resolving against
/// empty placeholders could misattribute an isolated container's tab to the
/// global bucket, so callers show a loading state instead.
@ProviderFor(effectiveAppLinkPolicy)
final effectiveAppLinkPolicyProvider = EffectiveAppLinkPolicyFamily._();
/// Compute the [EffectiveAppLinkPolicy] for a tab's live contextId. Returns
/// null until the container/isolation inputs have loaded — resolving against
/// empty placeholders could misattribute an isolated container's tab to the
/// global bucket, so callers show a loading state instead.
final class EffectiveAppLinkPolicyProvider
extends
$FunctionalProvider<
EffectiveAppLinkPolicy?,
EffectiveAppLinkPolicy?,
EffectiveAppLinkPolicy?
>
with $Provider<EffectiveAppLinkPolicy?> {
/// Compute the [EffectiveAppLinkPolicy] for a tab's live contextId. Returns
/// null until the container/isolation inputs have loaded — resolving against
/// empty placeholders could misattribute an isolated container's tab to the
/// global bucket, so callers show a loading state instead.
EffectiveAppLinkPolicyProvider._({
required EffectiveAppLinkPolicyFamily super.from,
required String? super.argument,
}) : super(
retry: null,
name: r'effectiveAppLinkPolicyProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$effectiveAppLinkPolicyHash();
@override
String toString() {
return r'effectiveAppLinkPolicyProvider'
''
'($argument)';
}
@$internal
@override
$ProviderElement<EffectiveAppLinkPolicy?> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
EffectiveAppLinkPolicy? create(Ref ref) {
final argument = this.argument as String?;
return effectiveAppLinkPolicy(ref, argument);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(EffectiveAppLinkPolicy? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<EffectiveAppLinkPolicy?>(value),
);
}
@override
bool operator ==(Object other) {
return other is EffectiveAppLinkPolicyProvider &&
other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$effectiveAppLinkPolicyHash() =>
r'da8101e842a9cf516eb18d817560813bc0cc94f0';
/// Compute the [EffectiveAppLinkPolicy] for a tab's live contextId. Returns
/// null until the container/isolation inputs have loaded — resolving against
/// empty placeholders could misattribute an isolated container's tab to the
/// global bucket, so callers show a loading state instead.
final class EffectiveAppLinkPolicyFamily extends $Family
with $FunctionalFamilyOverride<EffectiveAppLinkPolicy?, String?> {
EffectiveAppLinkPolicyFamily._()
: super(
retry: null,
name: r'effectiveAppLinkPolicyProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
/// Compute the [EffectiveAppLinkPolicy] for a tab's live contextId. Returns
/// null until the container/isolation inputs have loaded — resolving against
/// empty placeholders could misattribute an isolated container's tab to the
/// global bucket, so callers show a loading state instead.
EffectiveAppLinkPolicyProvider call(String? liveContextId) =>
EffectiveAppLinkPolicyProvider._(argument: liveContextId, from: this);
@override
String toString() => r'effectiveAppLinkPolicyProvider';
}
@@ -0,0 +1,343 @@
/*
* 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/>.
*/
/// Shared, pure routing-resolution model (see APP_LINKS_OWN_IMPLEMENTATION_PLAN.md
/// §2.3). Owned by neither `ProxySettingsReplication` nor app-link protection —
/// both consume it so there is exactly one notion of "how is this container
/// routed" and "is this tab effectively proxied".
library;
import 'package:fast_equatable/fast_equatable.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/site_assignment.dart';
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
/// How a container (or isolation context) is routed after resolving its own
/// proxy settings — before inheriting/aliasing.
sealed class ProxyAssignment with FastEquatable {
ProxyAssignment();
/// Follows the global (`general`) routing.
factory ProxyAssignment.inherit() = InheritProxyAssignment;
/// Explicitly bypasses the global proxy (direct connection), scoped to [scopeId].
factory ProxyAssignment.direct(String scopeId) = DirectProxyAssignment;
/// Routed through the proxy identified by [proxyId].
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 => ['direct', scopeId];
}
final class ExplicitProxyAssignment extends ProxyAssignment {
final String proxyId;
ExplicitProxyAssignment(this.proxyId);
@override
List<Object?> get hashParameters => ['explicit', proxyId];
}
/// Resolve a single container's routing from its own metadata fields.
///
/// - a set [proxyConnectionId] → `explicit`
/// - no proxy but [bypassGlobalProxy] → `direct` scoped to [contextId]
/// - otherwise → `inherit`
///
/// This is the one place the per-container `proxyConnectionId`/`bypassGlobalProxy`
/// precedence lives; the proxy replicator and app-link protection both call it.
ProxyAssignment resolveContainerAssignment({
required String contextId,
required ProxyConnectionId? proxyConnectionId,
required bool bypassGlobalProxy,
}) {
return switch (proxyConnectionId) {
final proxyId? => ProxyAssignment.explicit(proxyId.encode()),
null when bypassGlobalProxy => ProxyAssignment.direct(contextId),
null => ProxyAssignment.inherit(),
};
}
/// The result of collapsing the (possibly conflicting) routing of the containers
/// that share an isolation context into a single alias.
class IsolationContextRouting {
/// The assignment the isolation context aliases to.
final ProxyAssignment chosen;
/// Human-readable label for [chosen] (used in the conflict warning).
final String chosenLabel;
/// The distinct assignment labels observed, ordered `inherit`, `direct:*`,
/// then proxy ids — used to describe conflicts.
final List<String> assignmentLabels;
/// Number of distinct assignments; `> 1` means the containers disagree.
final int distinctAssignmentCount;
IsolationContextRouting({
required this.chosen,
required this.chosenLabel,
required this.assignmentLabels,
required this.distinctAssignmentCount,
});
}
/// Collapse the routing of the containers sharing one isolation context.
///
/// Precedence: any explicit proxy wins (lowest sorted id); else a direct
/// connection wins only if no container inherits; else inherit.
IsolationContextRouting resolveIsolationContextRouting(
Iterable<ProxyAssignment> assignments,
) {
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 chosen = proxyIds.isNotEmpty
? ProxyAssignment.explicit(proxyIds.first)
: directScopeIds.isNotEmpty && !hasInheritedAssignment
? ProxyAssignment.direct(directScopeIds.first)
: ProxyAssignment.inherit();
final chosenLabel = switch (chosen) {
DirectProxyAssignment(:final scopeId) => 'direct:$scopeId',
ExplicitProxyAssignment(:final proxyId) => proxyId,
InheritProxyAssignment() => 'inherit',
};
return IsolationContextRouting(
chosen: chosen,
chosenLabel: chosenLabel,
assignmentLabels: [
if (hasInheritedAssignment) 'inherit',
...directScopeIds.map((id) => 'direct:$id'),
...proxyIds,
],
distinctAssignmentCount:
proxyIds.length +
directScopeIds.length +
(hasInheritedAssignment ? 1 : 0),
);
}
/// Whether a tab whose container resolves to [assignment] is effectively
/// proxied — the app-link "protected context" test (§2.3).
///
/// - `explicit` → proxied
/// - `direct` → never proxied (deliberately bypasses the global proxy)
/// - `inherit` → proxied iff the global (`general`) route is a proxy
bool isAssignmentProtected(
ProxyAssignment assignment, {
required bool protectGeneralContext,
}) {
return switch (assignment) {
ExplicitProxyAssignment() => true,
DirectProxyAssignment() => false,
InheritProxyAssignment() => protectGeneralContext,
};
}
/// A target-side protection pattern (§2.3/§2.8). Any navigation target assigned
/// to an effectively-proxied or strict container is protected independent of the
/// source tab, because site assignment moves the URL into its container
/// *asynchronously*, after the navigation.
class ProtectedTargetPattern with FastEquatable {
final String scheme;
final String hostOrSuffix;
final bool includeSubdomains;
/// Effective port for exact entries; null for wildcard entries (which ignore
/// port), preserving [siteAssignmentMatches] semantics.
final int? port;
ProtectedTargetPattern({
required this.scheme,
required this.hostOrSuffix,
required this.includeSubdomains,
required this.port,
});
@override
List<Object?> get hashParameters => [
scheme,
hostOrSuffix,
includeSubdomains,
port,
];
}
/// Build the [ProtectedTargetPattern] for a single site-assignment [Uri],
/// preserving [siteAssignmentMatches] semantics: wildcard (`*.host`) entries
/// match apex+subdomains for the scheme and ignore port; exact entries compare
/// scheme + origin (including effective port).
ProtectedTargetPattern protectedTargetPatternForSite(Uri assignedSite) {
if (isWildcardSite(assignedSite)) {
return ProtectedTargetPattern(
scheme: assignedSite.scheme,
hostOrSuffix: assignedSite.host.substring('*.'.length),
includeSubdomains: true,
port: null,
);
}
return ProtectedTargetPattern(
scheme: assignedSite.scheme,
hostOrSuffix: assignedSite.host,
includeSubdomains: false,
// Uri.port yields the effective port (scheme default when unspecified), so
// exact entries preserve the effective port as `siteAssignmentMatches` does
// via origin comparison.
port: assignedSite.port,
);
}
/// Compute the protected target patterns from all site assignments, keeping only
/// those whose container [contextualIdentity] is effectively proxied or strict
/// ([protectedOrStrictContextIds]). Deduplicated.
List<ProtectedTargetPattern> computeProtectedTargetPatterns({
required Iterable<SiteAssignment> assignments,
required Set<String> protectedOrStrictContextIds,
}) {
final patterns = <ProtectedTargetPattern>{};
for (final assignment in assignments) {
final contextId = assignment.contextualIdentity;
if (contextId == null) continue;
if (!protectedOrStrictContextIds.contains(contextId)) continue;
patterns.add(protectedTargetPatternForSite(assignment.assignedSite));
}
return patterns.toList();
}
/// The complete app-link protection view (§2.3) replicated to native.
class AppLinkProtection with FastEquatable {
/// Regular / no-contextId tabs are proxied via the `general` scope.
final bool protectGeneralContext;
/// contextIds (containers and isolation contexts) that resolve to a proxy.
final Set<String> protectedContextIds;
/// strictMode-enforced contextIds, independent of routing.
final Set<String> strictContextIds;
final List<ProtectedTargetPattern> protectedTargetPatterns;
AppLinkProtection({
required this.protectGeneralContext,
required this.protectedContextIds,
required this.strictContextIds,
required this.protectedTargetPatterns,
});
@override
List<Object?> get hashParameters => [
protectGeneralContext,
protectedContextIds,
strictContextIds,
protectedTargetPatterns,
];
}
/// Pure protection computation from the routing/container/assignment inputs
/// (§2.3). A container's contextId is protected when its effective assignment is
/// proxied; an isolation context is protected when the alias it collapses to is
/// proxied; strict contexts are always protected. Target patterns cover any site
/// assigned to a protected or strict container.
AppLinkProtection computeAppLinkProtection({
required bool protectGeneralContext,
required Iterable<ContainerData> containers,
required Map<String, Set<String>> isolationContextContainerMap,
required Set<String> strictContextIds,
required Iterable<SiteAssignment> siteAssignments,
}) {
final assignmentByContextId = <String, ProxyAssignment>{};
final assignmentByContainerId = <String, ProxyAssignment>{};
for (final container in containers) {
final contextId = container.metadata.contextualIdentity;
if (contextId == null || contextId.isEmpty) continue;
final assignment = resolveContainerAssignment(
contextId: contextId,
proxyConnectionId: container.metadata.proxyConnectionId,
bypassGlobalProxy: container.metadata.bypassGlobalProxy,
);
assignmentByContextId[contextId] = assignment;
assignmentByContainerId[container.id] = assignment;
}
final protectedContextIds = <String>{};
for (final MapEntry(:key, :value) in assignmentByContextId.entries) {
if (isAssignmentProtected(value, protectGeneralContext: protectGeneralContext)) {
protectedContextIds.add(key);
}
}
for (final MapEntry(:key, :value) in isolationContextContainerMap.entries) {
final assignments = value
.map((containerId) => assignmentByContainerId[containerId])
.nonNulls
.toList();
if (assignments.isEmpty) continue;
final chosen = resolveIsolationContextRouting(assignments).chosen;
if (isAssignmentProtected(chosen, protectGeneralContext: protectGeneralContext)) {
protectedContextIds.add(key);
}
}
final protectedOrStrict = {...protectedContextIds, ...strictContextIds};
final patterns = computeProtectedTargetPatterns(
assignments: siteAssignments,
protectedOrStrictContextIds: protectedOrStrict,
);
return AppLinkProtection(
protectGeneralContext: protectGeneralContext,
protectedContextIds: protectedContextIds,
strictContextIds: strictContextIds,
protectedTargetPatterns: patterns,
);
}