pwa improvements: custom name and context selection

This commit is contained in:
Fabian Freund
2026-04-20 12:20:55 +02:00
parent 7af0f1d4e3
commit b699ef5fba
23 changed files with 1220 additions and 439 deletions
@@ -250,14 +250,25 @@ class TabTreeRoute extends GoRouteData with $TabTreeRoute {
class OpenSharedContentRoute extends GoRouteData with $OpenSharedContentRoute {
final String sharedUrl;
final String? contextId;
final String? containerMode;
const OpenSharedContentRoute({this.sharedUrl = 'about:blank'});
const OpenSharedContentRoute({
this.sharedUrl = 'about:blank',
this.contextId,
this.containerMode,
});
@override
Page<void> buildPage(BuildContext context, GoRouterState state) {
return BottomSheetPage(
builder: (_) => OpenSharedContent(
sharedUrl: Uri.tryParse(sharedUrl) ?? Uri.parse('about:blank'),
contextId: contextId,
containerMode: IntentContainerMode.fromWireValue(
containerMode,
contextId: contextId,
),
),
);
}
@@ -81,6 +81,7 @@ import 'package:weblibre/features/settings/presentation/screens/tracking_protect
import 'package:weblibre/features/settings/presentation/screens/web_content_settings.dart';
import 'package:weblibre/features/settings/presentation/screens/web_engine_hardening.dart';
import 'package:weblibre/features/settings/presentation/screens/web_engine_hardening_group.dart';
import 'package:weblibre/features/share_intent/domain/entities/intent_container_mode.dart';
import 'package:weblibre/features/sync/presentation/screens/sync_settings.dart';
import 'package:weblibre/features/tor/presentation/screens/country_picker.dart';
import 'package:weblibre/features/tor/presentation/screens/tor_proxy.dart';
@@ -860,6 +860,8 @@ mixin $OpenSharedContentRoute on GoRouteData {
static OpenSharedContentRoute _fromState(GoRouterState state) =>
OpenSharedContentRoute(
sharedUrl: state.uri.queryParameters['shared-url'] ?? 'about:blank',
contextId: state.uri.queryParameters['context-id'],
containerMode: state.uri.queryParameters['container-mode'],
);
OpenSharedContentRoute get _self => this as OpenSharedContentRoute;
@@ -869,6 +871,8 @@ mixin $OpenSharedContentRoute on GoRouteData {
'/browser/open_content',
queryParams: {
if (_self.sharedUrl != 'about:blank') 'shared-url': _self.sharedUrl,
if (_self.contextId != null) 'context-id': _self.contextId,
if (_self.containerMode != null) 'container-mode': _self.containerMode,
},
);
@@ -18,14 +18,21 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:fast_equatable/fast_equatable.dart';
import 'package:weblibre/features/share_intent/domain/entities/intent_container_mode.dart';
class ReceivedIntentParameter with FastEquatable {
final String? content;
final String? tool;
final String? contextId;
final IntentContainerMode containerMode;
ReceivedIntentParameter(this.content, this.tool, {this.contextId});
ReceivedIntentParameter(
this.content,
this.tool, {
this.contextId,
this.containerMode = IntentContainerMode.useSelected,
});
@override
List<Object?> get hashParameters => [content, tool, contextId];
List<Object?> get hashParameters => [content, tool, contextId, containerMode];
}
@@ -35,8 +35,11 @@ final _contentParserTransformer =
StreamTransformer<ReceivedIntentParameter, SharedContent>.fromHandlers(
handleData: (parameter, sink) {
final parsed = parameter.content.mapNotNull(
(content) =>
SharedContent.parse(content, contextId: parameter.contextId),
(content) => SharedContent.parse(
content,
contextId: parameter.contextId,
containerMode: parameter.containerMode,
),
);
if (parsed != null) {
@@ -58,6 +58,7 @@ import 'package:weblibre/features/intent_gatekeeper/domain/entities/pending_inte
import 'package:weblibre/features/intent_gatekeeper/domain/services/intent_gatekeeper.dart';
import 'package:weblibre/features/intent_gatekeeper/domain/services/native_gatekeeper_replicator.dart';
import 'package:weblibre/features/intent_gatekeeper/presentation/widgets/intent_gatekeeper_dialog.dart';
import 'package:weblibre/features/share_intent/domain/entities/intent_container_mode.dart';
import 'package:weblibre/features/share_intent/domain/entities/shared_content.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart';
import 'package:weblibre/features/user/domain/providers/profile_auth.dart';
@@ -403,6 +404,7 @@ class _BrowserViewState extends ConsumerState<BrowserView>
final containerSelection = await _resolveContainerSelection(
ref,
sharedContent.contextId,
sharedContent.containerMode,
);
await ref
@@ -443,6 +445,8 @@ class _BrowserViewState extends ConsumerState<BrowserView>
case SharedUrl():
final route = OpenSharedContentRoute(
sharedUrl: sharedContent.url.toString(),
contextId: sharedContent.contextId,
containerMode: sharedContent.containerMode.queryValueOrNull,
);
await router.push(route.location);
case SharedText():
@@ -704,13 +708,19 @@ class _BrowserViewState extends ConsumerState<BrowserView>
}
}
/// Resolves a [TabContainerSelection] from a shortcut intent's context ID.
/// Returns [TabContainerSelection.useSelected] if no contextId or container not found.
/// Resolves a [TabContainerSelection] from incoming launch container metadata.
Future<TabContainerSelection> _resolveContainerSelection(
WidgetRef ref,
String? contextId,
IntentContainerMode containerMode,
) async {
if (contextId == null) return const TabContainerSelection.useSelected();
if (contextId == null) {
return switch (containerMode) {
IntentContainerMode.unassigned =>
const TabContainerSelection.unassigned(),
_ => const TabContainerSelection.useSelected(),
};
}
final container = await ref
.read(containerRepositoryProvider.notifier)
@@ -720,5 +730,9 @@ Future<TabContainerSelection> _resolveContainerSelection(
return TabContainerSelection.specific(container);
}
return const TabContainerSelection.useSelected();
return switch (containerMode) {
IntentContainerMode.useSelected =>
const TabContainerSelection.useSelected(),
_ => const TabContainerSelection.unassigned(),
};
}
@@ -27,6 +27,7 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/design/app_colors.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/open_link_tools/domain/services/url_cleaner_catalog_service.dart';
@@ -35,22 +36,34 @@ import 'package:weblibre/features/geckoview/features/open_link_tools/presentatio
import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/hooks/url_cleaner_controller.dart';
import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/widgets/attribution_link.dart';
import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/widgets/url_cleaner_tile.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/animated_tab_type_switcher.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/isolation_context.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_chips.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/entities/container_selection_result.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/presentation/widgets/compact_container_selector.dart';
import 'package:weblibre/features/share_intent/domain/entities/intent_container_mode.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/presentation/hooks/cached_future.dart';
import 'package:weblibre/presentation/hooks/debouncer.dart';
import 'package:weblibre/presentation/icons/weblibre_icons.dart';
import 'package:weblibre/utils/form_validators.dart';
import 'package:weblibre/utils/ui_helper.dart';
class OpenSharedContent extends HookConsumerWidget {
final Uri sharedUrl;
final String? contextId;
final IntentContainerMode containerMode;
static final _appLinksService = GeckoAppLinksService();
const OpenSharedContent({super.key, required this.sharedUrl});
const OpenSharedContent({
super.key,
required this.sharedUrl,
this.contextId,
this.containerMode = IntentContainerMode.useSelected,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -58,7 +71,81 @@ class OpenSharedContent extends HookConsumerWidget {
final textController = useTextEditingController(text: sharedUrl.toString());
final appColors = AppColors.of(context);
final selectedContainer = useState<ContainerData?>(null);
final globalSelectedContainer = ref.watch(
selectedContainerDataProvider.select((value) => value.value),
);
final selectedContainer = useState<ContainerData?>(
containerMode == IntentContainerMode.useSelected
? globalSelectedContainer
: null,
);
final containerSelectionTouched = useRef(false);
final defaultTabType = ref.read(
generalSettingsWithDefaultsProvider.select(
(value) => value.effectiveDefaultCreateTabType,
),
);
// If the intent carried an isolated context, preselect the isolated tab
// type and preserve the existing context id so opening doesn't mint a
// fresh one.
final carriedIsolatedContextId = useMemoized(
() => isIsolatedContextId(contextId) ? contextId : null,
[contextId],
);
final selectedTabType = useState(
carriedIsolatedContextId != null ? TabType.isolated : defaultTabType,
);
useEffect(() {
if (!containerSelectionTouched.value &&
containerMode == IntentContainerMode.useSelected) {
selectedContainer.value = globalSelectedContainer;
}
return null;
}, [globalSelectedContainer, containerMode]);
useEffect(() {
if (containerMode != IntentContainerMode.specific ||
contextId == null ||
isIsolatedContextId(contextId)) {
if (!containerSelectionTouched.value &&
containerMode == IntentContainerMode.unassigned) {
selectedContainer.value = null;
}
return null;
}
var cancelled = false;
unawaited(
Future(() async {
final container = await ref
.read(containerRepositoryProvider.notifier)
.getContainerByContextualIdentity(contextId!);
if (cancelled ||
!context.mounted ||
containerSelectionTouched.value) {
return;
}
selectedContainer.value = container;
}),
);
return () {
cancelled = true;
};
}, [containerMode, contextId]);
TabMode resolveTabMode() {
if (selectedTabType.value == TabType.isolated &&
carriedIsolatedContextId != null) {
return TabMode.isolated(carriedIsolatedContextId);
}
return TabMode.fromTabType(selectedTabType.value);
}
final settings = ref.watch(generalSettingsWithDefaultsProvider);
final catalogAsync = ref.watch(urlCleanerCatalogServiceProvider);
@@ -151,7 +238,7 @@ class OpenSharedContent extends HookConsumerWidget {
}
}
Future<void> openCustomTab(bool isPrivate) async {
Future<void> openCustomTab(TabMode tabMode) async {
if (formKey.currentState?.validate() == true) {
final parsedUrl = parseValidatedUrl(
textController.text,
@@ -161,10 +248,14 @@ class OpenSharedContent extends HookConsumerWidget {
return;
}
final contextId = tabMode is IsolatedTabMode
? tabMode.isolationContextId
: selectedContainer.value?.metadata.contextualIdentity;
await GeckoBrowserService().openInCustomTab(
url: parsedUrl,
private: isPrivate,
contextId: selectedContainer.value?.id,
private: tabMode is PrivateTabMode,
contextId: contextId,
);
if (context.mounted) {
@@ -203,18 +294,7 @@ class OpenSharedContent extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Open link', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16),
if (settings.showContainerUi)
ContainerChips(
displayMenu: false,
selectedContainer: selectedContainer.value,
onSelected: (container) {
selectedContainer.value = container;
},
onDeleted: (container) {
selectedContainer.value = null;
},
),
const SizedBox(height: 8),
TextFormField(
controller: textController,
keyboardType: TextInputType.url,
@@ -337,64 +417,78 @@ class OpenSharedContent extends HookConsumerWidget {
icon: Icons.open_in_new,
onTap: openInApp,
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Builder(
builder: (context) {
final tabTypeSwitcher = AnimatedTabTypeSwitcher(
selected: selectedTabType.value,
onChanged: (value) => selectedTabType.value = value,
showIsolatedOption:
settings.showIsolatedTabUi ||
carriedIsolatedContextId != null,
selectedBackgroundColor: switch (selectedTabType.value) {
TabType.regular => null,
TabType.private => appColors.privateSelectionOverlay,
TabType.isolated => appColors.isolatedSelectionOverlay,
TabType.child => null,
},
);
if (!settings.showContainerUi) {
return Center(child: tabTypeSwitcher);
}
return Row(
children: [
Expanded(
flex: 4,
child: Align(
alignment: Alignment.centerLeft,
child: tabTypeSwitcher,
),
),
const SizedBox(width: 8),
Flexible(
flex: 2,
child: Align(
alignment: Alignment.centerRight,
child: CompactContainerSelector(
selectedContainer: selectedContainer.value,
onSelectionChanged: (selection) async {
containerSelectionTouched.value = true;
switch (selection) {
case ContainerSelectionSelected(
:final containerId,
):
selectedContainer.value = await ref
.read(
containerRepositoryProvider.notifier,
)
.getContainerData(containerId);
case ContainerSelectionUnassigned():
selectedContainer.value = null;
}
},
),
),
),
],
);
},
),
),
_OpenActionTile(
title: 'Open in new tab',
subtitle: 'Add to your browser tabs',
icon: MdiIcons.tab,
trailing: PopupMenuButton<TabMode>(
icon: const Icon(WebLibreIcons.tabType, size: 24),
tooltip: settings.showIsolatedTabUi
? 'Private / Isolated'
: 'Private',
onSelected: openTab,
itemBuilder: (context) => [
PopupMenuItem(
value: TabMode.private,
child: Row(
children: [
Icon(
MdiIcons.dominoMask,
color: appColors.privateTabPurple,
size: 20,
),
const SizedBox(width: 12),
const Text('Private'),
],
),
),
if (settings.showIsolatedTabUi)
PopupMenuItem(
value: TabMode.newIsolated(),
child: Row(
children: [
Icon(
MdiIcons.snowflake,
color: appColors.isolatedTabTeal,
size: 20,
),
const SizedBox(width: 12),
const Text('Isolated'),
],
),
),
],
),
onTap: () => openTab(TabMode.regular),
onTap: () => openTab(resolveTabMode()),
),
_OpenActionTile(
title: 'Open in custom tab',
subtitle: 'Open in a separate window',
icon: MdiIcons.applicationOutline,
trailing: IconButton(
icon: Icon(
MdiIcons.dominoMask,
color: appColors.privateTabPurple,
size: 24,
),
tooltip: 'Private',
onPressed: () => openCustomTab(true),
),
onTap: () => openCustomTab(false),
onTap: () => openCustomTab(resolveTabMode()),
),
],
),
@@ -491,7 +585,7 @@ class _OpenActionTile extends StatelessWidget {
final IconData icon;
final Widget? trailing;
final bool showTrailingDivider;
final VoidCallback onTap;
final VoidCallback? onTap;
const _OpenActionTile({
required this.title,
@@ -499,7 +593,7 @@ class _OpenActionTile extends StatelessWidget {
required this.icon,
this.trailing,
this.showTrailingDivider = true,
required this.onTap,
this.onTap,
});
@override
@@ -29,8 +29,6 @@ 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/tab_state.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';
@@ -95,7 +93,11 @@ bool isCurrentTabInstallable(Ref ref) {
/// 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 {
Future<bool> installCurrentWebApp(
Ref ref, {
String? overrideName,
String? contextId,
}) async {
final selectedTabId = ref.read(selectedTabProvider);
if (selectedTabId == null) {
@@ -104,18 +106,12 @@ Future<bool> installCurrentWebApp(Ref ref) async {
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);
return GeckoPwaApi().installWebApp(
selectedTabId,
profileUuid,
contextId,
overrideName,
);
}
/// Returns all installed PWAs.
@@ -139,7 +135,11 @@ bool isCurrentTabShortcutable(Ref ref) {
/// Creates a basic bookmark shortcut on the home screen for the current tab.
@Riverpod()
Future<bool> installBasicShortcut(Ref ref, {String? overrideName}) async {
Future<bool> installBasicShortcut(
Ref ref, {
String? overrideName,
String? contextId,
}) async {
final selectedTabId = ref.read(selectedTabProvider);
if (selectedTabId == null) {
@@ -148,17 +148,6 @@ Future<bool> installBasicShortcut(Ref ref, {String? overrideName}) async {
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,
@@ -213,7 +213,7 @@ String _$isCurrentTabInstallableHash() =>
/// in the shortcut intent so the PWA reopens with the same isolation.
@ProviderFor(installCurrentWebApp)
final installCurrentWebAppProvider = InstallCurrentWebAppProvider._();
final installCurrentWebAppProvider = InstallCurrentWebAppFamily._();
/// Installs the current tab as a PWA, embedding profile and container context
/// in the shortcut intent so the PWA reopens with the same isolation.
@@ -223,20 +223,27 @@ final class InstallCurrentWebAppProvider
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,
);
InstallCurrentWebAppProvider._({
required InstallCurrentWebAppFamily super.from,
required ({String? overrideName, String? contextId}) super.argument,
}) : super(
retry: null,
name: r'installCurrentWebAppProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$installCurrentWebAppHash();
@override
String toString() {
return r'installCurrentWebAppProvider'
''
'$argument';
}
@$internal
@override
$FutureProviderElement<bool> $createElement($ProviderPointer pointer) =>
@@ -244,12 +251,61 @@ final class InstallCurrentWebAppProvider
@override
FutureOr<bool> create(Ref ref) {
return installCurrentWebApp(ref);
final argument =
this.argument as ({String? overrideName, String? contextId});
return installCurrentWebApp(
ref,
overrideName: argument.overrideName,
contextId: argument.contextId,
);
}
@override
bool operator ==(Object other) {
return other is InstallCurrentWebAppProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$installCurrentWebAppHash() =>
r'da536e2aca886831e0bbbc5b70eae03ac4a4ea9f';
r'0e0013fac7b70441983f7af1e6db3c6470c23900';
/// 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 InstallCurrentWebAppFamily extends $Family
with
$FunctionalFamilyOverride<
FutureOr<bool>,
({String? overrideName, String? contextId})
> {
InstallCurrentWebAppFamily._()
: super(
retry: null,
name: r'installCurrentWebAppProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
/// 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 call({
String? overrideName,
String? contextId,
}) => InstallCurrentWebAppProvider._(
argument: (overrideName: overrideName, contextId: contextId),
from: this,
);
@override
String toString() => r'installCurrentWebAppProvider';
}
/// Returns all installed PWAs.
@@ -357,7 +413,7 @@ final class InstallBasicShortcutProvider
/// Creates a basic bookmark shortcut on the home screen for the current tab.
InstallBasicShortcutProvider._({
required InstallBasicShortcutFamily super.from,
required String? super.argument,
required ({String? overrideName, String? contextId}) super.argument,
}) : super(
retry: null,
name: r'installBasicShortcutProvider',
@@ -373,7 +429,7 @@ final class InstallBasicShortcutProvider
String toString() {
return r'installBasicShortcutProvider'
''
'($argument)';
'$argument';
}
@$internal
@@ -383,8 +439,13 @@ final class InstallBasicShortcutProvider
@override
FutureOr<bool> create(Ref ref) {
final argument = this.argument as String?;
return installBasicShortcut(ref, overrideName: argument);
final argument =
this.argument as ({String? overrideName, String? contextId});
return installBasicShortcut(
ref,
overrideName: argument.overrideName,
contextId: argument.contextId,
);
}
@override
@@ -399,12 +460,16 @@ final class InstallBasicShortcutProvider
}
String _$installBasicShortcutHash() =>
r'aa8a96e94eac19e3e0b2a087bc6dde12d28f48f2';
r'fcb5bec79f375b32a7a0953ad22859a0db222167';
/// Creates a basic bookmark shortcut on the home screen for the current tab.
final class InstallBasicShortcutFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<bool>, String?> {
with
$FunctionalFamilyOverride<
FutureOr<bool>,
({String? overrideName, String? contextId})
> {
InstallBasicShortcutFamily._()
: super(
retry: null,
@@ -416,8 +481,13 @@ final class InstallBasicShortcutFamily extends $Family
/// Creates a basic bookmark shortcut on the home screen for the current tab.
InstallBasicShortcutProvider call({String? overrideName}) =>
InstallBasicShortcutProvider._(argument: overrideName, from: this);
InstallBasicShortcutProvider call({
String? overrideName,
String? contextId,
}) => InstallBasicShortcutProvider._(
argument: (overrideName: overrideName, contextId: contextId),
from: this,
);
@override
String toString() => r'installBasicShortcutProvider';
@@ -18,41 +18,67 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/tabs/data/entities/isolation_context.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart';
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart';
/// 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, null if dismissed.
Future<bool?> showPwaInstallBottomSheet(
/// Result of the install configuration sheets.
class PwaInstallConfig {
final String name;
final String? contextId;
const PwaInstallConfig({required this.name, required this.contextId});
}
/// Result of the shortcut choice sheet (basic vs app + config).
class ShortcutInstallConfig {
final ShortcutInstallType type;
final String name;
final String? contextId;
const ShortcutInstallConfig({
required this.type,
required this.name,
required this.contextId,
});
}
/// Shows a bottom sheet to confirm installing a PWA with an editable name
/// and a storage (contextId) selection.
Future<PwaInstallConfig?> showPwaInstallBottomSheet(
BuildContext context, {
required String name,
required String defaultName,
required Uri url,
}) {
return showModalBottomSheet<bool>(
return showModalBottomSheet<ShortcutInstallConfig>(
context: context,
isScrollControlled: true,
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),
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(true),
),
],
builder: (context) => Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: _InstallConfigSheet(
defaultName: defaultName,
url: url,
showAppOption: true,
showShortcutOption: false,
),
),
);
).then((result) {
if (result == null) return null;
return PwaInstallConfig(name: result.name, contextId: result.contextId);
});
}
/// Shows a bottom sheet for non-manifest sites offering a choice between
@@ -60,99 +86,289 @@ Future<bool?> showPwaInstallBottomSheet(
///
/// [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(
Future<ShortcutInstallConfig?> showShortcutChoiceBottomSheet(
BuildContext context, {
required String name,
required String defaultName,
required Uri url,
required bool showAppOption,
}) {
return showModalBottomSheet<ShortcutInstallType>(
return showModalBottomSheet<ShortcutInstallConfig>(
context: context,
isScrollControlled: true,
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),
),
],
builder: (context) => Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: _InstallConfigSheet(
defaultName: defaultName,
url: url,
showAppOption: showAppOption,
showShortcutOption: true,
),
),
);
}
class _ShortcutSheetHeader extends StatelessWidget {
final String name;
final Uri url;
/// Internal storage options backing the radio selector.
sealed class _StorageOption {
const _StorageOption();
String? get contextId;
}
const _ShortcutSheetHeader({required this.name, required this.url});
class _StorageDefault extends _StorageOption {
const _StorageDefault();
@override
String? get contextId => null;
}
class _StorageContainer extends _StorageOption {
final String label;
@override
final String contextId;
const _StorageContainer({required this.label, required this.contextId});
}
class _StorageInheritIsolated extends _StorageOption {
@override
final String contextId;
const _StorageInheritIsolated(this.contextId);
}
class _StorageNewIsolated extends _StorageOption {
@override
final String contextId;
_StorageNewIsolated() : contextId = newIsolatedContextId();
}
class _InstallConfigSheet extends HookConsumerWidget {
final String defaultName;
final Uri url;
final bool showAppOption;
final bool showShortcutOption;
const _InstallConfigSheet({
required this.defaultName,
required this.url,
required this.showAppOption,
required this.showShortcutOption,
});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
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,
),
),
],
final nameController = useTextEditingController(text: defaultName);
final selectedTabId = ref.watch(selectedTabProvider);
final tabContextId = selectedTabId != null
? ref.watch(tabStateProvider(selectedTabId))?.contextId
: null;
final containerAsync = ref.watch(selectedContainerDataProvider);
final containerData = containerAsync.asData?.value;
final options = useMemoized<List<_StorageOption>>(
() {
final list = <_StorageOption>[const _StorageDefault()];
// If the current tab is in an isolated context, offer to inherit it.
if (isIsolatedContextId(tabContextId)) {
list.add(_StorageInheritIsolated(tabContextId!));
}
// If a regular (non-isolated) container is active, offer it.
final containerContextId = containerData?.metadata.contextualIdentity;
if (containerData != null &&
containerContextId != null &&
!isIsolatedContextId(containerContextId)) {
list.add(
_StorageContainer(
label: containerData.name ?? 'Container',
contextId: containerContextId,
),
);
}
list.add(_StorageNewIsolated());
return list;
},
[tabContextId, containerData?.id, containerData?.metadata.contextualIdentity],
);
// Pick sensible default selection based on current context.
final defaultIndex = useMemoized(() {
for (var i = 0; i < options.length; i++) {
final o = options[i];
if (o is _StorageInheritIsolated) return i;
if (o is _StorageContainer) return i;
}
return 0;
}, [options]);
final selectedIndex = useState(defaultIndex);
final userTouched = useRef(false);
// When options resolve async (e.g. selected container stream arrives
// after first build), re-sync the radio to the computed default — unless
// the user has already made a choice.
useEffect(() {
if (!userTouched.value) {
selectedIndex.value = defaultIndex;
}
return null;
}, [defaultIndex]);
void submit(ShortcutInstallType type) {
final name = nameController.text.trim();
Navigator.of(context).pop(
ShortcutInstallConfig(
type: type,
name: name.isEmpty ? defaultName : name,
contextId: options[selectedIndex.value].contextId,
),
);
}
return SafeArea(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
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: UriBreadcrumb(
uri: url,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
],
),
],
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: TextField(
controller: nameController,
decoration: const InputDecoration(
labelText: 'Name',
border: OutlineInputBorder(),
isDense: true,
),
textInputAction: TextInputAction.done,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Text(
'Storage',
style: textTheme.labelLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
RadioGroup<int>(
groupValue: selectedIndex.value,
onChanged: (v) {
if (v != null) {
userTouched.value = true;
selectedIndex.value = v;
}
},
child: Column(
children: [
for (var i = 0; i < options.length; i++)
_StorageTile(option: options[i], value: i),
],
),
),
const SizedBox(height: 8),
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: () => submit(ShortcutInstallType.app),
),
if (showShortcutOption)
ListTile(
leading: const Icon(Icons.shortcut),
title: const Text('Add Shortcut'),
subtitle: const Text(
'Opens as a standard tab in the browser.',
),
onTap: () => submit(ShortcutInstallType.shortcut),
),
const SizedBox(height: 8),
],
),
),
);
}
}
class _StorageTile extends StatelessWidget {
final _StorageOption option;
final int value;
const _StorageTile({required this.option, required this.value});
@override
Widget build(BuildContext context) {
final (title, subtitle, icon) = switch (option) {
_StorageDefault() => (
'Default',
'Uses the default browser storage (no container).',
Icons.public,
),
_StorageContainer(:final label) => (
'Container "$label"',
'Shares cookies and data with the selected container.',
Icons.folder_outlined,
),
_StorageInheritIsolated() => (
'Inherit current isolated context',
'Shares storage with the currently open isolated session.',
Icons.link,
),
_StorageNewIsolated() => (
'New isolated context',
'Creates a fresh storage jar just for this installation.',
Icons.shield_outlined,
),
};
return RadioListTile<int>(
value: value,
dense: true,
secondary: Icon(icon),
title: Text(title),
subtitle: Text(subtitle),
);
}
}
@@ -32,44 +32,52 @@ import 'package:weblibre/utils/ui_helper.dart';
Future<void> showPwaInstallDialog(BuildContext context, WidgetRef ref) async {
final selectedTabId = ref.read(selectedTabProvider);
final manifest = ref.read(currentTabManifestProvider);
final name = manifest?.name ?? manifest?.shortName ?? 'this web app';
final defaultName =
manifest?.shortName ?? manifest?.name ?? 'this web app';
final tabState = selectedTabId != null
? ref.read(tabStateProvider(selectedTabId))
: null;
final url = tabState?.url ?? Uri.parse('about:blank');
final confirmed = await showPwaInstallBottomSheet(
final config = await showPwaInstallBottomSheet(
context,
name: name,
defaultName: defaultName,
url: url,
);
if (confirmed == true) {
try {
final success = await ref.read(installCurrentWebAppProvider.future);
if (config == null) return;
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.',
);
}
final name = config.name;
try {
final success = await ref.read(
installCurrentWebAppProvider(
overrideName: name == defaultName ? null : name,
contextId: config.contextId,
).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);
}
} 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 (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);
if (e is StateError) {
errorMessage = 'No tab selected. Please try again.';
}
showErrorMessage(context, errorMessage);
}
}
}
@@ -84,28 +92,43 @@ Future<void> showShortcutInstallDialog(
if (selectedTabId == null) return;
final tabState = ref.read(tabStateProvider(selectedTabId));
final name = tabState?.title ?? 'this site';
final defaultName = tabState?.title.trim().isNotEmpty == true
? 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(
final config = await showShortcutChoiceBottomSheet(
context,
name: name,
defaultName: defaultName,
url: url,
showAppOption: showAppOption,
);
if (choice == null) return;
if (config == null) return;
final name = config.name;
final overrideName = name == defaultName ? null : name;
try {
final bool success;
switch (choice) {
switch (config.type) {
case ShortcutInstallType.shortcut:
success = await ref.read(installBasicShortcutProvider().future);
success = await ref.read(
installBasicShortcutProvider(
overrideName: overrideName,
contextId: config.contextId,
).future,
);
case ShortcutInstallType.app:
success = await ref.read(installCurrentWebAppProvider.future);
success = await ref.read(
installCurrentWebAppProvider(
overrideName: overrideName,
contextId: config.contextId,
).future,
);
}
if (context.mounted) {
@@ -51,21 +51,26 @@ class SelectedContainer extends _$SelectedContainer {
return null;
}
Future<SetContainerResult> setContainerId(String id) async {
Future<SetContainerResult> setContainerId(
String id, {
bool Function()? shouldApply,
}) async {
final container = await ref
.read(containerRepositoryProvider.notifier)
.getContainerData(id);
if (ref.mounted && container != null) {
bool canApply() => shouldApply?.call() ?? true;
if (ref.mounted && container != null && canApply()) {
if (container.metadata.useProxy) {
final proxyPluginHealthy = await GeckoContainerProxyService()
.healthcheck();
if (proxyPluginHealthy) {
if (ref.mounted && proxyPluginHealthy && canApply()) {
state = id;
return SetContainerResult.successHasProxy;
}
} else {
} else if (canApply()) {
state = id;
return SetContainerResult.success;
}
@@ -41,7 +41,7 @@ final class SelectedContainerProvider
}
}
String _$selectedContainerHash() => r'3d30966f0b8a8ee091afb48fb045be2274f4f417';
String _$selectedContainerHash() => r'1f0828ce1d3f8b88fd2731a7aae2602a7323092c';
abstract class _$SelectedContainer extends $Notifier<String?> {
String? build();
@@ -36,8 +36,14 @@ import 'package:weblibre/features/user/domain/repositories/general_settings.dart
/// Long pressing opens the edit screen for the selected container.
class CompactContainerSelector extends ConsumerWidget {
final ContainerData? selectedContainer;
final Future<void> Function(ContainerSelectionResult selection)?
onSelectionChanged;
const CompactContainerSelector({super.key, this.selectedContainer});
const CompactContainerSelector({
super.key,
this.selectedContainer,
this.onSelectionChanged,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -73,6 +79,14 @@ class CompactContainerSelector extends ConsumerWidget {
onPressed: () async {
final selection = await const ContainerSelectionRoute()
.push<ContainerSelectionResult?>(context);
if (selection == null) {
return;
}
if (onSelectionChanged != null) {
await onSelectionChanged!(selection);
return;
}
switch (selection) {
case ContainerSelectionSelected(:final containerId):
@@ -81,8 +95,6 @@ class CompactContainerSelector extends ConsumerWidget {
.setContainerId(containerId);
case ContainerSelectionUnassigned():
ref.read(selectedContainerProvider.notifier).clearContainer();
case null:
break;
}
},
),
@@ -0,0 +1,49 @@
/*
* 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/>.
*/
enum IntentContainerMode {
useSelected('use-selected'),
unassigned('unassigned'),
specific('specific');
const IntentContainerMode(this.wireValue);
final String wireValue;
String? get queryValueOrNull =>
this == IntentContainerMode.useSelected ? null : wireValue;
static IntentContainerMode fromWireValue(
String? wireValue, {
String? contextId,
}) {
for (final mode in values) {
if (mode.wireValue == wireValue) {
return mode;
}
}
if (contextId != null) {
return IntentContainerMode.specific;
}
return IntentContainerMode.useSelected;
}
}
@@ -18,29 +18,42 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:fast_equatable/fast_equatable.dart';
import 'package:weblibre/features/share_intent/domain/entities/intent_container_mode.dart';
import 'package:weblibre/utils/input_classification.dart';
sealed class SharedContent with FastEquatable {
final String? contextId;
final IntentContainerMode containerMode;
SharedContent({this.contextId});
SharedContent({
this.contextId,
this.containerMode = IntentContainerMode.useSelected,
});
factory SharedContent.parse(String content, {String? contextId}) {
factory SharedContent.parse(
String content, {
String? contextId,
IntentContainerMode containerMode = IntentContainerMode.useSelected,
}) {
if (parseSharedIntentUrl(content) case final Uri uri) {
return SharedUrl(uri, contextId: contextId);
return SharedUrl(uri, contextId: contextId, containerMode: containerMode);
} else {
return SharedText(content, contextId: contextId);
return SharedText(
content,
contextId: contextId,
containerMode: containerMode,
);
}
}
@override
List<Object?> get hashParameters => [contextId];
List<Object?> get hashParameters => [contextId, containerMode];
}
final class SharedUrl extends SharedContent {
final Uri url;
SharedUrl(this.url, {super.contextId});
SharedUrl(this.url, {super.contextId, super.containerMode});
@override
String toString() => url.toString();
@@ -52,7 +65,7 @@ final class SharedUrl extends SharedContent {
final class SharedText extends SharedContent {
final String text;
SharedText(this.text, {super.contextId});
SharedText(this.text, {super.contextId, super.containerMode});
@override
String toString() => text;
@@ -28,93 +28,109 @@ import 'package:uri_to_file/uri_to_file.dart' as uri_to_file;
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/data/models/received_intent_parameter.dart';
import 'package:weblibre/features/intent_gatekeeper/domain/services/intent_gatekeeper.dart';
import 'package:weblibre/features/share_intent/domain/entities/intent_container_mode.dart';
part 'sharing_intent.g.dart';
StreamTransformer<Intent, ReceivedIntentParameter>
_buildSharingIntentTransformer(IntentGatekeeper gatekeeper) =>
StreamTransformer<Intent, ReceivedIntentParameter>.fromHandlers(
handleData: (intent, sink) async {
// PWA shortcut intents carry our own signed context id — always allow.
final pwaContextId =
intent.action == 'android.intent.action.VIEW'
? intent.extra['pwa_context_id'] as String?
: null;
_buildSharingIntentTransformer(
IntentGatekeeper gatekeeper,
) => StreamTransformer<Intent, ReceivedIntentParameter>.fromHandlers(
handleData: (intent, sink) async {
final shortcutContextId = intent.action == 'android.intent.action.VIEW'
? intent.extra['pwa_context_id'] as String?
: null;
final containerMode = intent.action == 'android.intent.action.VIEW'
? IntentContainerMode.fromWireValue(
intent.extra['shortcut_container_mode'] as String?,
contextId: shortcutContextId,
)
: IntentContainerMode.useSelected;
if (pwaContextId == null) {
final allowed = await gatekeeper.shouldAllow(
fromPackageName: intent.fromPackageName,
url: intent.data,
);
if (!allowed) {
logger.i(
'Blocked intent from ${intent.fromPackageName ?? 'unknown app'}',
);
return;
}
}
final allowed = await gatekeeper.shouldAllow(
fromPackageName: intent.fromPackageName,
url: intent.data,
);
if (!allowed) {
logger.i(
'Blocked intent from ${intent.fromPackageName ?? 'unknown app'}',
);
return;
}
final data = switch (intent.action) {
'android.intent.action.PROCESS_TEXT' =>
intent.extra['android.intent.extra.PROCESS_TEXT'] as String?,
'android.intent.action.WEB_SEARCH' =>
intent.extra['query'] as String?,
'android.intent.action.VIEW' => intent.data,
'android.intent.action.SEND' =>
intent.extra['android.intent.extra.STREAM'] as String? ??
intent.extra['android.intent.extra.TEXT'] as String?,
_ => null,
};
final data = switch (intent.action) {
'android.intent.action.PROCESS_TEXT' =>
intent.extra['android.intent.extra.PROCESS_TEXT'] as String?,
'android.intent.action.WEB_SEARCH' => intent.extra['query'] as String?,
'android.intent.action.VIEW' => intent.data,
'android.intent.action.SEND' =>
intent.extra['android.intent.extra.STREAM'] as String? ??
intent.extra['android.intent.extra.TEXT'] as String?,
_ => null,
};
// Extract container context from shortcut intents
final contextId = pwaContextId;
// Extract container context from shortcut intents.
final contextId = shortcutContextId;
if (data != null) {
if (uri_to_file.isUriSupported(data)) {
var path = data;
if (p.extension(data).whenNotEmpty == null) {
if (intent.mimeType.whenNotEmpty != null) {
final ext = mime.extensionFromMime(intent.mimeType!);
if (ext != null) {
path = p.setExtension(path, '.$ext');
} else {
logger.w(
'Could not determine file extension for: ${intent.mimeType}',
);
}
} else {
logger.w(
'Received intent without extension and mime type $path',
);
}
}
try {
final file = await uri_to_file.toFile(path);
final mimeType = mime.lookupMimeType(file.path);
switch (mimeType) {
case 'application/pdf':
sink.add(
ReceivedIntentParameter(path, null, contextId: contextId),
);
default:
logger.w('Unhandled mime type: $mimeType');
}
} catch (e) {
logger.e('Failed to convert URI to file: $e');
// Fallback: pass the original URI
sink.add(
ReceivedIntentParameter(data, null, contextId: contextId),
if (data != null) {
if (uri_to_file.isUriSupported(data)) {
var path = data;
if (p.extension(data).whenNotEmpty == null) {
if (intent.mimeType.whenNotEmpty != null) {
final ext = mime.extensionFromMime(intent.mimeType!);
if (ext != null) {
path = p.setExtension(path, '.$ext');
} else {
logger.w(
'Could not determine file extension for: ${intent.mimeType}',
);
}
} else {
sink.add(
ReceivedIntentParameter(data, null, contextId: contextId),
);
logger.w('Received intent without extension and mime type $path');
}
}
},
);
try {
final file = await uri_to_file.toFile(path);
final mimeType = mime.lookupMimeType(file.path);
switch (mimeType) {
case 'application/pdf':
sink.add(
ReceivedIntentParameter(
path,
null,
contextId: contextId,
containerMode: containerMode,
),
);
default:
logger.w('Unhandled mime type: $mimeType');
}
} catch (e) {
logger.e('Failed to convert URI to file: $e');
// Fallback: pass the original URI
sink.add(
ReceivedIntentParameter(
data,
null,
contextId: contextId,
containerMode: containerMode,
),
);
}
} else {
sink.add(
ReceivedIntentParameter(
data,
null,
contextId: contextId,
containerMode: containerMode,
),
);
}
}
},
);
@Riverpod(keepAlive: true)
Raw<Stream<ReceivedIntentParameter>> sharingIntentStream(Ref ref) {
@@ -16,11 +16,16 @@ object PwaConstants {
const val EXTRA_PWA_TOKEN = "pwa_token"
const val EXTRA_PWA_INSTALL_START_URL = "pwa_install_start_url"
const val EXTRA_SHORTCUT_TYPE = "shortcut_type"
const val EXTRA_SHORTCUT_CONTAINER_MODE = "shortcut_container_mode"
// Shortcut type values
const val SHORTCUT_TYPE_BASIC = "basic"
const val SHORTCUT_TYPE_PWA = "pwa"
// Shortcut container mode values
const val SHORTCUT_CONTAINER_MODE_SPECIFIC = "specific"
const val SHORTCUT_CONTAINER_MODE_UNASSIGNED = "unassigned"
// Profile and file paths
const val CURRENT_PROFILE_FILE = "weblibre_profiles/current_profile"
const val PROFILE_MAPPING_PREFS = "pwa_profile_mapping"
@@ -222,14 +222,22 @@ class IntentReceiverActivity : Activity() {
PwaConstants.PROFILE_MAPPING_PREFS,
Context.MODE_PRIVATE,
)
val tokenKey = "${PwaConstants.PROFILE_MAPPING_TOKEN_PREFIX}${intentUrl}::${profileUuid}"
if (prefs.getString(tokenKey, null) == token) {
// Tokens are keyed by (url, profile, contextId) since each install
// variant gets its own token. Fall back to the legacy (url, profile)
// key for shortcuts pinned before context-scoping was introduced.
val contextId = intent.getStringExtra(PwaConstants.EXTRA_PWA_CONTEXT_ID).orEmpty()
val tokenKey = "${PwaConstants.PROFILE_MAPPING_TOKEN_PREFIX}${intentUrl}::${profileUuid}::${contextId}"
val legacyTokenKey = "${PwaConstants.PROFILE_MAPPING_TOKEN_PREFIX}${intentUrl}::${profileUuid}"
if (prefs.getString(tokenKey, null) == token ||
prefs.getString(legacyTokenKey, null) == token) {
return true
}
if (!installStartUrl.isNullOrEmpty() && installStartUrl != intentUrl) {
val installTokenKey = "${PwaConstants.PROFILE_MAPPING_TOKEN_PREFIX}${installStartUrl}::${profileUuid}"
if (prefs.getString(installTokenKey, null) == token) {
val installTokenKey = "${PwaConstants.PROFILE_MAPPING_TOKEN_PREFIX}${installStartUrl}::${profileUuid}::${contextId}"
val legacyInstallTokenKey = "${PwaConstants.PROFILE_MAPPING_TOKEN_PREFIX}${installStartUrl}::${profileUuid}"
if (prefs.getString(installTokenKey, null) == token ||
prefs.getString(legacyInstallTokenKey, null) == token) {
return true
}
}
@@ -52,6 +52,14 @@ class GeckoPwaApiImpl(
private val coroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
}
private enum class ShortcutKind(
val shortcutType: String,
val idPrefix: String,
) {
PWA(PwaConstants.SHORTCUT_TYPE_PWA, "pwa"),
BASIC(PwaConstants.SHORTCUT_TYPE_BASIC, "shortcut"),
}
private val logger = Logger("GeckoPwaApiImpl")
private val appPrefs by lazy {
context.applicationContext.getSharedPreferences(
@@ -68,6 +76,7 @@ class GeckoPwaApiImpl(
tabId: String?,
profileUuid: String,
contextId: String?,
overrideAppName: String?,
callback: (Result<Boolean>) -> Unit
) {
logger.debug("installWebApp called for tabId: $tabId, profileUuid: $profileUuid, contextId: $contextId")
@@ -86,7 +95,7 @@ class GeckoPwaApiImpl(
return@launch
}
val manifest = tab.content.webAppManifest ?: run {
val baseManifest = tab.content.webAppManifest ?: run {
// Generate a synthetic manifest for sites without one
val url = tab.content.url
val title = tab.content.title.ifBlank { url }
@@ -99,17 +108,26 @@ class GeckoPwaApiImpl(
)
}
val manifest = overrideAppName?.takeIf { it.isNotBlank() }?.let { name ->
baseManifest.copy(name = name, shortName = name)
} ?: baseManifest
logger.debug("Installing web app for tab ${tab.id}: ${manifest.startUrl}")
val success = createPwaShortcut(
manifest = manifest,
profileUuid = profileUuid,
contextId = contextId,
tabFavicon = tab.content.icon,
)
if (success) {
components.core.webAppManifestStorage.saveManifest(manifest)
storeProfileMapping(manifest.startUrl, profileUuid)
// Persist the unmodified manifest so a second install
// of the same URL with a different overrideAppName or
// contextId does not clobber the first install's
// standalone-window metadata. The user-chosen label
// lives on the shortcut itself.
components.core.webAppManifestStorage.saveManifest(baseManifest)
logger.debug("Web app installation completed for tab ${tab.id}")
} else {
logger.warn("Failed to create PWA shortcut for tab ${tab.id}")
@@ -130,6 +148,7 @@ class GeckoPwaApiImpl(
manifest: WebAppManifest,
profileUuid: String,
contextId: String?,
tabFavicon: Bitmap?,
): Boolean = withContext(Dispatchers.Main) {
try {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
@@ -148,18 +167,30 @@ class GeckoPwaApiImpl(
return@withContext false
}
val (iconBitmap, isMaskable) = loadPwaIcon(manifest)
// Prefer a manifest icon when available; for synthetic manifests
// (no icons declared) fall back to the tab favicon so we always
// ship a shortcut icon — some launchers crash when pinning a
// shortcut without one.
val iconBitmap = loadPwaIcon(manifest)
?: loadTabFaviconBitmap(manifest.startUrl, tabFavicon)
val shortcutId = generateShortcutId(manifest.startUrl, profileUuid)
val appName = manifest.shortName ?: manifest.name ?: "Web App"
val shortcutId = resolveShortcutId(
shortcutManager = shortcutManager,
url = manifest.startUrl,
profileUuid = profileUuid,
contextId = contextId,
shortcutKind = ShortcutKind.PWA,
)
val launchToken = resolveLaunchToken(
shortcutManager = shortcutManager,
shortcutId = shortcutId,
startUrl = manifest.startUrl,
profileUuid = profileUuid,
contextId = contextId,
)
val appName = manifest.shortName ?: manifest.name ?: "Web App"
val shortcutIntent = Intent(context, IntentReceiverActivity::class.java).apply {
action = Intent.ACTION_VIEW
data = Uri.parse(manifest.startUrl)
@@ -167,7 +198,11 @@ class GeckoPwaApiImpl(
putExtra(PwaConstants.EXTRA_PWA_CONTEXT_ID, contextId)
putExtra(PwaConstants.EXTRA_PWA_TOKEN, launchToken)
putExtra(PwaConstants.EXTRA_PWA_INSTALL_START_URL, manifest.startUrl)
putExtra(PwaConstants.EXTRA_SHORTCUT_TYPE, PwaConstants.SHORTCUT_TYPE_PWA)
putExtra(PwaConstants.EXTRA_SHORTCUT_TYPE, ShortcutKind.PWA.shortcutType)
putExtra(
PwaConstants.EXTRA_SHORTCUT_CONTAINER_MODE,
resolveShortcutContainerMode(contextId),
)
}
val shortcut = ShortcutInfo.Builder(context, shortcutId).apply {
@@ -176,19 +211,17 @@ class GeckoPwaApiImpl(
setIntent(shortcutIntent)
if (iconBitmap != null) {
// Only use adaptive bitmap for maskable icons (designed for adaptive shapes)
// Regular icons should use createWithBitmap to display as-is
if (isMaskable && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
setIcon(Icon.createWithAdaptiveBitmap(iconBitmap))
} else {
setIcon(Icon.createWithBitmap(iconBitmap))
}
// Always use createWithBitmap (never createWithAdaptiveBitmap):
// Launcher3's pin-preview routes adaptive icons through
// AdaptiveIconDrawable + BitmapShader and promotes intermediates
// to HARDWARE, crashing the software preview canvas.
setIcon(Icon.createWithBitmap(iconBitmap))
}
}.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.
// Update an existing install of the same kind in place. Some
// launchers reuse cached shortcut metadata unless we explicitly
// refresh the pinned record first.
updateExistingShortcut(shortcutManager, shortcut)
val success = shortcutManager.requestPinShortcut(shortcut, null)
@@ -218,53 +251,129 @@ class GeckoPwaApiImpl(
}
/**
* Generates a collision-resistant shortcut ID from URL + profile using SHA-256.
* Resolves the shortcut ID for the current install kind.
*
* New installs use a kind-specific ID so a standalone PWA and a regular
* shortcut for the same site do not overwrite each other. For minimal
* migration handling, we still reuse the legacy shared ID if a pinned
* shortcut with that ID already exists for the same kind.
*/
private fun generateShortcutId(url: String, profileUuid: String): String {
private fun resolveShortcutId(
shortcutManager: ShortcutManager,
url: String,
profileUuid: String,
contextId: String? = null,
shortcutKind: ShortcutKind,
): String {
val typedShortcutId = generateShortcutId(
url = url,
profileUuid = profileUuid,
contextId = contextId,
shortcutKind = shortcutKind,
)
if (shortcutManager.pinnedShortcuts.any { it.id == typedShortcutId }) {
return typedShortcutId
}
val legacyShortcutId = generateLegacyShortcutId(
url = url,
profileUuid = profileUuid,
contextId = contextId,
)
val matchingLegacyShortcut = shortcutManager.pinnedShortcuts.firstOrNull { shortcut ->
if (shortcut.id != legacyShortcutId) {
return@firstOrNull false
}
val shortcutIntent = shortcut.intent ?: return@firstOrNull false
shortcutIntent.getStringExtra(PwaConstants.EXTRA_PWA_PROFILE_UUID) == profileUuid &&
shortcutIntent.getStringExtra(PwaConstants.EXTRA_SHORTCUT_TYPE) == shortcutKind.shortcutType
}
return matchingLegacyShortcut?.id ?: typedShortcutId
}
/**
* Generates a collision-resistant shortcut ID from (type, url, profile,
* contextId) using SHA-256. The display label is deliberately excluded
* because it is presentation, not install identity.
*/
private fun generateShortcutId(
url: String,
profileUuid: String,
contextId: String? = null,
shortcutKind: ShortcutKind,
): String {
val digest = MessageDigest.getInstance("SHA-256")
val hash = digest.digest("$url::$profileUuid".toByteArray())
val key = if (contextId.isNullOrEmpty()) {
"${shortcutKind.shortcutType}::$url::$profileUuid"
} else {
"${shortcutKind.shortcutType}::$url::$profileUuid::$contextId"
}
val hash = digest.digest(key.toByteArray())
val hex = hash.take(16).joinToString("") { "%02x".format(it) }
return "${shortcutKind.idPrefix}_$hex"
}
/**
* Historical shared shortcut ID used before install kind became part of
* the identity. Both PWAs and basic shortcuts previously reused this ID.
*/
private fun generateLegacyShortcutId(
url: String,
profileUuid: String,
contextId: String? = null,
): String {
val digest = MessageDigest.getInstance("SHA-256")
val key = if (contextId.isNullOrEmpty()) {
"$url::$profileUuid"
} else {
"$url::$profileUuid::$contextId"
}
val hash = digest.digest(key.toByteArray())
val hex = hash.take(16).joinToString("") { "%02x".format(it) }
return "pwa_$hex"
}
private fun resolveShortcutContainerMode(contextId: String?): String {
return if (contextId.isNullOrEmpty()) {
PwaConstants.SHORTCUT_CONTAINER_MODE_UNASSIGNED
} else {
PwaConstants.SHORTCUT_CONTAINER_MODE_SPECIFIC
}
}
/**
* Loads the PWA icon from the manifest using BrowserIcons.
* Returns a pair of (bitmap, isMaskable) to determine proper icon format.
* Loads the PWA icon from the manifest using BrowserIcons. Requested at
* plain LAUNCHER size since the shortcut is set as a non-adaptive bitmap.
*/
private suspend fun loadPwaIcon(manifest: WebAppManifest): Pair<Bitmap?, Boolean> = withContext(Dispatchers.IO) {
private suspend fun loadPwaIcon(manifest: WebAppManifest): Bitmap? = withContext(Dispatchers.IO) {
try {
val iconResource = manifest.icons
.filter { it.purpose.contains(WebAppManifest.Icon.Purpose.MASKABLE) ||
it.purpose.contains(WebAppManifest.Icon.Purpose.ANY) }
.maxByOrNull { (it.sizes?.maxOf { size -> size.width * size.height } ?: 0) }
?: manifest.icons.firstOrNull()
?: return@withContext null
if (iconResource != null) {
val isMaskable = iconResource.purpose.contains(WebAppManifest.Icon.Purpose.MASKABLE)
val iconRequest = IconRequest(
url = manifest.startUrl,
size = IconRequest.Size.LAUNCHER_ADAPTIVE,
resources = listOf(
IconRequest.Resource(
url = iconResource.src,
type = IconRequest.Resource.Type.MANIFEST_ICON,
sizes = iconResource.sizes?.map { size ->
mozilla.components.concept.engine.manifest.Size(size.width, size.height)
} ?: emptyList(),
mimeType = iconResource.type,
maskable = isMaskable
)
val iconRequest = IconRequest(
url = manifest.startUrl,
size = IconRequest.Size.LAUNCHER,
resources = listOf(
IconRequest.Resource(
url = iconResource.src,
type = IconRequest.Resource.Type.MANIFEST_ICON,
sizes = iconResource.sizes?.map { size ->
mozilla.components.concept.engine.manifest.Size(size.width, size.height)
} ?: emptyList(),
mimeType = iconResource.type,
)
)
)
val iconResult = components.core.icons.loadIcon(iconRequest).await()
Pair(iconResult?.bitmap, isMaskable)
} else {
Pair(null, false)
}
components.core.icons.loadIcon(iconRequest).await()?.bitmap
} catch (e: Exception) {
logger.error("Failed to load PWA icon", e)
Pair(null, false)
null
}
}
@@ -335,16 +444,23 @@ class GeckoPwaApiImpl(
return@withContext false
}
val shortcutId = generateShortcutId(url, profileUuid)
val shortLabel = title.ifBlank { url }
val shortcutId = resolveShortcutId(
shortcutManager = shortcutManager,
url = url,
profileUuid = profileUuid,
contextId = contextId,
shortcutKind = ShortcutKind.BASIC,
)
val launchToken = resolveLaunchToken(
shortcutManager = shortcutManager,
shortcutId = shortcutId,
startUrl = url,
profileUuid = profileUuid,
contextId = contextId,
)
val shortLabel = title.ifBlank { url }
val shortcutIntent = Intent(context, IntentReceiverActivity::class.java).apply {
action = Intent.ACTION_VIEW
data = Uri.parse(url)
@@ -352,7 +468,11 @@ class GeckoPwaApiImpl(
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)
putExtra(PwaConstants.EXTRA_SHORTCUT_TYPE, ShortcutKind.BASIC.shortcutType)
putExtra(
PwaConstants.EXTRA_SHORTCUT_CONTAINER_MODE,
resolveShortcutContainerMode(contextId),
)
}
val icon = loadTabIcon(url, tabIcon)
@@ -364,7 +484,7 @@ class GeckoPwaApiImpl(
icon?.let { setIcon(it) }
}.build()
// Update existing shortcut intent if one exists with the same ID
// Update an existing install of the same kind in place.
updateExistingShortcut(shortcutManager, shortcut)
val success = shortcutManager.requestPinShortcut(shortcut, null)
@@ -377,31 +497,37 @@ class GeckoPwaApiImpl(
}
/**
* Loads an icon for the shortcut from the tab's favicon or BrowserIcons.
* Loads a favicon-style bitmap for [url], preferring the in-memory tab icon
* and falling back to BrowserIcons at LAUNCHER size. Returns a live bitmap
* (caller is responsible for converting to software before use).
*/
private suspend fun loadTabIcon(url: String, tabIcon: Bitmap?): Icon? = withContext(Dispatchers.IO) {
private suspend fun loadTabFaviconBitmap(
url: String,
tabIcon: Bitmap?,
): Bitmap? = withContext(Dispatchers.IO) {
try {
// Try using the tab's existing favicon first
val bitmap = tabIcon?.takeUnless { it.isRecycled }
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)
logger.error("Failed to load tab favicon bitmap", e)
null
}
}
/**
* Loads an icon for the shortcut from the tab's favicon or BrowserIcons.
*/
private suspend fun loadTabIcon(url: String, tabIcon: Bitmap?): Icon? {
val bitmap = loadTabFaviconBitmap(url, tabIcon)
return bitmap?.takeUnless { it.isRecycled }?.let(Icon::createWithBitmap)
}
/**
* Extracts the scope from a URL (origin + path up to last segment).
*/
@@ -425,14 +551,51 @@ class GeckoPwaApiImpl(
coroutineScope.launch {
try {
val storage = components.core.webAppManifestStorage
val manifests = storage.loadShareableManifests(System.currentTimeMillis())
val currentProfileUuid = getCurrentProfileUuid()
val pwaManifests = manifests.filter { manifest ->
val mappedProfile = getProfileMapping(manifest.startUrl)
currentProfileUuid == null || mappedProfile == null || mappedProfile == currentProfileUuid
}.map { manifest ->
manifest.toPwaManifest()
// Pinned shortcuts are the source of truth for installs: each
// pinned shortcut carries its own label, profile, and
// contextId in its intent extras. Two installs of the same
// URL with different contextIds or labels are two distinct
// shortcuts here, even though Mozilla's manifest storage
// keys the underlying manifest by URL only. We join the
// shared manifest with per-install fields from each shortcut.
val pwaShortcuts = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.getSystemService<ShortcutManager>()?.pinnedShortcuts
?.filter { shortcut ->
val intent = shortcut.intent ?: return@filter false
intent.getStringExtra(PwaConstants.EXTRA_SHORTCUT_TYPE) ==
PwaConstants.SHORTCUT_TYPE_PWA
}
?: emptyList()
} else {
emptyList()
}
val pwaManifests = pwaShortcuts.mapNotNull { shortcut ->
val intent = shortcut.intent ?: return@mapNotNull null
val shortcutProfile =
intent.getStringExtra(PwaConstants.EXTRA_PWA_PROFILE_UUID)
if (currentProfileUuid != null &&
shortcutProfile != null &&
shortcutProfile != currentProfileUuid
) {
return@mapNotNull null
}
val installStartUrl =
intent.getStringExtra(PwaConstants.EXTRA_PWA_INSTALL_START_URL)
?: intent.dataString
?: return@mapNotNull null
val manifest = storage.loadManifest(installStartUrl)
?: return@mapNotNull null
manifest.toPwaManifest(
contextId = intent.getStringExtra(PwaConstants.EXTRA_PWA_CONTEXT_ID),
installLabel = shortcut.shortLabel?.toString(),
)
}
logger.debug("Found ${pwaManifests.size} installed web apps")
callback(Result.success(pwaManifests))
} catch (e: Exception) {
@@ -442,19 +605,14 @@ class GeckoPwaApiImpl(
}
}
private fun storeProfileMapping(startUrl: String, profileUuid: String) {
appPrefs.edit()
.putString(startUrl, profileUuid)
.apply()
}
private fun resolveLaunchToken(
shortcutManager: ShortcutManager,
shortcutId: String,
startUrl: String,
profileUuid: String,
contextId: String?,
): String {
val storedToken = getStoredLaunchToken(startUrl, profileUuid)
val storedToken = getStoredLaunchToken(startUrl, profileUuid, contextId)
val existingShortcutToken = shortcutManager.pinnedShortcuts
.firstOrNull { shortcut -> shortcut.id == shortcutId }
?.intent
@@ -464,7 +622,7 @@ class GeckoPwaApiImpl(
?.getStringExtra(PwaConstants.EXTRA_PWA_TOKEN)
if (!existingShortcutToken.isNullOrEmpty()) {
val committed = storeLaunchToken(startUrl, profileUuid, existingShortcutToken)
val committed = storeLaunchToken(startUrl, profileUuid, contextId, existingShortcutToken)
if (!committed) {
logger.warn("Failed to persist pinned shortcut PWA token for $startUrl")
}
@@ -472,7 +630,7 @@ class GeckoPwaApiImpl(
}
if (!storedToken.isNullOrEmpty()) {
val committed = storeLaunchToken(startUrl, profileUuid, storedToken)
val committed = storeLaunchToken(startUrl, profileUuid, contextId, storedToken)
if (!committed) {
logger.warn("Failed to refresh stored PWA launch token index for $startUrl")
}
@@ -480,27 +638,40 @@ class GeckoPwaApiImpl(
}
val generatedToken = UUID.randomUUID().toString()
val committed = storeLaunchToken(startUrl, profileUuid, generatedToken)
val committed = storeLaunchToken(startUrl, profileUuid, contextId, generatedToken)
if (!committed) {
logger.warn("Failed to persist PWA launch token for $startUrl")
}
return generatedToken
}
private fun getStoredLaunchToken(startUrl: String, profileUuid: String): String? {
val tokenKey = "${PwaConstants.PROFILE_MAPPING_TOKEN_PREFIX}${startUrl}::${profileUuid}"
return appPrefs.getString(tokenKey, null)
private fun tokenKey(startUrl: String, profileUuid: String, contextId: String?): String {
// Keyed by (startUrl, profileUuid, contextId) so multiple installs of
// the same URL with different storage contexts each keep their own
// token and don't share or overwrite each other.
return "${PwaConstants.PROFILE_MAPPING_TOKEN_PREFIX}${startUrl}::${profileUuid}::${contextId.orEmpty()}"
}
private fun storeLaunchToken(startUrl: String, profileUuid: String, token: String): Boolean {
val tokenKey = "${PwaConstants.PROFILE_MAPPING_TOKEN_PREFIX}${startUrl}::${profileUuid}"
return appPrefs.edit()
.putString(tokenKey, token)
.commit()
private fun legacyTokenKey(startUrl: String, profileUuid: String): String {
return "${PwaConstants.PROFILE_MAPPING_TOKEN_PREFIX}${startUrl}::${profileUuid}"
}
private fun getProfileMapping(startUrl: String): String? {
return appPrefs.getString(startUrl, null)
private fun getStoredLaunchToken(startUrl: String, profileUuid: String, contextId: String?): String? {
return appPrefs.getString(tokenKey(startUrl, profileUuid, contextId), null)
?: if (contextId.isNullOrEmpty()) {
appPrefs.getString(legacyTokenKey(startUrl, profileUuid), null)
} else {
null
}
}
private fun storeLaunchToken(startUrl: String, profileUuid: String, contextId: String?, token: String): Boolean {
return appPrefs.edit().apply {
putString(tokenKey(startUrl, profileUuid, contextId), token)
if (contextId.isNullOrEmpty()) {
putString(legacyTokenKey(startUrl, profileUuid), token)
}
}.commit()
}
private fun getCurrentProfileUuid(): String? {
@@ -517,10 +688,16 @@ class GeckoPwaApiImpl(
}
}
private fun WebAppManifest.toPwaManifest(currentUrl: String = startUrl): PwaManifest {
private fun WebAppManifest.toPwaManifest(
currentUrl: String = startUrl,
contextId: String? = null,
installLabel: String? = null,
): PwaManifest {
return PwaManifest(
startUrl = startUrl,
currentUrl = currentUrl,
contextId = contextId,
installLabel = installLabel,
name = name,
shortName = shortName,
display = display?.name?.lowercase()?.replace("_", "-"),
@@ -5124,7 +5124,22 @@ data class PwaManifest (
* The URL of the page when the manifest was detected.
* Used for HTTPS/installability checks.
*/
val currentUrl: String
val currentUrl: String,
/**
* Storage context (container contextualIdentity or isolated `iso1_` id)
* that this install was pinned with. Only populated by
* `getInstalledWebApps` read from the pinned shortcut's intent extras,
* not from the manifest itself, because the same URL can have multiple
* installs that differ only in contextId.
*/
val contextId: String? = null,
/**
* User-chosen launcher label for this specific install (from the pinned
* shortcut's shortLabel). May differ from `name`/`shortName` because the
* underlying manifest is shared across install variants of the same URL.
* Only populated by `getInstalledWebApps`.
*/
val installLabel: String? = null
)
{
companion object {
@@ -5145,7 +5160,9 @@ data class PwaManifest (
val preferRelatedApplications = pigeonVar_list[13] as Boolean
val shareTarget = pigeonVar_list[14] as ShareTarget?
val currentUrl = pigeonVar_list[15] as String
return PwaManifest(startUrl, name, shortName, display, themeColor, backgroundColor, scope, description, icons, dir, lang, orientation, relatedApplications, preferRelatedApplications, shareTarget, currentUrl)
val contextId = pigeonVar_list[16] as String?
val installLabel = pigeonVar_list[17] as String?
return PwaManifest(startUrl, name, shortName, display, themeColor, backgroundColor, scope, description, icons, dir, lang, orientation, relatedApplications, preferRelatedApplications, shareTarget, currentUrl, contextId, installLabel)
}
}
fun toList(): List<Any?> {
@@ -5166,6 +5183,8 @@ data class PwaManifest (
preferRelatedApplications,
shareTarget,
currentUrl,
contextId,
installLabel,
)
}
override fun equals(other: Any?): Boolean {
@@ -5176,7 +5195,7 @@ data class PwaManifest (
return true
}
val other = other as PwaManifest
return GeckoPigeonUtils.deepEquals(this.startUrl, other.startUrl) && GeckoPigeonUtils.deepEquals(this.name, other.name) && GeckoPigeonUtils.deepEquals(this.shortName, other.shortName) && GeckoPigeonUtils.deepEquals(this.display, other.display) && GeckoPigeonUtils.deepEquals(this.themeColor, other.themeColor) && GeckoPigeonUtils.deepEquals(this.backgroundColor, other.backgroundColor) && GeckoPigeonUtils.deepEquals(this.scope, other.scope) && GeckoPigeonUtils.deepEquals(this.description, other.description) && GeckoPigeonUtils.deepEquals(this.icons, other.icons) && GeckoPigeonUtils.deepEquals(this.dir, other.dir) && GeckoPigeonUtils.deepEquals(this.lang, other.lang) && GeckoPigeonUtils.deepEquals(this.orientation, other.orientation) && GeckoPigeonUtils.deepEquals(this.relatedApplications, other.relatedApplications) && GeckoPigeonUtils.deepEquals(this.preferRelatedApplications, other.preferRelatedApplications) && GeckoPigeonUtils.deepEquals(this.shareTarget, other.shareTarget) && GeckoPigeonUtils.deepEquals(this.currentUrl, other.currentUrl)
return GeckoPigeonUtils.deepEquals(this.startUrl, other.startUrl) && GeckoPigeonUtils.deepEquals(this.name, other.name) && GeckoPigeonUtils.deepEquals(this.shortName, other.shortName) && GeckoPigeonUtils.deepEquals(this.display, other.display) && GeckoPigeonUtils.deepEquals(this.themeColor, other.themeColor) && GeckoPigeonUtils.deepEquals(this.backgroundColor, other.backgroundColor) && GeckoPigeonUtils.deepEquals(this.scope, other.scope) && GeckoPigeonUtils.deepEquals(this.description, other.description) && GeckoPigeonUtils.deepEquals(this.icons, other.icons) && GeckoPigeonUtils.deepEquals(this.dir, other.dir) && GeckoPigeonUtils.deepEquals(this.lang, other.lang) && GeckoPigeonUtils.deepEquals(this.orientation, other.orientation) && GeckoPigeonUtils.deepEquals(this.relatedApplications, other.relatedApplications) && GeckoPigeonUtils.deepEquals(this.preferRelatedApplications, other.preferRelatedApplications) && GeckoPigeonUtils.deepEquals(this.shareTarget, other.shareTarget) && GeckoPigeonUtils.deepEquals(this.currentUrl, other.currentUrl) && GeckoPigeonUtils.deepEquals(this.contextId, other.contextId) && GeckoPigeonUtils.deepEquals(this.installLabel, other.installLabel)
}
override fun hashCode(): Int {
@@ -5197,6 +5216,8 @@ data class PwaManifest (
result = 31 * result + GeckoPigeonUtils.deepHash(this.preferRelatedApplications)
result = 31 * result + GeckoPigeonUtils.deepHash(this.shareTarget)
result = 31 * result + GeckoPigeonUtils.deepHash(this.currentUrl)
result = 31 * result + GeckoPigeonUtils.deepHash(this.contextId)
result = 31 * result + GeckoPigeonUtils.deepHash(this.installLabel)
return result
}
}
@@ -10635,9 +10656,10 @@ interface GeckoPwaApi {
* The [tabId] identifies which tab to install from. 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, null for default container).
* The [overrideAppName] customizes the installed app's displayed name and persists in the saved manifest.
* Returns true if installation was successful.
*/
fun installWebApp(tabId: String?, profileUuid: String, contextId: String?, callback: (Result<Boolean>) -> Unit)
fun installWebApp(tabId: String?, profileUuid: String, contextId: String?, overrideAppName: String?, callback: (Result<Boolean>) -> Unit)
/** Returns a list of all installed PWA manifests. */
fun getInstalledWebApps(callback: (Result<List<PwaManifest>>) -> Unit)
/**
@@ -10672,7 +10694,8 @@ interface GeckoPwaApi {
val tabIdArg = args[0] as String?
val profileUuidArg = args[1] as String
val contextIdArg = args[2] as String?
api.installWebApp(tabIdArg, profileUuidArg, contextIdArg) { result: Result<Boolean> ->
val overrideAppNameArg = args[3] as String?
api.installWebApp(tabIdArg, profileUuidArg, contextIdArg, overrideAppNameArg) { result: Result<Boolean> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(GeckoPigeonUtils.wrapError(error))
@@ -5474,6 +5474,8 @@ class PwaManifest {
required this.preferRelatedApplications,
this.shareTarget,
required this.currentUrl,
this.contextId,
this.installLabel,
});
String startUrl;
@@ -5510,6 +5512,19 @@ class PwaManifest {
/// Used for HTTPS/installability checks.
String currentUrl;
/// Storage context (container contextualIdentity or isolated `iso1_…` id)
/// that this install was pinned with. Only populated by
/// `getInstalledWebApps` — read from the pinned shortcut's intent extras,
/// not from the manifest itself, because the same URL can have multiple
/// installs that differ only in contextId.
String? contextId;
/// User-chosen launcher label for this specific install (from the pinned
/// shortcut's shortLabel). May differ from `name`/`shortName` because the
/// underlying manifest is shared across install variants of the same URL.
/// Only populated by `getInstalledWebApps`.
String? installLabel;
List<Object?> _toList() {
return <Object?>[
startUrl,
@@ -5528,6 +5543,8 @@ class PwaManifest {
preferRelatedApplications,
shareTarget,
currentUrl,
contextId,
installLabel,
];
}
@@ -5553,6 +5570,8 @@ class PwaManifest {
preferRelatedApplications: result[13]! as bool,
shareTarget: result[14] as ShareTarget?,
currentUrl: result[15]! as String,
contextId: result[16] as String?,
installLabel: result[17] as String?,
);
}
@@ -5565,7 +5584,7 @@ class PwaManifest {
if (identical(this, other)) {
return true;
}
return _deepEquals(startUrl, other.startUrl) && _deepEquals(name, other.name) && _deepEquals(shortName, other.shortName) && _deepEquals(display, other.display) && _deepEquals(themeColor, other.themeColor) && _deepEquals(backgroundColor, other.backgroundColor) && _deepEquals(scope, other.scope) && _deepEquals(description, other.description) && _deepEquals(icons, other.icons) && _deepEquals(dir, other.dir) && _deepEquals(lang, other.lang) && _deepEquals(orientation, other.orientation) && _deepEquals(relatedApplications, other.relatedApplications) && _deepEquals(preferRelatedApplications, other.preferRelatedApplications) && _deepEquals(shareTarget, other.shareTarget) && _deepEquals(currentUrl, other.currentUrl);
return _deepEquals(startUrl, other.startUrl) && _deepEquals(name, other.name) && _deepEquals(shortName, other.shortName) && _deepEquals(display, other.display) && _deepEquals(themeColor, other.themeColor) && _deepEquals(backgroundColor, other.backgroundColor) && _deepEquals(scope, other.scope) && _deepEquals(description, other.description) && _deepEquals(icons, other.icons) && _deepEquals(dir, other.dir) && _deepEquals(lang, other.lang) && _deepEquals(orientation, other.orientation) && _deepEquals(relatedApplications, other.relatedApplications) && _deepEquals(preferRelatedApplications, other.preferRelatedApplications) && _deepEquals(shareTarget, other.shareTarget) && _deepEquals(currentUrl, other.currentUrl) && _deepEquals(contextId, other.contextId) && _deepEquals(installLabel, other.installLabel);
}
@override
@@ -10499,15 +10518,16 @@ class GeckoPwaApi {
/// The [tabId] identifies which tab to install from. 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, null for default container).
/// The [overrideAppName] customizes the installed app's displayed name and persists in the saved manifest.
/// Returns true if installation was successful.
Future<bool> installWebApp(String? tabId, String profileUuid, String? contextId) async {
Future<bool> installWebApp(String? tabId, String profileUuid, String? contextId, String? overrideAppName) async {
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoPwaApi.installWebApp$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]);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[tabId, profileUuid, contextId, overrideAppName]);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
@@ -2673,6 +2673,19 @@ class PwaManifest {
/// Used for HTTPS/installability checks.
final String currentUrl;
/// Storage context (container contextualIdentity or isolated `iso1_…` id)
/// that this install was pinned with. Only populated by
/// `getInstalledWebApps` — read from the pinned shortcut's intent extras,
/// not from the manifest itself, because the same URL can have multiple
/// installs that differ only in contextId.
final String? contextId;
/// User-chosen launcher label for this specific install (from the pinned
/// shortcut's shortLabel). May differ from `name`/`shortName` because the
/// underlying manifest is shared across install variants of the same URL.
/// Only populated by `getInstalledWebApps`.
final String? installLabel;
const PwaManifest({
required this.startUrl,
required this.currentUrl,
@@ -2690,6 +2703,8 @@ class PwaManifest {
this.relatedApplications = const [],
this.preferRelatedApplications = false,
this.shareTarget,
this.contextId,
this.installLabel,
});
}
@@ -2708,9 +2723,15 @@ abstract class GeckoPwaApi {
/// The [tabId] identifies which tab to install from. 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, null for default container).
/// The [overrideAppName] customizes the installed app's displayed name and persists in the saved manifest.
/// Returns true if installation was successful.
@async
bool installWebApp(String? tabId, String profileUuid, String? contextId);
bool installWebApp(
String? tabId,
String profileUuid,
String? contextId,
String? overrideAppName,
);
/// Returns a list of all installed PWA manifests.
@async