diff options
Diffstat (limited to 'plugins/epiphany/gs-plugin-epiphany.c')
-rw-r--r-- | plugins/epiphany/gs-plugin-epiphany.c | 1183 |
1 files changed, 1183 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; +} |