custom pwa and shortcut support
This commit is contained in:
@@ -22,9 +22,10 @@ import 'package:fast_equatable/fast_equatable.dart';
|
|||||||
class ReceivedIntentParameter with FastEquatable {
|
class ReceivedIntentParameter with FastEquatable {
|
||||||
final String? content;
|
final String? content;
|
||||||
final String? tool;
|
final String? tool;
|
||||||
|
final String? contextId;
|
||||||
|
|
||||||
ReceivedIntentParameter(this.content, this.tool);
|
ReceivedIntentParameter(this.content, this.tool, {this.contextId});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get hashParameters => [content, tool];
|
List<Object?> get hashParameters => [content, tool, contextId];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ extension UriX on Uri {
|
|||||||
bool get isHttps => isScheme('https');
|
bool get isHttps => isScheme('https');
|
||||||
bool get isHttpOrHttps => isHttp || isHttps;
|
bool get isHttpOrHttps => isHttp || isHttps;
|
||||||
|
|
||||||
|
bool get isLocalhost => host == 'localhost' || host == '127.0.0.1';
|
||||||
|
|
||||||
/// Removes a bare root path (`/`) when there is no query or fragment, so
|
/// Removes a bare root path (`/`) when there is no query or fragment, so
|
||||||
/// that `https://example.com/` and `https://example.com` are treated as
|
/// that `https://example.com/` and `https://example.com` are treated as
|
||||||
/// equivalent.
|
/// equivalent.
|
||||||
|
|||||||
@@ -36,7 +36,10 @@ final _contentParserTransformer =
|
|||||||
StreamTransformer<ReceivedIntentParameter, SharedContent>.fromHandlers(
|
StreamTransformer<ReceivedIntentParameter, SharedContent>.fromHandlers(
|
||||||
handleData: (parameter, sink) {
|
handleData: (parameter, sink) {
|
||||||
final parsed = parameter.content.mapNotNull(
|
final parsed = parameter.content.mapNotNull(
|
||||||
(content) => SharedContent.parse(content),
|
(content) => SharedContent.parse(
|
||||||
|
content,
|
||||||
|
contextId: parameter.contextId,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (parsed != null) {
|
if (parsed != null) {
|
||||||
|
|||||||
+10
-2
@@ -681,8 +681,10 @@ class _AddToHomeScreenTile extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final isInstallable = ref.watch(isCurrentTabInstallableProvider);
|
final isInstallable = ref.watch(isCurrentTabInstallableProvider);
|
||||||
|
final isShortcutable = ref.watch(isCurrentTabShortcutableProvider);
|
||||||
|
|
||||||
if (!isInstallable) return const SizedBox.shrink();
|
// Show for installable PWAs or any HTTPS page
|
||||||
|
if (!isInstallable && !isShortcutable) return const SizedBox.shrink();
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -691,7 +693,13 @@ class _AddToHomeScreenTile extends ConsumerWidget {
|
|||||||
leading: const Icon(Icons.add_to_home_screen),
|
leading: const Icon(Icons.add_to_home_screen),
|
||||||
title: const Text('Add to Home Screen'),
|
title: const Text('Add to Home Screen'),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
await showPwaInstallDialog(context, ref);
|
if (isInstallable) {
|
||||||
|
// Site has valid manifest — use existing PWA install flow
|
||||||
|
await showPwaInstallDialog(context, ref);
|
||||||
|
} else {
|
||||||
|
// No manifest — show shortcut choice dialog
|
||||||
|
await showShortcutInstallDialog(context, ref);
|
||||||
|
}
|
||||||
if (context.mounted) Navigator.pop(context);
|
if (context.mounted) Navigator.pop(context);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
+27
@@ -32,6 +32,7 @@ import 'package:weblibre/core/providers/router.dart';
|
|||||||
import 'package:weblibre/core/routing/routes.dart';
|
import 'package:weblibre/core/routing/routes.dart';
|
||||||
import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
|
import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
|
||||||
import 'package:weblibre/features/bangs/domain/services/search_history_cleanup.dart';
|
import 'package:weblibre/features/bangs/domain/services/search_history_cleanup.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers.dart';
|
import 'package:weblibre/features/geckoview/domain/providers.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/browser_extension.dart';
|
import 'package:weblibre/features/geckoview/domain/providers/browser_extension.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart';
|
import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart';
|
||||||
@@ -355,6 +356,12 @@ class _BrowserViewState extends ConsumerState<BrowserView>
|
|||||||
final router = await ref.read(routerProvider.future);
|
final router = await ref.read(routerProvider.future);
|
||||||
final settings = ref.read(generalSettingsWithDefaultsProvider);
|
final settings = ref.read(generalSettingsWithDefaultsProvider);
|
||||||
|
|
||||||
|
// Resolve container from shortcut intent context ID
|
||||||
|
final containerSelection = await _resolveContainerSelection(
|
||||||
|
ref,
|
||||||
|
sharedContent.contextId,
|
||||||
|
);
|
||||||
|
|
||||||
switch (settings.tabIntentOpenSetting) {
|
switch (settings.tabIntentOpenSetting) {
|
||||||
case TabIntentOpenSetting.regular:
|
case TabIntentOpenSetting.regular:
|
||||||
case TabIntentOpenSetting.private:
|
case TabIntentOpenSetting.private:
|
||||||
@@ -371,6 +378,7 @@ class _BrowserViewState extends ConsumerState<BrowserView>
|
|||||||
: TabMode.regular,
|
: TabMode.regular,
|
||||||
launchedFromIntent: true,
|
launchedFromIntent: true,
|
||||||
selectTab: true,
|
selectTab: true,
|
||||||
|
containerSelection: containerSelection,
|
||||||
);
|
);
|
||||||
case SharedText():
|
case SharedText():
|
||||||
final bang =
|
final bang =
|
||||||
@@ -642,3 +650,22 @@ class _BrowserViewState extends ConsumerState<BrowserView>
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolves a [TabContainerSelection] from a shortcut intent's context ID.
|
||||||
|
/// Returns [TabContainerSelection.useSelected] if no contextId or container not found.
|
||||||
|
Future<TabContainerSelection> _resolveContainerSelection(
|
||||||
|
WidgetRef ref,
|
||||||
|
String? contextId,
|
||||||
|
) async {
|
||||||
|
if (contextId == null) return const TabContainerSelection.useSelected();
|
||||||
|
|
||||||
|
final container = await ref
|
||||||
|
.read(containerRepositoryProvider.notifier)
|
||||||
|
.getContainerByContextualIdentity(contextId);
|
||||||
|
|
||||||
|
if (container != null) {
|
||||||
|
return TabContainerSelection.specific(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
return const TabContainerSelection.useSelected();
|
||||||
|
}
|
||||||
|
|||||||
@@ -211,15 +211,20 @@ class TabMenu extends HookConsumerWidget {
|
|||||||
Consumer(
|
Consumer(
|
||||||
builder: (context, ref, child) {
|
builder: (context, ref, child) {
|
||||||
final isInstallable = ref.watch(isCurrentTabInstallableProvider);
|
final isInstallable = ref.watch(isCurrentTabInstallableProvider);
|
||||||
|
final isShortcutable = ref.watch(isCurrentTabShortcutableProvider);
|
||||||
|
|
||||||
return Visibility(
|
return Visibility(
|
||||||
visible: isInstallable,
|
visible: isInstallable || isShortcutable,
|
||||||
child: MenuItemButton(
|
child: MenuItemButton(
|
||||||
closeOnActivate: false,
|
closeOnActivate: false,
|
||||||
leadingIcon: const Icon(Icons.add_to_home_screen),
|
leadingIcon: const Icon(Icons.add_to_home_screen),
|
||||||
child: const Text('Add to Home Screen'),
|
child: const Text('Add to Home Screen'),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await showPwaInstallDialog(context, ref);
|
if (isInstallable) {
|
||||||
|
await showPwaInstallDialog(context, ref);
|
||||||
|
} else {
|
||||||
|
await showShortcutInstallDialog(context, ref);
|
||||||
|
}
|
||||||
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
MenuController.maybeOf(context)?.close();
|
MenuController.maybeOf(context)?.close();
|
||||||
|
|||||||
@@ -24,8 +24,10 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
|||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import 'package:weblibre/core/filesystem.dart' show filesystem;
|
import 'package:weblibre/core/filesystem.dart' show filesystem;
|
||||||
import 'package:weblibre/core/logger.dart';
|
import 'package:weblibre/core/logger.dart';
|
||||||
|
import 'package:weblibre/extensions/uri.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers.dart';
|
import 'package:weblibre/features/geckoview/domain/providers.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
|
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/pwa/domain/pwa_installability.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/providers/selected_container.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
|
||||||
@@ -121,3 +123,46 @@ Future<bool> installCurrentWebApp(Ref ref) async {
|
|||||||
Future<List<PwaManifest>> installedWebApps(Ref ref) {
|
Future<List<PwaManifest>> installedWebApps(Ref ref) {
|
||||||
return GeckoPwaApi().getInstalledWebApps();
|
return GeckoPwaApi().getInstalledWebApps();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the current tab is on an HTTPS page (eligible for home screen shortcut).
|
||||||
|
@Riverpod()
|
||||||
|
bool isCurrentTabShortcutable(Ref ref) {
|
||||||
|
final selectedTabId = ref.watch(selectedTabProvider);
|
||||||
|
if (selectedTabId == null) return false;
|
||||||
|
|
||||||
|
final tabState = ref.watch(tabStateProvider(selectedTabId));
|
||||||
|
if (tabState == null) return false;
|
||||||
|
|
||||||
|
return tabState.url.isHttps ||
|
||||||
|
(tabState.url.isHttp && tabState.url.isLocalhost);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a basic bookmark shortcut on the home screen for the current tab.
|
||||||
|
@Riverpod()
|
||||||
|
Future<bool> installBasicShortcut(Ref ref, {String? overrideName}) 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().installBasicShortcut(
|
||||||
|
selectedTabId,
|
||||||
|
profileUuid,
|
||||||
|
contextId,
|
||||||
|
overrideName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -296,3 +296,129 @@ final class InstalledWebAppsProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$installedWebAppsHash() => r'ff185620ccd25bf6b71415e34e3e0c0f20d5e59d';
|
String _$installedWebAppsHash() => r'ff185620ccd25bf6b71415e34e3e0c0f20d5e59d';
|
||||||
|
|
||||||
|
/// Whether the current tab is on an HTTPS page (eligible for home screen shortcut).
|
||||||
|
|
||||||
|
@ProviderFor(isCurrentTabShortcutable)
|
||||||
|
final isCurrentTabShortcutableProvider = IsCurrentTabShortcutableProvider._();
|
||||||
|
|
||||||
|
/// Whether the current tab is on an HTTPS page (eligible for home screen shortcut).
|
||||||
|
|
||||||
|
final class IsCurrentTabShortcutableProvider
|
||||||
|
extends $FunctionalProvider<bool, bool, bool>
|
||||||
|
with $Provider<bool> {
|
||||||
|
/// Whether the current tab is on an HTTPS page (eligible for home screen shortcut).
|
||||||
|
IsCurrentTabShortcutableProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'isCurrentTabShortcutableProvider',
|
||||||
|
isAutoDispose: true,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$isCurrentTabShortcutableHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
$ProviderElement<bool> $createElement($ProviderPointer pointer) =>
|
||||||
|
$ProviderElement(pointer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool create(Ref ref) {
|
||||||
|
return isCurrentTabShortcutable(ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(bool value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<bool>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$isCurrentTabShortcutableHash() =>
|
||||||
|
r'a18fa837facc92397dacb79f38bdefd303ff5d00';
|
||||||
|
|
||||||
|
/// Creates a basic bookmark shortcut on the home screen for the current tab.
|
||||||
|
|
||||||
|
@ProviderFor(installBasicShortcut)
|
||||||
|
final installBasicShortcutProvider = InstallBasicShortcutFamily._();
|
||||||
|
|
||||||
|
/// Creates a basic bookmark shortcut on the home screen for the current tab.
|
||||||
|
|
||||||
|
final class InstallBasicShortcutProvider
|
||||||
|
extends $FunctionalProvider<AsyncValue<bool>, bool, FutureOr<bool>>
|
||||||
|
with $FutureModifier<bool>, $FutureProvider<bool> {
|
||||||
|
/// Creates a basic bookmark shortcut on the home screen for the current tab.
|
||||||
|
InstallBasicShortcutProvider._({
|
||||||
|
required InstallBasicShortcutFamily super.from,
|
||||||
|
required String? super.argument,
|
||||||
|
}) : super(
|
||||||
|
retry: null,
|
||||||
|
name: r'installBasicShortcutProvider',
|
||||||
|
isAutoDispose: true,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$installBasicShortcutHash();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return r'installBasicShortcutProvider'
|
||||||
|
''
|
||||||
|
'($argument)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
$FutureProviderElement<bool> $createElement($ProviderPointer pointer) =>
|
||||||
|
$FutureProviderElement(pointer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<bool> create(Ref ref) {
|
||||||
|
final argument = this.argument as String?;
|
||||||
|
return installBasicShortcut(ref, overrideName: argument);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return other is InstallBasicShortcutProvider && other.argument == argument;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return argument.hashCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$installBasicShortcutHash() =>
|
||||||
|
r'aa8a96e94eac19e3e0b2a087bc6dde12d28f48f2';
|
||||||
|
|
||||||
|
/// Creates a basic bookmark shortcut on the home screen for the current tab.
|
||||||
|
|
||||||
|
final class InstallBasicShortcutFamily extends $Family
|
||||||
|
with $FunctionalFamilyOverride<FutureOr<bool>, String?> {
|
||||||
|
InstallBasicShortcutFamily._()
|
||||||
|
: super(
|
||||||
|
retry: null,
|
||||||
|
name: r'installBasicShortcutProvider',
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
isAutoDispose: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Creates a basic bookmark shortcut on the home screen for the current tab.
|
||||||
|
|
||||||
|
InstallBasicShortcutProvider call({String? overrideName}) =>
|
||||||
|
InstallBasicShortcutProvider._(argument: overrideName, from: this);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => r'installBasicShortcutProvider';
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||||
|
import 'package:nullability/nullability.dart';
|
||||||
|
import 'package:weblibre/extensions/uri.dart';
|
||||||
|
|
||||||
/// Display modes that are valid for installable PWAs per W3C spec.
|
/// Display modes that are valid for installable PWAs per W3C spec.
|
||||||
const _validDisplayModes = {
|
const _validDisplayModes = {
|
||||||
@@ -40,11 +42,16 @@ const _validDisplayModes = {
|
|||||||
/// 3. start_url is within the scope
|
/// 3. start_url is within the scope
|
||||||
/// 4. prefer_related_applications is not true
|
/// 4. prefer_related_applications is not true
|
||||||
bool isManifestInstallable(PwaManifest manifest) {
|
bool isManifestInstallable(PwaManifest manifest) {
|
||||||
|
final currentUrl = Uri.tryParse(manifest.currentUrl);
|
||||||
|
final startUrl = Uri.tryParse(manifest.startUrl);
|
||||||
|
|
||||||
|
if (currentUrl == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Check HTTPS requirement (relaxed for localhost)
|
// Check HTTPS requirement (relaxed for localhost)
|
||||||
final isSecure =
|
final isSecure =
|
||||||
manifest.currentUrl.startsWith('https://') ||
|
currentUrl.isHttps || (currentUrl.isHttp && currentUrl.isLocalhost);
|
||||||
manifest.currentUrl.startsWith('http://localhost') ||
|
|
||||||
manifest.currentUrl.startsWith('http://127.0.0.1');
|
|
||||||
|
|
||||||
if (!isSecure) {
|
if (!isSecure) {
|
||||||
return false;
|
return false;
|
||||||
@@ -62,24 +69,23 @@ bool isManifestInstallable(PwaManifest manifest) {
|
|||||||
|
|
||||||
// W3C §1.10.6: start_url must be same-origin as the document URL
|
// W3C §1.10.6: start_url must be same-origin as the document URL
|
||||||
final hasValidStartUrl =
|
final hasValidStartUrl =
|
||||||
manifest.startUrl.isNotEmpty &&
|
startUrl != null && _isSameOrigin(startUrl, currentUrl);
|
||||||
_isSameOrigin(manifest.startUrl, manifest.currentUrl);
|
|
||||||
|
|
||||||
final hasValidDisplay =
|
final hasValidDisplay =
|
||||||
manifest.display != null &&
|
manifest.display != null &&
|
||||||
_validDisplayModes.contains(manifest.display!.toLowerCase());
|
_validDisplayModes.contains(manifest.display!.toLowerCase());
|
||||||
|
|
||||||
// Check that start_url is within scope
|
// Check that start_url is within scope
|
||||||
final isInScope = _isStartUrlInScope(manifest.startUrl, manifest.scope);
|
final isInScope =
|
||||||
|
hasValidStartUrl &&
|
||||||
|
_isStartUrlInScope(startUrl, manifest.scope.mapNotNull(Uri.tryParse));
|
||||||
|
|
||||||
return hasValidName && hasValidStartUrl && hasValidDisplay && isInScope;
|
return hasValidName && hasValidStartUrl && hasValidDisplay && isInScope;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true if two URLs share the same origin (scheme + host + port).
|
/// Returns true if two URLs share the same origin (scheme + host + port).
|
||||||
bool _isSameOrigin(String url1, String url2) {
|
bool _isSameOrigin(Uri uri1, Uri uri2) {
|
||||||
try {
|
try {
|
||||||
final uri1 = Uri.parse(url1);
|
|
||||||
final uri2 = Uri.parse(url2);
|
|
||||||
return uri1.scheme == uri2.scheme &&
|
return uri1.scheme == uri2.scheme &&
|
||||||
uri1.host == uri2.host &&
|
uri1.host == uri2.host &&
|
||||||
uri1.port == uri2.port;
|
uri1.port == uri2.port;
|
||||||
@@ -93,18 +99,14 @@ bool _isSameOrigin(String url1, String url2) {
|
|||||||
/// Per W3C spec: when scope is absent, the default scope is the start_url
|
/// Per W3C spec: when scope is absent, the default scope is the start_url
|
||||||
/// with its last path segment, query, and fragment removed.
|
/// with its last path segment, query, and fragment removed.
|
||||||
/// Scope matching uses path-prefix comparison on `/` boundaries.
|
/// Scope matching uses path-prefix comparison on `/` boundaries.
|
||||||
bool _isStartUrlInScope(String startUrl, String? scope) {
|
bool _isStartUrlInScope(Uri startUri, Uri? scopeUri) {
|
||||||
try {
|
try {
|
||||||
final startUri = Uri.parse(startUrl);
|
if (scopeUri == null) {
|
||||||
|
|
||||||
if (scope == null || scope.isEmpty) {
|
|
||||||
// Per W3C: default scope = start_url with filename/query/fragment removed
|
// Per W3C: default scope = start_url with filename/query/fragment removed
|
||||||
// The start_url is trivially within its own default scope.
|
// The start_url is trivially within its own default scope.
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
final scopeUri = Uri.parse(scope);
|
|
||||||
|
|
||||||
// Must be same origin
|
// Must be same origin
|
||||||
if (startUri.scheme != scopeUri.scheme ||
|
if (startUri.scheme != scopeUri.scheme ||
|
||||||
startUri.host != scopeUri.host ||
|
startUri.host != scopeUri.host ||
|
||||||
|
|||||||
+132
-17
@@ -18,26 +18,141 @@
|
|||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
|
||||||
|
import 'package:weblibre/presentation/widgets/url_icon.dart';
|
||||||
|
|
||||||
/// Shows a dialog asking the user to confirm adding a PWA to the home screen.
|
/// The type of home screen shortcut the user chose.
|
||||||
|
enum ShortcutInstallType { shortcut, app }
|
||||||
|
|
||||||
|
/// Shows a bottom sheet to confirm adding a PWA to the home screen.
|
||||||
///
|
///
|
||||||
/// Returns true if the user confirms, false if cancelled.
|
/// Returns true if the user confirms, null if dismissed.
|
||||||
Future<bool?> showPwaInstallConfirmDialog(BuildContext context, String name) {
|
Future<bool?> showPwaInstallBottomSheet(
|
||||||
return showDialog<bool>(
|
BuildContext context, {
|
||||||
|
required String name,
|
||||||
|
required Uri url,
|
||||||
|
}) {
|
||||||
|
return showModalBottomSheet<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
shape: const RoundedRectangleBorder(
|
||||||
title: const Text('Add to Home Screen'),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
content: Text('Add "$name" to your home screen?'),
|
),
|
||||||
actions: [
|
builder: (context) => SafeArea(
|
||||||
TextButton(
|
child: Column(
|
||||||
onPressed: () => Navigator.of(context).pop(false),
|
mainAxisSize: MainAxisSize.min,
|
||||||
child: const Text('Cancel'),
|
children: [
|
||||||
),
|
_ShortcutSheetHeader(name: name, url: url),
|
||||||
FilledButton(
|
const Divider(height: 1),
|
||||||
onPressed: () => Navigator.of(context).pop(true),
|
ListTile(
|
||||||
child: const Text('Add'),
|
leading: const Icon(Icons.install_mobile),
|
||||||
),
|
title: const Text('Install as App'),
|
||||||
],
|
subtitle: const Text('Runs standalone with its own window.'),
|
||||||
|
onTap: () => Navigator.of(context).pop(true),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shows a bottom sheet for non-manifest sites offering a choice between
|
||||||
|
/// "Add Shortcut" (opens in browser) and "Install as App" (standalone mode).
|
||||||
|
///
|
||||||
|
/// [showAppOption] controls whether the "Install as App" option is visible
|
||||||
|
/// (requires the allowNonManifestPwaInstall setting to be enabled).
|
||||||
|
///
|
||||||
|
/// Returns [ShortcutInstallType] or null if dismissed.
|
||||||
|
Future<ShortcutInstallType?> showShortcutChoiceBottomSheet(
|
||||||
|
BuildContext context, {
|
||||||
|
required String name,
|
||||||
|
required Uri url,
|
||||||
|
required bool showAppOption,
|
||||||
|
}) {
|
||||||
|
return showModalBottomSheet<ShortcutInstallType>(
|
||||||
|
context: context,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
|
),
|
||||||
|
builder: (context) => SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_ShortcutSheetHeader(name: name, url: url),
|
||||||
|
const Divider(height: 1),
|
||||||
|
if (showAppOption)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.install_mobile),
|
||||||
|
title: const Text('Install as App'),
|
||||||
|
subtitle: const Text('Runs standalone with its own window.'),
|
||||||
|
onTap: () =>
|
||||||
|
Navigator.of(context).pop(ShortcutInstallType.app),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.shortcut),
|
||||||
|
title: const Text('Add Shortcut'),
|
||||||
|
subtitle: const Text('Opens as a standard tab in the browser.'),
|
||||||
|
onTap: () =>
|
||||||
|
Navigator.of(context).pop(ShortcutInstallType.shortcut),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ShortcutSheetHeader extends StatelessWidget {
|
||||||
|
final String name;
|
||||||
|
final Uri url;
|
||||||
|
|
||||||
|
const _ShortcutSheetHeader({required this.name, required this.url});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Add to Home Screen', style: textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
RepaintBoundary(child: UrlIcon([url], iconSize: 32)),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
UriBreadcrumb(
|
||||||
|
uri: url,
|
||||||
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+70
-1
@@ -21,15 +21,28 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:weblibre/core/logger.dart';
|
import 'package:weblibre/core/logger.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/pwa/domain/providers.dart';
|
import 'package:weblibre/features/geckoview/features/pwa/domain/providers.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/pwa/presentation/dialogs/pwa_install_dialog.dart';
|
import 'package:weblibre/features/geckoview/features/pwa/presentation/dialogs/pwa_install_dialog.dart';
|
||||||
|
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||||
import 'package:weblibre/utils/ui_helper.dart';
|
import 'package:weblibre/utils/ui_helper.dart';
|
||||||
|
|
||||||
|
/// Shows install bottom sheet for sites with a valid PWA manifest (existing flow).
|
||||||
Future<void> showPwaInstallDialog(BuildContext context, WidgetRef ref) async {
|
Future<void> showPwaInstallDialog(BuildContext context, WidgetRef ref) async {
|
||||||
|
final selectedTabId = ref.read(selectedTabProvider);
|
||||||
final manifest = ref.read(currentTabManifestProvider);
|
final manifest = ref.read(currentTabManifestProvider);
|
||||||
final name = manifest?.name ?? manifest?.shortName ?? 'this web app';
|
final name = manifest?.name ?? manifest?.shortName ?? 'this web app';
|
||||||
|
final tabState = selectedTabId != null
|
||||||
|
? ref.read(tabStateProvider(selectedTabId))
|
||||||
|
: null;
|
||||||
|
final url = tabState?.url ?? Uri.parse('about:blank');
|
||||||
|
|
||||||
final confirmed = await showPwaInstallConfirmDialog(context, name);
|
final confirmed = await showPwaInstallBottomSheet(
|
||||||
|
context,
|
||||||
|
name: name,
|
||||||
|
url: url,
|
||||||
|
);
|
||||||
|
|
||||||
if (confirmed == true) {
|
if (confirmed == true) {
|
||||||
try {
|
try {
|
||||||
@@ -60,3 +73,59 @@ Future<void> showPwaInstallDialog(BuildContext context, WidgetRef ref) async {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shows choice dialog for sites without a manifest.
|
||||||
|
/// Offers "Add as Shortcut" (always) and "Add as App" (if setting enabled).
|
||||||
|
Future<void> showShortcutInstallDialog(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
) async {
|
||||||
|
final selectedTabId = ref.read(selectedTabProvider);
|
||||||
|
if (selectedTabId == null) return;
|
||||||
|
|
||||||
|
final tabState = ref.read(tabStateProvider(selectedTabId));
|
||||||
|
final name = tabState?.title ?? 'this site';
|
||||||
|
final url = tabState?.url ?? Uri.parse('about:blank');
|
||||||
|
|
||||||
|
final settings = ref.read(generalSettingsWithDefaultsProvider);
|
||||||
|
final showAppOption = settings.allowNonManifestPwaInstall;
|
||||||
|
|
||||||
|
final choice = await showShortcutChoiceBottomSheet(
|
||||||
|
context,
|
||||||
|
name: name,
|
||||||
|
url: url,
|
||||||
|
showAppOption: showAppOption,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (choice == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final bool success;
|
||||||
|
switch (choice) {
|
||||||
|
case ShortcutInstallType.shortcut:
|
||||||
|
success = await ref.read(installBasicShortcutProvider().future);
|
||||||
|
case ShortcutInstallType.app:
|
||||||
|
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 to home screen');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
logger.e('Failed to create shortcut', 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -196,4 +196,12 @@ class ContainerDao extends DatabaseAccessor<TabDatabase>
|
|||||||
Selectable<String?> containersToClearOnExit() {
|
Selectable<String?> containersToClearOnExit() {
|
||||||
return db.definitionsDrift.containersToClearOnExit();
|
return db.definitionsDrift.containersToClearOnExit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SingleOrNullSelectable<ContainerData> getContainerByContextualIdentity(
|
||||||
|
String contextId,
|
||||||
|
) {
|
||||||
|
return db.definitionsDrift.containerByContextualIdentity(
|
||||||
|
contextId: contextId,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -335,6 +335,11 @@ allAssignedSites WITH SiteAssignment:
|
|||||||
CROSS JOIN json_each(container.metadata, '$.assignedSites')
|
CROSS JOIN json_each(container.metadata, '$.assignedSites')
|
||||||
WHERE value IS NOT NULL;
|
WHERE value IS NOT NULL;
|
||||||
|
|
||||||
|
containerByContextualIdentity:
|
||||||
|
SELECT * FROM container
|
||||||
|
WHERE container.metadata ->> '$.contextualIdentity' = :contextId
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
containersToClearOnExit:
|
containersToClearOnExit:
|
||||||
SELECT container.metadata ->> '$.contextualIdentity' AS contextual_identity
|
SELECT container.metadata ->> '$.contextualIdentity' AS contextual_identity
|
||||||
FROM container
|
FROM container
|
||||||
|
|||||||
@@ -2601,6 +2601,16 @@ class DefinitionsDrift extends i9.ModularAccessor {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
i0.Selectable<i1.ContainerData> containerByContextualIdentity({
|
||||||
|
required String contextId,
|
||||||
|
}) {
|
||||||
|
return customSelect(
|
||||||
|
'SELECT * FROM container WHERE container.metadata ->> \'\$.contextualIdentity\' = ?1 LIMIT 1',
|
||||||
|
variables: [i0.Variable<String>(contextId)],
|
||||||
|
readsFrom: {container},
|
||||||
|
).asyncMap(container.mapFromRow);
|
||||||
|
}
|
||||||
|
|
||||||
i0.Selectable<String?> containersToClearOnExit() {
|
i0.Selectable<String?> containersToClearOnExit() {
|
||||||
return customSelect(
|
return customSelect(
|
||||||
'SELECT container.metadata ->> \'\$.contextualIdentity\' AS contextual_identity FROM container WHERE json_extract(container.metadata, \'\$.clearDataOnExit\') = 1 AND container.metadata ->> \'\$.contextualIdentity\' IS NOT NULL',
|
'SELECT container.metadata ->> \'\$.contextualIdentity\' AS contextual_identity FROM container WHERE json_extract(container.metadata, \'\$.clearDataOnExit\') = 1 AND container.metadata ->> \'\$.contextualIdentity\' IS NOT NULL',
|
||||||
|
|||||||
@@ -77,6 +77,14 @@ class ContainerRepository extends _$ContainerRepository {
|
|||||||
.getSingleOrNull();
|
.getSingleOrNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<ContainerData?> getContainerByContextualIdentity(String contextId) {
|
||||||
|
return ref
|
||||||
|
.read(tabDatabaseProvider)
|
||||||
|
.containerDao
|
||||||
|
.getContainerByContextualIdentity(contextId)
|
||||||
|
.getSingleOrNull();
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<String>> getContainerTabIds(String? id) {
|
Future<List<String>> getContainerTabIds(String? id) {
|
||||||
return ref
|
return ref
|
||||||
.read(tabDatabaseProvider)
|
.read(tabDatabaseProvider)
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ final class ContainerRepositoryProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$containerRepositoryHash() =>
|
String _$containerRepositoryHash() =>
|
||||||
r'5e46b6d9d3510aeeb70646ede75d71c2db9efb0f';
|
r'57ee339087a845e54dc79224e25fa0da19568a68';
|
||||||
|
|
||||||
abstract class _$ContainerRepository extends $Notifier<void> {
|
abstract class _$ContainerRepository extends $Notifier<void> {
|
||||||
void build();
|
void build();
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class BrowsingSettingsScreen extends StatelessWidget {
|
|||||||
children: const [
|
children: const [
|
||||||
_TabsSection(),
|
_TabsSection(),
|
||||||
_NavigationSection(),
|
_NavigationSection(),
|
||||||
|
_HomeScreenSection(),
|
||||||
_ExternalLinksSection(),
|
_ExternalLinksSection(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -665,6 +666,49 @@ class _DoubleBackCloseTabTile extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _HomeScreenSection extends StatelessWidget {
|
||||||
|
const _HomeScreenSection();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return const Column(
|
||||||
|
children: [
|
||||||
|
SettingSection(name: 'Home Screen'),
|
||||||
|
_AllowNonManifestPwaInstallTile(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AllowNonManifestPwaInstallTile extends HookConsumerWidget {
|
||||||
|
const _AllowNonManifestPwaInstallTile();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final allowNonManifestPwaInstall = ref.watch(
|
||||||
|
generalSettingsWithDefaultsProvider
|
||||||
|
.select((s) => s.allowNonManifestPwaInstall),
|
||||||
|
);
|
||||||
|
|
||||||
|
return SwitchListTile.adaptive(
|
||||||
|
title: const Text('Install Sites as Apps'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Allow installing websites without a PWA manifest as standalone apps',
|
||||||
|
),
|
||||||
|
secondary: const Icon(Icons.add_to_home_screen),
|
||||||
|
value: allowNonManifestPwaInstall,
|
||||||
|
onChanged: (value) async {
|
||||||
|
await ref
|
||||||
|
.read(saveGeneralSettingsControllerProvider.notifier)
|
||||||
|
.save(
|
||||||
|
(currentSettings) =>
|
||||||
|
currentSettings.copyWith.allowNonManifestPwaInstall(value),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _UrlCleanerSettingsTile extends StatelessWidget {
|
class _UrlCleanerSettingsTile extends StatelessWidget {
|
||||||
const _UrlCleanerSettingsTile();
|
const _UrlCleanerSettingsTile();
|
||||||
|
|
||||||
|
|||||||
@@ -21,37 +21,42 @@ import 'package:fast_equatable/fast_equatable.dart';
|
|||||||
import 'package:weblibre/utils/input_classification.dart';
|
import 'package:weblibre/utils/input_classification.dart';
|
||||||
|
|
||||||
sealed class SharedContent with FastEquatable {
|
sealed class SharedContent with FastEquatable {
|
||||||
SharedContent();
|
final String? contextId;
|
||||||
|
|
||||||
factory SharedContent.parse(String content) {
|
SharedContent({this.contextId});
|
||||||
|
|
||||||
|
factory SharedContent.parse(String content, {String? contextId}) {
|
||||||
if (parseSharedIntentUrl(content) case final Uri uri) {
|
if (parseSharedIntentUrl(content) case final Uri uri) {
|
||||||
return SharedUrl(uri);
|
return SharedUrl(uri, contextId: contextId);
|
||||||
} else {
|
} else {
|
||||||
return SharedText(content);
|
return SharedText(content, contextId: contextId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get hashParameters => [contextId];
|
||||||
}
|
}
|
||||||
|
|
||||||
final class SharedUrl extends SharedContent {
|
final class SharedUrl extends SharedContent {
|
||||||
final Uri url;
|
final Uri url;
|
||||||
|
|
||||||
SharedUrl(this.url);
|
SharedUrl(this.url, {super.contextId});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => url.toString();
|
String toString() => url.toString();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get hashParameters => [url];
|
List<Object?> get hashParameters => [url, ...super.hashParameters];
|
||||||
}
|
}
|
||||||
|
|
||||||
final class SharedText extends SharedContent {
|
final class SharedText extends SharedContent {
|
||||||
final String text;
|
final String text;
|
||||||
|
|
||||||
SharedText(this.text);
|
SharedText(this.text, {super.contextId});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => text;
|
String toString() => text;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get hashParameters => [text];
|
List<Object?> get hashParameters => [text, ...super.hashParameters];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ final _sharingIntentTransformer =
|
|||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Extract container context from shortcut intents
|
||||||
|
final contextId =
|
||||||
|
intent.action == 'android.intent.action.VIEW'
|
||||||
|
? intent.extra['pwa_context_id'] as String?
|
||||||
|
: null;
|
||||||
|
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
if (uri_to_file.isUriSupported(data)) {
|
if (uri_to_file.isUriSupported(data)) {
|
||||||
var path = data;
|
var path = data;
|
||||||
@@ -70,17 +76,23 @@ final _sharingIntentTransformer =
|
|||||||
final mimeType = mime.lookupMimeType(file.path);
|
final mimeType = mime.lookupMimeType(file.path);
|
||||||
switch (mimeType) {
|
switch (mimeType) {
|
||||||
case 'application/pdf':
|
case 'application/pdf':
|
||||||
sink.add(ReceivedIntentParameter(path, null));
|
sink.add(
|
||||||
|
ReceivedIntentParameter(path, null, contextId: contextId),
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
logger.w('Unhandled mime type: $mimeType');
|
logger.w('Unhandled mime type: $mimeType');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.e('Failed to convert URI to file: $e');
|
logger.e('Failed to convert URI to file: $e');
|
||||||
// Fallback: pass the original URI
|
// Fallback: pass the original URI
|
||||||
sink.add(ReceivedIntentParameter(data, null));
|
sink.add(
|
||||||
|
ReceivedIntentParameter(data, null, contextId: contextId),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
sink.add(ReceivedIntentParameter(data, null));
|
sink.add(
|
||||||
|
ReceivedIntentParameter(data, null, contextId: contextId),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ class GeneralSettings with FastEquatable {
|
|||||||
final bool tabBarLongPressUrlCopy;
|
final bool tabBarLongPressUrlCopy;
|
||||||
final bool unshortenerEnabled;
|
final bool unshortenerEnabled;
|
||||||
final String unshortenerToken;
|
final String unshortenerToken;
|
||||||
|
final bool allowNonManifestPwaInstall;
|
||||||
|
|
||||||
GeneralSettings({
|
GeneralSettings({
|
||||||
required this.themeMode,
|
required this.themeMode,
|
||||||
@@ -168,6 +169,7 @@ class GeneralSettings with FastEquatable {
|
|||||||
required this.tabBarLongPressUrlCopy,
|
required this.tabBarLongPressUrlCopy,
|
||||||
required this.unshortenerEnabled,
|
required this.unshortenerEnabled,
|
||||||
required this.unshortenerToken,
|
required this.unshortenerToken,
|
||||||
|
required this.allowNonManifestPwaInstall,
|
||||||
});
|
});
|
||||||
|
|
||||||
GeneralSettings.withDefaults({
|
GeneralSettings.withDefaults({
|
||||||
@@ -219,6 +221,7 @@ class GeneralSettings with FastEquatable {
|
|||||||
bool? tabBarLongPressUrlCopy,
|
bool? tabBarLongPressUrlCopy,
|
||||||
bool? unshortenerEnabled,
|
bool? unshortenerEnabled,
|
||||||
String? unshortenerToken,
|
String? unshortenerToken,
|
||||||
|
bool? allowNonManifestPwaInstall,
|
||||||
}) : themeMode = themeMode ?? ThemeMode.dark,
|
}) : themeMode = themeMode ?? ThemeMode.dark,
|
||||||
uiScaleFactor = uiScaleFactor ?? defaultUiScaleFactor,
|
uiScaleFactor = uiScaleFactor ?? defaultUiScaleFactor,
|
||||||
disableAnimations = disableAnimations ?? false,
|
disableAnimations = disableAnimations ?? false,
|
||||||
@@ -276,7 +279,8 @@ class GeneralSettings with FastEquatable {
|
|||||||
smallWebTabType = smallWebTabType ?? TabType.private,
|
smallWebTabType = smallWebTabType ?? TabType.private,
|
||||||
tabBarLongPressUrlCopy = tabBarLongPressUrlCopy ?? true,
|
tabBarLongPressUrlCopy = tabBarLongPressUrlCopy ?? true,
|
||||||
unshortenerEnabled = unshortenerEnabled ?? false,
|
unshortenerEnabled = unshortenerEnabled ?? false,
|
||||||
unshortenerToken = unshortenerToken ?? '';
|
unshortenerToken = unshortenerToken ?? '',
|
||||||
|
allowNonManifestPwaInstall = allowNonManifestPwaInstall ?? false;
|
||||||
|
|
||||||
factory GeneralSettings.fromJson(Map<String, dynamic> json) =>
|
factory GeneralSettings.fromJson(Map<String, dynamic> json) =>
|
||||||
_$GeneralSettingsFromJson(json);
|
_$GeneralSettingsFromJson(json);
|
||||||
@@ -348,5 +352,6 @@ class GeneralSettings with FastEquatable {
|
|||||||
tabBarLongPressUrlCopy,
|
tabBarLongPressUrlCopy,
|
||||||
unshortenerEnabled,
|
unshortenerEnabled,
|
||||||
unshortenerToken,
|
unshortenerToken,
|
||||||
|
allowNonManifestPwaInstall,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,8 @@ abstract class _$GeneralSettingsCWProxy {
|
|||||||
|
|
||||||
GeneralSettings unshortenerToken(String unshortenerToken);
|
GeneralSettings unshortenerToken(String unshortenerToken);
|
||||||
|
|
||||||
|
GeneralSettings allowNonManifestPwaInstall(bool allowNonManifestPwaInstall);
|
||||||
|
|
||||||
/// Creates a new instance with the provided field values.
|
/// Creates a new instance with the provided field values.
|
||||||
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GeneralSettings(...).copyWith.fieldName(value)`.
|
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GeneralSettings(...).copyWith.fieldName(value)`.
|
||||||
///
|
///
|
||||||
@@ -177,6 +179,7 @@ abstract class _$GeneralSettingsCWProxy {
|
|||||||
bool tabBarLongPressUrlCopy,
|
bool tabBarLongPressUrlCopy,
|
||||||
bool unshortenerEnabled,
|
bool unshortenerEnabled,
|
||||||
String unshortenerToken,
|
String unshortenerToken,
|
||||||
|
bool allowNonManifestPwaInstall,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,6 +394,10 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
|
|||||||
GeneralSettings unshortenerToken(String unshortenerToken) =>
|
GeneralSettings unshortenerToken(String unshortenerToken) =>
|
||||||
call(unshortenerToken: unshortenerToken);
|
call(unshortenerToken: unshortenerToken);
|
||||||
|
|
||||||
|
@override
|
||||||
|
GeneralSettings allowNonManifestPwaInstall(bool allowNonManifestPwaInstall) =>
|
||||||
|
call(allowNonManifestPwaInstall: allowNonManifestPwaInstall);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
/// Creates a new instance with the provided field values.
|
/// Creates a new instance with the provided field values.
|
||||||
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GeneralSettings(...).copyWith.fieldName(value)`.
|
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GeneralSettings(...).copyWith.fieldName(value)`.
|
||||||
@@ -449,6 +456,7 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
|
|||||||
Object? tabBarLongPressUrlCopy = const $CopyWithPlaceholder(),
|
Object? tabBarLongPressUrlCopy = const $CopyWithPlaceholder(),
|
||||||
Object? unshortenerEnabled = const $CopyWithPlaceholder(),
|
Object? unshortenerEnabled = const $CopyWithPlaceholder(),
|
||||||
Object? unshortenerToken = const $CopyWithPlaceholder(),
|
Object? unshortenerToken = const $CopyWithPlaceholder(),
|
||||||
|
Object? allowNonManifestPwaInstall = const $CopyWithPlaceholder(),
|
||||||
}) {
|
}) {
|
||||||
return GeneralSettings(
|
return GeneralSettings(
|
||||||
themeMode: themeMode == const $CopyWithPlaceholder() || themeMode == null
|
themeMode: themeMode == const $CopyWithPlaceholder() || themeMode == null
|
||||||
@@ -733,6 +741,12 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
|
|||||||
? _value.unshortenerToken
|
? _value.unshortenerToken
|
||||||
// ignore: cast_nullable_to_non_nullable
|
// ignore: cast_nullable_to_non_nullable
|
||||||
: unshortenerToken as String,
|
: unshortenerToken as String,
|
||||||
|
allowNonManifestPwaInstall:
|
||||||
|
allowNonManifestPwaInstall == const $CopyWithPlaceholder() ||
|
||||||
|
allowNonManifestPwaInstall == null
|
||||||
|
? _value.allowNonManifestPwaInstall
|
||||||
|
// ignore: cast_nullable_to_non_nullable
|
||||||
|
: allowNonManifestPwaInstall as bool,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -843,6 +857,7 @@ GeneralSettings _$GeneralSettingsFromJson(
|
|||||||
tabBarLongPressUrlCopy: json['tabBarLongPressUrlCopy'] as bool?,
|
tabBarLongPressUrlCopy: json['tabBarLongPressUrlCopy'] as bool?,
|
||||||
unshortenerEnabled: json['unshortenerEnabled'] as bool?,
|
unshortenerEnabled: json['unshortenerEnabled'] as bool?,
|
||||||
unshortenerToken: json['unshortenerToken'] as String?,
|
unshortenerToken: json['unshortenerToken'] as String?,
|
||||||
|
allowNonManifestPwaInstall: json['allowNonManifestPwaInstall'] as bool?,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$GeneralSettingsToJson(
|
Map<String, dynamic> _$GeneralSettingsToJson(
|
||||||
@@ -907,6 +922,7 @@ Map<String, dynamic> _$GeneralSettingsToJson(
|
|||||||
'tabBarLongPressUrlCopy': instance.tabBarLongPressUrlCopy,
|
'tabBarLongPressUrlCopy': instance.tabBarLongPressUrlCopy,
|
||||||
'unshortenerEnabled': instance.unshortenerEnabled,
|
'unshortenerEnabled': instance.unshortenerEnabled,
|
||||||
'unshortenerToken': instance.unshortenerToken,
|
'unshortenerToken': instance.unshortenerToken,
|
||||||
|
'allowNonManifestPwaInstall': instance.allowNonManifestPwaInstall,
|
||||||
};
|
};
|
||||||
|
|
||||||
const _$ThemeModeEnumMap = {
|
const _$ThemeModeEnumMap = {
|
||||||
|
|||||||
@@ -229,6 +229,8 @@ class GeneralSettingsRepository extends _$GeneralSettingsRepository {
|
|||||||
DriftSqlType.string,
|
DriftSqlType.string,
|
||||||
db.typeMapping,
|
db.typeMapping,
|
||||||
),
|
),
|
||||||
|
'allowNonManifestPwaInstall': settings['allowNonManifestPwaInstall']
|
||||||
|
?.readAs(DriftSqlType.bool, db.typeMapping),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ final class GeneralSettingsRepositoryProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$generalSettingsRepositoryHash() =>
|
String _$generalSettingsRepositoryHash() =>
|
||||||
r'ae071b136a14d1f907635579f134608023d81766';
|
r'afc63f4d929ea146f0b8a7c0f6936b06c5a41024';
|
||||||
|
|
||||||
abstract class _$GeneralSettingsRepository
|
abstract class _$GeneralSettingsRepository
|
||||||
extends $StreamNotifier<GeneralSettings> {
|
extends $StreamNotifier<GeneralSettings> {
|
||||||
|
|||||||
+1
-1
@@ -309,7 +309,7 @@ class ExternalAppBrowserFragment : BaseBrowserFragment(), UserInteractionHandler
|
|||||||
|
|
||||||
val manifest = webAppManifestUrl?.ifEmpty { null }?.let { url ->
|
val manifest = webAppManifestUrl?.ifEmpty { null }?.let { url ->
|
||||||
components.core.webAppManifestStorage.getManifestCache(url)
|
components.core.webAppManifestStorage.getManifestCache(url)
|
||||||
}
|
} ?: customTab.content.webAppManifest
|
||||||
|
|
||||||
windowFeature.set(
|
windowFeature.set(
|
||||||
feature = CustomTabWindowFeature(activity, store, sessionId),
|
feature = CustomTabWindowFeature(activity, store, sessionId),
|
||||||
|
|||||||
+5
@@ -15,6 +15,11 @@ object PwaConstants {
|
|||||||
const val EXTRA_PWA_CONTEXT_ID = "pwa_context_id"
|
const val EXTRA_PWA_CONTEXT_ID = "pwa_context_id"
|
||||||
const val EXTRA_PWA_TOKEN = "pwa_token"
|
const val EXTRA_PWA_TOKEN = "pwa_token"
|
||||||
const val EXTRA_PWA_INSTALL_START_URL = "pwa_install_start_url"
|
const val EXTRA_PWA_INSTALL_START_URL = "pwa_install_start_url"
|
||||||
|
const val EXTRA_SHORTCUT_TYPE = "shortcut_type"
|
||||||
|
|
||||||
|
// Shortcut type values
|
||||||
|
const val SHORTCUT_TYPE_BASIC = "basic"
|
||||||
|
const val SHORTCUT_TYPE_PWA = "pwa"
|
||||||
|
|
||||||
// Profile and file paths
|
// Profile and file paths
|
||||||
const val CURRENT_PROFILE_FILE = "weblibre_profiles/current_profile"
|
const val CURRENT_PROFILE_FILE = "weblibre_profiles/current_profile"
|
||||||
|
|||||||
+43
-9
@@ -89,8 +89,16 @@ class IntentReceiverActivity : Activity() {
|
|||||||
val profileUuid = intent.getStringExtra(PwaConstants.EXTRA_PWA_PROFILE_UUID)
|
val profileUuid = intent.getStringExtra(PwaConstants.EXTRA_PWA_PROFILE_UUID)
|
||||||
val contextId = intent.getStringExtra(PwaConstants.EXTRA_PWA_CONTEXT_ID)
|
val contextId = intent.getStringExtra(PwaConstants.EXTRA_PWA_CONTEXT_ID)
|
||||||
val token = intent.getStringExtra(PwaConstants.EXTRA_PWA_TOKEN)
|
val token = intent.getStringExtra(PwaConstants.EXTRA_PWA_TOKEN)
|
||||||
|
val shortcutType = intent.getStringExtra(PwaConstants.EXTRA_SHORTCUT_TYPE)
|
||||||
if (profileUuid != null) {
|
if (profileUuid != null) {
|
||||||
if (isTrustedPwaLaunch(intent, profileUuid, token)) {
|
if (isTrustedPwaLaunch(intent, profileUuid, token)) {
|
||||||
|
// Basic shortcuts open in the regular browser, not as standalone PWA
|
||||||
|
if (shortcutType == PwaConstants.SHORTCUT_TYPE_BASIC) {
|
||||||
|
Log.d(TAG, "Trusted basic shortcut, routing to regular browser: ${intent.dataString}")
|
||||||
|
handleBasicShortcutIntent(intent, profileUuid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
Log.d(TAG, "Trusted PWA intent with profile metadata: profileUuid=$profileUuid, contextId=$contextId")
|
Log.d(TAG, "Trusted PWA intent with profile metadata: profileUuid=$profileUuid, contextId=$contextId")
|
||||||
handlePwaIntent(intent, profileUuid, contextId)
|
handlePwaIntent(intent, profileUuid, contextId)
|
||||||
return
|
return
|
||||||
@@ -273,7 +281,11 @@ class IntentReceiverActivity : Activity() {
|
|||||||
|
|
||||||
if (currentProfileUuid != null && currentProfileUuid != profileUuid) {
|
if (currentProfileUuid != null && currentProfileUuid != profileUuid) {
|
||||||
Log.d(TAG, "Profile mismatch: current=$currentProfileUuid, expected=$profileUuid")
|
Log.d(TAG, "Profile mismatch: current=$currentProfileUuid, expected=$profileUuid")
|
||||||
showProfileMismatchDialog(url, contextId)
|
showProfileMismatchDialog(
|
||||||
|
intent = intent,
|
||||||
|
onProceed = { launchPwaWithContext(url, contextId) },
|
||||||
|
isPwa = true,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
Log.d(TAG, "Profile match or indeterminate, launching PWA with contextId=$contextId")
|
Log.d(TAG, "Profile match or indeterminate, launching PWA with contextId=$contextId")
|
||||||
launchPwaWithContext(url, contextId)
|
launchPwaWithContext(url, contextId)
|
||||||
@@ -300,26 +312,48 @@ class IntentReceiverActivity : Activity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows a dialog when the current profile doesn't match the PWA's installation profile.
|
* Handles basic shortcut intents with profile validation.
|
||||||
|
* Checks profile match and shows dialog if different, then forwards to regular browser.
|
||||||
|
*/
|
||||||
|
private fun handleBasicShortcutIntent(intent: Intent, profileUuid: String) {
|
||||||
|
val currentProfileUuid = getCurrentProfileUuid()
|
||||||
|
|
||||||
|
if (currentProfileUuid != null && currentProfileUuid != profileUuid) {
|
||||||
|
Log.d(TAG, "Basic shortcut profile mismatch: current=$currentProfileUuid, expected=$profileUuid")
|
||||||
|
showProfileMismatchDialog(
|
||||||
|
intent = intent,
|
||||||
|
onProceed = { handleRegularIntent(it) },
|
||||||
|
isPwa = false,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "Basic shortcut profile match or indeterminate, routing to browser")
|
||||||
|
handleRegularIntent(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a dialog when the current profile doesn't match the shortcut's installation profile.
|
||||||
*/
|
*/
|
||||||
private fun showProfileMismatchDialog(
|
private fun showProfileMismatchDialog(
|
||||||
url: String,
|
intent: Intent,
|
||||||
contextId: String?,
|
onProceed: (Intent) -> Unit,
|
||||||
|
isPwa: Boolean,
|
||||||
) {
|
) {
|
||||||
val message = "This PWA was originally installed in a different profile. " +
|
val typeLabel = if (isPwa) "PWA" else "shortcut"
|
||||||
|
val message = "This $typeLabel was originally installed in a different profile. " +
|
||||||
"Opening it here uses only your current profile's data and settings. " +
|
"Opening it here uses only your current profile's data and settings. " +
|
||||||
"The original profile's app state and saved data will not be used.\n\n" +
|
"The original profile's app state and saved data will not be used.\n\n" +
|
||||||
"Do you want to proceed anyway?"
|
"Do you want to proceed anyway?"
|
||||||
|
|
||||||
AlertDialog.Builder(this)
|
AlertDialog.Builder(this)
|
||||||
.setTitle("PWA Profile Mismatch")
|
.setTitle("Profile Mismatch")
|
||||||
.setMessage(message)
|
.setMessage(message)
|
||||||
.setPositiveButton("Open in Current Profile") { _, _ ->
|
.setPositiveButton("Open in Current Profile") { _, _ ->
|
||||||
Log.d(TAG, "User chose to open PWA despite profile mismatch")
|
Log.d(TAG, "User chose to open $typeLabel despite profile mismatch")
|
||||||
launchPwaWithContext(url, contextId)
|
onProceed(intent)
|
||||||
}
|
}
|
||||||
.setNegativeButton("Cancel") { _, _ ->
|
.setNegativeButton("Cancel") { _, _ ->
|
||||||
Log.d(TAG, "User cancelled PWA launch due to profile mismatch")
|
Log.d(TAG, "User cancelled $typeLabel launch due to profile mismatch")
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
.setOnCancelListener {
|
.setOnCancelListener {
|
||||||
|
|||||||
+186
-5
@@ -86,11 +86,17 @@ class GeckoPwaApiImpl(
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
val manifest = tab.content.webAppManifest
|
val manifest = tab.content.webAppManifest ?: run {
|
||||||
if (manifest == null) {
|
// Generate a synthetic manifest for sites without one
|
||||||
logger.warn("No manifest found for tab ${tab.id}")
|
val url = tab.content.url
|
||||||
callback(Result.success(false))
|
val title = tab.content.title.ifBlank { url }
|
||||||
return@launch
|
logger.debug("Generating synthetic manifest for tab ${tab.id}: $url")
|
||||||
|
WebAppManifest(
|
||||||
|
name = title,
|
||||||
|
startUrl = url,
|
||||||
|
display = WebAppManifest.DisplayMode.STANDALONE,
|
||||||
|
scope = extractScope(url),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug("Installing web app for tab ${tab.id}: ${manifest.startUrl}")
|
logger.debug("Installing web app for tab ${tab.id}: ${manifest.startUrl}")
|
||||||
@@ -161,6 +167,7 @@ class GeckoPwaApiImpl(
|
|||||||
putExtra(PwaConstants.EXTRA_PWA_CONTEXT_ID, contextId)
|
putExtra(PwaConstants.EXTRA_PWA_CONTEXT_ID, contextId)
|
||||||
putExtra(PwaConstants.EXTRA_PWA_TOKEN, launchToken)
|
putExtra(PwaConstants.EXTRA_PWA_TOKEN, launchToken)
|
||||||
putExtra(PwaConstants.EXTRA_PWA_INSTALL_START_URL, manifest.startUrl)
|
putExtra(PwaConstants.EXTRA_PWA_INSTALL_START_URL, manifest.startUrl)
|
||||||
|
putExtra(PwaConstants.EXTRA_SHORTCUT_TYPE, PwaConstants.SHORTCUT_TYPE_PWA)
|
||||||
}
|
}
|
||||||
|
|
||||||
val shortcut = ShortcutInfo.Builder(context, shortcutId).apply {
|
val shortcut = ShortcutInfo.Builder(context, shortcutId).apply {
|
||||||
@@ -179,6 +186,11 @@ class GeckoPwaApiImpl(
|
|||||||
}
|
}
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
|
// Update existing shortcut intent if one exists with the same ID
|
||||||
|
// (e.g. upgrading a basic shortcut to PWA). requestPinShortcut alone
|
||||||
|
// may reuse the cached intent on some launchers.
|
||||||
|
updateExistingShortcut(shortcutManager, shortcut)
|
||||||
|
|
||||||
val success = shortcutManager.requestPinShortcut(shortcut, null)
|
val success = shortcutManager.requestPinShortcut(shortcut, null)
|
||||||
logger.debug("PWA shortcut creation result: $success")
|
logger.debug("PWA shortcut creation result: $success")
|
||||||
success
|
success
|
||||||
@@ -188,6 +200,23 @@ class GeckoPwaApiImpl(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates an existing pinned/cached shortcut's intent and metadata.
|
||||||
|
* This is necessary because requestPinShortcut may reuse the old cached intent
|
||||||
|
* on some launchers instead of the new ShortcutInfo's intent.
|
||||||
|
*/
|
||||||
|
private fun updateExistingShortcut(shortcutManager: ShortcutManager, shortcut: ShortcutInfo) {
|
||||||
|
try {
|
||||||
|
val existingIds = shortcutManager.pinnedShortcuts.map { it.id }.toSet()
|
||||||
|
if (shortcut.id in existingIds) {
|
||||||
|
shortcutManager.updateShortcuts(listOf(shortcut))
|
||||||
|
logger.debug("Updated existing pinned shortcut: ${shortcut.id}")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.debug("Could not update existing shortcut (may not exist): ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a collision-resistant shortcut ID from URL + profile using SHA-256.
|
* Generates a collision-resistant shortcut ID from URL + profile using SHA-256.
|
||||||
*/
|
*/
|
||||||
@@ -239,6 +268,158 @@ class GeckoPwaApiImpl(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun installBasicShortcut(
|
||||||
|
tabId: String?,
|
||||||
|
profileUuid: String,
|
||||||
|
contextId: String?,
|
||||||
|
overrideShortcutName: String?,
|
||||||
|
callback: (Result<Boolean>) -> Unit
|
||||||
|
) {
|
||||||
|
logger.debug("installBasicShortcut called for tabId: $tabId, profileUuid: $profileUuid")
|
||||||
|
coroutineScope.launch {
|
||||||
|
try {
|
||||||
|
val store = components.core.store
|
||||||
|
val tab = if (tabId != null) {
|
||||||
|
store.state.findTab(tabId)
|
||||||
|
} else {
|
||||||
|
store.state.selectedTab
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tab == null) {
|
||||||
|
logger.warn("Tab not found for installBasicShortcut: $tabId")
|
||||||
|
callback(Result.success(false))
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val success = createBasicShortcut(
|
||||||
|
url = tab.content.url,
|
||||||
|
title = overrideShortcutName ?: tab.content.title,
|
||||||
|
tabIcon = tab.content.icon,
|
||||||
|
profileUuid = profileUuid,
|
||||||
|
contextId = contextId,
|
||||||
|
)
|
||||||
|
|
||||||
|
callback(Result.success(success))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("Failed to create basic shortcut", e)
|
||||||
|
callback(Result.failure(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a basic bookmark-style shortcut that opens in a regular browser tab.
|
||||||
|
* Does not require or store a manifest.
|
||||||
|
*/
|
||||||
|
private suspend fun createBasicShortcut(
|
||||||
|
url: String,
|
||||||
|
title: String,
|
||||||
|
tabIcon: Bitmap?,
|
||||||
|
profileUuid: String,
|
||||||
|
contextId: String?,
|
||||||
|
): Boolean = withContext(Dispatchers.Main) {
|
||||||
|
try {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||||
|
logger.warn("Pinned shortcuts require Android O or later")
|
||||||
|
return@withContext false
|
||||||
|
}
|
||||||
|
|
||||||
|
val shortcutManager = context.getSystemService<ShortcutManager>()
|
||||||
|
?: run {
|
||||||
|
logger.error("ShortcutManager not available")
|
||||||
|
return@withContext false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shortcutManager.isRequestPinShortcutSupported) {
|
||||||
|
logger.warn("Pinning shortcuts is not supported")
|
||||||
|
return@withContext false
|
||||||
|
}
|
||||||
|
|
||||||
|
val shortcutId = generateShortcutId(url, profileUuid)
|
||||||
|
val launchToken = resolveLaunchToken(
|
||||||
|
shortcutManager = shortcutManager,
|
||||||
|
shortcutId = shortcutId,
|
||||||
|
startUrl = url,
|
||||||
|
profileUuid = profileUuid,
|
||||||
|
)
|
||||||
|
|
||||||
|
val shortLabel = title.ifBlank { url }
|
||||||
|
|
||||||
|
val shortcutIntent = Intent(context, IntentReceiverActivity::class.java).apply {
|
||||||
|
action = Intent.ACTION_VIEW
|
||||||
|
data = Uri.parse(url)
|
||||||
|
putExtra(PwaConstants.EXTRA_PWA_PROFILE_UUID, profileUuid)
|
||||||
|
putExtra(PwaConstants.EXTRA_PWA_CONTEXT_ID, contextId)
|
||||||
|
putExtra(PwaConstants.EXTRA_PWA_TOKEN, launchToken)
|
||||||
|
putExtra(PwaConstants.EXTRA_PWA_INSTALL_START_URL, url)
|
||||||
|
putExtra(PwaConstants.EXTRA_SHORTCUT_TYPE, PwaConstants.SHORTCUT_TYPE_BASIC)
|
||||||
|
}
|
||||||
|
|
||||||
|
val icon = loadTabIcon(url, tabIcon)
|
||||||
|
|
||||||
|
val shortcut = ShortcutInfo.Builder(context, shortcutId).apply {
|
||||||
|
setShortLabel(shortLabel)
|
||||||
|
setLongLabel(shortLabel)
|
||||||
|
setIntent(shortcutIntent)
|
||||||
|
icon?.let { setIcon(it) }
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
// Update existing shortcut intent if one exists with the same ID
|
||||||
|
updateExistingShortcut(shortcutManager, shortcut)
|
||||||
|
|
||||||
|
val success = shortcutManager.requestPinShortcut(shortcut, null)
|
||||||
|
logger.debug("Basic shortcut creation result: $success")
|
||||||
|
success
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("Failed to create basic shortcut", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads an icon for the shortcut from the tab's favicon or BrowserIcons.
|
||||||
|
*/
|
||||||
|
private suspend fun loadTabIcon(url: String, tabIcon: Bitmap?): Icon? = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
// Try using the tab's existing favicon first
|
||||||
|
val bitmap = tabIcon?.takeUnless { it.isRecycled }
|
||||||
|
?: run {
|
||||||
|
// Fall back to loading via BrowserIcons
|
||||||
|
val iconRequest = IconRequest(
|
||||||
|
url = url,
|
||||||
|
size = IconRequest.Size.LAUNCHER,
|
||||||
|
)
|
||||||
|
components.core.icons.loadIcon(iconRequest).await()?.bitmap
|
||||||
|
}
|
||||||
|
|
||||||
|
bitmap?.takeUnless { it.isRecycled }?.let {
|
||||||
|
val bitmapCopy = it.copy(it.config ?: Bitmap.Config.ARGB_8888, false)
|
||||||
|
Icon.createWithBitmap(bitmapCopy)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("Failed to load tab icon", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the scope from a URL (origin + path up to last segment).
|
||||||
|
*/
|
||||||
|
private fun extractScope(url: String): String {
|
||||||
|
return try {
|
||||||
|
val uri = Uri.parse(url)
|
||||||
|
val path = uri.path ?: "/"
|
||||||
|
val scopePath = if (path.contains("/")) {
|
||||||
|
path.substringBeforeLast("/") + "/"
|
||||||
|
} else {
|
||||||
|
"/"
|
||||||
|
}
|
||||||
|
uri.buildUpon().path(scopePath).clearQuery().fragment(null).build().toString()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun getInstalledWebApps(callback: (Result<List<PwaManifest>>) -> Unit) {
|
override fun getInstalledWebApps(callback: (Result<List<PwaManifest>>) -> Unit) {
|
||||||
logger.debug("getInstalledWebApps called")
|
logger.debug("getInstalledWebApps called")
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
|
|||||||
+37
@@ -8943,6 +8943,20 @@ interface GeckoPwaApi {
|
|||||||
fun installWebApp(tabId: String?, profileUuid: String, contextId: String?, callback: (Result<Boolean>) -> Unit)
|
fun installWebApp(tabId: String?, profileUuid: String, contextId: String?, callback: (Result<Boolean>) -> Unit)
|
||||||
/** Returns a list of all installed PWA manifests. */
|
/** Returns a list of all installed PWA manifests. */
|
||||||
fun getInstalledWebApps(callback: (Result<List<PwaManifest>>) -> Unit)
|
fun getInstalledWebApps(callback: (Result<List<PwaManifest>>) -> Unit)
|
||||||
|
/**
|
||||||
|
* Creates a basic bookmark shortcut on the home screen (no manifest required).
|
||||||
|
*
|
||||||
|
* Unlike [installWebApp], this creates a simple shortcut that opens
|
||||||
|
* in a regular browser tab rather than standalone PWA mode.
|
||||||
|
* Uses the page title and favicon for the shortcut.
|
||||||
|
*
|
||||||
|
* The [tabId] identifies which tab to create the shortcut for. If null, uses the selected tab.
|
||||||
|
* The [profileUuid] is the UUID of the current user profile.
|
||||||
|
* The [contextId] is the container's contextual identity (optional).
|
||||||
|
* The [overrideShortcutName] allows customizing the shortcut label.
|
||||||
|
* Returns true if the shortcut was created successfully.
|
||||||
|
*/
|
||||||
|
fun installBasicShortcut(tabId: String?, profileUuid: String, contextId: String?, overrideShortcutName: String?, callback: (Result<Boolean>) -> Unit)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** The codec used by GeckoPwaApi. */
|
/** The codec used by GeckoPwaApi. */
|
||||||
@@ -8993,6 +9007,29 @@ interface GeckoPwaApi {
|
|||||||
channel.setMessageHandler(null)
|
channel.setMessageHandler(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoPwaApi.installBasicShortcut$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { message, reply ->
|
||||||
|
val args = message as List<Any?>
|
||||||
|
val tabIdArg = args[0] as String?
|
||||||
|
val profileUuidArg = args[1] as String
|
||||||
|
val contextIdArg = args[2] as String?
|
||||||
|
val overrideShortcutNameArg = args[3] as String?
|
||||||
|
api.installBasicShortcut(tabIdArg, profileUuidArg, contextIdArg, overrideShortcutNameArg) { result: Result<Boolean> ->
|
||||||
|
val error = result.exceptionOrNull()
|
||||||
|
if (error != null) {
|
||||||
|
reply.reply(GeckoPigeonUtils.wrapError(error))
|
||||||
|
} else {
|
||||||
|
val data = result.getOrNull()
|
||||||
|
reply.reply(GeckoPigeonUtils.wrapResult(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10665,4 +10665,42 @@ class GeckoPwaApi {
|
|||||||
return (pigeonVar_replyList[0] as List<Object?>?)!.cast<PwaManifest>();
|
return (pigeonVar_replyList[0] as List<Object?>?)!.cast<PwaManifest>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Creates a basic bookmark shortcut on the home screen (no manifest required).
|
||||||
|
///
|
||||||
|
/// Unlike [installWebApp], this creates a simple shortcut that opens
|
||||||
|
/// in a regular browser tab rather than standalone PWA mode.
|
||||||
|
/// Uses the page title and favicon for the shortcut.
|
||||||
|
///
|
||||||
|
/// The [tabId] identifies which tab to create the shortcut for. If null, uses the selected tab.
|
||||||
|
/// The [profileUuid] is the UUID of the current user profile.
|
||||||
|
/// The [contextId] is the container's contextual identity (optional).
|
||||||
|
/// The [overrideShortcutName] allows customizing the shortcut label.
|
||||||
|
/// Returns true if the shortcut was created successfully.
|
||||||
|
Future<bool> installBasicShortcut(String? tabId, String profileUuid, String? contextId, String? overrideShortcutName) async {
|
||||||
|
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoPwaApi.installBasicShortcut$pigeonVar_messageChannelSuffix';
|
||||||
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
|
pigeonVar_channelName,
|
||||||
|
pigeonChannelCodec,
|
||||||
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
|
);
|
||||||
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[tabId, profileUuid, contextId, overrideShortcutName]);
|
||||||
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
if (pigeonVar_replyList == null) {
|
||||||
|
throw _createConnectionError(pigeonVar_channelName);
|
||||||
|
} else if (pigeonVar_replyList.length > 1) {
|
||||||
|
throw PlatformException(
|
||||||
|
code: pigeonVar_replyList[0]! as String,
|
||||||
|
message: pigeonVar_replyList[1] as String?,
|
||||||
|
details: pigeonVar_replyList[2],
|
||||||
|
);
|
||||||
|
} else if (pigeonVar_replyList[0] == null) {
|
||||||
|
throw PlatformException(
|
||||||
|
code: 'null-error',
|
||||||
|
message: 'Host platform returned null value for non-null return value.',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (pigeonVar_replyList[0] as bool?)!;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2473,4 +2473,18 @@ abstract class GeckoPwaApi {
|
|||||||
/// Returns a list of all installed PWA manifests.
|
/// Returns a list of all installed PWA manifests.
|
||||||
@async
|
@async
|
||||||
List<PwaManifest> getInstalledWebApps();
|
List<PwaManifest> getInstalledWebApps();
|
||||||
|
|
||||||
|
/// Creates a basic bookmark shortcut on the home screen (no manifest required).
|
||||||
|
///
|
||||||
|
/// Unlike [installWebApp], this creates a simple shortcut that opens
|
||||||
|
/// in a regular browser tab rather than standalone PWA mode.
|
||||||
|
/// Uses the page title and favicon for the shortcut.
|
||||||
|
///
|
||||||
|
/// The [tabId] identifies which tab to create the shortcut for. If null, uses the selected tab.
|
||||||
|
/// The [profileUuid] is the UUID of the current user profile.
|
||||||
|
/// The [contextId] is the container's contextual identity (optional).
|
||||||
|
/// The [overrideShortcutName] allows customizing the shortcut label.
|
||||||
|
/// Returns true if the shortcut was created successfully.
|
||||||
|
@async
|
||||||
|
bool installBasicShortcut(String? tabId, String profileUuid, String? contextId, String? overrideShortcutName);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user