diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml
index ee68f21f..b3413864 100644
--- a/app/android/app/src/main/AndroidManifest.xml
+++ b/app/android/app/src/main/AndroidManifest.xml
@@ -100,12 +100,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -123,7 +146,7 @@
-
+
@@ -133,19 +156,29 @@
+
-
-
-
-
-
-
-
-
+
+
+
+
+
@style/PreferenceThemeOverlay
+
+
diff --git a/app/lib/core/filesystem.dart b/app/lib/core/filesystem.dart
index cc6ac308..ff21e920 100644
--- a/app/lib/core/filesystem.dart
+++ b/app/lib/core/filesystem.dart
@@ -70,6 +70,10 @@ class _Filesystem {
return fs.clearMozillaProfileCache(profileId);
}
+ List getMozillaProfileIds(UuidValue uuid) {
+ return fs.getMozillaProfileIds(getProfileDir(uuid));
+ }
+
Future checkForDuplicateMozillaProfile(UuidValue profile) async {
final duplicates = await fs
.getProfilesWithDuplicateMozillaProfiles(profilesDir)
diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart
index e7e37da5..3b605e38 100644
--- a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart
+++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart
@@ -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
);
},
);
+
+ // 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
diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart
index 2a7d1d5c..1f98e5b0 100644
--- a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart
+++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart
@@ -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: [
diff --git a/app/lib/features/geckoview/features/pwa/domain/providers.dart b/app/lib/features/geckoview/features/pwa/domain/providers.dart
new file mode 100644
index 00000000..6bf4eb0a
--- /dev/null
+++ b/app/lib/features/geckoview/features/pwa/domain/providers.dart
@@ -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 .
+ */
+
+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 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 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 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> installedWebApps(Ref ref) {
+ return GeckoPwaApi().getInstalledWebApps();
+}
diff --git a/app/lib/features/geckoview/features/pwa/domain/providers.g.dart b/app/lib/features/geckoview/features/pwa/domain/providers.g.dart
new file mode 100644
index 00000000..7daf9555
--- /dev/null
+++ b/app/lib/features/geckoview/features/pwa/domain/providers.g.dart
@@ -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,
+ Stream
+ >
+ with
+ $FutureModifier,
+ $StreamProvider {
+ /// 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 $createElement(
+ $ProviderPointer pointer,
+ ) => $StreamProviderElement(pointer);
+
+ @override
+ Stream 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> {
+ /// 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 value) {
+ return $ProviderOverride(
+ origin: this,
+ providerOverride: $SyncValueProvider