Add Supa account and search changes

This commit is contained in:
Fabian Freund
2026-05-22 18:10:22 +02:00
parent 3a19865b2e
commit 51289f1266
374 changed files with 54061 additions and 5013 deletions
@@ -19,31 +19,47 @@
*/
import 'dart:ui';
/// A wrapper around [Image] that compares by a precomputed byte-hash.
///
/// Instances are byte-hash deduped via the decoder's LRU, so the same
/// `EquatableImage` may be shared across many holders (multiple tabs of
/// the same site, the icon cache, widget closures). To make that safe,
/// the underlying `ui.Image` is disposed via a [Finalizer] when this
/// wrapper becomes unreachable — never on cache eviction or state
/// transitions, where another holder might still be using it.
class EquatableImage {
static final Finalizer<Image> _finalizer = Finalizer<Image>(
(image) => image.dispose(),
);
Image? _value;
final int _imageHash;
bool _isDisposed = false;
EquatableImage(Image value, {required int hash})
: _value = value,
_imageHash = hash;
_imageHash = hash {
_finalizer.attach(this, value, detach: this);
}
/// The underlying ui.Image. Returns null if disposed.
/// The underlying ui.Image. Returns null if explicitly disposed.
Image? get value => _isDisposed ? null : _value;
/// Whether this image has been disposed.
/// Whether this image has been explicitly disposed.
bool get isDisposed => _isDisposed;
/// Disposes the underlying ui.Image to free GPU memory.
/// This is safe to call multiple times.
/// Explicitly release the underlying GPU resource now.
///
/// Optional — if not called, the resource is freed when this wrapper is
/// garbage collected. Only call this when you are certain no other holder
/// still references the image; otherwise prefer to drop your reference
/// and let GC handle it.
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;
});
_finalizer.detach(this);
_value?.dispose();
_value = null;
}
@override
@@ -23,6 +23,7 @@ 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/account/domain/services/account_callback_handler.dart';
import 'package:weblibre/features/bangs/data/models/bang_group.dart';
import 'package:weblibre/features/bangs/domain/repositories/sync.dart';
@@ -78,6 +79,9 @@ class AppInitializationService extends _$AppInitializationService {
result.onFailure(errors.add);
}
// Activate account callback deep link handler
ref.read(accountCallbackHandlerProvider);
return (initialized: true, stage: null, errors: errors);
});
@@ -54,7 +54,7 @@ final class AppInitializationServiceProvider
}
String _$appInitializationServiceHash() =>
r'bf4c472d5b0b758d703b8ca40763cf2a59712a4c';
r'79339776571e4bf409213cba4b4696ad2cca179e';
abstract class _$AppInitializationService
extends
@@ -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:io';
import 'dart:typed_data';
import 'package:http/io_client.dart';
import 'package:socks5_proxy/socks_client.dart';
enum FaviconResolveStatus { hit, missing, error }
class FaviconResolveResult {
final FaviconResolveStatus status;
final Uint8List? bytes;
const FaviconResolveResult._({required this.status, this.bytes});
const FaviconResolveResult.hit(Uint8List bytes)
: this._(status: FaviconResolveStatus.hit, bytes: bytes);
const FaviconResolveResult.missing()
: this._(status: FaviconResolveStatus.missing);
const FaviconResolveResult.error()
: this._(status: FaviconResolveStatus.error);
}
abstract class FaviconResolver {
Future<FaviconResolveResult> resolve(Uri url, {int? proxyPort});
}
final class DdgFaviconResolver implements FaviconResolver {
static const _timeout = Duration(seconds: 15);
@override
Future<FaviconResolveResult> resolve(Uri url, {int? proxyPort}) async {
final host = url.host.trim().toLowerCase();
if (host.isEmpty) {
return const FaviconResolveResult.error();
}
final httpClient = HttpClient();
if (proxyPort != null) {
SocksTCPClient.assignToHttpClient(httpClient, [
ProxySettings(InternetAddress.loopbackIPv4, proxyPort),
]);
}
final client = IOClient(httpClient);
try {
final response = await client
.get(Uri.https('icons.duckduckgo.com', '/ip2/$host.ico'))
.timeout(_timeout);
if (response.statusCode == 200 && response.bodyBytes.isNotEmpty) {
return FaviconResolveResult.hit(response.bodyBytes);
}
if (response.statusCode == 404) {
return const FaviconResolveResult.missing();
}
return const FaviconResolveResult.error();
} on SocketException {
return const FaviconResolveResult.error();
} on HttpException {
return const FaviconResolveResult.error();
} on HandshakeException {
return const FaviconResolveResult.error();
} on TlsException {
return const FaviconResolveResult.error();
} catch (_) {
return const FaviconResolveResult.error();
} finally {
client.close();
}
}
}
@@ -23,15 +23,14 @@ 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/domain/services/favicon_resolver.dart';
import 'package:weblibre/extensions/http_encoding.dart';
import 'package:weblibre/extensions/uri.dart';
import 'package:weblibre/features/geckoview/domain/entities/browser_icon.dart';
@@ -41,184 +40,49 @@ 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,
};
@Riverpod(keepAlive: true)
FaviconResolver faviconResolver(Ref ref) => DdgFaviconResolver();
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)
GeckoIconService geckoIconService(Ref ref) => GeckoIconService();
@Riverpod(keepAlive: true)
class GenericWebsiteService extends _$GenericWebsiteService {
final GeckoIconService _iconsService;
late CacheRepository _cacheRepository;
late FaviconResolver _faviconResolver;
late GeckoIconService _iconsService;
late final LRUCache<String, BrowserIcon> _browserIconCache;
final _inFlightFetches = <Uri, _InFlightFetch>{};
final _inFlightIconFetches = <String, Future<BrowserIcon?>>{};
GenericWebsiteService()
: _iconsService = GeckoIconService(),
_browserIconCache = LRUCache(50, onEvict: (icon) => icon.image.dispose());
GenericWebsiteService() : _browserIconCache = LRUCache(50);
@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;
_faviconResolver = ref.watch(faviconResolverProvider);
_iconsService = ref.watch(geckoIconServiceProvider);
}
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,
),
);
}
}
}
static bool _isResolverEligible(Uri url) {
if (!_isHttpUrl(url) || url.host.isEmpty || url.isLocalhost) {
return 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,
),
);
}
}
}
final host = url.host.toLowerCase();
if (host.endsWith('.onion')) {
return 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;
return InternetAddress.tryParse(host) == null;
}
Future<Result<WebPageInfo>> fetchPageInfo({
required Uri url,
required bool isImageRequest,
required int? proxyPort,
bool forceRefresh = false,
}) {
return Result.fromAsync(() async {
final result = await compute((args) async {
@@ -251,7 +115,6 @@ class GenericWebsiteService extends _$GenericWebsiteService {
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,
@@ -259,7 +122,6 @@ class GenericWebsiteService extends _$GenericWebsiteService {
return {
'title': title,
'resources': resources.map(_serializeResource).toList(),
'feeds': feeds.map((uri) => uri.toString()).toList(),
};
} finally {
@@ -268,28 +130,21 @@ class GenericWebsiteService extends _$GenericWebsiteService {
}, <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 favicon = await BrowserIcon.fromBytes(
imageBytes,
dominantColor: null,
source: IconSource.download,
);
if (favicon != null) {
await _cacheRepository.cacheIcon(url, imageBytes);
_browserIconCache.set(url.origin, favicon);
}
return WebPageInfo(url: url, favicon: favicon);
}
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)),
),
@@ -300,7 +155,9 @@ class GenericWebsiteService extends _$GenericWebsiteService {
Future<BrowserIcon?> getCachedIcon(Uri url) async {
if (_isHttpUrl(url)) {
final cachedBrowserIcon = _browserIconCache.get(url.origin);
if (cachedBrowserIcon?.image.value != null) {
if (cachedBrowserIcon?.source == IconSource.generator) {
_browserIconCache.remove(url.origin);
} else if (cachedBrowserIcon?.image.value != null) {
return cachedBrowserIcon;
} else if (cachedBrowserIcon != null) {
_browserIconCache.remove(url.origin);
@@ -308,100 +165,192 @@ class GenericWebsiteService extends _$GenericWebsiteService {
final cachedIcon = await _cacheRepository.getCachedIcon(url.origin);
if (cachedIcon != null) {
return _browserIconCache.set(
url.origin,
await BrowserIcon.fromBytes(
cachedIcon,
dominantColor: null,
source: IconSource.disk,
),
final decoded = 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;
}
if (decoded != null) {
return _browserIconCache.set(url.origin, decoded);
}
}
}
return null;
}
Future<BrowserIcon?> _resolveIconWithDdg(
Uri url, {
required bool cacheMissing,
}) {
final origin = url.origin;
final existing = _inFlightIconFetches[origin];
if (existing != null) {
return existing;
}
late final Future<BrowserIcon?> inFlight;
inFlight = _fetchAndCacheDdgIcon(url, cacheMissing: cacheMissing)
.whenComplete(() {
if (identical(_inFlightIconFetches[origin], inFlight)) {
_inFlightIconFetches.remove(origin);
}
});
_inFlightIconFetches[origin] = inFlight;
return inFlight;
}
Future<void> primeCachedIcon(Uri url, Uint8List bytes) async {
await _cacheRepository.cacheIconIfAbsent(url, bytes);
}
static const _iconStaleTtl = Duration(days: 30);
static const _missingIconStaleTtl = Duration(days: 7);
Future<void> refreshIconIfStale(Uri url) async {
if (!_isHttpUrl(url)) return;
if (_inFlightIconFetches.containsKey(url.origin)) return;
final rawIcon = await _cacheRepository.getCachedIconRaw(url.origin);
if (rawIcon == null) return;
final fetchedAt = await _cacheRepository.getCachedIconFetchDate(url.origin);
if (fetchedAt == null) return;
final ttl = _cacheRepository.isMissingIconBytes(rawIcon)
? _missingIconStaleTtl
: _iconStaleTtl;
if (DateTime.now().difference(fetchedAt) < ttl) return;
if (!_isResolverEligible(url)) return;
if (!ref.mounted) return;
await _resolveIconWithDdg(
url,
cacheMissing: _cacheRepository.isMissingIconBytes(rawIcon),
);
}
Future<BrowserIcon?> getUrlIcon(
List<Uri> urlList, {
bool cacheOnly = false,
}) async {
final eligibleUrls = urlList.where(_isHttpUrl).toList();
if (eligibleUrls.isEmpty) {
return null;
}
for (final url in eligibleUrls) {
final cachedIcon = await getCachedIcon(url);
if (cachedIcon != null) {
return cachedIcon;
}
}
if (cacheOnly) {
return _loadIconWithoutNetwork(eligibleUrls.first);
}
for (final url in eligibleUrls) {
if (!_isResolverEligible(url)) {
continue;
}
if (await _hasFreshMissingIcon(url)) {
continue;
}
if (ref.mounted) {
final resolved = await _resolveIconWithDdg(url, cacheMissing: true);
if (resolved != null) {
return resolved;
}
}
}
BrowserIcon? generatedFallback;
for (final url in eligibleUrls) {
final icon = await _loadIconWithoutNetwork(url);
if (icon == null) {
continue;
}
if (icon.source != IconSource.generator) {
return icon;
}
generatedFallback ??= icon;
}
return generatedFallback;
}
Future<BrowserIcon?> _fetchAndCacheDdgIcon(
Uri url, {
required bool cacheMissing,
}) async {
final result = await _faviconResolver.resolve(url);
switch (result.status) {
case FaviconResolveStatus.hit:
final bytes = result.bytes!;
final decoded = await BrowserIcon.fromBytes(
bytes,
dominantColor: null,
source: IconSource.download,
);
if (decoded == null) {
return null;
}
await _cacheRepository.cacheIcon(url, bytes);
return _browserIconCache.set(url.origin, decoded);
case FaviconResolveStatus.missing:
if (cacheMissing) {
await _cacheRepository.cacheMissingIcon(url);
}
return null;
case FaviconResolveStatus.error:
return null;
}
}
Future<bool> _hasFreshMissingIcon(Uri url) async {
final rawIcon = await _cacheRepository.getCachedIconRaw(url.origin);
if (!_cacheRepository.isMissingIconBytes(rawIcon)) {
return false;
}
final fetchedAt = await _cacheRepository.getCachedIconFetchDate(url.origin);
if (fetchedAt == null) {
return false;
}
return DateTime.now().difference(fetchedAt) < _missingIconStaleTtl;
}
Future<BrowserIcon?> _loadIconWithoutNetwork(Uri url) async {
final result = await _iconsService.loadIcon(
url: url,
waitOnNetworkLoad: false,
);
final decoded = await BrowserIcon.fromBytes(
result.image,
dominantColor: result.color == null ? null : Color(result.color!),
source: result.source,
);
if (decoded == null) {
return null;
}
if (result.source == IconSource.generator) {
_browserIconCache.remove(url.origin);
return decoded;
}
if (result.source != IconSource.generator &&
result.source != IconSource.memory) {
await _cacheRepository.cacheIcon(url, result.image);
}
return _browserIconCache.set(url.origin, decoded);
}
}
@@ -9,6 +9,94 @@ part of 'generic_website.dart';
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(faviconResolver)
final faviconResolverProvider = FaviconResolverProvider._();
final class FaviconResolverProvider
extends
$FunctionalProvider<FaviconResolver, FaviconResolver, FaviconResolver>
with $Provider<FaviconResolver> {
FaviconResolverProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'faviconResolverProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$faviconResolverHash();
@$internal
@override
$ProviderElement<FaviconResolver> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
FaviconResolver create(Ref ref) {
return faviconResolver(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(FaviconResolver value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<FaviconResolver>(value),
);
}
}
String _$faviconResolverHash() => r'3935f8cb31a4cee625de343597e4a486e169f4f9';
@ProviderFor(geckoIconService)
final geckoIconServiceProvider = GeckoIconServiceProvider._();
final class GeckoIconServiceProvider
extends
$FunctionalProvider<
GeckoIconService,
GeckoIconService,
GeckoIconService
>
with $Provider<GeckoIconService> {
GeckoIconServiceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'geckoIconServiceProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$geckoIconServiceHash();
@$internal
@override
$ProviderElement<GeckoIconService> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
GeckoIconService create(Ref ref) {
return geckoIconService(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(GeckoIconService value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<GeckoIconService>(value),
);
}
}
String _$geckoIconServiceHash() => r'e7d2862992c6dfaa031499de06c2d6af04e30fba';
@ProviderFor(GenericWebsiteService)
final genericWebsiteServiceProvider = GenericWebsiteServiceProvider._();
@@ -42,7 +130,7 @@ final class GenericWebsiteServiceProvider
}
String _$genericWebsiteServiceHash() =>
r'a2bd892f0ca07467eaa906648e57f232a7e50155';
r'83c62a15492606a7ffe4a7990fc1f9bdbfee87cc';
abstract class _$GenericWebsiteService extends $Notifier<void> {
void build();