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 d060c68a..c044f3c4 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
@@ -18,7 +18,9 @@
* along with this program. If not, see .
*/
import 'dart:convert';
+import 'dart:io';
+import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:http/http.dart' as http;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/logger.dart';
@@ -26,6 +28,8 @@ import 'package:weblibre/features/geckoview/domain/providers.dart';
part 'browser_addon.g.dart';
+const _signatureRequiredPref = 'xpinstall.signatures.required';
+
@Riverpod(keepAlive: true)
class BrowserAddonService extends _$BrowserAddonService {
Future getAddonXpiUrl(String guid) async {
@@ -64,6 +68,49 @@ class BrowserAddonService extends _$BrowserAddonService {
}
}
+ Future installFromFile(
+ String filePath, {
+ bool allowUnsigned = false,
+ }) async {
+ final prefService = GeckoPrefService();
+
+ try {
+ // Validate file exists and has .xpi extension
+ final file = File(filePath);
+ if (!file.existsSync()) {
+ throw Exception('File does not exist: $filePath');
+ }
+
+ final extension = filePath.toLowerCase();
+ if (!extension.endsWith('.xpi')) {
+ throw Exception('Invalid file type. Expected .xpi file');
+ }
+
+ // Temporarily disable signature requirement if user allows unsigned
+ if (allowUnsigned) {
+ await prefService.applyPrefs({_signatureRequiredPref: false});
+ }
+
+ try {
+ // Create file:// URI and install
+ final fileUri = Uri.file(filePath);
+ if (!ref.mounted) return false;
+
+ await ref.read(addonServiceProvider).installAddon(fileUri);
+
+ return true;
+ } finally {
+ // Always restore signature requirement
+ if (allowUnsigned) {
+ await prefService.applyPrefs({_signatureRequiredPref: true});
+ }
+ }
+ } catch (e, s) {
+ logger.e('Failed installing from file: $filePath', error: e, stackTrace: s);
+ rethrow;
+ }
+ }
+
@override
void build() {}
}
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 ee362cd9..879d2a97 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
@@ -42,7 +42,7 @@ final class BrowserAddonServiceProvider
}
String _$browserAddonServiceHash() =>
- r'e7a94f86edc900339518ea0cc0e6fdbd9eccd065';
+ r'1485fd056ce32e142e342e920affb82a5c77a370';
abstract class _$BrowserAddonService 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
new file mode 100644
index 00000000..feee1790
--- /dev/null
+++ b/app/lib/features/geckoview/features/browser/presentation/dialogs/install_local_addon_dialog.dart
@@ -0,0 +1,239 @@
+/*
+ * Copyright (c) 2024-2025 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+import 'package:file_picker/file_picker.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:go_router/go_router.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:path/path.dart' as p;
+import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_addon.dart';
+
+Future showInstallLocalAddonDialog(BuildContext context) {
+ return showModalBottomSheet(
+ context: context,
+ isScrollControlled: true,
+ builder: (context) => const _InstallLocalAddonSheet(),
+ );
+}
+
+class _InstallLocalAddonSheet extends HookConsumerWidget {
+ const _InstallLocalAddonSheet();
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final selectedFile = useState(null);
+ final allowUnsigned = useState(false);
+ final isInstalling = useState(false);
+ final errorMessage = useState(null);
+
+ Future pickFile() async {
+ try {
+ final result = await FilePicker.platform.pickFiles();
+
+ if (result != null && result.files.isNotEmpty) {
+ final path = result.files.single.path;
+ if (path != null) {
+ final extension = p.extension(path).toLowerCase();
+ if (extension == '.xpi') {
+ selectedFile.value = path;
+ errorMessage.value = null;
+ } else {
+ errorMessage.value = 'Please select an .xpi file';
+ }
+ }
+ }
+ } catch (e) {
+ errorMessage.value = 'Failed to pick file: $e';
+ }
+ }
+
+ Future install() async {
+ if (selectedFile.value == null) return;
+
+ isInstalling.value = true;
+ errorMessage.value = null;
+
+ try {
+ await ref
+ .read(browserAddonServiceProvider.notifier)
+ .installFromFile(
+ selectedFile.value!,
+ allowUnsigned: allowUnsigned.value,
+ );
+
+ if (context.mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Extension installed successfully')),
+ );
+ context.pop(true);
+ }
+ } catch (e) {
+ final errorString = e.toString();
+ if (errorString.contains('NotSigned') ||
+ errorString.contains('SIGNEDSTATE')) {
+ errorMessage.value =
+ 'This extension is not signed by Mozilla. Enable "Allow unsigned extensions" to install it.';
+ } else {
+ errorMessage.value = 'Installation failed: $errorString';
+ }
+ } finally {
+ isInstalling.value = false;
+ }
+ }
+
+ final fileName = selectedFile.value != null
+ ? p.basename(selectedFile.value!)
+ : 'No file selected';
+
+ return SafeArea(
+ child: Padding(
+ padding: EdgeInsets.only(
+ left: 16,
+ right: 16,
+ top: 16,
+ bottom: MediaQuery.of(context).viewInsets.bottom + 16,
+ ),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Text(
+ 'Install Extension from File',
+ style: Theme.of(context).textTheme.titleLarge,
+ ),
+ const SizedBox(height: 16),
+ OutlinedButton.icon(
+ onPressed: isInstalling.value ? null : pickFile,
+ icon: const Icon(Icons.folder_open),
+ label: const Text('Select XPI File'),
+ ),
+ const SizedBox(height: 8),
+ Container(
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: Theme.of(context).colorScheme.surfaceContainerHighest,
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: Row(
+ children: [
+ Icon(
+ selectedFile.value != null
+ ? Icons.extension
+ : Icons.file_present_outlined,
+ size: 20,
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Text(
+ fileName,
+ style: TextStyle(
+ color: selectedFile.value != null
+ ? null
+ : Theme.of(context).colorScheme.outline,
+ ),
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ ],
+ ),
+ ),
+ 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(
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: Theme.of(context).colorScheme.errorContainer,
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: Text(
+ errorMessage.value!,
+ style: TextStyle(
+ color: Theme.of(context).colorScheme.onErrorContainer,
+ ),
+ maxLines: 10,
+ ),
+ ),
+ ],
+ const SizedBox(height: 16),
+ FilledButton(
+ onPressed: selectedFile.value == null || isInstalling.value
+ ? null
+ : install,
+ child: isInstalling.value
+ ? const SizedBox(
+ height: 20,
+ width: 20,
+ child: CircularProgressIndicator(strokeWidth: 2),
+ )
+ : const Text('Install'),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/app/lib/features/settings/presentation/screens/advanced_settings.dart b/app/lib/features/settings/presentation/screens/advanced_settings.dart
index c108bd42..75f32c0f 100644
--- a/app/lib/features/settings/presentation/screens/advanced_settings.dart
+++ b/app/lib/features/settings/presentation/screens/advanced_settings.dart
@@ -29,6 +29,7 @@ import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/providers/app_state.dart';
import 'package:weblibre/core/routing/routes.dart';
+import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/install_local_addon_dialog.dart';
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
import 'package:weblibre/features/settings/presentation/dialogs/user_agent_restart_dialog.dart';
import 'package:weblibre/features/settings/presentation/widgets/custom_list_tile.dart';
@@ -56,6 +57,7 @@ class AdvancedSettingsScreen extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 12.0),
children: const [
_ContentBehaviorSection(),
+ _ExtensionsSection(),
_StorageDebuggingSection(),
_ResetSection(),
],
@@ -83,6 +85,75 @@ class _ContentBehaviorSection extends StatelessWidget {
}
}
+class _ExtensionsSection extends StatelessWidget {
+ const _ExtensionsSection();
+
+ @override
+ Widget build(BuildContext context) {
+ return const Column(
+ children: [
+ SettingSection(name: 'Extensions'),
+ _InstallLocalAddonTile(),
+ _AddonCollectionTile(),
+ ],
+ );
+ }
+}
+
+class _InstallLocalAddonTile extends StatelessWidget {
+ const _InstallLocalAddonTile();
+
+ @override
+ Widget build(BuildContext context) {
+ return CustomListTile(
+ title: 'Install from File',
+ subtitle: 'Install an extension from a local .xpi file',
+ prefix: Padding(
+ padding: const EdgeInsets.only(right: 16.0),
+ child: Icon(
+ MdiIcons.puzzle,
+ size: 24,
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ ),
+ suffix: FilledButton.icon(
+ onPressed: () async {
+ await showInstallLocalAddonDialog(context);
+ },
+ icon: const Icon(Icons.file_open),
+ label: const Text('Install'),
+ ),
+ );
+ }
+}
+
+class _AddonCollectionTile extends StatelessWidget {
+ const _AddonCollectionTile();
+
+ @override
+ Widget build(BuildContext context) {
+ return CustomListTile(
+ title: 'Custom Collection',
+ subtitle: 'Use a custom Mozilla addon collection',
+ prefix: Padding(
+ padding: const EdgeInsets.only(right: 16.0),
+ child: Icon(
+ MdiIcons.folderMultiple,
+ size: 24,
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ ),
+ suffix: FilledButton.icon(
+ onPressed: () async {
+ await AddonCollectionRoute().push(context);
+ },
+ icon: const Icon(Icons.settings),
+ label: const Text('Configure'),
+ ),
+ );
+ }
+}
+
class _StorageDebuggingSection extends StatelessWidget {
const _StorageDebuggingSection();
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoAddonsApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoAddonsApiImpl.kt
index b18e9353..cb302749 100644
--- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoAddonsApiImpl.kt
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoAddonsApiImpl.kt
@@ -12,6 +12,7 @@ import eu.weblibre.flutter_mozilla_components.GlobalComponents
import eu.weblibre.flutter_mozilla_components.addons.AddonsActivity
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonsApi
import eu.weblibre.flutter_mozilla_components.pigeons.WebExtensionActionType
+import mozilla.components.concept.engine.webextension.InstallationMethod
class GeckoAddonsApiImpl(private val context: Context) : GeckoAddonsApi {
private val components by lazy {
@@ -32,8 +33,15 @@ class GeckoAddonsApiImpl(private val context: Context) : GeckoAddonsApi {
}
override fun installAddon(url: String, callback: (Result) -> Unit) {
+ val installMethod = if (url.startsWith("file://")) {
+ InstallationMethod.FROM_FILE
+ } else {
+ null
+ }
+
components.core.addonManager.installAddon(
url = url,
+ installationMethod = installMethod,
onSuccess = { _ ->
callback(Result.success(Unit))
},