initial pwa + custom tabs
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
+15
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user