app links initial

This commit is contained in:
Fabian Freund
2026-07-30 03:58:46 +02:00
parent 1b0c2b0d06
commit 4bc267969b
97 changed files with 9138 additions and 1054 deletions
@@ -0,0 +1,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',
};