diff --git a/apps/weblibre/android/app/src/main/AndroidManifest.xml b/apps/weblibre/android/app/src/main/AndroidManifest.xml
index 1ab1bea6..bbf44ffb 100644
--- a/apps/weblibre/android/app/src/main/AndroidManifest.xml
+++ b/apps/weblibre/android/app/src/main/AndroidManifest.xml
@@ -75,7 +75,7 @@
tools:node="remove" />
@@ -83,6 +83,7 @@
+
diff --git a/apps/weblibre/android/app/src/main/kotlin/eu/weblibre/gecko/MyApplication.kt b/apps/weblibre/android/app/src/main/kotlin/eu/weblibre/gecko/MyApplication.kt
index 88c7632e..295764e4 100644
--- a/apps/weblibre/android/app/src/main/kotlin/eu/weblibre/gecko/MyApplication.kt
+++ b/apps/weblibre/android/app/src/main/kotlin/eu/weblibre/gecko/MyApplication.kt
@@ -24,6 +24,7 @@ import android.content.SharedPreferences
import eu.weblibre.flutter_mozilla_components.ActiveProfile
import eu.weblibre.flutter_mozilla_components.MegazordSetup
import eu.weblibre.flutter_mozilla_components.feature.SandboxCaptureFeature
+import eu.weblibre.flutter_mozilla_components.push.PushMessageScheduler
class MyApplication : Application() {
override fun onCreate() {
@@ -33,7 +34,7 @@ class MyApplication : Application() {
// Resolve active profile EARLY so cold-start WorkManager workers
// get profile-prefixed SharedPreferences
- ActiveProfile.resolveFromDisk(this)
+ ActiveProfile.resolveFromDisk(this)?.let(PushMessageScheduler::recoverLater)
// Rehydrate the sandbox capture registry from the on-disk JSON mirror
// before Gecko has a chance to start restoring tabs. Each entry gets
diff --git a/apps/weblibre/lib/core/routing/routes.dart b/apps/weblibre/lib/core/routing/routes.dart
index 03ebe30d..71c454ad 100644
--- a/apps/weblibre/lib/core/routing/routes.dart
+++ b/apps/weblibre/lib/core/routing/routes.dart
@@ -111,6 +111,7 @@ import 'package:weblibre/features/web_feed/presentation/screens/feed_article_lis
import 'package:weblibre/features/web_feed/presentation/screens/feed_edit.dart';
import 'package:weblibre/features/web_feed/presentation/screens/feed_list.dart';
import 'package:weblibre/features/web_feed/presentation/select_feed_dialog.dart';
+import 'package:weblibre/features/web_push/presentation/screens/web_push_settings.dart';
part 'routes.bangs.dart';
part 'routes.bookmarks.dart';
diff --git a/apps/weblibre/lib/core/routing/routes.g.dart b/apps/weblibre/lib/core/routing/routes.g.dart
index b59bb5e3..05d1b8c6 100644
--- a/apps/weblibre/lib/core/routing/routes.g.dart
+++ b/apps/weblibre/lib/core/routing/routes.g.dart
@@ -1592,6 +1592,11 @@ RouteBase get $settingsRoute => GoRouteData.$route(
name: 'ExperimentalSettingsRoute',
factory: $ExperimentalSettingsRoute._fromState,
),
+ GoRouteData.$route(
+ path: 'push',
+ name: 'WebPushSettingsRoute',
+ factory: $WebPushSettingsRoute._fromState,
+ ),
GoRouteData.$route(
path: 'bang',
name: 'BangSettingsRoute',
@@ -1949,6 +1954,27 @@ mixin $ExperimentalSettingsRoute on GoRouteData {
void replace(BuildContext context) => context.replace(location);
}
+mixin $WebPushSettingsRoute on GoRouteData {
+ static WebPushSettingsRoute _fromState(GoRouterState state) =>
+ WebPushSettingsRoute();
+
+ @override
+ String get location => GoRouteData.$location('/settings/push');
+
+ @override
+ void go(BuildContext context) => context.go(location);
+
+ @override
+ Future push(BuildContext context) => context.push(location);
+
+ @override
+ void pushReplacement(BuildContext context) =>
+ context.pushReplacement(location);
+
+ @override
+ void replace(BuildContext context) => context.replace(location);
+}
+
mixin $BangSettingsRoute on GoRouteData {
static BangSettingsRoute _fromState(GoRouterState state) =>
BangSettingsRoute();
diff --git a/apps/weblibre/lib/core/routing/routes.settings.dart b/apps/weblibre/lib/core/routing/routes.settings.dart
index a9409225..4d926c46 100644
--- a/apps/weblibre/lib/core/routing/routes.settings.dart
+++ b/apps/weblibre/lib/core/routing/routes.settings.dart
@@ -63,6 +63,10 @@ part of 'routes.dart';
name: 'ExperimentalSettingsRoute',
path: 'experimental',
),
+ TypedGoRoute(
+ name: 'WebPushSettingsRoute',
+ path: 'push',
+ ),
TypedGoRoute(name: 'BangSettingsRoute', path: 'bang'),
TypedGoRoute(
name: 'WebEngineHardeningRoute',
@@ -232,6 +236,13 @@ class ExperimentalSettingsRoute extends GoRouteData
}
}
+class WebPushSettingsRoute extends GoRouteData with $WebPushSettingsRoute {
+ @override
+ Widget build(BuildContext context, GoRouterState state) {
+ return const WebPushSettingsScreen();
+ }
+}
+
class BangSettingsRoute extends GoRouteData with $BangSettingsRoute {
@override
Widget build(BuildContext context, GoRouterState state) {
diff --git a/apps/weblibre/lib/features/geckoview/features/search/domain/providers/engine_suggestions.g.dart b/apps/weblibre/lib/features/geckoview/features/search/domain/providers/engine_suggestions.g.dart
index 80f2e396..3da37f6f 100644
--- a/apps/weblibre/lib/features/geckoview/features/search/domain/providers/engine_suggestions.g.dart
+++ b/apps/weblibre/lib/features/geckoview/features/search/domain/providers/engine_suggestions.g.dart
@@ -33,7 +33,7 @@ final class EngineSuggestionsProvider
EngineSuggestions create() => EngineSuggestions();
}
-String _$engineSuggestionsHash() => r'10d4a8a53d184b6c1d107d8634a2f662dfd297f1';
+String _$engineSuggestionsHash() => r'4918e80a1e7dfb59fe67d0895e62a39f2704851f';
abstract class _$EngineSuggestions
extends $StreamNotifier> {
diff --git a/apps/weblibre/lib/features/settings/presentation/screens/experimental_settings.dart b/apps/weblibre/lib/features/settings/presentation/screens/experimental_settings.dart
index f7ccb856..afe61f29 100644
--- a/apps/weblibre/lib/features/settings/presentation/screens/experimental_settings.dart
+++ b/apps/weblibre/lib/features/settings/presentation/screens/experimental_settings.dart
@@ -19,7 +19,6 @@
*/
import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
-import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
@@ -27,20 +26,8 @@ import 'package:weblibre/features/user/data/models/engine_settings.dart';
import 'package:weblibre/features/user/domain/presentation/dialogs/quit_browser_dialog.dart';
import 'package:weblibre/features/user/domain/repositories/engine_settings.dart';
import 'package:weblibre/utils/exit_app.dart';
-import 'package:weblibre/utils/ui_helper.dart';
const List experimentalSettingsSections = [
- SettingsSectionDefinition(
- title: 'Web Push',
- entries: [
- SettingsEntryDefinition(
- title: 'Choose UnifiedPush Distributor',
- subtitle: 'Select the app that delivers website push notifications',
- keywords: ['notifications', 'push'],
- child: _UnifiedPushDistributorTile(),
- ),
- ],
- ),
SettingsSectionDefinition(
title: 'Runtime & Startup',
entries: [
@@ -67,46 +54,13 @@ class ExperimentalSettingsScreen extends StatelessWidget {
Widget build(BuildContext context) {
return const SettingsDetailScaffold(
title: 'Experimental',
- subtitle: 'Push delivery, runtime isolation, and startup behavior.',
+ subtitle: 'Runtime isolation and startup behavior.',
icon: MdiIcons.flaskOutline,
sections: experimentalSettingsSections,
);
}
}
-class _UnifiedPushDistributorTile extends StatelessWidget {
- const _UnifiedPushDistributorTile();
-
- @override
- Widget build(BuildContext context) {
- return ListTile(
- leading: const Icon(MdiIcons.bellBadgeOutline),
- title: const Text('Choose UnifiedPush Distributor'),
- subtitle: const Text(
- 'Select the app that should deliver website push notifications to WebLibre.',
- ),
- trailing: const Icon(Icons.chevron_right),
- onTap: () async {
- final success = await GeckoBrowserService()
- .pickUnifiedPushDistributor();
-
- if (!context.mounted) {
- return;
- }
-
- if (success) {
- showInfoMessage(context, 'UnifiedPush distributor configured.');
- } else {
- showErrorMessage(
- context,
- 'Could not configure UnifiedPush. Install a distributor and try again.',
- );
- }
- },
- );
- }
-}
-
class _IsolatedProcessEnabledTile extends HookConsumerWidget {
const _IsolatedProcessEnabledTile();
diff --git a/apps/weblibre/lib/features/settings/presentation/screens/settings.dart b/apps/weblibre/lib/features/settings/presentation/screens/settings.dart
index a0ee859d..7603cf82 100644
--- a/apps/weblibre/lib/features/settings/presentation/screens/settings.dart
+++ b/apps/weblibre/lib/features/settings/presentation/screens/settings.dart
@@ -34,6 +34,7 @@ import 'package:weblibre/features/settings/presentation/screens/search_settings.
import 'package:weblibre/features/settings/presentation/screens/web_content_settings.dart';
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
import 'package:weblibre/features/settings/presentation/widgets/toolbar_layout_content.dart';
+import 'package:weblibre/features/web_push/presentation/screens/web_push_settings.dart';
class SettingsScreen extends HookWidget {
const SettingsScreen({super.key});
@@ -135,6 +136,14 @@ _CategoryGroups _buildCategories() {
sections: webContentSettingsSections,
onTap: (context) => WebContentSettingsRoute().push(context),
),
+ _SettingsCategoryDefinition(
+ title: 'Notifications',
+ subtitle: 'Web push delivery, distributor, site subscriptions',
+ icon: MdiIcons.bellBadgeOutline,
+ keywords: const ['push', 'unifiedpush', 'ntfy', 'distributor'],
+ sections: webPushSettingsSections,
+ onTap: (context) => WebPushSettingsRoute().push(context),
+ ),
_SettingsCategoryDefinition(
title: 'Search',
subtitle: 'Providers, bangs, search history',
diff --git a/apps/weblibre/lib/features/user/domain/presentation/dialogs/switch_profile_dialog.dart b/apps/weblibre/lib/features/user/domain/presentation/dialogs/switch_profile_dialog.dart
index abab3dc2..85cbe80f 100644
--- a/apps/weblibre/lib/features/user/domain/presentation/dialogs/switch_profile_dialog.dart
+++ b/apps/weblibre/lib/features/user/domain/presentation/dialogs/switch_profile_dialog.dart
@@ -33,7 +33,7 @@ Future showSwitchProfileDialog(
icon: const Icon(Icons.warning),
title: const Text('Switch User'),
content: Text(
- "Switching to User '$profileName' will require a restart of the Browser.\n\nPrivate tab data will be cleared on restart.",
+ "Switching to User '$profileName' will require a restart of the Browser. Web notifications for the inactive profile will be paused.\n\nPrivate tab data will be cleared on restart.",
style: const TextStyle(fontWeight: FontWeight.bold),
),
actions: [
diff --git a/apps/weblibre/lib/features/user/domain/presentation/utils/profile_switch_handler.dart b/apps/weblibre/lib/features/user/domain/presentation/utils/profile_switch_handler.dart
index 74ef9dd7..b4cfd4df 100644
--- a/apps/weblibre/lib/features/user/domain/presentation/utils/profile_switch_handler.dart
+++ b/apps/weblibre/lib/features/user/domain/presentation/utils/profile_switch_handler.dart
@@ -55,9 +55,16 @@ Future handleSwitchProfile(
);
if (shouldSwitch == true) {
- await ref
- .read(profileRepositoryProvider.notifier)
- .switchProfile(profile.id);
+ try {
+ await ref
+ .read(profileRepositoryProvider.notifier)
+ .switchProfile(profile.id);
+ } catch (error) {
+ if (context.mounted) {
+ ui_helper.showErrorMessage(context, 'Could not switch profile: $error');
+ }
+ return;
+ }
await exitApp(ref.container);
}
}
diff --git a/apps/weblibre/lib/features/user/domain/repositories/profile.dart b/apps/weblibre/lib/features/user/domain/repositories/profile.dart
index 97549685..b48298e6 100644
--- a/apps/weblibre/lib/features/user/domain/repositories/profile.dart
+++ b/apps/weblibre/lib/features/user/domain/repositories/profile.dart
@@ -20,8 +20,10 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:uuid/uuid.dart';
import 'package:weblibre/core/filesystem.dart';
+import 'package:weblibre/core/logger.dart';
import 'package:weblibre/domain/entities/profile.dart';
import 'package:weblibre/features/user/data/models/auth_settings.dart';
+import 'package:weblibre/features/web_push/domain/providers.dart';
part 'profile.g.dart';
@@ -37,7 +39,23 @@ class ProfileRepository extends _$ProfileRepository {
}
Future switchProfile(String id) async {
- await filesystem.setStartupProfile(UuidValue.withValidation(id));
+ final profileId = UuidValue.withValidation(id).uuid;
+ final pushService = ref.read(pushServiceProvider);
+
+ try {
+ await pushService.suspendForProfileSwitch(profileId);
+ } catch (error, stackTrace) {
+ try {
+ await pushService.renewRegistration();
+ } catch (renewError, renewStackTrace) {
+ logger.e(
+ 'Failed to restore push registration after profile switch failure',
+ error: renewError,
+ stackTrace: renewStackTrace,
+ );
+ }
+ Error.throwWithStackTrace(error, stackTrace);
+ }
}
Future createProfile({
diff --git a/apps/weblibre/lib/features/user/domain/repositories/profile.g.dart b/apps/weblibre/lib/features/user/domain/repositories/profile.g.dart
index 310a8afe..3f678c9a 100644
--- a/apps/weblibre/lib/features/user/domain/repositories/profile.g.dart
+++ b/apps/weblibre/lib/features/user/domain/repositories/profile.g.dart
@@ -33,7 +33,7 @@ final class ProfileRepositoryProvider
ProfileRepository create() => ProfileRepository();
}
-String _$profileRepositoryHash() => r'b770e7406e1602f808cc8076c1eda67b4fce6b2d';
+String _$profileRepositoryHash() => r'3055487626bdf6bdc6a51284f68eaf4067cd52ef';
abstract class _$ProfileRepository extends $AsyncNotifier> {
FutureOr> build();
diff --git a/apps/weblibre/lib/features/web_push/domain/providers.dart b/apps/weblibre/lib/features/web_push/domain/providers.dart
new file mode 100644
index 00000000..5a37c6fa
--- /dev/null
+++ b/apps/weblibre/lib/features/web_push/domain/providers.dart
@@ -0,0 +1,216 @@
+/*
+ * 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:permission_handler/permission_handler.dart';
+import 'package:riverpod_annotation/riverpod_annotation.dart';
+
+part 'providers.g.dart';
+
+@Riverpod(keepAlive: true)
+GeckoPushService pushService(Ref ref) {
+ final service = GeckoPushService();
+ service.setUp();
+
+ ref.onDispose(() {
+ unawaited(service.dispose());
+ });
+
+ return service;
+}
+
+/// Current distributor selection and availability.
+///
+/// Re-reads native state whenever the distributor acknowledges registration or
+/// is uninstalled, so a distributor removed while this screen is open does not
+/// leave a stale "configured" reading on screen.
+///
+/// The native event stream is subscribed to *before* the initial snapshot is
+/// requested: `statusChanges` does not replay, so a PENDING → READY transition
+/// landing between the two would otherwise be lost and leave the screen stale.
+/// If an event wins that race the snapshot is discarded rather than emitted
+/// after it, since the snapshot is by then the older value.
+@riverpod
+Stream pushStatus(Ref ref) {
+ final service = ref.watch(pushServiceProvider);
+
+ final controller = StreamController();
+ var sawEvent = false;
+
+ final subscription = service.statusChanges.listen(
+ (status) {
+ sawEvent = true;
+ if (!controller.isClosed) {
+ controller.add(status);
+ }
+ },
+ onError: (Object error, StackTrace stackTrace) {
+ if (!controller.isClosed) {
+ controller.addError(error, stackTrace);
+ }
+ },
+ );
+
+ unawaited(
+ service
+ .getPushStatus()
+ .then((status) {
+ if (!sawEvent && !controller.isClosed) {
+ controller.add(status);
+ }
+ })
+ .onError