summaryrefslogtreecommitdiffstats
path: root/plugins/epiphany
diff options
context:
space:
mode:
Diffstat (limited to 'plugins/epiphany')
-rw-r--r--plugins/epiphany/gs-plugin-epiphany.c1183
-rw-r--r--plugins/epiphany/gs-plugin-epiphany.h20
-rw-r--r--plugins/epiphany/gs-self-test.c240
-rw-r--r--plugins/epiphany/meson.build87
-rw-r--r--plugins/epiphany/org.freedesktop.portal.DynamicLauncher.xml332
-rw-r--r--plugins/epiphany/org.gnome.Epiphany.WebAppProvider.xml85
-rw-r--r--plugins/epiphany/org.gnome.Software.Plugin.Epiphany.metainfo.xml.in11
7 files changed, 1958 insertions, 0 deletions
diff --git a/plugins/epiphany/gs-plugin-epiphany.c b/plugins/epiphany/gs-plugin-epiphany.c
new file mode 100644
index 0000000..537ed40
--- /dev/null
+++ b/plugins/epiphany/gs-plugin-epiphany.c
@@ -0,0 +1,1183 @@
+/* -*- 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+
+ */
+
+#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().
+ *
+ * Since: 43
+ */
+
+struct _GsPluginEpiphany
+{
+ GsPlugin parent;
+
+ GsWorkerThread *worker; /* (owned) */
+
+ GsEphyWebAppProvider *epiphany_proxy; /* (owned) */
+ GDBusProxy *launcher_portal_proxy; /* (owned) */
+ GFileMonitor *monitor; /* (owned) */
+ guint changed_id;
+ /* protects installed_apps_cached, url_id_map, and the plugin cache */
+ GMutex installed_apps_mutex;
+ /* 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)
+
+#define assert_in_worker(self) \
+ g_assert (gs_worker_thread_is_in_worker_context (self->worker))
+
+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);
+
+ {
+ g_autoptr(GMutexLocker) locker = g_mutex_locker_new (&self->installed_apps_mutex);
+ 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));
+ }
+
+ /* Start up a worker thread to process all the plugin’s function calls. */
+ self->worker = gs_worker_thread_new ("gs-plugin-epiphany");
+
+ 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 shutdown_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data);
+
+static void
+gs_plugin_epiphany_shutdown_async (GsPlugin *plugin,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data)
+{
+ GsPluginEpiphany *self = GS_PLUGIN_EPIPHANY (plugin);
+ g_autoptr(GTask) task = NULL;
+
+ task = g_task_new (self, cancellable, callback, user_data);
+ g_task_set_source_tag (task, gs_plugin_epiphany_shutdown_async);
+
+ /* Stop the worker thread. */
+ gs_worker_thread_shutdown_async (self->worker, cancellable, shutdown_cb, g_steal_pointer (&task));
+}
+
+static void
+shutdown_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data)
+{
+ g_autoptr(GTask) task = G_TASK (user_data);
+ GsPluginEpiphany *self = g_task_get_source_object (task);
+ g_autoptr(GsWorkerThread) worker = NULL;
+ g_autoptr(GError) local_error = NULL;
+
+ worker = g_steal_pointer (&self->worker);
+
+ if (!gs_worker_thread_shutdown_finish (worker, result, &local_error)) {
+ g_task_return_error (task, g_steal_pointer (&local_error));
+ return;
+ }
+
+ g_task_return_boolean (task, TRUE);
+}
+
+static gboolean
+gs_plugin_epiphany_shutdown_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);
+
+ /* set name of MetaInfo file */
+ gs_plugin_set_appstream_id (GS_PLUGIN (self), "org.gnome.Software.Plugin.Epiphany");
+
+ /* 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_object (&self->worker);
+ 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_mutex_clear (&self->installed_apps_mutex);
+ g_clear_object (&self->permissions);
+
+ G_OBJECT_CLASS (gs_plugin_epiphany_parent_class)->finalize (object);
+}
+
+static gboolean ensure_installed_apps_cache (GsPluginEpiphany *self,
+ GCancellable *cancellable,
+ GError **error);
+
+/* Run in @worker. The caller must have already done ensure_installed_apps_cache() */
+static void
+gs_epiphany_refine_app_state (GsPlugin *plugin,
+ GsApp *app)
+{
+ GsPluginEpiphany *self = GS_PLUGIN_EPIPHANY (plugin);
+
+ assert_in_worker (self);
+
+ 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,
+ "https://gitlab.gnome.org/GNOME/gnome-software/-/wikis/How-to-reinstall-a-web-app");
+ }
+ }
+}
+
+void
+gs_plugin_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 gint
+get_priority_for_interactivity (gboolean interactive)
+{
+ return interactive ? G_PRIORITY_DEFAULT : G_PRIORITY_LOW;
+}
+
+static void list_apps_thread_cb (GTask *task,
+ gpointer source_object,
+ gpointer task_data,
+ GCancellable *cancellable);
+
+static void
+gs_plugin_epiphany_list_apps_async (GsPlugin *plugin,
+ GsAppQuery *query,
+ GsPluginListAppsFlags flags,
+ 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);
+
+ task = gs_plugin_list_apps_data_new_task (plugin, query, flags,
+ cancellable, callback, user_data);
+ g_task_set_source_tag (task, gs_plugin_epiphany_list_apps_async);
+
+ /* Queue a job to get the apps. */
+ gs_worker_thread_queue (self->worker, get_priority_for_interactivity (interactive),
+ list_apps_thread_cb, g_steal_pointer (&task));
+}
+
+/* Run in @worker */
+static void
+refine_app (GsPluginEpiphany *self,
+ GsApp *app,
+ GsPluginRefineFlags 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");
+ gs_app_set_origin_ui (app, _("GNOME Web"));
+
+ gs_app_set_scope (app, AS_COMPONENT_SCOPE_USER);
+ gs_app_set_launchable (app, AS_LAUNCHABLE_KIND_URL, url);
+
+ 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;
+
+ 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 (flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_SIZE) {
+ 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);
+
+ 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 (flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_SIZE &&
+ icon_path) {
+ icon_file = g_file_new_for_path (icon_path);
+
+ g_clear_object (&file_info);
+ 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 (flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_ICON &&
+ gs_app_get_icons (app) == NULL &&
+ 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);
+ }
+ }
+}
+
+/* Run in @worker */
+static GsApp *
+gs_epiphany_create_app (GsPluginEpiphany *self,
+ const char *id)
+{
+ g_autoptr(GsApp) app = NULL;
+
+ assert_in_worker (self);
+
+ 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);
+}
+
+/* Run in @worker */
+static gboolean
+ensure_installed_apps_cache (GsPluginEpiphany *self,
+ GCancellable *cancellable,
+ GError **error)
+{
+ g_autoptr(GVariant) webapps_v = NULL;
+ g_auto(GStrv) webapps = NULL;
+ guint n_webapps;
+ g_autoptr(GsAppList) installed_cache = gs_app_list_new ();
+ g_autoptr(GMutexLocker) locker = g_mutex_locker_new (&self->installed_apps_mutex);
+
+ assert_in_worker (self);
+
+ if (self->installed_apps_cached)
+ return TRUE;
+
+ if (!gs_ephy_web_app_provider_call_get_installed_apps_sync (self->epiphany_proxy,
+ &webapps,
+ cancellable,
+ error)) {
+ gs_epiphany_error_convert (error);
+ return FALSE;
+ }
+
+ 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;
+ GsPluginRefineFlags refine_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);
+
+ 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() */
+ 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);
+ 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);
+
+ refine_flags = GS_PLUGIN_REFINE_FLAGS_REQUIRE_ICON |
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_SIZE |
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_ID;
+ refine_app (self, app, refine_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;
+ return TRUE;
+}
+
+/* Run in @worker */
+static void
+list_apps_thread_cb (GTask *task,
+ gpointer source_object,
+ gpointer task_data,
+ GCancellable *cancellable)
+{
+ GsPluginEpiphany *self = GS_PLUGIN_EPIPHANY (source_object);
+ g_autoptr(GsAppList) list = gs_app_list_new ();
+ GsPluginListAppsData *data = task_data;
+ GsAppQueryTristate is_installed = GS_APP_QUERY_TRISTATE_UNSET;
+ const gchar * const *keywords = NULL;
+ g_autoptr(GError) local_error = NULL;
+
+ assert_in_worker (self);
+
+ if (data->query != NULL) {
+ is_installed = gs_app_query_get_is_installed (data->query);
+ keywords = gs_app_query_get_keywords (data->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 (data->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. */
+ if (!ensure_installed_apps_cache (self, cancellable, &local_error)) {
+ g_task_return_error (task, g_steal_pointer (&local_error));
+ return;
+ }
+
+ 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 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,
+ GsPluginRefineFlags refine_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, refine_flags, uri, url);
+}
+
+static void refine_thread_cb (GTask *task,
+ gpointer source_object,
+ gpointer task_data,
+ GCancellable *cancellable);
+
+static void
+gs_plugin_epiphany_refine_async (GsPlugin *plugin,
+ GsAppList *list,
+ GsPluginRefineFlags flags,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data)
+{
+ GsPluginEpiphany *self = GS_PLUGIN_EPIPHANY (plugin);
+ g_autoptr(GTask) task = NULL;
+ gboolean interactive = gs_plugin_has_flags (GS_PLUGIN (self), GS_PLUGIN_FLAGS_INTERACTIVE);
+
+ task = gs_plugin_refine_data_new_task (plugin, list, flags, cancellable, callback, user_data);
+ g_task_set_source_tag (task, gs_plugin_epiphany_refine_async);
+
+ /* Queue a job for the refine. */
+ gs_worker_thread_queue (self->worker, get_priority_for_interactivity (interactive),
+ refine_thread_cb, g_steal_pointer (&task));
+}
+
+/* Run in @worker. */
+static void
+refine_thread_cb (GTask *task,
+ gpointer source_object,
+ gpointer task_data,
+ GCancellable *cancellable)
+{
+ GsPluginEpiphany *self = GS_PLUGIN_EPIPHANY (source_object);
+ GsPluginRefineData *data = task_data;
+ GsPluginRefineFlags flags = data->flags;
+ GsAppList *list = data->list;
+ g_autoptr(GError) local_error = NULL;
+
+ assert_in_worker (self);
+
+ if (!ensure_installed_apps_cache (self, cancellable, &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, 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);
+}
+
+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);
+}
+
+gboolean
+gs_plugin_app_install (GsPlugin *plugin,
+ GsApp *app,
+ GCancellable *cancellable,
+ GError **error)
+{
+ GsPluginEpiphany *self = GS_PLUGIN_EPIPHANY (plugin);
+ const char *url;
+ const char *name;
+ const char *token = NULL;
+ g_autofree char *installed_app_id = NULL;
+ g_autoptr(GVariant) token_v = NULL;
+ g_autoptr(GVariant) icon_v = NULL;
+ GVariantBuilder opt_builder;
+ const int icon_sizes[] = {512, 192, 128, 1};
+
+ if (!gs_app_has_management_plugin (app, plugin))
+ return TRUE;
+
+ url = gs_app_get_url (app, AS_URL_KIND_HOMEPAGE);
+ if (url == NULL || *url == '\0') {
+ g_set_error (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_FAILED,
+ "Can't install web app %s without url",
+ gs_app_get_id (app));
+ return FALSE;
+ }
+ name = gs_app_get_name (app);
+ if (name == NULL || *name == '\0') {
+ g_set_error (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_FAILED,
+ "Can't install web app %s without name",
+ gs_app_get_id (app));
+ return FALSE;
+ }
+ for (guint i = 0; i < G_N_ELEMENTS (icon_sizes); i++) {
+ GIcon *icon = gs_app_get_icon_for_size (app, icon_sizes[i], 1, NULL);
+ if (icon != NULL)
+ icon_v = get_serialized_icon (app, icon);
+ if (icon_v != NULL)
+ break;
+ }
+ if (icon_v == NULL) {
+ g_set_error (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_FAILED,
+ "Can't install web app %s without icon",
+ gs_app_get_id (app));
+ return FALSE;
+ }
+
+ gs_app_set_state (app, GS_APP_STATE_INSTALLING);
+ /* First get a token from xdg-desktop-portal so Epiphany can do the
+ * installation without user confirmation
+ */
+ g_variant_builder_init (&opt_builder, G_VARIANT_TYPE_VARDICT);
+ token_v = g_dbus_proxy_call_sync (self->launcher_portal_proxy,
+ "RequestInstallToken",
+ g_variant_new ("(sva{sv})",
+ name, icon_v, &opt_builder),
+ G_DBUS_CALL_FLAGS_NONE,
+ -1, cancellable, error);
+ if (token_v == NULL) {
+ gs_epiphany_error_convert (error);
+ gs_app_set_state_recover (app);
+ return FALSE;
+ }
+
+ /* Then pass the token to Epiphany which will use xdg-desktop-portal to
+ * complete the installation
+ */
+ g_variant_get (token_v, "(&s)", &token);
+ if (!gs_ephy_web_app_provider_call_install_sync (self->epiphany_proxy,
+ url, name, token,
+ &installed_app_id,
+ cancellable,
+ error)) {
+ gs_epiphany_error_convert (error);
+ gs_app_set_state_recover (app);
+ return FALSE;
+ }
+
+ {
+ g_autoptr(GMutexLocker) locker = g_mutex_locker_new (&self->installed_apps_mutex);
+ g_hash_table_insert (self->url_id_map, g_strdup (url),
+ g_strdup (installed_app_id));
+ }
+
+ gs_app_set_launchable (app, AS_LAUNCHABLE_KIND_DESKTOP_ID, installed_app_id);
+ gs_app_set_state (app, GS_APP_STATE_INSTALLED);
+
+ return TRUE;
+}
+
+gboolean
+gs_plugin_app_remove (GsPlugin *plugin,
+ GsApp *app,
+ GCancellable *cancellable,
+ GError **error)
+{
+ GsPluginEpiphany *self = GS_PLUGIN_EPIPHANY (plugin);
+ const char *installed_app_id;
+ const char *url;
+
+ if (!gs_app_has_management_plugin (app, plugin))
+ return TRUE;
+
+ installed_app_id = gs_app_get_launchable (app, AS_LAUNCHABLE_KIND_DESKTOP_ID);
+ if (installed_app_id == NULL) {
+ g_set_error_literal (error,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_NOT_SUPPORTED,
+ "App can't be uninstalled without installed app ID");
+ gs_app_set_state_recover (app);
+ return FALSE;
+ }
+
+ gs_app_set_state (app, GS_APP_STATE_REMOVING);
+ if (!gs_ephy_web_app_provider_call_uninstall_sync (self->epiphany_proxy,
+ installed_app_id,
+ cancellable,
+ error)) {
+ gs_epiphany_error_convert (error);
+ gs_app_set_state_recover (app);
+ return FALSE;
+ }
+
+ url = gs_app_get_launchable (app, AS_LAUNCHABLE_KIND_URL);
+ if (url != NULL && *url != '\0') {
+ g_autoptr(GMutexLocker) locker = g_mutex_locker_new (&self->installed_apps_mutex);
+ g_hash_table_remove (self->url_id_map, url);
+ }
+
+ /* The app is not necessarily available; it may have been installed
+ * directly in Epiphany
+ */
+ gs_app_set_state (app, GS_APP_STATE_UNKNOWN);
+
+ return TRUE;
+}
+
+gboolean
+gs_plugin_launch (GsPlugin *plugin,
+ GsApp *app,
+ GCancellable *cancellable,
+ GError **error)
+{
+ if (!gs_app_has_management_plugin (app, plugin))
+ return TRUE;
+
+ return gs_plugin_app_launch (plugin, app, 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->setup_async = gs_plugin_epiphany_setup_async;
+ plugin_class->setup_finish = gs_plugin_epiphany_setup_finish;
+ plugin_class->shutdown_async = gs_plugin_epiphany_shutdown_async;
+ plugin_class->shutdown_finish = gs_plugin_epiphany_shutdown_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;
+}
+
+GType
+gs_plugin_query_type (void)
+{
+ return GS_TYPE_PLUGIN_EPIPHANY;
+}
diff --git a/plugins/epiphany/gs-plugin-epiphany.h b/plugins/epiphany/gs-plugin-epiphany.h
new file mode 100644
index 0000000..955d059
--- /dev/null
+++ b/plugins/epiphany/gs-plugin-epiphany.h
@@ -0,0 +1,20 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2021 Matthew Leeds <mwleeds@protonmail.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#include <glib.h>
+#include <glib-object.h>
+
+G_BEGIN_DECLS
+
+#define GS_TYPE_PLUGIN_EPIPHANY (gs_plugin_epiphany_get_type ())
+
+G_DECLARE_FINAL_TYPE (GsPluginEpiphany, gs_plugin_epiphany, GS, PLUGIN_EPIPHANY, GsPlugin)
+
+G_END_DECLS
diff --git a/plugins/epiphany/gs-self-test.c b/plugins/epiphany/gs-self-test.c
new file mode 100644
index 0000000..1b91021
--- /dev/null
+++ b/plugins/epiphany/gs-self-test.c
@@ -0,0 +1,240 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2013-2017 Richard Hughes <richard@hughsie.com>
+ * Copyright (C) 2021 Matthew Leeds <mwleeds@protonmail.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#include "config.h"
+
+#include "gnome-software-private.h"
+
+#include "gs-test.h"
+#include "gs-dynamic-launcher-portal-iface.h"
+#include "gs-epiphany-generated.h"
+
+#include <libglib-testing/dbus-queue.h>
+
+/* This is run in a worker thread */
+static void
+epiphany_and_portal_mock_server_cb (GtDBusQueue *queue,
+ gpointer user_data)
+{
+ {
+ g_autoptr(GDBusMethodInvocation) invocation = NULL;
+ g_autoptr(GVariant) properties_variant = NULL;
+ const char *property_interface;
+ invocation = gt_dbus_queue_assert_pop_message (queue,
+ "/org/gnome/Epiphany/WebAppProvider",
+ "org.freedesktop.DBus.Properties",
+ "GetAll", "(&s)",
+ &property_interface);
+ g_assert_cmpstr (property_interface, ==, "org.gnome.Epiphany.WebAppProvider");
+ properties_variant = g_variant_new_parsed ("({'Version': <@u 1>},)");
+ g_dbus_method_invocation_return_value (invocation, g_steal_pointer (&properties_variant));
+ }
+ {
+ g_autoptr(GDBusMethodInvocation) invocation = NULL;
+ g_autoptr(GVariant) properties_variant = NULL;
+ const char *property_interface, *props_dict;
+ invocation = gt_dbus_queue_assert_pop_message (queue,
+ "/org/freedesktop/portal/desktop",
+ "org.freedesktop.DBus.Properties",
+ "GetAll", "(&s)",
+ &property_interface);
+ g_assert_cmpstr (property_interface, ==, "org.freedesktop.portal.DynamicLauncher");
+ props_dict = "({'version': <@u 1>,'SupportedLauncherTypes': <@u 3>},)";
+ properties_variant = g_variant_new_parsed (props_dict);
+ g_dbus_method_invocation_return_value (invocation, g_steal_pointer (&properties_variant));
+ }
+ {
+ g_autoptr(GDBusMethodInvocation) invocation = NULL;
+ const char *installed_apps[] = {"org.gnome.Epiphany.WebApp_e9d0e1e4b0a10856aa3b38d9eb4375de4070d043.desktop", NULL};
+ invocation = gt_dbus_queue_assert_pop_message (queue,
+ "/org/gnome/Epiphany/WebAppProvider",
+ "org.gnome.Epiphany.WebAppProvider",
+ "GetInstalledApps", "()", NULL);
+ g_dbus_method_invocation_return_value (invocation, g_variant_new ("(^as)", installed_apps));
+ }
+}
+
+static GtDBusQueue *
+bus_set_up (void)
+{
+ g_autoptr(GError) local_error = NULL;
+ g_autoptr(GtDBusQueue) queue = NULL;
+
+ queue = gt_dbus_queue_new ();
+
+ gt_dbus_queue_connect (queue, &local_error);
+ g_assert_no_error (local_error);
+
+ gt_dbus_queue_own_name (queue, "org.freedesktop.portal.Desktop");
+
+ gt_dbus_queue_export_object (queue,
+ "/org/freedesktop/portal/desktop",
+ (GDBusInterfaceInfo *) &org_freedesktop_portal_dynamic_launcher_interface,
+ &local_error);
+ g_assert_no_error (local_error);
+
+ gt_dbus_queue_own_name (queue, "org.gnome.Epiphany.WebAppProvider");
+
+ gt_dbus_queue_export_object (queue,
+ "/org/gnome/Epiphany/WebAppProvider",
+ gs_ephy_web_app_provider_interface_info (),
+ &local_error);
+ g_assert_no_error (local_error);
+
+ gt_dbus_queue_set_server_func (queue, epiphany_and_portal_mock_server_cb,
+ NULL);
+
+ return g_steal_pointer (&queue);
+}
+
+static void
+gs_plugins_epiphany_func (GsPluginLoader *plugin_loader)
+{
+ g_assert_true (gs_plugin_loader_get_enabled (plugin_loader, "epiphany"));
+}
+
+static char *
+create_fake_desktop_file (const char *app_id)
+{
+ g_autofree char *contents = NULL;
+ g_autoptr(GError) error = NULL;
+ g_autofree char *desktop_path = NULL;
+ g_autofree char *icon_path = NULL;
+
+ /* Use an icon we already have locally */
+ icon_path = gs_test_get_filename (TESTDATADIR, "icons/hicolor/scalable/org.gnome.Software.svg");
+ g_assert (icon_path != NULL);
+
+ /* Use true instead of epiphany in Exec and TryExec; otherwise
+ * g_desktop_app_info_new() in the plugin code will look for an
+ * epiphany binary and fail.
+ */
+ contents = g_strdup_printf ("[Desktop Entry]\n"
+ "Name=Pinafore\n"
+ "Exec=true --application-mode \"--profile=/home/nobody/.local/share/%s\" https://pinafore.social/\n"
+ "StartupNotify=true\n"
+ "Terminal=false\n"
+ "Type=Application\n"
+ "Categories=GNOME;GTK;\n"
+ "Icon=%s\n"
+ "StartupWMClass=%s\n"
+ "X-Purism-FormFactor=Workstation;Mobile;\n"
+ "TryExec=true\n",
+ app_id, icon_path, app_id);
+
+ desktop_path = g_strconcat (g_get_user_data_dir (), G_DIR_SEPARATOR_S,
+ "applications", G_DIR_SEPARATOR_S,
+ app_id, ".desktop", NULL);
+
+ g_debug ("Creating a fake desktop file at path: %s", desktop_path);
+ gs_mkdir_parent (desktop_path, &error);
+ g_assert_no_error (error);
+ g_file_set_contents (desktop_path, contents, -1, &error);
+ g_assert_no_error (error);
+
+ return g_steal_pointer (&desktop_path);
+}
+
+static void
+gs_plugins_epiphany_installed_func (GsPluginLoader *plugin_loader)
+{
+ g_autoptr(GsAppQuery) query = NULL;
+ g_autoptr(GsPluginJob) plugin_job = NULL;
+ g_autoptr(GError) error = NULL;
+ g_autoptr(GIcon) icon = NULL;
+ g_autoptr(GsAppList) list = NULL;
+ GsApp *app;
+ const char *app_id = "org.gnome.Epiphany.WebApp_e9d0e1e4b0a10856aa3b38d9eb4375de4070d043";
+ const char *metainfo_app_id = "org.gnome.Software.WebApp_e636aa5f2069f6e9c02deccc7b65f43da7985e32.desktop";
+ const char *launchable_app_id;
+ g_autofree char *app_id_desktop = NULL;
+ g_autofree char *desktop_path = NULL;
+ g_autofree char *origin_ui = NULL;
+
+ app_id_desktop = g_strdup_printf ("%s.desktop", app_id);
+ desktop_path = create_fake_desktop_file (app_id);
+
+ query = gs_app_query_new ("is-installed", GS_APP_QUERY_TRISTATE_TRUE,
+ "refine-flags", GS_PLUGIN_REFINE_FLAGS_REQUIRE_ORIGIN,
+ "dedupe-flags", GS_PLUGIN_JOB_DEDUPE_FLAGS_DEFAULT,
+ NULL);
+ plugin_job = gs_plugin_job_list_apps_new (query, GS_PLUGIN_LIST_APPS_FLAGS_NONE);
+ list = gs_plugin_loader_job_process (plugin_loader, plugin_job, NULL, &error);
+ gs_test_flush_main_context ();
+ g_assert_no_error (error);
+ g_assert_nonnull (list);
+
+ g_assert_cmpint (gs_app_list_length (list), ==, 1);
+ app = gs_app_list_index (list, 0);
+ g_assert_cmpstr (gs_app_get_id (app), ==, metainfo_app_id);
+ launchable_app_id = gs_app_get_launchable (app, AS_LAUNCHABLE_KIND_DESKTOP_ID);
+ g_assert_cmpstr (launchable_app_id, ==, app_id_desktop);
+ g_assert_cmpint (gs_app_get_kind (app), ==, AS_COMPONENT_KIND_WEB_APP);
+ g_assert_cmpint (gs_app_get_scope (app), ==, AS_COMPONENT_SCOPE_USER);
+ g_assert_cmpint (gs_app_get_state (app), ==, GS_APP_STATE_INSTALLED);
+ g_assert_cmpstr (gs_app_get_name (app), ==, "Pinafore");
+ g_assert_cmpstr (gs_app_get_summary (app), ==, "pinafore.social");
+ g_assert_cmpstr (gs_app_get_origin (app), ==, "gnome-web");
+ origin_ui = gs_app_dup_origin_ui (app, TRUE);
+ g_assert_cmpstr (origin_ui, ==, "GNOME Web");
+ icon = gs_app_get_icon_for_size (app, 4096, 1, NULL);
+ g_assert_nonnull (icon);
+ g_clear_object (&icon);
+
+ gs_utils_unlink (desktop_path, NULL);
+}
+
+int
+main (int argc, char **argv)
+{
+ gboolean ret;
+ g_autofree gchar *fn = NULL;
+ g_autofree gchar *xml = NULL;
+ g_autoptr(GError) error = NULL;
+ g_autoptr(GsPluginLoader) plugin_loader = NULL;
+ g_autoptr(GtDBusQueue) queue = NULL;
+ int res;
+ const gchar *allowlist[] = {
+ "epiphany",
+ "icons",
+ NULL
+ };
+
+ gs_test_init (&argc, &argv);
+ g_setenv ("GS_XMLB_VERBOSE", "1", TRUE);
+
+ /* Set up mock D-Bus services for the Epiphany WebAppProvider and the
+ * DynamicLauncher portal
+ */
+ queue = bus_set_up ();
+
+ /* we can only load this once per process */
+ plugin_loader = gs_plugin_loader_new (gt_dbus_queue_get_client_connection (queue), NULL);
+ gs_plugin_loader_add_location (plugin_loader, LOCALPLUGINDIR);
+ gs_plugin_loader_add_location (plugin_loader, LOCALPLUGINDIR_CORE);
+ ret = gs_plugin_loader_setup (plugin_loader,
+ allowlist,
+ NULL,
+ NULL,
+ &error);
+ g_assert_no_error (error);
+ g_assert_true (ret);
+
+ /* plugin tests go here */
+ g_test_add_data_func ("/gnome-software/plugins/epiphany/enabled",
+ plugin_loader,
+ (GTestDataFunc) gs_plugins_epiphany_func);
+ g_test_add_data_func ("/gnome-software/plugins/epiphany/installed",
+ plugin_loader,
+ (GTestDataFunc) gs_plugins_epiphany_installed_func);
+
+ res = g_test_run ();
+ gt_dbus_queue_disconnect (queue, TRUE);
+ return res;
+}
diff --git a/plugins/epiphany/meson.build b/plugins/epiphany/meson.build
new file mode 100644
index 0000000..05fc7b6
--- /dev/null
+++ b/plugins/epiphany/meson.build
@@ -0,0 +1,87 @@
+cargs = ['-DG_LOG_DOMAIN="GsPluginEpiphany"']
+
+# The WebAppProvider interface xml comes from:
+# https://gitlab.gnome.org/GNOME/epiphany/-/blob/master/src/webapp-provider/org.gnome.Epiphany.WebAppProvider.xml
+epiphany_generated = gnome.gdbus_codegen(
+ 'gs-epiphany-generated',
+ sources : ['org.gnome.Epiphany.WebAppProvider.xml'],
+ interface_prefix : 'org.gnome.Epiphany',
+ namespace : 'GsEphy',
+)
+
+shared_module(
+ 'gs_plugin_epiphany',
+ epiphany_generated,
+ sources : 'gs-plugin-epiphany.c',
+ include_directories : [
+ include_directories('../..'),
+ include_directories('../../lib'),
+ ],
+ install : true,
+ install_dir: plugin_dir,
+ c_args : cargs,
+ dependencies : plugin_libs,
+ link_with : [
+ libgnomesoftware,
+ ],
+)
+metainfo = 'org.gnome.Software.Plugin.Epiphany.metainfo.xml'
+
+i18n.merge_file(
+ input: metainfo + '.in',
+ output: metainfo,
+ type: 'xml',
+ po_dir: join_paths(meson.project_source_root(), 'po'),
+ install: true,
+ install_dir: join_paths(get_option('datadir'), 'metainfo'),
+)
+
+if get_option('tests')
+ # The DynamicLauncher interface xml comes from:
+ # https://github.com/flatpak/xdg-desktop-portal/blob/main/data/org.freedesktop.portal.DynamicLauncher.xml
+ gdbus_codegen = find_program('gdbus-codegen')
+ dynamic_launcher_portal_iface_h = custom_target(
+ 'gs-dynamic-launcher-portal-iface.h',
+ input: ['org.freedesktop.portal.DynamicLauncher.xml'],
+ output: ['gs-dynamic-launcher-portal-iface.h'],
+ command: [gdbus_codegen,
+ '--interface-info-header',
+ '--output', '@OUTPUT@',
+ '@INPUT@'],
+ )
+ dynamic_launcher_portal_iface_c = custom_target(
+ 'gs-dynamic-launcher-portal-iface.c',
+ input: ['org.freedesktop.portal.DynamicLauncher.xml'],
+ output: ['gs-dynamic-launcher-portal-iface.c'],
+ command: [gdbus_codegen,
+ '--interface-info-body',
+ '--output', '@OUTPUT@',
+ '@INPUT@'],
+ )
+ cargs += ['-DLOCALPLUGINDIR="' + meson.current_build_dir() + '"']
+ cargs += ['-DLOCALPLUGINDIR_CORE="' + meson.current_build_dir() + '/../core"']
+ cargs += ['-DTESTDATADIR="' + join_paths(meson.current_source_dir(), '..', '..', 'data') + '"']
+ e = executable(
+ 'gs-self-test-epiphany',
+ compiled_schemas,
+ sources : [
+ 'gs-self-test.c',
+ dynamic_launcher_portal_iface_c,
+ dynamic_launcher_portal_iface_h,
+ epiphany_generated,
+ ],
+ include_directories : [
+ include_directories('../..'),
+ include_directories('../../lib'),
+ ],
+ dependencies : [
+ plugin_libs,
+ dependency('glib-testing-0', fallback: ['libglib-testing', 'libglib_testing_dep']),
+ ],
+ link_with : [
+ libgnomesoftware,
+ ],
+ c_args : cargs,
+ )
+ test('gs-self-test-epiphany', e, suite: ['plugins', 'epiphany'], env: test_env)
+endif
diff --git a/plugins/epiphany/org.freedesktop.portal.DynamicLauncher.xml b/plugins/epiphany/org.freedesktop.portal.DynamicLauncher.xml
new file mode 100644
index 0000000..65aa90e
--- /dev/null
+++ b/plugins/epiphany/org.freedesktop.portal.DynamicLauncher.xml
@@ -0,0 +1,332 @@
+<?xml version="1.0"?>
+<!--
+ Copyright (C) 2021 Matthew Leeds
+
+ This library is free software; you can redistribute it and/or
+ modify it under the terms of the GNU Lesser General Public
+ License as published by the Free Software Foundation; either
+ version 2 of the License, or (at your option) any later version.
+
+ This library 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
+ Lesser General Public License for more details.
+
+ You should have received a copy of the GNU Lesser General Public
+ License along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+ Author: Matthew Leeds <mwleeds@protonmail.com>
+-->
+
+<node name="/" xmlns:doc="http://www.freedesktop.org/dbus/1.0/doc.dtd">
+ <!--
+ org.freedesktop.portal.DynamicLauncher:
+ @short_description: Portal for installing application launchers onto the
+ desktop
+
+ The DynamicLauncher portal allows sandboxed (or unsandboxed) applications
+ to install launchers (.desktop files) which have an icon associated with them
+ and which execute a command in the application. The desktop environment
+ would display the launcher to the user in its menu of installed applications.
+ For example this can be used by a sandboxed browser to install web app
+ launchers. The portal also allows apps to uninstall the launchers, launch
+ them, and read the desktop file and icon data for them.
+
+ The standard way to install a launcher is to use the PrepareInstall() method
+ which results in a dialog being presented to the user so they can confirm
+ they want to install the launcher. Then, the token returned by PrepareInstall()
+ would be passed to the Install() method to complete the installation.
+
+ However, in the rare circumstance that an unsandboxed process such as a
+ system component needs to install a launcher without user interaction, this
+ can be accomplished by using the RequestInstallToken() method and passing
+ the acquired token to Install().
+
+ This documentation describes version 1 of this interface.
+ -->
+ <interface name="org.freedesktop.portal.DynamicLauncher">
+ <!--
+ Install:
+ @token: Token proving authorization of the installation
+ @desktop_file_id: The .desktop file name to be used
+ @desktop_entry: The text of the Desktop Entry file to be installed, see below
+ @options: Vardict with optional further information
+
+ Installs a .desktop launcher and icon into appropriate directories to
+ allow the desktop environment to find them. Please note that this method
+ overwrites any existing launcher with the same id. If you want to
+ present the user with a confirmation dialog in that case, you can check
+ for it using the GetDesktopEntry() method and clean up any state from
+ the previous launcher if you want.
+
+ @token must be a token that was returned by a previous
+ org.freedesktop.portal.DynamicLauncher.PrepareInstall() or
+ org.freedesktop.portal.DynamicLauncher.RequestInstallToken() call.
+ The token can only be used once and is valid for up to five minutes.
+
+ The icon and name used for the launcher will be the ones from the previous
+ org.freedesktop.portal.DynamicLauncher.PrepareInstall() or
+ org.freedesktop.portal.DynamicLauncher.RequestInstallToken() call.
+
+ The @desktop_file_id must have ".desktop" as a suffix. Except in the
+ special case when the calling process has no associated app ID,
+ @desktop_file_id must have the app ID followed by a period as a prefix,
+ regardless of whether the calling process is sandboxed or unsandboxed.
+
+ The @desktop_entry should be a valid desktop entry file beginning with
+ "[Desktop Entry]", except it should not include Name= or Icon= entries
+ (if present, these will be overwritten by the portal implementation).
+ The Exec= entry will be rewritten to call the sandboxed application e.g.
+ via "flatpak run", if the application is sandboxed.
+
+ It is recommended to include a TryExec= line with either a binary name
+ or an absolute path. The launcher will be deleted if the TryExec binary
+ cannot be found on session start.
+
+ The @options vardict currently has no supported entries.
+ -->
+ <method name="Install">
+ <arg type="s" name="token" direction="in"/>
+ <arg type="s" name="desktop_file_id" direction="in"/>
+ <arg type="s" name="desktop_entry" direction="in"/>
+ <arg type="a{sv}" name="options" direction="in"/>
+ </method>
+ <!--
+ PrepareInstall:
+ @parent_window: Identifier for the application window, see <link linkend="parent_window">Common Conventions</link>
+ @name: The default name for the launcher
+ @icon_v: A #GBytesIcon icon as returned by g_icon_serialize(). Must be
+ a png or jpeg no larger than 512x512, or an svg
+ @options: Vardict with optional further information
+ @handle: Object path for the #org.freedesktop.portal.Request object representing this call
+
+ Presents a dialog to the user to allow them to see the icon, potentially
+ change the name, and confirm installation of the launcher.
+
+ Supported keys in the @options vardict:
+ <variablelist>
+ <varlistentry>
+ <term>handle_token s</term>
+ <listitem><para>
+ A string that will be used as the last element of the @handle. Must be a valid
+ object path element. See the #org.freedesktop.portal.Request documentation for
+ more information about the @handle.
+ </para></listitem>
+ </varlistentry>
+ <varlistentry>
+ <term>modal b</term>
+ <listitem><para>
+ Whether to make the dialog modal. Defaults to yes.
+ </para></listitem>
+ </varlistentry>
+ <varlistentry>
+ <term>launcher_type u</term>
+ <listitem><para>
+ The type of launcher being created. For supported values see the
+ SupportedLauncherTypes property. Defaults to "Application".
+ </para></listitem>
+ </varlistentry>
+ <varlistentry>
+ <term>target s</term>
+ <listitem><para>
+ For a launcher of type "Webapp", this is the URL of the web app
+ being installed. This is displayed in the user-facing dialog.
+ For other launcher types, this is not needed.
+ </para></listitem>
+ </varlistentry>
+ <varlistentry>
+ <term>editable_name b</term>
+ <listitem><para>
+ If true, the user will be able to edit the name of the launcher.
+ Defaults to true.
+ </para></listitem>
+ </varlistentry>
+ <varlistentry>
+ <term>editable_icon b</term>
+ <listitem><para>
+ If true, the user will be able to edit the icon of the launcher,
+ if the implementation supports this. Defaults to false.
+ </para></listitem>
+ </varlistentry>
+ </variablelist>
+
+ The following results get returned via the #org.freedesktop.portal.Request::Response signal:
+ <variablelist>
+ <varlistentry>
+ <term>name s</term>
+ <listitem><para>
+ The name chosen by the user for the launcher.
+ </para></listitem>
+ </varlistentry>
+ <varlistentry>
+ <term>token s</term>
+ <listitem><para>
+ Token that can be passed to a subsequent org.freedesktop.portal.DynamicLauncher.Install() call to
+ complete the installation without another dialog.
+ </para></listitem>
+ </varlistentry>
+ </variablelist>
+ -->
+ <method name="PrepareInstall">
+ <arg type="s" name="parent_window" direction="in"/>
+ <arg type="s" name="name" direction="in"/>
+ <arg type="v" name="icon_v" direction="in"/>
+ <arg type="a{sv}" name="options" direction="in"/>
+ <arg type="o" name="handle" direction="out"/>
+ </method>
+ <!--
+ RequestInstallToken:
+ @name: The name that will be used in the desktop file
+ @icon_v: A #GBytesIcon icon as returned by g_icon_serialize(). Must be
+ a png or jpeg no larger than 512x512, or an svg
+ @options: Vardict with optional further information
+ @token: the token to be used with the Install() method
+
+ This method is intended for use only by specific components that
+ have their application ID allowlisted in the portal backend (e.g. GNOME
+ Software and KDE Discover). It is otherwise not guaranteed to work.
+
+ The token returned by this method can be used to avoid the need for a
+ confirmation dialog; the token can be passed to the Install() method
+ just as if it were acquired via the PrepareInstall() method.
+
+ The @options vardict currently has no supported entries.
+ -->
+ <method name="RequestInstallToken">
+ <arg type="s" name="name" direction="in"/>
+ <arg type="v" name="icon_v" direction="in"/>
+ <arg type="a{sv}" name="options" direction="in"/>
+ <arg type="s" name="token" direction="out"/>
+ </method>
+ <!--
+ Uninstall:
+ @desktop_file_id: The .desktop file name
+ @options: Vardict with optional further information
+
+ This method deletes the desktop file and corresponding icon from the
+ appropriate directories to remove the launcher referred to by
+ @desktop_file_id.
+
+ The @desktop_file_id must have ".desktop" as a suffix. Except in the
+ special case when the calling process has no associated app ID,
+ @desktop_file_id must have the app ID followed by a period as a prefix,
+ regardless of whether the calling process is sandboxed or unsandboxed.
+
+ For example, Epiphany, which has the app ID "org.gnome.Epiphany"
+ in stable builds, might use a @desktop_file_id like
+ "org.gnome.Epiphany.WebApp_e9d0e1e4b0a10856aa3b38d9eb4375de4070d043.desktop"
+ In that example the desktop file would exist at the path
+ "~/.local/share/xdg-desktop-portal/applications/org.gnome.Epiphany.WebApp_e9d0e1e4b0a10856aa3b38d9eb4375de4070d043.desktop"
+ with a sym link in "~/.local/share/applications/".
+ The checksum at the end of the file name is an implementation detail in
+ Epiphany and not required by the portal.
+
+ This method is intended to be called by the application that created the
+ launcher, e.g. a web browser, so it can clean up associated data as part
+ of the uninstallation. Consequently, the proper way for a software center
+ to remove a launcher is by using the APIs provided by the application
+ that installed it. For example, for GNOME Software to remove web
+ launchers created by Epiphany, it would use the
+ org.gnome.Epiphany.WebAppProvider D-Bus interface.
+
+ Please note that this method call will fail if the specified launcher
+ already does not exist.
+
+ The @options vardict currently has no supported entries.
+ -->
+ <method name="Uninstall">
+ <arg type="s" name="desktop_file_id" direction="in"/>
+ <arg type="a{sv}" name="options" direction="in"/>
+ </method>
+ <!--
+ GetDesktopEntry:
+ @desktop_file_id: The .desktop file name
+ @contents: the contents of the named .desktop file
+
+ This function returns the contents of a desktop file with the name
+ @desktop_file_id in @contents.
+
+ The @desktop_file_id must have ".desktop" as a suffix. Except in the
+ special case when the calling process has no associated app ID,
+ @desktop_file_id must have the app ID followed by a period as a prefix.
+
+ This method only works for desktop files that were created by the
+ dynamic launcher portal.
+ -->
+ <method name="GetDesktopEntry">
+ <arg type="s" name="desktop_file_id" direction="in"/>
+ <arg type="s" name="contents" direction="out"/>
+ </method>
+ <!--
+ GetIcon:
+ @desktop_file_id: The .desktop file name
+ @icon_v: the icon as a serialized #GBytesIcon
+ @icon_format: one of "png", "jpeg", "svg"
+ @icon_size: the width and height in pixels of the icon
+
+ This function returns the contents of the icon specified in the "Icon"
+ key of the desktop file with the name @desktop_file_id in @icon_v. The
+ icon #GVariant can be passed to g_icon_deserialize() to reconstruct the
+ #GIcon.
+
+ The @desktop_file_id must have ".desktop" as a suffix. Except in the
+ special case when the calling process has no associated app ID,
+ @desktop_file_id must have the app ID followed by a period as a prefix.
+
+ The format and size of the icon are returned in @icon_format and
+ @icon_size. For svg icons, @icon_size is currently always set to 4096,
+ but don't depend on that as it may change in the future.
+
+ This method only works for desktop files that were created by the
+ dynamic launcher portal.
+ -->
+ <method name="GetIcon">
+ <arg type="s" name="desktop_file_id" direction="in"/>
+ <arg type="v" name="icon_v" direction="out"/>
+ <arg type="s" name="icon_format" direction="out"/>
+ <arg type="u" name="icon_size" direction="out"/>
+ </method>
+ <!--
+ Launch:
+ @desktop_file_id: The .desktop file name
+ @options: Vardict with optional further onformation
+
+ This function launches the app specified by @desktop_file_id.
+
+ The @desktop_file_id must have ".desktop" as a suffix. Except in the
+ special case when the calling process has no associated app ID,
+ @desktop_file_id must have the app ID followed by a period as a prefix.
+
+ This method only works for desktop files that were created by the
+ dynamic launcher portal.
+
+ Supported keys in the @options vardict include:
+ <variablelist>
+ <varlistentry>
+ <term>activation_token s</term>
+ <listitem><para>
+ A token that can be used to activate the chosen application.
+ </para><para>
+ The activation_token option was introduced in version 1 of the interface.
+ </para></listitem>
+ </varlistentry>
+ </variablelist>
+ -->
+ <method name="Launch">
+ <arg type="s" name="desktop_file_id" direction="in"/>
+ <arg type="a{sv}" name="options" direction="in"/>
+ </method>
+ <!--
+ SupportedLauncherTypes:
+
+ A bitmask of available launcher types. Currently defined types are:
+
+ <simplelist>
+ <member>1: Application. A launcher that represents an application.</member>
+ <member>2: Webapp. A launcher that represents a web app.</member>
+ </simplelist>
+ -->
+ <property name="SupportedLauncherTypes" type="u" access="read"/>
+ <property name="version" type="u" access="read"/>
+ </interface>
+</node>
diff --git a/plugins/epiphany/org.gnome.Epiphany.WebAppProvider.xml b/plugins/epiphany/org.gnome.Epiphany.WebAppProvider.xml
new file mode 100644
index 0000000..6c2954d
--- /dev/null
+++ b/plugins/epiphany/org.gnome.Epiphany.WebAppProvider.xml
@@ -0,0 +1,85 @@
+<!DOCTYPE node PUBLIC
+'-//freedesktop//DTD D-BUS Object Introspection 1.0//EN'
+'http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd'>
+<node>
+
+ <!--
+ org.gnome.Epiphany.WebAppProvider:
+ @short_description: Webapp provider interface
+
+ The interface used for handling Epiphany Webapps in GNOME Software, or other
+ clients (version 1).
+ -->
+ <interface name="org.gnome.Epiphany.WebAppProvider">
+ <!--
+ GetInstalledApps:
+ @desktop_file_ids: An array of .desktop file names, one for each
+ installed web app, with the .desktop suffix included
+
+ Returns the set of installed Epiphany web applications. The caller can
+ use them with g_desktop_app_info_new() if outside the sandbox.
+ -->
+ <method name="GetInstalledApps">
+ <arg type="as" name="webapps" direction="out" />
+ </method>
+
+ <!--
+ Install:
+ @url: the URL of the web app
+ @name: the human readable name of the web app
+ @install_token: the token acquired via org.freedesktop.portal.InstallDynamicLauncher
+ @desktop_file_id: the desktop file id of the installed app, with a
+ ".desktop" suffix
+
+ Installs a web app. This interface is expected to be used by trusted
+ system components such as GNOME Software, which can acquire an
+ @install_token using the portal method
+ org.freedesktop.portal.DynamicLauncher.RequestInstallToken(). This allows Epiphany
+ to install the web app without user interaction and despite being sandboxed.
+ This is desirable because the user would've already clicked "Install" in
+ Software; they should not have to confirm the operation again in a different
+ app (Epiphany).
+
+ The @install_token must be provided so that Epiphany can complete the
+ installation without a user-facing dialog. The icon given to
+ org.freedesktop.portal.InstallDynamicLauncher.RequestInstallToken() will
+ be used, and the name given to that method should match the @name given here.
+
+ If the arguments passed are invalid this method returns the error
+ `org.gnome.Epiphany.WebAppProvider.Error.InvalidArgs`, and otherwise
+ `org.gnome.Epiphany.WebAppProvider.Error.Failed`.
+ -->
+ <method name="Install">
+ <arg type="s" name="url" direction="in" />
+ <arg type="s" name="name" direction="in" />
+ <arg type="s" name="install_token" direction="in" />
+ <arg type="s" name="desktop_file_id" direction="out" />
+ </method>
+
+ <!--
+ Uninstall:
+ @desktop_file_id: the filename of the .desktop file for an installed web
+ app, with the .desktop suffix
+
+ Uninstalls a web app. Note that the @desktop_file_id is just a filename
+ not a full path, and it's the same one returned by the
+ GetInstalledWebApps() method.
+
+ The error `org.gnome.Epiphany.WebAppProvider.Error.NotInstalled` will be
+ returned if the specified web app is not installed. The other possible
+ error values are `org.gnome.Epiphany.WebAppProvider.Error.InvalidArgs`
+ and `org.gnome.Epiphany.WebAppProvider.Error.Failed`.
+ -->
+ <method name="Uninstall">
+ <arg type="s" name="desktop_file_id" direction="in" />
+ </method>
+ <!--
+ Version:
+
+ The API version number, to be incremented for backwards compatible
+ changes so clients can determine which features are available. For
+ backwards incompatible changes, the interface name will change.
+ -->
+ <property name="Version" type="u" access="read"/>
+ </interface>
+</node>
diff --git a/plugins/epiphany/org.gnome.Software.Plugin.Epiphany.metainfo.xml.in b/plugins/epiphany/org.gnome.Software.Plugin.Epiphany.metainfo.xml.in
new file mode 100644
index 0000000..626381f
--- /dev/null
+++ b/plugins/epiphany/org.gnome.Software.Plugin.Epiphany.metainfo.xml.in
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- Copyright 2013-2016 Richard Hughes <richard@hughsie.com> -->
+<component type="addon">
+ <id>org.gnome.Software.Plugin.Epiphany</id>
+ <extends>org.gnome.Software.desktop</extends>
+ <name>Web Apps Support</name>
+ <summary>Run popular web applications in a browser</summary>
+ <metadata_license>CC0-1.0</metadata_license>
+ <project_license>GPL-2.0+</project_license>
+ <update_contact>mwleeds_at_protonmail.com</update_contact>
+</component>