prepare for multiple apps

This commit is contained in:
Fabian Freund
2026-04-06 12:23:11 +02:00
parent bd1600e8dc
commit 5afc323f04
904 changed files with 29 additions and 29 deletions
+26
View File
@@ -0,0 +1,26 @@
/*
* 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/services.dart';
import 'package:weblibre/utils/uri_parser.dart';
Future<Uri?> tryGetUriFromClipboard({bool eagerParsing = true}) async {
final data = await Clipboard.getData('text/plain');
return tryParseUrl(data?.text, eagerParsing: eagerParsing);
}
+45
View File
@@ -0,0 +1,45 @@
/*
* 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';
class Debouncer {
final Duration debounce;
Timer? _timer;
bool _hasRan = false;
Debouncer(this.debounce);
bool get isDebouncing => _timer?.isActive ?? false;
bool get hasRan => _hasRan;
void eventOccured(void Function() callback) {
_timer?.cancel();
_timer = Timer(debounce, () {
callback.call();
_hasRan = true;
});
}
void dispose() {
_timer?.cancel();
_timer = null;
}
}
+86
View File
@@ -0,0 +1,86 @@
/*
* 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:io';
import 'package:flutter/services.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:riverpod/riverpod.dart';
import 'package:weblibre/core/database_registry.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
Future<void> exitApp(ProviderContainer container) async {
logger.i('Preparing exit');
// 1. Close private/isolated tabs (clears browsing data for those contexts)
try {
await container
.read(tabDataRepositoryProvider.notifier)
.closeAllTabs(includeRegular: false);
logger.i('Private tabs closed');
} catch (e, st) {
logger.e('Failed to close tabs', error: e, stackTrace: st);
}
// 2. Stop Tor proxy (only if it was initialized)
if (container.exists(torProxyServiceProvider)) {
try {
await container.read(torProxyServiceProvider.notifier).disconnect();
logger.i('Tor proxy stopped');
} catch (e, st) {
logger.e('Failed to stop Tor proxy', error: e, stackTrace: st);
}
}
// 3. Shutdown GeckoView engine. Must happen while the activity is still
// attached so shutdown() can access the FragmentManager. Internally it:
// a) removes the BrowserFragment via commitNow() (view teardown with
// the runtime still alive),
// b) stops component-level services (FxA, account manager),
// c) shuts down GeckoRuntime (safe — no views reference it anymore).
try {
await GeckoBrowserService().shutdown();
logger.i('GeckoView engine shut down');
} catch (e, st) {
logger.e('Failed to shut down GeckoView', error: e, stackTrace: st);
}
// 4. Close all registered databases
try {
await DatabaseRegistry.instance.closeAll();
} catch (e, st) {
logger.e('Failed to close databases', error: e, stackTrace: st);
}
// 5. Dispose the Riverpod container (remaining sync cleanup).
// This fires async onDispose callbacks (e.g. stream cancellations in
// GeckoView services, viewport service) as fire-and-forget futures.
container.dispose();
logger.i('Provider container disposed');
// 6. Signal the system to finish the activity and give fire-and-forget
// async onDispose callbacks time to settle.
await SystemNavigator.pop();
await Future.delayed(const Duration(seconds: 1));
logger.i('Bye !!1');
exit(0);
}
+185
View File
@@ -0,0 +1,185 @@
/*
* 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 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:uuid/uuid_value.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/domain/entities/profile.dart';
const profilesDirName = 'weblibre_profiles';
const profileDirPrefix = 'profile-';
const _startupProfileFileName = 'current_profile';
const _metadataFile = 'metadata.json';
final profileTransformer =
StreamTransformer<FileSystemEntity, Directory>.fromHandlers(
handleData: (entity, sink) {
if (entity is Directory &&
p.basename(entity.path).startsWith(profileDirPrefix)) {
sink.add(entity);
}
},
);
Future<List<Directory>> getAvailableProfileDirectories(Directory profilesDir) {
return profilesDir.list().transform(profileTransformer).toList();
}
Future<void> clearMozillaProfileCache(
Directory profileDir,
String profileId,
) async {
final cacheDir = Directory(p.join(profileDir.path, 'cache'));
final mozillaCacheDir = Directory(p.join(cacheDir.path, profileId));
if (await mozillaCacheDir.exists()) {
await mozillaCacheDir.delete(recursive: true);
}
}
/// Returns the list of Mozilla profile IDs (`.default` directory names)
/// inside the given profile directory's `files/mozilla/` subdirectory.
List<String> getMozillaProfileIds(Directory profileDir) {
final mozillaDir = Directory(p.join(profileDir.path, 'files', 'mozilla'));
if (!mozillaDir.existsSync()) return [];
return mozillaDir
.listSync()
.whereType<Directory>()
.map((dir) => p.basename(dir.path))
.where((name) => name.endsWith('.default'))
.toList();
}
Future<UuidValue?> readStartupProfile(Directory dir) async {
final file = File(p.join(dir.path, _startupProfileFileName));
if (await file.exists()) {
final contents = await file.readAsString();
try {
return UuidValue.withValidation(contents);
} catch (e, s) {
logger.e('Could not parse profile', error: e, stackTrace: s);
}
}
return null;
}
Future<void> writeStartupProfile(
Directory dir,
UuidValue profile, {
bool flush = false,
}) async {
final file = File(p.join(dir.path, _startupProfileFileName));
await file.writeAsString(profile.uuid, flush: flush);
}
Future<UuidValue?> selectStartupProfile(Directory profilesDir) async {
var startupProfile = await readStartupProfile(profilesDir);
final availableProfiles = await getAvailableProfileDirectories(profilesDir);
// Verify the startup profile directory actually exists
if (startupProfile != null) {
final profileDir = getProfileDir(profilesDir, startupProfile);
final exists = availableProfiles.any((dir) => dir.path == profileDir.path);
if (!exists) {
logger.w('Startup profile directory missing, selecting fallback');
startupProfile = null;
}
}
if (startupProfile == null) {
final sortedDirs = await sortByAccessTime(availableProfiles);
for (final dir in sortedDirs) {
try {
startupProfile = extractDirectoryUuid(dir);
await writeStartupProfile(profilesDir, startupProfile);
break;
} catch (e, s) {
logger.w('Could not parse profile folder', error: e, stackTrace: s);
}
}
}
return startupProfile;
}
UuidValue extractDirectoryUuid(Directory dir) => UuidValue.withValidation(
p.basename(dir.path).substring(profileDirPrefix.length),
);
Directory getProfileDir(Directory profilesDir, UuidValue profileUuid) {
return Directory(
p.join(profilesDir.path, '$profileDirPrefix${profileUuid.uuid}'),
);
}
Future<Profile?> readProfileMetadata(Directory profileDir) async {
final file = File(p.join(profileDir.path, _metadataFile));
if (!await file.exists()) {
return null;
}
final content = await file.readAsString();
return Profile.fromJson(jsonDecode(content) as Map<String, dynamic>);
}
Future<void> writeProfileMetadata(Directory profileDir, Profile profile) async {
final file = File(p.join(profileDir.path, _metadataFile));
await file.writeAsString(jsonEncode(profile.toJson()), flush: true);
}
Future<bool> createNewProfile(Directory profilesDir, Profile profile) async {
final profileDir = getProfileDir(profilesDir, profile.uuidValue);
if (await profileDir.exists()) {
return false;
}
await profileDir.create();
await writeProfileMetadata(profileDir, profile);
return true;
}
Future<List<Directory>> sortByAccessTime(
List<Directory> dirs, {
bool descending = true,
}) async {
final dirsWithStats = await Future.wait(
dirs.map((dir) async {
final stat = await dir.stat();
return (dir: dir, accessed: stat.accessed);
}),
);
dirsWithStats.sort((a, b) {
final comparison = a.accessed.compareTo(b.accessed);
return descending ? -comparison : comparison;
});
return dirsWithStats.map((record) => record.dir).toList();
}
@@ -0,0 +1,163 @@
/*
* 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:io';
import 'package:nullability/nullability.dart';
import 'package:path/path.dart' as p;
import 'package:weblibre/utils/uri_input_parser.dart';
import 'package:weblibre/utils/uri_policy.dart';
Uri? parseValidatedUrl(
String? value, {
required bool eagerParsing,
bool onlyHttpProtocol = false,
}) {
final policy = onlyHttpProtocol
? SchemePolicy.strictHttpOnly
: SchemePolicy.internalIntent;
return parseUserInputUrl(
value,
policy: policy,
allowSchemelessHosts: eagerParsing,
enforceMaxInputLength: true,
);
}
String? validateUrl(
String? value, {
required bool eagerParsing,
bool requireAuthority = true,
bool onlyHttpProtocol = false,
bool required = true,
}) {
if (value.isEmpty) {
if (required) {
return 'URL must be provided';
} else {
return null;
}
}
if (parseValidatedUrl(
value,
eagerParsing: eagerParsing,
onlyHttpProtocol: onlyHttpProtocol,
)
case final Uri url) {
if (!requireAuthority || url.authority.isNotEmpty) {
return null;
}
}
return 'Invalid URL';
}
String? validateRequired(String? value, {String message = 'Value required'}) {
if (value.isNotEmpty) {
return null;
}
return message;
}
String? validatePath(String? value) {
if (value == null || value.isEmpty) {
return 'Path cannot be empty';
}
// Check for invalid characters
// ignore: unnecessary_raw_strings
final invalidChars = RegExp(r'[<>"|?*]');
if (invalidChars.hasMatch(value)) {
return 'Path contains invalid characters';
}
// Validate path structure
try {
p.normalize(value);
} catch (e) {
return 'Invalid path format';
}
return null;
}
String? validateDirectoryExisting(String? value) {
if (value == null || value.isEmpty) {
return 'Path cannot be empty';
}
if (!Directory(value).existsSync()) {
return 'Directory is not existing';
}
return null;
}
String? validateDirectoryNotExisting(String? value) {
if (value == null || value.isEmpty) {
return 'Path cannot be empty';
}
if (Directory(value).existsSync()) {
return 'Directory already exisits';
}
return null;
}
String? validateFileNotExisting(String? value) {
if (value == null || value.isEmpty) {
return 'Path cannot be empty';
}
if (File(value).existsSync()) {
return 'File already exisits';
}
return null;
}
String? validateFileExisting(String? value) {
if (value == null || value.isEmpty) {
return 'Path cannot be empty';
}
if (!File(value).existsSync()) {
return 'File not existing';
}
return null;
}
final _profileNamePattern = RegExp(r"""^[^~)('!*<>:;,?"*|/_]+$""");
String? validateProfileName(String? value) {
if (value == null || value.isEmpty) {
return 'Name required';
}
if (!_profileNamePattern.hasMatch(value)) {
return 'Name contains invalid caharcters';
}
return null;
}
@@ -0,0 +1,198 @@
/*
* 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:io';
import 'package:weblibre/utils/uri_input_parser.dart';
import 'package:weblibre/utils/uri_policy.dart';
enum NavigationReason {
explicitScheme,
schemelessHost,
localhost,
ipLiteral,
aboutLike,
}
enum SearchReason {
containsWhitespace,
notHostCandidate,
invalidHostChars,
fallback,
}
enum InvalidReason {
unsupportedScheme,
malformedUri,
containsControlChars,
emptyInput,
tooLong,
}
sealed class InputClassification {
const InputClassification();
const factory InputClassification.navigate(Uri uri, NavigationReason reason) =
NavigateInputClassification;
const factory InputClassification.search(String query, SearchReason reason) =
SearchInputClassification;
const factory InputClassification.invalid(String raw, InvalidReason reason) =
InvalidInputClassification;
}
final class NavigateInputClassification extends InputClassification {
final Uri uri;
final NavigationReason reason;
const NavigateInputClassification(this.uri, this.reason);
}
final class SearchInputClassification extends InputClassification {
final String query;
final SearchReason reason;
const SearchInputClassification(this.query, this.reason);
}
final class InvalidInputClassification extends InputClassification {
final String raw;
final InvalidReason reason;
const InvalidInputClassification(this.raw, this.reason);
}
InputClassification classifyAddressBarInput(
String input, {
SchemePolicy policy = SchemePolicy.addressBarTyped,
}) {
final normalizedInput = normalizeInput(input);
if (normalizedInput.isEmpty) {
return InputClassification.invalid(
normalizedInput,
InvalidReason.emptyInput,
);
}
if (exceedsMaxInputLength(normalizedInput)) {
return InputClassification.invalid(normalizedInput, InvalidReason.tooLong);
}
if (containsControlChars(normalizedInput)) {
return InputClassification.invalid(
normalizedInput,
InvalidReason.containsControlChars,
);
}
if (hasExplicitScheme(normalizedInput)) {
final explicitUri = parseExplicitUri(normalizedInput, policy: policy);
if (explicitUri != null) {
final reason = explicitUri.isScheme('about')
? NavigationReason.aboutLike
: NavigationReason.explicitScheme;
return InputClassification.navigate(explicitUri, reason);
}
if (hasAllowedScheme(normalizedInput, policy.allowedSchemes)) {
return InputClassification.invalid(
normalizedInput,
InvalidReason.malformedUri,
);
}
return InputClassification.invalid(
normalizedInput,
InvalidReason.unsupportedScheme,
);
}
if (normalizedInput.contains(whitespaceRegex)) {
return InputClassification.search(
normalizedInput,
SearchReason.containsWhitespace,
);
}
final schemelessUri = parseSchemelessWebHost(
normalizedInput,
allowedSchemes: policy.allowedSchemes,
);
if (schemelessUri != null) {
if (schemelessUri.host.toLowerCase() == 'localhost') {
return InputClassification.navigate(
schemelessUri,
NavigationReason.localhost,
);
}
if (InternetAddress.tryParse(schemelessUri.host) != null) {
return InputClassification.navigate(
schemelessUri,
NavigationReason.ipLiteral,
);
}
return InputClassification.navigate(
schemelessUri,
NavigationReason.schemelessHost,
);
}
if (looksLikeHostExpression(normalizedInput) &&
hasInvalidHostLikeChars(normalizedInput)) {
return InputClassification.search(
normalizedInput,
SearchReason.invalidHostChars,
);
}
return InputClassification.search(
normalizedInput,
SearchReason.notHostCandidate,
);
}
Uri? parseSharedIntentUrl(
String input, {
SchemePolicy policy = SchemePolicy.sharedIntent,
}) {
final normalizedInput = normalizeInput(input);
if (normalizedInput.isEmpty ||
exceedsMaxInputLength(normalizedInput) ||
containsControlChars(normalizedInput)) {
return null;
}
if (hasExplicitScheme(normalizedInput)) {
return parseExplicitUri(normalizedInput, policy: policy);
}
if (normalizedInput.contains(whitespaceRegex)) {
return null;
}
return parseSchemelessWebHost(
normalizedInput,
allowedSchemes: policy.allowedSchemes,
);
}
+104
View File
@@ -0,0 +1,104 @@
/*
* 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:collection';
class LRUCache<K, V> {
int _capacity;
final LinkedHashMap<K, V> _cache;
final void Function(V)? _onEvict;
LRUCache(
this._capacity, {
bool Function(K, K)? equals,
int Function(K)? hashCode,
bool Function(dynamic)? isValidKey,
void Function(V)? onEvict,
}) : _onEvict = onEvict,
_cache = LinkedHashMap<K, V>(
equals: equals,
hashCode: hashCode,
isValidKey: isValidKey,
);
void resize(int capacity) {
if (_capacity > capacity) {
_cache.keys.take(_capacity - capacity).forEach((key) {
final evicted = _cache.remove(key);
if (evicted != null) {
_onEvict?.call(evicted);
}
});
}
_capacity = capacity;
}
bool contains(K key) {
return _cache.containsKey(key);
}
V? get(K key) {
final value = _cache.remove(key); // Temporarily remove the item.
if (value != null) {
_cache[key] =
value; // Re-inserting the item makes it the most-recently used.
}
return value;
}
V set(K key, V value) {
V? evicted;
if (_cache.containsKey(key)) {
evicted = _cache.remove(key); // Remove the existing item before updating.
} else if (_cache.length == _capacity) {
evicted = _cache.remove(
_cache.keys.first,
); // Explicitly remove the least recently used item if at capacity.
}
if (evicted != null) {
_onEvict?.call(evicted);
}
return _cache[key] = value; // Inserting or updating the item.
}
/// Clears all entries from the cache, calling onEvict for each entry.
void clear() {
if (_onEvict != null) {
for (final value in _cache.values) {
_onEvict(value);
}
}
_cache.clear();
}
/// Removes an entry by key, calling onEvict if it existed.
V? remove(K key) {
final value = _cache.remove(key);
if (value != null) {
_onEvict?.call(value);
}
return value;
}
}
@@ -0,0 +1,51 @@
/*
* 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:markdown/markdown.dart';
import 'package:nullability/nullability.dart';
import 'package:path/path.dart' as p;
List<Uri> extractImagesFromMarkdown(String markdownText) {
final imageUrls = <Uri>[];
final nodes = Document().parse(markdownText);
void findImages(List<Node> nodes) {
for (final node in nodes) {
if (node is Element && node.tag == 'img') {
final url = node.attributes['src'];
if (url.mapNotNull((url) => Uri.tryParse(url)) case final Uri url) {
if (p.extension(url.path)
case '.png' || '.jpg' || '.jpeg' || '.webp') {
imageUrls.add(url);
}
}
}
if (node is Element && node.children != null) {
findImages(node.children!);
}
}
}
findImages(nodes);
return imageUrls;
}
@@ -0,0 +1,29 @@
/*
* 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/services.dart';
const _channel = MethodChannel('eu.weblibre.gecko/activity');
/// Moves the app to the background without finishing the activity.
/// Unlike [SystemNavigator.pop], this keeps the Flutter engine attached,
/// avoiding crashes when the user returns to the app.
Future<void> moveToBackground() async {
await _channel.invokeMethod('moveTaskToBack');
}
@@ -0,0 +1,28 @@
/*
* 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/foundation.dart';
bool isAndroid() {
return !kIsWeb && defaultTargetPlatform == TargetPlatform.android;
}
bool isIOS() {
return !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS;
}
@@ -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 'dart:async';
import 'package:flutter/foundation.dart';
/// A ValueNotifier that acts as a proxy for another ValueNotifier,
/// sampling its values based on a given duration.
class SampledValueNotifier<T> extends ValueNotifier<T> {
/// The source ValueNotifier to sample from
final ValueNotifier<T> _source;
/// The duration to sample at
final Duration _sampleDuration;
/// Timer for sampling
Timer? _timer;
/// Whether a value has changed since the last sample
bool _hasNewValue = false;
/// Subscription to the source ValueNotifier
late final VoidCallback _sourceListener;
/// Creates a SampledValueNotifier that samples values from [source]
/// at the specified [sampleDuration].
SampledValueNotifier({
required ValueNotifier<T> source,
required Duration sampleDuration,
}) : _source = source,
_sampleDuration = sampleDuration,
super(source.value) {
// Set up listener for source changes
_sourceListener = () {
_hasNewValue = true;
// Start timer if not already running
if (_timer == null || !_timer!.isActive) {
_startTimer();
}
};
_source.addListener(_sourceListener);
_startTimer();
}
/// Starts the sampling timer
void _startTimer() {
_timer?.cancel();
_timer = Timer.periodic(_sampleDuration, _onSampleTime);
}
/// Called when it's time to sample
void _onSampleTime(Timer timer) {
if (_hasNewValue) {
value = _source.value;
_hasNewValue = false;
}
}
@override
void dispose() {
_timer?.cancel();
_source.removeListener(_sourceListener);
super.dispose();
}
}
extension ValueNotifierSampleExtension<T> on ValueNotifier<T> {
/// Creates a new ValueNotifier that samples this ValueNotifier's values
/// at the specified [duration].
///
/// The returned ValueNotifier must be disposed when no longer needed.
ValueNotifier<T> sampleTime(Duration duration) {
return SampledValueNotifier<T>(source: this, sampleDuration: duration);
}
}
@@ -0,0 +1,58 @@
/*
* 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';
int _getLineCountUsingBoxes(String text, TextStyle style, double maxWidth) {
final textSpan = TextSpan(text: text, style: style);
final textPainter = TextPainter(
text: textSpan,
textDirection: TextDirection.ltr,
);
textPainter.layout(maxWidth: maxWidth);
// Select all text
final selection = TextSelection(baseOffset: 0, extentOffset: text.length);
// Each box represents one line
final lines = textPainter.getBoxesForSelection(selection);
return lines.length;
}
int? getTextFieldLineCount(GlobalKey key, String text, TextStyle style) {
final box = key.currentContext?.findRenderObject();
if (box case final RenderBox box) {
final width = box.size.width;
return _getLineCountUsingBoxes(text, style, width);
}
return null;
}
double? getTextFieldHeight(GlobalKey key) {
final box = key.currentContext?.findRenderObject();
if (box case final RenderBox box) {
return box.size.height;
}
return null;
}
@@ -0,0 +1,98 @@
/*
* 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';
/// Builds a [TextSpan] with highlighted sections based on prefix/suffix markers.
///
/// This function parses text that contains highlight markers (e.g., from FTS5
/// search results) and creates a [TextSpan] with different styles for regular
/// and highlighted text.
///
/// Example:
/// ```dart
/// final span = buildHighlightedText(
/// 'Hello ***world***!',
/// baseStyle,
/// highlightStyle,
/// '***',
/// '***',
/// );
/// // Results in: "Hello " (base) + "world" (highlighted) + "!" (base)
/// ```
///
/// [text] The text to parse for highlights
/// [baseStyle] Style for non-highlighted text
/// [highlightStyle] Style for highlighted text
/// [matchPrefix] Marker that indicates the start of a highlight
/// [matchSuffix] Marker that indicates the end of a highlight
TextSpan buildHighlightedText(
String text,
TextStyle? baseStyle,
TextStyle? highlightStyle,
String matchPrefix,
String matchSuffix, {
bool normalizeWhitespaces = false,
}) {
final spans = <TextSpan>[];
var currentIndex = 0;
if (normalizeWhitespaces) {
// ignore: parameter_assignments
text = text.replaceAll(RegExp(r'\s+'), ' ');
}
while (currentIndex < text.length) {
final prefixIndex = text.indexOf(matchPrefix, currentIndex);
if (prefixIndex == -1) {
spans.add(TextSpan(text: text.substring(currentIndex), style: baseStyle));
break;
}
if (prefixIndex > currentIndex) {
spans.add(
TextSpan(
text: text.substring(currentIndex, prefixIndex),
style: baseStyle,
),
);
}
final suffixIndex = text.indexOf(
matchSuffix,
prefixIndex + matchPrefix.length,
);
if (suffixIndex == -1) {
// No closing marker - highlight everything from prefix to end
final highlightedText = text.substring(prefixIndex + matchPrefix.length);
spans.add(TextSpan(text: highlightedText, style: highlightStyle));
break;
}
final highlightedText = text.substring(
prefixIndex + matchPrefix.length,
suffixIndex,
);
spans.add(TextSpan(text: highlightedText, style: highlightStyle));
currentIndex = suffixIndex + matchSuffix.length;
}
return TextSpan(children: spans);
}
+288
View File
@@ -0,0 +1,288 @@
/*
* 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:nullability/nullability.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/utils/clipboard.dart';
/// Creates a floating snackbar.
/// The margin is controlled by the scaffold's snackBarTheme for proper
/// positioning above bottom app bars of varying heights.
SnackBar _createFloatingSnackBar({
required Widget content,
Color? backgroundColor,
SnackBarAction? action,
required Duration duration,
required bool persist,
}) {
return SnackBar(
content: content,
backgroundColor: backgroundColor,
action: action,
duration: duration,
persist: persist,
behavior: SnackBarBehavior.floating,
);
}
void showErrorMessage(
BuildContext context,
String message, {
Duration duration = const Duration(seconds: 4),
bool persist = false,
}) {
final snackBar = _createFloatingSnackBar(
content: Text(
message,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
backgroundColor: Theme.of(context).colorScheme.onError,
duration: duration,
persist: persist,
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
void showInfoMessage(
BuildContext context,
String message, {
Duration duration = const Duration(seconds: 4),
bool persist = false,
SnackBarAction? action,
}) {
final snackBar = _createFloatingSnackBar(
content: Text(message),
action: action,
duration: duration,
persist: persist,
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
void showOpenedTabsFromAnotherDeviceMessage(
BuildContext context,
int openedTabs, {
Duration duration = const Duration(seconds: 4),
bool persist = false,
}) {
if (openedTabs <= 0) {
return;
}
final message = openedTabs == 1
? 'Opened 1 tab received from another device'
: 'Opened $openedTabs tabs received from another device';
showInfoMessage(context, message, duration: duration, persist: persist);
}
void showTabBackButtonMessage(
BuildContext context,
int tabCount,
Duration duration, {
bool persist = false,
}) {
final snackbar = _createFloatingSnackBar(
content: (tabCount > 1)
? const Text('Navigate BACK again to close current tab')
: const Text('Navigate BACK again to exit app'),
duration: duration,
persist: persist,
);
ScaffoldMessenger.of(context)
..clearSnackBars()
..showSnackBar(snackbar);
}
void showTabOpenedMessage(
BuildContext context, {
String? tabName,
void Function()? onShow,
Duration duration = const Duration(seconds: 3),
bool persist = false,
}) {
final message = switch (tabName.whenNotEmpty) {
String() => "New tab '$tabName' opened in background",
null => 'New tab opened in background',
};
final snackBar = _createFloatingSnackBar(
content: Text(message),
action: onShow.mapNotNull(
(onPressed) => SnackBarAction(label: 'Show', onPressed: onPressed),
),
duration: duration,
persist: persist,
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
Future<void> showSuggestNewTabMessage(
BuildContext context, {
required void Function(String? searchText) onAdd,
Duration duration = const Duration(seconds: 3),
bool persist = false,
}) async {
final clipboardUrl = await tryGetUriFromClipboard();
if (clipboardUrl != null) {
final snackBar = _createFloatingSnackBar(
content: const Text('Want to open link from clipboard?'),
action: SnackBarAction(
label: 'Open',
onPressed: () {
onAdd(clipboardUrl.toString());
},
),
duration: duration,
persist: persist,
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
}
}
void showTabSwitchMessage(
BuildContext context, {
String? tabName,
void Function()? onSwitch,
Duration duration = const Duration(seconds: 3),
bool persist = false,
}) {
ScaffoldMessenger.of(context).clearSnackBars();
final message = switch (tabName.whenNotEmpty) {
String() => "New tab '$tabName' opened",
null => 'New tab opened',
};
final snackBar = _createFloatingSnackBar(
content: Text(message),
action: onSwitch.mapNotNull(
(onPressed) => SnackBarAction(label: 'Switch', onPressed: onPressed),
),
duration: duration,
persist: persist,
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
Future<void> launchUrlFeedback(
BuildContext context,
Uri url, {
LaunchMode mode = LaunchMode.externalApplication,
}) async {
if (await canLaunchUrl(url)) {
try {
if (!await launchUrl(url, mode: mode)) {
if (context.mounted) {
showErrorMessage(context, 'Could not launch URL ($url)');
}
}
} catch (e, s) {
logger.e('Failed to launch URL: $url', error: e, stackTrace: s);
if (context.mounted) {
showErrorMessage(context, 'Could not launch URL ($url)');
}
}
} else {
if (context.mounted) {
showErrorMessage(context, 'Can not handle "${url.scheme}"');
}
}
}
void showTabUndoClose(
BuildContext context,
VoidCallback onUndo, {
int count = 1,
Duration duration = const Duration(seconds: 3),
bool persist = false,
}) {
ScaffoldMessenger.of(context).clearSnackBars();
final snackBar = _createFloatingSnackBar(
content: (count > 1)
? Text('$count Tabs closed')
: const Text('Tab closed'),
action: SnackBarAction(label: 'Undo', onPressed: onUndo),
duration: duration,
persist: persist,
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
/// Shows a confirmation dialog before closing isolated tabs whose data
/// will be permanently cleared. Returns `true` if the user confirms.
Future<bool> confirmIsolatedTabClose(
BuildContext context, {
int groupCount = 1,
}) async {
final message = groupCount == 1
? 'This will permanently clear all browsing data for this isolated session.'
: 'This will permanently clear browsing data for $groupCount isolated sessions.';
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Close isolated tabs?'),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Close'),
),
],
),
);
return result ?? false;
}
void showDismissOverrideMessage(
BuildContext context,
VoidCallback onDismiss, {
Duration duration = const Duration(seconds: 4),
bool persist = false,
}) {
ScaffoldMessenger.of(context).clearSnackBars();
final snackBar = _createFloatingSnackBar(
content: const Text('Hiding disabled by site'),
action: SnackBarAction(label: 'Dismiss', onPressed: onDismiss),
duration: duration,
persist: persist,
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
@@ -0,0 +1,245 @@
/*
* 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:io';
import 'package:weblibre/utils/uri_policy.dart';
const maxUriInputLength = 4096;
final _controlCharacterRegex = RegExp(r'[\x00-\x1F\u007F-\u009F]');
final _explicitSchemeRegex = RegExp(r'^([a-zA-Z][a-zA-Z0-9+\-.]*):');
final _hostPortSuffixRegex = RegExp(r'^\d{1,5}([/?#].*)?$');
final _hostLabelRegex = RegExp(r'^[a-zA-Z0-9-]{1,63}$');
final _topLevelDomainRegex = RegExp(r'^[a-zA-Z]{2,63}$');
final _possibleHostCharsRegex = RegExp(
r"^[a-zA-Z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$",
);
final whitespaceRegex = RegExp(r'\s');
String normalizeInput(String input) {
return input.trim();
}
bool containsControlChars(String input) {
return _controlCharacterRegex.hasMatch(input);
}
bool hasAllowedScheme(String input, Set<String> allowedSchemes) {
final scheme = _extractExplicitScheme(input);
if (scheme == null) {
return false;
}
return allowedSchemes.contains(scheme);
}
bool hasExplicitScheme(String input) {
final match = _explicitSchemeRegex.firstMatch(input);
if (match == null) {
return false;
}
final scheme = match.group(1)?.toLowerCase();
if (scheme == null) {
return false;
}
final suffix = input.substring(match.end);
if (suffix.startsWith('//')) {
return true;
}
// Keep host:port shorthand treated as schemeless input.
if ((scheme == 'localhost' || scheme.contains('.')) &&
_hostPortSuffixRegex.hasMatch(suffix)) {
return false;
}
return true;
}
bool exceedsMaxInputLength(String input, {int limit = maxUriInputLength}) {
return input.length > limit;
}
bool isValidHostCandidate(String hostCandidate) {
final host = hostCandidate.trim().toLowerCase();
if (host.isEmpty) {
return false;
}
if (host == 'localhost') {
return true;
}
if (InternetAddress.tryParse(host) != null) {
return true;
}
final labels = host.split('.');
if (labels.length < 2) {
return false;
}
if (!_topLevelDomainRegex.hasMatch(labels.last)) {
return false;
}
for (final label in labels) {
if (!_hostLabelRegex.hasMatch(label) ||
label.startsWith('-') ||
label.endsWith('-')) {
return false;
}
}
return true;
}
bool looksLikeHostExpression(String input) {
return input.contains('.') ||
input.contains(':') ||
input.startsWith('[') ||
input.contains('/');
}
bool hasInvalidHostLikeChars(String input) {
return !_possibleHostCharsRegex.hasMatch(input);
}
Uri? parseExplicitUri(
String input, {
required SchemePolicy policy,
bool enforceMaxInputLength = false,
}) {
final normalizedInput = normalizeInput(input);
if (normalizedInput.isEmpty ||
(enforceMaxInputLength && exceedsMaxInputLength(normalizedInput)) ||
containsControlChars(normalizedInput) ||
!hasExplicitScheme(normalizedInput) ||
!hasAllowedScheme(normalizedInput, policy.allowedSchemes)) {
return null;
}
final uri = Uri.tryParse(normalizedInput);
if (uri == null) {
return null;
}
final scheme = uri.scheme.toLowerCase();
if (!policy.allows(scheme)) {
return null;
}
if (schemeRequiresAuthority(scheme) && uri.authority.isEmpty) {
return null;
}
return uri.replace(scheme: scheme);
}
Uri? parseSchemelessWebHost(
String input, {
Set<String>? allowedSchemes,
bool enforceMaxInputLength = false,
}) {
final normalizedInput = normalizeInput(input);
if (normalizedInput.isEmpty ||
(enforceMaxInputLength && exceedsMaxInputLength(normalizedInput)) ||
containsControlChars(normalizedInput) ||
hasExplicitScheme(normalizedInput) ||
normalizedInput.contains(whitespaceRegex)) {
return null;
}
final probeUri = Uri.tryParse('https://$normalizedInput');
if (probeUri == null || probeUri.host.isEmpty) {
return null;
}
if (!isValidHostCandidate(probeUri.host)) {
return null;
}
if (probeUri.hasPort && (probeUri.port < 1 || probeUri.port > 65535)) {
return null;
}
final scheme = probeUri.host.toLowerCase() == 'localhost' ? 'http' : 'https';
if (allowedSchemes != null && !allowedSchemes.contains(scheme)) {
return null;
}
return probeUri.replace(scheme: scheme);
}
Uri? parseUserInputUrl(
String? input, {
required SchemePolicy policy,
bool allowSchemelessHosts = false,
bool enforceMaxInputLength = false,
}) {
if (input == null) {
return null;
}
final normalizedInput = normalizeInput(input);
if (normalizedInput.isEmpty ||
(enforceMaxInputLength && exceedsMaxInputLength(normalizedInput)) ||
containsControlChars(normalizedInput)) {
return null;
}
if (hasExplicitScheme(normalizedInput)) {
return parseExplicitUri(
normalizedInput,
policy: policy,
enforceMaxInputLength: enforceMaxInputLength,
);
}
if (!allowSchemelessHosts) {
return null;
}
return parseSchemelessWebHost(
normalizedInput,
allowedSchemes: policy.allowedSchemes,
enforceMaxInputLength: enforceMaxInputLength,
);
}
Uri redactUriCredentials(Uri uri) {
if (uri.userInfo.isEmpty) {
return uri;
}
return uri.replace(userInfo: '');
}
String? _extractExplicitScheme(String input) {
final match = _explicitSchemeRegex.firstMatch(input);
if (match == null) {
return null;
}
return match.group(1)?.toLowerCase();
}
+30
View File
@@ -0,0 +1,30 @@
/*
* 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:weblibre/utils/uri_input_parser.dart';
import 'package:weblibre/utils/uri_policy.dart';
Uri? tryParseUrl(String? input, {bool eagerParsing = false}) {
return parseUserInputUrl(
input,
policy: SchemePolicy.internalIntent,
allowSchemelessHosts: eagerParsing,
);
}
+73
View File
@@ -0,0 +1,73 @@
/*
* 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/>.
*/
class Scheme {
final String name;
final bool requiresAuthority;
const Scheme(this.name, {this.requiresAuthority = false});
}
const kSchemeHttp = Scheme('http', requiresAuthority: true);
const kSchemeHttps = Scheme('https', requiresAuthority: true);
const kSchemeFtp = Scheme('ftp', requiresAuthority: true);
const kSchemeFile = Scheme('file');
const kSchemeContent = Scheme('content', requiresAuthority: true);
const kSchemeAbout = Scheme('about');
const kSchemeMozExtension = Scheme('moz-extension', requiresAuthority: true);
const allSupportedSchemes = [
kSchemeHttp,
kSchemeHttps,
kSchemeFtp,
kSchemeFile,
kSchemeContent,
kSchemeAbout,
kSchemeMozExtension,
];
const httpOnlySchemes = [kSchemeHttp, kSchemeHttps];
bool schemeRequiresAuthority(String scheme) {
return allSupportedSchemes.any(
(s) => s.name == scheme && s.requiresAuthority,
);
}
enum SchemePolicy {
addressBarTyped,
strictHttpOnly,
sharedIntent,
internalIntent,
}
extension SchemePolicyX on SchemePolicy {
List<Scheme> get _schemes => switch (this) {
SchemePolicy.addressBarTyped => allSupportedSchemes,
SchemePolicy.strictHttpOnly => httpOnlySchemes,
SchemePolicy.sharedIntent => httpOnlySchemes,
SchemePolicy.internalIntent => allSupportedSchemes,
};
Set<String> get allowedSchemes => _schemes.map((s) => s.name).toSet();
bool allows(String scheme) {
return allowedSchemes.contains(scheme.toLowerCase());
}
}