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;
|
||||
}
|
||||
@@ -1267,18 +1267,3 @@ class _TabGroupRecord {
|
||||
required this.dateKey,
|
||||
});
|
||||
}
|
||||
|
||||
@Riverpod()
|
||||
class AppLinksModeNotifier extends _$AppLinksModeNotifier {
|
||||
final _service = GeckoEngineSettingsService();
|
||||
|
||||
Future<void> setMode(AppLinksMode mode) async {
|
||||
await _service.setAppLinksMode(mode);
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AppLinksMode> build() {
|
||||
return _service.getAppLinksMode();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1253,48 +1253,3 @@ final class GroupedTabListItemsFamily extends $Family
|
||||
@override
|
||||
String toString() => r'groupedTabListItemsProvider';
|
||||
}
|
||||
|
||||
@ProviderFor(AppLinksModeNotifier)
|
||||
final appLinksModeProvider = AppLinksModeNotifierProvider._();
|
||||
|
||||
final class AppLinksModeNotifierProvider
|
||||
extends $AsyncNotifierProvider<AppLinksModeNotifier, AppLinksMode> {
|
||||
AppLinksModeNotifierProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'appLinksModeProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$appLinksModeNotifierHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
AppLinksModeNotifier create() => AppLinksModeNotifier();
|
||||
}
|
||||
|
||||
String _$appLinksModeNotifierHash() =>
|
||||
r'2643b7d2799870fd444f7db204ea452d975368a3';
|
||||
|
||||
abstract class _$AppLinksModeNotifier extends $AsyncNotifier<AppLinksMode> {
|
||||
FutureOr<AppLinksMode> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
WhenComplete runBuild() {
|
||||
final ref = this.ref as $Ref<AsyncValue<AppLinksMode>, AppLinksMode>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<AsyncValue<AppLinksMode>, AppLinksMode>,
|
||||
AsyncValue<AppLinksMode>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
return element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ final class BrowserDataServiceProvider
|
||||
}
|
||||
|
||||
String _$browserDataServiceHash() =>
|
||||
r'2df2f652342efc3e16606b92fdef6062b02f72df';
|
||||
r'5df7ca0b61a5f34e69280311777e98fc31907269';
|
||||
|
||||
abstract class _$BrowserDataService extends $Notifier<void> {
|
||||
void build();
|
||||
|
||||
+26
-94
@@ -22,6 +22,7 @@ import 'package:riverpod/riverpod.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:synchronized/synchronized.dart';
|
||||
import 'package:weblibre/core/logger.dart';
|
||||
import 'package:weblibre/features/app_links/domain/services/effective_routing.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
|
||||
@@ -33,45 +34,10 @@ import 'package:weblibre/features/user/domain/repositories/proxy_routing_setting
|
||||
|
||||
part 'proxy_settings_replication.g.dart';
|
||||
|
||||
sealed class _ProxyAssignment with FastEquatable {
|
||||
_ProxyAssignment();
|
||||
|
||||
factory _ProxyAssignment.inherit() = _InheritProxyAssignment;
|
||||
|
||||
factory _ProxyAssignment.direct(String scopeId) = _DirectProxyAssignment;
|
||||
|
||||
factory _ProxyAssignment.explicit(String proxyId) = _ExplicitProxyAssignment;
|
||||
}
|
||||
|
||||
final class _InheritProxyAssignment extends _ProxyAssignment {
|
||||
_InheritProxyAssignment();
|
||||
|
||||
@override
|
||||
List<Object?> get hashParameters => const ['inherit'];
|
||||
}
|
||||
|
||||
final class _DirectProxyAssignment extends _ProxyAssignment {
|
||||
final String scopeId;
|
||||
|
||||
_DirectProxyAssignment(this.scopeId);
|
||||
|
||||
@override
|
||||
List<Object?> get hashParameters => [scopeId];
|
||||
}
|
||||
|
||||
final class _ExplicitProxyAssignment extends _ProxyAssignment {
|
||||
final String proxyId;
|
||||
|
||||
_ExplicitProxyAssignment(this.proxyId);
|
||||
|
||||
@override
|
||||
List<Object?> get hashParameters => [proxyId];
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class ProxySettingsReplication extends _$ProxySettingsReplication {
|
||||
var _isolatedProxyAssignments = <String, _ProxyAssignment>{};
|
||||
var _appliedContainerProxies = <String, _ProxyAssignment>{};
|
||||
var _isolatedProxyAssignments = <String, ProxyAssignment>{};
|
||||
var _appliedContainerProxies = <String, ProxyAssignment>{};
|
||||
|
||||
final _recomputeLock = Lock();
|
||||
var _recomputeDirty = false;
|
||||
@@ -117,19 +83,17 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
|
||||
.read(containerRepositoryProvider.notifier)
|
||||
.getAllContainersWithCount();
|
||||
|
||||
final containerAssignments = <String, _ProxyAssignment>{
|
||||
final containerAssignments = <String, ProxyAssignment>{
|
||||
for (final c in containers)
|
||||
if (c.metadata.contextualIdentity case final contextId?)
|
||||
c.id: switch (c.metadata.proxyConnectionId) {
|
||||
final proxyId? => _ProxyAssignment.explicit(proxyId.encode()),
|
||||
null when c.metadata.bypassGlobalProxy => _ProxyAssignment.direct(
|
||||
contextId,
|
||||
),
|
||||
null => _ProxyAssignment.inherit(),
|
||||
},
|
||||
c.id: resolveContainerAssignment(
|
||||
contextId: contextId,
|
||||
proxyConnectionId: c.metadata.proxyConnectionId,
|
||||
bypassGlobalProxy: c.metadata.bypassGlobalProxy,
|
||||
),
|
||||
};
|
||||
|
||||
final newAssignments = <String, _ProxyAssignment>{};
|
||||
final newAssignments = <String, ProxyAssignment>{};
|
||||
for (final entry in contextContainerMap.entries) {
|
||||
final assignments = entry.value
|
||||
.map((containerId) => containerAssignments[containerId])
|
||||
@@ -138,50 +102,19 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
|
||||
|
||||
if (assignments.isEmpty) continue;
|
||||
|
||||
final proxyIds =
|
||||
assignments
|
||||
.whereType<_ExplicitProxyAssignment>()
|
||||
.map((assignment) => assignment.proxyId)
|
||||
.toSet()
|
||||
.toList()
|
||||
..sort();
|
||||
final directScopeIds =
|
||||
assignments
|
||||
.whereType<_DirectProxyAssignment>()
|
||||
.map((assignment) => assignment.scopeId)
|
||||
.toSet()
|
||||
.toList()
|
||||
..sort();
|
||||
final hasInheritedAssignment = assignments.any(
|
||||
(assignment) => assignment is _InheritProxyAssignment,
|
||||
);
|
||||
|
||||
final chosenAssignment = proxyIds.isNotEmpty
|
||||
? _ProxyAssignment.explicit(proxyIds.first)
|
||||
: directScopeIds.isNotEmpty && !hasInheritedAssignment
|
||||
? _ProxyAssignment.direct(directScopeIds.first)
|
||||
: _ProxyAssignment.inherit();
|
||||
if (chosenAssignment is! _InheritProxyAssignment) {
|
||||
newAssignments[entry.key] = chosenAssignment;
|
||||
final routing = resolveIsolationContextRouting(assignments);
|
||||
if (routing.chosen is! InheritProxyAssignment) {
|
||||
newAssignments[entry.key] = routing.chosen;
|
||||
}
|
||||
final chosenLabel = switch (chosenAssignment) {
|
||||
_DirectProxyAssignment(:final scopeId) => 'direct:$scopeId',
|
||||
_ExplicitProxyAssignment(:final proxyId) => proxyId,
|
||||
_InheritProxyAssignment() => 'inherit',
|
||||
};
|
||||
|
||||
final distinctAssignmentCount =
|
||||
proxyIds.length +
|
||||
directScopeIds.length +
|
||||
(hasInheritedAssignment ? 1 : 0);
|
||||
if (distinctAssignmentCount > 1) {
|
||||
if (routing.distinctAssignmentCount > 1) {
|
||||
// Isolation contexts can hold multiple containers; if they disagree on
|
||||
// routing, the alias is forced to pick one. Surface this so the user
|
||||
// can split the containers across isolation contexts.
|
||||
logger.w(
|
||||
'Isolation context ${entry.key} has containers with multiple '
|
||||
'proxy routing assignments '
|
||||
'(${[if (hasInheritedAssignment) 'inherit', ...directScopeIds.map((id) => 'direct:$id'), ...proxyIds].join(', ')}); using $chosenLabel',
|
||||
'(${routing.assignmentLabels.join(', ')}); using ${routing.chosenLabel}',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -339,20 +272,19 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
|
||||
) async {
|
||||
if (containers == null) return;
|
||||
|
||||
final desired = <String, _ProxyAssignment>{};
|
||||
final desired = <String, ProxyAssignment>{};
|
||||
for (final container in containers) {
|
||||
final contextId = container.metadata.contextualIdentity;
|
||||
if (contextId == null || contextId.isEmpty) continue;
|
||||
final proxyConnectionId = container.metadata.proxyConnectionId;
|
||||
desired[contextId] = proxyConnectionId != null
|
||||
? _ProxyAssignment.explicit(proxyConnectionId.encode())
|
||||
: container.metadata.bypassGlobalProxy
|
||||
? _ProxyAssignment.direct(contextId)
|
||||
: _ProxyAssignment.inherit();
|
||||
desired[contextId] = resolveContainerAssignment(
|
||||
contextId: contextId,
|
||||
proxyConnectionId: container.metadata.proxyConnectionId,
|
||||
bypassGlobalProxy: container.metadata.bypassGlobalProxy,
|
||||
);
|
||||
}
|
||||
|
||||
final repo = ref.read(containerProxyRepositoryProvider.notifier);
|
||||
final nextApplied = Map<String, _ProxyAssignment>.from(
|
||||
final nextApplied = Map<String, ProxyAssignment>.from(
|
||||
_appliedContainerProxies,
|
||||
);
|
||||
|
||||
@@ -395,15 +327,15 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
|
||||
|
||||
Future<void> _applyProxyAssignment(
|
||||
String contextId,
|
||||
_ProxyAssignment assignment,
|
||||
ProxyAssignment assignment,
|
||||
) async {
|
||||
final repo = ref.read(containerProxyRepositoryProvider.notifier);
|
||||
switch (assignment) {
|
||||
case _ExplicitProxyAssignment(:final proxyId):
|
||||
case ExplicitProxyAssignment(:final proxyId):
|
||||
await repo.setContainerProxy(contextId, proxyId);
|
||||
case _DirectProxyAssignment(:final scopeId):
|
||||
case DirectProxyAssignment(:final scopeId):
|
||||
await repo.setContainerDirectConnection(contextId, scopeId: scopeId);
|
||||
case _InheritProxyAssignment():
|
||||
case InheritProxyAssignment():
|
||||
await repo.clearContainerProxy(contextId);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ final class ProxySettingsReplicationProvider
|
||||
}
|
||||
|
||||
String _$proxySettingsReplicationHash() =>
|
||||
r'69787c85c94ff165e3eeb0f0a3f3fc83e88a1b83';
|
||||
r'bea07ab165545a6bef8a72ddf0503e0cd135eb8a';
|
||||
|
||||
abstract class _$ProxySettingsReplication extends $Notifier<void> {
|
||||
void build();
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ List<ToolbarButtonConfig> _buildDefaultToolbarButtonConfigs({
|
||||
return ToolbarButtonConfig(
|
||||
buttonId: spec.id.name,
|
||||
orderKey: key,
|
||||
isVisible: allHidden ? false : spec.defaultVisible,
|
||||
isVisible: !allHidden && spec.defaultVisible,
|
||||
fallbackId: allHidden ? null : spec.defaultFallback?.name,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
+34
@@ -29,6 +29,7 @@ import 'package:weblibre/core/providers/global_drop.dart';
|
||||
import 'package:weblibre/core/routing/routes.dart';
|
||||
import 'package:weblibre/data/models/drag_data.dart';
|
||||
import 'package:weblibre/extensions/media_query.dart';
|
||||
import 'package:weblibre/features/app_links/presentation/widgets/app_link_prompt_host.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/controllers/overlay.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
|
||||
@@ -1276,6 +1277,39 @@ class BrowserScreen extends HookConsumerWidget {
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Layer 7: App-link prompt banner (§2.6). Anchored above the bottom app
|
||||
// bar / keyboard exactly like find-in-page, so it is never hidden behind
|
||||
// the toolbar. Custom Tab sessions are prompted natively instead; this is
|
||||
// the browser-tab surface only.
|
||||
Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final toolbarState = ref.watch(
|
||||
toolbarVisibilityControllerProvider(selectedTabId),
|
||||
);
|
||||
final visible =
|
||||
sheetDisplayed ||
|
||||
(!tabInFullScreen &&
|
||||
toolbarState == ToolbarVisibility.visible);
|
||||
return Positioned(
|
||||
left: (tabBarPosition == TabBarPosition.left && visible)
|
||||
? sideRailTotalWidth
|
||||
: 0.0,
|
||||
right: (tabBarPosition == TabBarPosition.right && visible)
|
||||
? sideRailTotalWidth
|
||||
: 0.0,
|
||||
bottom: math.max(
|
||||
isRail
|
||||
? bottomSafeArea
|
||||
: (visible
|
||||
? bottomAppBarTotalHeight
|
||||
: bottomSafeArea),
|
||||
MediaQuery.viewInsetsOf(context).bottom,
|
||||
),
|
||||
child: const AppLinkPromptHost(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
+8
-5
@@ -1575,22 +1575,25 @@ class _OpenInAppTile extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tabState = ref.watch(tabStateProvider(selectedTabId));
|
||||
final url = tabState?.url;
|
||||
final hasExternalApp = useCachedFuture(
|
||||
() => url != null ? _service.hasExternalApp(url) : Future.value(false),
|
||||
final appLink = useCachedFuture(
|
||||
() => url != null ? _service.resolveAppLink(url) : Future.value(null),
|
||||
[url],
|
||||
);
|
||||
|
||||
if (hasExternalApp.data != true) return const SizedBox.shrink();
|
||||
final target = appLink.data;
|
||||
if (target == null) return const SizedBox.shrink();
|
||||
|
||||
final appName = target.appName;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_buildDivider(),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.open_in_new),
|
||||
title: const Text('Open in App'),
|
||||
title: Text(appName != null ? 'Open in $appName' : 'Open in App'),
|
||||
onTap: () async {
|
||||
if (url == null) return;
|
||||
final success = await _service.openAppLink(url);
|
||||
final success = await _service.launchAppLink(url);
|
||||
if (success && context.mounted) Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
|
||||
+14
@@ -30,6 +30,7 @@ import 'package:weblibre/core/logger.dart';
|
||||
import 'package:weblibre/core/providers/device_info.dart';
|
||||
import 'package:weblibre/core/providers/router.dart';
|
||||
import 'package:weblibre/core/routing/routes.dart';
|
||||
import 'package:weblibre/features/app_links/domain/services/app_link_policy_replication.dart';
|
||||
import 'package:weblibre/features/bangs/data/models/web_search_bang.dart';
|
||||
import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
|
||||
import 'package:weblibre/features/bangs/domain/services/search_history_cleanup.dart';
|
||||
@@ -676,6 +677,19 @@ class _BrowserViewState extends ConsumerState<BrowserView>
|
||||
},
|
||||
);
|
||||
|
||||
ref.listenManual(
|
||||
fireImmediately: true,
|
||||
appLinkPolicyReplicationProvider,
|
||||
(previous, next) {},
|
||||
onError: (error, stackTrace) {
|
||||
logger.e(
|
||||
'Error listening to appLinkPolicyReplicationProvider',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ref.listenManual(
|
||||
fireImmediately: true,
|
||||
historyExclusionReplicationProvider,
|
||||
|
||||
+8
-5
@@ -329,24 +329,27 @@ class OpenInAppMenuItemButton extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tabState = ref.watch(tabStateProvider(selectedTabId));
|
||||
final url = tabState?.url;
|
||||
final hasExternalApp = useCachedFuture(
|
||||
final appLink = useCachedFuture(
|
||||
// ignore: discarded_futures useFuture
|
||||
() => url != null ? _service.hasExternalApp(url) : Future.value(false),
|
||||
() => url != null ? _service.resolveAppLink(url) : Future.value(null),
|
||||
[url],
|
||||
);
|
||||
|
||||
if (hasExternalApp.data != true) {
|
||||
final target = appLink.data;
|
||||
if (target == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final appName = target.appName;
|
||||
|
||||
return MenuItemButton(
|
||||
leadingIcon: const Icon(Icons.open_in_new),
|
||||
closeOnActivate: false,
|
||||
child: const Text('Open in App'),
|
||||
child: Text(appName != null ? 'Open in $appName' : 'Open in App'),
|
||||
onPressed: () async {
|
||||
if (url == null) return;
|
||||
|
||||
final success = await _service.openAppLink(url);
|
||||
final success = await _service.launchAppLink(url);
|
||||
|
||||
if (success && context.mounted) {
|
||||
MenuController.maybeOf(context)?.close();
|
||||
|
||||
+8
-5
@@ -357,19 +357,22 @@ class _OpenInAppTile extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tabState = ref.watch(tabStateProvider(selectedTabId));
|
||||
final url = tabState?.url;
|
||||
final hasExternalApp = useCachedFuture(
|
||||
() => url != null ? _service.hasExternalApp(url) : Future.value(false),
|
||||
final appLink = useCachedFuture(
|
||||
() => url != null ? _service.resolveAppLink(url) : Future.value(null),
|
||||
[url],
|
||||
);
|
||||
|
||||
if (hasExternalApp.data != true) return const SizedBox.shrink();
|
||||
final target = appLink.data;
|
||||
if (target == null) return const SizedBox.shrink();
|
||||
|
||||
final appName = target.appName;
|
||||
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.open_in_new),
|
||||
title: const Text('Open in App'),
|
||||
title: Text(appName != null ? 'Open in $appName' : 'Open in App'),
|
||||
onTap: () async {
|
||||
if (url == null) return;
|
||||
final success = await _service.openAppLink(url);
|
||||
final success = await _service.launchAppLink(url);
|
||||
if (success && context.mounted) Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
|
||||
show AppLinkTarget, AppLinksMode, GeckoAppLinksService;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart';
|
||||
import 'package:weblibre/features/app_links/domain/entities/context_app_link_policy.dart';
|
||||
import 'package:weblibre/features/app_links/domain/services/effective_app_link_policy.dart';
|
||||
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
|
||||
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
||||
|
||||
final _appLinkTargetProvider = FutureProvider.autoDispose
|
||||
.family<AppLinkTarget?, Uri>((ref, url) {
|
||||
return GeckoAppLinksService().resolveAppLink(url);
|
||||
});
|
||||
|
||||
enum _SiteRuleChoice { followDefault, alwaysOpen, neverOpen }
|
||||
|
||||
/// Section widget showing the app-link rule for the current tab's site. Edits
|
||||
/// the effective bucket — the owning container's override when it has isolated
|
||||
/// app-link settings, otherwise the global rules — but does not expose the
|
||||
/// global/container default from this site-specific sheet.
|
||||
class AppLinkSection extends HookConsumerWidget {
|
||||
final Uri url;
|
||||
|
||||
/// The tab's live contextId (`TabState.contextId`): the container base
|
||||
/// contextId for a regular tab, the isolation contextId for an isolated tab.
|
||||
final String? contextId;
|
||||
|
||||
const AppLinkSection({required this.url, required this.contextId, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final policy = ref.watch(effectiveAppLinkPolicyProvider(contextId));
|
||||
final target = ref.watch(_appLinkTargetProvider(url));
|
||||
final isLoadingTarget = target.isLoading && !target.hasValue;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||
child: Text(
|
||||
'App Links',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (policy == null || isLoadingTarget)
|
||||
const Skeletonizer(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: Icon(Icons.link),
|
||||
title: Text('Open links for this site'),
|
||||
subtitle: Text('Follows the default'),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
_SiteRuleTile(
|
||||
policy: policy,
|
||||
target: target.hasValue ? target.value : null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SiteRuleTile extends ConsumerWidget {
|
||||
final EffectiveAppLinkPolicy policy;
|
||||
final AppLinkTarget? target;
|
||||
|
||||
const _SiteRuleTile({required this.policy, required this.target});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final scope = target?.scopeKey;
|
||||
final rule = (scope != null && scope.isNotEmpty)
|
||||
? policy.rules[scope]
|
||||
: null;
|
||||
final choice = switch (rule?.decision) {
|
||||
AppLinkRuleDecision.alwaysOpen => _SiteRuleChoice.alwaysOpen,
|
||||
AppLinkRuleDecision.neverOpen => _SiteRuleChoice.neverOpen,
|
||||
null => _SiteRuleChoice.followDefault,
|
||||
};
|
||||
final canAlwaysOpen = _alwaysOpenRuleFor(target) != null;
|
||||
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final (IconData icon, Color color) = switch (choice) {
|
||||
_SiteRuleChoice.alwaysOpen => (MdiIcons.openInApp, colorScheme.primary),
|
||||
_SiteRuleChoice.neverOpen => (Icons.public, colorScheme.primary),
|
||||
_SiteRuleChoice.followDefault => (
|
||||
Icons.link,
|
||||
colorScheme.onSurfaceVariant,
|
||||
),
|
||||
};
|
||||
|
||||
return ListTile(
|
||||
leading: Icon(icon, color: color),
|
||||
title: const Text('Open links for this site'),
|
||||
subtitle: Text(_subtitle(scope, rule, choice, canAlwaysOpen)),
|
||||
trailing: DropdownButton<_SiteRuleChoice>(
|
||||
value: choice,
|
||||
underline: const SizedBox(),
|
||||
items: [
|
||||
const DropdownMenuItem(
|
||||
value: _SiteRuleChoice.followDefault,
|
||||
child: Text('Follow default'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: _SiteRuleChoice.alwaysOpen,
|
||||
enabled: canAlwaysOpen || choice == _SiteRuleChoice.alwaysOpen,
|
||||
child: const Text('Open in app'),
|
||||
),
|
||||
const DropdownMenuItem(
|
||||
value: _SiteRuleChoice.neverOpen,
|
||||
child: Text('Keep in browser'),
|
||||
),
|
||||
],
|
||||
onChanged: scope == null || scope.isEmpty
|
||||
? null
|
||||
: (value) async {
|
||||
if (value != null && value != choice) {
|
||||
await _setSiteRule(ref, scope, target, value);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _subtitle(
|
||||
String? scope,
|
||||
PersistedAppLinkRule? rule,
|
||||
_SiteRuleChoice choice,
|
||||
bool canAlwaysOpen,
|
||||
) {
|
||||
if (scope == null || scope.isEmpty) return 'No app found for this site';
|
||||
return switch (choice) {
|
||||
_SiteRuleChoice.alwaysOpen =>
|
||||
'Always opens in ${rule!.packageName ?? 'the app'}',
|
||||
_SiteRuleChoice.neverOpen => 'Always stays in the browser',
|
||||
_SiteRuleChoice.followDefault => switch (policy.mode) {
|
||||
AppLinksMode.always =>
|
||||
canAlwaysOpen
|
||||
? 'Follows the default: opens in apps'
|
||||
: 'Follows the default: no app found',
|
||||
AppLinksMode.ask => 'Follows the default: asks first',
|
||||
AppLinksMode.never => 'Follows the default: stays in the browser',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> _setSiteRule(
|
||||
WidgetRef ref,
|
||||
String scope,
|
||||
AppLinkTarget? target,
|
||||
_SiteRuleChoice choice,
|
||||
) async {
|
||||
Map<String, PersistedAppLinkRule> updateRules(
|
||||
Map<String, PersistedAppLinkRule> rules,
|
||||
) {
|
||||
final next = {...rules};
|
||||
switch (choice) {
|
||||
case _SiteRuleChoice.followDefault:
|
||||
next.remove(scope);
|
||||
case _SiteRuleChoice.neverOpen:
|
||||
next[scope] = PersistedAppLinkRule(
|
||||
decision: AppLinkRuleDecision.neverOpen,
|
||||
scope: scope,
|
||||
);
|
||||
case _SiteRuleChoice.alwaysOpen:
|
||||
final rule = _alwaysOpenRuleFor(target);
|
||||
if (rule != null) next[scope] = rule;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
final overrideKey = policy.overrideKey;
|
||||
await ref.read(saveGeneralSettingsControllerProvider.notifier).save((
|
||||
current,
|
||||
) {
|
||||
if (overrideKey == null) {
|
||||
return current.copyWith.appLinkRules(updateRules(current.appLinkRules));
|
||||
}
|
||||
final existing =
|
||||
current.appLinkContextOverrides[overrideKey] ??
|
||||
ContextAppLinkPolicy.blank();
|
||||
return current.copyWith.appLinkContextOverrides({
|
||||
...current.appLinkContextOverrides,
|
||||
overrideKey: existing.copyWith.rules(updateRules(existing.rules)),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
PersistedAppLinkRule? _alwaysOpenRuleFor(AppLinkTarget? target) {
|
||||
final packageName = target?.packageName;
|
||||
final scope = target?.scopeKey;
|
||||
if (target == null ||
|
||||
target.isAmbiguous ||
|
||||
packageName == null ||
|
||||
packageName.isEmpty ||
|
||||
scope == null ||
|
||||
scope.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return PersistedAppLinkRule(
|
||||
decision: AppLinkRuleDecision.alwaysOpen,
|
||||
scope: scope,
|
||||
packageName: packageName,
|
||||
);
|
||||
}
|
||||
+7
@@ -26,6 +26,7 @@ import 'package:weblibre/core/routing/routes.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/certificate_tile.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/draggable_scrollable_header.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/app_link_section.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/clear_site_data_section.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/desktop_mode_section.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/gesture_exclusion_section.dart';
|
||||
@@ -164,6 +165,12 @@ class ViewTabSheetWidget extends HookConsumerWidget {
|
||||
url: initialTabState.url,
|
||||
),
|
||||
const Divider(),
|
||||
// App Link Section
|
||||
AppLinkSection(
|
||||
url: initialTabState.url,
|
||||
contextId: initialTabState.contextId,
|
||||
),
|
||||
const Divider(),
|
||||
// Permissions Section
|
||||
PermissionsSection(
|
||||
origin: initialTabState.url.origin,
|
||||
|
||||
+11
-3
@@ -23,6 +23,7 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:nullability/nullability.dart';
|
||||
import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart';
|
||||
import 'package:weblibre/presentation/hooks/cached_future.dart';
|
||||
|
||||
class LaunchExternal extends HookConsumerWidget {
|
||||
final HitResult hitResult;
|
||||
@@ -33,19 +34,26 @@ class LaunchExternal extends HookConsumerWidget {
|
||||
|
||||
static Future<bool> isSupported(HitResult hitResult) async {
|
||||
return hitResult.tryGetLink().mapNotNull(
|
||||
(url) => _service.hasExternalApp(url),
|
||||
(url) async => (await _service.resolveAppLink(url)) != null,
|
||||
) ??
|
||||
false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final url = hitResult.tryGetLink();
|
||||
final appLink = useCachedFuture(
|
||||
() => url != null ? _service.resolveAppLink(url) : Future.value(null),
|
||||
[url],
|
||||
);
|
||||
final appName = appLink.data?.appName;
|
||||
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.open_in_new),
|
||||
title: const Text('Open in App'),
|
||||
title: Text(appName != null ? 'Open in $appName' : 'Open in App'),
|
||||
onTap: () async {
|
||||
await hitResult.tryGetLink().mapNotNull((url) async {
|
||||
final success = await _service.openAppLink(url);
|
||||
final success = await _service.launchAppLink(url);
|
||||
|
||||
if (success && context.mounted) {
|
||||
context.pop();
|
||||
|
||||
+8
-6
@@ -184,11 +184,11 @@ class OpenSharedContent extends HookConsumerWidget {
|
||||
};
|
||||
}, [containerMode, contextId, selectionUrlKey, globalSelectedContainer]);
|
||||
|
||||
final hasExternalApp = useCachedFuture(
|
||||
final appLink = useCachedFuture(
|
||||
// ignore: discarded_futures useFuture
|
||||
() => parsedDebouncedUrl != null
|
||||
? _appLinksService.hasExternalApp(parsedDebouncedUrl)
|
||||
: Future.value(false),
|
||||
? _appLinksService.resolveAppLink(parsedDebouncedUrl)
|
||||
: Future.value(null),
|
||||
[parsedDebouncedUrl],
|
||||
);
|
||||
|
||||
@@ -288,7 +288,7 @@ class OpenSharedContent extends HookConsumerWidget {
|
||||
final uri = parseValidatedUrl(textController.text, eagerParsing: false);
|
||||
if (uri == null) return;
|
||||
|
||||
final success = await _appLinksService.openAppLink(uri);
|
||||
final success = await _appLinksService.launchAppLink(uri);
|
||||
|
||||
if (success && context.mounted) {
|
||||
context.pop(true);
|
||||
@@ -429,9 +429,11 @@ class OpenSharedContent extends HookConsumerWidget {
|
||||
},
|
||||
),
|
||||
],
|
||||
if (hasExternalApp.data == true)
|
||||
if (appLink.data != null)
|
||||
_OpenActionTile(
|
||||
title: 'Open in App',
|
||||
title: appLink.data?.appName != null
|
||||
? 'Open in ${appLink.data!.appName}'
|
||||
: 'Open in App',
|
||||
subtitle: 'Open in an installed app',
|
||||
icon: Icons.open_in_new,
|
||||
onTap: openInApp,
|
||||
|
||||
@@ -19,11 +19,8 @@
|
||||
*/
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift/internal/versioned_schema.dart';
|
||||
import 'package:drift_dev/api/migrations_native.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:lexo_rank/lexo_rank.dart';
|
||||
import 'package:weblibre/data/database/functions/lexo_rank_functions.dart';
|
||||
import 'package:weblibre/data/database/functions/url_functions.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/capture_tab.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/container.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/history.dart';
|
||||
|
||||
@@ -83,6 +83,16 @@ class ContainerMetadata with FastEquatable {
|
||||
@JsonKey(defaultValue: false)
|
||||
final bool strictMode;
|
||||
|
||||
// When true, this container has its own app-link policy (open-in-app mode +
|
||||
// remembered per-site rules) that fully replaces the global one for its tabs.
|
||||
// The override itself lives in `GeneralSettings.appLinkContextOverrides` keyed
|
||||
// by [contextualIdentity]; this flag only gates whether that override is
|
||||
// consulted. Requires a Gecko contextId — the native interceptor keys the
|
||||
// override on the tab's contextId, so it is normalized to false when
|
||||
// [contextualIdentity] is null (mirrors [strictMode]/[excludeFromHistory]).
|
||||
@JsonKey(defaultValue: false)
|
||||
final bool isolatedAppLinkSettings;
|
||||
|
||||
ContainerMetadata({
|
||||
required this.iconData,
|
||||
required this.contextualIdentity,
|
||||
@@ -94,6 +104,7 @@ class ContainerMetadata with FastEquatable {
|
||||
required this.useCustomColor,
|
||||
required this.assignedSites,
|
||||
required this.strictMode,
|
||||
required this.isolatedAppLinkSettings,
|
||||
});
|
||||
|
||||
ContainerMetadata.withDefaults({
|
||||
@@ -107,6 +118,7 @@ class ContainerMetadata with FastEquatable {
|
||||
bool? useCustomColor,
|
||||
List<Uri>? assignedSites,
|
||||
bool? strictMode,
|
||||
bool? isolatedAppLinkSettings,
|
||||
}) : this(
|
||||
iconData: iconData,
|
||||
contextualIdentity: contextualIdentity,
|
||||
@@ -128,6 +140,11 @@ class ContainerMetadata with FastEquatable {
|
||||
// normalize away the invalid combination on read, and writers re-apply
|
||||
// it via [sanitized].
|
||||
strictMode: (strictMode ?? false) && contextualIdentity != null,
|
||||
// Isolated app-link settings need a contextId — the native interceptor
|
||||
// keys the override on the tab's contextId. Normalize the invalid
|
||||
// combination on read; writers re-apply it via [sanitized].
|
||||
isolatedAppLinkSettings:
|
||||
(isolatedAppLinkSettings ?? false) && contextualIdentity != null,
|
||||
);
|
||||
|
||||
/// Enforce the [excludeFromHistory] invariant before persistence: it can only
|
||||
@@ -145,6 +162,11 @@ class ContainerMetadata with FastEquatable {
|
||||
if (result.strictMode && result.contextualIdentity == null) {
|
||||
result = result.copyWith(strictMode: false);
|
||||
}
|
||||
// Isolated app-link settings need a contextId: the interceptor keys the
|
||||
// override on the tab's contextId.
|
||||
if (result.isolatedAppLinkSettings && result.contextualIdentity == null) {
|
||||
result = result.copyWith(isolatedAppLinkSettings: false);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -167,6 +189,7 @@ class ContainerMetadata with FastEquatable {
|
||||
useCustomColor,
|
||||
assignedSites,
|
||||
strictMode,
|
||||
isolatedAppLinkSettings,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ abstract class _$ContainerMetadataCWProxy {
|
||||
|
||||
ContainerMetadata strictMode(bool strictMode);
|
||||
|
||||
ContainerMetadata isolatedAppLinkSettings(bool isolatedAppLinkSettings);
|
||||
|
||||
/// Creates a new instance with the provided field values.
|
||||
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `ContainerMetadata(...).copyWith.fieldName(value)`.
|
||||
///
|
||||
@@ -45,6 +47,7 @@ abstract class _$ContainerMetadataCWProxy {
|
||||
bool useCustomColor,
|
||||
List<Uri>? assignedSites,
|
||||
bool strictMode,
|
||||
bool isolatedAppLinkSettings,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -93,6 +96,10 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy {
|
||||
@override
|
||||
ContainerMetadata strictMode(bool strictMode) => call(strictMode: strictMode);
|
||||
|
||||
@override
|
||||
ContainerMetadata isolatedAppLinkSettings(bool isolatedAppLinkSettings) =>
|
||||
call(isolatedAppLinkSettings: isolatedAppLinkSettings);
|
||||
|
||||
@override
|
||||
/// Creates a new instance with the provided field values.
|
||||
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `ContainerMetadata(...).copyWith.fieldName(value)`.
|
||||
@@ -112,6 +119,7 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy {
|
||||
Object? useCustomColor = const $CopyWithPlaceholder(),
|
||||
Object? assignedSites = const $CopyWithPlaceholder(),
|
||||
Object? strictMode = const $CopyWithPlaceholder(),
|
||||
Object? isolatedAppLinkSettings = const $CopyWithPlaceholder(),
|
||||
}) {
|
||||
return ContainerMetadata(
|
||||
iconData: iconData == const $CopyWithPlaceholder()
|
||||
@@ -165,6 +173,12 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy {
|
||||
? _value.strictMode
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: strictMode as bool,
|
||||
isolatedAppLinkSettings:
|
||||
isolatedAppLinkSettings == const $CopyWithPlaceholder() ||
|
||||
isolatedAppLinkSettings == null
|
||||
? _value.isolatedAppLinkSettings
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: isolatedAppLinkSettings as bool,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -308,6 +322,8 @@ ContainerMetadata _$ContainerMetadataFromJson(Map<String, dynamic> json) =>
|
||||
?.map((e) => Uri.parse(e as String))
|
||||
.toList(),
|
||||
strictMode: json['strictMode'] as bool? ?? false,
|
||||
isolatedAppLinkSettings:
|
||||
json['isolatedAppLinkSettings'] as bool? ?? false,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ContainerMetadataToJson(
|
||||
@@ -326,6 +342,7 @@ Map<String, dynamic> _$ContainerMetadataToJson(
|
||||
'useCustomColor': instance.useCustomColor,
|
||||
'assignedSites': instance.assignedSites?.map((e) => e.toString()).toList(),
|
||||
'strictMode': instance.strictMode,
|
||||
'isolatedAppLinkSettings': instance.isolatedAppLinkSettings,
|
||||
};
|
||||
|
||||
Value? _$JsonConverterFromJson<Json, Value>(
|
||||
|
||||
@@ -103,12 +103,10 @@ class TabDataRepository extends _$TabDataRepository {
|
||||
),
|
||||
// parentId defaults to null - breaks parent chain when changing contextual identity
|
||||
selectTab: selectedTabId == tabState.id,
|
||||
// Assignment-driven navigation to an assigned site: bypass the
|
||||
// app-links delegate so cancelling an "open in app" prompt does
|
||||
// not re-trigger it on the recreated tab's load.
|
||||
flags: replacementUrl != null
|
||||
? LoadUrlFlags.LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE
|
||||
: LoadUrlFlags.NONE,
|
||||
// Assignment-driven navigation is classified in its assigned context
|
||||
// like any other load; the app-links fallback re-entry map (§2.7)
|
||||
// covers the redirect loop the old delegate bypass used to guard.
|
||||
flags: LoadUrlFlags.NONE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ final class TabDataRepositoryProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$tabDataRepositoryHash() => r'adc1c664b492e41a96a0310d92252dbbacc1a089';
|
||||
String _$tabDataRepositoryHash() => r'd4eb49e25077aea6de479ea738ec92a213b71f78';
|
||||
|
||||
abstract class _$TabDataRepository extends $Notifier<void> {
|
||||
void build();
|
||||
|
||||
+120
@@ -26,6 +26,7 @@ import 'package:flutter_material_design_icons/flutter_material_design_icons.dart
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/core/uuid.dart';
|
||||
import 'package:weblibre/features/app_links/presentation/widgets/container_app_link_settings_dialog.dart';
|
||||
import 'package:weblibre/features/geckoview/features/history/domain/repositories/container_history.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
|
||||
@@ -40,9 +41,32 @@ import 'package:weblibre/features/geckoview/features/tabs/utils/container_icons.
|
||||
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
|
||||
import 'package:weblibre/features/proxy/domain/providers/proxy_connection_options.dart';
|
||||
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart';
|
||||
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
|
||||
enum _DialogMode { create, edit }
|
||||
|
||||
/// Remove any per-container app-link overrides (§ container isolation) stored for
|
||||
/// [contextIds] in GeneralSettings. Null ids are ignored; a no-op when none are
|
||||
/// present. Keeps overrides from lingering after a container drops isolation or
|
||||
/// is deleted.
|
||||
Future<void> _removeAppLinkOverrides(
|
||||
WidgetRef ref,
|
||||
Set<String?> contextIds,
|
||||
) async {
|
||||
final ids = contextIds.nonNulls.toSet();
|
||||
if (ids.isEmpty) return;
|
||||
|
||||
await ref.read(generalSettingsRepositoryProvider.notifier).updateSettings((
|
||||
current,
|
||||
) {
|
||||
if (!ids.any(current.appLinkContextOverrides.containsKey)) return current;
|
||||
return current.copyWith.appLinkContextOverrides(
|
||||
{...current.appLinkContextOverrides}..removeWhere((key, _) => ids.contains(key)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
class ContainerEditScreen extends HookConsumerWidget {
|
||||
final _DialogMode _mode;
|
||||
|
||||
@@ -109,6 +133,9 @@ class ContainerEditScreen extends HookConsumerWidget {
|
||||
);
|
||||
final assignedSites = useState(initialContainer.metadata.assignedSites);
|
||||
final strictMode = useState(initialContainer.metadata.strictMode);
|
||||
final isolatedAppLinkSettings = useState(
|
||||
initialContainer.metadata.isolatedAppLinkSettings,
|
||||
);
|
||||
final isPinned = useState(initialContainer.isPinned);
|
||||
|
||||
final textController = useTextEditingController(
|
||||
@@ -147,6 +174,12 @@ class ContainerEditScreen extends HookConsumerWidget {
|
||||
// strictness on the tab's cookieStoreId). sanitized() enforces the
|
||||
// same invariant defensively on write.
|
||||
strictMode: strictMode.value && contextualIdentity.value != null,
|
||||
// Isolated app-link settings require a Gecko contextId (the
|
||||
// interceptor keys the override on the tab's contextId).
|
||||
// sanitized() enforces the same invariant defensively on write.
|
||||
isolatedAppLinkSettings:
|
||||
isolatedAppLinkSettings.value &&
|
||||
contextualIdentity.value != null,
|
||||
)
|
||||
.sanitized(),
|
||||
);
|
||||
@@ -167,6 +200,15 @@ class ContainerEditScreen extends HookConsumerWidget {
|
||||
isPinned: isPinned.value,
|
||||
);
|
||||
}
|
||||
// Keep the per-container app-link override in step with the isolation
|
||||
// toggle: drop it when the container is no longer isolated (or lost its
|
||||
// contextId) so it can't linger orphaned in GeneralSettings.
|
||||
if (!container.metadata.isolatedAppLinkSettings) {
|
||||
await _removeAppLinkOverrides(ref, {
|
||||
initialContainer.metadata.contextualIdentity,
|
||||
container.metadata.contextualIdentity,
|
||||
});
|
||||
}
|
||||
return container;
|
||||
}
|
||||
|
||||
@@ -264,6 +306,11 @@ class ContainerEditScreen extends HookConsumerWidget {
|
||||
.read(containerRepositoryProvider.notifier)
|
||||
.deleteContainer(initialContainer.id);
|
||||
|
||||
// Drop the container's app-link override so it doesn't outlive it.
|
||||
await _removeAppLinkOverrides(ref, {
|
||||
initialContainer.metadata.contextualIdentity,
|
||||
});
|
||||
|
||||
if (context.mounted) {
|
||||
context.pop();
|
||||
}
|
||||
@@ -654,6 +701,79 @@ class ContainerEditScreen extends HookConsumerWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'App Links',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Card.filled(
|
||||
margin: EdgeInsets.zero,
|
||||
color: colorScheme.surfaceContainer,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
SwitchListTile.adaptive(
|
||||
value:
|
||||
contextualIdentity.value != null &&
|
||||
isolatedAppLinkSettings.value,
|
||||
title: const Text('Isolated App Link Settings'),
|
||||
subtitle: Text(
|
||||
contextualIdentity.value != null
|
||||
? 'Use a separate open-in-app mode and remembered '
|
||||
'site rules for this container instead of the '
|
||||
'global settings'
|
||||
: 'Requires cookie isolation to be enabled',
|
||||
),
|
||||
secondary: const Icon(MdiIcons.openInApp),
|
||||
onChanged: (contextualIdentity.value != null)
|
||||
? (value) {
|
||||
isolatedAppLinkSettings.value = value;
|
||||
}
|
||||
: null,
|
||||
),
|
||||
// The per-container mode + rules live in GeneralSettings
|
||||
// (keyed by the persisted contextId) and are edited live,
|
||||
// like the global app-link settings. Only offered in edit
|
||||
// mode against the saved, immutable contextId — a create
|
||||
// draft's contextId can still churn (cookie-isolation
|
||||
// toggling regenerates it), which would orphan overrides.
|
||||
if (_mode == _DialogMode.edit &&
|
||||
initialContainer.metadata.contextualIdentity !=
|
||||
null &&
|
||||
isolatedAppLinkSettings.value) ...[
|
||||
const Divider(height: 1, indent: 56),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.tune),
|
||||
title: const Text('App Link Behavior'),
|
||||
subtitle: const Text(
|
||||
"Configure this container's open-in-app mode and "
|
||||
'remembered sites',
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () async {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) =>
|
||||
ContainerAppLinkSettingsDialog(
|
||||
contextId: initialContainer
|
||||
.metadata
|
||||
.contextualIdentity!,
|
||||
containerName:
|
||||
textController.text.trim().isNotEmpty
|
||||
? textController.text.trim()
|
||||
: initialContainer.name,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -23,7 +23,7 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/core/design/app_colors.dart';
|
||||
import 'package:weblibre/core/routing/routes.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart';
|
||||
import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart';
|
||||
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
|
||||
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
|
||||
@@ -709,7 +709,15 @@ class _AppLinksModeSection extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final appLinksMode = ref.watch(
|
||||
appLinksModeProvider.select((value) => value.value),
|
||||
generalSettingsWithDefaultsProvider.select((s) => s.appLinksMode),
|
||||
);
|
||||
final marketplaceFallback = ref.watch(
|
||||
generalSettingsWithDefaultsProvider.select(
|
||||
(s) => s.appLinkMarketplaceFallback,
|
||||
),
|
||||
);
|
||||
final rules = ref.watch(
|
||||
generalSettingsWithDefaultsProvider.select((s) => s.appLinkRules),
|
||||
);
|
||||
|
||||
return Padding(
|
||||
@@ -730,7 +738,9 @@ class _AppLinksModeSection extends HookConsumerWidget {
|
||||
groupValue: appLinksMode,
|
||||
onChanged: (value) async {
|
||||
if (value != null) {
|
||||
await ref.read(appLinksModeProvider.notifier).setMode(value);
|
||||
await ref
|
||||
.read(saveGeneralSettingsControllerProvider.notifier)
|
||||
.save((current) => current.copyWith.appLinksMode(value));
|
||||
}
|
||||
},
|
||||
child: const Column(
|
||||
@@ -757,12 +767,96 @@ class _AppLinksModeSection extends HookConsumerWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
SwitchListTile.adaptive(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Offer app store fallback'),
|
||||
subtitle: const Text(
|
||||
"When a link points to an app you don't have installed and there "
|
||||
'is no web fallback, offer to open the app store',
|
||||
),
|
||||
value: marketplaceFallback,
|
||||
onChanged: appLinksMode == AppLinksMode.never
|
||||
? null
|
||||
: (value) async {
|
||||
await ref
|
||||
.read(saveGeneralSettingsControllerProvider.notifier)
|
||||
.save(
|
||||
(current) =>
|
||||
current.copyWith.appLinkMarketplaceFallback(value),
|
||||
);
|
||||
},
|
||||
),
|
||||
_AppLinkRulesSubsection(rules: rules),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Managed per-site app-link rules (§2.5): "always open" and "never open"
|
||||
/// decisions the user remembered from a prompt. Read-only list with removal.
|
||||
class _AppLinkRulesSubsection extends ConsumerWidget {
|
||||
final Map<String, PersistedAppLinkRule> rules;
|
||||
|
||||
const _AppLinkRulesSubsection({required this.rules});
|
||||
|
||||
String _displayScope(String scope) {
|
||||
if (scope.startsWith('host:')) return scope.substring('host:'.length);
|
||||
if (scope.startsWith('pkg:')) return scope.substring('pkg:'.length);
|
||||
return scope;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
if (rules.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final entries = rules.entries.toList()
|
||||
..sort((a, b) => a.key.compareTo(b.key));
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 16, bottom: 4),
|
||||
child: Text('Remembered site rules'),
|
||||
),
|
||||
for (final MapEntry(:key, :value) in entries)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
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 ref
|
||||
.read(saveGeneralSettingsControllerProvider.notifier)
|
||||
.save(
|
||||
(current) => current.copyWith.appLinkRules({
|
||||
...current.appLinkRules,
|
||||
}..remove(key)),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GlobalDesktopModeTile extends HookConsumerWidget {
|
||||
const _GlobalDesktopModeTile();
|
||||
|
||||
|
||||
@@ -20,8 +20,12 @@
|
||||
import 'package:copy_with_extension/copy_with_extension.dart';
|
||||
import 'package:fast_equatable/fast_equatable.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
|
||||
show AppLinksMode;
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:weblibre/core/routing/routes.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/bangs/data/models/bang_group.dart';
|
||||
import 'package:weblibre/features/bangs/data/models/bang_key.dart';
|
||||
import 'package:weblibre/features/intent_gatekeeper/domain/entities/intent_source_policy.dart';
|
||||
@@ -202,6 +206,30 @@ class GeneralSettings with FastEquatable {
|
||||
/// via the intent gatekeeper prefs bridge. Defaults to true.
|
||||
final bool customTabsEnabled;
|
||||
|
||||
/// Global app-links behaviour: always open in native apps, ask each time, or
|
||||
/// never leave the browser. Defaults to [AppLinksMode.ask]. Per-site rules in
|
||||
/// [appLinkRules] and container/proxy protection can override this per-target.
|
||||
final AppLinksMode appLinksMode;
|
||||
|
||||
/// Remembered per-scope app-link rules, keyed by canonical scope
|
||||
/// (`host:youtube.com` | `pkg:...`). One rule per scope, last write wins.
|
||||
/// Malformed entries are dropped on read (see [parseAppLinkRules]).
|
||||
@JsonKey(fromJson: parseAppLinkRules)
|
||||
final Map<String, PersistedAppLinkRule> appLinkRules;
|
||||
|
||||
/// Per-container app-link overrides for containers with "isolated app link
|
||||
/// settings" enabled, keyed by the container's Gecko contextId. Each entry
|
||||
/// fully *replaces* the global mode + [appLinkRules] for navigations in that
|
||||
/// container (replace semantics). Containers without isolation have no entry
|
||||
/// and fall back to the global policy. Malformed entries dropped on read.
|
||||
@JsonKey(fromJson: parseAppLinkContextOverrides)
|
||||
final Map<String, ContextAppLinkPolicy> appLinkContextOverrides;
|
||||
|
||||
/// Whether an install-app (marketplace) intent is offered when an app link
|
||||
/// resolves to no installed app and has no validated http(s) fallback.
|
||||
/// Defaults to false — the wrong default for a de-Googled browser.
|
||||
final bool appLinkMarketplaceFallback;
|
||||
|
||||
/// Whether the local search index (`history` table populated via tab→
|
||||
/// history triggers) is active. When false, the SQL trigger guard returns
|
||||
/// without writing; existing rows stay until the user clears them.
|
||||
@@ -296,6 +324,10 @@ class GeneralSettings with FastEquatable {
|
||||
required this.blockExternalAppsEnabled,
|
||||
required this.externalAppIntentPolicies,
|
||||
required this.customTabsEnabled,
|
||||
required this.appLinksMode,
|
||||
required this.appLinkRules,
|
||||
required this.appLinkContextOverrides,
|
||||
required this.appLinkMarketplaceFallback,
|
||||
required this.enableLocalSearchIndex,
|
||||
required this.indexPrivateTabs,
|
||||
required this.acceptSuggestionOnSubmit,
|
||||
@@ -364,6 +396,10 @@ class GeneralSettings with FastEquatable {
|
||||
bool? blockExternalAppsEnabled,
|
||||
Map<String, IntentSourcePolicy>? externalAppIntentPolicies,
|
||||
bool? customTabsEnabled,
|
||||
AppLinksMode? appLinksMode,
|
||||
Map<String, PersistedAppLinkRule>? appLinkRules,
|
||||
Map<String, ContextAppLinkPolicy>? appLinkContextOverrides,
|
||||
bool? appLinkMarketplaceFallback,
|
||||
bool? enableLocalSearchIndex,
|
||||
bool? indexPrivateTabs,
|
||||
bool? acceptSuggestionOnSubmit,
|
||||
@@ -442,6 +478,10 @@ class GeneralSettings with FastEquatable {
|
||||
blockExternalAppsEnabled = blockExternalAppsEnabled ?? false,
|
||||
externalAppIntentPolicies = externalAppIntentPolicies ?? const {},
|
||||
customTabsEnabled = customTabsEnabled ?? true,
|
||||
appLinksMode = appLinksMode ?? AppLinksMode.ask,
|
||||
appLinkRules = appLinkRules ?? const {},
|
||||
appLinkContextOverrides = appLinkContextOverrides ?? const {},
|
||||
appLinkMarketplaceFallback = appLinkMarketplaceFallback ?? false,
|
||||
enableLocalSearchIndex = enableLocalSearchIndex ?? true,
|
||||
indexPrivateTabs = indexPrivateTabs ?? false,
|
||||
acceptSuggestionOnSubmit = acceptSuggestionOnSubmit ?? true,
|
||||
@@ -593,6 +633,10 @@ class GeneralSettings with FastEquatable {
|
||||
blockExternalAppsEnabled,
|
||||
externalAppIntentPolicies,
|
||||
customTabsEnabled,
|
||||
appLinksMode,
|
||||
appLinkRules,
|
||||
appLinkContextOverrides,
|
||||
appLinkMarketplaceFallback,
|
||||
enableLocalSearchIndex,
|
||||
indexPrivateTabs,
|
||||
acceptSuggestionOnSubmit,
|
||||
|
||||
@@ -143,6 +143,16 @@ abstract class _$GeneralSettingsCWProxy {
|
||||
|
||||
GeneralSettings customTabsEnabled(bool customTabsEnabled);
|
||||
|
||||
GeneralSettings appLinksMode(AppLinksMode appLinksMode);
|
||||
|
||||
GeneralSettings appLinkRules(Map<String, PersistedAppLinkRule> appLinkRules);
|
||||
|
||||
GeneralSettings appLinkContextOverrides(
|
||||
Map<String, ContextAppLinkPolicy> appLinkContextOverrides,
|
||||
);
|
||||
|
||||
GeneralSettings appLinkMarketplaceFallback(bool appLinkMarketplaceFallback);
|
||||
|
||||
GeneralSettings enableLocalSearchIndex(bool enableLocalSearchIndex);
|
||||
|
||||
GeneralSettings indexPrivateTabs(bool indexPrivateTabs);
|
||||
@@ -223,6 +233,10 @@ abstract class _$GeneralSettingsCWProxy {
|
||||
bool blockExternalAppsEnabled,
|
||||
Map<String, IntentSourcePolicy> externalAppIntentPolicies,
|
||||
bool customTabsEnabled,
|
||||
AppLinksMode appLinksMode,
|
||||
Map<String, PersistedAppLinkRule> appLinkRules,
|
||||
Map<String, ContextAppLinkPolicy> appLinkContextOverrides,
|
||||
bool appLinkMarketplaceFallback,
|
||||
bool enableLocalSearchIndex,
|
||||
bool indexPrivateTabs,
|
||||
bool acceptSuggestionOnSubmit,
|
||||
@@ -490,6 +504,24 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
|
||||
GeneralSettings customTabsEnabled(bool customTabsEnabled) =>
|
||||
call(customTabsEnabled: customTabsEnabled);
|
||||
|
||||
@override
|
||||
GeneralSettings appLinksMode(AppLinksMode appLinksMode) =>
|
||||
call(appLinksMode: appLinksMode);
|
||||
|
||||
@override
|
||||
GeneralSettings appLinkRules(
|
||||
Map<String, PersistedAppLinkRule> appLinkRules,
|
||||
) => call(appLinkRules: appLinkRules);
|
||||
|
||||
@override
|
||||
GeneralSettings appLinkContextOverrides(
|
||||
Map<String, ContextAppLinkPolicy> appLinkContextOverrides,
|
||||
) => call(appLinkContextOverrides: appLinkContextOverrides);
|
||||
|
||||
@override
|
||||
GeneralSettings appLinkMarketplaceFallback(bool appLinkMarketplaceFallback) =>
|
||||
call(appLinkMarketplaceFallback: appLinkMarketplaceFallback);
|
||||
|
||||
@override
|
||||
GeneralSettings enableLocalSearchIndex(bool enableLocalSearchIndex) =>
|
||||
call(enableLocalSearchIndex: enableLocalSearchIndex);
|
||||
@@ -586,6 +618,10 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
|
||||
Object? blockExternalAppsEnabled = const $CopyWithPlaceholder(),
|
||||
Object? externalAppIntentPolicies = const $CopyWithPlaceholder(),
|
||||
Object? customTabsEnabled = const $CopyWithPlaceholder(),
|
||||
Object? appLinksMode = const $CopyWithPlaceholder(),
|
||||
Object? appLinkRules = const $CopyWithPlaceholder(),
|
||||
Object? appLinkContextOverrides = const $CopyWithPlaceholder(),
|
||||
Object? appLinkMarketplaceFallback = const $CopyWithPlaceholder(),
|
||||
Object? enableLocalSearchIndex = const $CopyWithPlaceholder(),
|
||||
Object? indexPrivateTabs = const $CopyWithPlaceholder(),
|
||||
Object? acceptSuggestionOnSubmit = const $CopyWithPlaceholder(),
|
||||
@@ -938,6 +974,28 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
|
||||
? _value.customTabsEnabled
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: customTabsEnabled as bool,
|
||||
appLinksMode:
|
||||
appLinksMode == const $CopyWithPlaceholder() || appLinksMode == null
|
||||
? _value.appLinksMode
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: appLinksMode as AppLinksMode,
|
||||
appLinkRules:
|
||||
appLinkRules == const $CopyWithPlaceholder() || appLinkRules == null
|
||||
? _value.appLinkRules
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: appLinkRules as Map<String, PersistedAppLinkRule>,
|
||||
appLinkContextOverrides:
|
||||
appLinkContextOverrides == const $CopyWithPlaceholder() ||
|
||||
appLinkContextOverrides == null
|
||||
? _value.appLinkContextOverrides
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: appLinkContextOverrides as Map<String, ContextAppLinkPolicy>,
|
||||
appLinkMarketplaceFallback:
|
||||
appLinkMarketplaceFallback == const $CopyWithPlaceholder() ||
|
||||
appLinkMarketplaceFallback == null
|
||||
? _value.appLinkMarketplaceFallback
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: appLinkMarketplaceFallback as bool,
|
||||
enableLocalSearchIndex:
|
||||
enableLocalSearchIndex == const $CopyWithPlaceholder() ||
|
||||
enableLocalSearchIndex == null
|
||||
@@ -1110,6 +1168,17 @@ GeneralSettings _$GeneralSettingsFromJson(
|
||||
(k, e) => MapEntry(k, $enumDecode(_$IntentSourcePolicyEnumMap, e)),
|
||||
),
|
||||
customTabsEnabled: json['customTabsEnabled'] as bool?,
|
||||
appLinksMode: $enumDecodeNullable(
|
||||
_$AppLinksModeEnumMap,
|
||||
json['appLinksMode'],
|
||||
),
|
||||
appLinkRules: parseAppLinkRules(
|
||||
json['appLinkRules'] as Map<String, dynamic>?,
|
||||
),
|
||||
appLinkContextOverrides: parseAppLinkContextOverrides(
|
||||
json['appLinkContextOverrides'] as Map<String, dynamic>?,
|
||||
),
|
||||
appLinkMarketplaceFallback: json['appLinkMarketplaceFallback'] as bool?,
|
||||
enableLocalSearchIndex: json['enableLocalSearchIndex'] as bool?,
|
||||
indexPrivateTabs: json['indexPrivateTabs'] as bool?,
|
||||
acceptSuggestionOnSubmit: json['acceptSuggestionOnSubmit'] as bool?,
|
||||
@@ -1196,6 +1265,12 @@ Map<String, dynamic> _$GeneralSettingsToJson(
|
||||
(k, e) => MapEntry(k, _$IntentSourcePolicyEnumMap[e]!),
|
||||
),
|
||||
'customTabsEnabled': instance.customTabsEnabled,
|
||||
'appLinksMode': _$AppLinksModeEnumMap[instance.appLinksMode]!,
|
||||
'appLinkRules': instance.appLinkRules.map((k, e) => MapEntry(k, e.toJson())),
|
||||
'appLinkContextOverrides': instance.appLinkContextOverrides.map(
|
||||
(k, e) => MapEntry(k, e.toJson()),
|
||||
),
|
||||
'appLinkMarketplaceFallback': instance.appLinkMarketplaceFallback,
|
||||
'enableLocalSearchIndex': instance.enableLocalSearchIndex,
|
||||
'indexPrivateTabs': instance.indexPrivateTabs,
|
||||
'acceptSuggestionOnSubmit': instance.acceptSuggestionOnSubmit,
|
||||
@@ -1283,3 +1358,9 @@ const _$IntentSourcePolicyEnumMap = {
|
||||
IntentSourcePolicy.allow: 'allow',
|
||||
IntentSourcePolicy.block: 'block',
|
||||
};
|
||||
|
||||
const _$AppLinksModeEnumMap = {
|
||||
AppLinksMode.always: 'always',
|
||||
AppLinksMode.ask: 'ask',
|
||||
AppLinksMode.never: 'never',
|
||||
};
|
||||
|
||||
@@ -272,6 +272,18 @@ class GeneralSettingsRepository extends _$GeneralSettingsRepository {
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'appLinksMode': settings['appLinksMode']?.readAs(
|
||||
DriftSqlType.string,
|
||||
db.typeMapping,
|
||||
),
|
||||
'appLinkRules': settings['appLinkRules']
|
||||
?.readAs(DriftSqlType.string, db.typeMapping)
|
||||
.mapNotNull(jsonDecode),
|
||||
'appLinkContextOverrides': settings['appLinkContextOverrides']
|
||||
?.readAs(DriftSqlType.string, db.typeMapping)
|
||||
.mapNotNull(jsonDecode),
|
||||
'appLinkMarketplaceFallback': settings['appLinkMarketplaceFallback']
|
||||
?.readAs(DriftSqlType.bool, db.typeMapping),
|
||||
'enableLocalSearchIndex': settings['enableLocalSearchIndex']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
|
||||
@@ -35,7 +35,7 @@ final class GeneralSettingsRepositoryProvider
|
||||
}
|
||||
|
||||
String _$generalSettingsRepositoryHash() =>
|
||||
r'4e72c8ebed8b08ced417ca24d6e4a840f2abf1be';
|
||||
r'7020706aafbac7ee64f678f918ef9fc24c3b98fb';
|
||||
|
||||
abstract class _$GeneralSettingsRepository
|
||||
extends $StreamNotifier<GeneralSettings> {
|
||||
|
||||
@@ -33,7 +33,7 @@ final class ProfileRepositoryProvider
|
||||
ProfileRepository create() => ProfileRepository();
|
||||
}
|
||||
|
||||
String _$profileRepositoryHash() => r'3055487626bdf6bdc6a51284f68eaf4067cd52ef';
|
||||
String _$profileRepositoryHash() => r'504539c5ec7c9126ed7b07d920820af481f40444';
|
||||
|
||||
abstract class _$ProfileRepository extends $AsyncNotifier<List<Profile>> {
|
||||
FutureOr<List<Profile>> build();
|
||||
|
||||
@@ -226,7 +226,7 @@ final class PushDistributorMutationProvider
|
||||
}
|
||||
|
||||
String _$pushDistributorMutationHash() =>
|
||||
r'5797ca731c90c1e06e089fb71ad602aecda59634';
|
||||
r'58e489179c2e1fdaf6d8a6bd3b758ec16641358c';
|
||||
|
||||
abstract class _$PushDistributorMutation extends $AsyncNotifier<void> {
|
||||
FutureOr<void> build();
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* 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:flutter_test/flutter_test.dart';
|
||||
import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart';
|
||||
import 'package:weblibre/features/app_links/presentation/widgets/app_link_prompt_dialog.dart';
|
||||
|
||||
AppLinkTarget _target({
|
||||
String url = 'https://youtu.be/abc',
|
||||
String? packageName = 'com.google.android.youtube',
|
||||
bool isAmbiguous = false,
|
||||
String scopeKey = 'host:youtu.be',
|
||||
bool engineSupportsScheme = true,
|
||||
}) {
|
||||
return AppLinkTarget(
|
||||
url: url,
|
||||
appName: 'YouTube',
|
||||
packageName: packageName,
|
||||
fallbackUrl: null,
|
||||
isMarketplace: false,
|
||||
isAmbiguous: isAmbiguous,
|
||||
engineSupportsScheme: engineSupportsScheme,
|
||||
scopeKey: scopeKey,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('alwaysOpenRuleFor', () {
|
||||
test('binds the resolved package to the target scope', () {
|
||||
final rule = alwaysOpenRuleFor(_target());
|
||||
expect(rule, isNotNull);
|
||||
expect(rule!.decision, AppLinkRuleDecision.alwaysOpen);
|
||||
expect(rule.scope, 'host:youtu.be');
|
||||
expect(rule.packageName, 'com.google.android.youtube');
|
||||
});
|
||||
|
||||
test('cannot be remembered for an ambiguous resolution', () {
|
||||
expect(alwaysOpenRuleFor(_target(isAmbiguous: true)), isNull);
|
||||
});
|
||||
|
||||
test('cannot be remembered without a bound package', () {
|
||||
expect(alwaysOpenRuleFor(_target(packageName: null)), isNull);
|
||||
expect(alwaysOpenRuleFor(_target(packageName: '')), isNull);
|
||||
});
|
||||
|
||||
test('scopes a custom-scheme target by its package key', () {
|
||||
final rule = alwaysOpenRuleFor(
|
||||
_target(
|
||||
url: 'zoommtg://zoom.us/join',
|
||||
packageName: 'us.zoom.videomeetings',
|
||||
scopeKey: 'pkg:us.zoom.videomeetings',
|
||||
engineSupportsScheme: false,
|
||||
),
|
||||
);
|
||||
expect(rule, isNotNull);
|
||||
expect(rule!.scope, 'pkg:us.zoom.videomeetings');
|
||||
expect(rule.packageName, 'us.zoom.videomeetings');
|
||||
});
|
||||
});
|
||||
|
||||
group('neverOpenRuleFor', () {
|
||||
test('scopes to the target without binding a package', () {
|
||||
final rule = neverOpenRuleFor(_target());
|
||||
expect(rule.decision, AppLinkRuleDecision.neverOpen);
|
||||
expect(rule.scope, 'host:youtu.be');
|
||||
expect(rule.packageName, isNull);
|
||||
});
|
||||
|
||||
test('is producible even for an ambiguous resolution', () {
|
||||
// neverOpen never launches, so it does not need a bound package.
|
||||
final rule = neverOpenRuleFor(_target(isAmbiguous: true, packageName: null));
|
||||
expect(rule.decision, AppLinkRuleDecision.neverOpen);
|
||||
expect(rule.isValid, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* 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_test/flutter_test.dart';
|
||||
import 'package:weblibre/features/app_links/domain/entities/app_link_rule.dart';
|
||||
|
||||
void main() {
|
||||
group('PersistedAppLinkRule', () {
|
||||
test('round-trips through json', () {
|
||||
final rule = PersistedAppLinkRule(
|
||||
decision: AppLinkRuleDecision.alwaysOpen,
|
||||
scope: 'host:youtube.com',
|
||||
packageName: 'com.google.android.youtube',
|
||||
);
|
||||
final restored = PersistedAppLinkRule.fromJson(rule.toJson());
|
||||
expect(restored, rule);
|
||||
});
|
||||
|
||||
test('validity requires a package for alwaysOpen and a known prefix', () {
|
||||
expect(
|
||||
PersistedAppLinkRule(
|
||||
decision: AppLinkRuleDecision.alwaysOpen,
|
||||
scope: 'host:x.com',
|
||||
packageName: 'pkg',
|
||||
).isValid,
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
PersistedAppLinkRule(
|
||||
decision: AppLinkRuleDecision.alwaysOpen,
|
||||
scope: 'host:x.com',
|
||||
).isValid,
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
PersistedAppLinkRule(
|
||||
decision: AppLinkRuleDecision.neverOpen,
|
||||
scope: 'host:x.com',
|
||||
).isValid,
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
PersistedAppLinkRule(
|
||||
decision: AppLinkRuleDecision.neverOpen,
|
||||
scope: 'notaprefix',
|
||||
).isValid,
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('parseAppLinkRules', () {
|
||||
test('keeps valid rules keyed by matching scope', () {
|
||||
final parsed = parseAppLinkRules({
|
||||
'host:youtube.com': {
|
||||
'decision': 'alwaysOpen',
|
||||
'scope': 'host:youtube.com',
|
||||
'packageName': 'com.google.android.youtube',
|
||||
},
|
||||
'pkg:us.zoom.videomeetings': {
|
||||
'decision': 'neverOpen',
|
||||
'scope': 'pkg:us.zoom.videomeetings',
|
||||
},
|
||||
});
|
||||
expect(parsed.length, 2);
|
||||
expect(parsed['host:youtube.com']!.decision, AppLinkRuleDecision.alwaysOpen);
|
||||
});
|
||||
|
||||
test('drops entries whose map key disagrees with the rule scope', () {
|
||||
final parsed = parseAppLinkRules({
|
||||
'host:wrong.com': {
|
||||
'decision': 'neverOpen',
|
||||
'scope': 'host:right.com',
|
||||
},
|
||||
});
|
||||
expect(parsed, isEmpty);
|
||||
});
|
||||
|
||||
test('drops malformed and invalid rules', () {
|
||||
final parsed = parseAppLinkRules({
|
||||
'host:a.com': {'decision': 'garbage', 'scope': 'host:a.com'},
|
||||
'host:b.com': {
|
||||
'decision': 'alwaysOpen',
|
||||
'scope': 'host:b.com',
|
||||
}, // missing package
|
||||
'host:c.com': 'not a map',
|
||||
});
|
||||
expect(parsed, isEmpty);
|
||||
});
|
||||
|
||||
test('null input yields an empty map', () {
|
||||
expect(parseAppLinkRules(null), isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* 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_test/flutter_test.dart';
|
||||
import 'package:weblibre/features/app_links/domain/services/effective_app_link_policy.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
|
||||
|
||||
ContainerDataWithCount _container(
|
||||
String id, {
|
||||
String? contextId,
|
||||
bool isolatedAppLinkSettings = false,
|
||||
}) {
|
||||
return ContainerDataWithCount(
|
||||
id: id,
|
||||
name: 'Container $id',
|
||||
color: const Color(0xFF336699),
|
||||
orderKey: 'a',
|
||||
metadata: ContainerMetadata.withDefaults(
|
||||
contextualIdentity: contextId,
|
||||
isolatedAppLinkSettings: isolatedAppLinkSettings,
|
||||
),
|
||||
tabCount: 0,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('resolveAppLinkOverrideKey', () {
|
||||
test('null contextId resolves to the global bucket', () {
|
||||
final key = resolveAppLinkOverrideKey(
|
||||
liveContextId: null,
|
||||
containers: [
|
||||
_container('1', contextId: 'ctx-1', isolatedAppLinkSettings: true),
|
||||
],
|
||||
isolationContextContainerMap: const {},
|
||||
);
|
||||
expect(key, isNull);
|
||||
});
|
||||
|
||||
test('regular tab in a non-isolated container resolves globally', () {
|
||||
final key = resolveAppLinkOverrideKey(
|
||||
liveContextId: 'ctx-1',
|
||||
containers: [_container('1', contextId: 'ctx-1')],
|
||||
isolationContextContainerMap: const {},
|
||||
);
|
||||
expect(key, isNull);
|
||||
});
|
||||
|
||||
test(
|
||||
'regular tab in an isolated-app-link container resolves to its base',
|
||||
() {
|
||||
final key = resolveAppLinkOverrideKey(
|
||||
liveContextId: 'ctx-1',
|
||||
containers: [
|
||||
_container('1', contextId: 'ctx-1', isolatedAppLinkSettings: true),
|
||||
],
|
||||
isolationContextContainerMap: const {},
|
||||
);
|
||||
expect(key, 'ctx-1');
|
||||
},
|
||||
);
|
||||
|
||||
test('isolated tab resolves via the isolation map', () {
|
||||
final key = resolveAppLinkOverrideKey(
|
||||
liveContextId: 'iso-1',
|
||||
containers: [
|
||||
_container('1', contextId: 'ctx-1', isolatedAppLinkSettings: true),
|
||||
],
|
||||
isolationContextContainerMap: const {
|
||||
'iso-1': {'1'},
|
||||
},
|
||||
);
|
||||
expect(key, 'ctx-1');
|
||||
});
|
||||
|
||||
test(
|
||||
'isolated tab of a non-isolated-app-link container resolves globally',
|
||||
() {
|
||||
final key = resolveAppLinkOverrideKey(
|
||||
liveContextId: 'iso-1',
|
||||
containers: [_container('1', contextId: 'ctx-1')],
|
||||
isolationContextContainerMap: const {
|
||||
'iso-1': {'1'},
|
||||
},
|
||||
);
|
||||
expect(key, isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test('shared isolation context picks the lowest sorted base contextId', () {
|
||||
final key = resolveAppLinkOverrideKey(
|
||||
liveContextId: 'iso-1',
|
||||
containers: [
|
||||
_container('1', contextId: 'ctx-b', isolatedAppLinkSettings: true),
|
||||
_container('2', contextId: 'ctx-a', isolatedAppLinkSettings: true),
|
||||
],
|
||||
isolationContextContainerMap: const {
|
||||
'iso-1': {'1', '2'},
|
||||
},
|
||||
);
|
||||
expect(key, 'ctx-a');
|
||||
});
|
||||
|
||||
test(
|
||||
'shared isolation context skips containers without isolated settings',
|
||||
() {
|
||||
final key = resolveAppLinkOverrideKey(
|
||||
liveContextId: 'iso-1',
|
||||
containers: [
|
||||
_container('1', contextId: 'ctx-a'),
|
||||
_container('2', contextId: 'ctx-b', isolatedAppLinkSettings: true),
|
||||
],
|
||||
isolationContextContainerMap: const {
|
||||
'iso-1': {'1', '2'},
|
||||
},
|
||||
);
|
||||
expect(key, 'ctx-b');
|
||||
},
|
||||
);
|
||||
|
||||
test('unknown contextId resolves globally', () {
|
||||
final key = resolveAppLinkOverrideKey(
|
||||
liveContextId: 'ctx-unknown',
|
||||
containers: [
|
||||
_container('1', contextId: 'ctx-1', isolatedAppLinkSettings: true),
|
||||
],
|
||||
isolationContextContainerMap: const {},
|
||||
);
|
||||
expect(key, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
* 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_test/flutter_test.dart';
|
||||
import 'package:weblibre/features/app_links/domain/services/effective_routing.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/models/site_assignment.dart';
|
||||
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
|
||||
|
||||
SiteAssignment _assignment(String site, {String? contextId}) => SiteAssignment(
|
||||
id: site,
|
||||
contextualIdentity: contextId,
|
||||
assignedSite: site,
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('resolveContainerAssignment', () {
|
||||
test('explicit proxy connection wins', () {
|
||||
final assignment = resolveContainerAssignment(
|
||||
contextId: 'ctx',
|
||||
proxyConnectionId: const TorProxyConnectionId(),
|
||||
bypassGlobalProxy: false,
|
||||
);
|
||||
expect(assignment, isA<ExplicitProxyAssignment>());
|
||||
});
|
||||
|
||||
test('bypassGlobalProxy with no proxy is direct scoped to the context', () {
|
||||
final assignment = resolveContainerAssignment(
|
||||
contextId: 'ctx',
|
||||
proxyConnectionId: null,
|
||||
bypassGlobalProxy: true,
|
||||
);
|
||||
expect(assignment, isA<DirectProxyAssignment>());
|
||||
expect((assignment as DirectProxyAssignment).scopeId, 'ctx');
|
||||
});
|
||||
|
||||
test('no proxy and no bypass inherits', () {
|
||||
final assignment = resolveContainerAssignment(
|
||||
contextId: 'ctx',
|
||||
proxyConnectionId: null,
|
||||
bypassGlobalProxy: false,
|
||||
);
|
||||
expect(assignment, isA<InheritProxyAssignment>());
|
||||
});
|
||||
});
|
||||
|
||||
group('resolveIsolationContextRouting', () {
|
||||
test('any explicit proxy wins (lowest sorted id)', () {
|
||||
final routing = resolveIsolationContextRouting([
|
||||
ProxyAssignment.inherit(),
|
||||
ProxyAssignment.explicit('zeta'),
|
||||
ProxyAssignment.explicit('alpha'),
|
||||
ProxyAssignment.direct('scope'),
|
||||
]);
|
||||
expect(routing.chosen, isA<ExplicitProxyAssignment>());
|
||||
expect((routing.chosen as ExplicitProxyAssignment).proxyId, 'alpha');
|
||||
expect(routing.distinctAssignmentCount, 4);
|
||||
});
|
||||
|
||||
test('direct wins only when no container inherits', () {
|
||||
final routing = resolveIsolationContextRouting([
|
||||
ProxyAssignment.direct('scopeB'),
|
||||
ProxyAssignment.direct('scopeA'),
|
||||
]);
|
||||
expect(routing.chosen, isA<DirectProxyAssignment>());
|
||||
expect((routing.chosen as DirectProxyAssignment).scopeId, 'scopeA');
|
||||
});
|
||||
|
||||
test('direct plus inherit collapses to inherit', () {
|
||||
final routing = resolveIsolationContextRouting([
|
||||
ProxyAssignment.direct('scope'),
|
||||
ProxyAssignment.inherit(),
|
||||
]);
|
||||
expect(routing.chosen, isA<InheritProxyAssignment>());
|
||||
expect(routing.distinctAssignmentCount, 2);
|
||||
expect(routing.assignmentLabels, ['inherit', 'direct:scope']);
|
||||
});
|
||||
});
|
||||
|
||||
group('isAssignmentProtected', () {
|
||||
test('explicit is always protected', () {
|
||||
expect(
|
||||
isAssignmentProtected(
|
||||
ProxyAssignment.explicit('p'),
|
||||
protectGeneralContext: false,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('direct is never protected', () {
|
||||
expect(
|
||||
isAssignmentProtected(
|
||||
ProxyAssignment.direct('s'),
|
||||
protectGeneralContext: true,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('inherit follows the general context', () {
|
||||
expect(
|
||||
isAssignmentProtected(
|
||||
ProxyAssignment.inherit(),
|
||||
protectGeneralContext: true,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
isAssignmentProtected(
|
||||
ProxyAssignment.inherit(),
|
||||
protectGeneralContext: false,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('protectedTargetPatternForSite', () {
|
||||
test('wildcard entry includes subdomains and ignores port', () {
|
||||
final pattern = protectedTargetPatternForSite(
|
||||
Uri.parse('https://*.example.com'),
|
||||
);
|
||||
expect(pattern.scheme, 'https');
|
||||
expect(pattern.hostOrSuffix, 'example.com');
|
||||
expect(pattern.includeSubdomains, isTrue);
|
||||
expect(pattern.port, isNull);
|
||||
});
|
||||
|
||||
test('exact entry preserves effective port', () {
|
||||
final defaultPort = protectedTargetPatternForSite(
|
||||
Uri.parse('https://example.com'),
|
||||
);
|
||||
expect(defaultPort.hostOrSuffix, 'example.com');
|
||||
expect(defaultPort.includeSubdomains, isFalse);
|
||||
expect(defaultPort.port, 443);
|
||||
|
||||
final explicitPort = protectedTargetPatternForSite(
|
||||
Uri.parse('http://example.com:8080'),
|
||||
);
|
||||
expect(explicitPort.port, 8080);
|
||||
});
|
||||
});
|
||||
|
||||
group('computeProtectedTargetPatterns', () {
|
||||
test('keeps only assignments in a protected or strict container', () {
|
||||
final patterns = computeProtectedTargetPatterns(
|
||||
assignments: [
|
||||
_assignment('https://proxied.example', contextId: 'proxied'),
|
||||
_assignment('https://direct.example', contextId: 'direct'),
|
||||
_assignment('https://strict.example', contextId: 'strict'),
|
||||
_assignment('https://unassigned.example', contextId: null),
|
||||
],
|
||||
protectedOrStrictContextIds: {'proxied', 'strict'},
|
||||
);
|
||||
|
||||
final hosts = patterns.map((p) => p.hostOrSuffix).toSet();
|
||||
expect(hosts, {'proxied.example', 'strict.example'});
|
||||
});
|
||||
|
||||
test('deduplicates identical patterns', () {
|
||||
final patterns = computeProtectedTargetPatterns(
|
||||
assignments: [
|
||||
_assignment('https://dup.example', contextId: 'a'),
|
||||
_assignment('https://dup.example', contextId: 'a'),
|
||||
],
|
||||
protectedOrStrictContextIds: {'a'},
|
||||
);
|
||||
expect(patterns.length, 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* 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:flutter_test/flutter_test.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/user/data/models/general_settings.dart';
|
||||
|
||||
void main() {
|
||||
group('GeneralSettings app-link fields', () {
|
||||
test('defaults are ask / empty rules / marketplace off', () {
|
||||
final settings = GeneralSettings.withDefaults();
|
||||
expect(settings.appLinksMode, AppLinksMode.ask);
|
||||
expect(settings.appLinkRules, isEmpty);
|
||||
expect(settings.appLinkMarketplaceFallback, isFalse);
|
||||
});
|
||||
|
||||
test('the three fields survive a toJson -> fromJson round-trip', () {
|
||||
final rule = PersistedAppLinkRule(
|
||||
decision: AppLinkRuleDecision.alwaysOpen,
|
||||
scope: 'host:youtu.be',
|
||||
packageName: 'com.google.android.youtube',
|
||||
);
|
||||
final settings = GeneralSettings.withDefaults(
|
||||
appLinksMode: AppLinksMode.always,
|
||||
appLinkRules: {rule.scope: rule},
|
||||
appLinkMarketplaceFallback: true,
|
||||
);
|
||||
|
||||
final restored = GeneralSettings.fromJson(settings.toJson());
|
||||
|
||||
expect(restored.appLinksMode, AppLinksMode.always);
|
||||
expect(restored.appLinkMarketplaceFallback, isTrue);
|
||||
expect(restored.appLinkRules.keys, ['host:youtu.be']);
|
||||
expect(restored.appLinkRules['host:youtu.be'], rule);
|
||||
});
|
||||
|
||||
test('malformed persisted rules are dropped on read (parseAppLinkRules)', () {
|
||||
final json = GeneralSettings.withDefaults().toJson();
|
||||
// A scope key that disagrees with the rule's own scope is invalid and dropped.
|
||||
json['appLinkRules'] = {
|
||||
'host:youtu.be': {
|
||||
'decision': 'alwaysOpen',
|
||||
'scope': 'host:evil.example',
|
||||
'packageName': 'com.google.android.youtube',
|
||||
},
|
||||
};
|
||||
|
||||
final restored = GeneralSettings.fromJson(json);
|
||||
expect(restored.appLinkRules, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('GeneralSettings per-container app-link overrides', () {
|
||||
test('defaults to an empty override map', () {
|
||||
expect(GeneralSettings.withDefaults().appLinkContextOverrides, isEmpty);
|
||||
});
|
||||
|
||||
test('a container override survives a toJson -> fromJson round-trip', () {
|
||||
final rule = PersistedAppLinkRule(
|
||||
decision: AppLinkRuleDecision.neverOpen,
|
||||
scope: 'host:reddit.com',
|
||||
);
|
||||
final override = ContextAppLinkPolicy(
|
||||
mode: AppLinksMode.never,
|
||||
rules: {rule.scope: rule},
|
||||
);
|
||||
final settings = GeneralSettings.withDefaults(
|
||||
appLinkContextOverrides: {'work': override},
|
||||
);
|
||||
|
||||
final restored = GeneralSettings.fromJson(settings.toJson());
|
||||
|
||||
expect(restored.appLinkContextOverrides.keys, ['work']);
|
||||
final restoredOverride = restored.appLinkContextOverrides['work']!;
|
||||
expect(restoredOverride.mode, AppLinksMode.never);
|
||||
expect(restoredOverride.rules['host:reddit.com'], rule);
|
||||
});
|
||||
|
||||
test('the blank override is ask / empty rules', () {
|
||||
final blank = ContextAppLinkPolicy.blank();
|
||||
expect(blank.mode, AppLinksMode.ask);
|
||||
expect(blank.rules, isEmpty);
|
||||
});
|
||||
|
||||
test('malformed override entries are dropped on read', () {
|
||||
final json = GeneralSettings.withDefaults().toJson();
|
||||
json['appLinkContextOverrides'] = {
|
||||
'work': <String, dynamic>{'mode': 'not-a-mode'},
|
||||
};
|
||||
|
||||
final restored = GeneralSettings.fromJson(json);
|
||||
expect(restored.appLinkContextOverrides, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
+38
@@ -35,6 +35,44 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('ContainerMetadata isolatedAppLinkSettings invariant', () {
|
||||
test('stays enabled when the container has a contextId', () {
|
||||
final metadata = ContainerMetadata.withDefaults(
|
||||
contextualIdentity: 'work',
|
||||
isolatedAppLinkSettings: true,
|
||||
);
|
||||
|
||||
expect(metadata.isolatedAppLinkSettings, isTrue);
|
||||
expect(metadata.sanitized().isolatedAppLinkSettings, isTrue);
|
||||
});
|
||||
|
||||
test('is normalized off without a contextId (read + sanitized)', () {
|
||||
final metadata = ContainerMetadata.withDefaults(
|
||||
contextualIdentity: null,
|
||||
isolatedAppLinkSettings: true,
|
||||
);
|
||||
|
||||
// withDefaults normalizes on construction/read.
|
||||
expect(metadata.isolatedAppLinkSettings, isFalse);
|
||||
|
||||
// A record that somehow carries the bad combination is re-normalized.
|
||||
final restored = ContainerMetadata.fromJson({
|
||||
...metadata.toJson(),
|
||||
'isolatedAppLinkSettings': true,
|
||||
'contextualIdentity': null,
|
||||
});
|
||||
expect(restored.isolatedAppLinkSettings, isFalse);
|
||||
expect(restored.sanitized().isolatedAppLinkSettings, isFalse);
|
||||
});
|
||||
|
||||
test('defaults to false', () {
|
||||
expect(
|
||||
ContainerMetadata.withDefaults().isolatedAppLinkSettings,
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('ContainerMetadata icon serialization', () {
|
||||
test('stores MDI icon names', () {
|
||||
final metadata = ContainerMetadata.withDefaults(
|
||||
|
||||
Reference in New Issue
Block a user