/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
 * vi:set noexpandtab tabstop=8 shiftwidth=8:
 *
 * Copyright (C) 2021-2022 Matthew Leeds <mwleeds@protonmail.com>
 *
 * SPDX-License-Identifier: GPL-2.0-or-later
 */

#include <config.h>
#include <glib/gi18n.h>
#include <gnome-software.h>
#include <fcntl.h>
#include <gio/gunixfdlist.h>
#include <glib/gstdio.h>

#include "gs-epiphany-generated.h"
#include "gs-plugin-epiphany.h"
#include "gs-plugin-private.h"

/*
 * SECTION:
 * This plugin uses Epiphany to install, launch, and uninstall web applications.
 *
 * If the org.gnome.Epiphany.WebAppProvider D-Bus interface is not present or
 * the DynamicLauncher portal is not available then it self-disables. This
 * should work with both Flatpak'd and traditionally packaged Epiphany, for new
 * enough versions of Epiphany.
 *
 * It's worth noting that this plugin has to deal with two different app IDs
 * for installed web apps:
 *
 * 1. The app ID used in the <id> element in the AppStream metainfo file, which
 *    looks like "org.gnome.Software.WebApp_527a2dd6729c3574227c145bbc447997f0048537.desktop"
 *    See https://gitlab.gnome.org/mwleeds/gnome-pwa-list/-/blob/6e8b17b018f99dbf00b1fa956ed75c4a0ccbf389/pwa-metainfo-generator.py#L84-89
 *    This app ID is used for gs_app_new() so that the appstream plugin
 *    refines the apps created here, and used for the plugin cache.
 *
 * 2. The app ID generated by Epiphany when installing a web app, which looks
 *    like "org.gnome.Epiphany.WebApp_e9d0e1e4b0a10856aa3b38d9eb4375de4070d043.desktop"
 *    though it can have a different prefix if Epiphany was built with, for
 *    example, a development profile. Throughout this plugin this type of app
 *    ID is handled with a variable called "installed_app_id". This app ID is
 *    used in method calls to the org.gnome.Epiphany.WebAppProvider interface,
 *    and used for gs_app_set_launchable() and g_desktop_app_info_new().
 *
 * The webapps API is provided by Epiphany over D-Bus, so this plugin can run
 * entirely in the main thread; most of the work is done by a process belonging
 * to Epiphany. This plugin does need to do some local I/O (e.g. on app icons).
 * As all work happens in the main thread, no locking is required.
 *
 * Since: 43
 */

struct _GsPluginEpiphany
{
	GsPlugin parent;

	GsEphyWebAppProvider *epiphany_proxy;  /* (owned) */
	GDBusProxy *launcher_portal_proxy;  /* (owned) */
	GFileMonitor *monitor; /* (owned) */
	guint changed_id;
	/* installed_apps_cached: whether the plugin cache has all installed apps */
	gboolean installed_apps_cached;
	GHashTable *url_id_map; /* (owned) (not nullable) (element-type utf8 utf8) */

	/* default permissions, shared between all applications */
	GsAppPermissions *permissions; /* (owned) (not nullable) */
};

G_DEFINE_TYPE (GsPluginEpiphany, gs_plugin_epiphany, GS_TYPE_PLUGIN)

static void
gs_epiphany_error_convert (GError **perror)
{
	GError *error = perror != NULL ? *perror : NULL;

	/* not set */
	if (error == NULL)
		return;

	/* parse remote epiphany-webapp-provider error */
	if (g_dbus_error_is_remote_error (error)) {
		g_autofree gchar *remote_error = g_dbus_error_get_remote_error (error);

		g_dbus_error_strip_remote_error (error);

		if (g_str_equal (remote_error, "org.freedesktop.DBus.Error.ServiceUnknown")) {
			error->code = GS_PLUGIN_ERROR_NOT_SUPPORTED;
		} else if (g_str_has_prefix (remote_error, "org.gnome.Epiphany.WebAppProvider.Error")) {
			error->code = GS_PLUGIN_ERROR_FAILED;
		} else {
			g_warning ("Can’t reliably fixup remote error ‘%s’", remote_error);
			error->code = GS_PLUGIN_ERROR_FAILED;
		}
		error->domain = GS_PLUGIN_ERROR;
		return;
	}

	/* this is allowed for low-level errors */
	if (gs_utils_error_convert_gio (perror))
		return;

	/* this is allowed for low-level errors */
	if (gs_utils_error_convert_gdbus (perror))
		return;
}

/* Run in the main thread. */
static void
gs_plugin_epiphany_changed_cb (GFileMonitor      *monitor,
                               GFile             *file,
                               GFile             *other_file,
                               GFileMonitorEvent  event_type,
                               gpointer           user_data)
{
	GsPluginEpiphany *self = GS_PLUGIN_EPIPHANY (user_data);

	{
	  gs_plugin_cache_invalidate (GS_PLUGIN (self));
	  g_hash_table_remove_all (self->url_id_map);
	  self->installed_apps_cached = FALSE;
	}

	/* FIXME: With the current API this is the only way to reload the list
	 * of installed apps.
	 */
	gs_plugin_reload (GS_PLUGIN (self));
}

static void
epiphany_web_app_provider_proxy_created_cb (GObject      *source_object,
                                            GAsyncResult *result,
                                            gpointer      user_data);

static void
dynamic_launcher_portal_proxy_created_cb (GObject      *source_object,
                                          GAsyncResult *result,
                                          gpointer      user_data);

static void
gs_plugin_epiphany_setup_async (GsPlugin            *plugin,
                                GCancellable        *cancellable,
                                GAsyncReadyCallback  callback,
                                gpointer             user_data)
{
	GsPluginEpiphany *self = GS_PLUGIN_EPIPHANY (plugin);
	g_autoptr(GTask) task = NULL;
	g_autoptr(GError) local_error = NULL;
	g_autofree char *portal_apps_path = NULL;
	g_autoptr(GFile) portal_apps_file = NULL;
	GDBusConnection *connection;

	task = g_task_new (plugin, cancellable, callback, user_data);
	g_task_set_source_tag (task, gs_plugin_epiphany_setup_async);

	g_debug ("%s", G_STRFUNC);

	self->installed_apps_cached = FALSE;

	/* This is a mapping from URL to app ID, where the app ID comes from
	 * Epiphany. This allows us to use that app ID rather than the
	 * AppStream app ID in certain contexts (see the comment at the top of
	 * this file).
	 */
	self->url_id_map = g_hash_table_new_full (g_str_hash, g_str_equal, g_free, g_free);

	/* Watch for changes to the set of installed apps in the main thread.
	 * This will also trigger when other apps' dynamic launchers are
	 * installed or removed but that is expected to be infrequent.
	 */
	portal_apps_path = g_build_filename (g_get_user_data_dir (), "xdg-desktop-portal", "applications", NULL);
	portal_apps_file = g_file_new_for_path (portal_apps_path);
	/* Monitoring the directory works even if it doesn't exist yet */
	self->monitor = g_file_monitor_directory (portal_apps_file, G_FILE_MONITOR_WATCH_MOVES,
			                          cancellable, &local_error);
	if (self->monitor == NULL) {
		gs_epiphany_error_convert (&local_error);
		g_task_return_error (task, g_steal_pointer (&local_error));
		return;
	}

	self->changed_id = g_signal_connect (self->monitor, "changed",
					     G_CALLBACK (gs_plugin_epiphany_changed_cb), self);

	connection = gs_plugin_get_session_bus_connection (GS_PLUGIN (self));
	g_assert (connection != NULL);

	gs_ephy_web_app_provider_proxy_new (connection,
					    G_DBUS_PROXY_FLAGS_NONE,
					    "org.gnome.Epiphany.WebAppProvider",
					    "/org/gnome/Epiphany/WebAppProvider",
					    cancellable,
					    epiphany_web_app_provider_proxy_created_cb,
					    g_steal_pointer (&task));
}

static void
epiphany_web_app_provider_proxy_created_cb (GObject      *source_object,
                                            GAsyncResult *result,
                                            gpointer      user_data)
{
	g_autoptr(GTask) task = G_TASK (user_data);
	g_autofree gchar *name_owner = NULL;
	g_autoptr(GError) local_error = NULL;
	GsPluginEpiphany *self = g_task_get_source_object (task);
	GDBusConnection *connection;
	GCancellable *cancellable;

	/* Check that the proxy exists (and is owned; it should auto-start) so
	 * we can disable the plugin for systems which don’t have new enough
	 * Epiphany.
	 */
	self->epiphany_proxy = gs_ephy_web_app_provider_proxy_new_finish (result, &local_error);

	if (self->epiphany_proxy == NULL) {
		gs_epiphany_error_convert (&local_error);
		g_task_return_error (task, g_steal_pointer (&local_error));
		return;
	}

	name_owner = g_dbus_proxy_get_name_owner (G_DBUS_PROXY (self->epiphany_proxy));
	if (name_owner == NULL) {
		g_task_return_new_error (task, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_NOT_SUPPORTED,
					 "Couldn’t create Epiphany WebAppProvider proxy: couldn’t get name owner");
		return;
	}

	connection = g_dbus_proxy_get_connection (G_DBUS_PROXY (self->epiphany_proxy));
	cancellable = g_task_get_cancellable (task);

	g_dbus_proxy_new (connection,
			  G_DBUS_PROXY_FLAGS_DO_NOT_CONNECT_SIGNALS,
			  NULL,
			  "org.freedesktop.portal.Desktop",
			  "/org/freedesktop/portal/desktop",
			  "org.freedesktop.portal.DynamicLauncher",
			  cancellable,
			  dynamic_launcher_portal_proxy_created_cb,
			  g_steal_pointer (&task));
}

static void
dynamic_launcher_portal_proxy_created_cb (GObject      *source_object,
                                          GAsyncResult *result,
                                          gpointer      user_data)
{
	g_autoptr(GTask) task = G_TASK (user_data);
	g_autoptr(GVariant) version = NULL;
	g_autoptr(GError) local_error = NULL;
	GsPluginEpiphany *self = g_task_get_source_object (task);

	/* Check that the proxy exists (and is owned; it should auto-start) so
	 * we can disable the plugin for systems which don’t have new enough
	 * Epiphany.
	 */
	self->launcher_portal_proxy = g_dbus_proxy_new_finish (result, &local_error);

	if (self->launcher_portal_proxy == NULL) {
		gs_epiphany_error_convert (&local_error);
		g_task_return_error (task, g_steal_pointer (&local_error));
		return;
	}

	version = g_dbus_proxy_get_cached_property (self->launcher_portal_proxy, "version");
	if (version == NULL) {
		g_task_return_new_error (task, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_NOT_SUPPORTED,
					 "Dynamic launcher portal not available");
		return;
	} else {
		g_debug ("Found version %" G_GUINT32_FORMAT " of the dynamic launcher portal",
			 g_variant_get_uint32 (version));
	}

	g_task_return_boolean (task, TRUE);
}

static gboolean
gs_plugin_epiphany_setup_finish (GsPlugin      *plugin,
                                 GAsyncResult  *result,
                                 GError       **error)
{
	return g_task_propagate_boolean (G_TASK (result), error);
}

static void
gs_plugin_epiphany_init (GsPluginEpiphany *self)
{
	/* Re-used permissions by all GsApp instances; do not modify it out
	   of this place. */
	self->permissions = gs_app_permissions_new ();
	gs_app_permissions_set_flags (self->permissions, GS_APP_PERMISSIONS_FLAGS_NETWORK);
	gs_app_permissions_seal (self->permissions);

	/* need help from appstream */
	gs_plugin_add_rule (GS_PLUGIN (self), GS_PLUGIN_RULE_RUN_AFTER, "appstream");

	/* prioritize over packages */
	gs_plugin_add_rule (GS_PLUGIN (self), GS_PLUGIN_RULE_BETTER_THAN, "packagekit");
}

static void
gs_plugin_epiphany_dispose (GObject *object)
{
	GsPluginEpiphany *self = GS_PLUGIN_EPIPHANY (object);

	if (self->changed_id > 0) {
		g_signal_handler_disconnect (self->monitor, self->changed_id);
		self->changed_id = 0;
	}

	g_clear_object (&self->epiphany_proxy);
	g_clear_object (&self->launcher_portal_proxy);
	g_clear_object (&self->monitor);
	g_clear_pointer (&self->url_id_map, g_hash_table_unref);

	G_OBJECT_CLASS (gs_plugin_epiphany_parent_class)->dispose (object);
}

static void
gs_plugin_epiphany_finalize (GObject *object)
{
	GsPluginEpiphany *self = GS_PLUGIN_EPIPHANY (object);

	g_clear_object (&self->permissions);

	G_OBJECT_CLASS (gs_plugin_epiphany_parent_class)->finalize (object);
}

/* The caller must have already done ensure_installed_apps_cache_async() */
static void
gs_epiphany_refine_app_state (GsPlugin *plugin,
			      GsApp    *app)
{
	if (gs_app_get_state (app) == GS_APP_STATE_UNKNOWN) {
		g_autoptr(GsApp) cached_app = NULL;
		const char *appstream_source;

		/* If we have a cached app, set the state from there. Otherwise
		 * only set the state to available if the app came from
		 * appstream data, because there's no way to re-install an app
		 * in Software that was originally installed from Epiphany,
		 * unless we have appstream metainfo for it.
		 */
		cached_app = gs_plugin_cache_lookup (plugin, gs_app_get_id (app));
		appstream_source = gs_app_get_metadata_item (app, "appstream::source-file");
		if (cached_app)
			gs_app_set_state (app, gs_app_get_state (cached_app));
		else if (appstream_source)
			gs_app_set_state (app, GS_APP_STATE_AVAILABLE);
		else {
			gs_app_set_state (app, GS_APP_STATE_UNAVAILABLE);
			gs_app_set_url_missing (app,
						"help:gnome-software/how-to-reinstall-a-web-app");
		}
	}
}

static void
gs_plugin_epiphany_adopt_app (GsPlugin *plugin,
			      GsApp    *app)
{
	if (gs_app_get_kind (app) == AS_COMPONENT_KIND_WEB_APP &&
	    gs_app_get_bundle_kind (app) != AS_BUNDLE_KIND_PACKAGE) {
		gs_app_set_management_plugin (app, plugin);
	}
}

static void ensure_installed_apps_cache_async (GsPluginEpiphany    *self,
                                               gboolean             interactive,
                                               GCancellable        *cancellable,
                                               GAsyncReadyCallback  callback,
                                               void                *user_data);
static gboolean ensure_installed_apps_cache_finish (GsPluginEpiphany  *self,
                                                    GAsyncResult      *result,
                                                    GError           **error);
static gchar *generate_app_id_for_url (const gchar *url);

static void list_apps_cache_cb (GObject      *obj,
                                GAsyncResult *result,
                                void         *user_data);

static void
gs_plugin_epiphany_list_apps_async (GsPlugin              *plugin,
                                    GsAppQuery            *query,
                                    GsPluginListAppsFlags  flags,
                                    GsPluginEventCallback  event_callback,
                                    void                  *event_user_data,
                                    GCancellable          *cancellable,
                                    GAsyncReadyCallback    callback,
                                    gpointer               user_data)
{
	GsPluginEpiphany *self = GS_PLUGIN_EPIPHANY (plugin);
	g_autoptr(GTask) task = NULL;
	gboolean interactive = (flags & GS_PLUGIN_LIST_APPS_FLAGS_INTERACTIVE);
	GsAppQueryTristate is_installed = GS_APP_QUERY_TRISTATE_UNSET;
	const gchar * const *keywords = NULL;

	task = gs_plugin_list_apps_data_new_task (plugin, query, flags,
						  event_callback, event_user_data,
						  cancellable, callback, user_data);
	g_task_set_source_tag (task, gs_plugin_epiphany_list_apps_async);

	if (query != NULL) {
		is_installed = gs_app_query_get_is_installed (query);
		keywords = gs_app_query_get_keywords (query);
	}

	/* Currently only support a subset of query properties, and only one set at once.
	 * Also don’t currently support GS_APP_QUERY_TRISTATE_FALSE. */
	if ((is_installed == GS_APP_QUERY_TRISTATE_UNSET &&
	     keywords == NULL) ||
	    is_installed == GS_APP_QUERY_TRISTATE_FALSE ||
	    gs_app_query_get_n_properties_set (query) != 1) {
		g_task_return_new_error (task, G_IO_ERROR, G_IO_ERROR_NOT_SUPPORTED,
					 "Unsupported query");
		return;
	}

	/* Ensure the cache is up to date. */
	ensure_installed_apps_cache_async (self, interactive, cancellable, list_apps_cache_cb, g_steal_pointer (&task));
}

static void
list_apps_cache_cb (GObject      *obj,
                    GAsyncResult *result,
                    void         *user_data)
{
	GsPluginEpiphany *self = GS_PLUGIN_EPIPHANY (obj);
	g_autoptr(GTask) task = g_steal_pointer (&user_data);
	g_autoptr(GsAppList) list = gs_app_list_new ();
	GsPluginListAppsData *data = g_task_get_task_data (task);
	GsAppQueryTristate is_installed = GS_APP_QUERY_TRISTATE_UNSET;
	const gchar * const *keywords = NULL;
	g_autoptr(GError) local_error = NULL;

	if (!ensure_installed_apps_cache_finish (self, result, &local_error)) {
		g_task_return_error (task, g_steal_pointer (&local_error));
		return;
	}

	if (data->query != NULL) {
		is_installed = gs_app_query_get_is_installed (data->query);
		keywords = gs_app_query_get_keywords (data->query);
	}

	if (is_installed == GS_APP_QUERY_TRISTATE_TRUE)
		gs_plugin_cache_lookup_by_state (GS_PLUGIN (self), list, GS_APP_STATE_INSTALLED);
	else if (keywords != NULL) {
		for (gsize i = 0; keywords[i]; i++) {
			GHashTableIter iter;
			gpointer key, value;
			g_hash_table_iter_init (&iter, self->url_id_map);
			while (g_hash_table_iter_next (&iter, &key, &value)) {
				const gchar *url = key;
				const gchar *app_id = value;
				if (g_strcmp0 (app_id, keywords[i]) == 0) {
					g_autoptr(GsApp) app = NULL;
					g_autofree gchar *metainfo_app_id = NULL;
					metainfo_app_id = generate_app_id_for_url (url);
					app = gs_plugin_cache_lookup (GS_PLUGIN (self), metainfo_app_id);
					if (app != NULL)
						gs_app_list_add (list, app);
					break;
				}
			}
		}
	}

	g_task_return_pointer (task, g_steal_pointer (&list), g_object_unref);
}

static void
refine_app (GsPluginEpiphany           *self,
            GsApp                      *app,
            GsPluginRefineRequireFlags  require_flags,
            GUri                       *uri,
            const char                 *url)
{
	const char *hostname;
	const char *installed_app_id;
	const struct {
		const gchar *hostname;
		const gchar *license_spdx;
	} app_licenses[] = {
	/* Keep in alphabetical order by hostname */
	{ "app.diagrams.net", "Apache-2.0" },
	{ "devdocs.io", "MPL-2.0" },
	{ "discourse.flathub.org", "GPL-2.0-or-later" },
	{ "discourse.gnome.org", "GPL-2.0-or-later" },
	{ "excalidraw.com", "MIT" },
	{ "pinafore.social", "AGPL-3.0-only" },
	{ "snapdrop.net", "GPL-3.0-only" },
	{ "stackedit.io", "Apache-2.0" },
	{ "squoosh.app", "Apache-2.0" },
	};

	g_return_if_fail (GS_IS_APP (app));
	g_return_if_fail (uri != NULL);
	g_return_if_fail (url != NULL);

	gs_app_set_origin (app, "gnome-web");
	if (gs_app_get_name (app) == NULL)
		gs_app_set_origin_ui (app, _("Web App"));
	else
		gs_app_set_origin_ui (app, gs_app_get_name (app));
	gs_app_set_origin_hostname (app, g_uri_get_host (uri));
	gs_app_set_metadata (app, "GnomeSoftware::PackagingFormat", _("Web App"));
	gs_app_set_metadata (app, "GnomeSoftware::PackagingIcon", "web-browser-symbolic");

	gs_app_set_scope (app, AS_COMPONENT_SCOPE_USER);
	gs_app_set_launchable (app, AS_LAUNCHABLE_KIND_URL, url);

	/* Check if the app already has a desktop ID set (e.g. for installed
	 * apps with duplicate URLs where the url_id_map cannot hold both).
	 * Fall back to the url_id_map for apps coming from AppStream data.
	 */
	installed_app_id = gs_app_get_launchable (app, AS_LAUNCHABLE_KIND_DESKTOP_ID);
	if (installed_app_id == NULL) {
		installed_app_id = g_hash_table_lookup (self->url_id_map, url);
		if (installed_app_id)
			gs_app_set_launchable (app, AS_LAUNCHABLE_KIND_DESKTOP_ID, installed_app_id);
	}

	/* Hard-code the licenses as it's hard to get them programmatically. We
	 * can move them to an AppStream file if needed.
	 */
	hostname = g_uri_get_host (uri);
	if (gs_app_get_license (app) == NULL && hostname != NULL) {
		for (gsize i = 0; i < G_N_ELEMENTS (app_licenses); i++) {
			if (g_str_equal (hostname, app_licenses[i].hostname)) {
				gs_app_set_license (app, GS_APP_QUALITY_NORMAL,
						    app_licenses[i].license_spdx);
				break;
			}
		}
	}

	gs_app_set_size_download (app, GS_SIZE_TYPE_VALID, 0);

	/* Use the default permissions */
	gs_app_set_permissions (app, self->permissions);

	if (gs_app_get_url (app, AS_URL_KIND_HOMEPAGE) == NULL)
		gs_app_set_url (app, AS_URL_KIND_HOMEPAGE, url);

	/* Use the domain name (e.g. "discourse.gnome.org") as a fallback summary.
	 * FIXME: Fetch the summary from the site's webapp manifest.
	 */
	if (gs_app_get_summary (app) == NULL) {
		if (hostname != NULL && *hostname != '\0')
			gs_app_set_summary (app, GS_APP_QUALITY_LOWEST, hostname);
		else
			gs_app_set_summary (app, GS_APP_QUALITY_LOWEST, url);
	}

	if (installed_app_id == NULL)
		return;

	{
		const gchar *name;
		g_autofree char *icon_path = NULL;
		goffset desktop_size = 0, icon_size = 0;
		g_autoptr(GDesktopAppInfo) desktop_info = NULL;
		g_autoptr(GFileInfo) file_info = NULL;
		g_autoptr(GFile) icon_file = NULL;

		/* FIXME: Technically this does synchronous I/O, albeit on a
		 * local file. Ideally it should be done asynchronously. */
		desktop_info = g_desktop_app_info_new (installed_app_id);

		if (desktop_info == NULL) {
			g_warning ("Couldn't get GDesktopAppInfo for app %s", installed_app_id);
			return;
		}

		name = g_app_info_get_name (G_APP_INFO (desktop_info));
		gs_app_set_name (app, GS_APP_QUALITY_NORMAL, name);

		if ((require_flags & GS_PLUGIN_REFINE_REQUIRE_FLAGS_SIZE) != 0) {
			g_autoptr(GFile) desktop_file = NULL;
			const gchar *desktop_path;
			guint64 install_date = 0;

			desktop_path = g_desktop_app_info_get_filename (desktop_info);
			g_assert (desktop_path);
			desktop_file = g_file_new_for_path (desktop_path);

			/* FIXME: This should technically be async, but it’s touching
			 * a local file so should be very fast. Eventually, though,
			 * refine_app() needs to be refactored to be async. */
			file_info = g_file_query_info (desktop_file,
						       G_FILE_ATTRIBUTE_TIME_CREATED "," G_FILE_ATTRIBUTE_STANDARD_SIZE,
						       0, NULL, NULL);
			if (file_info) {
				install_date = g_file_info_get_attribute_uint64 (file_info, G_FILE_ATTRIBUTE_TIME_CREATED);
				desktop_size = g_file_info_get_size (file_info);
			}
			if (install_date) {
				gs_app_set_install_date (app, install_date);
			}
		}

		icon_path = g_desktop_app_info_get_string (desktop_info, "Icon");
		if ((require_flags & GS_PLUGIN_REFINE_REQUIRE_FLAGS_SIZE) != 0 &&
		    icon_path) {
			icon_file = g_file_new_for_path (icon_path);

			g_clear_object (&file_info);
			/* FIXME: This should technically be async as above. */
			file_info = g_file_query_info (icon_file,
						       G_FILE_ATTRIBUTE_STANDARD_SIZE,
						       0, NULL, NULL);
			if (file_info)
				icon_size = g_file_info_get_size (file_info);
		}
		if ((require_flags & GS_PLUGIN_REFINE_REQUIRE_FLAGS_ICON) != 0 &&
		    !gs_app_has_icons (app) &&
		    icon_path) {
			g_autoptr(GIcon) icon = g_file_icon_new (icon_file);
			g_autofree char *icon_dir = g_path_get_dirname (icon_path);
			g_autofree char *icon_dir_basename = g_path_get_basename (icon_dir);
			const char *x;
			guint64 width = 0;

			/* dir should be either scalable or e.g. 512x512 */
			if (g_strcmp0 (icon_dir_basename, "scalable") == 0) {
				/* Ensure scalable icons are preferred */
				width = 4096;
			} else if ((x = strchr (icon_dir_basename, 'x')) != NULL) {
				g_ascii_string_to_unsigned (x + 1, 10, 1, G_MAXINT, &width, NULL);
			}
			if (width > 0 && width <= 4096) {
				gs_icon_set_width (icon, width);
				gs_icon_set_height (icon, width);
			} else {
				g_warning ("Unexpectedly unable to determine width of icon %s", icon_path);
			}

			gs_app_add_icon (app, icon);
		}
		if (desktop_size > 0 || icon_size > 0) {
			gs_app_set_size_installed (app, GS_SIZE_TYPE_VALID, desktop_size + icon_size);
		}
	}
}

static GsApp *
gs_epiphany_create_app (GsPluginEpiphany *self,
			const char       *id)
{
	g_autoptr(GsApp) app = NULL;

	app = gs_plugin_cache_lookup (GS_PLUGIN (self), id);
	if (app != NULL)
		return g_steal_pointer (&app);

	app = gs_app_new (id);
	gs_app_set_management_plugin (app, GS_PLUGIN (self));
	gs_app_set_kind (app, AS_COMPONENT_KIND_WEB_APP);
	gs_app_set_metadata (app, "GnomeSoftware::Creator",
			     gs_plugin_get_name (GS_PLUGIN (self)));

	gs_plugin_cache_add (GS_PLUGIN (self), id, app);
	return g_steal_pointer (&app);
}

static gchar * /* (transfer full) */
generate_app_id_for_url (const gchar *url)
{
	/* Generate the app ID used in the AppStream data using the
	 * same method as pwa-metainfo-generator.py in
	 * https://gitlab.gnome.org/mwleeds/gnome-pwa-list
	 * Using this app ID rather than the one provided by Epiphany
	 * makes it possible for the appstream plugin to refine the
	 * GsApp we create (see the comment at the top of this file).
	 */
	g_autofree gchar *url_hash = g_compute_checksum_for_string (G_CHECKSUM_SHA1, url, -1);
	return g_strconcat ("org.gnome.Software.WebApp_", url_hash, ".desktop", NULL);
}

static void ensure_installed_apps_cache_get_installed_apps_cb (GObject      *obj,
                                                               GAsyncResult *result,
                                                               void         *user_data);

static void
ensure_installed_apps_cache_async (GsPluginEpiphany    *self,
                                   gboolean             interactive,
                                   GCancellable        *cancellable,
                                   GAsyncReadyCallback  callback,
                                   void                *user_data)
{
	g_autoptr(GTask) task = NULL;

	task = g_task_new (self, cancellable, callback, user_data);
	g_task_set_source_tag (task, ensure_installed_apps_cache_async);

	if (self->installed_apps_cached) {
		g_task_return_boolean (task, TRUE);
		return;
	}

	gs_ephy_web_app_provider_call_get_installed_apps (self->epiphany_proxy,
							  interactive ? G_DBUS_CALL_FLAGS_ALLOW_INTERACTIVE_AUTHORIZATION : G_DBUS_CALL_FLAGS_NONE,
							  -1  /* timeout */,
							  cancellable,
							  ensure_installed_apps_cache_get_installed_apps_cb,
							  g_steal_pointer (&task));
}

static void
ensure_installed_apps_cache_get_installed_apps_cb (GObject      *obj,
                                                   GAsyncResult *result,
                                                   void         *user_data)
{
	GsEphyWebAppProvider *epiphany_proxy = GS_EPHY_WEB_APP_PROVIDER (obj);
	g_autoptr(GTask) task = g_steal_pointer (&user_data);
	GsPluginEpiphany *self = g_task_get_source_object (task);
	g_auto(GStrv) webapps = NULL;
	guint n_webapps;
	g_autoptr(GsAppList) installed_cache = gs_app_list_new ();
	g_autoptr(GError) local_error = NULL;

	if (!gs_ephy_web_app_provider_call_get_installed_apps_finish (epiphany_proxy,
								      &webapps,
								      result,
								      &local_error)) {
		gs_epiphany_error_convert (&local_error);
		g_task_return_error (task, g_steal_pointer (&local_error));
		return;
	}

	n_webapps = g_strv_length (webapps);
	g_debug ("%s: epiphany-webapp-provider returned %u installed web apps", G_STRFUNC, n_webapps);
	for (guint i = 0; i < n_webapps; i++) {
		const gchar *desktop_file_id = webapps[i];
		const gchar *url = NULL;
		g_autofree char *metainfo_app_id = NULL;
		const gchar *exec;
		int argc;
		GsPluginRefineRequireFlags require_flags;
		g_auto(GStrv) argv = NULL;
		g_autoptr(GsApp) app = NULL;
		g_autoptr(GDesktopAppInfo) desktop_info = NULL;
		g_autoptr(GUri) uri = NULL;

		g_debug ("%s: Working on installed web app %s", G_STRFUNC, desktop_file_id);

		/* FIXME: Technically this does synchronous I/O, albeit on a
		 * local file. Ideally it should be done asynchronously. */
		desktop_info = g_desktop_app_info_new (desktop_file_id);

		if (desktop_info == NULL) {
			g_warning ("Epiphany returned a non-existent or invalid desktop ID %s", desktop_file_id);
			continue;
		}

		/* This way of getting the URL is a bit hacky but it's what
		 * Epiphany does, specifically in
		 * ephy_web_application_for_profile_directory() which lives in
		 * https://gitlab.gnome.org/GNOME/epiphany/-/blob/master/lib/ephy-web-app-utils.c
		 */
		exec = g_app_info_get_commandline (G_APP_INFO (desktop_info));
		if (g_shell_parse_argv (exec, &argc, &argv, NULL)) {
			g_assert (argc > 0);
			url = argv[argc - 1];
		}
		if (!url || !(uri = g_uri_parse (url, G_URI_FLAGS_NONE, NULL))) {
			g_warning ("Failed to parse URL for web app %s: %s",
				   desktop_file_id, url ? url : "(null)");
			continue;
		}

		/* Store the installed app id for use in refine_app().
		 * Only store the first mapping for a given URL; if there are
		 * multiple web apps with the same URL (e.g. two PWAs pointing
		 * at the same domain), each subsequent one will use the
		 * Epiphany desktop file ID as its app ID instead.
		 */
		if (!g_hash_table_contains (self->url_id_map, url)) {
			g_hash_table_insert (self->url_id_map, g_strdup (url),
					     g_strdup (desktop_file_id));
			metainfo_app_id = generate_app_id_for_url (url);
		} else {
			metainfo_app_id = g_strdup (desktop_file_id);
		}

		g_debug ("Creating GsApp for webapp with URL %s using app ID %s (desktop file id: %s)",
			 url, metainfo_app_id, desktop_file_id);

		/* App gets added to the plugin cache here */
		app = gs_epiphany_create_app (self, metainfo_app_id);

		gs_app_set_state (app, GS_APP_STATE_INSTALLED);

		/* Set the desktop file ID directly on the app so that
		 * refine_app() can find it even for apps whose URL is not
		 * in the url_id_map (i.e. duplicate-URL apps).
		 */
		gs_app_set_launchable (app, AS_LAUNCHABLE_KIND_DESKTOP_ID, desktop_file_id);

		require_flags = GS_PLUGIN_REFINE_REQUIRE_FLAGS_ICON |
			        GS_PLUGIN_REFINE_REQUIRE_FLAGS_SIZE |
			        GS_PLUGIN_REFINE_REQUIRE_FLAGS_ID;
		refine_app (self, app, require_flags, uri, url);
	}

	/* Update the state on any apps that were uninstalled outside
	 * gnome-software
	 */
	gs_plugin_cache_lookup_by_state (GS_PLUGIN (self), installed_cache, GS_APP_STATE_INSTALLED);
	for (guint i = 0; i < gs_app_list_length (installed_cache); i++) {
		GsApp *app = gs_app_list_index (installed_cache, i);
		const char *installed_app_id;
		const char *appstream_source;

		installed_app_id = gs_app_get_launchable (app, AS_LAUNCHABLE_KIND_DESKTOP_ID);
		if (installed_app_id == NULL) {
			g_warning ("Installed app unexpectedly has no desktop id: %s", gs_app_get_id (app));
			continue;
		}

		if (g_strv_contains ((const char * const *)webapps, installed_app_id))
			continue;

		gs_plugin_cache_remove (GS_PLUGIN (self), gs_app_get_id (app));

		appstream_source = gs_app_get_metadata_item (app, "appstream::source-file");
		if (appstream_source)
			gs_app_set_state (app, GS_APP_STATE_AVAILABLE);
		else
			gs_app_set_state (app, GS_APP_STATE_UNKNOWN);
	}

	self->installed_apps_cached = TRUE;

	g_task_return_boolean (task, TRUE);
}

static gboolean
ensure_installed_apps_cache_finish (GsPluginEpiphany  *self,
                                    GAsyncResult      *result,
                                    GError           **error)
{
	g_return_val_if_fail (GS_IS_PLUGIN_EPIPHANY (self), FALSE);
	g_return_val_if_fail (g_task_is_valid (result, self), FALSE);
	g_return_val_if_fail (error == NULL || *error == NULL, FALSE);

	return g_task_propagate_boolean (G_TASK (result), error);
}

static GsAppList *
gs_plugin_epiphany_list_apps_finish (GsPlugin      *plugin,
                                     GAsyncResult  *result,
                                     GError       **error)
{
	g_return_val_if_fail (g_task_get_source_tag (G_TASK (result)) == gs_plugin_epiphany_list_apps_async, FALSE);
	return g_task_propagate_pointer (G_TASK (result), error);
}

static void
gs_epiphany_refine_app (GsPluginEpiphany           *self,
                        GsApp                      *app,
                        GsPluginRefineRequireFlags  require_flags,
                        const char                 *url)
{
	g_autoptr(GUri) uri = NULL;

	g_return_if_fail (url != NULL && *url != '\0');

	if (!(uri = g_uri_parse (url, G_URI_FLAGS_NONE, NULL))) {
		g_warning ("Failed to parse URL for web app %s: %s", gs_app_get_id (app), url);
		return;
	}

	refine_app (self, app, require_flags, uri, url);
}

static void refine_cache_cb (GObject      *obj,
                             GAsyncResult *result,
                             void         *user_data);

static void
gs_plugin_epiphany_refine_async (GsPlugin                   *plugin,
                                 GsAppList                  *list,
                                 GsPluginRefineFlags         job_flags,
                                 GsPluginRefineRequireFlags  require_flags,
                                 GsPluginEventCallback       event_callback,
                                 void                       *event_user_data,
                                 GCancellable               *cancellable,
                                 GAsyncReadyCallback         callback,
                                 gpointer                    user_data)
{
	GsPluginEpiphany *self = GS_PLUGIN_EPIPHANY (plugin);
	g_autoptr(GTask) task = NULL;
	gboolean interactive = (job_flags & GS_PLUGIN_REFINE_FLAGS_INTERACTIVE) != 0;

	task = gs_plugin_refine_data_new_task (plugin, list, job_flags, require_flags, event_callback, event_user_data, cancellable, callback, user_data);
	g_task_set_source_tag (task, gs_plugin_epiphany_refine_async);

	ensure_installed_apps_cache_async (self, interactive, cancellable,
					   refine_cache_cb, g_steal_pointer (&task));
}

static void
refine_cache_cb (GObject      *obj,
                 GAsyncResult *result,
                 void         *user_data)
{
	GsPluginEpiphany *self = GS_PLUGIN_EPIPHANY (obj);
	g_autoptr(GTask) task = g_steal_pointer (&user_data);
	GsPluginRefineData *data = g_task_get_task_data (task);
	GsPluginRefineRequireFlags require_flags = data->require_flags;
	GsAppList *list = data->list;
	g_autoptr(GError) local_error = NULL;

	if (!ensure_installed_apps_cache_finish (self, result, &local_error)) {
		g_task_return_error (task, g_steal_pointer (&local_error));
		return;
	}

	for (guint i = 0; i < gs_app_list_length (list); i++) {
		GsApp *app = gs_app_list_index (list, i);
		const char *url;

		/* not us */
		if (gs_app_get_kind (app) != AS_COMPONENT_KIND_WEB_APP ||
		    gs_app_get_bundle_kind (app) == AS_BUNDLE_KIND_PACKAGE)
			continue;

		url = gs_app_get_launchable (app, AS_LAUNCHABLE_KIND_URL);
		if (url == NULL || *url == '\0') {
			/* A launchable URL is required by the AppStream spec */
			g_warning ("Web app %s missing launchable url", gs_app_get_id (app));
			continue;
		}

		g_debug ("epiphany: refining app %s", gs_app_get_id (app));
		gs_epiphany_refine_app (self, app, require_flags, url);
		gs_epiphany_refine_app_state (GS_PLUGIN (self), app);

		/* Usually the way to refine wildcard apps is to create a new
		 * GsApp and add it to the results list, but in this case we
		 * need to use the app that was refined by the appstream plugin
		 * as it has all the metadata set already, and this is the only
		 * plugin for dealing with web apps, so it should be safe to
		 * adopt the wildcard app.
		 */
		if (gs_app_has_quirk (app, GS_APP_QUIRK_IS_WILDCARD)) {
			gs_app_remove_quirk (app, GS_APP_QUIRK_IS_WILDCARD);
			gs_app_set_management_plugin (app, GS_PLUGIN (self));
			gs_plugin_cache_add (GS_PLUGIN (self), gs_app_get_id (app), app);
		}
	}

	/* success */
	g_task_return_boolean (task, TRUE);
}

static gboolean
gs_plugin_epiphany_refine_finish (GsPlugin      *plugin,
                                  GAsyncResult  *result,
                                  GError       **error)
{
	g_return_val_if_fail (g_task_get_source_tag (G_TASK (result)) == gs_plugin_epiphany_refine_async, FALSE);
	return g_task_propagate_boolean (G_TASK (result), error);
}

/* FIXME: This ideally needs to be made async so it doesn’t block the main
 * thread. Currently it’s being run synchronously on the main thread so could
 * block the main thread if the local I/O it’s doing stalls for some reason. */
static GVariant *
get_serialized_icon (GsApp *app,
		     GIcon *icon)
{
	g_autofree char *icon_path = NULL;
	g_autoptr(GInputStream) stream = NULL;
	g_autoptr(GBytes) bytes = NULL;
	g_autoptr(GIcon) bytes_icon = NULL;
	g_autoptr(GVariant) icon_v = NULL;
	guint icon_width;

	/* Note: GsRemoteIcon will work on this GFileIcon code path.
	 * The icons plugin should have called
	 * gs_app_ensure_icons_downloaded() for us.
	 */
	if (!G_IS_FILE_ICON (icon))
		return NULL;

	icon_path = g_file_get_path (g_file_icon_get_file (G_FILE_ICON (icon)));
	if (!g_str_has_suffix (icon_path, ".png") &&
	    !g_str_has_suffix (icon_path, ".svg") &&
	    !g_str_has_suffix (icon_path, ".jpeg") &&
	    !g_str_has_suffix (icon_path, ".jpg")) {
		g_warning ("Icon for app %s has unsupported file extension: %s",
			   gs_app_get_id (app), icon_path);
		return NULL;
	}

	/* Scale down to the portal's size limit if needed */
	icon_width = gs_icon_get_width (icon);
	if (icon_width > 512)
		icon_width = 512;

	/* Serialize the icon as a #GBytesIcon since that's
	 * what the dynamic launcher portal requires.
	 */
	stream = g_loadable_icon_load (G_LOADABLE_ICON (icon), icon_width, NULL, NULL, NULL);

	/* Icons are usually smaller than 1 MiB. Set a 10 MiB
	 * limit so we can't use a huge amount of memory or hit
	 * the D-Bus message size limit
	 */
	if (stream)
		bytes = g_input_stream_read_bytes (stream, 10485760 /* 10 MiB */, NULL, NULL);
	if (bytes)
		bytes_icon = g_bytes_icon_new (bytes);
	if (bytes_icon)
		icon_v = g_icon_serialize (bytes_icon);

	return g_steal_pointer (&icon_v);
}

typedef struct {
	/* Input data. */
	guint n_apps;
	GsPluginInstallAppsFlags flags;
	GsPluginProgressCallback progress_callback;
	gpointer progress_user_data;
	GsPluginEventCallback event_callback;
	void *event_user_data;

	/* In-progress data. */
	guint n_pending_ops;
	GError *saved_error;  /* (owned) (nullable) */

	/* For progress reporting */
	guint n_tokens_requested;
	guint n_tokens_received;
	guint n_apps_installed;
} InstallAppsData;

static void
install_apps_data_free (InstallAppsData *data)
{
	/* Error should have been propagated by now, and all pending ops completed. */
	g_assert (data->saved_error == NULL);
	g_assert (data->n_pending_ops == 0);

	g_free (data);
}

G_DEFINE_AUTOPTR_CLEANUP_FUNC (InstallAppsData, install_apps_data_free)

typedef struct {
	GTask *task;  /* (owned) */
	GsApp *app;  /* (owned) */
	gchar *name;  /* (owned) (not nullable) */
	gchar *url;  /* (owned) (not nullable) */
} InstallSingleAppData;

static void
install_single_app_data_free (InstallSingleAppData *data)
{
	g_clear_object (&data->app);
	g_clear_object (&data->task);
	g_free (data->name);
	g_free (data->url);
	g_free (data);
}

G_DEFINE_AUTOPTR_CLEANUP_FUNC (InstallSingleAppData, install_single_app_data_free)

static void install_request_token_cb (GObject      *source_object,
                                      GAsyncResult *result,
                                      gpointer      user_data);
static void install_install_cb (GObject      *source_object,
                                GAsyncResult *result,
                                gpointer      user_data);
static void finish_install_apps_op (GTask  *task,
                                    GError *error);

static void
gs_plugin_epiphany_install_apps_async (GsPlugin                           *plugin,
                                       GsAppList                          *apps,
                                       GsPluginInstallAppsFlags            flags,
                                       GsPluginProgressCallback            progress_callback,
                                       gpointer                            progress_user_data,
                                       GsPluginEventCallback               event_callback,
                                       void                               *event_user_data,
                                       GsPluginAppNeedsUserActionCallback  app_needs_user_action_callback,
                                       gpointer                            app_needs_user_action_data,
                                       GCancellable                       *cancellable,
                                       GAsyncReadyCallback                 callback,
                                       gpointer                            user_data)
{
	GsPluginEpiphany *self = GS_PLUGIN_EPIPHANY (plugin);
	g_autoptr(GTask) task = NULL;
	InstallAppsData *data;
	g_autoptr(InstallAppsData) data_owned = NULL;
	gboolean interactive = (flags & GS_PLUGIN_INSTALL_APPS_FLAGS_INTERACTIVE);
	g_autoptr(GError) local_error = NULL;

	task = g_task_new (self, cancellable, callback, user_data);
	g_task_set_source_tag (task, gs_plugin_epiphany_install_apps_async);

	data = data_owned = g_new0 (InstallAppsData, 1);
	data->flags = flags;
	data->progress_callback = progress_callback;
	data->progress_user_data = progress_user_data;
	data->event_callback = event_callback;
	data->event_user_data = event_user_data;
	data->n_apps = gs_app_list_length (apps);
	g_task_set_task_data (task, g_steal_pointer (&data_owned), (GDestroyNotify) install_apps_data_free);

	/* Start a load of operations in parallel to install the apps.
	 *
	 * When all installs are finished for all apps, finish_install_apps_op()
	 * will return success/error for the overall #GTask. */
	data->n_pending_ops = 1;
	data->n_tokens_requested = 0;
	data->n_tokens_received = 0;

	for (guint i = 0; i < data->n_apps; i++) {
		GsApp *app = gs_app_list_index (apps, i);
		g_autoptr(InstallSingleAppData) app_data = NULL;
		const char *url;
		const char *name;
		g_autoptr(GVariant) icon_v = NULL;
		GVariantBuilder opt_builder;
		const int icon_sizes[] = {512, 192, 128, 1};
		const char *missing_element = NULL;

		/* This should be guaranteed by GsPluginJobInstallApps */
		g_assert (gs_app_has_management_plugin (app, GS_PLUGIN (self)));

		/* This is a required flag for Epiphany. */
		if (flags & GS_PLUGIN_INSTALL_APPS_FLAGS_NO_APPLY)
			continue;

		url = gs_app_get_url (app, AS_URL_KIND_HOMEPAGE);
		if (url == NULL || *url == '\0')
			missing_element = "url";

		name = gs_app_get_name (app);
		if (name == NULL || *name == '\0')
			missing_element = "name";

		for (guint j = 0; j < G_N_ELEMENTS (icon_sizes); j++) {
			GIcon *icon = gs_app_get_icon_for_size (app, icon_sizes[j], 1, NULL);
			if (icon != NULL)
				icon_v = get_serialized_icon (app, icon);
			if (icon_v != NULL)
				break;
		}
		if (icon_v == NULL)
			missing_element = "icon";

		if (missing_element != NULL) {
			g_autoptr(GsPluginEvent) event = NULL;

			g_set_error (&local_error,
				     GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_FAILED,
				     "Can't install web app %s without %s",
				     gs_app_get_id (app), missing_element);

			event = gs_plugin_event_new ("error", local_error,
						     "app", app,
						     NULL);
			if (interactive)
				gs_plugin_event_add_flag (event, GS_PLUGIN_EVENT_FLAG_INTERACTIVE);
			gs_plugin_event_add_flag (event, GS_PLUGIN_EVENT_FLAG_WARNING);
			if (event_callback != NULL)
				event_callback (GS_PLUGIN (self), event, event_user_data);
			g_clear_error (&local_error);

			continue;
		}

		app_data = g_new0 (InstallSingleAppData, 1);
		app_data->task = g_object_ref (task);
		app_data->app = g_object_ref (app);
		app_data->name = g_strdup (name);
		app_data->url = g_strdup (url);

		gs_app_set_state (app, GS_APP_STATE_INSTALLING);
		gs_app_set_progress (app, 0);

		/* First get a token from xdg-desktop-portal so Epiphany can do the
		 * installation without user confirmation
		 */
		data->n_tokens_requested++;
		data->n_pending_ops++;
		g_variant_builder_init (&opt_builder, G_VARIANT_TYPE_VARDICT);
		g_dbus_proxy_call (self->launcher_portal_proxy,
				   "RequestInstallToken",
				   g_variant_new ("(sva{sv})",
						  name, icon_v, &opt_builder),
				   interactive ? G_DBUS_CALL_FLAGS_ALLOW_INTERACTIVE_AUTHORIZATION : G_DBUS_CALL_FLAGS_NONE,
				   -1,
				   cancellable,
				   install_request_token_cb,
				   g_steal_pointer (&app_data));
	}

	finish_install_apps_op (task, NULL);
}

static void
install_report_progress (GsPluginEpiphany *self,
                         InstallAppsData  *data)
{
	guint max_points, current_points;

	if (data->progress_callback == NULL)
		return;

	/* We assign two progress points to each app: one for when its token has
	 * been received, and one for when it’s been installed. */
	max_points = data->n_tokens_requested * 2;
	current_points = data->n_tokens_received + data->n_apps_installed;
	g_assert (current_points <= max_points);
	g_assert (max_points > 0);

	data->progress_callback (GS_PLUGIN (self),
				 current_points * 100 / max_points,
				 data->progress_user_data);
}

static void
install_request_token_cb (GObject      *source_object,
                          GAsyncResult *result,
                          gpointer      user_data)
{
	GDBusProxy *launcher_portal_proxy = G_DBUS_PROXY (source_object);
	g_autoptr(InstallSingleAppData) app_data = g_steal_pointer (&user_data);
	GTask *task = app_data->task;
	GsPluginEpiphany *self = g_task_get_source_object (task);
	InstallAppsData *data = g_task_get_task_data (task);
	GCancellable *cancellable = g_task_get_cancellable (task);
	gboolean interactive = (data->flags & GS_PLUGIN_INSTALL_APPS_FLAGS_INTERACTIVE);
	const char *token = NULL;
	g_autoptr(GVariant) token_v = NULL;
	g_autoptr(GError) local_error = NULL;

	gs_app_set_progress (app_data->app, 50);
	data->n_tokens_received++;
	install_report_progress (self, data);

	token_v = g_dbus_proxy_call_finish (launcher_portal_proxy, result, &local_error);
	if (token_v == NULL) {
		g_autoptr(GsPluginEvent) event = NULL;

		gs_app_set_state_recover (app_data->app);
		gs_epiphany_error_convert (&local_error);

		event = gs_plugin_event_new ("error", local_error,
					     "app", app_data->app,
					     NULL);
		if (interactive)
			gs_plugin_event_add_flag (event, GS_PLUGIN_EVENT_FLAG_INTERACTIVE);
		gs_plugin_event_add_flag (event, GS_PLUGIN_EVENT_FLAG_WARNING);
		if (data->event_callback != NULL)
			data->event_callback (GS_PLUGIN (self), event, data->event_user_data);
		g_clear_error (&local_error);

		finish_install_apps_op (task, g_steal_pointer (&local_error));
		return;
	}

	/* Then pass the token to Epiphany which will use xdg-desktop-portal to
	 * complete the installation
	 */
	g_variant_get (token_v, "(&s)", &token);
	gs_ephy_web_app_provider_call_install (self->epiphany_proxy,
					       app_data->url, app_data->name, token,
					       interactive ? G_DBUS_CALL_FLAGS_ALLOW_INTERACTIVE_AUTHORIZATION : G_DBUS_CALL_FLAGS_NONE,
					       -1  /* timeout */,
					       cancellable,
					       install_install_cb,
					       app_data  /* steals ownership */);
	app_data = NULL;
}

static void
install_install_cb (GObject      *source_object,
                    GAsyncResult *result,
                    gpointer      user_data)
{
	GsEphyWebAppProvider *epiphany_proxy = GS_EPHY_WEB_APP_PROVIDER (source_object);
	g_autoptr(InstallSingleAppData) app_data = g_steal_pointer (&user_data);
	GTask *task = app_data->task;
	GsPluginEpiphany *self = g_task_get_source_object (task);
	InstallAppsData *data = g_task_get_task_data (task);
	gboolean interactive = (data->flags & GS_PLUGIN_INSTALL_APPS_FLAGS_INTERACTIVE);
	g_autofree char *installed_app_id = NULL;
	g_autoptr(GError) local_error = NULL;

	gs_app_set_progress (app_data->app, 100);
	data->n_apps_installed++;
	install_report_progress (self, data);

	if (!gs_ephy_web_app_provider_call_install_finish (epiphany_proxy,
							   &installed_app_id,
							   result,
							   &local_error)) {
		g_autoptr(GsPluginEvent) event = NULL;

		gs_app_set_state_recover (app_data->app);
		gs_epiphany_error_convert (&local_error);

		event = gs_plugin_event_new ("error", local_error,
					     "app", app_data->app,
					     NULL);
		if (interactive)
			gs_plugin_event_add_flag (event, GS_PLUGIN_EVENT_FLAG_INTERACTIVE);
		gs_plugin_event_add_flag (event, GS_PLUGIN_EVENT_FLAG_WARNING);
		if (data->event_callback != NULL)
			data->event_callback (GS_PLUGIN (self), event, data->event_user_data);
		g_clear_error (&local_error);

		finish_install_apps_op (task, g_steal_pointer (&local_error));
		return;
	}

	/* Install complete! Update internal and app state. */
	{
		g_hash_table_insert (self->url_id_map, g_strdup (app_data->url),
				     g_strdup (installed_app_id));
	}

	gs_app_set_launchable (app_data->app, AS_LAUNCHABLE_KIND_DESKTOP_ID, installed_app_id);
	gs_app_set_state (app_data->app, GS_APP_STATE_INSTALLED);

	finish_install_apps_op (task, NULL);
}

/* @error is (transfer full) if non-%NULL */
static void
finish_install_apps_op (GTask  *task,
                        GError *error)
{
	InstallAppsData *data = g_task_get_task_data (task);
	g_autoptr(GError) error_owned = g_steal_pointer (&error);

	if (error_owned != NULL && data->saved_error == NULL)
		data->saved_error = g_steal_pointer (&error_owned);
	else if (error_owned != NULL)
		g_debug ("Additional error while installing apps: %s", error_owned->message);

	g_assert (data->n_pending_ops > 0);
	data->n_pending_ops--;

	if (data->n_pending_ops > 0)
		return;

	/* Get the results of the parallel ops. */
	if (data->saved_error != NULL)
		g_task_return_error (task, g_steal_pointer (&data->saved_error));
	else
		g_task_return_boolean (task, TRUE);
}

static gboolean
gs_plugin_epiphany_install_apps_finish (GsPlugin      *plugin,
                                        GAsyncResult  *result,
                                        GError       **error)
{
	return g_task_propagate_boolean (G_TASK (result), error);
}

typedef struct {
	/* Input data. */
	GsPluginUninstallAppsFlags flags;
	GsPluginProgressCallback progress_callback;
	gpointer progress_user_data;
	GsPluginEventCallback event_callback;
	void *event_user_data;

	/* In-progress data. */
	guint n_pending_ops;
	GError *saved_error;  /* (owned) (nullable) */

	/* For progress reporting */
	guint n_uninstalls_started;
	guint n_apps_uninstalled;
} UninstallAppsData;

static void
uninstall_apps_data_free (UninstallAppsData *data)
{
	/* Error should have been propagated by now, and all pending ops completed. */
	g_assert (data->saved_error == NULL);
	g_assert (data->n_pending_ops == 0);

	g_free (data);
}

G_DEFINE_AUTOPTR_CLEANUP_FUNC (UninstallAppsData, uninstall_apps_data_free)

typedef struct {
	GTask *task;  /* (owned) */
	GsApp *app;  /* (owned) */
} UninstallSingleAppData;

static void
uninstall_single_app_data_free (UninstallSingleAppData *data)
{
	g_clear_object (&data->app);
	g_clear_object (&data->task);
	g_free (data);
}

G_DEFINE_AUTOPTR_CLEANUP_FUNC (UninstallSingleAppData, uninstall_single_app_data_free)

static void uninstall_cb (GObject      *source_object,
                          GAsyncResult *result,
                          gpointer      user_data);
static void finish_uninstall_apps_op (GTask  *task,
                                      GError *error);

static void
gs_plugin_epiphany_uninstall_apps_async (GsPlugin                           *plugin,
                                         GsAppList                          *apps,
                                         GsPluginUninstallAppsFlags          flags,
                                         GsPluginProgressCallback            progress_callback,
                                         gpointer                            progress_user_data,
                                         GsPluginEventCallback               event_callback,
                                         void                               *event_user_data,
                                         GsPluginAppNeedsUserActionCallback  app_needs_user_action_callback,
                                         gpointer                            app_needs_user_action_data,
                                         GCancellable                       *cancellable,
                                         GAsyncReadyCallback                 callback,
                                         gpointer                            user_data)
{
	GsPluginEpiphany *self = GS_PLUGIN_EPIPHANY (plugin);
	g_autoptr(GTask) task = NULL;
	UninstallAppsData *data;
	g_autoptr(UninstallAppsData) data_owned = NULL;
	gboolean interactive = (flags & GS_PLUGIN_UNINSTALL_APPS_FLAGS_INTERACTIVE);
	g_autoptr(GError) local_error = NULL;

	task = g_task_new (self, cancellable, callback, user_data);
	g_task_set_source_tag (task, gs_plugin_epiphany_uninstall_apps_async);

	data = data_owned = g_new0 (UninstallAppsData, 1);
	data->flags = flags;
	data->progress_callback = progress_callback;
	data->progress_user_data = progress_user_data;
	data->event_callback = event_callback;
	data->event_user_data = event_user_data;
	g_task_set_task_data (task, g_steal_pointer (&data_owned), (GDestroyNotify) uninstall_apps_data_free);

	/* Start a load of operations in parallel to uninstall the apps.
	 *
	 * When all uninstalls are finished for all apps, finish_uninstall_apps_op()
	 * will return success/error for the overall #GTask. */
	data->n_pending_ops = 1;
	data->n_uninstalls_started = 0;

	for (guint i = 0; i < gs_app_list_length (apps); i++) {
		GsApp *app = gs_app_list_index (apps, i);
		g_autoptr(UninstallSingleAppData) app_data = NULL;
		const char *installed_app_id;

		/* This should be guaranteed by GsPluginJobUninstallApps */
		g_assert (gs_app_has_management_plugin (app, plugin));

		installed_app_id = gs_app_get_launchable (app, AS_LAUNCHABLE_KIND_DESKTOP_ID);
		if (installed_app_id == NULL) {
			g_autoptr(GsPluginEvent) event = NULL;

			g_set_error (&local_error,
				     GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_FAILED,
				     "Can’t uninstall web app %s without installed app ID",
				     gs_app_get_id (app));

			event = gs_plugin_event_new ("error", local_error,
						     "app", app,
						     NULL);
			if (interactive)
				gs_plugin_event_add_flag (event, GS_PLUGIN_EVENT_FLAG_INTERACTIVE);
			gs_plugin_event_add_flag (event, GS_PLUGIN_EVENT_FLAG_WARNING);
			if (event_callback != NULL)
				event_callback (GS_PLUGIN (self), event, event_user_data);
			g_clear_error (&local_error);

			continue;
		}

		app_data = g_new0 (UninstallSingleAppData, 1);
		app_data->task = g_object_ref (task);
		app_data->app = g_object_ref (app);

		gs_app_set_state (app, GS_APP_STATE_REMOVING);
		gs_app_set_progress (app, 0);

		data->n_uninstalls_started++;
		data->n_pending_ops++;
		gs_ephy_web_app_provider_call_uninstall (self->epiphany_proxy,
							 installed_app_id,
							 interactive ? G_DBUS_CALL_FLAGS_ALLOW_INTERACTIVE_AUTHORIZATION : G_DBUS_CALL_FLAGS_NONE,
							 -1  /* timeout */,
							 cancellable,
							 uninstall_cb,
							 g_steal_pointer (&app_data));
	}

	finish_uninstall_apps_op (task, NULL);
}

static void
uninstall_cb (GObject      *source_object,
              GAsyncResult *result,
              gpointer      user_data)
{
	GsEphyWebAppProvider *epiphany_proxy = GS_EPHY_WEB_APP_PROVIDER (source_object);
	g_autoptr(UninstallSingleAppData) app_data = g_steal_pointer (&user_data);
	GTask *task = app_data->task;
	GsPluginEpiphany *self = g_task_get_source_object (task);
	UninstallAppsData *data = g_task_get_task_data (task);
	gboolean interactive = (data->flags & GS_PLUGIN_UNINSTALL_APPS_FLAGS_INTERACTIVE);
	const char *url;
	g_autoptr(GError) local_error = NULL;

	gs_app_set_progress (app_data->app, 100);
	data->n_apps_uninstalled++;

	/* Progress report. */
	if (data->progress_callback != NULL) {
		g_assert (data->n_uninstalls_started > 0);
		data->progress_callback (GS_PLUGIN (self),
					 data->n_apps_uninstalled * 100 / data->n_uninstalls_started,
					 data->progress_user_data);
	}

	if (!gs_ephy_web_app_provider_call_uninstall_finish (epiphany_proxy,
							     result,
							     &local_error)) {
		g_autoptr(GsPluginEvent) event = NULL;

		gs_app_set_state_recover (app_data->app);
		gs_epiphany_error_convert (&local_error);

		event = gs_plugin_event_new ("error", local_error,
					     "app", app_data->app,
					     NULL);
		if (interactive)
			gs_plugin_event_add_flag (event, GS_PLUGIN_EVENT_FLAG_INTERACTIVE);
		gs_plugin_event_add_flag (event, GS_PLUGIN_EVENT_FLAG_WARNING);
		if (data->event_callback != NULL)
			data->event_callback (GS_PLUGIN (self), event, data->event_user_data);
		g_clear_error (&local_error);

		finish_uninstall_apps_op (task, NULL);
		return;
	}

	url = gs_app_get_launchable (app_data->app, AS_LAUNCHABLE_KIND_URL);
	if (url != NULL && *url != '\0') {
		g_hash_table_remove (self->url_id_map, url);
	}

	/* The app is not necessarily available; it may have been installed
	 * directly in Epiphany. So refine it to check.
	 */
	gs_app_set_state (app_data->app, GS_APP_STATE_UNKNOWN);
	gs_epiphany_refine_app (self, app_data->app,
				GS_PLUGIN_REFINE_REQUIRE_FLAGS_ORIGIN |
				GS_PLUGIN_REFINE_REQUIRE_FLAGS_SETUP_ACTION,
				url);
	gs_epiphany_refine_app_state (GS_PLUGIN (self), app_data->app);

	finish_uninstall_apps_op (task, NULL);
}

/* @error is (transfer full) if non-%NULL */
static void
finish_uninstall_apps_op (GTask  *task,
                          GError *error)
{
	UninstallAppsData *data = g_task_get_task_data (task);
	g_autoptr(GError) error_owned = g_steal_pointer (&error);

	if (error_owned != NULL && data->saved_error == NULL)
		data->saved_error = g_steal_pointer (&error_owned);
	else if (error_owned != NULL)
		g_debug ("Additional error while uninstalling apps: %s", error_owned->message);

	g_assert (data->n_pending_ops > 0);
	data->n_pending_ops--;

	if (data->n_pending_ops > 0)
		return;

	/* Get the results of the parallel ops. */
	if (data->saved_error != NULL)
		g_task_return_error (task, g_steal_pointer (&data->saved_error));
	else
		g_task_return_boolean (task, TRUE);
}

static gboolean
gs_plugin_epiphany_uninstall_apps_finish (GsPlugin      *plugin,
                                          GAsyncResult  *result,
                                          GError       **error)
{
	return g_task_propagate_boolean (G_TASK (result), error);
}

static void
gs_plugin_epiphany_launch_async (GsPlugin *plugin,
				 GsApp *app,
				 GsPluginLaunchFlags flags,
				 GCancellable *cancellable,
				 GAsyncReadyCallback callback,
				 gpointer user_data)
{
	gs_plugin_app_launch_async (plugin, app, flags, cancellable, callback, user_data);
}

static gboolean
gs_plugin_epiphany_launch_finish (GsPlugin *plugin,
				  GAsyncResult *result,
				  GError **error)
{
	return gs_plugin_app_launch_finish (plugin, result, error);
}

static void
gs_plugin_epiphany_class_init (GsPluginEpiphanyClass *klass)
{
	GObjectClass *object_class = G_OBJECT_CLASS (klass);
	GsPluginClass *plugin_class = GS_PLUGIN_CLASS (klass);

	object_class->dispose = gs_plugin_epiphany_dispose;
	object_class->finalize = gs_plugin_epiphany_finalize;

	plugin_class->adopt_app = gs_plugin_epiphany_adopt_app;
	plugin_class->setup_async = gs_plugin_epiphany_setup_async;
	plugin_class->setup_finish = gs_plugin_epiphany_setup_finish;
	plugin_class->refine_async = gs_plugin_epiphany_refine_async;
	plugin_class->refine_finish = gs_plugin_epiphany_refine_finish;
	plugin_class->list_apps_async = gs_plugin_epiphany_list_apps_async;
	plugin_class->list_apps_finish = gs_plugin_epiphany_list_apps_finish;
	plugin_class->install_apps_async = gs_plugin_epiphany_install_apps_async;
	plugin_class->install_apps_finish = gs_plugin_epiphany_install_apps_finish;
	plugin_class->uninstall_apps_async = gs_plugin_epiphany_uninstall_apps_async;
	plugin_class->uninstall_apps_finish = gs_plugin_epiphany_uninstall_apps_finish;
	plugin_class->launch_async = gs_plugin_epiphany_launch_async;
	plugin_class->launch_finish = gs_plugin_epiphany_launch_finish;
}

GType
gs_plugin_query_type (void)
{
	return GS_TYPE_PLUGIN_EPIPHANY;
}
