diff options
Diffstat (limited to '')
-rw-r--r-- | plugins/epiphany/gs-plugin-epiphany.c | 1183 | ||||
-rw-r--r-- | plugins/epiphany/gs-plugin-epiphany.h | 20 | ||||
-rw-r--r-- | plugins/epiphany/gs-self-test.c | 240 | ||||
-rw-r--r-- | plugins/epiphany/meson.build | 87 | ||||
-rw-r--r-- | plugins/epiphany/org.freedesktop.portal.DynamicLauncher.xml | 332 | ||||
-rw-r--r-- | plugins/epiphany/org.gnome.Epiphany.WebAppProvider.xml | 85 | ||||
-rw-r--r-- | plugins/epiphany/org.gnome.Software.Plugin.Epiphany.metainfo.xml.in | 11 |
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> |