initial pwa + custom tabs

This commit is contained in:
Fabian Freund
2026-02-10 16:20:54 +01:00
parent 592c6afdc5
commit 188dfdd003
47 changed files with 3974 additions and 130 deletions
+42 -9
View File
@@ -100,12 +100,35 @@
<data android:mimeType="text/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.PROCESS_TEXT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.WEB_SEARCH" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<!-- Intent router: receives all VIEW intents and routes Custom Tab / PWA to
ExternalAppBrowserActivity, regular VIEW intents to MainActivity -->
<activity
android:name="eu.weblibre.flutter_mozilla_components.activities.IntentReceiverActivity"
android:theme="@style/Theme.AppCompat.Translucent"
android:exported="true"
android:excludeFromRecents="true"
android:relinquishTaskIdentity="true"
android:taskAffinity="">
<!-- Required exactly like this to appear in both default app choosers and when explicitly browsing -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<category android:name="mozilla.components.pwa.category.SHORTCUT" />
<data android:scheme="http" />
<data android:scheme="https" />
@@ -123,7 +146,7 @@
<data android:mimeType="text/plain" />
<data android:mimeType="application/xhtml+xml" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
@@ -133,19 +156,29 @@
<data android:mimeType="application/pdf" />
</intent-filter>
<!-- PWA launch from home screen shortcut -->
<intent-filter>
<action android:name="android.intent.action.PROCESS_TEXT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.WEB_SEARCH" />
<action android:name="mozilla.components.feature.pwa.VIEW_PWA" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="https" />
</intent-filter>
</activity>
<!-- Native activity for Custom Tabs and PWA browsing (non-Flutter) -->
<activity
android:name="eu.weblibre.flutter_mozilla_components.activities.ExternalAppBrowserActivity"
android:exported="false"
android:taskAffinity=""
android:documentLaunchMode="always"
android:autoRemoveFromRecents="false"
android:theme="@style/ExternalAppBrowserTheme"
android:supportsPictureInPicture="true"
android:resizeableActivity="true"
android:persistableMode="persistNever"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize|stateAlwaysHidden" />
<activity
android:theme="@style/AddonsActivityTheme"
android:name="eu.weblibre.flutter_mozilla_components.addons.AddonsActivity"
@@ -30,6 +30,11 @@
<item name="preferenceTheme">@style/PreferenceThemeOverlay</item>
</style>
<style name="ExternalAppBrowserTheme" parent="Theme.Material3.DynamicColors.DayNight.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
<style name="AddonsActivityTheme" parent="AppTheme">
<item name="mozac_primary_text_color">@color/photonDarkGrey90</item>
</style>
+4
View File
@@ -70,6 +70,10 @@ class _Filesystem {
return fs.clearMozillaProfileCache(profileId);
}
List<String> getMozillaProfileIds(UuidValue uuid) {
return fs.getMozillaProfileIds(getProfileDir(uuid));
}
Future<String?> checkForDuplicateMozillaProfile(UuidValue profile) async {
final duplicates = await fs
.getProfilesWithDuplicateMozillaProfiles(profilesDir)
@@ -48,6 +48,7 @@ import 'package:weblibre/features/geckoview/features/browser/domain/services/eng
import 'package:weblibre/features/geckoview/features/browser/domain/services/proxy_settings_replication.dart';
import 'package:weblibre/features/geckoview/features/history/domain/repositories/history.dart';
import 'package:weblibre/features/geckoview/features/preferences/data/repositories/preference_observer.dart';
import 'package:weblibre/features/geckoview/features/pwa/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
import 'package:weblibre/features/share_intent/domain/entities/shared_content.dart';
@@ -503,6 +504,20 @@ class _BrowserViewState extends ConsumerState<BrowserView>
);
},
);
// Ensure PWA manifest state is collected and stays alive
ref.listenManual(
fireImmediately: true,
pwaManifestStateProvider,
(previous, next) {},
onError: (error, stackTrace) {
logger.e(
'Error listening to pwaManifestStateProvider',
error: error,
stackTrace: stackTrace,
);
},
);
}
@override
@@ -37,6 +37,8 @@ import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/navigation_buttons.dart';
import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart';
import 'package:weblibre/features/geckoview/features/pwa/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/pwa/presentation/widgets/pwa_install_button.dart';
import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart';
import 'package:weblibre/features/geckoview/features/readerview/presentation/widgets/reader_button.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
@@ -195,6 +197,27 @@ class TabMenu extends HookConsumerWidget {
).push(context);
},
),
Consumer(
builder: (context, ref, child) {
final isInstallable = ref.watch(isCurrentTabInstallableProvider);
return Visibility(
visible: isInstallable,
child: MenuItemButton(
closeOnActivate: false,
leadingIcon: const Icon(Icons.add_to_home_screen),
child: const Text('Add to Home Screen'),
onPressed: () async {
await showPwaInstallDialog(context, ref);
if (context.mounted) {
MenuController.maybeOf(context)?.close();
}
},
),
);
},
),
if (enableCloneTab)
SubmenuButton(
menuChildren: [
@@ -0,0 +1,123 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/filesystem.dart' show filesystem;
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/geckoview/domain/providers.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
import 'package:weblibre/features/geckoview/features/pwa/domain/pwa_installability.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
part 'providers.g.dart';
/// Stream of manifest update events from the event service.
@Riverpod(keepAlive: true)
Stream<ManifestUpdateEvent> manifestUpdateEventsStream(Ref ref) {
final eventService = ref.watch(eventServiceProvider);
return eventService.manifestUpdateEvents;
}
/// Manages PWA manifest state with a long-running subscription.
@Riverpod(keepAlive: true)
class PwaManifestState extends _$PwaManifestState {
PwaManifest? getManifest(String tabId) => state[tabId];
@override
Map<String, PwaManifest?> build() {
// Listen to manifest update events using ref.listenManual
ref.listen(
manifestUpdateEventsStreamProvider,
(previous, next) {
if (next.hasValue) {
final event = next.value!;
// Update state with new manifest - this triggers rebuilds
state = {...state, event.tabId: event.manifest};
}
},
onError: (error, stackTrace) {
logger.e(
'Error listening to manifest update events',
error: error,
stackTrace: stackTrace,
);
},
);
return {};
}
}
/// PWA manifest for the currently selected tab.
@Riverpod()
PwaManifest? currentTabManifest(Ref ref) {
final selectedTabId = ref.watch(selectedTabProvider);
final manifestState = ref.watch(pwaManifestStateProvider);
if (selectedTabId == null) return null;
return manifestState[selectedTabId];
}
/// Boolean indicating if the current tab is installable as a PWA.
@Riverpod()
bool isCurrentTabInstallable(Ref ref) {
final manifest = ref.watch(currentTabManifestProvider);
if (manifest == null) return false;
return isManifestInstallable(manifest);
}
/// Installs the current tab as a PWA, embedding profile and container context
/// in the shortcut intent so the PWA reopens with the same isolation.
@Riverpod()
Future<bool> installCurrentWebApp(Ref ref) async {
final selectedTabId = ref.read(selectedTabProvider);
if (selectedTabId == null) {
throw StateError('No tab selected');
}
final profileUuid = filesystem.selectedProfile.uuid;
final selectedContainerId = ref.read(selectedContainerProvider);
String? contextId;
if (selectedContainerId != null) {
final containerRepository = ref.read(containerRepositoryProvider.notifier);
final containerData = await containerRepository.getContainerData(
selectedContainerId,
);
contextId = containerData?.metadata.contextualIdentity;
}
return GeckoPwaApi().installWebApp(selectedTabId, profileUuid, contextId);
}
/// Returns all installed PWAs.
@Riverpod()
Future<List<PwaManifest>> installedWebApps(Ref ref) {
return GeckoPwaApi().getInstalledWebApps();
}
@@ -0,0 +1,298 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'providers.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Stream of manifest update events from the event service.
@ProviderFor(manifestUpdateEventsStream)
final manifestUpdateEventsStreamProvider =
ManifestUpdateEventsStreamProvider._();
/// Stream of manifest update events from the event service.
final class ManifestUpdateEventsStreamProvider
extends
$FunctionalProvider<
AsyncValue<ManifestUpdateEvent>,
ManifestUpdateEvent,
Stream<ManifestUpdateEvent>
>
with
$FutureModifier<ManifestUpdateEvent>,
$StreamProvider<ManifestUpdateEvent> {
/// Stream of manifest update events from the event service.
ManifestUpdateEventsStreamProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'manifestUpdateEventsStreamProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$manifestUpdateEventsStreamHash();
@$internal
@override
$StreamProviderElement<ManifestUpdateEvent> $createElement(
$ProviderPointer pointer,
) => $StreamProviderElement(pointer);
@override
Stream<ManifestUpdateEvent> create(Ref ref) {
return manifestUpdateEventsStream(ref);
}
}
String _$manifestUpdateEventsStreamHash() =>
r'60035756c129c1801f2f1f4e7da6384abf78d8fa';
/// Manages PWA manifest state with a long-running subscription.
@ProviderFor(PwaManifestState)
final pwaManifestStateProvider = PwaManifestStateProvider._();
/// Manages PWA manifest state with a long-running subscription.
final class PwaManifestStateProvider
extends $NotifierProvider<PwaManifestState, Map<String, PwaManifest?>> {
/// Manages PWA manifest state with a long-running subscription.
PwaManifestStateProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'pwaManifestStateProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$pwaManifestStateHash();
@$internal
@override
PwaManifestState create() => PwaManifestState();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(Map<String, PwaManifest?> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<Map<String, PwaManifest?>>(value),
);
}
}
String _$pwaManifestStateHash() => r'e5237d2aae5dce9cb805484eb2a09cc3f6aceda1';
/// Manages PWA manifest state with a long-running subscription.
abstract class _$PwaManifestState extends $Notifier<Map<String, PwaManifest?>> {
Map<String, PwaManifest?> build();
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref as $Ref<Map<String, PwaManifest?>, Map<String, PwaManifest?>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<Map<String, PwaManifest?>, Map<String, PwaManifest?>>,
Map<String, PwaManifest?>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
/// PWA manifest for the currently selected tab.
@ProviderFor(currentTabManifest)
final currentTabManifestProvider = CurrentTabManifestProvider._();
/// PWA manifest for the currently selected tab.
final class CurrentTabManifestProvider
extends $FunctionalProvider<PwaManifest?, PwaManifest?, PwaManifest?>
with $Provider<PwaManifest?> {
/// PWA manifest for the currently selected tab.
CurrentTabManifestProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'currentTabManifestProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$currentTabManifestHash();
@$internal
@override
$ProviderElement<PwaManifest?> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
PwaManifest? create(Ref ref) {
return currentTabManifest(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(PwaManifest? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<PwaManifest?>(value),
);
}
}
String _$currentTabManifestHash() =>
r'6121287eaaa18d080154c8ffe6e2a4b8c2d144d3';
/// Boolean indicating if the current tab is installable as a PWA.
@ProviderFor(isCurrentTabInstallable)
final isCurrentTabInstallableProvider = IsCurrentTabInstallableProvider._();
/// Boolean indicating if the current tab is installable as a PWA.
final class IsCurrentTabInstallableProvider
extends $FunctionalProvider<bool, bool, bool>
with $Provider<bool> {
/// Boolean indicating if the current tab is installable as a PWA.
IsCurrentTabInstallableProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'isCurrentTabInstallableProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$isCurrentTabInstallableHash();
@$internal
@override
$ProviderElement<bool> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
bool create(Ref ref) {
return isCurrentTabInstallable(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(bool value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<bool>(value),
);
}
}
String _$isCurrentTabInstallableHash() =>
r'293cdb6dcea24446343330ccdb30dc9f21f03618';
/// Installs the current tab as a PWA, embedding profile and container context
/// in the shortcut intent so the PWA reopens with the same isolation.
@ProviderFor(installCurrentWebApp)
final installCurrentWebAppProvider = InstallCurrentWebAppProvider._();
/// Installs the current tab as a PWA, embedding profile and container context
/// in the shortcut intent so the PWA reopens with the same isolation.
final class InstallCurrentWebAppProvider
extends $FunctionalProvider<AsyncValue<bool>, bool, FutureOr<bool>>
with $FutureModifier<bool>, $FutureProvider<bool> {
/// Installs the current tab as a PWA, embedding profile and container context
/// in the shortcut intent so the PWA reopens with the same isolation.
InstallCurrentWebAppProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'installCurrentWebAppProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$installCurrentWebAppHash();
@$internal
@override
$FutureProviderElement<bool> $createElement($ProviderPointer pointer) =>
$FutureProviderElement(pointer);
@override
FutureOr<bool> create(Ref ref) {
return installCurrentWebApp(ref);
}
}
String _$installCurrentWebAppHash() =>
r'da536e2aca886831e0bbbc5b70eae03ac4a4ea9f';
/// Returns all installed PWAs.
@ProviderFor(installedWebApps)
final installedWebAppsProvider = InstalledWebAppsProvider._();
/// Returns all installed PWAs.
final class InstalledWebAppsProvider
extends
$FunctionalProvider<
AsyncValue<List<PwaManifest>>,
List<PwaManifest>,
FutureOr<List<PwaManifest>>
>
with
$FutureModifier<List<PwaManifest>>,
$FutureProvider<List<PwaManifest>> {
/// Returns all installed PWAs.
InstalledWebAppsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'installedWebAppsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$installedWebAppsHash();
@$internal
@override
$FutureProviderElement<List<PwaManifest>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<PwaManifest>> create(Ref ref) {
return installedWebApps(ref);
}
}
String _$installedWebAppsHash() => r'ff185620ccd25bf6b71415e34e3e0c0f20d5e59d';
@@ -0,0 +1,132 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
/// Display modes that are valid for installable PWAs per W3C spec.
const _validDisplayModes = {
'standalone',
'fullscreen',
'minimal-ui',
'window-controls-overlay',
};
/// Determines if a PWA manifest meets installability criteria per W3C spec.
///
/// A web app is installable if:
/// 1. Served over HTTPS (or localhost for development)
/// 2. Has a valid manifest with required fields:
/// - name (or short_name)
/// - start_url (must be same-origin and within scope)
/// - display mode (standalone, fullscreen, minimal-ui, or
/// window-controls-overlay)
/// 3. start_url is within the scope
/// 4. prefer_related_applications is not true
bool isManifestInstallable(PwaManifest manifest) {
// Check HTTPS requirement (relaxed for localhost)
final isSecure =
manifest.currentUrl.startsWith('https://') ||
manifest.currentUrl.startsWith('http://localhost') ||
manifest.currentUrl.startsWith('http://127.0.0.1');
if (!isSecure) {
return false;
}
// prefer_related_applications must not be true
if (manifest.preferRelatedApplications) {
return false;
}
// Check required manifest fields
final hasValidName =
manifest.name?.isNotEmpty == true ||
manifest.shortName?.isNotEmpty == true;
// W3C §1.10.6: start_url must be same-origin as the document URL
final hasValidStartUrl =
manifest.startUrl.isNotEmpty &&
_isSameOrigin(manifest.startUrl, manifest.currentUrl);
final hasValidDisplay =
manifest.display != null &&
_validDisplayModes.contains(manifest.display!.toLowerCase());
// Check that start_url is within scope
final isInScope = _isStartUrlInScope(
manifest.startUrl,
manifest.scope,
);
return hasValidName &&
hasValidStartUrl &&
hasValidDisplay &&
isInScope;
}
/// Returns true if two URLs share the same origin (scheme + host + port).
bool _isSameOrigin(String url1, String url2) {
try {
final uri1 = Uri.parse(url1);
final uri2 = Uri.parse(url2);
return uri1.scheme == uri2.scheme &&
uri1.host == uri2.host &&
uri1.port == uri2.port;
} catch (e) {
return false;
}
}
/// Checks if startUrl is within scope using URL path containment.
///
/// Per W3C spec: when scope is absent, the default scope is the start_url
/// with its last path segment, query, and fragment removed.
/// Scope matching uses path-prefix comparison on `/` boundaries.
bool _isStartUrlInScope(String startUrl, String? scope) {
try {
final startUri = Uri.parse(startUrl);
if (scope == null || scope.isEmpty) {
// Per W3C: default scope = start_url with filename/query/fragment removed
// The start_url is trivially within its own default scope.
return true;
}
final scopeUri = Uri.parse(scope);
// Must be same origin
if (startUri.scheme != scopeUri.scheme ||
startUri.host != scopeUri.host ||
startUri.port != scopeUri.port) {
return false;
}
// Path containment: scope path must be a prefix of start_url path
// on a `/` boundary to avoid "/app" matching "/application"
final scopePath =
scopeUri.path.endsWith('/') ? scopeUri.path : '${scopeUri.path}/';
final startPath =
startUri.path.endsWith('/') ? startUri.path : '${startUri.path}/';
return startPath.startsWith(scopePath) || startUri.path == scopeUri.path;
} catch (e) {
return false;
}
}
@@ -0,0 +1,77 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/geckoview/features/pwa/domain/providers.dart';
import 'package:weblibre/utils/ui_helper.dart';
Future<void> showPwaInstallDialog(BuildContext context, WidgetRef ref) async {
final manifest = ref.read(currentTabManifestProvider);
final name = manifest?.name ?? manifest?.shortName ?? 'this web app';
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Add to Home Screen'),
content: Text('Add "$name" to your home screen?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Add'),
),
],
),
);
if (confirmed == true) {
try {
final success = await ref.read(installCurrentWebAppProvider.future);
if (context.mounted) {
if (success) {
showInfoMessage(context, '$name added to home screen');
} else {
showErrorMessage(
context,
'Failed to add $name. The site may not support installation.',
);
}
}
} catch (e, stackTrace) {
logger.e('Failed to install PWA', error: e, stackTrace: stackTrace);
if (context.mounted) {
var errorMessage = 'Failed to add $name to home screen';
if (e is StateError) {
errorMessage = 'No tab selected. Please try again.';
}
showErrorMessage(context, errorMessage);
}
}
}
}
@@ -62,6 +62,12 @@ class ProfileRepository extends _$ProfileRepository {
return false;
}
// Clean up Mozilla cache directories before deleting the profile
final mozillaProfileIds = filesystem.getMozillaProfileIds(uuid);
for (final profileId in mozillaProfileIds) {
await filesystem.clearMozillaProfileCache(profileId);
}
await filesystem.getProfileDir(uuid).delete(recursive: true);
ref.invalidateSelf();
@@ -33,7 +33,7 @@ final class ProfileRepositoryProvider
ProfileRepository create() => ProfileRepository();
}
String _$profileRepositoryHash() => r'249240ce0e775d4fd9ee3558e6b05326d09d79de';
String _$profileRepositoryHash() => r'e925dba74b0f15244fea8fff8391b5b67509be09';
abstract class _$ProfileRepository extends $AsyncNotifier<List<Profile>> {
FutureOr<List<Profile>> build();
@@ -51,6 +51,8 @@ class UserBackupService extends _$UserBackupService {
Stream<File> getBackupListStream() async* {
final backupDirectory = await getBackupDirectory();
if (!await backupDirectory.exists()) return;
await for (final entity in backupDirectory.list(recursive: true)) {
if (entity is File) {
yield entity;
@@ -144,32 +146,49 @@ class UserBackupService extends _$UserBackupService {
final existingProfile = await filesystem.readProfileMetadata(
outputDirectory,
);
if (existingProfile != null) {
if (existingProfile.uuidValue == filesystem.selectedProfile) {
throw Exception(
'Unable to override active User, please switch to another User and try again',
);
}
final profileDir = filesystem.getProfileDir(
existingProfile.uuidValue,
if (existingProfile == null) {
throw Exception(
'Backup does not contain valid profile metadata',
);
}
if (await profileDir.exists()) {
final result = await confirmOverrideCallback();
if (existingProfile.uuidValue == filesystem.selectedProfile) {
throw Exception(
'Unable to override active User, please switch to another User and try again',
);
}
if (result == true) {
await profileDir.delete(recursive: true);
await outputDirectory.rename(profileDir.path);
}
final profileDir = filesystem.getProfileDir(
existingProfile.uuidValue,
);
if (await profileDir.exists()) {
final result = await confirmOverrideCallback();
if (result == true) {
await profileDir.delete(recursive: true);
await outputDirectory.rename(profileDir.path);
}
} else {
// Profile doesn't exist yet, just move the restored data into place
await outputDirectory.rename(profileDir.path);
}
});
ref.invalidate(profileRepositoryProvider);
return true;
} finally {
await outputDirectory.delete(recursive: true);
try {
if (await outputDirectory.exists()) {
await outputDirectory.delete(recursive: true);
}
} catch (e, s) {
logger.w(
'Failed to cleanup temporary backup directory: ${outputDirectory.path}',
error: e,
stackTrace: s,
);
}
}
}
@@ -41,7 +41,7 @@ final class UserBackupServiceProvider
}
}
String _$userBackupServiceHash() => r'cbeffa04bb52d3c7d50a2eb38678750f2a0362cc';
String _$userBackupServiceHash() => r'a0bcf458e6b0a982f1fc90fce99e60cc3418f4d9';
abstract class _$UserBackupService extends $Notifier<void> {
void build();
+24
View File
@@ -75,6 +75,20 @@ Future<void> clearMozillaProfileCache(String profileId) async {
}
}
/// Returns the list of Mozilla profile IDs (`.default` directory names)
/// inside the given profile directory's `mozilla/` subdirectory.
List<String> getMozillaProfileIds(Directory profileDir) {
final mozillaDir = Directory(p.join(profileDir.path, 'mozilla'));
if (!mozillaDir.existsSync()) return [];
return mozillaDir
.listSync()
.whereType<Directory>()
.map((dir) => p.basename(dir.path))
.where((name) => name.endsWith('.default'))
.toList();
}
Future<List<Directory>> getProfilesWithDuplicateMozillaProfiles(
Directory profilesDir,
) async {
@@ -122,6 +136,16 @@ Future<UuidValue?> selectStartupProfile(Directory profilesDir) async {
var startupProfile = await readStartupProfile(profilesDir);
final availableProfiles = await getAvailableProfileDirectories(profilesDir);
// Verify the startup profile directory actually exists
if (startupProfile != null) {
final profileDir = getProfileDir(profilesDir, startupProfile);
final exists = availableProfiles.any((dir) => dir.path == profileDir.path);
if (!exists) {
logger.w('Startup profile directory missing, selecting fallback');
startupProfile = null;
}
}
if (startupProfile == null) {
final sortedDirs = await sortByAccessTime(availableProfiles);