app links initial
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* 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:copy_with_extension/copy_with_extension.dart';
|
||||
import 'package:fast_equatable/fast_equatable.dart';
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'app_link_rule.g.dart';
|
||||
|
||||
enum AppLinkRuleDecision { alwaysOpen, neverOpen }
|
||||
|
||||
/// A remembered per-scope app-link rule (persistence contract, §2.5/§2.9).
|
||||
///
|
||||
/// Stored in `GeneralSettings.appLinkRules` as `Map<String, PersistedAppLinkRule>`
|
||||
/// keyed by [scope] — one canonical rule per scope, upsert/last-write-wins. The
|
||||
/// [scope] is a native-owned canonical key (`host:youtube.com` | `pkg:...`) that
|
||||
/// Dart persists opaquely and never reconstructs.
|
||||
@CopyWith()
|
||||
@JsonSerializable()
|
||||
class PersistedAppLinkRule with FastEquatable {
|
||||
final AppLinkRuleDecision decision;
|
||||
|
||||
/// Canonical scope key: `host:<host>` or `pkg:<package>`.
|
||||
final String scope;
|
||||
|
||||
/// Resolved package name. Required for [AppLinkRuleDecision.alwaysOpen]
|
||||
/// (binds the launch target); null for [AppLinkRuleDecision.neverOpen].
|
||||
final String? packageName;
|
||||
|
||||
PersistedAppLinkRule({
|
||||
required this.decision,
|
||||
required this.scope,
|
||||
this.packageName,
|
||||
});
|
||||
|
||||
factory PersistedAppLinkRule.fromJson(Map<String, dynamic> json) =>
|
||||
_$PersistedAppLinkRuleFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$PersistedAppLinkRuleToJson(this);
|
||||
|
||||
/// Whether this rule is internally consistent: an `alwaysOpen` rule must bind
|
||||
/// a package; the scope must be a recognised canonical key.
|
||||
bool get isValid {
|
||||
if (scope.isEmpty) return false;
|
||||
final hasKnownPrefix =
|
||||
scope.startsWith('host:') || scope.startsWith('pkg:');
|
||||
if (!hasKnownPrefix) return false;
|
||||
if (decision == AppLinkRuleDecision.alwaysOpen &&
|
||||
(packageName == null || packageName!.isEmpty)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get hashParameters => [decision, scope, packageName];
|
||||
}
|
||||
|
||||
/// Parse the persisted rule map, dropping malformed rules (with a warning) and
|
||||
/// entries whose map key disagrees with the rule's own scope (§2.9).
|
||||
Map<String, PersistedAppLinkRule> parseAppLinkRules(
|
||||
Map<String, dynamic>? json,
|
||||
) {
|
||||
if (json == null) return const {};
|
||||
final result = <String, PersistedAppLinkRule>{};
|
||||
for (final MapEntry(:key, :value) in json.entries) {
|
||||
if (value is! Map<String, dynamic>) continue;
|
||||
final PersistedAppLinkRule rule;
|
||||
try {
|
||||
rule = PersistedAppLinkRule.fromJson(value);
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
if (rule.scope != key) continue;
|
||||
if (!rule.isValid) continue;
|
||||
result[key] = rule;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'app_link_rule.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// CopyWithGenerator
|
||||
// **************************************************************************
|
||||
|
||||
abstract class _$PersistedAppLinkRuleCWProxy {
|
||||
PersistedAppLinkRule decision(AppLinkRuleDecision decision);
|
||||
|
||||
PersistedAppLinkRule scope(String scope);
|
||||
|
||||
PersistedAppLinkRule packageName(String? packageName);
|
||||
|
||||
/// 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 `PersistedAppLinkRule(...).copyWith.fieldName(value)`.
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// PersistedAppLinkRule(...).copyWith(id: 12, name: "My name")
|
||||
/// ```
|
||||
PersistedAppLinkRule call({
|
||||
AppLinkRuleDecision decision,
|
||||
String scope,
|
||||
String? packageName,
|
||||
});
|
||||
}
|
||||
|
||||
/// Callable proxy for `copyWith` functionality.
|
||||
/// Use as `instanceOfPersistedAppLinkRule.copyWith(...)` or call `instanceOfPersistedAppLinkRule.copyWith.fieldName(value)` for a single field.
|
||||
class _$PersistedAppLinkRuleCWProxyImpl
|
||||
implements _$PersistedAppLinkRuleCWProxy {
|
||||
const _$PersistedAppLinkRuleCWProxyImpl(this._value);
|
||||
|
||||
final PersistedAppLinkRule _value;
|
||||
|
||||
@override
|
||||
PersistedAppLinkRule decision(AppLinkRuleDecision decision) =>
|
||||
call(decision: decision);
|
||||
|
||||
@override
|
||||
PersistedAppLinkRule scope(String scope) => call(scope: scope);
|
||||
|
||||
@override
|
||||
PersistedAppLinkRule packageName(String? packageName) =>
|
||||
call(packageName: packageName);
|
||||
|
||||
@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 `PersistedAppLinkRule(...).copyWith.fieldName(value)`.
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// PersistedAppLinkRule(...).copyWith(id: 12, name: "My name")
|
||||
/// ```
|
||||
PersistedAppLinkRule call({
|
||||
Object? decision = const $CopyWithPlaceholder(),
|
||||
Object? scope = const $CopyWithPlaceholder(),
|
||||
Object? packageName = const $CopyWithPlaceholder(),
|
||||
}) {
|
||||
return PersistedAppLinkRule(
|
||||
decision: decision == const $CopyWithPlaceholder() || decision == null
|
||||
? _value.decision
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: decision as AppLinkRuleDecision,
|
||||
scope: scope == const $CopyWithPlaceholder() || scope == null
|
||||
? _value.scope
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: scope as String,
|
||||
packageName: packageName == const $CopyWithPlaceholder()
|
||||
? _value.packageName
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: packageName as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension $PersistedAppLinkRuleCopyWith on PersistedAppLinkRule {
|
||||
/// Returns a callable class used to build a new instance with modified fields.
|
||||
/// Example: `instanceOfPersistedAppLinkRule.copyWith(...)` or `instanceOfPersistedAppLinkRule.copyWith.fieldName(...)`.
|
||||
// ignore: library_private_types_in_public_api
|
||||
_$PersistedAppLinkRuleCWProxy get copyWith =>
|
||||
_$PersistedAppLinkRuleCWProxyImpl(this);
|
||||
}
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
PersistedAppLinkRule _$PersistedAppLinkRuleFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => PersistedAppLinkRule(
|
||||
decision: $enumDecode(_$AppLinkRuleDecisionEnumMap, json['decision']),
|
||||
scope: json['scope'] as String,
|
||||
packageName: json['packageName'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$PersistedAppLinkRuleToJson(
|
||||
PersistedAppLinkRule instance,
|
||||
) => <String, dynamic>{
|
||||
'decision': _$AppLinkRuleDecisionEnumMap[instance.decision]!,
|
||||
'scope': instance.scope,
|
||||
'packageName': instance.packageName,
|
||||
};
|
||||
|
||||
const _$AppLinkRuleDecisionEnumMap = {
|
||||
AppLinkRuleDecision.alwaysOpen: 'alwaysOpen',
|
||||
AppLinkRuleDecision.neverOpen: 'neverOpen',
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* 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:copy_with_extension/copy_with_extension.dart';
|
||||
import 'package:fast_equatable/fast_equatable.dart';
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
|
||||
show AppLinksMode;
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart';
|
||||
|
||||
part 'context_app_link_policy.g.dart';
|
||||
|
||||
/// A container's self-contained app-link policy, used when the container has
|
||||
/// "isolated app link settings" enabled (replace semantics — it fully takes the
|
||||
/// place of the global mode + rules for navigations in that container).
|
||||
///
|
||||
/// Stored in `GeneralSettings.appLinkContextOverrides` keyed by the container's
|
||||
/// Gecko contextId (`contextualIdentity`). Only isolated containers have an
|
||||
/// entry; the snapshot builder synthesises a blank-slate default for a freshly
|
||||
/// isolated container that has not customised anything yet.
|
||||
@CopyWith()
|
||||
@JsonSerializable()
|
||||
class ContextAppLinkPolicy with FastEquatable {
|
||||
/// The container's own global app-links mode (default [AppLinksMode.ask]).
|
||||
final AppLinksMode mode;
|
||||
|
||||
/// The container's own remembered per-scope rules, keyed by canonical scope
|
||||
/// (`host:youtube.com` | `pkg:...`). Same shape/validation as the global
|
||||
/// [GeneralSettings.appLinkRules]; malformed entries are dropped on read.
|
||||
@JsonKey(fromJson: parseAppLinkRules)
|
||||
final Map<String, PersistedAppLinkRule> rules;
|
||||
|
||||
ContextAppLinkPolicy({required this.mode, required this.rules});
|
||||
|
||||
/// The blank-slate policy a container starts from when it is first isolated.
|
||||
ContextAppLinkPolicy.blank() : this(mode: AppLinksMode.ask, rules: const {});
|
||||
|
||||
factory ContextAppLinkPolicy.fromJson(Map<String, dynamic> json) =>
|
||||
_$ContextAppLinkPolicyFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$ContextAppLinkPolicyToJson(this);
|
||||
|
||||
@override
|
||||
List<Object?> get hashParameters => [mode, rules];
|
||||
}
|
||||
|
||||
/// Parse the persisted override map, dropping malformed entries (§2.9 style).
|
||||
/// Keys are contextIds; the interceptor only ever consults entries whose
|
||||
/// contextId belongs to a currently-isolated container, so an orphaned entry
|
||||
/// (container deleted / isolation turned off) is inert.
|
||||
Map<String, ContextAppLinkPolicy> parseAppLinkContextOverrides(
|
||||
Map<String, dynamic>? json,
|
||||
) {
|
||||
if (json == null) return const {};
|
||||
final result = <String, ContextAppLinkPolicy>{};
|
||||
for (final MapEntry(:key, :value) in json.entries) {
|
||||
if (value is! Map<String, dynamic>) continue;
|
||||
try {
|
||||
result[key] = ContextAppLinkPolicy.fromJson(value);
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'context_app_link_policy.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// CopyWithGenerator
|
||||
// **************************************************************************
|
||||
|
||||
abstract class _$ContextAppLinkPolicyCWProxy {
|
||||
ContextAppLinkPolicy mode(AppLinksMode mode);
|
||||
|
||||
ContextAppLinkPolicy rules(Map<String, PersistedAppLinkRule> rules);
|
||||
|
||||
/// 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 `ContextAppLinkPolicy(...).copyWith.fieldName(value)`.
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// ContextAppLinkPolicy(...).copyWith(id: 12, name: "My name")
|
||||
/// ```
|
||||
ContextAppLinkPolicy call({
|
||||
AppLinksMode mode,
|
||||
Map<String, PersistedAppLinkRule> rules,
|
||||
});
|
||||
}
|
||||
|
||||
/// Callable proxy for `copyWith` functionality.
|
||||
/// Use as `instanceOfContextAppLinkPolicy.copyWith(...)` or call `instanceOfContextAppLinkPolicy.copyWith.fieldName(value)` for a single field.
|
||||
class _$ContextAppLinkPolicyCWProxyImpl
|
||||
implements _$ContextAppLinkPolicyCWProxy {
|
||||
const _$ContextAppLinkPolicyCWProxyImpl(this._value);
|
||||
|
||||
final ContextAppLinkPolicy _value;
|
||||
|
||||
@override
|
||||
ContextAppLinkPolicy mode(AppLinksMode mode) => call(mode: mode);
|
||||
|
||||
@override
|
||||
ContextAppLinkPolicy rules(Map<String, PersistedAppLinkRule> rules) =>
|
||||
call(rules: rules);
|
||||
|
||||
@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 `ContextAppLinkPolicy(...).copyWith.fieldName(value)`.
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// ContextAppLinkPolicy(...).copyWith(id: 12, name: "My name")
|
||||
/// ```
|
||||
ContextAppLinkPolicy call({
|
||||
Object? mode = const $CopyWithPlaceholder(),
|
||||
Object? rules = const $CopyWithPlaceholder(),
|
||||
}) {
|
||||
return ContextAppLinkPolicy(
|
||||
mode: mode == const $CopyWithPlaceholder() || mode == null
|
||||
? _value.mode
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: mode as AppLinksMode,
|
||||
rules: rules == const $CopyWithPlaceholder() || rules == null
|
||||
? _value.rules
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: rules as Map<String, PersistedAppLinkRule>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension $ContextAppLinkPolicyCopyWith on ContextAppLinkPolicy {
|
||||
/// Returns a callable class used to build a new instance with modified fields.
|
||||
/// Example: `instanceOfContextAppLinkPolicy.copyWith(...)` or `instanceOfContextAppLinkPolicy.copyWith.fieldName(...)`.
|
||||
// ignore: library_private_types_in_public_api
|
||||
_$ContextAppLinkPolicyCWProxy get copyWith =>
|
||||
_$ContextAppLinkPolicyCWProxyImpl(this);
|
||||
}
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
ContextAppLinkPolicy _$ContextAppLinkPolicyFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => ContextAppLinkPolicy(
|
||||
mode: $enumDecode(_$AppLinksModeEnumMap, json['mode']),
|
||||
rules: parseAppLinkRules(json['rules'] as Map<String, dynamic>?),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ContextAppLinkPolicyToJson(
|
||||
ContextAppLinkPolicy instance,
|
||||
) => <String, dynamic>{
|
||||
'mode': _$AppLinksModeEnumMap[instance.mode]!,
|
||||
'rules': instance.rules.map((k, e) => MapEntry(k, e.toJson())),
|
||||
};
|
||||
|
||||
const _$AppLinksModeEnumMap = {
|
||||
AppLinksMode.always: 'always',
|
||||
AppLinksMode.ask: 'ask',
|
||||
AppLinksMode.never: 'never',
|
||||
};
|
||||
+273
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+194
@@ -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,
|
||||
);
|
||||
}
|
||||
+118
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* 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_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/features/app_links/domain/services/app_links_coordinator.dart';
|
||||
import 'package:weblibre/features/app_links/presentation/widgets/app_link_prompt_dialog.dart';
|
||||
|
||||
/// Non-modal banner for an http(s) app link (§2.2). The page is allowed to load
|
||||
/// while the banner is up; nothing blocks on it. Declining leaves the page
|
||||
/// loaded; choosing the app leaves the tab on the committed page.
|
||||
class AppLinkOpenBanner extends HookConsumerWidget {
|
||||
final AppLinkPromptRequest request;
|
||||
|
||||
const AppLinkOpenBanner({super.key, required this.request});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final target = request.target;
|
||||
final appName = target.appName;
|
||||
final remember = useState(false);
|
||||
final coordinator = ref.read(appLinksCoordinatorProvider.notifier);
|
||||
final theme = Theme.of(context);
|
||||
|
||||
Future<void> resolve(AppLinkDecision decision) async {
|
||||
if (remember.value && request.canRemember) {
|
||||
final rule = decision == AppLinkDecision.open
|
||||
? alwaysOpenRuleFor(target)
|
||||
: neverOpenRuleFor(target);
|
||||
if (rule != null) {
|
||||
await coordinator.resolveWithRule(
|
||||
request.requestId,
|
||||
decision,
|
||||
rule,
|
||||
contextId: request.contextId,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await coordinator.resolve(request.requestId, decision);
|
||||
}
|
||||
|
||||
return Material(
|
||||
elevation: 3,
|
||||
color: theme.colorScheme.surfaceContainerHigh,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.open_in_new, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
appName != null
|
||||
? 'Open this link in $appName?'
|
||||
: 'Open this link in an app?',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
tooltip: 'Dismiss',
|
||||
// A back/swipe/cancel resolves as dismiss (§2.6).
|
||||
onPressed: () => resolve(AppLinkDecision.dismiss),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (request.canRemember)
|
||||
Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: remember.value,
|
||||
onChanged: (value) => remember.value = value ?? false,
|
||||
),
|
||||
const Flexible(child: Text('Remember for this site')),
|
||||
],
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => resolve(AppLinkDecision.cancel),
|
||||
child: const Text('Stay in browser'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
onPressed: () => resolve(AppLinkDecision.open),
|
||||
child: const Text('Open app'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* 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_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart';
|
||||
import 'package:weblibre/features/app_links/domain/services/app_links_coordinator.dart';
|
||||
|
||||
/// Build the `alwaysOpen` rule for a target, or null when it cannot be remembered
|
||||
/// (ambiguous resolution / no bound package).
|
||||
PersistedAppLinkRule? alwaysOpenRuleFor(AppLinkTarget target) {
|
||||
final packageName = target.packageName;
|
||||
if (target.isAmbiguous || packageName == null || packageName.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return PersistedAppLinkRule(
|
||||
decision: AppLinkRuleDecision.alwaysOpen,
|
||||
scope: target.scopeKey,
|
||||
packageName: packageName,
|
||||
);
|
||||
}
|
||||
|
||||
PersistedAppLinkRule neverOpenRuleFor(AppLinkTarget target) {
|
||||
return PersistedAppLinkRule(
|
||||
decision: AppLinkRuleDecision.neverOpen,
|
||||
scope: target.scopeKey,
|
||||
);
|
||||
}
|
||||
|
||||
/// Modal prompt for an unsupported-scheme app link (§2.2). The navigation is
|
||||
/// genuinely stalled and there is no page to show behind it.
|
||||
class AppLinkPromptDialog extends HookConsumerWidget {
|
||||
final AppLinkPromptRequest request;
|
||||
|
||||
const AppLinkPromptDialog({super.key, required this.request});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final target = request.target;
|
||||
final appName = target.appName;
|
||||
final remember = useState(false);
|
||||
final coordinator = ref.read(appLinksCoordinatorProvider.notifier);
|
||||
// Guards against a double-tap running two resolves + two Navigator.pop()s
|
||||
// (the second pop would tear down the route beneath the dialog).
|
||||
final resolving = useRef(false);
|
||||
|
||||
Future<void> resolve(AppLinkDecision decision) async {
|
||||
if (resolving.value) return;
|
||||
resolving.value = true;
|
||||
final navigator = Navigator.of(context);
|
||||
if (remember.value && request.canRemember) {
|
||||
final rule = decision == AppLinkDecision.open
|
||||
? alwaysOpenRuleFor(target)
|
||||
: neverOpenRuleFor(target);
|
||||
if (rule != null) {
|
||||
await coordinator.resolveWithRule(
|
||||
request.requestId,
|
||||
decision,
|
||||
rule,
|
||||
contextId: request.contextId,
|
||||
);
|
||||
navigator.pop();
|
||||
return;
|
||||
}
|
||||
}
|
||||
await coordinator.resolve(request.requestId, decision);
|
||||
navigator.pop();
|
||||
}
|
||||
|
||||
return AlertDialog(
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
title: Text(
|
||||
appName != null ? 'Open in $appName?' : 'Open in another app?',
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('This link is handled by an app outside WebLibre.'),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_displayScope(target.scopeKey),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
if (request.canRemember)
|
||||
CheckboxListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
value: remember.value,
|
||||
onChanged: (value) => remember.value = value ?? false,
|
||||
title: const Text('Remember my choice for this site'),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => resolve(AppLinkDecision.cancel),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => resolve(AppLinkDecision.open),
|
||||
child: const Text('Open'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _displayScope(String scope) {
|
||||
if (scope.startsWith('host:')) return scope.substring('host:'.length);
|
||||
if (scope.startsWith('pkg:')) return scope.substring('pkg:'.length);
|
||||
return scope;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* 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/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/features/app_links/domain/services/app_links_coordinator.dart';
|
||||
import 'package:weblibre/features/app_links/presentation/widgets/app_link_open_banner.dart';
|
||||
import 'package:weblibre/features/app_links/presentation/widgets/app_link_prompt_dialog.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
|
||||
|
||||
/// Presents Flutter-owned app-link prompts (§2.6): renders at most one banner for
|
||||
/// the active tab, and drives one modal at a time via [showDialog]. A request is
|
||||
/// only shown while its originating tab is active. Rotation/teardown is not a
|
||||
/// dismissal — the request stays pending and is re-presented on the next query.
|
||||
///
|
||||
/// Mount this as a layer of the browser Stack that is positioned *above* the bottom
|
||||
/// app bar (see `browser.dart`, next to the find-in-page layer). It renders the
|
||||
/// banner inline, bottom-anchored within that positioned region — Flutter composites
|
||||
/// over the live GeckoView fine (the toolbars do the same); the only requirement is
|
||||
/// that the host is not placed underneath the bottom app bar.
|
||||
class AppLinkPromptHost extends HookConsumerWidget {
|
||||
const AppLinkPromptHost({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final prompts = ref.watch(appLinksCoordinatorProvider);
|
||||
final activeTabId = ref.watch(selectedTabProvider);
|
||||
|
||||
// The Pigeon availability event has no replay: an event emitted while Flutter
|
||||
// was detached is lost, so re-query the native pending store on resume (§2.6).
|
||||
useOnAppLifecycleStateChange((previous, current) {
|
||||
if (current == AppLifecycleState.resumed) {
|
||||
unawaited(ref.read(appLinksCoordinatorProvider.notifier).refresh());
|
||||
}
|
||||
});
|
||||
|
||||
final activeRequests = prompts
|
||||
.where((request) => request.tabId == activeTabId)
|
||||
.toList();
|
||||
|
||||
final modalRequest = activeRequests
|
||||
.where((request) => request.isModal)
|
||||
.lastOrNull;
|
||||
// At most one banner per tab; a newer banner-class request simply becomes the
|
||||
// one the UI renders.
|
||||
final bannerRequest = activeRequests
|
||||
.where((request) => !request.isModal)
|
||||
.lastOrNull;
|
||||
|
||||
// A modal is shown at most once per requestId. Rotation/teardown is not a
|
||||
// dismissal — the request stays pending and is re-presented on the next query
|
||||
// (a subsequent build re-runs this effect with the still-present id).
|
||||
final shownModalId = useRef<int?>(null);
|
||||
useEffect(() {
|
||||
final request = modalRequest;
|
||||
if (request == null) {
|
||||
shownModalId.value = null;
|
||||
return null;
|
||||
}
|
||||
if (shownModalId.value == request.requestId) {
|
||||
return null;
|
||||
}
|
||||
shownModalId.value = request.requestId;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!context.mounted) return;
|
||||
unawaited(
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) => AppLinkPromptDialog(request: request),
|
||||
).then((_) {
|
||||
// Catch-all for a passive dismissal (Android back / touch-outside):
|
||||
// the dialog buttons resolve the request themselves, but a barrier
|
||||
// dismiss closes it without resolving, leaving the native request
|
||||
// pending forever (and `shownModalId` blocks a re-show). Resolving as
|
||||
// dismiss here is idempotent — if a button already consumed it, the
|
||||
// native store returns stale and this is a no-op.
|
||||
unawaited(
|
||||
ref
|
||||
.read(appLinksCoordinatorProvider.notifier)
|
||||
.resolve(request.requestId, AppLinkDecision.dismiss),
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
return null;
|
||||
}, [modalRequest?.requestId]);
|
||||
|
||||
if (bannerRequest == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return AppLinkOpenBanner(
|
||||
key: ValueKey(bannerRequest.requestId),
|
||||
request: bannerRequest,
|
||||
);
|
||||
}
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
* 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 AppLinksMode;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.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/settings/presentation/controllers/save_settings.dart';
|
||||
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
|
||||
/// Per-container app-link settings (§ container isolation), bound to
|
||||
/// `GeneralSettings.appLinkContextOverrides[contextId]`. Mirrors the global
|
||||
/// app-links section but writes into the container's own override bucket, which
|
||||
/// fully replaces the global mode + rules for that container (replace semantics).
|
||||
///
|
||||
/// Present via `showDialog`; edits save live (no separate confirm step), matching
|
||||
/// the global settings screen. Only meaningful for an isolated, cookie-isolated
|
||||
/// container — the caller gates on that.
|
||||
class ContainerAppLinkSettingsDialog extends ConsumerWidget {
|
||||
/// The container's Gecko contextId (`contextualIdentity`); the override key.
|
||||
final String contextId;
|
||||
|
||||
/// Optional container name for the title.
|
||||
final String? containerName;
|
||||
|
||||
const ContainerAppLinkSettingsDialog({
|
||||
super.key,
|
||||
required this.contextId,
|
||||
this.containerName,
|
||||
});
|
||||
|
||||
Future<void> _updateOverride(
|
||||
WidgetRef ref,
|
||||
ContextAppLinkPolicy Function(ContextAppLinkPolicy current) update,
|
||||
) async {
|
||||
await ref
|
||||
.read(saveGeneralSettingsControllerProvider.notifier)
|
||||
.save((current) {
|
||||
final existing =
|
||||
current.appLinkContextOverrides[contextId] ??
|
||||
ContextAppLinkPolicy.blank();
|
||||
return current.copyWith.appLinkContextOverrides({
|
||||
...current.appLinkContextOverrides,
|
||||
contextId: update(existing),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final override = ref.watch(
|
||||
generalSettingsWithDefaultsProvider.select(
|
||||
(s) => s.appLinkContextOverrides[contextId],
|
||||
),
|
||||
);
|
||||
final policy = override ?? ContextAppLinkPolicy.blank();
|
||||
|
||||
final rules = policy.rules.entries.toList()
|
||||
..sort((a, b) => a.key.compareTo(b.key));
|
||||
|
||||
return Dialog.fullscreen(
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
containerName != null
|
||||
? 'App Links — $containerName'
|
||||
: 'Container App Links',
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: Text(
|
||||
'These settings apply only to this container and fully replace '
|
||||
'the global app-link settings for its tabs.',
|
||||
),
|
||||
),
|
||||
RadioGroup(
|
||||
groupValue: policy.mode,
|
||||
onChanged: (value) async {
|
||||
if (value != null) {
|
||||
await _updateOverride(ref, (c) => c.copyWith.mode(value));
|
||||
}
|
||||
},
|
||||
child: const Column(
|
||||
children: [
|
||||
RadioListTile.adaptive(
|
||||
value: AppLinksMode.always,
|
||||
title: Text('Always'),
|
||||
subtitle: Text(
|
||||
'Always open links in their native apps without asking',
|
||||
),
|
||||
),
|
||||
RadioListTile.adaptive(
|
||||
value: AppLinksMode.ask,
|
||||
title: Text('Ask before opening'),
|
||||
subtitle: Text('Show a prompt before opening links in apps'),
|
||||
),
|
||||
RadioListTile.adaptive(
|
||||
value: AppLinksMode.never,
|
||||
title: Text('Never'),
|
||||
subtitle: Text(
|
||||
'Always open links in the browser instead of apps',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (rules.isNotEmpty) ...[
|
||||
const Divider(),
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
child: Text('Remembered site rules'),
|
||||
),
|
||||
for (final MapEntry(:key, :value) in rules)
|
||||
ListTile(
|
||||
dense: true,
|
||||
leading: Icon(
|
||||
value.decision == AppLinkRuleDecision.alwaysOpen
|
||||
? MdiIcons.openInApp
|
||||
: Icons.public,
|
||||
),
|
||||
title: Text(_displayScope(key)),
|
||||
subtitle: Text(
|
||||
value.decision == AppLinkRuleDecision.alwaysOpen
|
||||
? 'Always open in the app'
|
||||
: 'Always keep in the browser',
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
tooltip: 'Remove rule',
|
||||
onPressed: () async {
|
||||
await _updateOverride(
|
||||
ref,
|
||||
(c) => c.copyWith.rules({...c.rules}..remove(key)),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _displayScope(String scope) {
|
||||
if (scope.startsWith('host:')) return scope.substring('host:'.length);
|
||||
if (scope.startsWith('pkg:')) return scope.substring('pkg:'.length);
|
||||
return scope;
|
||||
}
|
||||
Reference in New Issue
Block a user