prepare for multiple apps
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* 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:ui';
|
||||
|
||||
class EquatableImage {
|
||||
Image? _value;
|
||||
final int _imageHash;
|
||||
bool _isDisposed = false;
|
||||
|
||||
EquatableImage(Image value, {required int hash})
|
||||
: _value = value,
|
||||
_imageHash = hash;
|
||||
|
||||
/// The underlying ui.Image. Returns null if disposed.
|
||||
Image? get value => _isDisposed ? null : _value;
|
||||
|
||||
/// Whether this image has been disposed.
|
||||
bool get isDisposed => _isDisposed;
|
||||
|
||||
/// Disposes the underlying ui.Image to free GPU memory.
|
||||
/// This is safe to call multiple times.
|
||||
void dispose() {
|
||||
if (_isDisposed) return;
|
||||
_isDisposed = true;
|
||||
// Delay disposal to allow widgets to finish rendering
|
||||
Future.delayed(const Duration(seconds: 3), () {
|
||||
_value?.dispose();
|
||||
_value = null;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => _imageHash.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is EquatableImage && other._imageHash == _imageHash;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:copy_with_extension/copy_with_extension.dart';
|
||||
import 'package:fast_equatable/fast_equatable.dart';
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:uuid/uuid_value.dart';
|
||||
import 'package:weblibre/core/uuid.dart';
|
||||
import 'package:weblibre/features/user/data/models/auth_settings.dart';
|
||||
|
||||
part 'profile.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
@CopyWith()
|
||||
class Profile with FastEquatable {
|
||||
@CopyWithField(immutable: true)
|
||||
final String id;
|
||||
final String name;
|
||||
final AuthSettings authSettings;
|
||||
|
||||
late final uuidValue = UuidValue.fromString(id);
|
||||
|
||||
static String getNewProfileId() => uuid.v7();
|
||||
|
||||
Profile({required this.id, required this.name, AuthSettings? authSettings})
|
||||
: authSettings = authSettings ?? AuthSettings.withDefaults();
|
||||
|
||||
factory Profile.create({required String name, AuthSettings? authSettings}) {
|
||||
return Profile(
|
||||
id: getNewProfileId(),
|
||||
name: name,
|
||||
authSettings: authSettings,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get hashParameters => [id, name, authSettings];
|
||||
|
||||
factory Profile.fromJson(Map<String, dynamic> json) =>
|
||||
_$ProfileFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$ProfileToJson(this);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'profile.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// CopyWithGenerator
|
||||
// **************************************************************************
|
||||
|
||||
abstract class _$ProfileCWProxy {
|
||||
Profile name(String name);
|
||||
|
||||
Profile authSettings(AuthSettings? authSettings);
|
||||
|
||||
/// Creates a new instance with the provided field values.
|
||||
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `Profile(...).copyWith.fieldName(value)`.
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// Profile(...).copyWith(id: 12, name: "My name")
|
||||
/// ```
|
||||
Profile call({String name, AuthSettings? authSettings});
|
||||
}
|
||||
|
||||
/// Callable proxy for `copyWith` functionality.
|
||||
/// Use as `instanceOfProfile.copyWith(...)` or call `instanceOfProfile.copyWith.fieldName(value)` for a single field.
|
||||
class _$ProfileCWProxyImpl implements _$ProfileCWProxy {
|
||||
const _$ProfileCWProxyImpl(this._value);
|
||||
|
||||
final Profile _value;
|
||||
|
||||
@override
|
||||
Profile name(String name) => call(name: name);
|
||||
|
||||
@override
|
||||
Profile authSettings(AuthSettings? authSettings) =>
|
||||
call(authSettings: authSettings);
|
||||
|
||||
@override
|
||||
/// Creates a new instance with the provided field values.
|
||||
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `Profile(...).copyWith.fieldName(value)`.
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// Profile(...).copyWith(id: 12, name: "My name")
|
||||
/// ```
|
||||
Profile call({
|
||||
Object? name = const $CopyWithPlaceholder(),
|
||||
Object? authSettings = const $CopyWithPlaceholder(),
|
||||
}) {
|
||||
return Profile(
|
||||
id: _value.id,
|
||||
name: name == const $CopyWithPlaceholder() || name == null
|
||||
? _value.name
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: name as String,
|
||||
authSettings: authSettings == const $CopyWithPlaceholder()
|
||||
? _value.authSettings
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: authSettings as AuthSettings?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension $ProfileCopyWith on Profile {
|
||||
/// Returns a callable class used to build a new instance with modified fields.
|
||||
/// Example: `instanceOfProfile.copyWith(...)` or `instanceOfProfile.copyWith.fieldName(...)`.
|
||||
// ignore: library_private_types_in_public_api
|
||||
_$ProfileCWProxy get copyWith => _$ProfileCWProxyImpl(this);
|
||||
}
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
Profile _$ProfileFromJson(Map<String, dynamic> json) => Profile(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
authSettings: json['authSettings'] == null
|
||||
? null
|
||||
: AuthSettings.fromJson(json['authSettings'] as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ProfileToJson(Profile instance) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'name': instance.name,
|
||||
'authSettings': instance.authSettings.toJson(),
|
||||
};
|
||||
@@ -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/widgets.dart' hide Locale;
|
||||
import 'package:intl/locale.dart' as intl;
|
||||
import 'package:locale_resolver/locale_resolver.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/extensions/locale.dart';
|
||||
|
||||
part 'locale_resolver.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class LocaleResolverRepository extends _$LocaleResolverRepository {
|
||||
final _service = LocaleResolver();
|
||||
final _cache = <intl.Locale, LocalizedResult>{};
|
||||
|
||||
Future<LocalizedResult> resolve(intl.Locale locale) async {
|
||||
final cached = _cache[locale];
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
return _cache[locale] = await _service.resolve(
|
||||
locale.toLanguageTag(),
|
||||
targetLocale.toLanguageTag(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void build(intl.Locale targetLocale) {}
|
||||
}
|
||||
|
||||
@Riverpod()
|
||||
Future<LocalizedResult> resolveLocale(Ref ref, intl.Locale locale) {
|
||||
return ref
|
||||
.read(
|
||||
localeResolverRepositoryProvider(
|
||||
WidgetsBinding.instance.platformDispatcher.locale.toIntlLocale(),
|
||||
).notifier,
|
||||
)
|
||||
.resolve(locale);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'locale_resolver.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(LocaleResolverRepository)
|
||||
final localeResolverRepositoryProvider = LocaleResolverRepositoryFamily._();
|
||||
|
||||
final class LocaleResolverRepositoryProvider
|
||||
extends $NotifierProvider<LocaleResolverRepository, void> {
|
||||
LocaleResolverRepositoryProvider._({
|
||||
required LocaleResolverRepositoryFamily super.from,
|
||||
required intl.Locale super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'localeResolverRepositoryProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$localeResolverRepositoryHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'localeResolverRepositoryProvider'
|
||||
''
|
||||
'($argument)';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
LocaleResolverRepository create() => LocaleResolverRepository();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(void value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<void>(value),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is LocaleResolverRepositoryProvider &&
|
||||
other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$localeResolverRepositoryHash() =>
|
||||
r'dc74908d6ac1f5cc851e2a10dcf33b0dc68b5b15';
|
||||
|
||||
final class LocaleResolverRepositoryFamily extends $Family
|
||||
with
|
||||
$ClassFamilyOverride<
|
||||
LocaleResolverRepository,
|
||||
void,
|
||||
void,
|
||||
void,
|
||||
intl.Locale
|
||||
> {
|
||||
LocaleResolverRepositoryFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'localeResolverRepositoryProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: false,
|
||||
);
|
||||
|
||||
LocaleResolverRepositoryProvider call(intl.Locale targetLocale) =>
|
||||
LocaleResolverRepositoryProvider._(argument: targetLocale, from: this);
|
||||
|
||||
@override
|
||||
String toString() => r'localeResolverRepositoryProvider';
|
||||
}
|
||||
|
||||
abstract class _$LocaleResolverRepository extends $Notifier<void> {
|
||||
late final _$args = ref.$arg as intl.Locale;
|
||||
intl.Locale get targetLocale => _$args;
|
||||
|
||||
void build(intl.Locale targetLocale);
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<void, void>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<void, void>,
|
||||
void,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, () => build(_$args));
|
||||
}
|
||||
}
|
||||
|
||||
@ProviderFor(resolveLocale)
|
||||
final resolveLocaleProvider = ResolveLocaleFamily._();
|
||||
|
||||
final class ResolveLocaleProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<LocalizedResult>,
|
||||
LocalizedResult,
|
||||
FutureOr<LocalizedResult>
|
||||
>
|
||||
with $FutureModifier<LocalizedResult>, $FutureProvider<LocalizedResult> {
|
||||
ResolveLocaleProvider._({
|
||||
required ResolveLocaleFamily super.from,
|
||||
required intl.Locale super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'resolveLocaleProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$resolveLocaleHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'resolveLocaleProvider'
|
||||
''
|
||||
'($argument)';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<LocalizedResult> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<LocalizedResult> create(Ref ref) {
|
||||
final argument = this.argument as intl.Locale;
|
||||
return resolveLocale(ref, argument);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is ResolveLocaleProvider && other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$resolveLocaleHash() => r'94d7a9b307a81de372b8b40b06f046acc76e01c8';
|
||||
|
||||
final class ResolveLocaleFamily extends $Family
|
||||
with $FunctionalFamilyOverride<FutureOr<LocalizedResult>, intl.Locale> {
|
||||
ResolveLocaleFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'resolveLocaleProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
ResolveLocaleProvider call(intl.Locale locale) =>
|
||||
ResolveLocaleProvider._(argument: locale, from: this);
|
||||
|
||||
@override
|
||||
String toString() => r'resolveLocaleProvider';
|
||||
}
|
||||
@@ -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:exceptions/exceptions.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/core/providers/format.dart';
|
||||
import 'package:weblibre/features/about/domain/providers.dart';
|
||||
import 'package:weblibre/features/bangs/data/models/bang_group.dart';
|
||||
import 'package:weblibre/features/bangs/domain/repositories/sync.dart';
|
||||
|
||||
part 'app_initialization.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class AppInitializationService extends _$AppInitializationService {
|
||||
/// Will de facto restart the app
|
||||
Future<void> reinitialize() {
|
||||
ref.invalidateSelf();
|
||||
return initialize();
|
||||
}
|
||||
|
||||
Future<void> _initPackageInfo() {
|
||||
//Ensure Package info is loaded
|
||||
state = Result.success((
|
||||
initialized: false,
|
||||
stage: 'Loading Package Info...',
|
||||
errors: List.empty(),
|
||||
));
|
||||
|
||||
return ref.read(packageInfoProvider.future);
|
||||
}
|
||||
|
||||
Future<Map<BangGroup, Result<void>>> _initBangs() {
|
||||
state = Result.success((
|
||||
initialized: false,
|
||||
stage: 'Synchronizing Bangs...',
|
||||
errors: List.empty(),
|
||||
));
|
||||
|
||||
return ref
|
||||
.read(bangSyncRepositoryProvider.notifier)
|
||||
.syncBundledBangGroups();
|
||||
}
|
||||
|
||||
Future<void> initialize() async {
|
||||
state = await Result.fromAsync(() async {
|
||||
final errors = <ErrorMessage>[];
|
||||
|
||||
await ref.read(formatProvider.future);
|
||||
if (!ref.mounted) {
|
||||
return (initialized: false, stage: null, errors: errors);
|
||||
}
|
||||
|
||||
await _initPackageInfo();
|
||||
if (!ref.mounted) {
|
||||
return (initialized: false, stage: null, errors: errors);
|
||||
}
|
||||
|
||||
final bangSyncResults = await _initBangs();
|
||||
for (final MapEntry(value: result) in bangSyncResults.entries) {
|
||||
result.onFailure(errors.add);
|
||||
}
|
||||
|
||||
return (initialized: true, stage: null, errors: errors);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Result<({bool initialized, String? stage, List<ErrorMessage> errors})>
|
||||
build() {
|
||||
return Result.success((
|
||||
initialized: false,
|
||||
stage: null,
|
||||
errors: List.empty(),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'app_initialization.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(AppInitializationService)
|
||||
final appInitializationServiceProvider = AppInitializationServiceProvider._();
|
||||
|
||||
final class AppInitializationServiceProvider
|
||||
extends
|
||||
$NotifierProvider<
|
||||
AppInitializationService,
|
||||
Result<({List<ErrorMessage> errors, bool initialized, String? stage})>
|
||||
> {
|
||||
AppInitializationServiceProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'appInitializationServiceProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$appInitializationServiceHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
AppInitializationService create() => AppInitializationService();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(
|
||||
Result<({List<ErrorMessage> errors, bool initialized, String? stage})>
|
||||
value,
|
||||
) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride:
|
||||
$SyncValueProvider<
|
||||
Result<
|
||||
({List<ErrorMessage> errors, bool initialized, String? stage})
|
||||
>
|
||||
>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$appInitializationServiceHash() =>
|
||||
r'c26f968d53b4ea18f7be890c9610c9bf9d300322';
|
||||
|
||||
abstract class _$AppInitializationService
|
||||
extends
|
||||
$Notifier<
|
||||
Result<({List<ErrorMessage> errors, bool initialized, String? stage})>
|
||||
> {
|
||||
Result<({List<ErrorMessage> errors, bool initialized, String? stage})>
|
||||
build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref =
|
||||
this.ref
|
||||
as $Ref<
|
||||
Result<
|
||||
({List<ErrorMessage> errors, bool initialized, String? stage})
|
||||
>,
|
||||
Result<
|
||||
({List<ErrorMessage> errors, bool initialized, String? stage})
|
||||
>
|
||||
>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<
|
||||
Result<
|
||||
({List<ErrorMessage> errors, bool initialized, String? stage})
|
||||
>,
|
||||
Result<
|
||||
({List<ErrorMessage> errors, bool initialized, String? stage})
|
||||
>
|
||||
>,
|
||||
Result<
|
||||
({List<ErrorMessage> errors, bool initialized, String? stage})
|
||||
>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
/*
|
||||
* 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 'dart:ui';
|
||||
|
||||
import 'package:exceptions/exceptions.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:html/dom.dart';
|
||||
import 'package:html/parser.dart' as html_parser;
|
||||
import 'package:http/io_client.dart';
|
||||
import 'package:nullability/nullability.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:socks5_proxy/socks_client.dart';
|
||||
|
||||
import 'package:weblibre/core/http_error_handler.dart';
|
||||
import 'package:weblibre/data/models/web_page_info.dart';
|
||||
import 'package:weblibre/extensions/http_encoding.dart';
|
||||
import 'package:weblibre/extensions/uri.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/entities/browser_icon.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/cache.dart';
|
||||
import 'package:weblibre/features/web_feed/utils/feed_finder.dart';
|
||||
import 'package:weblibre/utils/lru_cache.dart';
|
||||
|
||||
part 'generic_website.g.dart';
|
||||
|
||||
const _typeMap = {
|
||||
"manifest": IconType.manifestIcon,
|
||||
"icon": IconType.favicon,
|
||||
"shortcut icon": IconType.favicon,
|
||||
"fluid-icon": IconType.fluidIcon,
|
||||
"apple-touch-icon": IconType.appleTouchIcon,
|
||||
"image_src": IconType.imageSrc,
|
||||
"apple-touch-icon image_src": IconType.appleTouchIcon,
|
||||
"apple-touch-icon-precomposed": IconType.appleTouchIcon,
|
||||
"og:image": IconType.openGraph,
|
||||
"og:image:url": IconType.openGraph,
|
||||
"og:image:secure_url": IconType.openGraph,
|
||||
"twitter:image": IconType.twitter,
|
||||
"msapplication-TileImage": IconType.microsoftTile,
|
||||
};
|
||||
|
||||
final class _InFlightFetch {
|
||||
final Future<Result<WebPageInfo>> future;
|
||||
|
||||
const _InFlightFetch(this.future);
|
||||
}
|
||||
|
||||
Iterable<ResourceSize> sizesToList(String? sizes) sync* {
|
||||
if (sizes != null) {
|
||||
final splitted = sizes
|
||||
.split(' ')
|
||||
.where((size) => size.contains('x'))
|
||||
.toList();
|
||||
|
||||
for (final size in splitted) {
|
||||
final dimensions = size.split('x');
|
||||
if (dimensions.length == 2) {
|
||||
final height = int.tryParse(dimensions[0]);
|
||||
final width = int.tryParse(dimensions[1]);
|
||||
|
||||
if (width != null && height != null) {
|
||||
yield ResourceSize(height: height, width: width);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class GenericWebsiteService extends _$GenericWebsiteService {
|
||||
final GeckoIconService _iconsService;
|
||||
|
||||
late CacheRepository _cacheRepository;
|
||||
late final LRUCache<String, BrowserIcon> _browserIconCache;
|
||||
final _inFlightFetches = <Uri, _InFlightFetch>{};
|
||||
|
||||
GenericWebsiteService()
|
||||
: _iconsService = GeckoIconService(),
|
||||
_browserIconCache = LRUCache(50, onEvict: (icon) => icon.image.dispose());
|
||||
|
||||
@override
|
||||
void build() {
|
||||
_cacheRepository = ref.watch(cacheRepositoryProvider.notifier);
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _serializeResource(Resource resource) {
|
||||
return {
|
||||
'url': resource.url,
|
||||
'type': resource.type,
|
||||
'sizes': resource.sizes.nonNulls.map((s) => [s.height, s.width]).toList(),
|
||||
'mimeType': resource.mimeType,
|
||||
'maskable': resource.maskable,
|
||||
};
|
||||
}
|
||||
|
||||
static Resource _deserializeResource(Map<String, dynamic> resource) {
|
||||
return Resource(
|
||||
url: resource['url'] as String,
|
||||
type: resource['type'] as IconType,
|
||||
mimeType: resource['mimeType'] as String?,
|
||||
sizes: (resource['sizes'] as List<List<int>>)
|
||||
.map((s) => ResourceSize(height: s[0], width: s[1]))
|
||||
.toList(),
|
||||
maskable: resource['maskable'] as bool,
|
||||
);
|
||||
}
|
||||
|
||||
static Uri _resolveRelativeUri(Uri baseUri, Uri uri) {
|
||||
if (!uri.isAbsolute) {
|
||||
return baseUri.resolveUri(uri);
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
static bool _isHttpUrl(Uri url) => url.isHttpOrHttps;
|
||||
|
||||
static List<Resource> _extractIcons(Uri baseUrl, Document document) {
|
||||
final List<Resource> icons = [];
|
||||
|
||||
void collectLinkIcons(String rel) {
|
||||
final links = document.querySelectorAll('link[rel="$rel"]');
|
||||
for (final link in links) {
|
||||
final href = link.attributes['href'];
|
||||
final type = _typeMap[rel];
|
||||
final mimeType = link.attributes['type'];
|
||||
if (href != null && type != null) {
|
||||
if (Uri.tryParse(href) case final Uri url) {
|
||||
icons.add(
|
||||
Resource(
|
||||
url: _resolveRelativeUri(baseUrl, url).toString(),
|
||||
type: type,
|
||||
sizes: sizesToList(link.attributes['sizes']).toList(),
|
||||
mimeType: mimeType.isNotEmpty ? mimeType : null,
|
||||
maskable: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void collectMetaPropertyIcons(String property) {
|
||||
final metas = document.querySelectorAll('meta[property="$property"]');
|
||||
for (final meta in metas) {
|
||||
final content = meta.attributes['content'];
|
||||
final type = _typeMap[property];
|
||||
if (content != null && type != null) {
|
||||
if (Uri.tryParse(content) case final Uri url) {
|
||||
icons.add(
|
||||
Resource(
|
||||
type: type,
|
||||
url: _resolveRelativeUri(baseUrl, url).toString(),
|
||||
sizes: [],
|
||||
maskable: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void collectMetaNameIcons(String name) {
|
||||
final metas = document.querySelectorAll('meta[name="$name"]');
|
||||
for (final meta in metas) {
|
||||
final content = meta.attributes['content'];
|
||||
final type = _typeMap[name];
|
||||
if (content != null && type != null) {
|
||||
if (Uri.tryParse(content) case final Uri url) {
|
||||
icons.add(
|
||||
Resource(
|
||||
type: type,
|
||||
url: _resolveRelativeUri(baseUrl, url).toString(),
|
||||
sizes: [],
|
||||
maskable: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collectLinkIcons("icon");
|
||||
collectLinkIcons("shortcut icon");
|
||||
collectLinkIcons("fluid-icon");
|
||||
collectLinkIcons("apple-touch-icon");
|
||||
collectLinkIcons("image_src");
|
||||
collectLinkIcons("apple-touch-icon image_src");
|
||||
collectLinkIcons("apple-touch-icon-precomposed");
|
||||
|
||||
collectMetaPropertyIcons("og:image");
|
||||
collectMetaPropertyIcons("og:image:url");
|
||||
collectMetaPropertyIcons("og:image:secure_url");
|
||||
|
||||
collectMetaNameIcons("twitter:image");
|
||||
collectMetaNameIcons("msapplication-TileImage");
|
||||
|
||||
return icons;
|
||||
}
|
||||
|
||||
Future<Result<WebPageInfo>> fetchPageInfo({
|
||||
required Uri url,
|
||||
required bool isImageRequest,
|
||||
required int? proxyPort,
|
||||
}) {
|
||||
return Result.fromAsync(() async {
|
||||
final result = await compute((args) async {
|
||||
final [String urlString, bool isImageRequest, int? proxyPort] = args;
|
||||
|
||||
final httpClient = HttpClient();
|
||||
if (proxyPort != null) {
|
||||
SocksTCPClient.assignToHttpClient(httpClient, [
|
||||
ProxySettings(InternetAddress.loopbackIPv4, proxyPort),
|
||||
]);
|
||||
}
|
||||
|
||||
final client = IOClient(httpClient);
|
||||
try {
|
||||
final baseUri = Uri.parse(urlString);
|
||||
final response = await client
|
||||
.get(baseUri)
|
||||
.timeout(const Duration(seconds: 15));
|
||||
|
||||
//When this is a request for an icon and we hit an image, directly return it
|
||||
if (isImageRequest) {
|
||||
final contentType = response.headers['content-type'];
|
||||
if (contentType?.contains('image/') == true) {
|
||||
return {
|
||||
'imageBytes': [response.bodyBytes],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
final document = html_parser.parse(response.bodyUnicodeFallback);
|
||||
|
||||
final title = document.querySelector('title')?.text;
|
||||
final resources = _extractIcons(baseUri, document);
|
||||
final feeds = await FeedFinder(
|
||||
url: baseUri,
|
||||
document: document,
|
||||
).parse();
|
||||
|
||||
return {
|
||||
'title': title,
|
||||
'resources': resources.map(_serializeResource).toList(),
|
||||
'feeds': feeds.map((uri) => uri.toString()).toList(),
|
||||
};
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}, <dynamic>[url.toString(), isImageRequest, proxyPort]);
|
||||
|
||||
if (result['imageBytes'] case final Uint8List imageBytes) {
|
||||
return WebPageInfo(
|
||||
url: url,
|
||||
favicon: await BrowserIcon.fromBytes(
|
||||
imageBytes,
|
||||
dominantColor: null,
|
||||
source: IconSource.download,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final resources = (result['resources']! as List<Map<String, dynamic>>)
|
||||
.map(_deserializeResource)
|
||||
.toList();
|
||||
|
||||
final favicon =
|
||||
await getCachedIcon(url) ??
|
||||
await loadIcon(url: url, resources: resources);
|
||||
|
||||
return WebPageInfo(
|
||||
url: url,
|
||||
title: (result['title'] as String?)?.trim(),
|
||||
favicon: favicon,
|
||||
feeds: Set.from(
|
||||
(result['feeds']! as List<String>).map((url) => Uri.tryParse(url)),
|
||||
),
|
||||
);
|
||||
}, exceptionHandler: handleHttpError);
|
||||
}
|
||||
|
||||
Future<BrowserIcon?> getCachedIcon(Uri url) async {
|
||||
if (_isHttpUrl(url)) {
|
||||
final cachedBrowserIcon = _browserIconCache.get(url.origin);
|
||||
if (cachedBrowserIcon?.image.value != null) {
|
||||
return cachedBrowserIcon;
|
||||
} else if (cachedBrowserIcon != null) {
|
||||
_browserIconCache.remove(url.origin);
|
||||
}
|
||||
|
||||
final cachedIcon = await _cacheRepository.getCachedIcon(url.origin);
|
||||
if (cachedIcon != null) {
|
||||
return _browserIconCache.set(
|
||||
url.origin,
|
||||
await BrowserIcon.fromBytes(
|
||||
cachedIcon,
|
||||
dominantColor: null,
|
||||
source: IconSource.disk,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<BrowserIcon> loadIcon({
|
||||
required Uri url,
|
||||
required List<Resource> resources,
|
||||
bool isPrivate = false,
|
||||
bool waitOnNetworkLoad = true,
|
||||
}) async {
|
||||
final result = await _iconsService.loadIcon(
|
||||
url: url,
|
||||
resources: resources,
|
||||
isPrivate: isPrivate,
|
||||
waitOnNetworkLoad: waitOnNetworkLoad,
|
||||
);
|
||||
|
||||
if (result.source != IconSource.generator &&
|
||||
result.source != IconSource.memory) {
|
||||
await _cacheRepository.cacheIcon(url, result.image);
|
||||
}
|
||||
|
||||
return _browserIconCache.set(
|
||||
url.origin,
|
||||
await BrowserIcon.fromBytes(
|
||||
result.image,
|
||||
dominantColor: result.color.mapNotNull((color) => Color(color)),
|
||||
source: result.source,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Result<WebPageInfo>> _deduplicatedFetchPageInfo(Uri url) {
|
||||
final existing = _inFlightFetches[url];
|
||||
if (existing != null) {
|
||||
return existing.future;
|
||||
}
|
||||
|
||||
late final Future<Result<WebPageInfo>> inFlightFetch;
|
||||
inFlightFetch = fetchPageInfo(url: url, isImageRequest: true, proxyPort: null)
|
||||
.timeout(
|
||||
const Duration(seconds: 20),
|
||||
onTimeout: () => Result.failure(
|
||||
const ErrorMessage(source: 'icon', message: 'Icon fetch timeout'),
|
||||
),
|
||||
)
|
||||
.whenComplete(() {
|
||||
// Only clear this entry if it is still the active in-flight request.
|
||||
if (identical(_inFlightFetches[url]?.future, inFlightFetch)) {
|
||||
_inFlightFetches.remove(url);
|
||||
}
|
||||
});
|
||||
|
||||
_inFlightFetches[url] = _InFlightFetch(inFlightFetch);
|
||||
return inFlightFetch;
|
||||
}
|
||||
|
||||
Future<BrowserIcon?> getUrlIcon(List<Uri> urlList) async {
|
||||
for (final url in urlList) {
|
||||
if (!_isHttpUrl(url)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final cachedIcon = await getCachedIcon(url);
|
||||
|
||||
if (cachedIcon != null) {
|
||||
return cachedIcon;
|
||||
}
|
||||
|
||||
if (ref.mounted) {
|
||||
final result = await _deduplicatedFetchPageInfo(url);
|
||||
|
||||
if (result.isSuccess) {
|
||||
if (result.value.favicon case final BrowserIcon favicon) {
|
||||
if (!_browserIconCache.contains(url.origin)) {
|
||||
_browserIconCache.set(url.origin, favicon);
|
||||
}
|
||||
|
||||
return favicon;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'generic_website.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(GenericWebsiteService)
|
||||
final genericWebsiteServiceProvider = GenericWebsiteServiceProvider._();
|
||||
|
||||
final class GenericWebsiteServiceProvider
|
||||
extends $NotifierProvider<GenericWebsiteService, void> {
|
||||
GenericWebsiteServiceProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'genericWebsiteServiceProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$genericWebsiteServiceHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
GenericWebsiteService create() => GenericWebsiteService();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(void value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<void>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$genericWebsiteServiceHash() =>
|
||||
r'a2bd892f0ca07467eaa906648e57f232a7e50155';
|
||||
|
||||
abstract class _$GenericWebsiteService extends $Notifier<void> {
|
||||
void build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<void, void>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<void, void>,
|
||||
void,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user