diff --git a/app/lib/features/geckoview/features/browser/domain/services/browser_addon.dart b/app/lib/features/geckoview/features/browser/domain/services/browser_addon.dart
index bc46df40..8bee4262 100644
--- a/app/lib/features/geckoview/features/browser/domain/services/browser_addon.dart
+++ b/app/lib/features/geckoview/features/browser/domain/services/browser_addon.dart
@@ -17,6 +17,7 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
+import 'dart:async';
import 'dart:convert';
import 'dart:io';
@@ -25,11 +26,43 @@ import 'package:http/http.dart' as http;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/geckoview/domain/providers.dart';
+import 'package:weblibre/features/geckoview/features/preferences/data/repositories/preference_observer.dart';
part 'browser_addon.g.dart';
const _signatureRequiredPref = 'xpinstall.signatures.required';
+@Riverpod(keepAlive: true)
+class AllowUnsignedExtensions extends _$AllowUnsignedExtensions {
+ Future setAllowUnsigned({required bool allow}) async {
+ final fixator = ref.read(preferenceFixatorProvider.notifier);
+ if (allow) {
+ await fixator.register(_signatureRequiredPref, false);
+ } else {
+ await fixator.unregister(_signatureRequiredPref);
+ await GeckoPrefService().applyPrefs({_signatureRequiredPref: true});
+ }
+ state = AsyncData(allow);
+ }
+
+ @override
+ FutureOr build() async {
+ final prefs =
+ await GeckoPrefService().getPrefs([_signatureRequiredPref]);
+ final pref = prefs[_signatureRequiredPref];
+ final allowUnsigned = pref?.value == false;
+
+ // Re-register with fixator to prevent Gecko from resetting
+ if (allowUnsigned) {
+ await ref
+ .read(preferenceFixatorProvider.notifier)
+ .register(_signatureRequiredPref, false);
+ }
+
+ return allowUnsigned;
+ }
+}
+
@Riverpod(keepAlive: true)
class BrowserAddonService extends _$BrowserAddonService {
Future getAddonXpiUrl(String guid) async {
@@ -68,12 +101,7 @@ class BrowserAddonService extends _$BrowserAddonService {
}
}
- Future installFromFile(
- String filePath, {
- bool allowUnsigned = false,
- }) async {
- final prefService = GeckoPrefService();
-
+ Future installFromFile(String filePath) async {
try {
// Validate file exists and has .xpi extension
final file = File(filePath);
@@ -86,25 +114,13 @@ class BrowserAddonService extends _$BrowserAddonService {
throw Exception('Invalid file type. Expected .xpi file');
}
- // Temporarily disable signature requirement if user allows unsigned
- if (allowUnsigned) {
- await prefService.applyPrefs({_signatureRequiredPref: false});
- }
+ // Create file:// URI and install
+ final fileUri = Uri.file(filePath);
+ if (!ref.mounted) return false;
- try {
- // Create file:// URI and install
- final fileUri = Uri.file(filePath);
- if (!ref.mounted) return false;
+ await ref.read(addonServiceProvider).installAddon(fileUri);
- await ref.read(addonServiceProvider).installAddon(fileUri);
-
- return true;
- } finally {
- // Always restore signature requirement
- if (allowUnsigned) {
- await prefService.applyPrefs({_signatureRequiredPref: true});
- }
- }
+ return true;
} catch (e, s) {
logger.e(
'Failed installing from file: $filePath',
diff --git a/app/lib/features/geckoview/features/browser/domain/services/browser_addon.g.dart b/app/lib/features/geckoview/features/browser/domain/services/browser_addon.g.dart
index 879d2a97..de3dfba0 100644
--- a/app/lib/features/geckoview/features/browser/domain/services/browser_addon.g.dart
+++ b/app/lib/features/geckoview/features/browser/domain/services/browser_addon.g.dart
@@ -9,6 +9,51 @@ part of 'browser_addon.dart';
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
+@ProviderFor(AllowUnsignedExtensions)
+final allowUnsignedExtensionsProvider = AllowUnsignedExtensionsProvider._();
+
+final class AllowUnsignedExtensionsProvider
+ extends $AsyncNotifierProvider {
+ AllowUnsignedExtensionsProvider._()
+ : super(
+ from: null,
+ argument: null,
+ retry: null,
+ name: r'allowUnsignedExtensionsProvider',
+ isAutoDispose: false,
+ dependencies: null,
+ $allTransitiveDependencies: null,
+ );
+
+ @override
+ String debugGetCreateSourceHash() => _$allowUnsignedExtensionsHash();
+
+ @$internal
+ @override
+ AllowUnsignedExtensions create() => AllowUnsignedExtensions();
+}
+
+String _$allowUnsignedExtensionsHash() =>
+ r'3b67f51cabf8e2f37d992320c5b93bb61c6dbe1f';
+
+abstract class _$AllowUnsignedExtensions extends $AsyncNotifier {
+ FutureOr build();
+ @$mustCallSuper
+ @override
+ void runBuild() {
+ final ref = this.ref as $Ref, bool>;
+ final element =
+ ref.element
+ as $ClassProviderElement<
+ AnyNotifier, bool>,
+ AsyncValue,
+ Object?,
+ Object?
+ >;
+ element.handleCreate(ref, build);
+ }
+}
+
@ProviderFor(BrowserAddonService)
final browserAddonServiceProvider = BrowserAddonServiceProvider._();
@@ -42,7 +87,7 @@ final class BrowserAddonServiceProvider
}
String _$browserAddonServiceHash() =>
- r'1485fd056ce32e142e342e920affb82a5c77a370';
+ r'b90e69ce77b846d6056c800f232a8aa50b4b21d0';
abstract class _$BrowserAddonService extends $Notifier {
void build();
diff --git a/app/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.dart b/app/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.dart
index 3271f2e9..5760470e 100644
--- a/app/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.dart
+++ b/app/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.dart
@@ -23,6 +23,7 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/logger.dart';
+import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_addon.dart';
import 'package:weblibre/features/geckoview/features/preferences/data/repositories/preference_observer.dart';
import 'package:weblibre/features/geckoview/features/preferences/data/repositories/preference_settings.dart';
import 'package:weblibre/features/user/domain/repositories/engine_settings.dart';
@@ -286,6 +287,9 @@ class EngineSettingsReplicationService
.read(preferenceFixatorProvider.notifier)
.register('intl.accept_languages', settings.locales.join(','));
+ // Initialize unsigned extensions fixator from Gecko pref
+ await ref.read(allowUnsignedExtensionsProvider.future);
+
initialSettingsSent = true;
}
}
diff --git a/app/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.g.dart b/app/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.g.dart
index c81595da..74f8b29b 100644
--- a/app/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.g.dart
+++ b/app/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.g.dart
@@ -44,7 +44,7 @@ final class EngineSettingsReplicationServiceProvider
}
String _$engineSettingsReplicationServiceHash() =>
- r'a2fe25ca6458dd22d705affee74036e1ba379972';
+ r'81443c37d18ad82f0694cd0e1115ccdf8a0f4c06';
abstract class _$EngineSettingsReplicationService extends $Notifier {
void build();
diff --git a/app/lib/features/geckoview/features/browser/presentation/dialogs/install_local_addon_dialog.dart b/app/lib/features/geckoview/features/browser/presentation/dialogs/install_local_addon_dialog.dart
index 99b070c3..01c58f5f 100644
--- a/app/lib/features/geckoview/features/browser/presentation/dialogs/install_local_addon_dialog.dart
+++ b/app/lib/features/geckoview/features/browser/presentation/dialogs/install_local_addon_dialog.dart
@@ -40,7 +40,6 @@ class _InstallLocalAddonSheet extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedFile = useState(null);
- final allowUnsigned = useState(false);
final isInstalling = useState(false);
final errorMessage = useState(null);
@@ -74,10 +73,7 @@ class _InstallLocalAddonSheet extends HookConsumerWidget {
try {
await ref
.read(browserAddonServiceProvider.notifier)
- .installFromFile(
- selectedFile.value!,
- allowUnsigned: allowUnsigned.value,
- );
+ .installFromFile(selectedFile.value!);
if (context.mounted) {
showInfoMessage(context, 'Extension installed successfully');
@@ -88,7 +84,7 @@ class _InstallLocalAddonSheet extends HookConsumerWidget {
if (errorString.contains('NotSigned') ||
errorString.contains('SIGNEDSTATE')) {
errorMessage.value =
- 'This extension is not signed by Mozilla. Enable "Allow unsigned extensions" to install it.';
+ 'This extension is not signed by Mozilla. Enable "Allow unsigned extensions" in Extensions settings to install it.';
} else {
errorMessage.value = 'Installation failed: $errorString';
}
@@ -153,53 +149,6 @@ class _InstallLocalAddonSheet extends HookConsumerWidget {
],
),
),
- const SizedBox(height: 16),
- SwitchListTile(
- contentPadding: EdgeInsets.zero,
- title: const Text('Allow unsigned extensions'),
- subtitle: const Text(
- 'Unsigned extensions have not been verified by Mozilla',
- ),
- value: allowUnsigned.value,
- onChanged: isInstalling.value
- ? null
- : (value) => allowUnsigned.value = value,
- ),
- if (allowUnsigned.value) ...[
- const SizedBox(height: 8),
- 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,
- ),
- ),
- 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,
- ),
- ),
- ),
- ],
- ),
- ),
- ],
if (errorMessage.value != null) ...[
const SizedBox(height: 8),
Container(
diff --git a/app/lib/features/settings/presentation/screens/extensions_settings.dart b/app/lib/features/settings/presentation/screens/extensions_settings.dart
index aac45719..3404e347 100644
--- a/app/lib/features/settings/presentation/screens/extensions_settings.dart
+++ b/app/lib/features/settings/presentation/screens/extensions_settings.dart
@@ -17,10 +17,15 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
+import 'dart:async';
+
import 'package:fading_scroll/fading_scroll.dart';
import 'package:flutter/material.dart';
+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/routing/routes.dart';
+import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_addon.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/install_local_addon_dialog.dart';
import 'package:weblibre/features/settings/presentation/widgets/custom_list_tile.dart';
import 'package:weblibre/features/settings/presentation/widgets/sections.dart';
@@ -43,6 +48,8 @@ class ExtensionsSettingsScreen extends StatelessWidget {
SettingSection(name: 'Extensions'),
_InstallLocalAddonTile(),
_AddonCollectionTile(),
+ SettingSection(name: 'Security'),
+ _AllowUnsignedExtensionsTile(),
],
);
},
@@ -105,3 +112,154 @@ class _AddonCollectionTile extends StatelessWidget {
);
}
}
+
+class _AllowUnsignedExtensionsTile extends ConsumerWidget {
+ const _AllowUnsignedExtensionsTile();
+
+ @override
+ 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(
+ 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,
+ 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,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+Future _showAllowUnsignedConfirmationDialog(BuildContext context) {
+ return showDialog(
+ context: context,
+ builder: (context) => const _AllowUnsignedConfirmationDialog(),
+ );
+}
+
+class _AllowUnsignedConfirmationDialog extends HookWidget {
+ const _AllowUnsignedConfirmationDialog();
+
+ static const _countdownSeconds = 15;
+
+ @override
+ Widget build(BuildContext context) {
+ final remaining = useState(_countdownSeconds);
+
+ useEffect(() {
+ final timer = Timer.periodic(const Duration(seconds: 1), (_) {
+ if (remaining.value > 0) {
+ remaining.value--;
+ }
+ });
+ return timer.cancel;
+ }, []);
+
+ final theme = Theme.of(context);
+ final canConfirm = remaining.value == 0;
+
+ return AlertDialog(
+ icon: Icon(
+ Icons.warning_amber_rounded,
+ color: theme.colorScheme.error,
+ size: 40,
+ ),
+ title: const Text('Allow unsigned extensions?'),
+ content: Text.rich(
+ TextSpan(
+ children: [
+ TextSpan(
+ text:
+ "Warning: This significantly weakens your browser's security."
+ '\n\n',
+ style: TextStyle(
+ fontWeight: FontWeight.bold,
+ color: theme.colorScheme.error,
+ ),
+ ),
+ const TextSpan(
+ text:
+ "Unsigned extensions bypass Mozilla's safety review process. "
+ 'Malicious extensions can:\n\n'
+ '\u2022 Read and modify everything you see on any website\n'
+ '\u2022 Steal passwords, banking details, and personal data\n'
+ '\u2022 Monitor your browsing activity silently\n'
+ '\u2022 Install additional malware on your device\n\n',
+ ),
+ const TextSpan(
+ text:
+ 'Only enable this if you are a developer installing your own '
+ 'extension or absolutely trust the source.',
+ ),
+ ],
+ ),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(false),
+ child: const Text('Cancel'),
+ ),
+ FilledButton(
+ onPressed: canConfirm ? () => Navigator.of(context).pop(true) : null,
+ style: FilledButton.styleFrom(
+ backgroundColor: theme.colorScheme.error,
+ foregroundColor: theme.colorScheme.onError,
+ ),
+ child: Text(canConfirm ? 'Allow' : 'Allow (${remaining.value})'),
+ ),
+ ],
+ );
+ }
+}
+