install extension from xpi
This commit is contained in:
@@ -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();
|
||||
|
||||
+239
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user