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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user