pwa improvements: custom name and context selection
This commit is contained in:
@@ -250,14 +250,25 @@ class TabTreeRoute extends GoRouteData with $TabTreeRoute {
|
|||||||
|
|
||||||
class OpenSharedContentRoute extends GoRouteData with $OpenSharedContentRoute {
|
class OpenSharedContentRoute extends GoRouteData with $OpenSharedContentRoute {
|
||||||
final String sharedUrl;
|
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
|
@override
|
||||||
Page<void> buildPage(BuildContext context, GoRouterState state) {
|
Page<void> buildPage(BuildContext context, GoRouterState state) {
|
||||||
return BottomSheetPage(
|
return BottomSheetPage(
|
||||||
builder: (_) => OpenSharedContent(
|
builder: (_) => OpenSharedContent(
|
||||||
sharedUrl: Uri.tryParse(sharedUrl) ?? Uri.parse('about:blank'),
|
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_content_settings.dart';
|
||||||
import 'package:weblibre/features/settings/presentation/screens/web_engine_hardening.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/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/sync/presentation/screens/sync_settings.dart';
|
||||||
import 'package:weblibre/features/tor/presentation/screens/country_picker.dart';
|
import 'package:weblibre/features/tor/presentation/screens/country_picker.dart';
|
||||||
import 'package:weblibre/features/tor/presentation/screens/tor_proxy.dart';
|
import 'package:weblibre/features/tor/presentation/screens/tor_proxy.dart';
|
||||||
|
|||||||
@@ -860,6 +860,8 @@ mixin $OpenSharedContentRoute on GoRouteData {
|
|||||||
static OpenSharedContentRoute _fromState(GoRouterState state) =>
|
static OpenSharedContentRoute _fromState(GoRouterState state) =>
|
||||||
OpenSharedContentRoute(
|
OpenSharedContentRoute(
|
||||||
sharedUrl: state.uri.queryParameters['shared-url'] ?? 'about:blank',
|
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;
|
OpenSharedContentRoute get _self => this as OpenSharedContentRoute;
|
||||||
@@ -869,6 +871,8 @@ mixin $OpenSharedContentRoute on GoRouteData {
|
|||||||
'/browser/open_content',
|
'/browser/open_content',
|
||||||
queryParams: {
|
queryParams: {
|
||||||
if (_self.sharedUrl != 'about:blank') 'shared-url': _self.sharedUrl,
|
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/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
import 'package:fast_equatable/fast_equatable.dart';
|
import 'package:fast_equatable/fast_equatable.dart';
|
||||||
|
import 'package:weblibre/features/share_intent/domain/entities/intent_container_mode.dart';
|
||||||
|
|
||||||
class ReceivedIntentParameter with FastEquatable {
|
class ReceivedIntentParameter with FastEquatable {
|
||||||
final String? content;
|
final String? content;
|
||||||
final String? tool;
|
final String? tool;
|
||||||
final String? contextId;
|
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
|
@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(
|
StreamTransformer<ReceivedIntentParameter, SharedContent>.fromHandlers(
|
||||||
handleData: (parameter, sink) {
|
handleData: (parameter, sink) {
|
||||||
final parsed = parameter.content.mapNotNull(
|
final parsed = parameter.content.mapNotNull(
|
||||||
(content) =>
|
(content) => SharedContent.parse(
|
||||||
SharedContent.parse(content, contextId: parameter.contextId),
|
content,
|
||||||
|
contextId: parameter.contextId,
|
||||||
|
containerMode: parameter.containerMode,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (parsed != null) {
|
if (parsed != null) {
|
||||||
|
|||||||
+18
-4
@@ -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/intent_gatekeeper.dart';
|
||||||
import 'package:weblibre/features/intent_gatekeeper/domain/services/native_gatekeeper_replicator.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/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/share_intent/domain/entities/shared_content.dart';
|
||||||
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
||||||
import 'package:weblibre/features/user/domain/providers/profile_auth.dart';
|
import 'package:weblibre/features/user/domain/providers/profile_auth.dart';
|
||||||
@@ -403,6 +404,7 @@ class _BrowserViewState extends ConsumerState<BrowserView>
|
|||||||
final containerSelection = await _resolveContainerSelection(
|
final containerSelection = await _resolveContainerSelection(
|
||||||
ref,
|
ref,
|
||||||
sharedContent.contextId,
|
sharedContent.contextId,
|
||||||
|
sharedContent.containerMode,
|
||||||
);
|
);
|
||||||
|
|
||||||
await ref
|
await ref
|
||||||
@@ -443,6 +445,8 @@ class _BrowserViewState extends ConsumerState<BrowserView>
|
|||||||
case SharedUrl():
|
case SharedUrl():
|
||||||
final route = OpenSharedContentRoute(
|
final route = OpenSharedContentRoute(
|
||||||
sharedUrl: sharedContent.url.toString(),
|
sharedUrl: sharedContent.url.toString(),
|
||||||
|
contextId: sharedContent.contextId,
|
||||||
|
containerMode: sharedContent.containerMode.queryValueOrNull,
|
||||||
);
|
);
|
||||||
await router.push(route.location);
|
await router.push(route.location);
|
||||||
case SharedText():
|
case SharedText():
|
||||||
@@ -704,13 +708,19 @@ class _BrowserViewState extends ConsumerState<BrowserView>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves a [TabContainerSelection] from a shortcut intent's context ID.
|
/// Resolves a [TabContainerSelection] from incoming launch container metadata.
|
||||||
/// Returns [TabContainerSelection.useSelected] if no contextId or container not found.
|
|
||||||
Future<TabContainerSelection> _resolveContainerSelection(
|
Future<TabContainerSelection> _resolveContainerSelection(
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
String? contextId,
|
String? contextId,
|
||||||
|
IntentContainerMode containerMode,
|
||||||
) async {
|
) 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
|
final container = await ref
|
||||||
.read(containerRepositoryProvider.notifier)
|
.read(containerRepositoryProvider.notifier)
|
||||||
@@ -720,5 +730,9 @@ Future<TabContainerSelection> _resolveContainerSelection(
|
|||||||
return TabContainerSelection.specific(container);
|
return TabContainerSelection.specific(container);
|
||||||
}
|
}
|
||||||
|
|
||||||
return const TabContainerSelection.useSelected();
|
return switch (containerMode) {
|
||||||
|
IntentContainerMode.useSelected =>
|
||||||
|
const TabContainerSelection.useSelected(),
|
||||||
|
_ => const TabContainerSelection.unassigned(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+164
-70
@@ -27,6 +27,7 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:weblibre/core/design/app_colors.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/entities/tab_container_selection.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/repositories/tab.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';
|
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/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/attribution_link.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/widgets/url_cleaner_tile.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/entities/tab_mode.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.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/features/user/domain/repositories/general_settings.dart';
|
||||||
import 'package:weblibre/presentation/hooks/cached_future.dart';
|
import 'package:weblibre/presentation/hooks/cached_future.dart';
|
||||||
import 'package:weblibre/presentation/hooks/debouncer.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/form_validators.dart';
|
||||||
import 'package:weblibre/utils/ui_helper.dart';
|
import 'package:weblibre/utils/ui_helper.dart';
|
||||||
|
|
||||||
class OpenSharedContent extends HookConsumerWidget {
|
class OpenSharedContent extends HookConsumerWidget {
|
||||||
final Uri sharedUrl;
|
final Uri sharedUrl;
|
||||||
|
final String? contextId;
|
||||||
|
final IntentContainerMode containerMode;
|
||||||
|
|
||||||
static final _appLinksService = GeckoAppLinksService();
|
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
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@@ -58,7 +71,81 @@ class OpenSharedContent extends HookConsumerWidget {
|
|||||||
final textController = useTextEditingController(text: sharedUrl.toString());
|
final textController = useTextEditingController(text: sharedUrl.toString());
|
||||||
final appColors = AppColors.of(context);
|
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 settings = ref.watch(generalSettingsWithDefaultsProvider);
|
||||||
final catalogAsync = ref.watch(urlCleanerCatalogServiceProvider);
|
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) {
|
if (formKey.currentState?.validate() == true) {
|
||||||
final parsedUrl = parseValidatedUrl(
|
final parsedUrl = parseValidatedUrl(
|
||||||
textController.text,
|
textController.text,
|
||||||
@@ -161,10 +248,14 @@ class OpenSharedContent extends HookConsumerWidget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final contextId = tabMode is IsolatedTabMode
|
||||||
|
? tabMode.isolationContextId
|
||||||
|
: selectedContainer.value?.metadata.contextualIdentity;
|
||||||
|
|
||||||
await GeckoBrowserService().openInCustomTab(
|
await GeckoBrowserService().openInCustomTab(
|
||||||
url: parsedUrl,
|
url: parsedUrl,
|
||||||
private: isPrivate,
|
private: tabMode is PrivateTabMode,
|
||||||
contextId: selectedContainer.value?.id,
|
contextId: contextId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
@@ -203,18 +294,7 @@ class OpenSharedContent extends HookConsumerWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Text('Open link', style: Theme.of(context).textTheme.titleLarge),
|
Text('Open link', style: Theme.of(context).textTheme.titleLarge),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 8),
|
||||||
if (settings.showContainerUi)
|
|
||||||
ContainerChips(
|
|
||||||
displayMenu: false,
|
|
||||||
selectedContainer: selectedContainer.value,
|
|
||||||
onSelected: (container) {
|
|
||||||
selectedContainer.value = container;
|
|
||||||
},
|
|
||||||
onDeleted: (container) {
|
|
||||||
selectedContainer.value = null;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: textController,
|
controller: textController,
|
||||||
keyboardType: TextInputType.url,
|
keyboardType: TextInputType.url,
|
||||||
@@ -337,64 +417,78 @@ class OpenSharedContent extends HookConsumerWidget {
|
|||||||
icon: Icons.open_in_new,
|
icon: Icons.open_in_new,
|
||||||
onTap: openInApp,
|
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(
|
_OpenActionTile(
|
||||||
title: 'Open in new tab',
|
title: 'Open in new tab',
|
||||||
subtitle: 'Add to your browser tabs',
|
subtitle: 'Add to your browser tabs',
|
||||||
icon: MdiIcons.tab,
|
icon: MdiIcons.tab,
|
||||||
trailing: PopupMenuButton<TabMode>(
|
onTap: () => openTab(resolveTabMode()),
|
||||||
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),
|
|
||||||
),
|
),
|
||||||
_OpenActionTile(
|
_OpenActionTile(
|
||||||
title: 'Open in custom tab',
|
title: 'Open in custom tab',
|
||||||
subtitle: 'Open in a separate window',
|
subtitle: 'Open in a separate window',
|
||||||
icon: MdiIcons.applicationOutline,
|
icon: MdiIcons.applicationOutline,
|
||||||
trailing: IconButton(
|
onTap: () => openCustomTab(resolveTabMode()),
|
||||||
icon: Icon(
|
|
||||||
MdiIcons.dominoMask,
|
|
||||||
color: appColors.privateTabPurple,
|
|
||||||
size: 24,
|
|
||||||
),
|
|
||||||
tooltip: 'Private',
|
|
||||||
onPressed: () => openCustomTab(true),
|
|
||||||
),
|
|
||||||
onTap: () => openCustomTab(false),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -491,7 +585,7 @@ class _OpenActionTile extends StatelessWidget {
|
|||||||
final IconData icon;
|
final IconData icon;
|
||||||
final Widget? trailing;
|
final Widget? trailing;
|
||||||
final bool showTrailingDivider;
|
final bool showTrailingDivider;
|
||||||
final VoidCallback onTap;
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
const _OpenActionTile({
|
const _OpenActionTile({
|
||||||
required this.title,
|
required this.title,
|
||||||
@@ -499,7 +593,7 @@ class _OpenActionTile extends StatelessWidget {
|
|||||||
required this.icon,
|
required this.icon,
|
||||||
this.trailing,
|
this.trailing,
|
||||||
this.showTrailingDivider = true,
|
this.showTrailingDivider = true,
|
||||||
required this.onTap,
|
this.onTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@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/selected_tab.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/tab_state.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/repositories/container.dart';
|
|
||||||
|
|
||||||
part 'providers.g.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
|
/// Installs the current tab as a PWA, embedding profile and container context
|
||||||
/// in the shortcut intent so the PWA reopens with the same isolation.
|
/// in the shortcut intent so the PWA reopens with the same isolation.
|
||||||
@Riverpod()
|
@Riverpod()
|
||||||
Future<bool> installCurrentWebApp(Ref ref) async {
|
Future<bool> installCurrentWebApp(
|
||||||
|
Ref ref, {
|
||||||
|
String? overrideName,
|
||||||
|
String? contextId,
|
||||||
|
}) async {
|
||||||
final selectedTabId = ref.read(selectedTabProvider);
|
final selectedTabId = ref.read(selectedTabProvider);
|
||||||
|
|
||||||
if (selectedTabId == null) {
|
if (selectedTabId == null) {
|
||||||
@@ -104,18 +106,12 @@ Future<bool> installCurrentWebApp(Ref ref) async {
|
|||||||
|
|
||||||
final profileUuid = filesystem.selectedProfile.uuid;
|
final profileUuid = filesystem.selectedProfile.uuid;
|
||||||
|
|
||||||
final selectedContainerId = ref.read(selectedContainerProvider);
|
return GeckoPwaApi().installWebApp(
|
||||||
String? contextId;
|
selectedTabId,
|
||||||
|
profileUuid,
|
||||||
if (selectedContainerId != null) {
|
contextId,
|
||||||
final containerRepository = ref.read(containerRepositoryProvider.notifier);
|
overrideName,
|
||||||
final containerData = await containerRepository.getContainerData(
|
);
|
||||||
selectedContainerId,
|
|
||||||
);
|
|
||||||
contextId = containerData?.metadata.contextualIdentity;
|
|
||||||
}
|
|
||||||
|
|
||||||
return GeckoPwaApi().installWebApp(selectedTabId, profileUuid, contextId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns all installed PWAs.
|
/// 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.
|
/// Creates a basic bookmark shortcut on the home screen for the current tab.
|
||||||
@Riverpod()
|
@Riverpod()
|
||||||
Future<bool> installBasicShortcut(Ref ref, {String? overrideName}) async {
|
Future<bool> installBasicShortcut(
|
||||||
|
Ref ref, {
|
||||||
|
String? overrideName,
|
||||||
|
String? contextId,
|
||||||
|
}) async {
|
||||||
final selectedTabId = ref.read(selectedTabProvider);
|
final selectedTabId = ref.read(selectedTabProvider);
|
||||||
|
|
||||||
if (selectedTabId == null) {
|
if (selectedTabId == null) {
|
||||||
@@ -148,17 +148,6 @@ Future<bool> installBasicShortcut(Ref ref, {String? overrideName}) async {
|
|||||||
|
|
||||||
final profileUuid = filesystem.selectedProfile.uuid;
|
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(
|
return GeckoPwaApi().installBasicShortcut(
|
||||||
selectedTabId,
|
selectedTabId,
|
||||||
profileUuid,
|
profileUuid,
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ String _$isCurrentTabInstallableHash() =>
|
|||||||
/// in the shortcut intent so the PWA reopens with the same isolation.
|
/// in the shortcut intent so the PWA reopens with the same isolation.
|
||||||
|
|
||||||
@ProviderFor(installCurrentWebApp)
|
@ProviderFor(installCurrentWebApp)
|
||||||
final installCurrentWebAppProvider = InstallCurrentWebAppProvider._();
|
final installCurrentWebAppProvider = InstallCurrentWebAppFamily._();
|
||||||
|
|
||||||
/// Installs the current tab as a PWA, embedding profile and container context
|
/// Installs the current tab as a PWA, embedding profile and container context
|
||||||
/// in the shortcut intent so the PWA reopens with the same isolation.
|
/// in the shortcut intent so the PWA reopens with the same isolation.
|
||||||
@@ -223,20 +223,27 @@ final class InstallCurrentWebAppProvider
|
|||||||
with $FutureModifier<bool>, $FutureProvider<bool> {
|
with $FutureModifier<bool>, $FutureProvider<bool> {
|
||||||
/// Installs the current tab as a PWA, embedding profile and container context
|
/// Installs the current tab as a PWA, embedding profile and container context
|
||||||
/// in the shortcut intent so the PWA reopens with the same isolation.
|
/// in the shortcut intent so the PWA reopens with the same isolation.
|
||||||
InstallCurrentWebAppProvider._()
|
InstallCurrentWebAppProvider._({
|
||||||
: super(
|
required InstallCurrentWebAppFamily super.from,
|
||||||
from: null,
|
required ({String? overrideName, String? contextId}) super.argument,
|
||||||
argument: null,
|
}) : super(
|
||||||
retry: null,
|
retry: null,
|
||||||
name: r'installCurrentWebAppProvider',
|
name: r'installCurrentWebAppProvider',
|
||||||
isAutoDispose: true,
|
isAutoDispose: true,
|
||||||
dependencies: null,
|
dependencies: null,
|
||||||
$allTransitiveDependencies: null,
|
$allTransitiveDependencies: null,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String debugGetCreateSourceHash() => _$installCurrentWebAppHash();
|
String debugGetCreateSourceHash() => _$installCurrentWebAppHash();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return r'installCurrentWebAppProvider'
|
||||||
|
''
|
||||||
|
'$argument';
|
||||||
|
}
|
||||||
|
|
||||||
@$internal
|
@$internal
|
||||||
@override
|
@override
|
||||||
$FutureProviderElement<bool> $createElement($ProviderPointer pointer) =>
|
$FutureProviderElement<bool> $createElement($ProviderPointer pointer) =>
|
||||||
@@ -244,12 +251,61 @@ final class InstallCurrentWebAppProvider
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
FutureOr<bool> create(Ref ref) {
|
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() =>
|
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.
|
/// Returns all installed PWAs.
|
||||||
|
|
||||||
@@ -357,7 +413,7 @@ final class InstallBasicShortcutProvider
|
|||||||
/// Creates a basic bookmark shortcut on the home screen for the current tab.
|
/// Creates a basic bookmark shortcut on the home screen for the current tab.
|
||||||
InstallBasicShortcutProvider._({
|
InstallBasicShortcutProvider._({
|
||||||
required InstallBasicShortcutFamily super.from,
|
required InstallBasicShortcutFamily super.from,
|
||||||
required String? super.argument,
|
required ({String? overrideName, String? contextId}) super.argument,
|
||||||
}) : super(
|
}) : super(
|
||||||
retry: null,
|
retry: null,
|
||||||
name: r'installBasicShortcutProvider',
|
name: r'installBasicShortcutProvider',
|
||||||
@@ -373,7 +429,7 @@ final class InstallBasicShortcutProvider
|
|||||||
String toString() {
|
String toString() {
|
||||||
return r'installBasicShortcutProvider'
|
return r'installBasicShortcutProvider'
|
||||||
''
|
''
|
||||||
'($argument)';
|
'$argument';
|
||||||
}
|
}
|
||||||
|
|
||||||
@$internal
|
@$internal
|
||||||
@@ -383,8 +439,13 @@ final class InstallBasicShortcutProvider
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
FutureOr<bool> create(Ref ref) {
|
FutureOr<bool> create(Ref ref) {
|
||||||
final argument = this.argument as String?;
|
final argument =
|
||||||
return installBasicShortcut(ref, overrideName: argument);
|
this.argument as ({String? overrideName, String? contextId});
|
||||||
|
return installBasicShortcut(
|
||||||
|
ref,
|
||||||
|
overrideName: argument.overrideName,
|
||||||
|
contextId: argument.contextId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -399,12 +460,16 @@ final class InstallBasicShortcutProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$installBasicShortcutHash() =>
|
String _$installBasicShortcutHash() =>
|
||||||
r'aa8a96e94eac19e3e0b2a087bc6dde12d28f48f2';
|
r'fcb5bec79f375b32a7a0953ad22859a0db222167';
|
||||||
|
|
||||||
/// Creates a basic bookmark shortcut on the home screen for the current tab.
|
/// Creates a basic bookmark shortcut on the home screen for the current tab.
|
||||||
|
|
||||||
final class InstallBasicShortcutFamily extends $Family
|
final class InstallBasicShortcutFamily extends $Family
|
||||||
with $FunctionalFamilyOverride<FutureOr<bool>, String?> {
|
with
|
||||||
|
$FunctionalFamilyOverride<
|
||||||
|
FutureOr<bool>,
|
||||||
|
({String? overrideName, String? contextId})
|
||||||
|
> {
|
||||||
InstallBasicShortcutFamily._()
|
InstallBasicShortcutFamily._()
|
||||||
: super(
|
: super(
|
||||||
retry: null,
|
retry: null,
|
||||||
@@ -416,8 +481,13 @@ final class InstallBasicShortcutFamily extends $Family
|
|||||||
|
|
||||||
/// Creates a basic bookmark shortcut on the home screen for the current tab.
|
/// Creates a basic bookmark shortcut on the home screen for the current tab.
|
||||||
|
|
||||||
InstallBasicShortcutProvider call({String? overrideName}) =>
|
InstallBasicShortcutProvider call({
|
||||||
InstallBasicShortcutProvider._(argument: overrideName, from: this);
|
String? overrideName,
|
||||||
|
String? contextId,
|
||||||
|
}) => InstallBasicShortcutProvider._(
|
||||||
|
argument: (overrideName: overrideName, contextId: contextId),
|
||||||
|
from: this,
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => r'installBasicShortcutProvider';
|
String toString() => r'installBasicShortcutProvider';
|
||||||
|
|||||||
+307
-91
@@ -18,41 +18,67 @@
|
|||||||
* 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: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/uri_breadcrumb.dart';
|
||||||
import 'package:weblibre/presentation/widgets/url_icon.dart';
|
import 'package:weblibre/presentation/widgets/url_icon.dart';
|
||||||
|
|
||||||
/// The type of home screen shortcut the user chose.
|
/// The type of home screen shortcut the user chose.
|
||||||
enum ShortcutInstallType { shortcut, app }
|
enum ShortcutInstallType { shortcut, app }
|
||||||
|
|
||||||
/// Shows a bottom sheet to confirm adding a PWA to the home screen.
|
/// Result of the install configuration sheets.
|
||||||
///
|
class PwaInstallConfig {
|
||||||
/// Returns true if the user confirms, null if dismissed.
|
final String name;
|
||||||
Future<bool?> showPwaInstallBottomSheet(
|
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, {
|
BuildContext context, {
|
||||||
required String name,
|
required String defaultName,
|
||||||
required Uri url,
|
required Uri url,
|
||||||
}) {
|
}) {
|
||||||
return showModalBottomSheet<bool>(
|
return showModalBottomSheet<ShortcutInstallConfig>(
|
||||||
context: context,
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
),
|
),
|
||||||
builder: (context) => SafeArea(
|
builder: (context) => Padding(
|
||||||
child: Column(
|
padding: EdgeInsets.only(
|
||||||
mainAxisSize: MainAxisSize.min,
|
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||||
children: [
|
),
|
||||||
_ShortcutSheetHeader(name: name, url: url),
|
child: _InstallConfigSheet(
|
||||||
const Divider(height: 1),
|
defaultName: defaultName,
|
||||||
ListTile(
|
url: url,
|
||||||
leading: const Icon(Icons.install_mobile),
|
showAppOption: true,
|
||||||
title: const Text('Install as App'),
|
showShortcutOption: false,
|
||||||
subtitle: const Text('Runs standalone with its own window.'),
|
|
||||||
onTap: () => Navigator.of(context).pop(true),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
).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
|
/// 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
|
/// [showAppOption] controls whether the "Install as App" option is visible
|
||||||
/// (requires the allowNonManifestPwaInstall setting to be enabled).
|
/// (requires the allowNonManifestPwaInstall setting to be enabled).
|
||||||
///
|
Future<ShortcutInstallConfig?> showShortcutChoiceBottomSheet(
|
||||||
/// Returns [ShortcutInstallType] or null if dismissed.
|
|
||||||
Future<ShortcutInstallType?> showShortcutChoiceBottomSheet(
|
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required String name,
|
required String defaultName,
|
||||||
required Uri url,
|
required Uri url,
|
||||||
required bool showAppOption,
|
required bool showAppOption,
|
||||||
}) {
|
}) {
|
||||||
return showModalBottomSheet<ShortcutInstallType>(
|
return showModalBottomSheet<ShortcutInstallConfig>(
|
||||||
context: context,
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
),
|
),
|
||||||
builder: (context) => SafeArea(
|
builder: (context) => Padding(
|
||||||
child: Column(
|
padding: EdgeInsets.only(
|
||||||
mainAxisSize: MainAxisSize.min,
|
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||||
children: [
|
),
|
||||||
_ShortcutSheetHeader(name: name, url: url),
|
child: _InstallConfigSheet(
|
||||||
const Divider(height: 1),
|
defaultName: defaultName,
|
||||||
if (showAppOption)
|
url: url,
|
||||||
ListTile(
|
showAppOption: showAppOption,
|
||||||
leading: const Icon(Icons.install_mobile),
|
showShortcutOption: true,
|
||||||
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 {
|
/// Internal storage options backing the radio selector.
|
||||||
final String name;
|
sealed class _StorageOption {
|
||||||
final Uri url;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
return Container(
|
final nameController = useTextEditingController(text: defaultName);
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.all(16),
|
final selectedTabId = ref.watch(selectedTabProvider);
|
||||||
decoration: BoxDecoration(
|
final tabContextId = selectedTabId != null
|
||||||
color: colorScheme.surfaceContainerHighest,
|
? ref.watch(tabStateProvider(selectedTabId))?.contextId
|
||||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
|
: null;
|
||||||
),
|
final containerAsync = ref.watch(selectedContainerDataProvider);
|
||||||
child: Column(
|
final containerData = containerAsync.asData?.value;
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
final options = useMemoized<List<_StorageOption>>(
|
||||||
Text('Add to Home Screen', style: textTheme.titleMedium),
|
() {
|
||||||
const SizedBox(height: 12),
|
final list = <_StorageOption>[const _StorageDefault()];
|
||||||
Row(
|
|
||||||
children: [
|
// If the current tab is in an isolated context, offer to inherit it.
|
||||||
RepaintBoundary(child: UrlIcon([url], iconSize: 32)),
|
if (isIsolatedContextId(tabContextId)) {
|
||||||
const SizedBox(width: 14),
|
list.add(_StorageInheritIsolated(tabContextId!));
|
||||||
Expanded(
|
}
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
// If a regular (non-isolated) container is active, offer it.
|
||||||
children: [
|
final containerContextId = containerData?.metadata.contextualIdentity;
|
||||||
Text(
|
if (containerData != null &&
|
||||||
name,
|
containerContextId != null &&
|
||||||
maxLines: 1,
|
!isIsolatedContextId(containerContextId)) {
|
||||||
overflow: TextOverflow.ellipsis,
|
list.add(
|
||||||
style: textTheme.bodyMedium?.copyWith(
|
_StorageContainer(
|
||||||
fontWeight: FontWeight.w600,
|
label: containerData.name ?? 'Container',
|
||||||
),
|
contextId: containerContextId,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 3),
|
);
|
||||||
UriBreadcrumb(
|
}
|
||||||
uri: url,
|
|
||||||
style: textTheme.bodySmall?.copyWith(
|
list.add(_StorageNewIsolated());
|
||||||
color: colorScheme.onSurfaceVariant,
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+54
-31
@@ -32,44 +32,52 @@ import 'package:weblibre/utils/ui_helper.dart';
|
|||||||
Future<void> showPwaInstallDialog(BuildContext context, WidgetRef ref) async {
|
Future<void> showPwaInstallDialog(BuildContext context, WidgetRef ref) async {
|
||||||
final selectedTabId = ref.read(selectedTabProvider);
|
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 defaultName =
|
||||||
|
manifest?.shortName ?? manifest?.name ?? 'this web app';
|
||||||
final tabState = selectedTabId != null
|
final tabState = selectedTabId != null
|
||||||
? ref.read(tabStateProvider(selectedTabId))
|
? ref.read(tabStateProvider(selectedTabId))
|
||||||
: null;
|
: null;
|
||||||
final url = tabState?.url ?? Uri.parse('about:blank');
|
final url = tabState?.url ?? Uri.parse('about:blank');
|
||||||
|
|
||||||
final confirmed = await showPwaInstallBottomSheet(
|
final config = await showPwaInstallBottomSheet(
|
||||||
context,
|
context,
|
||||||
name: name,
|
defaultName: defaultName,
|
||||||
url: url,
|
url: url,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed == true) {
|
if (config == null) return;
|
||||||
try {
|
|
||||||
final success = await ref.read(installCurrentWebAppProvider.future);
|
|
||||||
|
|
||||||
if (context.mounted) {
|
final name = config.name;
|
||||||
if (success) {
|
|
||||||
showInfoMessage(context, '$name added to home screen');
|
try {
|
||||||
} else {
|
final success = await ref.read(
|
||||||
showErrorMessage(
|
installCurrentWebAppProvider(
|
||||||
context,
|
overrideName: name == defaultName ? null : name,
|
||||||
'Failed to add $name. The site may not support installation.',
|
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) {
|
if (context.mounted) {
|
||||||
var errorMessage = 'Failed to add $name to home screen';
|
var errorMessage = 'Failed to add $name to home screen';
|
||||||
|
|
||||||
if (e is StateError) {
|
if (e is StateError) {
|
||||||
errorMessage = 'No tab selected. Please try again.';
|
errorMessage = 'No tab selected. Please try again.';
|
||||||
}
|
|
||||||
|
|
||||||
showErrorMessage(context, errorMessage);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showErrorMessage(context, errorMessage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,28 +92,43 @@ Future<void> showShortcutInstallDialog(
|
|||||||
if (selectedTabId == null) return;
|
if (selectedTabId == null) return;
|
||||||
|
|
||||||
final tabState = ref.read(tabStateProvider(selectedTabId));
|
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 url = tabState?.url ?? Uri.parse('about:blank');
|
||||||
|
|
||||||
final settings = ref.read(generalSettingsWithDefaultsProvider);
|
final settings = ref.read(generalSettingsWithDefaultsProvider);
|
||||||
final showAppOption = settings.allowNonManifestPwaInstall;
|
final showAppOption = settings.allowNonManifestPwaInstall;
|
||||||
|
|
||||||
final choice = await showShortcutChoiceBottomSheet(
|
final config = await showShortcutChoiceBottomSheet(
|
||||||
context,
|
context,
|
||||||
name: name,
|
defaultName: defaultName,
|
||||||
url: url,
|
url: url,
|
||||||
showAppOption: showAppOption,
|
showAppOption: showAppOption,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (choice == null) return;
|
if (config == null) return;
|
||||||
|
|
||||||
|
final name = config.name;
|
||||||
|
final overrideName = name == defaultName ? null : name;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final bool success;
|
final bool success;
|
||||||
switch (choice) {
|
switch (config.type) {
|
||||||
case ShortcutInstallType.shortcut:
|
case ShortcutInstallType.shortcut:
|
||||||
success = await ref.read(installBasicShortcutProvider().future);
|
success = await ref.read(
|
||||||
|
installBasicShortcutProvider(
|
||||||
|
overrideName: overrideName,
|
||||||
|
contextId: config.contextId,
|
||||||
|
).future,
|
||||||
|
);
|
||||||
case ShortcutInstallType.app:
|
case ShortcutInstallType.app:
|
||||||
success = await ref.read(installCurrentWebAppProvider.future);
|
success = await ref.read(
|
||||||
|
installCurrentWebAppProvider(
|
||||||
|
overrideName: overrideName,
|
||||||
|
contextId: config.contextId,
|
||||||
|
).future,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
|
|||||||
+9
-4
@@ -51,21 +51,26 @@ class SelectedContainer extends _$SelectedContainer {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<SetContainerResult> setContainerId(String id) async {
|
Future<SetContainerResult> setContainerId(
|
||||||
|
String id, {
|
||||||
|
bool Function()? shouldApply,
|
||||||
|
}) async {
|
||||||
final container = await ref
|
final container = await ref
|
||||||
.read(containerRepositoryProvider.notifier)
|
.read(containerRepositoryProvider.notifier)
|
||||||
.getContainerData(id);
|
.getContainerData(id);
|
||||||
|
|
||||||
if (ref.mounted && container != null) {
|
bool canApply() => shouldApply?.call() ?? true;
|
||||||
|
|
||||||
|
if (ref.mounted && container != null && canApply()) {
|
||||||
if (container.metadata.useProxy) {
|
if (container.metadata.useProxy) {
|
||||||
final proxyPluginHealthy = await GeckoContainerProxyService()
|
final proxyPluginHealthy = await GeckoContainerProxyService()
|
||||||
.healthcheck();
|
.healthcheck();
|
||||||
|
|
||||||
if (proxyPluginHealthy) {
|
if (ref.mounted && proxyPluginHealthy && canApply()) {
|
||||||
state = id;
|
state = id;
|
||||||
return SetContainerResult.successHasProxy;
|
return SetContainerResult.successHasProxy;
|
||||||
}
|
}
|
||||||
} else {
|
} else if (canApply()) {
|
||||||
state = id;
|
state = id;
|
||||||
return SetContainerResult.success;
|
return SetContainerResult.success;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -41,7 +41,7 @@ final class SelectedContainerProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$selectedContainerHash() => r'3d30966f0b8a8ee091afb48fb045be2274f4f417';
|
String _$selectedContainerHash() => r'1f0828ce1d3f8b88fd2731a7aae2602a7323092c';
|
||||||
|
|
||||||
abstract class _$SelectedContainer extends $Notifier<String?> {
|
abstract class _$SelectedContainer extends $Notifier<String?> {
|
||||||
String? build();
|
String? build();
|
||||||
|
|||||||
+15
-3
@@ -36,8 +36,14 @@ import 'package:weblibre/features/user/domain/repositories/general_settings.dart
|
|||||||
/// Long pressing opens the edit screen for the selected container.
|
/// Long pressing opens the edit screen for the selected container.
|
||||||
class CompactContainerSelector extends ConsumerWidget {
|
class CompactContainerSelector extends ConsumerWidget {
|
||||||
final ContainerData? selectedContainer;
|
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
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@@ -73,6 +79,14 @@ class CompactContainerSelector extends ConsumerWidget {
|
|||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final selection = await const ContainerSelectionRoute()
|
final selection = await const ContainerSelectionRoute()
|
||||||
.push<ContainerSelectionResult?>(context);
|
.push<ContainerSelectionResult?>(context);
|
||||||
|
if (selection == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onSelectionChanged != null) {
|
||||||
|
await onSelectionChanged!(selection);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
switch (selection) {
|
switch (selection) {
|
||||||
case ContainerSelectionSelected(:final containerId):
|
case ContainerSelectionSelected(:final containerId):
|
||||||
@@ -81,8 +95,6 @@ class CompactContainerSelector extends ConsumerWidget {
|
|||||||
.setContainerId(containerId);
|
.setContainerId(containerId);
|
||||||
case ContainerSelectionUnassigned():
|
case ContainerSelectionUnassigned():
|
||||||
ref.read(selectedContainerProvider.notifier).clearContainer();
|
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/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
import 'package:fast_equatable/fast_equatable.dart';
|
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';
|
import 'package:weblibre/utils/input_classification.dart';
|
||||||
|
|
||||||
sealed class SharedContent with FastEquatable {
|
sealed class SharedContent with FastEquatable {
|
||||||
final String? contextId;
|
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) {
|
if (parseSharedIntentUrl(content) case final Uri uri) {
|
||||||
return SharedUrl(uri, contextId: contextId);
|
return SharedUrl(uri, contextId: contextId, containerMode: containerMode);
|
||||||
} else {
|
} else {
|
||||||
return SharedText(content, contextId: contextId);
|
return SharedText(
|
||||||
|
content,
|
||||||
|
contextId: contextId,
|
||||||
|
containerMode: containerMode,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get hashParameters => [contextId];
|
List<Object?> get hashParameters => [contextId, containerMode];
|
||||||
}
|
}
|
||||||
|
|
||||||
final class SharedUrl extends SharedContent {
|
final class SharedUrl extends SharedContent {
|
||||||
final Uri url;
|
final Uri url;
|
||||||
|
|
||||||
SharedUrl(this.url, {super.contextId});
|
SharedUrl(this.url, {super.contextId, super.containerMode});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => url.toString();
|
String toString() => url.toString();
|
||||||
@@ -52,7 +65,7 @@ final class SharedUrl extends SharedContent {
|
|||||||
final class SharedText extends SharedContent {
|
final class SharedText extends SharedContent {
|
||||||
final String text;
|
final String text;
|
||||||
|
|
||||||
SharedText(this.text, {super.contextId});
|
SharedText(this.text, {super.contextId, super.containerMode});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => text;
|
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/core/logger.dart';
|
||||||
import 'package:weblibre/data/models/received_intent_parameter.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/intent_gatekeeper/domain/services/intent_gatekeeper.dart';
|
||||||
|
import 'package:weblibre/features/share_intent/domain/entities/intent_container_mode.dart';
|
||||||
|
|
||||||
part 'sharing_intent.g.dart';
|
part 'sharing_intent.g.dart';
|
||||||
|
|
||||||
StreamTransformer<Intent, ReceivedIntentParameter>
|
StreamTransformer<Intent, ReceivedIntentParameter>
|
||||||
_buildSharingIntentTransformer(IntentGatekeeper gatekeeper) =>
|
_buildSharingIntentTransformer(
|
||||||
StreamTransformer<Intent, ReceivedIntentParameter>.fromHandlers(
|
IntentGatekeeper gatekeeper,
|
||||||
handleData: (intent, sink) async {
|
) => StreamTransformer<Intent, ReceivedIntentParameter>.fromHandlers(
|
||||||
// PWA shortcut intents carry our own signed context id — always allow.
|
handleData: (intent, sink) async {
|
||||||
final pwaContextId =
|
final shortcutContextId = intent.action == 'android.intent.action.VIEW'
|
||||||
intent.action == 'android.intent.action.VIEW'
|
? intent.extra['pwa_context_id'] as String?
|
||||||
? intent.extra['pwa_context_id'] as String?
|
: null;
|
||||||
: 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(
|
||||||
final allowed = await gatekeeper.shouldAllow(
|
fromPackageName: intent.fromPackageName,
|
||||||
fromPackageName: intent.fromPackageName,
|
url: intent.data,
|
||||||
url: intent.data,
|
);
|
||||||
);
|
if (!allowed) {
|
||||||
if (!allowed) {
|
logger.i(
|
||||||
logger.i(
|
'Blocked intent from ${intent.fromPackageName ?? 'unknown app'}',
|
||||||
'Blocked intent from ${intent.fromPackageName ?? 'unknown app'}',
|
);
|
||||||
);
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final data = switch (intent.action) {
|
final data = switch (intent.action) {
|
||||||
'android.intent.action.PROCESS_TEXT' =>
|
'android.intent.action.PROCESS_TEXT' =>
|
||||||
intent.extra['android.intent.extra.PROCESS_TEXT'] as String?,
|
intent.extra['android.intent.extra.PROCESS_TEXT'] as String?,
|
||||||
'android.intent.action.WEB_SEARCH' =>
|
'android.intent.action.WEB_SEARCH' => intent.extra['query'] as String?,
|
||||||
intent.extra['query'] as String?,
|
'android.intent.action.VIEW' => intent.data,
|
||||||
'android.intent.action.VIEW' => intent.data,
|
'android.intent.action.SEND' =>
|
||||||
'android.intent.action.SEND' =>
|
intent.extra['android.intent.extra.STREAM'] as String? ??
|
||||||
intent.extra['android.intent.extra.STREAM'] as String? ??
|
intent.extra['android.intent.extra.TEXT'] as String?,
|
||||||
intent.extra['android.intent.extra.TEXT'] as String?,
|
_ => null,
|
||||||
_ => null,
|
};
|
||||||
};
|
|
||||||
|
|
||||||
// Extract container context from shortcut intents
|
// Extract container context from shortcut intents.
|
||||||
final contextId = pwaContextId;
|
final contextId = shortcutContextId;
|
||||||
|
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
if (uri_to_file.isUriSupported(data)) {
|
if (uri_to_file.isUriSupported(data)) {
|
||||||
var path = data;
|
var path = data;
|
||||||
if (p.extension(data).whenNotEmpty == null) {
|
if (p.extension(data).whenNotEmpty == null) {
|
||||||
if (intent.mimeType.whenNotEmpty != null) {
|
if (intent.mimeType.whenNotEmpty != null) {
|
||||||
final ext = mime.extensionFromMime(intent.mimeType!);
|
final ext = mime.extensionFromMime(intent.mimeType!);
|
||||||
if (ext != null) {
|
if (ext != null) {
|
||||||
path = p.setExtension(path, '.$ext');
|
path = p.setExtension(path, '.$ext');
|
||||||
} else {
|
} else {
|
||||||
logger.w(
|
logger.w(
|
||||||
'Could not determine file extension for: ${intent.mimeType}',
|
'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),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
sink.add(
|
logger.w('Received intent without extension and mime type $path');
|
||||||
ReceivedIntentParameter(data, null, contextId: contextId),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
);
|
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)
|
@Riverpod(keepAlive: true)
|
||||||
Raw<Stream<ReceivedIntentParameter>> sharingIntentStream(Ref ref) {
|
Raw<Stream<ReceivedIntentParameter>> sharingIntentStream(Ref ref) {
|
||||||
|
|||||||
+5
@@ -16,11 +16,16 @@ object PwaConstants {
|
|||||||
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"
|
const val EXTRA_SHORTCUT_TYPE = "shortcut_type"
|
||||||
|
const val EXTRA_SHORTCUT_CONTAINER_MODE = "shortcut_container_mode"
|
||||||
|
|
||||||
// Shortcut type values
|
// Shortcut type values
|
||||||
const val SHORTCUT_TYPE_BASIC = "basic"
|
const val SHORTCUT_TYPE_BASIC = "basic"
|
||||||
const val SHORTCUT_TYPE_PWA = "pwa"
|
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
|
// Profile and file paths
|
||||||
const val CURRENT_PROFILE_FILE = "weblibre_profiles/current_profile"
|
const val CURRENT_PROFILE_FILE = "weblibre_profiles/current_profile"
|
||||||
const val PROFILE_MAPPING_PREFS = "pwa_profile_mapping"
|
const val PROFILE_MAPPING_PREFS = "pwa_profile_mapping"
|
||||||
|
|||||||
+12
-4
@@ -222,14 +222,22 @@ class IntentReceiverActivity : Activity() {
|
|||||||
PwaConstants.PROFILE_MAPPING_PREFS,
|
PwaConstants.PROFILE_MAPPING_PREFS,
|
||||||
Context.MODE_PRIVATE,
|
Context.MODE_PRIVATE,
|
||||||
)
|
)
|
||||||
val tokenKey = "${PwaConstants.PROFILE_MAPPING_TOKEN_PREFIX}${intentUrl}::${profileUuid}"
|
// Tokens are keyed by (url, profile, contextId) since each install
|
||||||
if (prefs.getString(tokenKey, null) == token) {
|
// 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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!installStartUrl.isNullOrEmpty() && installStartUrl != intentUrl) {
|
if (!installStartUrl.isNullOrEmpty() && installStartUrl != intentUrl) {
|
||||||
val installTokenKey = "${PwaConstants.PROFILE_MAPPING_TOKEN_PREFIX}${installStartUrl}::${profileUuid}"
|
val installTokenKey = "${PwaConstants.PROFILE_MAPPING_TOKEN_PREFIX}${installStartUrl}::${profileUuid}::${contextId}"
|
||||||
if (prefs.getString(installTokenKey, null) == token) {
|
val legacyInstallTokenKey = "${PwaConstants.PROFILE_MAPPING_TOKEN_PREFIX}${installStartUrl}::${profileUuid}"
|
||||||
|
if (prefs.getString(installTokenKey, null) == token ||
|
||||||
|
prefs.getString(legacyInstallTokenKey, null) == token) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+265
-88
@@ -52,6 +52,14 @@ class GeckoPwaApiImpl(
|
|||||||
private val coroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
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 logger = Logger("GeckoPwaApiImpl")
|
||||||
private val appPrefs by lazy {
|
private val appPrefs by lazy {
|
||||||
context.applicationContext.getSharedPreferences(
|
context.applicationContext.getSharedPreferences(
|
||||||
@@ -68,6 +76,7 @@ class GeckoPwaApiImpl(
|
|||||||
tabId: String?,
|
tabId: String?,
|
||||||
profileUuid: String,
|
profileUuid: String,
|
||||||
contextId: String?,
|
contextId: String?,
|
||||||
|
overrideAppName: String?,
|
||||||
callback: (Result<Boolean>) -> Unit
|
callback: (Result<Boolean>) -> Unit
|
||||||
) {
|
) {
|
||||||
logger.debug("installWebApp called for tabId: $tabId, profileUuid: $profileUuid, contextId: $contextId")
|
logger.debug("installWebApp called for tabId: $tabId, profileUuid: $profileUuid, contextId: $contextId")
|
||||||
@@ -86,7 +95,7 @@ class GeckoPwaApiImpl(
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
val manifest = tab.content.webAppManifest ?: run {
|
val baseManifest = tab.content.webAppManifest ?: run {
|
||||||
// Generate a synthetic manifest for sites without one
|
// Generate a synthetic manifest for sites without one
|
||||||
val url = tab.content.url
|
val url = tab.content.url
|
||||||
val title = tab.content.title.ifBlank { 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}")
|
logger.debug("Installing web app for tab ${tab.id}: ${manifest.startUrl}")
|
||||||
|
|
||||||
val success = createPwaShortcut(
|
val success = createPwaShortcut(
|
||||||
manifest = manifest,
|
manifest = manifest,
|
||||||
profileUuid = profileUuid,
|
profileUuid = profileUuid,
|
||||||
contextId = contextId,
|
contextId = contextId,
|
||||||
|
tabFavicon = tab.content.icon,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
components.core.webAppManifestStorage.saveManifest(manifest)
|
// Persist the unmodified manifest so a second install
|
||||||
storeProfileMapping(manifest.startUrl, profileUuid)
|
// 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}")
|
logger.debug("Web app installation completed for tab ${tab.id}")
|
||||||
} else {
|
} else {
|
||||||
logger.warn("Failed to create PWA shortcut for tab ${tab.id}")
|
logger.warn("Failed to create PWA shortcut for tab ${tab.id}")
|
||||||
@@ -130,6 +148,7 @@ class GeckoPwaApiImpl(
|
|||||||
manifest: WebAppManifest,
|
manifest: WebAppManifest,
|
||||||
profileUuid: String,
|
profileUuid: String,
|
||||||
contextId: String?,
|
contextId: String?,
|
||||||
|
tabFavicon: Bitmap?,
|
||||||
): Boolean = withContext(Dispatchers.Main) {
|
): Boolean = withContext(Dispatchers.Main) {
|
||||||
try {
|
try {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||||
@@ -148,18 +167,30 @@ class GeckoPwaApiImpl(
|
|||||||
return@withContext false
|
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(
|
val launchToken = resolveLaunchToken(
|
||||||
shortcutManager = shortcutManager,
|
shortcutManager = shortcutManager,
|
||||||
shortcutId = shortcutId,
|
shortcutId = shortcutId,
|
||||||
startUrl = manifest.startUrl,
|
startUrl = manifest.startUrl,
|
||||||
profileUuid = profileUuid,
|
profileUuid = profileUuid,
|
||||||
|
contextId = contextId,
|
||||||
)
|
)
|
||||||
|
|
||||||
val appName = manifest.shortName ?: manifest.name ?: "Web App"
|
|
||||||
|
|
||||||
val shortcutIntent = Intent(context, IntentReceiverActivity::class.java).apply {
|
val shortcutIntent = Intent(context, IntentReceiverActivity::class.java).apply {
|
||||||
action = Intent.ACTION_VIEW
|
action = Intent.ACTION_VIEW
|
||||||
data = Uri.parse(manifest.startUrl)
|
data = Uri.parse(manifest.startUrl)
|
||||||
@@ -167,7 +198,11 @@ 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)
|
putExtra(PwaConstants.EXTRA_SHORTCUT_TYPE, ShortcutKind.PWA.shortcutType)
|
||||||
|
putExtra(
|
||||||
|
PwaConstants.EXTRA_SHORTCUT_CONTAINER_MODE,
|
||||||
|
resolveShortcutContainerMode(contextId),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val shortcut = ShortcutInfo.Builder(context, shortcutId).apply {
|
val shortcut = ShortcutInfo.Builder(context, shortcutId).apply {
|
||||||
@@ -176,19 +211,17 @@ class GeckoPwaApiImpl(
|
|||||||
setIntent(shortcutIntent)
|
setIntent(shortcutIntent)
|
||||||
|
|
||||||
if (iconBitmap != null) {
|
if (iconBitmap != null) {
|
||||||
// Only use adaptive bitmap for maskable icons (designed for adaptive shapes)
|
// Always use createWithBitmap (never createWithAdaptiveBitmap):
|
||||||
// Regular icons should use createWithBitmap to display as-is
|
// Launcher3's pin-preview routes adaptive icons through
|
||||||
if (isMaskable && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
// AdaptiveIconDrawable + BitmapShader and promotes intermediates
|
||||||
setIcon(Icon.createWithAdaptiveBitmap(iconBitmap))
|
// to HARDWARE, crashing the software preview canvas.
|
||||||
} else {
|
setIcon(Icon.createWithBitmap(iconBitmap))
|
||||||
setIcon(Icon.createWithBitmap(iconBitmap))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
// Update existing shortcut intent if one exists with the same ID
|
// Update an existing install of the same kind in place. Some
|
||||||
// (e.g. upgrading a basic shortcut to PWA). requestPinShortcut alone
|
// launchers reuse cached shortcut metadata unless we explicitly
|
||||||
// may reuse the cached intent on some launchers.
|
// refresh the pinned record first.
|
||||||
updateExistingShortcut(shortcutManager, shortcut)
|
updateExistingShortcut(shortcutManager, shortcut)
|
||||||
|
|
||||||
val success = shortcutManager.requestPinShortcut(shortcut, null)
|
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 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) }
|
val hex = hash.take(16).joinToString("") { "%02x".format(it) }
|
||||||
return "pwa_$hex"
|
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.
|
* Loads the PWA icon from the manifest using BrowserIcons. Requested at
|
||||||
* Returns a pair of (bitmap, isMaskable) to determine proper icon format.
|
* 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 {
|
try {
|
||||||
val iconResource = manifest.icons
|
val iconResource = manifest.icons
|
||||||
.filter { it.purpose.contains(WebAppManifest.Icon.Purpose.MASKABLE) ||
|
.filter { it.purpose.contains(WebAppManifest.Icon.Purpose.MASKABLE) ||
|
||||||
it.purpose.contains(WebAppManifest.Icon.Purpose.ANY) }
|
it.purpose.contains(WebAppManifest.Icon.Purpose.ANY) }
|
||||||
.maxByOrNull { (it.sizes?.maxOf { size -> size.width * size.height } ?: 0) }
|
.maxByOrNull { (it.sizes?.maxOf { size -> size.width * size.height } ?: 0) }
|
||||||
?: manifest.icons.firstOrNull()
|
?: manifest.icons.firstOrNull()
|
||||||
|
?: return@withContext null
|
||||||
|
|
||||||
if (iconResource != null) {
|
val iconRequest = IconRequest(
|
||||||
val isMaskable = iconResource.purpose.contains(WebAppManifest.Icon.Purpose.MASKABLE)
|
url = manifest.startUrl,
|
||||||
val iconRequest = IconRequest(
|
size = IconRequest.Size.LAUNCHER,
|
||||||
url = manifest.startUrl,
|
resources = listOf(
|
||||||
size = IconRequest.Size.LAUNCHER_ADAPTIVE,
|
IconRequest.Resource(
|
||||||
resources = listOf(
|
url = iconResource.src,
|
||||||
IconRequest.Resource(
|
type = IconRequest.Resource.Type.MANIFEST_ICON,
|
||||||
url = iconResource.src,
|
sizes = iconResource.sizes?.map { size ->
|
||||||
type = IconRequest.Resource.Type.MANIFEST_ICON,
|
mozilla.components.concept.engine.manifest.Size(size.width, size.height)
|
||||||
sizes = iconResource.sizes?.map { size ->
|
} ?: emptyList(),
|
||||||
mozilla.components.concept.engine.manifest.Size(size.width, size.height)
|
mimeType = iconResource.type,
|
||||||
} ?: emptyList(),
|
|
||||||
mimeType = iconResource.type,
|
|
||||||
maskable = isMaskable
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
val iconResult = components.core.icons.loadIcon(iconRequest).await()
|
components.core.icons.loadIcon(iconRequest).await()?.bitmap
|
||||||
Pair(iconResult?.bitmap, isMaskable)
|
|
||||||
} else {
|
|
||||||
Pair(null, false)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("Failed to load PWA icon", e)
|
logger.error("Failed to load PWA icon", e)
|
||||||
Pair(null, false)
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,16 +444,23 @@ class GeckoPwaApiImpl(
|
|||||||
return@withContext false
|
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(
|
val launchToken = resolveLaunchToken(
|
||||||
shortcutManager = shortcutManager,
|
shortcutManager = shortcutManager,
|
||||||
shortcutId = shortcutId,
|
shortcutId = shortcutId,
|
||||||
startUrl = url,
|
startUrl = url,
|
||||||
profileUuid = profileUuid,
|
profileUuid = profileUuid,
|
||||||
|
contextId = contextId,
|
||||||
)
|
)
|
||||||
|
|
||||||
val shortLabel = title.ifBlank { url }
|
|
||||||
|
|
||||||
val shortcutIntent = Intent(context, IntentReceiverActivity::class.java).apply {
|
val shortcutIntent = Intent(context, IntentReceiverActivity::class.java).apply {
|
||||||
action = Intent.ACTION_VIEW
|
action = Intent.ACTION_VIEW
|
||||||
data = Uri.parse(url)
|
data = Uri.parse(url)
|
||||||
@@ -352,7 +468,11 @@ 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, url)
|
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)
|
val icon = loadTabIcon(url, tabIcon)
|
||||||
@@ -364,7 +484,7 @@ class GeckoPwaApiImpl(
|
|||||||
icon?.let { setIcon(it) }
|
icon?.let { setIcon(it) }
|
||||||
}.build()
|
}.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)
|
updateExistingShortcut(shortcutManager, shortcut)
|
||||||
|
|
||||||
val success = shortcutManager.requestPinShortcut(shortcut, null)
|
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 {
|
||||||
// Try using the tab's existing favicon first
|
tabIcon?.takeUnless { it.isRecycled }
|
||||||
val bitmap = tabIcon?.takeUnless { it.isRecycled }
|
|
||||||
?: run {
|
?: run {
|
||||||
// Fall back to loading via BrowserIcons
|
|
||||||
val iconRequest = IconRequest(
|
val iconRequest = IconRequest(
|
||||||
url = url,
|
url = url,
|
||||||
size = IconRequest.Size.LAUNCHER,
|
size = IconRequest.Size.LAUNCHER,
|
||||||
)
|
)
|
||||||
components.core.icons.loadIcon(iconRequest).await()?.bitmap
|
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) {
|
} catch (e: Exception) {
|
||||||
logger.error("Failed to load tab icon", e)
|
logger.error("Failed to load tab favicon bitmap", e)
|
||||||
null
|
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).
|
* Extracts the scope from a URL (origin + path up to last segment).
|
||||||
*/
|
*/
|
||||||
@@ -425,14 +551,51 @@ class GeckoPwaApiImpl(
|
|||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
try {
|
try {
|
||||||
val storage = components.core.webAppManifestStorage
|
val storage = components.core.webAppManifestStorage
|
||||||
val manifests = storage.loadShareableManifests(System.currentTimeMillis())
|
|
||||||
val currentProfileUuid = getCurrentProfileUuid()
|
val currentProfileUuid = getCurrentProfileUuid()
|
||||||
val pwaManifests = manifests.filter { manifest ->
|
|
||||||
val mappedProfile = getProfileMapping(manifest.startUrl)
|
// Pinned shortcuts are the source of truth for installs: each
|
||||||
currentProfileUuid == null || mappedProfile == null || mappedProfile == currentProfileUuid
|
// pinned shortcut carries its own label, profile, and
|
||||||
}.map { manifest ->
|
// contextId in its intent extras. Two installs of the same
|
||||||
manifest.toPwaManifest()
|
// 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")
|
logger.debug("Found ${pwaManifests.size} installed web apps")
|
||||||
callback(Result.success(pwaManifests))
|
callback(Result.success(pwaManifests))
|
||||||
} catch (e: Exception) {
|
} 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(
|
private fun resolveLaunchToken(
|
||||||
shortcutManager: ShortcutManager,
|
shortcutManager: ShortcutManager,
|
||||||
shortcutId: String,
|
shortcutId: String,
|
||||||
startUrl: String,
|
startUrl: String,
|
||||||
profileUuid: String,
|
profileUuid: String,
|
||||||
|
contextId: String?,
|
||||||
): String {
|
): String {
|
||||||
val storedToken = getStoredLaunchToken(startUrl, profileUuid)
|
val storedToken = getStoredLaunchToken(startUrl, profileUuid, contextId)
|
||||||
val existingShortcutToken = shortcutManager.pinnedShortcuts
|
val existingShortcutToken = shortcutManager.pinnedShortcuts
|
||||||
.firstOrNull { shortcut -> shortcut.id == shortcutId }
|
.firstOrNull { shortcut -> shortcut.id == shortcutId }
|
||||||
?.intent
|
?.intent
|
||||||
@@ -464,7 +622,7 @@ class GeckoPwaApiImpl(
|
|||||||
?.getStringExtra(PwaConstants.EXTRA_PWA_TOKEN)
|
?.getStringExtra(PwaConstants.EXTRA_PWA_TOKEN)
|
||||||
|
|
||||||
if (!existingShortcutToken.isNullOrEmpty()) {
|
if (!existingShortcutToken.isNullOrEmpty()) {
|
||||||
val committed = storeLaunchToken(startUrl, profileUuid, existingShortcutToken)
|
val committed = storeLaunchToken(startUrl, profileUuid, contextId, existingShortcutToken)
|
||||||
if (!committed) {
|
if (!committed) {
|
||||||
logger.warn("Failed to persist pinned shortcut PWA token for $startUrl")
|
logger.warn("Failed to persist pinned shortcut PWA token for $startUrl")
|
||||||
}
|
}
|
||||||
@@ -472,7 +630,7 @@ class GeckoPwaApiImpl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!storedToken.isNullOrEmpty()) {
|
if (!storedToken.isNullOrEmpty()) {
|
||||||
val committed = storeLaunchToken(startUrl, profileUuid, storedToken)
|
val committed = storeLaunchToken(startUrl, profileUuid, contextId, storedToken)
|
||||||
if (!committed) {
|
if (!committed) {
|
||||||
logger.warn("Failed to refresh stored PWA launch token index for $startUrl")
|
logger.warn("Failed to refresh stored PWA launch token index for $startUrl")
|
||||||
}
|
}
|
||||||
@@ -480,27 +638,40 @@ class GeckoPwaApiImpl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val generatedToken = UUID.randomUUID().toString()
|
val generatedToken = UUID.randomUUID().toString()
|
||||||
val committed = storeLaunchToken(startUrl, profileUuid, generatedToken)
|
val committed = storeLaunchToken(startUrl, profileUuid, contextId, generatedToken)
|
||||||
if (!committed) {
|
if (!committed) {
|
||||||
logger.warn("Failed to persist PWA launch token for $startUrl")
|
logger.warn("Failed to persist PWA launch token for $startUrl")
|
||||||
}
|
}
|
||||||
return generatedToken
|
return generatedToken
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getStoredLaunchToken(startUrl: String, profileUuid: String): String? {
|
private fun tokenKey(startUrl: String, profileUuid: String, contextId: String?): String {
|
||||||
val tokenKey = "${PwaConstants.PROFILE_MAPPING_TOKEN_PREFIX}${startUrl}::${profileUuid}"
|
// Keyed by (startUrl, profileUuid, contextId) so multiple installs of
|
||||||
return appPrefs.getString(tokenKey, null)
|
// 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 {
|
private fun legacyTokenKey(startUrl: String, profileUuid: String): String {
|
||||||
val tokenKey = "${PwaConstants.PROFILE_MAPPING_TOKEN_PREFIX}${startUrl}::${profileUuid}"
|
return "${PwaConstants.PROFILE_MAPPING_TOKEN_PREFIX}${startUrl}::${profileUuid}"
|
||||||
return appPrefs.edit()
|
|
||||||
.putString(tokenKey, token)
|
|
||||||
.commit()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getProfileMapping(startUrl: String): String? {
|
private fun getStoredLaunchToken(startUrl: String, profileUuid: String, contextId: String?): String? {
|
||||||
return appPrefs.getString(startUrl, null)
|
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? {
|
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(
|
return PwaManifest(
|
||||||
startUrl = startUrl,
|
startUrl = startUrl,
|
||||||
currentUrl = currentUrl,
|
currentUrl = currentUrl,
|
||||||
|
contextId = contextId,
|
||||||
|
installLabel = installLabel,
|
||||||
name = name,
|
name = name,
|
||||||
shortName = shortName,
|
shortName = shortName,
|
||||||
display = display?.name?.lowercase()?.replace("_", "-"),
|
display = display?.name?.lowercase()?.replace("_", "-"),
|
||||||
|
|||||||
+28
-5
@@ -5124,7 +5124,22 @@ data class PwaManifest (
|
|||||||
* The URL of the page when the manifest was detected.
|
* The URL of the page when the manifest was detected.
|
||||||
* Used for HTTPS/installability checks.
|
* 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 {
|
companion object {
|
||||||
@@ -5145,7 +5160,9 @@ data class PwaManifest (
|
|||||||
val preferRelatedApplications = pigeonVar_list[13] as Boolean
|
val preferRelatedApplications = pigeonVar_list[13] as Boolean
|
||||||
val shareTarget = pigeonVar_list[14] as ShareTarget?
|
val shareTarget = pigeonVar_list[14] as ShareTarget?
|
||||||
val currentUrl = pigeonVar_list[15] as String
|
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?> {
|
fun toList(): List<Any?> {
|
||||||
@@ -5166,6 +5183,8 @@ data class PwaManifest (
|
|||||||
preferRelatedApplications,
|
preferRelatedApplications,
|
||||||
shareTarget,
|
shareTarget,
|
||||||
currentUrl,
|
currentUrl,
|
||||||
|
contextId,
|
||||||
|
installLabel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
override fun equals(other: Any?): Boolean {
|
override fun equals(other: Any?): Boolean {
|
||||||
@@ -5176,7 +5195,7 @@ data class PwaManifest (
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
val other = other as PwaManifest
|
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 {
|
override fun hashCode(): Int {
|
||||||
@@ -5197,6 +5216,8 @@ data class PwaManifest (
|
|||||||
result = 31 * result + GeckoPigeonUtils.deepHash(this.preferRelatedApplications)
|
result = 31 * result + GeckoPigeonUtils.deepHash(this.preferRelatedApplications)
|
||||||
result = 31 * result + GeckoPigeonUtils.deepHash(this.shareTarget)
|
result = 31 * result + GeckoPigeonUtils.deepHash(this.shareTarget)
|
||||||
result = 31 * result + GeckoPigeonUtils.deepHash(this.currentUrl)
|
result = 31 * result + GeckoPigeonUtils.deepHash(this.currentUrl)
|
||||||
|
result = 31 * result + GeckoPigeonUtils.deepHash(this.contextId)
|
||||||
|
result = 31 * result + GeckoPigeonUtils.deepHash(this.installLabel)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10635,9 +10656,10 @@ interface GeckoPwaApi {
|
|||||||
* The [tabId] identifies which tab to install from. If null, uses the selected tab.
|
* 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 [profileUuid] is the UUID of the current user profile.
|
||||||
* The [contextId] is the container's contextual identity (optional, null for default container).
|
* 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.
|
* 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. */
|
/** Returns a list of all installed PWA manifests. */
|
||||||
fun getInstalledWebApps(callback: (Result<List<PwaManifest>>) -> Unit)
|
fun getInstalledWebApps(callback: (Result<List<PwaManifest>>) -> Unit)
|
||||||
/**
|
/**
|
||||||
@@ -10672,7 +10694,8 @@ interface GeckoPwaApi {
|
|||||||
val tabIdArg = args[0] as String?
|
val tabIdArg = args[0] as String?
|
||||||
val profileUuidArg = args[1] as String
|
val profileUuidArg = args[1] as String
|
||||||
val contextIdArg = args[2] 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()
|
val error = result.exceptionOrNull()
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
reply.reply(GeckoPigeonUtils.wrapError(error))
|
reply.reply(GeckoPigeonUtils.wrapError(error))
|
||||||
|
|||||||
@@ -5474,6 +5474,8 @@ class PwaManifest {
|
|||||||
required this.preferRelatedApplications,
|
required this.preferRelatedApplications,
|
||||||
this.shareTarget,
|
this.shareTarget,
|
||||||
required this.currentUrl,
|
required this.currentUrl,
|
||||||
|
this.contextId,
|
||||||
|
this.installLabel,
|
||||||
});
|
});
|
||||||
|
|
||||||
String startUrl;
|
String startUrl;
|
||||||
@@ -5510,6 +5512,19 @@ class PwaManifest {
|
|||||||
/// Used for HTTPS/installability checks.
|
/// Used for HTTPS/installability checks.
|
||||||
String currentUrl;
|
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() {
|
List<Object?> _toList() {
|
||||||
return <Object?>[
|
return <Object?>[
|
||||||
startUrl,
|
startUrl,
|
||||||
@@ -5528,6 +5543,8 @@ class PwaManifest {
|
|||||||
preferRelatedApplications,
|
preferRelatedApplications,
|
||||||
shareTarget,
|
shareTarget,
|
||||||
currentUrl,
|
currentUrl,
|
||||||
|
contextId,
|
||||||
|
installLabel,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5553,6 +5570,8 @@ class PwaManifest {
|
|||||||
preferRelatedApplications: result[13]! as bool,
|
preferRelatedApplications: result[13]! as bool,
|
||||||
shareTarget: result[14] as ShareTarget?,
|
shareTarget: result[14] as ShareTarget?,
|
||||||
currentUrl: result[15]! as String,
|
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)) {
|
if (identical(this, other)) {
|
||||||
return true;
|
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
|
@override
|
||||||
@@ -10499,15 +10518,16 @@ class GeckoPwaApi {
|
|||||||
/// The [tabId] identifies which tab to install from. If null, uses the selected tab.
|
/// 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 [profileUuid] is the UUID of the current user profile.
|
||||||
/// The [contextId] is the container's contextual identity (optional, null for default container).
|
/// 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.
|
/// 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_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoPwaApi.installWebApp$pigeonVar_messageChannelSuffix';
|
||||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
binaryMessenger: pigeonVar_binaryMessenger,
|
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 pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||||
|
|||||||
@@ -2673,6 +2673,19 @@ class PwaManifest {
|
|||||||
/// Used for HTTPS/installability checks.
|
/// Used for HTTPS/installability checks.
|
||||||
final String currentUrl;
|
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({
|
const PwaManifest({
|
||||||
required this.startUrl,
|
required this.startUrl,
|
||||||
required this.currentUrl,
|
required this.currentUrl,
|
||||||
@@ -2690,6 +2703,8 @@ class PwaManifest {
|
|||||||
this.relatedApplications = const [],
|
this.relatedApplications = const [],
|
||||||
this.preferRelatedApplications = false,
|
this.preferRelatedApplications = false,
|
||||||
this.shareTarget,
|
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 [tabId] identifies which tab to install from. If null, uses the selected tab.
|
||||||
/// The [profileUuid] is the UUID of the current user profile.
|
/// The [profileUuid] is the UUID of the current user profile.
|
||||||
/// The [contextId] is the container's contextual identity (optional, null for default container).
|
/// 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.
|
/// Returns true if installation was successful.
|
||||||
@async
|
@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.
|
/// Returns a list of all installed PWA manifests.
|
||||||
@async
|
@async
|
||||||
|
|||||||
Reference in New Issue
Block a user