install extension from xpi

This commit is contained in:
Fabian Freund
2026-01-25 18:26:02 +01:00
parent b7058f94a8
commit 1e9ecb40ba
5 changed files with 366 additions and 1 deletions
@@ -18,7 +18,9 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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<Uri> getAddonXpiUrl(String guid) async {
@@ -64,6 +68,49 @@ class BrowserAddonService extends _$BrowserAddonService {
}
}
Future<bool> 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() {}
}
@@ -42,7 +42,7 @@ final class BrowserAddonServiceProvider
}
String _$browserAddonServiceHash() =>
r'e7a94f86edc900339518ea0cc0e6fdbd9eccd065';
r'1485fd056ce32e142e342e920affb82a5c77a370';
abstract class _$BrowserAddonService extends $Notifier<void> {
void build();
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<bool?> showInstallLocalAddonDialog(BuildContext context) {
return showModalBottomSheet<bool?>(
context: context,
isScrollControlled: true,
builder: (context) => const _InstallLocalAddonSheet(),
);
}
class _InstallLocalAddonSheet extends HookConsumerWidget {
const _InstallLocalAddonSheet();
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedFile = useState<String?>(null);
final allowUnsigned = useState(false);
final isInstalling = useState(false);
final errorMessage = useState<String?>(null);
Future<void> 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<void> 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'),
),
],
),
),
);
}
}
@@ -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();
@@ -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>) -> 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))
},