new extension management

This commit is contained in:
Fabian Freund
2026-04-17 19:09:27 +02:00
parent 8217472f59
commit f9bcd076f2
49 changed files with 7827 additions and 4533 deletions
@@ -209,38 +209,6 @@
android:name="eu.weblibre.flutter_mozilla_components.activities.AuthIntentReceiverActivity"
android:exported="false" />
<activity
android:theme="@style/AddonsActivityTheme"
android:name="eu.weblibre.flutter_mozilla_components.addons.AddonsActivity"
android:label="@string/mozac_feature_addons_addons"
android:exported="false"
android:parentActivityName=".MainActivity" />
<activity
android:theme="@style/AppTheme"
android:name="eu.weblibre.flutter_mozilla_components.addons.AddonDetailsActivity"
android:exported="false"
android:label="@string/mozac_feature_addons_addons" />
<activity
android:name="eu.weblibre.flutter_mozilla_components.addons.InstalledAddonDetailsActivity"
android:label="@string/mozac_feature_addons_addons"
android:parentActivityName="eu.weblibre.flutter_mozilla_components.addons.AddonsActivity"
android:exported="false"
android:theme="@style/AppTheme" />
<activity
android:name="eu.weblibre.flutter_mozilla_components.addons.PermissionsDetailsActivity"
android:label="@string/mozac_feature_addons_addons"
android:exported="false"
android:theme="@style/AppTheme" />
<activity
android:name="eu.weblibre.flutter_mozilla_components.addons.AddonInternalSettingsActivity"
android:label="@string/mozac_feature_addons_addons"
android:exported="false"
android:theme="@style/AppTheme" />
<activity
android:name="eu.weblibre.flutter_mozilla_components.addons.WebExtensionActionPopupActivity"
android:label="@string/mozac_feature_addons_addons"
@@ -34,8 +34,4 @@
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
<style name="AddonsActivityTheme" parent="AppTheme">
<item name="mozac_primary_text_color">@color/photonDarkGrey90</item>
</style>
</resources>
@@ -0,0 +1,81 @@
/*
* 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/>.
*/
part of 'routes.dart';
@TypedGoRoute<AddonManagerRoute>(
name: 'AddonManagerRoute',
path: '/addons',
routes: [
TypedGoRoute<AddonDetailsRoute>(
name: 'AddonDetailsRoute',
path: 'details/:addonId',
),
TypedGoRoute<AddonPermissionsRoute>(
name: 'AddonPermissionsRoute',
path: 'permissions/:addonId',
),
TypedGoRoute<AddonInternalSettingsRoute>(
name: 'AddonInternalSettingsRoute',
path: 'settings/:addonId',
),
],
)
class AddonManagerRoute extends GoRouteData with $AddonManagerRoute {
const AddonManagerRoute();
@override
Widget build(BuildContext context, GoRouterState state) {
return const AddonManagerScreen();
}
}
class AddonDetailsRoute extends GoRouteData with $AddonDetailsRoute {
final String addonId;
const AddonDetailsRoute({required this.addonId});
@override
Widget build(BuildContext context, GoRouterState state) {
return AddonDetailsScreen(addonId: addonId);
}
}
class AddonPermissionsRoute extends GoRouteData with $AddonPermissionsRoute {
final String addonId;
const AddonPermissionsRoute({required this.addonId});
@override
Widget build(BuildContext context, GoRouterState state) {
return AddonPermissionsScreen(addonId: addonId);
}
}
class AddonInternalSettingsRoute extends GoRouteData
with $AddonInternalSettingsRoute {
final String addonId;
const AddonInternalSettingsRoute({required this.addonId});
@override
Widget build(BuildContext context, GoRouterState state) {
return AddonInternalSettingsScreen(addonId: addonId);
}
}
@@ -28,6 +28,10 @@ import 'package:weblibre/core/routing/widgets/bottom_sheet_page.dart';
import 'package:weblibre/core/routing/widgets/dialog_page.dart';
import 'package:weblibre/domain/entities/profile.dart';
import 'package:weblibre/features/about/presentation/screens/about.dart';
import 'package:weblibre/features/addons/presentation/screens/addon_details.dart';
import 'package:weblibre/features/addons/presentation/screens/addon_internal_settings.dart';
import 'package:weblibre/features/addons/presentation/screens/addon_manager.dart';
import 'package:weblibre/features/addons/presentation/screens/addon_permissions.dart';
import 'package:weblibre/features/bangs/data/models/bang.dart';
import 'package:weblibre/features/bangs/presentation/screens/categories.dart';
import 'package:weblibre/features/bangs/presentation/screens/category.dart';
@@ -97,6 +101,7 @@ import 'package:weblibre/features/web_feed/presentation/select_feed_dialog.dart'
part 'routes.bangs.dart';
part 'routes.bookmarks.dart';
part 'routes.browser.dart';
part 'routes.addons.dart';
part 'routes.feeds.dart';
part 'routes.g.dart';
part 'routes.history.dart';
@@ -13,6 +13,7 @@ List<RouteBase> get $appRoutes => [
$bangMenuRoute,
$bookmarksRoute,
$browserRoute,
$addonManagerRoute,
$feedListRoute,
$historyRoute,
$profileListRoute,
@@ -931,6 +932,125 @@ extension<T extends Enum> on Map<T, String> {
entries.where((element) => element.value == value).firstOrNull?.key;
}
RouteBase get $addonManagerRoute => GoRouteData.$route(
path: '/addons',
name: 'AddonManagerRoute',
factory: $AddonManagerRoute._fromState,
routes: [
GoRouteData.$route(
path: 'details/:addonId',
name: 'AddonDetailsRoute',
factory: $AddonDetailsRoute._fromState,
),
GoRouteData.$route(
path: 'permissions/:addonId',
name: 'AddonPermissionsRoute',
factory: $AddonPermissionsRoute._fromState,
),
GoRouteData.$route(
path: 'settings/:addonId',
name: 'AddonInternalSettingsRoute',
factory: $AddonInternalSettingsRoute._fromState,
),
],
);
mixin $AddonManagerRoute on GoRouteData {
static AddonManagerRoute _fromState(GoRouterState state) =>
const AddonManagerRoute();
@override
String get location => GoRouteData.$location('/addons');
@override
void go(BuildContext context) => context.go(location);
@override
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
@override
void pushReplacement(BuildContext context) =>
context.pushReplacement(location);
@override
void replace(BuildContext context) => context.replace(location);
}
mixin $AddonDetailsRoute on GoRouteData {
static AddonDetailsRoute _fromState(GoRouterState state) =>
AddonDetailsRoute(addonId: state.pathParameters['addonId']!);
AddonDetailsRoute get _self => this as AddonDetailsRoute;
@override
String get location => GoRouteData.$location(
'/addons/details/${Uri.encodeComponent(_self.addonId)}',
);
@override
void go(BuildContext context) => context.go(location);
@override
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
@override
void pushReplacement(BuildContext context) =>
context.pushReplacement(location);
@override
void replace(BuildContext context) => context.replace(location);
}
mixin $AddonPermissionsRoute on GoRouteData {
static AddonPermissionsRoute _fromState(GoRouterState state) =>
AddonPermissionsRoute(addonId: state.pathParameters['addonId']!);
AddonPermissionsRoute get _self => this as AddonPermissionsRoute;
@override
String get location => GoRouteData.$location(
'/addons/permissions/${Uri.encodeComponent(_self.addonId)}',
);
@override
void go(BuildContext context) => context.go(location);
@override
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
@override
void pushReplacement(BuildContext context) =>
context.pushReplacement(location);
@override
void replace(BuildContext context) => context.replace(location);
}
mixin $AddonInternalSettingsRoute on GoRouteData {
static AddonInternalSettingsRoute _fromState(GoRouterState state) =>
AddonInternalSettingsRoute(addonId: state.pathParameters['addonId']!);
AddonInternalSettingsRoute get _self => this as AddonInternalSettingsRoute;
@override
String get location => GoRouteData.$location(
'/addons/settings/${Uri.encodeComponent(_self.addonId)}',
);
@override
void go(BuildContext context) => context.go(location);
@override
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
@override
void pushReplacement(BuildContext context) =>
context.pushReplacement(location);
@override
void replace(BuildContext context) => context.replace(location);
}
RouteBase get $feedListRoute => GoRouteData.$route(
path: '/feeds',
name: 'FeedListRoute',
@@ -0,0 +1,294 @@
/*
* 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: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/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<void> _run(Future<AddonInfo?> Function() action) async {
state = const AsyncLoading<AddonInfo?>();
state = await AsyncValue.guard(action);
ref.invalidate(addonListProvider);
}
Future<void> refresh() async {
ref.invalidateSelf();
await future;
}
Future<void> 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<void> uninstall() async {
await _run(() async {
await _service.uninstallAddon(addonId);
return null;
});
}
Future<void> setEnabled({required bool enabled}) async {
await _run(
() => enabled
? _service.enableAddon(addonId)
: _service.disableAddon(addonId),
);
}
Future<void> setAllowedInPrivateBrowsing({required bool allowed}) async {
await _run(
() => _service.setAddonAllowedInPrivateBrowsing(addonId, allowed),
);
}
Future<void> setAutoUpdateEnabled({required bool enabled}) async {
await _run(
() => _service.setAddonAutoUpdateEnabledForAddon(addonId, enabled),
);
}
@override
Future<AddonInfo?> build(String addonId) {
return _service.getAddonById(addonId);
}
}
@Riverpod()
Future<AddonStoreInfo?> addonStoreInfo(Ref ref, String addonId) {
return ref.read(addonServiceProvider).getAddonStoreInfo(addonId);
}
@Riverpod()
Future<AddonUpdateAttemptInfo?> 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<AddonUpdateOutcome> 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<AddonUpdateRunResult> triggerAndAwait() async {
state = const AsyncLoading();
final result = await AsyncValue.guard<AddonUpdateRunResult>(() 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);
});
state = result;
ref.invalidate(addonDetailsProvider(addonId));
ref.invalidate(lastAddonUpdateAttemptProvider(addonId));
return result.value ?? const AddonUpdateRunFailed();
}
@override
AsyncValue<AddonUpdateRunResult> build(String addonId) =>
const AsyncData(AddonUpdateRunDone(null));
}
@Riverpod()
class AddonList extends _$AddonList {
GeckoAddonService get _service => ref.read(addonServiceProvider);
Future<void> refresh() async {
ref.invalidateSelf();
await future;
}
Future<void> install(AddonInfo addon) async {
ref.read(addonBusyIdsProvider.notifier).add(addon.id);
try {
await _service.installAddon(Uri.parse(addon.downloadUrl));
ref.invalidate(addonDetailsProvider(addon.id));
ref.invalidateSelf();
await future;
} finally {
ref.read(addonBusyIdsProvider.notifier).remove(addon.id);
}
}
Future<void> uninstall(AddonInfo addon) async {
ref.read(addonBusyIdsProvider.notifier).add(addon.id);
try {
await _service.uninstallAddon(addon.id);
ref.invalidate(addonDetailsProvider(addon.id));
ref.invalidateSelf();
await future;
} finally {
ref.read(addonBusyIdsProvider.notifier).remove(addon.id);
}
}
@override
Future<List<AddonInfo>> build() {
return _service.getAddons();
}
}
@Riverpod()
class AddonBusyIds extends _$AddonBusyIds {
void add(String id) => state = {...state, id};
void remove(String id) => state = {...state}..remove(id);
@override
Set<String> 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<String> build() {
persist(
ref.watch(riverpodDatabaseStorageProvider),
key: 'PinnedAddonIds',
encode: (state) => jsonEncode(state.toList()),
decode: (encoded) =>
(jsonDecode(encoded) as List<dynamic>).cast<String>().toSet(),
);
return stateOrNull ?? const {};
}
}
@Riverpod()
class BulkAddonUpdate extends _$BulkAddonUpdate {
Future<void> triggerAll() async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
await ref.read(addonServiceProvider).triggerAllAddonUpdates();
});
}
@override
AsyncValue<void> build() => const AsyncData(null);
}
@@ -0,0 +1,562 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'providers.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(AddonDetails)
final addonDetailsProvider = AddonDetailsFamily._();
final class AddonDetailsProvider
extends $AsyncNotifierProvider<AddonDetails, AddonInfo?> {
AddonDetailsProvider._({
required AddonDetailsFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'addonDetailsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$addonDetailsHash();
@override
String toString() {
return r'addonDetailsProvider'
''
'($argument)';
}
@$internal
@override
AddonDetails create() => AddonDetails();
@override
bool operator ==(Object other) {
return other is AddonDetailsProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$addonDetailsHash() => r'26b4a33e9d17aced1d3fb5c6ff28921f611ca5b0';
final class AddonDetailsFamily extends $Family
with
$ClassFamilyOverride<
AddonDetails,
AsyncValue<AddonInfo?>,
AddonInfo?,
FutureOr<AddonInfo?>,
String
> {
AddonDetailsFamily._()
: super(
retry: null,
name: r'addonDetailsProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
AddonDetailsProvider call(String addonId) =>
AddonDetailsProvider._(argument: addonId, from: this);
@override
String toString() => r'addonDetailsProvider';
}
abstract class _$AddonDetails extends $AsyncNotifier<AddonInfo?> {
late final _$args = ref.$arg as String;
String get addonId => _$args;
FutureOr<AddonInfo?> build(String addonId);
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<AsyncValue<AddonInfo?>, AddonInfo?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<AddonInfo?>, AddonInfo?>,
AsyncValue<AddonInfo?>,
Object?,
Object?
>;
element.handleCreate(ref, () => build(_$args));
}
}
@ProviderFor(addonStoreInfo)
final addonStoreInfoProvider = AddonStoreInfoFamily._();
final class AddonStoreInfoProvider
extends
$FunctionalProvider<
AsyncValue<AddonStoreInfo?>,
AddonStoreInfo?,
FutureOr<AddonStoreInfo?>
>
with $FutureModifier<AddonStoreInfo?>, $FutureProvider<AddonStoreInfo?> {
AddonStoreInfoProvider._({
required AddonStoreInfoFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'addonStoreInfoProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$addonStoreInfoHash();
@override
String toString() {
return r'addonStoreInfoProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<AddonStoreInfo?> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<AddonStoreInfo?> create(Ref ref) {
final argument = this.argument as String;
return addonStoreInfo(ref, argument);
}
@override
bool operator ==(Object other) {
return other is AddonStoreInfoProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$addonStoreInfoHash() => r'0024a540ecf6f1d55243d9dc963e04fbc212275e';
final class AddonStoreInfoFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<AddonStoreInfo?>, String> {
AddonStoreInfoFamily._()
: super(
retry: null,
name: r'addonStoreInfoProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
AddonStoreInfoProvider call(String addonId) =>
AddonStoreInfoProvider._(argument: addonId, from: this);
@override
String toString() => r'addonStoreInfoProvider';
}
@ProviderFor(lastAddonUpdateAttempt)
final lastAddonUpdateAttemptProvider = LastAddonUpdateAttemptFamily._();
final class LastAddonUpdateAttemptProvider
extends
$FunctionalProvider<
AsyncValue<AddonUpdateAttemptInfo?>,
AddonUpdateAttemptInfo?,
FutureOr<AddonUpdateAttemptInfo?>
>
with
$FutureModifier<AddonUpdateAttemptInfo?>,
$FutureProvider<AddonUpdateAttemptInfo?> {
LastAddonUpdateAttemptProvider._({
required LastAddonUpdateAttemptFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'lastAddonUpdateAttemptProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$lastAddonUpdateAttemptHash();
@override
String toString() {
return r'lastAddonUpdateAttemptProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<AddonUpdateAttemptInfo?> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<AddonUpdateAttemptInfo?> create(Ref ref) {
final argument = this.argument as String;
return lastAddonUpdateAttempt(ref, argument);
}
@override
bool operator ==(Object other) {
return other is LastAddonUpdateAttemptProvider &&
other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$lastAddonUpdateAttemptHash() =>
r'79847d9f5720fea1f742d57c3b17272ffd980cc1';
final class LastAddonUpdateAttemptFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<AddonUpdateAttemptInfo?>, String> {
LastAddonUpdateAttemptFamily._()
: super(
retry: null,
name: r'lastAddonUpdateAttemptProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
LastAddonUpdateAttemptProvider call(String addonId) =>
LastAddonUpdateAttemptProvider._(argument: addonId, from: this);
@override
String toString() => r'lastAddonUpdateAttemptProvider';
}
@ProviderFor(AddonUpdateCheck)
final addonUpdateCheckProvider = AddonUpdateCheckFamily._();
final class AddonUpdateCheckProvider
extends
$NotifierProvider<AddonUpdateCheck, AsyncValue<AddonUpdateRunResult>> {
AddonUpdateCheckProvider._({
required AddonUpdateCheckFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'addonUpdateCheckProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$addonUpdateCheckHash();
@override
String toString() {
return r'addonUpdateCheckProvider'
''
'($argument)';
}
@$internal
@override
AddonUpdateCheck create() => AddonUpdateCheck();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(AsyncValue<AddonUpdateRunResult> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<AsyncValue<AddonUpdateRunResult>>(
value,
),
);
}
@override
bool operator ==(Object other) {
return other is AddonUpdateCheckProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$addonUpdateCheckHash() => r'4ef375b5cd9b0eb89fbbdaff3f93a35482191af5';
final class AddonUpdateCheckFamily extends $Family
with
$ClassFamilyOverride<
AddonUpdateCheck,
AsyncValue<AddonUpdateRunResult>,
AsyncValue<AddonUpdateRunResult>,
AsyncValue<AddonUpdateRunResult>,
String
> {
AddonUpdateCheckFamily._()
: super(
retry: null,
name: r'addonUpdateCheckProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
AddonUpdateCheckProvider call(String addonId) =>
AddonUpdateCheckProvider._(argument: addonId, from: this);
@override
String toString() => r'addonUpdateCheckProvider';
}
abstract class _$AddonUpdateCheck
extends $Notifier<AsyncValue<AddonUpdateRunResult>> {
late final _$args = ref.$arg as String;
String get addonId => _$args;
AsyncValue<AddonUpdateRunResult> build(String addonId);
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref
as $Ref<
AsyncValue<AddonUpdateRunResult>,
AsyncValue<AddonUpdateRunResult>
>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<
AsyncValue<AddonUpdateRunResult>,
AsyncValue<AddonUpdateRunResult>
>,
AsyncValue<AddonUpdateRunResult>,
Object?,
Object?
>;
element.handleCreate(ref, () => build(_$args));
}
}
@ProviderFor(AddonList)
final addonListProvider = AddonListProvider._();
final class AddonListProvider
extends $AsyncNotifierProvider<AddonList, List<AddonInfo>> {
AddonListProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'addonListProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$addonListHash();
@$internal
@override
AddonList create() => AddonList();
}
String _$addonListHash() => r'7625b69a433d073e186571453e39b557d8b48a5d';
abstract class _$AddonList extends $AsyncNotifier<List<AddonInfo>> {
FutureOr<List<AddonInfo>> build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<AsyncValue<List<AddonInfo>>, List<AddonInfo>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<List<AddonInfo>>, List<AddonInfo>>,
AsyncValue<List<AddonInfo>>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@ProviderFor(AddonBusyIds)
final addonBusyIdsProvider = AddonBusyIdsProvider._();
final class AddonBusyIdsProvider
extends $NotifierProvider<AddonBusyIds, Set<String>> {
AddonBusyIdsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'addonBusyIdsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$addonBusyIdsHash();
@$internal
@override
AddonBusyIds create() => AddonBusyIds();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(Set<String> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<Set<String>>(value),
);
}
}
String _$addonBusyIdsHash() => r'6f9761320d42b2b5132936797617fdb13b718dd3';
abstract class _$AddonBusyIds extends $Notifier<Set<String>> {
Set<String> build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<Set<String>, Set<String>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<Set<String>, Set<String>>,
Set<String>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@ProviderFor(PinnedAddonIds)
final pinnedAddonIdsProvider = PinnedAddonIdsProvider._();
final class PinnedAddonIdsProvider
extends $NotifierProvider<PinnedAddonIds, Set<String>> {
PinnedAddonIdsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'pinnedAddonIdsProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$pinnedAddonIdsHash();
@$internal
@override
PinnedAddonIds create() => PinnedAddonIds();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(Set<String> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<Set<String>>(value),
);
}
}
String _$pinnedAddonIdsHash() => r'4f46cd69d4817e6e19e4d04f4fdcf83d5e2efb8c';
abstract class _$PinnedAddonIds extends $Notifier<Set<String>> {
Set<String> build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<Set<String>, Set<String>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<Set<String>, Set<String>>,
Set<String>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@ProviderFor(BulkAddonUpdate)
final bulkAddonUpdateProvider = BulkAddonUpdateProvider._();
final class BulkAddonUpdateProvider
extends $NotifierProvider<BulkAddonUpdate, AsyncValue<void>> {
BulkAddonUpdateProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'bulkAddonUpdateProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$bulkAddonUpdateHash();
@$internal
@override
BulkAddonUpdate create() => BulkAddonUpdate();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(AsyncValue<void> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<AsyncValue<void>>(value),
);
}
}
String _$bulkAddonUpdateHash() => r'2605711734f9eb6af70e721a4337556a7ea85512';
abstract class _$BulkAddonUpdate extends $Notifier<AsyncValue<void>> {
AsyncValue<void> build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<AsyncValue<void>, AsyncValue<void>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<void>, AsyncValue<void>>,
AsyncValue<void>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,32 @@
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
extension AddonInfoUi on AddonInfo {
bool get hasOptionsPage => optionsPageUrl?.isNotEmpty ?? false;
bool get canUserToggleEnabled {
return switch (disabledReason) {
AddonDisabledReason.blocklisted ||
AddonDisabledReason.notCorrectlySigned ||
AddonDisabledReason.incompatible => false,
_ => true,
};
}
String? get statusBannerMessage {
return switch (disabledReason) {
AddonDisabledReason.blocklisted =>
'This extension has been blocklisted and should remain disabled.',
AddonDisabledReason.notCorrectlySigned =>
'This extension is not correctly signed and cannot be safely enabled.',
AddonDisabledReason.incompatible =>
'This extension is incompatible with the current app version.',
AddonDisabledReason.softBlocked =>
isEnabled
? 'This extension is soft-blocked. Use caution while it remains enabled.'
: 'This extension is soft-blocked, but it can still be re-enabled.',
AddonDisabledReason.unsupported =>
'This extension is installed, but WebLibre does not currently support it.',
_ => null,
};
}
}
@@ -0,0 +1,638 @@
/*
* 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:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/addons/domain/providers.dart';
import 'package:weblibre/features/addons/extensions/addon_info.dart';
import 'package:weblibre/features/addons/presentation/screens/addon_internal_settings.dart';
import 'package:weblibre/features/addons/presentation/widgets/addon_ui.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_addon.dart';
import 'package:weblibre/utils/ui_helper.dart';
class AddonDetailsScreen extends ConsumerWidget {
final String addonId;
const AddonDetailsScreen({required this.addonId, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final addonAsync = ref.watch(addonDetailsProvider(addonId));
final addon = addonAsync.value;
if (addonAsync.isLoading && addon == null) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
if (addon == null) {
return Scaffold(
appBar: AppBar(title: const Text('Extension')),
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
addonAsync.error?.toString() ??
'This extension could not be found.',
textAlign: TextAlign.center,
),
),
),
);
}
return Scaffold(
appBar: AppBar(
title: Text(addon.displayName),
actions: [
IconButton(
onPressed: addonAsync.isLoading
? null
: ref.read(addonDetailsProvider(addonId).notifier).refresh,
icon: const Icon(Icons.refresh),
),
],
),
body: RefreshIndicator(
onRefresh: ref.read(addonDetailsProvider(addonId).notifier).refresh,
child: _AddonDetailsBody(addonId: addonId),
),
);
}
}
class _AddonDetailsBody extends ConsumerWidget {
final String addonId;
const _AddonDetailsBody({required this.addonId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final addon = ref.watch(
addonDetailsProvider(addonId).select((value) => value.value),
);
if (addon == null) return const SizedBox.shrink();
return ListView(
padding: const EdgeInsets.all(16),
children: [
_AddonHeader(addon: addon),
const SizedBox(height: 16),
if (addon.isInstalled) ...[
_ManagementSection(addonId: addonId),
const SizedBox(height: 16),
_UpdatesSection(addonId: addonId),
] else ...[
_InstallButton(addonId: addonId),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: () =>
AddonPermissionsRoute(addonId: addon.id).push<void>(context),
icon: const Icon(Icons.privacy_tip_outlined),
label: const Text('View Permissions'),
),
],
const SizedBox(height: 16),
Text('Details', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
_DetailsCard(addon: addon),
const SizedBox(height: 16),
Text('Description', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
_DescriptionCard(addon: addon),
],
);
}
}
class _InstallButton extends ConsumerWidget {
final String addonId;
const _InstallButton({required this.addonId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final addonAsync = ref.watch(addonDetailsProvider(addonId));
final addon = addonAsync.value;
return FilledButton.icon(
onPressed: (addonAsync.isLoading || addon == null)
? null
: () async {
final displayName = addon.displayName;
await ref.read(addonDetailsProvider(addonId).notifier).install();
if (!context.mounted) return;
showInfoMessage(context, '$displayName installed');
},
icon: const Icon(Icons.download),
label: const Text('Install Extension'),
);
}
}
class _AddonHeader extends StatelessWidget {
final AddonInfo addon;
const _AddonHeader({required this.addon});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AddonIconView(addon: addon, size: 56),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
addon.displayName,
style: theme.textTheme.headlineSmall,
),
if ((addon.summary ?? '').isNotEmpty) ...[
const SizedBox(height: 8),
Text(addon.summary!),
],
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
Chip(
label: Text(
addon.isInstalled
? (addon.isEnabled ? 'Installed' : 'Disabled')
: 'Available',
),
),
if (addon.isAllowedInPrivateBrowsing)
const Chip(label: Text('Private Browsing')),
if (addon.ratingAverage != null)
Chip(
avatar: const Icon(Icons.star, size: 18),
label: Text(
'${addon.ratingAverage!.toStringAsFixed(1)}'
' (${addon.ratingReviews ?? 0})',
),
),
],
),
],
),
),
],
),
const SizedBox(height: 16),
AddonStatusBanner(addon: addon),
],
),
),
);
}
}
class _ManagementSection extends ConsumerWidget {
final String addonId;
const _ManagementSection({required this.addonId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final addonAsync = ref.watch(addonDetailsProvider(addonId));
final addon = addonAsync.value;
if (addon == null) return const SizedBox.shrink();
final globalAutoUpdate = ref.watch(addonAutoUpdateProvider);
final isLocalFileInstalled = addon.isLocalFileInstalled;
final isPinned = ref.watch(pinnedAddonIdsProvider).contains(addonId);
final (
globalAutoUpdateEnabled,
canChangePerAddonAutoUpdate,
) = globalAutoUpdate.when(
data: (enabled) =>
(enabled, !addonAsync.isLoading && enabled && !isLocalFileInstalled),
loading: () => (true, false),
error: (_, _) => (true, false),
);
final autoUpdateSubtitle = switch ((
isLocalFileInstalled,
addon.isAutoUpdateEnabled,
globalAutoUpdateEnabled,
)) {
(_, _, false) => 'Global automatic updates are disabled.',
(true, _, true) =>
'Run a manual update once and restart the app before automatic updates can be enabled.',
(false, true, true) =>
'Allow this extension to receive background updates.',
(false, false, true) =>
'Background updates are disabled for this extension.',
};
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Management', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Card(
child: Column(
children: [
if (addon.isSupported)
SwitchListTile.adaptive(
title: const Text('Enabled'),
subtitle: Text(
addon.canUserToggleEnabled
? 'Allow this extension to run in WebLibre.'
: 'This extension cannot be safely enabled.',
),
value: addon.isEnabled,
onChanged: addonAsync.isLoading || !addon.canUserToggleEnabled
? null
: (enabled) => ref
.read(addonDetailsProvider(addonId).notifier)
.setEnabled(enabled: enabled),
),
SwitchListTile.adaptive(
title: const Text('Allow in Private Browsing'),
subtitle: const Text(
'Let this extension run in private browsing tabs.',
),
value: addon.isAllowedInPrivateBrowsing,
onChanged: addonAsync.isLoading
? null
: (allowed) => ref
.read(addonDetailsProvider(addonId).notifier)
.setAllowedInPrivateBrowsing(allowed: allowed),
),
SwitchListTile.adaptive(
title: const Text('Automatic updates'),
subtitle: Text(autoUpdateSubtitle),
value: addon.isAutoUpdateEnabled,
onChanged: canChangePerAddonAutoUpdate
? (enabled) => ref
.read(addonDetailsProvider(addonId).notifier)
.setAutoUpdateEnabled(enabled: enabled)
: null,
),
SwitchListTile.adaptive(
title: const Text('Pin to toolbar'),
subtitle: const Text(
'Show this extension as an icon in the main tab bar.',
),
value: isPinned,
onChanged: (pinned) {
ref
.read(pinnedAddonIdsProvider.notifier)
.setPinned(addonId, pinned: pinned);
},
),
if (addon.hasOptionsPage)
ListTile(
leading: const Icon(Icons.settings_outlined),
title: const Text('Extension Settings'),
subtitle: Text(
addon.openOptionsPageInTab
? 'Open the extension options page in a browser tab'
: 'Open the extension options page',
),
trailing: const Icon(Icons.chevron_right),
onTap: () => openAddonSettingsFlow(context, ref, addon),
),
ListTile(
leading: const Icon(Icons.privacy_tip_outlined),
title: const Text('Permissions'),
trailing: const Icon(Icons.chevron_right),
onTap: () => AddonPermissionsRoute(
addonId: addon.id,
).push<void>(context),
),
ListTile(
leading: const Icon(Icons.delete_outline),
title: const Text('Remove Extension'),
textColor: theme.colorScheme.error,
iconColor: theme.colorScheme.error,
onTap: addonAsync.isLoading
? null
: () async {
final confirmed = await _showConfirmUninstallDialog(
context,
addon,
);
if (confirmed != true || !context.mounted) return;
final displayName = addon.displayName;
await ref
.read(addonDetailsProvider(addonId).notifier)
.uninstall();
if (!context.mounted) return;
showInfoMessage(context, '$displayName removed');
Navigator.of(context).pop();
},
),
],
),
),
],
);
}
}
Future<bool?> _showConfirmUninstallDialog(
BuildContext context,
AddonInfo addon,
) {
return showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Remove extension?'),
content: Text('Remove ${addon.displayName} from WebLibre?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Remove'),
),
],
),
);
}
class _UpdatesSection extends ConsumerWidget {
final String addonId;
const _UpdatesSection({required this.addonId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final addon = ref.watch(addonDetailsProvider(addonId)).value;
if (addon == null) return const SizedBox.shrink();
final storeInfo = ref.watch(addonStoreInfoProvider(addonId)).value;
final updateAttempt = ref
.watch(lastAddonUpdateAttemptProvider(addonId))
.value;
final checking = ref.watch(addonUpdateCheckProvider(addonId)).isLoading;
final availableVersion = _displayAvailableVersion(addon, storeInfo);
final hasAvailableUpdate =
addon.installedVersion != null &&
availableVersion.isNotEmpty &&
addon.installedVersion != availableVersion;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Updates', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(formatUpdateAttemptStatus(updateAttempt)),
const SizedBox(height: 8),
if (hasAvailableUpdate) ...[
Text(
'Update available: ${addon.installedVersion} \u2192 $availableVersion',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
],
Text(
updateAttempt == null
? 'No recent update attempt information is available yet.'
: 'Last checked: ${formatUpdateAttemptDate(updateAttempt)}',
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: checking
? null
: () => _runUpdateCheck(context, ref, addonId),
icon: checking
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.system_update_alt),
label: Text(
checking ? 'Checking for Updates' : 'Check for Updates',
),
),
],
),
),
),
],
);
}
}
String _displayAvailableVersion(AddonInfo addon, AddonStoreInfo? storeInfo) {
final latest = storeInfo?.latestVersion.trim();
return (latest != null && latest.isNotEmpty) ? latest : addon.version;
}
Future<void> _runUpdateCheck(
BuildContext context,
WidgetRef ref,
String addonId,
) async {
final outcome = await ref
.read(addonUpdateCheckProvider(addonId).notifier)
.resolveAvailableUpdate();
if (!context.mounted) return;
switch (outcome) {
case AddonUpdateOutcomeMissing():
return;
case AddonUpdateOutcomeUpToDate():
final result = await ref
.read(addonUpdateCheckProvider(addonId).notifier)
.triggerAndAwait();
if (!context.mounted) return;
_reportUpdateResult(context, result, fallback: 'No update available');
case AddonUpdateOutcomeAvailable(
addon: final fresh,
:final availableVersion,
):
final confirmed = await _confirmUpdateDialog(
context,
fresh,
availableVersion,
);
if (confirmed != true || !context.mounted) return;
final result = await ref
.read(addonUpdateCheckProvider(addonId).notifier)
.triggerAndAwait();
if (!context.mounted) return;
_reportUpdateResult(context, result);
}
}
Future<bool?> _confirmUpdateDialog(
BuildContext context,
AddonInfo addon,
String availableVersion,
) {
return showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Update available'),
content: Text(
'Update ${addon.displayName} from '
'${addon.installedVersion} to $availableVersion?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Not now'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Update'),
),
],
),
);
}
void _reportUpdateResult(
BuildContext context,
AddonUpdateRunResult result, {
String? fallback,
}) {
switch (result) {
case AddonUpdateRunDone(:final message):
final text = (message != null && message.isNotEmpty) ? message : fallback;
if (text != null) showInfoMessage(context, text);
case AddonUpdateRunNoRemoteSource():
showErrorMessage(
context,
'This locally installed extension has no remote update source.',
);
case AddonUpdateRunFailed():
showErrorMessage(context, 'Failed to start update check.');
}
}
class _DescriptionCard extends StatelessWidget {
final AddonInfo addon;
const _DescriptionCard({required this.addon});
@override
Widget build(BuildContext context) {
final description = addon.description;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
description.isNotEmpty ? description : 'No description provided.',
),
),
);
}
}
class _DetailsCard extends StatelessWidget {
final AddonInfo addon;
const _DetailsCard({required this.addon});
@override
Widget build(BuildContext context) {
return Card(
child: Column(
children: [
if ((addon.authorName ?? '').isNotEmpty)
ListTile(
leading: const Icon(Icons.person_outline),
title: const Text('Author'),
subtitle: Text(addon.authorName!),
onTap: (addon.authorUrl ?? '').isEmpty
? null
: () => launchUrl(Uri.parse(addon.authorUrl!)),
),
ListTile(
leading: const Icon(Icons.tag_outlined),
title: const Text('Version'),
subtitle: Text(addon.installedVersion ?? addon.version),
),
ListTile(
leading: const Icon(Icons.update_outlined),
title: const Text('Last Updated'),
subtitle: Text(formatAddonDate(addon.updatedAt)),
),
if (addon.homepageUrl.isNotEmpty)
ListTile(
leading: const Icon(Icons.public),
title: const Text('Homepage'),
subtitle: Text(addon.homepageUrl),
trailing: const Icon(Icons.open_in_new),
onTap: () => launchUrl(Uri.parse(addon.homepageUrl)),
),
if (addon.detailUrl.isNotEmpty)
ListTile(
leading: const Icon(Icons.storefront_outlined),
title: const Text('Addon Listing'),
subtitle: Text(addon.detailUrl),
trailing: const Icon(Icons.open_in_new),
onTap: () => launchUrl(Uri.parse(addon.detailUrl)),
),
],
),
);
}
}
@@ -0,0 +1,161 @@
/*
* 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';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/addons/domain/providers.dart';
import 'package:weblibre/features/addons/extensions/addon_info.dart';
import 'package:weblibre/features/geckoview/domain/providers.dart';
Future<void> openAddonSettingsFlow(
BuildContext context,
WidgetRef ref,
AddonInfo addon,
) async {
if (!addon.hasOptionsPage) {
await const AddonManagerRoute().push<void>(context);
return;
}
if (addon.openOptionsPageInTab) {
final optionsPageUrl = addon.optionsPageUrl;
if (optionsPageUrl == null || optionsPageUrl.isEmpty) {
return;
}
await GeckoTabService().selectOrAddTabByUrl(
url: Uri.parse(optionsPageUrl),
ignoreFragment: true,
);
if (context.mounted) {
const BrowserRoute().go(context);
}
return;
}
await AddonInternalSettingsRoute(addonId: addon.id).push<void>(context);
}
Future<void> openAddonSettingsFlowById(
BuildContext context,
WidgetRef ref,
String addonId,
) async {
final addon = await ref.read(addonServiceProvider).getAddonById(addonId);
if (addon == null) {
if (!context.mounted) return;
await const AddonManagerRoute().push<void>(context);
return;
}
if (!context.mounted) return;
await openAddonSettingsFlow(context, ref, addon);
}
class AddonInternalSettingsScreen extends ConsumerWidget {
final String addonId;
const AddonInternalSettingsScreen({required this.addonId, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final addonAsync = ref.watch(addonDetailsProvider(addonId));
final addon = addonAsync.value;
final optionsPageUrl = addon?.optionsPageUrl;
return Scaffold(
appBar: AppBar(
title: Text(
addon == null
? 'Extension Settings'
: '${addon.displayName} Settings',
),
),
body: switch (addonAsync) {
AsyncLoading() when addon == null => const Center(
child: CircularProgressIndicator(),
),
AsyncError(:final error) => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
'Failed to load extension settings: $error',
textAlign: TextAlign.center,
),
),
),
_
when addon == null ||
optionsPageUrl == null ||
optionsPageUrl.isEmpty =>
const Center(
child: Text('This extension does not expose a settings page.'),
),
_ => _AddonSettingsPlatformView(optionsPageUrl: optionsPageUrl),
},
);
}
}
class _AddonSettingsPlatformView extends StatelessWidget {
final String optionsPageUrl;
const _AddonSettingsPlatformView({required this.optionsPageUrl});
@override
Widget build(BuildContext context) {
return PlatformViewLink(
viewType: 'eu.weblibre/addon_settings',
surfaceFactory: (context, controller) {
return AndroidViewSurface(
controller: controller as AndroidViewController,
gestureRecognizers: const <Factory<OneSequenceGestureRecognizer>>{},
hitTestBehavior: PlatformViewHitTestBehavior.opaque,
);
},
onCreatePlatformView: (params) {
final controller = PlatformViewsService.initExpensiveAndroidView(
id: params.id,
viewType: 'eu.weblibre/addon_settings',
layoutDirection: TextDirection.ltr,
creationParams: <String, Object?>{'optionsPageUrl': optionsPageUrl},
creationParamsCodec: const StandardMessageCodec(),
);
controller.addOnPlatformViewCreatedListener(
params.onPlatformViewCreated,
);
unawaited(controller.create());
return controller;
},
);
}
}
@@ -0,0 +1,328 @@
/*
* 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:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/addons/domain/providers.dart';
import 'package:weblibre/features/addons/extensions/addon_info.dart';
import 'package:weblibre/features/addons/presentation/widgets/addon_ui.dart';
import 'package:weblibre/utils/ui_helper.dart';
class AddonManagerScreen extends ConsumerWidget {
const AddonManagerScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final addonsAsync = ref.watch(addonListProvider);
Future<void> refresh() => ref.read(addonListProvider.notifier).refresh();
return Scaffold(
appBar: AppBar(
title: const Text('Extensions'),
actions: [
IconButton(
onPressed: addonsAsync.isLoading ? null : refresh,
icon: const Icon(Icons.refresh),
),
_TriggerAllUpdatesButton(
enabled: addonsAsync.maybeWhen(
data: (addons) =>
addons.any((a) => a.isInstalled && a.isSupported),
orElse: () => false,
),
),
],
),
body: addonsAsync.when(
skipLoadingOnReload: true,
skipError: true,
data: (addons) => RefreshIndicator(
onRefresh: refresh,
child: _AddonList(addons: addons),
),
error: (error, _) => _AddonLoadError(error: error, onRetry: refresh),
loading: () => const Center(child: CircularProgressIndicator()),
),
);
}
}
class _TriggerAllUpdatesButton extends ConsumerWidget {
final bool enabled;
const _TriggerAllUpdatesButton({required this.enabled});
@override
Widget build(BuildContext context, WidgetRef ref) {
final busy = ref.watch(
bulkAddonUpdateProvider.select((value) => value.isLoading),
);
return IconButton(
onPressed: enabled && !busy
? () async {
await ref.read(bulkAddonUpdateProvider.notifier).triggerAll();
if (!context.mounted) return;
showInfoMessage(
context,
'Background update checks started for installed extensions',
);
}
: null,
icon: busy
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.system_update_alt),
tooltip: 'Check all installed extensions for updates',
);
}
}
class _AddonList extends StatelessWidget {
final List<AddonInfo> addons;
const _AddonList({required this.addons});
@override
Widget build(BuildContext context) {
final enabled = addons
.where((a) => a.isInstalled && a.isSupported && a.isEnabled)
.toList();
final disabled = addons
.where((a) => a.isInstalled && a.isSupported && !a.isEnabled)
.toList();
final recommended = addons.where((a) => !a.isInstalled).toList();
final unsupported = addons
.where((a) => a.isInstalled && !a.isSupported)
.toList();
return ListView(
padding: const EdgeInsets.all(16),
children: [
const Card(
child: ListTile(
leading: Icon(Icons.info_outline),
title: Text('Addon updates run in the background'),
subtitle: Text(
'Use each extension detail screen to view its last update result or trigger a manual check.',
),
),
),
if (enabled.isNotEmpty) ...[
const SizedBox(height: 16),
const _Section(title: 'Enabled'),
for (final addon in enabled) _AddonCard(addon: addon),
],
if (disabled.isNotEmpty) ...[
const SizedBox(height: 16),
const _Section(title: 'Disabled'),
for (final addon in disabled) _AddonCard(addon: addon),
],
if (recommended.isNotEmpty) ...[
const SizedBox(height: 16),
const _Section(title: 'Available'),
for (final addon in recommended)
_AddonCard(
addon: addon,
action: _InstallAction(addon: addon),
),
],
if (unsupported.isNotEmpty) ...[
const SizedBox(height: 16),
const _Section(title: 'Unsupported'),
for (final addon in unsupported)
_AddonCard(
addon: addon,
action: _UninstallAction(addon: addon),
),
],
if (addons.isEmpty)
const Padding(
padding: EdgeInsets.only(top: 48),
child: Center(child: Text('No extensions available right now.')),
),
],
);
}
}
class _InstallAction extends ConsumerWidget {
final AddonInfo addon;
const _InstallAction({required this.addon});
@override
Widget build(BuildContext context, WidgetRef ref) {
final busy = ref.watch(addonBusyIdsProvider).contains(addon.id);
return FilledButton(
onPressed: busy
? null
: () async {
await ref.read(addonListProvider.notifier).install(addon);
if (!context.mounted) return;
showInfoMessage(context, '${addon.displayName} installed');
},
child: const Text('Install'),
);
}
}
class _UninstallAction extends ConsumerWidget {
final AddonInfo addon;
const _UninstallAction({required this.addon});
@override
Widget build(BuildContext context, WidgetRef ref) {
final busy = ref.watch(addonBusyIdsProvider).contains(addon.id);
return IconButton(
tooltip: 'Remove extension',
onPressed: busy
? null
: () async {
await ref.read(addonListProvider.notifier).uninstall(addon);
if (!context.mounted) return;
showInfoMessage(context, '${addon.displayName} removed');
},
icon: const Icon(Icons.delete_outline),
);
}
}
class _Section extends StatelessWidget {
final String title;
const _Section({required this.title});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(title, style: Theme.of(context).textTheme.titleMedium),
);
}
}
class _AddonCard extends ConsumerWidget {
final AddonInfo addon;
final Widget? action;
const _AddonCard({required this.addon, this.action});
@override
Widget build(BuildContext context, WidgetRef ref) {
final busy = ref.watch(addonBusyIdsProvider).contains(addon.id);
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: InkWell(
onTap: busy
? null
: () => AddonDetailsRoute(addonId: addon.id).push<void>(context),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AddonIconView(addon: addon),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
addon.displayName,
style: Theme.of(context).textTheme.titleMedium,
),
if ((addon.summary ?? '').isNotEmpty) ...[
const SizedBox(height: 4),
Text(addon.summary!),
],
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
if (addon.isAllowedInPrivateBrowsing)
const Chip(label: Text('Private Browsing')),
if (addon.ratingAverage != null)
Chip(
avatar: const Icon(Icons.star, size: 16),
label: Text(
addon.ratingAverage!.toStringAsFixed(1),
),
),
],
),
],
),
),
const SizedBox(width: 8),
action ?? const Icon(Icons.chevron_right),
],
),
if (addon.statusBannerMessage != null) ...[
const SizedBox(height: 12),
AddonStatusBanner(addon: addon),
],
],
),
),
),
);
}
}
class _AddonLoadError extends StatelessWidget {
final Object? error;
final VoidCallback onRetry;
const _AddonLoadError({required this.error, required this.onRetry});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 40),
const SizedBox(height: 12),
const Text('Failed to load extensions'),
const SizedBox(height: 8),
Text(error.toString(), textAlign: TextAlign.center),
const SizedBox(height: 16),
FilledButton(onPressed: onRetry, child: const Text('Retry')),
],
),
),
);
}
}
@@ -0,0 +1,133 @@
/*
* 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:hooks_riverpod/hooks_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:weblibre/features/addons/domain/providers.dart';
const _permissionsLearnMoreUrl =
'https://support.mozilla.org/kb/permission-request-messages-firefox-extensions';
class AddonPermissionsScreen extends ConsumerWidget {
final String addonId;
const AddonPermissionsScreen({required this.addonId, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final addonAsync = ref.watch(addonDetailsProvider(addonId));
final addon = addonAsync.value;
final permissions = addon?.translatedPermissions.toList() ?? <String>[];
permissions.sort();
final dataCollection =
addon?.translatedRequiredDataCollectionPermissions.toList() ??
<String>[];
dataCollection.sort();
return Scaffold(
appBar: AppBar(
title: Text(
addon == null
? 'Extension Permissions'
: '${addon.displayName} Permissions',
),
),
body: switch (addonAsync) {
AsyncLoading() when addon == null => const Center(
child: CircularProgressIndicator(),
),
AsyncError(:final error) => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
'Failed to load extension permissions: $error',
textAlign: TextAlign.center,
),
),
),
_ when addon == null => const Center(
child: Text('This extension could not be found.'),
),
_ => ListView(
padding: const EdgeInsets.all(16),
children: [
if (permissions.isEmpty && dataCollection.isEmpty)
const Card(
child: ListTile(
leading: Icon(Icons.verified_user_outlined),
title: Text('No special permissions listed'),
subtitle: Text(
'This extension does not currently expose any translated permission details.',
),
),
),
if (permissions.isNotEmpty) ...[
Text(
'Permissions',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Card(
child: Column(
children: [
for (final permission in permissions)
ListTile(
leading: const Icon(Icons.check_circle_outline),
title: Text(permission),
),
],
),
),
const SizedBox(height: 16),
],
if (dataCollection.isNotEmpty) ...[
Text(
'Required Data Collection',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Card(
child: Column(
children: [
for (final permission in dataCollection)
ListTile(
leading: const Icon(Icons.data_usage_outlined),
title: Text(permission),
),
],
),
),
const SizedBox(height: 16),
],
FilledButton.icon(
onPressed: () async {
await launchUrl(Uri.parse(_permissionsLearnMoreUrl));
},
icon: const Icon(Icons.open_in_new),
label: const Text('Learn More'),
),
],
),
},
);
}
}
@@ -0,0 +1,149 @@
/*
* 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:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:intl/intl.dart';
import 'package:weblibre/features/addons/extensions/addon_info.dart';
class AddonIconView extends StatelessWidget {
final AddonInfo addon;
final double size;
const AddonIconView({required this.addon, this.size = 40, super.key});
@override
Widget build(BuildContext context) {
final bytes = addon.icon;
final borderRadius = BorderRadius.circular(12);
if (bytes != null && bytes.isNotEmpty) {
return ClipRRect(
borderRadius: borderRadius,
child: Image.memory(
bytes,
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => _FallbackIcon(size: size),
),
);
}
return _FallbackIcon(size: size);
}
}
class _FallbackIcon extends StatelessWidget {
final double size;
const _FallbackIcon({required this.size});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Icon(Icons.extension, color: theme.colorScheme.onSurfaceVariant),
);
}
}
class AddonStatusBanner extends StatelessWidget {
final AddonInfo addon;
const AddonStatusBanner({required this.addon, super.key});
@override
Widget build(BuildContext context) {
final message = addon.statusBannerMessage;
if (message == null) {
return const SizedBox.shrink();
}
final isWarning = addon.disabledReason == AddonDisabledReason.softBlocked;
final theme = Theme.of(context);
final background = isWarning
? theme.colorScheme.tertiaryContainer
: theme.colorScheme.errorContainer;
final foreground = isWarning
? theme.colorScheme.onTertiaryContainer
: theme.colorScheme.onErrorContainer;
final icon = isWarning ? Icons.warning_amber_rounded : Icons.error_outline;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(12),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: foreground, size: 20),
const SizedBox(width: 10),
Expanded(
child: Text(
message,
style: theme.textTheme.bodyMedium?.copyWith(color: foreground),
),
),
],
),
);
}
}
String formatAddonDate(String raw) {
final parsed = DateTime.tryParse(raw);
if (parsed == null) {
return raw.isEmpty ? 'Unknown' : raw;
}
return DateFormat.yMMMd().format(parsed.toLocal());
}
String formatUpdateAttemptDate(AddonUpdateAttemptInfo attempt) {
final date = DateTime.fromMillisecondsSinceEpoch(
attempt.dateMillisecondsSinceEpoch,
).toLocal();
return DateFormat.yMMMd().add_jm().format(date);
}
String formatUpdateAttemptStatus(AddonUpdateAttemptInfo? attempt) {
return switch (attempt?.status) {
AddonUpdateStatus.successfullyUpdated =>
attempt?.message?.isNotEmpty == true
? attempt!.message!
: 'Updated successfully',
AddonUpdateStatus.noUpdateAvailable => 'No update available',
AddonUpdateStatus.notInstalled => 'Extension not installed',
AddonUpdateStatus.error =>
attempt?.message?.isNotEmpty == true
? 'Update failed: ${attempt!.message}'
: 'Update failed',
null => 'No update checks recorded yet',
};
}
@@ -0,0 +1,81 @@
/*
* 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:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/addons/domain/providers.dart';
import 'package:weblibre/features/geckoview/domain/providers.dart';
import 'package:weblibre/features/geckoview/domain/providers/web_extensions_state.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/extension_badge_icon.dart';
class PinnedAddonBar extends ConsumerWidget {
const PinnedAddonBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pinnedIds = ref.watch(pinnedAddonIdsProvider);
if (pinnedIds.isEmpty) return const SizedBox.shrink();
final extensions = ref.watch(
webExtensionsStateProvider(
WebExtensionActionType.browser,
).select((value) => value.values.toList()),
);
final pinned = extensions
.where((e) => pinnedIds.contains(e.extensionId))
.toList();
if (pinned.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(right: 6.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (final extension in pinned)
InkResponse(
radius: 22,
onTap: () async {
await ref
.read(addonServiceProvider)
.invokeAddonAction(
extension.extensionId,
WebExtensionActionType.browser,
);
},
onLongPress: () async {
await AddonDetailsRoute(
addonId: extension.extensionId,
).push<void>(context);
},
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 6.0,
vertical: 8.0,
),
child: ExtensionBadgeIcon(extension),
),
),
],
),
);
}
}
@@ -47,8 +47,7 @@ class AllowUnsignedExtensions extends _$AllowUnsignedExtensions {
@override
FutureOr<bool> build() async {
final prefs =
await GeckoPrefService().getPrefs([_signatureRequiredPref]);
final prefs = await GeckoPrefService().getPrefs([_signatureRequiredPref]);
final pref = prefs[_signatureRequiredPref];
final allowUnsigned = pref?.value == false;
@@ -63,6 +62,21 @@ class AllowUnsignedExtensions extends _$AllowUnsignedExtensions {
}
}
@Riverpod(keepAlive: true)
class AddonAutoUpdate extends _$AddonAutoUpdate {
Future<void> setEnabled({required bool enabled}) async {
final service = ref.read(addonServiceProvider);
await service.setAddonAutoUpdateEnabled(enabled: enabled);
state = AsyncData(enabled);
}
@override
FutureOr<bool> build() {
return ref.read(addonServiceProvider).isAddonAutoUpdateEnabled();
}
}
@Riverpod(keepAlive: true)
class BrowserAddonService extends _$BrowserAddonService {
Future<Uri> getAddonXpiUrl(String guid) async {
@@ -54,6 +54,50 @@ abstract class _$AllowUnsignedExtensions extends $AsyncNotifier<bool> {
}
}
@ProviderFor(AddonAutoUpdate)
final addonAutoUpdateProvider = AddonAutoUpdateProvider._();
final class AddonAutoUpdateProvider
extends $AsyncNotifierProvider<AddonAutoUpdate, bool> {
AddonAutoUpdateProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'addonAutoUpdateProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$addonAutoUpdateHash();
@$internal
@override
AddonAutoUpdate create() => AddonAutoUpdate();
}
String _$addonAutoUpdateHash() => r'89791e8b771da715b068bbdbe5c3c24a3dad4194';
abstract class _$AddonAutoUpdate extends $AsyncNotifier<bool> {
FutureOr<bool> build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<AsyncValue<bool>, bool>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<bool>, bool>,
AsyncValue<bool>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@ProviderFor(BrowserAddonService)
final browserAddonServiceProvider = BrowserAddonServiceProvider._();
@@ -76,7 +76,10 @@ class _InstallLocalAddonSheet extends HookConsumerWidget {
.installFromFile(selectedFile.value!);
if (context.mounted) {
showInfoMessage(context, 'Extension installed successfully');
showInfoMessage(
context,
'Extension installed. Automatic updates are disabled for this local version.',
);
context.pop(true);
}
} catch (e) {
@@ -149,6 +152,11 @@ class _InstallLocalAddonSheet extends HookConsumerWidget {
],
),
),
const SizedBox(height: 12),
Text(
'Extensions installed from a local XPI stay pinned to that version and will not update automatically.',
style: Theme.of(context).textTheme.bodySmall,
),
if (errorMessage.value != null) ...[
const SizedBox(height: 8),
Container(
@@ -44,6 +44,7 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/contro
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/toolbar_visibility.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/keep_tab_dialog.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/providers/browser_viewport_toolbar_insets.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/addon_popup_bottom_sheet.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_fab.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart';
@@ -305,6 +306,18 @@ class BrowserScreen extends HookConsumerWidget {
},
);
final addonService = ref.watch(addonServiceProvider);
useOnStreamChange(
addonService.popupStream,
onData: (event) async {
await showAddonPopupBottomSheet(
context,
extensionId: event.extensionId,
extensionName: event.extensionName,
);
},
);
useOnAppLifecycleStateChange((previous, current) {
switch (current) {
case AppLifecycleState.resumed:
@@ -0,0 +1,121 @@
/*
* 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';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
const _viewType = 'eu.weblibre/addon_popup';
Future<void> showAddonPopupBottomSheet(
BuildContext context, {
required String extensionId,
required String extensionName,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (context) => _AddonPopupSheet(
extensionId: extensionId,
extensionName: extensionName,
),
);
}
class _AddonPopupSheet extends StatelessWidget {
final String extensionId;
final String extensionName;
const _AddonPopupSheet({
required this.extensionId,
required this.extensionName,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return DraggableScrollableSheet(
initialChildSize: 0.65,
minChildSize: 0.3,
maxChildSize: 0.95,
expand: false,
builder: (context, scrollController) {
return Column(
children: [
Container(
margin: const EdgeInsets.only(top: 12, bottom: 8),
height: 4,
width: 40,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
const Divider(height: 1),
Expanded(child: _AddonPopupPlatformView(extensionId: extensionId)),
],
);
},
);
}
}
class _AddonPopupPlatformView extends StatelessWidget {
final String extensionId;
const _AddonPopupPlatformView({required this.extensionId});
@override
Widget build(BuildContext context) {
return PlatformViewLink(
viewType: _viewType,
surfaceFactory: (context, controller) {
return AndroidViewSurface(
controller: controller as AndroidViewController,
gestureRecognizers: const <Factory<OneSequenceGestureRecognizer>>{
Factory<EagerGestureRecognizer>(EagerGestureRecognizer.new),
},
hitTestBehavior: PlatformViewHitTestBehavior.opaque,
);
},
onCreatePlatformView: (params) {
final controller = PlatformViewsService.initExpensiveAndroidView(
id: params.id,
viewType: _viewType,
layoutDirection: TextDirection.ltr,
creationParams: <String, Object?>{'extensionId': extensionId},
creationParamsCodec: const StandardMessageCodec(),
);
controller.addOnPlatformViewCreatedListener(
params.onPlatformViewCreated,
);
unawaited(controller.create());
return controller;
},
);
}
}
@@ -36,6 +36,7 @@ import 'package:skeletonizer/skeletonizer.dart';
import 'package:weblibre/core/design/app_colors.dart';
import 'package:weblibre/core/providers/persisted_bool.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/addons/presentation/screens/addon_internal_settings.dart';
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/readerable.dart';
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart';
@@ -1706,9 +1707,10 @@ class _ExtensionsCard extends HookConsumerWidget {
WebExtensionActionType.browser,
).select((value) => value.values.toList()),
);
final rootContext = Navigator.of(context, rootNavigator: true).context;
Future<void> openExtensionSettings(String extensionId) async {
Navigator.pop(context);
await addonService.startAddonSettingsActivity(extensionId);
await openAddonSettingsFlowById(rootContext, ref, extensionId);
}
return _buildMenuCard(
@@ -1804,7 +1806,7 @@ class _ExtensionsCard extends HookConsumerWidget {
icon: MdiIcons.puzzleEdit,
onTap: () async {
Navigator.pop(context);
await addonService.startAddonManagerActivity();
await const AddonManagerRoute().push<void>(rootContext);
},
),
_buildSubTile(
@@ -27,6 +27,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/design/app_colors.dart';
import 'package:weblibre/features/addons/presentation/widgets/pinned_addon_bar.dart';
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
@@ -252,6 +253,7 @@ class BrowserTabBar extends HookConsumerWidget {
: const AppBarTitle()
: null,
actions: [
const PinnedAddonBar(),
if (isSmallWebMode)
ReaderButton(
buttonBuilder: (isLoading, readerActive, icon) => ToolbarButton(
@@ -46,8 +46,11 @@ class ExtensionsSettingsScreen extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 12.0),
children: const [
SettingSection(name: 'Extensions'),
_ManageExtensionsTile(),
_InstallLocalAddonTile(),
_AddonCollectionTile(),
SettingSection(name: 'Updates'),
_AutoUpdateTile(),
SettingSection(name: 'Security'),
_AllowUnsignedExtensionsTile(),
],
@@ -59,6 +62,34 @@ class ExtensionsSettingsScreen extends StatelessWidget {
}
}
class _ManageExtensionsTile extends StatelessWidget {
const _ManageExtensionsTile();
@override
Widget build(BuildContext context) {
return CustomListTile(
title: 'Manage Extensions',
subtitle:
'Browse installed, disabled, available, and unsupported extensions',
prefix: Padding(
padding: const EdgeInsets.only(right: 16.0),
child: Icon(
MdiIcons.puzzleEdit,
size: 24,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
suffix: FilledButton.icon(
onPressed: () async {
await const AddonManagerRoute().push<void>(context);
},
icon: const Icon(Icons.open_in_new),
label: const Text('Open'),
),
);
}
}
class _InstallLocalAddonTile extends StatelessWidget {
const _InstallLocalAddonTile();
@@ -113,6 +144,45 @@ class _AddonCollectionTile extends StatelessWidget {
}
}
class _AutoUpdateTile extends ConsumerWidget {
const _AutoUpdateTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final autoUpdate = ref.watch(addonAutoUpdateProvider);
return autoUpdate.when(
data: (enabled) => SwitchListTile.adaptive(
title: const Text('Automatic updates'),
subtitle: const Text(
'Automatically check for and install extension updates every 12 hours',
),
secondary: const Icon(Icons.system_update_alt),
value: enabled,
onChanged: (value) async {
await ref
.read(addonAutoUpdateProvider.notifier)
.setEnabled(enabled: value);
},
),
loading: () => const SwitchListTile.adaptive(
title: Text('Automatic updates'),
subtitle: Text(
'Automatically check for and install extension updates every 12 hours',
),
secondary: Icon(Icons.system_update_alt),
value: true,
onChanged: null,
),
error: (error, stack) => ListTile(
leading: const Icon(Icons.error_outline),
title: const Text('Automatic updates'),
subtitle: Text('Failed to load: $error'),
),
);
}
}
class _AllowUnsignedExtensionsTile extends ConsumerWidget {
const _AllowUnsignedExtensionsTile();
@@ -120,63 +190,78 @@ class _AllowUnsignedExtensionsTile extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final allowUnsigned = ref.watch(allowUnsignedExtensionsProvider);
return Column(
children: [
SwitchListTile.adaptive(
title: const Text('Allow unsigned extensions'),
subtitle: const Text(
'Unsigned extensions have not been verified by Mozilla',
),
secondary: const Icon(Icons.extension_off),
value: allowUnsigned.value ?? false,
onChanged: allowUnsigned.isLoading
? null
: (value) async {
if (value) {
final confirmed =
await _showAllowUnsignedConfirmationDialog(context);
if (confirmed != true) return;
}
await ref
.read(allowUnsignedExtensionsProvider.notifier)
.setAllowUnsigned(allow: value);
},
),
if (allowUnsigned.value == true)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(
return allowUnsigned.when(
data: (allowed) => Column(
children: [
SwitchListTile.adaptive(
title: const Text('Allow unsigned extensions'),
subtitle: const Text(
'Unsigned extensions have not been verified by Mozilla',
),
secondary: const Icon(Icons.extension_off),
value: allowed,
onChanged: (value) async {
if (value) {
final confirmed = await _showAllowUnsignedConfirmationDialog(
context,
).colorScheme.errorContainer.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Theme.of(context).colorScheme.error),
),
child: Row(
children: [
Icon(
Icons.warning_amber,
);
if (confirmed != true) return;
}
await ref
.read(allowUnsignedExtensionsProvider.notifier)
.setAllowUnsigned(allow: value);
},
),
if (allowed)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.errorContainer.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).colorScheme.error,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Only install unsigned extensions from sources you trust. '
'They may contain malicious code.',
style: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer,
fontSize: 12,
),
child: Row(
children: [
Icon(
Icons.warning_amber,
color: Theme.of(context).colorScheme.error,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Only install unsigned extensions from sources you trust. '
'They may contain malicious code.',
style: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer,
fontSize: 12,
),
),
),
),
],
],
),
),
),
),
],
],
),
loading: () => const SwitchListTile.adaptive(
title: Text('Allow unsigned extensions'),
subtitle: Text('Unsigned extensions have not been verified by Mozilla'),
secondary: Icon(Icons.extension_off),
value: false,
onChanged: null,
),
error: (error, stack) => ListTile(
leading: const Icon(Icons.error_outline),
title: const Text('Allow unsigned extensions'),
subtitle: Text('Failed to load: $error'),
),
);
}
}
@@ -262,4 +347,3 @@ class _AllowUnsignedConfirmationDialog extends HookWidget {
);
}
}