/*
* Copyright (c) 2024-2026 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 'dart:async';
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/settings/presentation/widgets/settings_detail.dart';
const List extensionsSettingsSections = [
SettingsSectionDefinition(
title: 'Extensions',
entries: [
SettingsEntryDefinition(
title: 'Manage Extensions',
subtitle:
'Browse installed, disabled, available, and unsupported extensions',
keywords: ['addons', 'browser extensions'],
child: _ManageExtensionsTile(),
),
SettingsEntryDefinition(
title: 'Custom Collection',
subtitle: 'Use a custom Mozilla addon collection',
keywords: ['addons'],
child: _AddonCollectionTile(),
),
],
),
SettingsSectionDefinition(
title: 'Updates',
entries: [
SettingsEntryDefinition(
title: 'Automatic updates',
subtitle:
'Automatically check for and install extension updates every 12 hours',
keywords: ['addons'],
child: _AutoUpdateTile(),
),
],
),
SettingsSectionDefinition(
title: 'Security',
entries: [
SettingsEntryDefinition(
title: 'Allow unsigned extensions',
subtitle: 'Unsigned extensions have not been verified by Mozilla',
keywords: ['addons'],
child: _AllowUnsignedExtensionsTile(),
),
],
),
];
class ExtensionsSettingsScreen extends StatelessWidget {
const ExtensionsSettingsScreen({super.key});
@override
Widget build(BuildContext context) {
return const SettingsDetailScaffold(
title: 'Extensions',
subtitle: 'Manage add-ons, update behavior, and extension security.',
icon: MdiIcons.puzzleOutline,
sections: extensionsSettingsSections,
);
}
}
class _ManageExtensionsTile extends StatelessWidget {
const _ManageExtensionsTile();
@override
Widget build(BuildContext context) {
return ListTile(
leading: Icon(
MdiIcons.puzzleEdit,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
title: const Text('Manage Extensions'),
subtitle: const Text(
'Browse installed, disabled, available, and unsupported extensions',
),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await const AddonManagerRoute().push(context);
},
);
}
}
class _AddonCollectionTile extends StatelessWidget {
const _AddonCollectionTile();
@override
Widget build(BuildContext context) {
return ListTile(
leading: Icon(
MdiIcons.folderMultiple,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
title: const Text('Custom Collection'),
subtitle: const Text('Use a custom Mozilla addon collection'),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await AddonCollectionRoute().push(context);
},
);
}
}
class _AutoUpdateTile extends ConsumerWidget {
const _AutoUpdateTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final autoUpdate = ref.watch(addonAutoUpdateProvider);
return autoUpdate.when(
data: (enabled) => SwitchListTile.adaptive(
title: const Text('Automatic updates'),
subtitle: const Text(
'Automatically check for and install extension updates every 12 hours',
),
secondary: const Icon(Icons.system_update_alt),
value: enabled,
onChanged: (value) async {
await ref
.read(addonAutoUpdateProvider.notifier)
.setEnabled(enabled: value);
},
),
loading: () => const SwitchListTile.adaptive(
title: Text('Automatic updates'),
subtitle: Text(
'Automatically check for and install extension updates every 12 hours',
),
secondary: Icon(Icons.system_update_alt),
value: true,
onChanged: null,
),
error: (error, stack) => ListTile(
leading: const Icon(Icons.error_outline),
title: const Text('Automatic updates'),
subtitle: Text('Failed to load: $error'),
),
);
}
}
class _AllowUnsignedExtensionsTile extends ConsumerWidget {
const _AllowUnsignedExtensionsTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final allowUnsigned = ref.watch(allowUnsignedExtensionsProvider);
return allowUnsigned.when(
data: (allowed) => 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: allowed,
onChanged: (value) async {
if (value) {
final confirmed = await _showAllowUnsignedConfirmationDialog(
context,
);
if (confirmed != true) return;
}
await ref
.read(allowUnsignedExtensionsProvider.notifier)
.setAllowUnsigned(allow: value);
},
),
if (allowed)
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,
),
),
),
],
),
),
),
],
),
loading: () => const SwitchListTile.adaptive(
title: Text('Allow unsigned extensions'),
subtitle: Text('Unsigned extensions have not been verified by Mozilla'),
secondary: Icon(Icons.extension_off),
value: false,
onChanged: null,
),
error: (error, stack) => ListTile(
leading: const Icon(Icons.error_outline),
title: const Text('Allow unsigned extensions'),
subtitle: Text('Failed to load: $error'),
),
);
}
}
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})'),
),
],
);
}
}