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)) },