/* * 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 . */ import 'dart:convert'; import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:riverpod/experimental/persist.dart'; import 'package:riverpod_annotation/experimental/persist.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:weblibre/features/addons/utils/addon_html.dart'; import 'package:weblibre/features/geckoview/domain/providers.dart'; import 'package:weblibre/features/user/data/providers.dart'; part 'providers.g.dart'; sealed class AddonUpdateOutcome { const AddonUpdateOutcome(); } class AddonUpdateOutcomeAvailable extends AddonUpdateOutcome { final AddonInfo addon; final String availableVersion; const AddonUpdateOutcomeAvailable({ required this.addon, required this.availableVersion, }); } class AddonUpdateOutcomeUpToDate extends AddonUpdateOutcome { const AddonUpdateOutcomeUpToDate(); } class AddonUpdateOutcomeMissing extends AddonUpdateOutcome { const AddonUpdateOutcomeMissing(); } sealed class AddonUpdateRunResult { const AddonUpdateRunResult(); } class AddonUpdateRunDone extends AddonUpdateRunResult { final String? message; const AddonUpdateRunDone(this.message); } class AddonUpdateRunNoRemoteSource extends AddonUpdateRunResult { const AddonUpdateRunNoRemoteSource(); } class AddonUpdateRunFailed extends AddonUpdateRunResult { const AddonUpdateRunFailed(); } String _resolveAvailableVersion(AddonInfo addon, AddonStoreInfo? storeInfo) { final latest = storeInfo?.latestVersion.trim(); return (latest != null && latest.isNotEmpty) ? latest : addon.version; } @Riverpod() class AddonDetails extends _$AddonDetails { GeckoAddonService get _service => ref.read(addonServiceProvider); Future _run(Future Function() action) async { state = const AsyncLoading(); final result = await AsyncValue.guard(action); if (!ref.mounted) { return; } state = result; ref.invalidate(addonListProvider); } Future refresh() async { ref.invalidateSelf(); await future; } Future install() async { final current = state.value; if (current == null) return; await _run(() async { await _service.installAddon(Uri.parse(current.downloadUrl)); return _service.getAddonById(addonId); }); } Future uninstall() async { await _run(() async { await _service.uninstallAddon(addonId); return null; }); } Future setEnabled({required bool enabled}) async { await _run( () => enabled ? _service.enableAddon(addonId) : _service.disableAddon(addonId), ); } Future setAllowedInPrivateBrowsing({required bool allowed}) async { await _run( () => _service.setAddonAllowedInPrivateBrowsing(addonId, allowed), ); } Future setAutoUpdateEnabled({required bool enabled}) async { await _run( () => _service.setAddonAutoUpdateEnabledForAddon(addonId, enabled), ); } @override Future build(String addonId) { return _service.getAddonById(addonId); } } @Riverpod() Future addonStoreInfo(Ref ref, String addonId) { return ref.read(addonServiceProvider).getAddonStoreInfo(addonId); } @Riverpod() Future> featuredAddonListings(Ref ref, AddonStoreApp app) { return ref.read(addonServiceProvider).getFeaturedAddonListings(app: app); } @Riverpod() Future> searchAddonListings( Ref ref, String query, AddonStoreApp app, ) async { final trimmed = query.trim(); if (trimmed.isEmpty) { return ref.watch(featuredAddonListingsProvider(app).future); } return ref .read(addonServiceProvider) .searchAddonListings(query: trimmed, app: app); } @Riverpod(keepAlive: true) class AddonStoreAppFilter extends _$AddonStoreAppFilter { // ignore: use_setters_to_change_properties void setApp(AddonStoreApp app) => state = app; @override AddonStoreApp build() => AddonStoreApp.android; } @Riverpod() Future addonDescriptionMarkdown(Ref ref, String addonId) async { final description = await ref.watch( addonDetailsProvider( addonId, ).selectAsync((addon) => addon?.description ?? ''), ); return turndownAddonHtml(description); } @Riverpod() Future addonHtmlMarkdown(Ref ref, String html) { return turndownAddonHtml(html); } @Riverpod() Future lastAddonUpdateAttempt( Ref ref, String addonId, ) { return ref.read(addonServiceProvider).getLastAddonUpdateAttempt(addonId); } @Riverpod() class AddonUpdateCheck extends _$AddonUpdateCheck { /// Refreshes store info and returns whether an update is available. Future resolveAvailableUpdate() async { final storeInfo = await ref .read(addonServiceProvider) .getAddonStoreInfo(addonId); final fresh = await ref .read(addonServiceProvider) .getAddonById(addonId, allowCache: false); if (fresh == null) return const AddonUpdateOutcomeMissing(); final available = _resolveAvailableVersion(fresh, storeInfo); final hasUpdate = fresh.installedVersion != null && available.isNotEmpty && fresh.installedVersion != available; return hasUpdate ? AddonUpdateOutcomeAvailable(addon: fresh, availableVersion: available) : const AddonUpdateOutcomeUpToDate(); } /// Triggers a remote update and awaits completion. Invalidates dependent /// providers on completion. Future triggerAndAwait() async { state = const AsyncLoading(); final result = await AsyncValue.guard(() async { final AddonUpdateAttemptInfo? attempt; try { attempt = await ref .read(addonServiceProvider) .triggerAddonUpdate(addonId); } catch (error) { final noRemote = error.toString().contains( 'No remote update source is available for this locally installed extension.', ); return noRemote ? const AddonUpdateRunNoRemoteSource() : const AddonUpdateRunFailed(); } return attempt?.status == AddonUpdateStatus.error ? const AddonUpdateRunFailed() : AddonUpdateRunDone(attempt?.message); }); if (!ref.mounted) { return result.value ?? const AddonUpdateRunFailed(); } state = result; ref.invalidate(addonDetailsProvider(addonId)); ref.invalidate(lastAddonUpdateAttemptProvider(addonId)); return result.value ?? const AddonUpdateRunFailed(); } @override AsyncValue build(String addonId) => const AsyncData(AddonUpdateRunDone(null)); } @Riverpod() class AddonList extends _$AddonList { GeckoAddonService get _service => ref.read(addonServiceProvider); Future refresh() async { ref.invalidateSelf(); await future; } Future install(AddonInfo addon) async { final busyIds = ref.read(addonBusyIdsProvider.notifier); busyIds.add(addon.id); try { await _service.installAddon(Uri.parse(addon.downloadUrl)); if (!ref.mounted) { return; } ref.invalidate(addonDetailsProvider(addon.id)); ref.invalidateSelf(); await future; } finally { busyIds.remove(addon.id); } } Future uninstall(AddonInfo addon) async { final busyIds = ref.read(addonBusyIdsProvider.notifier); busyIds.add(addon.id); try { await _service.uninstallAddon(addon.id); if (!ref.mounted) { return; } ref.invalidate(addonDetailsProvider(addon.id)); ref.invalidateSelf(); await future; } finally { busyIds.remove(addon.id); } } @override Future> build() { return _service.getAddons(); } } @Riverpod() class AddonBusyIds extends _$AddonBusyIds { void add(String id) { if (!ref.mounted) return; state = {...state, id}; } void remove(String id) { if (!ref.mounted) return; state = {...state}..remove(id); } @override Set build() => const {}; } @Riverpod(keepAlive: true) class PinnedAddonIds extends _$PinnedAddonIds { void setPinned(String addonId, {required bool pinned}) { if (pinned) { if (!state.contains(addonId)) { state = {...state, addonId}; } } else if (state.contains(addonId)) { state = {...state}..remove(addonId); } } @override Set build() { persist( ref.watch(riverpodDatabaseStorageProvider), key: 'PinnedAddonIds', options: const StorageOptions(cacheTime: StorageCacheTime.unsafe_forever), encode: (state) => jsonEncode(state.toList()), decode: (encoded) => (jsonDecode(encoded) as List).cast().toSet(), ); return stateOrNull ?? const {}; } } @Riverpod() class BulkAddonUpdate extends _$BulkAddonUpdate { Future triggerAll() async { state = const AsyncLoading(); final result = await AsyncValue.guard(() async { await ref.read(addonServiceProvider).triggerAllAddonUpdates(); }); if (!ref.mounted) { return; } state = result; } @override AsyncValue build() => const AsyncData(null); }