prepare for multiple apps
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user