summaryrefslogtreecommitdiffstats
path: root/lib
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:57:27 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:57:27 +0000
commit6f0f7d1b40a8fa8d46a2d6f4317600001cdbbb18 (patch)
treed423850ae901365e582137bdf2b5cbdffd7ca266 /lib
parentInitial commit. (diff)
downloadgnome-software-6f0f7d1b40a8fa8d46a2d6f4317600001cdbbb18.tar.xz
gnome-software-6f0f7d1b40a8fa8d46a2d6f4317600001cdbbb18.zip
Adding upstream version 43.5.upstream/43.5upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r--lib/README.md7
-rw-r--r--lib/gnome-software-private.h24
-rw-r--r--lib/gnome-software.h41
-rw-r--r--lib/gs-app-collation.h21
-rw-r--r--lib/gs-app-list-private.h51
-rw-r--r--lib/gs-app-list.c1025
-rw-r--r--lib/gs-app-list.h89
-rw-r--r--lib/gs-app-permissions.c430
-rw-r--r--lib/gs-app-permissions.h74
-rw-r--r--lib/gs-app-private.h30
-rw-r--r--lib/gs-app-query.c1173
-rw-r--r--lib/gs-app-query.h108
-rw-r--r--lib/gs-app.c6714
-rw-r--r--lib/gs-app.h524
-rw-r--r--lib/gs-appstream.c2165
-rw-r--r--lib/gs-appstream.h95
-rw-r--r--lib/gs-build-ident.h.in32
-rw-r--r--lib/gs-category-manager.c151
-rw-r--r--lib/gs-category-manager.h32
-rw-r--r--lib/gs-category-private.h21
-rw-r--r--lib/gs-category.c724
-rw-r--r--lib/gs-category.h45
-rw-r--r--lib/gs-cmd.c847
-rw-r--r--lib/gs-debug.c295
-rw-r--r--lib/gs-debug.h27
-rw-r--r--lib/gs-desktop-data.c320
-rw-r--r--lib/gs-desktop-data.h43
-rw-r--r--lib/gs-download-utils.c870
-rw-r--r--lib/gs-download-utils.h88
-rw-r--r--lib/gs-external-appstream-utils.c627
-rw-r--r--lib/gs-external-appstream-utils.h50
-rw-r--r--lib/gs-fedora-third-party.c497
-rw-r--r--lib/gs-fedora-third-party.h93
-rw-r--r--lib/gs-icon.c315
-rw-r--r--lib/gs-icon.h32
-rw-r--r--lib/gs-ioprio.c197
-rw-r--r--lib/gs-ioprio.h31
-rw-r--r--lib/gs-key-colors.c334
-rw-r--r--lib/gs-key-colors.h27
-rw-r--r--lib/gs-metered.c312
-rw-r--r--lib/gs-metered.h35
-rw-r--r--lib/gs-odrs-provider.c1998
-rw-r--r--lib/gs-odrs-provider.h125
-rw-r--r--lib/gs-os-release.c347
-rw-r--r--lib/gs-os-release.h33
-rw-r--r--lib/gs-plugin-event.c453
-rw-r--r--lib/gs-plugin-event.h61
-rw-r--r--lib/gs-plugin-helpers.c338
-rw-r--r--lib/gs-plugin-helpers.h96
-rw-r--r--lib/gs-plugin-job-list-apps.c516
-rw-r--r--lib/gs-plugin-job-list-apps.h31
-rw-r--r--lib/gs-plugin-job-list-categories.c347
-rw-r--r--lib/gs-plugin-job-list-categories.h29
-rw-r--r--lib/gs-plugin-job-list-distro-upgrades.c428
-rw-r--r--lib/gs-plugin-job-list-distro-upgrades.h30
-rw-r--r--lib/gs-plugin-job-manage-repository.c365
-rw-r--r--lib/gs-plugin-job-manage-repository.h26
-rw-r--r--lib/gs-plugin-job-private.h40
-rw-r--r--lib/gs-plugin-job-refine.c860
-rw-r--r--lib/gs-plugin-job-refine.h31
-rw-r--r--lib/gs-plugin-job-refresh-metadata.c531
-rw-r--r--lib/gs-plugin-job-refresh-metadata.h28
-rw-r--r--lib/gs-plugin-job.c502
-rw-r--r--lib/gs-plugin-job.h68
-rw-r--r--lib/gs-plugin-loader-sync.c254
-rw-r--r--lib/gs-plugin-loader-sync.h42
-rw-r--r--lib/gs-plugin-loader.c4172
-rw-r--r--lib/gs-plugin-loader.h122
-rw-r--r--lib/gs-plugin-private.h57
-rw-r--r--lib/gs-plugin-types.h319
-rw-r--r--lib/gs-plugin-vfuncs.h429
-rw-r--r--lib/gs-plugin.c2187
-rw-r--r--lib/gs-plugin.h331
-rw-r--r--lib/gs-remote-icon.c375
-rw-r--r--lib/gs-remote-icon.h37
-rw-r--r--lib/gs-self-test.c786
-rw-r--r--lib/gs-test.c147
-rw-r--r--lib/gs-test.h27
-rw-r--r--lib/gs-utils.c1689
-rw-r--r--lib/gs-utils.h155
-rw-r--r--lib/gs-worker-thread.c425
-rw-r--r--lib/gs-worker-thread.h41
-rw-r--r--lib/meson.build205
-rw-r--r--lib/tools/meson.build24
-rw-r--r--lib/tools/profile-key-colors.c175
85 files changed, 37898 insertions, 0 deletions
diff --git a/lib/README.md b/lib/README.md
new file mode 100644
index 0000000..628c58e
--- /dev/null
+++ b/lib/README.md
@@ -0,0 +1,7 @@
+libgnomesoftware
+================
+
+This is a static library, and is not all API stable.
+
+Only the plugin headers installed into /usr/include/gnome-software should be
+considered API.
diff --git a/lib/gnome-software-private.h b/lib/gnome-software-private.h
new file mode 100644
index 0000000..1e3addf
--- /dev/null
+++ b/lib/gnome-software-private.h
@@ -0,0 +1,24 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2017 Richard Hughes <richard@hughsie.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#ifndef I_KNOW_THE_GNOME_SOFTWARE_API_IS_SUBJECT_TO_CHANGE
+#define I_KNOW_THE_GNOME_SOFTWARE_API_IS_SUBJECT_TO_CHANGE
+#endif
+
+#include <gnome-software.h>
+
+#include <gs-app-list-private.h>
+#include <gs-app-private.h>
+#include <gs-category-private.h>
+#include <gs-fedora-third-party.h>
+#include <gs-os-release.h>
+#include <gs-plugin-loader.h>
+#include <gs-plugin-loader-sync.h>
+#include <gs-plugin-private.h>
diff --git a/lib/gnome-software.h b/lib/gnome-software.h
new file mode 100644
index 0000000..b57e257
--- /dev/null
+++ b/lib/gnome-software.h
@@ -0,0 +1,41 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2016 Richard Hughes <richard@hughsie.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#ifndef I_KNOW_THE_GNOME_SOFTWARE_API_IS_SUBJECT_TO_CHANGE
+#error You have to define I_KNOW_THE_GNOME_SOFTWARE_API_IS_SUBJECT_TO_CHANGE
+#endif
+
+#include <gs-app.h>
+#include <gs-app-list.h>
+#include <gs-app-collation.h>
+#include <gs-app-permissions.h>
+#include <gs-app-query.h>
+#include <gs-category.h>
+#include <gs-category-manager.h>
+#include <gs-desktop-data.h>
+#include <gs-download-utils.h>
+#include <gs-enums.h>
+#include <gs-icon.h>
+#include <gs-metered.h>
+#include <gs-odrs-provider.h>
+#include <gs-os-release.h>
+#include <gs-plugin.h>
+#include <gs-plugin-helpers.h>
+#include <gs-plugin-job.h>
+#include <gs-plugin-job-list-apps.h>
+#include <gs-plugin-job-list-categories.h>
+#include <gs-plugin-job-list-distro-upgrades.h>
+#include <gs-plugin-job-manage-repository.h>
+#include <gs-plugin-job-refine.h>
+#include <gs-plugin-job-refresh-metadata.h>
+#include <gs-plugin-vfuncs.h>
+#include <gs-remote-icon.h>
+#include <gs-utils.h>
+#include <gs-worker-thread.h>
diff --git a/lib/gs-app-collation.h b/lib/gs-app-collation.h
new file mode 100644
index 0000000..876ae54
--- /dev/null
+++ b/lib/gs-app-collation.h
@@ -0,0 +1,21 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2017-2018 Richard Hughes <richard@hughsie.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#include <glib-object.h>
+
+#include "gs-app-list.h"
+
+G_BEGIN_DECLS
+
+GsAppList *gs_app_get_related (GsApp *app);
+GsAppList *gs_app_dup_addons (GsApp *app);
+GsAppList *gs_app_get_history (GsApp *app);
+
+G_END_DECLS
diff --git a/lib/gs-app-list-private.h b/lib/gs-app-list-private.h
new file mode 100644
index 0000000..5335173
--- /dev/null
+++ b/lib/gs-app-list-private.h
@@ -0,0 +1,51 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2016 Richard Hughes <richard@hughsie.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#include "gs-app-list.h"
+
+G_BEGIN_DECLS
+
+/**
+ * GsAppListFlags:
+ * @GS_APP_LIST_FLAG_NONE: No flags set
+ * @GS_APP_LIST_FLAG_IS_TRUNCATED: List has been truncated
+ * @GS_APP_LIST_FLAG_WATCH_APPS: Applications will be monitored
+ * @GS_APP_LIST_FLAG_WATCH_APPS_RELATED: Applications related apps will be monitored
+ * @GS_APP_LIST_FLAG_WATCH_APPS_ADDONS: Applications addon apps will be monitored
+ *
+ * Flags used to describe the list.
+ **/
+typedef enum {
+ GS_APP_LIST_FLAG_NONE = 0,
+ /* empty slot */
+ GS_APP_LIST_FLAG_IS_TRUNCATED = 1 << 1,
+ GS_APP_LIST_FLAG_WATCH_APPS = 1 << 2,
+ GS_APP_LIST_FLAG_WATCH_APPS_RELATED = 1 << 3,
+ GS_APP_LIST_FLAG_WATCH_APPS_ADDONS = 1 << 4,
+ GS_APP_LIST_FLAG_LAST /*< skip >*/
+} GsAppListFlags;
+
+guint gs_app_list_get_size_peak (GsAppList *list);
+void gs_app_list_set_size_peak (GsAppList *list,
+ guint size_peak);
+void gs_app_list_filter_duplicates (GsAppList *list,
+ GsAppListFilterFlags flags);
+void gs_app_list_randomize (GsAppList *list);
+void gs_app_list_remove_all (GsAppList *list);
+void gs_app_list_truncate (GsAppList *list,
+ guint length);
+gboolean gs_app_list_has_flag (GsAppList *list,
+ GsAppListFlags flag);
+void gs_app_list_add_flag (GsAppList *list,
+ GsAppListFlags flag);
+GsAppState gs_app_list_get_state (GsAppList *list);
+guint gs_app_list_get_progress (GsAppList *list);
+
+G_END_DECLS
diff --git a/lib/gs-app-list.c b/lib/gs-app-list.c
new file mode 100644
index 0000000..a5f6785
--- /dev/null
+++ b/lib/gs-app-list.c
@@ -0,0 +1,1025 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2013-2016 Richard Hughes <richard@hughsie.com>
+ * Copyright (C) 2017-2018 Kalev Lember <klember@redhat.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+/**
+ * SECTION:gs-app-list
+ * @title: GsAppList
+ * @include: gnome-software.h
+ * @stability: Unstable
+ * @short_description: An application list
+ *
+ * These functions provide a refcounted list of #GsApp objects.
+ */
+
+#include "config.h"
+
+#include <glib.h>
+
+#include "gs-app-private.h"
+#include "gs-app-list-private.h"
+#include "gs-app-collation.h"
+#include "gs-enums.h"
+
+struct _GsAppList
+{
+ GObject parent_instance;
+ GPtrArray *array;
+ GMutex mutex;
+ guint size_peak;
+ GsAppListFlags flags;
+ GsAppState state;
+ guint progress; /* 0–100 inclusive, or %GS_APP_PROGRESS_UNKNOWN */
+ guint custom_progress; /* overrides the 'progress', if not %GS_APP_PROGRESS_UNKNOWN */
+};
+
+G_DEFINE_TYPE (GsAppList, gs_app_list, G_TYPE_OBJECT)
+
+enum {
+ PROP_STATE = 1,
+ PROP_PROGRESS,
+ PROP_LAST
+};
+
+enum {
+ SIGNAL_APP_STATE_CHANGED,
+ SIGNAL_LAST
+};
+
+static guint signals [SIGNAL_LAST] = { 0 };
+
+/**
+ * gs_app_list_get_state:
+ * @list: A #GsAppList
+ *
+ * Gets the state of the list.
+ *
+ * This method will only return a valid result if gs_app_list_add_flag() has
+ * been called with %GS_APP_LIST_FLAG_WATCH_APPS.
+ *
+ * Returns: the #GsAppState, e.g. %GS_APP_STATE_INSTALLED
+ *
+ * Since: 3.30
+ **/
+GsAppState
+gs_app_list_get_state (GsAppList *list)
+{
+ g_return_val_if_fail (GS_IS_APP_LIST (list), GS_APP_STATE_UNKNOWN);
+ return list->state;
+}
+
+/**
+ * gs_app_list_get_progress:
+ * @list: A #GsAppList
+ *
+ * Gets the average percentage completion of all apps in the list. If any of the
+ * apps in the list has progress %GS_APP_PROGRESS_UNKNOWN, or if the app list
+ * is empty, %GS_APP_PROGRESS_UNKNOWN will be returned.
+ *
+ * This method will only return a valid result if gs_app_list_add_flag() has
+ * been called with %GS_APP_LIST_FLAG_WATCH_APPS.
+ *
+ * Returns: the percentage completion (0–100 inclusive), or %GS_APP_PROGRESS_UNKNOWN for unknown
+ *
+ * Since: 3.30
+ **/
+guint
+gs_app_list_get_progress (GsAppList *list)
+{
+ g_return_val_if_fail (GS_IS_APP_LIST (list), GS_APP_PROGRESS_UNKNOWN);
+ if (list->custom_progress != GS_APP_PROGRESS_UNKNOWN)
+ return list->custom_progress;
+ return list->progress;
+}
+
+static gboolean
+app_list_notify_progress_idle_cb (gpointer user_data)
+{
+ GsAppList *list = user_data;
+
+ g_object_notify (G_OBJECT (list), "progress");
+ g_object_unref (list);
+
+ return G_SOURCE_REMOVE;
+}
+
+/**
+ * gs_app_list_override_progress:
+ * @list: a #GsAppList
+ * @progress: a progress to set, between 0 and 100 inclusive, or %GS_APP_PROGRESS_UNKNOWN
+ *
+ * Override the progress property to be this value, or use %GS_APP_PROGRESS_UNKNOWN,
+ * to unset the override. This can be used when only the overall progress is known,
+ * instead of a per-application progress.
+ *
+ * Since: 42
+ **/
+void
+gs_app_list_override_progress (GsAppList *list,
+ guint progress)
+{
+ g_return_if_fail (GS_IS_APP_LIST (list));
+
+ if (list->custom_progress != progress) {
+ list->custom_progress = progress;
+ g_idle_add (app_list_notify_progress_idle_cb, g_object_ref (list));
+ }
+}
+
+static void
+gs_app_list_add_watched_for_app (GsAppList *list, GPtrArray *apps, GsApp *app)
+{
+ if (list->flags & GS_APP_LIST_FLAG_WATCH_APPS)
+ g_ptr_array_add (apps, app);
+ if (list->flags & GS_APP_LIST_FLAG_WATCH_APPS_ADDONS) {
+ g_autoptr(GsAppList) list2 = gs_app_dup_addons (app);
+
+ for (guint i = 0; list2 != NULL && i < gs_app_list_length (list2); i++) {
+ GsApp *app2 = gs_app_list_index (list2, i);
+ g_ptr_array_add (apps, app2);
+ }
+ }
+ if (list->flags & GS_APP_LIST_FLAG_WATCH_APPS_RELATED) {
+ GsAppList *list2 = gs_app_get_related (app);
+ for (guint i = 0; i < gs_app_list_length (list2); i++) {
+ GsApp *app2 = gs_app_list_index (list2, i);
+ g_ptr_array_add (apps, app2);
+ }
+ }
+}
+
+static GPtrArray *
+gs_app_list_get_watched_for_app (GsAppList *list, GsApp *app)
+{
+ GPtrArray *apps = g_ptr_array_new ();
+ gs_app_list_add_watched_for_app (list, apps, app);
+ return apps;
+}
+
+static GPtrArray *
+gs_app_list_get_watched (GsAppList *list)
+{
+ GPtrArray *apps = g_ptr_array_new ();
+ for (guint i = 0; i < list->array->len; i++) {
+ GsApp *app_tmp = g_ptr_array_index (list->array, i);
+ gs_app_list_add_watched_for_app (list, apps, app_tmp);
+ }
+ return apps;
+}
+
+static void
+gs_app_list_invalidate_progress (GsAppList *self)
+{
+ guint progress = 0;
+ g_autoptr(GPtrArray) apps = gs_app_list_get_watched (self);
+
+ /* find the average percentage complete of the list */
+ if (apps->len > 0) {
+ guint64 pc_cnt = 0;
+ gboolean unknown_seen = FALSE;
+
+ for (guint i = 0; i < apps->len; i++) {
+ GsApp *app_tmp = g_ptr_array_index (apps, i);
+ guint app_progress = gs_app_get_progress (app_tmp);
+
+ if (app_progress == GS_APP_PROGRESS_UNKNOWN) {
+ unknown_seen = TRUE;
+ break;
+ }
+ pc_cnt += gs_app_get_progress (app_tmp);
+ }
+
+ progress = (!unknown_seen) ? pc_cnt / apps->len : GS_APP_PROGRESS_UNKNOWN;
+ } else {
+ progress = GS_APP_PROGRESS_UNKNOWN;
+ }
+
+ if (self->progress != progress) {
+ self->progress = progress;
+ g_object_notify (G_OBJECT (self), "progress");
+ }
+}
+
+static void
+gs_app_list_invalidate_state (GsAppList *self)
+{
+ GsAppState state = GS_APP_STATE_UNKNOWN;
+ g_autoptr(GPtrArray) apps = gs_app_list_get_watched (self);
+
+ /* find any action state of the list */
+ for (guint i = 0; i < apps->len; i++) {
+ GsApp *app_tmp = g_ptr_array_index (apps, i);
+ GsAppState state_tmp = gs_app_get_state (app_tmp);
+ if (state_tmp == GS_APP_STATE_INSTALLING ||
+ state_tmp == GS_APP_STATE_REMOVING) {
+ state = state_tmp;
+ break;
+ }
+ }
+ if (self->state != state) {
+ self->state = state;
+ g_object_notify (G_OBJECT (self), "state");
+ }
+}
+
+static void
+gs_app_list_progress_notify_cb (GsApp *app, GParamSpec *pspec, GsAppList *self)
+{
+ gs_app_list_invalidate_progress (self);
+}
+
+static void
+gs_app_list_state_notify_cb (GsApp *app, GParamSpec *pspec, GsAppList *self)
+{
+ gs_app_list_invalidate_state (self);
+
+ g_signal_emit (self, signals[SIGNAL_APP_STATE_CHANGED], 0, app);
+}
+
+static void
+gs_app_list_maybe_watch_app (GsAppList *list, GsApp *app)
+{
+ g_autoptr(GPtrArray) apps = gs_app_list_get_watched_for_app (list, app);
+ for (guint i = 0; i < apps->len; i++) {
+ GsApp *app_tmp = g_ptr_array_index (apps, i);
+ g_signal_connect_object (app_tmp, "notify::progress",
+ G_CALLBACK (gs_app_list_progress_notify_cb),
+ list, 0);
+ g_signal_connect_object (app_tmp, "notify::state",
+ G_CALLBACK (gs_app_list_state_notify_cb),
+ list, 0);
+ }
+}
+
+static void
+gs_app_list_maybe_unwatch_app (GsAppList *list, GsApp *app)
+{
+ g_autoptr(GPtrArray) apps = gs_app_list_get_watched_for_app (list, app);
+ for (guint i = 0; i < apps->len; i++) {
+ GsApp *app_tmp = g_ptr_array_index (apps, i);
+ g_signal_handlers_disconnect_by_data (app_tmp, list);
+ }
+}
+
+/**
+ * gs_app_list_get_size_peak:
+ * @list: A #GsAppList
+ *
+ * Returns the largest size the list has ever been.
+ *
+ * Returns: integer
+ *
+ * Since: 3.24
+ **/
+guint
+gs_app_list_get_size_peak (GsAppList *list)
+{
+ return list->size_peak;
+}
+
+/**
+ * gs_app_list_set_size_peak:
+ * @list: A #GsAppList
+ * @size_peak: A value to set
+ *
+ * Sets the largest size the list has ever been.
+ *
+ * Since: 43
+ **/
+void
+gs_app_list_set_size_peak (GsAppList *list,
+ guint size_peak)
+{
+ g_return_if_fail (GS_IS_APP_LIST (list));
+ list->size_peak = size_peak;
+}
+
+static GsApp *
+gs_app_list_lookup_safe (GsAppList *list, const gchar *unique_id)
+{
+ for (guint i = 0; i < list->array->len; i++) {
+ GsApp *app = g_ptr_array_index (list->array, i);
+ if (as_utils_data_id_equal (gs_app_get_unique_id (app), unique_id))
+ return app;
+ }
+ return NULL;
+}
+
+/**
+ * gs_app_list_lookup:
+ * @list: A #GsAppList
+ * @unique_id: A unique_id
+ *
+ * Finds the first matching application in the list using the usual wildcard
+ * rules allowed in unique_ids.
+ *
+ * Returns: (transfer none): a #GsApp, or %NULL if not found
+ *
+ * Since: 3.22
+ **/
+GsApp *
+gs_app_list_lookup (GsAppList *list, const gchar *unique_id)
+{
+ g_autoptr(GMutexLocker) locker = g_mutex_locker_new (&list->mutex);
+ return gs_app_list_lookup_safe (list, unique_id);
+}
+
+/**
+ * gs_app_list_has_flag:
+ * @list: A #GsAppList
+ * @flag: A flag to test, e.g. %GS_APP_LIST_FLAG_IS_TRUNCATED
+ *
+ * Gets if a specific flag is set.
+ *
+ * Returns: %TRUE if the flag is set
+ *
+ * Since: 3.24
+ **/
+gboolean
+gs_app_list_has_flag (GsAppList *list, GsAppListFlags flag)
+{
+ return (list->flags & flag) > 0;
+}
+
+/**
+ * gs_app_list_add_flag:
+ * @list: A #GsAppList
+ * @flag: A flag to test, e.g. %GS_APP_LIST_FLAG_IS_TRUNCATED
+ *
+ * Gets if a specific flag is set.
+ *
+ * Returns: %TRUE if the flag is set
+ *
+ * Since: 3.30
+ **/
+void
+gs_app_list_add_flag (GsAppList *list, GsAppListFlags flag)
+{
+ if (list->flags & flag)
+ return;
+ list->flags |= flag;
+
+ /* turn this on for existing apps */
+ for (guint i = 0; i < list->array->len; i++) {
+ GsApp *app = g_ptr_array_index (list->array, i);
+ gs_app_list_maybe_watch_app (list, app);
+ }
+}
+
+static gboolean
+gs_app_list_check_for_duplicate (GsAppList *list, GsApp *app)
+{
+ GsApp *app_old;
+ const gchar *id;
+
+ /* adding a wildcard */
+ if (gs_app_has_quirk (app, GS_APP_QUIRK_IS_WILDCARD)) {
+ for (guint i = 0; i < list->array->len; i++) {
+ GsApp *app_tmp = g_ptr_array_index (list->array, i);
+ if (!gs_app_has_quirk (app_tmp, GS_APP_QUIRK_IS_WILDCARD))
+ continue;
+ /* not adding exactly the same wildcard */
+ if (g_strcmp0 (gs_app_get_unique_id (app_tmp),
+ gs_app_get_unique_id (app)) == 0)
+ return FALSE;
+ }
+ return TRUE;
+ }
+
+ for (guint i = 0; i < list->array->len; i++) {
+ GsApp *app_tmp = g_ptr_array_index (list->array, i);
+ if (app_tmp == app)
+ return FALSE;
+ }
+
+ /* does not exist */
+ id = gs_app_get_unique_id (app);
+ if (id == NULL) {
+ /* not much else we can do... */
+ return TRUE;
+ }
+
+ /* existing app is a wildcard */
+ app_old = gs_app_list_lookup_safe (list, id);
+ if (app_old == NULL)
+ return TRUE;
+ if (gs_app_has_quirk (app_old, GS_APP_QUIRK_IS_WILDCARD))
+ return TRUE;
+
+ /* already exists */
+ return FALSE;
+}
+
+typedef enum {
+ GS_APP_LIST_ADD_FLAG_NONE = 0,
+ GS_APP_LIST_ADD_FLAG_CHECK_FOR_DUPE = 1 << 0,
+ GS_APP_LIST_ADD_FLAG_LAST
+} GsAppListAddFlag;
+
+static void
+gs_app_list_add_safe (GsAppList *list, GsApp *app, GsAppListAddFlag flag)
+{
+ /* check for duplicate */
+ if ((flag & GS_APP_LIST_ADD_FLAG_CHECK_FOR_DUPE) > 0 &&
+ !gs_app_list_check_for_duplicate (list, app))
+ return;
+
+ /* just use the ref */
+ gs_app_list_maybe_watch_app (list, app);
+ g_ptr_array_add (list->array, g_object_ref (app));
+
+ /* update the historical max */
+ if (list->array->len > list->size_peak)
+ list->size_peak = list->array->len;
+}
+
+/**
+ * gs_app_list_add:
+ * @list: A #GsAppList
+ * @app: A #GsApp
+ *
+ * If the application does not already exist in the list then it is added,
+ * incrementing the reference count.
+ * If the application already exists then a warning is printed to the console.
+ *
+ * Applications that have the application ID lazy-loaded will always be added
+ * to the list, and to clean these up the plugin loader will also call the
+ * gs_app_list_filter_duplicates() method when all plugins have run.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_list_add (GsAppList *list, GsApp *app)
+{
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP_LIST (list));
+ g_return_if_fail (GS_IS_APP (app));
+ locker = g_mutex_locker_new (&list->mutex);
+ gs_app_list_add_safe (list, app, GS_APP_LIST_ADD_FLAG_CHECK_FOR_DUPE);
+
+ /* recalculate global state */
+ gs_app_list_invalidate_state (list);
+ gs_app_list_invalidate_progress (list);
+}
+
+/**
+ * gs_app_list_remove:
+ * @list: A #GsAppList
+ * @app: A #GsApp
+ *
+ * Removes an application from the list. If the application does not exist the
+ * request is ignored.
+ *
+ * Returns: %TRUE if the app was removed, %FALSE if it did not exist in the @list
+ * Since: 43
+ **/
+gboolean
+gs_app_list_remove (GsAppList *list, GsApp *app)
+{
+ g_autoptr(GMutexLocker) locker = NULL;
+ gboolean removed;
+
+ g_return_val_if_fail (GS_IS_APP_LIST (list), FALSE);
+ g_return_val_if_fail (GS_IS_APP (app), FALSE);
+
+ locker = g_mutex_locker_new (&list->mutex);
+ removed = g_ptr_array_remove (list->array, app);
+ if (removed) {
+ gs_app_list_maybe_unwatch_app (list, app);
+
+ /* recalculate global state */
+ gs_app_list_invalidate_state (list);
+ gs_app_list_invalidate_progress (list);
+ }
+
+ return removed;
+}
+
+/**
+ * gs_app_list_add_list:
+ * @list: A #GsAppList
+ * @donor: Another #GsAppList
+ *
+ * Adds all the applications in @donor to @list.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_list_add_list (GsAppList *list, GsAppList *donor)
+{
+ guint i;
+ g_autoptr(GMutexLocker) locker = NULL;
+
+ g_return_if_fail (GS_IS_APP_LIST (list));
+ g_return_if_fail (GS_IS_APP_LIST (donor));
+ g_return_if_fail (list != donor);
+
+ locker = g_mutex_locker_new (&list->mutex);
+
+ /* add each app */
+ for (i = 0; i < donor->array->len; i++) {
+ GsApp *app = gs_app_list_index (donor, i);
+ gs_app_list_add_safe (list, app, GS_APP_LIST_ADD_FLAG_CHECK_FOR_DUPE);
+ }
+
+ /* recalculate global state */
+ gs_app_list_invalidate_state (list);
+ gs_app_list_invalidate_progress (list);
+}
+
+/**
+ * gs_app_list_index:
+ * @list: A #GsAppList
+ * @idx: An index into the list
+ *
+ * Gets an application at a specific position in the list.
+ *
+ * Returns: (transfer none): a #GsApp, or %NULL if invalid
+ *
+ * Since: 3.22
+ **/
+GsApp *
+gs_app_list_index (GsAppList *list, guint idx)
+{
+ return GS_APP (g_ptr_array_index (list->array, idx));
+}
+
+/**
+ * gs_app_list_length:
+ * @list: A #GsAppList
+ *
+ * Gets the length of the application list.
+ *
+ * Returns: Integer
+ *
+ * Since: 3.22
+ **/
+guint
+gs_app_list_length (GsAppList *list)
+{
+ g_return_val_if_fail (GS_IS_APP_LIST (list), 0);
+ return list->array->len;
+}
+
+static void
+gs_app_list_remove_all_safe (GsAppList *list)
+{
+ for (guint i = 0; i < list->array->len; i++) {
+ GsApp *app = g_ptr_array_index (list->array, i);
+ gs_app_list_maybe_unwatch_app (list, app);
+ }
+ g_ptr_array_set_size (list->array, 0);
+ gs_app_list_invalidate_state (list);
+ gs_app_list_invalidate_progress (list);
+}
+
+/**
+ * gs_app_list_remove_all:
+ * @list: A #GsAppList
+ *
+ * Removes all applications from the list.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_list_remove_all (GsAppList *list)
+{
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP_LIST (list));
+ locker = g_mutex_locker_new (&list->mutex);
+ gs_app_list_remove_all_safe (list);
+}
+
+/**
+ * gs_app_list_filter:
+ * @list: A #GsAppList
+ * @func: A #GsAppListFilterFunc
+ * @user_data: the user pointer to pass to @func
+ *
+ * If func() returns TRUE for the GsApp, then the app is kept.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_list_filter (GsAppList *list, GsAppListFilterFunc func, gpointer user_data)
+{
+ guint i;
+ GsApp *app;
+ g_autoptr(GsAppList) old = NULL;
+ g_autoptr(GMutexLocker) locker = NULL;
+
+ g_return_if_fail (GS_IS_APP_LIST (list));
+ g_return_if_fail (func != NULL);
+
+ locker = g_mutex_locker_new (&list->mutex);
+
+ /* deep copy to a temp list and clear the current one */
+ old = gs_app_list_copy (list);
+ gs_app_list_remove_all_safe (list);
+
+ /* see if any of the apps need filtering */
+ for (i = 0; i < old->array->len; i++) {
+ app = gs_app_list_index (old, i);
+ if (func (app, user_data))
+ gs_app_list_add_safe (list, app, GS_APP_LIST_ADD_FLAG_NONE);
+ }
+}
+
+typedef struct {
+ GsAppListSortFunc func;
+ gpointer user_data;
+} GsAppListSortHelper;
+
+static gint
+gs_app_list_sort_cb (gconstpointer a, gconstpointer b, gpointer user_data)
+{
+ GsApp *app1 = GS_APP (*(GsApp **) a);
+ GsApp *app2 = GS_APP (*(GsApp **) b);
+ const GsAppListSortHelper *helper = (GsAppListSortHelper *) user_data;
+ return helper->func (app1, app2, helper->user_data);
+}
+
+/**
+ * gs_app_list_sort:
+ * @list: A #GsAppList
+ * @func: A #GsAppListSortFunc
+ * @user_data: user data to pass to @func
+ *
+ * Sorts the application list.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_list_sort (GsAppList *list, GsAppListSortFunc func, gpointer user_data)
+{
+ g_autoptr(GMutexLocker) locker = NULL;
+ GsAppListSortHelper helper;
+ g_return_if_fail (GS_IS_APP_LIST (list));
+ locker = g_mutex_locker_new (&list->mutex);
+ helper.func = func;
+ helper.user_data = user_data;
+ g_ptr_array_sort_with_data (list->array, gs_app_list_sort_cb, &helper);
+}
+
+/**
+ * gs_app_list_truncate:
+ * @list: A #GsAppList
+ * @length: the new length
+ *
+ * Truncates the application list. It is an error if @length is larger than the
+ * size of the list.
+ *
+ * Since: 3.24
+ **/
+void
+gs_app_list_truncate (GsAppList *list, guint length)
+{
+ g_autoptr(GMutexLocker) locker = NULL;
+
+ g_return_if_fail (GS_IS_APP_LIST (list));
+ g_return_if_fail (length <= list->array->len);
+
+ /* mark this list as unworthy */
+ list->flags |= GS_APP_LIST_FLAG_IS_TRUNCATED;
+
+ /* everything */
+ if (length == 0) {
+ gs_app_list_remove_all (list);
+ return;
+ }
+
+ /* remove the apps in the positions larger than the length */
+ locker = g_mutex_locker_new (&list->mutex);
+ g_ptr_array_set_size (list->array, length);
+}
+
+/**
+ * gs_app_list_randomize:
+ * @list: A #GsAppList
+ *
+ * Randomize the order of the list, but don't change the order until
+ * the next day.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_list_randomize (GsAppList *list)
+{
+ GRand *rand;
+ g_autoptr(GDateTime) date = NULL;
+ g_autoptr(GMutexLocker) locker = NULL;
+
+ g_return_if_fail (GS_IS_APP_LIST (list));
+
+ locker = g_mutex_locker_new (&list->mutex);
+
+ if (!gs_app_list_length (list))
+ return;
+
+ rand = g_rand_new ();
+ date = g_date_time_new_now_utc ();
+ g_rand_set_seed (rand, (guint32) g_date_time_get_day_of_year (date));
+
+ /* Fisher–Yates shuffle of the array.
+ * See https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle */
+ for (guint i = gs_app_list_length (list) - 1; i >= 1; i--) {
+ gpointer tmp;
+ guint j = g_rand_int_range (rand, 0, i + 1);
+
+ tmp = list->array->pdata[i];
+ list->array->pdata[i] = list->array->pdata[j];
+ list->array->pdata[j] = tmp;
+ }
+
+ g_rand_free (rand);
+}
+
+static gboolean
+gs_app_list_filter_app_is_better (GsApp *app, GsApp *found, GsAppListFilterFlags flags)
+{
+ /* optional 1st layer sort */
+ if ((flags & GS_APP_LIST_FILTER_FLAG_PREFER_INSTALLED) > 0) {
+ if (gs_app_is_installed (app) && !gs_app_is_installed (found))
+ return TRUE;
+ if (!gs_app_is_installed (app) && gs_app_is_installed (found))
+ return FALSE;
+ }
+
+ /* 2nd layer, priority and bundle kind */
+ if (gs_app_compare_priority (app, found) < 0)
+ return TRUE;
+
+ /* assume is worse */
+ return FALSE;
+}
+
+static GPtrArray *
+gs_app_list_filter_app_get_keys (GsApp *app, GsAppListFilterFlags flags)
+{
+ GPtrArray *keys = g_ptr_array_new_with_free_func (g_free);
+ g_autoptr(GString) key = NULL;
+
+ /* just use the unique ID */
+ if (flags == GS_APP_LIST_FILTER_FLAG_NONE) {
+ if (gs_app_get_unique_id (app) != NULL)
+ g_ptr_array_add (keys, g_strdup (gs_app_get_unique_id (app)));
+ return keys;
+ }
+
+ /* use the ID and any provided items */
+ if (flags & GS_APP_LIST_FILTER_FLAG_KEY_ID_PROVIDES) {
+ GPtrArray *provided = gs_app_get_provided (app);
+ g_ptr_array_add (keys, g_strdup (gs_app_get_id (app)));
+ for (guint i = 0; i < provided->len; i++) {
+ AsProvided *prov = g_ptr_array_index (provided, i);
+ GPtrArray *items;
+ if (as_provided_get_kind (prov) != AS_PROVIDED_KIND_ID)
+ continue;
+ items = as_provided_get_items (prov);
+ for (guint j = 0; j < items->len; j++)
+ g_ptr_array_add (keys, g_strdup (g_ptr_array_index (items, j)));
+ }
+ return keys;
+ }
+
+ /* specific compound type */
+ key = g_string_new (NULL);
+ if (flags & GS_APP_LIST_FILTER_FLAG_KEY_ID) {
+ const gchar *tmp = gs_app_get_id (app);
+ if (tmp != NULL)
+ g_string_append (key, gs_app_get_id (app));
+ }
+ if (flags & GS_APP_LIST_FILTER_FLAG_KEY_SOURCE) {
+ const gchar *tmp = gs_app_get_source_default (app);
+ if (tmp != NULL)
+ g_string_append_printf (key, ":%s", tmp);
+ }
+ if (flags & GS_APP_LIST_FILTER_FLAG_KEY_VERSION) {
+ const gchar *tmp = gs_app_get_version (app);
+ if (tmp != NULL)
+ g_string_append_printf (key, ":%s", tmp);
+ }
+ if (key->len == 0)
+ return keys;
+ g_ptr_array_add (keys, g_string_free (g_steal_pointer (&key), FALSE));
+ return keys;
+}
+
+/**
+ * gs_app_list_filter_duplicates:
+ * @list: A #GsAppList
+ * @flags: a #GsAppListFilterFlags, e.g. GS_APP_LIST_FILTER_KEY_ID
+ *
+ * Filter any duplicate applications from the list.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_list_filter_duplicates (GsAppList *list, GsAppListFilterFlags flags)
+{
+ g_autoptr(GHashTable) hash = NULL;
+ g_autoptr(GHashTable) kept_apps = NULL;
+ g_autoptr(GsAppList) old = NULL;
+ g_autoptr(GMutexLocker) locker = NULL;
+
+ g_return_if_fail (GS_IS_APP_LIST (list));
+
+ locker = g_mutex_locker_new (&list->mutex);
+
+ /* a hash table to hold apps with unique app ids */
+ hash = g_hash_table_new_full (g_str_hash, g_str_equal, g_free, NULL);
+ /* a hash table containing apps we want to keep */
+ kept_apps = g_hash_table_new (g_direct_hash, g_direct_equal);
+
+ for (guint i = 0; i < list->array->len; i++) {
+ GsApp *app = gs_app_list_index (list, i);
+ GsApp *found = NULL;
+ g_autoptr(GPtrArray) keys = NULL;
+
+ /* get all the keys used to identify this app */
+ keys = gs_app_list_filter_app_get_keys (app, flags);
+ for (guint j = 0; j < keys->len; j++) {
+ const gchar *key = g_ptr_array_index (keys, j);
+ found = g_hash_table_lookup (hash, key);
+ if (found != NULL)
+ break;
+ }
+
+ /* new app */
+ if (found == NULL) {
+ for (guint j = 0; j < keys->len; j++) {
+ const gchar *key = g_ptr_array_index (keys, j);
+ g_hash_table_insert (hash, g_strdup (key), app);
+ }
+ g_hash_table_add (kept_apps, app);
+ continue;
+ }
+
+ /* better? */
+ if (flags != GS_APP_LIST_FILTER_FLAG_NONE) {
+ if (gs_app_list_filter_app_is_better (app, found, flags)) {
+ for (guint j = 0; j < keys->len; j++) {
+ const gchar *key = g_ptr_array_index (keys, j);
+ g_hash_table_insert (hash, g_strdup (key), app);
+ }
+ g_hash_table_remove (kept_apps, found);
+ g_hash_table_add (kept_apps, app);
+ continue;
+ }
+ continue;
+ }
+ continue;
+ }
+
+ /* deep copy to a temp list and clear the current one */
+ old = gs_app_list_copy (list);
+ gs_app_list_remove_all_safe (list);
+
+ /* add back the apps we want to keep */
+ for (guint i = 0; i < old->array->len; i++) {
+ GsApp *app = gs_app_list_index (old, i);
+ if (g_hash_table_contains (kept_apps, app)) {
+ gs_app_list_add_safe (list, app, GS_APP_LIST_ADD_FLAG_NONE);
+ /* In case the same instance is in the 'list' multiple times */
+ g_hash_table_remove (kept_apps, app);
+ }
+ }
+}
+
+/**
+ * gs_app_list_copy:
+ * @list: A #GsAppList
+ *
+ * Returns a deep copy of the application list.
+ *
+ * Returns: A newly allocated #GsAppList
+ *
+ * Since: 3.22
+ **/
+GsAppList *
+gs_app_list_copy (GsAppList *list)
+{
+ GsAppList *new;
+ guint i;
+
+ g_return_val_if_fail (GS_IS_APP_LIST (list), NULL);
+
+ new = gs_app_list_new ();
+ for (i = 0; i < gs_app_list_length (list); i++) {
+ GsApp *app = gs_app_list_index (list, i);
+ gs_app_list_add_safe (new, app, GS_APP_LIST_ADD_FLAG_NONE);
+ }
+ return new;
+}
+
+static void
+gs_app_list_get_property (GObject *object, guint prop_id, GValue *value, GParamSpec *pspec)
+{
+ GsAppList *self = GS_APP_LIST (object);
+ switch (prop_id) {
+ case PROP_STATE:
+ g_value_set_enum (value, self->state);
+ break;
+ case PROP_PROGRESS:
+ g_value_set_uint (value, self->progress);
+ break;
+ default:
+ G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+ break;
+ }
+}
+
+static void
+gs_app_list_set_property (GObject *object, guint prop_id, const GValue *value, GParamSpec *pspec)
+{
+ switch (prop_id) {
+ default:
+ G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+ break;
+ }
+}
+
+static void
+gs_app_list_finalize (GObject *object)
+{
+ GsAppList *list = GS_APP_LIST (object);
+ g_ptr_array_unref (list->array);
+ g_mutex_clear (&list->mutex);
+ G_OBJECT_CLASS (gs_app_list_parent_class)->finalize (object);
+}
+
+static void
+gs_app_list_class_init (GsAppListClass *klass)
+{
+ GParamSpec *pspec;
+ GObjectClass *object_class = G_OBJECT_CLASS (klass);
+ object_class->get_property = gs_app_list_get_property;
+ object_class->set_property = gs_app_list_set_property;
+ object_class->finalize = gs_app_list_finalize;
+
+ /**
+ * GsAppList:state:
+ */
+ pspec = g_param_spec_enum ("state", NULL, NULL,
+ GS_TYPE_APP_STATE,
+ GS_APP_STATE_UNKNOWN,
+ G_PARAM_READABLE);
+ g_object_class_install_property (object_class, PROP_STATE, pspec);
+
+ /**
+ * GsAppList:progress:
+ *
+ * A percentage (0–100, inclusive) indicating the progress through the
+ * current task on this app list. The value may otherwise be
+ * %GS_APP_PROGRESS_UNKNOWN if the progress is unknown or has a wide
+ * confidence interval on any app, or if the app list is empty.
+ */
+ pspec = g_param_spec_uint ("progress", NULL, NULL,
+ 0, GS_APP_PROGRESS_UNKNOWN, GS_APP_PROGRESS_UNKNOWN,
+ G_PARAM_READABLE);
+ g_object_class_install_property (object_class, PROP_PROGRESS, pspec);
+
+ /**
+ * GsAppList:app-state-changed:
+ * @app: a #GsApp
+ *
+ * Emitted when any of the internal #GsApp instances changes its state.
+ *
+ * Since: 3.40
+ */
+ signals [SIGNAL_APP_STATE_CHANGED] =
+ g_signal_new ("app-state-changed",
+ G_TYPE_FROM_CLASS (object_class), G_SIGNAL_RUN_LAST,
+ 0, NULL, NULL, g_cclosure_marshal_generic,
+ G_TYPE_NONE, 1, GS_TYPE_APP);
+}
+
+static void
+gs_app_list_init (GsAppList *list)
+{
+ g_mutex_init (&list->mutex);
+ list->array = g_ptr_array_new_with_free_func ((GDestroyNotify) g_object_unref);
+ list->custom_progress = GS_APP_PROGRESS_UNKNOWN;
+}
+
+/**
+ * gs_app_list_new:
+ *
+ * Creates a new list.
+ *
+ * Returns: A newly allocated #GsAppList
+ *
+ * Since: 3.22
+ **/
+GsAppList *
+gs_app_list_new (void)
+{
+ GsAppList *list;
+ list = g_object_new (GS_TYPE_APP_LIST, NULL);
+ return GS_APP_LIST (list);
+}
diff --git a/lib/gs-app-list.h b/lib/gs-app-list.h
new file mode 100644
index 0000000..adf85f7
--- /dev/null
+++ b/lib/gs-app-list.h
@@ -0,0 +1,89 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2012-2016 Richard Hughes <richard@hughsie.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#include <glib-object.h>
+
+#include "gs-app.h"
+
+G_BEGIN_DECLS
+
+/**
+ * GsAppListFilterFlags:
+ * @GS_APP_LIST_FILTER_FLAG_NONE: No flags set
+ * @GS_APP_LIST_FILTER_FLAG_KEY_ID: Filter by ID
+ * @GS_APP_LIST_FILTER_FLAG_KEY_SOURCE: Filter by default source
+ * @GS_APP_LIST_FILTER_FLAG_KEY_VERSION: Filter by version
+ * @GS_APP_LIST_FILTER_FLAG_PREFER_INSTALLED: Prefer installed applications
+ * @GS_APP_LIST_FILTER_FLAG_KEY_ID_PROVIDES: Filter using the provides ID
+ *
+ * Flags to use when filtering. The priority of each #GsApp is used to choose
+ * which application object to keep.
+ *
+ * Since: 40
+ **/
+typedef enum {
+ GS_APP_LIST_FILTER_FLAG_NONE = 0,
+ GS_APP_LIST_FILTER_FLAG_KEY_ID = 1 << 0,
+ GS_APP_LIST_FILTER_FLAG_KEY_SOURCE = 1 << 1,
+ GS_APP_LIST_FILTER_FLAG_KEY_VERSION = 1 << 2,
+ GS_APP_LIST_FILTER_FLAG_PREFER_INSTALLED= 1 << 3,
+ GS_APP_LIST_FILTER_FLAG_KEY_ID_PROVIDES = 1 << 4,
+ GS_APP_LIST_FILTER_FLAG_LAST, /*< skip >*/
+ GS_APP_LIST_FILTER_FLAG_MASK = G_MAXUINT64
+} GsAppListFilterFlags;
+
+/* All the properties which use #GsAppListFilterFlags are guint64s. */
+G_STATIC_ASSERT (sizeof (GsAppListFilterFlags) == sizeof (guint64));
+
+#define GS_TYPE_APP_LIST (gs_app_list_get_type ())
+
+G_DECLARE_FINAL_TYPE (GsAppList, gs_app_list, GS, APP_LIST, GObject)
+
+/**
+ * GsAppListSortFunc:
+ * @app1:
+ * @app2:
+ * @user_data: user data passed into the sort function
+ *
+ * A version of #GCompareFunc which is specific to #GsApps.
+ *
+ * Returns: zero if @app1 and @app2 are equal, a negative value if @app1 comes
+ * before @app2, or a positive value if @app1 comes after @app2
+ * Since: 41
+ */
+typedef gint (*GsAppListSortFunc) (GsApp *app1,
+ GsApp *app2,
+ gpointer user_data);
+typedef gboolean (*GsAppListFilterFunc) (GsApp *app,
+ gpointer user_data);
+
+GsAppList *gs_app_list_new (void);
+GsAppList *gs_app_list_copy (GsAppList *list);
+void gs_app_list_add (GsAppList *list,
+ GsApp *app);
+void gs_app_list_add_list (GsAppList *list,
+ GsAppList *donor);
+gboolean gs_app_list_remove (GsAppList *list,
+ GsApp *app);
+GsApp *gs_app_list_index (GsAppList *list,
+ guint idx);
+GsApp *gs_app_list_lookup (GsAppList *list,
+ const gchar *unique_id);
+guint gs_app_list_length (GsAppList *list);
+void gs_app_list_sort (GsAppList *list,
+ GsAppListSortFunc func,
+ gpointer user_data);
+void gs_app_list_filter (GsAppList *list,
+ GsAppListFilterFunc func,
+ gpointer user_data);
+void gs_app_list_override_progress (GsAppList *list,
+ guint progress);
+
+G_END_DECLS
diff --git a/lib/gs-app-permissions.c b/lib/gs-app-permissions.c
new file mode 100644
index 0000000..bbae07c
--- /dev/null
+++ b/lib/gs-app-permissions.c
@@ -0,0 +1,430 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2022 Red Hat <www.redhat.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+/**
+ * SECTION:gs-app-permissions
+ * @short_description: A representation of the permissions requested by an app
+ *
+ * #GsAppPermissions is an object to represent the permissions requested by an app.
+ *
+ * While some common permissions are handled with the #GsAppPermissionsFlags,
+ * the object allows more detailed permissions to be represented, such as
+ * specific file system path access.
+ *
+ * Since: 43
+ */
+
+#include "config.h"
+
+#include <stdlib.h>
+
+#include <glib.h>
+#include <glib-object.h>
+
+#include "gs-app-permissions.h"
+
+#define DOES_NOT_CONTAIN ((guint) ~0)
+
+struct _GsAppPermissions
+{
+ GObject parent;
+
+ gboolean is_sealed;
+ GsAppPermissionsFlags flags;
+ GPtrArray *filesystem_read; /* (owner) (nullable) (element-type utf-8) */
+ GPtrArray *filesystem_full; /* (owner) (nullable) (element-type utf-8) */
+};
+
+G_DEFINE_TYPE (GsAppPermissions, gs_app_permissions, G_TYPE_OBJECT)
+
+static gint
+cmp_filename_qsort (gconstpointer item1,
+ gconstpointer item2)
+{
+ const gchar * const *pitem1 = item1;
+ const gchar * const *pitem2 = item2;
+ return strcmp (*pitem1, *pitem2);
+}
+
+static gint
+cmp_filename_bsearch (gconstpointer item1,
+ gconstpointer item2)
+{
+ return strcmp (item1, item2);
+}
+
+static void
+gs_app_permissions_finalize (GObject *object)
+{
+ GsAppPermissions *self = GS_APP_PERMISSIONS (object);
+
+ g_clear_pointer (&self->filesystem_read, g_ptr_array_unref);
+ g_clear_pointer (&self->filesystem_full, g_ptr_array_unref);
+
+ G_OBJECT_CLASS (gs_app_permissions_parent_class)->finalize (object);
+}
+
+static void
+gs_app_permissions_class_init (GsAppPermissionsClass *klass)
+{
+ GObjectClass *object_class = G_OBJECT_CLASS (klass);
+
+ object_class->finalize = gs_app_permissions_finalize;
+}
+
+static void
+gs_app_permissions_init (GsAppPermissions *self)
+{
+}
+
+/**
+ * gs_app_permissions_new:
+ *
+ * Create a new #GsAppPermissions containing the app permissions.
+ *
+ * Returns: (transfer full): a new #GsAppPermissions
+ * Since: 43
+ */
+GsAppPermissions *
+gs_app_permissions_new (void)
+{
+ return g_object_new (GS_TYPE_APP_PERMISSIONS, NULL);
+}
+
+/**
+ * gs_app_permissions_seal:
+ * @self: a #GsAppPermissions
+ *
+ * Seal the @self. After being called, no modifications can be
+ * done on the @self.
+ *
+ * Since: 43
+ **/
+void
+gs_app_permissions_seal (GsAppPermissions *self)
+{
+ g_return_if_fail (GS_IS_APP_PERMISSIONS (self));
+
+ if (self->is_sealed)
+ return;
+
+ self->is_sealed = TRUE;
+
+ /* Sort the arrays, which will help with searching */
+ if (self->filesystem_read)
+ qsort (self->filesystem_read->pdata, self->filesystem_read->len, sizeof (gpointer), cmp_filename_qsort);
+
+ if (self->filesystem_full)
+ qsort (self->filesystem_full->pdata, self->filesystem_full->len, sizeof (gpointer), cmp_filename_qsort);
+}
+
+/**
+ * gs_app_permissions_is_sealed:
+ * @self: a #GsAppPermissions
+ *
+ * Checks whether the @self had been sealed. Once the @self is sealed,
+ * no modifications can be made to it.
+ *
+ * Returns: whether the @self had been sealed
+ *
+ * Since: 43
+ **/
+gboolean
+gs_app_permissions_is_sealed (GsAppPermissions *self)
+{
+ g_return_val_if_fail (GS_IS_APP_PERMISSIONS (self), TRUE);
+
+ return self->is_sealed;
+}
+
+/**
+ * gs_app_permissions_set_flags:
+ * @self: a #GsAppPermissions
+ * @flags: a #GsAppPermissionsFlags to set
+ *
+ * Set the permission flags, overwriting any previously set flags.
+ * Compare to gs_app_permissions_add_flag() and
+ * gs_app_permissions_remove_flag().
+ *
+ * Since: 43
+ */
+void
+gs_app_permissions_set_flags (GsAppPermissions *self,
+ GsAppPermissionsFlags flags)
+{
+ g_return_if_fail (GS_IS_APP_PERMISSIONS (self));
+
+ g_assert (!self->is_sealed);
+
+ self->flags = flags;
+}
+
+/**
+ * gs_app_permissions_get_flags:
+ * @self: a #GsAppPermissions
+ *
+ * Get the permission flags.
+ *
+ * Returns: the permission flags
+ * Since: 43
+ */
+GsAppPermissionsFlags
+gs_app_permissions_get_flags (GsAppPermissions *self)
+{
+ g_return_val_if_fail (GS_IS_APP_PERMISSIONS (self), GS_APP_PERMISSIONS_FLAGS_UNKNOWN);
+
+ return self->flags;
+}
+
+/**
+ * gs_app_permissions_add_flag:
+ * @self: a #GsAppPermissions
+ * @flags: a #GsAppPermissionsFlags to add
+ *
+ * Add the @flags into the already set flags. The @flags cannot contain
+ * #GS_APP_PERMISSIONS_FLAGS_NONE, neither cannot be #GS_APP_PERMISSIONS_FLAGS_UNKNOWN.
+ * To set these two use gs_app_permissions_set_flags() instead.
+ *
+ * In case the current flags contain #GS_APP_PERMISSIONS_FLAGS_NONE, it's
+ * automatically unset.
+ *
+ * Since: 43
+ */
+void
+gs_app_permissions_add_flag (GsAppPermissions *self,
+ GsAppPermissionsFlags flags)
+{
+ g_return_if_fail (GS_IS_APP_PERMISSIONS (self));
+ g_return_if_fail (flags != GS_APP_PERMISSIONS_FLAGS_UNKNOWN);
+ g_return_if_fail ((flags & GS_APP_PERMISSIONS_FLAGS_NONE) == 0);
+
+ g_assert (!self->is_sealed);
+
+ self->flags = (self->flags & (~GS_APP_PERMISSIONS_FLAGS_NONE)) | flags;
+}
+
+/**
+ * gs_app_permissions_remove_flag:
+ * @self: a #GsAppPermissions
+ * @flags: a #GsAppPermissionsFlags to remove
+ *
+ * Remove the @flags from the already set flags. The @flags cannot contain
+ * #GS_APP_PERMISSIONS_FLAGS_NONE, neither cannot be #GS_APP_PERMISSIONS_FLAGS_UNKNOWN.
+ * To set these two use gs_app_permissions_set_flags() instead.
+ *
+ * In case the result of the removal would lead to no flag set the #GS_APP_PERMISSIONS_FLAGS_NONE
+ * is set automatically.
+ *
+ * Since: 43
+ */
+void
+gs_app_permissions_remove_flag (GsAppPermissions *self,
+ GsAppPermissionsFlags flags)
+{
+ g_return_if_fail (GS_IS_APP_PERMISSIONS (self));
+ g_return_if_fail (flags != GS_APP_PERMISSIONS_FLAGS_UNKNOWN);
+ g_return_if_fail ((flags & GS_APP_PERMISSIONS_FLAGS_NONE) == 0);
+
+ g_assert (!self->is_sealed);
+
+ self->flags = (self->flags & (~flags));
+
+ if (!self->flags)
+ self->flags = GS_APP_PERMISSIONS_FLAGS_NONE;
+}
+
+static guint
+app_permissions_get_array_index (GPtrArray *array,
+ const gchar *filename)
+{
+ g_return_val_if_fail (filename != NULL, DOES_NOT_CONTAIN);
+
+ if (array == NULL)
+ return DOES_NOT_CONTAIN;
+
+ for (guint i = 0; i < array->len; i++) {
+ const gchar *item = g_ptr_array_index (array, i);
+ if (g_strcmp0 (item, filename) == 0)
+ return 0;
+ }
+
+ return DOES_NOT_CONTAIN;
+}
+
+/**
+ * gs_app_permissions_add_filesystem_read:
+ * @self: a #GsAppPermissions
+ * @filename: a filename to access
+ *
+ * Add @filename as a file to access for read. The @filename
+ * can be either a path or a localized pretty name of it, like "Documents".
+ * The addition is ignored in case the same @filename is part of
+ * the read or full access file names.
+ *
+ * Since: 43
+ */
+void
+gs_app_permissions_add_filesystem_read (GsAppPermissions *self,
+ const gchar *filename)
+{
+ g_return_if_fail (GS_IS_APP_PERMISSIONS (self));
+ g_return_if_fail (filename != NULL);
+
+ g_assert (!self->is_sealed);
+
+ /* Already known */
+ if (app_permissions_get_array_index (self->filesystem_read, filename) != DOES_NOT_CONTAIN ||
+ app_permissions_get_array_index (self->filesystem_full, filename) != DOES_NOT_CONTAIN)
+ return;
+
+ if (self->filesystem_read == NULL)
+ self->filesystem_read = g_ptr_array_new_with_free_func (g_free);
+
+ g_ptr_array_add (self->filesystem_read, g_strdup (filename));
+}
+
+/**
+ * gs_app_permissions_get_filesystem_read:
+ * @self: a #GsAppPermissions
+ *
+ * Get the list of filesystem file names requested for read access using
+ * gs_app_permissions_add_filesystem_read().
+ * The array is owned by the @self and should not be modified by any way.
+ * It can be %NULL, when no file access was set.
+ *
+ * Returns: (nullable) (transfer none) (element-type utf-8): an array of
+ * file names requesting read access or %NULL, when none was set.
+ *
+ * Since: 43
+ */
+const GPtrArray *
+gs_app_permissions_get_filesystem_read (GsAppPermissions *self)
+{
+ g_return_val_if_fail (GS_IS_APP_PERMISSIONS (self), NULL);
+
+ return self->filesystem_read;
+}
+
+static gboolean
+array_contains_filename (GPtrArray *array,
+ const gchar *filename)
+{
+ if (array == NULL)
+ return FALSE;
+
+ return bsearch (filename, array->pdata, array->len, sizeof (gpointer), cmp_filename_bsearch) != NULL;
+}
+
+/**
+ * gs_app_permissions_contains_filesystem_read:
+ * @self: a #GsAppPermissions
+ * @filename: a file name to search for
+ *
+ * Checks whether the @filename is included in the filesystem read permissions.
+ * This can be called only after the @self is sealed.
+ *
+ * Returns: whether the @filename is part of the filesystem read permissions
+ *
+ * Since: 43
+ **/
+gboolean
+gs_app_permissions_contains_filesystem_read (GsAppPermissions *self,
+ const gchar *filename)
+{
+ g_return_val_if_fail (GS_IS_APP_PERMISSIONS (self), FALSE);
+ g_return_val_if_fail (filename != NULL, FALSE);
+ g_return_val_if_fail (self->is_sealed, FALSE);
+
+ return array_contains_filename (self->filesystem_read, filename);
+}
+
+/**
+ * gs_app_permissions_add_filesystem_full:
+ * @self: a #GsAppPermissions
+ * @filename: a filename to access
+ *
+ * Add @filename as a file to access for read and write. The @filename
+ * can be either a path or a localized pretty name of it, like "Documents".
+ * The addition is ignored in case the same @filename is include in the list
+ * already. The @filename is removed from the read list, if it's part of it.
+ *
+ * Since: 43
+ */
+void
+gs_app_permissions_add_filesystem_full (GsAppPermissions *self,
+ const gchar *filename)
+{
+ guint read_index;
+
+ g_return_if_fail (GS_IS_APP_PERMISSIONS (self));
+ g_return_if_fail (filename != NULL);
+
+ g_assert (!self->is_sealed);
+
+ /* Already known */
+ if (app_permissions_get_array_index (self->filesystem_full, filename) != DOES_NOT_CONTAIN)
+ return;
+
+ if (self->filesystem_full == NULL)
+ self->filesystem_full = g_ptr_array_new_with_free_func (g_free);
+
+ g_ptr_array_add (self->filesystem_full, g_strdup (filename));
+
+ /* Remove from the read list and free the read list if becomes empty */
+ read_index = app_permissions_get_array_index (self->filesystem_read, filename);
+ if (read_index != DOES_NOT_CONTAIN) {
+ g_ptr_array_remove_index (self->filesystem_read, read_index);
+ if (self->filesystem_read->len == 0)
+ g_clear_pointer (&self->filesystem_read, g_ptr_array_unref);
+ }
+}
+
+/**
+ * gs_app_permissions_get_filesystem_full:
+ * @self: a #GsAppPermissions
+ *
+ * Get the list of filesystem file names requested for read and write access using
+ * gs_app_permissions_add_filesystem_full().
+ * The array is owned by the @self and should not be modified by any way.
+ * It can be %NULL, when no file access was set.
+ *
+ * Returns: (nullable) (transfer none) (element-type utf-8): an array of
+ * file names requesting read and write access or %NULL, when none was set.
+ *
+ * Since: 43
+ */
+const GPtrArray *
+gs_app_permissions_get_filesystem_full (GsAppPermissions *self)
+{
+ g_return_val_if_fail (GS_IS_APP_PERMISSIONS (self), NULL);
+
+ return self->filesystem_full;
+}
+
+/**
+ * gs_app_permissions_contains_filesystem_full:
+ * @self: a #GsAppPermissions
+ * @filename: a file name to search for
+ *
+ * Checks whether the @filename is included in the filesystem full permissions.
+ * This can be called only after the @self is sealed.
+ *
+ * Returns: whether the @filename is part of the filesystem full permissions
+ *
+ * Since: 43
+ **/
+gboolean
+gs_app_permissions_contains_filesystem_full (GsAppPermissions *self,
+ const gchar *filename)
+{
+ g_return_val_if_fail (GS_IS_APP_PERMISSIONS (self), FALSE);
+ g_return_val_if_fail (filename != NULL, FALSE);
+ g_return_val_if_fail (self->is_sealed, FALSE);
+
+ return array_contains_filename (self->filesystem_full, filename);
+}
diff --git a/lib/gs-app-permissions.h b/lib/gs-app-permissions.h
new file mode 100644
index 0000000..96c482b
--- /dev/null
+++ b/lib/gs-app-permissions.h
@@ -0,0 +1,74 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2022 Red Hat <www.redhat.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#include <glib.h>
+#include <glib-object.h>
+
+G_BEGIN_DECLS
+
+typedef enum {
+ GS_APP_PERMISSIONS_FLAGS_UNKNOWN = 0,
+ GS_APP_PERMISSIONS_FLAGS_NONE = 1 << 0,
+ GS_APP_PERMISSIONS_FLAGS_NETWORK = 1 << 1,
+ GS_APP_PERMISSIONS_FLAGS_SYSTEM_BUS = 1 << 2,
+ GS_APP_PERMISSIONS_FLAGS_SESSION_BUS = 1 << 3,
+ GS_APP_PERMISSIONS_FLAGS_DEVICES = 1 << 4,
+ GS_APP_PERMISSIONS_FLAGS_HOME_FULL = 1 << 5,
+ GS_APP_PERMISSIONS_FLAGS_HOME_READ = 1 << 6,
+ GS_APP_PERMISSIONS_FLAGS_FILESYSTEM_FULL = 1 << 7,
+ GS_APP_PERMISSIONS_FLAGS_FILESYSTEM_READ = 1 << 8,
+ GS_APP_PERMISSIONS_FLAGS_DOWNLOADS_FULL = 1 << 9,
+ GS_APP_PERMISSIONS_FLAGS_DOWNLOADS_READ = 1 << 10,
+ GS_APP_PERMISSIONS_FLAGS_SETTINGS = 1 << 11,
+ GS_APP_PERMISSIONS_FLAGS_X11 = 1 << 12,
+ GS_APP_PERMISSIONS_FLAGS_ESCAPE_SANDBOX = 1 << 13,
+ GS_APP_PERMISSIONS_FLAGS_FILESYSTEM_OTHER = 1 << 14,
+ GS_APP_PERMISSIONS_FLAGS_LAST /*< skip >*/
+} GsAppPermissionsFlags;
+
+#define LIMITED_PERMISSIONS (GS_APP_PERMISSIONS_FLAGS_SETTINGS | \
+ GS_APP_PERMISSIONS_FLAGS_NETWORK | \
+ GS_APP_PERMISSIONS_FLAGS_DOWNLOADS_READ | \
+ GS_APP_PERMISSIONS_FLAGS_DOWNLOADS_FULL)
+#define MEDIUM_PERMISSIONS (LIMITED_PERMISSIONS | \
+ GS_APP_PERMISSIONS_FLAGS_X11)
+
+#define GS_TYPE_APP_PERMISSIONS (gs_app_permissions_get_type ())
+
+G_DECLARE_FINAL_TYPE (GsAppPermissions, gs_app_permissions, GS, APP_PERMISSIONS, GObject)
+
+GsAppPermissions *gs_app_permissions_new (void);
+void gs_app_permissions_seal (GsAppPermissions *self);
+gboolean gs_app_permissions_is_sealed (GsAppPermissions *self);
+void gs_app_permissions_set_flags (GsAppPermissions *self,
+ GsAppPermissionsFlags flags);
+GsAppPermissionsFlags gs_app_permissions_get_flags (GsAppPermissions *self);
+void gs_app_permissions_add_flag (GsAppPermissions *self,
+ GsAppPermissionsFlags flags);
+void gs_app_permissions_remove_flag (GsAppPermissions *self,
+ GsAppPermissionsFlags flags);
+void gs_app_permissions_add_filesystem_read
+ (GsAppPermissions *self,
+ const gchar *filename);
+const GPtrArray *gs_app_permissions_get_filesystem_read
+ (GsAppPermissions *self);
+gboolean gs_app_permissions_contains_filesystem_read
+ (GsAppPermissions *self,
+ const gchar *filename);
+void gs_app_permissions_add_filesystem_full
+ (GsAppPermissions *self,
+ const gchar *filename);
+const GPtrArray *gs_app_permissions_get_filesystem_full
+ (GsAppPermissions *self);
+gboolean gs_app_permissions_contains_filesystem_full
+ (GsAppPermissions *self,
+ const gchar *filename);
+
+G_END_DECLS
diff --git a/lib/gs-app-private.h b/lib/gs-app-private.h
new file mode 100644
index 0000000..1163bf3
--- /dev/null
+++ b/lib/gs-app-private.h
@@ -0,0 +1,30 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2016 Richard Hughes <richard@hughsie.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#include "gs-app.h"
+#include "gs-plugin-types.h"
+
+G_BEGIN_DECLS
+
+void gs_app_set_priority (GsApp *app,
+ guint priority);
+guint gs_app_get_priority (GsApp *app);
+void gs_app_set_unique_id (GsApp *app,
+ const gchar *unique_id);
+void gs_app_remove_addon (GsApp *app,
+ GsApp *addon);
+GCancellable *gs_app_get_cancellable (GsApp *app);
+GsPluginAction gs_app_get_pending_action (GsApp *app);
+void gs_app_set_pending_action (GsApp *app,
+ GsPluginAction action);
+gint gs_app_compare_priority (GsApp *app1,
+ GsApp *app2);
+
+G_END_DECLS
diff --git a/lib/gs-app-query.c b/lib/gs-app-query.c
new file mode 100644
index 0000000..cd2fc93
--- /dev/null
+++ b/lib/gs-app-query.c
@@ -0,0 +1,1173 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2022 Endless OS Foundation LLC
+ *
+ * Author: Philip Withnall <pwithnall@endlessos.org>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+/**
+ * SECTION:gs-app-query
+ * @short_description: Immutable representation of a query for apps
+ *
+ * #GsAppQuery is an object to represent a query for apps.
+ *
+ * It will typically be used with #GsPluginJobListApps, which searches for
+ * matching apps, but it may have multiple consumers. #GsAppQuery only
+ * represents the query and does not provide an implementation for executing
+ * that query.
+ *
+ * It is immutable after construction, and hence threadsafe. It may be extended
+ * in future by adding more query properties. The existing query properties are
+ * conjunctive: results should only be returned which match *all* properties
+ * which are set, not _any_ properties which are set.
+ *
+ * The set of apps returned for the query can be controlled with the
+ * #GsAppQuery:refine-flags,
+ * #GsAppQuery:max-results and
+ * #GsAppQuery:dedupe-flags properties. If `refine-flags` is
+ * set, all results must be refined using the given set of refine flags (see
+ * #GsPluginJobRefine). `max-results` and `dedupe-flags` are used to limit the
+ * set of results.
+ *
+ * Results must always be processed in this order:
+ * - Filtering using #GsAppQuery:filter-func (and any other custom filter
+ * functions the query executor provides).
+ * - Deduplication using #GsAppQuery:dedupe-flags.
+ * - Sorting using #GsAppQuery:sort-func.
+ * - Truncating result list length to #GsAppQuery:max-results.
+ *
+ * Since: 43
+ */
+
+#include "config.h"
+
+#include <glib.h>
+#include <glib-object.h>
+
+#include "gs-app.h"
+#include "gs-app-list.h"
+#include "gs-app-query.h"
+#include "gs-enums.h"
+#include "gs-plugin-types.h"
+#include "gs-utils.h"
+
+struct _GsAppQuery
+{
+ GObject parent;
+
+ GsPluginRefineFlags refine_flags;
+ guint max_results;
+ GsAppListFilterFlags dedupe_flags;
+
+ GsAppListSortFunc sort_func;
+ gpointer sort_user_data;
+ GDestroyNotify sort_user_data_notify;
+
+ GsAppListFilterFunc filter_func;
+ gpointer filter_user_data;
+ GDestroyNotify filter_user_data_notify;
+
+ /* This is guaranteed to either be %NULL, or a non-empty array */
+ gchar **provides_files; /* (owned) (nullable) (array zero-terminated=1) */
+ GDateTime *released_since; /* (owned) (nullable) */
+ GsAppQueryTristate is_curated;
+ GsAppQueryTristate is_featured;
+ GsCategory *category; /* (nullable) (owned) */
+ GsAppQueryTristate is_installed;
+
+ /* This is guaranteed to either be %NULL, or a non-empty array */
+ gchar **deployment_featured; /* (owned) (nullable) (array zero-terminated=1) */
+ /* This is guaranteed to either be %NULL, or a non-empty array */
+ gchar **developers; /* (owned) (nullable) (array zero-terminated=1) */
+
+ gchar **keywords; /* (owned) (nullable) (array zero-terminated=1) */
+ GsApp *alternate_of; /* (nullable) (owned) */
+ gchar *provides_tag; /* (owned) (nullable) */
+ GsAppQueryProvidesType provides_type;
+};
+
+G_DEFINE_TYPE (GsAppQuery, gs_app_query, G_TYPE_OBJECT)
+
+typedef enum {
+ PROP_REFINE_FLAGS = 1,
+ PROP_MAX_RESULTS,
+ PROP_DEDUPE_FLAGS,
+ PROP_SORT_FUNC,
+ PROP_SORT_USER_DATA,
+ PROP_SORT_USER_DATA_NOTIFY,
+ PROP_FILTER_FUNC,
+ PROP_FILTER_USER_DATA,
+ PROP_FILTER_USER_DATA_NOTIFY,
+ PROP_DEPLOYMENT_FEATURED,
+ PROP_DEVELOPERS,
+ PROP_PROVIDES_FILES,
+ PROP_RELEASED_SINCE,
+ PROP_IS_CURATED,
+ PROP_IS_FEATURED,
+ PROP_CATEGORY,
+ PROP_IS_INSTALLED,
+ PROP_KEYWORDS,
+ PROP_ALTERNATE_OF,
+ PROP_PROVIDES_TAG,
+ PROP_PROVIDES_TYPE,
+} GsAppQueryProperty;
+
+static GParamSpec *props[PROP_PROVIDES_TYPE + 1] = { NULL, };
+
+static void
+gs_app_query_constructed (GObject *object)
+{
+ GsAppQuery *self = GS_APP_QUERY (object);
+
+ G_OBJECT_CLASS (gs_app_query_parent_class)->constructed (object);
+
+ g_assert ((self->provides_tag != NULL) == (self->provides_type != GS_APP_QUERY_PROVIDES_UNKNOWN));
+}
+
+static void
+gs_app_query_get_property (GObject *object,
+ guint prop_id,
+ GValue *value,
+ GParamSpec *pspec)
+{
+ GsAppQuery *self = GS_APP_QUERY (object);
+
+ switch ((GsAppQueryProperty) prop_id) {
+ case PROP_REFINE_FLAGS:
+ g_value_set_flags (value, self->refine_flags);
+ break;
+ case PROP_MAX_RESULTS:
+ g_value_set_uint (value, self->max_results);
+ break;
+ case PROP_DEDUPE_FLAGS:
+ g_value_set_flags (value, self->dedupe_flags);
+ break;
+ case PROP_SORT_FUNC:
+ g_value_set_pointer (value, self->sort_func);
+ break;
+ case PROP_SORT_USER_DATA:
+ g_value_set_pointer (value, self->sort_user_data);
+ break;
+ case PROP_SORT_USER_DATA_NOTIFY:
+ g_value_set_pointer (value, self->sort_user_data_notify);
+ break;
+ case PROP_FILTER_FUNC:
+ g_value_set_pointer (value, self->filter_func);
+ break;
+ case PROP_FILTER_USER_DATA:
+ g_value_set_pointer (value, self->filter_user_data);
+ break;
+ case PROP_FILTER_USER_DATA_NOTIFY:
+ g_value_set_pointer (value, self->filter_user_data_notify);
+ break;
+ case PROP_DEPLOYMENT_FEATURED:
+ g_value_set_boxed (value, self->deployment_featured);
+ break;
+ case PROP_DEVELOPERS:
+ g_value_set_boxed (value, self->developers);
+ break;
+ case PROP_PROVIDES_FILES:
+ g_value_set_boxed (value, self->provides_files);
+ break;
+ case PROP_RELEASED_SINCE:
+ g_value_set_boxed (value, self->released_since);
+ break;
+ case PROP_IS_CURATED:
+ g_value_set_enum (value, self->is_curated);
+ break;
+ case PROP_IS_FEATURED:
+ g_value_set_enum (value, self->is_featured);
+ break;
+ case PROP_CATEGORY:
+ g_value_set_object (value, self->category);
+ break;
+ case PROP_IS_INSTALLED:
+ g_value_set_enum (value, self->is_installed);
+ break;
+ case PROP_KEYWORDS:
+ g_value_set_boxed (value, self->keywords);
+ break;
+ case PROP_ALTERNATE_OF:
+ g_value_set_object (value, self->alternate_of);
+ break;
+ case PROP_PROVIDES_TAG:
+ g_value_set_string (value, self->provides_tag);
+ break;
+ case PROP_PROVIDES_TYPE:
+ g_value_set_enum (value, self->provides_type);
+ break;
+ default:
+ G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+ break;
+ }
+}
+
+static void
+gs_app_query_set_property (GObject *object,
+ guint prop_id,
+ const GValue *value,
+ GParamSpec *pspec)
+{
+ GsAppQuery *self = GS_APP_QUERY (object);
+
+ switch ((GsAppQueryProperty) prop_id) {
+ case PROP_REFINE_FLAGS:
+ /* Construct only. */
+ g_assert (self->refine_flags == 0);
+ self->refine_flags = g_value_get_flags (value);
+ break;
+ case PROP_MAX_RESULTS:
+ /* Construct only. */
+ g_assert (self->max_results == 0);
+ self->max_results = g_value_get_uint (value);
+ break;
+ case PROP_DEDUPE_FLAGS:
+ /* Construct only. */
+ g_assert (self->dedupe_flags == 0);
+ self->dedupe_flags = g_value_get_flags (value);
+ break;
+ case PROP_SORT_FUNC:
+ /* Construct only. */
+ g_assert (self->sort_func == NULL);
+ self->sort_func = g_value_get_pointer (value);
+ break;
+ case PROP_SORT_USER_DATA:
+ /* Construct only. */
+ g_assert (self->sort_user_data == NULL);
+ self->sort_user_data = g_value_get_pointer (value);
+ break;
+ case PROP_SORT_USER_DATA_NOTIFY:
+ /* Construct only. */
+ g_assert (self->sort_user_data_notify == NULL);
+ self->sort_user_data_notify = g_value_get_pointer (value);
+ break;
+ case PROP_FILTER_FUNC:
+ /* Construct only. */
+ g_assert (self->filter_func == NULL);
+ self->filter_func = g_value_get_pointer (value);
+ break;
+ case PROP_FILTER_USER_DATA:
+ /* Construct only. */
+ g_assert (self->filter_user_data == NULL);
+ self->filter_user_data = g_value_get_pointer (value);
+ break;
+ case PROP_FILTER_USER_DATA_NOTIFY:
+ /* Construct only. */
+ g_assert (self->filter_user_data_notify == NULL);
+ self->filter_user_data_notify = g_value_get_pointer (value);
+ break;
+ case PROP_DEPLOYMENT_FEATURED:
+ /* Construct only. */
+ g_assert (self->deployment_featured == NULL);
+ self->deployment_featured = g_value_dup_boxed (value);
+
+ /* Squash empty arrays to %NULL. */
+ if (self->deployment_featured != NULL && self->deployment_featured[0] == NULL)
+ g_clear_pointer (&self->deployment_featured, g_strfreev);
+
+ break;
+ case PROP_DEVELOPERS:
+ /* Construct only. */
+ g_assert (self->developers == NULL);
+ self->developers = g_value_dup_boxed (value);
+
+ /* Squash empty arrays to %NULL. */
+ if (self->developers != NULL && self->developers[0] == NULL)
+ g_clear_pointer (&self->developers, g_strfreev);
+
+ break;
+ case PROP_PROVIDES_FILES:
+ /* Construct only. */
+ g_assert (self->provides_files == NULL);
+ self->provides_files = g_value_dup_boxed (value);
+
+ /* Squash empty arrays to %NULL. */
+ if (self->provides_files != NULL && self->provides_files[0] == NULL)
+ g_clear_pointer (&self->provides_files, g_strfreev);
+
+ break;
+ case PROP_RELEASED_SINCE:
+ /* Construct only. */
+ g_assert (self->released_since == NULL);
+ self->released_since = g_value_dup_boxed (value);
+ break;
+ case PROP_IS_CURATED:
+ /* Construct only. */
+ g_assert (self->is_curated == GS_APP_QUERY_TRISTATE_UNSET);
+ self->is_curated = g_value_get_enum (value);
+ break;
+ case PROP_IS_FEATURED:
+ /* Construct only. */
+ g_assert (self->is_featured == GS_APP_QUERY_TRISTATE_UNSET);
+ self->is_featured = g_value_get_enum (value);
+ break;
+ case PROP_CATEGORY:
+ /* Construct only. */
+ g_assert (self->category == NULL);
+ self->category = g_value_dup_object (value);
+ break;
+ case PROP_IS_INSTALLED:
+ /* Construct only. */
+ g_assert (self->is_installed == GS_APP_QUERY_TRISTATE_UNSET);
+ self->is_installed = g_value_get_enum (value);
+ break;
+ case PROP_KEYWORDS:
+ /* Construct only. */
+ g_assert (self->keywords == NULL);
+ self->keywords = g_value_dup_boxed (value);
+
+ /* Squash empty arrays to %NULL. */
+ if (self->keywords != NULL && self->keywords[0] == NULL)
+ g_clear_pointer (&self->keywords, g_strfreev);
+
+ break;
+ case PROP_ALTERNATE_OF:
+ /* Construct only. */
+ g_assert (self->alternate_of == NULL);
+ self->alternate_of = g_value_dup_object (value);
+ break;
+ case PROP_PROVIDES_TAG:
+ /* Construct only. */
+ g_assert (self->provides_tag == NULL);
+ self->provides_tag = g_value_dup_string (value);
+ break;
+ case PROP_PROVIDES_TYPE:
+ /* Construct only. */
+ g_assert (self->provides_type == GS_APP_QUERY_PROVIDES_UNKNOWN);
+ self->provides_type = g_value_get_enum (value);
+ break;
+ default:
+ G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+ break;
+ }
+}
+
+static void
+gs_app_query_dispose (GObject *object)
+{
+ GsAppQuery *self = GS_APP_QUERY (object);
+
+ if (self->sort_user_data_notify != NULL && self->sort_user_data != NULL) {
+ self->sort_user_data_notify (g_steal_pointer (&self->sort_user_data));
+ self->sort_user_data_notify = NULL;
+ }
+
+ if (self->filter_user_data_notify != NULL && self->filter_user_data != NULL) {
+ self->filter_user_data_notify (g_steal_pointer (&self->filter_user_data));
+ self->filter_user_data_notify = NULL;
+ }
+
+ g_clear_object (&self->category);
+ g_clear_object (&self->alternate_of);
+
+ G_OBJECT_CLASS (gs_app_query_parent_class)->dispose (object);
+}
+
+static void
+gs_app_query_finalize (GObject *object)
+{
+ GsAppQuery *self = GS_APP_QUERY (object);
+
+ g_clear_pointer (&self->deployment_featured, g_strfreev);
+ g_clear_pointer (&self->developers, g_strfreev);
+ g_clear_pointer (&self->provides_files, g_strfreev);
+ g_clear_pointer (&self->released_since, g_date_time_unref);
+ g_clear_pointer (&self->keywords, g_strfreev);
+ g_clear_pointer (&self->provides_tag, g_free);
+
+ G_OBJECT_CLASS (gs_app_query_parent_class)->finalize (object);
+}
+
+static void
+gs_app_query_class_init (GsAppQueryClass *klass)
+{
+ GObjectClass *object_class = G_OBJECT_CLASS (klass);
+
+ object_class->constructed = gs_app_query_constructed;
+ object_class->get_property = gs_app_query_get_property;
+ object_class->set_property = gs_app_query_set_property;
+ object_class->dispose = gs_app_query_dispose;
+ object_class->finalize = gs_app_query_finalize;
+
+ /**
+ * GsAppQuery:refine-flags:
+ *
+ * Flags to specify how the returned apps must be refined, if at all.
+ *
+ * Since: 43
+ */
+ props[PROP_REFINE_FLAGS] =
+ g_param_spec_flags ("refine-flags", "Refine Flags",
+ "Flags to specify how the returned apps must be refined, if at all.",
+ GS_TYPE_PLUGIN_REFINE_FLAGS, GS_PLUGIN_REFINE_FLAGS_NONE,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY |
+ G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ /**
+ * GsAppQuery:max-results:
+ *
+ * Maximum number of results to return, or 0 for no limit.
+ *
+ * Since: 43
+ */
+ props[PROP_MAX_RESULTS] =
+ g_param_spec_uint ("max-results", "Max Results",
+ "Maximum number of results to return, or 0 for no limit.",
+ 0, G_MAXUINT, 0,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY |
+ G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ /**
+ * GsAppQuery:dedupe-flags:
+ *
+ * Flags to specify how to deduplicate the returned apps, if at all.
+ *
+ * Since: 43
+ */
+ props[PROP_DEDUPE_FLAGS] =
+ g_param_spec_flags ("dedupe-flags", "Dedupe Flags",
+ "Flags to specify how to deduplicate the returned apps, if at all.",
+ GS_TYPE_APP_LIST_FILTER_FLAGS, GS_APP_LIST_FILTER_FLAG_NONE,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY |
+ G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ /**
+ * GsAppQuery:sort-func: (nullable)
+ *
+ * A sort function to sort the returned apps.
+ *
+ * This must be of type #GsAppListSortFunc.
+ *
+ * Since: 43
+ */
+ props[PROP_SORT_FUNC] =
+ g_param_spec_pointer ("sort-func", "Sort Function",
+ "A sort function to sort the returned apps.",
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY |
+ G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ /**
+ * GsAppQuery:sort-user-data: (nullable)
+ *
+ * User data to pass to #GsAppQuery:sort-func.
+ *
+ * Since: 43
+ */
+ props[PROP_SORT_USER_DATA] =
+ g_param_spec_pointer ("sort-user-data", "Sort User Data",
+ "User data to pass to #GsAppQuery:sort-func.",
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY |
+ G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ /**
+ * GsAppQuery:sort-user-data-notify: (nullable)
+ *
+ * A function to free #GsAppQuery:sort-user-data once it is no longer
+ * needed.
+ *
+ * This must be of type #GDestroyNotify.
+ *
+ * This will be called exactly once between being set and when the
+ * #GsAppQuery is finalized.
+ *
+ * Since: 43
+ */
+ props[PROP_SORT_USER_DATA_NOTIFY] =
+ g_param_spec_pointer ("sort-user-data-notify", "Sort User Data Notify",
+ "A function to free #GsAppQuery:sort-user-data once it is no longer needed.",
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY |
+ G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ /**
+ * GsAppQuery:filter-func: (nullable)
+ *
+ * A filter function to filter the returned apps.
+ *
+ * This must be of type #GsAppListFilterFunc.
+ *
+ * Since: 43
+ */
+ props[PROP_FILTER_FUNC] =
+ g_param_spec_pointer ("filter-func", "Filter Function",
+ "A filter function to filter the returned apps.",
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY |
+ G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ /**
+ * GsAppQuery:filter-user-data: (nullable)
+ *
+ * User data to pass to #GsAppQuery:filter-func.
+ *
+ * Since: 43
+ */
+ props[PROP_FILTER_USER_DATA] =
+ g_param_spec_pointer ("filter-user-data", "Filter User Data",
+ "User data to pass to #GsAppQuery:filter-func.",
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY |
+ G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ /**
+ * GsAppQuery:filter-user-data-notify: (nullable)
+ *
+ * A function to free #GsAppQuery:filter-user-data once it is no longer
+ * needed.
+ *
+ * This must be of type #GDestroyNotify.
+ *
+ * This will be called exactly once between being set and when the
+ * #GsAppQuery is finalized.
+ *
+ * Since: 43
+ */
+ props[PROP_FILTER_USER_DATA_NOTIFY] =
+ g_param_spec_pointer ("filter-user-data-notify", "Filter User Data Notify",
+ "A function to free #GsAppQuery:filter-user-data once it is no longer needed.",
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY |
+ G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ /**
+ * GsAppQuery:deployment-featured: (nullable)
+ *
+ * A list of `GnomeSoftware::DeploymentFeatured` app keys.
+ *
+ * Search for applications that should be featured in a deployment-specific
+ * section on the overview page.
+ * This is expected to be a curated list of applications that are high quality
+ * and feature-complete. Only apps matching at least one of the keys in this
+ * list are returned.
+ *
+ * This may be %NULL to not filter on it. An empty array is
+ * considered equivalent to %NULL.
+ *
+ * Since: 43
+ */
+ props[PROP_DEPLOYMENT_FEATURED] =
+ g_param_spec_boxed ("deployment-featured", "Deployment Featured",
+ "A list of `GnomeSoftware::DeploymentFeatured` app keys.",
+ G_TYPE_STRV,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY |
+ G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ /**
+ * GsAppQuery:developers: (nullable)
+ *
+ * A list of developers to search the apps for.
+ *
+ * Used to search for apps which are provided by given developer(s).
+ *
+ * This may be %NULL to not filter on by them. An empty array is
+ * considered equivalent to %NULL.
+ *
+ * Since: 43
+ */
+ props[PROP_DEVELOPERS] =
+ g_param_spec_boxed ("developers", "Developers",
+ "A list of developers who provide the apps.",
+ G_TYPE_STRV,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY |
+ G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ /**
+ * GsAppQuery:provides-files: (nullable)
+ *
+ * A list of file paths which the apps must provide.
+ *
+ * Used to search for apps which provide specific files on the local
+ * file system.
+ *
+ * This may be %NULL to not filter on file paths. An empty array is
+ * considered equivalent to %NULL.
+ *
+ * Since: 43
+ */
+ props[PROP_PROVIDES_FILES] =
+ g_param_spec_boxed ("provides-files", "Provides Files",
+ "A list of file paths which the apps must provide.",
+ G_TYPE_STRV,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY |
+ G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ /**
+ * GsAppQuery:released-since: (nullable)
+ *
+ * A date/time which apps must have been released since (exclusive).
+ *
+ * Used to search for apps which have been updated recently.
+ *
+ * This may be %NULL to not filter on release date.
+ *
+ * Since: 43
+ */
+ props[PROP_RELEASED_SINCE] =
+ g_param_spec_boxed ("released-since", "Released Since",
+ "A date/time which apps must have been released since (exclusive).",
+ G_TYPE_DATE_TIME,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY |
+ G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ /**
+ * GsAppQuery:is-curated:
+ *
+ * Whether apps must be curated (%GS_APP_QUERY_TRISTATE_TRUE), or not
+ * curated (%GS_APP_QUERY_TRISTATE_FALSE).
+ *
+ * If this is %GS_APP_QUERY_TRISTATE_UNSET, apps are not filtered by
+ * their curation state.
+ *
+ * ‘Curated’ apps have been reviewed and picked by an editor to be
+ * promoted to users in some way. They should be high quality and
+ * feature complete.
+ *
+ * Since: 43
+ */
+ props[PROP_IS_CURATED] =
+ g_param_spec_enum ("is-curated", "Is Curated",
+ "Whether apps must be curated, or not curated.",
+ GS_TYPE_APP_QUERY_TRISTATE,
+ GS_APP_QUERY_TRISTATE_UNSET,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY |
+ G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ /**
+ * GsAppQuery:is-featured:
+ *
+ * Whether apps must be featured (%GS_APP_QUERY_TRISTATE_TRUE), or not
+ * featured (%GS_APP_QUERY_TRISTATE_FALSE).
+ *
+ * If this is %GS_APP_QUERY_TRISTATE_UNSET, apps are not filtered by
+ * their featured state.
+ *
+ * ‘Featured’ apps have been selected by the distribution or software
+ * source to be highlighted or promoted to users in some way. They
+ * should be high quality and feature complete.
+ *
+ * Since: 43
+ */
+ props[PROP_IS_FEATURED] =
+ g_param_spec_enum ("is-featured", "Is Featured",
+ "Whether apps must be featured, or not featured.",
+ GS_TYPE_APP_QUERY_TRISTATE,
+ GS_APP_QUERY_TRISTATE_UNSET,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY |
+ G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ /**
+ * GsAppQuery:category: (nullable)
+ *
+ * A category which apps must be in.
+ *
+ * If this is %NULL, apps are not filtered by category.
+ *
+ * Since: 43
+ */
+ props[PROP_CATEGORY] =
+ g_param_spec_object ("category", "Category",
+ "A category which apps must be in.",
+ GS_TYPE_CATEGORY,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY |
+ G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ /**
+ * GsAppQuery:is-installed:
+ *
+ * Whether apps must be installed (%GS_APP_QUERY_TRISTATE_TRUE), or not
+ * installed (%GS_APP_QUERY_TRISTATE_FALSE).
+ *
+ * If this is %GS_APP_QUERY_TRISTATE_UNSET, apps are not filtered by
+ * their installed state.
+ *
+ * Since: 43
+ */
+ props[PROP_IS_INSTALLED] =
+ g_param_spec_enum ("is-installed", "Is Installed",
+ "Whether apps must be installed, or not installed.",
+ GS_TYPE_APP_QUERY_TRISTATE,
+ GS_APP_QUERY_TRISTATE_UNSET,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY |
+ G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ /**
+ * GsAppQuery:keywords:
+ *
+ * A set of search keywords which apps must match.
+ *
+ * Search matches may be done against multiple properties of the app,
+ * such as its name, description, supported content types, defined
+ * keywords, etc. The keywords in this property may be stemmed in an
+ * undefined way after being retrieved from #GsAppQuery.
+ *
+ * If this is %NULL, apps are not filtered by matches to this set of
+ * keywords. An empty array is considered equivalent to %NULL.
+ *
+ * Since: 43
+ */
+ props[PROP_KEYWORDS] =
+ g_param_spec_boxed ("keywords", "Keywords",
+ "A set of search keywords which apps must match.",
+ G_TYPE_STRV,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY |
+ G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ /**
+ * GsAppQuery:alternate-of: (nullable)
+ *
+ * An app which apps must be related to.
+ *
+ * The definition of ‘related to’ depends on the code consuming
+ * #GsAppQuery, but it will typically be other applications which
+ * implement the same feature, or other applications which are packaged
+ * together with this one.
+ *
+ * If this is %NULL, apps are not filtered by alternatives.
+ *
+ * Since: 43
+ */
+ props[PROP_ALTERNATE_OF] =
+ g_param_spec_object ("alternate-of", "Alternate Of",
+ "An app which apps must be related to.",
+ GS_TYPE_APP,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY |
+ G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ /**
+ * GsAppQuery:provides-tag: (nullable)
+ *
+ * A tag which apps must provide.
+ *
+ * The interpretation of the tag depends on #GsAppQuery:provides-type,
+ * which must not be %GS_APP_QUERY_PROVIDES_UNKNOWN if this is
+ * non-%NULL. Typically a tag will be a content type which the app
+ * implements, or the name of a printer which the app provides the
+ * driver for, etc.
+ *
+ * If this is %NULL, apps are not filtered by what they provide.
+ *
+ * Since: 43
+ */
+ props[PROP_PROVIDES_TAG] =
+ g_param_spec_string ("provides-tag", "Provides Tag",
+ "A tag which apps must provide.",
+ NULL,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY |
+ G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ /**
+ * GsAppQuery:provides-type:
+ *
+ * The type of #GsAppQuery:provides-tag.
+ *
+ * If this is %GS_APP_QUERY_PROVIDES_UNKNOWN, apps are not filtered by
+ * what they provide.
+ *
+ * Since: 43
+ */
+ props[PROP_PROVIDES_TYPE] =
+ g_param_spec_enum ("provides-type", "Provides Type",
+ "The type of #GsAppQuery:provides-tag.",
+ GS_TYPE_APP_QUERY_PROVIDES_TYPE, GS_APP_QUERY_PROVIDES_UNKNOWN,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY |
+ G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ g_object_class_install_properties (object_class, G_N_ELEMENTS (props), props);
+}
+
+static void
+gs_app_query_init (GsAppQuery *self)
+{
+ self->is_curated = GS_APP_QUERY_TRISTATE_UNSET;
+ self->is_featured = GS_APP_QUERY_TRISTATE_UNSET;
+ self->is_installed = GS_APP_QUERY_TRISTATE_UNSET;
+ self->provides_type = GS_APP_QUERY_PROVIDES_UNKNOWN;
+}
+
+/**
+ * gs_app_query_new:
+ * @first_property_name: name of the first #GObject property
+ * @...: value for the first property, followed by additional property/value
+ * pairs, then a terminating %NULL
+ *
+ * Create a new #GsAppQuery containing the given query properties.
+ *
+ * Returns: (transfer full): a new #GsAppQuery
+ * Since: 43
+ */
+GsAppQuery *
+gs_app_query_new (const gchar *first_property_name,
+ ...)
+{
+ va_list args;
+ g_autoptr(GsAppQuery) query = NULL;
+
+ va_start (args, first_property_name);
+ query = GS_APP_QUERY (g_object_new_valist (GS_TYPE_APP_QUERY, first_property_name, args));
+ va_end (args);
+
+ return g_steal_pointer (&query);
+}
+
+/**
+ * gs_app_query_get_refine_flags:
+ * @self: a #GsAppQuery
+ *
+ * Get the value of #GsAppQuery:refine-flags.
+ *
+ * Returns: the refine flags for the query
+ * Since: 43
+ */
+GsPluginRefineFlags
+gs_app_query_get_refine_flags (GsAppQuery *self)
+{
+ g_return_val_if_fail (GS_IS_APP_QUERY (self), GS_PLUGIN_REFINE_FLAGS_NONE);
+
+ return self->refine_flags;
+}
+
+/**
+ * gs_app_query_get_max_results:
+ * @self: a #GsAppQuery
+ *
+ * Get the value of #GsAppQuery:max-results.
+ *
+ * Returns: the maximum number of results to return for the query, or `0` to
+ * indicate no limit
+ * Since: 43
+ */
+guint
+gs_app_query_get_max_results (GsAppQuery *self)
+{
+ g_return_val_if_fail (GS_IS_APP_QUERY (self), 0);
+
+ return self->max_results;
+}
+
+/**
+ * gs_app_query_get_dedupe_flags:
+ * @self: a #GsAppQuery
+ *
+ * Get the value of #GsAppQuery:dedupe-flags.
+ *
+ * Returns: the dedupe flags for the query
+ * Since: 43
+ */
+GsAppListFilterFlags
+gs_app_query_get_dedupe_flags (GsAppQuery *self)
+{
+ g_return_val_if_fail (GS_IS_APP_QUERY (self), GS_APP_LIST_FILTER_FLAG_NONE);
+
+ return self->dedupe_flags;
+}
+
+/**
+ * gs_app_query_get_sort_func:
+ * @self: a #GsAppQuery
+ * @user_data_out: (out) (transfer none) (optional) (nullable): return location
+ * for the #GsAppQuery:sort-user-data, or %NULL to ignore
+ *
+ * Get the value of #GsAppQuery:sort-func.
+ *
+ * Returns: (nullable): the sort function for the query
+ * Since: 43
+ */
+GsAppListSortFunc
+gs_app_query_get_sort_func (GsAppQuery *self,
+ gpointer *user_data_out)
+{
+ g_return_val_if_fail (GS_IS_APP_QUERY (self), NULL);
+
+ if (user_data_out != NULL)
+ *user_data_out = self->sort_user_data;
+
+ return self->sort_func;
+}
+
+/**
+ * gs_app_query_get_filter_func:
+ * @self: a #GsAppQuery
+ * @user_data_out: (out) (transfer none) (optional) (nullable): return location
+ * for the #GsAppQuery:filter-user-data, or %NULL to ignore
+ *
+ * Get the value of #GsAppQuery:filter-func.
+ *
+ * Returns: (nullable): the filter function for the query
+ * Since: 43
+ */
+GsAppListFilterFunc
+gs_app_query_get_filter_func (GsAppQuery *self,
+ gpointer *user_data_out)
+{
+ g_return_val_if_fail (GS_IS_APP_QUERY (self), NULL);
+
+ if (user_data_out != NULL)
+ *user_data_out = self->filter_user_data;
+
+ return self->filter_func;
+}
+
+/**
+ * gs_app_query_get_n_properties_set:
+ * @self: a #GsAppQuery
+ *
+ * Get the number of query properties which have been set.
+ *
+ * These are the properties which determine the query results, rather than ones
+ * which control refining the results (#GsAppQuery:refine-flags,
+ * #GsAppQuery:max-results, #GsAppQuery:dedupe-flags, #GsAppQuery:sort-func and
+ * its user data, #GsAppQuery:filter-func and its user data).
+ *
+ * Returns: number of properties set so they will affect query results
+ * Since: 43
+ */
+guint
+gs_app_query_get_n_properties_set (GsAppQuery *self)
+{
+ guint n = 0;
+
+ g_return_val_if_fail (GS_IS_APP_QUERY (self), 0);
+
+ if (self->provides_files != NULL)
+ n++;
+ if (self->released_since != NULL)
+ n++;
+ if (self->is_curated != GS_APP_QUERY_TRISTATE_UNSET)
+ n++;
+ if (self->is_featured != GS_APP_QUERY_TRISTATE_UNSET)
+ n++;
+ if (self->category != NULL)
+ n++;
+ if (self->is_installed != GS_APP_QUERY_TRISTATE_UNSET)
+ n++;
+ if (self->deployment_featured != NULL)
+ n++;
+ if (self->developers != NULL)
+ n++;
+ if (self->keywords != NULL)
+ n++;
+ if (self->alternate_of != NULL)
+ n++;
+ if (self->provides_tag != NULL)
+ n++;
+
+ return n;
+}
+
+/**
+ * gs_app_query_get_provides_files:
+ * @self: a #GsAppQuery
+ *
+ * Get the value of #GsAppQuery:provides-files.
+ *
+ * Returns: (nullable): a list of file paths which the apps must provide,
+ * or %NULL to not filter on file paths
+ * Since: 43
+ */
+const gchar * const *
+gs_app_query_get_provides_files (GsAppQuery *self)
+{
+ g_return_val_if_fail (GS_IS_APP_QUERY (self), NULL);
+
+ /* Always return %NULL or a non-empty array */
+ g_assert (self->provides_files == NULL || self->provides_files[0] != NULL);
+
+ return (const gchar * const *) self->provides_files;
+}
+
+/**
+ * gs_app_query_get_released_since:
+ * @self: a #GsAppQuery
+ *
+ * Get the value of #GsAppQuery:released-since.
+ *
+ * Returns: (nullable): a date/time which apps must have been released since,
+ * or %NULL to not filter on release date
+ * Since: 43
+ */
+GDateTime *
+gs_app_query_get_released_since (GsAppQuery *self)
+{
+ g_return_val_if_fail (GS_IS_APP_QUERY (self), NULL);
+
+ return self->released_since;
+}
+
+/**
+ * gs_app_query_get_is_curated:
+ * @self: a #GsAppQuery
+ *
+ * Get the value of #GsAppQuery:is-curated.
+ *
+ * Returns: %GS_APP_QUERY_TRISTATE_TRUE if apps must be curated,
+ * %GS_APP_QUERY_TRISTATE_FALSE if they must be not curated, or
+ * %GS_APP_QUERY_TRISTATE_UNSET if it doesn’t matter
+ * Since: 43
+ */
+GsAppQueryTristate
+gs_app_query_get_is_curated (GsAppQuery *self)
+{
+ g_return_val_if_fail (GS_IS_APP_QUERY (self), GS_APP_QUERY_TRISTATE_UNSET);
+
+ return self->is_curated;
+}
+
+/**
+ * gs_app_query_get_is_featured:
+ * @self: a #GsAppQuery
+ *
+ * Get the value of #GsAppQuery:is-featured.
+ *
+ * Returns: %GS_APP_QUERY_TRISTATE_TRUE if apps must be featured,
+ * %GS_APP_QUERY_TRISTATE_FALSE if they must be not featured, or
+ * %GS_APP_QUERY_TRISTATE_UNSET if it doesn’t matter
+ * Since: 43
+ */
+GsAppQueryTristate
+gs_app_query_get_is_featured (GsAppQuery *self)
+{
+ g_return_val_if_fail (GS_IS_APP_QUERY (self), GS_APP_QUERY_TRISTATE_UNSET);
+
+ return self->is_featured;
+}
+
+/**
+ * gs_app_query_get_category:
+ * @self: a #GsAppQuery
+ *
+ * Get the value of #GsAppQuery:category.
+ *
+ * Returns: (nullable) (transfer none): a category which apps must be part of,
+ * or %NULL to not filter on category
+ * Since: 43
+ */
+GsCategory *
+gs_app_query_get_category (GsAppQuery *self)
+{
+ g_return_val_if_fail (GS_IS_APP_QUERY (self), NULL);
+
+ return self->category;
+}
+
+/**
+ * gs_app_query_get_is_installed:
+ * @self: a #GsAppQuery
+ *
+ * Get the value of #GsAppQuery:is-installed.
+ *
+ * Returns: %GS_APP_QUERY_TRISTATE_TRUE if apps must be installed,
+ * %GS_APP_QUERY_TRISTATE_FALSE if they must be not installed, or
+ * %GS_APP_QUERY_TRISTATE_UNSET if it doesn’t matter
+ * Since: 43
+ */
+GsAppQueryTristate
+gs_app_query_get_is_installed (GsAppQuery *self)
+{
+ g_return_val_if_fail (GS_IS_APP_QUERY (self), GS_APP_QUERY_TRISTATE_UNSET);
+
+ return self->is_installed;
+}
+
+/**
+ * gs_app_query_get_deployment_featured:
+ * @self: a #GsAppQuery
+ *
+ * Get the value of #GsAppQuery:deployment-featured.
+ *
+ * Returns: (nullable): a list of `GnomeSoftware::DeploymentFeatured` app keys,
+ * which the apps have set in a custom key, or %NULL to not filter on this
+ * Since: 43
+ */
+const gchar * const *
+gs_app_query_get_deployment_featured (GsAppQuery *self)
+{
+ g_return_val_if_fail (GS_IS_APP_QUERY (self), NULL);
+
+ /* Always return %NULL or a non-empty array */
+ g_assert (self->deployment_featured == NULL || self->deployment_featured[0] != NULL);
+
+ return (const gchar * const *) self->deployment_featured;
+}
+
+/**
+ * gs_app_query_get_developers:
+ * @self: a #GsAppQuery
+ *
+ * Get the value of #GsAppQuery:developers.
+ *
+ * Returns: (nullable): a list of developers who provide the apps,
+ * or %NULL to not filter by it
+ * Since: 43
+ */
+const gchar * const *
+gs_app_query_get_developers (GsAppQuery *self)
+{
+ g_return_val_if_fail (GS_IS_APP_QUERY (self), NULL);
+
+ /* Always return %NULL or a non-empty array */
+ g_assert (self->developers == NULL || self->developers[0] != NULL);
+
+ return (const gchar * const *) self->developers;
+}
+
+/**
+ * gs_app_query_get_keywords:
+ * @self: a #GsAppQuery
+ *
+ * Get the value of #GsAppQuery:keywords.
+ *
+ * Returns: a set of search keywords which apps must match, or %NULL to not
+ * filter by it
+ * Since: 43
+ */
+const gchar * const *
+gs_app_query_get_keywords (GsAppQuery *self)
+{
+ g_return_val_if_fail (GS_IS_APP_QUERY (self), NULL);
+
+ /* Always return %NULL or a non-empty array */
+ g_assert (self->keywords == NULL || self->keywords[0] != NULL);
+
+ return (const gchar * const *) self->keywords;
+}
+
+/**
+ * gs_app_query_get_alternate_of:
+ * @self: a #GsAppQuery
+ *
+ * Get the value of #GsAppQuery:alternate-of.
+ *
+ * Returns: (nullable) (transfer none): an app which apps must be related to,
+ * or %NULL to not filter on alternates
+ * Since: 43
+ */
+GsApp *
+gs_app_query_get_alternate_of (GsAppQuery *self)
+{
+ g_return_val_if_fail (GS_IS_APP_QUERY (self), NULL);
+
+ return self->alternate_of;
+}
+
+/**
+ * gs_app_query_get_provides:
+ * @self: a #GsAppQuery
+ * @out_provides_tag: (transfer none) (optional) (nullable) (out): return
+ * location for the value of #GsAppQuery:provides-tag, or %NULL to ignore
+ *
+ * Get the value of #GsAppQuery:provides-type and #GsAppQuery:provides-tag.
+ *
+ * Returns: the type of tag to filter on, or %GS_APP_QUERY_PROVIDES_UNKNOWN to
+ * not filter on provides
+ * Since: 43
+ */
+GsAppQueryProvidesType
+gs_app_query_get_provides (GsAppQuery *self,
+ const gchar **out_provides_tag)
+{
+ g_return_val_if_fail (GS_IS_APP_QUERY (self), GS_APP_QUERY_PROVIDES_UNKNOWN);
+
+ if (out_provides_tag != NULL)
+ *out_provides_tag = self->provides_tag;
+
+ return self->provides_type;
+}
diff --git a/lib/gs-app-query.h b/lib/gs-app-query.h
new file mode 100644
index 0000000..b66c56e
--- /dev/null
+++ b/lib/gs-app-query.h
@@ -0,0 +1,108 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2022 Endless OS Foundation LLC
+ *
+ * Author: Philip Withnall <pwithnall@endlessos.org>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#include <glib.h>
+#include <glib-object.h>
+#include <gio/gio.h>
+
+#include "gs-app-list.h"
+#include "gs-category.h"
+#include "gs-plugin-types.h"
+
+G_BEGIN_DECLS
+
+/**
+ * GsAppQueryTristate:
+ * @GS_APP_QUERY_TRISTATE_UNSET: Value is unset.
+ * @GS_APP_QUERY_TRISTATE_FALSE: False. Equal in value to %FALSE.
+ * @GS_APP_QUERY_TRISTATE_TRUE: True. Equal in value to %TRUE.
+ *
+ * A type for storing a boolean value which can also have an ‘unknown’ or
+ * ‘unset’ state.
+ *
+ * Within #GsAppQuery this is used for boolean query properties which are unset
+ * by default so that they don’t affect the query.
+ *
+ * Since: 43
+ */
+typedef enum
+{
+ GS_APP_QUERY_TRISTATE_UNSET = -1,
+ GS_APP_QUERY_TRISTATE_FALSE = 0,
+ GS_APP_QUERY_TRISTATE_TRUE = 1,
+} GsAppQueryTristate;
+
+/**
+ * GsAppQueryProvidesType:
+ * @GS_APP_QUERY_PROVIDES_UNKNOWN: Format is unknown and value is unset.
+ * @GS_APP_QUERY_PROVIDES_PACKAGE_NAME: A package name in whatever ID format is
+ * used natively by the current distro.
+ * @GS_APP_QUERY_PROVIDES_GSTREAMER: A GStreamer plugin name which the app must
+ * provide.
+ * @GS_APP_QUERY_PROVIDES_FONT: A font name which the app must provide.
+ * @GS_APP_QUERY_PROVIDES_MIME_HANDLER: A MIME type/content type which the app
+ * must support.
+ * @GS_APP_QUERY_PROVIDES_PS_DRIVER: A printer/PostScript driver which the app
+ * must provide.
+ * @GS_APP_QUERY_PROVIDES_PLASMA: A Plasma ID which the app must provide.
+ * (FIXME: It’s not really clear what this means, but it’s historically been
+ * supported.)
+ *
+ * A type for identifying the format or meaning of #GsAppQuery:provides-tag.
+ *
+ * This allows querying for apps which provide various types of functionality,
+ * such as printer drivers or fonts.
+ *
+ * Since: 43
+ */
+typedef enum {
+ GS_APP_QUERY_PROVIDES_UNKNOWN = 0,
+ GS_APP_QUERY_PROVIDES_PACKAGE_NAME,
+ GS_APP_QUERY_PROVIDES_GSTREAMER,
+ GS_APP_QUERY_PROVIDES_FONT,
+ GS_APP_QUERY_PROVIDES_MIME_HANDLER,
+ GS_APP_QUERY_PROVIDES_PS_DRIVER,
+ GS_APP_QUERY_PROVIDES_PLASMA,
+} GsAppQueryProvidesType;
+
+#define GS_TYPE_APP_QUERY (gs_app_query_get_type ())
+
+G_DECLARE_FINAL_TYPE (GsAppQuery, gs_app_query, GS, APP_QUERY, GObject)
+
+GsAppQuery *gs_app_query_new (const gchar *first_property_name,
+ ...) G_GNUC_NULL_TERMINATED;
+
+GsPluginRefineFlags gs_app_query_get_refine_flags (GsAppQuery *self);
+guint gs_app_query_get_max_results (GsAppQuery *self);
+GsAppListFilterFlags gs_app_query_get_dedupe_flags (GsAppQuery *self);
+GsAppListSortFunc gs_app_query_get_sort_func (GsAppQuery *self,
+ gpointer *user_data_out);
+GsAppListFilterFunc gs_app_query_get_filter_func (GsAppQuery *self,
+ gpointer *user_data_out);
+
+guint gs_app_query_get_n_properties_set (GsAppQuery *self);
+
+const gchar * const *gs_app_query_get_provides_files (GsAppQuery *self);
+GDateTime *gs_app_query_get_released_since (GsAppQuery *self);
+GsAppQueryTristate gs_app_query_get_is_curated (GsAppQuery *self);
+GsAppQueryTristate gs_app_query_get_is_featured (GsAppQuery *self);
+GsCategory *gs_app_query_get_category (GsAppQuery *self);
+GsAppQueryTristate gs_app_query_get_is_installed (GsAppQuery *self);
+const gchar * const *gs_app_query_get_deployment_featured
+ (GsAppQuery *self);
+const gchar * const *gs_app_query_get_developers (GsAppQuery *self);
+const gchar * const *gs_app_query_get_keywords (GsAppQuery *self);
+GsApp *gs_app_query_get_alternate_of (GsAppQuery *self);
+GsAppQueryProvidesType gs_app_query_get_provides (GsAppQuery *self,
+ const gchar **out_provides_tag);
+
+G_END_DECLS
diff --git a/lib/gs-app.c b/lib/gs-app.c
new file mode 100644
index 0000000..51215c7
--- /dev/null
+++ b/lib/gs-app.c
@@ -0,0 +1,6714 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2013-2016 Richard Hughes <richard@hughsie.com>
+ * Copyright (C) 2013 Matthias Clasen <mclasen@redhat.com>
+ * Copyright (C) 2014-2018 Kalev Lember <klember@redhat.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+/**
+ * SECTION:gs-app
+ * @title: GsApp
+ * @include: gnome-software.h
+ * @stability: Unstable
+ * @short_description: An application that is either installed or that can be installed
+ *
+ * For GsApps of kind %AS_COMPONENT_KIND_DESKTOP_APP, this object represents a 1:1 mapping
+ * to a .desktop file. The design is such so you can't have different GsApp's for different
+ * versions or architectures of a package. For other AppStream component types, GsApp maps
+ * their properties and %AS_COMPONENT_KIND_GENERIC is used if their type is a generic software
+ * component. For GNOME Software specific app-like entries, which don't correspond to desktop
+ * files or distinct software components, but e.g. represent a system update and its individual
+ * components, use the separate #GsAppSpecialKind enum and %gs_app_set_special_kind while setting
+ * the AppStream component-kind to generic.
+ *
+ * The #GsPluginLoader de-duplicates the GsApp instances that are produced by
+ * plugins to ensure that there is a single instance of GsApp for each id, making
+ * the id the primary key for this object. This ensures that actions triggered on
+ * a #GsApp in different parts of gnome-software can be observed by connecting to
+ * signals on the #GsApp.
+ *
+ * Information about other #GsApp objects can be stored in this object, for
+ * instance in the gs_app_add_related() method or gs_app_get_history().
+ */
+
+#include "config.h"
+
+#include <string.h>
+#include <gtk/gtk.h>
+#include <glib/gi18n.h>
+
+#include "gs-app-collation.h"
+#include "gs-app-private.h"
+#include "gs-desktop-data.h"
+#include "gs-enums.h"
+#include "gs-icon.h"
+#include "gs-key-colors.h"
+#include "gs-os-release.h"
+#include "gs-plugin.h"
+#include "gs-plugin-private.h"
+#include "gs-remote-icon.h"
+#include "gs-utils.h"
+
+typedef struct
+{
+ GMutex mutex;
+ gchar *id;
+ gchar *unique_id;
+ gboolean unique_id_valid;
+ gchar *branch;
+ gchar *name;
+ gchar *renamed_from;
+ GsAppQuality name_quality;
+ GPtrArray *icons; /* (nullable) (owned) (element-type AsIcon), sorted by pixel size, smallest first */
+ GPtrArray *sources;
+ GPtrArray *source_ids;
+ gchar *project_group;
+ gchar *developer_name;
+ gchar *agreement;
+ gchar *version;
+ gchar *version_ui;
+ gchar *summary;
+ GsAppQuality summary_quality;
+ gchar *summary_missing;
+ gchar *description;
+ GsAppQuality description_quality;
+ GPtrArray *screenshots;
+ GPtrArray *categories;
+ GArray *key_colors; /* (nullable) (element-type GdkRGBA) */
+ gboolean user_key_colors;
+ GHashTable *urls; /* (element-type AsUrlKind utf8) (owned) (nullable) */
+ GHashTable *launchables;
+ gchar *url_missing;
+ gchar *license;
+ GsAppQuality license_quality;
+ gchar **menu_path;
+ gchar *origin;
+ gchar *origin_ui;
+ gchar *origin_appstream;
+ gchar *origin_hostname;
+ gchar *update_version;
+ gchar *update_version_ui;
+ gchar *update_details_markup;
+ AsUrgencyKind update_urgency;
+ GsAppPermissions *update_permissions;
+ GWeakRef management_plugin_weak; /* (element-type GsPlugin) */
+ guint match_value;
+ guint priority;
+ gint rating;
+ GArray *review_ratings;
+ GPtrArray *reviews; /* of AsReview */
+ GPtrArray *provided; /* of AsProvided */
+
+ GsSizeType size_installed_type;
+ guint64 size_installed;
+ GsSizeType size_download_type;
+ guint64 size_download;
+ GsSizeType size_user_data_type;
+ guint64 size_user_data;
+ GsSizeType size_cache_data_type;
+ guint64 size_cache_data;
+
+ AsComponentKind kind;
+ GsAppSpecialKind special_kind;
+ GsAppState state;
+ GsAppState state_recover;
+ AsComponentScope scope;
+ AsBundleKind bundle_kind;
+ guint progress; /* integer 0–100 (inclusive), or %GS_APP_PROGRESS_UNKNOWN */
+ gboolean allow_cancel;
+ GHashTable *metadata;
+ GsAppList *addons;
+ GsAppList *related;
+ GsAppList *history;
+ guint64 install_date;
+ guint64 release_date;
+ guint64 kudos;
+ gboolean to_be_installed;
+ GsAppQuirk quirk;
+ gboolean license_is_free;
+ GsApp *runtime;
+ GFile *local_file;
+ AsContentRating *content_rating;
+ AsScreenshot *action_screenshot; /* (nullable) (owned) */
+ GCancellable *cancellable;
+ GsPluginAction pending_action;
+ GsAppPermissions *permissions;
+ gboolean is_update_downloaded;
+ GPtrArray *version_history; /* (element-type AsRelease) (nullable) (owned) */
+ GPtrArray *relations; /* (nullable) (element-type AsRelation) (owned) */
+ gboolean has_translations;
+} GsAppPrivate;
+
+typedef enum {
+ PROP_ID = 1,
+ PROP_NAME,
+ PROP_VERSION,
+ PROP_SUMMARY,
+ PROP_DESCRIPTION,
+ PROP_RATING,
+ PROP_KIND,
+ PROP_SPECIAL_KIND,
+ PROP_STATE,
+ PROP_PROGRESS,
+ PROP_CAN_CANCEL_INSTALLATION,
+ PROP_INSTALL_DATE,
+ PROP_RELEASE_DATE,
+ PROP_QUIRK,
+ PROP_PENDING_ACTION,
+ PROP_KEY_COLORS,
+ PROP_IS_UPDATE_DOWNLOADED,
+ PROP_URLS,
+ PROP_URL_MISSING,
+ PROP_CONTENT_RATING,
+ PROP_LICENSE,
+ PROP_SIZE_CACHE_DATA_TYPE,
+ PROP_SIZE_CACHE_DATA,
+ PROP_SIZE_DOWNLOAD_TYPE,
+ PROP_SIZE_DOWNLOAD,
+ PROP_SIZE_DOWNLOAD_DEPENDENCIES_TYPE,
+ PROP_SIZE_DOWNLOAD_DEPENDENCIES,
+ PROP_SIZE_INSTALLED_TYPE,
+ PROP_SIZE_INSTALLED,
+ PROP_SIZE_INSTALLED_DEPENDENCIES_TYPE,
+ PROP_SIZE_INSTALLED_DEPENDENCIES,
+ PROP_SIZE_USER_DATA_TYPE,
+ PROP_SIZE_USER_DATA,
+ PROP_PERMISSIONS,
+ PROP_RELATIONS,
+ PROP_ORIGIN_UI,
+ PROP_HAS_TRANSLATIONS,
+} GsAppProperty;
+
+static GParamSpec *obj_props[PROP_HAS_TRANSLATIONS + 1] = { NULL, };
+
+G_DEFINE_TYPE_WITH_PRIVATE (GsApp, gs_app, G_TYPE_OBJECT)
+
+static gboolean
+_g_set_str (gchar **str_ptr, const gchar *new_str)
+{
+ if (*str_ptr == new_str || g_strcmp0 (*str_ptr, new_str) == 0)
+ return FALSE;
+ g_free (*str_ptr);
+ *str_ptr = g_strdup (new_str);
+ return TRUE;
+}
+
+static gboolean
+_g_set_strv (gchar ***strv_ptr, gchar **new_strv)
+{
+ if (*strv_ptr == new_strv)
+ return FALSE;
+ g_strfreev (*strv_ptr);
+ *strv_ptr = g_strdupv (new_strv);
+ return TRUE;
+}
+
+static gboolean
+_g_set_ptr_array (GPtrArray **array_ptr, GPtrArray *new_array)
+{
+ if (*array_ptr == new_array)
+ return FALSE;
+ if (new_array != NULL)
+ g_ptr_array_ref (new_array);
+ if (*array_ptr != NULL)
+ g_ptr_array_unref (*array_ptr);
+ *array_ptr = new_array;
+ return TRUE;
+}
+
+static gboolean
+_g_set_array (GArray **array_ptr, GArray *new_array)
+{
+ if (*array_ptr == new_array)
+ return FALSE;
+ if (new_array != NULL)
+ g_array_ref (new_array);
+ if (*array_ptr != NULL)
+ g_array_unref (*array_ptr);
+ *array_ptr = new_array;
+ return TRUE;
+}
+
+/**
+ * gs_app_state_to_string:
+ * @state: the #GsAppState.
+ *
+ * Converts the enumerated value to an text representation.
+ *
+ * Returns: string version of @state, or %NULL for unknown
+ **/
+const gchar *
+gs_app_state_to_string (GsAppState state)
+{
+ if (state == GS_APP_STATE_UNKNOWN)
+ return "unknown";
+ if (state == GS_APP_STATE_INSTALLED)
+ return "installed";
+ if (state == GS_APP_STATE_AVAILABLE)
+ return "available";
+ if (state == GS_APP_STATE_PURCHASABLE)
+ return "purchasable";
+ if (state == GS_APP_STATE_PURCHASING)
+ return "purchasing";
+ if (state == GS_APP_STATE_AVAILABLE_LOCAL)
+ return "local";
+ if (state == GS_APP_STATE_QUEUED_FOR_INSTALL)
+ return "queued";
+ if (state == GS_APP_STATE_INSTALLING)
+ return "installing";
+ if (state == GS_APP_STATE_REMOVING)
+ return "removing";
+ if (state == GS_APP_STATE_UPDATABLE)
+ return "updatable";
+ if (state == GS_APP_STATE_UPDATABLE_LIVE)
+ return "updatable-live";
+ if (state == GS_APP_STATE_UNAVAILABLE)
+ return "unavailable";
+ if (state == GS_APP_STATE_PENDING_INSTALL)
+ return "pending-install";
+ if (state == GS_APP_STATE_PENDING_REMOVE)
+ return "pending-remove";
+ return NULL;
+}
+
+static void
+gs_app_kv_lpad (GString *str, const gchar *key, const gchar *value)
+{
+ gs_utils_append_key_value (str, 20, key, value);
+}
+
+static void
+gs_app_kv_size (GString *str,
+ const gchar *key,
+ GsSizeType size_type,
+ guint64 value)
+{
+ g_autofree gchar *tmp = NULL;
+
+ switch (size_type) {
+ case GS_SIZE_TYPE_UNKNOWN:
+ gs_app_kv_lpad (str, key, "unknown");
+ break;
+ case GS_SIZE_TYPE_UNKNOWABLE:
+ gs_app_kv_lpad (str, key, "unknowable");
+ break;
+ case GS_SIZE_TYPE_VALID:
+ tmp = g_format_size (value);
+ gs_app_kv_lpad (str, key, tmp);
+ break;
+ default:
+ g_assert_not_reached ();
+ }
+}
+
+G_GNUC_PRINTF (3, 4)
+static void
+gs_app_kv_printf (GString *str, const gchar *key, const gchar *fmt, ...)
+{
+ va_list args;
+ g_autofree gchar *tmp = NULL;
+ va_start (args, fmt);
+ tmp = g_strdup_vprintf (fmt, args);
+ va_end (args);
+ gs_app_kv_lpad (str, key, tmp);
+}
+
+static const gchar *
+_as_component_quirk_flag_to_string (GsAppQuirk quirk)
+{
+ switch (quirk) {
+ case GS_APP_QUIRK_PROVENANCE:
+ return "provenance";
+ case GS_APP_QUIRK_COMPULSORY:
+ return "compulsory";
+ case GS_APP_QUIRK_HAS_SOURCE:
+ return "has-source";
+ case GS_APP_QUIRK_IS_WILDCARD:
+ return "is-wildcard";
+ case GS_APP_QUIRK_NEEDS_REBOOT:
+ return "needs-reboot";
+ case GS_APP_QUIRK_NOT_REVIEWABLE:
+ return "not-reviewable";
+ case GS_APP_QUIRK_NOT_LAUNCHABLE:
+ return "not-launchable";
+ case GS_APP_QUIRK_NEEDS_USER_ACTION:
+ return "needs-user-action";
+ case GS_APP_QUIRK_IS_PROXY:
+ return "is-proxy";
+ case GS_APP_QUIRK_REMOVABLE_HARDWARE:
+ return "removable-hardware";
+ case GS_APP_QUIRK_DEVELOPER_VERIFIED:
+ return "developer-verified";
+ case GS_APP_QUIRK_PARENTAL_FILTER:
+ return "parental-filter";
+ case GS_APP_QUIRK_NEW_PERMISSIONS:
+ return "new-permissions";
+ case GS_APP_QUIRK_PARENTAL_NOT_LAUNCHABLE:
+ return "parental-not-launchable";
+ case GS_APP_QUIRK_HIDE_FROM_SEARCH:
+ return "hide-from-search";
+ case GS_APP_QUIRK_HIDE_EVERYWHERE:
+ return "hide-everywhere";
+ case GS_APP_QUIRK_DO_NOT_AUTO_UPDATE:
+ return "do-not-auto-update";
+ default:
+ return NULL;
+ }
+}
+
+/* mutex must be held */
+static const gchar *
+gs_app_get_unique_id_unlocked (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+
+ /* invalid */
+ if (priv->id == NULL)
+ return NULL;
+
+ /* hmm, do what we can */
+ if (priv->unique_id == NULL || !priv->unique_id_valid) {
+ g_free (priv->unique_id);
+ priv->unique_id = gs_utils_build_unique_id (priv->scope,
+ priv->bundle_kind,
+ priv->origin,
+ priv->id,
+ priv->branch);
+ priv->unique_id_valid = TRUE;
+ }
+ return priv->unique_id;
+}
+
+/**
+ * gs_app_compare_priority:
+ * @app1: a #GsApp
+ * @app2: a #GsApp
+ *
+ * Compares two applications using their priority.
+ *
+ * Use `gs_plugin_add_rule(plugin,GS_PLUGIN_RULE_BETTER_THAN,"plugin-name")`
+ * to set the application priority values.
+ *
+ * Returns: a negative value if @app1 is less than @app2, a positive value if
+ * @app1 is greater than @app2, and zero if @app1 is equal to @app2
+ **/
+gint
+gs_app_compare_priority (GsApp *app1, GsApp *app2)
+{
+ GsAppPrivate *priv1 = gs_app_get_instance_private (app1);
+ GsAppPrivate *priv2 = gs_app_get_instance_private (app2);
+ guint prio1, prio2;
+
+ g_return_val_if_fail (GS_IS_APP (app1), 0);
+ g_return_val_if_fail (GS_IS_APP (app2), 0);
+
+ /* prefer prio */
+ prio1 = gs_app_get_priority (app1);
+ prio2 = gs_app_get_priority (app2);
+ if (prio1 > prio2)
+ return -1;
+ if (prio1 < prio2)
+ return 1;
+
+ /* fall back to bundle kind */
+ if (priv1->bundle_kind < priv2->bundle_kind)
+ return -1;
+ if (priv1->bundle_kind > priv2->bundle_kind)
+ return 1;
+ return 0;
+}
+
+/**
+ * gs_app_quirk_to_string:
+ * @quirk: a #GsAppQuirk
+ *
+ * Returns the quirk bitfield as a string.
+ *
+ * Returns: (transfer full): a string
+ **/
+static gchar *
+gs_app_quirk_to_string (GsAppQuirk quirk)
+{
+ GString *str = g_string_new ("");
+ guint64 i;
+
+ /* nothing set */
+ if (quirk == GS_APP_QUIRK_NONE) {
+ g_string_append (str, "none");
+ return g_string_free (str, FALSE);
+ }
+
+ /* get flags */
+ for (i = 1; i < GS_APP_QUIRK_LAST; i *= 2) {
+ if ((quirk & i) == 0)
+ continue;
+ g_string_append_printf (str, "%s,",
+ _as_component_quirk_flag_to_string (i));
+ }
+
+ /* nothing recognised */
+ if (str->len == 0) {
+ g_string_append (str, "unknown");
+ return g_string_free (str, FALSE);
+ }
+
+ /* remove trailing comma */
+ g_string_truncate (str, str->len - 1);
+ return g_string_free (str, FALSE);
+}
+
+static gchar *
+gs_app_kudos_to_string (guint64 kudos)
+{
+ g_autoptr(GPtrArray) array = g_ptr_array_new ();
+#pragma GCC diagnostic push
+#pragma GCC diagnostic ignored "-Wdiscarded-qualifiers"
+ if ((kudos & GS_APP_KUDO_MY_LANGUAGE) > 0)
+ g_ptr_array_add (array, "my-language");
+ if ((kudos & GS_APP_KUDO_RECENT_RELEASE) > 0)
+ g_ptr_array_add (array, "recent-release");
+ if ((kudos & GS_APP_KUDO_FEATURED_RECOMMENDED) > 0)
+ g_ptr_array_add (array, "featured-recommended");
+ if ((kudos & GS_APP_KUDO_MODERN_TOOLKIT) > 0)
+ g_ptr_array_add (array, "modern-toolkit");
+ if ((kudos & GS_APP_KUDO_SEARCH_PROVIDER) > 0)
+ g_ptr_array_add (array, "search-provider");
+ if ((kudos & GS_APP_KUDO_INSTALLS_USER_DOCS) > 0)
+ g_ptr_array_add (array, "installs-user-docs");
+ if ((kudos & GS_APP_KUDO_USES_NOTIFICATIONS) > 0)
+ g_ptr_array_add (array, "uses-notifications");
+ if ((kudos & GS_APP_KUDO_HAS_KEYWORDS) > 0)
+ g_ptr_array_add (array, "has-keywords");
+ if ((kudos & GS_APP_KUDO_HAS_SCREENSHOTS) > 0)
+ g_ptr_array_add (array, "has-screenshots");
+ if ((kudos & GS_APP_KUDO_HIGH_CONTRAST) > 0)
+ g_ptr_array_add (array, "high-contrast");
+ if ((kudos & GS_APP_KUDO_HI_DPI_ICON) > 0)
+ g_ptr_array_add (array, "hi-dpi-icon");
+ if ((kudos & GS_APP_KUDO_SANDBOXED) > 0)
+ g_ptr_array_add (array, "sandboxed");
+ if ((kudos & GS_APP_KUDO_SANDBOXED_SECURE) > 0)
+ g_ptr_array_add (array, "sandboxed-secure");
+#pragma GCC diagnostic pop
+ g_ptr_array_add (array, NULL);
+ return g_strjoinv ("|", (gchar **) array->pdata);
+}
+
+/**
+ * gs_app_to_string:
+ * @app: a #GsApp
+ *
+ * Converts the application to a string.
+ * This is not designed to serialize the object but to produce a string suitable
+ * for debugging.
+ *
+ * Returns: A multi-line string
+ *
+ * Since: 3.22
+ **/
+gchar *
+gs_app_to_string (GsApp *app)
+{
+ GString *str;
+
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+
+ str = g_string_new ("GsApp:");
+ gs_app_to_string_append (app, str);
+ if (str->len > 0)
+ g_string_truncate (str, str->len - 1);
+ return g_string_free (str, FALSE);
+}
+
+/**
+ * gs_app_to_string_append:
+ * @app: a #GsApp
+ * @str: a #GString
+ *
+ * Appends the application to an existing string.
+ *
+ * Since: 3.26
+ **/
+void
+gs_app_to_string_append (GsApp *app, GString *str)
+{
+ GsAppClass *klass;
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ AsImage *im;
+ GList *keys;
+ const gchar *tmp;
+ guint i;
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_autoptr(GsPlugin) management_plugin = NULL;
+ GsSizeType size_download_dependencies_type, size_installed_dependencies_type;
+ guint64 size_download_dependencies_bytes, size_installed_dependencies_bytes;
+
+ g_return_if_fail (GS_IS_APP (app));
+ g_return_if_fail (str != NULL);
+
+ klass = GS_APP_GET_CLASS (app);
+
+ locker = g_mutex_locker_new (&priv->mutex);
+
+ g_string_append_printf (str, " [%p]\n", app);
+ gs_app_kv_lpad (str, "kind", as_component_kind_to_string (priv->kind));
+ gs_app_kv_lpad (str, "state", gs_app_state_to_string (priv->state));
+ if (priv->quirk > 0) {
+ g_autofree gchar *qstr = gs_app_quirk_to_string (priv->quirk);
+ gs_app_kv_lpad (str, "quirk", qstr);
+ }
+ if (priv->progress == GS_APP_PROGRESS_UNKNOWN)
+ gs_app_kv_printf (str, "progress", "unknown");
+ else
+ gs_app_kv_printf (str, "progress", "%u%%", priv->progress);
+ if (priv->id != NULL)
+ gs_app_kv_lpad (str, "id", priv->id);
+ if (priv->unique_id != NULL)
+ gs_app_kv_lpad (str, "unique-id", priv->unique_id);
+ if (priv->scope != AS_COMPONENT_SCOPE_UNKNOWN)
+ gs_app_kv_lpad (str, "scope", as_component_scope_to_string (priv->scope));
+ if (priv->bundle_kind != AS_BUNDLE_KIND_UNKNOWN) {
+ gs_app_kv_lpad (str, "bundle-kind",
+ as_bundle_kind_to_string (priv->bundle_kind));
+ }
+ if (priv->kudos > 0) {
+ g_autofree gchar *kudo_str = NULL;
+ kudo_str = gs_app_kudos_to_string (priv->kudos);
+ gs_app_kv_lpad (str, "kudos", kudo_str);
+ }
+ gs_app_kv_printf (str, "kudo-percentage", "%u",
+ gs_app_get_kudos_percentage (app));
+ if (priv->name != NULL)
+ gs_app_kv_lpad (str, "name", priv->name);
+ if (priv->action_screenshot != NULL)
+ gs_app_kv_printf (str, "action-screenshot", "%p", priv->action_screenshot);
+ for (i = 0; priv->icons != NULL && i < priv->icons->len; i++) {
+ GIcon *icon = g_ptr_array_index (priv->icons, i);
+ g_autofree gchar *icon_str = g_icon_to_string (icon);
+ gs_app_kv_lpad (str, "icon", icon_str);
+ }
+ if (priv->match_value != 0)
+ gs_app_kv_printf (str, "match-value", "%05x", priv->match_value);
+ if (gs_app_get_priority (app) != 0)
+ gs_app_kv_printf (str, "priority", "%u", gs_app_get_priority (app));
+ if (priv->version != NULL)
+ gs_app_kv_lpad (str, "version", priv->version);
+ if (priv->version_ui != NULL)
+ gs_app_kv_lpad (str, "version-ui", priv->version_ui);
+ if (priv->update_version != NULL)
+ gs_app_kv_lpad (str, "update-version", priv->update_version);
+ if (priv->update_version_ui != NULL)
+ gs_app_kv_lpad (str, "update-version-ui", priv->update_version_ui);
+ if (priv->update_details_markup != NULL)
+ gs_app_kv_lpad (str, "update-details-markup", priv->update_details_markup);
+ if (priv->update_urgency != AS_URGENCY_KIND_UNKNOWN) {
+ gs_app_kv_printf (str, "update-urgency", "%u",
+ priv->update_urgency);
+ }
+ if (priv->summary != NULL)
+ gs_app_kv_lpad (str, "summary", priv->summary);
+ if (priv->description != NULL)
+ gs_app_kv_lpad (str, "description", priv->description);
+ for (i = 0; i < priv->screenshots->len; i++) {
+ AsScreenshot *ss = g_ptr_array_index (priv->screenshots, i);
+ g_autofree gchar *key = NULL;
+ tmp = as_screenshot_get_caption (ss);
+ im = as_screenshot_get_image (ss, 0, 0);
+ if (im == NULL)
+ continue;
+ key = g_strdup_printf ("screenshot-%02u", i);
+ gs_app_kv_printf (str, key, "%s [%s]",
+ as_image_get_url (im),
+ tmp != NULL ? tmp : "<none>");
+ }
+ for (i = 0; i < priv->sources->len; i++) {
+ g_autofree gchar *key = NULL;
+ tmp = g_ptr_array_index (priv->sources, i);
+ key = g_strdup_printf ("source-%02u", i);
+ gs_app_kv_lpad (str, key, tmp);
+ }
+ for (i = 0; i < priv->source_ids->len; i++) {
+ g_autofree gchar *key = NULL;
+ tmp = g_ptr_array_index (priv->source_ids, i);
+ key = g_strdup_printf ("source-id-%02u", i);
+ gs_app_kv_lpad (str, key, tmp);
+ }
+ if (priv->local_file != NULL) {
+ g_autofree gchar *fn = g_file_get_path (priv->local_file);
+ gs_app_kv_lpad (str, "local-filename", fn);
+ }
+ if (priv->content_rating != NULL) {
+ guint age = as_content_rating_get_minimum_age (priv->content_rating);
+ if (age != G_MAXUINT) {
+ g_autofree gchar *value = g_strdup_printf ("%u", age);
+ gs_app_kv_lpad (str, "content-age", value);
+ }
+ gs_app_kv_lpad (str, "content-rating",
+ as_content_rating_get_kind (priv->content_rating));
+ }
+ if (priv->urls != NULL) {
+ tmp = g_hash_table_lookup (priv->urls, GINT_TO_POINTER (AS_URL_KIND_HOMEPAGE));
+ if (tmp != NULL)
+ gs_app_kv_lpad (str, "url{homepage}", tmp);
+ }
+ keys = g_hash_table_get_keys (priv->launchables);
+ for (GList *l = keys; l != NULL; l = l->next) {
+ g_autofree gchar *key = NULL;
+ key = g_strdup_printf ("launchable{%s}", (const gchar *) l->data);
+ tmp = g_hash_table_lookup (priv->launchables, l->data);
+ gs_app_kv_lpad (str, key, tmp);
+ }
+ g_list_free (keys);
+ if (priv->license != NULL) {
+ gs_app_kv_lpad (str, "license", priv->license);
+ gs_app_kv_lpad (str, "license-is-free",
+ gs_app_get_license_is_free (app) ? "yes" : "no");
+ }
+ management_plugin = g_weak_ref_get (&priv->management_plugin_weak);
+ if (management_plugin != NULL)
+ gs_app_kv_lpad (str, "management-plugin", gs_plugin_get_name (management_plugin));
+ if (priv->summary_missing != NULL)
+ gs_app_kv_lpad (str, "summary-missing", priv->summary_missing);
+ if (priv->menu_path != NULL &&
+ priv->menu_path[0] != NULL &&
+ priv->menu_path[0][0] != '\0') {
+ g_autofree gchar *path = g_strjoinv (" → ", priv->menu_path);
+ gs_app_kv_lpad (str, "menu-path", path);
+ }
+ if (priv->branch != NULL)
+ gs_app_kv_lpad (str, "branch", priv->branch);
+ if (priv->origin != NULL && priv->origin[0] != '\0')
+ gs_app_kv_lpad (str, "origin", priv->origin);
+ if (priv->origin_ui != NULL && priv->origin_ui[0] != '\0')
+ gs_app_kv_lpad (str, "origin-ui", priv->origin_ui);
+ if (priv->origin_appstream != NULL && priv->origin_appstream[0] != '\0')
+ gs_app_kv_lpad (str, "origin-appstream", priv->origin_appstream);
+ if (priv->origin_hostname != NULL && priv->origin_hostname[0] != '\0')
+ gs_app_kv_lpad (str, "origin-hostname", priv->origin_hostname);
+ if (priv->rating != -1)
+ gs_app_kv_printf (str, "rating", "%i", priv->rating);
+ if (priv->review_ratings != NULL) {
+ for (i = 0; i < priv->review_ratings->len; i++) {
+ guint32 rat = g_array_index (priv->review_ratings, guint32, i);
+ gs_app_kv_printf (str, "review-rating", "[%u:%u]",
+ i, rat);
+ }
+ }
+ if (priv->reviews != NULL)
+ gs_app_kv_printf (str, "reviews", "%u", priv->reviews->len);
+ if (priv->provided != NULL) {
+ guint total = 0;
+ for (i = 0; i < priv->provided->len; i++)
+ total += as_provided_get_items (AS_PROVIDED (g_ptr_array_index (priv->provided, i)))->len;
+ gs_app_kv_printf (str, "provided", "%u", total);
+ }
+ if (priv->install_date != 0) {
+ gs_app_kv_printf (str, "install-date", "%"
+ G_GUINT64_FORMAT "",
+ priv->install_date);
+ }
+ if (priv->release_date != 0) {
+ gs_app_kv_printf (str, "release-date", "%"
+ G_GUINT64_FORMAT "",
+ priv->release_date);
+ }
+
+ gs_app_kv_size (str, "size-installed", priv->size_installed_type, priv->size_installed);
+ size_installed_dependencies_type = gs_app_get_size_installed_dependencies (app, &size_installed_dependencies_bytes);
+ gs_app_kv_size (str, "size-installed-dependencies", size_installed_dependencies_type, size_installed_dependencies_bytes);
+ gs_app_kv_size (str, "size-download", priv->size_download_type, priv->size_download);
+ size_download_dependencies_type = gs_app_get_size_download_dependencies (app, &size_download_dependencies_bytes);
+ gs_app_kv_size (str, "size-download-dependencies", size_download_dependencies_type, size_download_dependencies_bytes);
+ gs_app_kv_size (str, "size-cache-data", priv->size_cache_data_type, priv->size_cache_data);
+ gs_app_kv_size (str, "size-user-data", priv->size_user_data_type, priv->size_user_data);
+
+ for (i = 0; i < gs_app_list_length (priv->related); i++) {
+ GsApp *app_tmp = gs_app_list_index (priv->related, i);
+ const gchar *id = gs_app_get_unique_id (app_tmp);
+ if (id == NULL)
+ id = gs_app_get_source_default (app_tmp);
+ /* For example PackageKit can create apps without id */
+ if (id != NULL)
+ gs_app_kv_lpad (str, "related", id);
+ }
+ for (i = 0; i < gs_app_list_length (priv->history); i++) {
+ GsApp *app_tmp = gs_app_list_index (priv->history, i);
+ const gchar *id = gs_app_get_unique_id (app_tmp);
+ if (id == NULL)
+ id = gs_app_get_source_default (app_tmp);
+ /* For example PackageKit can create apps without id */
+ if (id != NULL)
+ gs_app_kv_lpad (str, "history", id);
+ }
+ for (i = 0; i < priv->categories->len; i++) {
+ tmp = g_ptr_array_index (priv->categories, i);
+ gs_app_kv_lpad (str, "category", tmp);
+ }
+ if (priv->user_key_colors)
+ gs_app_kv_lpad (str, "user-key-colors", "yes");
+ for (i = 0; priv->key_colors != NULL && i < priv->key_colors->len; i++) {
+ GdkRGBA *color = &g_array_index (priv->key_colors, GdkRGBA, i);
+ g_autofree gchar *key = NULL;
+ key = g_strdup_printf ("key-color-%02u", i);
+ gs_app_kv_printf (str, key, "%.0f,%.0f,%.0f",
+ color->red * 255.f,
+ color->green * 255.f,
+ color->blue * 255.f);
+ }
+ keys = g_hash_table_get_keys (priv->metadata);
+ for (GList *l = keys; l != NULL; l = l->next) {
+ GVariant *val;
+ const GVariantType *val_type;
+ g_autofree gchar *key = NULL;
+ g_autofree gchar *val_str = NULL;
+
+ key = g_strdup_printf ("{%s}", (const gchar *) l->data);
+ val = g_hash_table_lookup (priv->metadata, l->data);
+ val_type = g_variant_get_type (val);
+ if (g_variant_type_equal (val_type, G_VARIANT_TYPE_STRING)) {
+ val_str = g_variant_dup_string (val, NULL);
+ } else if (g_variant_type_equal (val_type, G_VARIANT_TYPE_BOOLEAN)) {
+ val_str = g_strdup (g_variant_get_boolean (val) ? "True" : "False");
+ } else if (g_variant_type_equal (val_type, G_VARIANT_TYPE_UINT32)) {
+ val_str = g_strdup_printf ("%" G_GUINT32_FORMAT,
+ g_variant_get_uint32 (val));
+ } else {
+ val_str = g_strdup_printf ("unknown type of %s",
+ g_variant_get_type_string (val));
+ }
+ gs_app_kv_lpad (str, key, val_str);
+ }
+ g_list_free (keys);
+
+ for (i = 0; priv->relations != NULL && i < priv->relations->len; i++) {
+ AsRelation *relation = g_ptr_array_index (priv->relations, i);
+ gs_app_kv_printf (str, "relation", "%s, %s",
+ as_relation_kind_to_string (as_relation_get_kind (relation)),
+ as_relation_item_kind_to_string (as_relation_get_item_kind (relation)));
+ }
+
+ /* add subclassed info */
+ if (klass->to_string != NULL)
+ klass->to_string (app, str);
+
+ /* print runtime data too */
+ if (priv->runtime != NULL) {
+ g_string_append (str, "\n\tRuntime:\n\t");
+ gs_app_to_string_append (priv->runtime, str);
+ }
+ g_string_append_printf (str, "\n");
+}
+
+typedef struct {
+ GsApp *app;
+ GParamSpec *pspec;
+} AppNotifyData;
+
+static gboolean
+notify_idle_cb (gpointer data)
+{
+ AppNotifyData *notify_data = data;
+
+ g_object_notify_by_pspec (G_OBJECT (notify_data->app), notify_data->pspec);
+
+ g_object_unref (notify_data->app);
+ g_free (notify_data);
+
+ return G_SOURCE_REMOVE;
+}
+
+static void
+gs_app_queue_notify (GsApp *app, GParamSpec *pspec)
+{
+ AppNotifyData *notify_data;
+
+ notify_data = g_new (AppNotifyData, 1);
+ notify_data->app = g_object_ref (app);
+ notify_data->pspec = pspec;
+
+ g_idle_add (notify_idle_cb, notify_data);
+}
+
+/**
+ * gs_app_get_id:
+ * @app: a #GsApp
+ *
+ * Gets the application ID.
+ *
+ * Returns: The whole ID, e.g. "gimp.desktop"
+ *
+ * Since: 3.22
+ **/
+const gchar *
+gs_app_get_id (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ return priv->id;
+}
+
+/**
+ * gs_app_set_id:
+ * @app: a #GsApp
+ * @id: a application ID, e.g. "gimp.desktop"
+ *
+ * Sets the application ID.
+ */
+void
+gs_app_set_id (GsApp *app, const gchar *id)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+ locker = g_mutex_locker_new (&priv->mutex);
+ if (_g_set_str (&priv->id, id))
+ priv->unique_id_valid = FALSE;
+}
+
+/**
+ * gs_app_get_scope:
+ * @app: a #GsApp
+ *
+ * Gets the scope of the application.
+ *
+ * Returns: the #AsComponentScope, e.g. %AS_COMPONENT_SCOPE_USER
+ *
+ * Since: 40
+ **/
+AsComponentScope
+gs_app_get_scope (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), AS_COMPONENT_SCOPE_UNKNOWN);
+ return priv->scope;
+}
+
+/**
+ * gs_app_set_scope:
+ * @app: a #GsApp
+ * @scope: a #AsComponentScope, e.g. %AS_COMPONENT_SCOPE_SYSTEM
+ *
+ * This sets the scope of the application.
+ *
+ * Since: 40
+ **/
+void
+gs_app_set_scope (GsApp *app, AsComponentScope scope)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+
+ g_return_if_fail (GS_IS_APP (app));
+
+ /* same */
+ if (scope == priv->scope)
+ return;
+
+ priv->scope = scope;
+
+ /* no longer valid */
+ priv->unique_id_valid = FALSE;
+}
+
+/**
+ * gs_app_get_bundle_kind:
+ * @app: a #GsApp
+ *
+ * Gets the bundle kind of the application.
+ *
+ * Returns: the #AsComponentScope, e.g. %AS_BUNDLE_KIND_FLATPAK
+ *
+ * Since: 40
+ **/
+AsBundleKind
+gs_app_get_bundle_kind (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), AS_BUNDLE_KIND_UNKNOWN);
+ return priv->bundle_kind;
+}
+
+/**
+ * gs_app_set_bundle_kind:
+ * @app: a #GsApp
+ * @bundle_kind: a #AsComponentScope, e.g. AS_BUNDLE_KIND_FLATPAK
+ *
+ * This sets the bundle kind of the application.
+ *
+ * Since: 40
+ **/
+void
+gs_app_set_bundle_kind (GsApp *app, AsBundleKind bundle_kind)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+
+ g_return_if_fail (GS_IS_APP (app));
+
+ /* same */
+ if (bundle_kind == priv->bundle_kind)
+ return;
+
+ priv->bundle_kind = bundle_kind;
+
+ /* no longer valid */
+ priv->unique_id_valid = FALSE;
+}
+
+/**
+ * gs_app_get_special_kind:
+ * @app: a #GsApp
+ *
+ * Gets the special occupation of the application.
+ *
+ * Returns: the #GsAppSpecialKind, e.g. %GS_APP_SPECIAL_KIND_OS_UPDATE
+ *
+ * Since: 40
+ **/
+GsAppSpecialKind
+gs_app_get_special_kind (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), GS_APP_SPECIAL_KIND_NONE);
+ return priv->special_kind;
+}
+
+/**
+ * gs_app_set_special_kind:
+ * @app: a #GsApp
+ * @kind: a #GsAppSpecialKind, e.g. %GS_APP_SPECIAL_KIND_OS_UPDATE
+ *
+ * This sets the special occupation of the application (making
+ * the #AsComponentKind of this application %AS_COMPONENT_KIND_GENERIC
+ * per definition).
+ *
+ * Since: 40
+ **/
+void
+gs_app_set_special_kind (GsApp *app, GsAppSpecialKind kind)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_if_fail (GS_IS_APP (app));
+
+ if (priv->special_kind == kind)
+ return;
+ gs_app_set_kind (app, AS_COMPONENT_KIND_GENERIC);
+ priv->special_kind = kind;
+ gs_app_queue_notify (app, obj_props[PROP_SPECIAL_KIND]);
+}
+
+/**
+ * gs_app_get_state:
+ * @app: a #GsApp
+ *
+ * Gets the state of the application.
+ *
+ * Returns: the #GsAppState, e.g. %GS_APP_STATE_INSTALLED
+ *
+ * Since: 40
+ **/
+GsAppState
+gs_app_get_state (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), GS_APP_STATE_UNKNOWN);
+ return priv->state;
+}
+
+/**
+ * gs_app_get_progress:
+ * @app: a #GsApp
+ *
+ * Gets the percentage completion.
+ *
+ * Returns: the percentage completion (0–100 inclusive), or %GS_APP_PROGRESS_UNKNOWN for unknown
+ *
+ * Since: 3.22
+ **/
+guint
+gs_app_get_progress (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), GS_APP_PROGRESS_UNKNOWN);
+ return priv->progress;
+}
+
+/**
+ * gs_app_get_allow_cancel:
+ * @app: a #GsApp
+ *
+ * Gets whether the app's installation or upgrade can be cancelled.
+ *
+ * Returns: TRUE if cancellation is possible, FALSE otherwise.
+ *
+ * Since: 3.26
+ **/
+gboolean
+gs_app_get_allow_cancel (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), FALSE);
+ return priv->allow_cancel;
+}
+
+/**
+ * gs_app_set_state_recover:
+ * @app: a #GsApp
+ *
+ * Sets the application state to the last status value that was not
+ * transient.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_set_state_recover (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+
+ g_return_if_fail (GS_IS_APP (app));
+
+ if (priv->state_recover == GS_APP_STATE_UNKNOWN)
+ return;
+ if (priv->state_recover == priv->state)
+ return;
+
+ g_debug ("recovering state on %s from %s to %s",
+ priv->id,
+ gs_app_state_to_string (priv->state),
+ gs_app_state_to_string (priv->state_recover));
+
+ /* make sure progress gets reset when recovering state, to prevent
+ * confusing initial states when going through more than one attempt */
+ gs_app_set_progress (app, GS_APP_PROGRESS_UNKNOWN);
+
+ priv->state = priv->state_recover;
+ gs_app_queue_notify (app, obj_props[PROP_STATE]);
+}
+
+/* mutex must be held */
+static gboolean
+gs_app_set_state_internal (GsApp *app, GsAppState state)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ gboolean state_change_ok = FALSE;
+
+ /* same */
+ if (priv->state == state)
+ return FALSE;
+
+ /* check the state change is allowed */
+ switch (priv->state) {
+ case GS_APP_STATE_UNKNOWN:
+ /* unknown has to go into one of the stable states */
+ if (state == GS_APP_STATE_INSTALLED ||
+ state == GS_APP_STATE_QUEUED_FOR_INSTALL ||
+ state == GS_APP_STATE_AVAILABLE ||
+ state == GS_APP_STATE_AVAILABLE_LOCAL ||
+ state == GS_APP_STATE_UPDATABLE ||
+ state == GS_APP_STATE_UPDATABLE_LIVE ||
+ state == GS_APP_STATE_UNAVAILABLE ||
+ state == GS_APP_STATE_PENDING_INSTALL ||
+ state == GS_APP_STATE_PENDING_REMOVE)
+ state_change_ok = TRUE;
+ break;
+ case GS_APP_STATE_INSTALLED:
+ /* installed has to go into an action state */
+ if (state == GS_APP_STATE_UNKNOWN ||
+ state == GS_APP_STATE_REMOVING ||
+ state == GS_APP_STATE_UNAVAILABLE ||
+ state == GS_APP_STATE_UPDATABLE ||
+ state == GS_APP_STATE_UPDATABLE_LIVE)
+ state_change_ok = TRUE;
+ break;
+ case GS_APP_STATE_QUEUED_FOR_INSTALL:
+ if (state == GS_APP_STATE_UNKNOWN ||
+ state == GS_APP_STATE_INSTALLING ||
+ state == GS_APP_STATE_AVAILABLE)
+ state_change_ok = TRUE;
+ break;
+ case GS_APP_STATE_AVAILABLE:
+ /* available has to go into an action state */
+ if (state == GS_APP_STATE_UNKNOWN ||
+ state == GS_APP_STATE_QUEUED_FOR_INSTALL ||
+ state == GS_APP_STATE_INSTALLING)
+ state_change_ok = TRUE;
+ break;
+ case GS_APP_STATE_INSTALLING:
+ /* installing has to go into an stable state */
+ if (state == GS_APP_STATE_UNKNOWN ||
+ state == GS_APP_STATE_INSTALLED ||
+ state == GS_APP_STATE_UPDATABLE ||
+ state == GS_APP_STATE_UPDATABLE_LIVE ||
+ state == GS_APP_STATE_AVAILABLE ||
+ state == GS_APP_STATE_PENDING_INSTALL)
+ state_change_ok = TRUE;
+ break;
+ case GS_APP_STATE_REMOVING:
+ /* removing has to go into an stable state */
+ if (state == GS_APP_STATE_UNKNOWN ||
+ state == GS_APP_STATE_UNAVAILABLE ||
+ state == GS_APP_STATE_AVAILABLE ||
+ state == GS_APP_STATE_INSTALLED ||
+ state == GS_APP_STATE_PENDING_REMOVE)
+ state_change_ok = TRUE;
+ break;
+ case GS_APP_STATE_UPDATABLE:
+ /* updatable has to go into an action state */
+ if (state == GS_APP_STATE_UNKNOWN ||
+ state == GS_APP_STATE_REMOVING ||
+ state == GS_APP_STATE_INSTALLING)
+ state_change_ok = TRUE;
+ break;
+ case GS_APP_STATE_UPDATABLE_LIVE:
+ /* updatable-live has to go into an action state */
+ if (state == GS_APP_STATE_UNKNOWN ||
+ state == GS_APP_STATE_REMOVING ||
+ state == GS_APP_STATE_INSTALLING)
+ state_change_ok = TRUE;
+ break;
+ case GS_APP_STATE_UNAVAILABLE:
+ /* updatable has to go into an action state */
+ if (state == GS_APP_STATE_UNKNOWN ||
+ state == GS_APP_STATE_AVAILABLE)
+ state_change_ok = TRUE;
+ break;
+ case GS_APP_STATE_AVAILABLE_LOCAL:
+ /* local has to go into an action state */
+ if (state == GS_APP_STATE_UNKNOWN ||
+ state == GS_APP_STATE_QUEUED_FOR_INSTALL ||
+ state == GS_APP_STATE_INSTALLING)
+ state_change_ok = TRUE;
+ break;
+ case GS_APP_STATE_PENDING_INSTALL:
+ case GS_APP_STATE_PENDING_REMOVE:
+ state_change_ok = TRUE;
+ break;
+ default:
+ g_warning ("state %s unhandled",
+ gs_app_state_to_string (priv->state));
+ g_assert_not_reached ();
+ }
+
+ /* this state change was unexpected */
+ if (!state_change_ok) {
+ g_warning ("State change on %s (%s) from %s to %s is not OK",
+ gs_app_get_unique_id_unlocked (app),
+ priv->name,
+ gs_app_state_to_string (priv->state),
+ gs_app_state_to_string (state));
+ }
+
+ priv->state = state;
+
+ if (state == GS_APP_STATE_UNKNOWN ||
+ state == GS_APP_STATE_AVAILABLE_LOCAL ||
+ state == GS_APP_STATE_AVAILABLE)
+ priv->install_date = 0;
+
+ /* save this to simplify error handling in the plugins */
+ switch (state) {
+ case GS_APP_STATE_INSTALLING:
+ case GS_APP_STATE_REMOVING:
+ case GS_APP_STATE_QUEUED_FOR_INSTALL:
+ /* transient, so ignore */
+ break;
+ default:
+ if (priv->state_recover != state)
+ priv->state_recover = state;
+ break;
+ }
+
+ return TRUE;
+}
+
+/**
+ * gs_app_set_progress:
+ * @app: a #GsApp
+ * @percentage: a percentage progress (0–100 inclusive), or %GS_APP_PROGRESS_UNKNOWN
+ *
+ * This sets the progress completion of the application. Use
+ * %GS_APP_PROGRESS_UNKNOWN if the progress is unknown or has a wide confidence
+ * interval.
+ *
+ * If called more than once with the same value then subsequent calls
+ * will be ignored.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_set_progress (GsApp *app, guint percentage)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+ locker = g_mutex_locker_new (&priv->mutex);
+ if (priv->progress == percentage)
+ return;
+ if (percentage != GS_APP_PROGRESS_UNKNOWN && percentage > 100) {
+ g_warning ("cannot set %u%% for %s, setting instead: 100%%",
+ percentage, gs_app_get_unique_id_unlocked (app));
+ percentage = 100;
+ }
+ priv->progress = percentage;
+ gs_app_queue_notify (app, obj_props[PROP_PROGRESS]);
+}
+
+/**
+ * gs_app_set_allow_cancel:
+ * @app: a #GsApp
+ * @allow_cancel: if the installation or upgrade can be cancelled or not
+ *
+ * This sets a flag indicating whether the operation can be cancelled or not.
+ * This is used by the UI to set the "Cancel" button insensitive as
+ * appropriate.
+ *
+ * Since: 3.26
+ **/
+void
+gs_app_set_allow_cancel (GsApp *app, gboolean allow_cancel)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+ locker = g_mutex_locker_new (&priv->mutex);
+ if (priv->allow_cancel == allow_cancel)
+ return;
+ priv->allow_cancel = allow_cancel;
+ gs_app_queue_notify (app, obj_props[PROP_CAN_CANCEL_INSTALLATION]);
+}
+
+static void
+gs_app_set_pending_action_internal (GsApp *app,
+ GsPluginAction action)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ if (priv->pending_action == action)
+ return;
+
+ priv->pending_action = action;
+ gs_app_queue_notify (app, obj_props[PROP_PENDING_ACTION]);
+}
+
+/**
+ * gs_app_set_state:
+ * @app: a #GsApp
+ * @state: a #GsAppState, e.g. GS_APP_STATE_UPDATABLE_LIVE
+ *
+ * This sets the state of the application.
+ * The following state diagram explains the typical states.
+ * All applications start in state %GS_APP_STATE_UNKNOWN,
+ * but the frontend is not supposed to see GsApps with this state.
+ *
+ * Plugins are responsible for changing the state to one of the other
+ * states before the GsApp is passed to the frontend.
+ *
+ * |[
+ * UPDATABLE --> INSTALLING --> INSTALLED
+ * UPDATABLE --> REMOVING --> AVAILABLE
+ * INSTALLED --> REMOVING --> AVAILABLE
+ * AVAILABLE --> INSTALLING --> INSTALLED
+ * AVAILABLE <--> QUEUED --> INSTALLING --> INSTALLED
+ * UNKNOWN --> UNAVAILABLE
+ * ]|
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_set_state (GsApp *app, GsAppState state)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+
+ locker = g_mutex_locker_new (&priv->mutex);
+
+ if (gs_app_set_state_internal (app, state)) {
+ /* since the state changed, and the pending-action refers to
+ * actions that usually change the state, we assign it to the
+ * appropriate action here */
+ GsPluginAction action = GS_PLUGIN_ACTION_UNKNOWN;
+ if (priv->state == GS_APP_STATE_QUEUED_FOR_INSTALL) {
+ if (priv->kind == AS_COMPONENT_KIND_REPOSITORY)
+ action = GS_PLUGIN_ACTION_INSTALL_REPO;
+ else
+ action = GS_PLUGIN_ACTION_INSTALL;
+ }
+ gs_app_set_pending_action_internal (app, action);
+
+ gs_app_queue_notify (app, obj_props[PROP_STATE]);
+ }
+}
+
+/**
+ * gs_app_get_kind:
+ * @app: a #GsApp
+ *
+ * Gets the kind of the application.
+ *
+ * Returns: the #AsComponentKind, e.g. %AS_COMPONENT_KIND_UNKNOWN
+ *
+ * Since: 40
+ **/
+AsComponentKind
+gs_app_get_kind (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), AS_COMPONENT_KIND_UNKNOWN);
+ return priv->kind;
+}
+
+/**
+ * gs_app_set_kind:
+ * @app: a #GsApp
+ * @kind: a #AsComponentKind, e.g. #AS_COMPONENT_KIND_DESKTOP_APP
+ *
+ * This sets the kind of the application.
+ * The following state diagram explains the typical states.
+ * All applications start with kind %AS_COMPONENT_KIND_UNKNOWN.
+ *
+ * |[
+ * PACKAGE --> NORMAL
+ * PACKAGE --> SYSTEM
+ * NORMAL --> SYSTEM
+ * ]|
+ *
+ * Since: 40
+ **/
+void
+gs_app_set_kind (GsApp *app, AsComponentKind kind)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ gboolean state_change_ok = FALSE;
+ g_autoptr(GMutexLocker) locker = NULL;
+
+ g_return_if_fail (GS_IS_APP (app));
+
+ locker = g_mutex_locker_new (&priv->mutex);
+
+ /* same */
+ if (priv->kind == kind)
+ return;
+
+ /* trying to change */
+ if (priv->kind != AS_COMPONENT_KIND_UNKNOWN &&
+ kind == AS_COMPONENT_KIND_UNKNOWN) {
+ g_warning ("automatically prevented from changing "
+ "kind on %s from %s to %s!",
+ gs_app_get_unique_id_unlocked (app),
+ as_component_kind_to_string (priv->kind),
+ as_component_kind_to_string (kind));
+ return;
+ }
+
+ /* check the state change is allowed */
+ switch (priv->kind) {
+ case AS_COMPONENT_KIND_UNKNOWN:
+ case AS_COMPONENT_KIND_GENERIC:
+ /* all others derive from generic */
+ state_change_ok = TRUE;
+ break;
+ case AS_COMPONENT_KIND_DESKTOP_APP:
+ /* desktop has to be reset to override */
+ if (kind == AS_COMPONENT_KIND_UNKNOWN)
+ state_change_ok = TRUE;
+ break;
+ default:
+ /* this can never change state */
+ break;
+ }
+
+ /* this state change was unexpected */
+ if (!state_change_ok) {
+ g_warning ("Kind change on %s from %s to %s is not OK",
+ priv->id,
+ as_component_kind_to_string (priv->kind),
+ as_component_kind_to_string (kind));
+ return;
+ }
+
+ priv->kind = kind;
+ gs_app_queue_notify (app, obj_props[PROP_KIND]);
+
+ /* no longer valid */
+ priv->unique_id_valid = FALSE;
+}
+
+/**
+ * gs_app_get_unique_id:
+ * @app: a #GsApp
+ *
+ * Gets the unique application ID used for de-duplication.
+ *
+ * The format is "&lt;scope&gt;/&lt;kind&gt;/&lt;origin&gt;/&lt;id&gt;/&lt;branch&gt;". Any unset fields will
+ * appear as "*". This string can be used with libappstream's functions for
+ * handling data IDs, e.g.
+ * https://www.freedesktop.org/software/appstream/docs/api/appstream-as-utils.html#as-utils-data-id-valid
+ *
+ * Returns: The unique ID, e.g. `system/flatpak/flathub/org.gnome.Notes/stable`, or %NULL
+ *
+ * Since: 3.22
+ **/
+const gchar *
+gs_app_get_unique_id (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ locker = g_mutex_locker_new (&priv->mutex);
+ return gs_app_get_unique_id_unlocked (app);
+}
+
+/**
+ * gs_app_set_unique_id:
+ * @app: a #GsApp
+ * @unique_id: a unique application ID, e.g. `user/fedora/\*\/gimp.desktop/\*`
+ *
+ * Sets the unique application ID used for de-duplication. See
+ * gs_app_get_unique_id() for information about the format. Normally you should
+ * not have to use this function since the unique ID can be constructed from
+ * other fields, but it can be useful for unit tests.
+ */
+void
+gs_app_set_unique_id (GsApp *app, const gchar *unique_id)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+
+ locker = g_mutex_locker_new (&priv->mutex);
+
+ /* check for sanity */
+ if (!as_utils_data_id_valid (unique_id))
+ g_warning ("unique_id %s not valid", unique_id);
+
+ g_free (priv->unique_id);
+ priv->unique_id = g_strdup (unique_id);
+ priv->unique_id_valid = TRUE;
+}
+
+/**
+ * gs_app_get_name:
+ * @app: a #GsApp
+ *
+ * Gets the application name.
+ *
+ * Returns: a string, or %NULL for unset
+ *
+ * Since: 3.22
+ **/
+const gchar *
+gs_app_get_name (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ return priv->name;
+}
+
+/**
+ * gs_app_set_name:
+ * @app: a #GsApp
+ * @quality: A #GsAppQuality, e.g. %GS_APP_QUALITY_LOWEST
+ * @name: The short localized name, e.g. "Calculator"
+ *
+ * Sets the application name.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_set_name (GsApp *app, GsAppQuality quality, const gchar *name)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+
+ locker = g_mutex_locker_new (&priv->mutex);
+
+ /* only save this if the data is sufficiently high quality */
+ if (quality < priv->name_quality)
+ return;
+ priv->name_quality = quality;
+ if (_g_set_str (&priv->name, name))
+ gs_app_queue_notify (app, obj_props[PROP_NAME]);
+}
+
+/**
+ * gs_app_get_renamed_from:
+ * @app: a #GsApp
+ *
+ * Gets the old human-readable name of an application that's being renamed, the
+ * same name that was returned by gs_app_get_name() before the rename.
+ *
+ * Returns: (nullable): a string, or %NULL for unset
+ *
+ * Since: 40
+ **/
+const gchar *
+gs_app_get_renamed_from (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ return priv->renamed_from;
+}
+
+/**
+ * gs_app_set_renamed_from:
+ * @app: a #GsApp
+ * @renamed_from: (nullable): The old name, e.g. "Iagno"
+ *
+ * Sets the old name of an application that's being renamed
+ *
+ * Since: 40
+ **/
+void
+gs_app_set_renamed_from (GsApp *app, const gchar *renamed_from)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+ locker = g_mutex_locker_new (&priv->mutex);
+ _g_set_str (&priv->renamed_from, renamed_from);
+}
+
+/**
+ * gs_app_get_branch:
+ * @app: a #GsApp
+ *
+ * Gets the application branch.
+ *
+ * Returns: a string, or %NULL for unset
+ *
+ * Since: 3.22
+ **/
+const gchar *
+gs_app_get_branch (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ return priv->branch;
+}
+
+/**
+ * gs_app_set_branch:
+ * @app: a #GsApp
+ * @branch: The branch, e.g. "master"
+ *
+ * Sets the application branch.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_set_branch (GsApp *app, const gchar *branch)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+ locker = g_mutex_locker_new (&priv->mutex);
+ if (_g_set_str (&priv->branch, branch))
+ priv->unique_id_valid = FALSE;
+}
+
+/**
+ * gs_app_get_source_default:
+ * @app: a #GsApp
+ *
+ * Gets the default source.
+ *
+ * Returns: a string, or %NULL
+ *
+ * Since: 3.22
+ **/
+const gchar *
+gs_app_get_source_default (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ if (priv->sources->len == 0)
+ return NULL;
+ return g_ptr_array_index (priv->sources, 0);
+}
+
+/**
+ * gs_app_add_source:
+ * @app: a #GsApp
+ * @source: a source name
+ *
+ * Adds a source name for the application.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_add_source (GsApp *app, const gchar *source)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ const gchar *tmp;
+ guint i;
+ g_autoptr(GMutexLocker) locker = NULL;
+
+ g_return_if_fail (GS_IS_APP (app));
+ g_return_if_fail (source != NULL);
+
+ locker = g_mutex_locker_new (&priv->mutex);
+
+ /* check source doesn't already exist */
+ for (i = 0; i < priv->sources->len; i++) {
+ tmp = g_ptr_array_index (priv->sources, i);
+ if (g_strcmp0 (tmp, source) == 0)
+ return;
+ }
+ g_ptr_array_add (priv->sources, g_strdup (source));
+}
+
+/**
+ * gs_app_get_sources:
+ * @app: a #GsApp
+ *
+ * Gets the list of sources for the application.
+ *
+ * Returns: (element-type utf8) (transfer none): a list
+ *
+ * Since: 3.22
+ **/
+GPtrArray *
+gs_app_get_sources (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ return priv->sources;
+}
+
+/**
+ * gs_app_set_sources:
+ * @app: a #GsApp
+ * @sources: The non-localized short names, e.g. ["gnome-calculator"]
+ *
+ * This name is used for the update page if the application is collected into
+ * the 'OS Updates' group.
+ * It is typically the package names, although this should not be relied upon.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_set_sources (GsApp *app, GPtrArray *sources)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+ locker = g_mutex_locker_new (&priv->mutex);
+ _g_set_ptr_array (&priv->sources, sources);
+}
+
+/**
+ * gs_app_get_source_id_default:
+ * @app: a #GsApp
+ *
+ * Gets the default source ID.
+ *
+ * Returns: a string, or %NULL for unset
+ *
+ * Since: 3.22
+ **/
+const gchar *
+gs_app_get_source_id_default (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ if (priv->source_ids->len == 0)
+ return NULL;
+ return g_ptr_array_index (priv->source_ids, 0);
+}
+
+/**
+ * gs_app_get_source_ids:
+ * @app: a #GsApp
+ *
+ * Gets the list of source IDs.
+ *
+ * Returns: (element-type utf8) (transfer none): a list
+ *
+ * Since: 3.22
+ **/
+GPtrArray *
+gs_app_get_source_ids (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ return priv->source_ids;
+}
+
+/**
+ * gs_app_clear_source_ids:
+ * @app: a #GsApp
+ *
+ * Clear the list of source IDs.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_clear_source_ids (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+ locker = g_mutex_locker_new (&priv->mutex);
+ g_ptr_array_set_size (priv->source_ids, 0);
+}
+
+/**
+ * gs_app_set_source_ids:
+ * @app: a #GsApp
+ * @source_ids: The source-id, e.g. ["gnome-calculator;0.134;fedora"]
+ * or ["/home/hughsie/.local/share/applications/0ad.desktop"]
+ *
+ * This ID is used internally to the controlling plugin.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_set_source_ids (GsApp *app, GPtrArray *source_ids)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+ locker = g_mutex_locker_new (&priv->mutex);
+ _g_set_ptr_array (&priv->source_ids, source_ids);
+}
+
+/**
+ * gs_app_add_source_id:
+ * @app: a #GsApp
+ * @source_id: a source ID, e.g. "gnome-calculator;0.134;fedora"
+ *
+ * Adds a source ID to the application.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_add_source_id (GsApp *app, const gchar *source_id)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ const gchar *tmp;
+ guint i;
+
+ g_return_if_fail (GS_IS_APP (app));
+ g_return_if_fail (source_id != NULL);
+
+ /* only add if not already present */
+ for (i = 0; i < priv->source_ids->len; i++) {
+ tmp = g_ptr_array_index (priv->source_ids, i);
+ if (g_strcmp0 (tmp, source_id) == 0)
+ return;
+ }
+ g_ptr_array_add (priv->source_ids, g_strdup (source_id));
+}
+
+/**
+ * gs_app_get_project_group:
+ * @app: a #GsApp
+ *
+ * Gets a project group for the application.
+ * Applications belonging to other project groups may not be shown in
+ * this software center.
+ *
+ * Returns: a string, or %NULL for unset
+ *
+ * Since: 3.22
+ **/
+const gchar *
+gs_app_get_project_group (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ return priv->project_group;
+}
+
+/**
+ * gs_app_get_developer_name:
+ * @app: a #GsApp
+ *
+ * Gets the developer name for the application.
+ *
+ * Returns: a string, or %NULL for unset
+ *
+ * Since: 3.22
+ **/
+const gchar *
+gs_app_get_developer_name (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ return priv->developer_name;
+}
+
+/**
+ * gs_app_set_project_group:
+ * @app: a #GsApp
+ * @project_group: The non-localized project group, e.g. "GNOME" or "KDE"
+ *
+ * Sets a project group for the application.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_set_project_group (GsApp *app, const gchar *project_group)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+ locker = g_mutex_locker_new (&priv->mutex);
+ _g_set_str (&priv->project_group, project_group);
+}
+
+/**
+ * gs_app_set_developer_name:
+ * @app: a #GsApp
+ * @developer_name: The developer name, e.g. "Richard Hughes"
+ *
+ * Sets a developer name for the application.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_set_developer_name (GsApp *app, const gchar *developer_name)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+ locker = g_mutex_locker_new (&priv->mutex);
+ _g_set_str (&priv->developer_name, developer_name);
+}
+
+/**
+ * gs_app_get_icon_for_size:
+ * @app: a #GsApp
+ * @size: size (width or height, square) of the icon to fetch, in device pixels
+ * @scale: scale of the icon to fetch, typically from gtk_widget_get_scale_factor()
+ * @fallback_icon_name: (nullable): name of an icon to load as a fallback if
+ * no other suitable one is found, or %NULL for no fallback
+ *
+ * Finds the most appropriate icon in the @app’s set of icons to be loaded at
+ * the given @size×@scale to represent the application. This might be provided
+ * by the backend at the given @size, or downsized from a larger icon provided
+ * by the backend. The return value is guaranteed to be suitable for loading as
+ * a pixbuf at @size, if it’s not %NULL.
+ *
+ * If an image at least @size pixels in width isn’t available, and
+ * @fallback_icon_name has not been provided, %NULL will be returned. If
+ * @fallback_icon_name has been provided, a #GIcon representing that will be
+ * returned, and %NULL is guaranteed not to be returned.
+ *
+ * Icons which come from a remote server (over HTTP or HTTPS) will be returned
+ * as a pointer into a local cache, which may not have been populated. You must
+ * call gs_remote_icon_ensure_cached() on icons of type #GsRemoteIcon to
+ * download them; this function will not do that for you.
+ *
+ * This function may do disk I/O or image resizing, but it will not do network
+ * I/O to load a pixbuf. It should be acceptable to call this from a UI thread.
+ *
+ * Returns: (transfer full) (nullable): a #GIcon, or %NULL
+ *
+ * Since: 40
+ */
+GIcon *
+gs_app_get_icon_for_size (GsApp *app,
+ guint size,
+ guint scale,
+ const gchar *fallback_icon_name)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ g_return_val_if_fail (size > 0, NULL);
+ g_return_val_if_fail (scale >= 1, NULL);
+
+ g_debug ("Looking for icon for %s, at size %u×%u, with fallback %s",
+ gs_app_get_id (app), size, scale, fallback_icon_name);
+
+ /* See if there’s an icon the right size, or the first one which is too
+ * big which could be scaled down. Note that the icons array may be
+ * lazily created. */
+ for (guint i = 0; priv->icons != NULL && i < priv->icons->len; i++) {
+ GIcon *icon = priv->icons->pdata[i];
+ g_autofree gchar *icon_str = g_icon_to_string (icon);
+ guint icon_width = gs_icon_get_width (icon);
+ guint icon_height = gs_icon_get_height (icon);
+ guint icon_scale = gs_icon_get_scale (icon);
+
+ g_debug ("\tConsidering icon of type %s (%s), width %u×%u",
+ G_OBJECT_TYPE_NAME (icon), icon_str, icon_width, icon_scale);
+
+ /* Appstream only guarantees the 64x64@1 cached icon is present, ignore other icons that aren't installed. */
+ if (G_IS_FILE_ICON (icon) && !(icon_width == 64 && icon_height == 64 && icon_scale == 1)) {
+ GFile *file = g_file_icon_get_file (G_FILE_ICON (icon));
+ if (!g_file_query_exists (file, NULL)) {
+ continue;
+ }
+ }
+
+ /* Ignore icons with unknown width and skip over ones which
+ * are too small. */
+ if (icon_width == 0 || icon_width * icon_scale < size * scale)
+ continue;
+
+ if (icon_width * icon_scale >= size * scale)
+ return g_object_ref (icon);
+ }
+
+ g_debug ("Found no icons of the right size; checking themed icons");
+
+ /* If there’s a themed icon with no width set, use that, as typically
+ * themed icons are available in all the right sizes. */
+ for (guint i = 0; priv->icons != NULL && i < priv->icons->len; i++) {
+ GIcon *icon = priv->icons->pdata[i];
+ guint icon_width = gs_icon_get_width (icon);
+
+ if (icon_width == 0 && G_IS_THEMED_ICON (icon))
+ return g_object_ref (icon);
+ }
+
+ if (scale > 1) {
+ g_debug ("Retrying at scale 1");
+ return gs_app_get_icon_for_size (app, size, 1, fallback_icon_name);
+ } else if (fallback_icon_name != NULL) {
+ g_debug ("Using fallback icon %s", fallback_icon_name);
+ return g_themed_icon_new (fallback_icon_name);
+ } else {
+ g_debug ("No icon found");
+ return NULL;
+ }
+}
+
+/**
+ * gs_app_get_action_screenshot:
+ * @app: a #GsApp
+ *
+ * Gets a screenshot for the pending user action.
+ *
+ * Returns: (transfer none) (nullable): a #AsScreenshot, or %NULL
+ *
+ * Since: 40
+ **/
+AsScreenshot *
+gs_app_get_action_screenshot (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ return priv->action_screenshot;
+}
+
+/**
+ * gs_app_get_icons:
+ * @app: a #GsApp
+ *
+ * Gets the icons for the application.
+ *
+ * This will never return an empty array; it will always return either %NULL or
+ * a non-empty array.
+ *
+ * Returns: (transfer none) (element-type GIcon) (nullable): an array of icons,
+ * or %NULL if there are no icons
+ *
+ * Since: 3.22
+ **/
+GPtrArray *
+gs_app_get_icons (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+
+ if (priv->icons != NULL && priv->icons->len == 0)
+ return NULL;
+
+ return priv->icons;
+}
+
+static gint
+icon_sort_width_cb (gconstpointer a,
+ gconstpointer b)
+{
+ GIcon *icon_a = *((GIcon **) a);
+ GIcon *icon_b = *((GIcon **) b);
+ guint width_a = gs_icon_get_width (icon_a);
+ guint width_b = gs_icon_get_width (icon_b);
+
+ /* Sort unknown widths (0 value) to the end. */
+ if (width_a == 0 && width_b == 0)
+ return 0;
+ else if (width_a == 0)
+ return 1;
+ else if (width_b == 0)
+ return -1;
+ else
+ return width_a - width_b;
+}
+
+/**
+ * gs_app_add_icon:
+ * @app: a #GsApp
+ * @icon: a #GIcon
+ *
+ * Adds an icon to use for the application.
+ * If the first icon added cannot be loaded then the next one is tried.
+ *
+ * Since: 40
+ **/
+void
+gs_app_add_icon (GsApp *app, GIcon *icon)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+ g_return_if_fail (G_IS_ICON (icon));
+
+ locker = g_mutex_locker_new (&priv->mutex);
+
+ if (priv->icons == NULL)
+ priv->icons = g_ptr_array_new_with_free_func ((GDestroyNotify) g_object_unref);
+
+ g_ptr_array_add (priv->icons, g_object_ref (icon));
+
+ /* Ensure the array is sorted by increasing width. */
+ g_ptr_array_sort (priv->icons, icon_sort_width_cb);
+}
+
+/**
+ * gs_app_remove_all_icons:
+ * @app: a #GsApp
+ *
+ * Remove all icons from @app.
+ *
+ * Since: 40
+ */
+void
+gs_app_remove_all_icons (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+ locker = g_mutex_locker_new (&priv->mutex);
+
+ if (priv->icons != NULL)
+ g_ptr_array_set_size (priv->icons, 0);
+}
+
+/**
+ * gs_app_get_agreement:
+ * @app: a #GsApp
+ *
+ * Gets the agreement text for the application.
+ *
+ * Returns: a string in AppStream description format, or %NULL for unset
+ *
+ * Since: 3.28
+ **/
+const gchar *
+gs_app_get_agreement (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ return priv->agreement;
+}
+
+/**
+ * gs_app_set_agreement:
+ * @app: a #GsApp
+ * @agreement: The agreement text, e.g. "<p>Foobar</p>"
+ *
+ * Sets the application end-user agreement (e.g. a EULA) in AppStream
+ * description format.
+ *
+ * Since: 3.28
+ **/
+void
+gs_app_set_agreement (GsApp *app, const gchar *agreement)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+ locker = g_mutex_locker_new (&priv->mutex);
+ _g_set_str (&priv->agreement, agreement);
+}
+
+/**
+ * gs_app_get_local_file:
+ * @app: a #GsApp
+ *
+ * Gets the file that backs this application, for instance this might
+ * be a local file in ~/Downloads that we are installing.
+ *
+ * Returns: (transfer none): a #GFile, or %NULL
+ *
+ * Since: 3.22
+ **/
+GFile *
+gs_app_get_local_file (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ return priv->local_file;
+}
+
+/**
+ * gs_app_set_local_file:
+ * @app: a #GsApp
+ * @local_file: a #GFile, or %NULL
+ *
+ * Sets the file that backs this application, for instance this might
+ * be a local file in ~/Downloads that we are installing.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_set_local_file (GsApp *app, GFile *local_file)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+ locker = g_mutex_locker_new (&priv->mutex);
+ g_set_object (&priv->local_file, local_file);
+}
+
+/**
+ * gs_app_dup_content_rating:
+ * @app: a #GsApp
+ *
+ * Gets the content rating for this application.
+ *
+ * Returns: (transfer full) (nullable): a #AsContentRating, or %NULL
+ *
+ * Since: 41
+ **/
+AsContentRating *
+gs_app_dup_content_rating (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ locker = g_mutex_locker_new (&priv->mutex);
+ return (priv->content_rating != NULL) ? g_object_ref (priv->content_rating) : NULL;
+}
+
+/**
+ * gs_app_set_content_rating:
+ * @app: a #GsApp
+ * @content_rating: a #AsContentRating, or %NULL
+ *
+ * Sets the content rating for this application.
+ *
+ * Since: 40
+ **/
+void
+gs_app_set_content_rating (GsApp *app, AsContentRating *content_rating)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+ locker = g_mutex_locker_new (&priv->mutex);
+ if (g_set_object (&priv->content_rating, content_rating))
+ gs_app_queue_notify (app, obj_props[PROP_CONTENT_RATING]);
+}
+
+/**
+ * gs_app_get_runtime:
+ * @app: a #GsApp
+ *
+ * Gets the runtime for the installed application.
+ *
+ * Returns: (transfer none): a #GsApp, or %NULL for unset
+ *
+ * Since: 3.22
+ **/
+GsApp *
+gs_app_get_runtime (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ return priv->runtime;
+}
+
+/**
+ * gs_app_set_runtime:
+ * @app: a #GsApp
+ * @runtime: a #GsApp
+ *
+ * Sets the runtime that the installed application requires.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_set_runtime (GsApp *app, GsApp *runtime)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+ g_return_if_fail (GS_IS_APP (runtime));
+ g_return_if_fail (app != runtime);
+ locker = g_mutex_locker_new (&priv->mutex);
+ g_set_object (&priv->runtime, runtime);
+
+ /* The runtime adds to the main app’s sizes. */
+ gs_app_queue_notify (app, obj_props[PROP_SIZE_DOWNLOAD_DEPENDENCIES_TYPE]);
+ gs_app_queue_notify (app, obj_props[PROP_SIZE_DOWNLOAD_DEPENDENCIES]);
+}
+
+/**
+ * gs_app_set_action_screenshot:
+ * @app: a #GsApp
+ * @action_screenshot: (transfer none) (nullable): a #AsScreenshot, or %NULL
+ *
+ * Sets a screenshot used to represent the action.
+ *
+ * Since: 40
+ **/
+void
+gs_app_set_action_screenshot (GsApp *app, AsScreenshot *action_screenshot)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+ locker = g_mutex_locker_new (&priv->mutex);
+ g_set_object (&priv->action_screenshot, action_screenshot);
+}
+
+typedef enum {
+ GS_APP_VERSION_FIXUP_RELEASE = 1,
+ GS_APP_VERSION_FIXUP_DISTRO_SUFFIX = 2,
+ GS_APP_VERSION_FIXUP_GIT_SUFFIX = 4,
+ GS_APP_VERSION_FIXUP_LAST,
+} GsAppVersionFixup;
+
+/**
+ * gs_app_get_ui_version:
+ *
+ * convert 1:1.6.2-7.fc17 into "Version 1.6.2"
+ **/
+static gchar *
+gs_app_get_ui_version (const gchar *version, guint64 flags)
+{
+ guint i;
+ gchar *new;
+ gchar *f;
+
+ /* nothing set */
+ if (version == NULL)
+ return NULL;
+
+ /* first remove any epoch */
+ for (i = 0; version[i] != '\0'; i++) {
+ if (version[i] == ':') {
+ version = &version[i+1];
+ break;
+ }
+ if (!g_ascii_isdigit (version[i]))
+ break;
+ }
+
+ /* then remove any distro suffix */
+ new = g_strdup (version);
+ if ((flags & GS_APP_VERSION_FIXUP_DISTRO_SUFFIX) > 0) {
+ f = g_strstr_len (new, -1, ".fc");
+ if (f != NULL)
+ *f= '\0';
+ f = g_strstr_len (new, -1, ".el");
+ if (f != NULL)
+ *f= '\0';
+ }
+
+ /* then remove any release */
+ if ((flags & GS_APP_VERSION_FIXUP_RELEASE) > 0) {
+ f = g_strrstr_len (new, -1, "-");
+ if (f != NULL)
+ *f= '\0';
+ }
+
+ /* then remove any git suffix */
+ if ((flags & GS_APP_VERSION_FIXUP_GIT_SUFFIX) > 0) {
+ f = g_strrstr_len (new, -1, ".2012");
+ if (f != NULL)
+ *f= '\0';
+ f = g_strrstr_len (new, -1, ".2013");
+ if (f != NULL)
+ *f= '\0';
+ }
+
+ return new;
+}
+
+static void
+gs_app_ui_versions_invalidate (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_free (priv->version_ui);
+ g_free (priv->update_version_ui);
+ priv->version_ui = NULL;
+ priv->update_version_ui = NULL;
+}
+
+static void
+gs_app_ui_versions_populate (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ guint i;
+ guint64 flags[] = { GS_APP_VERSION_FIXUP_RELEASE |
+ GS_APP_VERSION_FIXUP_DISTRO_SUFFIX |
+ GS_APP_VERSION_FIXUP_GIT_SUFFIX,
+ GS_APP_VERSION_FIXUP_DISTRO_SUFFIX |
+ GS_APP_VERSION_FIXUP_GIT_SUFFIX,
+ GS_APP_VERSION_FIXUP_DISTRO_SUFFIX,
+ 0 };
+
+ /* try each set of bitfields in order */
+ for (i = 0; flags[i] != 0; i++) {
+ priv->version_ui = gs_app_get_ui_version (priv->version, flags[i]);
+ priv->update_version_ui = gs_app_get_ui_version (priv->update_version, flags[i]);
+ if (g_strcmp0 (priv->version_ui, priv->update_version_ui) != 0) {
+ gs_app_queue_notify (app, obj_props[PROP_VERSION]);
+ return;
+ }
+ gs_app_ui_versions_invalidate (app);
+ }
+
+ /* we tried, but failed */
+ priv->version_ui = g_strdup (priv->version);
+ priv->update_version_ui = g_strdup (priv->update_version);
+}
+
+/**
+ * gs_app_get_version:
+ * @app: a #GsApp
+ *
+ * Gets the exact version for the application.
+ *
+ * Returns: a string, or %NULL for unset
+ *
+ * Since: 3.22
+ **/
+const gchar *
+gs_app_get_version (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ return priv->version;
+}
+
+/**
+ * gs_app_get_version_ui:
+ * @app: a #GsApp
+ *
+ * Gets a version string that can be displayed in a UI.
+ *
+ * Returns: a string, or %NULL for unset
+ *
+ * Since: 3.22
+ **/
+const gchar *
+gs_app_get_version_ui (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+
+ /* work out the two version numbers */
+ if (priv->version != NULL &&
+ priv->version_ui == NULL) {
+ gs_app_ui_versions_populate (app);
+ }
+
+ return priv->version_ui;
+}
+
+/**
+ * gs_app_set_version:
+ * @app: a #GsApp
+ * @version: The version, e.g. "2:1.2.3.fc19"
+ *
+ * This saves the version after stripping out any non-friendly parts, such as
+ * distro tags, git revisions and that kind of thing.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_set_version (GsApp *app, const gchar *version)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+
+ locker = g_mutex_locker_new (&priv->mutex);
+
+ if (_g_set_str (&priv->version, version)) {
+ gs_app_ui_versions_invalidate (app);
+ gs_app_queue_notify (app, obj_props[PROP_VERSION]);
+ }
+}
+
+/**
+ * gs_app_get_summary:
+ * @app: a #GsApp
+ *
+ * Gets the single-line description of the application.
+ *
+ * Returns: a string, or %NULL for unset
+ *
+ * Since: 3.22
+ **/
+const gchar *
+gs_app_get_summary (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ return priv->summary;
+}
+
+/**
+ * gs_app_set_summary:
+ * @app: a #GsApp
+ * @quality: a #GsAppQuality, e.g. %GS_APP_QUALITY_LOWEST
+ * @summary: a string, e.g. "A graphical calculator for GNOME"
+ *
+ * The medium length one-line localized name.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_set_summary (GsApp *app, GsAppQuality quality, const gchar *summary)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+
+ locker = g_mutex_locker_new (&priv->mutex);
+
+ /* only save this if the data is sufficiently high quality */
+ if (quality < priv->summary_quality)
+ return;
+ priv->summary_quality = quality;
+ if (_g_set_str (&priv->summary, summary))
+ gs_app_queue_notify (app, obj_props[PROP_SUMMARY]);
+}
+
+/**
+ * gs_app_get_description:
+ * @app: a #GsApp
+ *
+ * Gets the long multi-line description of the application.
+ *
+ * Returns: a string, or %NULL for unset
+ *
+ * Since: 3.22
+ **/
+const gchar *
+gs_app_get_description (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ return priv->description;
+}
+
+/**
+ * gs_app_set_description:
+ * @app: a #GsApp
+ * @quality: a #GsAppQuality, e.g. %GS_APP_QUALITY_LOWEST
+ * @description: a string, e.g. "GNOME Calculator is a graphical calculator for GNOME..."
+ *
+ * Sets the long multi-line description of the application.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_set_description (GsApp *app, GsAppQuality quality, const gchar *description)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+
+ locker = g_mutex_locker_new (&priv->mutex);
+
+ /* only save this if the data is sufficiently high quality */
+ if (quality < priv->description_quality)
+ return;
+ priv->description_quality = quality;
+ _g_set_str (&priv->description, description);
+}
+
+/**
+ * gs_app_get_url:
+ * @app: a #GsApp
+ * @kind: a #AsUrlKind, e.g. %AS_URL_KIND_HOMEPAGE
+ *
+ * Gets a web address of a specific type.
+ *
+ * Returns: (nullable): a string, or %NULL for unset
+ *
+ * Since: 40
+ **/
+const gchar *
+gs_app_get_url (GsApp *app, AsUrlKind kind)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ locker = g_mutex_locker_new (&priv->mutex);
+
+ if (priv->urls == NULL)
+ return NULL;
+ return g_hash_table_lookup (priv->urls, GINT_TO_POINTER (kind));
+}
+
+/**
+ * gs_app_set_url:
+ * @app: a #GsApp
+ * @kind: a #AsUrlKind, e.g. %AS_URL_KIND_HOMEPAGE
+ * @url: a web URL, e.g. "http://www.hughsie.com/"
+ *
+ * Sets a web address of a specific type.
+ *
+ * Since: 40
+ **/
+void
+gs_app_set_url (GsApp *app, AsUrlKind kind, const gchar *url)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+ locker = g_mutex_locker_new (&priv->mutex);
+
+ if (priv->urls == NULL)
+ priv->urls = g_hash_table_new_full (g_direct_hash, g_direct_equal,
+ NULL, g_free);
+
+ g_hash_table_insert (priv->urls,
+ GINT_TO_POINTER (kind),
+ g_strdup (url));
+
+ gs_app_queue_notify (app, obj_props[PROP_URLS]);
+}
+
+/**
+ * gs_app_get_url_missing:
+ * @app: a #GsApp
+ *
+ * Gets a web address for the application with explanations
+ * why it does not have an installation candidate.
+ *
+ * Returns: (nullable): a string, or %NULL for unset
+ *
+ * Since: 40
+ **/
+const gchar *
+gs_app_get_url_missing (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ locker = g_mutex_locker_new (&priv->mutex);
+ return priv->url_missing;
+}
+
+/**
+ * gs_app_set_url_missing:
+ * @app: a #GsApp
+ * @url: (nullable): a web URL, e.g. `http://www.packagekit.org/pk-package-not-found.html`, or %NULL
+ *
+ * Sets a web address containing explanations why this app
+ * does not have an installation candidate.
+ *
+ * Since: 40
+ **/
+void
+gs_app_set_url_missing (GsApp *app, const gchar *url)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+ locker = g_mutex_locker_new (&priv->mutex);
+
+ if (g_strcmp0 (priv->url_missing, url) == 0)
+ return;
+ g_free (priv->url_missing);
+ priv->url_missing = g_strdup (url);
+ gs_app_queue_notify (app, obj_props[PROP_URL_MISSING]);
+}
+
+/**
+ * gs_app_get_launchable:
+ * @app: a #GsApp
+ * @kind: a #AsLaunchableKind, e.g. %AS_LAUNCHABLE_KIND_DESKTOP_ID
+ *
+ * Gets a launchable of a specific type.
+ *
+ * Returns: a string, or %NULL for unset
+ *
+ * Since: 40
+ **/
+const gchar *
+gs_app_get_launchable (GsApp *app, AsLaunchableKind kind)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ locker = g_mutex_locker_new (&priv->mutex);
+ return g_hash_table_lookup (priv->launchables,
+ as_launchable_kind_to_string (kind));
+}
+
+/**
+ * gs_app_set_launchable:
+ * @app: a #GsApp
+ * @kind: a #AsLaunchableKind, e.g. %AS_LAUNCHABLE_KIND_DESKTOP_ID
+ * @launchable: a way to launch, e.g. "org.gnome.Sysprof2.desktop"
+ *
+ * Sets a launchable of a specific type.
+ *
+ * Since: 40
+ **/
+void
+gs_app_set_launchable (GsApp *app, AsLaunchableKind kind, const gchar *launchable)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ gpointer current_value = NULL;
+ const gchar *key;
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+ locker = g_mutex_locker_new (&priv->mutex);
+ key = as_launchable_kind_to_string (kind);
+ if (g_hash_table_lookup_extended (priv->launchables, key, NULL, &current_value)) {
+ if (g_strcmp0 ((const gchar *) current_value, launchable) != 0)
+ g_debug ("Preventing app '%s' replace of %s's launchable '%s' with '%s'",
+ priv->name, key, (const gchar *) current_value, launchable);
+ } else {
+ g_hash_table_insert (priv->launchables,
+ (gpointer) as_launchable_kind_to_string (kind),
+ g_strdup (launchable));
+ }
+}
+
+/**
+ * gs_app_get_license:
+ * @app: a #GsApp
+ *
+ * Gets the project license of the application.
+ *
+ * Returns: a string, or %NULL for unset
+ *
+ * Since: 3.22
+ **/
+const gchar *
+gs_app_get_license (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ return priv->license;
+}
+
+/**
+ * gs_app_get_license_is_free:
+ * @app: a #GsApp
+ *
+ * Returns if the application is free software.
+ *
+ * Returns: %TRUE if the application is free software
+ *
+ * Since: 3.22
+ **/
+gboolean
+gs_app_get_license_is_free (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), FALSE);
+ return priv->license_is_free;
+}
+
+/**
+ * gs_app_set_license:
+ * @app: a #GsApp
+ * @quality: a #GsAppQuality, e.g. %GS_APP_QUALITY_NORMAL
+ * @license: a SPDX license string, e.g. "GPL-3.0 AND LGPL-2.0+"
+ *
+ * Sets the project licenses used in the application.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_set_license (GsApp *app, GsAppQuality quality, const gchar *license)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+
+ g_return_if_fail (GS_IS_APP (app));
+
+ locker = g_mutex_locker_new (&priv->mutex);
+
+ /* only save this if the data is sufficiently high quality */
+ if (quality <= priv->license_quality)
+ return;
+ if (license == NULL)
+ return;
+ priv->license_quality = quality;
+
+ priv->license_is_free = as_license_is_free_license (license);
+
+ if (_g_set_str (&priv->license, license))
+ gs_app_queue_notify (app, obj_props[PROP_LICENSE]);
+}
+
+/**
+ * gs_app_get_summary_missing:
+ * @app: a #GsApp
+ *
+ * Gets the one-line summary to use when this application is missing.
+ *
+ * Returns: a string, or %NULL for unset
+ *
+ * Since: 3.22
+ **/
+const gchar *
+gs_app_get_summary_missing (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ return priv->summary_missing;
+}
+
+/**
+ * gs_app_set_summary_missing:
+ * @app: a #GsApp
+ * @summary_missing: a string, or %NULL
+ *
+ * Sets the one-line summary to use when this application is missing.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_set_summary_missing (GsApp *app, const gchar *summary_missing)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+ locker = g_mutex_locker_new (&priv->mutex);
+ _g_set_str (&priv->summary_missing, summary_missing);
+}
+
+static gboolean
+_gs_app_has_desktop_group (GsApp *app, const gchar *desktop_group)
+{
+ guint i;
+ g_auto(GStrv) split = g_strsplit (desktop_group, "::", -1);
+ for (i = 0; split[i] != NULL; i++) {
+ if (!gs_app_has_category (app, split[i]))
+ return FALSE;
+ }
+ return TRUE;
+}
+
+/**
+ * gs_app_get_menu_path:
+ * @app: a #GsApp
+ *
+ * Returns the menu path which is an array of path elements.
+ * The resulting array is an internal structure and must not be
+ * modified or freed.
+ *
+ * Returns: a %NULL-terminated array of strings
+ *
+ * Since: 3.22
+ **/
+gchar **
+gs_app_get_menu_path (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+
+ /* Lazy load. */
+ if (priv->menu_path == NULL) {
+ const gchar *strv[] = { "", NULL, NULL };
+ const GsDesktopData *msdata;
+ gboolean found = FALSE;
+
+ /* find a top level category the app has */
+ msdata = gs_desktop_get_data ();
+ for (gsize i = 0; !found && msdata[i].id != NULL; i++) {
+ const GsDesktopData *data = &msdata[i];
+ for (gsize j = 0; !found && data->mapping[j].id != NULL; j++) {
+ const GsDesktopMap *map = &data->mapping[j];
+ g_autofree gchar *msgctxt = NULL;
+
+ if (g_strcmp0 (map->id, "all") == 0)
+ continue;
+ if (g_strcmp0 (map->id, "featured") == 0)
+ continue;
+ msgctxt = g_strdup_printf ("Menu of %s", data->name);
+ for (gsize k = 0; !found && map->fdo_cats[k] != NULL; k++) {
+ const gchar *tmp = msdata[i].mapping[j].fdo_cats[k];
+ if (_gs_app_has_desktop_group (app, tmp)) {
+ strv[0] = g_dgettext (GETTEXT_PACKAGE, msdata[i].name);
+ strv[1] = g_dpgettext2 (GETTEXT_PACKAGE, msgctxt,
+ msdata[i].mapping[j].name);
+ found = TRUE;
+ break;
+ }
+ }
+ }
+ }
+
+ /* always set something to avoid keep searching for this */
+ gs_app_set_menu_path (app, (gchar **) strv);
+ }
+
+ return priv->menu_path;
+}
+
+/**
+ * gs_app_set_menu_path:
+ * @app: a #GsApp
+ * @menu_path: a %NULL-terminated array of strings
+ *
+ * Sets the new menu path. The menu path is an array of path elements.
+ * This function creates a deep copy of the path.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_set_menu_path (GsApp *app, gchar **menu_path)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+ locker = g_mutex_locker_new (&priv->mutex);
+ _g_set_strv (&priv->menu_path, menu_path);
+}
+
+/**
+ * gs_app_get_origin:
+ * @app: a #GsApp
+ *
+ * Gets the origin for the application, e.g. "fedora".
+ *
+ * Returns: a string, or %NULL for unset
+ *
+ * Since: 3.22
+ **/
+const gchar *
+gs_app_get_origin (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ return priv->origin;
+}
+
+/**
+ * gs_app_set_origin:
+ * @app: a #GsApp
+ * @origin: a string, or %NULL
+ *
+ * The origin is the original source of the application e.g. "fedora-updates"
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_set_origin (GsApp *app, const gchar *origin)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+
+ locker = g_mutex_locker_new (&priv->mutex);
+
+ /* same */
+ if (g_strcmp0 (origin, priv->origin) == 0)
+ return;
+
+ /* trying to change */
+ if (priv->origin != NULL && origin != NULL) {
+ g_warning ("automatically prevented from changing "
+ "origin on %s from %s to %s!",
+ gs_app_get_unique_id_unlocked (app),
+ priv->origin, origin);
+ return;
+ }
+
+ g_free (priv->origin);
+ priv->origin = g_strdup (origin);
+
+ /* no longer valid */
+ priv->unique_id_valid = FALSE;
+}
+
+/**
+ * gs_app_get_origin_appstream:
+ * @app: a #GsApp
+ *
+ * Gets the appstream origin for the application, e.g. "fedora".
+ *
+ * Returns: a string, or %NULL for unset
+ *
+ * Since: 3.28
+ **/
+const gchar *
+gs_app_get_origin_appstream (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ return priv->origin_appstream;
+}
+
+/**
+ * gs_app_set_origin_appstream:
+ * @app: a #GsApp
+ * @origin_appstream: a string, or %NULL
+ *
+ * The appstream origin is the appstream source of the application e.g. "fedora"
+ *
+ * Since: 3.28
+ **/
+void
+gs_app_set_origin_appstream (GsApp *app, const gchar *origin_appstream)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+
+ locker = g_mutex_locker_new (&priv->mutex);
+
+ /* same */
+ if (g_strcmp0 (origin_appstream, priv->origin_appstream) == 0)
+ return;
+
+ g_free (priv->origin_appstream);
+ priv->origin_appstream = g_strdup (origin_appstream);
+}
+
+/**
+ * gs_app_get_origin_hostname:
+ * @app: a #GsApp
+ *
+ * Gets the hostname of the origin used to install the application, e.g.
+ * "fedoraproject.org" or "sdk.gnome.org".
+ *
+ * Returns: a string, or %NULL for unset
+ *
+ * Since: 3.22
+ **/
+const gchar *
+gs_app_get_origin_hostname (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ return priv->origin_hostname;
+}
+
+/**
+ * gs_app_set_origin_hostname:
+ * @app: a #GsApp
+ * @origin_hostname: a string, or %NULL
+ *
+ * The origin is the hostname of the source used to install the application
+ * e.g. "fedoraproject.org"
+ *
+ * You can also use a full URL as @origin_hostname and this will be parsed and
+ * the hostname extracted. This process will also remove any unnecessary DNS
+ * prefixes like "download" or "mirrors".
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_set_origin_hostname (GsApp *app, const gchar *origin_hostname)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_autoptr(GUri) uri = NULL;
+ guint i;
+ const gchar *prefixes[] = { "download.", "mirrors.", NULL };
+
+ g_return_if_fail (GS_IS_APP (app));
+
+ locker = g_mutex_locker_new (&priv->mutex);
+
+ /* same */
+ if (g_strcmp0 (origin_hostname, priv->origin_hostname) == 0)
+ return;
+ g_free (priv->origin_hostname);
+
+ /* convert a URL */
+ uri = g_uri_parse (origin_hostname, SOUP_HTTP_URI_FLAGS, NULL);
+ if (uri != NULL)
+ origin_hostname = g_uri_get_host (uri);
+
+ /* remove some common prefixes */
+ for (i = 0; prefixes[i] != NULL; i++) {
+ if (g_str_has_prefix (origin_hostname, prefixes[i]))
+ origin_hostname += strlen (prefixes[i]);
+ }
+
+ /* fallback for localhost */
+ if (g_strcmp0 (origin_hostname, "") == 0)
+ origin_hostname = "localhost";
+
+ /* success */
+ priv->origin_hostname = g_strdup (origin_hostname);
+}
+
+/**
+ * gs_app_add_screenshot:
+ * @app: a #GsApp
+ * @screenshot: a #AsScreenshot
+ *
+ * Adds a screenshot to the application.
+ *
+ * Since: 40
+ **/
+void
+gs_app_add_screenshot (GsApp *app, AsScreenshot *screenshot)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+
+ g_return_if_fail (GS_IS_APP (app));
+ g_return_if_fail (AS_IS_SCREENSHOT (screenshot));
+
+ locker = g_mutex_locker_new (&priv->mutex);
+ g_ptr_array_add (priv->screenshots, g_object_ref (screenshot));
+}
+
+/**
+ * gs_app_get_screenshots:
+ * @app: a #GsApp
+ *
+ * Gets the list of screenshots.
+ *
+ * Returns: (element-type AsScreenshot) (transfer none): a list
+ *
+ * Since: 3.22
+ **/
+GPtrArray *
+gs_app_get_screenshots (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ return priv->screenshots;
+}
+
+/**
+ * gs_app_get_update_version:
+ * @app: a #GsApp
+ *
+ * Gets the newest update version.
+ *
+ * Returns: a string, or %NULL for unset
+ *
+ * Since: 3.22
+ **/
+const gchar *
+gs_app_get_update_version (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ return priv->update_version;
+}
+
+/**
+ * gs_app_get_update_version_ui:
+ * @app: a #GsApp
+ *
+ * Gets the update version for the UI.
+ *
+ * Returns: a string, or %NULL for unset
+ *
+ * Since: 3.22
+ **/
+const gchar *
+gs_app_get_update_version_ui (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+
+ /* work out the two version numbers */
+ if (priv->update_version != NULL &&
+ priv->update_version_ui == NULL) {
+ gs_app_ui_versions_populate (app);
+ }
+
+ return priv->update_version_ui;
+}
+
+static void
+gs_app_set_update_version_internal (GsApp *app, const gchar *update_version)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ if (_g_set_str (&priv->update_version, update_version))
+ gs_app_ui_versions_invalidate (app);
+}
+
+/**
+ * gs_app_set_update_version:
+ * @app: a #GsApp
+ * @update_version: a string, e.g. "0.1.2.3"
+ *
+ * Sets the new version number of the update.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_set_update_version (GsApp *app, const gchar *update_version)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+ locker = g_mutex_locker_new (&priv->mutex);
+ gs_app_set_update_version_internal (app, update_version);
+ gs_app_queue_notify (app, obj_props[PROP_VERSION]);
+}
+
+/**
+ * gs_app_get_update_details_markup:
+ * @app: a #GsApp
+ *
+ * Gets the multi-line description for the update as a Pango markup.
+ *
+ * Returns: a string, or %NULL for unset
+ *
+ * Since: 42.0
+ **/
+const gchar *
+gs_app_get_update_details_markup (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ return priv->update_details_markup;
+}
+
+/**
+ * gs_app_set_update_details_markup:
+ * @app: a #GsApp
+ * @markup: a Pango markup
+ *
+ * Sets the multi-line description for the update as markup.
+ *
+ * See: gs_app_set_update_details_text()
+ *
+ * Since: 42.0
+ **/
+void
+gs_app_set_update_details_markup (GsApp *app,
+ const gchar *markup)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+ locker = g_mutex_locker_new (&priv->mutex);
+ _g_set_str (&priv->update_details_markup, markup);
+}
+
+/**
+ * gs_app_set_update_details_text:
+ * @app: a #GsApp
+ * @text: a text without Pango markup
+ *
+ * Sets the multi-line description for the update as text,
+ * escaping the @text to be safe for a Pango markup.
+ *
+ * See: gs_app_set_update_details_markup()
+ *
+ * Since: 42.0
+ **/
+void
+gs_app_set_update_details_text (GsApp *app,
+ const gchar *text)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+ locker = g_mutex_locker_new (&priv->mutex);
+ if (text == NULL) {
+ _g_set_str (&priv->update_details_markup, NULL);
+ } else {
+ gchar *markup = g_markup_escape_text (text, -1);
+ g_free (priv->update_details_markup);
+ priv->update_details_markup = markup;
+ }
+}
+
+/**
+ * gs_app_get_update_urgency:
+ * @app: a #GsApp
+ *
+ * Gets the update urgency.
+ *
+ * Returns: a #AsUrgencyKind, or %AS_URGENCY_KIND_UNKNOWN for unset
+ *
+ * Since: 40
+ **/
+AsUrgencyKind
+gs_app_get_update_urgency (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), AS_URGENCY_KIND_UNKNOWN);
+ return priv->update_urgency;
+}
+
+/**
+ * gs_app_set_update_urgency:
+ * @app: a #GsApp
+ * @update_urgency: a #AsUrgencyKind
+ *
+ * Sets the update urgency.
+ *
+ * Since: 40
+ **/
+void
+gs_app_set_update_urgency (GsApp *app, AsUrgencyKind update_urgency)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_if_fail (GS_IS_APP (app));
+ if (update_urgency == priv->update_urgency)
+ return;
+ priv->update_urgency = update_urgency;
+}
+
+/**
+ * gs_app_dup_management_plugin:
+ * @app: a #GsApp
+ *
+ * Gets the management plugin.
+ *
+ * This is some metadata about the application which gives which plugin should
+ * handle the install, remove or upgrade actions.
+ *
+ * Returns: (nullable) (transfer full): the management plugin, or %NULL for unset
+ *
+ * Since: 42
+ **/
+GsPlugin *
+gs_app_dup_management_plugin (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ return g_weak_ref_get (&priv->management_plugin_weak);
+}
+
+/**
+ * gs_app_has_management_plugin:
+ * @app: a #GsApp
+ * @plugin: (nullable) (transfer none): a #GsPlugin to check against, or %NULL
+ *
+ * Check whether the management plugin for @app is set to @plugin.
+ *
+ * If @plugin is %NULL, %TRUE is returned only if the @app has no management
+ * plugin set.
+ *
+ * Returns: %TRUE if @plugin is the management plugin for @app, %FALSE otherwise
+ * Since: 42
+ */
+gboolean
+gs_app_has_management_plugin (GsApp *app,
+ GsPlugin *plugin)
+{
+ g_autoptr(GsPlugin) app_plugin = gs_app_dup_management_plugin (app);
+ return (app_plugin == plugin);
+}
+
+/**
+ * gs_app_set_management_plugin:
+ * @app: a #GsApp
+ * @management_plugin: (nullable) (transfer none): a plugin, or %NULL
+ *
+ * The management plugin is the plugin that can handle doing install and remove
+ * operations on the #GsApp.
+ *
+ * It is an error to attempt to change the management plugin once it has been
+ * previously set or to try to use this function on a wildcard application.
+ *
+ * Since: 42
+ **/
+void
+gs_app_set_management_plugin (GsApp *app,
+ GsPlugin *management_plugin)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_autoptr(GsPlugin) old_plugin = NULL;
+
+ g_return_if_fail (GS_IS_APP (app));
+ g_return_if_fail (management_plugin == NULL || GS_IS_PLUGIN (management_plugin));
+
+ locker = g_mutex_locker_new (&priv->mutex);
+
+ /* plugins cannot adopt wildcard packages */
+ if (gs_app_has_quirk (app, GS_APP_QUIRK_IS_WILDCARD)) {
+ g_warning ("plugins should not set the management plugin on "
+ "%s to %s -- create a new GsApp in refine()!",
+ gs_app_get_unique_id_unlocked (app),
+ (management_plugin != NULL) ? gs_plugin_get_name (management_plugin) : "(null)");
+ return;
+ }
+
+ /* same */
+ old_plugin = g_weak_ref_get (&priv->management_plugin_weak);
+
+ if (old_plugin == management_plugin)
+ return;
+
+ /* trying to change */
+ if (old_plugin != NULL && management_plugin != NULL) {
+ g_warning ("automatically prevented from changing "
+ "management plugin on %s from %s to %s!",
+ gs_app_get_unique_id_unlocked (app),
+ gs_plugin_get_name (old_plugin),
+ gs_plugin_get_name (management_plugin));
+ return;
+ }
+
+ g_weak_ref_set (&priv->management_plugin_weak, management_plugin);
+}
+
+/**
+ * gs_app_get_rating:
+ * @app: a #GsApp
+ *
+ * Gets the percentage rating of the application, where 100 is 5 stars.
+ *
+ * Returns: a percentage, or -1 for unset
+ *
+ * Since: 3.22
+ **/
+gint
+gs_app_get_rating (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), -1);
+ return priv->rating;
+}
+
+/**
+ * gs_app_set_rating:
+ * @app: a #GsApp
+ * @rating: a percentage, or -1 for invalid
+ *
+ * Gets the percentage rating of the application.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_set_rating (GsApp *app, gint rating)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+ locker = g_mutex_locker_new (&priv->mutex);
+ if (rating == priv->rating)
+ return;
+ priv->rating = rating;
+ gs_app_queue_notify (app, obj_props[PROP_RATING]);
+}
+
+/**
+ * gs_app_get_review_ratings:
+ * @app: a #GsApp
+ *
+ * Gets the review ratings.
+ *
+ * Returns: (element-type guint32) (transfer none): a list
+ *
+ * Since: 3.22
+ **/
+GArray *
+gs_app_get_review_ratings (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ return priv->review_ratings;
+}
+
+/**
+ * gs_app_set_review_ratings:
+ * @app: a #GsApp
+ * @review_ratings: (element-type guint32): a list
+ *
+ * Sets the review ratings.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_set_review_ratings (GsApp *app, GArray *review_ratings)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+ locker = g_mutex_locker_new (&priv->mutex);
+ _g_set_array (&priv->review_ratings, review_ratings);
+}
+
+/**
+ * gs_app_get_reviews:
+ * @app: a #GsApp
+ *
+ * Gets all the user-submitted reviews for the application.
+ *
+ * Returns: (element-type AsReview) (transfer none): the list of reviews
+ *
+ * Since: 3.22
+ **/
+GPtrArray *
+gs_app_get_reviews (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ return priv->reviews;
+}
+
+/**
+ * gs_app_add_review:
+ * @app: a #GsApp
+ * @review: a #AsReview
+ *
+ * Adds a user-submitted review to the application.
+ *
+ * Since: 40
+ **/
+void
+gs_app_add_review (GsApp *app, AsReview *review)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+ g_return_if_fail (AS_IS_REVIEW (review));
+ locker = g_mutex_locker_new (&priv->mutex);
+ g_ptr_array_add (priv->reviews, g_object_ref (review));
+}
+
+/**
+ * gs_app_remove_review:
+ * @app: a #GsApp
+ * @review: a #AsReview
+ *
+ * Removes a user-submitted review to the application.
+ *
+ * Since: 40
+ **/
+void
+gs_app_remove_review (GsApp *app, AsReview *review)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+ locker = g_mutex_locker_new (&priv->mutex);
+ g_ptr_array_remove (priv->reviews, review);
+}
+
+/**
+ * gs_app_get_provided:
+ * @app: a #GsApp
+ *
+ * Gets all the provided item sets for the application.
+ *
+ * Returns: (element-type AsProvided) (transfer none): the list of provided items
+ *
+ * Since: 40
+ **/
+GPtrArray*
+gs_app_get_provided (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ return priv->provided;
+}
+
+/**
+ * gs_app_get_provided_for_kind:
+ * @cpt: a #AsComponent instance.
+ * @kind: kind of the provided item, e.g. %AS_PROVIDED_KIND_MIMETYPE
+ *
+ * Get an #AsProvided object for the given interface type, or %NULL if
+ * none was found.
+ *
+ * Returns: (nullable) (transfer none): the #AsProvided
+ *
+ * Since: 40
+ */
+AsProvided*
+gs_app_get_provided_for_kind (GsApp *app, AsProvidedKind kind)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+
+ for (guint i = 0; i < priv->provided->len; i++) {
+ AsProvided *prov = AS_PROVIDED (g_ptr_array_index (priv->provided, i));
+ if (as_provided_get_kind (prov) == kind)
+ return prov;
+ }
+ return NULL;
+}
+
+/**
+ * gs_app_add_provided:
+ * @app: a #GsApp
+ * @kind: the kind of the provided item, e.g. %AS_PROVIDED_KIND_MEDIATYPE
+ * @item: the item to add.
+ *
+ * Adds a provided items of the given kind to the application.
+ *
+ * Since: 40
+ **/
+void
+gs_app_add_provided_item (GsApp *app, AsProvidedKind kind, const gchar *item)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ AsProvided *prov;
+ g_autoptr(GMutexLocker) locker = NULL;
+
+ g_return_if_fail (GS_IS_APP (app));
+ g_return_if_fail (item != NULL);
+ g_return_if_fail (kind != AS_PROVIDED_KIND_UNKNOWN && kind < AS_PROVIDED_KIND_LAST);
+
+ locker = g_mutex_locker_new (&priv->mutex);
+ prov = gs_app_get_provided_for_kind (app, kind);
+ if (prov == NULL) {
+ prov = as_provided_new ();
+ as_provided_set_kind (prov, kind);
+ g_ptr_array_add (priv->provided, prov);
+ }
+ as_provided_add_item (prov, item);
+}
+
+/**
+ * gs_app_get_size_download:
+ * @app: A #GsApp
+ * @size_bytes_out: (optional) (out caller-allocates): return location for
+ * the download size, in bytes, or %NULL to ignore
+ *
+ * Get the values of #GsApp:size-download-type and #GsApp:size-download.
+ *
+ * If this returns %GS_SIZE_TYPE_VALID, @size_bytes_out (if non-%NULL) will be
+ * set to the download size. Otherwise, its value will be undefined.
+ *
+ * Returns: type of the download size
+ * Since: 43
+ **/
+GsSizeType
+gs_app_get_size_download (GsApp *app,
+ guint64 *size_bytes_out)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+
+ g_return_val_if_fail (GS_IS_APP (app), GS_SIZE_TYPE_UNKNOWN);
+
+ if (size_bytes_out != NULL)
+ *size_bytes_out = (priv->size_download_type == GS_SIZE_TYPE_VALID) ? priv->size_download : 0;
+
+ return priv->size_download_type;
+}
+
+/**
+ * gs_app_set_size_download:
+ * @app: a #GsApp
+ * @size_type: type of the download size
+ * @size_bytes: size in bytes
+ *
+ * Sets the download size of the application, not including any
+ * required runtime.
+ *
+ * @size_bytes will be ignored unless @size_type is %GS_SIZE_TYPE_VALID.
+ *
+ * Since: 43
+ **/
+void
+gs_app_set_size_download (GsApp *app,
+ GsSizeType size_type,
+ guint64 size_bytes)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+
+ g_return_if_fail (GS_IS_APP (app));
+
+ if (size_type != GS_SIZE_TYPE_VALID)
+ size_bytes = 0;
+
+ if (priv->size_download_type != size_type) {
+ priv->size_download_type = size_type;
+ gs_app_queue_notify (app, obj_props[PROP_SIZE_DOWNLOAD_TYPE]);
+ }
+
+ if (priv->size_download != size_bytes) {
+ priv->size_download = size_bytes;
+ gs_app_queue_notify (app, obj_props[PROP_SIZE_DOWNLOAD]);
+ }
+}
+
+/* Add two sizes, accounting for their validity, and checking for overflow. This
+ * is essentially `out_bytes = a_bytes + b_bytes` with additional checking.
+ *
+ * If either of @a_type or @b_type is %GS_SIZE_TYPE_UNKNOWN or
+ * %GS_SIZE_TYPE_UNKNOWABLE, that type will be propagated to @out_type.
+ *
+ * If the sum of @a_bytes and @b_bytes exceeds %G_MAXUINT64, the result in
+ * @out_bytes will silently be clamped to %G_MAXUINT64.
+ *
+ * The lifetime of @app must be at least as long as the lifetime of
+ * @covered_uids, which allows us to avoid some string copies.
+ */
+static gboolean
+add_sizes (GsApp *app,
+ GHashTable *covered_uids,
+ GsSizeType a_type,
+ guint64 a_bytes,
+ GsSizeType b_type,
+ guint64 b_bytes,
+ GsSizeType *out_type,
+ guint64 *out_bytes)
+{
+ g_return_val_if_fail (out_type != NULL, FALSE);
+ g_return_val_if_fail (out_bytes != NULL, FALSE);
+
+ if (app != NULL && covered_uids != NULL) {
+ const gchar *id = gs_app_get_unique_id (app);
+ if (id != NULL &&
+ !g_hash_table_add (covered_uids, (gpointer) id))
+ return TRUE;
+ }
+
+ if (a_type == GS_SIZE_TYPE_VALID && b_type == GS_SIZE_TYPE_VALID) {
+ *out_type = GS_SIZE_TYPE_VALID;
+ if (!g_uint64_checked_add (out_bytes, a_bytes, b_bytes))
+ *out_bytes = G_MAXUINT64;
+ return TRUE;
+ }
+
+ *out_type = (a_type == GS_SIZE_TYPE_UNKNOWABLE || b_type == GS_SIZE_TYPE_UNKNOWABLE) ? GS_SIZE_TYPE_UNKNOWABLE : GS_SIZE_TYPE_UNKNOWN;
+ *out_bytes = 0;
+
+ return FALSE;
+}
+
+static GsSizeType
+get_size_download_dependencies (GsApp *app,
+ guint64 *size_bytes_out,
+ GHashTable *covered_uids)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ GsSizeType size_type = GS_SIZE_TYPE_VALID;
+ guint64 size_bytes = 0;
+
+ g_return_val_if_fail (GS_IS_APP (app), GS_SIZE_TYPE_UNKNOWN);
+
+ /* add the runtime if this is not installed */
+ if (priv->runtime != NULL &&
+ gs_app_get_state (priv->runtime) == GS_APP_STATE_AVAILABLE) {
+ GsSizeType runtime_size_download_type, runtime_size_download_dependencies_type;
+ guint64 runtime_size_download_bytes, runtime_size_download_dependencies_bytes;
+
+ runtime_size_download_type = gs_app_get_size_download (priv->runtime, &runtime_size_download_bytes);
+
+ if (add_sizes (priv->runtime, covered_uids,
+ size_type, size_bytes,
+ runtime_size_download_type, runtime_size_download_bytes,
+ &size_type, &size_bytes)) {
+ runtime_size_download_dependencies_type = get_size_download_dependencies (priv->runtime,
+ &runtime_size_download_dependencies_bytes,
+ covered_uids);
+
+ add_sizes (NULL, NULL,
+ size_type, size_bytes,
+ runtime_size_download_dependencies_type, runtime_size_download_dependencies_bytes,
+ &size_type, &size_bytes);
+ }
+ }
+
+ /* add related apps */
+ for (guint i = 0; i < gs_app_list_length (priv->related); i++) {
+ GsApp *app_related = gs_app_list_index (priv->related, i);
+ GsSizeType related_size_download_type, related_size_download_dependencies_type;
+ guint64 related_size_download_bytes, related_size_download_dependencies_bytes;
+
+ related_size_download_type = gs_app_get_size_download (app_related, &related_size_download_bytes);
+
+ if (!add_sizes (app_related, covered_uids,
+ size_type, size_bytes,
+ related_size_download_type, related_size_download_bytes,
+ &size_type, &size_bytes))
+ break;
+
+ related_size_download_dependencies_type = get_size_download_dependencies (app_related,
+ &related_size_download_dependencies_bytes,
+ covered_uids);
+
+ if (!add_sizes (NULL, NULL,
+ size_type, size_bytes,
+ related_size_download_dependencies_type, related_size_download_dependencies_bytes,
+ &size_type, &size_bytes))
+ break;
+ }
+
+ if (size_bytes_out != NULL)
+ *size_bytes_out = (size_type == GS_SIZE_TYPE_VALID) ? size_bytes : 0;
+
+ return size_type;
+}
+
+/**
+ * gs_app_get_size_download_dependencies:
+ * @app: A #GsApp
+ * @size_bytes_out: (optional) (out caller-allocates): return location for
+ * the download size of dependencies, in bytes, or %NULL to ignore
+ *
+ * Get the value of #GsApp:size-download-dependencies-type and
+ * #GsApp:size-download-dependencies.
+ *
+ * If this returns %GS_SIZE_TYPE_VALID, @size_bytes_out (if non-%NULL) will be
+ * set to the download size of dependencies. Otherwise, its value will be
+ * undefined.
+ *
+ * Returns: type of the download size of dependencies
+ * Since: 43
+ **/
+GsSizeType
+gs_app_get_size_download_dependencies (GsApp *app,
+ guint64 *size_bytes_out)
+{
+ g_autoptr(GHashTable) covered_uids = NULL;
+
+ g_return_val_if_fail (GS_IS_APP (app), GS_SIZE_TYPE_UNKNOWN);
+
+ covered_uids = g_hash_table_new_full ((GHashFunc) as_utils_data_id_hash, (GEqualFunc) as_utils_data_id_equal, NULL, NULL);
+
+ return get_size_download_dependencies (app, size_bytes_out, covered_uids);
+}
+
+/**
+ * gs_app_get_size_installed:
+ * @app: a #GsApp
+ * @size_bytes_out: (optional) (out caller-allocates): return location for
+ * the installed size, in bytes, or %NULL to ignore
+ *
+ * Get the values of #GsApp:size-installed-type and #GsApp:size-installed.
+ *
+ * If this returns %GS_SIZE_TYPE_VALID, @size_bytes_out (if non-%NULL) will be
+ * set to the installed size. Otherwise, its value will be undefined.
+ *
+ * Returns: type of the installed size
+ * Since: 43
+ **/
+GsSizeType
+gs_app_get_size_installed (GsApp *app,
+ guint64 *size_bytes_out)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+
+ g_return_val_if_fail (GS_IS_APP (app), GS_SIZE_TYPE_UNKNOWN);
+
+ if (size_bytes_out != NULL)
+ *size_bytes_out = (priv->size_installed_type == GS_SIZE_TYPE_VALID) ? priv->size_installed : 0;
+
+ return priv->size_installed_type;
+}
+
+/**
+ * gs_app_set_size_installed:
+ * @app: a #GsApp
+ * @size_type: type of the installed size
+ * @size_bytes: size in bytes
+ *
+ * Sets the installed size of the application.
+ *
+ * @size_bytes will be ignored unless @size_type is %GS_SIZE_TYPE_VALID.
+ *
+ * Since: 43
+ **/
+void
+gs_app_set_size_installed (GsApp *app,
+ GsSizeType size_type,
+ guint64 size_bytes)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+
+ g_return_if_fail (GS_IS_APP (app));
+
+ if (size_type != GS_SIZE_TYPE_VALID)
+ size_bytes = 0;
+
+ if (priv->size_installed_type != size_type) {
+ priv->size_installed_type = size_type;
+ gs_app_queue_notify (app, obj_props[PROP_SIZE_INSTALLED_TYPE]);
+ }
+
+ if (priv->size_installed != size_bytes) {
+ priv->size_installed = size_bytes;
+ gs_app_queue_notify (app, obj_props[PROP_SIZE_INSTALLED]);
+ }
+}
+
+static GsSizeType
+get_size_installed_dependencies (GsApp *app,
+ guint64 *size_bytes_out,
+ GHashTable *covered_uids)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ GsSizeType size_type = GS_SIZE_TYPE_VALID;
+ guint64 size_bytes = 0;
+
+ g_return_val_if_fail (GS_IS_APP (app), GS_SIZE_TYPE_UNKNOWN);
+
+ /* add related apps */
+ for (guint i = 0; i < gs_app_list_length (priv->related); i++) {
+ GsApp *app_related = gs_app_list_index (priv->related, i);
+ GsSizeType related_size_installed_type, related_size_installed_dependencies_type;
+ guint64 related_size_installed_bytes, related_size_installed_dependencies_bytes;
+
+ related_size_installed_type = gs_app_get_size_installed (app_related, &related_size_installed_bytes);
+
+ if (!add_sizes (app_related, covered_uids,
+ size_type, size_bytes,
+ related_size_installed_type, related_size_installed_bytes,
+ &size_type, &size_bytes))
+ break;
+
+ related_size_installed_dependencies_type = get_size_installed_dependencies (app_related,
+ &related_size_installed_dependencies_bytes,
+ covered_uids);
+
+ if (!add_sizes (NULL, NULL,
+ size_type, size_bytes,
+ related_size_installed_dependencies_type, related_size_installed_dependencies_bytes,
+ &size_type, &size_bytes))
+ break;
+ }
+
+ if (size_bytes_out != NULL)
+ *size_bytes_out = (size_type == GS_SIZE_TYPE_VALID) ? size_bytes : 0;
+
+ return size_type;
+}
+
+/**
+ * gs_app_get_size_installed_dependencies:
+ * @app: a #GsApp
+ * @size_bytes_out: (optional) (out caller-allocates): return location for
+ * the installed size of dependencies, in bytes, or %NULL to ignore
+ *
+ * Get the values of #GsApp:size-installed-dependencies-type and
+ * #GsApp:size-installed-dependencies.
+ *
+ * If this returns %GS_SIZE_TYPE_VALID, @size_bytes_out (if non-%NULL) will be
+ * set to the installed size of dependencies. Otherwise, its value will be
+ * undefined.
+ *
+ * Returns: type of the installed size of dependencies
+ * Since: 43
+ **/
+GsSizeType
+gs_app_get_size_installed_dependencies (GsApp *app,
+ guint64 *size_bytes_out)
+{
+ g_autoptr(GHashTable) covered_uids = NULL;
+
+ g_return_val_if_fail (GS_IS_APP (app), GS_SIZE_TYPE_UNKNOWN);
+
+ covered_uids = g_hash_table_new_full ((GHashFunc) as_utils_data_id_hash, (GEqualFunc) as_utils_data_id_equal, NULL, NULL);
+
+ return get_size_installed_dependencies (app, size_bytes_out, covered_uids);
+}
+
+/**
+ * gs_app_get_size_user_data:
+ * @app: A #GsApp
+ * @size_bytes_out: (optional) (out caller-allocates): return location for
+ * the user data size, in bytes, or %NULL to ignore
+ *
+ * Get the values of #GsApp:size-user-data-type and #GsApp:size-user-data.
+ *
+ * If this returns %GS_SIZE_TYPE_VALID, @size_bytes_out (if non-%NULL) will be
+ * set to the user data size. Otherwise, its value will be undefined.
+ *
+ * Returns: type of the user data size
+ * Since: 43
+ **/
+GsSizeType
+gs_app_get_size_user_data (GsApp *app,
+ guint64 *size_bytes_out)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+
+ g_return_val_if_fail (GS_IS_APP (app), GS_SIZE_TYPE_UNKNOWN);
+
+ if (size_bytes_out != NULL)
+ *size_bytes_out = (priv->size_user_data_type == GS_SIZE_TYPE_VALID) ? priv->size_user_data : 0;
+
+ return priv->size_user_data_type;
+}
+
+/**
+ * gs_app_set_size_user_data:
+ * @app: a #GsApp
+ * @size_type: type of the user data size
+ * @size_bytes: size in bytes
+ *
+ * Sets the user data size of the @app.
+ *
+ * @size_bytes will be ignored unless @size_type is %GS_SIZE_TYPE_VALID.
+ *
+ * Since: 43
+ **/
+void
+gs_app_set_size_user_data (GsApp *app,
+ GsSizeType size_type,
+ guint64 size_bytes)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+
+ g_return_if_fail (GS_IS_APP (app));
+
+ if (size_type != GS_SIZE_TYPE_VALID)
+ size_bytes = 0;
+
+ if (priv->size_user_data_type != size_type) {
+ priv->size_user_data_type = size_type;
+ gs_app_queue_notify (app, obj_props[PROP_SIZE_USER_DATA_TYPE]);
+ }
+
+ if (priv->size_user_data != size_bytes) {
+ priv->size_user_data = size_bytes;
+ gs_app_queue_notify (app, obj_props[PROP_SIZE_USER_DATA]);
+ }
+}
+
+/**
+ * gs_app_get_size_cache_data:
+ * @app: A #GsApp
+ * @size_bytes_out: (optional) (out caller-allocates): return location for
+ * the cache data size, in bytes, or %NULL to ignore
+ *
+ * Get the values of #GsApp:size-cache-data-type and #GsApp:size-cache-data.
+ *
+ * If this returns %GS_SIZE_TYPE_VALID, @size_bytes_out (if non-%NULL) will be
+ * set to the cache data size. Otherwise, its value will be undefined.
+ *
+ * Returns: type of the cache data size
+ * Since: 43
+ **/
+GsSizeType
+gs_app_get_size_cache_data (GsApp *app,
+ guint64 *size_bytes_out)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+
+ g_return_val_if_fail (GS_IS_APP (app), GS_SIZE_TYPE_UNKNOWN);
+
+ if (size_bytes_out != NULL)
+ *size_bytes_out = (priv->size_cache_data_type == GS_SIZE_TYPE_VALID) ? priv->size_cache_data : 0;
+
+ return priv->size_cache_data_type;
+}
+
+/**
+ * gs_app_set_size_cache_data:
+ * @app: a #GsApp
+ * @size_type: type of the cache data size
+ * @size_bytes: size in bytes
+ *
+ * Sets the cache data size of the @app.
+ *
+ * @size_bytes will be ignored unless @size_type is %GS_SIZE_TYPE_VALID.
+ *
+ * Since: 43
+ **/
+void
+gs_app_set_size_cache_data (GsApp *app,
+ GsSizeType size_type,
+ guint64 size_bytes)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+
+ g_return_if_fail (GS_IS_APP (app));
+
+ if (size_type != GS_SIZE_TYPE_VALID)
+ size_bytes = 0;
+
+ if (priv->size_cache_data_type != size_type) {
+ priv->size_cache_data_type = size_type;
+ gs_app_queue_notify (app, obj_props[PROP_SIZE_CACHE_DATA_TYPE]);
+ }
+
+ if (priv->size_cache_data != size_bytes) {
+ priv->size_cache_data = size_bytes;
+ gs_app_queue_notify (app, obj_props[PROP_SIZE_CACHE_DATA]);
+ }
+}
+
+/**
+ * gs_app_get_metadata_item:
+ * @app: a #GsApp
+ * @key: a string, e.g. "fwupd::device-id"
+ *
+ * Gets some metadata for the application.
+ * Is is expected that plugins namespace any plugin-specific metadata,
+ * for example `fwupd::device-id`.
+ *
+ * Returns: a string, or %NULL for unset
+ *
+ * Since: 3.22
+ **/
+const gchar *
+gs_app_get_metadata_item (GsApp *app, const gchar *key)
+{
+ GVariant *tmp;
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ g_return_val_if_fail (key != NULL, NULL);
+ tmp = gs_app_get_metadata_variant (app, key);
+ if (tmp == NULL)
+ return NULL;
+ return g_variant_get_string (tmp, NULL);
+}
+
+/**
+ * gs_app_set_metadata:
+ * @app: a #GsApp
+ * @key: a string, e.g. "fwupd::DeviceID"
+ * @value: a string, e.g. "fubar"
+ *
+ * Sets some metadata for the application.
+ * Is is expected that plugins namespace any plugin-specific metadata.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_set_metadata (GsApp *app, const gchar *key, const gchar *value)
+{
+ g_autoptr(GVariant) tmp = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+ g_return_if_fail (key != NULL);
+ if (value != NULL)
+ tmp = g_variant_new_string (value);
+ gs_app_set_metadata_variant (app, key, tmp);
+}
+
+/**
+ * gs_app_get_metadata_variant:
+ * @app: a #GsApp
+ * @key: a string, e.g. "fwupd::device-id"
+ *
+ * Gets some metadata for the application.
+ * Is is expected that plugins namespace any plugin-specific metadata.
+ *
+ * Returns: (transfer none) (nullable): a variant, or %NULL for unset
+ *
+ * Since: 3.26
+ **/
+GVariant *
+gs_app_get_metadata_variant (GsApp *app, const gchar *key)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ g_return_val_if_fail (key != NULL, NULL);
+ return g_hash_table_lookup (priv->metadata, key);
+}
+
+/**
+ * gs_app_set_metadata_variant:
+ * @app: a #GsApp
+ * @key: a string, e.g. "fwupd::DeviceID"
+ * @value: a #GVariant
+ *
+ * Sets some metadata for the application.
+ * Is is expected that plugins namespace any plugin-specific metadata,
+ * for example `fwupd::device-id`.
+ *
+ * Since: 3.26
+ **/
+void
+gs_app_set_metadata_variant (GsApp *app, const gchar *key, GVariant *value)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ GVariant *found;
+
+ g_return_if_fail (GS_IS_APP (app));
+
+ locker = g_mutex_locker_new (&priv->mutex);
+
+ /* if no value, then remove the key */
+ if (value == NULL) {
+ g_hash_table_remove (priv->metadata, key);
+ return;
+ }
+
+ /* check we're not overwriting */
+ found = g_hash_table_lookup (priv->metadata, key);
+ if (found != NULL) {
+ if (g_variant_equal (found, value))
+ return;
+ if (g_variant_type_equal (g_variant_get_type (value), G_VARIANT_TYPE_STRING) &&
+ g_variant_type_equal (g_variant_get_type (found), G_VARIANT_TYPE_STRING)) {
+ g_debug ("tried overwriting %s key %s from %s to %s",
+ priv->id, key,
+ g_variant_get_string (found, NULL),
+ g_variant_get_string (value, NULL));
+ } else {
+ g_debug ("tried overwriting %s key %s (%s->%s)",
+ priv->id, key,
+ g_variant_get_type_string (found),
+ g_variant_get_type_string (value));
+ }
+ return;
+ }
+ g_hash_table_insert (priv->metadata, g_strdup (key), g_variant_ref (value));
+}
+
+/**
+ * gs_app_dup_addons:
+ * @app: a #GsApp
+ *
+ * Gets the list of addons for the application.
+ *
+ * Returns: (transfer full) (nullable): a list of addons, or %NULL if there are none
+ *
+ * Since: 43
+ */
+GsAppList *
+gs_app_dup_addons (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ locker = g_mutex_locker_new (&priv->mutex);
+ return (priv->addons != NULL) ? g_object_ref (priv->addons) : NULL;
+}
+
+/**
+ * gs_app_add_addons:
+ * @app: a #GsApp
+ * @addons: (transfer none) (not nullable): a list of #GsApps
+ *
+ * Adds zero or more addons to the list of application addons.
+ *
+ * Since: 43
+ **/
+void
+gs_app_add_addons (GsApp *app,
+ GsAppList *addons)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_autoptr(GsAppList) new_addons = NULL;
+
+ g_return_if_fail (GS_IS_APP (app));
+ g_return_if_fail (GS_IS_APP_LIST (addons));
+
+ if (gs_app_list_length (addons) == 0)
+ return;
+
+ locker = g_mutex_locker_new (&priv->mutex);
+
+ if (priv->addons != NULL)
+ new_addons = gs_app_list_copy (priv->addons);
+ else
+ new_addons = gs_app_list_new ();
+ gs_app_list_add_list (new_addons, addons);
+
+ g_set_object (&priv->addons, new_addons);
+}
+
+/**
+ * gs_app_remove_addon:
+ * @app: a #GsApp
+ * @addon: a #GsApp
+ *
+ * Removes an addon from the list of application addons.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_remove_addon (GsApp *app, GsApp *addon)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+ g_return_if_fail (GS_IS_APP (addon));
+ locker = g_mutex_locker_new (&priv->mutex);
+
+ if (priv->addons != NULL)
+ gs_app_list_remove (priv->addons, addon);
+}
+
+/**
+ * gs_app_get_related:
+ * @app: a #GsApp
+ *
+ * Gets any related applications.
+ *
+ * Returns: (transfer none): a list of applications
+ *
+ * Since: 3.22
+ **/
+GsAppList *
+gs_app_get_related (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ return priv->related;
+}
+
+/**
+ * gs_app_add_related:
+ * @app: a #GsApp
+ * @app2: a #GsApp
+ *
+ * Adds a related application.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_add_related (GsApp *app, GsApp *app2)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ GsAppPrivate *priv2 = gs_app_get_instance_private (app2);
+ g_autoptr(GMutexLocker) locker = NULL;
+
+ g_return_if_fail (GS_IS_APP (app));
+ g_return_if_fail (GS_IS_APP (app2));
+
+ locker = g_mutex_locker_new (&priv->mutex);
+
+ /* if the app is updatable-live and any related app is not then
+ * degrade to the offline state */
+ if (priv->state == GS_APP_STATE_UPDATABLE_LIVE &&
+ priv2->state == GS_APP_STATE_UPDATABLE)
+ priv->state = priv2->state;
+
+ gs_app_list_add (priv->related, app2);
+
+ /* The related apps add to the main app’s sizes. */
+ gs_app_queue_notify (app, obj_props[PROP_SIZE_DOWNLOAD_DEPENDENCIES_TYPE]);
+ gs_app_queue_notify (app, obj_props[PROP_SIZE_DOWNLOAD_DEPENDENCIES]);
+ gs_app_queue_notify (app, obj_props[PROP_SIZE_INSTALLED_DEPENDENCIES_TYPE]);
+ gs_app_queue_notify (app, obj_props[PROP_SIZE_INSTALLED_DEPENDENCIES]);
+}
+
+/**
+ * gs_app_get_history:
+ * @app: a #GsApp
+ *
+ * Gets the history of this application.
+ *
+ * Returns: (transfer none): a list
+ *
+ * Since: 3.22
+ **/
+GsAppList *
+gs_app_get_history (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ return priv->history;
+}
+
+/**
+ * gs_app_add_history:
+ * @app: a #GsApp
+ * @app2: a #GsApp
+ *
+ * Adds a history item for this package.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_add_history (GsApp *app, GsApp *app2)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+ g_return_if_fail (GS_IS_APP (app2));
+ locker = g_mutex_locker_new (&priv->mutex);
+ gs_app_list_add (priv->history, app2);
+}
+
+/**
+ * gs_app_get_install_date:
+ * @app: a #GsApp
+ *
+ * Gets the date that an application was installed.
+ *
+ * Returns: A UNIX epoch, or 0 for unset
+ *
+ * Since: 3.22
+ **/
+guint64
+gs_app_get_install_date (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), 0);
+ return priv->install_date;
+}
+
+/**
+ * gs_app_set_install_date:
+ * @app: a #GsApp
+ * @install_date: an epoch, or %GS_APP_INSTALL_DATE_UNKNOWN
+ *
+ * Sets the date that an application was installed.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_set_install_date (GsApp *app, guint64 install_date)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_if_fail (GS_IS_APP (app));
+ if (install_date == priv->install_date)
+ return;
+ priv->install_date = install_date;
+}
+
+/**
+ * gs_app_get_release_date:
+ * @app: a #GsApp
+ *
+ * Gets the date that an application was released.
+ *
+ * Returns: A UNIX epoch, or 0 for unset
+ *
+ * Since: 3.40
+ **/
+guint64
+gs_app_get_release_date (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), 0);
+ return priv->release_date;
+}
+
+/**
+ * gs_app_set_release_date:
+ * @app: a #GsApp
+ * @release_date: an epoch, or 0
+ *
+ * Sets the date that an application was released.
+ *
+ * Since: 3.40
+ **/
+void
+gs_app_set_release_date (GsApp *app, guint64 release_date)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_if_fail (GS_IS_APP (app));
+ if (release_date == priv->release_date)
+ return;
+ priv->release_date = release_date;
+
+ gs_app_queue_notify (app, obj_props[PROP_RELEASE_DATE]);
+}
+
+/**
+ * gs_app_is_installed:
+ * @app: a #GsApp
+ *
+ * Gets whether the app is installed or not.
+ *
+ * Returns: %TRUE if the app is installed, %FALSE otherwise.
+ *
+ * Since: 3.22
+ **/
+gboolean
+gs_app_is_installed (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), FALSE);
+ return (priv->state == GS_APP_STATE_INSTALLED) ||
+ (priv->state == GS_APP_STATE_UPDATABLE) ||
+ (priv->state == GS_APP_STATE_UPDATABLE_LIVE) ||
+ (priv->state == GS_APP_STATE_REMOVING);
+}
+
+/**
+ * gs_app_is_updatable:
+ * @app: a #GsApp
+ *
+ * Gets whether the app is updatable or not.
+ *
+ * Returns: %TRUE if the app is updatable, %FALSE otherwise.
+ *
+ * Since: 3.22
+ **/
+gboolean
+gs_app_is_updatable (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), FALSE);
+ if (priv->kind == AS_COMPONENT_KIND_OPERATING_SYSTEM)
+ return TRUE;
+ return (priv->state == GS_APP_STATE_UPDATABLE) ||
+ (priv->state == GS_APP_STATE_UPDATABLE_LIVE);
+}
+
+/**
+ * gs_app_get_categories:
+ * @app: a #GsApp
+ *
+ * Gets the list of categories for an application.
+ *
+ * Returns: (element-type utf8) (transfer none): a list
+ *
+ * Since: 3.22
+ **/
+GPtrArray *
+gs_app_get_categories (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ return priv->categories;
+}
+
+/**
+ * gs_app_has_category:
+ * @app: a #GsApp
+ * @category: a category ID, e.g. "AudioVideo"
+ *
+ * Checks if the application is in a specific category.
+ *
+ * Returns: %TRUE for success
+ *
+ * Since: 3.22
+ **/
+gboolean
+gs_app_has_category (GsApp *app, const gchar *category)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ const gchar *tmp;
+ guint i;
+
+ g_return_val_if_fail (GS_IS_APP (app), FALSE);
+
+ /* find the category */
+ for (i = 0; i < priv->categories->len; i++) {
+ tmp = g_ptr_array_index (priv->categories, i);
+ if (g_strcmp0 (tmp, category) == 0)
+ return TRUE;
+ }
+ return FALSE;
+}
+
+/**
+ * gs_app_set_categories:
+ * @app: a #GsApp
+ * @categories: a set of categories
+ *
+ * Set the list of categories for an application.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_set_categories (GsApp *app, GPtrArray *categories)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+ g_return_if_fail (categories != NULL);
+ locker = g_mutex_locker_new (&priv->mutex);
+ _g_set_ptr_array (&priv->categories, categories);
+}
+
+/**
+ * gs_app_add_category:
+ * @app: a #GsApp
+ * @category: a category ID, e.g. "AudioVideo"
+ *
+ * Adds a category ID to an application.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_add_category (GsApp *app, const gchar *category)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+ g_return_if_fail (category != NULL);
+ locker = g_mutex_locker_new (&priv->mutex);
+ if (gs_app_has_category (app, category))
+ return;
+ g_ptr_array_add (priv->categories, g_strdup (category));
+}
+
+/**
+ * gs_app_remove_category:
+ * @app: a #GsApp
+ * @category: a category ID, e.g. "AudioVideo"
+ *
+ * Removes an category ID from an application, it exists.
+ *
+ * Returns: %TRUE for success
+ *
+ * Since: 3.24
+ **/
+gboolean
+gs_app_remove_category (GsApp *app, const gchar *category)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ const gchar *tmp;
+ guint i;
+ g_autoptr(GMutexLocker) locker = NULL;
+
+ g_return_val_if_fail (GS_IS_APP (app), FALSE);
+
+ locker = g_mutex_locker_new (&priv->mutex);
+
+ for (i = 0; i < priv->categories->len; i++) {
+ tmp = g_ptr_array_index (priv->categories, i);
+ if (g_strcmp0 (tmp, category) != 0)
+ continue;
+ g_ptr_array_remove_index_fast (priv->categories, i);
+ return TRUE;
+ }
+ return FALSE;
+}
+
+/**
+ * gs_app_set_is_update_downloaded:
+ * @app: a #GsApp
+ * @is_update_downloaded: Whether a new update is already downloaded locally
+ *
+ * Sets if the new update is already downloaded for the app.
+ *
+ * Since: 3.36
+ **/
+void
+gs_app_set_is_update_downloaded (GsApp *app, gboolean is_update_downloaded)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_if_fail (GS_IS_APP (app));
+ priv->is_update_downloaded = is_update_downloaded;
+}
+
+/**
+ * gs_app_get_is_update_downloaded:
+ * @app: a #GsApp
+ *
+ * Gets if the new update is already downloaded for the app and
+ * is locally available.
+ *
+ * Returns: (element-type gboolean): Whether a new update for the #GsApp is already downloaded.
+ *
+ * Since: 3.36
+ **/
+gboolean
+gs_app_get_is_update_downloaded (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), FALSE);
+ return priv->is_update_downloaded;
+}
+
+static void
+calculate_key_colors (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GIcon) icon_small = NULL;
+ g_autoptr(GdkPixbuf) pb_small = NULL;
+ const gchar *overrides_str;
+
+ /* Lazily create the array */
+ if (priv->key_colors == NULL)
+ priv->key_colors = g_array_new (FALSE, FALSE, sizeof (GdkRGBA));
+ priv->user_key_colors = FALSE;
+
+ /* Look for an override first. Parse and use it if possible. This is
+ * typically specified in the appdata for an app as:
+ * |[
+ * <component>
+ * <custom>
+ * <value key="GnomeSoftware::key-colors">[(124, 53, 77), (99, 16, 0)]</value>
+ * </custom>
+ * </component>
+ * ]|
+ */
+ overrides_str = gs_app_get_metadata_item (app, "GnomeSoftware::key-colors");
+ if (overrides_str != NULL) {
+ g_autoptr(GVariant) overrides = NULL;
+ g_autoptr(GError) local_error = NULL;
+
+ overrides = g_variant_parse (G_VARIANT_TYPE ("a(yyy)"),
+ overrides_str,
+ NULL,
+ NULL,
+ &local_error);
+
+ if (overrides != NULL && g_variant_n_children (overrides) > 0) {
+ GVariantIter iter;
+ guint8 red, green, blue;
+
+ g_variant_iter_init (&iter, overrides);
+ while (g_variant_iter_loop (&iter, "(yyy)", &red, &green, &blue)) {
+ GdkRGBA rgba;
+ rgba.red = (gdouble) red / 255.0;
+ rgba.green = (gdouble) green / 255.0;
+ rgba.blue = (gdouble) blue / 255.0;
+ rgba.alpha = 1.0;
+ g_array_append_val (priv->key_colors, rgba);
+ }
+
+ priv->user_key_colors = TRUE;
+
+ return;
+ } else {
+ g_warning ("Invalid value for GnomeSoftware::key-colors for %s: %s",
+ gs_app_get_id (app), local_error->message);
+ /* fall through */
+ }
+ }
+
+ /* Try and load the pixbuf. */
+ icon_small = gs_app_get_icon_for_size (app, 32, 1, NULL);
+
+ if (icon_small == NULL) {
+ g_debug ("no pixbuf, so no key colors");
+ return;
+ } else if (G_IS_LOADABLE_ICON (icon_small)) {
+ g_autoptr(GInputStream) icon_stream = g_loadable_icon_load (G_LOADABLE_ICON (icon_small), 32, NULL, NULL, NULL);
+ if (icon_stream)
+ pb_small = gdk_pixbuf_new_from_stream_at_scale (icon_stream, 32, 32, TRUE, NULL, NULL);
+ } else if (G_IS_THEMED_ICON (icon_small)) {
+ g_autoptr(GtkIconPaintable) icon_paintable = NULL;
+ g_autoptr(GtkIconTheme) theme = NULL;
+ GdkDisplay *display;
+
+ display = gdk_display_get_default ();
+ if (display != NULL) {
+ theme = g_object_ref (gtk_icon_theme_get_for_display (display));
+ } else {
+ const gchar *test_search_path;
+
+ /* This fallback path is needed for the unit tests,
+ * which run without a screen, and in an environment
+ * where the XDG dir variables don’t point to the system
+ * datadir which contains the system icon theme. */
+ theme = gtk_icon_theme_new ();
+
+ test_search_path = g_getenv ("GS_SELF_TEST_ICON_THEME_PATH");
+ if (test_search_path != NULL) {
+ g_auto(GStrv) dirs = g_strsplit (test_search_path, ":", -1);
+ gtk_icon_theme_set_search_path (theme, (const char * const *)dirs);
+
+ }
+ }
+
+ icon_paintable = gtk_icon_theme_lookup_by_gicon (theme, icon_small,
+ 32, 1,
+ gtk_get_locale_direction (),
+ 0);
+ if (icon_paintable != NULL) {
+ g_autoptr(GFile) file = NULL;
+ g_autofree gchar *path = NULL;
+
+ file = gtk_icon_paintable_get_file (icon_paintable);
+ if (file != NULL)
+ path = g_file_get_path (file);
+
+ if (path != NULL) {
+ pb_small = gdk_pixbuf_new_from_file_at_size (path, 32, 32, NULL);
+ } else {
+ g_autoptr(GskRenderNode) render_node = NULL;
+ g_autoptr(GtkSnapshot) snapshot = NULL;
+ cairo_surface_t *surface;
+ cairo_t *cr;
+
+ surface = cairo_image_surface_create (CAIRO_FORMAT_ARGB32, 32, 32);
+ cr = cairo_create (surface);
+
+ /* TODO: this can be done entirely on the GPU using shaders */
+ snapshot = gtk_snapshot_new ();
+ gdk_paintable_snapshot (GDK_PAINTABLE (icon_paintable),
+ GDK_SNAPSHOT (snapshot),
+ 32.0,
+ 32.0);
+
+ render_node = gtk_snapshot_free_to_node (g_steal_pointer (&snapshot));
+ gsk_render_node_draw (render_node, cr);
+
+ pb_small = gdk_pixbuf_get_from_surface (surface, 0, 0, 32, 32);
+
+ cairo_surface_destroy (surface);
+ cairo_destroy (cr);
+ }
+ }
+
+ } else {
+ g_debug ("unsupported pixbuf, so no key colors");
+ return;
+ }
+
+ if (pb_small == NULL) {
+ g_debug ("pixbuf couldn’t be loaded, so no key colors");
+ return;
+ }
+
+ /* get a list of key colors */
+ g_clear_pointer (&priv->key_colors, g_array_unref);
+ priv->key_colors = gs_calculate_key_colors (pb_small);
+}
+
+/**
+ * gs_app_get_key_colors:
+ * @app: a #GsApp
+ *
+ * Gets the key colors used in the application icon.
+ *
+ * Returns: (element-type GdkRGBA) (transfer none): a list
+ *
+ * Since: 40
+ **/
+GArray *
+gs_app_get_key_colors (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+
+ if (priv->key_colors == NULL)
+ calculate_key_colors (app);
+
+ return priv->key_colors;
+}
+
+/**
+ * gs_app_set_key_colors:
+ * @app: a #GsApp
+ * @key_colors: (element-type GdkRGBA): a set of key colors
+ *
+ * Sets the key colors used in the application icon.
+ *
+ * Since: 40
+ **/
+void
+gs_app_set_key_colors (GsApp *app, GArray *key_colors)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+ g_return_if_fail (key_colors != NULL);
+ locker = g_mutex_locker_new (&priv->mutex);
+ priv->user_key_colors = FALSE;
+ if (_g_set_array (&priv->key_colors, key_colors))
+ gs_app_queue_notify (app, obj_props[PROP_KEY_COLORS]);
+}
+
+/**
+ * gs_app_add_key_color:
+ * @app: a #GsApp
+ * @key_color: a #GdkRGBA
+ *
+ * Adds a key color used in the application icon.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_add_key_color (GsApp *app, GdkRGBA *key_color)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_if_fail (GS_IS_APP (app));
+ g_return_if_fail (key_color != NULL);
+
+ /* Lazily create the array */
+ if (priv->key_colors == NULL)
+ priv->key_colors = g_array_new (FALSE, FALSE, sizeof (GdkRGBA));
+
+ priv->user_key_colors = FALSE;
+ g_array_append_val (priv->key_colors, *key_color);
+ gs_app_queue_notify (app, obj_props[PROP_KEY_COLORS]);
+}
+
+/**
+ * gs_app_get_user_key_colors:
+ * @app: a #GsApp
+ *
+ * Returns whether the key colors provided by gs_app_get_key_colors()
+ * are set by the user (using `GnomeSoftware::key-colors`). %FALSE
+ * means the colors have been calculated from the @app icon.
+ *
+ * Returns: whether the key colors have been provided by the user.
+ *
+ * Since: 42
+ **/
+gboolean
+gs_app_get_user_key_colors (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), FALSE);
+ return priv->user_key_colors;
+}
+
+/**
+ * gs_app_add_kudo:
+ * @app: a #GsApp
+ * @kudo: a #GsAppKudo, e.g. %GS_APP_KUDO_MY_LANGUAGE
+ *
+ * Adds a kudo to the application.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_add_kudo (GsApp *app, GsAppKudo kudo)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_if_fail (GS_IS_APP (app));
+ if (kudo & GS_APP_KUDO_SANDBOXED_SECURE)
+ kudo |= GS_APP_KUDO_SANDBOXED;
+ priv->kudos |= kudo;
+}
+
+/**
+ * gs_app_remove_kudo:
+ * @app: a #GsApp
+ * @kudo: a #GsAppKudo, e.g. %GS_APP_KUDO_MY_LANGUAGE
+ *
+ * Removes a kudo from the application.
+ *
+ * Since: 3.30
+ **/
+void
+gs_app_remove_kudo (GsApp *app, GsAppKudo kudo)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_if_fail (GS_IS_APP (app));
+ priv->kudos &= ~kudo;
+}
+
+/**
+ * gs_app_has_kudo:
+ * @app: a #GsApp
+ * @kudo: a #GsAppKudo, e.g. %GS_APP_KUDO_MY_LANGUAGE
+ *
+ * Finds out if a kudo has been awarded by the application.
+ *
+ * Returns: %TRUE if the app has the specified kudo
+ *
+ * Since: 3.22
+ **/
+gboolean
+gs_app_has_kudo (GsApp *app, GsAppKudo kudo)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), FALSE);
+ return (priv->kudos & kudo) > 0;
+}
+
+/**
+ * gs_app_get_kudos:
+ * @app: a #GsApp
+ *
+ * Gets all the kudos the application has been awarded.
+ *
+ * Returns: the kudos, as a bitfield
+ *
+ * Since: 3.22
+ **/
+guint64
+gs_app_get_kudos (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), 0);
+ return priv->kudos;
+}
+
+/**
+ * gs_app_get_kudos_percentage:
+ * @app: a #GsApp
+ *
+ * Gets the kudos, as a percentage value.
+ *
+ * Returns: a percentage, with 0 for no kudos and a maximum of 100.
+ *
+ * Since: 3.22
+ **/
+guint
+gs_app_get_kudos_percentage (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ guint percentage = 0;
+
+ g_return_val_if_fail (GS_IS_APP (app), 0);
+
+ if ((priv->kudos & GS_APP_KUDO_MY_LANGUAGE) > 0)
+ percentage += 20;
+ if ((priv->kudos & GS_APP_KUDO_RECENT_RELEASE) > 0)
+ percentage += 20;
+ if ((priv->kudos & GS_APP_KUDO_FEATURED_RECOMMENDED) > 0)
+ percentage += 20;
+ if ((priv->kudos & GS_APP_KUDO_MODERN_TOOLKIT) > 0)
+ percentage += 20;
+ if ((priv->kudos & GS_APP_KUDO_SEARCH_PROVIDER) > 0)
+ percentage += 10;
+ if ((priv->kudos & GS_APP_KUDO_INSTALLS_USER_DOCS) > 0)
+ percentage += 10;
+ if ((priv->kudos & GS_APP_KUDO_USES_NOTIFICATIONS) > 0)
+ percentage += 20;
+ if ((priv->kudos & GS_APP_KUDO_HAS_KEYWORDS) > 0)
+ percentage += 5;
+ if ((priv->kudos & GS_APP_KUDO_HAS_SCREENSHOTS) > 0)
+ percentage += 20;
+ if ((priv->kudos & GS_APP_KUDO_HIGH_CONTRAST) > 0)
+ percentage += 20;
+ if ((priv->kudos & GS_APP_KUDO_HI_DPI_ICON) > 0)
+ percentage += 20;
+ if ((priv->kudos & GS_APP_KUDO_SANDBOXED) > 0)
+ percentage += 20;
+ if ((priv->kudos & GS_APP_KUDO_SANDBOXED_SECURE) > 0)
+ percentage += 20;
+
+ return MIN (percentage, 100);
+}
+
+/**
+ * gs_app_get_to_be_installed:
+ * @app: a #GsApp
+ *
+ * Gets if the application is queued for installation.
+ *
+ * This is only set for addons when the user has selected some addons to be
+ * installed before installing the main application.
+ * Plugins should check all the addons for this property when installing
+ * main applications so that the chosen set of addons is also installed at the
+ * same time. This is never set when applications do not have addons.
+ *
+ * Returns: %TRUE for success
+ *
+ * Since: 3.22
+ **/
+gboolean
+gs_app_get_to_be_installed (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), FALSE);
+
+ return priv->to_be_installed;
+}
+
+/**
+ * gs_app_set_to_be_installed:
+ * @app: a #GsApp
+ * @to_be_installed: if the app is due to be installed
+ *
+ * Sets if the application is queued for installation.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_set_to_be_installed (GsApp *app, gboolean to_be_installed)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_if_fail (GS_IS_APP (app));
+
+ priv->to_be_installed = to_be_installed;
+}
+
+/**
+ * gs_app_has_quirk:
+ * @app: a #GsApp
+ * @quirk: a #GsAppQuirk, e.g. %GS_APP_QUIRK_COMPULSORY
+ *
+ * Finds out if an application has a specific quirk.
+ *
+ * Returns: %TRUE for success
+ *
+ * Since: 3.22
+ **/
+gboolean
+gs_app_has_quirk (GsApp *app, GsAppQuirk quirk)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), FALSE);
+
+ return (priv->quirk & quirk) > 0;
+}
+
+/**
+ * gs_app_add_quirk:
+ * @app: a #GsApp
+ * @quirk: a #GsAppQuirk, e.g. %GS_APP_QUIRK_COMPULSORY
+ *
+ * Adds a quirk to an application.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_add_quirk (GsApp *app, GsAppQuirk quirk)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+
+ /* same */
+ if ((priv->quirk & quirk) > 0)
+ return;
+
+ locker = g_mutex_locker_new (&priv->mutex);
+ priv->quirk |= quirk;
+ gs_app_queue_notify (app, obj_props[PROP_QUIRK]);
+}
+
+/**
+ * gs_app_remove_quirk:
+ * @app: a #GsApp
+ * @quirk: a #GsAppQuirk, e.g. %GS_APP_QUIRK_COMPULSORY
+ *
+ * Removes a quirk from an application.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_remove_quirk (GsApp *app, GsAppQuirk quirk)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+
+ /* same */
+ if ((priv->quirk & quirk) == 0)
+ return;
+
+ locker = g_mutex_locker_new (&priv->mutex);
+ priv->quirk &= ~quirk;
+ gs_app_queue_notify (app, obj_props[PROP_QUIRK]);
+}
+
+/**
+ * gs_app_set_match_value:
+ * @app: a #GsApp
+ * @match_value: a value
+ *
+ * Set a match quality value, where higher values correspond to a
+ * "better" search match, and should be shown above lower results.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_set_match_value (GsApp *app, guint match_value)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_if_fail (GS_IS_APP (app));
+ priv->match_value = match_value;
+}
+
+/**
+ * gs_app_get_match_value:
+ * @app: a #GsApp
+ *
+ * Get a match quality value, where higher values correspond to a
+ * "better" search match, and should be shown above lower results.
+ *
+ * Note: This value is only valid when processing the result set
+ * and may be overwritten on subsequent searches if the plugin is using
+ * a cache.
+ *
+ * Returns: a value, where higher is better
+ *
+ * Since: 3.22
+ **/
+guint
+gs_app_get_match_value (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), 0);
+ return priv->match_value;
+}
+
+/**
+ * gs_app_set_priority:
+ * @app: a #GsApp
+ * @priority: a value
+ *
+ * Set a priority value.
+ *
+ * Since: 3.22
+ **/
+void
+gs_app_set_priority (GsApp *app, guint priority)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_if_fail (GS_IS_APP (app));
+ priv->priority = priority;
+}
+
+/**
+ * gs_app_get_priority:
+ * @app: a #GsApp
+ *
+ * Get a priority value, where higher values will be chosen where
+ * multiple #GsApp's match a specific rule.
+ *
+ * Returns: a value, where higher is better
+ *
+ * Since: 3.22
+ **/
+guint
+gs_app_get_priority (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_return_val_if_fail (GS_IS_APP (app), 0);
+
+ /* If the priority hasn’t been explicitly set, fetch it from the app’s
+ * management plugin. */
+ if (priv->priority == 0) {
+ g_autoptr(GsPlugin) plugin = gs_app_dup_management_plugin (app);
+ if (plugin != NULL)
+ priv->priority = gs_plugin_get_priority (plugin);
+ }
+
+ return priv->priority;
+}
+
+/**
+ * gs_app_get_cancellable:
+ * @app: a #GsApp
+ *
+ * Get a cancellable to be used with operations related to the #GsApp. This is a
+ * way for views to be able to cancel an on-going operation. If the #GCancellable
+ * is canceled, it will be unreferenced and renewed before returning it, i.e. the
+ * cancellable object will always be ready to use for new operations. So be sure
+ * to keep a reference to it if you do more than just passing the cancellable to
+ * a process.
+ *
+ * Returns: a #GCancellable
+ *
+ * Since: 3.28
+ **/
+GCancellable *
+gs_app_get_cancellable (GsApp *app)
+{
+ g_autoptr(GCancellable) cancellable = NULL;
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+
+ locker = g_mutex_locker_new (&priv->mutex);
+
+ if (priv->cancellable == NULL || g_cancellable_is_cancelled (priv->cancellable)) {
+ cancellable = g_cancellable_new ();
+ g_set_object (&priv->cancellable, cancellable);
+ }
+ return priv->cancellable;
+}
+
+/**
+ * gs_app_get_pending_action:
+ * @app: a #GsApp
+ *
+ * Get the pending action for this #GsApp, or %NULL if no action is pending.
+ *
+ * Returns: the #GsAppAction of the @app.
+ **/
+GsPluginAction
+gs_app_get_pending_action (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_val_if_fail (GS_IS_APP (app), GS_PLUGIN_ACTION_UNKNOWN);
+ locker = g_mutex_locker_new (&priv->mutex);
+ return priv->pending_action;
+}
+
+/**
+ * gs_app_set_pending_action:
+ * @app: a #GsApp
+ * @action: a #GsPluginAction
+ *
+ * Set an action that is pending on this #GsApp.
+ **/
+void
+gs_app_set_pending_action (GsApp *app,
+ GsPluginAction action)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+ locker = g_mutex_locker_new (&priv->mutex);
+ gs_app_set_pending_action_internal (app, action);
+}
+
+static void
+gs_app_get_property (GObject *object, guint prop_id, GValue *value, GParamSpec *pspec)
+{
+ GsApp *app = GS_APP (object);
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+
+ switch ((GsAppProperty) prop_id) {
+ case PROP_ID:
+ g_value_set_string (value, priv->id);
+ break;
+ case PROP_NAME:
+ g_value_set_string (value, priv->name);
+ break;
+ case PROP_VERSION:
+ g_value_set_string (value, priv->version);
+ break;
+ case PROP_SUMMARY:
+ g_value_set_string (value, priv->summary);
+ break;
+ case PROP_DESCRIPTION:
+ g_value_set_string (value, priv->description);
+ break;
+ case PROP_RATING:
+ g_value_set_int (value, priv->rating);
+ break;
+ case PROP_KIND:
+ g_value_set_uint (value, priv->kind);
+ break;
+ case PROP_SPECIAL_KIND:
+ g_value_set_enum (value, priv->special_kind);
+ break;
+ case PROP_STATE:
+ g_value_set_enum (value, priv->state);
+ break;
+ case PROP_PROGRESS:
+ g_value_set_uint (value, priv->progress);
+ break;
+ case PROP_CAN_CANCEL_INSTALLATION:
+ g_value_set_boolean (value, priv->allow_cancel);
+ break;
+ case PROP_INSTALL_DATE:
+ g_value_set_uint64 (value, priv->install_date);
+ break;
+ case PROP_RELEASE_DATE:
+ g_value_set_uint64 (value, priv->release_date);
+ break;
+ case PROP_QUIRK:
+ g_value_set_flags (value, priv->quirk);
+ break;
+ case PROP_PENDING_ACTION:
+ g_value_set_enum (value, priv->pending_action);
+ break;
+ case PROP_KEY_COLORS:
+ g_value_set_boxed (value, gs_app_get_key_colors (app));
+ break;
+ case PROP_IS_UPDATE_DOWNLOADED:
+ g_value_set_boolean (value, priv->is_update_downloaded);
+ break;
+ case PROP_URLS:
+ g_value_set_boxed (value, priv->urls);
+ break;
+ case PROP_URL_MISSING:
+ g_value_set_string (value, priv->url_missing);
+ break;
+ case PROP_CONTENT_RATING:
+ g_value_set_object (value, priv->content_rating);
+ break;
+ case PROP_LICENSE:
+ g_value_set_string (value, priv->license);
+ break;
+ case PROP_SIZE_CACHE_DATA_TYPE:
+ g_value_set_enum (value, gs_app_get_size_cache_data (app, NULL));
+ break;
+ case PROP_SIZE_CACHE_DATA: {
+ guint64 size_bytes;
+ gs_app_get_size_cache_data (app, &size_bytes);
+ g_value_set_uint64 (value, size_bytes);
+ break;
+ }
+ case PROP_SIZE_DOWNLOAD_TYPE:
+ g_value_set_enum (value, gs_app_get_size_download (app, NULL));
+ break;
+ case PROP_SIZE_DOWNLOAD: {
+ guint64 size_bytes;
+ gs_app_get_size_download (app, &size_bytes);
+ g_value_set_uint64 (value, size_bytes);
+ break;
+ }
+ case PROP_SIZE_DOWNLOAD_DEPENDENCIES_TYPE:
+ g_value_set_enum (value, gs_app_get_size_download_dependencies (app, NULL));
+ break;
+ case PROP_SIZE_DOWNLOAD_DEPENDENCIES: {
+ guint64 size_bytes;
+ gs_app_get_size_download_dependencies (app, &size_bytes);
+ g_value_set_uint64 (value, size_bytes);
+ break;
+ }
+ case PROP_SIZE_INSTALLED_TYPE:
+ g_value_set_enum (value, gs_app_get_size_installed (app, NULL));
+ break;
+ case PROP_SIZE_INSTALLED: {
+ guint64 size_bytes;
+ gs_app_get_size_installed (app, &size_bytes);
+ g_value_set_uint64 (value, size_bytes);
+ break;
+ }
+ case PROP_SIZE_INSTALLED_DEPENDENCIES_TYPE:
+ g_value_set_enum (value, gs_app_get_size_installed_dependencies (app, NULL));
+ break;
+ case PROP_SIZE_INSTALLED_DEPENDENCIES: {
+ guint64 size_bytes;
+ gs_app_get_size_installed_dependencies (app, &size_bytes);
+ g_value_set_uint64 (value, size_bytes);
+ break;
+ }
+ case PROP_SIZE_USER_DATA_TYPE:
+ g_value_set_enum (value, gs_app_get_size_user_data (app, NULL));
+ break;
+ case PROP_SIZE_USER_DATA: {
+ guint64 size_bytes;
+ gs_app_get_size_user_data (app, &size_bytes);
+ g_value_set_uint64 (value, size_bytes);
+ break;
+ }
+ case PROP_PERMISSIONS:
+ g_value_take_object (value, gs_app_dup_permissions (app));
+ break;
+ case PROP_RELATIONS:
+ g_value_take_boxed (value, gs_app_get_relations (app));
+ break;
+ case PROP_ORIGIN_UI:
+ g_value_take_string (value, gs_app_dup_origin_ui (app, TRUE));
+ break;
+ case PROP_HAS_TRANSLATIONS:
+ g_value_set_boolean (value, gs_app_get_has_translations (app));
+ break;
+ default:
+ G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+ break;
+ }
+}
+
+static void
+gs_app_set_property (GObject *object, guint prop_id, const GValue *value, GParamSpec *pspec)
+{
+ GsApp *app = GS_APP (object);
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+
+ switch ((GsAppProperty) prop_id) {
+ case PROP_ID:
+ gs_app_set_id (app, g_value_get_string (value));
+ break;
+ case PROP_NAME:
+ gs_app_set_name (app,
+ GS_APP_QUALITY_UNKNOWN,
+ g_value_get_string (value));
+ break;
+ case PROP_VERSION:
+ gs_app_set_version (app, g_value_get_string (value));
+ break;
+ case PROP_SUMMARY:
+ gs_app_set_summary (app,
+ GS_APP_QUALITY_UNKNOWN,
+ g_value_get_string (value));
+ break;
+ case PROP_DESCRIPTION:
+ gs_app_set_description (app,
+ GS_APP_QUALITY_UNKNOWN,
+ g_value_get_string (value));
+ break;
+ case PROP_RATING:
+ gs_app_set_rating (app, g_value_get_int (value));
+ break;
+ case PROP_KIND:
+ gs_app_set_kind (app, g_value_get_uint (value));
+ break;
+ case PROP_SPECIAL_KIND:
+ gs_app_set_special_kind (app, g_value_get_enum (value));
+ break;
+ case PROP_STATE:
+ gs_app_set_state_internal (app, g_value_get_enum (value));
+ break;
+ case PROP_PROGRESS:
+ gs_app_set_progress (app, g_value_get_uint (value));
+ break;
+ case PROP_CAN_CANCEL_INSTALLATION:
+ priv->allow_cancel = g_value_get_boolean (value);
+ break;
+ case PROP_INSTALL_DATE:
+ gs_app_set_install_date (app, g_value_get_uint64 (value));
+ break;
+ case PROP_RELEASE_DATE:
+ gs_app_set_release_date (app, g_value_get_uint64 (value));
+ break;
+ case PROP_QUIRK:
+ priv->quirk = g_value_get_flags (value);
+ break;
+ case PROP_PENDING_ACTION:
+ /* Read only */
+ g_assert_not_reached ();
+ break;
+ case PROP_KEY_COLORS:
+ gs_app_set_key_colors (app, g_value_get_boxed (value));
+ break;
+ case PROP_IS_UPDATE_DOWNLOADED:
+ gs_app_set_is_update_downloaded (app, g_value_get_boolean (value));
+ break;
+ case PROP_URLS:
+ /* Read only */
+ g_assert_not_reached ();
+ break;
+ case PROP_URL_MISSING:
+ gs_app_set_url_missing (app, g_value_get_string (value));
+ break;
+ case PROP_CONTENT_RATING:
+ gs_app_set_content_rating (app, g_value_get_object (value));
+ break;
+ case PROP_LICENSE:
+ /* Read-only */
+ g_assert_not_reached ();
+ case PROP_SIZE_CACHE_DATA_TYPE:
+ gs_app_set_size_cache_data (app, g_value_get_enum (value), priv->size_cache_data);
+ break;
+ case PROP_SIZE_CACHE_DATA:
+ gs_app_set_size_cache_data (app, priv->size_cache_data_type, g_value_get_uint64 (value));
+ break;
+ case PROP_SIZE_DOWNLOAD_TYPE:
+ gs_app_set_size_download (app, g_value_get_enum (value), priv->size_download);
+ break;
+ case PROP_SIZE_DOWNLOAD:
+ gs_app_set_size_download (app, priv->size_download_type, g_value_get_uint64 (value));
+ break;
+ case PROP_SIZE_DOWNLOAD_DEPENDENCIES_TYPE:
+ case PROP_SIZE_DOWNLOAD_DEPENDENCIES:
+ /* Read-only */
+ g_assert_not_reached ();
+ case PROP_SIZE_INSTALLED_TYPE:
+ gs_app_set_size_installed (app, g_value_get_enum (value), priv->size_installed);
+ break;
+ case PROP_SIZE_INSTALLED:
+ gs_app_set_size_installed (app, priv->size_installed_type, g_value_get_uint64 (value));
+ break;
+ case PROP_SIZE_INSTALLED_DEPENDENCIES_TYPE:
+ case PROP_SIZE_INSTALLED_DEPENDENCIES:
+ /* Read-only */
+ g_assert_not_reached ();
+ case PROP_SIZE_USER_DATA_TYPE:
+ gs_app_set_size_user_data (app, g_value_get_enum (value), priv->size_user_data);
+ break;
+ case PROP_SIZE_USER_DATA:
+ gs_app_set_size_user_data (app, priv->size_user_data_type, g_value_get_uint64 (value));
+ break;
+ case PROP_PERMISSIONS:
+ gs_app_set_permissions (app, g_value_get_object (value));
+ break;
+ case PROP_RELATIONS:
+ gs_app_set_relations (app, g_value_get_boxed (value));
+ break;
+ case PROP_ORIGIN_UI:
+ gs_app_set_origin_ui (app, g_value_get_string (value));
+ break;
+ case PROP_HAS_TRANSLATIONS:
+ gs_app_set_has_translations (app, g_value_get_boolean (value));
+ break;
+ default:
+ G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+ break;
+ }
+}
+
+static void
+gs_app_dispose (GObject *object)
+{
+ GsApp *app = GS_APP (object);
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+
+ g_clear_object (&priv->runtime);
+
+ g_clear_pointer (&priv->addons, g_object_unref);
+ g_clear_pointer (&priv->history, g_object_unref);
+ g_clear_pointer (&priv->related, g_object_unref);
+ g_clear_pointer (&priv->screenshots, g_ptr_array_unref);
+ g_clear_pointer (&priv->review_ratings, g_array_unref);
+ g_clear_pointer (&priv->reviews, g_ptr_array_unref);
+ g_clear_pointer (&priv->provided, g_ptr_array_unref);
+ g_clear_pointer (&priv->icons, g_ptr_array_unref);
+ g_clear_pointer (&priv->version_history, g_ptr_array_unref);
+ g_clear_pointer (&priv->relations, g_ptr_array_unref);
+ g_weak_ref_clear (&priv->management_plugin_weak);
+
+ G_OBJECT_CLASS (gs_app_parent_class)->dispose (object);
+}
+
+static void
+gs_app_finalize (GObject *object)
+{
+ GsApp *app = GS_APP (object);
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+
+ g_mutex_clear (&priv->mutex);
+ g_free (priv->id);
+ g_free (priv->unique_id);
+ g_free (priv->branch);
+ g_free (priv->name);
+ g_free (priv->renamed_from);
+ g_free (priv->url_missing);
+ g_clear_pointer (&priv->urls, g_hash_table_unref);
+ g_hash_table_unref (priv->launchables);
+ g_free (priv->license);
+ g_strfreev (priv->menu_path);
+ g_free (priv->origin);
+ g_free (priv->origin_ui);
+ g_free (priv->origin_appstream);
+ g_free (priv->origin_hostname);
+ g_ptr_array_unref (priv->sources);
+ g_ptr_array_unref (priv->source_ids);
+ g_free (priv->project_group);
+ g_free (priv->developer_name);
+ g_free (priv->agreement);
+ g_free (priv->version);
+ g_free (priv->version_ui);
+ g_free (priv->summary);
+ g_free (priv->summary_missing);
+ g_free (priv->description);
+ g_free (priv->update_version);
+ g_free (priv->update_version_ui);
+ g_free (priv->update_details_markup);
+ g_hash_table_unref (priv->metadata);
+ g_ptr_array_unref (priv->categories);
+ g_clear_pointer (&priv->key_colors, g_array_unref);
+ g_clear_object (&priv->cancellable);
+ g_clear_object (&priv->local_file);
+ g_clear_object (&priv->content_rating);
+ g_clear_object (&priv->action_screenshot);
+ g_clear_object (&priv->update_permissions);
+ g_clear_object (&priv->permissions);
+
+ G_OBJECT_CLASS (gs_app_parent_class)->finalize (object);
+}
+
+static void
+gs_app_class_init (GsAppClass *klass)
+{
+ GObjectClass *object_class = G_OBJECT_CLASS (klass);
+ object_class->dispose = gs_app_dispose;
+ object_class->finalize = gs_app_finalize;
+ object_class->get_property = gs_app_get_property;
+ object_class->set_property = gs_app_set_property;
+
+ /**
+ * GsApp:id:
+ */
+ obj_props[PROP_ID] = g_param_spec_string ("id", NULL, NULL,
+ NULL,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * GsApp:name:
+ */
+ obj_props[PROP_NAME] = g_param_spec_string ("name", NULL, NULL,
+ NULL,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * GsApp:version:
+ */
+ obj_props[PROP_VERSION] = g_param_spec_string ("version", NULL, NULL,
+ NULL,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * GsApp:summary:
+ */
+ obj_props[PROP_SUMMARY] = g_param_spec_string ("summary", NULL, NULL,
+ NULL,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * GsApp:description:
+ */
+ obj_props[PROP_DESCRIPTION] = g_param_spec_string ("description", NULL, NULL,
+ NULL,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * GsApp:rating:
+ */
+ obj_props[PROP_RATING] = g_param_spec_int ("rating", NULL, NULL,
+ -1, 100, -1,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * GsApp:kind:
+ */
+ /* FIXME: Should use AS_TYPE_APP_KIND when it’s available */
+ obj_props[PROP_KIND] = g_param_spec_uint ("kind", NULL, NULL,
+ AS_COMPONENT_KIND_UNKNOWN,
+ AS_COMPONENT_KIND_LAST,
+ AS_COMPONENT_KIND_UNKNOWN,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * GsApp:special-kind:
+ *
+ * GNOME Software specific occupation of the #GsApp entity
+ * that does not reflect a software type defined by AppStream.
+ *
+ * Since: 40
+ */
+ obj_props[PROP_SPECIAL_KIND] = g_param_spec_enum ("special-kind", NULL, NULL,
+ GS_TYPE_APP_SPECIAL_KIND,
+ GS_APP_SPECIAL_KIND_NONE,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * GsApp:state:
+ */
+ obj_props[PROP_STATE] = g_param_spec_enum ("state", NULL, NULL,
+ GS_TYPE_APP_STATE,
+ GS_APP_STATE_UNKNOWN,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * GsApp:progress:
+ *
+ * A percentage (0–100, inclusive) indicating the progress through the
+ * current task on this app. The value may otherwise be
+ * %GS_APP_PROGRESS_UNKNOWN if the progress is unknown or has a wide
+ * confidence interval.
+ */
+ obj_props[PROP_PROGRESS] = g_param_spec_uint ("progress", NULL, NULL,
+ 0, GS_APP_PROGRESS_UNKNOWN, GS_APP_PROGRESS_UNKNOWN,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * GsApp:allow-cancel:
+ */
+ obj_props[PROP_CAN_CANCEL_INSTALLATION] =
+ g_param_spec_boolean ("allow-cancel", NULL, NULL, TRUE,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * GsApp:install-date:
+ */
+ obj_props[PROP_INSTALL_DATE] = g_param_spec_uint64 ("install-date", NULL, NULL,
+ 0, G_MAXUINT64, 0,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * GsApp:release-date:
+ *
+ * Set to the release date of the application on the server. Can be 0,
+ * which means the release date is unknown.
+ *
+ * Since: 3.40
+ */
+ obj_props[PROP_RELEASE_DATE] = g_param_spec_uint64 ("release-date", NULL, NULL,
+ 0, G_MAXUINT64, 0,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * GsApp:quirk:
+ */
+ obj_props[PROP_QUIRK] = g_param_spec_flags ("quirk", NULL, NULL,
+ GS_TYPE_APP_QUIRK, GS_APP_QUIRK_NONE,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * GsApp:pending-action:
+ */
+ obj_props[PROP_PENDING_ACTION] = g_param_spec_enum ("pending-action", NULL, NULL,
+ GS_TYPE_PLUGIN_ACTION, GS_PLUGIN_ACTION_UNKNOWN,
+ G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * GsApp:key-colors:
+ */
+ obj_props[PROP_KEY_COLORS] = g_param_spec_boxed ("key-colors", NULL, NULL,
+ G_TYPE_ARRAY, G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * GsApp:is-update-downloaded:
+ */
+ obj_props[PROP_IS_UPDATE_DOWNLOADED] = g_param_spec_boolean ("is-update-downloaded", NULL, NULL,
+ FALSE,
+ G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * GsApp:urls: (nullable) (element-type AsUrlKind utf8)
+ *
+ * The URLs associated with the app.
+ *
+ * This is %NULL if no URLs are available. If provided, it is a mapping
+ * from #AsUrlKind to the URLs.
+ *
+ * This property is read-only: use gs_app_set_url() to set URLs.
+ *
+ * Since: 41
+ */
+ obj_props[PROP_URLS] =
+ g_param_spec_boxed ("urls", NULL, NULL,
+ G_TYPE_HASH_TABLE,
+ G_PARAM_READABLE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * GsApp:url-missing:
+ *
+ * A web URL pointing to explanations why this app
+ * does not have an installation candidate.
+ *
+ * Since: 40
+ */
+ obj_props[PROP_URL_MISSING] = g_param_spec_string ("url-missing", NULL, NULL,
+ NULL,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * GsApp:content-rating: (nullable)
+ *
+ * The content rating for the app, which gives information on how
+ * suitable it is for different age ranges of user.
+ *
+ * This is %NULL if no content rating information is available.
+ *
+ * Since: 41
+ */
+ obj_props[PROP_CONTENT_RATING] =
+ g_param_spec_object ("content-rating", NULL, NULL,
+ /* FIXME: Use the get_type() function directly here to work
+ * around https://github.com/ximion/appstream/pull/318 */
+ as_content_rating_get_type (),
+ G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * GsApp:license: (nullable)
+ *
+ * The license for the app, which is typically its source code license.
+ *
+ * Use gs_app_set_license() to set this.
+ *
+ * This is %NULL if no licensing information is available.
+ *
+ * Since: 41
+ */
+ obj_props[PROP_LICENSE] =
+ g_param_spec_string ("license", NULL, NULL,
+ NULL,
+ G_PARAM_READABLE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * GsApp:size-cache-data-type
+ *
+ * The type of #GsApp:size-cache-data.
+ *
+ * Since: 43
+ */
+ obj_props[PROP_SIZE_CACHE_DATA_TYPE] =
+ g_param_spec_enum ("size-cache-data-type", NULL, NULL,
+ GS_TYPE_SIZE_TYPE, GS_SIZE_TYPE_UNKNOWN,
+ G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * GsApp:size-cache-data
+ *
+ * The size on the disk for the cache data of the application.
+ *
+ * This is undefined if #GsApp:size-cache-data-type is not
+ * %GS_SIZE_TYPE_VALID.
+ *
+ * Since: 41
+ */
+ obj_props[PROP_SIZE_CACHE_DATA] =
+ g_param_spec_uint64 ("size-cache-data", NULL, NULL,
+ 0, G_MAXUINT64, 0,
+ G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * GsApp:size-download-type
+ *
+ * The type of #GsApp:size-download.
+ *
+ * Since: 43
+ */
+ obj_props[PROP_SIZE_DOWNLOAD_TYPE] =
+ g_param_spec_enum ("size-download-type", NULL, NULL,
+ GS_TYPE_SIZE_TYPE, GS_SIZE_TYPE_UNKNOWN,
+ G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * GsApp:size-download
+ *
+ * The size of the total download needed to either install or update
+ * this application, in bytes. If the app is partially downloaded, this
+ * is the number of bytes remaining to download.
+ *
+ * This is undefined if #GsApp:size-download-type is not
+ * %GS_SIZE_TYPE_VALID.
+ *
+ * To get the runtime or other dependencies download size,
+ * use #GsApp:size-download-dependencies.
+ *
+ * Since: 41
+ */
+ obj_props[PROP_SIZE_DOWNLOAD] =
+ g_param_spec_uint64 ("size-download", NULL, NULL,
+ 0, G_MAXUINT64, 0,
+ G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * GsApp:size-download-dependencies-type
+ *
+ * The type of #GsApp:size-download-dependencies.
+ *
+ * Since: 43
+ */
+ obj_props[PROP_SIZE_DOWNLOAD_DEPENDENCIES_TYPE] =
+ g_param_spec_enum ("size-download-dependencies-type", NULL, NULL,
+ GS_TYPE_SIZE_TYPE, GS_SIZE_TYPE_UNKNOWN,
+ G_PARAM_READABLE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * GsApp:size-download-dependencies
+ *
+ * The size of the total download needed to either install or update
+ * this application's dependencies, in bytes. If the dependencies are partially
+ * downloaded, this is the number of bytes remaining to download.
+ *
+ * This is undefined if #GsApp:size-download-dependencies-type is not
+ * %GS_SIZE_TYPE_VALID.
+ *
+ * Since: 41
+ */
+ obj_props[PROP_SIZE_DOWNLOAD_DEPENDENCIES] =
+ g_param_spec_uint64 ("size-download-dependencies", NULL, NULL,
+ 0, G_MAXUINT64, 0,
+ G_PARAM_READABLE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * GsApp:size-installed-type
+ *
+ * The type of #GsApp:size-installed.
+ *
+ * Since: 43
+ */
+ obj_props[PROP_SIZE_INSTALLED_TYPE] =
+ g_param_spec_enum ("size-installed-type", NULL, NULL,
+ GS_TYPE_SIZE_TYPE, GS_SIZE_TYPE_UNKNOWN,
+ G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * GsApp:size-installed
+ *
+ * The size of the application on disk, in bytes. If the application is
+ * not yet installed, this is the size it would need, once installed.
+ *
+ * This is undefined if #GsApp:size-installed-type is not
+ * %GS_SIZE_TYPE_VALID.
+ *
+ * To get the application runtime or extensions installed sizes,
+ * use #GsApp:size-installed-dependencies.
+ *
+ * Since: 41
+ */
+ obj_props[PROP_SIZE_INSTALLED] =
+ g_param_spec_uint64 ("size-installed", NULL, NULL,
+ 0, G_MAXUINT64, 0,
+ G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * GsApp:size-installed-dependencies-type
+ *
+ * The type of #GsApp:size-installed-dependencies.
+ *
+ * Since: 43
+ */
+ obj_props[PROP_SIZE_INSTALLED_DEPENDENCIES_TYPE] =
+ g_param_spec_enum ("size-installed-dependencies-type", NULL, NULL,
+ GS_TYPE_SIZE_TYPE, GS_SIZE_TYPE_UNKNOWN,
+ G_PARAM_READABLE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * GsApp:size-installed-dependencies
+ *
+ * The size of the application's dependencies on disk, in bytes. If the dependencies are
+ * not yet installed, this is the size it would need, once installed.
+ *
+ * This is undefined if #GsApp:size-installed-dependencies-type is not
+ * %GS_SIZE_TYPE_VALID.
+ *
+ * Since: 41
+ */
+ obj_props[PROP_SIZE_INSTALLED_DEPENDENCIES] =
+ g_param_spec_uint64 ("size-installed-dependencies", NULL, NULL,
+ 0, G_MAXUINT64, 0,
+ G_PARAM_READABLE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * GsApp:size-user-data-type
+ *
+ * The type of #GsApp:size-user-data.
+ *
+ * Since: 43
+ */
+ obj_props[PROP_SIZE_USER_DATA_TYPE] =
+ g_param_spec_enum ("size-user-data-type", NULL, NULL,
+ GS_TYPE_SIZE_TYPE, GS_SIZE_TYPE_UNKNOWN,
+ G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * GsApp:size-user-data
+ *
+ * The size on the disk for the user data of the application.
+ *
+ * This is undefined if #GsApp:size-user-data-type is not
+ * %GS_SIZE_TYPE_VALID.
+ *
+ * Since: 41
+ */
+ obj_props[PROP_SIZE_USER_DATA] =
+ g_param_spec_uint64 ("size-user-data", NULL, NULL,
+ 0, G_MAXUINT64, 0,
+ G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * GsApp:permissions
+ *
+ * The permissions the app requires to run, as a #GsAppPermissions object.
+ *
+ * This is %NULL, if the permissions are unknown.
+ *
+ * Since: 43
+ */
+ obj_props[PROP_PERMISSIONS] =
+ g_param_spec_object ("permissions", NULL, NULL,
+ GS_TYPE_APP_PERMISSIONS,
+ G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * GsApp:relations: (nullable) (element-type AsRelation)
+ *
+ * Relations between this app and other things. For example,
+ * requirements or recommendations that the computer have certain input
+ * devices to use the app (the app requires a touchscreen or gamepad),
+ * or that the screen is a certain size.
+ *
+ * %NULL is equivalent to an empty array. Relations of kind
+ * %AS_RELATION_KIND_REQUIRES are conjunctive, so each additional
+ * relation further restricts the set of computers which can run the
+ * app. Relations of kind %AS_RELATION_KIND_RECOMMENDS and
+ * %AS_RELATION_KIND_SUPPORTS are disjunctive.
+ *
+ * Since: 41
+ */
+ obj_props[PROP_RELATIONS] =
+ g_param_spec_boxed ("relations", NULL, NULL,
+ G_TYPE_PTR_ARRAY,
+ G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * GsApp:origin-ui: (not nullable)
+ *
+ * The package origin, in a human readable format suitable for use in
+ * the UI. For example ‘Local file (RPM)’ or ‘Flathub (Flatpak)’.
+ *
+ * Since: 41
+ */
+ obj_props[PROP_ORIGIN_UI] =
+ g_param_spec_string ("origin-ui", NULL, NULL,
+ NULL,
+ G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * GsApp:has-translations
+ *
+ * Whether the app has any information about provided translations. If
+ * this is %TRUE, the app provides information about the translations
+ * it ships. If %FALSE, the app does not provide any information (but
+ * might ship translations which aren’t mentioned).
+ *
+ * Since: 41
+ */
+ obj_props[PROP_HAS_TRANSLATIONS] =
+ g_param_spec_boolean ("has-translations", NULL, NULL,
+ FALSE,
+ G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS);
+
+ g_object_class_install_properties (object_class, G_N_ELEMENTS (obj_props), obj_props);
+}
+
+static void
+gs_app_init (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ priv->rating = -1;
+ priv->sources = g_ptr_array_new_with_free_func (g_free);
+ priv->source_ids = g_ptr_array_new_with_free_func (g_free);
+ priv->categories = g_ptr_array_new_with_free_func (g_free);
+ priv->related = gs_app_list_new ();
+ priv->history = gs_app_list_new ();
+ priv->screenshots = g_ptr_array_new_with_free_func ((GDestroyNotify) g_object_unref);
+ priv->reviews = g_ptr_array_new_with_free_func ((GDestroyNotify) g_object_unref);
+ priv->provided = g_ptr_array_new_with_free_func ((GDestroyNotify) g_object_unref);
+ priv->metadata = g_hash_table_new_full (g_str_hash,
+ g_str_equal,
+ g_free,
+ (GDestroyNotify) g_variant_unref);
+ priv->launchables = g_hash_table_new_full (g_str_hash,
+ g_str_equal,
+ NULL,
+ g_free);
+ priv->allow_cancel = TRUE;
+ priv->size_download_type = GS_SIZE_TYPE_UNKNOWN;
+ priv->size_installed_type = GS_SIZE_TYPE_UNKNOWN;
+ priv->size_cache_data_type = GS_SIZE_TYPE_UNKNOWN;
+ priv->size_user_data_type = GS_SIZE_TYPE_UNKNOWN;
+ g_mutex_init (&priv->mutex);
+}
+
+/**
+ * gs_app_new:
+ * @id: an application ID, or %NULL, e.g. "org.gnome.Software.desktop"
+ *
+ * Creates a new application object.
+ *
+ * The ID should only be set when the application ID (with optional prefix) is
+ * known; it is perfectly valid to use gs_app_new() with an @id of %NULL, and
+ * then relying on another plugin to set the @id using gs_app_set_id() based on
+ * some other information.
+ *
+ * For instance, a #GsApp is created with no ID when returning results from the
+ * packagekit plugin, but with the default source name set as the package name.
+ * The source name is read by the appstream plugin, and if matched in the
+ * AppStream XML the correct ID is set, along with other higher quality data
+ * like the application icon and long description.
+ *
+ * Returns: a new #GsApp
+ *
+ * Since: 3.22
+ **/
+GsApp *
+gs_app_new (const gchar *id)
+{
+ GsApp *app;
+ app = g_object_new (GS_TYPE_APP,
+ "id", id,
+ NULL);
+ return GS_APP (app);
+}
+
+/**
+ * gs_app_set_from_unique_id:
+ * @app: a #GsApp
+ * @unique_id: an application unique ID, e.g.
+ * `system/flatpak/gnome/desktop/org.gnome.Software.desktop/master`
+ *
+ * Sets details on an application object.
+ *
+ * The unique ID will be parsed to set some information in the application such
+ * as the scope, bundle kind, id, etc.
+ *
+ * Since: 3.26
+ **/
+void
+gs_app_set_from_unique_id (GsApp *app, const gchar *unique_id, AsComponentKind kind)
+{
+ g_auto(GStrv) split = NULL;
+
+ g_return_if_fail (GS_IS_APP (app));
+ g_return_if_fail (unique_id != NULL);
+
+ if (kind != AS_COMPONENT_KIND_UNKNOWN)
+ gs_app_set_kind (app, kind);
+
+ split = g_strsplit (unique_id, "/", -1);
+ if (g_strv_length (split) != 5)
+ return;
+ if (g_strcmp0 (split[0], "*") != 0)
+ gs_app_set_scope (app, as_component_scope_from_string (split[0]));
+ if (g_strcmp0 (split[1], "*") != 0)
+ gs_app_set_bundle_kind (app, as_bundle_kind_from_string (split[1]));
+ if (g_strcmp0 (split[2], "*") != 0)
+ gs_app_set_origin (app, split[2]);
+ if (g_strcmp0 (split[3], "*") != 0)
+ gs_app_set_id (app, split[3]);
+ if (g_strcmp0 (split[4], "*") != 0)
+ gs_app_set_branch (app, split[4]);
+}
+
+/**
+ * gs_app_new_from_unique_id:
+ * @unique_id: an application unique ID, e.g.
+ * `system/flatpak/gnome/desktop/org.gnome.Software.desktop/master`
+ *
+ * Creates a new application object.
+ *
+ * The unique ID will be parsed to set some information in the application such
+ * as the scope, bundle kind, id, etc. Unlike gs_app_new(), it cannot take a
+ * %NULL argument.
+ *
+ * Returns: a new #GsApp
+ *
+ * Since: 3.22
+ **/
+GsApp *
+gs_app_new_from_unique_id (const gchar *unique_id)
+{
+ GsApp *app;
+ g_return_val_if_fail (unique_id != NULL, NULL);
+ app = gs_app_new (NULL);
+ gs_app_set_from_unique_id (app, unique_id, AS_COMPONENT_KIND_UNKNOWN);
+ return app;
+}
+
+/**
+ * gs_app_dup_origin_ui:
+ * @app: a #GsApp
+ * @with_packaging_format: %TRUE, to include also packaging format
+ *
+ * Gets the package origin that's suitable for UI use, i.e. the value of
+ * #GsApp:origin-ui.
+ *
+ * Returns: (not nullable) (transfer full): The package origin for UI use
+ *
+ * Since: 43
+ **/
+gchar *
+gs_app_dup_origin_ui (GsApp *app,
+ gboolean with_packaging_format)
+{
+ GsAppPrivate *priv;
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_autoptr(GsOsRelease) os_release = NULL;
+ const gchar *origin_str = NULL;
+
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+
+ /* use the distro name for official packages */
+ if (gs_app_has_quirk (app, GS_APP_QUIRK_PROVENANCE) &&
+ gs_app_get_kind (app) != AS_COMPONENT_KIND_REPOSITORY) {
+ os_release = gs_os_release_new (NULL);
+ if (os_release != NULL)
+ origin_str = gs_os_release_get_name (os_release);
+ }
+
+ priv = gs_app_get_instance_private (app);
+ locker = g_mutex_locker_new (&priv->mutex);
+
+ if (!origin_str) {
+ origin_str = priv->origin_ui;
+
+ if (origin_str == NULL || origin_str[0] == '\0') {
+ /* use "Local file" rather than the filename for local files */
+ if (gs_app_get_state (app) == GS_APP_STATE_AVAILABLE_LOCAL ||
+ gs_app_get_local_file (app) != NULL)
+ origin_str = _("Local file");
+ else if (g_strcmp0 (gs_app_get_origin (app), "flathub") == 0)
+ origin_str = "Flathub";
+ else if (g_strcmp0 (gs_app_get_origin (app), "flathub-beta") == 0)
+ origin_str = "Flathub Beta";
+ else
+ origin_str = gs_app_get_origin (app);
+ }
+ }
+
+ if (with_packaging_format) {
+ g_autofree gchar *packaging_format = NULL;
+
+ packaging_format = gs_app_get_packaging_format (app);
+
+ if (packaging_format) {
+ /* TRANSLATORS: the first %s is replaced with an origin name;
+ the second %s is replaced with the packaging format.
+ Example string: "Local file (RPM)" */
+ return g_strdup_printf (_("%s (%s)"), origin_str, packaging_format);
+ }
+ }
+
+ return g_strdup (origin_str);
+}
+
+/**
+ * gs_app_set_origin_ui:
+ * @app: a #GsApp
+ * @origin_ui: (not nullable): the new origin UI
+ *
+ * Set the value of #GsApp:origin-ui.
+ */
+void
+gs_app_set_origin_ui (GsApp *app,
+ const gchar *origin_ui)
+{
+ GsAppPrivate *priv;
+ g_autoptr(GMutexLocker) locker = NULL;
+
+ g_return_if_fail (GS_IS_APP (app));
+
+ priv = gs_app_get_instance_private (app);
+ locker = g_mutex_locker_new (&priv->mutex);
+
+ if (origin_ui && !*origin_ui)
+ origin_ui = NULL;
+
+ if (g_strcmp0 (priv->origin_ui, origin_ui) == 0)
+ return;
+
+ g_free (priv->origin_ui);
+ priv->origin_ui = g_strdup (origin_ui);
+ gs_app_queue_notify (app, obj_props[PROP_ORIGIN_UI]);
+}
+
+/**
+ * gs_app_get_packaging_format:
+ * @app: a #GsApp
+ *
+ * Gets the packaging format, e.g. 'RPM' or 'Flatpak'.
+ *
+ * Returns: The packaging format
+ *
+ * Since: 3.32
+ **/
+gchar *
+gs_app_get_packaging_format (GsApp *app)
+{
+ AsBundleKind bundle_kind;
+ const gchar *bundle_kind_ui;
+ const gchar *packaging_format;
+
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+
+ /* does the app have packaging format set? */
+ packaging_format = gs_app_get_metadata_item (app, "GnomeSoftware::PackagingFormat");
+ if (packaging_format != NULL)
+ return g_strdup (packaging_format);
+
+ /* fall back to bundle kind */
+ bundle_kind = gs_app_get_bundle_kind (app);
+ switch (bundle_kind) {
+ case AS_BUNDLE_KIND_UNKNOWN:
+ bundle_kind_ui = NULL;
+ break;
+ case AS_BUNDLE_KIND_LIMBA:
+ bundle_kind_ui = "Limba";
+ break;
+ case AS_BUNDLE_KIND_FLATPAK:
+ bundle_kind_ui = "Flatpak";
+ break;
+ case AS_BUNDLE_KIND_SNAP:
+ bundle_kind_ui = "Snap";
+ break;
+ case AS_BUNDLE_KIND_PACKAGE:
+ bundle_kind_ui = _("Package");
+ break;
+ case AS_BUNDLE_KIND_CABINET:
+ bundle_kind_ui = "Cabinet";
+ break;
+ case AS_BUNDLE_KIND_APPIMAGE:
+ bundle_kind_ui = "AppImage";
+ break;
+ default:
+ g_warning ("unhandled bundle kind %s", as_bundle_kind_to_string (bundle_kind));
+ bundle_kind_ui = as_bundle_kind_to_string (bundle_kind);
+ }
+
+ return g_strdup (bundle_kind_ui);
+}
+
+/**
+ * gs_app_get_packaging_format_raw:
+ * @app: a #GsApp
+ *
+ * Similar to gs_app_get_packaging_format(), but it does not return a newly
+ * allocated string and the value is not suitable for the UI. Depending on
+ * the plugin, it can be "deb", "flatpak", "package", "RPM", "snap", ....
+ *
+ * Returns: The raw value of the packaging format
+ *
+ * Since: 41
+ **/
+const gchar *
+gs_app_get_packaging_format_raw (GsApp *app)
+{
+ const gchar *packaging_format;
+
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+
+ packaging_format = gs_app_get_metadata_item (app, "GnomeSoftware::PackagingFormat");
+ if (packaging_format != NULL)
+ return packaging_format;
+
+ return as_bundle_kind_to_string (gs_app_get_bundle_kind (app));
+}
+
+/**
+ * gs_app_subsume_metadata:
+ * @app: a #GsApp
+ * @donor: another #GsApp
+ *
+ * Copies any metadata from @donor to @app.
+ *
+ * Since: 3.32
+ **/
+void
+gs_app_subsume_metadata (GsApp *app, GsApp *donor)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (donor);
+ g_autoptr(GList) keys = NULL;
+
+ g_return_if_fail (GS_IS_APP (app));
+ g_return_if_fail (GS_IS_APP (donor));
+
+ keys = g_hash_table_get_keys (priv->metadata);
+ for (GList *l = keys; l != NULL; l = l->next) {
+ const gchar *key = l->data;
+ GVariant *tmp = gs_app_get_metadata_variant (donor, key);
+ if (gs_app_get_metadata_variant (app, key) != NULL)
+ continue;
+ gs_app_set_metadata_variant (app, key, tmp);
+ }
+}
+
+/**
+ * gs_app_dup_permissions:
+ * @app: a #GsApp
+ *
+ * Get a reference to the @app permissions. The returned value can
+ * be %NULL, when the app's permissions are unknown. Free the returned pointer,
+ * if not %NULL, with g_object_unref(), when no longer needed.
+ *
+ * Returns: (nullable) (transfer full): referenced #GsAppPermissions,
+ * or %NULL
+ *
+ * Since: 43
+ **/
+GsAppPermissions *
+gs_app_dup_permissions (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ locker = g_mutex_locker_new (&priv->mutex);
+ return priv->permissions ? g_object_ref (priv->permissions) : NULL;
+}
+
+/**
+ * gs_app_set_permissions:
+ * @app: a #GsApp
+ * @permissions: (nullable) (transfer none): a #GsAppPermissions, or %NULL
+ *
+ * Set permissions for the @app. The @permissions is referenced,
+ * if not %NULL.
+ *
+ * Note the @permissions need to be sealed.
+ *
+ * Since: 43
+ **/
+void
+gs_app_set_permissions (GsApp *app,
+ GsAppPermissions *permissions)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+ g_return_if_fail (permissions == NULL || gs_app_permissions_is_sealed (permissions));
+
+ locker = g_mutex_locker_new (&priv->mutex);
+ if (priv->permissions == permissions)
+ return;
+ g_clear_object (&priv->permissions);
+ if (permissions != NULL)
+ priv->permissions = g_object_ref (permissions);
+ gs_app_queue_notify (app, obj_props[PROP_PERMISSIONS]);
+}
+
+/**
+ * gs_app_dup_update_permissions:
+ * @app: a #GsApp
+ *
+ * Get a reference to the update permissions. The returned value can
+ * be %NULL, when no update permissions had been set. Free
+ * the returned pointer, if not %NULL, with g_object_unref(), when
+ * no longer needed.
+ *
+ * Returns: (nullable) (transfer full): referenced #GsAppPermissions,
+ * or %NULL
+ *
+ * Since: 43
+ **/
+GsAppPermissions *
+gs_app_dup_update_permissions (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+ locker = g_mutex_locker_new (&priv->mutex);
+ return priv->update_permissions ? g_object_ref (priv->update_permissions) : NULL;
+}
+
+/**
+ * gs_app_set_update_permissions:
+ * @app: a #GsApp
+ * @update_permissions: (nullable) (transfer none): a #GsAppPermissions, or %NULL
+ *
+ * Set update permissions for the @app, that is, the permissions, which change
+ * in an update or similar reasons. The @update_permissions is referenced,
+ * if not %NULL.
+ *
+ * Note the @update_permissions need to be sealed.
+ *
+ * Since: 43
+ **/
+void
+gs_app_set_update_permissions (GsApp *app,
+ GsAppPermissions *update_permissions)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+ g_return_if_fail (update_permissions == NULL || gs_app_permissions_is_sealed (update_permissions));
+ locker = g_mutex_locker_new (&priv->mutex);
+ if (priv->update_permissions != update_permissions) {
+ g_clear_object (&priv->update_permissions);
+ if (update_permissions != NULL)
+ priv->update_permissions = g_object_ref (update_permissions);
+ }
+}
+
+/**
+ * gs_app_get_version_history:
+ * @app: a #GsApp
+ *
+ * Gets the list of past releases for an application (including the latest
+ * one).
+ *
+ * Returns: (element-type AsRelease) (transfer container) (nullable): a list, or
+ * %NULL if the version history is not known
+ *
+ * Since: 41
+ **/
+GPtrArray *
+gs_app_get_version_history (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+
+ locker = g_mutex_locker_new (&priv->mutex);
+ if (priv->version_history == NULL)
+ return NULL;
+ return g_ptr_array_ref (priv->version_history);
+}
+
+/**
+ * gs_app_set_version_history:
+ * @app: a #GsApp
+ * @version_history: (element-type AsRelease) (nullable): a set of entries
+ * representing the version history, or %NULL if none are known
+ *
+ * Set the list of past releases for an application (including the latest one).
+ *
+ * Since: 40
+ **/
+void
+gs_app_set_version_history (GsApp *app, GPtrArray *version_history)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_return_if_fail (GS_IS_APP (app));
+
+ if (version_history != NULL && version_history->len == 0)
+ version_history = NULL;
+
+ locker = g_mutex_locker_new (&priv->mutex);
+ _g_set_ptr_array (&priv->version_history, version_history);
+}
+
+/**
+ * gs_app_ensure_icons_downloaded:
+ * @app: a #GsApp
+ * @soup_session: a #SoupSession
+ * @maximum_icon_size: maximum icon size
+ * @cancellable: (nullable): optional #GCancellable object
+ *
+ * Ensure all remote icons in the @app's icons are locally cached.
+ *
+ * Since: 41
+ **/
+void
+gs_app_ensure_icons_downloaded (GsApp *app,
+ SoupSession *soup_session,
+ guint maximum_icon_size,
+ GCancellable *cancellable)
+{
+ GsAppPrivate *priv;
+ g_autoptr(GMutexLocker) locker = NULL;
+ GPtrArray *icons;
+ guint i;
+
+ g_return_if_fail (GS_IS_APP (app));
+
+ priv = gs_app_get_instance_private (app);
+ locker = g_mutex_locker_new (&priv->mutex);
+
+ /* process all icons */
+ icons = priv->icons;
+
+ for (i = 0; icons != NULL && i < icons->len; i++) {
+ GIcon *icon = g_ptr_array_index (icons, i);
+ g_autoptr(GError) error_local = NULL;
+
+ /* Only remote icons need to be cached. */
+ if (!GS_IS_REMOTE_ICON (icon))
+ continue;
+
+ if (!gs_remote_icon_ensure_cached (GS_REMOTE_ICON (icon),
+ soup_session,
+ maximum_icon_size,
+ cancellable,
+ &error_local)) {
+ /* we failed, but keep going */
+ g_debug ("failed to cache icon for %s: %s",
+ gs_app_get_id (app),
+ error_local->message);
+ }
+ }
+}
+
+/**
+ * gs_app_get_relations:
+ * @app: a #GsApp
+ *
+ * Gets the value of #GsApp:relations. %NULL is equivalent to an empty array.
+ *
+ * The returned array should not be modified.
+ *
+ * Returns: (transfer container) (element-type AsRelation) (nullable): the value of
+ * #GsApp:relations, or %NULL
+ * Since: 41
+ */
+GPtrArray *
+gs_app_get_relations (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+
+ g_return_val_if_fail (GS_IS_APP (app), NULL);
+
+ locker = g_mutex_locker_new (&priv->mutex);
+ return (priv->relations != NULL) ? g_ptr_array_ref (priv->relations) : NULL;
+}
+
+/**
+ * gs_app_add_relation:
+ * @app: a #GsApp
+ * @relation: (transfer none) (not nullable): a new #AsRelation to add to the app
+ *
+ * Adds @relation to #GsApp:relations. @relation must have all its properties
+ * set already.
+ *
+ * Since: 41
+ */
+void
+gs_app_add_relation (GsApp *app,
+ AsRelation *relation)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+
+ g_return_if_fail (GS_IS_APP (app));
+ g_return_if_fail (AS_IS_RELATION (relation));
+
+ locker = g_mutex_locker_new (&priv->mutex);
+
+ if (priv->relations == NULL)
+ priv->relations = g_ptr_array_new_with_free_func (g_object_unref);
+ g_ptr_array_add (priv->relations, g_object_ref (relation));
+
+ gs_app_queue_notify (app, obj_props[PROP_RELATIONS]);
+}
+
+/**
+ * gs_app_set_relations:
+ * @app: a #GsApp
+ * @relations: (element-type AsRelation) (nullable) (transfer none): a new set
+ * of relations for #GsApp:relations; %NULL represents an empty array
+ *
+ * Set #GsApp:relations to @relations, replacing its previous value. %NULL is
+ * equivalent to an empty array.
+ *
+ * Since: 41
+ */
+void
+gs_app_set_relations (GsApp *app,
+ GPtrArray *relations)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_autoptr(GPtrArray) old_relations = NULL;
+
+ g_return_if_fail (GS_IS_APP (app));
+
+ locker = g_mutex_locker_new (&priv->mutex);
+
+ if (relations == NULL && priv->relations == NULL)
+ return;
+
+ if (priv->relations != NULL)
+ old_relations = g_steal_pointer (&priv->relations);
+
+ if (relations != NULL)
+ priv->relations = g_ptr_array_ref (relations);
+
+ gs_app_queue_notify (app, obj_props[PROP_RELATIONS]);
+}
+
+/**
+ * gs_app_get_has_translations:
+ * @app: a #GsApp
+ *
+ * Get the value of #GsApp:has-translations.
+ *
+ * Returns: %TRUE if the app has translation metadata, %FALSE otherwise
+ * Since: 41
+ */
+gboolean
+gs_app_get_has_translations (GsApp *app)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+
+ g_return_val_if_fail (GS_IS_APP (app), FALSE);
+
+ return priv->has_translations;
+}
+
+/**
+ * gs_app_set_has_translations:
+ * @app: a #GsApp
+ * @has_translations: %TRUE if the app has translation metadata, %FALSE otherwise
+ *
+ * Set the value of #GsApp:has-translations.
+ *
+ * Since: 41
+ */
+void
+gs_app_set_has_translations (GsApp *app,
+ gboolean has_translations)
+{
+ GsAppPrivate *priv = gs_app_get_instance_private (app);
+ g_autoptr(GMutexLocker) locker = NULL;
+
+ g_return_if_fail (GS_IS_APP (app));
+
+ locker = g_mutex_locker_new (&priv->mutex);
+
+ if (priv->has_translations == has_translations)
+ return;
+
+ priv->has_translations = has_translations;
+ gs_app_queue_notify (app, obj_props[PROP_HAS_TRANSLATIONS]);
+}
+
+/**
+ * gs_app_is_downloaded:
+ * @app: a #GsApp
+ *
+ * Returns whether the @app is downloaded for updates or not,
+ * considering also its dependencies.
+ *
+ * Returns: %TRUE, when the @app is downloaded, %FALSE otherwise
+ *
+ * Since: 43
+ **/
+gboolean
+gs_app_is_downloaded (GsApp *app)
+{
+ GsSizeType size_type;
+ guint64 size_bytes = 0;
+
+ g_return_val_if_fail (GS_IS_APP (app), FALSE);
+
+ if (!gs_app_has_quirk (app, GS_APP_QUIRK_IS_PROXY)) {
+ size_type = gs_app_get_size_download (app, &size_bytes);
+ if (size_type != GS_SIZE_TYPE_VALID || size_bytes != 0)
+ return FALSE;
+ }
+
+ size_type = gs_app_get_size_download_dependencies (app, &size_bytes);
+ if (size_type != GS_SIZE_TYPE_VALID || size_bytes != 0)
+ return FALSE;
+
+ return TRUE;
+}
diff --git a/lib/gs-app.h b/lib/gs-app.h
new file mode 100644
index 0000000..5240b2e
--- /dev/null
+++ b/lib/gs-app.h
@@ -0,0 +1,524 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2013-2018 Richard Hughes <richard@hughsie.com>
+ * Copyright (C) 2014-2018 Kalev Lember <klember@redhat.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#include <glib-object.h>
+#include <gdk/gdk.h>
+#include <gdk-pixbuf/gdk-pixbuf.h>
+#include <libsoup/soup.h>
+#include <appstream.h>
+
+#include <gs-app-permissions.h>
+
+G_BEGIN_DECLS
+
+/* Dependency loop means we can’t include the header. */
+typedef struct _GsPlugin GsPlugin;
+typedef struct _GsAppList GsAppList;
+
+#define GS_TYPE_APP (gs_app_get_type ())
+
+G_DECLARE_DERIVABLE_TYPE (GsApp, gs_app, GS, APP, GObject)
+
+struct _GsAppClass
+{
+ GObjectClass parent_class;
+ void (*to_string) (GsApp *app,
+ GString *str);
+ gpointer padding[30];
+};
+
+/**
+ * GsAppState:
+ * @GS_APP_STATE_UNKNOWN: Unknown state
+ * @GS_APP_STATE_INSTALLED: Application is installed
+ * @GS_APP_STATE_AVAILABLE: Application is available
+ * @GS_APP_STATE_AVAILABLE_LOCAL: Application is locally available as a file
+ * @GS_APP_STATE_UPDATABLE: Application is installed and updatable
+ * @GS_APP_STATE_UNAVAILABLE: Application is referenced, but not available
+ * @GS_APP_STATE_QUEUED_FOR_INSTALL: Application is queued for install
+ * @GS_APP_STATE_INSTALLING: Application is being installed
+ * @GS_APP_STATE_REMOVING: Application is being removed
+ * @GS_APP_STATE_UPDATABLE_LIVE: Application is installed and updatable live
+ * @GS_APP_STATE_PURCHASABLE: Application is available for purchasing
+ * @GS_APP_STATE_PURCHASING: Application is being purchased
+ * @GS_APP_STATE_PENDING_INSTALL: Application is installed, but may have pending some actions,
+ * like restart, to finish it
+ * @GS_APP_STATE_PENDING_REMOVE: Application is removed, but may have pending some actions,
+ * like restart, to finish it
+ *
+ * The application state.
+ **/
+typedef enum {
+ GS_APP_STATE_UNKNOWN, /* Since: 0.2.2 */
+ GS_APP_STATE_INSTALLED, /* Since: 0.2.2 */
+ GS_APP_STATE_AVAILABLE, /* Since: 0.2.2 */
+ GS_APP_STATE_AVAILABLE_LOCAL, /* Since: 0.2.2 */
+ GS_APP_STATE_UPDATABLE, /* Since: 0.2.2 */
+ GS_APP_STATE_UNAVAILABLE, /* Since: 0.2.2 */
+ GS_APP_STATE_QUEUED_FOR_INSTALL, /* Since: 0.2.2 */
+ GS_APP_STATE_INSTALLING, /* Since: 0.2.2 */
+ GS_APP_STATE_REMOVING, /* Since: 0.2.2 */
+ GS_APP_STATE_UPDATABLE_LIVE, /* Since: 0.5.4 */
+ GS_APP_STATE_PURCHASABLE, /* Since: 0.5.17 */
+ GS_APP_STATE_PURCHASING, /* Since: 0.5.17 */
+ GS_APP_STATE_PENDING_INSTALL, /* Since: 41 */
+ GS_APP_STATE_PENDING_REMOVE, /* Since: 41 */
+ GS_APP_STATE_LAST /*< skip >*/
+} GsAppState;
+
+/**
+ * GsAppSpecialKind:
+ * @GS_APP_SPECIAL_KIND_NONE: No special occupation
+ * @GS_APP_SPECIAL_KIND_OS_UPDATE: Application represents an OS update
+ *
+ * A special occupation for #GsApp. #AsComponentKind can not represent certain
+ * GNOME Software specific features, like representing a #GsApp as OS updates
+ * which have no associated AppStream entry.
+ * They are represented by a #GsApp of kind %AS_COMPONENT_KIND_GENERIC and a value
+ * from #GsAppSpecialKind. which does not match any AppStream component type.
+ **/
+typedef enum {
+ GS_APP_SPECIAL_KIND_NONE, /* Since: 40 */
+ GS_APP_SPECIAL_KIND_OS_UPDATE, /* Since: 40 */
+} GsAppSpecialKind;
+
+/**
+ * GsAppKudo:
+ * @GS_APP_KUDO_MY_LANGUAGE: Localised in my language
+ * @GS_APP_KUDO_RECENT_RELEASE: Released recently
+ * @GS_APP_KUDO_FEATURED_RECOMMENDED: Chosen for the front page
+ * @GS_APP_KUDO_MODERN_TOOLKIT: Uses a modern toolkit
+ * @GS_APP_KUDO_SEARCH_PROVIDER: Provides a search provider
+ * @GS_APP_KUDO_INSTALLS_USER_DOCS: Installs user docs
+ * @GS_APP_KUDO_USES_NOTIFICATIONS: Registers notifications
+ * @GS_APP_KUDO_HAS_KEYWORDS: Has at least 1 keyword
+ * @GS_APP_KUDO_HAS_SCREENSHOTS: Supplies screenshots
+ * @GS_APP_KUDO_HIGH_CONTRAST: Installs a high contrast icon
+ * @GS_APP_KUDO_HI_DPI_ICON: Installs a HiDPI icon
+ * @GS_APP_KUDO_SANDBOXED: Application is sandboxed
+ * @GS_APP_KUDO_SANDBOXED_SECURE: Application is sandboxed securely
+ *
+ * Any awards given to the application.
+ **/
+typedef enum {
+ GS_APP_KUDO_MY_LANGUAGE = 1 << 0,
+ GS_APP_KUDO_RECENT_RELEASE = 1 << 1,
+ GS_APP_KUDO_FEATURED_RECOMMENDED = 1 << 2,
+ GS_APP_KUDO_MODERN_TOOLKIT = 1 << 3,
+ GS_APP_KUDO_SEARCH_PROVIDER = 1 << 4,
+ GS_APP_KUDO_INSTALLS_USER_DOCS = 1 << 5,
+ GS_APP_KUDO_USES_NOTIFICATIONS = 1 << 6,
+ GS_APP_KUDO_HAS_KEYWORDS = 1 << 7,
+ GS_APP_KUDO_HAS_SCREENSHOTS = 1 << 9,
+ GS_APP_KUDO_HIGH_CONTRAST = 1 << 13,
+ GS_APP_KUDO_HI_DPI_ICON = 1 << 14,
+ GS_APP_KUDO_SANDBOXED = 1 << 15,
+ GS_APP_KUDO_SANDBOXED_SECURE = 1 << 16,
+ GS_APP_KUDO_LAST /*< skip >*/
+} GsAppKudo;
+
+/**
+ * GsAppQuirk:
+ * @GS_APP_QUIRK_NONE: No special attributes
+ * @GS_APP_QUIRK_PROVENANCE: Installed by OS vendor
+ * @GS_APP_QUIRK_COMPULSORY: Cannot be removed
+ * @GS_APP_QUIRK_HAS_SOURCE: Has a source to allow staying up-to-date
+ * @GS_APP_QUIRK_IS_WILDCARD: Matches applications from any plugin
+ * @GS_APP_QUIRK_NEEDS_REBOOT: A reboot is required after the action
+ * @GS_APP_QUIRK_NOT_REVIEWABLE: The app is not reviewable
+ * @GS_APP_QUIRK_NOT_LAUNCHABLE: The app is not launchable (run-able)
+ * @GS_APP_QUIRK_NEEDS_USER_ACTION: The component requires some kind of user action
+ * @GS_APP_QUIRK_IS_PROXY: Is a proxy app that operates on other applications
+ * @GS_APP_QUIRK_REMOVABLE_HARDWARE: The device is unusable whilst the action is performed
+ * @GS_APP_QUIRK_DEVELOPER_VERIFIED: The app developer has been verified
+ * @GS_APP_QUIRK_PARENTAL_FILTER: The app has been filtered by parental controls, and should be hidden
+ * @GS_APP_QUIRK_NEW_PERMISSIONS: The update requires new permissions
+ * @GS_APP_QUIRK_PARENTAL_NOT_LAUNCHABLE: The app cannot be run by the current user due to parental controls, and should not be launchable
+ * @GS_APP_QUIRK_HIDE_FROM_SEARCH: The app should not be shown in search results
+ * @GS_APP_QUIRK_HIDE_EVERYWHERE: The app should not be shown anywhere (it’s blocklisted)
+ * @GS_APP_QUIRK_DO_NOT_AUTO_UPDATE: The app should not be automatically updated
+ * @GS_APP_QUIRK_DEVELOPMENT_SOURCE: The app is from a development source (Since: 43)
+ *
+ * The application attributes.
+ **/
+typedef enum {
+ GS_APP_QUIRK_NONE = 0, /* Since: 3.32 */
+ GS_APP_QUIRK_PROVENANCE = 1 << 0, /* Since: 3.32 */
+ GS_APP_QUIRK_COMPULSORY = 1 << 1, /* Since: 3.32 */
+ GS_APP_QUIRK_HAS_SOURCE = 1 << 2, /* Since: 3.32 */
+ GS_APP_QUIRK_IS_WILDCARD = 1 << 3, /* Since: 3.32 */
+ GS_APP_QUIRK_NEEDS_REBOOT = 1 << 4, /* Since: 3.32 */
+ GS_APP_QUIRK_NOT_REVIEWABLE = 1 << 5, /* Since: 3.32 */
+ /* there’s a hole here where GS_APP_QUIRK_HAS_SHORTCUT used to be */
+ GS_APP_QUIRK_NOT_LAUNCHABLE = 1 << 7, /* Since: 3.32 */
+ GS_APP_QUIRK_NEEDS_USER_ACTION = 1 << 8, /* Since: 3.32 */
+ GS_APP_QUIRK_IS_PROXY = 1 << 9, /* Since: 3.32 */
+ GS_APP_QUIRK_REMOVABLE_HARDWARE = 1 << 10, /* Since: 3.32 */
+ GS_APP_QUIRK_DEVELOPER_VERIFIED = 1 << 11, /* Since: 3.32 */
+ GS_APP_QUIRK_PARENTAL_FILTER = 1 << 12, /* Since: 3.32 */
+ GS_APP_QUIRK_NEW_PERMISSIONS = 1 << 13, /* Since: 3.32 */
+ GS_APP_QUIRK_PARENTAL_NOT_LAUNCHABLE = 1 << 14, /* Since: 3.32 */
+ GS_APP_QUIRK_HIDE_FROM_SEARCH = 1 << 15, /* Since: 3.32 */
+ GS_APP_QUIRK_HIDE_EVERYWHERE = 1 << 16, /* Since: 3.36 */
+ GS_APP_QUIRK_DO_NOT_AUTO_UPDATE = 1 << 17, /* Since: 3.36 */
+ GS_APP_QUIRK_DEVELOPMENT_SOURCE = 1 << 18, /* Since: 43 */
+ GS_APP_QUIRK_LAST /*< skip >*/
+} GsAppQuirk;
+
+#define GS_APP_INSTALL_DATE_UNSET 0
+#define GS_APP_INSTALL_DATE_UNKNOWN 1 /* 1s past the epoch */
+
+/**
+ * GsSizeType:
+ * @GS_SIZE_TYPE_UNKNOWN: Size is unknown
+ * @GS_SIZE_TYPE_UNKNOWABLE: Size is unknown and is impossible to calculate
+ * @GS_SIZE_TYPE_VALID: Size is known and valid
+ *
+ * Types of download or file size for applications.
+ *
+ * These are used to represent the validity of properties like
+ * #GsApp:size-download.
+ *
+ * Since: 43
+ */
+typedef enum {
+ GS_SIZE_TYPE_UNKNOWN,
+ GS_SIZE_TYPE_UNKNOWABLE,
+ GS_SIZE_TYPE_VALID,
+} GsSizeType;
+
+/**
+ * GsAppQuality:
+ * @GS_APP_QUALITY_UNKNOWN: The quality value is unknown
+ * @GS_APP_QUALITY_LOWEST: Lowest quality
+ * @GS_APP_QUALITY_NORMAL: Normal quality
+ * @GS_APP_QUALITY_HIGHEST: Highest quality
+ *
+ * Any awards given to the application.
+ **/
+typedef enum {
+ GS_APP_QUALITY_UNKNOWN,
+ GS_APP_QUALITY_LOWEST,
+ GS_APP_QUALITY_NORMAL,
+ GS_APP_QUALITY_HIGHEST,
+ GS_APP_QUALITY_LAST /*< skip >*/
+} GsAppQuality;
+
+/**
+ * GS_APP_PROGRESS_UNKNOWN:
+ *
+ * A value returned by gs_app_get_progress() if the app’s progress is unknown
+ * or has a wide confidence interval. Typically this would be represented in the
+ * UI using a pulsing progress bar or spinner.
+ *
+ * Since: 3.38
+ */
+#define GS_APP_PROGRESS_UNKNOWN G_MAXUINT
+
+const gchar *gs_app_state_to_string (GsAppState state);
+
+GsApp *gs_app_new (const gchar *id);
+G_DEPRECATED_FOR(gs_app_set_from_unique_id)
+GsApp *gs_app_new_from_unique_id (const gchar *unique_id);
+void gs_app_set_from_unique_id (GsApp *app,
+ const gchar *unique_id,
+ AsComponentKind kind);
+gchar *gs_app_to_string (GsApp *app);
+void gs_app_to_string_append (GsApp *app,
+ GString *str);
+
+const gchar *gs_app_get_id (GsApp *app);
+void gs_app_set_id (GsApp *app,
+ const gchar *id);
+AsComponentKind gs_app_get_kind (GsApp *app);
+void gs_app_set_kind (GsApp *app,
+ AsComponentKind kind);
+GsAppState gs_app_get_state (GsApp *app);
+void gs_app_set_state (GsApp *app,
+ GsAppState state);
+AsComponentScope gs_app_get_scope (GsApp *app);
+void gs_app_set_scope (GsApp *app,
+ AsComponentScope scope);
+AsBundleKind gs_app_get_bundle_kind (GsApp *app);
+void gs_app_set_bundle_kind (GsApp *app,
+ AsBundleKind bundle_kind);
+GsAppSpecialKind gs_app_get_special_kind (GsApp *app);
+void gs_app_set_special_kind (GsApp *app,
+ GsAppSpecialKind kind);
+void gs_app_set_state_recover (GsApp *app);
+guint gs_app_get_progress (GsApp *app);
+void gs_app_set_progress (GsApp *app,
+ guint percentage);
+gboolean gs_app_get_allow_cancel (GsApp *app);
+void gs_app_set_allow_cancel (GsApp *app,
+ gboolean allow_cancel);
+const gchar *gs_app_get_unique_id (GsApp *app);
+const gchar *gs_app_get_branch (GsApp *app);
+void gs_app_set_branch (GsApp *app,
+ const gchar *branch);
+const gchar *gs_app_get_name (GsApp *app);
+void gs_app_set_name (GsApp *app,
+ GsAppQuality quality,
+ const gchar *name);
+const gchar *gs_app_get_renamed_from (GsApp *app);
+void gs_app_set_renamed_from (GsApp *app,
+ const gchar *renamed_from);
+const gchar *gs_app_get_source_default (GsApp *app);
+void gs_app_add_source (GsApp *app,
+ const gchar *source);
+GPtrArray *gs_app_get_sources (GsApp *app);
+void gs_app_set_sources (GsApp *app,
+ GPtrArray *sources);
+const gchar *gs_app_get_source_id_default (GsApp *app);
+void gs_app_add_source_id (GsApp *app,
+ const gchar *source_id);
+GPtrArray *gs_app_get_source_ids (GsApp *app);
+void gs_app_set_source_ids (GsApp *app,
+ GPtrArray *source_ids);
+void gs_app_clear_source_ids (GsApp *app);
+const gchar *gs_app_get_project_group (GsApp *app);
+void gs_app_set_project_group (GsApp *app,
+ const gchar *project_group);
+const gchar *gs_app_get_developer_name (GsApp *app);
+void gs_app_set_developer_name (GsApp *app,
+ const gchar *developer_name);
+const gchar *gs_app_get_agreement (GsApp *app);
+void gs_app_set_agreement (GsApp *app,
+ const gchar *agreement);
+const gchar *gs_app_get_version (GsApp *app);
+const gchar *gs_app_get_version_ui (GsApp *app);
+void gs_app_set_version (GsApp *app,
+ const gchar *version);
+const gchar *gs_app_get_summary (GsApp *app);
+void gs_app_set_summary (GsApp *app,
+ GsAppQuality quality,
+ const gchar *summary);
+const gchar *gs_app_get_summary_missing (GsApp *app);
+void gs_app_set_summary_missing (GsApp *app,
+ const gchar *summary_missing);
+const gchar *gs_app_get_description (GsApp *app);
+void gs_app_set_description (GsApp *app,
+ GsAppQuality quality,
+ const gchar *description);
+const gchar *gs_app_get_url (GsApp *app,
+ AsUrlKind kind);
+void gs_app_set_url (GsApp *app,
+ AsUrlKind kind,
+ const gchar *url);
+const gchar *gs_app_get_url_missing (GsApp *app);
+void gs_app_set_url_missing (GsApp *app,
+ const gchar *url);
+const gchar *gs_app_get_launchable (GsApp *app,
+ AsLaunchableKind kind);
+void gs_app_set_launchable (GsApp *app,
+ AsLaunchableKind kind,
+ const gchar *launchable);
+const gchar *gs_app_get_license (GsApp *app);
+gboolean gs_app_get_license_is_free (GsApp *app);
+void gs_app_set_license (GsApp *app,
+ GsAppQuality quality,
+ const gchar *license);
+gchar **gs_app_get_menu_path (GsApp *app);
+void gs_app_set_menu_path (GsApp *app,
+ gchar **menu_path);
+const gchar *gs_app_get_origin (GsApp *app);
+void gs_app_set_origin (GsApp *app,
+ const gchar *origin);
+const gchar *gs_app_get_origin_appstream (GsApp *app);
+void gs_app_set_origin_appstream (GsApp *app,
+ const gchar *origin_appstream);
+const gchar *gs_app_get_origin_hostname (GsApp *app);
+void gs_app_set_origin_hostname (GsApp *app,
+ const gchar *origin_hostname);
+GPtrArray *gs_app_get_screenshots (GsApp *app);
+void gs_app_add_screenshot (GsApp *app,
+ AsScreenshot *screenshot);
+AsScreenshot *gs_app_get_action_screenshot (GsApp *app);
+void gs_app_set_action_screenshot (GsApp *app,
+ AsScreenshot *screenshot);
+const gchar *gs_app_get_update_version (GsApp *app);
+const gchar *gs_app_get_update_version_ui (GsApp *app);
+void gs_app_set_update_version (GsApp *app,
+ const gchar *update_version);
+const gchar *gs_app_get_update_details_markup
+ (GsApp *app);
+void gs_app_set_update_details_markup
+ (GsApp *app,
+ const gchar *markup);
+void gs_app_set_update_details_text (GsApp *app,
+ const gchar *text);
+AsUrgencyKind gs_app_get_update_urgency (GsApp *app);
+void gs_app_set_update_urgency (GsApp *app,
+ AsUrgencyKind update_urgency);
+GsPlugin *gs_app_dup_management_plugin (GsApp *app);
+gboolean gs_app_has_management_plugin (GsApp *app,
+ GsPlugin *plugin);
+void gs_app_set_management_plugin (GsApp *app,
+ GsPlugin *management_plugin);
+GIcon *gs_app_get_icon_for_size (GsApp *app,
+ guint size,
+ guint scale,
+ const gchar *fallback_icon_name);
+GPtrArray *gs_app_get_icons (GsApp *app);
+void gs_app_add_icon (GsApp *app,
+ GIcon *icon);
+void gs_app_remove_all_icons (GsApp *app);
+GFile *gs_app_get_local_file (GsApp *app);
+void gs_app_set_local_file (GsApp *app,
+ GFile *local_file);
+AsContentRating *gs_app_dup_content_rating (GsApp *app);
+void gs_app_set_content_rating (GsApp *app,
+ AsContentRating *content_rating);
+GsApp *gs_app_get_runtime (GsApp *app);
+void gs_app_set_runtime (GsApp *app,
+ GsApp *runtime);
+const gchar *gs_app_get_metadata_item (GsApp *app,
+ const gchar *key);
+GVariant *gs_app_get_metadata_variant (GsApp *app,
+ const gchar *key);
+void gs_app_set_metadata (GsApp *app,
+ const gchar *key,
+ const gchar *value);
+void gs_app_set_metadata_variant (GsApp *app,
+ const gchar *key,
+ GVariant *value);
+gint gs_app_get_rating (GsApp *app);
+void gs_app_set_rating (GsApp *app,
+ gint rating);
+GArray *gs_app_get_review_ratings (GsApp *app);
+void gs_app_set_review_ratings (GsApp *app,
+ GArray *review_ratings);
+GPtrArray *gs_app_get_reviews (GsApp *app);
+void gs_app_add_review (GsApp *app,
+ AsReview *review);
+void gs_app_remove_review (GsApp *app,
+ AsReview *review);
+GPtrArray *gs_app_get_provided (GsApp *app);
+AsProvided *gs_app_get_provided_for_kind (GsApp *app,
+ AsProvidedKind kind);
+void gs_app_add_provided_item (GsApp *app,
+ AsProvidedKind kind,
+ const gchar *item);
+GsSizeType gs_app_get_size_installed (GsApp *app,
+ guint64 *size_bytes_out);
+void gs_app_set_size_installed (GsApp *app,
+ GsSizeType size_type,
+ guint64 size_bytes);
+GsSizeType gs_app_get_size_installed_dependencies
+ (GsApp *app,
+ guint64 *size_bytes_out);
+GsSizeType gs_app_get_size_user_data (GsApp *app,
+ guint64 *size_bytes_out);
+void gs_app_set_size_user_data (GsApp *app,
+ GsSizeType size_type,
+ guint64 size_bytes);
+GsSizeType gs_app_get_size_cache_data (GsApp *app,
+ guint64 *size_bytes_out);
+void gs_app_set_size_cache_data (GsApp *app,
+ GsSizeType size_type,
+ guint64 size_bytes);
+GsSizeType gs_app_get_size_download (GsApp *app,
+ guint64 *size_bytes_out);
+void gs_app_set_size_download (GsApp *app,
+ GsSizeType size_type,
+ guint64 size_bytes);
+GsSizeType gs_app_get_size_download_dependencies
+ (GsApp *app,
+ guint64 *size_bytes_out);
+void gs_app_add_related (GsApp *app,
+ GsApp *app2);
+void gs_app_add_addons (GsApp *app,
+ GsAppList *addons);
+void gs_app_add_history (GsApp *app,
+ GsApp *app2);
+guint64 gs_app_get_install_date (GsApp *app);
+void gs_app_set_install_date (GsApp *app,
+ guint64 install_date);
+guint64 gs_app_get_release_date (GsApp *app);
+void gs_app_set_release_date (GsApp *app,
+ guint64 release_date);
+GPtrArray *gs_app_get_categories (GsApp *app);
+void gs_app_set_categories (GsApp *app,
+ GPtrArray *categories);
+GArray *gs_app_get_key_colors (GsApp *app);
+void gs_app_set_key_colors (GsApp *app,
+ GArray *key_colors);
+void gs_app_add_key_color (GsApp *app,
+ GdkRGBA *key_color);
+gboolean gs_app_get_user_key_colors (GsApp *app);
+void gs_app_set_is_update_downloaded (GsApp *app,
+ gboolean is_update_downloaded);
+gboolean gs_app_get_is_update_downloaded (GsApp *app);
+gboolean gs_app_has_category (GsApp *app,
+ const gchar *category);
+void gs_app_add_category (GsApp *app,
+ const gchar *category);
+gboolean gs_app_remove_category (GsApp *app,
+ const gchar *category);
+void gs_app_add_kudo (GsApp *app,
+ GsAppKudo kudo);
+void gs_app_remove_kudo (GsApp *app,
+ GsAppKudo kudo);
+gboolean gs_app_has_kudo (GsApp *app,
+ GsAppKudo kudo);
+guint64 gs_app_get_kudos (GsApp *app);
+guint gs_app_get_kudos_percentage (GsApp *app);
+gboolean gs_app_get_to_be_installed (GsApp *app);
+void gs_app_set_to_be_installed (GsApp *app,
+ gboolean to_be_installed);
+void gs_app_set_match_value (GsApp *app,
+ guint match_value);
+guint gs_app_get_match_value (GsApp *app);
+
+gboolean gs_app_has_quirk (GsApp *app,
+ GsAppQuirk quirk);
+void gs_app_add_quirk (GsApp *app,
+ GsAppQuirk quirk);
+void gs_app_remove_quirk (GsApp *app,
+ GsAppQuirk quirk);
+gboolean gs_app_is_installed (GsApp *app);
+gboolean gs_app_is_updatable (GsApp *app);
+gchar *gs_app_dup_origin_ui (GsApp *app,
+ gboolean with_packaging_format);
+void gs_app_set_origin_ui (GsApp *app,
+ const gchar *origin_ui);
+gchar *gs_app_get_packaging_format (GsApp *app);
+const gchar *gs_app_get_packaging_format_raw(GsApp *app);
+void gs_app_subsume_metadata (GsApp *app,
+ GsApp *donor);
+GsAppPermissions *
+ gs_app_dup_permissions (GsApp *app);
+void gs_app_set_permissions (GsApp *app,
+ GsAppPermissions *permissions);
+GsAppPermissions *
+ gs_app_dup_update_permissions (GsApp *app);
+void gs_app_set_update_permissions (GsApp *app,
+ GsAppPermissions *update_permissions);
+GPtrArray *gs_app_get_version_history (GsApp *app);
+void gs_app_set_version_history (GsApp *app,
+ GPtrArray *version_history);
+void gs_app_ensure_icons_downloaded (GsApp *app,
+ SoupSession *soup_session,
+ guint maximum_icon_size,
+ GCancellable *cancellable);
+
+GPtrArray *gs_app_get_relations (GsApp *app);
+void gs_app_add_relation (GsApp *app,
+ AsRelation *relation);
+void gs_app_set_relations (GsApp *app,
+ GPtrArray *relations);
+
+gboolean gs_app_get_has_translations (GsApp *app);
+void gs_app_set_has_translations (GsApp *app,
+ gboolean has_translations);
+gboolean gs_app_is_downloaded (GsApp *app);
+
+G_END_DECLS
diff --git a/lib/gs-appstream.c b/lib/gs-appstream.c
new file mode 100644
index 0000000..fa94a6e
--- /dev/null
+++ b/lib/gs-appstream.c
@@ -0,0 +1,2165 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2015-2017 Richard Hughes <richard@hughsie.com>
+ * Copyright (C) 2018-2019 Kalev Lember <klember@redhat.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#include "config.h"
+
+#include <gnome-software.h>
+#include <locale.h>
+
+#include "gs-appstream.h"
+
+#define GS_APPSTREAM_MAX_SCREENSHOTS 5
+
+GsApp *
+gs_appstream_create_app (GsPlugin *plugin, XbSilo *silo, XbNode *component, GError **error)
+{
+ GsApp *app;
+ g_autoptr(GsApp) app_new = NULL;
+
+ /* The 'plugin' can be NULL, when creating app for --show-metainfo */
+ g_return_val_if_fail (XB_IS_SILO (silo), NULL);
+ g_return_val_if_fail (XB_IS_NODE (component), NULL);
+
+ app_new = gs_app_new (NULL);
+
+ /* refine enough to get the unique ID */
+ if (!gs_appstream_refine_app (plugin, app_new, silo, component,
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_ID,
+ error))
+ return NULL;
+
+ /* never add wildcard apps to the plugin cache, and only add to
+ * the cache if it’s available */
+ if (gs_app_has_quirk (app_new, GS_APP_QUIRK_IS_WILDCARD) || plugin == NULL)
+ return g_steal_pointer (&app_new);
+
+ if (plugin == NULL)
+ return g_steal_pointer (&app_new);
+
+ /* look for existing object */
+ app = gs_plugin_cache_lookup (plugin, gs_app_get_unique_id (app_new));
+ if (app != NULL)
+ return app;
+
+ /* use the temp object we just created */
+ gs_app_set_metadata (app_new, "GnomeSoftware::Creator",
+ gs_plugin_get_name (plugin));
+ gs_plugin_cache_add (plugin, NULL, app_new);
+ return g_steal_pointer (&app_new);
+}
+
+/* Helper function to do the equivalent of
+ * *node = xb_node_get_next (*node)
+ * but with correct reference counting, since xb_node_get_next() returns a new
+ * ref. */
+static void
+node_set_to_next (XbNode **node)
+{
+ g_autoptr(XbNode) next_node = NULL;
+
+ g_assert (node != NULL);
+ g_assert (*node != NULL);
+
+ next_node = xb_node_get_next (*node);
+ g_object_unref (*node);
+ *node = g_steal_pointer (&next_node);
+}
+
+/* Returns escaped text */
+static gchar *
+gs_appstream_format_description_text (XbNode *node)
+{
+ g_autoptr(GString) str = g_string_new (NULL);
+ const gchar *node_text;
+
+ if (node == NULL)
+ return NULL;
+
+ node_text = xb_node_get_text (node);
+ if (node_text != NULL && *node_text != '\0') {
+ g_autofree gchar *escaped = g_markup_escape_text (node_text, -1);
+ g_string_append (str, escaped);
+ }
+
+ for (g_autoptr(XbNode) n = xb_node_get_child (node); n != NULL; node_set_to_next (&n)) {
+ const gchar *start_elem = "", *end_elem = "";
+ g_autofree gchar *text = NULL;
+ if (g_strcmp0 (xb_node_get_element (n), "em") == 0) {
+ start_elem = "<i>";
+ end_elem = "</i>";
+ } else if (g_strcmp0 (xb_node_get_element (n), "code") == 0) {
+ start_elem = "<tt>";
+ end_elem = "</tt>";
+ }
+
+ /* These can be nested */
+ text = gs_appstream_format_description_text (n);
+ if (text != NULL) {
+ g_string_append_printf (str, "%s%s%s", start_elem, text, end_elem);
+ }
+
+ node_text = xb_node_get_tail (n);
+ if (node_text != NULL && *node_text != '\0') {
+ g_autofree gchar *escaped = g_markup_escape_text (node_text, -1);
+ g_string_append (str, escaped);
+ }
+ }
+
+ if (str->len == 0)
+ return NULL;
+
+ return g_string_free (g_steal_pointer (&str), FALSE);
+}
+
+static gchar *
+gs_appstream_format_description (XbNode *root, GError **error)
+{
+ g_autoptr(GString) str = g_string_new (NULL);
+
+ for (g_autoptr(XbNode) n = xb_node_get_child (root); n != NULL; node_set_to_next (&n)) {
+ /* support <p>, <em>, <code>, <ul>, <ol> and <li>, ignore all else */
+ if (g_strcmp0 (xb_node_get_element (n), "p") == 0) {
+ g_autofree gchar *escaped = gs_appstream_format_description_text (n);
+ /* Treat a self-closing paragraph (`<p/>`) as
+ * nonexistent. This is consistent with Firefox. */
+ if (escaped != NULL)
+ g_string_append_printf (str, "%s\n\n", escaped);
+ } else if (g_strcmp0 (xb_node_get_element (n), "ul") == 0) {
+ g_autoptr(GPtrArray) children = xb_node_get_children (n);
+
+ for (guint i = 0; i < children->len; i++) {
+ XbNode *nc = g_ptr_array_index (children, i);
+ if (g_strcmp0 (xb_node_get_element (nc), "li") == 0) {
+ g_autofree gchar *escaped = gs_appstream_format_description_text (nc);
+
+ /* Treat a self-closing `<li/>` as an empty
+ * list element (equivalent to `<li></li>`).
+ * This is consistent with Firefox. */
+ g_string_append_printf (str, " • %s\n",
+ (escaped != NULL) ? escaped : "");
+ }
+ }
+ g_string_append (str, "\n");
+ } else if (g_strcmp0 (xb_node_get_element (n), "ol") == 0) {
+ g_autoptr(GPtrArray) children = xb_node_get_children (n);
+ for (guint i = 0; i < children->len; i++) {
+ XbNode *nc = g_ptr_array_index (children, i);
+ if (g_strcmp0 (xb_node_get_element (nc), "li") == 0) {
+ g_autofree gchar *escaped = gs_appstream_format_description_text (nc);
+
+ /* Treat self-closing elements as with `<ul>` above. */
+ g_string_append_printf (str, " %u. %s\n",
+ i + 1,
+ (escaped != NULL) ? escaped : "");
+ }
+ }
+ g_string_append (str, "\n");
+ }
+ }
+
+ /* remove extra newlines */
+ while (str->len > 0 && str->str[str->len - 1] == '\n')
+ g_string_truncate (str, str->len - 1);
+
+ /* success */
+ return g_string_free (g_steal_pointer (&str), FALSE);
+}
+
+static gchar *
+gs_appstream_build_icon_prefix (XbNode *component)
+{
+ const gchar *origin;
+ const gchar *tmp;
+ gint npath;
+ g_auto(GStrv) path = NULL;
+ g_autoptr(XbNode) components = NULL;
+
+ /* no parent, e.g. AppData */
+ components = xb_node_get_parent (component);
+ if (components == NULL)
+ return NULL;
+
+ /* set explicitly */
+ tmp = xb_node_query_text (components, "info/icon-prefix", NULL);
+ if (tmp != NULL)
+ return g_strdup (tmp);
+
+ /* fall back to origin */
+ origin = xb_node_get_attr (components, "origin");
+ if (origin == NULL)
+ return NULL;
+
+ /* no metadata */
+ tmp = xb_node_query_text (components, "info/filename", NULL);
+ if (tmp == NULL)
+ return NULL;
+
+ /* check format */
+ path = g_strsplit (tmp, "/", -1);
+ npath = g_strv_length (path);
+ if (npath < 3 ||
+ !(g_strcmp0 (path[npath-2], "xmls") == 0 ||
+ g_strcmp0 (path[npath-2], "yaml") == 0 ||
+ g_strcmp0 (path[npath-2], "xml") == 0))
+ return NULL;
+
+ /* fix the new path */
+ g_free (path[npath-1]);
+ g_free (path[npath-2]);
+ path[npath-1] = g_strdup (origin);
+ path[npath-2] = g_strdup ("icons");
+ return g_strjoinv ("/", path);
+}
+
+/* This function is designed to do no disk or network I/O. */
+static AsIcon *
+gs_appstream_new_icon (XbNode *component, XbNode *n, AsIconKind icon_kind, guint sz)
+{
+ AsIcon *icon = as_icon_new ();
+ g_autofree gchar *icon_path = NULL;
+ as_icon_set_kind (icon, icon_kind);
+ switch (icon_kind) {
+ case AS_ICON_KIND_LOCAL:
+ as_icon_set_filename (icon, xb_node_get_text (n));
+ break;
+ case AS_ICON_KIND_REMOTE:
+ as_icon_set_url (icon, xb_node_get_text (n));
+ break;
+ default:
+ as_icon_set_name (icon, xb_node_get_text (n));
+ }
+ if (sz == 0) {
+ guint64 width = xb_node_get_attr_as_uint (n, "width");
+ if (width > 0 && width < G_MAXUINT)
+ sz = width;
+ }
+
+ if (sz > 0) {
+ as_icon_set_width (icon, sz);
+ as_icon_set_height (icon, sz);
+ }
+
+ if (icon_kind != AS_ICON_KIND_LOCAL && icon_kind != AS_ICON_KIND_REMOTE) {
+ /* add partial filename for now, we will compose the full one later */
+ icon_path = gs_appstream_build_icon_prefix (component);
+ as_icon_set_filename (icon, icon_path);
+ }
+ return icon;
+}
+
+static void
+app_add_icon (GsApp *app,
+ AsIcon *as_icon)
+{
+ g_autoptr(GIcon) icon = gs_icon_new_for_appstream_icon (as_icon);
+ if (icon != NULL)
+ gs_app_add_icon (app, icon);
+}
+
+static void
+traverse_component_icons (GsApp *app,
+ XbNode *component,
+ GPtrArray *icons)
+{
+ if (!icons)
+ return;
+
+ /* This code deliberately does *not* check that the icon files or theme
+ * icons exist, as that would mean doing disk I/O for all the apps in
+ * the appstream file, regardless of whether the calling code is
+ * actually going to use the icons. Better to add all the possible icons
+ * and let the calling code check which ones exist, if it needs to. */
+ for (guint i = 0; i < icons->len; i++) {
+ XbNode *icon_node = g_ptr_array_index (icons, i);
+ g_autoptr(AsIcon) icon = NULL;
+ const gchar *icon_kind_str = xb_node_get_attr (icon_node, "type");
+ AsIconKind icon_kind = as_icon_kind_from_string (icon_kind_str);
+
+ if (icon_kind == AS_ICON_KIND_UNKNOWN) {
+ g_debug ("unknown icon kind ‘%s’", icon_kind_str);
+ continue;
+ }
+
+ icon = gs_appstream_new_icon (component, icon_node, icon_kind, 0);
+ app_add_icon (app, icon);
+ }
+}
+
+static void
+traverse_components_xpath_for_icons (GsApp *app,
+ XbSilo *silo,
+ const gchar *xpath,
+ gboolean try_with_launchable)
+{
+ g_autoptr(GPtrArray) components = NULL;
+ g_autoptr(GError) local_error = NULL;
+
+ components = xb_silo_query (silo, xpath, 0, &local_error);
+ if (components) {
+ for (guint i = 0; i < components->len; i++) {
+ g_autoptr(GPtrArray) icons = NULL; /* (element-type XbNode) */
+ XbNode *component = g_ptr_array_index (components, i);
+ g_autofree gchar *xml = xb_node_export (component, 0, NULL);
+ icons = xb_node_query (component, "icon", 0, NULL);
+ traverse_component_icons (app, component, icons);
+
+ if (try_with_launchable && gs_app_get_icons (app) == NULL) {
+ const gchar *launchable_id = xb_node_query_text (component, "launchable[@type='desktop-id']", NULL);
+ if (launchable_id != NULL) {
+ g_autofree gchar *xpath2 = NULL;
+
+ /* Inherit the icon from the .desktop file */
+ xpath2 = g_strdup_printf ("/component[@type='desktop-application']/launchable[@type='desktop-id'][text()='%s']/..",
+ launchable_id);
+ traverse_components_xpath_for_icons (app, silo, xpath2, FALSE);
+ }
+ }
+ }
+ }
+}
+
+static void
+gs_appstream_refine_icon (GsApp *app,
+ XbSilo *silo,
+ XbNode *component)
+{
+ g_autoptr(GError) local_error = NULL;
+ g_autoptr(GPtrArray) icons = NULL; /* (element-type XbNode) */
+
+ icons = xb_node_query (component, "icon", 0, &local_error);
+ traverse_component_icons (app, component, icons);
+ g_clear_pointer (&icons, g_ptr_array_unref);
+
+ /* If no icon found, try to inherit the icon from the .desktop file */
+ if (gs_app_get_icons (app) == NULL) {
+ g_autofree gchar *xpath = NULL;
+ const gchar *launchable_id = xb_node_query_text (component, "launchable[@type='desktop-id']", NULL);
+ if (launchable_id != NULL) {
+ xpath = g_strdup_printf ("/component[@type='desktop-application']/launchable[@type='desktop-id'][text()='%s']/..",
+ launchable_id);
+ traverse_components_xpath_for_icons (app, silo, xpath, FALSE);
+ g_clear_pointer (&xpath, g_free);
+ }
+
+ xpath = g_strdup_printf ("/component[@type='desktop-application']/launchable[@type='desktop-id'][text()='%s']/..",
+ gs_app_get_id (app));
+ traverse_components_xpath_for_icons (app, silo, xpath, FALSE);
+ }
+}
+
+static gboolean
+gs_appstream_refine_add_addons (GsPlugin *plugin,
+ GsApp *app,
+ XbSilo *silo,
+ GError **error)
+{
+ g_autofree gchar *xpath = NULL;
+ g_autoptr(GError) error_local = NULL;
+ g_autoptr(GPtrArray) addons = NULL;
+ g_autoptr(GsAppList) addons_list = NULL;
+
+ /* get all components */
+ xpath = g_strdup_printf ("components/component/extends[text()='%s']/..",
+ gs_app_get_id (app));
+ addons = xb_silo_query (silo, xpath, 0, &error_local);
+ if (addons == NULL) {
+ if (g_error_matches (error_local, G_IO_ERROR, G_IO_ERROR_NOT_FOUND))
+ return TRUE;
+ g_propagate_error (error, g_steal_pointer (&error_local));
+ return FALSE;
+ }
+
+ addons_list = gs_app_list_new ();
+
+ for (guint i = 0; i < addons->len; i++) {
+ XbNode *addon = g_ptr_array_index (addons, i);
+ g_autoptr(GsApp) addon_app = NULL;
+
+ addon_app = gs_appstream_create_app (plugin, silo, addon, error);
+ if (addon_app == NULL)
+ return FALSE;
+
+ gs_app_list_add (addons_list, addon_app);
+ }
+
+ gs_app_add_addons (app, addons_list);
+
+ return TRUE;
+}
+
+static gboolean
+gs_appstream_refine_add_images (GsApp *app,
+ AsScreenshot *ss,
+ XbNode *screenshot,
+ gboolean *out_any_added,
+ GError **error)
+{
+ g_autoptr(GError) error_local = NULL;
+ g_autoptr(GPtrArray) images = NULL;
+
+ /* get all components */
+ images = xb_node_query (screenshot, "image", 0, &error_local);
+ if (images == NULL) {
+ if (g_error_matches (error_local, G_IO_ERROR, G_IO_ERROR_NOT_FOUND))
+ return TRUE;
+ g_propagate_error (error, g_steal_pointer (&error_local));
+ return FALSE;
+ }
+ for (guint i = 0; i < images->len; i++) {
+ XbNode *image = g_ptr_array_index (images, i);
+ g_autoptr(AsImage) im = as_image_new ();
+ as_image_set_height (im, xb_node_get_attr_as_uint (image, "height"));
+ as_image_set_width (im, xb_node_get_attr_as_uint (image, "width"));
+ as_image_set_kind (im, as_image_kind_from_string (xb_node_get_attr (image, "type")));
+ as_image_set_url (im, xb_node_get_text (image));
+ as_screenshot_add_image (ss, im);
+ }
+
+ *out_any_added = *out_any_added || images->len > 0;
+
+ /* success */
+ return TRUE;
+}
+
+static gboolean
+gs_appstream_refine_add_videos (GsApp *app,
+ AsScreenshot *ss,
+ XbNode *screenshot,
+ gboolean *out_any_added,
+ GError **error)
+{
+ g_autoptr(GError) error_local = NULL;
+ g_autoptr(GPtrArray) videos = NULL;
+
+ videos = xb_node_query (screenshot, "video", 0, &error_local);
+ if (videos == NULL) {
+ if (g_error_matches (error_local, G_IO_ERROR, G_IO_ERROR_NOT_FOUND))
+ return TRUE;
+ g_propagate_error (error, g_steal_pointer (&error_local));
+ return FALSE;
+ }
+ for (guint i = 0; i < videos->len; i++) {
+ XbNode *video = g_ptr_array_index (videos, i);
+ g_autoptr(AsVideo) vid = as_video_new ();
+ as_video_set_height (vid, xb_node_get_attr_as_uint (video, "height"));
+ as_video_set_width (vid, xb_node_get_attr_as_uint (video, "width"));
+ as_video_set_codec_kind (vid, as_video_codec_kind_from_string (xb_node_get_attr (video, "codec")));
+ as_video_set_container_kind (vid, as_video_container_kind_from_string (xb_node_get_attr (video, "container")));
+ as_video_set_url (vid, xb_node_get_text (video));
+ as_screenshot_add_video (ss, vid);
+ }
+
+ *out_any_added = *out_any_added || videos->len > 0;
+
+ return TRUE;
+}
+
+static gboolean
+gs_appstream_refine_add_screenshots (GsApp *app, XbNode *component, GError **error)
+{
+ g_autoptr(GError) error_local = NULL;
+ g_autoptr(GPtrArray) screenshots = NULL;
+
+ /* get all components */
+ screenshots = xb_node_query (component, "screenshots/screenshot", 0, &error_local);
+ if (screenshots == NULL) {
+ if (g_error_matches (error_local, G_IO_ERROR, G_IO_ERROR_NOT_FOUND))
+ return TRUE;
+ g_propagate_error (error, g_steal_pointer (&error_local));
+ return FALSE;
+ }
+ for (guint i = 0; i < screenshots->len; i++) {
+ XbNode *screenshot = g_ptr_array_index (screenshots, i);
+ g_autoptr(AsScreenshot) ss = as_screenshot_new ();
+ gboolean any_added = FALSE;
+ if (!gs_appstream_refine_add_images (app, ss, screenshot, &any_added, error) ||
+ !gs_appstream_refine_add_videos (app, ss, screenshot, &any_added, error))
+ return FALSE;
+ if (any_added)
+ gs_app_add_screenshot (app, ss);
+ }
+
+ /* FIXME: move into no refine flags section? */
+ if (screenshots ->len > 0)
+ gs_app_add_kudo (app, GS_APP_KUDO_HAS_SCREENSHOTS);
+
+ /* success */
+ return TRUE;
+}
+
+static gboolean
+gs_appstream_refine_add_provides (GsApp *app, XbNode *component, GError **error)
+{
+ g_autoptr(GError) error_local = NULL;
+ g_autoptr(GPtrArray) provides = NULL;
+
+ /* get all components */
+ provides = xb_node_query (component, "provides/*", 0, &error_local);
+ if (provides == NULL) {
+ if (g_error_matches (error_local, G_IO_ERROR, G_IO_ERROR_NOT_FOUND))
+ return TRUE;
+ g_propagate_error (error, g_steal_pointer (&error_local));
+ return FALSE;
+ }
+ for (guint i = 0; i < provides->len; i++) {
+ AsProvidedKind kind;
+ const gchar *element_name;
+ XbNode *provide = g_ptr_array_index (provides, i);
+ element_name = xb_node_get_element (provide);
+
+ /* try the simple case */
+ kind = as_provided_kind_from_string (element_name);
+ if (kind == AS_PROVIDED_KIND_UNKNOWN) {
+ /* try the complex cases */
+
+ if (g_strcmp0 (element_name, "library") == 0) {
+ kind = AS_PROVIDED_KIND_LIBRARY;
+ } else if (g_strcmp0 (element_name, "binary") == 0) {
+ kind = AS_PROVIDED_KIND_BINARY;
+ } else if (g_strcmp0 (element_name, "firmware") == 0) {
+ const gchar *fw_type = xb_node_get_attr (provide, "type");
+ if (g_strcmp0 (fw_type, "runtime") == 0)
+ kind = AS_PROVIDED_KIND_FIRMWARE_RUNTIME;
+ else if (g_strcmp0 (fw_type, "flashed") == 0)
+ kind = AS_PROVIDED_KIND_FIRMWARE_FLASHED;
+ } else if (g_strcmp0 (element_name, "python2") == 0) {
+ kind = AS_PROVIDED_KIND_PYTHON_2;
+ } else if (g_strcmp0 (element_name, "python3") == 0) {
+ kind = AS_PROVIDED_KIND_PYTHON;
+ } else if (g_strcmp0 (element_name, "dbus") == 0) {
+ const gchar *dbus_type = xb_node_get_attr (provide, "type");
+ if (g_strcmp0 (dbus_type, "system") == 0)
+ kind = AS_PROVIDED_KIND_DBUS_SYSTEM;
+ else if ((g_strcmp0 (dbus_type, "user") == 0) || (g_strcmp0 (dbus_type, "session") == 0))
+ kind = AS_PROVIDED_KIND_DBUS_USER;
+ }
+ }
+
+ if (kind == AS_PROVIDED_KIND_UNKNOWN ||
+ xb_node_get_text (provide) == NULL) {
+ /* give up */
+ g_warning ("ignoring unknown or empty provided item type: %s", element_name);
+ continue;
+ }
+
+ gs_app_add_provided_item (app,
+ kind,
+ xb_node_get_text (provide));
+ }
+
+ /* success */
+ return TRUE;
+}
+
+static guint64
+component_get_release_timestamp (XbNode *component)
+{
+ guint64 timestamp;
+ const gchar *date_str;
+
+ /* Spec says to prefer `timestamp` over `date` if both are provided:
+ * https://www.freedesktop.org/software/appstream/docs/chap-Metadata.html#tag-releases */
+ timestamp = xb_node_query_attr_as_uint (component, "releases/release", "timestamp", NULL);
+ date_str = xb_node_query_attr (component, "releases/release", "date", NULL);
+
+ if (timestamp != G_MAXUINT64) {
+ return timestamp;
+ } else if (date_str != NULL) {
+ g_autoptr(GDateTime) date = g_date_time_new_from_iso8601 (date_str, NULL);
+ if (date != NULL)
+ return g_date_time_to_unix (date);
+ }
+
+ /* Unknown. */
+ return G_MAXUINT64;
+}
+
+static gboolean
+gs_appstream_is_recent_release (XbNode *component)
+{
+ guint64 ts;
+ gint64 secs;
+
+ /* get newest release */
+ ts = component_get_release_timestamp (component);
+ if (ts == G_MAXUINT64)
+ return FALSE;
+
+ /* is last build less than one year ago? */
+ secs = (g_get_real_time () / G_USEC_PER_SEC) - ts;
+ return secs / (60 * 60 * 24) < 365;
+}
+
+static gboolean
+gs_appstream_copy_metadata (GsApp *app, XbNode *component, GError **error)
+{
+ g_autoptr(GError) error_local = NULL;
+ g_autoptr(GPtrArray) values = NULL;
+
+ /* get all components */
+ values = xb_node_query (component, "custom/value", 0, &error_local);
+ if (values == NULL) {
+ if (g_error_matches (error_local, G_IO_ERROR, G_IO_ERROR_NOT_FOUND))
+ return TRUE;
+ g_propagate_error (error, g_steal_pointer (&error_local));
+ return FALSE;
+ }
+ for (guint i = 0; i < values->len; i++) {
+ XbNode *value = g_ptr_array_index (values, i);
+ const gchar *key = xb_node_get_attr (value, "key");
+ if (key == NULL)
+ continue;
+ if (gs_app_get_metadata_item (app, key) != NULL)
+ continue;
+ gs_app_set_metadata (app, key, xb_node_get_text (value));
+ }
+ return TRUE;
+}
+
+static gboolean
+gs_appstream_refine_app_updates (GsApp *app,
+ XbSilo *silo,
+ XbNode *component,
+ GError **error)
+{
+ AsUrgencyKind urgency_best = AS_URGENCY_KIND_UNKNOWN;
+ g_autofree gchar *xpath = NULL;
+ g_autoptr(GError) error_local = NULL;
+ g_autoptr(GHashTable) installed = g_hash_table_new (g_str_hash, g_str_equal);
+ g_autoptr(GPtrArray) releases_inst = NULL;
+ g_autoptr(GPtrArray) releases = NULL;
+ g_autoptr(GPtrArray) updates_list = g_ptr_array_new ();
+
+ /* only for UPDATABLE apps */
+ if (!gs_app_is_updatable (app))
+ return TRUE;
+
+ /* find out which releases are already installed */
+ xpath = g_strdup_printf ("component/id[text()='%s']/../releases/*[@version]",
+ gs_app_get_id (app));
+ releases_inst = xb_silo_query (silo, xpath, 0, &error_local);
+ if (releases_inst == NULL) {
+ if (!g_error_matches (error_local, G_IO_ERROR, G_IO_ERROR_NOT_FOUND)) {
+ g_propagate_error (error, g_steal_pointer (&error_local));
+ return FALSE;
+ }
+ } else {
+ for (guint i = 0; i < releases_inst->len; i++) {
+ XbNode *release = g_ptr_array_index (releases_inst, i);
+ g_hash_table_insert (installed,
+ (gpointer) xb_node_get_attr (release, "version"),
+ (gpointer) release);
+ }
+ }
+ g_clear_error (&error_local);
+
+ /* get all components */
+ releases = xb_node_query (component, "releases/*", 0, &error_local);
+ if (releases == NULL) {
+ if (g_error_matches (error_local, G_IO_ERROR, G_IO_ERROR_NOT_FOUND))
+ return TRUE;
+ g_propagate_error (error, g_steal_pointer (&error_local));
+ return FALSE;
+ }
+ for (guint i = 0; i < releases->len; i++) {
+ XbNode *release = g_ptr_array_index (releases, i);
+ const gchar *version = xb_node_get_attr (release, "version");
+ g_autoptr(XbNode) description = NULL;
+ AsUrgencyKind urgency_tmp;
+
+ /* ignore releases with no version */
+ if (version == NULL)
+ continue;
+
+ /* already installed */
+ if (g_hash_table_lookup (installed, version) != NULL)
+ continue;
+
+ /* limit this to three versions backwards if there has never
+ * been a detected installed version */
+ if (g_hash_table_size (installed) == 0 && i >= 3)
+ break;
+
+ /* use the 'worst' urgency, e.g. critical over enhancement */
+ urgency_tmp = as_urgency_kind_from_string (xb_node_get_attr (release, "urgency"));
+ if (urgency_tmp > urgency_best)
+ urgency_best = urgency_tmp;
+
+ /* add updates with a description */
+ description = xb_node_query_first (release, "description", NULL);
+ if (description == NULL)
+ continue;
+ g_ptr_array_add (updates_list, release);
+ }
+
+ /* only set if known */
+ if (urgency_best != AS_URGENCY_KIND_UNKNOWN)
+ gs_app_set_update_urgency (app, urgency_best);
+
+ /* no prefix on each release */
+ if (updates_list->len == 1) {
+ XbNode *release = g_ptr_array_index (updates_list, 0);
+ g_autoptr(XbNode) n = NULL;
+ g_autofree gchar *desc = NULL;
+ n = xb_node_query_first (release, "description", NULL);
+ desc = gs_appstream_format_description (n, NULL);
+ gs_app_set_update_details_markup (app, desc);
+
+ /* get the descriptions with a version prefix */
+ } else if (updates_list->len > 1) {
+ const gchar *version = gs_app_get_version (app);
+ g_autoptr(GString) update_desc = g_string_new ("");
+ for (guint i = 0; i < updates_list->len; i++) {
+ XbNode *release = g_ptr_array_index (updates_list, i);
+ const gchar *release_version = xb_node_get_attr (release, "version");
+ g_autofree gchar *desc = NULL;
+ g_autoptr(XbNode) n = NULL;
+
+ /* skip the currently installed version and all below it */
+ if (version != NULL && as_vercmp_simple (version, release_version) >= 0)
+ continue;
+
+ n = xb_node_query_first (release, "description", NULL);
+ desc = gs_appstream_format_description (n, NULL);
+ g_string_append_printf (update_desc,
+ "Version %s:\n%s\n\n",
+ xb_node_get_attr (release, "version"),
+ desc);
+ }
+
+ /* remove trailing newlines */
+ if (update_desc->len > 2)
+ g_string_truncate (update_desc, update_desc->len - 2);
+ if (update_desc->len > 0)
+ gs_app_set_update_details_markup (app, update_desc->str);
+ }
+
+ /* if there is no already set update version use the newest */
+ if (gs_app_get_update_version (app) == NULL &&
+ updates_list->len > 0) {
+ XbNode *release = g_ptr_array_index (updates_list, 0);
+ gs_app_set_update_version (app, xb_node_get_attr (release, "version"));
+ }
+
+ /* success */
+ return TRUE;
+}
+
+static gboolean
+gs_appstream_refine_add_version_history (GsApp *app, XbNode *component, GError **error)
+{
+ g_autoptr(GError) error_local = NULL;
+ g_autoptr(GPtrArray) version_history = NULL; /* (element-type AsRelease) */
+ g_autoptr(GPtrArray) releases = NULL; /* (element-type XbNode) */
+
+ /* get all components */
+ releases = xb_node_query (component, "releases/*", 0, &error_local);
+ if (releases == NULL) {
+ if (g_error_matches (error_local, G_IO_ERROR, G_IO_ERROR_NOT_FOUND))
+ return TRUE;
+ g_propagate_error (error, g_steal_pointer (&error_local));
+ return FALSE;
+ }
+
+ version_history = g_ptr_array_new_with_free_func ((GDestroyNotify) g_object_unref);
+ for (guint i = 0; i < releases->len; i++) {
+ XbNode *release_node = g_ptr_array_index (releases, i);
+ const gchar *version = xb_node_get_attr (release_node, "version");
+ g_autoptr(XbNode) description_node = NULL;
+ g_autofree gchar *description = NULL;
+ guint64 timestamp;
+ const gchar *date_str;
+ g_autoptr(AsRelease) release = NULL;
+ g_autofree char *timestamp_xpath = NULL;
+
+ /* ignore releases with no version */
+ if (version == NULL)
+ continue;
+
+ timestamp_xpath = g_strdup_printf ("releases/release[%u]", i+1);
+ timestamp = xb_node_query_attr_as_uint (component, timestamp_xpath, "timestamp", NULL);
+ date_str = xb_node_query_attr (component, timestamp_xpath, "date", NULL);
+
+ /* include updates with or without a description */
+ description_node = xb_node_query_first (release_node, "description", NULL);
+ if (description_node != NULL)
+ description = gs_appstream_format_description (description_node, NULL);
+
+ release = as_release_new ();
+ as_release_set_version (release, version);
+ if (timestamp != G_MAXUINT64)
+ as_release_set_timestamp (release, timestamp);
+ else if (date_str != NULL) /* timestamp takes precedence over date */
+ as_release_set_date (release, date_str);
+ if (description != NULL)
+ as_release_set_description (release, description, NULL);
+
+ g_ptr_array_add (version_history, g_steal_pointer (&release));
+ }
+
+ if (version_history->len > 0)
+ gs_app_set_version_history (app, version_history);
+
+ /* success */
+ return TRUE;
+}
+
+/**
+ * _gs_utils_locale_has_translations:
+ * @locale: A locale, e.g. `en_GB` or `uz_UZ.utf8@cyrillic`
+ *
+ * Looks up if the locale is likely to have translations.
+ *
+ * Returns: %TRUE if the locale should have translations
+ **/
+static gboolean
+_gs_utils_locale_has_translations (const gchar *locale)
+{
+ g_autofree gchar *locale_copy = g_strdup (locale);
+ gchar *separator;
+
+ /* Strip off the territory, codeset and modifier, if present. */
+ separator = strpbrk (locale_copy, "_.@");
+ if (separator != NULL)
+ *separator = '\0';
+
+ if (g_strcmp0 (locale_copy, "C") == 0)
+ return FALSE;
+ if (g_strcmp0 (locale_copy, "en") == 0)
+ return FALSE;
+ return TRUE;
+}
+
+static gboolean
+gs_appstream_origin_valid (const gchar *origin)
+{
+ if (origin == NULL)
+ return FALSE;
+ if (g_strcmp0 (origin, "") == 0)
+ return FALSE;
+ return TRUE;
+}
+
+static gboolean
+gs_appstream_is_valid_project_group (const gchar *project_group)
+{
+ if (project_group == NULL)
+ return FALSE;
+ return as_utils_is_desktop_environment (project_group);
+}
+
+static gboolean
+gs_appstream_refine_app_content_rating (GsApp *app,
+ XbNode *content_rating,
+ GError **error)
+{
+ g_autoptr(AsContentRating) cr = as_content_rating_new ();
+ g_autoptr(GError) error_local = NULL;
+ g_autoptr(GPtrArray) content_attributes = NULL;
+ const gchar *content_rating_kind = NULL;
+
+ /* get kind */
+ content_rating_kind = xb_node_get_attr (content_rating, "type");
+ /* we only really expect/support OARS 1.0 and 1.1 */
+ if (content_rating_kind == NULL ||
+ (g_strcmp0 (content_rating_kind, "oars-1.0") != 0 &&
+ g_strcmp0 (content_rating_kind, "oars-1.1") != 0)) {
+ return TRUE;
+ }
+
+ as_content_rating_set_kind (cr, content_rating_kind);
+
+ /* get attributes; no attributes being found (i.e.
+ * `<content_rating type="*"/>`) is OK: it means that all attributes have
+ * value `none`, as per the
+ * [OARS semantics](https://github.com/hughsie/oars/blob/HEAD/specification/oars-1.1.md) */
+ content_attributes = xb_node_query (content_rating, "content_attribute", 0, &error_local);
+ if (content_attributes == NULL &&
+ g_error_matches (error_local, G_IO_ERROR, G_IO_ERROR_NOT_FOUND)) {
+ g_clear_error (&error_local);
+ } else if (content_attributes == NULL) {
+ g_propagate_error (error, g_steal_pointer (&error_local));
+ return FALSE;
+ }
+
+ for (guint i = 0; content_attributes != NULL && i < content_attributes->len; i++) {
+ XbNode *content_attribute = g_ptr_array_index (content_attributes, i);
+ as_content_rating_add_attribute (cr,
+ xb_node_get_attr (content_attribute, "id"),
+ as_content_rating_value_from_string (xb_node_get_text (content_attribute)));
+ }
+
+ gs_app_set_content_rating (app, cr);
+ return TRUE;
+}
+
+static gboolean
+gs_appstream_refine_app_content_ratings (GsApp *app,
+ XbNode *component,
+ GError **error)
+{
+ g_autoptr(GPtrArray) content_ratings = NULL;
+ g_autoptr(GError) error_local = NULL;
+
+ /* find any content ratings */
+ content_ratings = xb_node_query (component, "content_rating", 0, &error_local);
+ if (content_ratings == NULL) {
+ if (g_error_matches (error_local, G_IO_ERROR, G_IO_ERROR_NOT_FOUND))
+ return TRUE;
+ g_propagate_error (error, g_steal_pointer (&error_local));
+ return FALSE;
+ }
+ for (guint i = 0; i < content_ratings->len; i++) {
+ XbNode *content_rating = g_ptr_array_index (content_ratings, i);
+ if (!gs_appstream_refine_app_content_rating (app, content_rating, error))
+ return FALSE;
+ }
+ return TRUE;
+}
+
+static gboolean
+gs_appstream_refine_app_relation (GsApp *app,
+ XbNode *relation_node,
+ AsRelationKind kind,
+ GError **error)
+{
+ /* Iterate over the children, which might be any combination of zero or
+ * more <id/>, <modalias/>, <kernel/>, <memory/>, <firmware/>,
+ * <control/> or <display_length/> elements. For the moment, we only
+ * support some of these. */
+ for (g_autoptr(XbNode) child = xb_node_get_child (relation_node); child != NULL; node_set_to_next (&child)) {
+ const gchar *item_kind = xb_node_get_element (child);
+ g_autoptr(AsRelation) relation = as_relation_new ();
+
+ as_relation_set_kind (relation, kind);
+
+ if (g_str_equal (item_kind, "control")) {
+ /* https://www.freedesktop.org/software/appstream/docs/chap-Metadata.html#tag-relations-control */
+ as_relation_set_item_kind (relation, AS_RELATION_ITEM_KIND_CONTROL);
+ as_relation_set_value_control_kind (relation, as_control_kind_from_string (xb_node_get_text (child)));
+ } else if (g_str_equal (item_kind, "display_length")) {
+ AsDisplayLengthKind display_length_kind;
+ const gchar *compare;
+
+ /* https://www.freedesktop.org/software/appstream/docs/chap-Metadata.html#tag-relations-display_length */
+ as_relation_set_item_kind (relation, AS_RELATION_ITEM_KIND_DISPLAY_LENGTH);
+
+ compare = xb_node_get_attr (child, "compare");
+ as_relation_set_compare (relation, (compare != NULL) ? as_relation_compare_from_string (compare) : AS_RELATION_COMPARE_GE);
+
+ display_length_kind = as_display_length_kind_from_string (xb_node_get_text (child));
+ if (display_length_kind != AS_DISPLAY_LENGTH_KIND_UNKNOWN) {
+ /* Ignore the `side` attribute */
+ as_relation_set_value_display_length_kind (relation, display_length_kind);
+ } else {
+ const gchar *side = xb_node_get_attr (child, "side");
+ as_relation_set_display_side_kind (relation, (side != NULL) ? as_display_side_kind_from_string (side) : AS_DISPLAY_SIDE_KIND_SHORTEST);
+ as_relation_set_value_px (relation, xb_node_get_text_as_uint (child));
+ }
+ } else {
+ g_debug ("Relation type ‘%s’ not currently supported for %s; ignoring",
+ item_kind, gs_app_get_id (app));
+ continue;
+ }
+
+ gs_app_add_relation (app, relation);
+ }
+
+ return TRUE;
+}
+
+static gboolean
+gs_appstream_refine_app_relations (GsApp *app,
+ XbNode *component,
+ GError **error)
+{
+ const struct {
+ const gchar *element_name;
+ AsRelationKind relation_kind;
+ } relation_types[] = {
+#if AS_CHECK_VERSION(0, 15, 0)
+ { "supports", AS_RELATION_KIND_SUPPORTS },
+#endif
+ { "recommends", AS_RELATION_KIND_RECOMMENDS },
+ { "requires", AS_RELATION_KIND_REQUIRES },
+ };
+
+ for (gsize i = 0; i < G_N_ELEMENTS (relation_types); i++) {
+ g_autoptr(GPtrArray) relations = NULL;
+ g_autoptr(GError) error_local = NULL;
+
+ /* find any instances of this @element_name */
+ relations = xb_node_query (component, relation_types[i].element_name, 0, &error_local);
+ if (relations == NULL &&
+ !g_error_matches (error_local, G_IO_ERROR, G_IO_ERROR_NOT_FOUND)) {
+ g_propagate_error (error, g_steal_pointer (&error_local));
+ return FALSE;
+ }
+
+ for (guint j = 0; relations != NULL && j < relations->len; j++) {
+ XbNode *relation = g_ptr_array_index (relations, j);
+ if (!gs_appstream_refine_app_relation (app, relation, relation_types[i].relation_kind, error))
+ return FALSE;
+ }
+ }
+
+ return TRUE;
+}
+
+gboolean
+gs_appstream_refine_app (GsPlugin *plugin,
+ GsApp *app,
+ XbSilo *silo,
+ XbNode *component,
+ GsPluginRefineFlags refine_flags,
+ GError **error)
+{
+ const gchar *tmp;
+ guint64 timestamp;
+ g_autoptr(GPtrArray) bundles = NULL;
+ g_autoptr(GPtrArray) launchables = NULL;
+ g_autoptr(XbNode) req = NULL;
+
+ /* The 'plugin' can be NULL, when creating app for --show-metainfo */
+ g_return_val_if_fail (GS_IS_APP (app), FALSE);
+ g_return_val_if_fail (XB_IS_SILO (silo), FALSE);
+ g_return_val_if_fail (XB_IS_NODE (component), FALSE);
+
+ /* is compatible */
+ req = xb_node_query_first (component,
+ "requires/id[@type='id']"
+ "[text()='org.gnome.Software.desktop']", NULL);
+ if (req != NULL) {
+ gint rc = as_vercmp_simple (xb_node_get_attr (req, "version"),
+ PACKAGE_VERSION);
+ if (rc > 0) {
+ g_set_error (error,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_NOT_SUPPORTED,
+ "not for this gnome-software");
+ return FALSE;
+ }
+ }
+
+ /* set id kind */
+ if (gs_app_get_kind (app) == AS_COMPONENT_KIND_UNKNOWN ||
+ gs_app_get_kind (app) == AS_COMPONENT_KIND_GENERIC) {
+ AsComponentKind kind;
+ tmp = xb_node_get_attr (component, "type");
+ kind = as_component_kind_from_string (tmp);
+ if (kind != AS_COMPONENT_KIND_UNKNOWN)
+ gs_app_set_kind (app, kind);
+ }
+
+ /* types we can never launch */
+ switch (gs_app_get_kind (app)) {
+ case AS_COMPONENT_KIND_ADDON:
+ case AS_COMPONENT_KIND_CODEC:
+ case AS_COMPONENT_KIND_DRIVER:
+ case AS_COMPONENT_KIND_FIRMWARE:
+ case AS_COMPONENT_KIND_FONT:
+ case AS_COMPONENT_KIND_GENERIC:
+ case AS_COMPONENT_KIND_INPUT_METHOD:
+ case AS_COMPONENT_KIND_LOCALIZATION:
+ case AS_COMPONENT_KIND_OPERATING_SYSTEM:
+ case AS_COMPONENT_KIND_RUNTIME:
+ case AS_COMPONENT_KIND_REPOSITORY:
+ gs_app_add_quirk (app, GS_APP_QUIRK_NOT_LAUNCHABLE);
+ break;
+ default:
+ break;
+ }
+
+ /* check if the special metadata affects the not-launchable quirk */
+ tmp = gs_app_get_metadata_item (app, "GnomeSoftware::quirks::not-launchable");
+ if (tmp != NULL) {
+ if (g_strcmp0 (tmp, "true") == 0)
+ gs_app_add_quirk (app, GS_APP_QUIRK_NOT_LAUNCHABLE);
+ else if (g_strcmp0 (tmp, "false") == 0)
+ gs_app_remove_quirk (app, GS_APP_QUIRK_NOT_LAUNCHABLE);
+ }
+
+ tmp = gs_app_get_metadata_item (app, "GnomeSoftware::quirks::hide-everywhere");
+ if (tmp != NULL) {
+ if (g_strcmp0 (tmp, "true") == 0)
+ gs_app_add_quirk (app, GS_APP_QUIRK_HIDE_EVERYWHERE);
+ else if (g_strcmp0 (tmp, "false") == 0)
+ gs_app_remove_quirk (app, GS_APP_QUIRK_HIDE_EVERYWHERE);
+ }
+
+ /* try to detect old-style AppStream 'override'
+ * files without the merge attribute */
+ if (xb_node_query_text (component, "name", NULL) == NULL &&
+ xb_node_query_text (component, "metadata_license", NULL) == NULL) {
+ gs_app_add_quirk (app, GS_APP_QUIRK_IS_WILDCARD);
+ }
+
+ /* set id */
+ tmp = xb_node_query_text (component, "id", NULL);
+ if (tmp != NULL && gs_app_get_id (app) == NULL)
+ gs_app_set_id (app, tmp);
+
+ /* set source */
+ tmp = xb_node_query_text (component, "info/filename", NULL);
+ if (tmp == NULL)
+ tmp = xb_node_query_text (component, "../info/filename", NULL);
+ if (tmp != NULL && gs_app_get_metadata_item (app, "appstream::source-file") == NULL) {
+ gs_app_set_metadata (app, "appstream::source-file", tmp);
+ }
+
+ /* set scope */
+ tmp = xb_node_query_text (component, "../info/scope", NULL);
+ if (tmp != NULL)
+ gs_app_set_scope (app, as_component_scope_from_string (tmp));
+
+ /* set content rating */
+ if (TRUE) {
+ if (!gs_appstream_refine_app_content_ratings (app, component, error))
+ return FALSE;
+ }
+
+ /* recommends/requires
+ * FIXME: Technically this could do with a more specific refine flag,
+ * but essentially the relations are used on the details page and so
+ * are the permissions. It would be good to eliminate refine flags at
+ * some point in the future. */
+ if (refine_flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_PERMISSIONS) {
+ if (!gs_appstream_refine_app_relations (app, component, error))
+ return FALSE;
+ }
+
+ /* set name */
+ tmp = xb_node_query_text (component, "name", NULL);
+ if (tmp != NULL)
+ gs_app_set_name (app, GS_APP_QUALITY_HIGHEST, tmp);
+
+ /* set summary */
+ tmp = xb_node_query_text (component, "summary", NULL);
+ if (tmp != NULL)
+ gs_app_set_summary (app, GS_APP_QUALITY_HIGHEST, tmp);
+
+ /* add urls */
+ if (refine_flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_URL) {
+ g_autoptr(GPtrArray) urls = NULL;
+ urls = xb_node_query (component, "url", 0, NULL);
+ if (urls != NULL) {
+ for (guint i = 0; i < urls->len; i++) {
+ XbNode *url = g_ptr_array_index (urls, i);
+ const gchar *kind = xb_node_get_attr (url, "type");
+ if (kind == NULL)
+ continue;
+ gs_app_set_url (app,
+ as_url_kind_from_string (kind),
+ xb_node_get_text (url));
+ }
+ }
+ }
+
+ /* add launchables */
+ launchables = xb_node_query (component, "launchable", 0, NULL);
+ if (launchables != NULL) {
+ for (guint i = 0; i < launchables->len; i++) {
+ XbNode *launchable = g_ptr_array_index (launchables, i);
+ const gchar *kind = xb_node_get_attr (launchable, "type");
+ if (g_strcmp0 (kind, "desktop-id") == 0) {
+ gs_app_set_launchable (app,
+ AS_LAUNCHABLE_KIND_DESKTOP_ID,
+ xb_node_get_text (launchable));
+ break;
+ } else if (g_strcmp0 (kind, "url") == 0) {
+ gs_app_set_launchable (app,
+ AS_LAUNCHABLE_KIND_URL,
+ xb_node_get_text (launchable));
+ }
+ }
+ }
+
+ /* set license */
+ if ((refine_flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_LICENSE) > 0 &&
+ gs_app_get_license (app) == NULL) {
+ tmp = xb_node_query_text (component, "project_license", NULL);
+ if (tmp != NULL)
+ gs_app_set_license (app, GS_APP_QUALITY_HIGHEST, tmp);
+ }
+
+ /* set description */
+ if (refine_flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_DESCRIPTION) {
+ g_autofree gchar *description = NULL;
+ g_autoptr(XbNode) n = xb_node_query_first (component, "description", NULL);
+ if (n != NULL)
+ description = gs_appstream_format_description (n, NULL);
+ if (description != NULL)
+ gs_app_set_description (app, GS_APP_QUALITY_HIGHEST, description);
+ }
+
+ /* set icon */
+ if ((refine_flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_ICON) > 0 &&
+ gs_app_get_icons (app) == NULL)
+ gs_appstream_refine_icon (app, silo, component);
+
+ /* set categories */
+ if (refine_flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_CATEGORIES) {
+ g_autoptr(GPtrArray) categories = NULL;
+ categories = xb_node_query (component, "categories/category", 0, NULL);
+ if (categories != NULL) {
+ for (guint i = 0; i < categories->len; i++) {
+ XbNode *category = g_ptr_array_index (categories, i);
+ gs_app_add_category (app, xb_node_get_text (category));
+
+ /* Special case: We used to use the `Blacklisted`
+ * category to hide apps from their .desktop
+ * file or appdata. We now use a quirk for that.
+ * This special case can be removed when all
+ * appstream files no longer use the `Blacklisted`
+ * category (including external-appstream files
+ * put together by distributions). */
+ if (g_strcmp0 (xb_node_get_text (category), "Blacklisted") == 0)
+ gs_app_add_quirk (app, GS_APP_QUIRK_HIDE_EVERYWHERE);
+ }
+ }
+ }
+
+ /* set project group */
+ if ((refine_flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_PROJECT_GROUP) > 0 &&
+ gs_app_get_project_group (app) == NULL) {
+ tmp = xb_node_query_text (component, "project_group", NULL);
+ if (tmp != NULL && gs_appstream_is_valid_project_group (tmp))
+ gs_app_set_project_group (app, tmp);
+ }
+
+ /* set developer name */
+ if ((refine_flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_DEVELOPER_NAME) > 0 &&
+ gs_app_get_developer_name (app) == NULL) {
+ tmp = xb_node_query_text (component, "developer_name", NULL);
+ if (tmp != NULL)
+ gs_app_set_developer_name (app, tmp);
+ }
+
+ /* set the release date */
+ timestamp = component_get_release_timestamp (component);
+ if (timestamp != G_MAXUINT64)
+ gs_app_set_release_date (app, timestamp);
+
+ /* set the version history */
+ if (!gs_appstream_refine_add_version_history (app, component, error))
+ return FALSE;
+
+ /* copy all the metadata */
+ if (!gs_appstream_copy_metadata (app, component, error))
+ return FALSE;
+
+ /* add bundles */
+ bundles = xb_node_query (component, "bundle", 0, NULL);
+ if (bundles != NULL && gs_app_get_sources(app)->len == 0) {
+ for (guint i = 0; i < bundles->len; i++) {
+ XbNode *bundle = g_ptr_array_index (bundles, i);
+ const gchar *kind = xb_node_get_attr (bundle, "type");
+ const gchar *bundle_id = xb_node_get_text (bundle);
+
+ if (bundle_id == NULL || kind == NULL)
+ continue;
+
+ gs_app_add_source (app, bundle_id);
+ gs_app_set_bundle_kind (app, as_bundle_kind_from_string (kind));
+
+ /* get the type/name/arch/branch */
+ if (gs_app_get_bundle_kind (app) == AS_BUNDLE_KIND_FLATPAK) {
+ g_auto(GStrv) split = g_strsplit (bundle_id, "/", -1);
+ if (g_strv_length (split) != 4) {
+ g_set_error (error,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_NOT_SUPPORTED,
+ "invalid ID %s for a flatpak ref",
+ bundle_id);
+ return FALSE;
+ }
+
+ /* we only need the branch for the unique ID */
+ gs_app_set_branch (app, split[3]);
+ }
+ }
+ }
+
+ /* add legacy package names */
+ if (gs_app_get_bundle_kind (app) == AS_BUNDLE_KIND_UNKNOWN) {
+ g_autoptr(GPtrArray) pkgnames = NULL;
+ pkgnames = xb_node_query (component, "pkgname", 0, NULL);
+ if (pkgnames != NULL && gs_app_get_sources(app)->len == 0) {
+ for (guint i = 0; i < pkgnames->len; i++) {
+ XbNode *pkgname = g_ptr_array_index (pkgnames, i);
+ tmp = xb_node_get_text (pkgname);
+ if (tmp != NULL && tmp[0] != '\0')
+ gs_app_add_source (app, tmp);
+ }
+ gs_app_set_bundle_kind (app, AS_BUNDLE_KIND_PACKAGE);
+ }
+ }
+
+ /* set origin */
+ tmp = xb_node_query_attr (component, "..", "origin", NULL);
+ if (gs_appstream_origin_valid (tmp)) {
+ gs_app_set_origin_appstream (app, tmp);
+
+ if (gs_app_get_origin (app) == NULL && (
+ gs_app_get_bundle_kind (app) == AS_BUNDLE_KIND_FLATPAK ||
+ gs_app_get_bundle_kind (app) == AS_BUNDLE_KIND_PACKAGE)) {
+ gs_app_set_origin (app, tmp);
+ }
+ }
+
+ /* set addons */
+ if ((refine_flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_ADDONS) != 0 &&
+ plugin != NULL && silo != NULL) {
+ if (!gs_appstream_refine_add_addons (plugin, app, silo, error))
+ return FALSE;
+ }
+
+ /* set screenshots */
+ if ((refine_flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_SCREENSHOTS) > 0 &&
+ gs_app_get_screenshots(app)->len == 0) {
+ if (!gs_appstream_refine_add_screenshots (app, component, error))
+ return FALSE;
+ }
+
+ /* set provides */
+ if (!gs_appstream_refine_add_provides (app, component, error))
+ return FALSE;
+
+ /* add kudos */
+ if (refine_flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_KUDOS) {
+ g_autoptr(GPtrArray) kudos = NULL;
+ tmp = setlocale (LC_MESSAGES, NULL);
+ if (!_gs_utils_locale_has_translations (tmp)) {
+ gs_app_add_kudo (app, GS_APP_KUDO_MY_LANGUAGE);
+ } else {
+
+ g_autoptr(GString) xpath = g_string_new (NULL);
+ g_auto(GStrv) variants = g_get_locale_variants (tmp);
+
+ /* @variants includes @tmp */
+ for (gsize i = 0; variants[i] != NULL; i++)
+ xb_string_append_union (xpath, "languages/lang[(text()='%s') and (@percentage>50)]", variants[i]);
+
+ if (xb_node_query_text (component, xpath->str, NULL) != NULL)
+ gs_app_add_kudo (app, GS_APP_KUDO_MY_LANGUAGE);
+ }
+
+ /* Set this under the FLAGS_REQUIRE_KUDOS flag because it’s
+ * only useful in combination with KUDO_MY_LANGUAGE */
+ if (xb_node_query_text (component, "languages/lang", NULL) != NULL)
+ gs_app_set_has_translations (app, TRUE);
+
+ /* any keywords */
+ if (xb_node_query_text (component, "keywords/keyword", NULL) != NULL)
+ gs_app_add_kudo (app, GS_APP_KUDO_HAS_KEYWORDS);
+
+ /* HiDPI icon */
+ if (xb_node_query_text (component, "icon[@width='128']", NULL) != NULL)
+ gs_app_add_kudo (app, GS_APP_KUDO_HI_DPI_ICON);
+
+ /* was this application released recently */
+ if (gs_appstream_is_recent_release (component))
+ gs_app_add_kudo (app, GS_APP_KUDO_RECENT_RELEASE);
+
+ /* add a kudo to featured and popular apps */
+ if (xb_node_query_text (component, "kudos/kudo[text()='GnomeSoftware::popular']", NULL) != NULL)
+ gs_app_add_kudo (app, GS_APP_KUDO_FEATURED_RECOMMENDED);
+ if (xb_node_query_text (component, "categories/category[text()='Featured']", NULL) != NULL)
+ gs_app_add_kudo (app, GS_APP_KUDO_FEATURED_RECOMMENDED);
+ }
+
+ /* we have an origin in the XML */
+ if ((refine_flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_ORIGIN) > 0 &&
+ gs_app_get_origin_appstream (app) == NULL) {
+ g_autoptr(XbNode) parent = xb_node_get_parent (component);
+ if (parent != NULL) {
+ tmp = xb_node_get_attr (parent, "origin");
+ if (gs_appstream_origin_valid (tmp))
+ gs_app_set_origin_appstream (app, tmp);
+ }
+ }
+
+ /* is there any update information */
+ if ((refine_flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_UPDATE_DETAILS) != 0 &&
+ silo != NULL) {
+ if (!gs_appstream_refine_app_updates (app,
+ silo,
+ component,
+ error))
+ return FALSE;
+ }
+
+ return TRUE;
+}
+
+typedef struct {
+ AsSearchTokenMatch match_value;
+ XbQuery *query;
+} GsAppstreamSearchHelper;
+
+static void
+gs_appstream_search_helper_free (GsAppstreamSearchHelper *helper)
+{
+ g_object_unref (helper->query);
+ g_free (helper);
+}
+
+static guint16
+gs_appstream_silo_search_component2 (GPtrArray *array, XbNode *component, const gchar *search)
+{
+ guint16 match_value = 0;
+
+ /* do searches */
+ for (guint i = 0; i < array->len; i++) {
+ g_autoptr(GPtrArray) n = NULL;
+ GsAppstreamSearchHelper *helper = g_ptr_array_index (array, i);
+#if LIBXMLB_CHECK_VERSION(0, 3, 0)
+ g_auto(XbQueryContext) context = XB_QUERY_CONTEXT_INIT ();
+ xb_value_bindings_bind_str (xb_query_context_get_bindings (&context), 0, search, NULL);
+ n = xb_node_query_with_context (component, helper->query, &context, NULL);
+#else
+ xb_query_bind_str (helper->query, 0, search, NULL);
+ n = xb_node_query_full (component, helper->query, NULL);
+#endif
+ if (n != NULL)
+ match_value |= helper->match_value;
+ }
+ return match_value;
+}
+
+static guint16
+gs_appstream_silo_search_component (GPtrArray *array, XbNode *component, const gchar * const *search)
+{
+ guint16 matches_sum = 0;
+
+ /* do *all* search keywords match */
+ for (guint i = 0; search[i] != NULL; i++) {
+ guint tmp = gs_appstream_silo_search_component2 (array, component, search[i]);
+ if (tmp == 0)
+ return 0;
+ matches_sum |= tmp;
+ }
+ return matches_sum;
+}
+
+typedef struct {
+ AsSearchTokenMatch match_value;
+ const gchar *xpath;
+} Query;
+
+static gboolean
+gs_appstream_do_search (GsPlugin *plugin,
+ XbSilo *silo,
+ const gchar * const *values,
+ const Query queries[],
+ GsAppList *list,
+ GCancellable *cancellable,
+ GError **error)
+{
+ g_autoptr(GError) error_local = NULL;
+ g_autoptr(GPtrArray) array = g_ptr_array_new_with_free_func ((GDestroyNotify) gs_appstream_search_helper_free);
+ g_autoptr(GPtrArray) components = NULL;
+ g_autoptr(GTimer) timer = g_timer_new ();
+
+ g_return_val_if_fail (GS_IS_PLUGIN (plugin), FALSE);
+ g_return_val_if_fail (XB_IS_SILO (silo), FALSE);
+ g_return_val_if_fail (values != NULL, FALSE);
+ g_return_val_if_fail (GS_IS_APP_LIST (list), FALSE);
+
+ /* add some weighted queries */
+ for (guint i = 0; queries[i].xpath != NULL; i++) {
+ g_autoptr(GError) error_query = NULL;
+ g_autoptr(XbQuery) query = xb_query_new (silo, queries[i].xpath, &error_query);
+ if (query != NULL) {
+ GsAppstreamSearchHelper *helper = g_new0 (GsAppstreamSearchHelper, 1);
+ helper->match_value = queries[i].match_value;
+ helper->query = g_steal_pointer (&query);
+ g_ptr_array_add (array, helper);
+ } else {
+ g_debug ("ignoring: %s", error_query->message);
+ }
+ }
+
+ /* get all components */
+ components = xb_silo_query (silo, "components/component", 0, &error_local);
+ if (components == NULL) {
+ if (g_error_matches (error_local, G_IO_ERROR, G_IO_ERROR_NOT_FOUND))
+ return TRUE;
+ g_propagate_error (error, g_steal_pointer (&error_local));
+ return FALSE;
+ }
+ for (guint i = 0; i < components->len; i++) {
+ XbNode *component = g_ptr_array_index (components, i);
+ guint16 match_value = gs_appstream_silo_search_component (array, component, values);
+ if (match_value != 0) {
+ g_autoptr(GsApp) app = gs_appstream_create_app (plugin, silo, component, error);
+ if (app == NULL)
+ return FALSE;
+ if (gs_app_has_quirk (app, GS_APP_QUIRK_IS_WILDCARD)) {
+ g_debug ("not returning wildcard %s",
+ gs_app_get_unique_id (app));
+ continue;
+ }
+ g_debug ("add %s", gs_app_get_unique_id (app));
+
+ /* The match value is used for prioritising results.
+ * Drop the ID token from it as it’s the highest
+ * numeric value but isn’t visible to the user in the
+ * UI, which leads to confusing results ordering. */
+ gs_app_set_match_value (app, match_value & (~AS_SEARCH_TOKEN_MATCH_ID));
+ gs_app_list_add (list, app);
+
+ if (gs_app_get_kind (app) == AS_COMPONENT_KIND_ADDON) {
+ g_autoptr(GPtrArray) extends = NULL;
+
+ /* add the parent app as a wildcard, to be refined later */
+ extends = xb_node_query (component, "extends", 0, NULL);
+ for (guint jj = 0; extends && jj < extends->len; jj++) {
+ XbNode *extend = g_ptr_array_index (extends, jj);
+ g_autoptr(GsApp) app2 = NULL;
+ const gchar *tmp;
+ app2 = gs_app_new (xb_node_get_text (extend));
+ gs_app_add_quirk (app2, GS_APP_QUIRK_IS_WILDCARD);
+ tmp = xb_node_query_attr (extend, "../..", "origin", NULL);
+ if (gs_appstream_origin_valid (tmp))
+ gs_app_set_origin_appstream (app2, tmp);
+ gs_app_list_add (list, app2);
+ }
+ }
+ }
+
+ if (g_cancellable_set_error_if_cancelled (cancellable, error))
+ return FALSE;
+ }
+ g_debug ("search took %fms", g_timer_elapsed (timer, NULL) * 1000);
+ return TRUE;
+}
+
+/* This tokenises and stems @values internally for comparison against the
+ * already-stemmed tokens in the libxmlb silo */
+gboolean
+gs_appstream_search (GsPlugin *plugin,
+ XbSilo *silo,
+ const gchar * const *values,
+ GsAppList *list,
+ GCancellable *cancellable,
+ GError **error)
+{
+ const Query queries[] = {
+ { AS_SEARCH_TOKEN_MATCH_MIMETYPE, "mimetypes/mimetype[text()~=stem(?)]" },
+ { AS_SEARCH_TOKEN_MATCH_PKGNAME, "pkgname[text()~=stem(?)]" },
+ { AS_SEARCH_TOKEN_MATCH_SUMMARY, "summary[text()~=stem(?)]" },
+ { AS_SEARCH_TOKEN_MATCH_NAME, "name[text()~=stem(?)]" },
+ { AS_SEARCH_TOKEN_MATCH_KEYWORD, "keywords/keyword[text()~=stem(?)]" },
+ { AS_SEARCH_TOKEN_MATCH_ID, "id[text()~=stem(?)]" },
+ { AS_SEARCH_TOKEN_MATCH_ID, "launchable[text()~=stem(?)]" },
+ { AS_SEARCH_TOKEN_MATCH_ORIGIN, "../components[@origin~=stem(?)]" },
+ { AS_SEARCH_TOKEN_MATCH_NONE, NULL }
+ };
+
+ return gs_appstream_do_search (plugin, silo, values, queries, list, cancellable, error);
+}
+
+gboolean
+gs_appstream_search_developer_apps (GsPlugin *plugin,
+ XbSilo *silo,
+ const gchar * const *values,
+ GsAppList *list,
+ GCancellable *cancellable,
+ GError **error)
+{
+ const Query queries[] = {
+ { AS_SEARCH_TOKEN_MATCH_PKGNAME, "developer_name[text()~=stem(?)]" },
+ { AS_SEARCH_TOKEN_MATCH_SUMMARY, "project_group[text()~=stem(?)]" },
+ { AS_SEARCH_TOKEN_MATCH_NONE, NULL }
+ };
+
+ return gs_appstream_do_search (plugin, silo, values, queries, list, cancellable, error);
+}
+
+gboolean
+gs_appstream_add_category_apps (GsPlugin *plugin,
+ XbSilo *silo,
+ GsCategory *category,
+ GsAppList *list,
+ GCancellable *cancellable,
+ GError **error)
+{
+ GPtrArray *desktop_groups;
+
+ g_return_val_if_fail (GS_IS_PLUGIN (plugin), FALSE);
+ g_return_val_if_fail (XB_IS_SILO (silo), FALSE);
+ g_return_val_if_fail (GS_IS_CATEGORY (category), FALSE);
+ g_return_val_if_fail (GS_IS_APP_LIST (list), FALSE);
+
+ desktop_groups = gs_category_get_desktop_groups (category);
+ if (desktop_groups->len == 0) {
+ g_warning ("no desktop_groups for %s", gs_category_get_id (category));
+ return TRUE;
+ }
+ for (guint j = 0; j < desktop_groups->len; j++) {
+ const gchar *desktop_group = g_ptr_array_index (desktop_groups, j);
+ g_autofree gchar *xpath = NULL;
+ g_auto(GStrv) split = g_strsplit (desktop_group, "::", -1);
+ g_autoptr(GPtrArray) components = NULL;
+ g_autoptr(GError) error_local = NULL;
+
+ /* generate query */
+ if (g_strv_length (split) == 1) {
+ xpath = g_strdup_printf ("components/component/categories/"
+ "category[text()='%s']/../..",
+ split[0]);
+ } else if (g_strv_length (split) == 2) {
+ xpath = g_strdup_printf ("components/component/categories/"
+ "category[text()='%s']/../"
+ "category[text()='%s']/../..",
+ split[0], split[1]);
+ }
+ components = xb_silo_query (silo, xpath, 0, &error_local);
+ if (components == NULL) {
+ if (g_error_matches (error_local, G_IO_ERROR, G_IO_ERROR_NOT_FOUND))
+ continue;
+ g_propagate_error (error, g_steal_pointer (&error_local));
+ return FALSE;
+ }
+
+ /* create app */
+ for (guint i = 0; i < components->len; i++) {
+ XbNode *component = g_ptr_array_index (components, i);
+ g_autoptr(GsApp) app = NULL;
+ const gchar *id = xb_node_query_text (component, "id", NULL);
+ if (id == NULL)
+ continue;
+ app = gs_app_new (id);
+ gs_app_set_metadata (app, "GnomeSoftware::Creator",
+ gs_plugin_get_name (plugin));
+ gs_app_add_quirk (app, GS_APP_QUIRK_IS_WILDCARD);
+ gs_app_list_add (list, app);
+ }
+
+ }
+ return TRUE;
+}
+
+static guint
+gs_appstream_count_component_for_groups (XbSilo *silo,
+ const gchar *desktop_group)
+{
+ guint limit = 10;
+ g_autofree gchar *xpath = NULL;
+ g_auto(GStrv) split = g_strsplit (desktop_group, "::", -1);
+ g_autoptr(GPtrArray) array = NULL;
+ g_autoptr(GError) error_local = NULL;
+
+ if (g_strv_length (split) == 1) { /* "all" group for a parent category */
+ xpath = g_strdup_printf ("components/component/categories/"
+ "category[text()='%s']/../..",
+ split[0]);
+ } else if (g_strv_length (split) == 2) {
+ xpath = g_strdup_printf ("components/component/categories/"
+ "category[text()='%s']/../"
+ "category[text()='%s']/../..",
+ split[0], split[1]);
+ } else {
+ return 0;
+ }
+
+ array = xb_silo_query (silo, xpath, limit, &error_local);
+ if (array == NULL) {
+ if (g_error_matches (error_local, G_IO_ERROR, G_IO_ERROR_NOT_FOUND))
+ return 0;
+ g_warning ("%s", error_local->message);
+ return 0;
+ }
+ return array->len;
+}
+
+/* we're not actually adding categories here, we're just setting the number of
+ * applications available in each category */
+gboolean
+gs_appstream_refine_category_sizes (XbSilo *silo,
+ GPtrArray *list,
+ GCancellable *cancellable,
+ GError **error)
+{
+ g_return_val_if_fail (XB_IS_SILO (silo), FALSE);
+ g_return_val_if_fail (list != NULL, FALSE);
+
+ for (guint j = 0; j < list->len; j++) {
+ GsCategory *parent = GS_CATEGORY (g_ptr_array_index (list, j));
+ GPtrArray *children = gs_category_get_children (parent);
+
+ for (guint i = 0; i < children->len; i++) {
+ GsCategory *cat = g_ptr_array_index (children, i);
+ GPtrArray *groups = gs_category_get_desktop_groups (cat);
+ for (guint k = 0; k < groups->len; k++) {
+ const gchar *group = g_ptr_array_index (groups, k);
+ guint cnt = gs_appstream_count_component_for_groups (silo, group);
+ if (cnt > 0) {
+ gs_category_increment_size (parent, cnt);
+ if (children->len > 1) {
+ /* Parent category has multiple groups, so increment
+ * each group's size too */
+ gs_category_increment_size (cat, cnt);
+ }
+ }
+ }
+ }
+ continue;
+ }
+ return TRUE;
+}
+
+gboolean
+gs_appstream_add_installed (GsPlugin *plugin,
+ XbSilo *silo,
+ GsAppList *list,
+ GCancellable *cancellable,
+ GError **error)
+{
+ g_autoptr(GPtrArray) components = NULL;
+ g_autoptr(GError) local_error = NULL;
+
+ g_return_val_if_fail (GS_IS_PLUGIN (plugin), FALSE);
+ g_return_val_if_fail (XB_IS_SILO (silo), FALSE);
+ g_return_val_if_fail (GS_IS_APP_LIST (list), FALSE);
+
+ /* get all installed appdata files (notice no 'components/' prefix...) */
+ components = xb_silo_query (silo, "component/description/..", 0, NULL);
+ if (components == NULL)
+ return TRUE;
+
+ for (guint i = 0; i < components->len; i++) {
+ XbNode *component = g_ptr_array_index (components, i);
+ g_autoptr(GsApp) app = gs_appstream_create_app (plugin, silo, component, error);
+ if (app == NULL)
+ return FALSE;
+
+ /* Can get cached GsApp, which has the state already updated */
+ if (gs_app_get_state (app) != GS_APP_STATE_UPDATABLE &&
+ gs_app_get_state (app) != GS_APP_STATE_UPDATABLE_LIVE)
+ gs_app_set_state (app, GS_APP_STATE_INSTALLED);
+ gs_app_set_scope (app, AS_COMPONENT_SCOPE_SYSTEM);
+ gs_app_list_add (list, app);
+ }
+
+ return TRUE;
+}
+
+gboolean
+gs_appstream_add_popular (XbSilo *silo,
+ GsAppList *list,
+ GCancellable *cancellable,
+ GError **error)
+{
+ g_autoptr(GError) error_local = NULL;
+ g_autoptr(GPtrArray) array = NULL;
+
+ g_return_val_if_fail (XB_IS_SILO (silo), FALSE);
+ g_return_val_if_fail (GS_IS_APP_LIST (list), FALSE);
+
+ /* find out how many packages are in each category */
+ array = xb_silo_query (silo,
+ "components/component/kudos/"
+ "kudo[text()='GnomeSoftware::popular']/../..",
+ 0, &error_local);
+ if (array == NULL) {
+ if (g_error_matches (error_local, G_IO_ERROR, G_IO_ERROR_NOT_FOUND))
+ return TRUE;
+ g_propagate_error (error, g_steal_pointer (&error_local));
+ return FALSE;
+ }
+ for (guint i = 0; i < array->len; i++) {
+ g_autoptr(GsApp) app = NULL;
+ XbNode *component = g_ptr_array_index (array, i);
+ const gchar *component_id = xb_node_query_text (component, "id", NULL);
+ if (component_id == NULL)
+ continue;
+ app = gs_app_new (component_id);
+ gs_app_add_quirk (app, GS_APP_QUIRK_IS_WILDCARD);
+ gs_app_list_add (list, app);
+ }
+ return TRUE;
+}
+
+gboolean
+gs_appstream_add_recent (GsPlugin *plugin,
+ XbSilo *silo,
+ GsAppList *list,
+ guint64 age,
+ GCancellable *cancellable,
+ GError **error)
+{
+ guint64 now = (guint64) g_get_real_time () / G_USEC_PER_SEC;
+ g_autofree gchar *xpath = NULL;
+ g_autoptr(GError) error_local = NULL;
+ g_autoptr(GPtrArray) array = NULL;
+
+ g_return_val_if_fail (GS_IS_PLUGIN (plugin), FALSE);
+ g_return_val_if_fail (XB_IS_SILO (silo), FALSE);
+ g_return_val_if_fail (GS_IS_APP_LIST (list), FALSE);
+
+ /* use predicate conditions to the max */
+ xpath = g_strdup_printf ("components/component/releases/"
+ "release[@timestamp>%" G_GUINT64_FORMAT "]/../..",
+ now - age);
+ array = xb_silo_query (silo, xpath, 0, &error_local);
+ if (array == NULL) {
+ if (g_error_matches (error_local, G_IO_ERROR, G_IO_ERROR_NOT_FOUND))
+ return TRUE;
+ g_propagate_error (error, g_steal_pointer (&error_local));
+ return FALSE;
+ }
+ for (guint i = 0; i < array->len; i++) {
+ XbNode *component = g_ptr_array_index (array, i);
+ g_autoptr(GsApp) app = gs_appstream_create_app (plugin, silo, component, error);
+ guint64 timestamp;
+ if (app == NULL)
+ return FALSE;
+ /* set the release date */
+ timestamp = component_get_release_timestamp (component);
+ if (timestamp != G_MAXUINT64)
+ gs_app_set_release_date (app, timestamp);
+ gs_app_list_add (list, app);
+ }
+ return TRUE;
+}
+
+gboolean
+gs_appstream_add_alternates (XbSilo *silo,
+ GsApp *app,
+ GsAppList *list,
+ GCancellable *cancellable,
+ GError **error)
+{
+ GPtrArray *sources = gs_app_get_sources (app);
+ g_autoptr(GError) error_local = NULL;
+ g_autoptr(GPtrArray) ids = NULL;
+ g_autoptr(GString) xpath = g_string_new (NULL);
+
+ g_return_val_if_fail (XB_IS_SILO (silo), FALSE);
+ g_return_val_if_fail (GS_IS_APP (app), FALSE);
+ g_return_val_if_fail (GS_IS_APP_LIST (list), FALSE);
+
+ /* probably a package we know nothing about */
+ if (gs_app_get_id (app) == NULL)
+ return TRUE;
+
+ /* actual ID */
+ xb_string_append_union (xpath, "components/component/id[text()='%s']",
+ gs_app_get_id (app));
+
+ /* new ID -> old ID */
+ xb_string_append_union (xpath, "components/component/id[text()='%s']/../provides/id",
+ gs_app_get_id (app));
+
+ /* old ID -> new ID */
+ xb_string_append_union (xpath, "components/component/provides/id[text()='%s']/../../id",
+ gs_app_get_id (app));
+
+ /* find apps that use the same pkgname */
+ for (guint j = 0; j < sources->len; j++) {
+ const gchar *source = g_ptr_array_index (sources, j);
+ g_autofree gchar *source_safe = xb_string_escape (source);
+ xb_string_append_union (xpath,
+ "components/component/pkgname[text()='%s']/../id",
+ source_safe);
+ }
+
+ /* do a big query, and return all the unique results */
+ ids = xb_silo_query (silo, xpath->str, 0, &error_local);
+ if (ids == NULL) {
+ if (g_error_matches (error_local, G_IO_ERROR, G_IO_ERROR_NOT_FOUND))
+ return TRUE;
+ g_propagate_error (error, g_steal_pointer (&error_local));
+ return FALSE;
+ }
+ for (guint i = 0; i < ids->len; i++) {
+ XbNode *n = g_ptr_array_index (ids, i);
+ g_autoptr(GsApp) app2 = NULL;
+ const gchar *tmp;
+ app2 = gs_app_new (xb_node_get_text (n));
+ gs_app_add_quirk (app2, GS_APP_QUIRK_IS_WILDCARD);
+
+ tmp = xb_node_query_attr (n, "../..", "origin", NULL);
+ if (gs_appstream_origin_valid (tmp))
+ gs_app_set_origin_appstream (app2, tmp);
+ gs_app_list_add (list, app2);
+ }
+ return TRUE;
+}
+
+static gboolean
+gs_appstream_add_featured_with_query (XbSilo *silo,
+ const gchar *query,
+ GsAppList *list,
+ GCancellable *cancellable,
+ GError **error)
+{
+ g_autoptr(GError) error_local = NULL;
+ g_autoptr(GPtrArray) array = NULL;
+
+ g_return_val_if_fail (XB_IS_SILO (silo), FALSE);
+ g_return_val_if_fail (GS_IS_APP_LIST (list), FALSE);
+
+ /* find out how many packages are in each category */
+ array = xb_silo_query (silo, query, 0, &error_local);
+ if (array == NULL) {
+ if (g_error_matches (error_local, G_IO_ERROR, G_IO_ERROR_NOT_FOUND))
+ return TRUE;
+ g_propagate_error (error, g_steal_pointer (&error_local));
+ return FALSE;
+ }
+ for (guint i = 0; i < array->len; i++) {
+ g_autoptr(GsApp) app = NULL;
+ XbNode *component = g_ptr_array_index (array, i);
+ const gchar *component_id = xb_node_query_text (component, "id", NULL);
+ if (component_id == NULL)
+ continue;
+ app = gs_app_new (component_id);
+ gs_app_add_quirk (app, GS_APP_QUIRK_IS_WILDCARD);
+ if (!gs_appstream_copy_metadata (app, component, error))
+ return FALSE;
+ gs_app_list_add (list, app);
+ }
+ return TRUE;
+}
+
+gboolean
+gs_appstream_add_featured (XbSilo *silo,
+ GsAppList *list,
+ GCancellable *cancellable,
+ GError **error)
+{
+ const gchar *query = "components/component/custom/value[@key='GnomeSoftware::FeatureTile']/../..|"
+ "components/component/custom/value[@key='GnomeSoftware::FeatureTile-css']/../..";
+ return gs_appstream_add_featured_with_query (silo, query, list, cancellable, error);
+}
+
+gboolean
+gs_appstream_add_deployment_featured (XbSilo *silo,
+ const gchar * const *deployments,
+ GsAppList *list,
+ GCancellable *cancellable,
+ GError **error)
+{
+ g_autoptr(GString) query = g_string_new (NULL);
+ g_return_val_if_fail (XB_IS_SILO (silo), FALSE);
+ g_return_val_if_fail (deployments != NULL, FALSE);
+ g_return_val_if_fail (GS_IS_APP_LIST (list), FALSE);
+ for (guint ii = 0; deployments[ii] != NULL; ii++) {
+ g_autofree gchar *escaped = xb_string_escape (deployments[ii]);
+ if (escaped != NULL && *escaped != '\0') {
+ xb_string_append_union (query,
+ "components/component/custom/value[@key='GnomeSoftware::DeploymentFeatured'][text()='%s']/../..",
+ escaped);
+ }
+ }
+ if (!query->len)
+ return TRUE;
+ return gs_appstream_add_featured_with_query (silo, query->str, list, cancellable, error);
+}
+
+gboolean
+gs_appstream_url_to_app (GsPlugin *plugin,
+ XbSilo *silo,
+ GsAppList *list,
+ const gchar *url,
+ GCancellable *cancellable,
+ GError **error)
+{
+ g_autofree gchar *path = NULL;
+ g_autofree gchar *scheme = NULL;
+ g_autofree gchar *xpath = NULL;
+ g_autoptr(GPtrArray) components = NULL;
+
+ g_return_val_if_fail (GS_IS_PLUGIN (plugin), FALSE);
+ g_return_val_if_fail (XB_IS_SILO (silo), FALSE);
+ g_return_val_if_fail (GS_IS_APP_LIST (list), FALSE);
+ g_return_val_if_fail (url != NULL, FALSE);
+
+ /* not us */
+ scheme = gs_utils_get_url_scheme (url);
+ if (g_strcmp0 (scheme, "appstream") != 0)
+ return TRUE;
+
+ path = gs_utils_get_url_path (url);
+ xpath = g_strdup_printf ("components/component/id[text()='%s']/..", path);
+ components = xb_silo_query (silo, xpath, 0, NULL);
+ if (components == NULL)
+ return TRUE;
+
+ for (guint i = 0; i < components->len; i++) {
+ XbNode *component = g_ptr_array_index (components, i);
+ g_autoptr(GsApp) app = NULL;
+ app = gs_appstream_create_app (plugin, silo, component, error);
+ if (app == NULL)
+ return FALSE;
+ gs_app_set_scope (app, AS_COMPONENT_SCOPE_SYSTEM);
+ gs_app_list_add (list, app);
+ }
+
+ return TRUE;
+}
+
+void
+gs_appstream_component_add_keyword (XbBuilderNode *component, const gchar *str)
+{
+ g_autoptr(XbBuilderNode) keyword = NULL;
+ g_autoptr(XbBuilderNode) keywords = NULL;
+
+ g_return_if_fail (XB_IS_BUILDER_NODE (component));
+ g_return_if_fail (str != NULL);
+
+ /* create <keywords> if it does not already exist */
+ keywords = xb_builder_node_get_child (component, "keywords", NULL);
+ if (keywords == NULL)
+ keywords = xb_builder_node_insert (component, "keywords", NULL);
+
+ /* create <keyword>str</keyword> if it does not already exist */
+ keyword = xb_builder_node_get_child (keywords, "keyword", str);
+ if (keyword == NULL) {
+ keyword = xb_builder_node_insert (keywords, "keyword", NULL);
+ xb_builder_node_set_text (keyword, str, -1);
+ }
+}
+
+void
+gs_appstream_component_add_provide (XbBuilderNode *component, const gchar *str)
+{
+ g_autoptr(XbBuilderNode) provide = NULL;
+ g_autoptr(XbBuilderNode) provides = NULL;
+
+ g_return_if_fail (XB_IS_BUILDER_NODE (component));
+ g_return_if_fail (str != NULL);
+
+ /* create <provides> if it does not already exist */
+ provides = xb_builder_node_get_child (component, "provides", NULL);
+ if (provides == NULL)
+ provides = xb_builder_node_insert (component, "provides", NULL);
+
+ /* create <id>str</id> if it does not already exist */
+ provide = xb_builder_node_get_child (provides, "id", str);
+ if (provide == NULL) {
+ provide = xb_builder_node_insert (provides, "id", NULL);
+ xb_builder_node_set_text (provide, str, -1);
+ }
+}
+
+void
+gs_appstream_component_add_category (XbBuilderNode *component, const gchar *str)
+{
+ g_autoptr(XbBuilderNode) category = NULL;
+ g_autoptr(XbBuilderNode) categories = NULL;
+
+ g_return_if_fail (XB_IS_BUILDER_NODE (component));
+ g_return_if_fail (str != NULL);
+
+ /* create <categories> if it does not already exist */
+ categories = xb_builder_node_get_child (component, "categories", NULL);
+ if (categories == NULL)
+ categories = xb_builder_node_insert (component, "categories", NULL);
+
+ /* create <category>str</category> if it does not already exist */
+ category = xb_builder_node_get_child (categories, "category", str);
+ if (category == NULL) {
+ category = xb_builder_node_insert (categories, "category", NULL);
+ xb_builder_node_set_text (category, str, -1);
+ }
+}
+
+void
+gs_appstream_component_add_icon (XbBuilderNode *component, const gchar *str)
+{
+ g_autoptr(XbBuilderNode) icon = NULL;
+
+ g_return_if_fail (XB_IS_BUILDER_NODE (component));
+ g_return_if_fail (str != NULL);
+
+ /* create <icon>str</icon> if it does not already exist */
+ icon = xb_builder_node_get_child (component, "icon", NULL);
+ if (icon == NULL) {
+ icon = xb_builder_node_insert (component, "icon",
+ "type", "stock",
+ NULL);
+ xb_builder_node_set_text (icon, str, -1);
+ }
+}
+
+void
+gs_appstream_component_add_extra_info (XbBuilderNode *component)
+{
+ const gchar *kind;
+
+ g_return_if_fail (XB_IS_BUILDER_NODE (component));
+
+ kind = xb_builder_node_get_attr (component, "type");
+
+ /* add the gnome-software-specific 'Addon' group and ensure they
+ * all have an icon set */
+ switch (as_component_kind_from_string (kind)) {
+ case AS_COMPONENT_KIND_WEB_APP:
+ gs_appstream_component_add_keyword (component, kind);
+ break;
+ case AS_COMPONENT_KIND_FONT:
+ gs_appstream_component_add_category (component, "Addon");
+ gs_appstream_component_add_category (component, "Font");
+ break;
+ case AS_COMPONENT_KIND_DRIVER:
+ gs_appstream_component_add_category (component, "Addon");
+ gs_appstream_component_add_category (component, "Driver");
+ gs_appstream_component_add_icon (component, "system-component-driver");
+ break;
+ case AS_COMPONENT_KIND_LOCALIZATION:
+ gs_appstream_component_add_category (component, "Addon");
+ gs_appstream_component_add_category (component, "Localization");
+ gs_appstream_component_add_icon (component, "system-component-language");
+ break;
+ case AS_COMPONENT_KIND_CODEC:
+ gs_appstream_component_add_category (component, "Addon");
+ gs_appstream_component_add_category (component, "Codec");
+ gs_appstream_component_add_icon (component, "system-component-codecs");
+ break;
+ case AS_COMPONENT_KIND_INPUT_METHOD:
+ gs_appstream_component_add_keyword (component, kind);
+ gs_appstream_component_add_category (component, "Addon");
+ gs_appstream_component_add_category (component, "InputSource");
+ gs_appstream_component_add_icon (component, "system-component-input-sources");
+ break;
+ case AS_COMPONENT_KIND_FIRMWARE:
+ gs_appstream_component_add_icon (component, "system-component-firmware");
+ break;
+ default:
+ break;
+ }
+}
+
+/* Resolve any media URIs which are actually relative
+ * paths against the media_baseurl property */
+void
+gs_appstream_component_fix_url (XbBuilderNode *component, const gchar *baseurl)
+{
+ const gchar *text;
+ g_autofree gchar *url = NULL;
+
+ g_return_if_fail (XB_IS_BUILDER_NODE (component));
+ g_return_if_fail (baseurl != NULL);
+
+ text = xb_builder_node_get_text (component);
+
+ if (text == NULL)
+ return;
+
+ if (g_str_has_prefix (text, "http:") ||
+ g_str_has_prefix (text, "https:"))
+ return;
+
+ url = g_strconcat (baseurl, "/", text, NULL);
+ xb_builder_node_set_text (component, url , -1);
+}
diff --git a/lib/gs-appstream.h b/lib/gs-appstream.h
new file mode 100644
index 0000000..d0d6ea2
--- /dev/null
+++ b/lib/gs-appstream.h
@@ -0,0 +1,95 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2015-2017 Richard Hughes <richard@hughsie.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#include <gnome-software.h>
+#include <xmlb.h>
+
+G_BEGIN_DECLS
+
+GsApp *gs_appstream_create_app (GsPlugin *plugin,
+ XbSilo *silo,
+ XbNode *component,
+ GError **error);
+gboolean gs_appstream_refine_app (GsPlugin *plugin,
+ GsApp *app,
+ XbSilo *silo,
+ XbNode *component,
+ GsPluginRefineFlags flags,
+ GError **error);
+gboolean gs_appstream_search (GsPlugin *plugin,
+ XbSilo *silo,
+ const gchar * const *values,
+ GsAppList *list,
+ GCancellable *cancellable,
+ GError **error);
+gboolean gs_appstream_search_developer_apps (GsPlugin *plugin,
+ XbSilo *silo,
+ const gchar * const *values,
+ GsAppList *list,
+ GCancellable *cancellable,
+ GError **error);
+gboolean gs_appstream_refine_category_sizes (XbSilo *silo,
+ GPtrArray *list,
+ GCancellable *cancellable,
+ GError **error);
+gboolean gs_appstream_add_category_apps (GsPlugin *plugin,
+ XbSilo *silo,
+ GsCategory *category,
+ GsAppList *list,
+ GCancellable *cancellable,
+ GError **error);
+gboolean gs_appstream_add_installed (GsPlugin *plugin,
+ XbSilo *silo,
+ GsAppList *list,
+ GCancellable *cancellable,
+ GError **error);
+gboolean gs_appstream_add_popular (XbSilo *silo,
+ GsAppList *list,
+ GCancellable *cancellable,
+ GError **error);
+gboolean gs_appstream_add_featured (XbSilo *silo,
+ GsAppList *list,
+ GCancellable *cancellable,
+ GError **error);
+gboolean gs_appstream_add_deployment_featured (XbSilo *silo,
+ const gchar * const *deployments,
+ GsAppList *list,
+ GCancellable *cancellable,
+ GError **error);
+gboolean gs_appstream_add_alternates (XbSilo *silo,
+ GsApp *app,
+ GsAppList *list,
+ GCancellable *cancellable,
+ GError **error);
+gboolean gs_appstream_add_recent (GsPlugin *plugin,
+ XbSilo *silo,
+ GsAppList *list,
+ guint64 age,
+ GCancellable *cancellable,
+ GError **error);
+gboolean gs_appstream_url_to_app (GsPlugin *plugin,
+ XbSilo *silo,
+ GsAppList *list,
+ const gchar *url,
+ GCancellable *cancellable,
+ GError **error);
+void gs_appstream_component_add_extra_info (XbBuilderNode *component);
+void gs_appstream_component_add_keyword (XbBuilderNode *component,
+ const gchar *str);
+void gs_appstream_component_add_category (XbBuilderNode *component,
+ const gchar *str);
+void gs_appstream_component_add_icon (XbBuilderNode *component,
+ const gchar *str);
+void gs_appstream_component_add_provide (XbBuilderNode *component,
+ const gchar *str);
+void gs_appstream_component_fix_url (XbBuilderNode *component,
+ const gchar *baseurl);
+
+G_END_DECLS
diff --git a/lib/gs-build-ident.h.in b/lib/gs-build-ident.h.in
new file mode 100644
index 0000000..92cd79a
--- /dev/null
+++ b/lib/gs-build-ident.h.in
@@ -0,0 +1,32 @@
+/* -*- 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@endlessos.org>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#include <glib.h>
+
+G_BEGIN_DECLS
+
+/**
+ * SECTION:gs-build-ident
+ * @title: Build Identifier
+ * @short_description: Identify a build by unique build identifier
+ *
+ * Since: 40
+ */
+
+/**
+ * GS_BUILD_IDENTIFIER:
+ *
+ * A string containing a tag that defines the version of Software that
+ * was built. Generally, this will be a small version tag plus some
+ * information to identify the git commit hash when applicable.
+ */
+#define GS_BUILD_IDENTIFIER "@VCS_TAG@"
+
+G_END_DECLS
diff --git a/lib/gs-category-manager.c b/lib/gs-category-manager.c
new file mode 100644
index 0000000..0e37264
--- /dev/null
+++ b/lib/gs-category-manager.c
@@ -0,0 +1,151 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2021 Endless OS Foundation LLC
+ *
+ * Author: Philip Withnall <pwithnall@endlessos.org>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+/**
+ * SECTION:gs-category-manager
+ * @short_description: A container to store #GsCategory instances in
+ *
+ * #GsCategoryManager is a container object which stores #GsCategory instances,
+ * so that they can be consistently reused by other code, without creating
+ * multiple #GsCategory instances for the same category ID.
+ *
+ * It is intended to be used as a singleton, and typically accessed by calling
+ * gs_plugin_loader_get_category_manager().
+ *
+ * Since: 40
+ */
+
+#include "config.h"
+
+#include <glib.h>
+#include <glib-object.h>
+#include <glib/gi18n.h>
+
+#include "gs-category-manager.h"
+#include "gs-desktop-data.h"
+
+struct _GsCategoryManager
+{
+ GObject parent;
+
+ /* Array of #GsCategory instances corresponding to the entries in gs_desktop_get_data()
+ * The +1 is for a NULL terminator */
+ GsCategory *categories[GS_DESKTOP_DATA_N_ENTRIES + 1];
+};
+
+G_DEFINE_TYPE (GsCategoryManager, gs_category_manager, G_TYPE_OBJECT)
+
+static void
+gs_category_manager_dispose (GObject *object)
+{
+ GsCategoryManager *self = GS_CATEGORY_MANAGER (object);
+
+ for (gsize i = 0; i < G_N_ELEMENTS (self->categories); i++)
+ g_clear_object (&self->categories[i]);
+
+ G_OBJECT_CLASS (gs_category_manager_parent_class)->dispose (object);
+}
+
+static void
+gs_category_manager_class_init (GsCategoryManagerClass *klass)
+{
+ GObjectClass *object_class = G_OBJECT_CLASS (klass);
+
+ object_class->dispose = gs_category_manager_dispose;
+}
+
+static void
+gs_category_manager_init (GsCategoryManager *self)
+{
+ const GsDesktopData *msdata;
+
+ /* Set up the category data, and check our expectations about the length
+ * of gs_desktop_get_data() match reality. */
+ msdata = gs_desktop_get_data ();
+ for (gsize i = 0; msdata[i].id != NULL; i++) {
+ g_assert (i < G_N_ELEMENTS (self->categories) - 1);
+ self->categories[i] = gs_category_new_for_desktop_data (&msdata[i]);
+ }
+
+ g_assert (self->categories[G_N_ELEMENTS (self->categories) - 2] != NULL);
+ g_assert (self->categories[G_N_ELEMENTS (self->categories) - 1] == NULL);
+}
+
+/**
+ * gs_category_manager_new:
+ *
+ * Create a new #GsCategoryManager. It will contain all the categories, but
+ * their sizes will not be set until gs_category_increment_size() is called
+ * on them.
+ *
+ * Returns: (transfer full): a new #GsCategoryManager
+ * Since: 40
+ */
+GsCategoryManager *
+gs_category_manager_new (void)
+{
+ return g_object_new (GS_TYPE_CATEGORY_MANAGER, NULL);
+}
+
+/**
+ * gs_category_manager_lookup:
+ * @self: a #GsCategoryManager
+ * @id: ID of the category to look up
+ *
+ * Look up a category by its ID. If the category is not found, %NULL is
+ * returned.
+ *
+ * Returns: (transfer full) (nullable): the #GsCategory, or %NULL
+ * Since: 40
+ */
+GsCategory *
+gs_category_manager_lookup (GsCategoryManager *self,
+ const gchar *id)
+{
+ g_return_val_if_fail (GS_IS_CATEGORY_MANAGER (self), NULL);
+ g_return_val_if_fail (id != NULL && *id != '\0', NULL);
+
+ /* There are only on the order of 10 categories, so this is quick */
+ for (gsize i = 0; i < G_N_ELEMENTS (self->categories) - 1; i++) {
+ if (g_str_equal (gs_category_get_id (self->categories[i]), id))
+ return g_object_ref (self->categories[i]);
+ }
+
+ return NULL;
+}
+
+/**
+ * gs_category_manager_get_categories:
+ * @self: a #GsCategoryManager
+ * @out_n_categories: (optional) (out caller-allocates): return location for
+ * the number of categories in the return value, or %NULL to ignore
+ *
+ * Get the full list of categories from the category manager. The returned array
+ * is %NULL terminated and guaranteed to be non-%NULL (although it may be
+ * empty).
+ *
+ * If @out_n_categories is provided, it will be set to the number of #GsCategory
+ * objects in the return value, not including the %NULL terminator.
+ *
+ * Returns: (array length=out_n_categories) (transfer none) (not nullable): the
+ * categories; do not free this memory
+ * Since: 40
+ */
+GsCategory * const *
+gs_category_manager_get_categories (GsCategoryManager *self,
+ gsize *out_n_categories)
+{
+ g_return_val_if_fail (GS_IS_CATEGORY_MANAGER (self), NULL);
+
+ if (out_n_categories != NULL)
+ *out_n_categories = G_N_ELEMENTS (self->categories) - 1;
+
+ return self->categories;
+}
diff --git a/lib/gs-category-manager.h b/lib/gs-category-manager.h
new file mode 100644
index 0000000..8e85fa3
--- /dev/null
+++ b/lib/gs-category-manager.h
@@ -0,0 +1,32 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2021 Endless OS Foundation LLC
+ *
+ * Author: Philip Withnall <pwithnall@endlessos.org>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#include <glib.h>
+#include <glib-object.h>
+
+#include "gs-category.h"
+
+G_BEGIN_DECLS
+
+#define GS_TYPE_CATEGORY_MANAGER (gs_category_manager_get_type ())
+
+G_DECLARE_FINAL_TYPE (GsCategoryManager, gs_category_manager, GS, CATEGORY_MANAGER, GObject)
+
+GsCategoryManager *gs_category_manager_new (void);
+
+GsCategory *gs_category_manager_lookup (GsCategoryManager *self,
+ const gchar *id);
+
+GsCategory * const *gs_category_manager_get_categories (GsCategoryManager *self,
+ gsize *out_n_categories);
+
+G_END_DECLS
diff --git a/lib/gs-category-private.h b/lib/gs-category-private.h
new file mode 100644
index 0000000..1c9c292
--- /dev/null
+++ b/lib/gs-category-private.h
@@ -0,0 +1,21 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2013-2016 Richard Hughes <richard@hughsie.com>
+ * Copyright (C) 2013 Matthias Clasen <mclasen@redhat.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#include "gs-category.h"
+
+G_BEGIN_DECLS
+
+void gs_category_sort_children (GsCategory *category);
+void gs_category_set_size (GsCategory *category,
+ guint size);
+gchar *gs_category_to_string (GsCategory *category);
+
+G_END_DECLS
diff --git a/lib/gs-category.c b/lib/gs-category.c
new file mode 100644
index 0000000..befc167
--- /dev/null
+++ b/lib/gs-category.c
@@ -0,0 +1,724 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2013-2016 Richard Hughes <richard@hughsie.com>
+ * Copyright (C) 2013 Matthias Clasen <mclasen@redhat.com>
+ * Copyright (C) 2015 Kalev Lember <klember@redhat.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+/**
+ * SECTION:gs-category
+ * @short_description: An category that contains applications
+ *
+ * This object provides functionality that allows a plugin to create
+ * a tree structure of categories that each contain #GsApp's.
+ */
+
+#include "config.h"
+
+#include <glib/gi18n.h>
+
+#include "gs-category-private.h"
+#include "gs-desktop-data.h"
+
+struct _GsCategory
+{
+ GObject parent_instance;
+
+ const GsDesktopData *desktop_data; /* NULL for subcategories */
+ const GsDesktopMap *desktop_map; /* NULL for parent categories */
+
+ GPtrArray *desktop_groups; /* potentially NULL if empty */
+ GsCategory *parent;
+ guint size; /* (atomic) */
+ GPtrArray *children; /* potentially NULL if empty */
+};
+
+G_DEFINE_TYPE (GsCategory, gs_category, G_TYPE_OBJECT)
+
+typedef enum {
+ PROP_ID = 1,
+ PROP_NAME,
+ PROP_ICON_NAME,
+ PROP_SCORE,
+ PROP_PARENT,
+ PROP_SIZE,
+} GsCategoryProperty;
+
+static GParamSpec *obj_props[PROP_SIZE + 1] = { NULL, };
+
+/**
+ * gs_category_to_string:
+ * @category: a #GsCategory
+ *
+ * Returns a string representation of the category
+ *
+ * Returns: a string
+ *
+ * Since: 3.22
+ **/
+gchar *
+gs_category_to_string (GsCategory *category)
+{
+ guint i;
+ GString *str = g_string_new (NULL);
+ g_string_append_printf (str, "GsCategory[%p]:\n", category);
+ g_string_append_printf (str, " id: %s\n",
+ gs_category_get_id (category));
+ if (gs_category_get_name (category) != NULL) {
+ g_string_append_printf (str, " name: %s\n",
+ gs_category_get_name (category));
+ }
+ if (gs_category_get_icon_name (category) != NULL) {
+ g_string_append_printf (str, " icon-name: %s\n",
+ gs_category_get_icon_name (category));
+ }
+ g_string_append_printf (str, " size: %u\n",
+ gs_category_get_size (category));
+ g_string_append_printf (str, " desktop-groups: %u\n",
+ (category->desktop_groups != NULL) ? category->desktop_groups->len : 0);
+ if (category->parent != NULL) {
+ g_string_append_printf (str, " parent: %s\n",
+ gs_category_get_id (category->parent));
+ }
+ g_string_append_printf (str, " score: %i\n", gs_category_get_score (category));
+ if (category->children == NULL || category->children->len == 0) {
+ g_string_append_printf (str, " children: %u\n", 0u);
+ } else {
+ g_string_append_printf (str, " children: %u\n", category->children->len);
+ for (i = 0; i < category->children->len; i++) {
+ GsCategory *child = g_ptr_array_index (category->children, i);
+ g_string_append_printf (str, " - %s\n",
+ gs_category_get_id (child));
+ }
+ }
+ return g_string_free (str, FALSE);
+}
+
+/**
+ * gs_category_get_size:
+ * @category: a #GsCategory
+ *
+ * Returns how many applications the category could contain.
+ *
+ * NOTE: This may over-estimate the number if duplicate applications are
+ * filtered or core applications are not shown.
+ *
+ * Returns: the number of apps in the category
+ *
+ * Since: 3.22
+ **/
+guint
+gs_category_get_size (GsCategory *category)
+{
+ g_return_val_if_fail (GS_IS_CATEGORY (category), 0);
+
+ /* The ‘all’ subcategory is a bit special. */
+ if (category->parent != NULL && g_str_equal (gs_category_get_id (category), "all"))
+ return gs_category_get_size (category->parent);
+
+ return g_atomic_int_get (&category->size);
+}
+
+/**
+ * gs_category_set_size:
+ * @category: a #GsCategory
+ * @size: the number of applications
+ *
+ * Sets the number of applications in the category.
+ * Most plugins do not need to call this function.
+ *
+ * Since: 3.22
+ **/
+void
+gs_category_set_size (GsCategory *category, guint size)
+{
+ g_return_if_fail (GS_IS_CATEGORY (category));
+
+ g_atomic_int_set (&category->size, size);
+ g_object_notify_by_pspec (G_OBJECT (category), obj_props[PROP_SIZE]);
+}
+
+/**
+ * gs_category_increment_size:
+ * @category: a #GsCategory
+ * @value: how many to add
+ *
+ * Adds @value to the size count.
+ *
+ * Since: 3.22
+ **/
+void
+gs_category_increment_size (GsCategory *category,
+ guint value)
+{
+ g_return_if_fail (GS_IS_CATEGORY (category));
+
+ g_atomic_int_add (&category->size, value);
+ if (value != 0)
+ g_object_notify_by_pspec (G_OBJECT (category), obj_props[PROP_SIZE]);
+}
+
+/**
+ * gs_category_get_id:
+ * @category: a #GsCategory
+ *
+ * Gets the category ID.
+ *
+ * Returns: the string, e.g. "other"
+ *
+ * Since: 3.22
+ **/
+const gchar *
+gs_category_get_id (GsCategory *category)
+{
+ g_return_val_if_fail (GS_IS_CATEGORY (category), NULL);
+
+ if (category->desktop_data != NULL)
+ return category->desktop_data->id;
+ else if (category->desktop_map != NULL)
+ return category->desktop_map->id;
+ g_assert_not_reached ();
+}
+
+/**
+ * gs_category_get_name:
+ * @category: a #GsCategory
+ *
+ * Gets the category name.
+ *
+ * Returns: the string, or %NULL
+ *
+ * Since: 3.22
+ **/
+const gchar *
+gs_category_get_name (GsCategory *category)
+{
+ const gchar *category_id;
+
+ g_return_val_if_fail (GS_IS_CATEGORY (category), NULL);
+
+ category_id = gs_category_get_id (category);
+
+ /* special case, we don't want translations in the plugins */
+ if (g_strcmp0 (category_id, "other") == 0) {
+ /* TRANSLATORS: this is where all applications that don't
+ * fit in other groups are put */
+ return _("Other");
+ }
+ if (g_strcmp0 (category_id, "all") == 0) {
+ /* TRANSLATORS: this is a subcategory matching all the
+ * different apps in the parent category, e.g. "Games" */
+ return C_("Category", "All");
+ }
+ if (g_strcmp0 (category_id, "featured") == 0) {
+ /* TRANSLATORS: this is a subcategory of featured apps */
+ return _("Featured");
+ }
+
+ /* normal case */
+ if (category->desktop_data != NULL) {
+ return gettext (category->desktop_data->name);
+ } else if (category->desktop_map != NULL) {
+ g_autofree gchar *msgctxt = g_strdup_printf ("Menu of %s", category->parent->desktop_data->name);
+ return g_dpgettext2 (GETTEXT_PACKAGE, msgctxt, category->desktop_map->name);
+ }
+
+ g_assert_not_reached ();
+}
+
+/**
+ * gs_category_get_icon_name:
+ * @category: a #GsCategory
+ *
+ * Gets the category icon name.
+ *
+ * Returns: the string, or %NULL
+ *
+ * Since: 3.22
+ **/
+const gchar *
+gs_category_get_icon_name (GsCategory *category)
+{
+ const gchar *category_id;
+
+ g_return_val_if_fail (GS_IS_CATEGORY (category), NULL);
+
+ category_id = gs_category_get_id (category);
+
+ /* special case */
+ if (g_strcmp0 (category_id, "other") == 0)
+ return "emblem-system-symbolic";
+ if (g_strcmp0 (category_id, "all") == 0)
+ return "emblem-default-symbolic";
+ if (g_strcmp0 (category_id, "featured") == 0)
+ return "emblem-favorite-symbolic";
+
+ if (category->desktop_data != NULL)
+ return category->desktop_data->icon;
+ else
+ return NULL;
+}
+
+/**
+ * gs_category_get_score:
+ * @category: a #GsCategory
+ *
+ * Gets if the category score.
+ * Important categories may be shown before other categories, or tagged in a
+ * different way, for example with color or in a different section.
+ *
+ * Returns: the string, or %NULL
+ *
+ * Since: 3.22
+ **/
+gint
+gs_category_get_score (GsCategory *category)
+{
+ g_return_val_if_fail (GS_IS_CATEGORY (category), FALSE);
+
+ if (category->desktop_data != NULL)
+ return category->desktop_data->score;
+ else
+ return 0;
+}
+
+/**
+ * gs_category_get_desktop_groups:
+ * @category: a #GsCategory
+ *
+ * Gets the list of AppStream groups for the category.
+ *
+ * Returns: (element-type utf8) (transfer none): An array
+ *
+ * Since: 3.22
+ **/
+GPtrArray *
+gs_category_get_desktop_groups (GsCategory *category)
+{
+ g_return_val_if_fail (GS_IS_CATEGORY (category), NULL);
+
+ if (category->desktop_groups == NULL)
+ category->desktop_groups = g_ptr_array_new_with_free_func (g_free);
+
+ return category->desktop_groups;
+}
+
+/**
+ * gs_category_has_desktop_group:
+ * @category: a #GsCategory
+ * @desktop_group: a group of categories found in AppStream, e.g. "AudioVisual::Player"
+ *
+ * Finds out if the category has the specific AppStream desktop group.
+ *
+ * Returns: %TRUE if found, %FALSE otherwise
+ *
+ * Since: 3.22
+ **/
+gboolean
+gs_category_has_desktop_group (GsCategory *category, const gchar *desktop_group)
+{
+ guint i;
+
+ g_return_val_if_fail (GS_IS_CATEGORY (category), FALSE);
+ g_return_val_if_fail (desktop_group != NULL, FALSE);
+
+ if (category->desktop_groups == NULL)
+ return FALSE;
+
+ for (i = 0; i < category->desktop_groups->len; i++) {
+ const gchar *tmp = g_ptr_array_index (category->desktop_groups, i);
+ if (g_strcmp0 (tmp, desktop_group) == 0)
+ return TRUE;
+ }
+ return FALSE;
+}
+
+/*
+ * gs_category_add_desktop_group:
+ * @category: a #GsCategory
+ * @desktop_group: a group of categories found in AppStream, e.g. "AudioVisual::Player"
+ *
+ * Adds a desktop group to the category.
+ * A desktop group is a set of category strings that all must exist.
+ *
+ * Since: 3.22
+ */
+static void
+gs_category_add_desktop_group (GsCategory *category, const gchar *desktop_group)
+{
+ g_return_if_fail (GS_IS_CATEGORY (category));
+ g_return_if_fail (desktop_group != NULL);
+
+ /* add if not already found, and lazily create the groups array
+ * (since it’s only needed in child categories) */
+ if (gs_category_has_desktop_group (category, desktop_group))
+ return;
+ if (category->desktop_groups == NULL)
+ category->desktop_groups = g_ptr_array_new_with_free_func (g_free);
+ g_ptr_array_add (category->desktop_groups, g_strdup (desktop_group));
+}
+
+/**
+ * gs_category_find_child:
+ * @category: a #GsCategory
+ * @id: a category ID, e.g. "other"
+ *
+ * Find a child category with a specific ID.
+ *
+ * Returns: (transfer none): the #GsCategory, or %NULL
+ *
+ * Since: 3.22
+ **/
+GsCategory *
+gs_category_find_child (GsCategory *category, const gchar *id)
+{
+ GsCategory *tmp;
+ guint i;
+
+ if (category->children == NULL)
+ return NULL;
+
+ /* find the subcategory */
+ for (i = 0; i < category->children->len; i++) {
+ tmp = GS_CATEGORY (g_ptr_array_index (category->children, i));
+ if (g_strcmp0 (id, gs_category_get_id (tmp)) == 0)
+ return tmp;
+ }
+ return NULL;
+}
+
+/**
+ * gs_category_get_parent:
+ * @category: a #GsCategory
+ *
+ * Gets the parent category.
+ *
+ * Returns: the #GsCategory or %NULL
+ *
+ * Since: 3.22
+ **/
+GsCategory *
+gs_category_get_parent (GsCategory *category)
+{
+ g_return_val_if_fail (GS_IS_CATEGORY (category), NULL);
+ return category->parent;
+}
+
+/**
+ * gs_category_get_children:
+ * @category: a #GsCategory
+ *
+ * Gets the list if children for a category.
+ *
+ * Return value: (element-type GsApp) (transfer none): A list of children
+ *
+ * Since: 3.22
+ **/
+GPtrArray *
+gs_category_get_children (GsCategory *category)
+{
+ g_return_val_if_fail (GS_IS_CATEGORY (category), NULL);
+
+ if (category->children == NULL)
+ category->children = g_ptr_array_new_with_free_func ((GDestroyNotify) g_object_unref);
+
+ return category->children;
+}
+
+/*
+ * gs_category_add_child:
+ * @category: a #GsCategory
+ * @subcategory: a #GsCategory
+ *
+ * Adds a child category to a parent category.
+ *
+ * Since: 3.22
+ */
+static void
+gs_category_add_child (GsCategory *category, GsCategory *subcategory)
+{
+ g_return_if_fail (GS_IS_CATEGORY (category));
+ g_return_if_fail (GS_IS_CATEGORY (subcategory));
+
+ /* lazily create the array to save memory in subcategories, which don’t
+ * recursively have children */
+ if (category->children == NULL)
+ category->children = g_ptr_array_new_with_free_func ((GDestroyNotify) g_object_unref);
+
+ /* FIXME: do we need this? */
+ subcategory->parent = category;
+ g_object_add_weak_pointer (G_OBJECT (subcategory->parent),
+ (gpointer *) &subcategory->parent);
+
+ g_ptr_array_add (category->children,
+ g_object_ref (subcategory));
+}
+
+static gchar *
+gs_category_get_sort_key (GsCategory *category)
+{
+ guint sort_order = 5;
+ if (g_strcmp0 (gs_category_get_id (category), "featured") == 0)
+ sort_order = 0;
+ else if (g_strcmp0 (gs_category_get_id (category), "all") == 0)
+ sort_order = 2;
+ else if (g_strcmp0 (gs_category_get_id (category), "other") == 0)
+ sort_order = 9;
+ return g_strdup_printf ("%u:%s",
+ sort_order,
+ gs_category_get_name (category));
+}
+
+static gint
+gs_category_sort_children_cb (gconstpointer a, gconstpointer b)
+{
+ GsCategory *ca = GS_CATEGORY (*(GsCategory **) a);
+ GsCategory *cb = GS_CATEGORY (*(GsCategory **) b);
+ g_autofree gchar *id_a = gs_category_get_sort_key (ca);
+ g_autofree gchar *id_b = gs_category_get_sort_key (cb);
+ return g_strcmp0 (id_a, id_b);
+}
+
+/**
+ * gs_category_sort_children:
+ * @category: a #GsCategory
+ *
+ * Sorts the list of children.
+ *
+ * Since: 3.22
+ **/
+void
+gs_category_sort_children (GsCategory *category)
+{
+ if (category->children == NULL)
+ return;
+
+ g_ptr_array_sort (category->children,
+ gs_category_sort_children_cb);
+}
+
+static void
+gs_category_get_property (GObject *object, guint prop_id, GValue *value, GParamSpec *pspec)
+{
+ GsCategory *self = GS_CATEGORY (object);
+
+ switch ((GsCategoryProperty) prop_id) {
+ case PROP_ID:
+ g_value_set_string (value, gs_category_get_id (self));
+ break;
+ case PROP_NAME:
+ g_value_set_string (value, gs_category_get_name (self));
+ break;
+ case PROP_ICON_NAME:
+ g_value_set_string (value, gs_category_get_icon_name (self));
+ break;
+ case PROP_SCORE:
+ g_value_set_int (value, gs_category_get_score (self));
+ break;
+ case PROP_PARENT:
+ g_value_set_object (value, self->parent);
+ break;
+ case PROP_SIZE:
+ g_value_set_uint (value, gs_category_get_size (self));
+ break;
+ default:
+ G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+ break;
+ }
+}
+
+static void
+gs_category_set_property (GObject *object, guint prop_id, const GValue *value, GParamSpec *pspec)
+{
+ GsCategory *self = GS_CATEGORY (object);
+
+ switch ((GsCategoryProperty) prop_id) {
+ case PROP_ID:
+ case PROP_NAME:
+ case PROP_ICON_NAME:
+ case PROP_SCORE:
+ case PROP_PARENT:
+ /* Read only */
+ g_assert_not_reached ();
+ break;
+ case PROP_SIZE:
+ gs_category_set_size (self, g_value_get_uint (value));
+ break;
+ default:
+ G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+ break;
+ }
+}
+
+static void
+gs_category_finalize (GObject *object)
+{
+ GsCategory *category = GS_CATEGORY (object);
+
+ if (category->parent != NULL)
+ g_object_remove_weak_pointer (G_OBJECT (category->parent),
+ (gpointer *) &category->parent);
+ g_clear_pointer (&category->children, g_ptr_array_unref);
+ g_clear_pointer (&category->desktop_groups, g_ptr_array_unref);
+
+ G_OBJECT_CLASS (gs_category_parent_class)->finalize (object);
+}
+
+static void
+gs_category_class_init (GsCategoryClass *klass)
+{
+ GObjectClass *object_class = G_OBJECT_CLASS (klass);
+
+ object_class->get_property = gs_category_get_property;
+ object_class->set_property = gs_category_set_property;
+ object_class->finalize = gs_category_finalize;
+
+ /**
+ * GsCategory:id:
+ *
+ * A machine readable identifier for the category. Must be non-empty
+ * and in a valid format to be a
+ * [desktop category ID](https://specifications.freedesktop.org/menu-spec/latest/).
+ *
+ * Since: 40
+ */
+ obj_props[PROP_ID] =
+ g_param_spec_string ("id", NULL, NULL,
+ NULL,
+ G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * GsCategory:name:
+ *
+ * Human readable name for the category.
+ *
+ * Since: 40
+ */
+ obj_props[PROP_NAME] =
+ g_param_spec_string ("name", NULL, NULL,
+ NULL,
+ G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * GsCategory:icon-name: (nullable)
+ *
+ * Name of the icon to use for the category, or %NULL if none is set.
+ *
+ * Since: 40
+ */
+ obj_props[PROP_ICON_NAME] =
+ g_param_spec_string ("icon-name", NULL, NULL,
+ NULL,
+ G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * GsCategory:score:
+ *
+ * Score for sorting the category. Lower numeric values indicate more
+ * important categories.
+ *
+ * Since: 40
+ */
+ obj_props[PROP_SCORE] =
+ g_param_spec_int ("score", NULL, NULL,
+ G_MININT, G_MAXINT, 0,
+ G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * GsCategory:parent: (nullable)
+ *
+ * The parent #GsCategory, or %NULL if this category is at the top
+ * level.
+ *
+ * Since: 40
+ */
+ obj_props[PROP_PARENT] =
+ g_param_spec_object ("parent", NULL, NULL,
+ GS_TYPE_CATEGORY,
+ G_PARAM_READABLE | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * GsCategory:size:
+ *
+ * Number of apps in this category, including apps in its subcategories.
+ *
+ * This has to be initialised externally to the #GsCategory by calling
+ * gs_category_increment_size().
+ *
+ * Since: 40
+ */
+ obj_props[PROP_SIZE] =
+ g_param_spec_uint ("size", NULL, NULL,
+ 0, G_MAXUINT, 0,
+ G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS);
+
+ g_object_class_install_properties (object_class, G_N_ELEMENTS (obj_props), obj_props);
+}
+
+static void
+gs_category_init (GsCategory *category)
+{
+}
+
+/**
+ * gs_category_new_for_desktop_data:
+ * @data: data for the category, which must be static and constant
+ *
+ * Create a new #GsCategory instance which wraps the desktop category
+ * information in @data. Where possible, the static data will be reused, so
+ * @data must be static and constant across the lifetime of the process.
+ *
+ * Returns: (transfer full): a new #GsCategory wrapping @data
+ * Since: 40
+ */
+GsCategory *
+gs_category_new_for_desktop_data (const GsDesktopData *data)
+{
+ g_autoptr(GsCategory) category = NULL;
+ GsCategory *subcategory_all = NULL;
+
+ /* parent category */
+ category = g_object_new (GS_TYPE_CATEGORY, NULL);
+ category->desktop_data = data;
+
+ /* add subcategories */
+ for (gsize j = 0; data->mapping[j].id != NULL; j++) {
+ const GsDesktopMap *map = &data->mapping[j];
+ g_autoptr(GsCategory) sub = g_object_new (GS_TYPE_CATEGORY, NULL);
+ sub->desktop_map = map;
+ for (gsize k = 0; map->fdo_cats[k] != NULL; k++)
+ gs_category_add_desktop_group (sub, map->fdo_cats[k]);
+ gs_category_add_child (category, sub);
+
+ if (g_str_equal (map->id, "all"))
+ subcategory_all = sub;
+ }
+
+ /* set up the ‘all’ subcategory specially, adding all the desktop groups
+ * from all other child categories to it */
+ if (subcategory_all != NULL) {
+ g_assert (category->children != NULL);
+
+ for (guint i = 0; i < category->children->len; i++) {
+ GPtrArray *desktop_groups;
+ GsCategory *child;
+
+ /* ignore the all category */
+ child = g_ptr_array_index (category->children, i);
+ if (child == subcategory_all)
+ continue;
+
+ /* add all desktop groups */
+ desktop_groups = gs_category_get_desktop_groups (child);
+ for (guint j = 0; j < desktop_groups->len; j++) {
+ const gchar *tmp = g_ptr_array_index (desktop_groups, j);
+ gs_category_add_desktop_group (subcategory_all, tmp);
+ }
+ }
+ }
+
+ return g_steal_pointer (&category);
+}
diff --git a/lib/gs-category.h b/lib/gs-category.h
new file mode 100644
index 0000000..1e82591
--- /dev/null
+++ b/lib/gs-category.h
@@ -0,0 +1,45 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2013-2016 Richard Hughes <richard@hughsie.com>
+ * Copyright (C) 2013 Matthias Clasen <mclasen@redhat.com>
+ * Copyright (C) 2015 Kalev Lember <klember@redhat.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#include <glib.h>
+#include <glib-object.h>
+
+#include "gs-desktop-data.h"
+
+G_BEGIN_DECLS
+
+#define GS_TYPE_CATEGORY (gs_category_get_type ())
+
+G_DECLARE_FINAL_TYPE (GsCategory, gs_category, GS, CATEGORY, GObject)
+
+GsCategory *gs_category_new_for_desktop_data (const GsDesktopData *data);
+
+const gchar *gs_category_get_id (GsCategory *category);
+GsCategory *gs_category_get_parent (GsCategory *category);
+
+const gchar *gs_category_get_name (GsCategory *category);
+const gchar *gs_category_get_icon_name (GsCategory *category);
+gint gs_category_get_score (GsCategory *category);
+
+GPtrArray *gs_category_get_desktop_groups (GsCategory *category);
+gboolean gs_category_has_desktop_group (GsCategory *category,
+ const gchar *desktop_group);
+
+GsCategory *gs_category_find_child (GsCategory *category,
+ const gchar *id);
+GPtrArray *gs_category_get_children (GsCategory *category);
+
+guint gs_category_get_size (GsCategory *category);
+void gs_category_increment_size (GsCategory *category,
+ guint value);
+
+G_END_DECLS
diff --git a/lib/gs-cmd.c b/lib/gs-cmd.c
new file mode 100644
index 0000000..895e1b8
--- /dev/null
+++ b/lib/gs-cmd.c
@@ -0,0 +1,847 @@
+/* -*- 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) 2014-2015 Kalev Lember <klember@redhat.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#include "config.h"
+
+#include <glib/gi18n.h>
+#include <gtk/gtk.h>
+#include <locale.h>
+
+#include "gnome-software-private.h"
+
+#include "gs-debug.h"
+
+typedef struct {
+ GsPluginLoader *plugin_loader;
+ guint64 refine_flags;
+ guint max_results;
+ gboolean interactive;
+} GsCmdSelf;
+
+static void
+gs_cmd_show_results_apps (GsAppList *list)
+{
+ for (guint j = 0; j < gs_app_list_length (list); j++) {
+ GsApp *app = gs_app_list_index (list, j);
+ GsAppList *related = gs_app_get_related (app);
+ g_autofree gchar *tmp = gs_app_to_string (app);
+ g_print ("%s\n", tmp);
+ for (guint i = 0; i < gs_app_list_length (related); i++) {
+ g_autofree gchar *tmp_rel = NULL;
+ GsApp *app_rel = GS_APP (gs_app_list_index (related, i));
+ tmp_rel = gs_app_to_string (app_rel);
+ g_print ("\t%s\n", tmp_rel);
+ }
+ }
+}
+
+static gchar *
+gs_cmd_pad_spaces (const gchar *text, guint length)
+{
+ gsize i;
+ GString *str;
+ str = g_string_sized_new (length + 1);
+ g_string_append (str, text);
+ for (i = strlen (text); i < length; i++)
+ g_string_append_c (str, ' ');
+ return g_string_free (str, FALSE);
+}
+
+static void
+gs_cmd_show_results_categories (GPtrArray *list)
+{
+ for (guint i = 0; i < list->len; i++) {
+ GsCategory *cat = GS_CATEGORY (g_ptr_array_index (list, i));
+ GsCategory *parent = gs_category_get_parent (cat);
+ g_autofree gchar *tmp = NULL;
+ if (parent != NULL){
+ g_autofree gchar *id = NULL;
+ id = g_strdup_printf ("%s/%s [%u]",
+ gs_category_get_id (parent),
+ gs_category_get_id (cat),
+ gs_category_get_size (cat));
+ tmp = gs_cmd_pad_spaces (id, 32);
+ g_print ("%s : %s\n",
+ tmp, gs_category_get_name (cat));
+ } else {
+ GPtrArray *subcats = gs_category_get_children (cat);
+ tmp = gs_cmd_pad_spaces (gs_category_get_id (cat), 32);
+ g_print ("%s : %s\n",
+ tmp, gs_category_get_name (cat));
+ gs_cmd_show_results_categories (subcats);
+ }
+ }
+}
+
+static GsPluginRefineFlags
+gs_cmd_refine_flag_from_string (const gchar *flag, GError **error)
+{
+ if (g_strcmp0 (flag, "all") == 0)
+ return G_MAXINT32;
+ if (g_strcmp0 (flag, "license") == 0)
+ return GS_PLUGIN_REFINE_FLAGS_REQUIRE_LICENSE;
+ if (g_strcmp0 (flag, "url") == 0)
+ return GS_PLUGIN_REFINE_FLAGS_REQUIRE_URL;
+ if (g_strcmp0 (flag, "description") == 0)
+ return GS_PLUGIN_REFINE_FLAGS_REQUIRE_DESCRIPTION;
+ if (g_strcmp0 (flag, "size") == 0)
+ return GS_PLUGIN_REFINE_FLAGS_REQUIRE_SIZE;
+ if (g_strcmp0 (flag, "rating") == 0)
+ return GS_PLUGIN_REFINE_FLAGS_REQUIRE_RATING;
+ if (g_strcmp0 (flag, "version") == 0)
+ return GS_PLUGIN_REFINE_FLAGS_REQUIRE_VERSION;
+ if (g_strcmp0 (flag, "history") == 0)
+ return GS_PLUGIN_REFINE_FLAGS_REQUIRE_HISTORY;
+ if (g_strcmp0 (flag, "setup-action") == 0)
+ return GS_PLUGIN_REFINE_FLAGS_REQUIRE_SETUP_ACTION;
+ if (g_strcmp0 (flag, "update-details") == 0)
+ return GS_PLUGIN_REFINE_FLAGS_REQUIRE_UPDATE_DETAILS;
+ if (g_strcmp0 (flag, "origin") == 0)
+ return GS_PLUGIN_REFINE_FLAGS_REQUIRE_ORIGIN;
+ if (g_strcmp0 (flag, "related") == 0)
+ return GS_PLUGIN_REFINE_FLAGS_REQUIRE_RELATED;
+ if (g_strcmp0 (flag, "menu-path") == 0)
+ /* no longer supported by itself; categories are largely equivalent */
+ return GS_PLUGIN_REFINE_FLAGS_REQUIRE_CATEGORIES;
+ if (g_strcmp0 (flag, "upgrade-removed") == 0)
+ return GS_PLUGIN_REFINE_FLAGS_REQUIRE_UPGRADE_REMOVED;
+ if (g_strcmp0 (flag, "provenance") == 0)
+ return GS_PLUGIN_REFINE_FLAGS_REQUIRE_PROVENANCE;
+ if (g_strcmp0 (flag, "reviews") == 0)
+ return GS_PLUGIN_REFINE_FLAGS_REQUIRE_REVIEWS;
+ if (g_strcmp0 (flag, "review-ratings") == 0)
+ return GS_PLUGIN_REFINE_FLAGS_REQUIRE_REVIEW_RATINGS;
+ if (g_strcmp0 (flag, "key-colors") == 0)
+ /* no longer supported by itself; derived automatically from the icon */
+ return GS_PLUGIN_REFINE_FLAGS_REQUIRE_ICON;
+ if (g_strcmp0 (flag, "icon") == 0)
+ return GS_PLUGIN_REFINE_FLAGS_REQUIRE_ICON;
+ if (g_strcmp0 (flag, "permissions") == 0)
+ return GS_PLUGIN_REFINE_FLAGS_REQUIRE_PERMISSIONS;
+ if (g_strcmp0 (flag, "origin-hostname") == 0)
+ return GS_PLUGIN_REFINE_FLAGS_REQUIRE_ORIGIN_HOSTNAME;
+ if (g_strcmp0 (flag, "origin-ui") == 0)
+ return GS_PLUGIN_REFINE_FLAGS_REQUIRE_ORIGIN_UI;
+ if (g_strcmp0 (flag, "runtime") == 0)
+ return GS_PLUGIN_REFINE_FLAGS_REQUIRE_RUNTIME;
+ if (g_strcmp0 (flag, "categories") == 0)
+ return GS_PLUGIN_REFINE_FLAGS_REQUIRE_CATEGORIES;
+ if (g_strcmp0 (flag, "project-group") == 0)
+ return GS_PLUGIN_REFINE_FLAGS_REQUIRE_PROJECT_GROUP;
+ if (g_strcmp0 (flag, "developer-name") == 0)
+ return GS_PLUGIN_REFINE_FLAGS_REQUIRE_DEVELOPER_NAME;
+ if (g_strcmp0 (flag, "kudos") == 0)
+ return GS_PLUGIN_REFINE_FLAGS_REQUIRE_KUDOS;
+ if (g_strcmp0 (flag, "content-rating") == 0)
+ return GS_PLUGIN_REFINE_FLAGS_REQUIRE_CONTENT_RATING;
+ g_set_error (error,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_NOT_SUPPORTED,
+ "GsPluginRefineFlag '%s' not recognised", flag);
+ return 0;
+}
+
+static guint64
+gs_cmd_parse_refine_flags (const gchar *extra, GError **error)
+{
+ GsPluginRefineFlags tmp;
+ guint i;
+ guint64 refine_flags = GS_PLUGIN_REFINE_FLAGS_NONE;
+ g_auto(GStrv) split = NULL;
+
+ if (extra == NULL)
+ return GS_PLUGIN_REFINE_FLAGS_NONE;
+
+ split = g_strsplit (extra, ",", -1);
+ for (i = 0; split[i] != NULL; i++) {
+ tmp = gs_cmd_refine_flag_from_string (split[i], error);
+ if (tmp == 0)
+ return G_MAXUINT64;
+ refine_flags |= tmp;
+ }
+ return refine_flags;
+}
+
+static guint
+gs_cmd_prompt_for_number (guint maxnum)
+{
+ gint retval;
+ guint answer = 0;
+
+ do {
+ char buffer[64];
+
+ /* swallow the \n at end of line too */
+ if (!fgets (buffer, sizeof (buffer), stdin))
+ break;
+ if (strlen (buffer) == sizeof (buffer) - 1)
+ continue;
+
+ /* get a number */
+ retval = sscanf (buffer, "%u", &answer);
+
+ /* positive */
+ if (retval == 1 && answer > 0 && answer <= maxnum)
+ break;
+
+ /* TRANSLATORS: the user isn't reading the question */
+ g_print (_("Please enter a number from 1 to %u: "), maxnum);
+ } while (TRUE);
+ return answer;
+}
+
+static gboolean
+gs_cmd_action_exec (GsCmdSelf *self, GsPluginAction action, const gchar *name, GError **error)
+{
+ g_autoptr(GsApp) app = NULL;
+ g_autoptr(GsAppList) list = NULL;
+ g_autoptr(GsAppList) list_filtered = NULL;
+ g_autoptr(GsAppQuery) query = NULL;
+ g_autoptr(GsPluginJob) plugin_job2 = NULL;
+ g_autoptr(GsPluginJob) plugin_job = NULL;
+ gboolean show_installed = TRUE;
+ const gchar * const keywords[] = { name, NULL };
+ GsPluginListAppsFlags flags = GS_PLUGIN_LIST_APPS_FLAGS_NONE;
+
+ /* ensure set */
+ self->refine_flags |= GS_PLUGIN_REFINE_FLAGS_REQUIRE_ICON;
+ self->refine_flags |= GS_PLUGIN_REFINE_FLAGS_REQUIRE_SETUP_ACTION;
+
+ /* do search */
+ query = gs_app_query_new ("keywords", keywords,
+ "refine-flags", self->refine_flags,
+ "max-results", self->max_results,
+ "dedupe-flags", GS_PLUGIN_JOB_DEDUPE_FLAGS_DEFAULT,
+ "sort-func", gs_utils_app_sort_match_value,
+ NULL);
+
+ if (self->interactive)
+ flags |= GS_PLUGIN_LIST_APPS_FLAGS_INTERACTIVE;
+
+ plugin_job = gs_plugin_job_list_apps_new (query, flags);
+ list = gs_plugin_loader_job_process (self->plugin_loader, plugin_job, NULL, error);
+ if (list == NULL)
+ return FALSE;
+ if (gs_app_list_length (list) == 0) {
+ g_set_error (error,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_FAILED,
+ "no components matched '%s'",
+ name);
+ return FALSE;
+ }
+
+ /* filter */
+ if (action == GS_PLUGIN_ACTION_INSTALL)
+ show_installed = FALSE;
+ list_filtered = gs_app_list_new ();
+ for (guint i = 0; i < gs_app_list_length (list); i++) {
+ GsApp *app_tmp = gs_app_list_index (list, i);
+ if (gs_app_is_installed (app_tmp) == show_installed)
+ gs_app_list_add (list_filtered, app_tmp);
+ }
+
+ /* nothing */
+ if (gs_app_list_length (list_filtered) == 0) {
+ g_set_error (error,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_FAILED,
+ "no components were in the correct state for '%s %s'",
+ gs_plugin_action_to_string (action), name);
+ return FALSE;
+ }
+
+ /* get one GsApp */
+ if (gs_app_list_length (list_filtered) == 1) {
+ app = g_object_ref (gs_app_list_index (list_filtered, 0));
+ } else {
+ guint idx;
+ /* TRANSLATORS: asking the user to choose an app from a list */
+ g_print ("%s\n", _("Choose an application:"));
+ for (guint i = 0; i < gs_app_list_length (list_filtered); i++) {
+ GsApp *app_tmp = gs_app_list_index (list_filtered, i);
+ g_print ("%u.\t%s\n",
+ i + 1,
+ gs_app_get_unique_id (app_tmp));
+ }
+ idx = gs_cmd_prompt_for_number (gs_app_list_length (list_filtered));
+ app = g_object_ref (gs_app_list_index (list_filtered, idx - 1));
+ }
+
+ /* install */
+ plugin_job2 = gs_plugin_job_newv (action,
+ "app", app,
+ "interactive", self->interactive,
+ NULL);
+ return gs_plugin_loader_job_action (self->plugin_loader, plugin_job2,
+ NULL, error);
+}
+
+static void
+gs_cmd_self_free (GsCmdSelf *self)
+{
+ if (self->plugin_loader != NULL)
+ g_object_unref (self->plugin_loader);
+ g_free (self);
+}
+
+G_DEFINE_AUTOPTR_CLEANUP_FUNC(GsCmdSelf, gs_cmd_self_free)
+
+static gint
+app_sort_kind_cb (GsApp *app1, GsApp *app2, gpointer user_data)
+{
+ if (gs_app_get_kind (app1) == AS_COMPONENT_KIND_DESKTOP_APP)
+ return -1;
+ if (gs_app_get_kind (app2) == AS_COMPONENT_KIND_DESKTOP_APP)
+ return 1;
+ return 0;
+}
+
+int
+main (int argc, char **argv)
+{
+ g_autoptr(GOptionContext) context = NULL;
+ gboolean prefer_local = FALSE;
+ gboolean ret;
+ gboolean show_results = FALSE;
+ gboolean verbose = FALSE;
+ gint i;
+ guint64 cache_age_secs = 0;
+ gint repeat = 1;
+ g_auto(GStrv) plugin_blocklist = NULL;
+ g_auto(GStrv) plugin_allowlist = NULL;
+ g_autoptr(GError) error = NULL;
+ g_autoptr(GsAppList) list = NULL;
+ g_autoptr(GPtrArray) categories = NULL;
+ g_autoptr(GsDebug) debug = gs_debug_new_from_environment ();
+ g_autofree gchar *plugin_blocklist_str = NULL;
+ g_autofree gchar *plugin_allowlist_str = NULL;
+ g_autofree gchar *refine_flags_str = NULL;
+ g_autoptr(GsApp) app = NULL;
+ g_autoptr(GFile) file = NULL;
+ g_autoptr(GsCmdSelf) self = g_new0 (GsCmdSelf, 1);
+ const GOptionEntry options[] = {
+ { "show-results", '\0', 0, G_OPTION_ARG_NONE, &show_results,
+ "Show the results for the action", NULL },
+ { "refine-flags", '\0', 0, G_OPTION_ARG_STRING, &refine_flags_str,
+ "Set any refine flags required for the action", NULL },
+ { "repeat", '\0', 0, G_OPTION_ARG_INT, &repeat,
+ "Repeat the action this number of times", NULL },
+ { "cache-age", '\0', 0, G_OPTION_ARG_INT64, &cache_age_secs,
+ "Use this maximum cache age in seconds", NULL },
+ { "max-results", '\0', 0, G_OPTION_ARG_INT, &self->max_results,
+ "Return a maximum number of results", NULL },
+ { "prefer-local", '\0', 0, G_OPTION_ARG_NONE, &prefer_local,
+ "Prefer local file sources to AppStream", NULL },
+ { "plugin-blocklist", '\0', 0, G_OPTION_ARG_STRING, &plugin_blocklist_str,
+ "Do not load specific plugins", NULL },
+ { "plugin-allowlist", '\0', 0, G_OPTION_ARG_STRING, &plugin_allowlist_str,
+ "Only load specific plugins", NULL },
+ { "verbose", '\0', 0, G_OPTION_ARG_NONE, &verbose,
+ "Show verbose debugging information", NULL },
+ { "interactive", 'i', 0, G_OPTION_ARG_NONE, &self->interactive,
+ "Allow interactive authentication", NULL },
+ { NULL}
+ };
+
+ setlocale (LC_ALL, "");
+
+ bindtextdomain (GETTEXT_PACKAGE, LOCALEDIR);
+ bind_textdomain_codeset (GETTEXT_PACKAGE, "UTF-8");
+ textdomain (GETTEXT_PACKAGE);
+
+ gtk_init ();
+
+ context = g_option_context_new (NULL);
+ g_option_context_set_summary (context, "GNOME Software Test Program");
+ g_option_context_add_main_entries (context, options, NULL);
+ ret = g_option_context_parse (context, &argc, &argv, &error);
+ if (!ret) {
+ g_print ("Failed to parse options: %s\n", error->message);
+ return EXIT_FAILURE;
+ }
+ gs_debug_set_verbose (debug, verbose);
+
+ /* prefer local sources */
+ if (prefer_local)
+ g_setenv ("GNOME_SOFTWARE_PREFER_LOCAL", "true", TRUE);
+
+ /* parse any refine flags */
+ self->refine_flags = gs_cmd_parse_refine_flags (refine_flags_str, &error);
+ if (self->refine_flags == G_MAXUINT64) {
+ g_print ("Flag unknown: %s\n", error->message);
+ return EXIT_FAILURE;
+ }
+
+ /* load plugins */
+ self->plugin_loader = gs_plugin_loader_new (NULL, NULL);
+ if (g_file_test (LOCALPLUGINDIR, G_FILE_TEST_EXISTS))
+ gs_plugin_loader_add_location (self->plugin_loader, LOCALPLUGINDIR);
+ if (plugin_allowlist_str != NULL)
+ plugin_allowlist = g_strsplit (plugin_allowlist_str, ",", -1);
+ if (plugin_blocklist_str != NULL)
+ plugin_blocklist = g_strsplit (plugin_blocklist_str, ",", -1);
+ ret = gs_plugin_loader_setup (self->plugin_loader,
+ (const gchar * const *) plugin_allowlist,
+ (const gchar * const *) plugin_blocklist,
+ NULL,
+ &error);
+ if (!ret) {
+ g_print ("Failed to setup plugins: %s\n", error->message);
+ return EXIT_FAILURE;
+ }
+ gs_plugin_loader_dump_state (self->plugin_loader);
+
+ /* ensure that at least some metadata of any age is present, and also
+ * spin up the plugins enough as to prime caches */
+ if (g_getenv ("GS_CMD_NO_INITIAL_REFRESH") == NULL) {
+ g_autoptr(GsPluginJob) plugin_job = NULL;
+ GsPluginRefreshMetadataFlags refresh_metadata_flags = GS_PLUGIN_REFRESH_METADATA_FLAGS_NONE;
+
+ if (self->interactive)
+ refresh_metadata_flags |= GS_PLUGIN_REFRESH_METADATA_FLAGS_INTERACTIVE;
+
+ plugin_job = gs_plugin_job_refresh_metadata_new (G_MAXUINT64, refresh_metadata_flags);
+ ret = gs_plugin_loader_job_action (self->plugin_loader, plugin_job,
+ NULL, &error);
+ if (!ret) {
+ g_print ("Failed to refresh plugins: %s\n", error->message);
+ return EXIT_FAILURE;
+ }
+ }
+
+ /* do action */
+ if (argc == 2 && g_strcmp0 (argv[1], "installed") == 0) {
+ for (i = 0; i < repeat; i++) {
+ g_autoptr(GsAppQuery) query = NULL;
+ g_autoptr(GsPluginJob) plugin_job = NULL;
+ GsPluginListAppsFlags flags = GS_PLUGIN_LIST_APPS_FLAGS_NONE;
+
+ if (list != NULL)
+ g_object_unref (list);
+
+ query = gs_app_query_new ("is-installed", GS_APP_QUERY_TRISTATE_TRUE,
+ "refine-flags", self->refine_flags,
+ "max-results", self->max_results,
+ "dedupe-flags", GS_PLUGIN_JOB_DEDUPE_FLAGS_DEFAULT,
+ NULL);
+
+ if (self->interactive)
+ flags |= GS_PLUGIN_LIST_APPS_FLAGS_INTERACTIVE;
+
+ plugin_job = gs_plugin_job_list_apps_new (query, flags);
+ list = gs_plugin_loader_job_process (self->plugin_loader, plugin_job,
+ NULL, &error);
+ if (list == NULL) {
+ ret = FALSE;
+ break;
+ }
+ }
+ } else if (argc == 3 && g_strcmp0 (argv[1], "search") == 0) {
+ for (i = 0; i < repeat; i++) {
+ g_autoptr(GsAppQuery) query = NULL;
+ g_autoptr(GsPluginJob) plugin_job = NULL;
+ GsPluginListAppsFlags flags = GS_PLUGIN_LIST_APPS_FLAGS_NONE;
+ const gchar *keywords[2] = { argv[2], NULL };
+
+ if (list != NULL)
+ g_object_unref (list);
+
+ query = gs_app_query_new ("keywords", keywords,
+ "refine-flags", self->refine_flags,
+ "max-results", self->max_results,
+ "dedupe-flags", GS_PLUGIN_JOB_DEDUPE_FLAGS_DEFAULT,
+ "sort-func", gs_utils_app_sort_match_value,
+ NULL);
+
+ if (self->interactive)
+ flags |= GS_PLUGIN_LIST_APPS_FLAGS_INTERACTIVE;
+
+ plugin_job = gs_plugin_job_list_apps_new (query, flags);
+ list = gs_plugin_loader_job_process (self->plugin_loader, plugin_job, NULL, &error);
+ if (list == NULL) {
+ ret = FALSE;
+ break;
+ }
+ }
+ } else if (argc == 3 && g_strcmp0 (argv[1], "get-alternates") == 0) {
+ app = gs_app_new (argv[2]);
+ gs_app_add_quirk (app, GS_APP_QUIRK_IS_WILDCARD);
+ for (i = 0; i < repeat; i++) {
+ g_autoptr(GsAppQuery) query = NULL;
+ g_autoptr(GsPluginJob) plugin_job = NULL;
+ GsPluginListAppsFlags flags = GS_PLUGIN_LIST_APPS_FLAGS_NONE;
+
+ if (list != NULL)
+ g_object_unref (list);
+
+ query = gs_app_query_new ("alternate-of", app,
+ "refine-flags", self->refine_flags,
+ "max-results", self->max_results,
+ "dedupe-flags", GS_PLUGIN_JOB_DEDUPE_FLAGS_DEFAULT,
+ "sort-func", gs_utils_app_sort_priority,
+ NULL);
+
+ if (self->interactive)
+ flags |= GS_PLUGIN_LIST_APPS_FLAGS_INTERACTIVE;
+
+ plugin_job = gs_plugin_job_list_apps_new (query, flags);
+ list = gs_plugin_loader_job_process (self->plugin_loader, plugin_job, NULL, &error);
+ if (list == NULL) {
+ ret = FALSE;
+ break;
+ }
+ }
+ } else if (argc == 4 && g_strcmp0 (argv[1], "action") == 0) {
+ GsPluginAction action = gs_plugin_action_from_string (argv[2]);
+ if (action == GS_PLUGIN_ACTION_UNKNOWN) {
+ ret = FALSE;
+ g_set_error (&error,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_FAILED,
+ "Did not recognise action '%s'", argv[2]);
+ } else {
+ ret = gs_cmd_action_exec (self, action, argv[3], &error);
+ }
+ } else if (argc == 3 && g_strcmp0 (argv[1], "action-upgrade-download") == 0) {
+ g_autoptr(GsPluginJob) plugin_job = NULL;
+ app = gs_app_new (argv[2]);
+ gs_app_set_kind (app, AS_COMPONENT_KIND_OPERATING_SYSTEM);
+ plugin_job = gs_plugin_job_newv (GS_PLUGIN_ACTION_UPGRADE_DOWNLOAD,
+ "app", app,
+ "interactive", self->interactive,
+ NULL);
+ ret = gs_plugin_loader_job_action (self->plugin_loader, plugin_job,
+ NULL, &error);
+ if (ret)
+ gs_app_list_add (list, app);
+ } else if (argc == 3 && g_strcmp0 (argv[1], "refine") == 0) {
+ app = gs_app_new (argv[2]);
+ for (i = 0; i < repeat; i++) {
+ g_autoptr(GsPluginJob) plugin_job = NULL;
+ plugin_job = gs_plugin_job_refine_new_for_app (app, self->refine_flags);
+ ret = gs_plugin_loader_job_action (self->plugin_loader, plugin_job,
+ NULL, &error);
+ if (!ret)
+ break;
+ }
+ list = gs_app_list_new ();
+ gs_app_list_add (list, app);
+ } else if (argc == 3 && g_strcmp0 (argv[1], "launch") == 0) {
+ app = gs_app_new (argv[2]);
+ for (i = 0; i < repeat; i++) {
+ g_autoptr(GsPluginJob) plugin_job = NULL;
+ plugin_job = gs_plugin_job_newv (GS_PLUGIN_ACTION_LAUNCH,
+ "app", app,
+ "interactive", self->interactive,
+ NULL);
+ ret = gs_plugin_loader_job_action (self->plugin_loader, plugin_job,
+ NULL, &error);
+ if (!ret)
+ break;
+ }
+ } else if (argc == 3 && g_strcmp0 (argv[1], "filename-to-app") == 0) {
+ g_autoptr(GsPluginJob) plugin_job = NULL;
+ file = g_file_new_for_path (argv[2]);
+ plugin_job = gs_plugin_job_newv (GS_PLUGIN_ACTION_FILE_TO_APP,
+ "file", file,
+ "refine-flags", self->refine_flags,
+ "max-results", self->max_results,
+ "interactive", self->interactive,
+ NULL);
+ app = gs_plugin_loader_job_process_app (self->plugin_loader, plugin_job, NULL, &error);
+ if (app == NULL) {
+ ret = FALSE;
+ } else {
+ list = gs_app_list_new ();
+ gs_app_list_add (list, app);
+ }
+ } else if (argc == 3 && g_strcmp0 (argv[1], "url-to-app") == 0) {
+ g_autoptr(GsPluginJob) plugin_job = NULL;
+ plugin_job = gs_plugin_job_newv (GS_PLUGIN_ACTION_URL_TO_APP,
+ "search", argv[2],
+ "refine-flags", self->refine_flags,
+ "max-results", self->max_results,
+ "interactive", self->interactive,
+ NULL);
+ app = gs_plugin_loader_job_process_app (self->plugin_loader, plugin_job,
+ NULL, &error);
+ if (app == NULL) {
+ ret = FALSE;
+ } else {
+ list = gs_app_list_new ();
+ gs_app_list_add (list, app);
+ }
+ } else if (argc == 2 && g_strcmp0 (argv[1], "updates") == 0) {
+ for (i = 0; i < repeat; i++) {
+ g_autoptr(GsPluginJob) plugin_job = NULL;
+ if (list != NULL)
+ g_object_unref (list);
+ plugin_job = gs_plugin_job_newv (GS_PLUGIN_ACTION_GET_UPDATES,
+ "refine-flags", self->refine_flags,
+ "max-results", self->max_results,
+ "interactive", self->interactive,
+ NULL);
+ list = gs_plugin_loader_job_process (self->plugin_loader, plugin_job,
+ NULL, &error);
+ if (list == NULL) {
+ ret = FALSE;
+ break;
+ }
+ }
+ } else if (argc == 2 && g_strcmp0 (argv[1], "upgrades") == 0) {
+ for (i = 0; i < repeat; i++) {
+ g_autoptr(GsPluginJob) plugin_job = NULL;
+ GsPluginListDistroUpgradesFlags upgrades_flags = GS_PLUGIN_LIST_DISTRO_UPGRADES_FLAGS_NONE;
+
+ if (list != NULL)
+ g_object_unref (list);
+
+ if (self->interactive)
+ upgrades_flags |= GS_PLUGIN_LIST_DISTRO_UPGRADES_FLAGS_INTERACTIVE;
+
+ plugin_job = gs_plugin_job_list_distro_upgrades_new (upgrades_flags, self->refine_flags);
+ list = gs_plugin_loader_job_process (self->plugin_loader, plugin_job,
+ NULL, &error);
+ if (list == NULL) {
+ ret = FALSE;
+ break;
+ }
+ }
+ } else if (argc == 2 && g_strcmp0 (argv[1], "sources") == 0) {
+ g_autoptr(GsPluginJob) plugin_job = NULL;
+ plugin_job = gs_plugin_job_newv (GS_PLUGIN_ACTION_GET_SOURCES,
+ "refine-flags", self->refine_flags,
+ "max-results", self->max_results,
+ "interactive", self->interactive,
+ NULL);
+ list = gs_plugin_loader_job_process (self->plugin_loader,
+ plugin_job,
+ NULL,
+ &error);
+ if (list == NULL)
+ ret = FALSE;
+ } else if (argc == 2 && g_strcmp0 (argv[1], "popular") == 0) {
+ for (i = 0; i < repeat; i++) {
+ g_autoptr(GsPluginJob) plugin_job = NULL;
+ g_autoptr(GsAppQuery) query = NULL;
+ GsPluginListAppsFlags flags = GS_PLUGIN_LIST_APPS_FLAGS_NONE;
+
+ if (list != NULL)
+ g_object_unref (list);
+
+ query = gs_app_query_new ("is-curated", GS_APP_QUERY_TRISTATE_TRUE,
+ "refine-flags", self->refine_flags,
+ "max-results", self->max_results,
+ "sort-func", app_sort_kind_cb,
+ NULL);
+
+ if (self->interactive)
+ flags |= GS_PLUGIN_LIST_APPS_FLAGS_INTERACTIVE;
+
+ plugin_job = gs_plugin_job_list_apps_new (query, flags);
+ list = gs_plugin_loader_job_process (self->plugin_loader, plugin_job,
+ NULL, &error);
+ if (list == NULL) {
+ ret = FALSE;
+ break;
+ }
+ }
+ } else if (argc == 2 && g_strcmp0 (argv[1], "featured") == 0) {
+ for (i = 0; i < repeat; i++) {
+ g_autoptr(GsPluginJob) plugin_job = NULL;
+ g_autoptr(GsAppQuery) query = NULL;
+ GsPluginListAppsFlags flags = GS_PLUGIN_LIST_APPS_FLAGS_NONE;
+
+ if (list != NULL)
+ g_object_unref (list);
+
+ query = gs_app_query_new ("is-featured", GS_APP_QUERY_TRISTATE_TRUE,
+ "refine-flags", self->refine_flags,
+ "max-results", self->max_results,
+ NULL);
+
+ if (self->interactive)
+ flags |= GS_PLUGIN_LIST_APPS_FLAGS_INTERACTIVE;
+
+ plugin_job = gs_plugin_job_list_apps_new (query, flags);
+ list = gs_plugin_loader_job_process (self->plugin_loader, plugin_job,
+ NULL, &error);
+
+ if (list == NULL) {
+ ret = FALSE;
+ break;
+ }
+ }
+ } else if (argc == 3 && g_strcmp0 (argv[1], "deployment-featured") == 0) {
+ g_auto(GStrv) split = g_strsplit (argv[2], ",", -1);
+ for (i = 0; i < repeat; i++) {
+ g_autoptr(GsPluginJob) plugin_job = NULL;
+ g_autoptr(GsAppQuery) query = NULL;
+ GsPluginListAppsFlags flags = GS_PLUGIN_LIST_APPS_FLAGS_NONE;
+
+ if (list != NULL)
+ g_object_unref (list);
+
+ query = gs_app_query_new ("deployment-featured", split,
+ "refine-flags", self->refine_flags,
+ "dedupe-flags", GS_APP_LIST_FILTER_FLAG_KEY_ID,
+ "max-results", self->max_results,
+ NULL);
+
+ if (self->interactive)
+ flags |= GS_PLUGIN_LIST_APPS_FLAGS_INTERACTIVE;
+
+ plugin_job = gs_plugin_job_list_apps_new (query, flags);
+ list = gs_plugin_loader_job_process (self->plugin_loader, plugin_job,
+ NULL, &error);
+ if (list == NULL) {
+ ret = FALSE;
+ break;
+ }
+ }
+ } else if (argc == 2 && g_strcmp0 (argv[1], "recent") == 0) {
+ if (cache_age_secs == 0)
+ cache_age_secs = 60 * 60 * 24 * 60;
+ for (i = 0; i < repeat; i++) {
+ g_autoptr(GsPluginJob) plugin_job = NULL;
+ g_autoptr(GDateTime) now = NULL;
+ g_autoptr(GDateTime) released_since = NULL;
+ g_autoptr(GsAppQuery) query = NULL;
+ GsPluginListAppsFlags flags = GS_PLUGIN_LIST_APPS_FLAGS_NONE;
+
+ if (list != NULL)
+ g_object_unref (list);
+
+ now = g_date_time_new_now_local ();
+ released_since = g_date_time_add_seconds (now, -cache_age_secs);
+ query = gs_app_query_new ("released-since", released_since,
+ "refine-flags", self->refine_flags,
+ "dedupe-flags", GS_APP_LIST_FILTER_FLAG_KEY_ID,
+ "max-results", self->max_results,
+ "sort-func", app_sort_kind_cb,
+ NULL);
+
+ if (self->interactive)
+ flags |= GS_PLUGIN_LIST_APPS_FLAGS_INTERACTIVE;
+
+ plugin_job = gs_plugin_job_list_apps_new (query, flags);
+ list = gs_plugin_loader_job_process (self->plugin_loader, plugin_job,
+ NULL, &error);
+ if (list == NULL) {
+ ret = FALSE;
+ break;
+ }
+ }
+ } else if (argc == 2 && g_strcmp0 (argv[1], "get-categories") == 0) {
+ for (i = 0; i < repeat; i++) {
+ g_autoptr(GsPluginJob) plugin_job = NULL;
+ GsPluginRefineCategoriesFlags flags = GS_PLUGIN_REFINE_CATEGORIES_FLAGS_SIZE;
+
+ if (categories != NULL)
+ g_ptr_array_unref (categories);
+
+ if (self->interactive)
+ flags |= GS_PLUGIN_REFINE_CATEGORIES_FLAGS_INTERACTIVE;
+
+ plugin_job = gs_plugin_job_list_categories_new (flags);
+ if (!gs_plugin_loader_job_action (self->plugin_loader, plugin_job, NULL, &error)) {
+ ret = FALSE;
+ break;
+ }
+
+ categories = g_ptr_array_ref (gs_plugin_job_list_categories_get_result_list (GS_PLUGIN_JOB_LIST_CATEGORIES (plugin_job)));
+ }
+ } else if (argc == 3 && g_strcmp0 (argv[1], "get-category-apps") == 0) {
+ g_autoptr(GsCategory) category_owned = NULL;
+ GsCategory *category = NULL;
+ g_auto(GStrv) split = NULL;
+ GsCategoryManager *manager = gs_plugin_loader_get_category_manager (self->plugin_loader);
+
+ split = g_strsplit (argv[2], "/", 2);
+ if (g_strv_length (split) == 1) {
+ category_owned = gs_category_manager_lookup (manager, split[0]);
+ category = category_owned;
+ } else {
+ g_autoptr(GsCategory) parent = gs_category_manager_lookup (manager, split[0]);
+ if (parent != NULL)
+ category = gs_category_find_child (parent, split[1]);
+ }
+
+ if (category == NULL) {
+ g_printerr ("Error: Could not find category ‘%s’\n", argv[2]);
+ return EXIT_FAILURE;
+ }
+
+ for (i = 0; i < repeat; i++) {
+ g_autoptr(GsPluginJob) plugin_job = NULL;
+ g_autoptr(GsAppQuery) query = NULL;
+ GsPluginListAppsFlags flags = GS_PLUGIN_LIST_APPS_FLAGS_NONE;
+
+ if (list != NULL)
+ g_object_unref (list);
+
+ query = gs_app_query_new ("category", category,
+ "refine-flags", self->refine_flags,
+ "max-results", self->max_results,
+ "sort-func", gs_utils_app_sort_name,
+ NULL);
+
+ if (self->interactive)
+ flags |= GS_PLUGIN_LIST_APPS_FLAGS_INTERACTIVE;
+
+ plugin_job = gs_plugin_job_list_apps_new (query, flags);
+ list = gs_plugin_loader_job_process (self->plugin_loader, plugin_job, NULL, &error);
+ if (list == NULL) {
+ ret = FALSE;
+ break;
+ }
+ }
+ } else if (argc >= 2 && g_strcmp0 (argv[1], "refresh") == 0) {
+ g_autoptr(GsPluginJob) plugin_job = NULL;
+ GsPluginRefreshMetadataFlags refresh_metadata_flags = GS_PLUGIN_REFRESH_METADATA_FLAGS_NONE;
+
+ if (self->interactive)
+ refresh_metadata_flags |= GS_PLUGIN_REFRESH_METADATA_FLAGS_INTERACTIVE;
+
+ plugin_job = gs_plugin_job_refresh_metadata_new (cache_age_secs, refresh_metadata_flags);
+ ret = gs_plugin_loader_job_action (self->plugin_loader, plugin_job,
+ NULL, &error);
+ } else if (argc >= 1 && g_strcmp0 (argv[1], "user-hash") == 0) {
+ g_autofree gchar *user_hash = gs_utils_get_user_hash (&error);
+ if (user_hash == NULL) {
+ ret = FALSE;
+ } else {
+ g_print ("%s\n", user_hash);
+ ret = TRUE;
+ }
+ } else {
+ ret = FALSE;
+ g_set_error_literal (&error,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_FAILED,
+ "Did not recognise option, use 'installed', "
+ "'updates', 'popular', 'get-categories', "
+ "'get-category-apps', 'get-alternates', 'filename-to-app', "
+ "'action install', 'action remove', "
+ "'sources', 'refresh', 'launch' or 'search'");
+ }
+ if (!ret) {
+ g_print ("Failed: %s\n", error->message);
+ return EXIT_FAILURE;
+ }
+
+ if (show_results) {
+ if (list != NULL)
+ gs_cmd_show_results_apps (list);
+ if (categories != NULL)
+ gs_cmd_show_results_categories (categories);
+ }
+ return EXIT_SUCCESS;
+}
diff --git a/lib/gs-debug.c b/lib/gs-debug.c
new file mode 100644
index 0000000..f76788a
--- /dev/null
+++ b/lib/gs-debug.c
@@ -0,0 +1,295 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2016 Richard Hughes <richard@hughsie.com>
+ * Copyright (C) 2017 Kalev Lember <klember@redhat.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#include "config.h"
+
+#include <stdio.h>
+#include <unistd.h>
+
+#include "gs-os-release.h"
+#include "gs-debug.h"
+
+struct _GsDebug
+{
+ GObject parent_instance;
+
+ gchar **domains; /* (owned) (nullable), read-only after construction, guaranteed to be %NULL if empty */
+ gboolean verbose; /* (atomic) */
+ gboolean use_time; /* read-only after construction */
+};
+
+G_DEFINE_TYPE (GsDebug, gs_debug, G_TYPE_OBJECT)
+
+static GLogWriterOutput
+gs_log_writer_console (GLogLevelFlags log_level,
+ const GLogField *fields,
+ gsize n_fields,
+ gpointer user_data)
+{
+ GsDebug *debug = GS_DEBUG (user_data);
+ gboolean verbose;
+ const gchar * const *domains = NULL;
+ const gchar *log_domain = NULL;
+ const gchar *log_message = NULL;
+ g_autofree gchar *tmp = NULL;
+ g_autoptr(GString) domain = NULL;
+
+ domains = (const gchar * const *) debug->domains;
+ verbose = g_atomic_int_get (&debug->verbose);
+
+ /* check enabled, fast path without parsing fields */
+ if ((log_level == G_LOG_LEVEL_DEBUG ||
+ log_level == G_LOG_LEVEL_INFO) &&
+ !verbose &&
+ debug->domains == NULL)
+ return G_LOG_WRITER_HANDLED;
+
+ /* get data from arguments */
+ for (gsize i = 0; i < n_fields; i++) {
+ if (g_strcmp0 (fields[i].key, "MESSAGE") == 0) {
+ log_message = fields[i].value;
+ continue;
+ }
+ if (g_strcmp0 (fields[i].key, "GLIB_DOMAIN") == 0) {
+ log_domain = fields[i].value;
+ continue;
+ }
+ }
+
+ /* check enabled, slower path */
+ if ((log_level == G_LOG_LEVEL_DEBUG ||
+ log_level == G_LOG_LEVEL_INFO) &&
+ !verbose &&
+ debug->domains != NULL &&
+ g_strcmp0 (debug->domains[0], "all") != 0 &&
+ (log_domain == NULL || !g_strv_contains (domains, log_domain)))
+ return G_LOG_WRITER_HANDLED;
+
+ /* this is really verbose */
+ if ((g_strcmp0 (log_domain, "dconf") == 0 ||
+ g_strcmp0 (log_domain, "GLib-GIO") == 0 ||
+ g_strcmp0 (log_domain, "GLib-Net") == 0 ||
+ g_strcmp0 (log_domain, "GdkPixbuf") == 0) &&
+ log_level == G_LOG_LEVEL_DEBUG)
+ return G_LOG_WRITER_HANDLED;
+
+ /* time header */
+ if (debug->use_time) {
+ g_autoptr(GDateTime) dt = g_date_time_new_now_utc ();
+ tmp = g_strdup_printf ("%02i:%02i:%02i:%03i",
+ g_date_time_get_hour (dt),
+ g_date_time_get_minute (dt),
+ g_date_time_get_second (dt),
+ g_date_time_get_microsecond (dt) / 1000);
+ }
+
+ /* make these shorter */
+ if (g_strcmp0 (log_domain, "PackageKit") == 0) {
+ log_domain = "PK";
+ } else if (g_strcmp0 (log_domain, "GsPlugin") == 0) {
+ log_domain = "Gs";
+ }
+
+ /* pad out domain */
+ domain = g_string_new (log_domain);
+ for (guint i = domain->len; i < 3; i++)
+ g_string_append (domain, " ");
+
+ switch (log_level) {
+ case G_LOG_LEVEL_ERROR:
+ case G_LOG_LEVEL_CRITICAL:
+ case G_LOG_LEVEL_WARNING:
+ /* to screen */
+ if (isatty (fileno (stderr)) == 1) {
+ /* critical in red */
+ if (tmp != NULL)
+ g_printerr ("%c[%dm%s ", 0x1B, 32, tmp);
+ g_printerr ("%s ", domain->str);
+ g_printerr ("%c[%dm%s\n%c[%dm", 0x1B, 31, log_message, 0x1B, 0);
+ } else { /* to file */
+ if (tmp != NULL)
+ g_printerr ("%s ", tmp);
+ g_printerr ("%s ", domain->str);
+ g_printerr ("%s\n", log_message);
+ }
+ break;
+ default:
+ /* to screen */
+ if (isatty (fileno (stdout)) == 1) {
+ /* debug in blue */
+ if (tmp != NULL)
+ g_print ("%c[%dm%s ", 0x1B, 32, tmp);
+ g_print ("%s ", domain->str);
+ g_print ("%c[%dm%s\n%c[%dm", 0x1B, 34, log_message, 0x1B, 0);
+ break;
+ } else { /* to file */
+ if (tmp != NULL)
+ g_print ("%s ", tmp);
+ g_print ("%s ", domain->str);
+ g_print ("%s\n", log_message);
+ }
+ }
+
+ /* success */
+ return G_LOG_WRITER_HANDLED;
+}
+
+static GLogWriterOutput
+gs_log_writer_journald (GLogLevelFlags log_level,
+ const GLogField *fields,
+ gsize n_fields,
+ gpointer user_data)
+{
+ /* important enough to force to the journal */
+ switch (log_level) {
+ case G_LOG_LEVEL_ERROR:
+ case G_LOG_LEVEL_CRITICAL:
+ case G_LOG_LEVEL_WARNING:
+ case G_LOG_LEVEL_INFO:
+ return g_log_writer_journald (log_level, fields, n_fields, user_data);
+ break;
+ default:
+ break;
+ }
+
+ return G_LOG_WRITER_UNHANDLED;
+}
+
+static GLogWriterOutput
+gs_debug_log_writer (GLogLevelFlags log_level,
+ const GLogField *fields,
+ gsize n_fields,
+ gpointer user_data)
+{
+ if (g_log_writer_is_journald (fileno (stderr)))
+ return gs_log_writer_journald (log_level, fields, n_fields, user_data);
+ else
+ return gs_log_writer_console (log_level, fields, n_fields, user_data);
+}
+
+static void
+gs_debug_finalize (GObject *object)
+{
+ GsDebug *debug = GS_DEBUG (object);
+
+ g_clear_pointer (&debug->domains, g_strfreev);
+
+ G_OBJECT_CLASS (gs_debug_parent_class)->finalize (object);
+}
+
+static void
+gs_debug_class_init (GsDebugClass *klass)
+{
+ GObjectClass *object_class = G_OBJECT_CLASS (klass);
+ object_class->finalize = gs_debug_finalize;
+}
+
+static void
+gs_debug_init (GsDebug *debug)
+{
+ g_log_set_writer_func (gs_debug_log_writer,
+ g_object_ref (debug),
+ (GDestroyNotify) g_object_unref);
+}
+
+/**
+ * gs_debug_new:
+ * @domains: (transfer full) (nullable): a #GStrv of debug log domains to output,
+ * or `{ "all", NULL }` to output all debug log domains; %NULL is equivalent
+ * to an empty array
+ * @verbose: whether to output log debug messages
+ * @use_time: whether to output a timestamp with each log message
+ *
+ * Create a new #GsDebug with the given configuration.
+ *
+ * Ownership of @domains is transferred to this function. It will be freed with
+ * g_strfreev() when the #GsDebug is destroyed.
+ *
+ * Returns: (transfer full): a new #GsDebug
+ * Since: 40
+ */
+GsDebug *
+gs_debug_new (gchar **domains,
+ gboolean verbose,
+ gboolean use_time)
+{
+ g_autoptr(GsDebug) debug = g_object_new (GS_TYPE_DEBUG, NULL);
+
+ /* Strictly speaking these should be set before g_log_set_writer_func()
+ * is called, but threads probably haven’t been started at this point. */
+ debug->domains = (domains != NULL && domains[0] != NULL) ? g_steal_pointer (&domains) : NULL;
+ debug->verbose = verbose;
+ debug->use_time = use_time;
+
+ return g_steal_pointer (&debug);
+}
+
+/**
+ * gs_debug_new_from_environment:
+ *
+ * Create a new #GsDebug with its configuration loaded from environment
+ * variables.
+ *
+ * Returns: (transfer full): a new #GsDebug
+ * Since: 40
+ */
+GsDebug *
+gs_debug_new_from_environment (void)
+{
+ g_auto(GStrv) domains = NULL;
+ gboolean verbose, use_time;
+
+ if (g_getenv ("G_MESSAGES_DEBUG") != NULL) {
+ domains = g_strsplit (g_getenv ("G_MESSAGES_DEBUG"), " ", -1);
+ if (domains[0] == NULL)
+ g_clear_pointer (&domains, g_strfreev);
+ }
+
+ verbose = (g_getenv ("GS_DEBUG") != NULL);
+ use_time = (g_getenv ("GS_DEBUG_NO_TIME") == NULL);
+
+ return gs_debug_new (g_steal_pointer (&domains), verbose, use_time);
+}
+
+/**
+ * gs_debug_set_verbose:
+ * @self: a #GsDebug
+ * @verbose: whether to output log debug messages
+ *
+ * Enable or disable verbose logging mode.
+ *
+ * This can be called at any time, from any thread.
+ *
+ * Since: 40
+ */
+void
+gs_debug_set_verbose (GsDebug *self,
+ gboolean verbose)
+{
+ g_return_if_fail (GS_IS_DEBUG (self));
+
+ /* If we’re changing from !verbose → verbose, print OS information.
+ * This is helpful in verbose logs when people file bug reports. */
+ if (g_atomic_int_compare_and_exchange (&self->verbose, !verbose, verbose) &&
+ verbose) {
+ g_autoptr(GsOsRelease) os_release = NULL;
+ g_autoptr(GError) error = NULL;
+
+ g_debug (PACKAGE_NAME " " PACKAGE_VERSION);
+
+ os_release = gs_os_release_new (&error);
+ if (os_release) {
+ g_debug ("OS: %s; %s",
+ gs_os_release_get_name (os_release),
+ gs_os_release_get_version (os_release));
+ } else {
+ g_debug ("Failed to get OS Release information: %s", error->message);
+ }
+ }
+}
diff --git a/lib/gs-debug.h b/lib/gs-debug.h
new file mode 100644
index 0000000..d927f81
--- /dev/null
+++ b/lib/gs-debug.h
@@ -0,0 +1,27 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2016 Richard Hughes <richard@hughsie.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#include <glib.h>
+#include <glib-object.h>
+
+G_BEGIN_DECLS
+
+#define GS_TYPE_DEBUG (gs_debug_get_type ())
+
+G_DECLARE_FINAL_TYPE (GsDebug, gs_debug, GS, DEBUG, GObject)
+
+GsDebug *gs_debug_new (gchar **domains,
+ gboolean verbose,
+ gboolean use_time);
+GsDebug *gs_debug_new_from_environment (void);
+void gs_debug_set_verbose (GsDebug *self,
+ gboolean verbose);
+
+G_END_DECLS
diff --git a/lib/gs-desktop-data.c b/lib/gs-desktop-data.c
new file mode 100644
index 0000000..c2ee93b
--- /dev/null
+++ b/lib/gs-desktop-data.c
@@ -0,0 +1,320 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2015-2016 Richard Hughes <richard@hughsie.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#include "config.h"
+
+#include <glib/gi18n.h>
+
+#include "gs-desktop-data.h"
+
+static const GsDesktopMap map_create[] = {
+ { "all", NC_("Menu of Graphics & Photography", "All"),
+ { "Graphics",
+ "AudioVideo",
+ NULL } },
+ { "featured", NC_("Menu of Graphics & Photography", "Featured"),
+ { "Graphics::Featured",
+ "AudioVideo::Featured",
+ NULL} },
+ { "3d", NC_("Menu of Graphics & Photography", "3D Graphics"),
+ { "Graphics::3DGraphics",
+ NULL} },
+ { "photography", NC_("Menu of Graphics & Photography", "Photography"),
+ { "Graphics::Photography",
+ NULL} },
+ { "scanning", NC_("Menu of Graphics & Photography", "Scanning"),
+ { "Graphics::Scanning",
+ NULL} },
+ { "vector", NC_("Menu of Graphics & Photography", "Vector Graphics"),
+ { "Graphics::VectorGraphics",
+ NULL} },
+ { "viewers", NC_("Menu of Graphics & Photography", "Viewers"),
+ { "Graphics::Viewer",
+ NULL} },
+ { "creation-editing", NC_("Menu of Audio & Video", "Audio Creation & Editing"),
+ { "AudioVideo::AudioVideoEditing",
+ "AudioVideo::Midi",
+ "AudioVideo::DiscBurning",
+ "AudioVideo::Sequencer",
+ NULL} },
+ { "music-players", NC_("Menu of Audio & Video", "Music Players"),
+ { "AudioVideo::Music",
+ "AudioVideo::Player",
+ NULL} },
+ { NULL }
+};
+
+static const GsDesktopMap map_work[] = {
+ { "all", NC_("Menu of Productivity", "All"),
+ { "Office",
+ "Utility",
+ "Network::WebBrowser",
+ NULL } },
+ { "featured", NC_("Menu of Productivity", "Featured"),
+ { "Office::Featured",
+ "Utility::Featured",
+ NULL} },
+ { "calendar", NC_("Menu of Productivity", "Calendar"),
+ { "Office::Calendar",
+ "Office::ProjectManagement",
+ NULL} },
+ { "database", NC_("Menu of Productivity", "Database"),
+ { "Office::Database",
+ NULL} },
+ { "finance", NC_("Menu of Productivity", "Finance"),
+ { "Office::Finance",
+ "Office::Spreadsheet",
+ NULL} },
+ { "word-processor", NC_("Menu of Productivity", "Word Processor"),
+ { "Office::WordProcessor",
+ "Office::Dictionary",
+ NULL} },
+ { "text-editors", NC_("Menu of Utilities", "Text Editors"),
+ { "Utility::TextEditor",
+ NULL} },
+ { "web-browsers", NC_("Menu of Communication & News", "Web Browsers"),
+ { "Network::WebBrowser",
+ NULL} },
+ { NULL }
+};
+
+static const GsDesktopMap map_play[] = {
+ { "all", NC_("Menu of Audio & Video", "All"),
+ { "Game",
+ NULL } },
+ { "featured", NC_("Menu of Audio & Video", "Featured"),
+ { "Game::Featured",
+ NULL} },
+ { "action", NC_("Menu of Games", "Action"),
+ { "Game::ActionGame",
+ NULL} },
+ { "adventure", NC_("Menu of Games", "Adventure"),
+ { "Game::AdventureGame",
+ NULL} },
+ { "arcade", NC_("Menu of Games", "Arcade"),
+ { "Game::ArcadeGame",
+ NULL} },
+ { "blocks", NC_("Menu of Games", "Blocks"),
+ { "Game::BlocksGame",
+ NULL} },
+ { "board", NC_("Menu of Games", "Board"),
+ { "Game::BoardGame",
+ NULL} },
+ { "card", NC_("Menu of Games", "Card"),
+ { "Game::CardGame",
+ NULL} },
+ { "emulator", NC_("Menu of Games", "Emulators"),
+ { "Game::Emulator",
+ NULL} },
+ { "kids", NC_("Menu of Games", "Kids"),
+ { "Game::KidsGame",
+ NULL} },
+ { "logic", NC_("Menu of Games", "Logic"),
+ { "Game::LogicGame",
+ "Game::Simulation",
+ NULL} },
+ { "role-playing", NC_("Menu of Games", "Role Playing"),
+ { "Game::RolePlaying",
+ NULL} },
+ { "sports", NC_("Menu of Games", "Sports"),
+ { "Game::SportsGame",
+ NULL} },
+ { "strategy", NC_("Menu of Games", "Strategy"),
+ { "Game::StrategyGame",
+ NULL} },
+ { NULL }
+};
+
+static const GsDesktopMap map_socialize[] = {
+ { "all", NC_("Menu of Communication & News", "All"),
+ { "Network",
+ NULL } },
+ { "featured", NC_("Menu of Communication & News", "Featured"),
+ { "Network::Featured",
+ NULL} },
+ { "chat", NC_("Menu of Communication & News", "Chat"),
+ { "Network::Chat",
+ "Network::IRCClient",
+ "Network::Telephony",
+ "Network::VideoConference",
+ "Network::Email",
+ NULL} },
+ { NULL }
+};
+
+static const GsDesktopMap map_learn[] = {
+ { "all", NC_("Menu of Education & Science", "All"),
+ { "Education",
+ "Science",
+ "Reference",
+ "Network::Feed",
+ "Network::News",
+ NULL } },
+ { "featured", NC_("Menu of Education & Science", "Featured"),
+ { "Education::Featured",
+ "Science::Featured",
+ "Reference::Featured",
+ NULL} },
+ { "artificial-intelligence", NC_("Menu of Education & Science", "Artificial Intelligence"),
+ { "Science::ArtificialIntelligence",
+ NULL} },
+ { "astronomy", NC_("Menu of Education & Science", "Astronomy"),
+ { "Education::Astronomy",
+ "Science::Astronomy",
+ NULL} },
+ { "chemistry", NC_("Menu of Education & Science", "Chemistry"),
+ { "Education::Chemistry",
+ "Science::Chemistry",
+ NULL} },
+ { "languages", NC_("Menu of Education & Science", "Languages"),
+ { "Education::Languages",
+ "Education::Literature",
+ NULL} },
+ { "math", NC_("Menu of Education & Science", "Math"),
+ { "Education::Math",
+ "Education::NumericalAnalysis",
+ "Science::Math",
+ "Science::Physics",
+ "Science::NumericalAnalysis",
+ NULL} },
+ { "news", NC_("Menu of Communication & News", "News"),
+ { "Network::Feed",
+ "Network::News",
+ NULL} },
+ { "robotics", NC_("Menu of Education & Science", "Robotics"),
+ { "Science::Robotics",
+ NULL} },
+ { "art", NC_("Menu of Art", "Art"),
+ { "Reference::Art",
+ NULL} },
+ { "biography", NC_("Menu of Reference", "Biography"),
+ { "Reference::Biography",
+ NULL} },
+ { "comics", NC_("Menu of Reference", "Comics"),
+ { "Reference::Comics",
+ NULL} },
+ { "fiction", NC_("Menu of Reference", "Fiction"),
+ { "Reference::Fiction",
+ NULL} },
+ { "health", NC_("Menu of Reference", "Health"),
+ { "Reference::Health",
+ NULL} },
+ { "history", NC_("Menu of Reference", "History"),
+ { "Reference::History",
+ NULL} },
+ { "lifestyle", NC_("Menu of Reference", "Lifestyle"),
+ { "Reference::Lifestyle",
+ NULL} },
+ { "politics", NC_("Menu of Reference", "Politics"),
+ { "Reference::Politics",
+ NULL} },
+ { "sports", NC_("Menu of Reference", "Sports"),
+ { "Reference::Sports",
+ NULL} },
+ { NULL }
+};
+
+static const GsDesktopMap map_develop[] = {
+ { "all", NC_("Menu of Developer Tools", "All"),
+ { "Development",
+ NULL } },
+ { "featured", NC_("Menu of Developer Tools", "Featured"),
+ { "Development::Featured",
+ NULL} },
+ { "debuggers", NC_("Menu of Developer Tools", "Debuggers"),
+ { "Development::Debugger",
+ NULL} },
+ { "ide", NC_("Menu of Developer Tools", "IDEs"),
+ { "Development::IDE",
+ "Development::GUIDesigner",
+ NULL} },
+ { NULL }
+};
+
+static const GsDesktopMap map_addon_codecs[] = {
+ { "all", NC_("Menu of Add-ons", "Codecs"),
+ { "Addon::Codec",
+ NULL } },
+ { NULL }
+};
+
+static const GsDesktopMap map_addon_drivers[] = {
+ { "all", NC_("Menu of Add-ons", "Hardware Drivers"),
+ { "Addon::Driver",
+ NULL } },
+ { NULL }
+};
+
+static const GsDesktopMap map_addon_fonts[] = {
+ { "all", NC_("Menu of Add-ons", "Fonts"),
+ { "Addon::Font",
+ NULL } },
+ { NULL }
+};
+
+static const GsDesktopMap map_addon_input_sources[] = {
+ { "all", NC_("Menu of Add-ons", "Input Sources"),
+ { "Addon::InputSource",
+ NULL } },
+ { NULL }
+};
+
+static const GsDesktopMap map_addon_language_packs[] = {
+ { "all", NC_("Menu of Add-ons", "Language Packs"),
+ { "Addon::LanguagePack",
+ NULL } },
+ { NULL }
+};
+
+static const GsDesktopMap map_addon_localization[] = {
+ { "all", NC_("Menu of Add-ons", "Localization"),
+ { "Addon::Localization",
+ NULL } },
+ { NULL }
+};
+
+/* main categories */
+/* Please keep category name and subcategory context synchronized!!! */
+static const GsDesktopData msdata[] = {
+ /* Translators: this is a menu category */
+ { "create", map_create, N_("Create"), "org.gnome.Software.Create", 100 },
+ /* Translators: this is a menu category */
+ { "work", map_work, N_("Work"), "org.gnome.Software.Work", 90 },
+ /* Translators: this is a menu category */
+ { "play", map_play, N_("Play"), "org.gnome.Software.Play", 80 },
+ /* Translators: this is a menu category */
+ { "socialize", map_socialize, N_("Socialize"), "org.gnome.Software.Socialize", 70 },
+ /* Translators: this is a menu category */
+ { "learn", map_learn, N_("Learn"), "org.gnome.Software.Learn", 60 },
+ /* Translators: this is a menu category */
+ { "develop", map_develop, N_("Develop"), "org.gnome.Software.Develop", 50 },
+
+ /* Translators: this is a menu category */
+ { "codecs", map_addon_codecs, N_("Codecs"), NULL, 10 },
+ /* Translators: this is a menu category */
+ { "drivers", map_addon_drivers, N_("Hardware Drivers"), NULL, 10 },
+ /* Translators: this is a menu category */
+ { "fonts", map_addon_fonts, N_("Fonts"), NULL, 10 },
+ /* Translators: this is a menu category */
+ { "input-sources", map_addon_input_sources, N_("Input Sources"), NULL, 10 },
+ /* Translators: this is a menu category */
+ { "language-packs", map_addon_language_packs, N_("Language Packs"), NULL, 10 },
+ /* Translators: this is a menu category */
+ { "localization", map_addon_localization, N_("Localization"), NULL, 10 },
+
+ { NULL }
+};
+
+/* the -1 is for the NULL terminator */
+G_STATIC_ASSERT (G_N_ELEMENTS (msdata) - 1 == GS_DESKTOP_DATA_N_ENTRIES);
+
+const GsDesktopData *
+gs_desktop_get_data (void)
+{
+ return msdata;
+}
diff --git a/lib/gs-desktop-data.h b/lib/gs-desktop-data.h
new file mode 100644
index 0000000..049656a
--- /dev/null
+++ b/lib/gs-desktop-data.h
@@ -0,0 +1,43 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2011-2016 Richard Hughes <richard@hughsie.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#include <glib-object.h>
+
+G_BEGIN_DECLS
+
+typedef struct {
+ const gchar *id;
+ const gchar *name;
+ const gchar *fdo_cats[16];
+} GsDesktopMap;
+
+typedef struct {
+ const gchar *id;
+ const GsDesktopMap *mapping;
+ const gchar *name;
+ const gchar *icon;
+ gint score;
+} GsDesktopData;
+
+const GsDesktopData *gs_desktop_get_data (void);
+
+/**
+ * GS_DESKTOP_DATA_N_ENTRIES:
+ *
+ * Number of entries in the array returned by gs_desktop_get_data(). This is
+ * static and guaranteed to be up to date. It’s intended to be used when
+ * defining static arrays which need to be the same size as the array returned
+ * by gs_desktop_get_data().
+ *
+ * Since: 40
+ */
+#define GS_DESKTOP_DATA_N_ENTRIES 12
+
+G_END_DECLS
diff --git a/lib/gs-download-utils.c b/lib/gs-download-utils.c
new file mode 100644
index 0000000..4949064
--- /dev/null
+++ b/lib/gs-download-utils.c
@@ -0,0 +1,870 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2021, 2022 Endless OS Foundation LLC
+ *
+ * Author: Philip Withnall <pwithnall@endlessos.org>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+/**
+ * SECTION:gs-download-utils
+ * @short_description: Download and HTTP utilities
+ *
+ * A set of utilities for downloading things and doing HTTP requests.
+ *
+ * Since: 42
+ */
+
+#include "config.h"
+
+#include <gio/gio.h>
+#include <glib.h>
+#include <glib-object.h>
+#include <glib/gi18n.h>
+#include <libsoup/soup.h>
+
+#include "gs-download-utils.h"
+#include "gs-utils.h"
+
+G_DEFINE_QUARK (gs-download-error-quark, gs_download_error)
+
+/**
+ * gs_build_soup_session:
+ *
+ * Build a new #SoupSession configured with the gnome-software user agent.
+ *
+ * A new #SoupSession should be used for each independent download context, such
+ * as in different plugins. Each #SoupSession caches HTTP connections and
+ * authentication information, and these likely needn’t be shared between
+ * plugins. Using separate sessions reduces thread contention.
+ *
+ * Returns: (transfer full): a new #SoupSession
+ * Since: 42
+ */
+SoupSession *
+gs_build_soup_session (void)
+{
+ return soup_session_new_with_options ("user-agent", gs_user_agent (),
+ "timeout", 10,
+ NULL);
+}
+
+/* See https://httpwg.org/specs/rfc7231.html#http.date
+ * For example: Sun, 06 Nov 1994 08:49:37 GMT */
+static gchar *
+date_time_to_rfc7231 (GDateTime *date_time)
+{
+#if SOUP_CHECK_VERSION(3, 0, 0)
+ return soup_date_time_to_string (date_time, SOUP_DATE_HTTP);
+#else
+ const gchar *day_names[] = { "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun" };
+ const gchar *month_names[] = { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" };
+
+ /* We can’t just use g_date_time_format() here because its output
+ * (particularly day and month names) is locale-dependent.
+ * #SoupDate is also a pain to use because there’s no easy way to
+ * convert from a #GDateTime with libsoup-2.4, while preserving the timezone. */
+ g_autofree gchar *time_str = g_date_time_format (date_time, "%H:%M:%S %Z");
+
+ return g_strdup_printf ("%s, %02d %s %d %s",
+ day_names[g_date_time_get_day_of_week (date_time) - 1],
+ g_date_time_get_day_of_month (date_time),
+ month_names[g_date_time_get_month (date_time) - 1],
+ g_date_time_get_year (date_time),
+ time_str);
+#endif
+}
+
+static GDateTime *
+date_time_from_rfc7231 (const gchar *rfc7231_str)
+{
+#if SOUP_CHECK_VERSION(3, 0, 0)
+ return soup_date_time_new_from_http_string (rfc7231_str);
+#else
+ g_autoptr(SoupDate) soup_date = NULL;
+ g_autoptr(GTimeZone) tz = NULL;
+
+ soup_date = soup_date_new_from_string (rfc7231_str);
+ if (soup_date == NULL)
+ return NULL;
+
+ if (soup_date->utc)
+ tz = g_time_zone_new_utc ();
+ else
+ tz = g_time_zone_new_offset (soup_date->offset * 60);
+
+ return g_date_time_new (tz, soup_date->year, soup_date->month,
+ soup_date->day, soup_date->hour,
+ soup_date->minute, soup_date->second);
+#endif
+}
+
+typedef struct {
+ /* Input data. */
+ gchar *uri; /* (not nullable) (owned) */
+ GInputStream *input_stream; /* (nullable) (owned) */
+ GOutputStream *output_stream; /* (nullable) (owned) */
+ gsize buffer_size_bytes;
+ gchar *last_etag; /* (nullable) (owned) */
+ GDateTime *last_modified_date; /* (nullable) (owned) */
+ int io_priority;
+ GsDownloadProgressCallback progress_callback; /* (nullable) */
+ gpointer progress_user_data;
+
+ /* In-progress state. */
+ SoupMessage *message; /* (nullable) (owned) */
+ gboolean close_input_stream;
+ gboolean close_output_stream;
+ gboolean discard_output_stream;
+ gsize total_read_bytes;
+ gsize total_written_bytes;
+ gsize expected_stream_size_bytes;
+ GBytes *currently_unwritten_chunk; /* (nullable) (owned) */
+
+ /* Output data. */
+ gchar *new_etag; /* (nullable) (owned) */
+ GDateTime *new_last_modified_date; /* (nullable) (owned) */
+ GError *error; /* (nullable) (owned) */
+} DownloadData;
+
+static void
+download_data_free (DownloadData *data)
+{
+ g_assert (data->input_stream == NULL || g_input_stream_is_closed (data->input_stream));
+ g_assert (data->output_stream == NULL || g_output_stream_is_closed (data->output_stream));
+
+ g_assert (data->currently_unwritten_chunk == NULL || data->error != NULL);
+
+ g_clear_object (&data->input_stream);
+ g_clear_object (&data->output_stream);
+
+ g_clear_pointer (&data->last_etag, g_free);
+ g_clear_pointer (&data->last_modified_date, g_date_time_unref);
+ g_clear_object (&data->message);
+ g_clear_pointer (&data->uri, g_free);
+ g_clear_pointer (&data->new_etag, g_free);
+ g_clear_pointer (&data->new_last_modified_date, g_date_time_unref);
+ g_clear_pointer (&data->currently_unwritten_chunk, g_bytes_unref);
+ g_clear_error (&data->error);
+
+ g_free (data);
+}
+
+G_DEFINE_AUTOPTR_CLEANUP_FUNC (DownloadData, download_data_free)
+
+static void open_input_stream_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data);
+static void read_bytes_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data);
+static void write_bytes_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data);
+static void finish_download (GTask *task,
+ GError *error);
+static void close_stream_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data);
+static void download_progress (GTask *task);
+
+/**
+ * gs_download_stream_async:
+ * @soup_session: a #SoupSession
+ * @uri: (not nullable): the URI to download
+ * @output_stream: (not nullable): an output stream to write the download to
+ * @last_etag: (nullable): the last-known ETag of the URI, or %NULL if unknown
+ * @last_modified_date: (nullable): the last-known Last-Modified date of the
+ * URI, or %NULL if unknown
+ * @io_priority: I/O priority to download and write at
+ * @progress_callback: (nullable): callback to call with progress information
+ * @progress_user_data: (nullable) (closure progress_callback): data to pass
+ * to @progress_callback
+ * @cancellable: (nullable): a #GCancellable, or %NULL
+ * @callback: callback to call once the operation is complete
+ * @user_data: (closure callback): data to pass to @callback
+ *
+ * Download @uri and write it to @output_stream asynchronously.
+ *
+ * If @last_etag is non-%NULL or @last_modified_date is non-%NULL, they will be
+ * sent to the server, which may return a ‘not modified’ response. If so,
+ * @output_stream will not be written to, and will be closed with a cancelled
+ * close operation. This will ensure that the existing content of the output
+ * stream (if it’s a file, for example) will not be overwritten.
+ *
+ * Note that @last_etag must be the ETag value returned by the server last time
+ * the file was downloaded, not the local file ETag generated by GLib.
+ *
+ * If specified, @progress_callback will be called zero or more times until
+ * @callback is called, providing progress updates on the download.
+ *
+ * Since: 43
+ */
+void
+gs_download_stream_async (SoupSession *soup_session,
+ const gchar *uri,
+ GOutputStream *output_stream,
+ const gchar *last_etag,
+ GDateTime *last_modified_date,
+ int io_priority,
+ GsDownloadProgressCallback progress_callback,
+ gpointer progress_user_data,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data)
+{
+ g_autoptr(GTask) task = NULL;
+ g_autoptr(GError) local_error = NULL;
+ g_autoptr(SoupMessage) msg = NULL;
+ DownloadData *data;
+ g_autoptr(DownloadData) data_owned = NULL;
+
+ g_return_if_fail (SOUP_IS_SESSION (soup_session));
+ g_return_if_fail (uri != NULL);
+ g_return_if_fail (G_IS_OUTPUT_STREAM (output_stream));
+ g_return_if_fail (cancellable == NULL || G_IS_CANCELLABLE (cancellable));
+
+ task = g_task_new (soup_session, cancellable, callback, user_data);
+ g_task_set_source_tag (task, gs_download_stream_async);
+
+ data = data_owned = g_new0 (DownloadData, 1);
+ data->uri = g_strdup (uri);
+ data->output_stream = g_object_ref (output_stream);
+ data->close_output_stream = TRUE;
+ data->buffer_size_bytes = 8192; /* arbitrarily chosen */
+ data->io_priority = io_priority;
+ data->progress_callback = progress_callback;
+ data->progress_user_data = progress_user_data;
+
+ g_task_set_task_data (task, g_steal_pointer (&data_owned), (GDestroyNotify) download_data_free);
+
+ /* local */
+ if (g_str_has_prefix (uri, "file://")) {
+ g_autoptr(GFile) local_file = g_file_new_for_path (uri + strlen ("file://"));
+ g_file_read_async (local_file, io_priority, cancellable, open_input_stream_cb, g_steal_pointer (&task));
+ return;
+ }
+
+ /* remote */
+ g_debug ("Downloading %s to %s", uri, G_OBJECT_TYPE_NAME (output_stream));
+ msg = soup_message_new (SOUP_METHOD_GET, uri);
+ if (msg == NULL) {
+ finish_download (task,
+ g_error_new (G_IO_ERROR,
+ G_IO_ERROR_INVALID_ARGUMENT,
+ "Failed to parse URI ‘%s’", uri));
+ return;
+ }
+
+ data->message = g_object_ref (msg);
+
+ /* Caching support. Prefer ETags to modification dates, as the latter
+ * have problems with rapid updates and clock drift. */
+ if (last_etag != NULL && *last_etag == '\0')
+ last_etag = NULL;
+ data->last_etag = g_strdup (last_etag);
+
+ if (last_modified_date != NULL)
+ data->last_modified_date = g_date_time_ref (last_modified_date);
+
+ if (last_etag != NULL) {
+#if SOUP_CHECK_VERSION(3, 0, 0)
+ soup_message_headers_append (soup_message_get_request_headers (msg), "If-None-Match", last_etag);
+#else
+ soup_message_headers_append (msg->request_headers, "If-None-Match", last_etag);
+#endif
+ } else if (last_modified_date != NULL) {
+ g_autofree gchar *last_modified_date_str = date_time_to_rfc7231 (last_modified_date);
+#if SOUP_CHECK_VERSION(3, 0, 0)
+ soup_message_headers_append (soup_message_get_request_headers (msg), "If-Modified-Since", last_modified_date_str);
+#else
+ soup_message_headers_append (msg->request_headers, "If-Modified-Since", last_modified_date_str);
+#endif
+ }
+
+#if SOUP_CHECK_VERSION(3, 0, 0)
+ soup_session_send_async (soup_session, msg, data->io_priority, cancellable, open_input_stream_cb, g_steal_pointer (&task));
+#else
+ soup_session_send_async (soup_session, msg, cancellable, open_input_stream_cb, g_steal_pointer (&task));
+#endif
+}
+
+static void
+open_input_stream_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data)
+{
+ g_autoptr(GTask) task = g_steal_pointer (&user_data);
+ DownloadData *data = g_task_get_task_data (task);
+ GCancellable *cancellable = g_task_get_cancellable (task);
+ g_autoptr(GInputStream) input_stream = NULL;
+ g_autoptr(GError) local_error = NULL;
+
+ /* This function can be called as a result of either reading a local
+ * file, or sending an HTTP request, so @source_object’s type can vary. */
+ if (G_IS_FILE (source_object)) {
+ GFile *local_file = G_FILE (source_object);
+
+ /* Local file. */
+ input_stream = G_INPUT_STREAM (g_file_read_finish (local_file, result, &local_error));
+
+ if (input_stream == NULL) {
+ g_prefix_error (&local_error, "Failed to read ‘%s’: ",
+ g_file_peek_path (local_file));
+ finish_download (task, g_steal_pointer (&local_error));
+ return;
+ }
+
+ g_assert (data->input_stream == NULL);
+ data->input_stream = g_object_ref (input_stream);
+ data->close_input_stream = TRUE;
+ } else if (SOUP_IS_SESSION (source_object)) {
+ SoupSession *soup_session = SOUP_SESSION (source_object);
+ guint status_code;
+ const gchar *new_etag, *new_last_modified_str;
+
+ /* HTTP request. */
+#if SOUP_CHECK_VERSION(3, 0, 0)
+ input_stream = soup_session_send_finish (soup_session, result, &local_error);
+ status_code = soup_message_get_status (data->message);
+#else
+ input_stream = soup_session_send_finish (soup_session, result, &local_error);
+ status_code = data->message->status_code;
+#endif
+
+ if (input_stream != NULL) {
+ g_assert (data->input_stream == NULL);
+ data->input_stream = g_object_ref (input_stream);
+ data->close_input_stream = TRUE;
+ }
+
+ if (status_code == SOUP_STATUS_NOT_MODIFIED) {
+ /* If the file has not been modified from the ETag or
+ * Last-Modified date we have, finish the download
+ * early. Ensure to close the output stream so that its
+ * existing content is *not* overwritten.
+ *
+ * Preserve the existing ETag. */
+ data->discard_output_stream = TRUE;
+ data->new_etag = g_strdup (data->last_etag);
+ data->new_last_modified_date = (data->last_modified_date != NULL) ? g_date_time_ref (data->last_modified_date) : NULL;
+ finish_download (task,
+ g_error_new (GS_DOWNLOAD_ERROR,
+ GS_DOWNLOAD_ERROR_NOT_MODIFIED,
+ "Skipped downloading ‘%s’: %s",
+ data->uri, soup_status_get_phrase (status_code)));
+ return;
+ } else if (status_code != SOUP_STATUS_OK) {
+ g_autoptr(GString) str = g_string_new (NULL);
+ g_string_append (str, soup_status_get_phrase (status_code));
+
+ if (local_error != NULL) {
+ g_string_append (str, ": ");
+ g_string_append (str, local_error->message);
+ }
+
+ finish_download (task,
+ g_error_new (G_IO_ERROR,
+ G_IO_ERROR_FAILED,
+ "Failed to download ‘%s’: %s",
+ data->uri, str->str));
+ return;
+ }
+
+ g_assert (input_stream != NULL);
+
+ /* Get the expected download size. */
+#if SOUP_CHECK_VERSION(3, 0, 0)
+ data->expected_stream_size_bytes = soup_message_headers_get_content_length (soup_message_get_response_headers (data->message));
+#else
+ data->expected_stream_size_bytes = soup_message_headers_get_content_length (data->message->response_headers);
+#endif
+
+ /* Store the new ETag for later use. */
+#if SOUP_CHECK_VERSION(3, 0, 0)
+ new_etag = soup_message_headers_get_one (soup_message_get_response_headers (data->message), "ETag");
+#else
+ new_etag = soup_message_headers_get_one (data->message->response_headers, "ETag");
+#endif
+ if (new_etag != NULL && *new_etag == '\0')
+ new_etag = NULL;
+ data->new_etag = g_strdup (new_etag);
+
+ /* Store the Last-Modified date for later use. */
+#if SOUP_CHECK_VERSION(3, 0, 0)
+ new_last_modified_str = soup_message_headers_get_one (soup_message_get_response_headers (data->message), "Last-Modified");
+#else
+ new_last_modified_str = soup_message_headers_get_one (data->message->response_headers, "Last-Modified");
+#endif
+ if (new_last_modified_str != NULL && *new_last_modified_str == '\0')
+ new_last_modified_str = NULL;
+ if (new_last_modified_str != NULL)
+ data->new_last_modified_date = date_time_from_rfc7231 (new_last_modified_str);
+ } else {
+ g_assert_not_reached ();
+ }
+
+ /* Splice in an asynchronous loop. We unfortunately can’t use
+ * g_output_stream_splice_async() here, as it doesn’t provide a progress
+ * callback. The approach is the same though. */
+ g_input_stream_read_bytes_async (input_stream, data->buffer_size_bytes, data->io_priority,
+ cancellable, read_bytes_cb, g_steal_pointer (&task));
+}
+
+static void
+read_bytes_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data)
+{
+ GInputStream *input_stream = G_INPUT_STREAM (source_object);
+ g_autoptr(GTask) task = g_steal_pointer (&user_data);
+ DownloadData *data = g_task_get_task_data (task);
+ GCancellable *cancellable = g_task_get_cancellable (task);
+ g_autoptr(GBytes) bytes = NULL;
+ g_autoptr(GError) local_error = NULL;
+
+ bytes = g_input_stream_read_bytes_finish (input_stream, result, &local_error);
+
+ if (bytes == NULL) {
+ finish_download (task, g_steal_pointer (&local_error));
+ return;
+ }
+
+ /* Report progress. */
+ data->total_read_bytes += g_bytes_get_size (bytes);
+ data->expected_stream_size_bytes = MAX (data->expected_stream_size_bytes, data->total_read_bytes);
+ download_progress (task);
+
+ /* Write the downloaded data. */
+ if (g_bytes_get_size (bytes) > 0) {
+ g_clear_pointer (&data->currently_unwritten_chunk, g_bytes_unref);
+ data->currently_unwritten_chunk = g_bytes_ref (bytes);
+
+ g_output_stream_write_bytes_async (data->output_stream, bytes, data->io_priority,
+ cancellable, write_bytes_cb, g_steal_pointer (&task));
+ } else {
+ finish_download (task, NULL);
+ }
+}
+
+static void
+write_bytes_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data)
+{
+ GOutputStream *output_stream = G_OUTPUT_STREAM (source_object);
+ g_autoptr(GTask) task = g_steal_pointer (&user_data);
+ DownloadData *data = g_task_get_task_data (task);
+ GCancellable *cancellable = g_task_get_cancellable (task);
+ gssize bytes_written_signed;
+ gsize bytes_written;
+ g_autoptr(GError) local_error = NULL;
+
+ bytes_written_signed = g_output_stream_write_bytes_finish (output_stream, result, &local_error);
+
+ if (bytes_written_signed < 0) {
+ finish_download (task, g_steal_pointer (&local_error));
+ return;
+ }
+
+ /* We know this is non-negative now. */
+ bytes_written = (gsize) bytes_written_signed;
+
+ /* Report progress. */
+ data->total_written_bytes += bytes_written;
+ download_progress (task);
+
+ g_assert (data->currently_unwritten_chunk != NULL);
+
+ if (bytes_written < g_bytes_get_size (data->currently_unwritten_chunk)) {
+ /* Partial write; try again with the remaining bytes. */
+ g_autoptr(GBytes) sub_bytes = g_bytes_new_from_bytes (data->currently_unwritten_chunk, bytes_written, g_bytes_get_size (data->currently_unwritten_chunk) - bytes_written);
+ g_assert (bytes_written > 0);
+
+ g_clear_pointer (&data->currently_unwritten_chunk, g_bytes_unref);
+ data->currently_unwritten_chunk = g_bytes_ref (sub_bytes);
+
+ g_output_stream_write_bytes_async (output_stream, sub_bytes, data->io_priority,
+ cancellable, write_bytes_cb, g_steal_pointer (&task));
+ } else {
+ /* Full write succeeded. Start the next read. */
+ g_clear_pointer (&data->currently_unwritten_chunk, g_bytes_unref);
+
+ g_input_stream_read_bytes_async (data->input_stream, data->buffer_size_bytes, data->io_priority,
+ cancellable, read_bytes_cb, g_steal_pointer (&task));
+ }
+}
+
+static inline gboolean
+is_not_modidifed_error (GError *error)
+{
+ return g_error_matches (error, GS_DOWNLOAD_ERROR, GS_DOWNLOAD_ERROR_NOT_MODIFIED);
+}
+
+/* error is (transfer full) */
+static void
+finish_download (GTask *task,
+ GError *error)
+{
+ DownloadData *data = g_task_get_task_data (task);
+ GCancellable *cancellable = g_task_get_cancellable (task);
+
+ /* Final progress update. */
+ if (error == NULL || is_not_modidifed_error (error)) {
+ data->expected_stream_size_bytes = data->total_read_bytes;
+ download_progress (task);
+ }
+
+ /* Record the error from the operation, if set. */
+ g_assert (data->error == NULL);
+ data->error = g_steal_pointer (&error);
+
+ g_assert (!data->discard_output_stream || data->close_output_stream);
+
+ if (data->close_output_stream) {
+ g_autoptr(GCancellable) output_cancellable = NULL;
+
+ g_assert (data->output_stream != NULL);
+
+ /* If there’s been a prior error, or we are aborting writing the
+ * output stream (perhaps because of a cache hit), close the
+ * output stream but cancel the close operation so that the old
+ * output file is not overwritten. */
+ if ((data->error != NULL && !is_not_modidifed_error (data->error)) || data->discard_output_stream) {
+ output_cancellable = g_cancellable_new ();
+ g_cancellable_cancel (output_cancellable);
+ } else if (g_task_get_cancellable (task) != NULL) {
+ output_cancellable = g_object_ref (g_task_get_cancellable (task));
+ }
+
+ g_output_stream_close_async (data->output_stream, data->io_priority, output_cancellable, close_stream_cb, g_object_ref (task));
+ }
+
+ if (data->close_input_stream && data->input_stream != NULL) {
+ g_input_stream_close_async (data->input_stream, data->io_priority, cancellable, close_stream_cb, g_object_ref (task));
+ }
+
+ /* Check in case both streams are already closed. */
+ close_stream_cb (NULL, NULL, g_object_ref (task));
+}
+
+static void
+close_stream_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data)
+{
+ g_autoptr(GTask) task = g_steal_pointer (&user_data);
+ DownloadData *data = g_task_get_task_data (task);
+ g_autoptr(GError) local_error = NULL;
+
+ if (G_IS_INPUT_STREAM (source_object)) {
+ /* Errors in closing the input stream are not fatal. */
+ if (!g_input_stream_close_finish (G_INPUT_STREAM (source_object),
+ result, &local_error))
+ g_debug ("Error closing input stream: %s", local_error->message);
+ g_clear_error (&local_error);
+
+ data->close_input_stream = FALSE;
+ } else if (G_IS_OUTPUT_STREAM (source_object)) {
+ /* Errors in closing the output stream are fatal, but don’t
+ * overwrite errors set earlier in the operation. */
+ if (!g_output_stream_close_finish (G_OUTPUT_STREAM (source_object),
+ result, &local_error)) {
+ /* If we are aborting writing the output stream (perhaps
+ * because of a cache hit), don’t report the error at
+ * all. */
+ if (data->discard_output_stream &&
+ g_error_matches (local_error, G_IO_ERROR, G_IO_ERROR_CANCELLED))
+ g_clear_error (&local_error);
+ else if (data->error == NULL)
+ data->error = g_steal_pointer (&local_error);
+ else if (!g_error_matches (local_error, G_IO_ERROR, G_IO_ERROR_CANCELLED))
+ g_debug ("Error closing output stream: %s", local_error->message);
+ }
+ g_clear_error (&local_error);
+
+ data->close_output_stream = FALSE;
+ data->discard_output_stream = FALSE;
+ } else {
+ /* finish_download() calls this with a NULL source_object */
+ }
+
+ /* Still waiting for one of the streams to close? */
+ if (data->close_input_stream || data->close_output_stream)
+ return;
+
+ if (data->error != NULL) {
+ g_task_return_error (task, g_error_copy (data->error));
+ } else {
+ g_task_return_boolean (task, TRUE);
+ }
+}
+
+static void
+download_progress (GTask *task)
+{
+ DownloadData *data = g_task_get_task_data (task);
+
+ if (data->progress_callback != NULL) {
+ /* This should be guaranteed by the rest of the download code. */
+ g_assert (data->expected_stream_size_bytes >= data->total_written_bytes);
+
+ data->progress_callback (data->total_written_bytes, data->expected_stream_size_bytes,
+ data->progress_user_data);
+ }
+}
+
+/**
+ * gs_download_stream_finish:
+ * @soup_session: a #SoupSession
+ * @result: result of the asynchronous operation
+ * @new_etag_out: (out callee-allocates) (transfer full) (optional) (nullable):
+ * return location for the ETag of the downloaded file (which may be %NULL),
+ * or %NULL to ignore it
+ * @new_last_modified_date_out: (out callee-allocates) (transfer full) (optional) (nullable):
+ * return location for the new Last-Modified date of the downloaded file
+ * (which may be %NULL), or %NULL to ignore it
+ * @error: return location for a #GError
+ *
+ * Finish an asynchronous download operation started with
+ * gs_download_stream_async().
+ *
+ * Returns: %TRUE on success, %FALSE otherwise
+ * Since: 43
+ */
+gboolean
+gs_download_stream_finish (SoupSession *soup_session,
+ GAsyncResult *result,
+ gchar **new_etag_out,
+ GDateTime **new_last_modified_date_out,
+ GError **error)
+{
+ DownloadData *data;
+
+ g_return_val_if_fail (g_task_is_valid (result, soup_session), FALSE);
+ g_return_val_if_fail (g_task_get_source_tag (G_TASK (result)) == gs_download_stream_async, FALSE);
+ g_return_val_if_fail (error == NULL || *error == NULL, FALSE);
+
+ data = g_task_get_task_data (G_TASK (result));
+
+ if (new_etag_out != NULL)
+ *new_etag_out = g_strdup (data->new_etag);
+ if (new_last_modified_date_out != NULL)
+ *new_last_modified_date_out = (data->new_last_modified_date != NULL) ? g_date_time_ref (data->new_last_modified_date) : NULL;
+
+ return g_task_propagate_boolean (G_TASK (result), error);
+}
+
+typedef struct {
+ /* Input data. */
+ gchar *uri; /* (not nullable) (owned) */
+ GFile *output_file; /* (not nullable) (owned) */
+ int io_priority;
+ GsDownloadProgressCallback progress_callback;
+ gpointer progress_user_data;
+
+ /* In-progress data. */
+ gchar *last_etag; /* (nullable) (owned) */
+ GDateTime *last_modified_date; /* (nullable) (owned) */
+} DownloadFileData;
+
+static void
+download_file_data_free (DownloadFileData *data)
+{
+ g_free (data->uri);
+ g_clear_object (&data->output_file);
+ g_free (data->last_etag);
+ g_clear_pointer (&data->last_modified_date, g_date_time_unref);
+ g_free (data);
+}
+
+G_DEFINE_AUTOPTR_CLEANUP_FUNC (DownloadFileData, download_file_data_free)
+
+static void download_replace_file_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data);
+static void download_file_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data);
+
+/**
+ * gs_download_file_async:
+ * @soup_session: a #SoupSession
+ * @uri: (not nullable): the URI to download
+ * @output_file: (not nullable): an output file to write the download to
+ * @io_priority: I/O priority to download and write at
+ * @progress_callback: (nullable): callback to call with progress information
+ * @progress_user_data: (nullable) (closure progress_callback): data to pass
+ * to @progress_callback
+ * @cancellable: (nullable): a #GCancellable, or %NULL
+ * @callback: callback to call once the operation is complete
+ * @user_data: (closure callback): data to pass to @callback
+ *
+ * Download @uri and write it to @output_file asynchronously, overwriting the
+ * existing content of @output_file.
+ *
+ * The ETag and modification time of @output_file will be queried and, if known,
+ * used to skip the download if @output_file is already up to date.
+ *
+ * If specified, @progress_callback will be called zero or more times until
+ * @callback is called, providing progress updates on the download.
+ *
+ * Since: 42
+ */
+void
+gs_download_file_async (SoupSession *soup_session,
+ const gchar *uri,
+ GFile *output_file,
+ int io_priority,
+ GsDownloadProgressCallback progress_callback,
+ gpointer progress_user_data,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data)
+{
+ g_autoptr(GTask) task = NULL;
+ DownloadFileData *data;
+ g_autoptr(DownloadFileData) data_owned = NULL;
+ g_autoptr(GFile) output_file_parent = NULL;
+ g_autoptr(GError) local_error = NULL;
+
+ g_return_if_fail (SOUP_IS_SESSION (soup_session));
+ g_return_if_fail (uri != NULL);
+ g_return_if_fail (G_IS_FILE (output_file));
+ g_return_if_fail (cancellable == NULL || G_IS_CANCELLABLE (cancellable));
+
+ task = g_task_new (soup_session, cancellable, callback, user_data);
+ g_task_set_source_tag (task, gs_download_file_async);
+
+ data = data_owned = g_new0 (DownloadFileData, 1);
+ data->uri = g_strdup (uri);
+ data->output_file = g_object_ref (output_file);
+ data->io_priority = io_priority;
+ data->progress_callback = progress_callback;
+ data->progress_user_data = progress_user_data;
+ g_task_set_task_data (task, g_steal_pointer (&data_owned), (GDestroyNotify) download_file_data_free);
+
+ /* Create the destination file’s directory.
+ * FIXME: This should be made async; it hasn’t done for now as it’s
+ * likely to be fast. */
+ output_file_parent = g_file_get_parent (output_file);
+
+ if (output_file_parent != NULL &&
+ !g_file_make_directory_with_parents (output_file_parent, cancellable, &local_error) &&
+ !g_error_matches (local_error, G_IO_ERROR, G_IO_ERROR_EXISTS)) {
+ g_task_return_error (task, g_steal_pointer (&local_error));
+ return;
+ }
+
+ g_clear_error (&local_error);
+
+ /* Query the old ETag and modification date if the file already exists. */
+ data->last_etag = gs_utils_get_file_etag (output_file, &data->last_modified_date, cancellable);
+
+ /* Create the output file.
+ *
+ * Note that `data->last_etag` is *not* passed in here, as the ETag from
+ * the server and the file modification ETag that GLib uses are
+ * different things. For g_file_replace_async(), GLib always uses an
+ * ETag it generates internally based on the file mtime (see
+ * _g_local_file_info_create_etag()), which will never match what the
+ * server returns in its ETag header.
+ *
+ * This is fine, as we are using the ETag to avoid an unnecessary HTTP
+ * download if possible. We don’t care about tracking changes to the
+ * file on disk. */
+ g_file_replace_async (output_file,
+ NULL, /* ETag */
+ FALSE, /* make_backup */
+ G_FILE_CREATE_PRIVATE | G_FILE_CREATE_REPLACE_DESTINATION,
+ io_priority,
+ cancellable,
+ download_replace_file_cb,
+ g_steal_pointer (&task));
+}
+
+static void
+download_replace_file_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data)
+{
+ GFile *output_file = G_FILE (source_object);
+ g_autoptr(GTask) task = g_steal_pointer (&user_data);
+ SoupSession *soup_session = g_task_get_source_object (task);
+ GCancellable *cancellable = g_task_get_cancellable (task);
+ DownloadFileData *data = g_task_get_task_data (task);
+ g_autoptr(GFileOutputStream) output_stream = NULL;
+ g_autoptr(GError) local_error = NULL;
+
+ output_stream = g_file_replace_finish (output_file, result, &local_error);
+
+ if (output_stream == NULL) {
+ g_task_return_error (task, g_steal_pointer (&local_error));
+ return;
+ }
+
+ /* Do the download. */
+ gs_download_stream_async (soup_session, data->uri, G_OUTPUT_STREAM (output_stream),
+ data->last_etag, data->last_modified_date, data->io_priority,
+ data->progress_callback, data->progress_user_data,
+ cancellable, download_file_cb, g_steal_pointer (&task));
+}
+
+static void
+download_file_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data)
+{
+ SoupSession *soup_session = SOUP_SESSION (source_object);
+ g_autoptr(GTask) task = g_steal_pointer (&user_data);
+ GCancellable *cancellable = g_task_get_cancellable (task);
+ DownloadFileData *data = g_task_get_task_data (task);
+ g_autofree gchar *new_etag = NULL;
+ g_autoptr(GError) local_error = NULL;
+
+ if (!gs_download_stream_finish (soup_session, result, &new_etag, NULL, &local_error)) {
+ g_task_return_error (task, g_steal_pointer (&local_error));
+ return;
+ }
+
+ /* Update the stored HTTP ETag.
+ *
+ * Under the assumption that this code is only ever used for locally
+ * cached copies of remote files (i.e. the local copies are never
+ * modified except by downloading an updated version from the server),
+ * it’s safe to use the local file modification date for Last-Modified,
+ * and save having to update that explicitly. This is because the
+ * modification time of the local file equals when gnome-software last
+ * checked for updates to it — which is correct to send as the
+ * If-Modified-Since the next time gnome-software checks for updates to
+ * the file. */
+ gs_utils_set_file_etag (data->output_file, new_etag, cancellable);
+
+ g_task_return_boolean (task, TRUE);
+}
+
+/**
+ * gs_download_file_finish:
+ * @soup_session: a #SoupSession
+ * @result: result of the asynchronous operation
+ * @error: return location for a #GError
+ *
+ * Finish an asynchronous download operation started with
+ * gs_download_file_async().
+ *
+ * Returns: %TRUE on success, %FALSE otherwise
+ * Since: 42
+ */
+gboolean
+gs_download_file_finish (SoupSession *soup_session,
+ GAsyncResult *result,
+ GError **error)
+{
+ g_return_val_if_fail (g_task_is_valid (result, soup_session), FALSE);
+ g_return_val_if_fail (g_task_get_source_tag (G_TASK (result)) == gs_download_file_async, FALSE);
+ g_return_val_if_fail (error == NULL || *error == NULL, FALSE);
+
+ return g_task_propagate_boolean (G_TASK (result), error);
+}
diff --git a/lib/gs-download-utils.h b/lib/gs-download-utils.h
new file mode 100644
index 0000000..cd9dd46
--- /dev/null
+++ b/lib/gs-download-utils.h
@@ -0,0 +1,88 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2022 Endless OS Foundation LLC
+ *
+ * Author: Philip Withnall <pwithnall@endlessos.org>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#include <glib.h>
+#include <glib-object.h>
+#include <gio/gio.h>
+#include <libsoup/soup.h>
+
+G_BEGIN_DECLS
+
+SoupSession *gs_build_soup_session (void);
+
+/**
+ * GsDownloadProgressCallback:
+ * @bytes_downloaded: number of bytes downloaded so far
+ * @total_download_size: the total size of the download, in bytes
+ * @user_data: data passed to the calling function
+ *
+ * A progress callback to indicate how far a download has progressed.
+ *
+ * @total_download_size may be zero (for example, at the start of the download),
+ * so implementations of this callback must be careful to avoid division by zero
+ * errors.
+ *
+ * @total_download_size is guaranteed to always be greater than or equal to
+ * @bytes_downloaded.
+ *
+ * Since: 42
+ */
+typedef void (*GsDownloadProgressCallback) (gsize bytes_downloaded,
+ gsize total_download_size,
+ gpointer user_data);
+
+/**
+ * GsExternalAppstreamError:
+ * @GS_DOWNLOAD_ERROR_NOT_MODIFIED: The ETag matches that of the server file.
+ *
+ * Error codes for download operations.
+ *
+ * Since: 44
+ */
+typedef enum {
+ GS_DOWNLOAD_ERROR_NOT_MODIFIED,
+} GsDownloadError;
+
+#define GS_DOWNLOAD_ERROR gs_download_error_quark ()
+GQuark gs_download_error_quark (void);
+
+void gs_download_stream_async (SoupSession *soup_session,
+ const gchar *uri,
+ GOutputStream *output_stream,
+ const gchar *last_etag,
+ GDateTime *last_modified_date,
+ int io_priority,
+ GsDownloadProgressCallback progress_callback,
+ gpointer progress_user_data,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data);
+gboolean gs_download_stream_finish (SoupSession *soup_session,
+ GAsyncResult *result,
+ gchar **new_etag_out,
+ GDateTime **new_last_modified_date_out,
+ GError **error);
+
+void gs_download_file_async (SoupSession *soup_session,
+ const gchar *uri,
+ GFile *output_file,
+ int io_priority,
+ GsDownloadProgressCallback progress_callback,
+ gpointer progress_user_data,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data);
+gboolean gs_download_file_finish (SoupSession *soup_session,
+ GAsyncResult *result,
+ GError **error);
+
+G_END_DECLS
diff --git a/lib/gs-external-appstream-utils.c b/lib/gs-external-appstream-utils.c
new file mode 100644
index 0000000..24ce3a8
--- /dev/null
+++ b/lib/gs-external-appstream-utils.c
@@ -0,0 +1,627 @@
+ /* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2018 Endless Mobile, Inc.
+ *
+ * Authors: Joaquim Rocha <jrocha@endlessm.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+/*
+ * SECTION:gs-external-appstream-urls
+ * @short_description: Provides support for downloading external AppStream files.
+ *
+ * This downloads the set of configured external AppStream files, and caches
+ * them locally.
+ *
+ * According to the `external-appstream-system-wide` GSetting, the files will
+ * either be downloaded to a per-user cache, or to a system-wide cache. In the
+ * case of a system-wide cache, they are downloaded to a temporary file writable
+ * by the user, and then the suexec binary `gnome-software-install-appstream` is
+ * run to copy them to the system location.
+ *
+ * All the downloads are done in the default #GMainContext for the thread which
+ * calls gs_external_appstream_refresh_async(). They are done in parallel and
+ * the async refresh function will only complete once the last download is
+ * complete.
+ *
+ * Progress data is reported via a callback, and gives the total progress of all
+ * parallel downloads. Internally this is done by updating #ProgressTuple
+ * structs as each download progresses. A periodic timeout callback sums these
+ * and reports the total progress to the caller. That means that progress
+ * reports from gs_external_appstream_refresh_async() are done at a constant
+ * frequency.
+ *
+ * To test this code locally you will probably want to change your GSettings
+ * configuration to add some external AppStream URIs:
+ * ```
+ * gsettings set org.gnome.software external-appstream-urls '["https://example.com/appdata.xml.gz"]'
+ * ```
+ *
+ * When you are done with development, run the following command to use the real
+ * external AppStream list again:
+ * ```
+ * gsettings reset org.gnome.software external-appstream-urls
+ * ```
+ *
+ * Since: 42
+ */
+
+#include <errno.h>
+#include <glib.h>
+#include <glib/gi18n.h>
+#include <glib/gstdio.h>
+#include <libsoup/soup.h>
+
+#include "gs-external-appstream-utils.h"
+
+#define APPSTREAM_SYSTEM_DIR LOCALSTATEDIR "/cache/swcatalog/xml"
+
+G_DEFINE_QUARK (gs-external-appstream-error-quark, gs_external_appstream_error)
+
+gchar *
+gs_external_appstream_utils_get_file_cache_path (const gchar *file_name)
+{
+ g_autofree gchar *prefixed_file_name = g_strdup_printf (EXTERNAL_APPSTREAM_PREFIX "-%s",
+ file_name);
+ return g_build_filename (APPSTREAM_SYSTEM_DIR, prefixed_file_name, NULL);
+}
+
+/* To be able to delete old files, when the path changed */
+gchar *
+gs_external_appstream_utils_get_legacy_file_cache_path (const gchar *file_name)
+{
+ g_autofree gchar *prefixed_file_name = g_strdup_printf (EXTERNAL_APPSTREAM_PREFIX "-%s",
+ file_name);
+ return g_build_filename (LOCALSTATEDIR "/cache/app-info/xmls", prefixed_file_name, NULL);
+}
+
+const gchar *
+gs_external_appstream_utils_get_system_dir (void)
+{
+ return APPSTREAM_SYSTEM_DIR;
+}
+
+static gboolean
+gs_external_appstream_check (GFile *appstream_file,
+ guint64 cache_age_secs)
+{
+ guint64 appstream_file_age = gs_utils_get_file_age (appstream_file);
+ return appstream_file_age >= cache_age_secs;
+}
+
+static gboolean
+gs_external_appstream_install (const gchar *appstream_file,
+ GCancellable *cancellable,
+ GError **error)
+{
+ g_autoptr(GSubprocess) subprocess = NULL;
+ const gchar *argv[] = { "pkexec",
+ LIBEXECDIR "/gnome-software-install-appstream",
+ appstream_file, NULL};
+ g_debug ("Installing the appstream file %s in the system",
+ appstream_file);
+ subprocess = g_subprocess_newv (argv,
+ G_SUBPROCESS_FLAGS_STDOUT_PIPE |
+ G_SUBPROCESS_FLAGS_STDIN_PIPE, error);
+ if (subprocess == NULL)
+ return FALSE;
+ return g_subprocess_wait_check (subprocess, cancellable, error);
+}
+
+static void download_replace_file_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data);
+static void download_stream_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data);
+
+/* A tuple to store the last-received progress data for a single download.
+ * Each download (refresh_url_async()) has a pointer to the relevant
+ * #ProgressTuple for its download. These are stored in an array in #RefreshData
+ * and a timeout callback periodically sums them all and reports progress to the
+ * caller. */
+typedef struct {
+ gsize bytes_downloaded;
+ gsize total_download_size;
+} ProgressTuple;
+
+typedef struct {
+ /* Input data. */
+ gchar *url; /* (not nullable) (owned) */
+ GTask *task; /* (not nullable) (owned) */
+ GFile *output_file; /* (not nullable) (owned) */
+ ProgressTuple *progress_tuple; /* (not nullable) */
+ SoupSession *soup_session; /* (not nullable) (owned) */
+ gboolean system_wide;
+
+ /* In-progress data. */
+ gchar *last_etag; /* (nullable) (owned) */
+ GDateTime *last_modified_date; /* (nullable) (owned) */
+} DownloadAppStreamData;
+
+static void
+download_appstream_data_free (DownloadAppStreamData *data)
+{
+ g_free (data->url);
+ g_clear_object (&data->task);
+ g_clear_object (&data->output_file);
+ g_clear_object (&data->soup_session);
+ g_free (data->last_etag);
+ g_clear_pointer (&data->last_modified_date, g_date_time_unref);
+ g_free (data);
+}
+
+G_DEFINE_AUTOPTR_CLEANUP_FUNC (DownloadAppStreamData, download_appstream_data_free)
+
+static void
+refresh_url_progress_cb (gsize bytes_downloaded,
+ gsize total_download_size,
+ gpointer user_data)
+{
+ ProgressTuple *tuple = user_data;
+
+ tuple->bytes_downloaded = bytes_downloaded;
+ tuple->total_download_size = total_download_size;
+
+ /* The timeout callback in progress_cb() periodically sums these. No
+ * need to notify of progress from here. */
+}
+
+static void
+refresh_url_async (GSettings *settings,
+ const gchar *url,
+ SoupSession *soup_session,
+ guint64 cache_age_secs,
+ ProgressTuple *progress_tuple,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data)
+{
+ g_autoptr(GTask) task = NULL;
+ g_autofree gchar *basename = NULL;
+ g_autofree gchar *basename_url = g_path_get_basename (url);
+ /* make sure different uris with same basenames differ */
+ g_autofree gchar *hash = NULL;
+ g_autofree gchar *target_file_path = NULL;
+ g_autoptr(GFile) target_file = NULL;
+ g_autoptr(GFile) tmp_file_parent = NULL;
+ g_autoptr(GFile) tmp_file = NULL;
+ g_autoptr(GsApp) app_dl = gs_app_new ("external-appstream");
+ g_autoptr(GError) local_error = NULL;
+ DownloadAppStreamData *data;
+ gboolean system_wide;
+
+ task = g_task_new (NULL, cancellable, callback, user_data);
+ g_task_set_source_tag (task, refresh_url_async);
+
+ /* Calculate the basename of the target file. */
+ hash = g_compute_checksum_for_string (G_CHECKSUM_SHA1, url, -1);
+ if (hash == NULL) {
+ g_task_return_new_error (task,
+ GS_EXTERNAL_APPSTREAM_ERROR,
+ GS_EXTERNAL_APPSTREAM_ERROR_DOWNLOADING,
+ "Failed to hash URI ‘%s’", url);
+ return;
+ }
+ basename = g_strdup_printf ("%s-%s", hash, basename_url);
+
+ /* Are we downloading for the user, or the system? */
+ system_wide = g_settings_get_boolean (settings, "external-appstream-system-wide");
+
+ /* Check cache file age. */
+ if (system_wide) {
+ target_file_path = gs_external_appstream_utils_get_file_cache_path (basename);
+ } else {
+ g_autofree gchar *legacy_file_path = NULL;
+
+ target_file_path = g_build_filename (g_get_user_data_dir (),
+ "swcatalog",
+ "xml",
+ basename,
+ NULL);
+
+ /* Delete an old file, from a legacy location */
+ legacy_file_path = g_build_filename (g_get_user_data_dir (),
+ "app-info",
+ "xmls",
+ basename,
+ NULL);
+
+ if (g_unlink (legacy_file_path) == -1) {
+ int errn = errno;
+ if (errn != ENOENT)
+ g_debug ("Failed to unlink '%s': %s", legacy_file_path, g_strerror (errn));
+
+ }
+ }
+
+ target_file = g_file_new_for_path (target_file_path);
+
+ if (!gs_external_appstream_check (target_file, cache_age_secs)) {
+ g_debug ("skipping updating external appstream file %s: "
+ "cache age is older than file",
+ target_file_path);
+ g_task_return_boolean (task, TRUE);
+ return;
+ }
+
+ /* If downloading system wide, write the download contents into a
+ * temporary file that will be copied into the system location later. */
+ if (system_wide) {
+ g_autofree gchar *tmp_file_path = NULL;
+
+ tmp_file_path = gs_utils_get_cache_filename ("external-appstream",
+ basename,
+ GS_UTILS_CACHE_FLAG_WRITEABLE |
+ GS_UTILS_CACHE_FLAG_CREATE_DIRECTORY,
+ &local_error);
+ if (tmp_file_path == NULL) {
+ g_task_return_error (task, g_steal_pointer (&local_error));
+ return;
+ }
+
+ tmp_file = g_file_new_for_path (tmp_file_path);
+ } else {
+ tmp_file = g_object_ref (target_file);
+ }
+
+ gs_app_set_summary_missing (app_dl,
+ /* TRANSLATORS: status text when downloading */
+ _("Downloading extra metadata files…"));
+
+ data = g_new0 (DownloadAppStreamData, 1);
+ data->url = g_strdup (url);
+ data->task = g_object_ref (task);
+ data->output_file = g_object_ref (tmp_file);
+ data->progress_tuple = progress_tuple;
+ data->soup_session = g_object_ref (soup_session);
+ data->system_wide = system_wide;
+ g_task_set_task_data (task, data, (GDestroyNotify) download_appstream_data_free);
+
+ /* Create the destination file’s directory.
+ * FIXME: This should be made async; it hasn’t done for now as it’s
+ * likely to be fast. */
+ tmp_file_parent = g_file_get_parent (tmp_file);
+
+ if (tmp_file_parent != NULL &&
+ !g_file_make_directory_with_parents (tmp_file_parent, cancellable, &local_error) &&
+ !g_error_matches (local_error, G_IO_ERROR, G_IO_ERROR_EXISTS)) {
+ g_task_return_error (task, g_steal_pointer (&local_error));
+ return;
+ }
+
+ g_clear_error (&local_error);
+
+ /* Query the ETag and modification date of the target file, if the file already exists. For
+ * system-wide installations, this is the ETag of the AppStream file installed system-wide.
+ * For local installations, this is just the local output file. */
+ data->last_etag = gs_utils_get_file_etag (target_file, &data->last_modified_date, cancellable);
+ g_debug ("Queried ETag of file %s: %s", g_file_peek_path (target_file), data->last_etag);
+
+ /* Create the output file */
+ g_file_replace_async (tmp_file,
+ NULL, /* ETag */
+ FALSE, /* make_backup */
+ G_FILE_CREATE_PRIVATE | G_FILE_CREATE_REPLACE_DESTINATION,
+ G_PRIORITY_LOW,
+ cancellable,
+ download_replace_file_cb,
+ g_steal_pointer (&task));
+}
+
+static void
+download_replace_file_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data)
+{
+ GFile *output_file = G_FILE (source_object);
+ g_autoptr(GTask) task = g_steal_pointer (&user_data);
+ GCancellable *cancellable = g_task_get_cancellable (task);
+ DownloadAppStreamData *data = g_task_get_task_data (task);
+ g_autoptr(GFileOutputStream) output_stream = NULL;
+ g_autoptr(GError) local_error = NULL;
+
+ output_stream = g_file_replace_finish (output_file, result, &local_error);
+
+ if (output_stream == NULL) {
+ g_task_return_error (task, g_steal_pointer (&local_error));
+ return;
+ }
+
+ /* Do the download. */
+ gs_download_stream_async (data->soup_session,
+ data->url,
+ G_OUTPUT_STREAM (output_stream),
+ data->last_etag,
+ data->last_modified_date,
+ G_PRIORITY_LOW,
+ refresh_url_progress_cb,
+ data->progress_tuple,
+ cancellable,
+ download_stream_cb,
+ g_steal_pointer (&task));
+}
+
+static void
+download_stream_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data)
+{
+ SoupSession *soup_session = SOUP_SESSION (source_object);
+ g_autoptr(GTask) task = g_steal_pointer (&user_data);
+ GCancellable *cancellable = g_task_get_cancellable (task);
+ DownloadAppStreamData *data = g_task_get_task_data (task);
+ g_autoptr(GError) local_error = NULL;
+ g_autofree gchar *new_etag = NULL;
+
+ if (!gs_download_stream_finish (soup_session, result, &new_etag, NULL, &local_error)) {
+ if (data->system_wide && g_error_matches (local_error, GS_DOWNLOAD_ERROR, GS_DOWNLOAD_ERROR_NOT_MODIFIED)) {
+ g_debug ("External AppStream file not modified, removing temporary download file %s",
+ g_file_peek_path (data->output_file));
+
+ /* System-wide installs should delete the empty file created when preparing to
+ * download the external AppStream file. */
+ g_file_delete_async (data->output_file, G_PRIORITY_LOW, NULL, NULL, NULL);
+ g_task_return_boolean (task, TRUE);
+ } else if (!g_network_monitor_get_network_available (g_network_monitor_get_default ())) {
+ g_task_return_new_error (task,
+ GS_EXTERNAL_APPSTREAM_ERROR,
+ GS_EXTERNAL_APPSTREAM_ERROR_NO_NETWORK,
+ "External AppStream could not be downloaded due to being offline");
+ } else {
+ g_task_return_new_error (task,
+ GS_EXTERNAL_APPSTREAM_ERROR,
+ GS_EXTERNAL_APPSTREAM_ERROR_DOWNLOADING,
+ "Server returned no data for external AppStream file: %s",
+ local_error->message);
+ }
+ return;
+ }
+
+ g_debug ("Downloaded appstream file %s", g_file_peek_path (data->output_file));
+
+ gs_utils_set_file_etag (data->output_file, new_etag, cancellable);
+
+ if (data->system_wide) {
+ /* install file systemwide */
+ if (!gs_external_appstream_install (g_file_peek_path (data->output_file),
+ cancellable,
+ &local_error)) {
+ g_task_return_new_error (task,
+ GS_EXTERNAL_APPSTREAM_ERROR,
+ GS_EXTERNAL_APPSTREAM_ERROR_INSTALLING_ON_SYSTEM,
+ "Error installing external AppStream file on system: %s", local_error->message);
+ return;
+ }
+ g_debug ("Installed appstream file %s", g_file_peek_path (data->output_file));
+ }
+
+ g_task_return_boolean (task, TRUE);
+}
+
+static gboolean
+refresh_url_finish (GAsyncResult *result,
+ GError **error)
+{
+ return g_task_propagate_boolean (G_TASK (result), error);
+}
+
+static void refresh_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data);
+static gboolean progress_cb (gpointer user_data);
+static void finish_refresh_op (GTask *task,
+ GError *error);
+
+typedef struct {
+ /* Input data. */
+ guint64 cache_age_secs;
+
+ /* In-progress data. */
+ guint n_pending_ops;
+ GError *error; /* (nullable) (owned) */
+ gsize n_appstream_urls;
+ GsDownloadProgressCallback progress_callback; /* (nullable) */
+ gpointer progress_user_data; /* (closure progress_callback) */
+ ProgressTuple *progress_tuples; /* (array length=n_appstream_urls) (owned) */
+ GSource *progress_source; /* (owned) */
+} RefreshData;
+
+static void
+refresh_data_free (RefreshData *data)
+{
+ g_assert (data->n_pending_ops == 0);
+
+ /* If this was set it should have been stolen for g_task_return_error()
+ * by now. */
+ g_assert (data->error == NULL);
+
+ /* Similarly, progress reporting should have been stopped by now. */
+ g_assert (g_source_is_destroyed (data->progress_source));
+ g_source_unref (data->progress_source);
+
+ g_free (data->progress_tuples);
+
+ g_free (data);
+}
+
+G_DEFINE_AUTOPTR_CLEANUP_FUNC (RefreshData, refresh_data_free)
+
+/**
+ * gs_external_appstream_refresh_async:
+ * @cache_age_secs: cache age, in seconds, as passed to #GsPluginClass.refresh_metadata_async()
+ * @progress_callback: (nullable): callback to call with progress information
+ * @progress_user_data: (nullable) (closure progress_callback): data to pass
+ * to @progress_callback
+ * @cancellable: (nullable): a #GCancellable, or %NULL
+ * @callback: function call when the asynchronous operation is complete
+ * @user_data: data to pass to @callback
+ *
+ * Refresh any configured external appstream files, if the cache is too old.
+ *
+ * Since: 42
+ */
+void
+gs_external_appstream_refresh_async (guint64 cache_age_secs,
+ GsDownloadProgressCallback progress_callback,
+ gpointer progress_user_data,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data)
+{
+ g_autoptr(GSettings) settings = NULL;
+ g_auto(GStrv) appstream_urls = NULL;
+ gsize n_appstream_urls;
+ g_autoptr(SoupSession) soup_session = NULL;
+ g_autoptr(GTask) task = NULL;
+ RefreshData *data;
+ g_autoptr(RefreshData) data_owned = NULL;
+
+ /* Chosen to allow a few UI updates per second without updating the
+ * progress label so often it’s unreadable. */
+ const guint progress_update_period_ms = 300;
+
+ task = g_task_new (NULL, cancellable, callback, user_data);
+ g_task_set_source_tag (task, gs_external_appstream_refresh_async);
+
+ settings = g_settings_new ("org.gnome.software");
+ soup_session = gs_build_soup_session ();
+ appstream_urls = g_settings_get_strv (settings,
+ "external-appstream-urls");
+ n_appstream_urls = g_strv_length (appstream_urls);
+
+ data = data_owned = g_new0 (RefreshData, 1);
+ data->progress_callback = progress_callback;
+ data->progress_user_data = progress_user_data;
+ data->n_appstream_urls = n_appstream_urls;
+ data->progress_tuples = g_new0 (ProgressTuple, n_appstream_urls);
+ data->progress_source = g_timeout_source_new (progress_update_period_ms);
+ g_task_set_task_data (task, g_steal_pointer (&data_owned), (GDestroyNotify) refresh_data_free);
+
+ /* Set up the progress timeout. This periodically sums up the progress
+ * tuples in `data->progress_tuples` and reports them to the calling
+ * function via @progress_callback, giving an overall progress for all
+ * the parallel operations. */
+ g_source_set_callback (data->progress_source, progress_cb, g_object_ref (task), g_object_unref);
+ g_source_attach (data->progress_source, g_main_context_get_thread_default ());
+
+ /* Refresh all the URIs in parallel. */
+ data->n_pending_ops = 1;
+
+ for (gsize i = 0; i < n_appstream_urls; i++) {
+ if (!g_str_has_prefix (appstream_urls[i], "https")) {
+ g_warning ("Not considering %s as an external "
+ "appstream source: please use an https URL",
+ appstream_urls[i]);
+ continue;
+ }
+
+ data->n_pending_ops++;
+ refresh_url_async (settings,
+ appstream_urls[i],
+ soup_session,
+ cache_age_secs,
+ &data->progress_tuples[i],
+ cancellable,
+ refresh_cb,
+ g_object_ref (task));
+ }
+
+ finish_refresh_op (task, NULL);
+}
+
+static void
+refresh_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data)
+{
+ g_autoptr(GTask) task = g_steal_pointer (&user_data);
+ g_autoptr(GError) local_error = NULL;
+
+ refresh_url_finish (result, &local_error);
+ finish_refresh_op (task, g_steal_pointer (&local_error));
+}
+
+static gboolean
+progress_cb (gpointer user_data)
+{
+ GTask *task = G_TASK (user_data);
+ RefreshData *data = g_task_get_task_data (task);
+ gsize parallel_bytes_downloaded = 0, parallel_total_download_size = 0;
+
+ /* Sum up the progress numerator and denominator for all parallel
+ * downloads. */
+ for (gsize i = 0; i < data->n_appstream_urls; i++) {
+ const ProgressTuple *progress_tuple = &data->progress_tuples[i];
+
+ if (!g_size_checked_add (&parallel_bytes_downloaded,
+ parallel_bytes_downloaded,
+ progress_tuple->bytes_downloaded))
+ parallel_bytes_downloaded = G_MAXSIZE;
+ if (!g_size_checked_add (&parallel_total_download_size,
+ parallel_total_download_size,
+ progress_tuple->total_download_size))
+ parallel_total_download_size = G_MAXSIZE;
+ }
+
+ /* Report progress to the calling function. */
+ if (data->progress_callback != NULL)
+ data->progress_callback (parallel_bytes_downloaded,
+ parallel_total_download_size,
+ data->progress_user_data);
+
+ return G_SOURCE_CONTINUE;
+}
+
+/* @error is (transfer full) if non-%NULL */
+static void
+finish_refresh_op (GTask *task,
+ GError *error)
+{
+ RefreshData *data = g_task_get_task_data (task);
+ g_autoptr(GError) error_owned = g_steal_pointer (&error);
+
+ if (data->error == NULL && error_owned != NULL)
+ data->error = g_steal_pointer (&error_owned);
+ else if (error_owned != NULL)
+ g_debug ("Additional error while refreshing external appstream: %s", error_owned->message);
+
+ g_assert (data->n_pending_ops > 0);
+ data->n_pending_ops--;
+
+ if (data->n_pending_ops > 0)
+ return;
+
+ /* Emit one final progress update, then stop any further ones. */
+ progress_cb (task);
+ g_source_destroy (data->progress_source);
+
+ /* All complete. */
+ if (data->error != NULL)
+ g_task_return_error (task, g_steal_pointer (&data->error));
+ else
+ g_task_return_boolean (task, TRUE);
+}
+
+/**
+ * gs_external_appstream_refresh_finish:
+ * @result: a #GAsyncResult
+ * @error: return location for a #GError, or %NULL
+ *
+ * Finish an asynchronous refresh operation started with
+ * gs_external_appstream_refresh_async().
+ *
+ * Returns: %TRUE on success, %FALSE otherwise
+ * Since: 42
+ */
+gboolean
+gs_external_appstream_refresh_finish (GAsyncResult *result,
+ GError **error)
+{
+ g_return_val_if_fail (g_task_is_valid (result, NULL), FALSE);
+ g_return_val_if_fail (error == NULL || *error == NULL, FALSE);
+
+ return g_task_propagate_boolean (G_TASK (result), error);
+}
diff --git a/lib/gs-external-appstream-utils.h b/lib/gs-external-appstream-utils.h
new file mode 100644
index 0000000..63e67f8
--- /dev/null
+++ b/lib/gs-external-appstream-utils.h
@@ -0,0 +1,50 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2018 Endless Mobile, Inc.
+ *
+ * Authors: Joaquim Rocha <jrocha@endlessm.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#include "config.h"
+
+#include <glib.h>
+#include <gnome-software.h>
+
+#define EXTERNAL_APPSTREAM_PREFIX "org.gnome.Software"
+
+/**
+ * GsExternalAppstreamError:
+ * @GS_EXTERNAL_APPSTREAM_ERROR_DOWNLOADING: Error while downloading external appstream data.
+ * @GS_EXTERNAL_APPSTREAM_ERROR_NO_NETWORK: Offline or network unavailable.
+ * @GS_EXTERNAL_APPSTREAM_ERROR_INSTALLING_ON_SYSTEM: Error while installing an external AppStream file system-wide.
+ *
+ * Error codes for external appstream operations.
+ *
+ * Since: 42
+ */
+typedef enum {
+ GS_EXTERNAL_APPSTREAM_ERROR_DOWNLOADING,
+ GS_EXTERNAL_APPSTREAM_ERROR_NO_NETWORK,
+ GS_EXTERNAL_APPSTREAM_ERROR_INSTALLING_ON_SYSTEM,
+} GsExternalAppstreamError;
+
+#define GS_EXTERNAL_APPSTREAM_ERROR gs_external_appstream_error_quark ()
+GQuark gs_external_appstream_error_quark (void);
+
+const gchar *gs_external_appstream_utils_get_system_dir (void);
+gchar *gs_external_appstream_utils_get_file_cache_path (const gchar *file_name);
+gchar *gs_external_appstream_utils_get_legacy_file_cache_path (const gchar *file_name);
+
+void gs_external_appstream_refresh_async (guint64 cache_age_secs,
+ GsDownloadProgressCallback progress_callback,
+ gpointer progress_user_data,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data);
+gboolean gs_external_appstream_refresh_finish (GAsyncResult *result,
+ GError **error);
diff --git a/lib/gs-fedora-third-party.c b/lib/gs-fedora-third-party.c
new file mode 100644
index 0000000..6afebc0
--- /dev/null
+++ b/lib/gs-fedora-third-party.c
@@ -0,0 +1,497 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2021 Red Hat <www.redhat.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#include <glib.h>
+#include <gio/gio.h>
+
+#include "gs-fedora-third-party.h"
+
+struct _GsFedoraThirdParty
+{
+ GObject parent_instance;
+ GMutex lock;
+ gchar *executable;
+ GHashTable *repos; /* gchar *name ~> gchar *packaging format */
+ gint64 last_update;
+};
+
+G_DEFINE_TYPE (GsFedoraThirdParty, gs_fedora_third_party, G_TYPE_OBJECT)
+
+static GObject *
+gs_fedora_third_party_constructor (GType type,
+ guint n_construct_properties,
+ GObjectConstructParam *construct_properties)
+{
+ static GWeakRef singleton;
+ GObject *result;
+
+ result = g_weak_ref_get (&singleton);
+ if (result == NULL) {
+ result = G_OBJECT_CLASS (gs_fedora_third_party_parent_class)->constructor (type, n_construct_properties, construct_properties);
+
+ if (result)
+ g_weak_ref_set (&singleton, result);
+ }
+
+ return result;
+}
+
+static void
+gs_fedora_third_party_finalize (GObject *object)
+{
+ GsFedoraThirdParty *self = GS_FEDORA_THIRD_PARTY (object);
+
+ g_clear_pointer (&self->executable, g_free);
+ g_clear_pointer (&self->repos, g_hash_table_unref);
+ g_mutex_clear (&self->lock);
+
+ /* Chain up to parent's method. */
+ G_OBJECT_CLASS (gs_fedora_third_party_parent_class)->finalize (object);
+}
+
+static void
+gs_fedora_third_party_class_init (GsFedoraThirdPartyClass *klass)
+{
+ GObjectClass *object_class = G_OBJECT_CLASS (klass);
+ object_class->constructor = gs_fedora_third_party_constructor;
+ object_class->finalize = gs_fedora_third_party_finalize;
+}
+
+static void
+gs_fedora_third_party_init (GsFedoraThirdParty *self)
+{
+ g_mutex_init (&self->lock);
+}
+
+GsFedoraThirdParty *
+gs_fedora_third_party_new (void)
+{
+ return g_object_new (GS_TYPE_FEDORA_THIRD_PARTY, NULL);
+}
+
+static gboolean
+gs_fedora_third_party_ensure_executable_locked (GsFedoraThirdParty *self,
+ GError **error)
+{
+ if (self->executable == NULL)
+ self->executable = g_find_program_in_path ("fedora-third-party");
+
+ if (self->executable == NULL) {
+ g_set_error (error, G_IO_ERROR, G_IO_ERROR_NOT_FOUND, "File 'fedora-third-party' not found");
+ return FALSE;
+ }
+
+ return TRUE;
+}
+
+gboolean
+gs_fedora_third_party_is_available (GsFedoraThirdParty *self)
+{
+ gboolean res;
+
+ g_return_val_if_fail (GS_IS_FEDORA_THIRD_PARTY (self), FALSE);
+
+ g_mutex_lock (&self->lock);
+ res = gs_fedora_third_party_ensure_executable_locked (self, NULL);
+ g_mutex_unlock (&self->lock);
+
+ return res;
+}
+
+void
+gs_fedora_third_party_invalidate (GsFedoraThirdParty *self)
+{
+ g_return_if_fail (GS_IS_FEDORA_THIRD_PARTY (self));
+
+ g_mutex_lock (&self->lock);
+ g_clear_pointer (&self->executable, g_free);
+ g_clear_pointer (&self->repos, g_hash_table_unref);
+ self->last_update = 0;
+ g_mutex_unlock (&self->lock);
+}
+
+typedef struct _AsyncData
+{
+ gboolean enable;
+ gboolean config_only;
+} AsyncData;
+
+static AsyncData *
+async_data_new (gboolean enable,
+ gboolean config_only)
+{
+ AsyncData *async_data = g_slice_new0 (AsyncData);
+ async_data->enable = enable;
+ async_data->config_only = config_only;
+ return async_data;
+}
+
+static void
+async_data_free (gpointer ptr)
+{
+ AsyncData *async_data = ptr;
+ if (async_data != NULL)
+ g_slice_free (AsyncData, async_data);
+}
+
+static void
+gs_fedora_third_party_query_thread (GTask *task,
+ gpointer source_object,
+ gpointer task_data,
+ GCancellable *cancellable)
+{
+ g_autoptr(GError) error = NULL;
+ GsFedoraThirdPartyState state;
+ if (gs_fedora_third_party_query_sync (GS_FEDORA_THIRD_PARTY (source_object), &state, cancellable, &error))
+ g_task_return_int (task, state);
+ else
+ g_task_return_error (task, g_steal_pointer (&error));
+}
+
+void
+gs_fedora_third_party_query (GsFedoraThirdParty *self,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data)
+{
+ g_autoptr(GTask) task = NULL;
+
+ g_return_if_fail (GS_IS_FEDORA_THIRD_PARTY (self));
+
+ task = g_task_new (self, cancellable, callback, user_data);
+ g_task_set_source_tag (task, gs_fedora_third_party_query);
+ g_task_run_in_thread (task, gs_fedora_third_party_query_thread);
+}
+
+gboolean
+gs_fedora_third_party_query_finish (GsFedoraThirdParty *self,
+ GAsyncResult *result,
+ GsFedoraThirdPartyState *out_state,
+ GError **error)
+{
+ GError *local_error = NULL;
+ GsFedoraThirdPartyState state = GS_FEDORA_THIRD_PARTY_STATE_UNKNOWN;
+
+ g_return_val_if_fail (GS_IS_FEDORA_THIRD_PARTY (self), FALSE);
+
+ state = g_task_propagate_int (G_TASK (result), &local_error);
+ if (local_error) {
+ g_propagate_error (error, local_error);
+ return FALSE;
+ }
+
+ if (out_state)
+ *out_state = state;
+
+ return TRUE;
+}
+
+gboolean
+gs_fedora_third_party_query_sync (GsFedoraThirdParty *self,
+ GsFedoraThirdPartyState *out_state,
+ GCancellable *cancellable,
+ GError **error)
+{
+ const gchar *args[] = {
+ "", /* executable */
+ "query",
+ "--quiet",
+ NULL
+ };
+ gboolean success = FALSE;
+
+ g_return_val_if_fail (GS_IS_FEDORA_THIRD_PARTY (self), FALSE);
+
+ g_mutex_lock (&self->lock);
+ if (gs_fedora_third_party_ensure_executable_locked (self, error)) {
+ gint wait_status = -1;
+ args[0] = self->executable;
+ success = g_spawn_sync (NULL, (gchar **) args, NULL, G_SPAWN_SEARCH_PATH, NULL, NULL, NULL, NULL, &wait_status, error);
+ if (success) {
+ GsFedoraThirdPartyState state = GS_FEDORA_THIRD_PARTY_STATE_UNKNOWN;
+ /* See https://pagure.io/fedora-third-party/blob/main/f/doc/fedora-third-party.1.md */
+ switch (WEXITSTATUS (wait_status)) {
+ case 0:
+ state = GS_FEDORA_THIRD_PARTY_STATE_ENABLED;
+ break;
+ case 1:
+ state = GS_FEDORA_THIRD_PARTY_STATE_DISABLED;
+ break;
+ case 2:
+ state = GS_FEDORA_THIRD_PARTY_STATE_ASK;
+ break;
+ default:
+ break;
+ }
+ if (out_state)
+ *out_state = state;
+ }
+ }
+ g_mutex_unlock (&self->lock);
+
+ return success;
+}
+
+static void
+gs_fedora_third_party_switch_thread (GTask *task,
+ gpointer source_object,
+ gpointer task_data,
+ GCancellable *cancellable)
+{
+ g_autoptr(GError) error = NULL;
+ AsyncData *async_data = task_data;
+ if (gs_fedora_third_party_switch_sync (GS_FEDORA_THIRD_PARTY (source_object), async_data->enable, async_data->config_only, cancellable, &error))
+ g_task_return_boolean (task, TRUE);
+ else
+ g_task_return_error (task, g_steal_pointer (&error));
+}
+
+void
+gs_fedora_third_party_switch (GsFedoraThirdParty *self,
+ gboolean enable,
+ gboolean config_only,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data)
+{
+ g_autoptr(GTask) task = NULL;
+
+ g_return_if_fail (GS_IS_FEDORA_THIRD_PARTY (self));
+
+ task = g_task_new (self, cancellable, callback, user_data);
+ g_task_set_source_tag (task, gs_fedora_third_party_switch);
+ g_task_set_task_data (task, async_data_new (enable, config_only), async_data_free);
+ g_task_run_in_thread (task, gs_fedora_third_party_switch_thread);
+}
+
+gboolean
+gs_fedora_third_party_switch_finish (GsFedoraThirdParty *self,
+ GAsyncResult *result,
+ GError **error)
+{
+ g_return_val_if_fail (GS_IS_FEDORA_THIRD_PARTY (self), FALSE);
+ return g_task_propagate_boolean (G_TASK (result), error);
+}
+
+gboolean
+gs_fedora_third_party_switch_sync (GsFedoraThirdParty *self,
+ gboolean enable,
+ gboolean config_only,
+ GCancellable *cancellable,
+ GError **error)
+{
+ const gchar *args[] = {
+ "pkexec",
+ "", /* executable */
+ "", /* command */
+ "", /* config-only */
+ NULL
+ };
+ gboolean success = FALSE;
+
+ g_return_val_if_fail (GS_IS_FEDORA_THIRD_PARTY (self), FALSE);
+
+ g_mutex_lock (&self->lock);
+ if (gs_fedora_third_party_ensure_executable_locked (self, error)) {
+ gint wait_status = -1;
+ args[1] = self->executable;
+ args[2] = enable ? "enable" : "disable";
+ args[3] = config_only ? "--config-only" : NULL;
+ success = g_spawn_sync (NULL, (gchar **) args, NULL, G_SPAWN_SEARCH_PATH, NULL, NULL, NULL, NULL, &wait_status, error) &&
+ g_spawn_check_wait_status (wait_status, error);
+ }
+ g_mutex_unlock (&self->lock);
+
+ return success;
+}
+
+static void
+gs_fedora_third_party_opt_out_thread (GTask *task,
+ gpointer source_object,
+ gpointer task_data,
+ GCancellable *cancellable)
+{
+ g_autoptr(GError) error = NULL;
+ if (gs_fedora_third_party_opt_out_sync (GS_FEDORA_THIRD_PARTY (source_object), cancellable, &error))
+ g_task_return_boolean (task, TRUE);
+ else
+ g_task_return_error (task, g_steal_pointer (&error));
+}
+
+void
+gs_fedora_third_party_opt_out (GsFedoraThirdParty *self,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data)
+{
+ g_autoptr(GTask) task = NULL;
+
+ g_return_if_fail (GS_IS_FEDORA_THIRD_PARTY (self));
+
+ task = g_task_new (self, cancellable, callback, user_data);
+ g_task_set_source_tag (task, gs_fedora_third_party_opt_out);
+ g_task_run_in_thread (task, gs_fedora_third_party_opt_out_thread);
+}
+
+gboolean
+gs_fedora_third_party_opt_out_finish (GsFedoraThirdParty *self,
+ GAsyncResult *result,
+ GError **error)
+{
+ g_return_val_if_fail (GS_IS_FEDORA_THIRD_PARTY (self), FALSE);
+ return g_task_propagate_boolean (G_TASK (result), error);
+}
+
+gboolean
+gs_fedora_third_party_opt_out_sync (GsFedoraThirdParty *self,
+ GCancellable *cancellable,
+ GError **error)
+{
+ /* fedora-third-party-opt-out is a single-purpose script that changes
+ * the third-party status from unset => disabled. It exists to allow
+ * a different pkexec configuration for opting-out and thus avoid
+ * admin users needing to authenticate to opt-out.
+ */
+ const gchar *args[] = {
+ "pkexec",
+ "/usr/lib/fedora-third-party/fedora-third-party-opt-out",
+ NULL
+ };
+ gboolean success = FALSE;
+
+ g_return_val_if_fail (GS_IS_FEDORA_THIRD_PARTY (self), FALSE);
+
+ g_mutex_lock (&self->lock);
+ if (gs_fedora_third_party_ensure_executable_locked (self, error)) {
+ gint wait_status = -1;
+ success = g_spawn_sync (NULL, (gchar **) args, NULL, G_SPAWN_SEARCH_PATH, NULL, NULL, NULL, NULL, &wait_status, error) &&
+ g_spawn_check_wait_status (wait_status, error);
+ }
+ g_mutex_unlock (&self->lock);
+
+ return success;
+}
+
+static void
+gs_fedora_third_party_list_thread (GTask *task,
+ gpointer source_object,
+ gpointer task_data,
+ GCancellable *cancellable)
+{
+ g_autoptr(GError) error = NULL;
+ g_autoptr(GHashTable) repos = NULL;
+ if (gs_fedora_third_party_list_sync (GS_FEDORA_THIRD_PARTY (source_object), &repos, cancellable, &error))
+ g_task_return_pointer (task, g_steal_pointer (&repos), (GDestroyNotify) g_hash_table_unref);
+ else
+ g_task_return_error (task, g_steal_pointer (&error));
+}
+
+void
+gs_fedora_third_party_list (GsFedoraThirdParty *self,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data)
+{
+ g_autoptr(GTask) task = NULL;
+
+ g_return_if_fail (GS_IS_FEDORA_THIRD_PARTY (self));
+
+ task = g_task_new (self, cancellable, callback, user_data);
+ g_task_set_source_tag (task, gs_fedora_third_party_list);
+ g_task_run_in_thread (task, gs_fedora_third_party_list_thread);
+}
+
+gboolean
+gs_fedora_third_party_list_finish (GsFedoraThirdParty *self,
+ GAsyncResult *result,
+ GHashTable **out_repos, /* gchar *name ~> gchar *management_plugin */
+ GError **error)
+{
+ g_autoptr(GHashTable) repos = NULL;
+ g_return_val_if_fail (GS_IS_FEDORA_THIRD_PARTY (self), FALSE);
+ repos = g_task_propagate_pointer (G_TASK (result), error);
+ if (repos == NULL)
+ return FALSE;
+ if (out_repos)
+ *out_repos = g_steal_pointer (&repos);
+ return TRUE;
+}
+
+gboolean
+gs_fedora_third_party_list_sync (GsFedoraThirdParty *self,
+ GHashTable **out_repos, /* gchar *name ~> gchar *management_plugin */
+ GCancellable *cancellable,
+ GError **error)
+{
+ const gchar *args[] = {
+ "", /* executable */
+ "list",
+ "--csv",
+ "--columns=type,name",
+ NULL
+ };
+ gboolean success = FALSE;
+
+ g_return_val_if_fail (GS_IS_FEDORA_THIRD_PARTY (self), FALSE);
+
+ g_mutex_lock (&self->lock);
+ /* Auto-recheck only twice a day */
+ if (self->repos == NULL || (g_get_real_time () / G_USEC_PER_SEC) - self->last_update > 12 * 60 * 60) {
+ g_clear_pointer (&self->repos, g_hash_table_unref);
+ if (gs_fedora_third_party_ensure_executable_locked (self, error)) {
+ gint wait_status = -1;
+ g_autofree gchar *stdoutput = NULL;
+ args[0] = self->executable;
+ if (g_spawn_sync (NULL, (gchar **) args, NULL, G_SPAWN_DEFAULT, NULL, NULL, &stdoutput, NULL, &wait_status, error) &&
+ g_spawn_check_wait_status (wait_status, error)) {
+ GHashTable *repos = g_hash_table_new_full (g_str_hash, g_str_equal, g_free, g_free);
+ g_auto(GStrv) lines = NULL;
+
+ lines = g_strsplit (stdoutput != NULL ? stdoutput : "", "\n", -1);
+
+ for (gsize ii = 0; lines != NULL && lines[ii]; ii++) {
+ g_auto(GStrv) tokens = g_strsplit (lines[ii], ",", 2);
+ if (tokens != NULL && tokens[0] != NULL && tokens[1] != NULL) {
+ const gchar *repo_type = tokens[0];
+ /* The 'dnf' means 'packagekit' here */
+ if (g_str_equal (repo_type, "dnf"))
+ repo_type = "packagekit";
+ /* Hash them by name, which cannot clash between types */
+ g_hash_table_insert (repos, g_strdup (tokens[1]), g_strdup (repo_type));
+ }
+ }
+
+ self->repos = repos;
+ }
+ }
+ self->last_update = g_get_real_time () / G_USEC_PER_SEC;
+ }
+ success = self->repos != NULL;
+ if (success && out_repos)
+ *out_repos = g_hash_table_ref (self->repos);
+ g_mutex_unlock (&self->lock);
+
+ return success;
+}
+
+gboolean
+gs_fedora_third_party_util_is_third_party_repo (GHashTable *third_party_repos,
+ const gchar *origin,
+ const gchar *management_plugin)
+{
+ const gchar *expected_management_plugin;
+
+ if (third_party_repos == NULL || origin == NULL)
+ return FALSE;
+
+ expected_management_plugin = g_hash_table_lookup (third_party_repos, origin);
+ if (expected_management_plugin == NULL)
+ return FALSE;
+
+ return g_strcmp0 (management_plugin, expected_management_plugin) == 0;
+}
diff --git a/lib/gs-fedora-third-party.h b/lib/gs-fedora-third-party.h
new file mode 100644
index 0000000..11a0144
--- /dev/null
+++ b/lib/gs-fedora-third-party.h
@@ -0,0 +1,93 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2021 Red Hat <www.redhat.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#include <glib.h>
+#include <gio/gio.h>
+
+G_BEGIN_DECLS
+
+#define GS_TYPE_FEDORA_THIRD_PARTY (gs_fedora_third_party_get_type ())
+
+G_DECLARE_FINAL_TYPE (GsFedoraThirdParty, gs_fedora_third_party, GS, FEDORA_THIRD_PARTY, GObject)
+
+typedef enum _GsFedoraThirdPartyState {
+ GS_FEDORA_THIRD_PARTY_STATE_UNKNOWN,
+ GS_FEDORA_THIRD_PARTY_STATE_ENABLED,
+ GS_FEDORA_THIRD_PARTY_STATE_DISABLED,
+ GS_FEDORA_THIRD_PARTY_STATE_ASK
+} GsFedoraThirdPartyState;
+
+GsFedoraThirdParty *
+ gs_fedora_third_party_new (void);
+gboolean gs_fedora_third_party_is_available
+ (GsFedoraThirdParty *self);
+void gs_fedora_third_party_invalidate(GsFedoraThirdParty *self);
+void gs_fedora_third_party_query (GsFedoraThirdParty *self,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data);
+gboolean gs_fedora_third_party_query_finish
+ (GsFedoraThirdParty *self,
+ GAsyncResult *result,
+ GsFedoraThirdPartyState *out_state,
+ GError **error);
+gboolean gs_fedora_third_party_query_sync(GsFedoraThirdParty *self,
+ GsFedoraThirdPartyState *out_state,
+ GCancellable *cancellable,
+ GError **error);
+void gs_fedora_third_party_switch (GsFedoraThirdParty *self,
+ gboolean enable,
+ gboolean config_only,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data);
+gboolean gs_fedora_third_party_switch_finish
+ (GsFedoraThirdParty *self,
+ GAsyncResult *result,
+ GError **error);
+gboolean gs_fedora_third_party_switch_sync
+ (GsFedoraThirdParty *self,
+ gboolean enable,
+ gboolean config_only,
+ GCancellable *cancellable,
+ GError **error);
+void gs_fedora_third_party_opt_out (GsFedoraThirdParty *self,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data);
+gboolean gs_fedora_third_party_opt_out_finish
+ (GsFedoraThirdParty *self,
+ GAsyncResult *result,
+ GError **error);
+gboolean gs_fedora_third_party_opt_out_sync
+ (GsFedoraThirdParty *self,
+ GCancellable *cancellable,
+ GError **error);
+void gs_fedora_third_party_list (GsFedoraThirdParty *self,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data);
+gboolean gs_fedora_third_party_list_finish
+ (GsFedoraThirdParty *self,
+ GAsyncResult *result,
+ GHashTable **out_repos,
+ GError **error);
+gboolean gs_fedora_third_party_list_sync (GsFedoraThirdParty *self,
+ GHashTable **out_repos,
+ GCancellable *cancellable,
+ GError **error);
+
+/* Utility functions */
+gboolean gs_fedora_third_party_util_is_third_party_repo
+ (GHashTable *third_party_repos,
+ const gchar *origin,
+ const gchar *management_plugin);
+
+G_END_DECLS
diff --git a/lib/gs-icon.c b/lib/gs-icon.c
new file mode 100644
index 0000000..955ab02
--- /dev/null
+++ b/lib/gs-icon.c
@@ -0,0 +1,315 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2021 Endless OS Foundation, Inc
+ *
+ * Author: Philip Withnall <pwithnall@endlessos.org>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+/**
+ * SECTION:gs-icon
+ * @short_description: Utilities for handling #GIcons
+ *
+ * This file provides several utilities for creating and handling #GIcon
+ * instances. #GIcon is used for representing icon sources throughout
+ * gnome-software, as it has low memory overheads, and allows the most
+ * appropriate icon data to be loaded when it’s needed to be used in a UI.
+ *
+ * gnome-software uses various classes which implement #GIcon, mostly the
+ * built-in ones provided by GIO, but also #GsRemoteIcon. All of them are tagged
+ * with `width` and `height` metadata (when that data was available at
+ * construction time). See gs_icon_get_width().
+ *
+ * Since: 40
+ */
+
+#include "config.h"
+
+#include <appstream.h>
+#include <gdk-pixbuf/gdk-pixbuf.h>
+#include <gio/gio.h>
+#include <glib.h>
+#include <glib-object.h>
+#include <gtk/gtk.h>
+
+#include "gs-icon.h"
+#include "gs-remote-icon.h"
+
+/**
+ * gs_icon_get_width:
+ * @icon: a #GIcon
+ *
+ * Get the width of an icon, if it was attached as metadata when the #GIcon was
+ * created from an #AsIcon.
+ *
+ * Returns: width of the icon (in device pixels), or `0` if unknown
+ * Since: 40
+ */
+guint
+gs_icon_get_width (GIcon *icon)
+{
+ g_return_val_if_fail (G_IS_ICON (icon), 0);
+
+ return GPOINTER_TO_UINT (g_object_get_data (G_OBJECT (icon), "width"));
+}
+
+/**
+ * gs_icon_set_width:
+ * @icon: a #GIcon
+ * @width: width of the icon, in device pixels
+ *
+ * Set the width of an icon. See gs_icon_get_width().
+ *
+ * Since: 40
+ */
+void
+gs_icon_set_width (GIcon *icon,
+ guint width)
+{
+ g_return_if_fail (G_IS_ICON (icon));
+
+ g_object_set_data (G_OBJECT (icon), "width", GUINT_TO_POINTER (width));
+}
+
+/**
+ * gs_icon_get_height:
+ * @icon: a #GIcon
+ *
+ * Get the height of an icon, if it was attached as metadata when the #GIcon was
+ * created from an #AsIcon.
+ *
+ * Returns: height of the icon (in device pixels), or `0` if unknown
+ * Since: 40
+ */
+guint
+gs_icon_get_height (GIcon *icon)
+{
+ g_return_val_if_fail (G_IS_ICON (icon), 0);
+
+ return GPOINTER_TO_UINT (g_object_get_data (G_OBJECT (icon), "height"));
+}
+
+/**
+ * gs_icon_set_height:
+ * @icon: a #GIcon
+ * @height: height of the icon, in device pixels
+ *
+ * Set the height of an icon. See gs_icon_get_height().
+ *
+ * Since: 40
+ */
+void
+gs_icon_set_height (GIcon *icon,
+ guint height)
+{
+ g_return_if_fail (G_IS_ICON (icon));
+
+ g_object_set_data (G_OBJECT (icon), "height", GUINT_TO_POINTER (height));
+}
+
+/**
+ * gs_icon_get_scale:
+ * @icon: a #GIcon
+ *
+ * Get the scale of an icon, if it was attached as metadata when the #GIcon was
+ * created from an #AsIcon.
+ *
+ * See gtk_widget_get_scale_factor() for more information about scales.
+ *
+ * Returns: scale of the icon, or `1` if unknown; guaranteed to always be
+ * greater than or equal to 1
+ * Since: 40
+ */
+guint
+gs_icon_get_scale (GIcon *icon)
+{
+ g_return_val_if_fail (G_IS_ICON (icon), 0);
+
+ return MAX (1, GPOINTER_TO_UINT (g_object_get_data (G_OBJECT (icon), "scale")));
+}
+
+/**
+ * gs_icon_set_scale:
+ * @icon: a #GIcon
+ * @scale: scale of the icon, which must be greater than or equal to 1
+ *
+ * Set the scale of an icon. See gs_icon_get_scale().
+ *
+ * Since: 40
+ */
+void
+gs_icon_set_scale (GIcon *icon,
+ guint scale)
+{
+ g_return_if_fail (G_IS_ICON (icon));
+ g_return_if_fail (scale >= 1);
+
+ g_object_set_data (G_OBJECT (icon), "scale", GUINT_TO_POINTER (scale));
+}
+
+static GIcon *
+gs_icon_load_local (AsIcon *icon)
+{
+ const gchar *filename = as_icon_get_filename (icon);
+ g_autoptr(GFile) file = NULL;
+
+ if (filename == NULL)
+ return NULL;
+
+ file = g_file_new_for_path (filename);
+ return g_file_icon_new (file);
+}
+
+static GIcon *
+gs_icon_load_stock (AsIcon *icon)
+{
+ const gchar *name = as_icon_get_name (icon);
+
+ if (name == NULL)
+ return NULL;
+
+ return g_themed_icon_new (name);
+}
+
+static GIcon *
+gs_icon_load_remote (AsIcon *icon)
+{
+ const gchar *url = as_icon_get_url (icon);
+
+ if (url == NULL)
+ return NULL;
+
+ /* Load local files directly. */
+ if (g_str_has_prefix (url, "file:")) {
+ g_autoptr(GFile) file = g_file_new_for_path (url + strlen ("file:"));
+ return g_file_icon_new (file);
+ }
+
+ /* Only HTTP and HTTPS are supported. */
+ if (!g_str_has_prefix (url, "http:") &&
+ !g_str_has_prefix (url, "https:"))
+ return NULL;
+
+ return gs_remote_icon_new (url);
+}
+
+static GIcon *
+gs_icon_load_cached (AsIcon *icon)
+{
+ const gchar *filename = as_icon_get_filename (icon);
+ const gchar *name = as_icon_get_name (icon);
+ g_autofree gchar *name_allocated = NULL;
+ g_autofree gchar *full_filename = NULL;
+ g_autoptr(GFile) file = NULL;
+
+ if (filename == NULL || name == NULL)
+ return NULL;
+
+ /* FIXME: Work around https://github.com/hughsie/appstream-glib/pull/390
+ * where appstream files generated with appstream-builder from
+ * appstream-glib, with its hidpi option enabled, will contain an
+ * unnecessary size subdirectory in the icon name. */
+ if (g_str_has_prefix (name, "64x64/"))
+ name = name_allocated = g_strdup (name + strlen ("64x64/"));
+ else if (g_str_has_prefix (name, "128x128/"))
+ name = name_allocated = g_strdup (name + strlen ("128x128/"));
+
+ if (!g_str_has_suffix (filename, name)) {
+ /* Spec: https://www.freedesktop.org/software/appstream/docs/sect-AppStream-IconCache.html#spec-iconcache-location */
+ if (as_icon_get_scale (icon) <= 1) {
+ full_filename = g_strdup_printf ("%s/%ux%u/%s",
+ filename,
+ as_icon_get_width (icon),
+ as_icon_get_height (icon),
+ name);
+ } else {
+ full_filename = g_strdup_printf ("%s/%ux%u@%u/%s",
+ filename,
+ as_icon_get_width (icon),
+ as_icon_get_height (icon),
+ as_icon_get_scale (icon),
+ name);
+ }
+
+ filename = full_filename;
+ }
+
+ file = g_file_new_for_path (filename);
+ return g_file_icon_new (file);
+}
+
+/**
+ * gs_icon_new_for_appstream_icon:
+ * @appstream_icon: an #AsIcon
+ *
+ * Create a new #GIcon representing the given #AsIcon. The actual type of the
+ * returned icon will vary depending on the #AsIconKind of @appstream_icon.
+ *
+ * If the width or height of the icon are set on the #AsIcon, they are stored
+ * as the `width` and `height` data associated with the returned object, using
+ * g_object_set_data().
+ *
+ * This can fail (and return %NULL) if the @appstream_icon has invalid or
+ * missing properties.
+ *
+ * Returns: (transfer full) (nullable): the #GIcon, or %NULL
+ * Since: 40
+ */
+GIcon *
+gs_icon_new_for_appstream_icon (AsIcon *appstream_icon)
+{
+ g_autoptr(GIcon) icon = NULL;
+
+ g_return_val_if_fail (AS_IS_ICON (appstream_icon), NULL);
+
+ switch (as_icon_get_kind (appstream_icon)) {
+ case AS_ICON_KIND_LOCAL:
+ icon = gs_icon_load_local (appstream_icon);
+ break;
+ case AS_ICON_KIND_STOCK:
+ icon = gs_icon_load_stock (appstream_icon);
+ break;
+ case AS_ICON_KIND_REMOTE:
+ icon = gs_icon_load_remote (appstream_icon);
+ break;
+ case AS_ICON_KIND_CACHED:
+ icon = gs_icon_load_cached (appstream_icon);
+ break;
+ default:
+ g_assert_not_reached ();
+ }
+
+ if (icon == NULL) {
+ g_debug ("Error creating GIcon for AsIcon of kind %s",
+ as_icon_kind_to_string (as_icon_get_kind (appstream_icon)));
+ return NULL;
+ }
+
+ /* Store the width, height and scale as associated metadata (if
+ * available) so that #GsApp can sort icons by size and return the most
+ * appropriately sized one in gs_app_get_icon_by_size().
+ *
+ * FIXME: Ideally we’d store these as properties on the objects, but
+ * GIO currently doesn’t allow subclassing of its #GIcon classes. If we
+ * were to implement a #GLoadableIcon with these as properties, all the
+ * fast paths in GTK for loading icon data (particularly named icons)
+ * would be ignored.
+ *
+ * Storing the width and height as associated metadata means GObject
+ * creates a hash table for each GIcon object. This is a waste of memory
+ * (compared to using properties), but seems like the least-worst
+ * option.
+ *
+ * See https://gitlab.gnome.org/GNOME/glib/-/issues/2345
+ */
+ if (as_icon_get_width (appstream_icon) != 0 || as_icon_get_height (appstream_icon) != 0) {
+ gs_icon_set_width (icon, as_icon_get_width (appstream_icon));
+ gs_icon_set_height (icon, as_icon_get_height (appstream_icon));
+ }
+ if (as_icon_get_scale (appstream_icon) != 0)
+ gs_icon_set_scale (icon, as_icon_get_scale (appstream_icon));
+
+ return g_steal_pointer (&icon);
+}
diff --git a/lib/gs-icon.h b/lib/gs-icon.h
new file mode 100644
index 0000000..b1b7a89
--- /dev/null
+++ b/lib/gs-icon.h
@@ -0,0 +1,32 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2021 Endless OS Foundation, Inc
+ *
+ * Author: Philip Withnall <pwithnall@endlessos.org>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#include <appstream.h>
+#include <gio/gio.h>
+#include <glib.h>
+#include <glib-object.h>
+
+G_BEGIN_DECLS
+
+guint gs_icon_get_width (GIcon *icon);
+void gs_icon_set_width (GIcon *icon,
+ guint width);
+guint gs_icon_get_height (GIcon *icon);
+void gs_icon_set_height (GIcon *icon,
+ guint height);
+guint gs_icon_get_scale (GIcon *icon);
+void gs_icon_set_scale (GIcon *icon,
+ guint scale);
+
+GIcon *gs_icon_new_for_appstream_icon (AsIcon *appstream_icon);
+
+G_END_DECLS
diff --git a/lib/gs-ioprio.c b/lib/gs-ioprio.c
new file mode 100644
index 0000000..3a63d07
--- /dev/null
+++ b/lib/gs-ioprio.c
@@ -0,0 +1,197 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2005, Novell, Inc.
+ * Copyright (C) 2006, Jamie McCracken <jamiemcc@gnome.org>
+ * Copyright (C) 2006, Anders Aagaard
+ *
+ * Based mostly on code by Robert Love <rml@novell.com>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a
+ * copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+ * DEALINGS IN THE SOFTWARE.
+ */
+
+#include "config.h"
+
+#ifdef __linux__
+
+#include <stdio.h>
+#include <errno.h>
+
+#ifdef HAVE_LINUX_UNISTD_H
+#include <linux/unistd.h>
+#endif
+
+#include <sys/syscall.h>
+#include <unistd.h>
+
+#include <glib/gstdio.h>
+
+#endif /* __linux__ */
+
+#include "gs-ioprio.h"
+
+/* We assume ALL linux architectures have the syscalls defined here */
+#ifdef __linux__
+
+/* Make sure the system call is supported */
+#ifndef __NR_ioprio_set
+
+#if defined(__i386__)
+#define __NR_ioprio_set 289
+#define __NR_ioprio_get 290
+#elif defined(__powerpc__) || defined(__powerpc64__)
+#define __NR_ioprio_set 273
+#define __NR_ioprio_get 274
+#elif defined(__x86_64__)
+#define __NR_ioprio_set 251
+#define __NR_ioprio_get 252
+#elif defined(__ia64__)
+#define __NR_ioprio_set 1274
+#define __NR_ioprio_get 1275
+#elif defined(__alpha__)
+#define __NR_ioprio_set 442
+#define __NR_ioprio_get 443
+#elif defined(__s390x__) || defined(__s390__)
+#define __NR_ioprio_set 282
+#define __NR_ioprio_get 283
+#elif defined(__SH4__)
+#define __NR_ioprio_set 288
+#define __NR_ioprio_get 289
+#elif defined(__SH5__)
+#define __NR_ioprio_set 316
+#define __NR_ioprio_get 317
+#elif defined(__sparc__) || defined(__sparc64__)
+#define __NR_ioprio_set 196
+#define __NR_ioprio_get 218
+#elif defined(__arm__)
+#define __NR_ioprio_set 314
+#define __NR_ioprio_get 315
+#else
+#error "Unsupported architecture!"
+#endif
+
+#endif /* __NR_ioprio_set */
+
+enum {
+ IOPRIO_CLASS_NONE,
+ IOPRIO_CLASS_RT,
+ IOPRIO_CLASS_BE,
+ IOPRIO_CLASS_IDLE,
+};
+
+enum {
+ IOPRIO_WHO_PROCESS = 1,
+ IOPRIO_WHO_PGRP,
+ IOPRIO_WHO_USER,
+};
+
+#define IOPRIO_CLASS_SHIFT 13
+
+static inline int
+ioprio_set (int which, int who, int ioprio_val)
+{
+ return syscall (__NR_ioprio_set, which, who, ioprio_val);
+}
+
+static int
+set_io_priority (int ioprio,
+ int ioclass)
+{
+ return ioprio_set (IOPRIO_WHO_PROCESS, 0, ioprio | (ioclass << IOPRIO_CLASS_SHIFT));
+}
+
+static const gchar *
+ioclass_to_string (int ioclass)
+{
+ switch (ioclass) {
+ case IOPRIO_CLASS_IDLE:
+ return "IDLE";
+ case IOPRIO_CLASS_BE:
+ return "BE";
+ default:
+ return "unknown";
+ }
+}
+
+/**
+ * gs_ioprio_set:
+ * @priority: I/O priority, with higher numeric values indicating lower priority;
+ * use %G_PRIORITY_DEFAULT as the default
+ *
+ * Set the I/O priority of the current thread using the `ioprio_set()` syscall.
+ *
+ * The @priority is quantised before being passed to the kernel.
+ *
+ * This function may fail if the process doesn’t have permission to change its
+ * I/O priority to the given value. If so, a warning will be printed, as the
+ * quantised priority values are chosen so they shouldn’t typically require
+ * permissions to set.
+ */
+void
+gs_ioprio_set (gint priority)
+{
+ int ioprio, ioclass;
+
+ /* If the priority is lower than default, use an idle I/O priority. The
+ * condition looks wrong because higher integers indicate lower priority
+ * in GLib.
+ *
+ * Otherwise use a default best-effort priority, which is the same as
+ * what all new threads get (in the absence of an I/O context with
+ * `CLONE_IO`). */
+ if (priority > G_PRIORITY_DEFAULT) {
+ ioprio = 7;
+ ioclass = IOPRIO_CLASS_IDLE;
+ } else if (priority == G_PRIORITY_DEFAULT) {
+ ioprio = 4; /* this is the default priority in the BE class */
+ ioclass = IOPRIO_CLASS_BE;
+ } else {
+ ioprio = 0; /* this is the highest priority in the BE class */
+ ioclass = IOPRIO_CLASS_BE;
+ }
+
+ g_debug ("Setting I/O priority of thread %p to %s, %d",
+ g_thread_self (), ioclass_to_string (ioclass), ioprio);
+
+ if (set_io_priority (ioprio, ioclass) == -1) {
+ g_warning ("Could not set I/O priority to %s, %d",
+ ioclass_to_string (ioclass), ioprio);
+
+ /* If we were trying to set to idle priority, try again with the
+ * lowest-possible best-effort priority. This is because kernels
+ * older than 2.6.25 required `CAP_SYS_ADMIN` to set
+ * `IOPRIO_CLASS_IDLE`. Newer kernels do not. */
+ if (ioclass == IOPRIO_CLASS_IDLE) {
+ ioprio = 7; /* this is the lowest priority in the BE class */
+ ioclass = IOPRIO_CLASS_BE;
+
+ if (set_io_priority (ioprio, ioclass) == -1)
+ g_warning ("Could not set best effort IO priority either, giving up");
+ }
+ }
+}
+
+#else /* __linux__ */
+
+void
+gs_ioprio_set (gint priority)
+{
+}
+
+#endif /* __linux__ */
diff --git a/lib/gs-ioprio.h b/lib/gs-ioprio.h
new file mode 100644
index 0000000..e630b8a
--- /dev/null
+++ b/lib/gs-ioprio.h
@@ -0,0 +1,31 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2006, Anders Aagaard
+ * Copyright (C) 2008, Nokia <ivan.frade@nokia.com>
+ *
+ * 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.1 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, write to the
+ * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
+ * Boston, MA 02110-1301, USA.
+ */
+
+#pragma once
+
+#include <glib.h>
+
+G_BEGIN_DECLS
+
+void gs_ioprio_set (gint priority);
+
+G_END_DECLS
diff --git a/lib/gs-key-colors.c b/lib/gs-key-colors.c
new file mode 100644
index 0000000..fb0850c
--- /dev/null
+++ b/lib/gs-key-colors.c
@@ -0,0 +1,334 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2013-2016 Richard Hughes <richard@hughsie.com>
+ * Copyright (C) 2013 Matthias Clasen <mclasen@redhat.com>
+ * Copyright (C) 2014-2018 Kalev Lember <klember@redhat.com>
+ * Copyright (C) 2021 Endless OS Foundation LLC
+ *
+ * Authors:
+ * - Richard Hughes <richard@hughsie.com>
+ * - Kalev Lember <klember@redhat.com>
+ * - Philip Withnall <pwithnall@endlessos.org>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+/**
+ * SECTION:gs-key-colors
+ * @short_description: Helper functions for calculating key colors
+ *
+ * Key colors are RGB colors which represent an app, and they are derived from
+ * the app’s icon, or manually specified as an override.
+ *
+ * Use gs_calculate_key_colors() to calculate the key colors from an app’s icon.
+ *
+ * Since: 40
+ */
+
+#include "config.h"
+
+#include <glib.h>
+#include <gdk/gdk.h>
+#include <gdk-pixbuf/gdk-pixbuf.h>
+
+#include "gs-key-colors.h"
+
+/* Hard-code the number of clusters to split the icon color space into. This
+ * gives the maximum number of key colors returned for an icon. This number has
+ * been chosen by examining 1000 icons to subjectively see how many key colors
+ * each has. The number of key colors ranged from 1 to 6, but the mode was
+ * definitely 3. */
+const guint n_clusters = 3;
+
+/* Discard pixels with less than this level of alpha. Almost all icons have a
+ * transparent background/border at 100% transparency, and a blending fringe
+ * with some intermediate level of transparency which should be ignored for
+ * choosing key colors. A number of icons have partially-transparent colored
+ * sections in the main body of the icon, which should be used if they’re above
+ * this threshold. About 1% of icons have no completely opaque pixels, so we
+ * can’t discard non-opaque pixels entirely. */
+const guint minimum_alpha = 0.5 * 255;
+
+typedef struct {
+ guint8 red;
+ guint8 green;
+ guint8 blue;
+} Pixel8;
+
+typedef struct {
+ Pixel8 color;
+ union {
+ guint8 alpha;
+ guint8 cluster;
+ };
+} ClusterPixel8;
+
+typedef struct {
+ guint red;
+ guint green;
+ guint blue;
+ guint n_members;
+} CentroidAccumulator;
+
+static inline ClusterPixel8 *
+get_pixel (guint8 *pixels,
+ guint x,
+ guint y,
+ guint rowstride,
+ guint n_channels)
+{
+ return (ClusterPixel8 *) (pixels + y * rowstride + x * n_channels);
+}
+
+static inline guint
+color_distance (const Pixel8 *a,
+ const Pixel8 *b)
+{
+ /* Take the absolute value rather than the square root to save some
+ * time, as the caller is comparing distances.
+ *
+ * The arithmetic here can’t overflow, as the R/G/B components have a
+ * maximum value of 255 but the arithmetic is done in (at least) 32-bit
+ * variables.*/
+ gint dr = b->red - a->red;
+ gint dg = b->green - a->green;
+ gint db = b->blue - a->blue;
+
+ return abs (dr * dr + dg * dg + db * db);
+}
+
+/* NOTE: This has to return stable results when more than one cluster is
+ * equidistant from the @pixel, or the k_means() function may not terminate. */
+static inline gsize
+nearest_cluster (const Pixel8 *pixel,
+ const Pixel8 *cluster_centres,
+ gsize n_cluster_centres)
+{
+ gsize nearest_cluster = 0;
+ guint nearest_cluster_distance = color_distance (&cluster_centres[0], pixel);
+
+ for (gsize i = 1; i < n_cluster_centres; i++) {
+ guint distance = color_distance (&cluster_centres[i], pixel);
+ if (distance < nearest_cluster_distance) {
+ nearest_cluster = i;
+ nearest_cluster_distance = distance;
+ }
+ }
+
+ return nearest_cluster;
+}
+
+/* A variant of g_random_int_range() which chooses without replacement,
+ * tracking the used integers in @used_ints and @n_used_ints.
+ * Once all integers in 0..max_ints have been used once, it will choose
+ * with replacement. */
+static gint32
+random_int_range_no_replacement (guint max_ints,
+ gboolean *used_ints,
+ guint *n_used_ints)
+{
+ gint32 random_value = g_random_int_range (0, (gint32) max_ints);
+
+ if (*n_used_ints < max_ints) {
+ while (used_ints[random_value])
+ random_value = (random_value + 1) % max_ints;
+
+ used_ints[random_value] = TRUE;
+ *n_used_ints = *n_used_ints + 1;
+ }
+
+ return random_value;
+}
+
+/* Extract the key colors from @pb by clustering the pixels in RGB space.
+ * Clustering is done using k-means, with initialisation using a
+ * Random Partition.
+ *
+ * This approach can be thought of as plotting every pixel in @pb in a
+ * three-dimensional color space, with red, green and blue axes (alpha is
+ * clipped to 0 (pixel is ignored) or 1 (pixel is used)). The key colors for
+ * the image are the ones where a large number of pixels are plotted in a group
+ * in the color space — either a lot of pixels with an identical color
+ * (repeated use of exactly the same color in the image) or a lot of pixels in
+ * a rough group (use of a lot of similar shades of the same color in the
+ * image).
+ *
+ * By transforming to a color space, information about the X and Y positions of
+ * each color is ignored, so a thin outline in the image of a single color
+ * will appear in the color space as a cluster, just as a contiguous block of
+ * one color would.
+ *
+ * The k-means clustering algorithm is then used to find these clusters. k-means
+ * is used, rather than (say) principal component analysis, because it
+ * inherently calculates the centroid for each cluster. In a color space, the
+ * centroid is itself a color, which can then be used as the key color to
+ * return.
+ *
+ * The number of clusters is limited to @n_clusters, as a subjective survey of
+ * 1000 icons found that they commonly used this number of key colors.
+ *
+ * Various other shortcuts have been taken which make this approach quite
+ * specific to key color extraction from icons, with the aim of making it
+ * faster. That’s fine — it doesn’t matter if the results this function produces
+ * are optimal, only that they’re good enough. */
+static void
+k_means (GArray *colors,
+ GdkPixbuf *pb)
+{
+ gint rowstride, n_channels;
+ gint width, height;
+ guint8 *raw_pixels;
+ ClusterPixel8 *pixels;
+ const ClusterPixel8 *pixels_end;
+ Pixel8 cluster_centres[n_clusters];
+ CentroidAccumulator cluster_accumulators[n_clusters];
+ gboolean used_clusters[n_clusters];
+ guint n_used_clusters = 0;
+ guint n_assignments_changed;
+ guint n_iterations = 0;
+ guint assignments_termination_limit;
+
+ n_channels = gdk_pixbuf_get_n_channels (pb);
+ rowstride = gdk_pixbuf_get_rowstride (pb);
+ raw_pixels = gdk_pixbuf_get_pixels (pb);
+ width = gdk_pixbuf_get_width (pb);
+ height = gdk_pixbuf_get_height (pb);
+
+ /* The pointer arithmetic over pixels can be simplified if we can assume
+ * there are no gaps in the @raw_pixels data. Since the caller is
+ * downsizing the #GdkPixbuf, this is a reasonable assumption. */
+ g_assert (rowstride == width * n_channels);
+ g_assert (n_channels == 4);
+
+ pixels = (ClusterPixel8 *) raw_pixels;
+ pixels_end = &pixels[height * width];
+
+ memset (cluster_centres, 0, sizeof (cluster_centres));
+ memset (used_clusters, 0, sizeof (used_clusters));
+
+ /* Initialise the clusters using the Random Partition method: randomly
+ * assign a starting cluster to each pixel.
+ *
+ * The Forgy method (choosing random pixels as the starting cluster
+ * centroids) is not appropriate as the checks required to make sure
+ * they aren’t transparent or duplicated colors mean that the
+ * initialisation step may never complete. Consider the case of an icon
+ * which is a block of solid color. */
+ for (ClusterPixel8 *p = pixels; p < pixels_end; p++) {
+ if (p->alpha < minimum_alpha)
+ p->cluster = G_N_ELEMENTS (cluster_centres);
+ else
+ p->cluster = random_int_range_no_replacement (G_N_ELEMENTS (cluster_centres), used_clusters, &n_used_clusters);
+ }
+
+ /* Iterate until every cluster is relatively settled. This is determined
+ * by the number of pixels whose assignment to a cluster changes in
+ * each iteration — if the number of pixels is less than 1% of the image
+ * then subsequent iterations are not going to significantly affect the
+ * results.
+ *
+ * As we’re choosing key colors, finding the optimal result is not
+ * needed. We just need to find one which is good enough, quickly.
+ *
+ * A second termination condition is set on the number of iterations, to
+ * avoid a potential infinite loop. This termination condition is never
+ * normally expected to be hit — typically an icon will require 5–10
+ * iterations to terminate based on @n_assignments_changed. */
+ assignments_termination_limit = width * height * 0.01;
+ n_iterations = 0;
+ do {
+ /* Update step. Re-calculate the centroid of each cluster from
+ * the colors which are in it. */
+ memset (cluster_accumulators, 0, sizeof (cluster_accumulators));
+
+ for (const ClusterPixel8 *p = pixels; p < pixels_end; p++) {
+ if (p->cluster >= G_N_ELEMENTS (cluster_centres))
+ continue;
+
+ cluster_accumulators[p->cluster].red += p->color.red;
+ cluster_accumulators[p->cluster].green += p->color.green;
+ cluster_accumulators[p->cluster].blue += p->color.blue;
+ cluster_accumulators[p->cluster].n_members++;
+ }
+
+ for (gsize i = 0; i < G_N_ELEMENTS (cluster_centres); i++) {
+ if (cluster_accumulators[i].n_members == 0)
+ continue;
+
+ cluster_centres[i].red = cluster_accumulators[i].red / cluster_accumulators[i].n_members;
+ cluster_centres[i].green = cluster_accumulators[i].green / cluster_accumulators[i].n_members;
+ cluster_centres[i].blue = cluster_accumulators[i].blue / cluster_accumulators[i].n_members;
+ }
+
+ /* Update assignments of colors to clusters. */
+ n_assignments_changed = 0;
+ for (ClusterPixel8 *p = pixels; p < pixels_end; p++) {
+ gsize new_cluster;
+
+ if (p->cluster >= G_N_ELEMENTS (cluster_centres))
+ continue;
+
+ new_cluster = nearest_cluster (&p->color, cluster_centres, G_N_ELEMENTS (cluster_centres));
+ if (new_cluster != p->cluster)
+ n_assignments_changed++;
+ p->cluster = new_cluster;
+ }
+
+ n_iterations++;
+ } while (n_assignments_changed > assignments_termination_limit && n_iterations < 50);
+
+ /* Output the cluster centres: these are the icon’s key colors. */
+ for (gsize i = 0; i < G_N_ELEMENTS (cluster_centres); i++) {
+ GdkRGBA color;
+
+ if (cluster_accumulators[i].n_members == 0)
+ continue;
+
+ color.red = (gdouble) cluster_centres[i].red / 255.0;
+ color.green = (gdouble) cluster_centres[i].green / 255.0;
+ color.blue = (gdouble) cluster_centres[i].blue / 255.0;
+ color.alpha = 1.0;
+ g_array_append_val (colors, color);
+ }
+}
+
+/**
+ * gs_calculate_key_colors:
+ * @pixbuf: an app icon to calculate key colors from
+ *
+ * Calculate the set of key colors present in @pixbuf. These are the colors
+ * which stand out the most, and they are subjective. This function does not
+ * guarantee to return perfect results, but should return workable results for
+ * most icons.
+ *
+ * @pixbuf will be scaled down to 32×32 pixels, so if it can be provided at
+ * that resolution by the caller, this function will return faster.
+ *
+ * Returns: (transfer full) (element-type GdkRGBA): key colors for @pixbuf
+ * Since: 40
+ */
+GArray *
+gs_calculate_key_colors (GdkPixbuf *pixbuf)
+{
+ g_autoptr(GdkPixbuf) pb_small = NULL;
+ g_autoptr(GArray) colors = g_array_new (FALSE, FALSE, sizeof (GdkRGBA));
+
+ /* people almost always use BILINEAR scaling with pixbufs, but we can
+ * use NEAREST here since we only care about the rough colour data, not
+ * whether the edges in the image are smooth and visually appealing;
+ * NEAREST is twice as fast as BILINEAR */
+ pb_small = gdk_pixbuf_scale_simple (pixbuf, 32, 32, GDK_INTERP_NEAREST);
+
+ /* require an alpha channel for storing temporary values; most images
+ * have one already, about 2% don’t */
+ if (gdk_pixbuf_get_n_channels (pixbuf) != 4) {
+ g_autoptr(GdkPixbuf) temp = g_steal_pointer (&pb_small);
+ pb_small = gdk_pixbuf_add_alpha (temp, FALSE, 0, 0, 0);
+ }
+
+ /* get a list of key colors */
+ k_means (colors, pb_small);
+
+ return g_steal_pointer (&colors);
+}
diff --git a/lib/gs-key-colors.h b/lib/gs-key-colors.h
new file mode 100644
index 0000000..de1baee
--- /dev/null
+++ b/lib/gs-key-colors.h
@@ -0,0 +1,27 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2013-2016 Richard Hughes <richard@hughsie.com>
+ * Copyright (C) 2013 Matthias Clasen <mclasen@redhat.com>
+ * Copyright (C) 2014-2018 Kalev Lember <klember@redhat.com>
+ * Copyright (C) 2021 Endless OS Foundation LLC
+ *
+ * Authors:
+ * - Richard Hughes <richard@hughsie.com>
+ * - Kalev Lember <klember@redhat.com>
+ * - Philip Withnall <pwithnall@endlessos.org>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#include <glib.h>
+#include <gdk/gdk.h>
+#include <gdk-pixbuf/gdk-pixbuf.h>
+
+G_BEGIN_DECLS
+
+GArray *gs_calculate_key_colors (GdkPixbuf *pixbuf);
+
+G_END_DECLS
diff --git a/lib/gs-metered.c b/lib/gs-metered.c
new file mode 100644
index 0000000..e6a82d9
--- /dev/null
+++ b/lib/gs-metered.c
@@ -0,0 +1,312 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2019 Endless Mobile, Inc.
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+/**
+ * SECTION:gs-metered
+ * @title: Metered Data Utilities
+ * @include: gnome-software.h
+ * @stability: Unstable
+ * @short_description: Utility functions to help with metered data handling
+ *
+ * Metered data handling is provided by Mogwai, which implements a download
+ * scheduler to control when, and in which order, large downloads happen on
+ * the system.
+ *
+ * All large downloads from gs_plugin_download() or gs_plugin_download_app()
+ * calls should be scheduled using Mogwai, which will notify gnome-software
+ * when those downloads can start and stop, according to system policy.
+ *
+ * The functions in this file make interacting with the scheduling daemon a
+ * little simpler. Since all #GsPlugin method calls happen in worker threads,
+ * typically without a #GMainContext, all interaction with the scheduler should
+ * be blocking. libmogwai-schedule-client was designed to be asynchronous; so
+ * these helpers make it synchronous.
+ *
+ * Since: 3.34
+ */
+
+#include "config.h"
+
+#include <glib.h>
+
+#ifdef HAVE_MOGWAI
+#include <libmogwai-schedule-client/scheduler.h>
+#endif
+
+#include "gs-metered.h"
+#include "gs-utils.h"
+
+
+#ifdef HAVE_MOGWAI
+
+typedef struct
+{
+ gboolean *out_download_now; /* (unowned) */
+ GMainContext *context; /* (unowned) */
+} DownloadNowData;
+
+static void
+download_now_cb (GObject *obj,
+ GParamSpec *pspec,
+ gpointer user_data)
+{
+ DownloadNowData *data = user_data;
+ *data->out_download_now = mwsc_schedule_entry_get_download_now (MWSC_SCHEDULE_ENTRY (obj));
+ g_main_context_wakeup (data->context);
+}
+
+typedef struct
+{
+ GError **out_error; /* (unowned) */
+ GMainContext *context; /* (unowned) */
+} InvalidatedData;
+
+static void
+invalidated_cb (MwscScheduleEntry *entry,
+ const GError *error,
+ gpointer user_data)
+{
+ InvalidatedData *data = user_data;
+ *data->out_error = g_error_copy (error);
+ g_main_context_wakeup (data->context);
+}
+
+#endif /* HAVE_MOGWAI */
+
+/**
+ * gs_metered_block_on_download_scheduler:
+ * @parameters: (nullable): a #GVariant of type `a{sv}` specifying parameters
+ * for the schedule entry, or %NULL to pass no parameters
+ * @schedule_entry_handle_out: (out) (not optional): return location for a
+ * handle to the resulting schedule entry
+ * @cancellable: (nullable): a #GCancellable, or %NULL
+ * @error: return location for a #GError, or %NULL
+ *
+ * Create a schedule entry with the given @parameters, and block until
+ * permission is given to download.
+ *
+ * FIXME: This will currently ignore later revocations of that download
+ * permission, and does not support creating a schedule entry per app.
+ * The schedule entry must later be removed from the schedule by passing
+ * the handle from @schedule_entry_handle_out to
+ * gs_metered_remove_from_download_scheduler(), otherwise resources will leak.
+ * This is an opaque handle and should not be inspected.
+ *
+ * If a schedule entry cannot be created, or if @cancellable is cancelled,
+ * an error will be set and %FALSE returned.
+ *
+ * The keys understood by @parameters are listed in the documentation for
+ * mwsc_scheduler_schedule_async().
+ *
+ * This function will likely be called from a #GsPluginLoader worker thread.
+ *
+ * Returns: %TRUE on success, %FALSE otherwise
+ * Since: 3.38
+ */
+gboolean
+gs_metered_block_on_download_scheduler (GVariant *parameters,
+ gpointer *schedule_entry_handle_out,
+ GCancellable *cancellable,
+ GError **error)
+{
+#ifdef HAVE_MOGWAI
+ g_autoptr(MwscScheduler) scheduler = NULL;
+ g_autoptr(MwscScheduleEntry) schedule_entry = NULL;
+ g_autofree gchar *parameters_str = NULL;
+ g_autoptr(GMainContext) context = NULL;
+ g_autoptr(GMainContextPusher) pusher = NULL;
+
+ g_return_val_if_fail (schedule_entry_handle_out != NULL, FALSE);
+
+ /* set this in case of error */
+ *schedule_entry_handle_out = NULL;
+
+ parameters_str = (parameters != NULL) ? g_variant_print (parameters, TRUE) : g_strdup ("(none)");
+ g_debug ("%s: Waiting with parameters: %s", G_STRFUNC, parameters_str);
+
+ /* Push the context early so that the #MwscScheduler is created to run within it. */
+ context = g_main_context_new ();
+ pusher = g_main_context_pusher_new (context);
+
+ /* Wait until the download can be scheduled.
+ * FIXME: In future, downloads could be split up by app, so they can all
+ * be scheduled separately and, for example, higher priority ones could
+ * be scheduled with a higher priority. This would have to be aware of
+ * dependencies. */
+ scheduler = mwsc_scheduler_new (cancellable, error);
+ if (scheduler == NULL)
+ return FALSE;
+
+ /* Create a schedule entry for the group of downloads.
+ * FIXME: The underlying OSTree code supports resuming downloads
+ * (at a granularity of individual objects), so it should be
+ * possible to plumb through here. */
+ schedule_entry = mwsc_scheduler_schedule (scheduler, parameters, cancellable,
+ error);
+ if (schedule_entry == NULL)
+ return FALSE;
+
+ /* Wait until the download is allowed to proceed. */
+ if (!mwsc_schedule_entry_get_download_now (schedule_entry)) {
+ gboolean download_now = FALSE;
+ g_autoptr(GError) invalidated_error = NULL;
+ gulong notify_id, invalidated_id;
+ DownloadNowData download_now_data = { &download_now, context };
+ InvalidatedData invalidated_data = { &invalidated_error, context };
+
+ notify_id = g_signal_connect (schedule_entry, "notify::download-now",
+ (GCallback) download_now_cb, &download_now_data);
+ invalidated_id = g_signal_connect (schedule_entry, "invalidated",
+ (GCallback) invalidated_cb, &invalidated_data);
+
+ while (!download_now && invalidated_error == NULL &&
+ !g_cancellable_is_cancelled (cancellable))
+ g_main_context_iteration (context, TRUE);
+
+ g_signal_handler_disconnect (schedule_entry, invalidated_id);
+ g_signal_handler_disconnect (schedule_entry, notify_id);
+
+ if (!download_now && invalidated_error != NULL) {
+ /* no need to remove the schedule entry as it’s been
+ * invalidated */
+ g_propagate_error (error, g_steal_pointer (&invalidated_error));
+ return FALSE;
+ } else if (!download_now && g_cancellable_set_error_if_cancelled (cancellable, error)) {
+ /* remove the schedule entry and fail */
+ gs_metered_remove_from_download_scheduler (schedule_entry, NULL, NULL);
+ return FALSE;
+ }
+
+ g_assert (download_now);
+ }
+
+ *schedule_entry_handle_out = g_object_ref (schedule_entry);
+
+ g_debug ("%s: Allowed to download", G_STRFUNC);
+#else /* if !HAVE_MOGWAI */
+ g_debug ("%s: Allowed to download (Mogwai support compiled out)", G_STRFUNC);
+#endif /* !HAVE_MOGWAI */
+
+ return TRUE;
+}
+
+/**
+ * gs_metered_remove_from_download_scheduler:
+ * @schedule_entry_handle: (transfer full) (nullable): schedule entry handle as
+ * returned by gs_metered_block_on_download_scheduler()
+ * @cancellable: (nullable): a #GCancellable, or %NULL
+ * @error: return location for a #GError, or %NULL
+ *
+ * Remove a schedule entry previously created by
+ * gs_metered_block_on_download_scheduler(). This must be called after
+ * gs_metered_block_on_download_scheduler() has successfully returned, or
+ * resources will leak. It should be called once the corresponding download is
+ * complete.
+ *
+ * Returns: %TRUE on success, %FALSE otherwise
+ * Since: 3.38
+ */
+gboolean
+gs_metered_remove_from_download_scheduler (gpointer schedule_entry_handle,
+ GCancellable *cancellable,
+ GError **error)
+{
+#ifdef HAVE_MOGWAI
+ g_autoptr(MwscScheduleEntry) schedule_entry = schedule_entry_handle;
+#endif
+
+ g_return_val_if_fail (cancellable == NULL || G_IS_CANCELLABLE (cancellable), FALSE);
+ g_return_val_if_fail (error == NULL || *error == NULL, FALSE);
+
+ g_debug ("Removing schedule entry handle %p", schedule_entry_handle);
+
+ if (schedule_entry_handle == NULL)
+ return TRUE;
+
+#ifdef HAVE_MOGWAI
+ return mwsc_schedule_entry_remove (schedule_entry, cancellable, error);
+#else
+ return TRUE;
+#endif
+}
+
+/**
+ * gs_metered_block_app_on_download_scheduler:
+ * @app: a #GsApp to get the scheduler parameters from
+ * @schedule_entry_handle_out: (out) (not optional): return location for a
+ * handle to the resulting schedule entry
+ * @cancellable: a #GCancellable, or %NULL
+ * @error: return location for a #GError, or %NULL
+ *
+ * Version of gs_metered_block_on_download_scheduler() which extracts the
+ * download parameters from the given @app.
+ *
+ * Returns: %TRUE on success, %FALSE otherwise
+ * Since: 3.38
+ */
+gboolean
+gs_metered_block_app_on_download_scheduler (GsApp *app,
+ gpointer *schedule_entry_handle_out,
+ GCancellable *cancellable,
+ GError **error)
+{
+ g_auto(GVariantDict) parameters_dict = G_VARIANT_DICT_INIT (NULL);
+ g_autoptr(GVariant) parameters = NULL;
+ guint64 download_size;
+
+ /* Currently no plugins support resumable downloads. This may change in
+ * future, in which case this parameter should be refactored. */
+ g_variant_dict_insert (&parameters_dict, "resumable", "b", FALSE);
+
+ if (gs_app_get_size_download (app, &download_size) == GS_SIZE_TYPE_VALID) {
+ g_variant_dict_insert (&parameters_dict, "size-minimum", "t", download_size);
+ g_variant_dict_insert (&parameters_dict, "size-maximum", "t", download_size);
+ }
+
+ parameters = g_variant_ref_sink (g_variant_dict_end (&parameters_dict));
+
+ return gs_metered_block_on_download_scheduler (parameters, schedule_entry_handle_out, cancellable, error);
+}
+
+/**
+ * gs_metered_block_app_list_on_download_scheduler:
+ * @app_list: a #GsAppList to get the scheduler parameters from
+ * @schedule_entry_handle_out: (out) (not optional): return location for a
+ * handle to the resulting schedule entry
+ * @cancellable: a #GCancellable, or %NULL
+ * @error: return location for a #GError, or %NULL
+ *
+ * Version of gs_metered_block_on_download_scheduler() which extracts the
+ * download parameters from the apps in the given @app_list.
+ *
+ * Returns: %TRUE on success, %FALSE otherwise
+ * Since: 3.38
+ */
+gboolean
+gs_metered_block_app_list_on_download_scheduler (GsAppList *app_list,
+ gpointer *schedule_entry_handle_out,
+ GCancellable *cancellable,
+ GError **error)
+{
+ g_auto(GVariantDict) parameters_dict = G_VARIANT_DICT_INIT (NULL);
+ g_autoptr(GVariant) parameters = NULL;
+
+ /* Currently no plugins support resumable downloads. This may change in
+ * future, in which case this parameter should be refactored. */
+ g_variant_dict_insert (&parameters_dict, "resumable", "b", FALSE);
+
+ /* FIXME: Currently this creates a single Mogwai schedule entry for the
+ * entire app list. Eventually, we probably want one schedule entry per
+ * app being downloaded, so that they can be individually prioritised.
+ * However, that requires much deeper integration into the download
+ * code, and Mogwai does not currently support that level of
+ * prioritisation, so go with this simple implementation for now. */
+ parameters = g_variant_ref_sink (g_variant_dict_end (&parameters_dict));
+
+ return gs_metered_block_on_download_scheduler (parameters, schedule_entry_handle_out, cancellable, error);
+}
diff --git a/lib/gs-metered.h b/lib/gs-metered.h
new file mode 100644
index 0000000..f81d171
--- /dev/null
+++ b/lib/gs-metered.h
@@ -0,0 +1,35 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2019 Endless Mobile, Inc.
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#include <glib-object.h>
+#include <gio/gio.h>
+
+#include "gs-app.h"
+#include "gs-app-list.h"
+
+G_BEGIN_DECLS
+
+gboolean gs_metered_block_on_download_scheduler (GVariant *parameters,
+ gpointer *schedule_entry_handle_out,
+ GCancellable *cancellable,
+ GError **error);
+gboolean gs_metered_remove_from_download_scheduler (gpointer schedule_entry_handle,
+ GCancellable *cancellable,
+ GError **error);
+gboolean gs_metered_block_app_on_download_scheduler (GsApp *app,
+ gpointer *schedule_entry_handle_out,
+ GCancellable *cancellable,
+ GError **error);
+gboolean gs_metered_block_app_list_on_download_scheduler (GsAppList *app_list,
+ gpointer *schedule_entry_handle_out,
+ GCancellable *cancellable,
+ GError **error);
+
+G_END_DECLS
diff --git a/lib/gs-odrs-provider.c b/lib/gs-odrs-provider.c
new file mode 100644
index 0000000..cb9d37d
--- /dev/null
+++ b/lib/gs-odrs-provider.c
@@ -0,0 +1,1998 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2016 Richard Hughes <richard@hughsie.com>
+ * Copyright (C) 2016-2018 Kalev Lember <klember@redhat.com>
+ * Copyright (C) 2021 Endless OS Foundation LLC
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+/*
+ * SECTION:gs-odrs-provider
+ * @short_description: Provides review data from the Open Desktop Ratings Service.
+ *
+ * To test this plugin locally you will probably want to build and run the
+ * `odrs-web` container, following the instructions in the
+ * [`odrs-web` repository](https://gitlab.gnome.org/Infrastructure/odrs-web/-/blob/HEAD/app_data/README.md),
+ * and then get gnome-software to use your local review server by running:
+ * ```
+ * gsettings set org.gnome.software review-server 'http://127.0.0.1:5000/1.0/reviews/api'
+ * ```
+ *
+ * When you are done with development, run the following command to use the real
+ * ODRS server again:
+ * ```
+ * gsettings reset org.gnome.software review-server
+ * ```
+ *
+ * Since: 41
+ */
+
+#include "config.h"
+
+#include <glib.h>
+#include <glib-object.h>
+#include <glib/gi18n.h>
+#include <gnome-software.h>
+#include <json-glib/json-glib.h>
+#include <libsoup/soup.h>
+#include <locale.h>
+#include <math.h>
+#include <string.h>
+
+G_DEFINE_QUARK (gs-odrs-provider-error-quark, gs_odrs_provider_error)
+
+/* Element in self->ratings, all allocated in one big block and sorted
+ * alphabetically to reduce the number of allocations and fragmentation. */
+typedef struct {
+ gchar *app_id; /* (owned) */
+ guint32 n_star_ratings[6];
+} GsOdrsRating;
+
+static int
+rating_compare (const GsOdrsRating *a, const GsOdrsRating *b)
+{
+ return g_strcmp0 (a->app_id, b->app_id);
+}
+
+static void
+rating_clear (GsOdrsRating *rating)
+{
+ g_free (rating->app_id);
+}
+
+struct _GsOdrsProvider
+{
+ GObject parent_instance;
+
+ gchar *distro; /* (not nullable) (owned) */
+ gchar *user_hash; /* (not nullable) (owned) */
+ gchar *review_server; /* (not nullable) (owned) */
+ GArray *ratings; /* (element-type GsOdrsRating) (mutex ratings_mutex) (owned) (nullable) */
+ GMutex ratings_mutex;
+ guint64 max_cache_age_secs;
+ guint n_results_max;
+ SoupSession *session; /* (owned) (not nullable) */
+};
+
+G_DEFINE_TYPE (GsOdrsProvider, gs_odrs_provider, G_TYPE_OBJECT)
+
+typedef enum {
+ PROP_REVIEW_SERVER = 1,
+ PROP_USER_HASH,
+ PROP_DISTRO,
+ PROP_MAX_CACHE_AGE_SECS,
+ PROP_N_RESULTS_MAX,
+ PROP_SESSION,
+} GsOdrsProviderProperty;
+
+static GParamSpec *obj_props[PROP_SESSION + 1] = { NULL, };
+
+static gboolean
+gs_odrs_provider_load_ratings_for_app (JsonObject *json_app,
+ const gchar *app_id,
+ GsOdrsRating *rating_out)
+{
+ guint i;
+ const gchar *names[] = { "star0", "star1", "star2", "star3",
+ "star4", "star5", NULL };
+
+ for (i = 0; names[i] != NULL; i++) {
+ if (!json_object_has_member (json_app, names[i]))
+ return FALSE;
+ rating_out->n_star_ratings[i] = (guint64) json_object_get_int_member (json_app, names[i]);
+ }
+
+ rating_out->app_id = g_strdup (app_id);
+
+ return TRUE;
+}
+
+static gboolean
+gs_odrs_provider_load_ratings (GsOdrsProvider *self,
+ const gchar *filename,
+ GError **error)
+{
+ JsonNode *json_root;
+ JsonObject *json_item;
+ g_autoptr(JsonParser) json_parser = NULL;
+ const gchar *app_id;
+ JsonNode *json_app_node;
+ JsonObjectIter iter;
+ g_autoptr(GArray) new_ratings = NULL;
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_autoptr(GError) local_error = NULL;
+
+ /* parse the data and find the success */
+ json_parser = json_parser_new_immutable ();
+ if (!json_parser_load_from_mapped_file (json_parser, filename, &local_error)) {
+ g_set_error (error,
+ GS_ODRS_PROVIDER_ERROR,
+ GS_ODRS_PROVIDER_ERROR_PARSING_DATA,
+ "Error parsing ODRS data: %s", local_error->message);
+ return FALSE;
+ }
+ json_root = json_parser_get_root (json_parser);
+ if (json_root == NULL) {
+ g_set_error_literal (error,
+ GS_ODRS_PROVIDER_ERROR,
+ GS_ODRS_PROVIDER_ERROR_PARSING_DATA,
+ "no ratings root");
+ return FALSE;
+ }
+ if (json_node_get_node_type (json_root) != JSON_NODE_OBJECT) {
+ g_set_error_literal (error,
+ GS_ODRS_PROVIDER_ERROR,
+ GS_ODRS_PROVIDER_ERROR_PARSING_DATA,
+ "no ratings array");
+ return FALSE;
+ }
+
+ json_item = json_node_get_object (json_root);
+
+ new_ratings = g_array_sized_new (FALSE, /* don’t zero-terminate */
+ FALSE, /* don’t clear */
+ sizeof (GsOdrsRating),
+ json_object_get_size (json_item));
+ g_array_set_clear_func (new_ratings, (GDestroyNotify) rating_clear);
+
+ /* parse each app */
+ json_object_iter_init (&iter, json_item);
+ while (json_object_iter_next (&iter, &app_id, &json_app_node)) {
+ GsOdrsRating rating;
+ JsonObject *json_app;
+
+ if (!JSON_NODE_HOLDS_OBJECT (json_app_node))
+ continue;
+ json_app = json_node_get_object (json_app_node);
+
+ if (gs_odrs_provider_load_ratings_for_app (json_app, app_id, &rating))
+ g_array_append_val (new_ratings, rating);
+ }
+
+ /* Allow for binary searches later. */
+ g_array_sort (new_ratings, (GCompareFunc) rating_compare);
+
+ /* Update the shared state */
+ locker = g_mutex_locker_new (&self->ratings_mutex);
+ g_clear_pointer (&self->ratings, g_array_unref);
+ self->ratings = g_steal_pointer (&new_ratings);
+
+ return TRUE;
+}
+
+static AsReview *
+gs_odrs_provider_parse_review_object (JsonObject *item)
+{
+ AsReview *rev = as_review_new ();
+
+ /* date */
+ if (json_object_has_member (item, "date_created")) {
+ gint64 timestamp;
+ g_autoptr(GDateTime) dt = NULL;
+ timestamp = json_object_get_int_member (item, "date_created");
+ dt = g_date_time_new_from_unix_utc (timestamp);
+ as_review_set_date (rev, dt);
+ }
+
+ /* assemble review */
+ if (json_object_has_member (item, "rating"))
+ as_review_set_rating (rev, (gint) json_object_get_int_member (item, "rating"));
+ if (json_object_has_member (item, "score")) {
+ as_review_set_priority (rev, (gint) json_object_get_int_member (item, "score"));
+ } else if (json_object_has_member (item, "karma_up") &&
+ json_object_has_member (item, "karma_down")) {
+ gdouble ku = (gdouble) json_object_get_int_member (item, "karma_up");
+ gdouble kd = (gdouble) json_object_get_int_member (item, "karma_down");
+ gdouble wilson = 0.f;
+
+ /* from http://www.evanmiller.org/how-not-to-sort-by-average-rating.html */
+ if (ku > 0 || kd > 0) {
+ wilson = ((ku + 1.9208) / (ku + kd) -
+ 1.96 * sqrt ((ku * kd) / (ku + kd) + 0.9604) /
+ (ku + kd)) / (1 + 3.8416 / (ku + kd));
+ wilson *= 100.f;
+ }
+ as_review_set_priority (rev, (gint) wilson);
+ }
+ if (json_object_has_member (item, "user_hash"))
+ as_review_set_reviewer_id (rev, json_object_get_string_member (item, "user_hash"));
+ if (json_object_has_member (item, "user_display")) {
+ g_autofree gchar *user_display = g_strdup (json_object_get_string_member (item, "user_display"));
+ if (user_display)
+ g_strstrip (user_display);
+ as_review_set_reviewer_name (rev, user_display);
+ }
+ if (json_object_has_member (item, "summary")) {
+ g_autofree gchar *summary = g_strdup (json_object_get_string_member (item, "summary"));
+ if (summary)
+ g_strstrip (summary);
+ as_review_set_summary (rev, summary);
+ }
+ if (json_object_has_member (item, "description")) {
+ g_autofree gchar *description = g_strdup (json_object_get_string_member (item, "description"));
+ if (description)
+ g_strstrip (description);
+ as_review_set_description (rev, description);
+ }
+ if (json_object_has_member (item, "version"))
+ as_review_set_version (rev, json_object_get_string_member (item, "version"));
+
+ /* add extra metadata for the plugin */
+ if (json_object_has_member (item, "user_skey")) {
+ as_review_add_metadata (rev, "user_skey",
+ json_object_get_string_member (item, "user_skey"));
+ }
+ if (json_object_has_member (item, "app_id")) {
+ as_review_add_metadata (rev, "app_id",
+ json_object_get_string_member (item, "app_id"));
+ }
+ if (json_object_has_member (item, "review_id")) {
+ g_autofree gchar *review_id = NULL;
+ review_id = g_strdup_printf ("%" G_GINT64_FORMAT,
+ json_object_get_int_member (item, "review_id"));
+ as_review_set_id (rev, review_id);
+ }
+
+ /* don't allow multiple votes */
+ if (json_object_has_member (item, "vote_id"))
+ as_review_add_flags (rev, AS_REVIEW_FLAG_VOTED);
+
+ return rev;
+}
+
+/* json_parser_load*() must have been called on @json_parser before calling
+ * this function. */
+static GPtrArray *
+gs_odrs_provider_parse_reviews (GsOdrsProvider *self,
+ JsonParser *json_parser,
+ GError **error)
+{
+ JsonArray *json_reviews;
+ JsonNode *json_root;
+ guint i;
+ g_autoptr(GHashTable) reviewer_ids = NULL;
+ g_autoptr(GPtrArray) reviews = NULL;
+ g_autoptr(GError) local_error = NULL;
+
+ json_root = json_parser_get_root (json_parser);
+ if (json_root == NULL) {
+ g_set_error_literal (error,
+ GS_ODRS_PROVIDER_ERROR,
+ GS_ODRS_PROVIDER_ERROR_PARSING_DATA,
+ "no root");
+ return NULL;
+ }
+ if (json_node_get_node_type (json_root) != JSON_NODE_ARRAY) {
+ g_set_error_literal (error,
+ GS_ODRS_PROVIDER_ERROR,
+ GS_ODRS_PROVIDER_ERROR_PARSING_DATA,
+ "no array");
+ return NULL;
+ }
+
+ /* parse each rating */
+ reviews = g_ptr_array_new_with_free_func ((GDestroyNotify) g_object_unref);
+ json_reviews = json_node_get_array (json_root);
+ reviewer_ids = g_hash_table_new_full (g_str_hash, g_str_equal, g_free, NULL);
+ for (i = 0; i < json_array_get_length (json_reviews); i++) {
+ JsonNode *json_review;
+ JsonObject *json_item;
+ const gchar *reviewer_id;
+ g_autoptr(AsReview) review = NULL;
+
+ /* extract the data */
+ json_review = json_array_get_element (json_reviews, i);
+ if (json_node_get_node_type (json_review) != JSON_NODE_OBJECT) {
+ g_set_error_literal (error,
+ GS_ODRS_PROVIDER_ERROR,
+ GS_ODRS_PROVIDER_ERROR_PARSING_DATA,
+ "no object type");
+ return NULL;
+ }
+ json_item = json_node_get_object (json_review);
+ if (json_item == NULL) {
+ g_set_error_literal (error,
+ GS_ODRS_PROVIDER_ERROR,
+ GS_ODRS_PROVIDER_ERROR_PARSING_DATA,
+ "no object");
+ return NULL;
+ }
+
+ /* create review */
+ review = gs_odrs_provider_parse_review_object (json_item);
+
+ reviewer_id = as_review_get_reviewer_id (review);
+ if (reviewer_id == NULL)
+ continue;
+
+ /* dedupe each on the user_hash */
+ if (g_hash_table_lookup (reviewer_ids, reviewer_id) != NULL) {
+ g_debug ("duplicate review %s, skipping", reviewer_id);
+ continue;
+ }
+ g_hash_table_add (reviewer_ids, g_strdup (reviewer_id));
+ g_ptr_array_add (reviews, g_object_ref (review));
+ }
+ return g_steal_pointer (&reviews);
+}
+
+static gboolean
+gs_odrs_provider_parse_success (GInputStream *input_stream,
+ GError **error)
+{
+ JsonNode *json_root;
+ JsonObject *json_item;
+ const gchar *msg = NULL;
+ g_autoptr(JsonParser) json_parser = NULL;
+ g_autoptr(GError) local_error = NULL;
+
+ /* parse the data and find the success
+ * FIXME: This should probably eventually be refactored and made async */
+ json_parser = json_parser_new_immutable ();
+ if (!json_parser_load_from_stream (json_parser, input_stream, NULL, &local_error)) {
+ g_set_error (error,
+ GS_ODRS_PROVIDER_ERROR,
+ GS_ODRS_PROVIDER_ERROR_PARSING_DATA,
+ "Error parsing ODRS data: %s", local_error->message);
+ return FALSE;
+ }
+ json_root = json_parser_get_root (json_parser);
+ if (json_root == NULL) {
+ g_set_error_literal (error,
+ GS_ODRS_PROVIDER_ERROR,
+ GS_ODRS_PROVIDER_ERROR_PARSING_DATA,
+ "no error root");
+ return FALSE;
+ }
+ if (json_node_get_node_type (json_root) != JSON_NODE_OBJECT) {
+ g_set_error_literal (error,
+ GS_ODRS_PROVIDER_ERROR,
+ GS_ODRS_PROVIDER_ERROR_PARSING_DATA,
+ "no error object");
+ return FALSE;
+ }
+ json_item = json_node_get_object (json_root);
+ if (json_item == NULL) {
+ g_set_error_literal (error,
+ GS_ODRS_PROVIDER_ERROR,
+ GS_ODRS_PROVIDER_ERROR_PARSING_DATA,
+ "no error object");
+ return FALSE;
+ }
+
+ /* failed? */
+ if (json_object_has_member (json_item, "msg"))
+ msg = json_object_get_string_member (json_item, "msg");
+ if (!json_object_get_boolean_member (json_item, "success")) {
+ g_set_error_literal (error,
+ GS_ODRS_PROVIDER_ERROR,
+ GS_ODRS_PROVIDER_ERROR_PARSING_DATA,
+ msg != NULL ? msg : "unknown failure");
+ return FALSE;
+ }
+
+ /* just for the console */
+ if (msg != NULL)
+ g_debug ("success: %s", msg);
+ return TRUE;
+}
+
+#if SOUP_CHECK_VERSION(3, 0, 0)
+typedef struct {
+ GInputStream *input_stream;
+ gssize length;
+ goffset read_from;
+} MessageData;
+
+static MessageData *
+message_data_new (GInputStream *input_stream,
+ gssize length)
+{
+ MessageData *md;
+
+ md = g_slice_new0 (MessageData);
+ md->input_stream = g_object_ref (input_stream);
+ md->length = length;
+
+ if (G_IS_SEEKABLE (input_stream))
+ md->read_from = g_seekable_tell (G_SEEKABLE (input_stream));
+
+ return md;
+}
+
+static void
+message_data_free (gpointer ptr,
+ GClosure *closure)
+{
+ MessageData *md = ptr;
+
+ if (md) {
+ g_object_unref (md->input_stream);
+ g_slice_free (MessageData, md);
+ }
+}
+
+static void
+g_odrs_provider_message_restarted_cb (SoupMessage *message,
+ gpointer user_data)
+{
+ MessageData *md = user_data;
+
+ if (G_IS_SEEKABLE (md->input_stream) && md->read_from != g_seekable_tell (G_SEEKABLE (md->input_stream)))
+ g_seekable_seek (G_SEEKABLE (md->input_stream), md->read_from, G_SEEK_SET, NULL, NULL);
+
+ soup_message_set_request_body (message, NULL, md->input_stream, md->length);
+}
+
+static void
+g_odrs_provider_set_message_request_body (SoupMessage *message,
+ const gchar *content_type,
+ gconstpointer data,
+ gsize length)
+{
+ MessageData *md;
+ GInputStream *input_stream;
+
+ g_return_if_fail (SOUP_IS_MESSAGE (message));
+ g_return_if_fail (data != NULL);
+
+ input_stream = g_memory_input_stream_new_from_data (g_memdup2 (data, length), length, g_free);
+ md = message_data_new (input_stream, length);
+
+ g_signal_connect_data (message, "restarted",
+ G_CALLBACK (g_odrs_provider_message_restarted_cb), md, message_data_free, 0);
+
+ soup_message_set_request_body (message, content_type, input_stream, length);
+
+ g_object_unref (input_stream);
+}
+#endif
+
+static gboolean
+gs_odrs_provider_json_post (SoupSession *session,
+ const gchar *uri,
+ const gchar *data,
+ GCancellable *cancellable,
+ GError **error)
+{
+ guint status_code;
+ g_autoptr(SoupMessage) msg = NULL;
+ gconstpointer downloaded_data;
+ gsize downloaded_data_length;
+ g_autoptr(GInputStream) input_stream = NULL;
+#if SOUP_CHECK_VERSION(3, 0, 0)
+ g_autoptr(GBytes) bytes = NULL;
+#endif
+ /* create the GET data */
+ g_debug ("Sending ODRS request to %s: %s", uri, data);
+ msg = soup_message_new (SOUP_METHOD_POST, uri);
+#if SOUP_CHECK_VERSION(3, 0, 0)
+ g_odrs_provider_set_message_request_body (msg, "application/json; charset=utf-8",
+ data, strlen (data));
+ bytes = soup_session_send_and_read (session, msg, cancellable, error);
+ if (bytes == NULL)
+ return FALSE;
+
+ downloaded_data = g_bytes_get_data (bytes, &downloaded_data_length);
+ status_code = soup_message_get_status (msg);
+#else
+ soup_message_set_request (msg, "application/json; charset=utf-8",
+ SOUP_MEMORY_COPY, data, strlen (data));
+
+ /* set sync request */
+ status_code = soup_session_send_message (session, msg);
+ downloaded_data = msg->response_body ? msg->response_body->data : NULL;
+ downloaded_data_length = msg->response_body ? msg->response_body->length : 0;
+#endif
+ g_debug ("ODRS server returned status %u: %.*s", status_code, (gint) downloaded_data_length, (const gchar *) downloaded_data);
+ if (status_code != SOUP_STATUS_OK) {
+ g_warning ("Failed to set rating on ODRS: %s",
+ soup_status_get_phrase (status_code));
+ g_set_error (error,
+ GS_ODRS_PROVIDER_ERROR,
+ GS_ODRS_PROVIDER_ERROR_SERVER_ERROR,
+ "Failed to submit review to ODRS: %s", soup_status_get_phrase (status_code));
+ return FALSE;
+ }
+
+ /* process returned JSON */
+ input_stream = g_memory_input_stream_new_from_data (downloaded_data, downloaded_data_length, NULL);
+ return gs_odrs_provider_parse_success (input_stream, error);
+}
+
+static GPtrArray *
+_gs_app_get_reviewable_ids (GsApp *app)
+{
+ GPtrArray *ids = g_ptr_array_new_with_free_func (g_free);
+ GPtrArray *provided = gs_app_get_provided (app);
+
+ /* add the main component id */
+ g_ptr_array_add (ids, g_strdup (gs_app_get_id (app)));
+
+ /* add any ID provides */
+ for (guint i = 0; i < provided->len; i++) {
+ GPtrArray *items;
+ AsProvided *prov = g_ptr_array_index (provided, i);
+ if (as_provided_get_kind (prov) != AS_PROVIDED_KIND_ID)
+ continue;
+
+ items = as_provided_get_items (prov);
+ for (guint j = 0; j < items->len; j++) {
+ const gchar *value = (const gchar *) g_ptr_array_index (items, j);
+ if (value == NULL)
+ continue;
+ g_ptr_array_add (ids, g_strdup (value));
+ }
+ }
+ return ids;
+}
+
+static gboolean
+gs_odrs_provider_refine_ratings (GsOdrsProvider *self,
+ GsApp *app,
+ GCancellable *cancellable,
+ GError **error)
+{
+ gint rating;
+ guint32 ratings_raw[6] = { 0, 0, 0, 0, 0, 0 };
+ guint cnt = 0;
+ g_autoptr(GArray) review_ratings = NULL;
+ g_autoptr(GPtrArray) reviewable_ids = NULL;
+ g_autoptr(GMutexLocker) locker = NULL;
+
+ /* get ratings for each reviewable ID */
+ reviewable_ids = _gs_app_get_reviewable_ids (app);
+
+ locker = g_mutex_locker_new (&self->ratings_mutex);
+
+ if (!self->ratings) {
+ g_autofree gchar *cache_filename = NULL;
+
+ g_clear_pointer (&locker, g_mutex_locker_free);
+
+ /* Load from the local cache, if available, when in offline or
+ when refresh/download disabled on start */
+ cache_filename = gs_utils_get_cache_filename ("odrs",
+ "ratings.json",
+ GS_UTILS_CACHE_FLAG_WRITEABLE |
+ GS_UTILS_CACHE_FLAG_CREATE_DIRECTORY,
+ error);
+
+ if (!cache_filename)
+ return TRUE;
+
+ if (!gs_odrs_provider_load_ratings (self, cache_filename, NULL)) {
+ g_autoptr(GFile) cache_file = g_file_new_for_path (cache_filename);
+ g_debug ("Failed to load cache file ‘%s’, deleting it", cache_filename);
+ g_file_delete (cache_file, NULL, NULL);
+ return TRUE;
+ }
+
+ locker = g_mutex_locker_new (&self->ratings_mutex);
+
+ if (!self->ratings)
+ return TRUE;
+ }
+
+ for (guint i = 0; i < reviewable_ids->len; i++) {
+ const gchar *id = g_ptr_array_index (reviewable_ids, i);
+ const GsOdrsRating search_rating = { (gchar *) id, { 0, }};
+ guint found_index;
+ const GsOdrsRating *found_rating;
+
+ if (!g_array_binary_search (self->ratings, &search_rating,
+ (GCompareFunc) rating_compare, &found_index))
+ continue;
+
+ found_rating = &g_array_index (self->ratings, GsOdrsRating, found_index);
+
+ /* copy into accumulator array */
+ for (guint j = 0; j < 6; j++)
+ ratings_raw[j] += found_rating->n_star_ratings[j];
+ cnt++;
+ }
+ if (cnt == 0)
+ return TRUE;
+
+ /* Done with self->ratings now */
+ g_clear_pointer (&locker, g_mutex_locker_free);
+
+ /* merge to accumulator array back to one GArray blob */
+ review_ratings = g_array_sized_new (FALSE, TRUE, sizeof(guint32), 6);
+ for (guint i = 0; i < 6; i++)
+ g_array_append_val (review_ratings, ratings_raw[i]);
+ gs_app_set_review_ratings (app, review_ratings);
+
+ /* find the wilson rating */
+ rating = gs_utils_get_wilson_rating (g_array_index (review_ratings, guint32, 1),
+ g_array_index (review_ratings, guint32, 2),
+ g_array_index (review_ratings, guint32, 3),
+ g_array_index (review_ratings, guint32, 4),
+ g_array_index (review_ratings, guint32, 5));
+ if (rating > 0)
+ gs_app_set_rating (app, rating);
+ return TRUE;
+}
+
+static JsonNode *
+gs_odrs_provider_get_compat_ids (GsApp *app)
+{
+ GPtrArray *provided = gs_app_get_provided (app);
+ g_autoptr(GHashTable) ids = NULL;
+ g_autoptr(JsonArray) json_array = json_array_new ();
+ g_autoptr(JsonNode) json_node = json_node_new (JSON_NODE_ARRAY);
+
+ ids = g_hash_table_new_full (g_str_hash, g_str_equal, NULL, NULL);
+ for (guint i = 0; i < provided->len; i++) {
+ GPtrArray *items;
+ AsProvided *prov = g_ptr_array_index (provided, i);
+
+ if (as_provided_get_kind (prov) != AS_PROVIDED_KIND_ID)
+ continue;
+
+ items = as_provided_get_items (prov);
+ for (guint j = 0; j < items->len; j++) {
+ const gchar *value = g_ptr_array_index (items, j);
+ if (value == NULL)
+ continue;
+
+ if (g_hash_table_add (ids, (gpointer) value))
+ json_array_add_string_element (json_array, value);
+ }
+ }
+ if (json_array_get_length (json_array) == 0)
+ return NULL;
+ json_node_set_array (json_node, json_array);
+ return g_steal_pointer (&json_node);
+}
+
+static void open_input_stream_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data);
+static void parse_reviews_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data);
+static void set_reviews_on_app (GsOdrsProvider *self,
+ GsApp *app,
+ GPtrArray *reviews);
+
+typedef struct {
+ GsApp *app; /* (not nullable) (owned) */
+ gchar *cache_filename; /* (not nullable) (owned) */
+ SoupMessage *message; /* (nullable) (owned) */
+} FetchReviewsForAppData;
+
+static void
+fetch_reviews_for_app_data_free (FetchReviewsForAppData *data)
+{
+ g_clear_object (&data->app);
+ g_free (data->cache_filename);
+ g_clear_object (&data->message);
+
+ g_free (data);
+}
+
+G_DEFINE_AUTOPTR_CLEANUP_FUNC (FetchReviewsForAppData, fetch_reviews_for_app_data_free)
+
+static void
+gs_odrs_provider_fetch_reviews_for_app_async (GsOdrsProvider *self,
+ GsApp *app,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data)
+{
+ JsonNode *json_compat_ids;
+ const gchar *version;
+ g_autofree gchar *cachefn_basename = NULL;
+ g_autofree gchar *cachefn = NULL;
+ g_autofree gchar *request_body = NULL;
+ g_autofree gchar *uri = NULL;
+ g_autoptr(GFile) cachefn_file = NULL;
+ g_autoptr(GPtrArray) reviews = NULL;
+ g_autoptr(JsonBuilder) builder = NULL;
+ g_autoptr(JsonParser) json_parser = NULL;
+ g_autoptr(JsonGenerator) json_generator = NULL;
+ g_autoptr(JsonNode) json_root = NULL;
+ g_autoptr(SoupMessage) msg = NULL;
+#if SOUP_CHECK_VERSION(3, 0, 0)
+ g_autoptr(GBytes) bytes = NULL;
+#endif
+ g_autoptr(GTask) task = NULL;
+ FetchReviewsForAppData *data;
+ g_autoptr(FetchReviewsForAppData) data_owned = NULL;
+ g_autoptr(GError) local_error = NULL;
+
+ task = g_task_new (self, cancellable, callback, user_data);
+ g_task_set_source_tag (task, gs_odrs_provider_fetch_reviews_for_app_async);
+
+ data = data_owned = g_new0 (FetchReviewsForAppData, 1);
+ data->app = g_object_ref (app);
+ g_task_set_task_data (task, g_steal_pointer (&data_owned), (GDestroyNotify) fetch_reviews_for_app_data_free);
+
+ /* look in the cache */
+ cachefn_basename = g_strdup_printf ("%s.json", gs_app_get_id (app));
+ cachefn = gs_utils_get_cache_filename ("odrs",
+ cachefn_basename,
+ GS_UTILS_CACHE_FLAG_WRITEABLE |
+ GS_UTILS_CACHE_FLAG_CREATE_DIRECTORY,
+ &local_error);
+ if (cachefn == NULL) {
+ g_task_return_error (task, g_steal_pointer (&local_error));
+ return;
+ }
+
+ data->cache_filename = g_strdup (cachefn);
+ cachefn_file = g_file_new_for_path (cachefn);
+ if (gs_utils_get_file_age (cachefn_file) < self->max_cache_age_secs) {
+ g_debug ("got review data for %s from %s",
+ gs_app_get_id (app), cachefn);
+
+ /* parse the data and find the array of ratings */
+ json_parser = json_parser_new_immutable ();
+ if (!json_parser_load_from_mapped_file (json_parser, cachefn, &local_error)) {
+ g_task_return_new_error (task,
+ GS_ODRS_PROVIDER_ERROR,
+ GS_ODRS_PROVIDER_ERROR_PARSING_DATA,
+ "Error parsing ODRS data: %s", local_error->message);
+ return;
+ }
+
+ reviews = gs_odrs_provider_parse_reviews (self, json_parser, &local_error);
+ if (reviews == NULL) {
+ g_task_return_error (task, g_steal_pointer (&local_error));
+ } else {
+ set_reviews_on_app (self, app, reviews);
+ g_task_return_boolean (task, TRUE);
+ }
+
+ return;
+ }
+
+ /* not always available */
+ version = gs_app_get_version (app);
+ if (version == NULL)
+ version = "unknown";
+
+ /* create object with review data */
+ builder = json_builder_new ();
+ json_builder_begin_object (builder);
+ json_builder_set_member_name (builder, "user_hash");
+ json_builder_add_string_value (builder, self->user_hash);
+ json_builder_set_member_name (builder, "app_id");
+ json_builder_add_string_value (builder, gs_app_get_id (app));
+ json_builder_set_member_name (builder, "locale");
+ json_builder_add_string_value (builder, setlocale (LC_MESSAGES, NULL));
+ json_builder_set_member_name (builder, "distro");
+ json_builder_add_string_value (builder, self->distro);
+ json_builder_set_member_name (builder, "version");
+ json_builder_add_string_value (builder, version);
+ json_builder_set_member_name (builder, "limit");
+ json_builder_add_int_value (builder, self->n_results_max);
+ json_compat_ids = gs_odrs_provider_get_compat_ids (app);
+ if (json_compat_ids != NULL) {
+ json_builder_set_member_name (builder, "compat_ids");
+ json_builder_add_value (builder, json_compat_ids);
+ }
+ json_builder_end_object (builder);
+
+ /* export as a string */
+ json_root = json_builder_get_root (builder);
+ json_generator = json_generator_new ();
+ json_generator_set_pretty (json_generator, TRUE);
+ json_generator_set_root (json_generator, json_root);
+ request_body = json_generator_to_data (json_generator, NULL);
+
+ uri = g_strdup_printf ("%s/fetch", self->review_server);
+ g_debug ("Updating ODRS cache for %s from %s to %s; request %s", gs_app_get_id (app),
+ uri, cachefn, request_body);
+ msg = soup_message_new (SOUP_METHOD_POST, uri);
+ data->message = g_object_ref (msg);
+
+#if SOUP_CHECK_VERSION(3, 0, 0)
+ g_odrs_provider_set_message_request_body (msg, "application/json; charset=utf-8",
+ request_body, strlen (request_body));
+ soup_session_send_async (self->session, msg, G_PRIORITY_DEFAULT,
+ cancellable, open_input_stream_cb, g_steal_pointer (&task));
+#else
+ soup_message_set_request (msg, "application/json; charset=utf-8",
+ SOUP_MEMORY_COPY, request_body, strlen (request_body));
+ soup_session_send_async (self->session, msg, cancellable,
+ open_input_stream_cb, g_steal_pointer (&task));
+#endif
+}
+
+static void
+open_input_stream_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data)
+{
+ SoupSession *soup_session = SOUP_SESSION (source_object);
+ g_autoptr(GTask) task = g_steal_pointer (&user_data);
+ FetchReviewsForAppData *data = g_task_get_task_data (task);
+ GCancellable *cancellable = g_task_get_cancellable (task);
+ g_autoptr(GInputStream) input_stream = NULL;
+ guint status_code;
+ g_autoptr(JsonParser) json_parser = NULL;
+ g_autoptr(GError) local_error = NULL;
+
+#if SOUP_CHECK_VERSION(3, 0, 0)
+ input_stream = soup_session_send_finish (soup_session, result, &local_error);
+ status_code = soup_message_get_status (data->message);
+#else
+ input_stream = soup_session_send_finish (soup_session, result, &local_error);
+ status_code = data->message->status_code;
+#endif
+
+ if (input_stream == NULL) {
+ if (!g_network_monitor_get_network_available (g_network_monitor_get_default ()))
+ g_task_return_new_error (task,
+ GS_ODRS_PROVIDER_ERROR,
+ GS_ODRS_PROVIDER_ERROR_NO_NETWORK,
+ "server couldn't be reached");
+ else
+ g_task_return_new_error (task,
+ GS_ODRS_PROVIDER_ERROR,
+ GS_ODRS_PROVIDER_ERROR_PARSING_DATA,
+ "server returned no data");
+ return;
+ }
+
+ if (status_code != SOUP_STATUS_OK) {
+ if (!gs_odrs_provider_parse_success (input_stream, &local_error)) {
+ g_task_return_error (task, g_steal_pointer (&local_error));
+ return;
+ }
+
+ /* not sure what to do here */
+ g_task_return_new_error (task,
+ GS_ODRS_PROVIDER_ERROR,
+ GS_ODRS_PROVIDER_ERROR_DOWNLOADING,
+ "status code invalid");
+ return;
+ }
+
+ /* parse the data and find the array of ratings */
+ json_parser = json_parser_new_immutable ();
+ json_parser_load_from_stream_async (json_parser, input_stream, cancellable, parse_reviews_cb, g_steal_pointer (&task));
+}
+
+static void
+parse_reviews_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data)
+{
+ JsonParser *json_parser = JSON_PARSER (source_object);
+ g_autoptr(GTask) task = g_steal_pointer (&user_data);
+ GsOdrsProvider *self = g_task_get_source_object (task);
+ FetchReviewsForAppData *data = g_task_get_task_data (task);
+ g_autoptr(GPtrArray) reviews = NULL;
+ g_autoptr(JsonGenerator) cache_generator = NULL;
+ g_autoptr(GError) local_error = NULL;
+
+ if (!json_parser_load_from_stream_finish (json_parser, result, &local_error)) {
+ g_task_return_new_error (task,
+ GS_ODRS_PROVIDER_ERROR,
+ GS_ODRS_PROVIDER_ERROR_PARSING_DATA,
+ "Error parsing ODRS data: %s", local_error->message);
+ return;
+ }
+
+ reviews = gs_odrs_provider_parse_reviews (self, json_parser, &local_error);
+ if (reviews == NULL) {
+ g_task_return_error (task, g_steal_pointer (&local_error));
+ return;
+ }
+
+ /* save to the cache */
+ cache_generator = json_generator_new ();
+ json_generator_set_pretty (cache_generator, FALSE);
+ json_generator_set_root (cache_generator, json_parser_get_root (json_parser));
+
+ if (!json_generator_to_file (cache_generator, data->cache_filename, &local_error)) {
+ g_task_return_error (task, g_steal_pointer (&local_error));
+ return;
+ }
+
+ set_reviews_on_app (self, data->app, reviews);
+
+ /* success */
+ g_task_return_boolean (task, TRUE);
+}
+
+static void
+set_reviews_on_app (GsOdrsProvider *self,
+ GsApp *app,
+ GPtrArray *reviews)
+{
+ for (guint i = 0; i < reviews->len; i++) {
+ AsReview *review = g_ptr_array_index (reviews, i);
+
+ /* save this on the application object so we can use it for
+ * submitting a new review */
+ if (i == 0) {
+ gs_app_set_metadata (app, "ODRS::user_skey",
+ as_review_get_metadata_item (review, "user_skey"));
+ }
+
+ /* ignore invalid reviews */
+ if (as_review_get_rating (review) == 0)
+ continue;
+
+ /* the user_hash matches, so mark this as our own review */
+ if (g_strcmp0 (as_review_get_reviewer_id (review),
+ self->user_hash) == 0) {
+ as_review_set_flags (review, AS_REVIEW_FLAG_SELF);
+ }
+ gs_app_add_review (app, review);
+ }
+}
+
+static gboolean
+gs_odrs_provider_fetch_reviews_for_app_finish (GsOdrsProvider *self,
+ GAsyncResult *result,
+ GError **error)
+{
+ return g_task_propagate_boolean (G_TASK (result), error);
+}
+
+static gchar *
+gs_odrs_provider_trim_version (const gchar *version)
+{
+ gchar *str;
+ gchar *tmp;
+
+ /* nothing set */
+ if (version == NULL)
+ return g_strdup ("unknown");
+
+ /* remove epoch */
+ str = g_strrstr (version, ":");
+ if (str != NULL)
+ version = str + 1;
+
+ /* remove release */
+ tmp = g_strdup (version);
+ g_strdelimit (tmp, "-", '\0');
+
+ /* remove '+dfsg' suffix */
+ str = g_strstr_len (tmp, -1, "+dfsg");
+ if (str != NULL)
+ *str = '\0';
+
+ return tmp;
+}
+
+static gboolean
+gs_odrs_provider_invalidate_cache (AsReview *review, GError **error)
+{
+ g_autofree gchar *cachefn_basename = NULL;
+ g_autofree gchar *cachefn = NULL;
+ g_autoptr(GFile) cachefn_file = NULL;
+
+ /* look in the cache */
+ cachefn_basename = g_strdup_printf ("%s.json",
+ as_review_get_metadata_item (review, "app_id"));
+ cachefn = gs_utils_get_cache_filename ("odrs",
+ cachefn_basename,
+ GS_UTILS_CACHE_FLAG_WRITEABLE |
+ GS_UTILS_CACHE_FLAG_CREATE_DIRECTORY,
+ error);
+ if (cachefn == NULL)
+ return FALSE;
+ cachefn_file = g_file_new_for_path (cachefn);
+ if (!g_file_query_exists (cachefn_file, NULL))
+ return TRUE;
+ return g_file_delete (cachefn_file, NULL, error);
+}
+
+static gboolean
+gs_odrs_provider_vote (GsOdrsProvider *self,
+ AsReview *review,
+ const gchar *uri,
+ GCancellable *cancellable,
+ GError **error)
+{
+ const gchar *tmp;
+ g_autofree gchar *data = NULL;
+ g_autoptr(JsonBuilder) builder = NULL;
+ g_autoptr(JsonGenerator) json_generator = NULL;
+ g_autoptr(JsonNode) json_root = NULL;
+
+ /* create object with vote data */
+ builder = json_builder_new ();
+ json_builder_begin_object (builder);
+
+ json_builder_set_member_name (builder, "user_hash");
+ json_builder_add_string_value (builder, self->user_hash);
+ json_builder_set_member_name (builder, "user_skey");
+ json_builder_add_string_value (builder,
+ as_review_get_metadata_item (review, "user_skey"));
+ json_builder_set_member_name (builder, "app_id");
+ json_builder_add_string_value (builder,
+ as_review_get_metadata_item (review, "app_id"));
+ tmp = as_review_get_id (review);
+ if (tmp != NULL) {
+ gint64 review_id;
+ json_builder_set_member_name (builder, "review_id");
+ review_id = g_ascii_strtoll (tmp, NULL, 10);
+ json_builder_add_int_value (builder, review_id);
+ }
+ json_builder_end_object (builder);
+
+ /* export as a string */
+ json_root = json_builder_get_root (builder);
+ json_generator = json_generator_new ();
+ json_generator_set_pretty (json_generator, TRUE);
+ json_generator_set_root (json_generator, json_root);
+ data = json_generator_to_data (json_generator, NULL);
+ if (data == NULL)
+ return FALSE;
+
+ /* clear cache */
+ if (!gs_odrs_provider_invalidate_cache (review, error))
+ return FALSE;
+
+ /* send to server */
+ if (!gs_odrs_provider_json_post (self->session, uri, data, cancellable, error))
+ return FALSE;
+
+ /* mark as voted */
+ as_review_add_flags (review, AS_REVIEW_FLAG_VOTED);
+
+ /* success */
+ return TRUE;
+}
+
+static GsApp *
+gs_odrs_provider_create_app_dummy (const gchar *id)
+{
+ GsApp *app = gs_app_new (id);
+ g_autoptr(GString) str = NULL;
+ str = g_string_new (id);
+ as_gstring_replace (str, ".desktop", "");
+ g_string_prepend (str, "No description is available for ");
+ gs_app_set_name (app, GS_APP_QUALITY_LOWEST, "Unknown Application");
+ gs_app_set_summary (app, GS_APP_QUALITY_LOWEST, "Application not found");
+ gs_app_set_description (app, GS_APP_QUALITY_LOWEST, str->str);
+ return app;
+}
+
+static void
+gs_odrs_provider_init (GsOdrsProvider *self)
+{
+ g_mutex_init (&self->ratings_mutex);
+}
+
+static void
+gs_odrs_provider_constructed (GObject *object)
+{
+ GsOdrsProvider *self = GS_ODRS_PROVIDER (object);
+
+ G_OBJECT_CLASS (gs_odrs_provider_parent_class)->constructed (object);
+
+ /* Check all required properties have been set. */
+ g_assert (self->review_server != NULL);
+ g_assert (self->user_hash != NULL);
+ g_assert (self->distro != NULL);
+}
+
+static void
+gs_odrs_provider_get_property (GObject *object,
+ guint prop_id,
+ GValue *value,
+ GParamSpec *pspec)
+{
+ GsOdrsProvider *self = GS_ODRS_PROVIDER (object);
+
+ switch ((GsOdrsProviderProperty) prop_id) {
+ case PROP_REVIEW_SERVER:
+ g_value_set_string (value, self->review_server);
+ break;
+ case PROP_USER_HASH:
+ g_value_set_string (value, self->user_hash);
+ break;
+ case PROP_DISTRO:
+ g_value_set_string (value, self->distro);
+ break;
+ case PROP_MAX_CACHE_AGE_SECS:
+ g_value_set_uint64 (value, self->max_cache_age_secs);
+ break;
+ case PROP_N_RESULTS_MAX:
+ g_value_set_uint (value, self->n_results_max);
+ break;
+ case PROP_SESSION:
+ g_value_set_object (value, self->session);
+ break;
+ default:
+ G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+ break;
+ }
+}
+
+static void
+gs_odrs_provider_set_property (GObject *object,
+ guint prop_id,
+ const GValue *value,
+ GParamSpec *pspec)
+{
+ GsOdrsProvider *self = GS_ODRS_PROVIDER (object);
+
+ switch ((GsOdrsProviderProperty) prop_id) {
+ case PROP_REVIEW_SERVER:
+ /* Construct-only */
+ g_assert (self->review_server == NULL);
+ self->review_server = g_value_dup_string (value);
+ break;
+ case PROP_USER_HASH:
+ /* Construct-only */
+ g_assert (self->user_hash == NULL);
+ self->user_hash = g_value_dup_string (value);
+ break;
+ case PROP_DISTRO:
+ /* Construct-only */
+ g_assert (self->distro == NULL);
+ self->distro = g_value_dup_string (value);
+ break;
+ case PROP_MAX_CACHE_AGE_SECS:
+ /* Construct-only */
+ g_assert (self->max_cache_age_secs == 0);
+ self->max_cache_age_secs = g_value_get_uint64 (value);
+ break;
+ case PROP_N_RESULTS_MAX:
+ /* Construct-only */
+ g_assert (self->n_results_max == 0);
+ self->n_results_max = g_value_get_uint (value);
+ break;
+ case PROP_SESSION:
+ /* Construct-only */
+ g_assert (self->session == NULL);
+ self->session = g_value_dup_object (value);
+ break;
+ default:
+ G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+ break;
+ }
+}
+
+static void
+gs_odrs_provider_dispose (GObject *object)
+{
+ GsOdrsProvider *self = GS_ODRS_PROVIDER (object);
+
+ g_clear_object (&self->session);
+
+ G_OBJECT_CLASS (gs_odrs_provider_parent_class)->dispose (object);
+}
+
+static void
+gs_odrs_provider_finalize (GObject *object)
+{
+ GsOdrsProvider *self = GS_ODRS_PROVIDER (object);
+
+ g_free (self->user_hash);
+ g_free (self->distro);
+ g_free (self->review_server);
+ g_clear_pointer (&self->ratings, g_array_unref);
+ g_mutex_clear (&self->ratings_mutex);
+
+ G_OBJECT_CLASS (gs_odrs_provider_parent_class)->finalize (object);
+}
+
+static void
+gs_odrs_provider_class_init (GsOdrsProviderClass *klass)
+{
+ GObjectClass *object_class = G_OBJECT_CLASS (klass);
+
+ object_class->constructed = gs_odrs_provider_constructed;
+ object_class->get_property = gs_odrs_provider_get_property;
+ object_class->set_property = gs_odrs_provider_set_property;
+ object_class->dispose = gs_odrs_provider_dispose;
+ object_class->finalize = gs_odrs_provider_finalize;
+
+ /**
+ * GsOdrsProvider:review-server: (not nullable)
+ *
+ * The URI of the ODRS review server to contact.
+ *
+ * Since: 41
+ */
+ obj_props[PROP_REVIEW_SERVER] =
+ g_param_spec_string ("review-server", NULL, NULL,
+ NULL,
+ G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS | G_PARAM_CONSTRUCT_ONLY);
+
+ /**
+ * GsOdrsProvider:user-hash: (not nullable)
+ *
+ * An opaque hash of the user identifier, used to identify the user on
+ * the server.
+ *
+ * Since: 41
+ */
+ obj_props[PROP_USER_HASH] =
+ g_param_spec_string ("user-hash", NULL, NULL,
+ NULL,
+ G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS | G_PARAM_CONSTRUCT_ONLY);
+
+ /**
+ * GsOdrsProvider:distro: (not nullable)
+ *
+ * A human readable string identifying the current distribution.
+ *
+ * Since: 41
+ */
+ obj_props[PROP_DISTRO] =
+ g_param_spec_string ("distro", NULL, NULL,
+ NULL,
+ G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS | G_PARAM_CONSTRUCT_ONLY);
+
+ /**
+ * GsOdrsProvider:max-cache-age-secs:
+ *
+ * The maximum age of the ODRS cache files, in seconds. Older files will
+ * be refreshed on demand.
+ *
+ * Since: 41
+ */
+ obj_props[PROP_MAX_CACHE_AGE_SECS] =
+ g_param_spec_uint64 ("max-cache-age-secs", NULL, NULL,
+ 0, G_MAXUINT64, 0,
+ G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS | G_PARAM_CONSTRUCT_ONLY);
+
+ /**
+ * GsOdrsProvider:n-results-max:
+ *
+ * Maximum number of reviews or ratings to download. The default value
+ * of 0 means no limit is applied.
+ *
+ * Since: 41
+ */
+ obj_props[PROP_N_RESULTS_MAX] =
+ g_param_spec_uint ("n-results-max", NULL, NULL,
+ 0, G_MAXUINT, 0,
+ G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS | G_PARAM_CONSTRUCT_ONLY);
+
+ /**
+ * GsOdrsProvider:session: (not nullable)
+ *
+ * #SoupSession to use for downloading things.
+ *
+ * Since: 41
+ */
+ obj_props[PROP_SESSION] =
+ g_param_spec_object ("session", NULL, NULL,
+ SOUP_TYPE_SESSION,
+ G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS | G_PARAM_CONSTRUCT_ONLY);
+
+ g_object_class_install_properties (object_class, G_N_ELEMENTS (obj_props), obj_props);
+}
+
+/**
+ * gs_odrs_provider_new:
+ * @review_server: (not nullable): value for #GsOdrsProvider:review-server
+ * @user_hash: (not nullable): value for #GsOdrsProvider:user-hash
+ * @distro: (not nullable): value for #GsOdrsProvider:distro
+ * @max_cache_age_secs: value for #GsOdrsProvider:max-cache-age-secs
+ * @n_results_max: value for #GsOdrsProvider:n-results-max
+ * @session: value for #GsOdrsProvider:session
+ *
+ * Create a new #GsOdrsProvider. This does no network activity.
+ *
+ * Returns: (transfer full): a new #GsOdrsProvider
+ * Since: 41
+ */
+GsOdrsProvider *
+gs_odrs_provider_new (const gchar *review_server,
+ const gchar *user_hash,
+ const gchar *distro,
+ guint64 max_cache_age_secs,
+ guint n_results_max,
+ SoupSession *session)
+{
+ g_return_val_if_fail (review_server != NULL && *review_server != '\0', NULL);
+ g_return_val_if_fail (user_hash != NULL && *user_hash != '\0', NULL);
+ g_return_val_if_fail (distro != NULL && *distro != '\0', NULL);
+ g_return_val_if_fail (SOUP_IS_SESSION (session), NULL);
+
+ return g_object_new (GS_TYPE_ODRS_PROVIDER,
+ "review-server", review_server,
+ "user-hash", user_hash,
+ "distro", distro,
+ "max-cache-age-secs", max_cache_age_secs,
+ "n-results-max", n_results_max,
+ "session", session,
+ NULL);
+}
+
+static void download_ratings_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data);
+
+/**
+ * gs_odrs_provider_refresh_ratings_async:
+ * @self: a #GsOdrsProvider
+ * @cache_age_secs: cache age, in seconds, as passed to #GsPluginClass.refresh_metadata_async()
+ * @progress_callback: (nullable): callback to call with progress information
+ * @progress_user_data: (nullable) (closure progress_callback): data to pass
+ * to @progress_callback
+ * @cancellable: (nullable): a #GCancellable, or %NULL
+ * @callback: function to call when the asynchronous operation is complete
+ * @user_data: data to pass to @callback
+ *
+ * Refresh the cached ODRS ratings and re-load them asynchronously.
+ *
+ * Since: 42
+ */
+void
+gs_odrs_provider_refresh_ratings_async (GsOdrsProvider *self,
+ guint64 cache_age_secs,
+ GsDownloadProgressCallback progress_callback,
+ gpointer progress_user_data,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data)
+{
+ g_autofree gchar *cache_filename = NULL;
+ g_autoptr(GFile) cache_file = NULL;
+ g_autofree gchar *uri = NULL;
+ g_autoptr(GError) error_local = NULL;
+ g_autoptr(GTask) task = NULL;
+
+ task = g_task_new (self, cancellable, callback, user_data);
+ g_task_set_source_tag (task, gs_odrs_provider_refresh_ratings_async);
+
+ /* check cache age */
+ cache_filename = gs_utils_get_cache_filename ("odrs",
+ "ratings.json",
+ GS_UTILS_CACHE_FLAG_WRITEABLE |
+ GS_UTILS_CACHE_FLAG_CREATE_DIRECTORY,
+ &error_local);
+ if (cache_filename == NULL) {
+ g_task_return_error (task, g_steal_pointer (&error_local));
+ return;
+ }
+
+ cache_file = g_file_new_for_path (cache_filename);
+ g_task_set_task_data (task, g_object_ref (cache_file), g_object_unref);
+
+ if (cache_age_secs > 0) {
+ guint64 tmp;
+
+ tmp = gs_utils_get_file_age (cache_file);
+ if (tmp < cache_age_secs) {
+ g_debug ("%s is only %" G_GUINT64_FORMAT " seconds old, so ignoring refresh",
+ cache_filename, tmp);
+ if (!gs_odrs_provider_load_ratings (self, cache_filename, &error_local)) {
+ g_debug ("Failed to load cache file ‘%s’, deleting it", cache_filename);
+ g_file_delete (cache_file, NULL, NULL);
+
+ g_task_return_error (task, g_steal_pointer (&error_local));
+ } else {
+ g_task_return_boolean (task, TRUE);
+ }
+ return;
+ }
+ }
+
+ /* download the complete file */
+ uri = g_strdup_printf ("%s/ratings", self->review_server);
+ g_debug ("Updating ODRS cache from %s to %s", uri, cache_filename);
+
+ gs_download_file_async (self->session, uri, cache_file, G_PRIORITY_LOW,
+ progress_callback, progress_user_data,
+ cancellable, download_ratings_cb, g_steal_pointer (&task));
+}
+
+static void
+download_ratings_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data)
+{
+ SoupSession *soup_session = SOUP_SESSION (source_object);
+ g_autoptr(GTask) task = g_steal_pointer (&user_data);
+ GsOdrsProvider *self = g_task_get_source_object (task);
+ GFile *cache_file = g_task_get_task_data (task);
+ const gchar *cache_file_path = NULL;
+ g_autoptr(GError) local_error = NULL;
+
+ if (!gs_download_file_finish (soup_session, result, &local_error) &&
+ !g_error_matches (local_error, GS_DOWNLOAD_ERROR, GS_DOWNLOAD_ERROR_NOT_MODIFIED)) {
+ g_task_return_new_error (task, GS_ODRS_PROVIDER_ERROR,
+ GS_ODRS_PROVIDER_ERROR_DOWNLOADING,
+ "%s", local_error->message);
+ return;
+ }
+
+ g_clear_error (&local_error);
+
+ cache_file_path = g_file_peek_path (cache_file);
+ if (!gs_odrs_provider_load_ratings (self, cache_file_path, &local_error)) {
+ g_debug ("Failed to load cache file ‘%s’, deleting it", cache_file_path);
+ g_file_delete (cache_file, NULL, NULL);
+
+ g_task_return_new_error (task, GS_ODRS_PROVIDER_ERROR,
+ GS_ODRS_PROVIDER_ERROR_PARSING_DATA,
+ "%s", local_error->message);
+ } else {
+ g_task_return_boolean (task, TRUE);
+ }
+}
+
+/**
+ * gs_odrs_provider_refresh_ratings_finish:
+ * @self: a #GsOdrsProvider
+ * @result: result of the asynchronous operation
+ * @error: return location for a #GError, or %NULL
+ *
+ * Finish an asynchronous refresh operation started with
+ * gs_odrs_provider_refresh_ratings_async().
+ *
+ * Returns: %TRUE on success, %FALSE otherwise
+ * Since: 42
+ */
+gboolean
+gs_odrs_provider_refresh_ratings_finish (GsOdrsProvider *self,
+ GAsyncResult *result,
+ GError **error)
+{
+ g_return_val_if_fail (GS_IS_ODRS_PROVIDER (self), FALSE);
+ g_return_val_if_fail (g_task_is_valid (result, self), FALSE);
+ g_return_val_if_fail (g_task_get_source_tag (G_TASK (result)) == gs_odrs_provider_refresh_ratings_async, FALSE);
+ g_return_val_if_fail (error == NULL || *error == NULL, FALSE);
+
+ return g_task_propagate_boolean (G_TASK (result), error);
+}
+
+static void refine_app_op (GsOdrsProvider *self,
+ GTask *task,
+ GsApp *app,
+ GsOdrsProviderRefineFlags flags,
+ GCancellable *cancellable);
+static void refine_reviews_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data);
+static void finish_refine_op (GTask *task,
+ GError *error);
+
+typedef struct {
+ /* Input data. */
+ GsAppList *list; /* (owned) (not nullable) */
+ GsOdrsProviderRefineFlags flags;
+
+ /* In-progress data. */
+ guint n_pending_ops;
+ GError *error; /* (nullable) (owned) */
+} RefineData;
+
+static void
+refine_data_free (RefineData *data)
+{
+ g_assert (data->n_pending_ops == 0);
+
+ g_clear_object (&data->list);
+ g_clear_error (&data->error);
+
+ g_free (data);
+}
+
+G_DEFINE_AUTOPTR_CLEANUP_FUNC (RefineData, refine_data_free)
+
+/**
+ * gs_odrs_provider_refine_async:
+ * @self: a #GsOdrsProvider
+ * @list: list of apps to refine
+ * @flags: refine flags
+ * @cancellable: (nullable): a #GCancellable, or %NULL
+ * @callback: callback for asynchronous completion
+ * @user_data: data to pass to @callback
+ *
+ * Asynchronously refine the given @list of apps to add ratings and review data
+ * to them, as specified in @flags.
+ *
+ * Since: 42
+ */
+void
+gs_odrs_provider_refine_async (GsOdrsProvider *self,
+ GsAppList *list,
+ GsOdrsProviderRefineFlags flags,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data)
+{
+ g_autoptr(GTask) task = NULL;
+ g_autoptr(RefineData) data = NULL;
+ RefineData *data_unowned = NULL;
+
+ task = g_task_new (self, cancellable, callback, user_data);
+ g_task_set_source_tag (task, gs_odrs_provider_refine_async);
+
+ data_unowned = data = g_new0 (RefineData, 1);
+ data->list = g_object_ref (list);
+ data->flags = flags;
+ g_task_set_task_data (task, g_steal_pointer (&data), (GDestroyNotify) refine_data_free);
+
+ if ((flags & (GS_ODRS_PROVIDER_REFINE_FLAGS_GET_RATINGS |
+ GS_ODRS_PROVIDER_REFINE_FLAGS_GET_REVIEWS)) == 0) {
+ g_task_return_boolean (task, TRUE);
+ return;
+ }
+
+ /* Mark one operation as pending while all the operations are started,
+ * so the overall operation can’t complete while things are still being
+ * started. */
+ data_unowned->n_pending_ops++;
+
+ for (guint i = 0; i < gs_app_list_length (list); i++) {
+ GsApp *app = gs_app_list_index (list, i);
+
+ /* not valid */
+ if (gs_app_get_kind (app) == AS_COMPONENT_KIND_ADDON)
+ continue;
+ if (gs_app_get_id (app) == NULL)
+ continue;
+
+ data_unowned->n_pending_ops++;
+ refine_app_op (self, task, app, flags, cancellable);
+ }
+
+ finish_refine_op (task, NULL);
+}
+
+static void
+refine_app_op (GsOdrsProvider *self,
+ GTask *task,
+ GsApp *app,
+ GsOdrsProviderRefineFlags flags,
+ GCancellable *cancellable)
+{
+ g_autoptr(GError) local_error = NULL;
+
+ /* add ratings if possible */
+ if ((flags & GS_ODRS_PROVIDER_REFINE_FLAGS_GET_RATINGS) &&
+ gs_app_get_review_ratings (app) == NULL) {
+ if (!gs_odrs_provider_refine_ratings (self, app, cancellable, &local_error)) {
+ if (g_error_matches (local_error, GS_ODRS_PROVIDER_ERROR, GS_ODRS_PROVIDER_ERROR_NO_NETWORK)) {
+ g_debug ("failed to refine app %s: %s",
+ gs_app_get_unique_id (app), local_error->message);
+ } else {
+ g_prefix_error (&local_error, "failed to refine app: ");
+ finish_refine_op (task, g_steal_pointer (&local_error));
+ return;
+ }
+ }
+ }
+
+ /* add reviews if possible */
+ if ((flags & GS_ODRS_PROVIDER_REFINE_FLAGS_GET_REVIEWS) &&
+ gs_app_get_reviews (app)->len == 0) {
+ /* get from server asynchronously */
+ gs_odrs_provider_fetch_reviews_for_app_async (self, app, cancellable, refine_reviews_cb, g_object_ref (task));
+ } else {
+ finish_refine_op (task, NULL);
+ }
+}
+
+static void
+refine_reviews_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data)
+{
+ GsOdrsProvider *self = GS_ODRS_PROVIDER (source_object);
+ g_autoptr(GTask) task = g_steal_pointer (&user_data);
+ g_autoptr(GError) local_error = NULL;
+
+ if (!gs_odrs_provider_fetch_reviews_for_app_finish (self, result, &local_error)) {
+ if (g_error_matches (local_error, GS_ODRS_PROVIDER_ERROR, GS_ODRS_PROVIDER_ERROR_NO_NETWORK)) {
+ g_debug ("failed to refine app: %s", local_error->message);
+ } else {
+ g_prefix_error (&local_error, "failed to refine app: ");
+ finish_refine_op (task, g_steal_pointer (&local_error));
+ return;
+ }
+ }
+
+ finish_refine_op (task, NULL);
+}
+
+/* @error is (transfer full) if non-NULL. */
+static void
+finish_refine_op (GTask *task,
+ GError *error)
+{
+ RefineData *data = g_task_get_task_data (task);
+ g_autoptr(GError) error_owned = g_steal_pointer (&error);
+
+ if (data->error == NULL && error_owned != NULL)
+ data->error = g_steal_pointer (&error_owned);
+ else if (error_owned != NULL)
+ g_debug ("Additional error while refining ODRS data: %s", error_owned->message);
+
+ g_assert (data->n_pending_ops > 0);
+ data->n_pending_ops--;
+
+ if (data->n_pending_ops == 0) {
+ if (data->error != NULL)
+ g_task_return_error (task, g_steal_pointer (&data->error));
+ else
+ g_task_return_boolean (task, TRUE);
+ }
+}
+
+/**
+ * gs_odrs_provider_refine_finish:
+ * @self: a #GsOdrsProvider
+ * @result: result of the asynchronous operation
+ * @error: return location for a #GError, or %NULL
+ *
+ * Finish an asynchronous refine operation started with
+ * gs_odrs_provider_refine_finish().
+ *
+ * Returns: %TRUE on success, %FALSE otherwise
+ * Since: 42
+ */
+gboolean
+gs_odrs_provider_refine_finish (GsOdrsProvider *self,
+ GAsyncResult *result,
+ GError **error)
+{
+ g_return_val_if_fail (GS_IS_ODRS_PROVIDER (self), FALSE);
+ g_return_val_if_fail (g_task_is_valid (result, self), FALSE);
+ g_return_val_if_fail (g_async_result_is_tagged (result, gs_odrs_provider_refine_async), FALSE);
+ g_return_val_if_fail (error == NULL || *error == NULL, FALSE);
+
+ return g_task_propagate_boolean (G_TASK (result), error);
+}
+
+/**
+ * gs_odrs_provider_submit_review:
+ * @self: a #GsOdrsProvider
+ * @app: the app being reviewed
+ * @review: the review
+ * @cancellable: (nullable): a #GCancellable, or %NULL
+ * @error: return location for a #GError
+ *
+ * Submit a new @review for @app.
+ *
+ * Returns: %TRUE on success, %FALSE otherwise
+ * Since: 41
+ */
+gboolean
+gs_odrs_provider_submit_review (GsOdrsProvider *self,
+ GsApp *app,
+ AsReview *review,
+ GCancellable *cancellable,
+ GError **error)
+{
+ g_autofree gchar *data = NULL;
+ g_autofree gchar *uri = NULL;
+ g_autofree gchar *version = NULL;
+ g_autoptr(JsonBuilder) builder = NULL;
+ g_autoptr(JsonGenerator) json_generator = NULL;
+ g_autoptr(JsonNode) json_root = NULL;
+
+ /* save as we don't re-request the review from the server */
+ as_review_add_flags (review, AS_REVIEW_FLAG_SELF);
+ as_review_set_reviewer_name (review, g_get_real_name ());
+ as_review_add_metadata (review, "app_id", gs_app_get_id (app));
+ as_review_add_metadata (review, "user_skey",
+ gs_app_get_metadata_item (app, "ODRS::user_skey"));
+
+ /* create object with review data */
+ builder = json_builder_new ();
+ json_builder_begin_object (builder);
+ json_builder_set_member_name (builder, "user_hash");
+ json_builder_add_string_value (builder, self->user_hash);
+ json_builder_set_member_name (builder, "user_skey");
+ json_builder_add_string_value (builder,
+ as_review_get_metadata_item (review, "user_skey"));
+ json_builder_set_member_name (builder, "app_id");
+ json_builder_add_string_value (builder,
+ as_review_get_metadata_item (review, "app_id"));
+ json_builder_set_member_name (builder, "locale");
+ json_builder_add_string_value (builder, setlocale (LC_MESSAGES, NULL));
+ json_builder_set_member_name (builder, "distro");
+ json_builder_add_string_value (builder, self->distro);
+ json_builder_set_member_name (builder, "version");
+ version = gs_odrs_provider_trim_version (as_review_get_version (review));
+ json_builder_add_string_value (builder, version);
+ json_builder_set_member_name (builder, "user_display");
+ json_builder_add_string_value (builder, as_review_get_reviewer_name (review));
+ json_builder_set_member_name (builder, "summary");
+ json_builder_add_string_value (builder, as_review_get_summary (review));
+ json_builder_set_member_name (builder, "description");
+ json_builder_add_string_value (builder, as_review_get_description (review));
+ json_builder_set_member_name (builder, "rating");
+ json_builder_add_int_value (builder, as_review_get_rating (review));
+ json_builder_end_object (builder);
+
+ /* export as a string */
+ json_root = json_builder_get_root (builder);
+ json_generator = json_generator_new ();
+ json_generator_set_pretty (json_generator, TRUE);
+ json_generator_set_root (json_generator, json_root);
+ data = json_generator_to_data (json_generator, NULL);
+
+ /* clear cache */
+ if (!gs_odrs_provider_invalidate_cache (review, error))
+ return FALSE;
+
+ /* POST */
+ uri = g_strdup_printf ("%s/submit", self->review_server);
+ if (!gs_odrs_provider_json_post (self->session, uri, data, cancellable, error))
+ return FALSE;
+
+ /* modify the local app */
+ gs_app_add_review (app, review);
+
+ return TRUE;
+}
+
+/**
+ * gs_odrs_provider_report_review:
+ * @self: a #GsOdrsProvider
+ * @app: the app whose review is being reported
+ * @review: the review to report
+ * @cancellable: (nullable): a #GCancellable, or %NULL
+ * @error: return location for a #GError
+ *
+ * Report the given @review on @app for being incorrect or breaking the code of
+ * conduct.
+ *
+ * Returns: %TRUE on success, %FALSE otherwise
+ * Since: 41
+ */
+gboolean
+gs_odrs_provider_report_review (GsOdrsProvider *self,
+ GsApp *app,
+ AsReview *review,
+ GCancellable *cancellable,
+ GError **error)
+{
+ g_autofree gchar *uri = NULL;
+ uri = g_strdup_printf ("%s/report", self->review_server);
+ return gs_odrs_provider_vote (self, review, uri, cancellable, error);
+}
+
+/**
+ * gs_odrs_provider_upvote_review:
+ * @self: a #GsOdrsProvider
+ * @app: the app whose review is being upvoted
+ * @review: the review to upvote
+ * @cancellable: (nullable): a #GCancellable, or %NULL
+ * @error: return location for a #GError
+ *
+ * Add one vote to @review on @app.
+ *
+ * Returns: %TRUE on success, %FALSE otherwise
+ * Since: 41
+ */
+gboolean
+gs_odrs_provider_upvote_review (GsOdrsProvider *self,
+ GsApp *app,
+ AsReview *review,
+ GCancellable *cancellable,
+ GError **error)
+{
+ g_autofree gchar *uri = NULL;
+ uri = g_strdup_printf ("%s/upvote", self->review_server);
+ return gs_odrs_provider_vote (self, review, uri, cancellable, error);
+}
+
+/**
+ * gs_odrs_provider_downvote_review:
+ * @self: a #GsOdrsProvider
+ * @app: the app whose review is being downvoted
+ * @review: the review to downvote
+ * @cancellable: (nullable): a #GCancellable, or %NULL
+ * @error: return location for a #GError
+ *
+ * Remove one vote from @review on @app.
+ *
+ * Returns: %TRUE on success, %FALSE otherwise
+ * Since: 41
+ */
+gboolean
+gs_odrs_provider_downvote_review (GsOdrsProvider *self,
+ GsApp *app,
+ AsReview *review,
+ GCancellable *cancellable,
+ GError **error)
+{
+ g_autofree gchar *uri = NULL;
+ uri = g_strdup_printf ("%s/downvote", self->review_server);
+ return gs_odrs_provider_vote (self, review, uri, cancellable, error);
+}
+
+/**
+ * gs_odrs_provider_dismiss_review:
+ * @self: a #GsOdrsProvider
+ * @app: the app whose review is being dismissed
+ * @review: the review to dismiss
+ * @cancellable: (nullable): a #GCancellable, or %NULL
+ * @error: return location for a #GError
+ *
+ * Dismiss (ignore) @review on @app when moderating.
+ *
+ * Returns: %TRUE on success, %FALSE otherwise
+ * Since: 41
+ */
+gboolean
+gs_odrs_provider_dismiss_review (GsOdrsProvider *self,
+ GsApp *app,
+ AsReview *review,
+ GCancellable *cancellable,
+ GError **error)
+{
+ g_autofree gchar *uri = NULL;
+ uri = g_strdup_printf ("%s/dismiss", self->review_server);
+ return gs_odrs_provider_vote (self, review, uri, cancellable, error);
+}
+
+/**
+ * gs_odrs_provider_remove_review:
+ * @self: a #GsOdrsProvider
+ * @app: the app whose review is being removed
+ * @review: the review to remove
+ * @cancellable: (nullable): a #GCancellable, or %NULL
+ * @error: return location for a #GError
+ *
+ * Remove a @review written by the user, from @app.
+ *
+ * Returns: %TRUE on success, %FALSE otherwise
+ * Since: 41
+ */
+gboolean
+gs_odrs_provider_remove_review (GsOdrsProvider *self,
+ GsApp *app,
+ AsReview *review,
+ GCancellable *cancellable,
+ GError **error)
+{
+ g_autofree gchar *uri = NULL;
+ uri = g_strdup_printf ("%s/remove", self->review_server);
+ if (!gs_odrs_provider_vote (self, review, uri, cancellable, error))
+ return FALSE;
+
+ /* update the local app */
+ gs_app_remove_review (app, review);
+
+ return TRUE;
+}
+
+/**
+ * gs_odrs_provider_add_unvoted_reviews:
+ * @self: a #GsOdrsProvider
+ * @list: list of apps to add unvoted reviews to
+ * @cancellable: (nullable): a #GCancellable, or %NULL
+ * @error: return location for a #GError
+ *
+ * Add the unmoderated reviews for each app in @list to the apps.
+ *
+ * Returns: %TRUE on success, %FALSE otherwise
+ * Since: 41
+ */
+gboolean
+gs_odrs_provider_add_unvoted_reviews (GsOdrsProvider *self,
+ GsAppList *list,
+ GCancellable *cancellable,
+ GError **error)
+{
+ guint status_code;
+ guint i;
+ gconstpointer downloaded_data;
+ gsize downloaded_data_length;
+ g_autofree gchar *uri = NULL;
+ g_autoptr(GHashTable) hash = NULL;
+ g_autoptr(JsonParser) json_parser = NULL;
+ g_autoptr(GPtrArray) reviews = NULL;
+ g_autoptr(SoupMessage) msg = NULL;
+#if SOUP_CHECK_VERSION(3, 0, 0)
+ g_autoptr(GBytes) bytes = NULL;
+#endif
+ g_autoptr(GError) local_error = NULL;
+
+ /* create the GET data *with* the machine hash so we can later
+ * review the application ourselves */
+ uri = g_strdup_printf ("%s/moderate/%s/%s",
+ self->review_server,
+ self->user_hash,
+ setlocale (LC_MESSAGES, NULL));
+ msg = soup_message_new (SOUP_METHOD_GET, uri);
+#if SOUP_CHECK_VERSION(3, 0, 0)
+ bytes = soup_session_send_and_read (self->session, msg, cancellable, error);
+ if (bytes == NULL)
+ return FALSE;
+
+ downloaded_data = g_bytes_get_data (bytes, &downloaded_data_length);
+ status_code = soup_message_get_status (msg);
+#else
+ status_code = soup_session_send_message (self->session, msg);
+ downloaded_data = msg->response_body ? msg->response_body->data : NULL;
+ downloaded_data_length = msg->response_body ? msg->response_body->length : 0;
+#endif
+ if (status_code != SOUP_STATUS_OK) {
+ g_autoptr(GInputStream) input_stream = g_memory_input_stream_new_from_data (downloaded_data, downloaded_data_length, NULL);
+ if (!gs_odrs_provider_parse_success (input_stream, error))
+ return FALSE;
+ /* not sure what to do here */
+ g_set_error_literal (error,
+ GS_ODRS_PROVIDER_ERROR,
+ GS_ODRS_PROVIDER_ERROR_DOWNLOADING,
+ "status code invalid");
+ return FALSE;
+ }
+ g_debug ("odrs returned: %.*s", (gint) downloaded_data_length, (const gchar *) downloaded_data);
+
+ /* nothing */
+ if (downloaded_data == NULL) {
+ if (!g_network_monitor_get_network_available (g_network_monitor_get_default ()))
+ g_set_error_literal (error,
+ GS_ODRS_PROVIDER_ERROR,
+ GS_ODRS_PROVIDER_ERROR_NO_NETWORK,
+ "server couldn't be reached");
+ else
+ g_set_error_literal (error,
+ GS_ODRS_PROVIDER_ERROR,
+ GS_ODRS_PROVIDER_ERROR_PARSING_DATA,
+ "server returned no data");
+ return FALSE;
+ }
+
+ /* parse the data and find the array of ratings */
+ json_parser = json_parser_new_immutable ();
+ if (!json_parser_load_from_data (json_parser, downloaded_data, downloaded_data_length, &local_error)) {
+ g_set_error (error,
+ GS_ODRS_PROVIDER_ERROR,
+ GS_ODRS_PROVIDER_ERROR_PARSING_DATA,
+ "Error parsing ODRS data: %s", local_error->message);
+ return FALSE;
+ }
+
+ reviews = gs_odrs_provider_parse_reviews (self, json_parser, error);
+ if (reviews == NULL)
+ return FALSE;
+
+ /* look at all the reviews; faking application objects */
+ hash = g_hash_table_new_full (g_str_hash, g_str_equal,
+ g_free, g_object_unref);
+ for (i = 0; i < reviews->len; i++) {
+ GsApp *app;
+ AsReview *review;
+ const gchar *app_id;
+
+ /* same app? */
+ review = g_ptr_array_index (reviews, i);
+ app_id = as_review_get_metadata_item (review, "app_id");
+ app = g_hash_table_lookup (hash, app_id);
+ if (app == NULL) {
+ app = gs_odrs_provider_create_app_dummy (app_id);
+ gs_app_list_add (list, app);
+ g_hash_table_insert (hash, g_strdup (app_id), app);
+ }
+ gs_app_add_review (app, review);
+ }
+
+ return TRUE;
+}
diff --git a/lib/gs-odrs-provider.h b/lib/gs-odrs-provider.h
new file mode 100644
index 0000000..f02ffde
--- /dev/null
+++ b/lib/gs-odrs-provider.h
@@ -0,0 +1,125 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2021 Endless OS Foundation LLC
+ *
+ * Author: Philip Withnall <pwithnall@endlessos.org>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#include <glib.h>
+#include <glib-object.h>
+#include <libsoup/soup.h>
+
+#include "gs-app-list.h"
+#include "gs-download-utils.h"
+
+G_BEGIN_DECLS
+
+/**
+ * GsOdrsProviderError:
+ * @GS_ODRS_PROVIDER_ERROR_DOWNLOADING: Error while downloading ODRS data.
+ * @GS_ODRS_PROVIDER_ERROR_PARSING_DATA: Problem parsing downloaded ODRS data.
+ * @GS_ODRS_PROVIDER_ERROR_NO_NETWORK: Offline or network unavailable.
+ * @GS_ODRS_PROVIDER_ERROR_SERVER_ERROR: Server rejected ODRS submission or returned an error.
+ *
+ * Error codes for #GsOdrsProvider.
+ *
+ * Since: 42
+ */
+typedef enum {
+ GS_ODRS_PROVIDER_ERROR_DOWNLOADING,
+ GS_ODRS_PROVIDER_ERROR_PARSING_DATA,
+ GS_ODRS_PROVIDER_ERROR_NO_NETWORK,
+ GS_ODRS_PROVIDER_ERROR_SERVER_ERROR,
+} GsOdrsProviderError;
+
+#define GS_ODRS_PROVIDER_ERROR gs_odrs_provider_error_quark ()
+GQuark gs_odrs_provider_error_quark (void);
+
+/**
+ * GsOdrsProviderRefineFlags:
+ * @GS_ODRS_PROVIDER_REFINE_FLAGS_GET_RATINGS: Get the numerical ratings for the app.
+ * @GS_ODRS_PROVIDER_REFINE_FLAGS_GET_REVIEWS: Get the written reviews for the app.
+ *
+ * The flags for refining apps to get their reviews or ratings.
+ *
+ * Since: 42
+ */
+typedef enum {
+ GS_ODRS_PROVIDER_REFINE_FLAGS_GET_RATINGS = (1 << 0),
+ GS_ODRS_PROVIDER_REFINE_FLAGS_GET_REVIEWS = (1 << 1),
+} GsOdrsProviderRefineFlags;
+
+#define GS_TYPE_ODRS_PROVIDER (gs_odrs_provider_get_type ())
+
+G_DECLARE_FINAL_TYPE (GsOdrsProvider, gs_odrs_provider, GS, ODRS_PROVIDER, GObject)
+
+GsOdrsProvider *gs_odrs_provider_new (const gchar *review_server,
+ const gchar *user_hash,
+ const gchar *distro,
+ guint64 max_cache_age_secs,
+ guint n_results_max,
+ SoupSession *session);
+
+void gs_odrs_provider_refresh_ratings_async (GsOdrsProvider *self,
+ guint64 cache_age_secs,
+ GsDownloadProgressCallback progress_callback,
+ gpointer progress_user_data,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data);
+gboolean gs_odrs_provider_refresh_ratings_finish(GsOdrsProvider *self,
+ GAsyncResult *result,
+ GError **error);
+
+void gs_odrs_provider_refine_async (GsOdrsProvider *self,
+ GsAppList *list,
+ GsOdrsProviderRefineFlags flags,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data);
+gboolean gs_odrs_provider_refine_finish (GsOdrsProvider *self,
+ GAsyncResult *result,
+ GError **error);
+
+gboolean gs_odrs_provider_submit_review (GsOdrsProvider *self,
+ GsApp *app,
+ AsReview *review,
+ GCancellable *cancellable,
+ GError **error);
+gboolean gs_odrs_provider_report_review (GsOdrsProvider *self,
+ GsApp *app,
+ AsReview *review,
+ GCancellable *cancellable,
+ GError **error);
+gboolean gs_odrs_provider_upvote_review (GsOdrsProvider *self,
+ GsApp *app,
+ AsReview *review,
+ GCancellable *cancellable,
+ GError **error);
+gboolean gs_odrs_provider_downvote_review (GsOdrsProvider *self,
+ GsApp *app,
+ AsReview *review,
+ GCancellable *cancellable,
+ GError **error);
+gboolean gs_odrs_provider_dismiss_review (GsOdrsProvider *self,
+ GsApp *app,
+ AsReview *review,
+ GCancellable *cancellable,
+ GError **error);
+gboolean gs_odrs_provider_remove_review (GsOdrsProvider *self,
+ GsApp *app,
+ AsReview *review,
+ GCancellable *cancellable,
+ GError **error);
+
+gboolean gs_odrs_provider_add_unvoted_reviews (GsOdrsProvider *self,
+ GsAppList *list,
+ GCancellable *cancellable,
+ GError **error);
+
+G_END_DECLS
diff --git a/lib/gs-os-release.c b/lib/gs-os-release.c
new file mode 100644
index 0000000..191a385
--- /dev/null
+++ b/lib/gs-os-release.c
@@ -0,0 +1,347 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2016 Kalev Lember <klember@redhat.com>
+ * Copyright (C) 2016 Richard Hughes <richard@hughsie.com>
+ *
+ * SPDX-License-Identifier: LGPL-2.1+
+ */
+
+/**
+ * SECTION:gs-os-release
+ * @title: GsOsRelease
+ * @include: gnome-software.h
+ * @stability: Unstable
+ * @short_description: Data from os-release
+ *
+ * This object allows plugins to parse /etc/os-release for distribution
+ * metadata information.
+ */
+
+#include "config.h"
+
+#include <glib.h>
+
+#include "gs-os-release.h"
+
+struct _GsOsRelease
+{
+ GObject parent_instance;
+ gchar *name;
+ gchar *version;
+ gchar *id;
+ gchar **id_like;
+ gchar *version_id;
+ gchar *pretty_name;
+ gchar *cpe_name;
+ gchar *distro_codename;
+ gchar *home_url;
+};
+
+static void gs_os_release_initable_iface_init (GInitableIface *iface);
+
+G_DEFINE_TYPE_WITH_CODE (GsOsRelease, gs_os_release, G_TYPE_OBJECT,
+ G_IMPLEMENT_INTERFACE(G_TYPE_INITABLE, gs_os_release_initable_iface_init))
+
+static gboolean
+gs_os_release_initable_init (GInitable *initable,
+ GCancellable *cancellable,
+ GError **error)
+{
+ GsOsRelease *os_release = GS_OS_RELEASE (initable);
+ const gchar *filename;
+ g_autofree gchar *data = NULL;
+ g_auto(GStrv) lines = NULL;
+ guint i;
+
+ g_return_val_if_fail (GS_IS_OS_RELEASE (os_release), FALSE);
+ g_return_val_if_fail (error == NULL || *error == NULL, FALSE);
+
+ /* get contents */
+ filename = g_getenv ("GS_SELF_TEST_OS_RELEASE_FILENAME");
+ if (filename == NULL) {
+ filename = "/etc/os-release";
+ if (!g_file_test (filename, G_FILE_TEST_EXISTS))
+ filename = "/usr/lib/os-release";
+ }
+ if (!g_file_get_contents (filename, &data, NULL, error))
+ return FALSE;
+
+ /* parse */
+ lines = g_strsplit (data, "\n", -1);
+ for (i = 0; lines[i] != NULL; i++) {
+ gchar *tmp;
+
+ /* split the line up into two halves */
+ tmp = g_strstr_len (lines[i], -1, "=");
+ if (tmp == NULL)
+ continue;
+ *tmp = '\0';
+ tmp++;
+
+ /* ignore trailing quote */
+ if (tmp[0] == '\"')
+ tmp++;
+
+ /* ignore trailing quote */
+ g_strdelimit (tmp, "\"", '\0');
+
+ /* match fields we're interested in */
+ if (g_strcmp0 (lines[i], "NAME") == 0) {
+ os_release->name = g_strdup (tmp);
+ continue;
+ }
+ if (g_strcmp0 (lines[i], "VERSION") == 0) {
+ os_release->version = g_strdup (tmp);
+ continue;
+ }
+ if (g_strcmp0 (lines[i], "ID") == 0) {
+ os_release->id = g_strdup (tmp);
+ continue;
+ }
+ if (g_strcmp0 (lines[i], "ID_LIKE") == 0) {
+ os_release->id_like = g_strsplit (tmp, " ", 0);
+ continue;
+ }
+ if (g_strcmp0 (lines[i], "VERSION_ID") == 0) {
+ os_release->version_id = g_strdup (tmp);
+ continue;
+ }
+ if (g_strcmp0 (lines[i], "PRETTY_NAME") == 0) {
+ os_release->pretty_name = g_strdup (tmp);
+ continue;
+ }
+ if (g_strcmp0 (lines[i], "CPE_NAME") == 0) {
+ os_release->cpe_name = g_strdup (tmp);
+ continue;
+ }
+ if (g_strcmp0 (lines[i], "UBUNTU_CODENAME") == 0) {
+ os_release->distro_codename = g_strdup (tmp);
+ continue;
+ }
+ if (g_strcmp0 (lines[i], "HOME_URL") == 0) {
+ os_release->home_url = g_strdup (tmp);
+ continue;
+ }
+ }
+ return TRUE;
+}
+
+/**
+ * gs_os_release_get_name:
+ * @os_release: A #GsOsRelease
+ *
+ * Gets the name from the os-release parser.
+ *
+ * Returns: a string, or %NULL
+ *
+ * Since: 3.22
+ **/
+const gchar *
+gs_os_release_get_name (GsOsRelease *os_release)
+{
+ g_return_val_if_fail (GS_IS_OS_RELEASE (os_release), NULL);
+ return os_release->name;
+}
+
+/**
+ * gs_os_release_get_version:
+ * @os_release: A #GsOsRelease
+ *
+ * Gets the version from the os-release parser.
+ *
+ * Returns: a string, or %NULL
+ *
+ * Since: 3.22
+ **/
+const gchar *
+gs_os_release_get_version (GsOsRelease *os_release)
+{
+ g_return_val_if_fail (GS_IS_OS_RELEASE (os_release), NULL);
+ return os_release->version;
+}
+
+/**
+ * gs_os_release_get_id:
+ * @os_release: A #GsOsRelease
+ *
+ * Gets the ID from the os-release parser.
+ *
+ * Returns: a string, or %NULL
+ *
+ * Since: 3.22
+ **/
+const gchar *
+gs_os_release_get_id (GsOsRelease *os_release)
+{
+ g_return_val_if_fail (GS_IS_OS_RELEASE (os_release), NULL);
+ return os_release->id;
+}
+
+/**
+ * gs_os_release_get_id_like:
+ * @os_release: A #GsOsRelease
+ *
+ * Gets the ID_LIKE from the os-release parser. This is a list of operating
+ * systems that are "closely related" to the local operating system, possibly
+ * by being a derivative distribution.
+ *
+ * Returns: a %NULL terminated list
+ *
+ * Since: 3.26.2
+ **/
+const gchar * const *
+gs_os_release_get_id_like (GsOsRelease *os_release)
+{
+ g_return_val_if_fail (GS_IS_OS_RELEASE (os_release), NULL);
+ return (const gchar * const *) os_release->id_like;
+}
+
+/**
+ * gs_os_release_get_version_id:
+ * @os_release: A #GsOsRelease
+ *
+ * Gets the version ID from the os-release parser.
+ *
+ * Returns: a string, or %NULL
+ *
+ * Since: 3.22
+ **/
+const gchar *
+gs_os_release_get_version_id (GsOsRelease *os_release)
+{
+ g_return_val_if_fail (GS_IS_OS_RELEASE (os_release), NULL);
+ return os_release->version_id;
+}
+
+/**
+ * gs_os_release_get_pretty_name:
+ * @os_release: A #GsOsRelease
+ *
+ * Gets the pretty name from the os-release parser.
+ *
+ * Returns: a string, or %NULL
+ *
+ * Since: 3.22
+ **/
+const gchar *
+gs_os_release_get_pretty_name (GsOsRelease *os_release)
+{
+ g_return_val_if_fail (GS_IS_OS_RELEASE (os_release), NULL);
+ return os_release->pretty_name;
+}
+
+/**
+ * gs_os_release_get_cpe_name:
+ * @os_release: A #GsOsRelease
+ *
+ * Gets the pretty name from the os-release parser.
+ *
+ * Returns: a string, or %NULL
+ *
+ * Since: 3.22
+ **/
+const gchar *
+gs_os_release_get_cpe_name (GsOsRelease *os_release)
+{
+ g_return_val_if_fail (GS_IS_OS_RELEASE (os_release), NULL);
+ return os_release->cpe_name;
+}
+
+/**
+ * gs_os_release_get_distro_codename:
+ * @os_release: A #GsOsRelease
+ *
+ * Gets the distro codename from the os-release parser.
+ *
+ * Returns: a string, or %NULL
+ *
+ * Since: 3.22
+ **/
+const gchar *
+gs_os_release_get_distro_codename (GsOsRelease *os_release)
+{
+ g_return_val_if_fail (GS_IS_OS_RELEASE (os_release), NULL);
+ return os_release->distro_codename;
+}
+
+/**
+ * gs_os_release_get_home_url:
+ * @os_release: A #GsOsRelease
+ *
+ * Gets the home URL from the os-release parser.
+ *
+ * Returns: a string, or %NULL
+ *
+ * Since: 3.22
+ **/
+const gchar *
+gs_os_release_get_home_url (GsOsRelease *os_release)
+{
+ g_return_val_if_fail (GS_IS_OS_RELEASE (os_release), NULL);
+ return os_release->home_url;
+}
+
+static void
+gs_os_release_finalize (GObject *object)
+{
+ GsOsRelease *os_release = GS_OS_RELEASE (object);
+ g_free (os_release->name);
+ g_free (os_release->version);
+ g_free (os_release->id);
+ g_strfreev (os_release->id_like);
+ g_free (os_release->version_id);
+ g_free (os_release->pretty_name);
+ g_free (os_release->cpe_name);
+ g_free (os_release->distro_codename);
+ g_free (os_release->home_url);
+ G_OBJECT_CLASS (gs_os_release_parent_class)->finalize (object);
+}
+
+static void
+gs_os_release_class_init (GsOsReleaseClass *klass)
+{
+ GObjectClass *object_class = G_OBJECT_CLASS (klass);
+ object_class->finalize = gs_os_release_finalize;
+}
+
+static void
+gs_os_release_initable_iface_init (GInitableIface *iface)
+{
+ iface->init = gs_os_release_initable_init;
+}
+
+static void
+gs_os_release_init (GsOsRelease *os_release)
+{
+}
+
+/**
+ * gs_os_release_new:
+ * @error: a #GError, or %NULL
+ *
+ * Returns a new reference to a #GsOsRelease. The information may be cached.
+ *
+ * Returns: (transfer full): A new reference to a #GsOsRelease, or %NULL for error
+ *
+ * Since: 3.22
+ **/
+GsOsRelease *
+gs_os_release_new (GError **error)
+{
+ static gsize initialised = 0;
+ static GsOsRelease *os_release = NULL;
+ static GError *os_release_error = NULL;
+
+ if (g_once_init_enter (&initialised)) {
+ os_release = g_initable_new (GS_TYPE_OS_RELEASE, NULL, &os_release_error, NULL);
+ g_once_init_leave (&initialised, 1);
+ }
+
+ if (os_release != NULL) {
+ return g_object_ref (os_release);
+ } else {
+ g_propagate_error (error, g_error_copy (os_release_error));
+ return NULL;
+ }
+}
diff --git a/lib/gs-os-release.h b/lib/gs-os-release.h
new file mode 100644
index 0000000..336f17c
--- /dev/null
+++ b/lib/gs-os-release.h
@@ -0,0 +1,33 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2016 Kalev Lember <klember@redhat.com>
+ * Copyright (C) 2016 Richard Hughes <richard@hughsie.com>
+ *
+ * SPDX-License-Identifier: LGPL-2.1+
+ */
+
+#pragma once
+
+#include <glib-object.h>
+
+#include "gs-app.h"
+
+G_BEGIN_DECLS
+
+#define GS_TYPE_OS_RELEASE (gs_os_release_get_type ())
+
+G_DECLARE_FINAL_TYPE (GsOsRelease, gs_os_release, GS, OS_RELEASE, GObject)
+
+GsOsRelease *gs_os_release_new (GError **error);
+const gchar *gs_os_release_get_name (GsOsRelease *os_release);
+const gchar *gs_os_release_get_version (GsOsRelease *os_release);
+const gchar *gs_os_release_get_id (GsOsRelease *os_release);
+const gchar * const *gs_os_release_get_id_like (GsOsRelease *os_release);
+const gchar *gs_os_release_get_version_id (GsOsRelease *os_release);
+const gchar *gs_os_release_get_pretty_name (GsOsRelease *os_release);
+const gchar *gs_os_release_get_cpe_name (GsOsRelease *os_release);
+const gchar *gs_os_release_get_distro_codename (GsOsRelease *os_release);
+const gchar *gs_os_release_get_home_url (GsOsRelease *os_release);
+
+G_END_DECLS
diff --git a/lib/gs-plugin-event.c b/lib/gs-plugin-event.c
new file mode 100644
index 0000000..699e529
--- /dev/null
+++ b/lib/gs-plugin-event.c
@@ -0,0 +1,453 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2016 Richard Hughes <richard@hughsie.com>
+ * Copyright (C) 2016 Kalev Lember <klember@redhat.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+/**
+ * SECTION:gs-plugin-event
+ * @title: GsPluginEvent
+ * @include: gnome-software.h
+ * @stability: Unstable
+ * @short_description: Information about a plugin event
+ *
+ * These functions provide a way for plugins to tell the UI layer about events
+ * that may require displaying to the user. Plugins should not assume that a
+ * specific event is actually shown to the user as it may be ignored
+ * automatically.
+ */
+
+#include "config.h"
+
+#include <glib.h>
+
+#include "gs-enums.h"
+#include "gs-plugin-private.h"
+#include "gs-plugin-event.h"
+#include "gs-plugin-job.h"
+#include "gs-utils.h"
+
+struct _GsPluginEvent
+{
+ GObject parent_instance;
+ GsApp *app;
+ GsApp *origin;
+ GsPluginAction action;
+ GsPluginJob *job; /* (owned) (nullable) */
+ GError *error;
+ GsPluginEventFlag flags;
+ gchar *unique_id;
+};
+
+G_DEFINE_TYPE (GsPluginEvent, gs_plugin_event, G_TYPE_OBJECT)
+
+typedef enum {
+ PROP_APP = 1,
+ PROP_ORIGIN,
+ PROP_ACTION,
+ PROP_JOB,
+ PROP_ERROR,
+} GsPluginEventProperty;
+
+static GParamSpec *props[PROP_ERROR + 1] = { NULL, };
+
+/**
+ * gs_plugin_event_get_app:
+ * @event: A #GsPluginEvent
+ *
+ * Gets an application that created the event.
+ *
+ * Returns: (transfer none): a #GsApp, or %NULL if unset
+ *
+ * Since: 3.22
+ **/
+GsApp *
+gs_plugin_event_get_app (GsPluginEvent *event)
+{
+ g_return_val_if_fail (GS_IS_PLUGIN_EVENT (event), NULL);
+ return event->app;
+}
+
+/**
+ * gs_plugin_event_get_origin:
+ * @event: A #GsPluginEvent
+ *
+ * Gets an origin that created the event.
+ *
+ * Returns: (transfer none): a #GsApp, or %NULL if unset
+ *
+ * Since: 3.22
+ **/
+GsApp *
+gs_plugin_event_get_origin (GsPluginEvent *event)
+{
+ g_return_val_if_fail (GS_IS_PLUGIN_EVENT (event), NULL);
+ return event->origin;
+}
+
+/**
+ * gs_plugin_event_get_action:
+ * @event: A #GsPluginEvent
+ *
+ * Gets an action that created the event.
+ *
+ * Returns: (transfer none): a #GsPluginAction, e.g. %GS_PLUGIN_ACTION_UPDATE
+ *
+ * Since: 3.22
+ **/
+GsPluginAction
+gs_plugin_event_get_action (GsPluginEvent *event)
+{
+ g_return_val_if_fail (GS_IS_PLUGIN_EVENT (event), 0);
+ return event->action;
+}
+
+/**
+ * gs_plugin_event_get_job:
+ * @event: A #GsPluginEvent
+ *
+ * Gets the job that created the event.
+ *
+ * Returns: (transfer none) (nullable): a #GsPluginJob
+ *
+ * Since: 42
+ **/
+GsPluginJob *
+gs_plugin_event_get_job (GsPluginEvent *event)
+{
+ g_return_val_if_fail (GS_IS_PLUGIN_EVENT (event), NULL);
+ return event->job;
+}
+
+/**
+ * gs_plugin_event_get_unique_id:
+ * @event: A #GsPluginEvent
+ *
+ * Gets the unique ID for the event. In most cases (if an app has been set)
+ * this will just be the actual #GsApp unique-id. In the cases where only error
+ * has been set a virtual (but plausible) ID will be generated.
+ *
+ * Returns: a string, or %NULL for invalid
+ *
+ * Since: 3.22
+ **/
+const gchar *
+gs_plugin_event_get_unique_id (GsPluginEvent *event)
+{
+ /* just proxy */
+ if (event->origin != NULL &&
+ gs_app_get_unique_id (event->origin) != NULL) {
+ return gs_app_get_unique_id (event->origin);
+ }
+ if (event->app != NULL &&
+ gs_app_get_unique_id (event->app) != NULL) {
+ return gs_app_get_unique_id (event->app);
+ }
+
+ /* generate from error */
+ if (event->error != NULL) {
+ if (event->unique_id == NULL) {
+ g_autofree gchar *id = NULL;
+ id = g_strdup_printf ("%s.error",
+ gs_plugin_error_to_string (event->error->code));
+ event->unique_id = gs_utils_build_unique_id (AS_COMPONENT_SCOPE_UNKNOWN,
+ AS_BUNDLE_KIND_UNKNOWN,
+ NULL,
+ id,
+ NULL);
+ }
+ return event->unique_id;
+ }
+
+ /* failed */
+ return NULL;
+}
+
+/**
+ * gs_plugin_event_get_kind:
+ * @event: A #GsPluginEvent
+ * @flag: A #GsPluginEventFlag, e.g. %GS_PLUGIN_EVENT_FLAG_INVALID
+ *
+ * Adds a flag to the event.
+ *
+ * Since: 3.22
+ **/
+void
+gs_plugin_event_add_flag (GsPluginEvent *event, GsPluginEventFlag flag)
+{
+ g_return_if_fail (GS_IS_PLUGIN_EVENT (event));
+ event->flags |= flag;
+}
+
+/**
+ * gs_plugin_event_set_kind:
+ * @event: A #GsPluginEvent
+ * @flag: A #GsPluginEventFlag, e.g. %GS_PLUGIN_EVENT_FLAG_INVALID
+ *
+ * Removes a flag from the event.
+ *
+ * Since: 3.22
+ **/
+void
+gs_plugin_event_remove_flag (GsPluginEvent *event, GsPluginEventFlag flag)
+{
+ g_return_if_fail (GS_IS_PLUGIN_EVENT (event));
+ event->flags &= ~flag;
+}
+
+/**
+ * gs_plugin_event_has_flag:
+ * @event: A #GsPluginEvent
+ * @flag: A #GsPluginEventFlag, e.g. %GS_PLUGIN_EVENT_FLAG_INVALID
+ *
+ * Finds out if the event has a specific flag.
+ *
+ * Returns: %TRUE if the flag is set
+ *
+ * Since: 3.22
+ **/
+gboolean
+gs_plugin_event_has_flag (GsPluginEvent *event, GsPluginEventFlag flag)
+{
+ g_return_val_if_fail (GS_IS_PLUGIN_EVENT (event), FALSE);
+ return ((event->flags & flag) > 0);
+}
+
+/**
+ * gs_plugin_event_get_error:
+ * @event: A #GsPluginEvent
+ *
+ * Gets the event error.
+ *
+ * Returns: a #GError, or %NULL for unset
+ *
+ * Since: 3.22
+ **/
+const GError *
+gs_plugin_event_get_error (GsPluginEvent *event)
+{
+ return event->error;
+}
+
+static void
+gs_plugin_event_get_property (GObject *object,
+ guint prop_id,
+ GValue *value,
+ GParamSpec *pspec)
+{
+ GsPluginEvent *self = GS_PLUGIN_EVENT (object);
+
+ switch ((GsPluginEventProperty) prop_id) {
+ case PROP_APP:
+ g_value_set_object (value, self->app);
+ break;
+ case PROP_ORIGIN:
+ g_value_set_object (value, self->origin);
+ break;
+ case PROP_ACTION:
+ g_value_set_enum (value, self->action);
+ break;
+ case PROP_JOB:
+ g_value_set_object (value, self->job);
+ break;
+ case PROP_ERROR:
+ g_value_set_boxed (value, self->error);
+ break;
+ default:
+ G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+ break;
+ }
+}
+
+static void
+gs_plugin_event_set_property (GObject *object,
+ guint prop_id,
+ const GValue *value,
+ GParamSpec *pspec)
+{
+ GsPluginEvent *self = GS_PLUGIN_EVENT (object);
+
+ switch ((GsPluginEventProperty) prop_id) {
+ case PROP_APP:
+ /* Construct only. */
+ g_assert (self->app == NULL);
+ self->app = g_value_dup_object (value);
+ g_object_notify_by_pspec (object, props[prop_id]);
+ break;
+ case PROP_ORIGIN:
+ /* Construct only. */
+ g_assert (self->origin == NULL);
+ self->origin = g_value_dup_object (value);
+ g_object_notify_by_pspec (object, props[prop_id]);
+ break;
+ case PROP_ACTION:
+ /* Construct only. */
+ g_assert (self->action == GS_PLUGIN_ACTION_UNKNOWN);
+ self->action = g_value_get_enum (value);
+ g_object_notify_by_pspec (object, props[prop_id]);
+ break;
+ case PROP_JOB:
+ /* Construct only. */
+ g_assert (self->job == NULL);
+ self->job = g_value_dup_object (value);
+ g_object_notify_by_pspec (object, props[prop_id]);
+ break;
+ case PROP_ERROR:
+ /* Construct only. */
+ g_assert (self->error == NULL);
+ self->error = g_value_dup_boxed (value);
+ if (self->error) {
+ /* Just in case the caller left there any D-Bus remote error notes */
+ g_dbus_error_strip_remote_error (self->error);
+ }
+ g_object_notify_by_pspec (object, props[prop_id]);
+ break;
+ default:
+ G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+ break;
+ }
+}
+
+static void
+gs_plugin_event_dispose (GObject *object)
+{
+ GsPluginEvent *event = GS_PLUGIN_EVENT (object);
+
+ g_clear_object (&event->app);
+ g_clear_object (&event->origin);
+ g_clear_object (&event->job);
+
+ G_OBJECT_CLASS (gs_plugin_event_parent_class)->dispose (object);
+}
+
+static void
+gs_plugin_event_finalize (GObject *object)
+{
+ GsPluginEvent *event = GS_PLUGIN_EVENT (object);
+
+ g_clear_error (&event->error);
+ g_free (event->unique_id);
+
+ G_OBJECT_CLASS (gs_plugin_event_parent_class)->finalize (object);
+}
+
+static void
+gs_plugin_event_class_init (GsPluginEventClass *klass)
+{
+ GObjectClass *object_class = G_OBJECT_CLASS (klass);
+
+ object_class->get_property = gs_plugin_event_get_property;
+ object_class->set_property = gs_plugin_event_set_property;
+ object_class->dispose = gs_plugin_event_dispose;
+ object_class->finalize = gs_plugin_event_finalize;
+
+ /**
+ * GsPluginEvent:app: (nullable)
+ *
+ * The application (or source, or whatever component) that caused the
+ * event to be created.
+ *
+ * Since: 42
+ */
+ props[PROP_APP] =
+ g_param_spec_object ("app", "App",
+ "The application (or source, or whatever component) that caused the event to be created.",
+ GS_TYPE_APP,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY |
+ G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ /**
+ * GsPluginEvent:origin: (nullable)
+ *
+ * The origin that caused the event to be created.
+ *
+ * Since: 42
+ */
+ props[PROP_ORIGIN] =
+ g_param_spec_object ("origin", "Origin",
+ "The origin that caused the event to be created.",
+ GS_TYPE_APP,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY |
+ G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ /**
+ * GsPluginEvent:action:
+ *
+ * The action that caused the event to be created.
+ *
+ * Since: 42
+ */
+ props[PROP_ACTION] =
+ g_param_spec_enum ("action", "Action",
+ "The action that caused the event to be created.",
+ GS_TYPE_PLUGIN_ACTION, GS_PLUGIN_ACTION_UNKNOWN,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY |
+ G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ /**
+ * GsPluginEvent:job: (nullable)
+ *
+ * The job that caused the event to be created.
+ *
+ * Since: 42
+ */
+ props[PROP_JOB] =
+ g_param_spec_object ("job", "Job",
+ "The job that caused the event to be created.",
+ GS_TYPE_PLUGIN_JOB,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY |
+ G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ /**
+ * GsPluginEvent:error: (nullable)
+ *
+ * The error the event is reporting.
+ *
+ * Since: 42
+ */
+ props[PROP_ERROR] =
+ g_param_spec_boxed ("error", "Error",
+ "The error the event is reporting.",
+ G_TYPE_ERROR,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY |
+ G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ g_object_class_install_properties (object_class, G_N_ELEMENTS (props), props);
+}
+
+static void
+gs_plugin_event_init (GsPluginEvent *event)
+{
+}
+
+/**
+ * gs_plugin_event_new:
+ * @first_property_name: the name of the first property
+ * @...: the value of the first property, followed by zero or more pairs of
+ * property name/value pairs, then %NULL
+ *
+ * Creates a new event.
+ *
+ * The arguments are as for g_object_new(): property name/value pairs to set
+ * the properties of the event.
+ *
+ * Returns: (transfer full): A newly allocated #GsPluginEvent
+ *
+ * Since: 42
+ **/
+GsPluginEvent *
+gs_plugin_event_new (const gchar *first_property_name,
+ ...)
+{
+ GsPluginEvent *event;
+ va_list args;
+
+ va_start (args, first_property_name);
+ event = GS_PLUGIN_EVENT (g_object_new_valist (GS_TYPE_PLUGIN_EVENT, first_property_name, args));
+ va_end (args);
+
+ return GS_PLUGIN_EVENT (event);
+}
diff --git a/lib/gs-plugin-event.h b/lib/gs-plugin-event.h
new file mode 100644
index 0000000..949eb85
--- /dev/null
+++ b/lib/gs-plugin-event.h
@@ -0,0 +1,61 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2016 Richard Hughes <richard@hughsie.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#include <glib-object.h>
+
+#include "gs-app.h"
+#include "gs-plugin-types.h"
+
+G_BEGIN_DECLS
+
+#define GS_TYPE_PLUGIN_EVENT (gs_plugin_event_get_type ())
+
+G_DECLARE_FINAL_TYPE (GsPluginEvent, gs_plugin_event, GS, PLUGIN_EVENT, GObject)
+
+typedef struct _GsPluginJob GsPluginJob;
+
+/**
+ * GsPluginEventFlag:
+ * @GS_PLUGIN_EVENT_FLAG_NONE: No special flags set
+ * @GS_PLUGIN_EVENT_FLAG_INVALID: Event is no longer valid, e.g. was dismissed
+ * @GS_PLUGIN_EVENT_FLAG_VISIBLE: Event is is visible on the screen
+ * @GS_PLUGIN_EVENT_FLAG_WARNING: Event should be shown with more urgency
+ * @GS_PLUGIN_EVENT_FLAG_INTERACTIVE: The plugin job was created with interactive=True
+ *
+ * Any flags an event can have.
+ **/
+typedef enum {
+ GS_PLUGIN_EVENT_FLAG_NONE = 0, /* Since: 3.22 */
+ GS_PLUGIN_EVENT_FLAG_INVALID = 1 << 0, /* Since: 3.22 */
+ GS_PLUGIN_EVENT_FLAG_VISIBLE = 1 << 1, /* Since: 3.22 */
+ GS_PLUGIN_EVENT_FLAG_WARNING = 1 << 2, /* Since: 3.22 */
+ GS_PLUGIN_EVENT_FLAG_INTERACTIVE = 1 << 3, /* Since: 3.30 */
+ GS_PLUGIN_EVENT_FLAG_LAST /*< skip >*/
+} GsPluginEventFlag;
+
+GsPluginEvent *gs_plugin_event_new (const gchar *first_property_name,
+ ...) G_GNUC_NULL_TERMINATED;
+
+const gchar *gs_plugin_event_get_unique_id (GsPluginEvent *event);
+
+GsApp *gs_plugin_event_get_app (GsPluginEvent *event);
+GsApp *gs_plugin_event_get_origin (GsPluginEvent *event);
+GsPluginAction gs_plugin_event_get_action (GsPluginEvent *event);
+GsPluginJob *gs_plugin_event_get_job (GsPluginEvent *event);
+const GError *gs_plugin_event_get_error (GsPluginEvent *event);
+
+void gs_plugin_event_add_flag (GsPluginEvent *event,
+ GsPluginEventFlag flag);
+void gs_plugin_event_remove_flag (GsPluginEvent *event,
+ GsPluginEventFlag flag);
+gboolean gs_plugin_event_has_flag (GsPluginEvent *event,
+ GsPluginEventFlag flag);
+
+G_END_DECLS
diff --git a/lib/gs-plugin-helpers.c b/lib/gs-plugin-helpers.c
new file mode 100644
index 0000000..40c8a61
--- /dev/null
+++ b/lib/gs-plugin-helpers.c
@@ -0,0 +1,338 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2021 Endless OS Foundation LLC
+ *
+ * Author: Philip Withnall <pwithnall@endlessos.org>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+/**
+ * SECTION:gs-plugin-helpers
+ * @short_description: Helpers for storing call closures for #GsPlugin vfuncs
+ *
+ * The helpers in this file each create a context structure to store the
+ * arguments passed to a standard #GsPlugin vfunc.
+ *
+ * These are intended to be used by plugin implementations to easily create
+ * #GTasks for handling #GsPlugin vfunc calls, without all having to write the
+ * same code to create a structure to wrap the vfunc arguments.
+ *
+ * Since: 42
+ */
+
+#include "config.h"
+
+#include <glib.h>
+#include <glib-object.h>
+
+#include "gs-plugin-helpers.h"
+
+/**
+ * gs_plugin_refine_data_new:
+ * @list: list of #GsApps to refine
+ * @flags: refine flags
+ *
+ * Context data for a call to #GsPluginClass.refine_async.
+ *
+ * Returns: (transfer full): context data structure
+ * Since: 42
+ */
+GsPluginRefineData *
+gs_plugin_refine_data_new (GsAppList *list,
+ GsPluginRefineFlags flags)
+{
+ g_autoptr(GsPluginRefineData) data = g_new0 (GsPluginRefineData, 1);
+ data->list = g_object_ref (list);
+ data->flags = flags;
+
+ return g_steal_pointer (&data);
+}
+
+/**
+ * gs_plugin_refine_data_new_task:
+ * @source_object: task source object
+ * @list: list of #GsApps to refine
+ * @flags: refine flags
+ * @cancellable: (nullable): a #GCancellable, or %NULL
+ * @callback: function to call once asynchronous operation is finished
+ * @user_data: data to pass to @callback
+ *
+ * Create a #GTask for a refine operation with the given arguments. The task
+ * data will be set to a #GsPluginRefineData containing the given context.
+ *
+ * This is essentially a combination of gs_plugin_refine_data_new(),
+ * g_task_new() and g_task_set_task_data().
+ *
+ * Returns: (transfer full): new #GTask with the given context data
+ * Since: 42
+ */
+GTask *
+gs_plugin_refine_data_new_task (gpointer source_object,
+ GsAppList *list,
+ GsPluginRefineFlags flags,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data)
+{
+ g_autoptr(GTask) task = g_task_new (source_object, cancellable, callback, user_data);
+ g_task_set_task_data (task, gs_plugin_refine_data_new (list, flags), (GDestroyNotify) gs_plugin_refine_data_free);
+ return g_steal_pointer (&task);
+}
+
+/**
+ * gs_plugin_refine_data_free:
+ * @data: (transfer full): a #GsPluginRefineData
+ *
+ * Free the given @data.
+ *
+ * Since: 42
+ */
+void
+gs_plugin_refine_data_free (GsPluginRefineData *data)
+{
+ g_clear_object (&data->list);
+ g_free (data);
+}
+
+/**
+ * gs_plugin_refresh_metadata_data_new:
+ * @cache_age_secs: maximum allowed age of the cache in order for it to remain valid, in seconds
+ * @flags: refresh metadata flags
+ *
+ * Context data for a call to #GsPluginClass.refresh_metadata_async.
+ *
+ * Returns: (transfer full): context data structure
+ * Since: 42
+ */
+GsPluginRefreshMetadataData *
+gs_plugin_refresh_metadata_data_new (guint64 cache_age_secs,
+ GsPluginRefreshMetadataFlags flags)
+{
+ g_autoptr(GsPluginRefreshMetadataData) data = g_new0 (GsPluginRefreshMetadataData, 1);
+ data->cache_age_secs = cache_age_secs;
+ data->flags = flags;
+
+ return g_steal_pointer (&data);
+}
+
+/**
+ * gs_plugin_refresh_metadata_data_free:
+ * @data: (transfer full): a #GsPluginRefreshMetadataData
+ *
+ * Free the given @data.
+ *
+ * Since: 42
+ */
+void
+gs_plugin_refresh_metadata_data_free (GsPluginRefreshMetadataData *data)
+{
+ g_free (data);
+}
+
+/**
+ * gs_plugin_list_apps_data_new:
+ * @query: (nullable) (transfer none): a query to filter apps, or %NULL for
+ * no filtering
+ * @flags: list apps flags
+ *
+ * Context data for a call to #GsPluginClass.list_apps_async.
+ *
+ * Returns: (transfer full): context data structure
+ * Since: 43
+ */
+GsPluginListAppsData *
+gs_plugin_list_apps_data_new (GsAppQuery *query,
+ GsPluginListAppsFlags flags)
+{
+ g_autoptr(GsPluginListAppsData) data = g_new0 (GsPluginListAppsData, 1);
+ data->query = (query != NULL) ? g_object_ref (query) : NULL;
+ data->flags = flags;
+
+ return g_steal_pointer (&data);
+}
+
+/**
+ * gs_plugin_list_apps_data_new_task:
+ * @source_object: task source object
+ * @query: (nullable) (transfer none): a query to filter apps, or %NULL for
+ * no filtering
+ * @flags: list apps flags
+ * @cancellable: (nullable): a #GCancellable, or %NULL
+ * @callback: function to call once asynchronous operation is finished
+ * @user_data: data to pass to @callback
+ *
+ * Create a #GTask for a list apps operation with the given arguments. The task
+ * data will be set to a #GsPluginListAppsData containing the given context.
+ *
+ * This is essentially a combination of gs_plugin_list_apps_data_new(),
+ * g_task_new() and g_task_set_task_data().
+ *
+ * Returns: (transfer full): new #GTask with the given context data
+ * Since: 43
+ */
+GTask *
+gs_plugin_list_apps_data_new_task (gpointer source_object,
+ GsAppQuery *query,
+ GsPluginListAppsFlags flags,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data)
+{
+ g_autoptr(GTask) task = g_task_new (source_object, cancellable, callback, user_data);
+ g_task_set_task_data (task, gs_plugin_list_apps_data_new (query, flags), (GDestroyNotify) gs_plugin_list_apps_data_free);
+ return g_steal_pointer (&task);
+}
+
+/**
+ * gs_plugin_list_apps_data_free:
+ * @data: (transfer full): a #GsPluginListAppsData
+ *
+ * Free the given @data.
+ *
+ * Since: 43
+ */
+void
+gs_plugin_list_apps_data_free (GsPluginListAppsData *data)
+{
+ g_clear_object (&data->query);
+ g_free (data);
+}
+
+/**
+ * gs_plugin_manage_repository_data_new:
+ * @repository: (not-nullable) (transfer none): a repository to manage
+ * @flags: manage repository flags
+ *
+ * Common context data for a call to #GsPluginClass.install_repository_async,
+ * #GsPluginClass.remove_repository_async, #GsPluginClass.enable_repository_async
+ * and #GsPluginClass.disable_repository_async.
+ *
+ * Returns: (transfer full): context data structure
+ * Since: 43
+ */
+GsPluginManageRepositoryData *
+gs_plugin_manage_repository_data_new (GsApp *repository,
+ GsPluginManageRepositoryFlags flags)
+{
+ g_autoptr(GsPluginManageRepositoryData) data = g_new0 (GsPluginManageRepositoryData, 1);
+ data->repository = g_object_ref (repository);
+ data->flags = flags;
+
+ return g_steal_pointer (&data);
+}
+
+/**
+ * gs_plugin_manage_repository_data_new_task:
+ * @source_object: task source object
+ * @repository: (not-nullable) (transfer none): a repository to manage
+ * @flags: manage repository flags
+ * @cancellable: (nullable): a #GCancellable, or %NULL
+ * @callback: function to call once asynchronous operation is finished
+ * @user_data: data to pass to @callback
+ *
+ * Create a #GTask for a manage repository operation with the given arguments. The task
+ * data will be set to a #GsPluginManageRepositoryData containing the given context.
+ *
+ * This is essentially a combination of gs_plugin_manage_repository_data_new(),
+ * g_task_new() and g_task_set_task_data().
+ *
+ * Returns: (transfer full): new #GTask with the given context data
+ * Since: 43
+ */
+GTask *
+gs_plugin_manage_repository_data_new_task (gpointer source_object,
+ GsApp *repository,
+ GsPluginManageRepositoryFlags flags,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data)
+{
+ g_autoptr(GTask) task = g_task_new (source_object, cancellable, callback, user_data);
+ g_task_set_task_data (task, gs_plugin_manage_repository_data_new (repository, flags), (GDestroyNotify) gs_plugin_manage_repository_data_free);
+ return g_steal_pointer (&task);
+}
+
+/**
+ * gs_plugin_manage_repository_data_free:
+ * @data: (transfer full): a #GsPluginManageRepositoryData
+ *
+ * Free the given @data.
+ *
+ * Since: 43
+ */
+void
+gs_plugin_manage_repository_data_free (GsPluginManageRepositoryData *data)
+{
+ g_clear_object (&data->repository);
+ g_free (data);
+}
+
+/**
+ * gs_plugin_refine_categories_data_new:
+ * @list: (element-type GsCategory): list of #GsCategory objects to refine
+ * @flags: refine flags
+ *
+ * Context data for a call to #GsPluginClass.refine_categories_async.
+ *
+ * Returns: (transfer full): context data structure
+ * Since: 43
+ */
+GsPluginRefineCategoriesData *
+gs_plugin_refine_categories_data_new (GPtrArray *list,
+ GsPluginRefineCategoriesFlags flags)
+{
+ g_autoptr(GsPluginRefineCategoriesData) data = g_new0 (GsPluginRefineCategoriesData, 1);
+ data->list = g_ptr_array_ref (list);
+ data->flags = flags;
+
+ return g_steal_pointer (&data);
+}
+
+/**
+ * gs_plugin_refine_categories_data_new_task:
+ * @source_object: task source object
+ * @list: (element-type GsCategory): list of #GsCategory objects to refine
+ * @flags: refine flags
+ * @cancellable: (nullable): a #GCancellable, or %NULL
+ * @callback: function to call once asynchronous operation is finished
+ * @user_data: data to pass to @callback
+ *
+ * Create a #GTask for a refine categories operation with the given arguments.
+ * The task data will be set to a #GsPluginRefineCategoriesData containing the
+ * given context.
+ *
+ * This is essentially a combination of gs_plugin_refine_categories_data_new(),
+ * g_task_new() and g_task_set_task_data().
+ *
+ * Returns: (transfer full): new #GTask with the given context data
+ * Since: 43
+ */
+GTask *
+gs_plugin_refine_categories_data_new_task (gpointer source_object,
+ GPtrArray *list,
+ GsPluginRefineCategoriesFlags flags,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data)
+{
+ g_autoptr(GTask) task = g_task_new (source_object, cancellable, callback, user_data);
+ g_task_set_task_data (task, gs_plugin_refine_categories_data_new (list, flags), (GDestroyNotify) gs_plugin_refine_categories_data_free);
+ return g_steal_pointer (&task);
+}
+
+/**
+ * gs_plugin_refine_categories_data_free:
+ * @data: (transfer full): a #GsPluginRefineCategoriesData
+ *
+ * Free the given @data.
+ *
+ * Since: 43
+ */
+void
+gs_plugin_refine_categories_data_free (GsPluginRefineCategoriesData *data)
+{
+ g_clear_pointer (&data->list, g_ptr_array_unref);
+ g_free (data);
+}
diff --git a/lib/gs-plugin-helpers.h b/lib/gs-plugin-helpers.h
new file mode 100644
index 0000000..b4a19f3
--- /dev/null
+++ b/lib/gs-plugin-helpers.h
@@ -0,0 +1,96 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2021 Endless OS Foundation LLC
+ *
+ * Author: Philip Withnall <pwithnall@endlessos.org>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#include <gio/gio.h>
+#include <glib.h>
+#include <glib-object.h>
+
+#include <gnome-software.h>
+
+G_BEGIN_DECLS
+
+typedef struct {
+ GsAppList *list; /* (owned) (not nullable) */
+ GsPluginRefineFlags flags;
+} GsPluginRefineData;
+
+GsPluginRefineData *gs_plugin_refine_data_new (GsAppList *list,
+ GsPluginRefineFlags flags);
+GTask *gs_plugin_refine_data_new_task (gpointer source_object,
+ GsAppList *list,
+ GsPluginRefineFlags flags,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data);
+void gs_plugin_refine_data_free (GsPluginRefineData *data);
+G_DEFINE_AUTOPTR_CLEANUP_FUNC (GsPluginRefineData, gs_plugin_refine_data_free)
+
+typedef struct {
+ guint64 cache_age_secs;
+ GsPluginRefreshMetadataFlags flags;
+} GsPluginRefreshMetadataData;
+
+GsPluginRefreshMetadataData *gs_plugin_refresh_metadata_data_new (guint64 cache_age_secs,
+ GsPluginRefreshMetadataFlags flags);
+void gs_plugin_refresh_metadata_data_free (GsPluginRefreshMetadataData *data);
+G_DEFINE_AUTOPTR_CLEANUP_FUNC (GsPluginRefreshMetadataData, gs_plugin_refresh_metadata_data_free)
+
+typedef struct {
+ GsAppQuery *query; /* (owned) (nullable) */
+ GsPluginListAppsFlags flags;
+} GsPluginListAppsData;
+
+GsPluginListAppsData *gs_plugin_list_apps_data_new (GsAppQuery *query,
+ GsPluginListAppsFlags flags);
+GTask *gs_plugin_list_apps_data_new_task (gpointer source_object,
+ GsAppQuery *query,
+ GsPluginListAppsFlags flags,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data);
+void gs_plugin_list_apps_data_free (GsPluginListAppsData *data);
+G_DEFINE_AUTOPTR_CLEANUP_FUNC (GsPluginListAppsData, gs_plugin_list_apps_data_free)
+
+typedef struct {
+ GsApp *repository; /* (owned) (nullable) */
+ GsPluginManageRepositoryFlags flags;
+} GsPluginManageRepositoryData;
+
+GsPluginManageRepositoryData *
+ gs_plugin_manage_repository_data_new (GsApp *repository,
+ GsPluginManageRepositoryFlags flags);
+GTask * gs_plugin_manage_repository_data_new_task (gpointer source_object,
+ GsApp *repository,
+ GsPluginManageRepositoryFlags flags,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data);
+void gs_plugin_manage_repository_data_free (GsPluginManageRepositoryData *data);
+G_DEFINE_AUTOPTR_CLEANUP_FUNC (GsPluginManageRepositoryData, gs_plugin_manage_repository_data_free)
+
+typedef struct {
+ GPtrArray *list; /* (element-type GsCategory) (owned) (not nullable) */
+ GsPluginRefineCategoriesFlags flags;
+} GsPluginRefineCategoriesData;
+
+GsPluginRefineCategoriesData *gs_plugin_refine_categories_data_new (GPtrArray *list,
+ GsPluginRefineCategoriesFlags flags);
+GTask *gs_plugin_refine_categories_data_new_task (gpointer source_object,
+ GPtrArray *list,
+ GsPluginRefineCategoriesFlags flags,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data);
+void gs_plugin_refine_categories_data_free (GsPluginRefineCategoriesData *data);
+G_DEFINE_AUTOPTR_CLEANUP_FUNC (GsPluginRefineCategoriesData, gs_plugin_refine_categories_data_free)
+
+G_END_DECLS
diff --git a/lib/gs-plugin-job-list-apps.c b/lib/gs-plugin-job-list-apps.c
new file mode 100644
index 0000000..56a4e14
--- /dev/null
+++ b/lib/gs-plugin-job-list-apps.c
@@ -0,0 +1,516 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2022 Endless OS Foundation LLC
+ *
+ * Author: Philip Withnall <pwithnall@endlessos.org>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+/**
+ * SECTION:gs-plugin-job-list-apps
+ * @short_description: A plugin job to list apps according to a search query
+ *
+ * #GsPluginJobListApps is a #GsPluginJob representing an operation to
+ * list apps which match a given query, from all #GsPlugins.
+ *
+ * The known properties on the set of apps returned by this operation can be
+ * controlled with the #GsAppQuery:refine-flags property of the query. All
+ * results will be refined using the given set of refine flags. See
+ * #GsPluginJobRefine.
+ *
+ * This class is a wrapper around #GsPluginClass.list_apps_async,
+ * calling it for all loaded plugins, with #GsPluginJobRefine used to refine
+ * them.
+ *
+ * Retrieve the resulting #GsAppList using
+ * gs_plugin_job_list_apps_get_result_list().
+ *
+ * See also: #GsPluginClass.list_apps_async
+ * Since: 43
+ */
+
+#include "config.h"
+
+#include <glib.h>
+#include <glib-object.h>
+
+#include "gs-app.h"
+#include "gs-app-list-private.h"
+#include "gs-app-query.h"
+#include "gs-enums.h"
+#include "gs-plugin-job.h"
+#include "gs-plugin-job-list-apps.h"
+#include "gs-plugin-job-private.h"
+#include "gs-plugin-job-refine.h"
+#include "gs-plugin-private.h"
+#include "gs-plugin-types.h"
+#include "gs-utils.h"
+
+struct _GsPluginJobListApps
+{
+ GsPluginJob parent;
+
+ /* Input arguments. */
+ GsAppQuery *query; /* (owned) (nullable) */
+ GsPluginListAppsFlags flags;
+
+ /* In-progress data. */
+ GsAppList *merged_list; /* (owned) (nullable) */
+ GError *saved_error; /* (owned) (nullable) */
+ guint n_pending_ops;
+
+ /* Results. */
+ GsAppList *result_list; /* (owned) (nullable) */
+};
+
+G_DEFINE_TYPE (GsPluginJobListApps, gs_plugin_job_list_apps, GS_TYPE_PLUGIN_JOB)
+
+typedef enum {
+ PROP_QUERY = 1,
+ PROP_FLAGS,
+} GsPluginJobListAppsProperty;
+
+static GParamSpec *props[PROP_FLAGS + 1] = { NULL, };
+
+static void
+gs_plugin_job_list_apps_dispose (GObject *object)
+{
+ GsPluginJobListApps *self = GS_PLUGIN_JOB_LIST_APPS (object);
+
+ g_assert (self->merged_list == NULL);
+ g_assert (self->saved_error == NULL);
+ g_assert (self->n_pending_ops == 0);
+
+ g_clear_object (&self->result_list);
+ g_clear_object (&self->query);
+
+ G_OBJECT_CLASS (gs_plugin_job_list_apps_parent_class)->dispose (object);
+}
+
+static void
+gs_plugin_job_list_apps_get_property (GObject *object,
+ guint prop_id,
+ GValue *value,
+ GParamSpec *pspec)
+{
+ GsPluginJobListApps *self = GS_PLUGIN_JOB_LIST_APPS (object);
+
+ switch ((GsPluginJobListAppsProperty) prop_id) {
+ case PROP_QUERY:
+ g_value_set_object (value, self->query);
+ break;
+ case PROP_FLAGS:
+ g_value_set_flags (value, self->flags);
+ break;
+ default:
+ G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+ break;
+ }
+}
+
+static void
+gs_plugin_job_list_apps_set_property (GObject *object,
+ guint prop_id,
+ const GValue *value,
+ GParamSpec *pspec)
+{
+ GsPluginJobListApps *self = GS_PLUGIN_JOB_LIST_APPS (object);
+
+ switch ((GsPluginJobListAppsProperty) prop_id) {
+ case PROP_QUERY:
+ /* Construct only. */
+ g_assert (self->query == NULL);
+ self->query = g_value_dup_object (value);
+ g_object_notify_by_pspec (object, props[prop_id]);
+ break;
+ case PROP_FLAGS:
+ /* Construct only. */
+ g_assert (self->flags == 0);
+ self->flags = g_value_get_flags (value);
+ g_object_notify_by_pspec (object, props[prop_id]);
+ break;
+ default:
+ G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+ break;
+ }
+}
+
+static gboolean
+filter_valid_apps (GsApp *app,
+ gpointer user_data)
+{
+ GsPluginJobListApps *self = GS_PLUGIN_JOB_LIST_APPS (user_data);
+ GsPluginRefineFlags refine_flags = GS_PLUGIN_REFINE_FLAGS_NONE;
+
+ if (self->query)
+ refine_flags = gs_app_query_get_refine_flags (self->query);
+
+ return gs_plugin_loader_app_is_valid (app, refine_flags);
+}
+
+static gboolean
+app_filter_qt_for_gtk_and_compatible (GsApp *app,
+ gpointer user_data)
+{
+ GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (user_data);
+
+ /* hide the QT versions in preference to the GTK ones */
+ if (g_strcmp0 (gs_app_get_id (app), "transmission-qt.desktop") == 0 ||
+ g_strcmp0 (gs_app_get_id (app), "nntpgrab_qt.desktop") == 0 ||
+ g_strcmp0 (gs_app_get_id (app), "gimagereader-qt4.desktop") == 0 ||
+ g_strcmp0 (gs_app_get_id (app), "gimagereader-qt5.desktop") == 0 ||
+ g_strcmp0 (gs_app_get_id (app), "nntpgrab_server_qt.desktop") == 0 ||
+ g_strcmp0 (gs_app_get_id (app), "hotot-qt.desktop") == 0) {
+ g_debug ("removing QT version of %s",
+ gs_app_get_unique_id (app));
+ return FALSE;
+ }
+
+ /* hide the KDE version in preference to the GTK one */
+ if (g_strcmp0 (gs_app_get_id (app), "qalculate_kde.desktop") == 0) {
+ g_debug ("removing KDE version of %s",
+ gs_app_get_unique_id (app));
+ return FALSE;
+ }
+
+ /* hide the KDE version in preference to the Qt one */
+ if (g_strcmp0 (gs_app_get_id (app), "kid3.desktop") == 0 ||
+ g_strcmp0 (gs_app_get_id (app), "kchmviewer.desktop") == 0) {
+ g_debug ("removing KDE version of %s",
+ gs_app_get_unique_id (app));
+ return FALSE;
+ }
+
+ return gs_plugin_loader_app_is_compatible (plugin_loader, app);
+}
+
+static void plugin_list_apps_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data);
+static void finish_op (GTask *task,
+ GError *error);
+static void refine_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data);
+static void finish_task (GTask *task,
+ GsAppList *merged_list);
+
+static void
+gs_plugin_job_list_apps_run_async (GsPluginJob *job,
+ GsPluginLoader *plugin_loader,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data)
+{
+ GsPluginJobListApps *self = GS_PLUGIN_JOB_LIST_APPS (job);
+ g_autoptr(GTask) task = NULL;
+ GPtrArray *plugins; /* (element-type GsPlugin) */
+ gboolean anything_ran = FALSE;
+
+ task = g_task_new (job, cancellable, callback, user_data);
+ g_task_set_source_tag (task, gs_plugin_job_list_apps_run_async);
+ g_task_set_task_data (task, g_object_ref (plugin_loader), (GDestroyNotify) g_object_unref);
+
+ /* run each plugin, keeping a counter of pending operations which is
+ * initialised to 1 until all the operations are started */
+ self->n_pending_ops = 1;
+ self->merged_list = gs_app_list_new ();
+ plugins = gs_plugin_loader_get_plugins (plugin_loader);
+
+ for (guint i = 0; i < plugins->len; i++) {
+ GsPlugin *plugin = g_ptr_array_index (plugins, i);
+ GsPluginClass *plugin_class = GS_PLUGIN_GET_CLASS (plugin);
+
+ if (!gs_plugin_get_enabled (plugin))
+ continue;
+ if (plugin_class->list_apps_async == NULL)
+ continue;
+
+ /* at least one plugin supports this vfunc */
+ anything_ran = TRUE;
+
+ /* run the plugin */
+ self->n_pending_ops++;
+ plugin_class->list_apps_async (plugin, self->query, self->flags, cancellable, plugin_list_apps_cb, g_object_ref (task));
+ }
+
+ if (!anything_ran)
+ g_debug ("no plugin could handle listing apps");
+
+ finish_op (task, NULL);
+}
+
+static void
+plugin_list_apps_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data)
+{
+ GsPlugin *plugin = GS_PLUGIN (source_object);
+ GsPluginClass *plugin_class = GS_PLUGIN_GET_CLASS (plugin);
+ g_autoptr(GTask) task = G_TASK (user_data);
+ GsPluginJobListApps *self = g_task_get_source_object (task);
+ g_autoptr(GsAppList) plugin_apps = NULL;
+ g_autoptr(GError) local_error = NULL;
+
+ plugin_apps = plugin_class->list_apps_finish (plugin, result, &local_error);
+ gs_plugin_status_update (plugin, NULL, GS_PLUGIN_STATUS_FINISHED);
+
+ if (plugin_apps != NULL)
+ gs_app_list_add_list (self->merged_list, plugin_apps);
+
+ /* Since #GsAppQuery supports a number of different query parameters,
+ * not all plugins will support all of them. Ignore errors related to
+ * that. */
+ if (g_error_matches (local_error, G_IO_ERROR, G_IO_ERROR_NOT_SUPPORTED))
+ g_clear_error (&local_error);
+
+ finish_op (task, g_steal_pointer (&local_error));
+}
+
+/* @error is (transfer full) if non-%NULL */
+static void
+finish_op (GTask *task,
+ GError *error)
+{
+ GsPluginJobListApps *self = g_task_get_source_object (task);
+ GCancellable *cancellable = g_task_get_cancellable (task);
+ GsPluginLoader *plugin_loader = g_task_get_task_data (task);
+ g_autoptr(GsAppList) merged_list = NULL;
+ GsPluginRefineFlags refine_flags = GS_PLUGIN_REFINE_FLAGS_NONE;
+ g_autoptr(GError) error_owned = g_steal_pointer (&error);
+
+ if (error_owned != NULL && self->saved_error == NULL)
+ self->saved_error = g_steal_pointer (&error_owned);
+ else if (error_owned != NULL)
+ g_debug ("Additional error while listing apps: %s", error_owned->message);
+
+ g_assert (self->n_pending_ops > 0);
+ self->n_pending_ops--;
+
+ if (self->n_pending_ops > 0)
+ return;
+
+ /* Get the results of the parallel ops. */
+ merged_list = g_steal_pointer (&self->merged_list);
+
+ if (self->saved_error != NULL) {
+ g_task_return_error (task, g_steal_pointer (&self->saved_error));
+ return;
+ }
+
+ /* run refine() on each one if required */
+ if (self->query != NULL)
+ refine_flags = gs_app_query_get_refine_flags (self->query);
+
+ if (merged_list != NULL &&
+ gs_app_list_length (merged_list) > 0 &&
+ refine_flags != GS_PLUGIN_REFINE_FLAGS_NONE) {
+ g_autoptr(GsPluginJob) refine_job = NULL;
+
+ refine_job = gs_plugin_job_refine_new (merged_list,
+ refine_flags |
+ GS_PLUGIN_REFINE_FLAGS_DISABLE_FILTERING);
+ gs_plugin_loader_job_process_async (plugin_loader, refine_job,
+ cancellable,
+ refine_cb,
+ g_object_ref (task));
+ } else {
+ g_debug ("No apps to refine");
+ finish_task (task, merged_list);
+ }
+}
+
+static void
+refine_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data)
+{
+ GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (source_object);
+ g_autoptr(GTask) task = G_TASK (user_data);
+ g_autoptr(GsAppList) new_list = NULL;
+ g_autoptr(GError) local_error = NULL;
+
+ new_list = gs_plugin_loader_job_process_finish (plugin_loader, result, &local_error);
+ if (new_list == NULL) {
+ gs_utils_error_convert_gio (&local_error);
+ g_task_return_error (task, g_steal_pointer (&local_error));
+ return;
+ }
+
+ finish_task (task, new_list);
+}
+
+static void
+finish_task (GTask *task,
+ GsAppList *merged_list)
+{
+ GsPluginJobListApps *self = g_task_get_source_object (task);
+ GsPluginLoader *plugin_loader = g_task_get_task_data (task);
+ GsAppListFilterFlags dedupe_flags = GS_APP_LIST_FILTER_FLAG_NONE;
+ GsAppListSortFunc sort_func = NULL;
+ gpointer sort_func_data = NULL;
+ GsAppListFilterFunc filter_func = NULL;
+ gpointer filter_func_data = NULL;
+ guint max_results = 0;
+ g_autofree gchar *job_debug = NULL;
+
+ /* Standard filtering.
+ *
+ * FIXME: It feels like this filter should be done in a different layer. */
+ gs_app_list_filter (merged_list, filter_valid_apps, self);
+ gs_app_list_filter (merged_list, app_filter_qt_for_gtk_and_compatible, plugin_loader);
+
+ /* Caller-specified filtering. */
+ if (self->query != NULL)
+ filter_func = gs_app_query_get_filter_func (self->query, &filter_func_data);
+
+ if (filter_func != NULL)
+ gs_app_list_filter (merged_list, filter_func, filter_func_data);
+
+ /* Filter duplicates with priority, taking into account the source name
+ * & version, so we combine available updates with the installed app */
+ if (self->query != NULL)
+ dedupe_flags = gs_app_query_get_dedupe_flags (self->query);
+
+ if (dedupe_flags != GS_APP_LIST_FILTER_FLAG_NONE)
+ gs_app_list_filter_duplicates (merged_list, dedupe_flags);
+
+ /* Sort the results. The refine may have added useful metadata. */
+ if (self->query != NULL)
+ sort_func = gs_app_query_get_sort_func (self->query, &sort_func_data);
+
+ if (sort_func != NULL) {
+ gs_app_list_sort (merged_list, sort_func, sort_func_data);
+ } else {
+ g_debug ("no ->sort_func() set, using random!");
+ gs_app_list_randomize (merged_list);
+ }
+
+ /* Truncate the results if needed. */
+ if (self->query != NULL)
+ max_results = gs_app_query_get_max_results (self->query);
+
+ if (max_results > 0 && gs_app_list_length (merged_list) > max_results) {
+ g_debug ("truncating results from %u to %u",
+ gs_app_list_length (merged_list), max_results);
+ gs_app_list_truncate (merged_list, max_results);
+ }
+
+ /* show elapsed time */
+ job_debug = gs_plugin_job_to_string (GS_PLUGIN_JOB (self));
+ g_debug ("%s", job_debug);
+
+ /* Check the intermediate working values are all cleared. */
+ g_assert (self->merged_list == NULL);
+ g_assert (self->saved_error == NULL);
+ g_assert (self->n_pending_ops == 0);
+
+ /* success */
+ g_set_object (&self->result_list, merged_list);
+ g_task_return_boolean (task, TRUE);
+}
+
+static gboolean
+gs_plugin_job_list_apps_run_finish (GsPluginJob *self,
+ GAsyncResult *result,
+ GError **error)
+{
+ return g_task_propagate_boolean (G_TASK (result), error);
+}
+
+static void
+gs_plugin_job_list_apps_class_init (GsPluginJobListAppsClass *klass)
+{
+ GObjectClass *object_class = G_OBJECT_CLASS (klass);
+ GsPluginJobClass *job_class = GS_PLUGIN_JOB_CLASS (klass);
+
+ object_class->dispose = gs_plugin_job_list_apps_dispose;
+ object_class->get_property = gs_plugin_job_list_apps_get_property;
+ object_class->set_property = gs_plugin_job_list_apps_set_property;
+
+ job_class->run_async = gs_plugin_job_list_apps_run_async;
+ job_class->run_finish = gs_plugin_job_list_apps_run_finish;
+
+ /**
+ * GsPluginJobListApps:query: (nullable)
+ *
+ * A #GsAppQuery defining the query parameters.
+ *
+ * If this is %NULL, all apps will be returned.
+ *
+ * Since: 43
+ */
+ props[PROP_QUERY] =
+ g_param_spec_object ("query", "Query",
+ "A #GsAppQuery defining the query parameters.",
+ GS_TYPE_APP_QUERY,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY |
+ G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ /**
+ * GsPluginJobListApps:flags:
+ *
+ * Flags to specify how the operation should run.
+ *
+ * Since: 43
+ */
+ props[PROP_FLAGS] =
+ g_param_spec_flags ("flags", "Flags",
+ "Flags to specify how the operation should run.",
+ GS_TYPE_PLUGIN_LIST_APPS_FLAGS,
+ GS_PLUGIN_LIST_APPS_FLAGS_NONE,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY |
+ G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ g_object_class_install_properties (object_class, G_N_ELEMENTS (props), props);
+}
+
+static void
+gs_plugin_job_list_apps_init (GsPluginJobListApps *self)
+{
+}
+
+/**
+ * gs_plugin_job_list_apps_new:
+ * @query: (nullable) (transfer none): query to affect which apps to return
+ * @flags: flags affecting how the operation runs
+ *
+ * Create a new #GsPluginJobListApps for listing apps according to the given
+ * @query.
+ *
+ * Returns: (transfer full): a new #GsPluginJobListApps
+ * Since: 43
+ */
+GsPluginJob *
+gs_plugin_job_list_apps_new (GsAppQuery *query,
+ GsPluginListAppsFlags flags)
+{
+ g_return_val_if_fail (query == NULL || GS_IS_APP_QUERY (query), NULL);
+
+ return g_object_new (GS_TYPE_PLUGIN_JOB_LIST_APPS,
+ "query", query,
+ "flags", flags,
+ NULL);
+}
+
+/**
+ * gs_plugin_job_list_apps_get_result_list:
+ * @self: a #GsPluginJobListApps
+ *
+ * Get the full list of apps matching the query.
+ *
+ * If this is called before the job is complete, %NULL will be returned.
+ *
+ * Returns: (transfer none) (nullable): the job results, or %NULL on error
+ * or if called before the job has completed
+ * Since: 43
+ */
+GsAppList *
+gs_plugin_job_list_apps_get_result_list (GsPluginJobListApps *self)
+{
+ g_return_val_if_fail (GS_IS_PLUGIN_JOB_LIST_APPS (self), NULL);
+
+ return self->result_list;
+}
diff --git a/lib/gs-plugin-job-list-apps.h b/lib/gs-plugin-job-list-apps.h
new file mode 100644
index 0000000..e6aec46
--- /dev/null
+++ b/lib/gs-plugin-job-list-apps.h
@@ -0,0 +1,31 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2022 Endless OS Foundation LLC
+ *
+ * Author: Philip Withnall <pwithnall@endlessos.org>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#include <glib.h>
+#include <glib-object.h>
+#include <gio/gio.h>
+
+#include "gs-app-query.h"
+#include "gs-plugin-job.h"
+
+G_BEGIN_DECLS
+
+#define GS_TYPE_PLUGIN_JOB_LIST_APPS (gs_plugin_job_list_apps_get_type ())
+
+G_DECLARE_FINAL_TYPE (GsPluginJobListApps, gs_plugin_job_list_apps, GS, PLUGIN_JOB_LIST_APPS, GsPluginJob)
+
+GsPluginJob *gs_plugin_job_list_apps_new (GsAppQuery *query,
+ GsPluginListAppsFlags flags);
+
+GsAppList *gs_plugin_job_list_apps_get_result_list (GsPluginJobListApps *self);
+
+G_END_DECLS
diff --git a/lib/gs-plugin-job-list-categories.c b/lib/gs-plugin-job-list-categories.c
new file mode 100644
index 0000000..f2d1644
--- /dev/null
+++ b/lib/gs-plugin-job-list-categories.c
@@ -0,0 +1,347 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2022 Endless OS Foundation LLC
+ *
+ * Author: Philip Withnall <pwithnall@endlessos.org>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+/**
+ * SECTION:gs-plugin-job-list-categories
+ * @short_description: A plugin job to list categories
+ *
+ * #GsPluginJobListCategories is a #GsPluginJob representing an operation to
+ * list categories.
+ *
+ * All results will be refined using the given set of refine flags, similarly to
+ * how #GsPluginJobRefine refines apps.
+ *
+ * This class is a wrapper around #GsPluginClass.refine_categories_async,
+ * calling it for all loaded plugins on the list of categories exposed by a
+ * #GsCategoryManager.
+ *
+ * Retrieve the resulting #GPtrArray of #GsCategory objects using
+ * gs_plugin_job_list_categories_get_result_list().
+ *
+ * See also: #GsPluginClass.refine_categories_async
+ * Since: 43
+ */
+
+#include "config.h"
+
+#include <glib.h>
+#include <glib-object.h>
+
+#include "gs-category.h"
+#include "gs-category-private.h"
+#include "gs-enums.h"
+#include "gs-plugin-job.h"
+#include "gs-plugin-job-list-categories.h"
+#include "gs-plugin-job-private.h"
+#include "gs-plugin-private.h"
+#include "gs-plugin-types.h"
+#include "gs-utils.h"
+
+struct _GsPluginJobListCategories
+{
+ GsPluginJob parent;
+
+ /* Input arguments. */
+ GsPluginRefineCategoriesFlags flags;
+
+ /* In-progress data. */
+ GPtrArray *category_list; /* (element-type GsCategory) (owned) (nullable) */
+ GError *saved_error; /* (owned) (nullable) */
+ guint n_pending_ops;
+
+ /* Results. */
+ GPtrArray *result_list; /* (element-type GsCategory) (owned) (nullable) */
+};
+
+G_DEFINE_TYPE (GsPluginJobListCategories, gs_plugin_job_list_categories, GS_TYPE_PLUGIN_JOB)
+
+typedef enum {
+ PROP_FLAGS = 1,
+} GsPluginJobListCategoriesProperty;
+
+static GParamSpec *props[PROP_FLAGS + 1] = { NULL, };
+
+static void
+gs_plugin_job_list_categories_dispose (GObject *object)
+{
+ GsPluginJobListCategories *self = GS_PLUGIN_JOB_LIST_CATEGORIES (object);
+
+ g_assert (self->category_list == NULL);
+ g_assert (self->saved_error == NULL);
+ g_assert (self->n_pending_ops == 0);
+
+ g_clear_pointer (&self->result_list, g_ptr_array_unref);
+
+ G_OBJECT_CLASS (gs_plugin_job_list_categories_parent_class)->dispose (object);
+}
+
+static void
+gs_plugin_job_list_categories_get_property (GObject *object,
+ guint prop_id,
+ GValue *value,
+ GParamSpec *pspec)
+{
+ GsPluginJobListCategories *self = GS_PLUGIN_JOB_LIST_CATEGORIES (object);
+
+ switch ((GsPluginJobListCategoriesProperty) prop_id) {
+ case PROP_FLAGS:
+ g_value_set_flags (value, self->flags);
+ break;
+ default:
+ G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+ break;
+ }
+}
+
+static void
+gs_plugin_job_list_categories_set_property (GObject *object,
+ guint prop_id,
+ const GValue *value,
+ GParamSpec *pspec)
+{
+ GsPluginJobListCategories *self = GS_PLUGIN_JOB_LIST_CATEGORIES (object);
+
+ switch ((GsPluginJobListCategoriesProperty) prop_id) {
+ case PROP_FLAGS:
+ /* Construct only. */
+ g_assert (self->flags == 0);
+ self->flags = g_value_get_flags (value);
+ g_object_notify_by_pspec (object, props[prop_id]);
+ break;
+ default:
+ G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+ break;
+ }
+}
+
+static void plugin_refine_categories_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data);
+static void finish_op (GTask *task,
+ GError *error);
+
+static void
+gs_plugin_job_list_categories_run_async (GsPluginJob *job,
+ GsPluginLoader *plugin_loader,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data)
+{
+ GsPluginJobListCategories *self = GS_PLUGIN_JOB_LIST_CATEGORIES (job);
+ g_autoptr(GTask) task = NULL;
+ GPtrArray *plugins; /* (element-type GsPlugin) */
+ gboolean anything_ran = FALSE;
+ GsCategory * const *categories = NULL;
+ gsize n_categories;
+
+ task = g_task_new (job, cancellable, callback, user_data);
+ g_task_set_source_tag (task, gs_plugin_job_list_categories_run_async);
+ g_task_set_task_data (task, g_object_ref (plugin_loader), (GDestroyNotify) g_object_unref);
+
+ /* get the categories */
+ categories = gs_category_manager_get_categories (gs_plugin_loader_get_category_manager (plugin_loader), &n_categories);
+ self->category_list = g_ptr_array_new_full (n_categories, (GDestroyNotify) g_object_unref);
+
+ for (gsize i = 0; i < n_categories; i++)
+ g_ptr_array_add (self->category_list, g_object_ref (categories[i]));
+
+ /* run each plugin, keeping a counter of pending operations which is
+ * initialised to 1 until all the operations are started */
+ self->n_pending_ops = 1;
+ plugins = gs_plugin_loader_get_plugins (plugin_loader);
+
+ for (guint i = 0; i < plugins->len; i++) {
+ GsPlugin *plugin = g_ptr_array_index (plugins, i);
+ GsPluginClass *plugin_class = GS_PLUGIN_GET_CLASS (plugin);
+
+ if (!gs_plugin_get_enabled (plugin))
+ continue;
+ if (plugin_class->refine_categories_async == NULL)
+ continue;
+
+ /* at least one plugin supports this vfunc */
+ anything_ran = TRUE;
+
+ /* run the plugin */
+ self->n_pending_ops++;
+ plugin_class->refine_categories_async (plugin, self->category_list, self->flags, cancellable, plugin_refine_categories_cb, g_object_ref (task));
+ }
+
+ if (!anything_ran)
+ g_debug ("no plugin could handle listing categories");
+
+ finish_op (task, NULL);
+}
+
+static void
+plugin_refine_categories_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data)
+{
+ GsPlugin *plugin = GS_PLUGIN (source_object);
+ GsPluginClass *plugin_class = GS_PLUGIN_GET_CLASS (plugin);
+ g_autoptr(GTask) task = G_TASK (user_data);
+ g_autoptr(GError) local_error = NULL;
+
+ if (!plugin_class->refine_categories_finish (plugin, result, &local_error)) {
+ finish_op (task, g_steal_pointer (&local_error));
+ return;
+ }
+
+ gs_plugin_status_update (plugin, NULL, GS_PLUGIN_STATUS_FINISHED);
+
+ finish_op (task, g_steal_pointer (&local_error));
+}
+
+static gint
+category_sort_cb (gconstpointer a,
+ gconstpointer b)
+{
+ GsCategory *category_a = GS_CATEGORY (*(GsCategory **) a);
+ GsCategory *category_b = GS_CATEGORY (*(GsCategory **) b);
+ gint score_a = gs_category_get_score (category_a);
+ gint score_b = gs_category_get_score (category_b);
+
+ if (score_a != score_b)
+ return score_b - score_a;
+ return gs_utils_sort_strcmp (gs_category_get_name (category_a),
+ gs_category_get_name (category_b));
+}
+
+/* @error is (transfer full) if non-%NULL */
+static void
+finish_op (GTask *task,
+ GError *error)
+{
+ GsPluginJobListCategories *self = g_task_get_source_object (task);
+ g_autoptr(GPtrArray) category_list = NULL;
+ g_autoptr(GError) error_owned = g_steal_pointer (&error);
+ g_autofree gchar *job_debug = NULL;
+
+ if (error_owned != NULL && self->saved_error == NULL)
+ self->saved_error = g_steal_pointer (&error_owned);
+ else if (error_owned != NULL)
+ g_debug ("Additional error while listing categories: %s", error_owned->message);
+
+ g_assert (self->n_pending_ops > 0);
+ self->n_pending_ops--;
+
+ if (self->n_pending_ops > 0)
+ return;
+
+ /* Get the results of the parallel ops. */
+ category_list = g_steal_pointer (&self->category_list);
+
+ if (self->saved_error != NULL) {
+ g_task_return_error (task, g_steal_pointer (&self->saved_error));
+ return;
+ }
+
+ /* sort by name */
+ g_ptr_array_sort (category_list, category_sort_cb);
+ for (guint i = 0; i < category_list->len; i++) {
+ GsCategory *category = GS_CATEGORY (g_ptr_array_index (category_list, i));
+ gs_category_sort_children (category);
+ }
+
+ /* show elapsed time */
+ job_debug = gs_plugin_job_to_string (GS_PLUGIN_JOB (self));
+ g_debug ("%s", job_debug);
+
+ /* Check the intermediate working values are all cleared. */
+ g_assert (self->category_list == NULL);
+ g_assert (self->saved_error == NULL);
+ g_assert (self->n_pending_ops == 0);
+
+ /* success */
+ self->result_list = g_ptr_array_ref (category_list);
+ g_task_return_boolean (task, TRUE);
+}
+
+static gboolean
+gs_plugin_job_list_categories_run_finish (GsPluginJob *self,
+ GAsyncResult *result,
+ GError **error)
+{
+ return g_task_propagate_boolean (G_TASK (result), error);
+}
+
+static void
+gs_plugin_job_list_categories_class_init (GsPluginJobListCategoriesClass *klass)
+{
+ GObjectClass *object_class = G_OBJECT_CLASS (klass);
+ GsPluginJobClass *job_class = GS_PLUGIN_JOB_CLASS (klass);
+
+ object_class->dispose = gs_plugin_job_list_categories_dispose;
+ object_class->get_property = gs_plugin_job_list_categories_get_property;
+ object_class->set_property = gs_plugin_job_list_categories_set_property;
+
+ job_class->run_async = gs_plugin_job_list_categories_run_async;
+ job_class->run_finish = gs_plugin_job_list_categories_run_finish;
+
+ /**
+ * GsPluginJobListCategories:flags:
+ *
+ * Flags to specify how the operation should run.
+ *
+ * Since: 43
+ */
+ props[PROP_FLAGS] =
+ g_param_spec_flags ("flags", "Flags",
+ "Flags to specify how the operation should run.",
+ GS_TYPE_PLUGIN_REFINE_CATEGORIES_FLAGS,
+ GS_PLUGIN_REFINE_CATEGORIES_FLAGS_NONE,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY |
+ G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ g_object_class_install_properties (object_class, G_N_ELEMENTS (props), props);
+}
+
+static void
+gs_plugin_job_list_categories_init (GsPluginJobListCategories *self)
+{
+}
+
+/**
+ * gs_plugin_job_list_categories_new:
+ * @flags: flags affecting how the operation runs
+ *
+ * Create a new #GsPluginJobListCategories for listing categories.
+ *
+ * Returns: (transfer full): a new #GsPluginJobListCategories
+ * Since: 43
+ */
+GsPluginJob *
+gs_plugin_job_list_categories_new (GsPluginRefineCategoriesFlags flags)
+{
+ return g_object_new (GS_TYPE_PLUGIN_JOB_LIST_CATEGORIES,
+ "flags", flags,
+ NULL);
+}
+
+/**
+ * gs_plugin_job_list_categories_get_result_list:
+ * @self: a #GsPluginJobListCategories
+ *
+ * Get the full list of categories.
+ *
+ * If this is called before the job is complete, %NULL will be returned.
+ *
+ * Returns: (transfer none) (nullable) (element-type GsCategory): the job
+ * results, or %NULL on error or if called before the job has completed
+ * Since: 43
+ */
+GPtrArray *
+gs_plugin_job_list_categories_get_result_list (GsPluginJobListCategories *self)
+{
+ g_return_val_if_fail (GS_IS_PLUGIN_JOB_LIST_CATEGORIES (self), NULL);
+
+ return self->result_list;
+}
diff --git a/lib/gs-plugin-job-list-categories.h b/lib/gs-plugin-job-list-categories.h
new file mode 100644
index 0000000..49857f3
--- /dev/null
+++ b/lib/gs-plugin-job-list-categories.h
@@ -0,0 +1,29 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2022 Endless OS Foundation LLC
+ *
+ * Author: Philip Withnall <pwithnall@endlessos.org>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#include <glib.h>
+#include <glib-object.h>
+#include <gio/gio.h>
+
+#include "gs-plugin-job.h"
+
+G_BEGIN_DECLS
+
+#define GS_TYPE_PLUGIN_JOB_LIST_CATEGORIES (gs_plugin_job_list_categories_get_type ())
+
+G_DECLARE_FINAL_TYPE (GsPluginJobListCategories, gs_plugin_job_list_categories, GS, PLUGIN_JOB_LIST_CATEGORIES, GsPluginJob)
+
+GsPluginJob *gs_plugin_job_list_categories_new (GsPluginRefineCategoriesFlags flags);
+
+GPtrArray *gs_plugin_job_list_categories_get_result_list (GsPluginJobListCategories *self);
+
+G_END_DECLS
diff --git a/lib/gs-plugin-job-list-distro-upgrades.c b/lib/gs-plugin-job-list-distro-upgrades.c
new file mode 100644
index 0000000..618bb2a
--- /dev/null
+++ b/lib/gs-plugin-job-list-distro-upgrades.c
@@ -0,0 +1,428 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2022 Endless OS Foundation LLC
+ *
+ * Author: Philip Withnall <pwithnall@endlessos.org>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+/**
+ * SECTION:gs-plugin-job-list-distro-upgrades
+ * @short_description: A plugin job to list distro upgrades
+ *
+ * #GsPluginJobListDistroUpgrades is a #GsPluginJob representing an operation to
+ * list available upgrades for the distro, from all #GsPlugins.
+ *
+ * Upgrades for the distro are large upgrades, such as from Fedora 34 to
+ * Fedora 35. They are not small package updates.
+ *
+ * This job will list the available upgrades, but will not download them or
+ * install them. Due to the typical size of an upgrade, these should not be
+ * downloaded until the user has explicitly requested it.
+ *
+ * The known properties on the set of apps returned by this operation can be
+ * controlled with the #GsPluginJobListDistroUpgrades:refine-flags property. All
+ * results will be refined using %GS_PLUGIN_REFINE_FLAGS_REQUIRE_SETUP_ACTION
+ * plus the given set of refine flags. See #GsPluginJobRefine.
+ *
+ * This class is a wrapper around #GsPluginClass.list_distro_upgrades_async,
+ * calling it for all loaded plugins, with some additional filtering
+ * done on the results and #GsPluginJobRefine used to refine them.
+ *
+ * Retrieve the resulting #GsAppList using
+ * gs_plugin_job_list_distro_upgrades_get_result_list(). Components in the list
+ * are expected to be of type %AS_COMPONENT_KIND_OPERATING_SYSTEM.
+ *
+ * See also: #GsPluginClass.list_distro_upgrades_async
+ * Since: 42
+ */
+
+#include "config.h"
+
+#include <glib.h>
+#include <glib-object.h>
+#include <glib/gi18n.h>
+
+#include "gs-app.h"
+#include "gs-app-list-private.h"
+#include "gs-enums.h"
+#include "gs-plugin-job-private.h"
+#include "gs-plugin-job-list-distro-upgrades.h"
+#include "gs-plugin-job-refine.h"
+#include "gs-plugin-private.h"
+#include "gs-plugin-types.h"
+#include "gs-utils.h"
+
+struct _GsPluginJobListDistroUpgrades
+{
+ GsPluginJob parent;
+
+ /* Input arguments. */
+ GsPluginListDistroUpgradesFlags flags;
+ GsPluginRefineFlags refine_flags;
+
+ /* In-progress data. */
+ GsAppList *merged_list; /* (owned) (nullable) */
+ GError *saved_error; /* (owned) (nullable) */
+ guint n_pending_ops;
+
+ /* Results. */
+ GsAppList *result_list; /* (owned) (nullable) */
+};
+
+G_DEFINE_TYPE (GsPluginJobListDistroUpgrades, gs_plugin_job_list_distro_upgrades, GS_TYPE_PLUGIN_JOB)
+
+typedef enum {
+ PROP_REFINE_FLAGS = 1,
+ PROP_FLAGS,
+} GsPluginJobListDistroUpgradesProperty;
+
+static GParamSpec *props[PROP_FLAGS + 1] = { NULL, };
+
+static void
+gs_plugin_job_list_distro_upgrades_dispose (GObject *object)
+{
+ GsPluginJobListDistroUpgrades *self = GS_PLUGIN_JOB_LIST_DISTRO_UPGRADES (object);
+
+ g_assert (self->merged_list == NULL);
+ g_assert (self->saved_error == NULL);
+ g_assert (self->n_pending_ops == 0);
+
+ g_clear_object (&self->result_list);
+
+ G_OBJECT_CLASS (gs_plugin_job_list_distro_upgrades_parent_class)->dispose (object);
+}
+
+static void
+gs_plugin_job_list_distro_upgrades_get_property (GObject *object,
+ guint prop_id,
+ GValue *value,
+ GParamSpec *pspec)
+{
+ GsPluginJobListDistroUpgrades *self = GS_PLUGIN_JOB_LIST_DISTRO_UPGRADES (object);
+
+ switch ((GsPluginJobListDistroUpgradesProperty) prop_id) {
+ case PROP_REFINE_FLAGS:
+ g_value_set_flags (value, self->refine_flags);
+ break;
+ case PROP_FLAGS:
+ g_value_set_flags (value, self->flags);
+ break;
+ default:
+ G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+ break;
+ }
+}
+
+static void
+gs_plugin_job_list_distro_upgrades_set_property (GObject *object,
+ guint prop_id,
+ const GValue *value,
+ GParamSpec *pspec)
+{
+ GsPluginJobListDistroUpgrades *self = GS_PLUGIN_JOB_LIST_DISTRO_UPGRADES (object);
+
+ switch ((GsPluginJobListDistroUpgradesProperty) prop_id) {
+ case PROP_REFINE_FLAGS:
+ /* Construct only. */
+ g_assert (self->refine_flags == 0);
+ self->refine_flags = g_value_get_flags (value);
+ g_object_notify_by_pspec (object, props[prop_id]);
+ break;
+ case PROP_FLAGS:
+ /* Construct only. */
+ g_assert (self->flags == 0);
+ self->flags = g_value_get_flags (value);
+ g_object_notify_by_pspec (object, props[prop_id]);
+ break;
+ default:
+ G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+ break;
+ }
+}
+
+static gint
+app_sort_version_cb (GsApp *app1,
+ GsApp *app2,
+ gpointer user_data)
+{
+ return as_vercmp_simple (gs_app_get_version (app1),
+ gs_app_get_version (app2));
+}
+
+static void plugin_list_distro_upgrades_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data);
+static void finish_op (GTask *task,
+ GError *error);
+static void refine_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data);
+static void finish_task (GTask *task,
+ GsAppList *merged_list);
+
+static void
+gs_plugin_job_list_distro_upgrades_run_async (GsPluginJob *job,
+ GsPluginLoader *plugin_loader,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data)
+{
+ GsPluginJobListDistroUpgrades *self = GS_PLUGIN_JOB_LIST_DISTRO_UPGRADES (job);
+ g_autoptr(GTask) task = NULL;
+ GPtrArray *plugins; /* (element-type GsPlugin) */
+ gboolean anything_ran = FALSE;
+
+ /* check required args */
+ task = g_task_new (job, cancellable, callback, user_data);
+ g_task_set_source_tag (task, gs_plugin_job_list_distro_upgrades_run_async);
+ g_task_set_task_data (task, g_object_ref (plugin_loader), (GDestroyNotify) g_object_unref);
+
+ /* run each plugin, keeping a counter of pending operations which is
+ * initialised to 1 until all the operations are started */
+ self->n_pending_ops = 1;
+ self->merged_list = gs_app_list_new ();
+ plugins = gs_plugin_loader_get_plugins (plugin_loader);
+
+ for (guint i = 0; i < plugins->len; i++) {
+ GsPlugin *plugin = g_ptr_array_index (plugins, i);
+ GsPluginClass *plugin_class = GS_PLUGIN_GET_CLASS (plugin);
+
+ if (!gs_plugin_get_enabled (plugin))
+ continue;
+ if (plugin_class->list_distro_upgrades_async == NULL)
+ continue;
+
+ /* at least one plugin supports this vfunc */
+ anything_ran = TRUE;
+
+ /* run the plugin */
+ self->n_pending_ops++;
+ plugin_class->list_distro_upgrades_async (plugin, self->flags, cancellable, plugin_list_distro_upgrades_cb, g_object_ref (task));
+ }
+
+ if (!anything_ran)
+ g_debug ("no plugin could handle listing distro upgrades");
+
+ finish_op (task, NULL);
+}
+
+static void
+plugin_list_distro_upgrades_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data)
+{
+ GsPlugin *plugin = GS_PLUGIN (source_object);
+ GsPluginClass *plugin_class = GS_PLUGIN_GET_CLASS (plugin);
+ g_autoptr(GTask) task = G_TASK (user_data);
+ GsPluginJobListDistroUpgrades *self = g_task_get_source_object (task);
+ g_autoptr(GsAppList) plugin_apps = NULL;
+ g_autoptr(GError) local_error = NULL;
+
+ plugin_apps = plugin_class->list_distro_upgrades_finish (plugin, result, &local_error);
+ gs_plugin_status_update (plugin, NULL, GS_PLUGIN_STATUS_FINISHED);
+
+ if (plugin_apps != NULL)
+ gs_app_list_add_list (self->merged_list, plugin_apps);
+
+ finish_op (task, g_steal_pointer (&local_error));
+}
+
+/* @error is (transfer full) if non-%NULL */
+static void
+finish_op (GTask *task,
+ GError *error)
+{
+ GsPluginJobListDistroUpgrades *self = g_task_get_source_object (task);
+ GCancellable *cancellable = g_task_get_cancellable (task);
+ GsPluginLoader *plugin_loader = g_task_get_task_data (task);
+ g_autoptr(GsAppList) merged_list = NULL;
+ g_autoptr(GError) error_owned = g_steal_pointer (&error);
+
+ if (error_owned != NULL && self->saved_error == NULL)
+ self->saved_error = g_steal_pointer (&error_owned);
+ else if (error_owned != NULL)
+ g_debug ("Additional error while listing distro upgrades: %s", error_owned->message);
+
+ g_assert (self->n_pending_ops > 0);
+ self->n_pending_ops--;
+
+ if (self->n_pending_ops > 0)
+ return;
+
+ /* Get the results of the parallel ops. */
+ merged_list = g_steal_pointer (&self->merged_list);
+
+ if (self->saved_error != NULL) {
+ g_task_return_error (task, g_steal_pointer (&self->saved_error));
+ return;
+ }
+
+ /* run refine() on each one if required */
+ if (merged_list != NULL &&
+ gs_app_list_length (merged_list) > 0) {
+ g_autoptr(GsPluginJob) refine_job = NULL;
+
+ /* Always specify REQUIRE_SETUP_ACTION, as that requires enough
+ * information to be able to install the upgrade later if
+ * requested. */
+ refine_job = gs_plugin_job_refine_new (merged_list,
+ self->refine_flags |
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_SETUP_ACTION |
+ GS_PLUGIN_REFINE_FLAGS_DISABLE_FILTERING);
+ gs_plugin_loader_job_process_async (plugin_loader, refine_job,
+ cancellable,
+ refine_cb,
+ g_object_ref (task));
+ } else {
+ g_debug ("No distro upgrades to refine");
+ finish_task (task, merged_list);
+ }
+}
+
+static void
+refine_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data)
+{
+ GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (source_object);
+ g_autoptr(GTask) task = G_TASK (user_data);
+ g_autoptr(GsAppList) new_list = NULL;
+ g_autoptr(GError) local_error = NULL;
+
+ new_list = gs_plugin_loader_job_process_finish (plugin_loader, result, &local_error);
+ if (new_list == NULL) {
+ gs_utils_error_convert_gio (&local_error);
+ g_task_return_error (task, g_steal_pointer (&local_error));
+ return;
+ }
+
+ finish_task (task, new_list);
+}
+
+static void
+finish_task (GTask *task,
+ GsAppList *merged_list)
+{
+ GsPluginJobListDistroUpgrades *self = g_task_get_source_object (task);
+ g_autofree gchar *job_debug = NULL;
+
+ /* Sort the results. The refine may have added useful metadata. */
+ gs_app_list_sort (merged_list, app_sort_version_cb, NULL);
+
+ /* show elapsed time */
+ job_debug = gs_plugin_job_to_string (GS_PLUGIN_JOB (self));
+ g_debug ("%s", job_debug);
+
+ /* Check the intermediate working values are all cleared. */
+ g_assert (self->merged_list == NULL);
+ g_assert (self->saved_error == NULL);
+ g_assert (self->n_pending_ops == 0);
+
+ /* success */
+ g_set_object (&self->result_list, merged_list);
+ g_task_return_boolean (task, TRUE);
+}
+
+static gboolean
+gs_plugin_job_list_distro_upgrades_run_finish (GsPluginJob *self,
+ GAsyncResult *result,
+ GError **error)
+{
+ return g_task_propagate_boolean (G_TASK (result), error);
+}
+
+static void
+gs_plugin_job_list_distro_upgrades_class_init (GsPluginJobListDistroUpgradesClass *klass)
+{
+ GObjectClass *object_class = G_OBJECT_CLASS (klass);
+ GsPluginJobClass *job_class = GS_PLUGIN_JOB_CLASS (klass);
+
+ object_class->dispose = gs_plugin_job_list_distro_upgrades_dispose;
+ object_class->get_property = gs_plugin_job_list_distro_upgrades_get_property;
+ object_class->set_property = gs_plugin_job_list_distro_upgrades_set_property;
+
+ job_class->run_async = gs_plugin_job_list_distro_upgrades_run_async;
+ job_class->run_finish = gs_plugin_job_list_distro_upgrades_run_finish;
+
+ /**
+ * GsPluginJobListDistroUpgrades:refine-flags:
+ *
+ * Flags to specify how to refine the returned apps.
+ *
+ * %GS_PLUGIN_REFINE_FLAGS_REQUIRE_SETUP_ACTION will always be used.
+ *
+ * Since: 42
+ */
+ props[PROP_REFINE_FLAGS] =
+ g_param_spec_flags ("refine-flags", "Refine Flags",
+ "Flags to specify how to refine the returned apps.",
+ GS_TYPE_PLUGIN_REFINE_FLAGS, GS_PLUGIN_REFINE_FLAGS_NONE,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY |
+ G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ /**
+ * GsPluginJobListDistroUpgrades:flags:
+ *
+ * Flags to specify how the operation should run.
+ *
+ * Since: 42
+ */
+ props[PROP_FLAGS] =
+ g_param_spec_flags ("flags", "Flags",
+ "Flags to specify how the operation should run.",
+ GS_TYPE_PLUGIN_LIST_DISTRO_UPGRADES_FLAGS,
+ GS_PLUGIN_LIST_DISTRO_UPGRADES_FLAGS_NONE,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY |
+ G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ g_object_class_install_properties (object_class, G_N_ELEMENTS (props), props);
+}
+
+static void
+gs_plugin_job_list_distro_upgrades_init (GsPluginJobListDistroUpgrades *self)
+{
+}
+
+/**
+ * gs_plugin_job_list_distro_upgrades_new:
+ * @flags: flags affecting how the operation runs
+ * @refine_flags: flags to affect how the results are refined
+ *
+ * Create a new #GsPluginJobListDistroUpgrades for listing the available distro
+ * upgrades.
+ *
+ * Returns: (transfer full): a new #GsPluginJobListDistroUpgrades
+ * Since: 42
+ */
+GsPluginJob *
+gs_plugin_job_list_distro_upgrades_new (GsPluginListDistroUpgradesFlags flags,
+ GsPluginRefineFlags refine_flags)
+{
+ return g_object_new (GS_TYPE_PLUGIN_JOB_LIST_DISTRO_UPGRADES,
+ "refine-flags", refine_flags,
+ "flags", flags,
+ NULL);
+}
+
+/**
+ * gs_plugin_job_list_distro_upgrades_get_result_list:
+ * @self: a #GsPluginJobListDistroUpgrades
+ *
+ * Get the full list of available distro upgrades.
+ *
+ * If this is called before the job is complete, %NULL will be returned.
+ *
+ * Returns: (transfer none) (nullable): the job results, or %NULL on error
+ * or if called before the job has completed
+ * Since: 42
+ */
+GsAppList *
+gs_plugin_job_list_distro_upgrades_get_result_list (GsPluginJobListDistroUpgrades *self)
+{
+ g_return_val_if_fail (GS_IS_PLUGIN_JOB_LIST_DISTRO_UPGRADES (self), NULL);
+
+ return self->result_list;
+}
diff --git a/lib/gs-plugin-job-list-distro-upgrades.h b/lib/gs-plugin-job-list-distro-upgrades.h
new file mode 100644
index 0000000..0d3b0a2
--- /dev/null
+++ b/lib/gs-plugin-job-list-distro-upgrades.h
@@ -0,0 +1,30 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2022 Endless OS Foundation LLC
+ *
+ * Author: Philip Withnall <pwithnall@endlessos.org>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#include <glib.h>
+#include <glib-object.h>
+#include <gio/gio.h>
+
+#include "gs-plugin-job.h"
+
+G_BEGIN_DECLS
+
+#define GS_TYPE_PLUGIN_JOB_LIST_DISTRO_UPGRADES (gs_plugin_job_list_distro_upgrades_get_type ())
+
+G_DECLARE_FINAL_TYPE (GsPluginJobListDistroUpgrades, gs_plugin_job_list_distro_upgrades, GS, PLUGIN_JOB_LIST_DISTRO_UPGRADES, GsPluginJob)
+
+GsPluginJob *gs_plugin_job_list_distro_upgrades_new (GsPluginListDistroUpgradesFlags flags,
+ GsPluginRefineFlags refine_flags);
+
+GsAppList *gs_plugin_job_list_distro_upgrades_get_result_list (GsPluginJobListDistroUpgrades *self);
+
+G_END_DECLS
diff --git a/lib/gs-plugin-job-manage-repository.c b/lib/gs-plugin-job-manage-repository.c
new file mode 100644
index 0000000..2b8ee66
--- /dev/null
+++ b/lib/gs-plugin-job-manage-repository.c
@@ -0,0 +1,365 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2022 Red Hat <www.redhat.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+/**
+ * SECTION:gs-plugin-job-manage-repository
+ * @short_description: A plugin job on a repository
+ *
+ * #GsPluginJobManageRepository is a #GsPluginJob representing an operation on
+ * a repository, like install, remove, enable and disable it.
+ *
+ * This class is a wrapper around #GsPluginClass.install_repository_async,
+ * #GsPluginClass.remove_repository_async, #GsPluginClass.enable_repository_async
+ * and #GsPluginClass.disable_repository_async calling it for all loaded plugins.
+ *
+ * Since: 43
+ */
+
+#include "config.h"
+
+#include <glib.h>
+#include <glib-object.h>
+
+#include "gs-app.h"
+#include "gs-app-collation.h"
+#include "gs-enums.h"
+#include "gs-plugin-job.h"
+#include "gs-plugin-job-manage-repository.h"
+#include "gs-plugin-job-private.h"
+#include "gs-plugin-types.h"
+
+struct _GsPluginJobManageRepository
+{
+ GsPluginJob parent;
+
+ /* Input arguments. */
+ GsApp *repository; /* (owned) (not nullable) */
+ GsPluginManageRepositoryFlags flags;
+
+ /* In-progress data. */
+ GError *saved_error; /* (owned) (nullable) */
+ guint n_pending_ops;
+};
+
+G_DEFINE_TYPE (GsPluginJobManageRepository, gs_plugin_job_manage_repository, GS_TYPE_PLUGIN_JOB)
+
+typedef enum {
+ PROP_FLAGS = 1,
+ PROP_REPOSITORY,
+} GsPluginJobManageRepositoryProperty;
+
+static GParamSpec *props[PROP_REPOSITORY + 1] = { NULL, };
+
+static void
+gs_plugin_job_manage_repository_dispose (GObject *object)
+{
+ GsPluginJobManageRepository *self = GS_PLUGIN_JOB_MANAGE_REPOSITORY (object);
+
+ g_assert (self->saved_error == NULL);
+ g_assert (self->n_pending_ops == 0);
+
+ g_clear_object (&self->repository);
+
+ G_OBJECT_CLASS (gs_plugin_job_manage_repository_parent_class)->dispose (object);
+}
+
+static void
+gs_plugin_job_manage_repository_get_property (GObject *object,
+ guint prop_id,
+ GValue *value,
+ GParamSpec *pspec)
+{
+ GsPluginJobManageRepository *self = GS_PLUGIN_JOB_MANAGE_REPOSITORY (object);
+
+ switch ((GsPluginJobManageRepositoryProperty) prop_id) {
+ case PROP_FLAGS:
+ g_value_set_flags (value, self->flags);
+ break;
+ case PROP_REPOSITORY:
+ g_value_set_object (value, self->repository);
+ break;
+ default:
+ G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+ break;
+ }
+}
+
+static void
+gs_plugin_job_manage_repository_set_property (GObject *object,
+ guint prop_id,
+ const GValue *value,
+ GParamSpec *pspec)
+{
+ GsPluginJobManageRepository *self = GS_PLUGIN_JOB_MANAGE_REPOSITORY (object);
+
+ switch ((GsPluginJobManageRepositoryProperty) prop_id) {
+ case PROP_FLAGS:
+ /* Construct only. */
+ g_assert (self->flags == 0);
+ self->flags = g_value_get_flags (value);
+ g_object_notify_by_pspec (object, props[prop_id]);
+ break;
+ case PROP_REPOSITORY:
+ /* Construct only. */
+ g_assert (self->repository == NULL);
+ self->repository = g_value_dup_object (value);
+ g_assert (self->repository != NULL);
+ g_object_notify_by_pspec (object, props[prop_id]);
+ break;
+ default:
+ G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+ break;
+ }
+}
+
+static void plugin_repository_func_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data);
+static void finish_op (GTask *task,
+ GError *error);
+
+static void
+gs_plugin_job_manage_repository_run_async (GsPluginJob *job,
+ GsPluginLoader *plugin_loader,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data)
+{
+ GsPluginJobManageRepository *self = GS_PLUGIN_JOB_MANAGE_REPOSITORY (job);
+ g_autoptr(GTask) task = NULL;
+ GPtrArray *plugins; /* (element-type GsPlugin) */
+ gboolean anything_ran = FALSE;
+
+ task = g_task_new (job, cancellable, callback, user_data);
+ g_task_set_source_tag (task, gs_plugin_job_manage_repository_run_async);
+ g_task_set_task_data (task, g_object_ref (plugin_loader), (GDestroyNotify) g_object_unref);
+
+ /* run each plugin, keeping a counter of pending operations which is
+ * initialised to 1 until all the operations are started */
+ self->n_pending_ops = 1;
+ plugins = gs_plugin_loader_get_plugins (plugin_loader);
+
+ for (guint i = 0; i < plugins->len; i++) {
+ GsPlugin *plugin = g_ptr_array_index (plugins, i);
+ GsPluginClass *plugin_class = GS_PLUGIN_GET_CLASS (plugin);
+ void (* repository_func_async) (GsPlugin *plugin,
+ GsApp *repository,
+ GsPluginManageRepositoryFlags flags,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data) = NULL;
+
+ if (!gs_plugin_get_enabled (plugin))
+ continue;
+ if ((self->flags & GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_INSTALL) != 0)
+ repository_func_async = plugin_class->install_repository_async;
+ else if ((self->flags & GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_REMOVE) != 0)
+ repository_func_async = plugin_class->remove_repository_async;
+ else if ((self->flags & GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_ENABLE) != 0)
+ repository_func_async = plugin_class->enable_repository_async;
+ else if ((self->flags & GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_DISABLE) != 0)
+ repository_func_async = plugin_class->disable_repository_async;
+ else
+ g_assert_not_reached ();
+
+ if (repository_func_async == NULL)
+ continue;
+
+ /* at least one plugin supports this vfunc */
+ anything_ran = TRUE;
+
+ /* run the plugin */
+ self->n_pending_ops++;
+ repository_func_async (plugin, self->repository, self->flags, cancellable, plugin_repository_func_cb, g_object_ref (task));
+ }
+
+ if (!anything_ran)
+ g_debug ("no plugin could handle repository operation");
+
+ finish_op (task, NULL);
+}
+
+static void
+plugin_repository_func_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data)
+{
+ GsPlugin *plugin = GS_PLUGIN (source_object);
+ GsPluginClass *plugin_class = GS_PLUGIN_GET_CLASS (plugin);
+ g_autoptr(GTask) task = G_TASK (user_data);
+ GsPluginJobManageRepository *self = g_task_get_source_object (task);
+ gboolean success;
+ g_autoptr(GError) local_error = NULL;
+ gboolean (* repository_func_finish) (GsPlugin *plugin,
+ GAsyncResult *result,
+ GError **error) = NULL;
+
+ if ((self->flags & GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_INSTALL) != 0)
+ repository_func_finish = plugin_class->install_repository_finish;
+ else if ((self->flags & GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_REMOVE) != 0)
+ repository_func_finish = plugin_class->remove_repository_finish;
+ else if ((self->flags & GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_ENABLE) != 0)
+ repository_func_finish = plugin_class->enable_repository_finish;
+ else if ((self->flags & GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_DISABLE) != 0)
+ repository_func_finish = plugin_class->disable_repository_finish;
+ else
+ g_assert_not_reached ();
+
+ success = repository_func_finish (plugin, result, &local_error);
+ gs_plugin_status_update (plugin, NULL, GS_PLUGIN_STATUS_FINISHED);
+
+ g_assert (success || local_error != NULL);
+
+ finish_op (task, g_steal_pointer (&local_error));
+}
+
+static void
+reset_app_progress (GsApp *app)
+{
+ g_autoptr(GsAppList) addons = gs_app_dup_addons (app);
+ GsAppList *related = gs_app_get_related (app);
+
+ gs_app_set_progress (app, GS_APP_PROGRESS_UNKNOWN);
+
+ for (guint i = 0; addons != NULL && i < gs_app_list_length (addons); i++) {
+ GsApp *app_addons = gs_app_list_index (addons, i);
+ gs_app_set_progress (app_addons, GS_APP_PROGRESS_UNKNOWN);
+ }
+ for (guint i = 0; i < gs_app_list_length (related); i++) {
+ GsApp *app_related = gs_app_list_index (related, i);
+ gs_app_set_progress (app_related, GS_APP_PROGRESS_UNKNOWN);
+ }
+}
+
+/* @error is (transfer full) if non-%NULL */
+static void
+finish_op (GTask *task,
+ GError *error)
+{
+ GsPluginJobManageRepository *self = g_task_get_source_object (task);
+ g_autoptr(GError) error_owned = g_steal_pointer (&error);
+ g_autofree gchar *job_debug = NULL;
+
+ if (error_owned != NULL && self->saved_error == NULL)
+ self->saved_error = g_steal_pointer (&error_owned);
+ else if (error_owned != NULL)
+ g_debug ("Additional error while managing repository: %s", error_owned->message);
+
+ g_assert (self->n_pending_ops > 0);
+ self->n_pending_ops--;
+
+ if (self->n_pending_ops > 0)
+ return;
+
+ /* show elapsed time */
+ job_debug = gs_plugin_job_to_string (GS_PLUGIN_JOB (self));
+ g_debug ("%s", job_debug);
+
+ reset_app_progress (self->repository);
+
+ if (self->saved_error != NULL)
+ g_task_return_error (task, g_steal_pointer (&self->saved_error));
+ else
+ g_task_return_boolean (task, TRUE);
+}
+
+static gboolean
+gs_plugin_job_manage_repository_run_finish (GsPluginJob *self,
+ GAsyncResult *result,
+ GError **error)
+{
+ return g_task_propagate_boolean (G_TASK (result), error);
+}
+
+static void
+gs_plugin_job_manage_repository_class_init (GsPluginJobManageRepositoryClass *klass)
+{
+ GObjectClass *object_class = G_OBJECT_CLASS (klass);
+ GsPluginJobClass *job_class = GS_PLUGIN_JOB_CLASS (klass);
+
+ object_class->dispose = gs_plugin_job_manage_repository_dispose;
+ object_class->get_property = gs_plugin_job_manage_repository_get_property;
+ object_class->set_property = gs_plugin_job_manage_repository_set_property;
+
+ job_class->run_async = gs_plugin_job_manage_repository_run_async;
+ job_class->run_finish = gs_plugin_job_manage_repository_run_finish;
+
+ /**
+ * GsPluginJobManageRepository:repository: (not nullable)
+ *
+ * A #GsApp describing the repository to run the operation on.
+ *
+ * Since: 43
+ */
+ props[PROP_REPOSITORY] =
+ g_param_spec_object ("repository", "Repository",
+ "A #GsApp describing the repository to run the operation on.",
+ GS_TYPE_APP,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY |
+ G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ /**
+ * GsPluginJobManageRepository:flags:
+ *
+ * Flags to specify how and which the operation should run.
+ * Only one of the %GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_INSTALL,
+ * %GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_REMOVE, %GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_ENABLE and
+ * %GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_DISABLE can be specified.
+ *
+ * Since: 43
+ */
+ props[PROP_FLAGS] =
+ g_param_spec_flags ("flags", "Flags",
+ "Flags to specify how and which the operation should run.",
+ GS_TYPE_PLUGIN_MANAGE_REPOSITORY_FLAGS,
+ GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_NONE,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY |
+ G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ g_object_class_install_properties (object_class, G_N_ELEMENTS (props), props);
+}
+
+static void
+gs_plugin_job_manage_repository_init (GsPluginJobManageRepository *self)
+{
+}
+
+/**
+ * gs_plugin_job_manage_repository_new:
+ * @repository: (not nullable) (transfer none): a repository to run the operation on
+ * @flags: flags affecting how the operation runs
+ *
+ * Create a new #GsPluginJobManageRepository to manage the given @repository.
+ *
+ * Returns: (transfer full): a new #GsPluginJobManageRepository
+ * Since: 43
+ */
+GsPluginJob *
+gs_plugin_job_manage_repository_new (GsApp *repository,
+ GsPluginManageRepositoryFlags flags)
+{
+ guint nops = 0;
+
+ g_return_val_if_fail (GS_IS_APP (repository), NULL);
+
+ if ((flags & GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_INSTALL) != 0)
+ nops++;
+ if ((flags & GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_REMOVE) != 0)
+ nops++;
+ if ((flags & GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_ENABLE) != 0)
+ nops++;
+ if ((flags & GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_DISABLE) != 0)
+ nops++;
+
+ g_return_val_if_fail (nops == 1, NULL);
+
+ return g_object_new (GS_TYPE_PLUGIN_JOB_MANAGE_REPOSITORY,
+ "repository", repository,
+ "flags", flags,
+ NULL);
+}
diff --git a/lib/gs-plugin-job-manage-repository.h b/lib/gs-plugin-job-manage-repository.h
new file mode 100644
index 0000000..86233b7
--- /dev/null
+++ b/lib/gs-plugin-job-manage-repository.h
@@ -0,0 +1,26 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2022 Red Hat <www.redhat.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#include <glib.h>
+#include <glib-object.h>
+#include <gio/gio.h>
+
+#include "gs-plugin-job.h"
+
+G_BEGIN_DECLS
+
+#define GS_TYPE_PLUGIN_JOB_MANAGE_REPOSITORY (gs_plugin_job_manage_repository_get_type ())
+
+G_DECLARE_FINAL_TYPE (GsPluginJobManageRepository, gs_plugin_job_manage_repository, GS, PLUGIN_JOB_MANAGE_REPOSITORY, GsPluginJob)
+
+GsPluginJob *gs_plugin_job_manage_repository_new (GsApp *repository,
+ GsPluginManageRepositoryFlags flags);
+
+G_END_DECLS
diff --git a/lib/gs-plugin-job-private.h b/lib/gs-plugin-job-private.h
new file mode 100644
index 0000000..9cdbbf3
--- /dev/null
+++ b/lib/gs-plugin-job-private.h
@@ -0,0 +1,40 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2017 Richard Hughes <richard@hughsie.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#include <glib-object.h>
+
+#include "gs-plugin-job.h"
+
+G_BEGIN_DECLS
+
+GsPluginAction gs_plugin_job_get_action (GsPluginJob *self);
+GsAppListFilterFlags gs_plugin_job_get_dedupe_flags (GsPluginJob *self);
+GsPluginRefineFlags gs_plugin_job_get_refine_flags (GsPluginJob *self);
+gboolean gs_plugin_job_has_refine_flags (GsPluginJob *self,
+ GsPluginRefineFlags refine_flags);
+void gs_plugin_job_add_refine_flags (GsPluginJob *self,
+ GsPluginRefineFlags refine_flags);
+void gs_plugin_job_remove_refine_flags (GsPluginJob *self,
+ GsPluginRefineFlags refine_flags);
+gboolean gs_plugin_job_get_interactive (GsPluginJob *self);
+gboolean gs_plugin_job_get_propagate_error (GsPluginJob *self);
+guint gs_plugin_job_get_max_results (GsPluginJob *self);
+GsAppListSortFunc gs_plugin_job_get_sort_func (GsPluginJob *self,
+ gpointer *user_data_out);
+const gchar *gs_plugin_job_get_search (GsPluginJob *self);
+GsApp *gs_plugin_job_get_app (GsPluginJob *self);
+GsAppList *gs_plugin_job_get_list (GsPluginJob *self);
+GFile *gs_plugin_job_get_file (GsPluginJob *self);
+GsPlugin *gs_plugin_job_get_plugin (GsPluginJob *self);
+gchar *gs_plugin_job_to_string (GsPluginJob *self);
+void gs_plugin_job_set_action (GsPluginJob *self,
+ GsPluginAction action);
+
+G_END_DECLS
diff --git a/lib/gs-plugin-job-refine.c b/lib/gs-plugin-job-refine.c
new file mode 100644
index 0000000..f9618cd
--- /dev/null
+++ b/lib/gs-plugin-job-refine.c
@@ -0,0 +1,860 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2021 Endless OS Foundation LLC
+ *
+ * Author: Philip Withnall <pwithnall@endlessos.org>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+/**
+ * SECTION:gs-plugin-job-refine
+ * @short_description: A plugin job to refine #GsApps and add more data
+ *
+ * #GsPluginJobRefine is a #GsPluginJob representing a refine operation.
+ *
+ * It’s used to query and add more data to a set of #GsApps. The data to be set
+ * is controlled by the #GsPluginRefineFlags, and is looked up for all the apps
+ * in a #GsAppList by the loaded plugins.
+ *
+ * This class is a wrapper around #GsPluginClass.refine_async, calling it for
+ * all loaded plugins, with some additional refinements done on the results.
+ *
+ * In particular, if an app in the #GsAppList has %GS_APP_QUIRK_IS_WILDCARD,
+ * refining it will replace it with zero or more non-wildcard #GsApps in the
+ * #GsAppList, all of which are candidates for what the wildcard represents.
+ * For example, they may have the same ID as the wildcard, or match its name.
+ * Refining is the canonical process for resolving wildcards.
+ *
+ * This means that the #GsAppList at the end of the refine operation may not
+ * match the #GsAppList passed in as input. Retrieve the final #GsAppList using
+ * gs_plugin_job_refine_get_result_list(). The #GsAppList which was passed
+ * into the job will not be modified.
+ *
+ * Internally, the #GsPluginClass.refine_async() functions are called on all
+ * the plugins in series, and in series with a call to
+ * gs_odrs_provider_refine_async(). Once all of those calls are finished,
+ * zero or more recursive calls to run_refine_internal_async() are made in
+ * parallel to do a similar refine process on the addons, runtime and related
+ * components for all the components in the input #GsAppList. The refine job is
+ * complete once all these recursive calls complete.
+ *
+ * FIXME: Ideally, the #GsPluginClass.refine_async() calls would happen in
+ * parallel, but this cannot be the case until the results of the refine_async()
+ * call in one plugin don’t depend on the results of refine_async() in another.
+ * This still happens with several pairs of plugins.
+ *
+ * ```
+ * run_async()
+ * |
+ * v
+ * /-----------------------+-------------+----------------\
+ * | | | |
+ * plugin->refine_async() | | |
+ * v plugin->refine_async() | |
+ * | v … |
+ * | | v gs_odrs_provider_refine_async()
+ * | | | v
+ * | | | |
+ * \-----------------------+-------------+----------------/
+ * |
+ * finish_refine_internal_op()
+ * |
+ * v
+ * /----------------------------+-----------------\
+ * | | |
+ * run_refine_internal_async() run_refine_internal_async() …
+ * | | |
+ * v v v
+ * \----------------------------+-----------------/
+ * |
+ * finish_refine_internal_recursion()
+ * ```
+ *
+ * See also: #GsPluginClass.refine_async
+ * Since: 42
+ */
+
+#include "config.h"
+
+#include <glib.h>
+#include <glib-object.h>
+#include <glib/gi18n.h>
+
+#include "gs-app.h"
+#include "gs-app-collation.h"
+#include "gs-app-private.h"
+#include "gs-app-list-private.h"
+#include "gs-enums.h"
+#include "gs-plugin-job-private.h"
+#include "gs-plugin-job-refine.h"
+#include "gs-utils.h"
+
+struct _GsPluginJobRefine
+{
+ GsPluginJob parent;
+
+ /* Input data. */
+ GsAppList *app_list; /* (owned) */
+ GsPluginRefineFlags flags;
+
+ /* Output data. */
+ GsAppList *result_list; /* (owned) (nullable) */
+};
+
+G_DEFINE_TYPE (GsPluginJobRefine, gs_plugin_job_refine, GS_TYPE_PLUGIN_JOB)
+
+typedef enum {
+ PROP_APP_LIST = 1,
+ PROP_FLAGS,
+} GsPluginJobRefineProperty;
+
+static GParamSpec *props[PROP_FLAGS + 1] = { NULL, };
+
+static void
+gs_plugin_job_refine_dispose (GObject *object)
+{
+ GsPluginJobRefine *self = GS_PLUGIN_JOB_REFINE (object);
+
+ g_clear_object (&self->app_list);
+ g_clear_object (&self->result_list);
+
+ G_OBJECT_CLASS (gs_plugin_job_refine_parent_class)->dispose (object);
+}
+
+static void
+gs_plugin_job_refine_constructed (GObject *object)
+{
+ GsPluginJobRefine *self = GS_PLUGIN_JOB_REFINE (object);
+
+ G_OBJECT_CLASS (gs_plugin_job_refine_parent_class)->constructed (object);
+
+ /* FIXME: the plugins should specify this, rather than hardcoding */
+ if (self->flags & (GS_PLUGIN_REFINE_FLAGS_REQUIRE_ORIGIN_UI |
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_ORIGIN_HOSTNAME))
+ self->flags |= GS_PLUGIN_REFINE_FLAGS_REQUIRE_ORIGIN;
+ if (self->flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_SIZE)
+ self->flags |= GS_PLUGIN_REFINE_FLAGS_REQUIRE_RUNTIME;
+}
+
+static void
+gs_plugin_job_refine_get_property (GObject *object,
+ guint prop_id,
+ GValue *value,
+ GParamSpec *pspec)
+{
+ GsPluginJobRefine *self = GS_PLUGIN_JOB_REFINE (object);
+
+ switch ((GsPluginJobRefineProperty) prop_id) {
+ case PROP_APP_LIST:
+ g_value_set_object (value, self->app_list);
+ break;
+ case PROP_FLAGS:
+ g_value_set_flags (value, self->flags);
+ break;
+ default:
+ G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+ break;
+ }
+}
+
+static void
+gs_plugin_job_refine_set_property (GObject *object,
+ guint prop_id,
+ const GValue *value,
+ GParamSpec *pspec)
+{
+ GsPluginJobRefine *self = GS_PLUGIN_JOB_REFINE (object);
+
+ switch ((GsPluginJobRefineProperty) prop_id) {
+ case PROP_APP_LIST:
+ /* Construct only. */
+ g_assert (self->app_list == NULL);
+ self->app_list = g_value_dup_object (value);
+ g_object_notify_by_pspec (object, props[PROP_APP_LIST]);
+ break;
+ case PROP_FLAGS:
+ /* Construct only. */
+ g_assert (self->flags == 0);
+ self->flags = g_value_get_flags (value);
+ break;
+ default:
+ G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+ break;
+ }
+}
+
+static gboolean
+app_is_valid_filter (GsApp *app,
+ gpointer user_data)
+{
+ GsPluginJobRefine *self = GS_PLUGIN_JOB_REFINE (user_data);
+
+ return gs_plugin_loader_app_is_valid (app, self->flags);
+}
+
+static gint
+review_score_sort_cb (gconstpointer a, gconstpointer b)
+{
+ AsReview *ra = *((AsReview **) a);
+ AsReview *rb = *((AsReview **) b);
+ if (as_review_get_priority (ra) < as_review_get_priority (rb))
+ return 1;
+ if (as_review_get_priority (ra) > as_review_get_priority (rb))
+ return -1;
+ return 0;
+}
+
+static gboolean
+app_is_non_wildcard (GsApp *app, gpointer user_data)
+{
+ return !gs_app_has_quirk (app, GS_APP_QUIRK_IS_WILDCARD);
+}
+
+static void plugin_refine_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data);
+static void odrs_provider_refine_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data);
+static void finish_refine_internal_op (GTask *task,
+ GError *error);
+static void recursive_internal_refine_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data);
+static void finish_refine_internal_recursion (GTask *task,
+ GError *error);
+static gboolean run_refine_internal_finish (GsPluginJobRefine *self,
+ GAsyncResult *result,
+ GError **error);
+
+typedef struct {
+ /* Input data. */
+ GsPluginLoader *plugin_loader; /* (not nullable) (owned) */
+ GsAppList *list; /* (not nullable) (owned) */
+ GsPluginRefineFlags flags;
+
+ /* In-progress data. */
+ guint n_pending_ops;
+ guint n_pending_recursions;
+ guint next_plugin_index;
+
+ /* Output data. */
+ GError *error; /* (nullable) (owned) */
+} RefineInternalData;
+
+static void
+refine_internal_data_free (RefineInternalData *data)
+{
+ g_clear_object (&data->plugin_loader);
+ g_clear_object (&data->list);
+
+ g_assert (data->n_pending_ops == 0);
+ g_assert (data->n_pending_recursions == 0);
+
+ /* If an error occurred, it should have been stolen to pass to
+ * g_task_return_error() by now. */
+ g_assert (data->error == NULL);
+
+ g_free (data);
+}
+
+G_DEFINE_AUTOPTR_CLEANUP_FUNC (RefineInternalData, refine_internal_data_free)
+
+static void
+run_refine_internal_async (GsPluginJobRefine *self,
+ GsPluginLoader *plugin_loader,
+ GsAppList *list,
+ GsPluginRefineFlags flags,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data)
+{
+ GPtrArray *plugins; /* (element-type GsPlugin) */
+ g_autoptr(GTask) task = NULL;
+ RefineInternalData *data;
+ g_autoptr(RefineInternalData) data_owned = NULL;
+
+ task = g_task_new (self, cancellable, callback, user_data);
+ g_task_set_source_tag (task, run_refine_internal_async);
+
+ data = data_owned = g_new0 (RefineInternalData, 1);
+ data->plugin_loader = g_object_ref (plugin_loader);
+ data->list = g_object_ref (list);
+ data->flags = flags;
+ g_task_set_task_data (task, g_steal_pointer (&data_owned), (GDestroyNotify) refine_internal_data_free);
+
+ /* try to adopt each application with a plugin */
+ gs_plugin_loader_run_adopt (plugin_loader, list);
+
+ data->n_pending_ops = 0;
+
+ /* run each plugin
+ *
+ * FIXME: For now, we have to run these vfuncs sequentially rather than
+ * all in parallel. This is because there are still dependencies between
+ * some of the plugins, where the code to refine an app in one plugin
+ * depends on the results of refining it in another plugin first.
+ *
+ * Eventually, the plugins should all be changed/removed so that they
+ * can operate independently. At that point, this code can be reverted
+ * so that the refine_async() vfuncs are called in parallel. */
+ plugins = gs_plugin_loader_get_plugins (plugin_loader);
+
+ for (guint i = 0; i < plugins->len; i++) {
+ GsPlugin *plugin = g_ptr_array_index (plugins, i);
+ GsPluginClass *plugin_class = GS_PLUGIN_GET_CLASS (plugin);
+
+ if (!gs_plugin_get_enabled (plugin))
+ continue;
+ if (plugin_class->refine_async == NULL)
+ continue;
+
+ /* FIXME: The next refine_async() call is made in
+ * finish_refine_internal_op(). */
+ data->next_plugin_index = i + 1;
+
+ /* run the batched plugin symbol */
+ data->n_pending_ops++;
+ plugin_class->refine_async (plugin, list, flags,
+ cancellable, plugin_refine_cb, g_object_ref (task));
+
+ /* FIXME: The next refine_async() call is made in
+ * finish_refine_internal_op(). */
+ return;
+ }
+
+ data->n_pending_ops++;
+ finish_refine_internal_op (task, NULL);
+}
+
+static void
+plugin_refine_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data)
+{
+ GsPlugin *plugin = GS_PLUGIN (source_object);
+ g_autoptr(GTask) task = g_steal_pointer (&user_data);
+ GsPluginClass *plugin_class = GS_PLUGIN_GET_CLASS (plugin);
+ g_autoptr(GError) local_error = NULL;
+
+ if (!plugin_class->refine_finish (plugin, result, &local_error)) {
+ finish_refine_internal_op (task, g_steal_pointer (&local_error));
+ return;
+ }
+
+ gs_plugin_status_update (plugin, NULL, GS_PLUGIN_STATUS_FINISHED);
+
+ finish_refine_internal_op (task, NULL);
+}
+
+static void
+odrs_provider_refine_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data)
+{
+ GsOdrsProvider *odrs_provider = GS_ODRS_PROVIDER (source_object);
+ g_autoptr(GTask) task = g_steal_pointer (&user_data);
+ g_autoptr(GError) local_error = NULL;
+
+ gs_odrs_provider_refine_finish (odrs_provider, result, &local_error);
+ finish_refine_internal_op (task, g_steal_pointer (&local_error));
+}
+
+/* @error is (transfer full) if non-NULL */
+static void
+finish_refine_internal_op (GTask *task,
+ GError *error)
+{
+ GsPluginJobRefine *self = g_task_get_source_object (task);
+ GCancellable *cancellable = g_task_get_cancellable (task);
+ g_autoptr(GError) error_owned = g_steal_pointer (&error);
+ RefineInternalData *data = g_task_get_task_data (task);
+ GsPluginLoader *plugin_loader = data->plugin_loader;
+ GsAppList *list = data->list;
+ GsPluginRefineFlags flags = data->flags;
+ GsOdrsProvider *odrs_provider;
+ GsOdrsProviderRefineFlags odrs_refine_flags = 0;
+ GPtrArray *plugins; /* (element-type GsPlugin) */
+
+ if (data->error == NULL && error_owned != NULL) {
+ data->error = g_steal_pointer (&error_owned);
+ } else if (error_owned != NULL) {
+ g_debug ("Additional error while refining: %s", error_owned->message);
+ }
+
+ g_assert (data->n_pending_ops > 0);
+ data->n_pending_ops--;
+
+ plugins = gs_plugin_loader_get_plugins (plugin_loader);
+
+ for (guint i = data->next_plugin_index; i < plugins->len; i++) {
+ GsPlugin *plugin = g_ptr_array_index (plugins, i);
+ GsPluginClass *plugin_class = GS_PLUGIN_GET_CLASS (plugin);
+
+ if (!gs_plugin_get_enabled (plugin))
+ continue;
+ if (plugin_class->refine_async == NULL)
+ continue;
+
+ /* FIXME: The next refine_async() call is made in
+ * finish_refine_internal_op(). */
+ data->next_plugin_index = i + 1;
+
+ /* run the batched plugin symbol */
+ data->n_pending_ops++;
+ plugin_class->refine_async (plugin, list, flags,
+ cancellable, plugin_refine_cb, g_object_ref (task));
+
+ /* FIXME: The next refine_async() call is made in
+ * finish_refine_internal_op(). */
+ return;
+ }
+
+ if (data->next_plugin_index == plugins->len) {
+ /* Avoid the ODRS refine being run multiple times. */
+ data->next_plugin_index++;
+
+ /* Add ODRS data if needed */
+ odrs_provider = gs_plugin_loader_get_odrs_provider (plugin_loader);
+
+ if (flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_REVIEWS)
+ odrs_refine_flags |= GS_ODRS_PROVIDER_REFINE_FLAGS_GET_REVIEWS;
+ if (flags & (GS_PLUGIN_REFINE_FLAGS_REQUIRE_REVIEW_RATINGS |
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_RATING))
+ odrs_refine_flags |= GS_ODRS_PROVIDER_REFINE_FLAGS_GET_RATINGS;
+
+ if (odrs_provider != NULL && odrs_refine_flags != 0) {
+ data->n_pending_ops++;
+ gs_odrs_provider_refine_async (odrs_provider, list, odrs_refine_flags,
+ cancellable, odrs_provider_refine_cb, g_object_ref (task));
+ }
+ }
+
+ if (data->n_pending_ops > 0)
+ return;
+
+ /* At this point, all the plugin->refine() calls are complete and the
+ * gs_odrs_provider_refine_async() call is also complete. If an error
+ * occurred during those calls, return with it now rather than
+ * proceeding to the recursive calls below. */
+ if (data->error != NULL) {
+ g_task_return_error (task, g_steal_pointer (&data->error));
+ return;
+ }
+
+ /* filter any wildcard apps left in the list */
+ gs_app_list_filter (list, app_is_non_wildcard, NULL);
+
+ /* ensure these are sorted by score */
+ if (flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_REVIEWS) {
+ GPtrArray *reviews;
+ for (guint i = 0; i < gs_app_list_length (list); i++) {
+ GsApp *app = gs_app_list_index (list, i);
+ reviews = gs_app_get_reviews (app);
+ g_ptr_array_sort (reviews, review_score_sort_cb);
+ }
+ }
+
+ /* Now run several recursive calls to run_refine_internal_async() in
+ * parallel, to refine related components. */
+ data->n_pending_recursions = 1;
+
+ /* refine addons one layer deep */
+ if (flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_ADDONS) {
+ g_autoptr(GsAppList) addons_list = gs_app_list_new ();
+ GsPluginRefineFlags addons_flags = flags;
+
+ addons_flags &= ~(GS_PLUGIN_REFINE_FLAGS_REQUIRE_ADDONS |
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_REVIEWS |
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_REVIEW_RATINGS);
+
+ for (guint i = 0; i < gs_app_list_length (list); i++) {
+ GsApp *app = gs_app_list_index (list, i);
+ g_autoptr(GsAppList) addons = gs_app_dup_addons (app);
+
+ for (guint j = 0; addons != NULL && j < gs_app_list_length (addons); j++) {
+ GsApp *addon = gs_app_list_index (addons, j);
+ g_debug ("refining app %s addon %s",
+ gs_app_get_id (app),
+ gs_app_get_id (addon));
+ gs_app_list_add (addons_list, addon);
+ }
+ }
+
+ if (gs_app_list_length (addons_list) > 0 && addons_flags != 0) {
+ data->n_pending_recursions++;
+ run_refine_internal_async (self, plugin_loader,
+ addons_list, addons_flags,
+ cancellable, recursive_internal_refine_cb,
+ g_object_ref (task));
+ }
+ }
+
+ /* also do runtime */
+ if (flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_RUNTIME) {
+ g_autoptr(GsAppList) runtimes_list = gs_app_list_new ();
+ GsPluginRefineFlags runtimes_flags = flags;
+
+ runtimes_flags &= ~GS_PLUGIN_REFINE_FLAGS_REQUIRE_RUNTIME;
+
+ for (guint i = 0; i < gs_app_list_length (list); i++) {
+ GsApp *app = gs_app_list_index (list, i);
+ GsApp *runtime = gs_app_get_runtime (app);
+
+ if (runtime != NULL)
+ gs_app_list_add (runtimes_list, runtime);
+ }
+
+ if (gs_app_list_length (runtimes_list) > 0 && runtimes_flags != 0) {
+ data->n_pending_recursions++;
+ run_refine_internal_async (self, plugin_loader,
+ runtimes_list, runtimes_flags,
+ cancellable, recursive_internal_refine_cb,
+ g_object_ref (task));
+ }
+ }
+
+ /* also do related packages one layer deep */
+ if (flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_RELATED) {
+ g_autoptr(GsAppList) related_list = gs_app_list_new ();
+ GsPluginRefineFlags related_flags = flags;
+
+ related_flags &= ~GS_PLUGIN_REFINE_FLAGS_REQUIRE_RELATED;
+
+ for (guint i = 0; i < gs_app_list_length (list); i++) {
+ GsApp *app = gs_app_list_index (list, i);
+ GsAppList *related = gs_app_get_related (app);
+ for (guint j = 0; j < gs_app_list_length (related); j++) {
+ GsApp *app2 = gs_app_list_index (related, j);
+ g_debug ("refining related: %s[%s]",
+ gs_app_get_id (app2),
+ gs_app_get_source_default (app2));
+ gs_app_list_add (related_list, app2);
+ }
+ }
+
+ if (gs_app_list_length (related_list) > 0 && related_flags != 0) {
+ data->n_pending_recursions++;
+ run_refine_internal_async (self, plugin_loader,
+ related_list, related_flags,
+ cancellable, recursive_internal_refine_cb,
+ g_object_ref (task));
+ }
+ }
+
+ finish_refine_internal_recursion (task, NULL);
+}
+
+static void
+recursive_internal_refine_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data)
+{
+ GsPluginJobRefine *self = GS_PLUGIN_JOB_REFINE (source_object);
+ g_autoptr(GTask) task = g_steal_pointer (&user_data);
+ g_autoptr(GError) local_error = NULL;
+
+ run_refine_internal_finish (self, result, &local_error);
+ finish_refine_internal_recursion (task, g_steal_pointer (&local_error));
+}
+
+/* @error is (transfer full) if non-NULL */
+static void
+finish_refine_internal_recursion (GTask *task,
+ GError *error)
+{
+ g_autoptr(GError) error_owned = g_steal_pointer (&error);
+ RefineInternalData *data = g_task_get_task_data (task);
+
+ if (data->error == NULL && error_owned != NULL) {
+ data->error = g_steal_pointer (&error_owned);
+ } else if (error_owned != NULL) {
+ g_debug ("Additional error while refining: %s", error_owned->message);
+ }
+
+ g_assert (data->n_pending_recursions > 0);
+ data->n_pending_recursions--;
+
+ if (data->n_pending_recursions > 0)
+ return;
+
+ /* The entire refine operation (and all its sub-operations and
+ * recursions) is complete. */
+ if (data->error != NULL)
+ g_task_return_error (task, g_steal_pointer (&data->error));
+ else
+ g_task_return_boolean (task, TRUE);
+}
+
+static gboolean
+run_refine_internal_finish (GsPluginJobRefine *self,
+ GAsyncResult *result,
+ GError **error)
+{
+ return g_task_propagate_boolean (G_TASK (result), error);
+}
+
+static gboolean
+app_thaw_notify_idle (gpointer data)
+{
+ GsApp *app = GS_APP (data);
+ g_object_thaw_notify (G_OBJECT (app));
+ g_object_unref (app);
+ return G_SOURCE_REMOVE;
+}
+
+static void run_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data);
+static void finish_run (GTask *task,
+ GsAppList *result_list);
+
+static void
+gs_plugin_job_refine_run_async (GsPluginJob *job,
+ GsPluginLoader *plugin_loader,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data)
+{
+ GsPluginJobRefine *self = GS_PLUGIN_JOB_REFINE (job);
+ g_autoptr(GTask) task = NULL;
+ g_autoptr(GsAppList) result_list = NULL;
+
+ /* check required args */
+ task = g_task_new (job, cancellable, callback, user_data);
+ g_task_set_source_tag (task, gs_plugin_job_refine_run_async);
+
+ /* Operate on a copy of the input list so we don’t modify it when
+ * resolving wildcards. */
+ result_list = gs_app_list_copy (self->app_list);
+ g_task_set_task_data (task, g_object_ref (result_list), (GDestroyNotify) g_object_unref);
+
+ /* nothing to do */
+ if (self->flags == 0 ||
+ gs_app_list_length (result_list) == 0) {
+ g_debug ("no refine flags set for transaction or app list is empty");
+ finish_run (task, result_list);
+ return;
+ }
+
+ /* freeze all apps */
+ for (guint i = 0; i < gs_app_list_length (self->app_list); i++) {
+ GsApp *app = gs_app_list_index (self->app_list, i);
+ g_object_freeze_notify (G_OBJECT (app));
+ }
+
+ /* Start refining the apps. */
+ run_refine_internal_async (self, plugin_loader, result_list,
+ self->flags, cancellable,
+ run_cb, g_steal_pointer (&task));
+}
+
+static void
+run_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data)
+{
+ GsPluginJobRefine *self = GS_PLUGIN_JOB_REFINE (source_object);
+ g_autoptr(GTask) task = g_steal_pointer (&user_data);
+ GsAppList *result_list = g_task_get_task_data (task);
+ g_autoptr(GError) local_error = NULL;
+
+ if (run_refine_internal_finish (self, result, &local_error)) {
+ /* remove any addons that have the same source as the parent app */
+ for (guint i = 0; i < gs_app_list_length (result_list); i++) {
+ g_autoptr(GPtrArray) to_remove = g_ptr_array_new ();
+ GsApp *app = gs_app_list_index (result_list, i);
+ g_autoptr(GsAppList) addons = gs_app_dup_addons (app);
+
+ /* find any apps with the same source */
+ const gchar *pkgname_parent = gs_app_get_source_default (app);
+ if (pkgname_parent == NULL)
+ continue;
+ for (guint j = 0; addons != NULL && j < gs_app_list_length (addons); j++) {
+ GsApp *addon = gs_app_list_index (addons, j);
+ if (g_strcmp0 (gs_app_get_source_default (addon),
+ pkgname_parent) == 0) {
+ g_debug ("%s has the same pkgname of %s as %s",
+ gs_app_get_unique_id (app),
+ pkgname_parent,
+ gs_app_get_unique_id (addon));
+ g_ptr_array_add (to_remove, addon);
+ }
+ }
+
+ /* remove any addons with the same source */
+ for (guint j = 0; j < to_remove->len; j++) {
+ GsApp *addon = g_ptr_array_index (to_remove, j);
+ gs_app_remove_addon (app, addon);
+ }
+ }
+ }
+
+ /* now emit all the changed signals */
+ for (guint i = 0; i < gs_app_list_length (self->app_list); i++) {
+ GsApp *app = gs_app_list_index (self->app_list, i);
+ g_idle_add (app_thaw_notify_idle, g_object_ref (app));
+ }
+
+ /* Delayed error handling. */
+ if (local_error != NULL) {
+ gs_utils_error_convert_gio (&local_error);
+ g_task_return_error (task, g_steal_pointer (&local_error));
+ return;
+ }
+
+ finish_run (task, result_list);
+}
+
+static void
+finish_run (GTask *task,
+ GsAppList *result_list)
+{
+ GsPluginJobRefine *self = g_task_get_source_object (task);
+ g_autofree gchar *job_debug = NULL;
+
+ /* Internal calls to #GsPluginJobRefine may want to do their own
+ * filtering, typically if the refine is being done as part of another
+ * plugin job. If so, only filter to remove wildcards. Wildcards should
+ * always be removed, as they should have been resolved as part of the
+ * refine; any remaining wildcards will never be resolved.
+ *
+ * If the flag is not specified, filter by a variety of indicators of
+ * what a ‘valid’ app is. */
+ if (self->flags & GS_PLUGIN_REFINE_FLAGS_DISABLE_FILTERING)
+ gs_app_list_filter (result_list, app_is_non_wildcard, NULL);
+ else
+ gs_app_list_filter (result_list, app_is_valid_filter, self);
+
+ /* show elapsed time */
+ job_debug = gs_plugin_job_to_string (GS_PLUGIN_JOB (self));
+ g_debug ("%s", job_debug);
+
+ /* success */
+ g_set_object (&self->result_list, result_list);
+ g_task_return_boolean (task, TRUE);
+}
+
+static gboolean
+gs_plugin_job_refine_run_finish (GsPluginJob *self,
+ GAsyncResult *result,
+ GError **error)
+{
+ return g_task_propagate_boolean (G_TASK (result), error);
+}
+
+static void
+gs_plugin_job_refine_class_init (GsPluginJobRefineClass *klass)
+{
+ GObjectClass *object_class = G_OBJECT_CLASS (klass);
+ GsPluginJobClass *job_class = GS_PLUGIN_JOB_CLASS (klass);
+
+ object_class->dispose = gs_plugin_job_refine_dispose;
+ object_class->constructed = gs_plugin_job_refine_constructed;
+ object_class->get_property = gs_plugin_job_refine_get_property;
+ object_class->set_property = gs_plugin_job_refine_set_property;
+
+ job_class->run_async = gs_plugin_job_refine_run_async;
+ job_class->run_finish = gs_plugin_job_refine_run_finish;
+
+ /**
+ * GsPluginJobRefine:app-list:
+ *
+ * List of #GsApps to refine.
+ *
+ * This will not change during the course of the operation.
+ *
+ * Since: 42
+ */
+ props[PROP_APP_LIST] =
+ g_param_spec_object ("app-list", "App List",
+ "List of GsApps to refine.",
+ GS_TYPE_APP_LIST,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY |
+ G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ /**
+ * GsPluginJobRefine:flags:
+ *
+ * Flags to control what to refine.
+ *
+ * Since: 42
+ */
+ props[PROP_FLAGS] =
+ g_param_spec_flags ("flags", "Flags",
+ "Flags to control what to refine.",
+ GS_TYPE_PLUGIN_REFINE_FLAGS, GS_PLUGIN_REFINE_FLAGS_NONE,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY |
+ G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ g_object_class_install_properties (object_class, G_N_ELEMENTS (props), props);
+}
+
+static void
+gs_plugin_job_refine_init (GsPluginJobRefine *self)
+{
+}
+
+/**
+ * gs_plugin_job_refine_new:
+ * @app_list: the list of #GsApps to refine
+ * @flags: flags to affect what is refined
+ *
+ * Create a new #GsPluginJobRefine for refining the given @app_list.
+ *
+ * Returns: (transfer full): a new #GsPluginJobRefine
+ * Since: 42
+ */
+GsPluginJob *
+gs_plugin_job_refine_new (GsAppList *app_list,
+ GsPluginRefineFlags flags)
+{
+ return g_object_new (GS_TYPE_PLUGIN_JOB_REFINE,
+ "app-list", app_list,
+ "flags", flags,
+ NULL);
+}
+
+/**
+ * gs_plugin_job_refine_new_for_app:
+ * @app: the #GsApp to refine
+ * @flags: flags to affect what is refined
+ *
+ * Create a new #GsPluginJobRefine for refining the given @app.
+ *
+ * Returns: (transfer full): a new #GsPluginJobRefine
+ * Since: 42
+ */
+GsPluginJob *
+gs_plugin_job_refine_new_for_app (GsApp *app,
+ GsPluginRefineFlags flags)
+{
+ g_autoptr(GsAppList) list = gs_app_list_new ();
+ gs_app_list_add (list, app);
+
+ return gs_plugin_job_refine_new (list, flags);
+}
+
+/**
+ * gs_plugin_job_refine_get_result_list:
+ * @self: a #GsPluginJobRefine
+ *
+ * Get the full list of refined #GsApps. This includes apps created in place of
+ * wildcards, if wildcards were provided in the #GsAppList passed to
+ * gs_plugin_job_refine_new().
+ *
+ * If this is called before the job is complete, %NULL will be returned.
+ *
+ * Returns: (transfer none) (nullable): the job results, or %NULL on error
+ * or if called before the job has completed
+ * Since: 42
+ */
+GsAppList *
+gs_plugin_job_refine_get_result_list (GsPluginJobRefine *self)
+{
+ g_return_val_if_fail (GS_IS_PLUGIN_JOB_REFINE (self), NULL);
+
+ return self->result_list;
+}
diff --git a/lib/gs-plugin-job-refine.h b/lib/gs-plugin-job-refine.h
new file mode 100644
index 0000000..ed207bc
--- /dev/null
+++ b/lib/gs-plugin-job-refine.h
@@ -0,0 +1,31 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2021 Endless OS Foundation LLC
+ *
+ * Author: Philip Withnall <pwithnall@endlessos.org>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#include <glib.h>
+#include <glib-object.h>
+
+#include "gs-plugin-job.h"
+
+G_BEGIN_DECLS
+
+#define GS_TYPE_PLUGIN_JOB_REFINE (gs_plugin_job_refine_get_type ())
+
+G_DECLARE_FINAL_TYPE (GsPluginJobRefine, gs_plugin_job_refine, GS, PLUGIN_JOB_REFINE, GsPluginJob)
+
+GsPluginJob *gs_plugin_job_refine_new_for_app (GsApp *app,
+ GsPluginRefineFlags flags);
+GsPluginJob *gs_plugin_job_refine_new (GsAppList *app_list,
+ GsPluginRefineFlags flags);
+
+GsAppList *gs_plugin_job_refine_get_result_list (GsPluginJobRefine *self);
+
+G_END_DECLS
diff --git a/lib/gs-plugin-job-refresh-metadata.c b/lib/gs-plugin-job-refresh-metadata.c
new file mode 100644
index 0000000..01c65d1
--- /dev/null
+++ b/lib/gs-plugin-job-refresh-metadata.c
@@ -0,0 +1,531 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2022 Endless OS Foundation LLC
+ *
+ * Author: Philip Withnall <pwithnall@endlessos.org>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+/**
+ * SECTION:gs-plugin-job-refresh-metadata
+ * @short_description: A plugin job to refresh metadata
+ *
+ * #GsPluginJobRefreshMetadata is a #GsPluginJob representing an operation to
+ * refresh metadata inside plugins and about applications.
+ *
+ * For example, the metadata could be the list of applications available, or
+ * the list of updates, or a new set of popular applications to highlight.
+ *
+ * The maximum cache age should be set using
+ * #GsPluginJobRefreshMetadata:cache-age-secs. If this is not a low value, this
+ * job is not expected to do much work. Set it to zero to force all caches to be
+ * refreshed.
+ *
+ * This class is a wrapper around #GsPluginClass.refresh_metadata_async(),
+ * calling it for all loaded plugins. In addition it will refresh ODRS data on
+ * the #GsOdrsProvider set on the #GsPluginLoader.
+ *
+ * Once the refresh is complete, signals may be asynchronously emitted on
+ * plugins, apps and the #GsPluginLoader to indicate what metadata or sets of
+ * apps have changed.
+ *
+ * See also: #GsPluginClass.refresh_metadata_async
+ * Since: 42
+ */
+
+#include "config.h"
+
+#include <glib.h>
+#include <glib-object.h>
+#include <glib/gi18n.h>
+
+#include "gs-enums.h"
+#include "gs-external-appstream-utils.h"
+#include "gs-plugin-job-private.h"
+#include "gs-plugin-job-refresh-metadata.h"
+#include "gs-plugin-types.h"
+#include "gs-odrs-provider.h"
+#include "gs-utils.h"
+
+/* A tuple to store the last-received progress data for a single download.
+ * See progress_cb() for more details. */
+typedef struct {
+ gsize bytes_downloaded;
+ gsize total_download_size;
+} ProgressTuple;
+
+struct _GsPluginJobRefreshMetadata
+{
+ GsPluginJob parent;
+
+ /* Input arguments. */
+ guint64 cache_age_secs;
+ GsPluginRefreshMetadataFlags flags;
+
+ /* In-progress data. */
+ GError *saved_error; /* (owned) (nullable) */
+ guint n_pending_ops;
+#ifdef ENABLE_EXTERNAL_APPSTREAM
+ ProgressTuple external_appstream_progress;
+#endif
+ ProgressTuple odrs_progress;
+ struct {
+ guint n_plugins;
+ guint n_plugins_complete;
+ } plugins_progress;
+ GSource *progress_source; /* (owned) (nullable) */
+};
+
+G_DEFINE_TYPE (GsPluginJobRefreshMetadata, gs_plugin_job_refresh_metadata, GS_TYPE_PLUGIN_JOB)
+
+typedef enum {
+ PROP_CACHE_AGE_SECS = 1,
+ PROP_FLAGS,
+} GsPluginJobRefreshMetadataProperty;
+
+static GParamSpec *props[PROP_FLAGS + 1] = { NULL, };
+
+typedef enum {
+ SIGNAL_PROGRESS,
+} GsPluginJobRefreshMetadataSignal;
+
+static guint signals[SIGNAL_PROGRESS + 1] = { 0, };
+
+static void
+gs_plugin_job_refresh_metadata_dispose (GObject *object)
+{
+ GsPluginJobRefreshMetadata *self = GS_PLUGIN_JOB_REFRESH_METADATA (object);
+
+ g_assert (self->saved_error == NULL);
+ g_assert (self->n_pending_ops == 0);
+
+ /* Progress reporting should have been stopped by now. */
+ if (self->progress_source != NULL) {
+ g_assert (g_source_is_destroyed (self->progress_source));
+ g_clear_pointer (&self->progress_source, g_source_unref);
+ }
+
+ G_OBJECT_CLASS (gs_plugin_job_refresh_metadata_parent_class)->dispose (object);
+}
+
+static void
+gs_plugin_job_refresh_metadata_get_property (GObject *object,
+ guint prop_id,
+ GValue *value,
+ GParamSpec *pspec)
+{
+ GsPluginJobRefreshMetadata *self = GS_PLUGIN_JOB_REFRESH_METADATA (object);
+
+ switch ((GsPluginJobRefreshMetadataProperty) prop_id) {
+ case PROP_CACHE_AGE_SECS:
+ g_value_set_uint64 (value, self->cache_age_secs);
+ break;
+ case PROP_FLAGS:
+ g_value_set_flags (value, self->flags);
+ break;
+ default:
+ G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+ break;
+ }
+}
+
+static void
+gs_plugin_job_refresh_metadata_set_property (GObject *object,
+ guint prop_id,
+ const GValue *value,
+ GParamSpec *pspec)
+{
+ GsPluginJobRefreshMetadata *self = GS_PLUGIN_JOB_REFRESH_METADATA (object);
+
+ switch ((GsPluginJobRefreshMetadataProperty) prop_id) {
+ case PROP_CACHE_AGE_SECS:
+ /* Construct only. */
+ g_assert (self->cache_age_secs == 0);
+ self->cache_age_secs = g_value_get_uint64 (value);
+ g_object_notify_by_pspec (object, props[prop_id]);
+ break;
+ case PROP_FLAGS:
+ /* Construct only. */
+ g_assert (self->flags == 0);
+ self->flags = g_value_get_flags (value);
+ g_object_notify_by_pspec (object, props[prop_id]);
+ break;
+ default:
+ G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+ break;
+ }
+}
+
+static void refresh_progress_tuple_cb (gsize bytes_downloaded,
+ gsize total_download_size,
+ gpointer user_data);
+static gboolean progress_cb (gpointer user_data);
+#ifdef ENABLE_EXTERNAL_APPSTREAM
+static void external_appstream_refresh_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data);
+#endif
+static void odrs_provider_refresh_ratings_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data);
+static void plugin_refresh_metadata_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data);
+static void finish_op (GTask *task,
+ GError *error);
+
+static void
+gs_plugin_job_refresh_metadata_run_async (GsPluginJob *job,
+ GsPluginLoader *plugin_loader,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data)
+{
+ GsPluginJobRefreshMetadata *self = GS_PLUGIN_JOB_REFRESH_METADATA (job);
+ g_autoptr(GTask) task = NULL;
+ GPtrArray *plugins; /* (element-type GsPlugin) */
+ gboolean any_plugins_ran = FALSE;
+ GsOdrsProvider *odrs_provider;
+
+ /* Chosen to allow a few UI updates per second without updating the
+ * progress label so often it’s unreadable. */
+ const guint progress_update_period_ms = 300;
+
+ /* check required args */
+ task = g_task_new (job, cancellable, callback, user_data);
+ g_task_set_source_tag (task, gs_plugin_job_refresh_metadata_run_async);
+ g_task_set_task_data (task, g_object_ref (plugin_loader), (GDestroyNotify) g_object_unref);
+
+ /* Set up the progress timeout. This periodically sums up the progress
+ * tuples in `self->*_progress` and reports them to the calling
+ * function via the #GsPluginJobRefreshMetadata::progress signal, giving
+ * an overall progress for all the parallel operations. */
+ self->progress_source = g_timeout_source_new (progress_update_period_ms);
+ g_source_set_callback (self->progress_source, progress_cb, g_object_ref (self), g_object_unref);
+ g_source_attach (self->progress_source, g_main_context_get_thread_default ());
+
+ /* run each plugin, keeping a counter of pending operations which is
+ * initialised to 1 until all the operations are started */
+ self->n_pending_ops = 1;
+ plugins = gs_plugin_loader_get_plugins (plugin_loader);
+ odrs_provider = gs_plugin_loader_get_odrs_provider (plugin_loader);
+
+ /* Start downloading updated external appstream before anything else */
+#ifdef ENABLE_EXTERNAL_APPSTREAM
+ self->n_pending_ops++;
+ gs_external_appstream_refresh_async (self->cache_age_secs,
+ refresh_progress_tuple_cb,
+ &self->external_appstream_progress,
+ cancellable,
+ external_appstream_refresh_cb,
+ g_object_ref (task));
+#endif
+
+ for (guint i = 0; i < plugins->len; i++) {
+ GsPlugin *plugin = g_ptr_array_index (plugins, i);
+ GsPluginClass *plugin_class = GS_PLUGIN_GET_CLASS (plugin);
+
+ if (!gs_plugin_get_enabled (plugin))
+ continue;
+ if (plugin_class->refresh_metadata_async == NULL)
+ continue;
+
+ /* at least one plugin supports this vfunc */
+ any_plugins_ran = TRUE;
+
+ /* Set up progress reporting for this plugin. */
+ self->plugins_progress.n_plugins++;
+
+ /* run the plugin */
+ self->n_pending_ops++;
+ plugin_class->refresh_metadata_async (plugin,
+ self->cache_age_secs,
+ self->flags,
+ cancellable,
+ plugin_refresh_metadata_cb,
+ g_object_ref (task));
+ }
+
+ if (odrs_provider != NULL) {
+ self->n_pending_ops++;
+ gs_odrs_provider_refresh_ratings_async (odrs_provider,
+ self->cache_age_secs,
+ refresh_progress_tuple_cb,
+ &self->odrs_progress,
+ cancellable,
+ odrs_provider_refresh_ratings_cb,
+ g_object_ref (task));
+ }
+
+ /* some functions are really required for proper operation */
+ if (!any_plugins_ran) {
+ g_autoptr(GError) local_error = NULL;
+ g_set_error_literal (&local_error,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_NOT_SUPPORTED,
+ "no plugin could handle refreshing");
+ finish_op (task, g_steal_pointer (&local_error));
+ } else {
+ finish_op (task, NULL);
+ }
+}
+
+static void
+refresh_progress_tuple_cb (gsize bytes_downloaded,
+ gsize total_download_size,
+ gpointer user_data)
+{
+ ProgressTuple *tuple = user_data;
+
+ tuple->bytes_downloaded = bytes_downloaded;
+ tuple->total_download_size = total_download_size;
+
+ /* The timeout callback in progress_cb() periodically sums these. No
+ * need to notify of progress from here. */
+}
+
+static gboolean
+progress_cb (gpointer user_data)
+{
+ GsPluginJobRefreshMetadata *self = GS_PLUGIN_JOB_REFRESH_METADATA (user_data);
+#ifdef ENABLE_EXTERNAL_APPSTREAM
+ gdouble external_appstream_completion = 0.0;
+#endif
+ gdouble odrs_completion = 0.0;
+ gdouble progress;
+ guint n_portions;
+
+ /* Sum up the progress for all parallel operations. This is complicated
+ * by the fact that external-appstream and ODRS operations report their
+ * progress in terms of bytes downloaded, but the other operations are
+ * just a counter.
+ *
+ * There is further complication from the fact that external-appstream
+ * support can be compiled out.
+ *
+ * Allocate each operation an equal portion of 100 percentage points. In
+ * this context, an operation is either a call to a plugin’s
+ * refresh_metadata_async() vfunc, or an external-appstream or ODRS
+ * refresh. */
+ n_portions = self->plugins_progress.n_plugins;
+
+#ifdef ENABLE_EXTERNAL_APPSTREAM
+ if (self->external_appstream_progress.total_download_size > 0)
+ external_appstream_completion = (self->external_appstream_progress.bytes_downloaded /
+ self->external_appstream_progress.total_download_size);
+ n_portions++;
+#endif
+
+ if (self->odrs_progress.total_download_size > 0)
+ odrs_completion = (self->odrs_progress.bytes_downloaded /
+ self->odrs_progress.total_download_size);
+ n_portions++;
+
+ /* Report progress via signal emission. */
+ progress = (100.0 / n_portions) * (self->plugins_progress.n_plugins_complete + odrs_completion);
+#ifdef ENABLE_EXTERNAL_APPSTREAM
+ progress += (100.0 / n_portions) * external_appstream_completion;
+#endif
+
+ g_signal_emit (self, signals[SIGNAL_PROGRESS], 0, (guint) progress);
+
+ return G_SOURCE_CONTINUE;
+}
+
+#ifdef ENABLE_EXTERNAL_APPSTREAM
+static void
+external_appstream_refresh_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data)
+{
+ g_autoptr(GTask) task = G_TASK (user_data);
+ g_autoptr(GError) local_error = NULL;
+
+ if (!gs_external_appstream_refresh_finish (result, &local_error))
+ g_debug ("Failed to refresh external appstream: %s", local_error->message);
+ /* Intentionally ignore errors, to not block other plugins */
+ finish_op (task, NULL);
+}
+#endif /* ENABLE_EXTERNAL_APPSTREAM */
+
+static void
+odrs_provider_refresh_ratings_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data)
+{
+ GsOdrsProvider *odrs_provider = GS_ODRS_PROVIDER (source_object);
+ g_autoptr(GTask) task = G_TASK (user_data);
+ g_autoptr(GError) local_error = NULL;
+
+ if (!gs_odrs_provider_refresh_ratings_finish (odrs_provider, result, &local_error))
+ g_debug ("Failed to refresh ratings: %s", local_error->message);
+ /* Intentionally ignore errors, to not block other plugins */
+ finish_op (task, NULL);
+}
+
+static void
+plugin_refresh_metadata_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data)
+{
+ GsPlugin *plugin = GS_PLUGIN (source_object);
+ GsPluginClass *plugin_class = GS_PLUGIN_GET_CLASS (plugin);
+ g_autoptr(GTask) task = G_TASK (user_data);
+ GsPluginJobRefreshMetadata *self = g_task_get_source_object (task);
+ g_autoptr(GError) local_error = NULL;
+
+ if (!plugin_class->refresh_metadata_finish (plugin, result, &local_error))
+ g_debug ("Failed to refresh plugin '%s': %s", gs_plugin_get_name (plugin), local_error->message);
+ gs_plugin_status_update (plugin, NULL, GS_PLUGIN_STATUS_FINISHED);
+
+ /* Update progress reporting. */
+ self->plugins_progress.n_plugins_complete++;
+
+ /* Intentionally ignore errors, to not block other plugins */
+ finish_op (task, NULL);
+}
+
+/* @error is (transfer full) if non-%NULL */
+static void
+finish_op (GTask *task,
+ GError *error)
+{
+ GsPluginJobRefreshMetadata *self = g_task_get_source_object (task);
+ g_autoptr(GError) error_owned = g_steal_pointer (&error);
+ g_autofree gchar *job_debug = NULL;
+
+ if (error_owned != NULL && self->saved_error == NULL)
+ self->saved_error = g_steal_pointer (&error_owned);
+ else if (error_owned != NULL)
+ g_debug ("Additional error while refreshing metadata: %s", error_owned->message);
+
+ g_assert (self->n_pending_ops > 0);
+ self->n_pending_ops--;
+
+ if (self->n_pending_ops > 0)
+ return;
+
+ /* Emit one final progress update, then stop any further ones.
+ * Ensure the emission is in the right #GMainContext. */
+ g_assert (g_main_context_is_owner (g_task_get_context (task)));
+ progress_cb (self);
+ g_source_destroy (self->progress_source);
+
+ /* Get the results of the parallel ops. */
+ if (self->saved_error != NULL) {
+ g_task_return_error (task, g_steal_pointer (&self->saved_error));
+ return;
+ }
+
+ /* show elapsed time */
+ job_debug = gs_plugin_job_to_string (GS_PLUGIN_JOB (self));
+ g_debug ("%s", job_debug);
+
+ /* Check the intermediate working values are all cleared. */
+ g_assert (self->saved_error == NULL);
+ g_assert (self->n_pending_ops == 0);
+
+ /* success */
+ g_task_return_boolean (task, TRUE);
+}
+
+static gboolean
+gs_plugin_job_refresh_metadata_run_finish (GsPluginJob *self,
+ GAsyncResult *result,
+ GError **error)
+{
+ return g_task_propagate_boolean (G_TASK (result), error);
+}
+
+static void
+gs_plugin_job_refresh_metadata_class_init (GsPluginJobRefreshMetadataClass *klass)
+{
+ GObjectClass *object_class = G_OBJECT_CLASS (klass);
+ GsPluginJobClass *job_class = GS_PLUGIN_JOB_CLASS (klass);
+
+ object_class->dispose = gs_plugin_job_refresh_metadata_dispose;
+ object_class->get_property = gs_plugin_job_refresh_metadata_get_property;
+ object_class->set_property = gs_plugin_job_refresh_metadata_set_property;
+
+ job_class->run_async = gs_plugin_job_refresh_metadata_run_async;
+ job_class->run_finish = gs_plugin_job_refresh_metadata_run_finish;
+
+ /**
+ * GsPluginJobRefreshMetadata:cache-age-secs:
+ *
+ * Maximum age of caches before they are refreshed.
+ *
+ * Since: 42
+ */
+ props[PROP_CACHE_AGE_SECS] =
+ g_param_spec_uint64 ("cache-age-secs", "Cache Age",
+ "Maximum age of caches before they are refreshed.",
+ 0, G_MAXUINT64, 0,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY |
+ G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ /**
+ * GsPluginJobRefreshMetadata:flags:
+ *
+ * Flags to specify how the refresh job should behave.
+ *
+ * Since: 42
+ */
+ props[PROP_FLAGS] =
+ g_param_spec_flags ("flags", "Flags",
+ "Flags to specify how the refresh job should behave.",
+ GS_TYPE_PLUGIN_REFRESH_METADATA_FLAGS, GS_PLUGIN_REFRESH_METADATA_FLAGS_NONE,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY |
+ G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ g_object_class_install_properties (object_class, G_N_ELEMENTS (props), props);
+
+ /**
+ * GsPluginJobRefreshMetadata::progress:
+ * @progress_percent: percentage completion of the job, [0, 100], or
+ * %G_MAXUINT to indicate that progress is unknown
+ *
+ * Emitted during #GsPluginJob.run_async() when progress is made.
+ *
+ * It’s emitted in the thread which is running the #GMainContext which
+ * was the thread-default context when #GsPluginJob.run_async() was
+ * called.
+ *
+ * Since: 42
+ */
+ signals[SIGNAL_PROGRESS] =
+ g_signal_new ("progress",
+ G_TYPE_FROM_CLASS (object_class), G_SIGNAL_RUN_LAST,
+ 0, NULL, NULL, g_cclosure_marshal_VOID__UINT,
+ G_TYPE_NONE, 1, G_TYPE_UINT);
+}
+
+static void
+gs_plugin_job_refresh_metadata_init (GsPluginJobRefreshMetadata *self)
+{
+}
+
+/**
+ * gs_plugin_job_refresh_metadata_new:
+ * @cache_age_secs: maximum allowed cache age, in seconds
+ * @flags: flags to affect the refresh
+ *
+ * Create a new #GsPluginJobRefreshMetadata for refreshing metadata about
+ * available applications.
+ *
+ * Caches will be refreshed if they are older than @cache_age_secs.
+ *
+ * Returns: (transfer full): a new #GsPluginJobRefreshMetadata
+ * Since: 42
+ */
+GsPluginJob *
+gs_plugin_job_refresh_metadata_new (guint64 cache_age_secs,
+ GsPluginRefreshMetadataFlags flags)
+{
+ return g_object_new (GS_TYPE_PLUGIN_JOB_REFRESH_METADATA,
+ "cache-age-secs", cache_age_secs,
+ "flags", flags,
+ NULL);
+}
diff --git a/lib/gs-plugin-job-refresh-metadata.h b/lib/gs-plugin-job-refresh-metadata.h
new file mode 100644
index 0000000..94b69c7
--- /dev/null
+++ b/lib/gs-plugin-job-refresh-metadata.h
@@ -0,0 +1,28 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2022 Endless OS Foundation LLC
+ *
+ * Author: Philip Withnall <pwithnall@endlessos.org>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#include <glib.h>
+#include <glib-object.h>
+#include <gio/gio.h>
+
+#include "gs-plugin-job.h"
+
+G_BEGIN_DECLS
+
+#define GS_TYPE_PLUGIN_JOB_REFRESH_METADATA (gs_plugin_job_refresh_metadata_get_type ())
+
+G_DECLARE_FINAL_TYPE (GsPluginJobRefreshMetadata, gs_plugin_job_refresh_metadata, GS, PLUGIN_JOB_REFRESH_METADATA, GsPluginJob)
+
+GsPluginJob *gs_plugin_job_refresh_metadata_new (guint64 cache_age_secs,
+ GsPluginRefreshMetadataFlags flags);
+
+G_END_DECLS
diff --git a/lib/gs-plugin-job.c b/lib/gs-plugin-job.c
new file mode 100644
index 0000000..a40980f
--- /dev/null
+++ b/lib/gs-plugin-job.c
@@ -0,0 +1,502 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2017-2018 Richard Hughes <richard@hughsie.com>
+ * Copyright (C) 2018 Kalev Lember <klember@redhat.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#include "config.h"
+
+#include <glib.h>
+
+#include "gs-enums.h"
+#include "gs-plugin-private.h"
+#include "gs-plugin-job-private.h"
+
+typedef struct
+{
+ GsPluginRefineFlags refine_flags;
+ GsAppListFilterFlags dedupe_flags;
+ gboolean interactive;
+ gboolean propagate_error;
+ guint max_results;
+ GsPlugin *plugin;
+ GsPluginAction action;
+ GsAppListSortFunc sort_func;
+ gpointer sort_func_data;
+ gchar *search;
+ GsApp *app;
+ GsAppList *list;
+ GFile *file;
+ gint64 time_created;
+} GsPluginJobPrivate;
+
+enum {
+ PROP_0,
+ PROP_ACTION,
+ PROP_SEARCH,
+ PROP_REFINE_FLAGS,
+ PROP_DEDUPE_FLAGS,
+ PROP_INTERACTIVE,
+ PROP_APP,
+ PROP_LIST,
+ PROP_FILE,
+ PROP_MAX_RESULTS,
+ PROP_PROPAGATE_ERROR,
+ PROP_LAST
+};
+
+G_DEFINE_TYPE_WITH_PRIVATE (GsPluginJob, gs_plugin_job, G_TYPE_OBJECT)
+
+gchar *
+gs_plugin_job_to_string (GsPluginJob *self)
+{
+ GsPluginJobPrivate *priv = gs_plugin_job_get_instance_private (self);
+ GString *str = g_string_new (NULL);
+ gint64 time_now = g_get_monotonic_time ();
+ g_string_append_printf (str, "running %s",
+ gs_plugin_action_to_string (priv->action));
+ if (priv->plugin != NULL) {
+ g_string_append_printf (str, " on plugin=%s",
+ gs_plugin_get_name (priv->plugin));
+ }
+ if (priv->dedupe_flags > 0)
+ g_string_append_printf (str, " with dedupe-flags=%" G_GUINT64_FORMAT, priv->dedupe_flags);
+ if (priv->refine_flags > 0) {
+ g_autofree gchar *tmp = gs_plugin_refine_flags_to_string (priv->refine_flags);
+ g_string_append_printf (str, " with refine-flags=%s", tmp);
+ }
+ if (priv->interactive)
+ g_string_append_printf (str, " with interactive=True");
+ if (priv->propagate_error)
+ g_string_append_printf (str, " with propagate-error=True");
+
+ if (priv->max_results > 0)
+ g_string_append_printf (str, " with max-results=%u", priv->max_results);
+ if (priv->search != NULL) {
+ g_string_append_printf (str, " with search=%s",
+ priv->search);
+ }
+ if (priv->file != NULL) {
+ g_autofree gchar *path = g_file_get_path (priv->file);
+ g_string_append_printf (str, " with file=%s", path);
+ }
+ if (priv->list != NULL && gs_app_list_length (priv->list) > 0) {
+ g_autofree const gchar **unique_ids = NULL;
+ g_autofree gchar *unique_ids_str = NULL;
+ unique_ids = g_new0 (const gchar *, gs_app_list_length (priv->list) + 1);
+ for (guint i = 0; i < gs_app_list_length (priv->list); i++) {
+ GsApp *app = gs_app_list_index (priv->list, i);
+ unique_ids[i] = gs_app_get_unique_id (app);
+ }
+ unique_ids_str = g_strjoinv (",", (gchar**) unique_ids);
+ g_string_append_printf (str, " on apps %s", unique_ids_str);
+ }
+ if (time_now - priv->time_created > 1000) {
+ g_string_append_printf (str, ", elapsed time since creation %" G_GINT64_FORMAT "ms",
+ (time_now - priv->time_created) / 1000);
+ }
+ return g_string_free (str, FALSE);
+}
+
+void
+gs_plugin_job_set_refine_flags (GsPluginJob *self, GsPluginRefineFlags refine_flags)
+{
+ GsPluginJobPrivate *priv = gs_plugin_job_get_instance_private (self);
+ g_return_if_fail (GS_IS_PLUGIN_JOB (self));
+ priv->refine_flags = refine_flags;
+}
+
+void
+gs_plugin_job_set_dedupe_flags (GsPluginJob *self, GsAppListFilterFlags dedupe_flags)
+{
+ GsPluginJobPrivate *priv = gs_plugin_job_get_instance_private (self);
+ g_return_if_fail (GS_IS_PLUGIN_JOB (self));
+ priv->dedupe_flags = dedupe_flags;
+}
+
+GsPluginRefineFlags
+gs_plugin_job_get_refine_flags (GsPluginJob *self)
+{
+ GsPluginJobPrivate *priv = gs_plugin_job_get_instance_private (self);
+ g_return_val_if_fail (GS_IS_PLUGIN_JOB (self), GS_PLUGIN_REFINE_FLAGS_NONE);
+ return priv->refine_flags;
+}
+
+GsAppListFilterFlags
+gs_plugin_job_get_dedupe_flags (GsPluginJob *self)
+{
+ GsPluginJobPrivate *priv = gs_plugin_job_get_instance_private (self);
+ g_return_val_if_fail (GS_IS_PLUGIN_JOB (self), GS_APP_LIST_FILTER_FLAG_NONE);
+ return priv->dedupe_flags;
+}
+
+gboolean
+gs_plugin_job_has_refine_flags (GsPluginJob *self, GsPluginRefineFlags refine_flags)
+{
+ GsPluginJobPrivate *priv = gs_plugin_job_get_instance_private (self);
+ g_return_val_if_fail (GS_IS_PLUGIN_JOB (self), FALSE);
+ return (priv->refine_flags & refine_flags) > 0;
+}
+
+void
+gs_plugin_job_add_refine_flags (GsPluginJob *self, GsPluginRefineFlags refine_flags)
+{
+ GsPluginJobPrivate *priv = gs_plugin_job_get_instance_private (self);
+ g_return_if_fail (GS_IS_PLUGIN_JOB (self));
+ priv->refine_flags |= refine_flags;
+}
+
+void
+gs_plugin_job_remove_refine_flags (GsPluginJob *self, GsPluginRefineFlags refine_flags)
+{
+ GsPluginJobPrivate *priv = gs_plugin_job_get_instance_private (self);
+ g_return_if_fail (GS_IS_PLUGIN_JOB (self));
+ priv->refine_flags &= ~refine_flags;
+}
+
+void
+gs_plugin_job_set_interactive (GsPluginJob *self, gboolean interactive)
+{
+ GsPluginJobPrivate *priv = gs_plugin_job_get_instance_private (self);
+ g_return_if_fail (GS_IS_PLUGIN_JOB (self));
+ priv->interactive = interactive;
+}
+
+gboolean
+gs_plugin_job_get_interactive (GsPluginJob *self)
+{
+ GsPluginJobPrivate *priv = gs_plugin_job_get_instance_private (self);
+ g_return_val_if_fail (GS_IS_PLUGIN_JOB (self), FALSE);
+ return priv->interactive;
+}
+
+void
+gs_plugin_job_set_propagate_error (GsPluginJob *self, gboolean propagate_error)
+{
+ GsPluginJobPrivate *priv = gs_plugin_job_get_instance_private (self);
+ g_return_if_fail (GS_IS_PLUGIN_JOB (self));
+ priv->propagate_error = propagate_error;
+}
+
+gboolean
+gs_plugin_job_get_propagate_error (GsPluginJob *self)
+{
+ GsPluginJobPrivate *priv = gs_plugin_job_get_instance_private (self);
+ g_return_val_if_fail (GS_IS_PLUGIN_JOB (self), FALSE);
+ return priv->propagate_error;
+}
+
+void
+gs_plugin_job_set_max_results (GsPluginJob *self, guint max_results)
+{
+ GsPluginJobPrivate *priv = gs_plugin_job_get_instance_private (self);
+ g_return_if_fail (GS_IS_PLUGIN_JOB (self));
+ priv->max_results = max_results;
+}
+
+guint
+gs_plugin_job_get_max_results (GsPluginJob *self)
+{
+ GsPluginJobPrivate *priv = gs_plugin_job_get_instance_private (self);
+ g_return_val_if_fail (GS_IS_PLUGIN_JOB (self), 0);
+ return priv->max_results;
+}
+
+void
+gs_plugin_job_set_action (GsPluginJob *self, GsPluginAction action)
+{
+ GsPluginJobPrivate *priv = gs_plugin_job_get_instance_private (self);
+ g_return_if_fail (GS_IS_PLUGIN_JOB (self));
+ priv->action = action;
+}
+
+GsPluginAction
+gs_plugin_job_get_action (GsPluginJob *self)
+{
+ GsPluginJobPrivate *priv = gs_plugin_job_get_instance_private (self);
+ g_return_val_if_fail (GS_IS_PLUGIN_JOB (self), GS_PLUGIN_ACTION_UNKNOWN);
+ return priv->action;
+}
+
+void
+gs_plugin_job_set_sort_func (GsPluginJob *self, GsAppListSortFunc sort_func, gpointer user_data)
+{
+ GsPluginJobPrivate *priv = gs_plugin_job_get_instance_private (self);
+ g_return_if_fail (GS_IS_PLUGIN_JOB (self));
+ priv->sort_func = sort_func;
+ priv->sort_func_data = user_data;
+}
+
+GsAppListSortFunc
+gs_plugin_job_get_sort_func (GsPluginJob *self, gpointer *user_data_out)
+{
+ GsPluginJobPrivate *priv = gs_plugin_job_get_instance_private (self);
+ g_return_val_if_fail (GS_IS_PLUGIN_JOB (self), NULL);
+ if (user_data_out != NULL)
+ *user_data_out = priv->sort_func_data;
+ return priv->sort_func;
+}
+
+void
+gs_plugin_job_set_search (GsPluginJob *self, const gchar *search)
+{
+ GsPluginJobPrivate *priv = gs_plugin_job_get_instance_private (self);
+ g_return_if_fail (GS_IS_PLUGIN_JOB (self));
+ g_free (priv->search);
+ priv->search = g_strdup (search);
+}
+
+const gchar *
+gs_plugin_job_get_search (GsPluginJob *self)
+{
+ GsPluginJobPrivate *priv = gs_plugin_job_get_instance_private (self);
+ g_return_val_if_fail (GS_IS_PLUGIN_JOB (self), NULL);
+ return priv->search;
+}
+
+void
+gs_plugin_job_set_app (GsPluginJob *self, GsApp *app)
+{
+ GsPluginJobPrivate *priv = gs_plugin_job_get_instance_private (self);
+ g_return_if_fail (GS_IS_PLUGIN_JOB (self));
+ g_set_object (&priv->app, app);
+
+ /* ensure we can always operate on a list object */
+ if (priv->list != NULL && app != NULL && gs_app_list_length (priv->list) == 0)
+ gs_app_list_add (priv->list, priv->app);
+}
+
+GsApp *
+gs_plugin_job_get_app (GsPluginJob *self)
+{
+ GsPluginJobPrivate *priv = gs_plugin_job_get_instance_private (self);
+ g_return_val_if_fail (GS_IS_PLUGIN_JOB (self), NULL);
+ return priv->app;
+}
+
+void
+gs_plugin_job_set_list (GsPluginJob *self, GsAppList *list)
+{
+ GsPluginJobPrivate *priv = gs_plugin_job_get_instance_private (self);
+ g_return_if_fail (GS_IS_PLUGIN_JOB (self));
+ if (list == NULL)
+ g_warning ("trying to set list to NULL, not a good idea");
+ g_set_object (&priv->list, list);
+}
+
+GsAppList *
+gs_plugin_job_get_list (GsPluginJob *self)
+{
+ GsPluginJobPrivate *priv = gs_plugin_job_get_instance_private (self);
+ g_return_val_if_fail (GS_IS_PLUGIN_JOB (self), NULL);
+ return priv->list;
+}
+
+void
+gs_plugin_job_set_file (GsPluginJob *self, GFile *file)
+{
+ GsPluginJobPrivate *priv = gs_plugin_job_get_instance_private (self);
+ g_return_if_fail (GS_IS_PLUGIN_JOB (self));
+ g_set_object (&priv->file, file);
+}
+
+GFile *
+gs_plugin_job_get_file (GsPluginJob *self)
+{
+ GsPluginJobPrivate *priv = gs_plugin_job_get_instance_private (self);
+ g_return_val_if_fail (GS_IS_PLUGIN_JOB (self), NULL);
+ return priv->file;
+}
+
+void
+gs_plugin_job_set_plugin (GsPluginJob *self, GsPlugin *plugin)
+{
+ GsPluginJobPrivate *priv = gs_plugin_job_get_instance_private (self);
+ g_return_if_fail (GS_IS_PLUGIN_JOB (self));
+ g_set_object (&priv->plugin, plugin);
+}
+
+GsPlugin *
+gs_plugin_job_get_plugin (GsPluginJob *self)
+{
+ GsPluginJobPrivate *priv = gs_plugin_job_get_instance_private (self);
+ g_return_val_if_fail (GS_IS_PLUGIN_JOB (self), NULL);
+ return priv->plugin;
+}
+
+static void
+gs_plugin_job_get_property (GObject *obj, guint prop_id, GValue *value, GParamSpec *pspec)
+{
+ GsPluginJob *self = GS_PLUGIN_JOB (obj);
+ GsPluginJobPrivate *priv = gs_plugin_job_get_instance_private (self);
+
+ switch (prop_id) {
+ case PROP_ACTION:
+ g_value_set_enum (value, priv->action);
+ break;
+ case PROP_REFINE_FLAGS:
+ g_value_set_flags (value, priv->refine_flags);
+ break;
+ case PROP_DEDUPE_FLAGS:
+ g_value_set_flags (value, priv->dedupe_flags);
+ break;
+ case PROP_INTERACTIVE:
+ g_value_set_boolean (value, priv->interactive);
+ break;
+ case PROP_SEARCH:
+ g_value_set_string (value, priv->search);
+ break;
+ case PROP_APP:
+ g_value_set_object (value, priv->app);
+ break;
+ case PROP_LIST:
+ g_value_set_object (value, priv->list);
+ break;
+ case PROP_FILE:
+ g_value_set_object (value, priv->file);
+ break;
+ case PROP_MAX_RESULTS:
+ g_value_set_uint (value, priv->max_results);
+ break;
+ case PROP_PROPAGATE_ERROR:
+ g_value_set_boolean (value, priv->propagate_error);
+ break;
+ default:
+ G_OBJECT_WARN_INVALID_PROPERTY_ID (obj, prop_id, pspec);
+ break;
+ }
+}
+
+static void
+gs_plugin_job_set_property (GObject *obj, guint prop_id, const GValue *value, GParamSpec *pspec)
+{
+ GsPluginJob *self = GS_PLUGIN_JOB (obj);
+
+ switch (prop_id) {
+ case PROP_ACTION:
+ gs_plugin_job_set_action (self, g_value_get_enum (value));
+ break;
+ case PROP_REFINE_FLAGS:
+ gs_plugin_job_set_refine_flags (self, g_value_get_flags (value));
+ break;
+ case PROP_DEDUPE_FLAGS:
+ gs_plugin_job_set_dedupe_flags (self, g_value_get_flags (value));
+ break;
+ case PROP_INTERACTIVE:
+ gs_plugin_job_set_interactive (self, g_value_get_boolean (value));
+ break;
+ case PROP_SEARCH:
+ gs_plugin_job_set_search (self, g_value_get_string (value));
+ break;
+ case PROP_APP:
+ gs_plugin_job_set_app (self, g_value_get_object (value));
+ break;
+ case PROP_LIST:
+ gs_plugin_job_set_list (self, g_value_get_object (value));
+ break;
+ case PROP_FILE:
+ gs_plugin_job_set_file (self, g_value_get_object (value));
+ break;
+ case PROP_MAX_RESULTS:
+ gs_plugin_job_set_max_results (self, g_value_get_uint (value));
+ break;
+ case PROP_PROPAGATE_ERROR:
+ gs_plugin_job_set_propagate_error (self, g_value_get_boolean (value));
+ break;
+ default:
+ G_OBJECT_WARN_INVALID_PROPERTY_ID (obj, prop_id, pspec);
+ break;
+ }
+}
+
+static void
+gs_plugin_job_finalize (GObject *obj)
+{
+ GsPluginJob *self = GS_PLUGIN_JOB (obj);
+ GsPluginJobPrivate *priv = gs_plugin_job_get_instance_private (self);
+
+ g_free (priv->search);
+ g_clear_object (&priv->app);
+ g_clear_object (&priv->list);
+ g_clear_object (&priv->file);
+ g_clear_object (&priv->plugin);
+
+ G_OBJECT_CLASS (gs_plugin_job_parent_class)->finalize (obj);
+}
+
+static void
+gs_plugin_job_class_init (GsPluginJobClass *klass)
+{
+ GParamSpec *pspec;
+ GObjectClass *object_class = G_OBJECT_CLASS (klass);
+ object_class->finalize = gs_plugin_job_finalize;
+ object_class->get_property = gs_plugin_job_get_property;
+ object_class->set_property = gs_plugin_job_set_property;
+
+ pspec = g_param_spec_enum ("action", NULL, NULL,
+ GS_TYPE_PLUGIN_ACTION, GS_PLUGIN_ACTION_UNKNOWN,
+ G_PARAM_READWRITE);
+ g_object_class_install_property (object_class, PROP_ACTION, pspec);
+
+ pspec = g_param_spec_flags ("refine-flags", NULL, NULL,
+ GS_TYPE_PLUGIN_REFINE_FLAGS, GS_PLUGIN_REFINE_FLAGS_NONE,
+ G_PARAM_READWRITE);
+ g_object_class_install_property (object_class, PROP_REFINE_FLAGS, pspec);
+
+ pspec = g_param_spec_flags ("dedupe-flags", NULL, NULL,
+ GS_TYPE_APP_LIST_FILTER_FLAGS, GS_APP_LIST_FILTER_FLAG_NONE,
+ G_PARAM_READWRITE);
+ g_object_class_install_property (object_class, PROP_DEDUPE_FLAGS, pspec);
+
+ pspec = g_param_spec_boolean ("interactive", NULL, NULL,
+ FALSE,
+ G_PARAM_READWRITE);
+
+ g_object_class_install_property (object_class, PROP_INTERACTIVE, pspec);
+
+ pspec = g_param_spec_string ("search", NULL, NULL,
+ NULL,
+ G_PARAM_READWRITE);
+ g_object_class_install_property (object_class, PROP_SEARCH, pspec);
+
+ pspec = g_param_spec_object ("app", NULL, NULL,
+ GS_TYPE_APP,
+ G_PARAM_READWRITE);
+ g_object_class_install_property (object_class, PROP_APP, pspec);
+
+ pspec = g_param_spec_object ("list", NULL, NULL,
+ GS_TYPE_APP_LIST,
+ G_PARAM_READWRITE);
+ g_object_class_install_property (object_class, PROP_LIST, pspec);
+
+ pspec = g_param_spec_object ("file", NULL, NULL,
+ G_TYPE_FILE,
+ G_PARAM_READWRITE);
+ g_object_class_install_property (object_class, PROP_FILE, pspec);
+
+ pspec = g_param_spec_uint ("max-results", NULL, NULL,
+ 0, G_MAXUINT, 0,
+ G_PARAM_READWRITE);
+ g_object_class_install_property (object_class, PROP_MAX_RESULTS, pspec);
+
+ pspec = g_param_spec_boolean ("propagate-error", NULL, NULL,
+ FALSE,
+ G_PARAM_READWRITE);
+ g_object_class_install_property (object_class, PROP_PROPAGATE_ERROR, pspec);
+}
+
+static void
+gs_plugin_job_init (GsPluginJob *self)
+{
+ GsPluginJobPrivate *priv = gs_plugin_job_get_instance_private (self);
+
+ priv->refine_flags = GS_PLUGIN_REFINE_FLAGS_NONE;
+ priv->dedupe_flags = GS_APP_LIST_FILTER_FLAG_KEY_ID |
+ GS_APP_LIST_FILTER_FLAG_KEY_SOURCE |
+ GS_APP_LIST_FILTER_FLAG_KEY_VERSION;
+ priv->list = gs_app_list_new ();
+ priv->time_created = g_get_monotonic_time ();
+}
diff --git a/lib/gs-plugin-job.h b/lib/gs-plugin-job.h
new file mode 100644
index 0000000..dcdbc85
--- /dev/null
+++ b/lib/gs-plugin-job.h
@@ -0,0 +1,68 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2017-2018 Richard Hughes <richard@hughsie.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#include <glib-object.h>
+
+#include "gs-app-list.h"
+#include "gs-plugin-types.h"
+
+G_BEGIN_DECLS
+
+#define GS_TYPE_PLUGIN_JOB (gs_plugin_job_get_type ())
+
+G_DECLARE_DERIVABLE_TYPE (GsPluginJob, gs_plugin_job, GS, PLUGIN_JOB, GObject)
+
+#include "gs-plugin-loader.h"
+
+struct _GsPluginJobClass
+{
+ GObjectClass parent_class;
+
+ void (*run_async) (GsPluginJob *self,
+ GsPluginLoader *plugin_loader,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data);
+ gboolean (*run_finish) (GsPluginJob *self,
+ GAsyncResult *result,
+ GError **error);
+};
+
+void gs_plugin_job_set_refine_flags (GsPluginJob *self,
+ GsPluginRefineFlags refine_flags);
+void gs_plugin_job_set_dedupe_flags (GsPluginJob *self,
+ GsAppListFilterFlags dedupe_flags);
+void gs_plugin_job_set_interactive (GsPluginJob *self,
+ gboolean interactive);
+void gs_plugin_job_set_propagate_error (GsPluginJob *self,
+ gboolean propagate_error);
+void gs_plugin_job_set_max_results (GsPluginJob *self,
+ guint max_results);
+void gs_plugin_job_set_sort_func (GsPluginJob *self,
+ GsAppListSortFunc sort_func,
+ gpointer user_data);
+void gs_plugin_job_set_search (GsPluginJob *self,
+ const gchar *search);
+void gs_plugin_job_set_app (GsPluginJob *self,
+ GsApp *app);
+void gs_plugin_job_set_list (GsPluginJob *self,
+ GsAppList *list);
+void gs_plugin_job_set_file (GsPluginJob *self,
+ GFile *file);
+void gs_plugin_job_set_plugin (GsPluginJob *self,
+ GsPlugin *plugin);
+
+#define gs_plugin_job_newv(a,...) GS_PLUGIN_JOB(g_object_new(GS_TYPE_PLUGIN_JOB, "action", a, __VA_ARGS__))
+
+#define GS_PLUGIN_JOB_DEDUPE_FLAGS_DEFAULT (GS_APP_LIST_FILTER_FLAG_KEY_ID | \
+ GS_APP_LIST_FILTER_FLAG_KEY_SOURCE | \
+ GS_APP_LIST_FILTER_FLAG_KEY_VERSION)
+
+G_END_DECLS
diff --git a/lib/gs-plugin-loader-sync.c b/lib/gs-plugin-loader-sync.c
new file mode 100644
index 0000000..2b8b39a
--- /dev/null
+++ b/lib/gs-plugin-loader-sync.c
@@ -0,0 +1,254 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2012-2017 Richard Hughes <richard@hughsie.com>
+ * Copyright (C) 2017 Kalev Lember <klember@redhat.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#include "config.h"
+
+#include "gs-plugin-loader-sync.h"
+
+/* tiny helper to help us do the async operation */
+typedef struct {
+ GAsyncResult *res;
+ GMainContext *context;
+ GMainLoop *loop;
+} GsPluginLoaderHelper;
+
+static void
+_helper_finish_sync (GObject *source_object,
+ GAsyncResult *res,
+ gpointer user_data)
+{
+ GsPluginLoaderHelper *helper = user_data;
+ helper->res = g_object_ref (res);
+ g_main_loop_quit (helper->loop);
+}
+
+GsAppList *
+gs_plugin_loader_job_process (GsPluginLoader *plugin_loader,
+ GsPluginJob *plugin_job,
+ GCancellable *cancellable,
+ GError **error)
+{
+ GsPluginLoaderHelper helper;
+ GsAppList *list;
+
+ /* create temp object */
+ helper.res = NULL;
+ helper.context = g_main_context_new ();
+ helper.loop = g_main_loop_new (helper.context, FALSE);
+
+ g_main_context_push_thread_default (helper.context);
+
+ /* run async method */
+ gs_plugin_loader_job_process_async (plugin_loader,
+ plugin_job,
+ cancellable,
+ _helper_finish_sync,
+ &helper);
+ g_main_loop_run (helper.loop);
+ list = gs_plugin_loader_job_process_finish (plugin_loader,
+ helper.res,
+ error);
+
+ g_main_context_pop_thread_default (helper.context);
+
+ g_main_loop_unref (helper.loop);
+ g_main_context_unref (helper.context);
+ if (helper.res != NULL)
+ g_object_unref (helper.res);
+
+ return list;
+}
+
+gboolean
+gs_plugin_loader_job_action (GsPluginLoader *plugin_loader,
+ GsPluginJob *plugin_job,
+ GCancellable *cancellable,
+ GError **error)
+{
+ GsPluginLoaderHelper helper;
+ gboolean ret;
+
+ /* create temp object */
+ helper.res = NULL;
+ helper.context = g_main_context_new ();
+ helper.loop = g_main_loop_new (helper.context, FALSE);
+
+ g_main_context_push_thread_default (helper.context);
+
+ /* run async method */
+ gs_plugin_loader_job_process_async (plugin_loader,
+ plugin_job,
+ cancellable,
+ _helper_finish_sync,
+ &helper);
+ g_main_loop_run (helper.loop);
+ ret = gs_plugin_loader_job_action_finish (plugin_loader,
+ helper.res,
+ error);
+
+ g_main_context_pop_thread_default (helper.context);
+
+ g_main_loop_unref (helper.loop);
+ g_main_context_unref (helper.context);
+ if (helper.res != NULL)
+ g_object_unref (helper.res);
+
+ return ret;
+}
+
+GsApp *
+gs_plugin_loader_job_process_app (GsPluginLoader *plugin_loader,
+ GsPluginJob *plugin_job,
+ GCancellable *cancellable,
+ GError **error)
+{
+ GsPluginLoaderHelper helper;
+ g_autoptr(GsAppList) list = NULL;
+ GsApp *app = NULL;
+
+ /* create temp object */
+ helper.res = NULL;
+ helper.context = g_main_context_new ();
+ helper.loop = g_main_loop_new (helper.context, FALSE);
+
+ g_main_context_push_thread_default (helper.context);
+
+ /* run async method */
+ gs_plugin_loader_job_process_async (plugin_loader,
+ plugin_job,
+ cancellable,
+ _helper_finish_sync,
+ &helper);
+ g_main_loop_run (helper.loop);
+ list = gs_plugin_loader_job_process_finish (plugin_loader,
+ helper.res,
+ error);
+ if (list != NULL)
+ app = g_object_ref (gs_app_list_index (list, 0));
+
+ g_main_context_pop_thread_default (helper.context);
+
+ g_main_loop_unref (helper.loop);
+ g_main_context_unref (helper.context);
+ if (helper.res != NULL)
+ g_object_unref (helper.res);
+
+ return app;
+}
+
+GsApp *
+gs_plugin_loader_app_create (GsPluginLoader *plugin_loader,
+ const gchar *unique_id,
+ GCancellable *cancellable,
+ GError **error)
+{
+ GsPluginLoaderHelper helper;
+ GsApp *app;
+
+ /* create temp object */
+ helper.res = NULL;
+ helper.context = g_main_context_new ();
+ helper.loop = g_main_loop_new (helper.context, FALSE);
+
+ g_main_context_push_thread_default (helper.context);
+
+ /* run async method */
+ gs_plugin_loader_app_create_async (plugin_loader,
+ unique_id,
+ cancellable,
+ _helper_finish_sync,
+ &helper);
+ g_main_loop_run (helper.loop);
+ app = gs_plugin_loader_app_create_finish (plugin_loader,
+ helper.res,
+ error);
+
+ g_main_context_pop_thread_default (helper.context);
+
+ g_main_loop_unref (helper.loop);
+ g_main_context_unref (helper.context);
+ if (helper.res != NULL)
+ g_object_unref (helper.res);
+
+ return app;
+}
+
+GsApp *
+gs_plugin_loader_get_system_app (GsPluginLoader *plugin_loader,
+ GCancellable *cancellable,
+ GError **error)
+{
+ GsPluginLoaderHelper helper;
+ GsApp *app;
+
+ /* create temp object */
+ helper.res = NULL;
+ helper.context = g_main_context_new ();
+ helper.loop = g_main_loop_new (helper.context, FALSE);
+
+ g_main_context_push_thread_default (helper.context);
+
+ /* run async method */
+ gs_plugin_loader_get_system_app_async (plugin_loader,
+ cancellable,
+ _helper_finish_sync,
+ &helper);
+ g_main_loop_run (helper.loop);
+ app = gs_plugin_loader_get_system_app_finish (plugin_loader,
+ helper.res,
+ error);
+
+ g_main_context_pop_thread_default (helper.context);
+
+ g_main_loop_unref (helper.loop);
+ g_main_context_unref (helper.context);
+ if (helper.res != NULL)
+ g_object_unref (helper.res);
+
+ return app;
+}
+
+gboolean
+gs_plugin_loader_setup (GsPluginLoader *plugin_loader,
+ const gchar * const *allowlist,
+ const gchar * const *blocklist,
+ GCancellable *cancellable,
+ GError **error)
+{
+ GsPluginLoaderHelper helper;
+ gboolean retval;
+
+ /* create temp object */
+ helper.res = NULL;
+ helper.context = g_main_context_new ();
+ helper.loop = g_main_loop_new (helper.context, FALSE);
+
+ g_main_context_push_thread_default (helper.context);
+
+ /* run async method */
+ gs_plugin_loader_setup_async (plugin_loader,
+ allowlist,
+ blocklist,
+ cancellable,
+ _helper_finish_sync,
+ &helper);
+ g_main_loop_run (helper.loop);
+ retval = gs_plugin_loader_setup_finish (plugin_loader,
+ helper.res,
+ error);
+
+ g_main_context_pop_thread_default (helper.context);
+
+ g_main_loop_unref (helper.loop);
+ g_main_context_unref (helper.context);
+ if (helper.res != NULL)
+ g_object_unref (helper.res);
+
+ return retval;
+}
diff --git a/lib/gs-plugin-loader-sync.h b/lib/gs-plugin-loader-sync.h
new file mode 100644
index 0000000..844e2aa
--- /dev/null
+++ b/lib/gs-plugin-loader-sync.h
@@ -0,0 +1,42 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2007-2017 Richard Hughes <richard@hughsie.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#include <glib-object.h>
+
+#include "gs-plugin-loader.h"
+
+G_BEGIN_DECLS
+
+GsAppList *gs_plugin_loader_job_process (GsPluginLoader *plugin_loader,
+ GsPluginJob *plugin_job,
+ GCancellable *cancellable,
+ GError **error);
+GsApp *gs_plugin_loader_job_process_app (GsPluginLoader *plugin_loader,
+ GsPluginJob *plugin_job,
+ GCancellable *cancellable,
+ GError **error);
+gboolean gs_plugin_loader_job_action (GsPluginLoader *plugin_loader,
+ GsPluginJob *plugin_job,
+ GCancellable *cancellable,
+ GError **error);
+GsApp *gs_plugin_loader_app_create (GsPluginLoader *plugin_loader,
+ const gchar *unique_id,
+ GCancellable *cancellable,
+ GError **error);
+GsApp *gs_plugin_loader_get_system_app (GsPluginLoader *plugin_loader,
+ GCancellable *cancellable,
+ GError **error);
+gboolean gs_plugin_loader_setup (GsPluginLoader *plugin_loader,
+ const gchar * const *allowlist,
+ const gchar * const *blocklist,
+ GCancellable *cancellable,
+ GError **error);
+
+G_END_DECLS
diff --git a/lib/gs-plugin-loader.c b/lib/gs-plugin-loader.c
new file mode 100644
index 0000000..d10b1be
--- /dev/null
+++ b/lib/gs-plugin-loader.c
@@ -0,0 +1,4172 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2007-2018 Richard Hughes <richard@hughsie.com>
+ * Copyright (C) 2014-2020 Kalev Lember <klember@redhat.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#include "config.h"
+
+#include <locale.h>
+#include <glib/gi18n.h>
+#include <glib/gstdio.h>
+#include <appstream.h>
+#include <math.h>
+
+#ifdef HAVE_SYSPROF
+#include <sysprof-capture.h>
+#endif
+
+#include "gs-app-collation.h"
+#include "gs-app-private.h"
+#include "gs-app-list-private.h"
+#include "gs-category-manager.h"
+#include "gs-category-private.h"
+#include "gs-external-appstream-utils.h"
+#include "gs-ioprio.h"
+#include "gs-os-release.h"
+#include "gs-plugin-loader.h"
+#include "gs-plugin.h"
+#include "gs-plugin-event.h"
+#include "gs-plugin-job-private.h"
+#include "gs-plugin-private.h"
+#include "gs-utils.h"
+
+#define GS_PLUGIN_LOADER_UPDATES_CHANGED_DELAY 3 /* s */
+#define GS_PLUGIN_LOADER_RELOAD_DELAY 5 /* s */
+
+struct _GsPluginLoader
+{
+ GObject parent;
+
+ gboolean setup_complete;
+ GCancellable *setup_complete_cancellable; /* (nullable) (owned) */
+
+ GPtrArray *plugins;
+ GPtrArray *locations;
+ gchar *language;
+ gboolean plugin_dir_dirty;
+ GPtrArray *file_monitors;
+ GsPluginStatus global_status_last;
+
+ GMutex pending_apps_mutex;
+ GsAppList *pending_apps; /* (nullable) (owned) */
+ GCancellable *pending_apps_cancellable; /* (nullable) (owned) */
+
+ GThreadPool *queued_ops_pool;
+ gint active_jobs;
+
+ GSettings *settings;
+
+ GMutex events_by_id_mutex;
+ GHashTable *events_by_id; /* unique-id : GsPluginEvent */
+
+ gchar **compatible_projects;
+ guint scale;
+
+ guint updates_changed_id;
+ guint updates_changed_cnt;
+ guint reload_id;
+ GHashTable *disallow_updates; /* GsPlugin : const char *name */
+
+ GNetworkMonitor *network_monitor;
+ gulong network_changed_handler;
+ gulong network_available_notify_handler;
+ gulong network_metered_notify_handler;
+
+ GsCategoryManager *category_manager;
+ GsOdrsProvider *odrs_provider; /* (owned) (nullable) */
+
+#ifdef HAVE_SYSPROF
+ SysprofCaptureWriter *sysprof_writer; /* (owned) (nullable) */
+#endif
+
+ GDBusConnection *session_bus_connection; /* (owned); (not nullable) after setup */
+ GDBusConnection *system_bus_connection; /* (owned); (not nullable) after setup */
+};
+
+static void gs_plugin_loader_monitor_network (GsPluginLoader *plugin_loader);
+static void add_app_to_install_queue (GsPluginLoader *plugin_loader, GsApp *app);
+static gboolean remove_app_from_install_queue (GsPluginLoader *plugin_loader, GsApp *app);
+static void gs_plugin_loader_process_in_thread_pool_cb (gpointer data, gpointer user_data);
+static void gs_plugin_loader_status_changed_cb (GsPlugin *plugin,
+ GsApp *app,
+ GsPluginStatus status,
+ GsPluginLoader *plugin_loader);
+static void async_result_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data);
+
+G_DEFINE_TYPE (GsPluginLoader, gs_plugin_loader, G_TYPE_OBJECT)
+
+enum {
+ SIGNAL_STATUS_CHANGED,
+ SIGNAL_PENDING_APPS_CHANGED,
+ SIGNAL_UPDATES_CHANGED,
+ SIGNAL_RELOAD,
+ SIGNAL_BASIC_AUTH_START,
+ SIGNAL_ASK_UNTRUSTED,
+ SIGNAL_LAST
+};
+
+static guint signals [SIGNAL_LAST] = { 0 };
+
+typedef enum {
+ PROP_EVENTS = 1,
+ PROP_ALLOW_UPDATES,
+ PROP_NETWORK_AVAILABLE,
+ PROP_NETWORK_METERED,
+ PROP_SESSION_BUS_CONNECTION,
+ PROP_SYSTEM_BUS_CONNECTION,
+} GsPluginLoaderProperty;
+
+static GParamSpec *obj_props[PROP_SYSTEM_BUS_CONNECTION + 1] = { NULL, };
+
+typedef void (*GsPluginFunc) (GsPlugin *plugin);
+typedef gboolean (*GsPluginSetupFunc) (GsPlugin *plugin,
+ GCancellable *cancellable,
+ GError **error);
+typedef gboolean (*GsPluginSearchFunc) (GsPlugin *plugin,
+ gchar **value,
+ GsAppList *list,
+ GCancellable *cancellable,
+ GError **error);
+typedef gboolean (*GsPluginAlternatesFunc) (GsPlugin *plugin,
+ GsApp *app,
+ GsAppList *list,
+ GCancellable *cancellable,
+ GError **error);
+typedef gboolean (*GsPluginCategoryFunc) (GsPlugin *plugin,
+ GsCategory *category,
+ GsAppList *list,
+ GCancellable *cancellable,
+ GError **error);
+typedef gboolean (*GsPluginGetRecentFunc) (GsPlugin *plugin,
+ GsAppList *list,
+ guint64 age,
+ GCancellable *cancellable,
+ GError **error);
+typedef gboolean (*GsPluginResultsFunc) (GsPlugin *plugin,
+ GsAppList *list,
+ GCancellable *cancellable,
+ GError **error);
+typedef gboolean (*GsPluginCategoriesFunc) (GsPlugin *plugin,
+ GPtrArray *list,
+ GCancellable *cancellable,
+ GError **error);
+typedef gboolean (*GsPluginActionFunc) (GsPlugin *plugin,
+ GsApp *app,
+ GCancellable *cancellable,
+ GError **error);
+typedef gboolean (*GsPluginRefreshFunc) (GsPlugin *plugin,
+ guint cache_age,
+ GCancellable *cancellable,
+ GError **error);
+typedef gboolean (*GsPluginFileToAppFunc) (GsPlugin *plugin,
+ GsAppList *list,
+ GFile *file,
+ GCancellable *cancellable,
+ GError **error);
+typedef gboolean (*GsPluginUrlToAppFunc) (GsPlugin *plugin,
+ GsAppList *list,
+ const gchar *url,
+ GCancellable *cancellable,
+ GError **error);
+typedef gboolean (*GsPluginUpdateFunc) (GsPlugin *plugin,
+ GsAppList *apps,
+ GCancellable *cancellable,
+ GError **error);
+typedef void (*GsPluginAdoptAppFunc) (GsPlugin *plugin,
+ GsApp *app);
+typedef gboolean (*GsPluginGetLangPacksFunc) (GsPlugin *plugin,
+ GsAppList *list,
+ const gchar *locale,
+ GCancellable *cancellable,
+ GError **error);
+
+
+/* async helper */
+typedef struct {
+ GsPluginLoader *plugin_loader;
+ const gchar *function_name;
+ const gchar *function_name_parent;
+ GPtrArray *catlist;
+ GsPluginJob *plugin_job;
+ gboolean anything_ran;
+ gchar **tokens;
+} GsPluginLoaderHelper;
+
+static GsPluginLoaderHelper *
+gs_plugin_loader_helper_new (GsPluginLoader *plugin_loader, GsPluginJob *plugin_job)
+{
+ GsPluginLoaderHelper *helper = g_slice_new0 (GsPluginLoaderHelper);
+ GsPluginAction action = gs_plugin_job_get_action (plugin_job);
+ helper->plugin_loader = g_object_ref (plugin_loader);
+ helper->plugin_job = g_object_ref (plugin_job);
+ helper->function_name = gs_plugin_action_to_function_name (action);
+ return helper;
+}
+
+static void
+reset_app_progress (GsApp *app)
+{
+ g_autoptr(GsAppList) addons = gs_app_dup_addons (app);
+ GsAppList *related = gs_app_get_related (app);
+
+ gs_app_set_progress (app, GS_APP_PROGRESS_UNKNOWN);
+
+ for (guint i = 0; addons != NULL && i < gs_app_list_length (addons); i++) {
+ GsApp *app_addons = gs_app_list_index (addons, i);
+ gs_app_set_progress (app_addons, GS_APP_PROGRESS_UNKNOWN);
+ }
+ for (guint i = 0; i < gs_app_list_length (related); i++) {
+ GsApp *app_related = gs_app_list_index (related, i);
+ gs_app_set_progress (app_related, GS_APP_PROGRESS_UNKNOWN);
+ }
+}
+
+static void
+gs_plugin_loader_helper_free (GsPluginLoaderHelper *helper)
+{
+ /* reset progress */
+ switch (gs_plugin_job_get_action (helper->plugin_job)) {
+ case GS_PLUGIN_ACTION_INSTALL:
+ case GS_PLUGIN_ACTION_REMOVE:
+ case GS_PLUGIN_ACTION_UPDATE:
+ case GS_PLUGIN_ACTION_DOWNLOAD:
+ {
+ GsApp *app;
+ GsAppList *list;
+
+ app = gs_plugin_job_get_app (helper->plugin_job);
+ if (app != NULL)
+ reset_app_progress (app);
+
+ list = gs_plugin_job_get_list (helper->plugin_job);
+ for (guint i = 0; i < gs_app_list_length (list); i++) {
+ GsApp *app_tmp = gs_app_list_index (list, i);
+ reset_app_progress (app_tmp);
+ }
+ }
+ break;
+ default:
+ break;
+ }
+
+ g_object_unref (helper->plugin_loader);
+ if (helper->plugin_job != NULL)
+ g_object_unref (helper->plugin_job);
+ if (helper->catlist != NULL)
+ g_ptr_array_unref (helper->catlist);
+ g_strfreev (helper->tokens);
+ g_slice_free (GsPluginLoaderHelper, helper);
+}
+
+G_DEFINE_AUTOPTR_CLEANUP_FUNC(GsPluginLoaderHelper, gs_plugin_loader_helper_free)
+
+GsPlugin *
+gs_plugin_loader_find_plugin (GsPluginLoader *plugin_loader,
+ const gchar *plugin_name)
+{
+ for (guint i = 0; i < plugin_loader->plugins->len; i++) {
+ GsPlugin *plugin = g_ptr_array_index (plugin_loader->plugins, i);
+ if (g_strcmp0 (gs_plugin_get_name (plugin), plugin_name) == 0)
+ return plugin;
+ }
+ return NULL;
+}
+
+static gboolean
+gs_plugin_loader_notify_idle_cb (gpointer user_data)
+{
+ GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (user_data);
+ g_object_notify_by_pspec (G_OBJECT (plugin_loader), obj_props[PROP_EVENTS]);
+ return FALSE;
+}
+
+void
+gs_plugin_loader_add_event (GsPluginLoader *plugin_loader, GsPluginEvent *event)
+{
+ g_autoptr(GMutexLocker) locker = g_mutex_locker_new (&plugin_loader->events_by_id_mutex);
+
+ /* events should always have a unique ID, either constructed from the
+ * app they are processing or preferably from the GError message */
+ if (gs_plugin_event_get_unique_id (event) == NULL) {
+ g_warning ("failed to add event from action %s",
+ gs_plugin_action_to_string (gs_plugin_event_get_action (event)));
+ return;
+ }
+
+ g_hash_table_insert (plugin_loader->events_by_id,
+ g_strdup (gs_plugin_event_get_unique_id (event)),
+ g_object_ref (event));
+ g_idle_add (gs_plugin_loader_notify_idle_cb, plugin_loader);
+}
+
+/**
+ * gs_plugin_loader_claim_error:
+ * @plugin_loader: a #GsPluginLoader
+ * @plugin: (nullable): a #GsPlugin to get an application from, or %NULL
+ * @action: a #GsPluginAction associated with the @error
+ * @app: (nullable): a #GsApp for the event, or %NULL
+ * @interactive: whether to set interactive flag
+ * @error: a #GError to claim
+ *
+ * Convert the @error into a plugin event and add it to the queue.
+ *
+ * The @plugin is used only if the @error contains a reference
+ * to a concrete application, in which case any cached application
+ * overrides the passed in @app.
+ *
+ * The %GS_PLUGIN_ERROR_CANCELLED and %G_IO_ERROR_CANCELLED errors
+ * are automatically ignored.
+ *
+ * Since: 41
+ **/
+void
+gs_plugin_loader_claim_error (GsPluginLoader *plugin_loader,
+ GsPlugin *plugin,
+ GsPluginAction action,
+ GsApp *app,
+ gboolean interactive,
+ const GError *error)
+{
+ g_autoptr(GError) error_copy = NULL;
+ g_autofree gchar *app_id = NULL;
+ g_autofree gchar *origin_id = NULL;
+ g_autoptr(GsPluginEvent) event = NULL;
+ g_autoptr(GsApp) event_app = NULL;
+ g_autoptr(GsApp) event_origin = NULL;
+
+ g_return_if_fail (GS_IS_PLUGIN_LOADER (plugin_loader));
+ g_return_if_fail (error != NULL);
+
+ if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_CANCELLED) ||
+ g_error_matches (error, G_IO_ERROR, G_IO_ERROR_CANCELLED))
+ return;
+
+ /* find and strip any unique IDs from the error message */
+ error_copy = g_error_copy (error);
+
+ for (guint i = 0; i < 2; i++) {
+ if (app_id == NULL)
+ app_id = gs_utils_error_strip_app_id (error_copy);
+ if (origin_id == NULL)
+ origin_id = gs_utils_error_strip_origin_id (error_copy);
+ }
+
+ /* invalid */
+ if (error_copy->domain != GS_PLUGIN_ERROR) {
+ g_warning ("not GsPlugin error %s:%i: %s",
+ g_quark_to_string (error_copy->domain),
+ error_copy->code,
+ error_copy->message);
+ error_copy->domain = GS_PLUGIN_ERROR;
+ error_copy->code = GS_PLUGIN_ERROR_FAILED;
+ }
+
+ /* set the app and origin IDs if we managed to scrape them from the error above */
+ if (app != NULL)
+ event_app = g_object_ref (app);
+ event_origin = NULL;
+
+ if (plugin != NULL && as_utils_data_id_valid (app_id)) {
+ g_autoptr(GsApp) cached_app = gs_plugin_cache_lookup (plugin, app_id);
+ if (cached_app != NULL) {
+ g_debug ("found app %s in error", app_id);
+ g_set_object (&event_app, cached_app);
+ } else {
+ g_debug ("no unique ID found for app %s", app_id);
+ }
+ }
+ if (plugin != NULL && as_utils_data_id_valid (origin_id)) {
+ g_autoptr(GsApp) origin = gs_plugin_cache_lookup (plugin, origin_id);
+ if (origin != NULL) {
+ g_debug ("found origin %s in error", origin_id);
+ g_set_object (&event_origin, origin);
+ } else {
+ g_debug ("no unique ID found for origin %s", origin_id);
+ }
+ }
+
+ /* create event which is handled by the GsShell */
+ event = gs_plugin_event_new ("error", error_copy,
+ "action", action,
+ "app", event_app,
+ "origin", event_origin,
+ NULL);
+ if (interactive)
+ gs_plugin_event_add_flag (event, GS_PLUGIN_EVENT_FLAG_INTERACTIVE);
+ gs_plugin_event_add_flag (event, GS_PLUGIN_EVENT_FLAG_WARNING);
+
+ /* add event to the queue */
+ gs_plugin_loader_add_event (plugin_loader, event);
+}
+
+/**
+ * gs_plugin_loader_claim_job_error:
+ * @plugin_loader: a #GsPluginLoader
+ * @plugin: (nullable): a #GsPlugin to get an application from, or %NULL
+ * @job: a #GsPluginJob for the @error
+ * @error: a #GError to claim
+ *
+ * The same as gs_plugin_loader_claim_error(), only reads the information
+ * from the @job.
+ *
+ * Since: 41
+ **/
+void
+gs_plugin_loader_claim_job_error (GsPluginLoader *plugin_loader,
+ GsPlugin *plugin,
+ GsPluginJob *job,
+ const GError *error)
+{
+ g_return_if_fail (GS_IS_PLUGIN_LOADER (plugin_loader));
+ g_return_if_fail (GS_IS_PLUGIN_JOB (job));
+ g_return_if_fail (error != NULL);
+
+ gs_plugin_loader_claim_error (plugin_loader, plugin,
+ gs_plugin_job_get_action (job),
+ gs_plugin_job_get_app (job),
+ gs_plugin_job_get_interactive (job),
+ error);
+}
+
+static gboolean
+gs_plugin_loader_is_error_fatal (const GError *err)
+{
+ if (g_error_matches (err, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_TIMED_OUT))
+ return TRUE;
+ if (g_error_matches (err, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_AUTH_REQUIRED))
+ return TRUE;
+ if (g_error_matches (err, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_AUTH_INVALID))
+ return TRUE;
+ return FALSE;
+}
+
+static gboolean
+gs_plugin_error_handle_failure (GsPluginLoaderHelper *helper,
+ GsPlugin *plugin,
+ const GError *error_local,
+ GError **error)
+{
+ g_autoptr(GError) error_local_copy = NULL;
+ g_autofree gchar *app_id = NULL;
+ g_autofree gchar *origin_id = NULL;
+
+ /* badly behaved plugin */
+ if (error_local == NULL) {
+ g_critical ("%s did not set error for %s",
+ gs_plugin_get_name (plugin),
+ helper->function_name);
+ return TRUE;
+ }
+
+ if (gs_plugin_job_get_propagate_error (helper->plugin_job)) {
+ g_propagate_error (error, g_error_copy (error_local));
+ return FALSE;
+ }
+
+ /* this is only ever informational */
+ if (g_error_matches (error_local, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_CANCELLED) ||
+ g_error_matches (error_local, G_IO_ERROR, G_IO_ERROR_CANCELLED)) {
+ g_debug ("ignoring error cancelled: %s", error_local->message);
+ return TRUE;
+ }
+
+ /* find and strip any unique IDs from the error message */
+ error_local_copy = g_error_copy (error_local);
+
+ for (guint i = 0; i < 2; i++) {
+ if (app_id == NULL)
+ app_id = gs_utils_error_strip_app_id (error_local_copy);
+ if (origin_id == NULL)
+ origin_id = gs_utils_error_strip_origin_id (error_local_copy);
+ }
+
+ /* fatal error */
+ if (gs_plugin_loader_is_error_fatal (error_local_copy) ||
+ g_getenv ("GS_SELF_TEST_PLUGIN_ERROR_FAIL_HARD") != NULL) {
+ if (error != NULL)
+ *error = g_steal_pointer (&error_local_copy);
+ return FALSE;
+ }
+
+ gs_plugin_loader_claim_job_error (helper->plugin_loader, plugin, helper->plugin_job, error_local);
+
+ return TRUE;
+}
+
+/**
+ * gs_plugin_loader_run_adopt:
+ * @plugin_loader: a #GsPluginLoader
+ * @list: list of apps to try and adopt
+ *
+ * Call the gs_plugin_adopt_app() function on each plugin on each app in @list
+ * to try and find the plugin which should manage each app.
+ *
+ * This function is intended to be used by internal gnome-software code.
+ *
+ * Since: 42
+ */
+void
+gs_plugin_loader_run_adopt (GsPluginLoader *plugin_loader, GsAppList *list)
+{
+ guint i;
+ guint j;
+
+ /* go through each plugin in order */
+ for (i = 0; i < plugin_loader->plugins->len; i++) {
+ GsPluginAdoptAppFunc adopt_app_func = NULL;
+ GsPlugin *plugin = g_ptr_array_index (plugin_loader->plugins, i);
+ adopt_app_func = gs_plugin_get_symbol (plugin, "gs_plugin_adopt_app");
+ if (adopt_app_func == NULL)
+ continue;
+ for (j = 0; j < gs_app_list_length (list); j++) {
+ GsApp *app = gs_app_list_index (list, j);
+
+ if (gs_app_has_quirk (app, GS_APP_QUIRK_IS_WILDCARD))
+ continue;
+ if (!gs_app_has_management_plugin (app, NULL))
+ continue;
+
+ adopt_app_func (plugin, app);
+
+ if (!gs_app_has_management_plugin (app, NULL)) {
+ g_debug ("%s adopted %s",
+ gs_plugin_get_name (plugin),
+ gs_app_get_unique_id (app));
+ }
+ }
+ }
+ for (j = 0; j < gs_app_list_length (list); j++) {
+ GsApp *app = gs_app_list_index (list, j);
+
+ if (gs_app_has_quirk (app, GS_APP_QUIRK_IS_WILDCARD))
+ continue;
+ if (!gs_app_has_management_plugin (app, NULL))
+ continue;
+
+ g_debug ("nothing adopted %s", gs_app_get_unique_id (app));
+ }
+}
+
+static gboolean
+gs_plugin_loader_call_vfunc (GsPluginLoaderHelper *helper,
+ GsPlugin *plugin,
+ GsApp *app,
+ GsAppList *list,
+ GsPluginRefineFlags refine_flags,
+ GCancellable *cancellable,
+ GError **error)
+{
+ GsPluginLoader *plugin_loader = helper->plugin_loader;
+ GsPluginAction action = gs_plugin_job_get_action (helper->plugin_job);
+ gboolean ret = TRUE;
+ gpointer func = NULL;
+ g_autoptr(GError) error_local = NULL;
+ g_autoptr(GTimer) timer = g_timer_new ();
+#ifdef HAVE_SYSPROF
+ gint64 begin_time_nsec = SYSPROF_CAPTURE_CURRENT_TIME;
+#endif
+
+ /* load the possible symbol */
+ func = gs_plugin_get_symbol (plugin, helper->function_name);
+ if (func == NULL)
+ return TRUE;
+
+ /* at least one plugin supports this vfunc */
+ helper->anything_ran = TRUE;
+
+ /* fallback if unset */
+ if (app == NULL)
+ app = gs_plugin_job_get_app (helper->plugin_job);
+ if (list == NULL)
+ list = gs_plugin_job_get_list (helper->plugin_job);
+ if (refine_flags == GS_PLUGIN_REFINE_FLAGS_NONE)
+ refine_flags = gs_plugin_job_get_refine_flags (helper->plugin_job);
+
+ /* set what plugin is running on the job */
+ gs_plugin_job_set_plugin (helper->plugin_job, plugin);
+
+ /* run the correct vfunc */
+ if (gs_plugin_job_get_interactive (helper->plugin_job))
+ gs_plugin_interactive_inc (plugin);
+ switch (action) {
+ case GS_PLUGIN_ACTION_UPDATE:
+ if (g_strcmp0 (helper->function_name, "gs_plugin_update_app") == 0) {
+ GsPluginActionFunc plugin_func = func;
+ ret = plugin_func (plugin, app, cancellable, &error_local);
+ } else if (g_strcmp0 (helper->function_name, "gs_plugin_update") == 0) {
+ GsPluginUpdateFunc plugin_func = func;
+ ret = plugin_func (plugin, list, cancellable, &error_local);
+ } else {
+ g_critical ("function_name %s invalid for %s",
+ helper->function_name,
+ gs_plugin_action_to_string (action));
+ }
+ break;
+ case GS_PLUGIN_ACTION_DOWNLOAD:
+ if (g_strcmp0 (helper->function_name, "gs_plugin_download_app") == 0) {
+ GsPluginActionFunc plugin_func = func;
+ ret = plugin_func (plugin, app, cancellable, &error_local);
+ } else if (g_strcmp0 (helper->function_name, "gs_plugin_download") == 0) {
+ GsPluginUpdateFunc plugin_func = func;
+ ret = plugin_func (plugin, list, cancellable, &error_local);
+ } else {
+ g_critical ("function_name %s invalid for %s",
+ helper->function_name,
+ gs_plugin_action_to_string (action));
+ }
+ break;
+ case GS_PLUGIN_ACTION_INSTALL:
+ case GS_PLUGIN_ACTION_REMOVE:
+ case GS_PLUGIN_ACTION_UPGRADE_DOWNLOAD:
+ case GS_PLUGIN_ACTION_UPGRADE_TRIGGER:
+ case GS_PLUGIN_ACTION_LAUNCH:
+ case GS_PLUGIN_ACTION_UPDATE_CANCEL:
+ {
+ GsPluginActionFunc plugin_func = func;
+ ret = plugin_func (plugin, app, cancellable, &error_local);
+ }
+ break;
+ case GS_PLUGIN_ACTION_GET_UPDATES:
+ case GS_PLUGIN_ACTION_GET_UPDATES_HISTORICAL:
+ case GS_PLUGIN_ACTION_GET_SOURCES:
+ {
+ GsPluginResultsFunc plugin_func = func;
+ ret = plugin_func (plugin, list, cancellable, &error_local);
+ }
+ break;
+ case GS_PLUGIN_ACTION_FILE_TO_APP:
+ {
+ GsPluginFileToAppFunc plugin_func = func;
+ ret = plugin_func (plugin, list,
+ gs_plugin_job_get_file (helper->plugin_job),
+ cancellable, &error_local);
+ }
+ break;
+ case GS_PLUGIN_ACTION_URL_TO_APP:
+ {
+ GsPluginUrlToAppFunc plugin_func = func;
+ ret = plugin_func (plugin, list,
+ gs_plugin_job_get_search (helper->plugin_job),
+ cancellable, &error_local);
+ }
+ break;
+ case GS_PLUGIN_ACTION_GET_LANGPACKS:
+ {
+ GsPluginGetLangPacksFunc plugin_func = func;
+ ret = plugin_func (plugin, list,
+ gs_plugin_job_get_search (helper->plugin_job),
+ cancellable, &error_local);
+ }
+ break;
+ default:
+ g_critical ("no handler for %s", helper->function_name);
+ break;
+ }
+ if (gs_plugin_job_get_interactive (helper->plugin_job))
+ gs_plugin_interactive_dec (plugin);
+
+ /* plugin did not return error on cancellable abort */
+ if (ret && g_cancellable_set_error_if_cancelled (cancellable, &error_local)) {
+ g_debug ("plugin %s did not return error with cancellable set",
+ gs_plugin_get_name (plugin));
+ gs_utils_error_convert_gio (&error_local);
+ ret = FALSE;
+ }
+
+ /* failed */
+ if (!ret) {
+ return gs_plugin_error_handle_failure (helper,
+ plugin,
+ error_local,
+ error);
+ }
+
+ /* add app to the pending installation queue if necessary */
+ if (action == GS_PLUGIN_ACTION_INSTALL &&
+ app != NULL && gs_app_get_state (app) == GS_APP_STATE_QUEUED_FOR_INSTALL) {
+ add_app_to_install_queue (plugin_loader, app);
+ }
+
+#ifdef HAVE_SYSPROF
+ if (plugin_loader->sysprof_writer != NULL) {
+ g_autofree gchar *sysprof_name = NULL;
+ g_autofree gchar *sysprof_message = NULL;
+
+ sysprof_name = g_strconcat ("vfunc:", gs_plugin_action_to_string (action), NULL);
+ sysprof_message = gs_plugin_job_to_string (helper->plugin_job);
+ sysprof_capture_writer_add_mark (plugin_loader->sysprof_writer,
+ begin_time_nsec,
+ sched_getcpu (),
+ getpid (),
+ SYSPROF_CAPTURE_CURRENT_TIME - begin_time_nsec,
+ "gnome-software",
+ sysprof_name,
+ sysprof_message);
+ }
+#endif /* HAVE_SYSPROF */
+
+ /* check the plugin didn't take too long */
+ if (g_timer_elapsed (timer, NULL) > 1.0f) {
+ g_log_structured_standard (G_LOG_DOMAIN, G_LOG_LEVEL_DEBUG,
+ __FILE__, G_STRINGIFY (__LINE__),
+ G_STRFUNC,
+ "plugin %s took %.1f seconds to do %s",
+ gs_plugin_get_name (plugin),
+ g_timer_elapsed (timer, NULL),
+ gs_plugin_action_to_string (action));
+ }
+
+ return TRUE;
+}
+
+static void
+gs_plugin_loader_job_sorted_truncation_again (GsPluginJob *plugin_job,
+ GsAppList *list)
+{
+ GsAppListSortFunc sort_func;
+ gpointer sort_func_data;
+
+ /* not valid */
+ if (list == NULL)
+ return;
+
+ /* unset */
+ sort_func = gs_plugin_job_get_sort_func (plugin_job, &sort_func_data);
+ if (sort_func == NULL)
+ return;
+ gs_app_list_sort (list, sort_func, sort_func_data);
+}
+
+static void
+gs_plugin_loader_job_sorted_truncation (GsPluginJob *plugin_job,
+ GsAppList *list)
+{
+ GsAppListSortFunc sort_func;
+ gpointer sort_func_data;
+ guint max_results;
+
+ /* not valid */
+ if (list == NULL)
+ return;
+
+ /* unset */
+ max_results = gs_plugin_job_get_max_results (plugin_job);
+ if (max_results == 0)
+ return;
+
+ /* already small enough */
+ if (gs_app_list_length (list) <= max_results)
+ return;
+
+ /* nothing set */
+ g_debug ("truncating results to %u from %u",
+ max_results, gs_app_list_length (list));
+ sort_func = gs_plugin_job_get_sort_func (plugin_job, &sort_func_data);
+ if (sort_func == NULL) {
+ GsPluginAction action = gs_plugin_job_get_action (plugin_job);
+ g_debug ("no ->sort_func() set for %s, using random!",
+ gs_plugin_action_to_string (action));
+ gs_app_list_randomize (list);
+ } else {
+ gs_app_list_sort (list, sort_func, sort_func_data);
+ }
+ gs_app_list_truncate (list, max_results);
+}
+
+static gboolean
+gs_plugin_loader_run_results (GsPluginLoaderHelper *helper,
+ GCancellable *cancellable,
+ GError **error)
+{
+ GsPluginLoader *plugin_loader = helper->plugin_loader;
+#ifdef HAVE_SYSPROF
+ gint64 begin_time_nsec G_GNUC_UNUSED = SYSPROF_CAPTURE_CURRENT_TIME;
+#endif
+
+ /* Refining is done separately as it’s a special action */
+ g_assert (!GS_IS_PLUGIN_JOB_REFINE (helper->plugin_job));
+
+ /* run each plugin */
+ for (guint i = 0; i < plugin_loader->plugins->len; i++) {
+ GsPlugin *plugin = g_ptr_array_index (plugin_loader->plugins, i);
+ if (g_cancellable_set_error_if_cancelled (cancellable, error)) {
+ gs_utils_error_convert_gio (error);
+ return FALSE;
+ }
+ if (!gs_plugin_loader_call_vfunc (helper, plugin, NULL, NULL,
+ GS_PLUGIN_REFINE_FLAGS_NONE,
+ cancellable, error)) {
+ return FALSE;
+ }
+ gs_plugin_status_update (plugin, NULL, GS_PLUGIN_STATUS_FINISHED);
+ }
+
+#ifdef HAVE_SYSPROF
+ if (plugin_loader->sysprof_writer != NULL) {
+ g_autofree gchar *sysprof_name = NULL;
+ g_autofree gchar *sysprof_message = NULL;
+
+ sysprof_name = g_strconcat ("run-results:",
+ gs_plugin_action_to_string (gs_plugin_job_get_action (helper->plugin_job)),
+ NULL);
+ sysprof_message = gs_plugin_job_to_string (helper->plugin_job);
+ sysprof_capture_writer_add_mark (plugin_loader->sysprof_writer,
+ begin_time_nsec,
+ sched_getcpu (),
+ getpid (),
+ SYSPROF_CAPTURE_CURRENT_TIME - begin_time_nsec,
+ "gnome-software",
+ sysprof_name,
+ sysprof_message);
+ }
+#endif /* HAVE_SYSPROF */
+
+ return TRUE;
+}
+
+static const gchar *
+gs_plugin_loader_get_app_str (GsApp *app)
+{
+ const gchar *id;
+
+ /* first try the actual id */
+ id = gs_app_get_unique_id (app);
+ if (id != NULL)
+ return id;
+
+ /* then try the source */
+ id = gs_app_get_source_default (app);
+ if (id != NULL)
+ return id;
+
+ /* lastly try the source id */
+ id = gs_app_get_source_id_default (app);
+ if (id != NULL)
+ return id;
+
+ /* urmmm */
+ return "<invalid>";
+}
+
+gboolean
+gs_plugin_loader_app_is_valid (GsApp *app,
+ GsPluginRefineFlags refine_flags)
+{
+ /* never show addons */
+ if (gs_app_get_kind (app) == AS_COMPONENT_KIND_ADDON) {
+ g_debug ("app invalid as addon %s",
+ gs_plugin_loader_get_app_str (app));
+ return FALSE;
+ }
+
+ /* never show CLI apps */
+ if (gs_app_get_kind (app) == AS_COMPONENT_KIND_CONSOLE_APP) {
+ g_debug ("app invalid as console %s",
+ gs_plugin_loader_get_app_str (app));
+ return FALSE;
+ }
+
+ /* don't show unknown state */
+ if (gs_app_get_state (app) == GS_APP_STATE_UNKNOWN) {
+ g_debug ("app invalid as state unknown %s",
+ gs_plugin_loader_get_app_str (app));
+ return FALSE;
+ }
+
+ /* don't show unconverted unavailables */
+ if (gs_app_get_kind (app) == AS_COMPONENT_KIND_UNKNOWN &&
+ gs_app_get_state (app) == GS_APP_STATE_UNAVAILABLE) {
+ g_debug ("app invalid as unconverted unavailable %s",
+ gs_plugin_loader_get_app_str (app));
+ return FALSE;
+ }
+
+ /* don't show blocklisted apps */
+ if (gs_app_has_quirk (app, GS_APP_QUIRK_HIDE_EVERYWHERE)) {
+ g_debug ("app invalid as blocklisted %s",
+ gs_plugin_loader_get_app_str (app));
+ return FALSE;
+ }
+
+ /* Don’t show parentally filtered apps unless they’re already
+ * installed. See the comments in gs-details-page.c for details. */
+ if (!gs_app_is_installed (app) &&
+ gs_app_has_quirk (app, GS_APP_QUIRK_PARENTAL_FILTER)) {
+ g_debug ("app invalid as parentally filtered %s",
+ gs_plugin_loader_get_app_str (app));
+ return FALSE;
+ }
+
+ /* don't show apps with hide-from-search quirk, unless they are already installed */
+ if (!gs_app_is_installed (app) &&
+ gs_app_has_quirk (app, GS_APP_QUIRK_HIDE_FROM_SEARCH)) {
+ g_debug ("app invalid as hide-from-search quirk set %s",
+ gs_plugin_loader_get_app_str (app));
+ return FALSE;
+ }
+
+ /* don't show sources */
+ if (gs_app_get_kind (app) == AS_COMPONENT_KIND_REPOSITORY) {
+ g_debug ("app invalid as source %s",
+ gs_plugin_loader_get_app_str (app));
+ return FALSE;
+ }
+
+ /* don't show unknown kind */
+ if (gs_app_get_kind (app) == AS_COMPONENT_KIND_UNKNOWN) {
+ g_debug ("app invalid as kind unknown %s",
+ gs_plugin_loader_get_app_str (app));
+ return FALSE;
+ }
+
+ /* don't show unconverted packages in the application view */
+ if (!(refine_flags & GS_PLUGIN_REFINE_FLAGS_ALLOW_PACKAGES) &&
+ gs_app_get_kind (app) == AS_COMPONENT_KIND_GENERIC &&
+ gs_app_get_special_kind (app) == GS_APP_SPECIAL_KIND_NONE) {
+ g_debug ("app invalid as only a %s: %s",
+ as_component_kind_to_string (gs_app_get_kind (app)),
+ gs_plugin_loader_get_app_str (app));
+ return FALSE;
+ }
+
+ /* don't show apps that do not have the required details */
+ if (gs_app_get_name (app) == NULL) {
+ g_debug ("app invalid as no name %s",
+ gs_plugin_loader_get_app_str (app));
+ return FALSE;
+ }
+ if (gs_app_get_summary (app) == NULL) {
+ g_debug ("app invalid as no summary %s",
+ gs_plugin_loader_get_app_str (app));
+ return FALSE;
+ }
+
+ /* ignore this crazy application */
+ if (g_strcmp0 (gs_app_get_id (app), "gnome-system-monitor-kde.desktop") == 0) {
+ g_debug ("Ignoring KDE version of %s", gs_app_get_id (app));
+ return FALSE;
+ }
+ return TRUE;
+}
+
+static gboolean
+gs_plugin_loader_app_is_valid_filter (GsApp *app,
+ gpointer user_data)
+{
+ GsPluginLoaderHelper *helper = (GsPluginLoaderHelper *) user_data;
+
+ return gs_plugin_loader_app_is_valid (app, gs_plugin_job_get_refine_flags (helper->plugin_job));
+}
+
+static gboolean
+gs_plugin_loader_app_is_valid_updatable (GsApp *app, gpointer user_data)
+{
+ return gs_plugin_loader_app_is_valid_filter (app, user_data) &&
+ (gs_app_is_updatable (app) || gs_app_get_state (app) == GS_APP_STATE_INSTALLING);
+}
+
+gboolean
+gs_plugin_loader_app_is_compatible (GsPluginLoader *plugin_loader,
+ GsApp *app)
+{
+ const gchar *tmp;
+ guint i;
+
+ /* search for any compatible projects */
+ tmp = gs_app_get_project_group (app);
+ if (tmp == NULL)
+ return TRUE;
+ for (i = 0; plugin_loader->compatible_projects[i] != NULL; i++) {
+ if (g_strcmp0 (tmp, plugin_loader->compatible_projects[i]) == 0)
+ return TRUE;
+ }
+ g_debug ("removing incompatible %s from project group %s",
+ gs_app_get_id (app), gs_app_get_project_group (app));
+ return FALSE;
+}
+
+/******************************************************************************/
+
+/**
+ * gs_plugin_loader_job_process_finish:
+ * @plugin_loader: A #GsPluginLoader
+ * @res: a #GAsyncResult
+ * @error: A #GError, or %NULL
+ *
+ * Return value: (element-type GsApp) (transfer full): A list of applications
+ **/
+GsAppList *
+gs_plugin_loader_job_process_finish (GsPluginLoader *plugin_loader,
+ GAsyncResult *res,
+ GError **error)
+{
+ GTask *task;
+ GsAppList *list = NULL;
+
+ g_return_val_if_fail (GS_IS_PLUGIN_LOADER (plugin_loader), NULL);
+ g_return_val_if_fail (G_IS_TASK (res), NULL);
+ g_return_val_if_fail (g_task_is_valid (res, plugin_loader), NULL);
+ g_return_val_if_fail (error == NULL || *error == NULL, NULL);
+
+ task = G_TASK (res);
+
+ /* Return cancelled if the task was cancelled and there is no other error set.
+ *
+ * This is needed because we set the task `check_cancellable` to FALSE,
+ * to be able to catch other errors such as timeout, but that means
+ * g_task_propagate_pointer() will ignore if the task was cancelled and only
+ * check if there was an error (i.e. g_task_return_*error*).
+ *
+ * We only do this if there is no error already set in the task (e.g.
+ * timeout) because in that case we want to return the existing error.
+ */
+ if (!g_task_had_error (task)) {
+ GCancellable *cancellable = g_task_get_cancellable (task);
+
+ if (g_cancellable_set_error_if_cancelled (cancellable, error)) {
+ gs_utils_error_convert_gio (error);
+ return NULL;
+ }
+ }
+ list = g_task_propagate_pointer (task, error);
+ gs_utils_error_convert_gio (error);
+ return list;
+}
+
+/**
+ * gs_plugin_loader_job_action_finish:
+ * @plugin_loader: A #GsPluginLoader
+ * @res: a #GAsyncResult
+ * @error: A #GError, or %NULL
+ *
+ * Return value: success
+ **/
+gboolean
+gs_plugin_loader_job_action_finish (GsPluginLoader *plugin_loader,
+ GAsyncResult *res,
+ GError **error)
+{
+ g_autoptr(GsAppList) list = NULL;
+
+ g_return_val_if_fail (GS_IS_PLUGIN_LOADER (plugin_loader), FALSE);
+ g_return_val_if_fail (G_IS_TASK (res), FALSE);
+ g_return_val_if_fail (g_task_is_valid (res, plugin_loader), FALSE);
+ g_return_val_if_fail (error == NULL || *error == NULL, FALSE);
+
+ list = g_task_propagate_pointer (G_TASK (res), error);
+ return list != NULL;
+}
+
+/******************************************************************************/
+
+static gboolean
+emit_pending_apps_idle (gpointer loader)
+{
+ g_signal_emit (loader, signals[SIGNAL_PENDING_APPS_CHANGED], 0);
+ g_object_unref (loader);
+
+ return G_SOURCE_REMOVE;
+}
+
+static void
+gs_plugin_loader_pending_apps_add (GsPluginLoader *plugin_loader,
+ GsPluginLoaderHelper *helper)
+{
+ GsAppList *list = gs_plugin_job_get_list (helper->plugin_job);
+
+ g_assert (gs_app_list_length (list) > 0);
+ for (guint i = 0; i < gs_app_list_length (list); i++) {
+ GsApp *app = gs_app_list_index (list, i);
+ switch (gs_plugin_job_get_action (helper->plugin_job)) {
+ case GS_PLUGIN_ACTION_INSTALL:
+ if (gs_app_get_state (app) != GS_APP_STATE_AVAILABLE_LOCAL)
+ add_app_to_install_queue (plugin_loader, app);
+ /* make sure the progress is properly initialized */
+ gs_app_set_progress (app, GS_APP_PROGRESS_UNKNOWN);
+ break;
+ case GS_PLUGIN_ACTION_REMOVE:
+ remove_app_from_install_queue (plugin_loader, app);
+ break;
+ default:
+ break;
+ }
+ }
+ g_idle_add (emit_pending_apps_idle, g_object_ref (plugin_loader));
+}
+
+static void
+gs_plugin_loader_pending_apps_remove (GsPluginLoader *plugin_loader,
+ GsPluginLoaderHelper *helper)
+{
+ GsAppList *list = gs_plugin_job_get_list (helper->plugin_job);
+
+ g_assert (gs_app_list_length (list) > 0);
+ for (guint i = 0; i < gs_app_list_length (list); i++) {
+ GsApp *app = gs_app_list_index (list, i);
+ remove_app_from_install_queue (plugin_loader, app);
+
+ /* check the app is not still in an action helper */
+ switch (gs_app_get_state (app)) {
+ case GS_APP_STATE_INSTALLING:
+ case GS_APP_STATE_REMOVING:
+ g_warning ("application %s left in %s helper",
+ gs_app_get_unique_id (app),
+ gs_app_state_to_string (gs_app_get_state (app)));
+ gs_app_set_state (app, GS_APP_STATE_UNKNOWN);
+ break;
+ default:
+ break;
+ }
+
+ }
+ g_idle_add (emit_pending_apps_idle, g_object_ref (plugin_loader));
+}
+
+static void
+async_result_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data)
+{
+ GAsyncResult **result_out = user_data;
+
+ *result_out = g_object_ref (result);
+ g_main_context_wakeup (g_main_context_get_thread_default ());
+}
+
+/* This will load the install queue and add it to #GsPluginLoader.pending_apps,
+ * but it won’t refine the loaded apps. */
+static GsAppList *
+load_install_queue (GsPluginLoader *plugin_loader,
+ GError **error)
+{
+ g_autofree gchar *contents = NULL;
+ g_autofree gchar *file = NULL;
+ g_auto(GStrv) names = NULL;
+ g_autoptr(GsAppList) list = NULL;
+
+ /* load from file */
+ file = g_build_filename (g_get_user_data_dir (),
+ "gnome-software",
+ "install-queue",
+ NULL);
+ if (!g_file_test (file, G_FILE_TEST_EXISTS))
+ return gs_app_list_new ();
+
+ g_debug ("loading install queue from %s", file);
+ if (!g_file_get_contents (file, &contents, NULL, error))
+ return NULL;
+
+ /* add to GsAppList, deduplicating if required */
+ list = gs_app_list_new ();
+ names = g_strsplit (contents, "\n", 0);
+ for (guint i = 0; names[i] != NULL; i++) {
+ g_autoptr(GsApp) app = NULL;
+ g_auto(GStrv) split = g_strsplit (names[i], "\t", -1);
+ if (split[0] == NULL || split[1] == NULL)
+ continue;
+ app = gs_app_new (NULL);
+ gs_app_set_from_unique_id (app, split[0], as_component_kind_from_string (split[1]));
+ gs_app_set_state (app, GS_APP_STATE_QUEUED_FOR_INSTALL);
+ gs_app_add_quirk (app, GS_APP_QUIRK_IS_WILDCARD);
+ gs_app_list_add (list, app);
+ }
+
+ /* add to pending list */
+ g_mutex_lock (&plugin_loader->pending_apps_mutex);
+ for (guint i = 0; i < gs_app_list_length (list); i++) {
+ GsApp *app = gs_app_list_index (list, i);
+ g_debug ("adding pending app %s", gs_app_get_unique_id (app));
+ if (plugin_loader->pending_apps == NULL)
+ plugin_loader->pending_apps = gs_app_list_new ();
+ gs_app_list_add (plugin_loader->pending_apps, app);
+ }
+ g_mutex_unlock (&plugin_loader->pending_apps_mutex);
+
+ return g_steal_pointer (&list);
+}
+
+static void
+save_install_queue (GsPluginLoader *plugin_loader)
+{
+ gboolean ret;
+ g_autoptr(GError) error = NULL;
+ g_autoptr(GString) s = NULL;
+ g_autofree gchar *file = NULL;
+
+ s = g_string_new ("");
+ g_mutex_lock (&plugin_loader->pending_apps_mutex);
+ for (guint i = 0; plugin_loader->pending_apps != NULL && i < gs_app_list_length (plugin_loader->pending_apps); i++) {
+ GsApp *app = gs_app_list_index (plugin_loader->pending_apps, i);
+ if (gs_app_get_state (app) == GS_APP_STATE_QUEUED_FOR_INSTALL) {
+ g_string_append (s, gs_app_get_unique_id (app));
+ g_string_append_c (s, '\t');
+ g_string_append (s, as_component_kind_to_string (gs_app_get_kind (app)));
+ g_string_append_c (s, '\n');
+ }
+ }
+ g_mutex_unlock (&plugin_loader->pending_apps_mutex);
+
+ /* save file */
+ file = g_build_filename (g_get_user_data_dir (),
+ "gnome-software",
+ "install-queue",
+ NULL);
+ if (s->len == 0) {
+ if (g_unlink (file) == -1 && errno != ENOENT) {
+ gint errn = errno;
+ g_warning ("Failed to unlink '%s': %s", file, g_strerror (errn));
+ }
+ return;
+ }
+
+ if (!gs_mkdir_parent (file, &error)) {
+ g_warning ("failed to create dir for %s: %s",
+ file, error->message);
+ return;
+ }
+ g_debug ("saving install queue to %s", file);
+ ret = g_file_set_contents (file, s->str, (gssize) s->len, &error);
+ if (!ret)
+ g_warning ("failed to save install queue: %s", error->message);
+}
+
+static void
+add_app_to_install_queue (GsPluginLoader *plugin_loader, GsApp *app)
+{
+ g_autoptr(GsAppList) addons = NULL;
+ g_autoptr(GSource) source = NULL;
+ guint i;
+
+ /* queue the app itself */
+ g_mutex_lock (&plugin_loader->pending_apps_mutex);
+ if (plugin_loader->pending_apps == NULL)
+ plugin_loader->pending_apps = gs_app_list_new ();
+ gs_app_list_add (plugin_loader->pending_apps, app);
+ g_mutex_unlock (&plugin_loader->pending_apps_mutex);
+
+ gs_app_set_state (app, GS_APP_STATE_QUEUED_FOR_INSTALL);
+
+ source = g_idle_source_new ();
+ g_source_set_callback (source, emit_pending_apps_idle, g_object_ref (plugin_loader), NULL);
+ g_source_set_name (source, "[gnome-software] emit_pending_apps_idle");
+ g_source_attach (source, NULL);
+
+ save_install_queue (plugin_loader);
+
+ /* recursively queue any addons */
+ addons = gs_app_dup_addons (app);
+ for (i = 0; addons != NULL && i < gs_app_list_length (addons); i++) {
+ GsApp *addon = gs_app_list_index (addons, i);
+ if (gs_app_get_to_be_installed (addon))
+ add_app_to_install_queue (plugin_loader, addon);
+ }
+}
+
+static gboolean
+remove_app_from_install_queue (GsPluginLoader *plugin_loader, GsApp *app)
+{
+ g_autoptr(GsAppList) addons = NULL;
+ gboolean ret;
+ guint i;
+
+ g_mutex_lock (&plugin_loader->pending_apps_mutex);
+ ret = plugin_loader->pending_apps != NULL && gs_app_list_remove (plugin_loader->pending_apps, app);
+ g_mutex_unlock (&plugin_loader->pending_apps_mutex);
+
+ if (ret) {
+ g_autoptr(GSource) source = NULL;
+
+ if (gs_app_get_state (app) == GS_APP_STATE_QUEUED_FOR_INSTALL)
+ gs_app_set_state (app, GS_APP_STATE_UNKNOWN);
+
+ source = g_idle_source_new ();
+ g_source_set_callback (source, emit_pending_apps_idle, g_object_ref (plugin_loader), NULL);
+ g_source_set_name (source, "[gnome-software] emit_pending_apps_idle");
+ g_source_attach (source, NULL);
+
+ save_install_queue (plugin_loader);
+
+ /* recursively remove any queued addons */
+ addons = gs_app_dup_addons (app);
+ for (i = 0; addons != NULL && i < gs_app_list_length (addons); i++) {
+ GsApp *addon = gs_app_list_index (addons, i);
+ remove_app_from_install_queue (plugin_loader, addon);
+ }
+ }
+
+ return ret;
+}
+
+/******************************************************************************/
+
+gboolean
+gs_plugin_loader_get_allow_updates (GsPluginLoader *plugin_loader)
+{
+ GHashTableIter iter;
+ gpointer value;
+
+ /* nothing */
+ if (g_hash_table_size (plugin_loader->disallow_updates) == 0)
+ return TRUE;
+
+ /* list */
+ g_hash_table_iter_init (&iter, plugin_loader->disallow_updates);
+ while (g_hash_table_iter_next (&iter, NULL, &value)) {
+ const gchar *reason = value;
+ g_debug ("managed updates inhibited by %s", reason);
+ }
+ return FALSE;
+}
+
+GsAppList *
+gs_plugin_loader_get_pending (GsPluginLoader *plugin_loader)
+{
+ GsAppList *array;
+
+ array = gs_app_list_new ();
+ g_mutex_lock (&plugin_loader->pending_apps_mutex);
+ if (plugin_loader->pending_apps != NULL)
+ gs_app_list_add_list (array, plugin_loader->pending_apps);
+ g_mutex_unlock (&plugin_loader->pending_apps_mutex);
+
+ return array;
+}
+
+gboolean
+gs_plugin_loader_get_enabled (GsPluginLoader *plugin_loader,
+ const gchar *plugin_name)
+{
+ GsPlugin *plugin;
+ plugin = gs_plugin_loader_find_plugin (plugin_loader, plugin_name);
+ if (plugin == NULL)
+ return FALSE;
+ return gs_plugin_get_enabled (plugin);
+}
+
+/**
+ * gs_plugin_loader_get_events:
+ * @plugin_loader: A #GsPluginLoader
+ *
+ * Gets all plugin events, even ones that are not active or visible anymore.
+ *
+ * Returns: (transfer container) (element-type GsPluginEvent): events
+ **/
+GPtrArray *
+gs_plugin_loader_get_events (GsPluginLoader *plugin_loader)
+{
+ GPtrArray *events = g_ptr_array_new_with_free_func ((GDestroyNotify) g_object_unref);
+ g_autoptr(GMutexLocker) locker = g_mutex_locker_new (&plugin_loader->events_by_id_mutex);
+ GHashTableIter iter;
+ gpointer key, value;
+
+ /* just add everything */
+ g_hash_table_iter_init (&iter, plugin_loader->events_by_id);
+ while (g_hash_table_iter_next (&iter, &key, &value)) {
+ const gchar *id = key;
+ GsPluginEvent *event = value;
+ if (event == NULL) {
+ g_warning ("failed to get event for '%s'", id);
+ continue;
+ }
+ g_ptr_array_add (events, g_object_ref (event));
+ }
+ return events;
+}
+
+/**
+ * gs_plugin_loader_get_event_default:
+ * @plugin_loader: A #GsPluginLoader
+ *
+ * Gets an active plugin event where active means that it was not been
+ * already dismissed by the user.
+ *
+ * Returns: (transfer full): a #GsPluginEvent, or %NULL for none
+ **/
+GsPluginEvent *
+gs_plugin_loader_get_event_default (GsPluginLoader *plugin_loader)
+{
+ g_autoptr(GMutexLocker) locker = g_mutex_locker_new (&plugin_loader->events_by_id_mutex);
+ GHashTableIter iter;
+ gpointer key, value;
+
+ /* just add everything */
+ g_hash_table_iter_init (&iter, plugin_loader->events_by_id);
+ while (g_hash_table_iter_next (&iter, &key, &value)) {
+ const gchar *id = key;
+ GsPluginEvent *event = value;
+ if (event == NULL) {
+ g_warning ("failed to get event for '%s'", id);
+ continue;
+ }
+ if (!gs_plugin_event_has_flag (event, GS_PLUGIN_EVENT_FLAG_INVALID))
+ return g_object_ref (event);
+ }
+ return NULL;
+}
+
+/**
+ * gs_plugin_loader_remove_events:
+ * @plugin_loader: A #GsPluginLoader
+ *
+ * Removes all plugin events from the loader. This function should only be
+ * called from the self tests.
+ **/
+void
+gs_plugin_loader_remove_events (GsPluginLoader *plugin_loader)
+{
+ g_autoptr(GMutexLocker) locker = g_mutex_locker_new (&plugin_loader->events_by_id_mutex);
+ g_hash_table_remove_all (plugin_loader->events_by_id);
+}
+
+static void
+gs_plugin_loader_report_event_cb (GsPlugin *plugin,
+ GsPluginEvent *event,
+ GsPluginLoader *plugin_loader)
+{
+ if (gs_plugin_has_flags (plugin, GS_PLUGIN_FLAGS_INTERACTIVE))
+ gs_plugin_event_add_flag (event, GS_PLUGIN_EVENT_FLAG_INTERACTIVE);
+ gs_plugin_loader_add_event (plugin_loader, event);
+}
+
+static void
+gs_plugin_loader_allow_updates_cb (GsPlugin *plugin,
+ gboolean allow_updates,
+ GsPluginLoader *plugin_loader)
+{
+ gboolean changed = FALSE;
+
+ /* plugin now allowing gnome-software to show updates panel */
+ if (allow_updates) {
+ if (g_hash_table_remove (plugin_loader->disallow_updates, plugin)) {
+ g_debug ("plugin %s no longer inhibited managed updates",
+ gs_plugin_get_name (plugin));
+ changed = TRUE;
+ }
+
+ /* plugin preventing the updates panel from being shown */
+ } else {
+ if (g_hash_table_replace (plugin_loader->disallow_updates,
+ (gpointer) plugin,
+ (gpointer) gs_plugin_get_name (plugin))) {
+ g_debug ("plugin %s inhibited managed updates",
+ gs_plugin_get_name (plugin));
+ changed = TRUE;
+ }
+ }
+
+ /* notify display layer */
+ if (changed)
+ g_object_notify_by_pspec (G_OBJECT (plugin_loader), obj_props[PROP_ALLOW_UPDATES]);
+}
+
+static void
+gs_plugin_loader_status_changed_cb (GsPlugin *plugin,
+ GsApp *app,
+ GsPluginStatus status,
+ GsPluginLoader *plugin_loader)
+{
+ /* nothing specific */
+ if (app == NULL || gs_app_get_id (app) == NULL) {
+ if (plugin_loader->global_status_last != status) {
+ g_debug ("emitting global %s",
+ gs_plugin_status_to_string (status));
+ g_signal_emit (plugin_loader,
+ signals[SIGNAL_STATUS_CHANGED],
+ 0, app, status);
+ plugin_loader->global_status_last = status;
+ }
+ return;
+ }
+
+ /* a specific app */
+ g_debug ("emitting %s(%s)",
+ gs_plugin_status_to_string (status),
+ gs_app_get_id (app));
+ g_signal_emit (plugin_loader,
+ signals[SIGNAL_STATUS_CHANGED],
+ 0, app, status);
+}
+
+static void
+gs_plugin_loader_basic_auth_start_cb (GsPlugin *plugin,
+ const gchar *remote,
+ const gchar *realm,
+ GCallback callback,
+ gpointer user_data,
+ GsPluginLoader *plugin_loader)
+{
+ g_debug ("emitting basic-auth-start %s", realm);
+ g_signal_emit (plugin_loader,
+ signals[SIGNAL_BASIC_AUTH_START], 0,
+ remote,
+ realm,
+ callback,
+ user_data);
+}
+
+static gboolean
+gs_plugin_loader_ask_untrusted_cb (GsPlugin *plugin,
+ const gchar *title,
+ const gchar *msg,
+ const gchar *details,
+ const gchar *accept_label,
+ GsPluginLoader *plugin_loader)
+{
+ gboolean accepts = FALSE;
+ g_debug ("emitting ask-untrusted title:'%s', msg:'%s' details:'%s' accept-label:'%s'", title, msg, details, accept_label);
+ g_signal_emit (plugin_loader,
+ signals[SIGNAL_ASK_UNTRUSTED], 0,
+ title, msg, details, accept_label, &accepts);
+ return accepts;
+}
+
+static gboolean
+gs_plugin_loader_job_updates_changed_delay_cb (gpointer user_data)
+{
+ GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (user_data);
+
+ /* notify shells */
+ g_debug ("updates-changed");
+ g_signal_emit (plugin_loader, signals[SIGNAL_UPDATES_CHANGED], 0);
+ plugin_loader->updates_changed_id = 0;
+ plugin_loader->updates_changed_cnt = 0;
+
+ return FALSE;
+}
+
+static void
+gs_plugin_loader_updates_changed (GsPluginLoader *plugin_loader)
+{
+ if (plugin_loader->updates_changed_id != 0)
+ return;
+ plugin_loader->updates_changed_id =
+ g_timeout_add_seconds_full (G_PRIORITY_DEFAULT,
+ GS_PLUGIN_LOADER_UPDATES_CHANGED_DELAY,
+ gs_plugin_loader_job_updates_changed_delay_cb,
+ g_object_ref (plugin_loader),
+ g_object_unref);
+}
+
+static void
+gs_plugin_loader_job_updates_changed_cb (GsPlugin *plugin,
+ GsPluginLoader *plugin_loader)
+{
+ plugin_loader->updates_changed_cnt++;
+
+ /* Schedule emit of updates changed when no job is active.
+ This helps to avoid a race condition when a plugin calls
+ updates-changed at the end of the job, but the job is
+ finished before the callback gets called in the main thread. */
+ if (!g_atomic_int_get (&plugin_loader->active_jobs))
+ gs_plugin_loader_updates_changed (plugin_loader);
+}
+
+static gboolean
+gs_plugin_loader_reload_delay_cb (gpointer user_data)
+{
+ GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (user_data);
+
+ /* notify shells */
+ g_debug ("emitting ::reload");
+ g_signal_emit (plugin_loader, signals[SIGNAL_RELOAD], 0);
+ plugin_loader->reload_id = 0;
+
+ g_object_unref (plugin_loader);
+ return FALSE;
+}
+
+static void
+gs_plugin_loader_reload_cb (GsPlugin *plugin,
+ GsPluginLoader *plugin_loader)
+{
+ if (plugin_loader->reload_id != 0)
+ return;
+ plugin_loader->reload_id =
+ g_timeout_add_seconds (GS_PLUGIN_LOADER_RELOAD_DELAY,
+ gs_plugin_loader_reload_delay_cb,
+ g_object_ref (plugin_loader));
+}
+
+static void
+gs_plugin_loader_repository_changed_cb (GsPlugin *plugin,
+ GsApp *repository,
+ GsPluginLoader *plugin_loader)
+{
+ GApplication *application = g_application_get_default ();
+
+ /* Can be NULL when running the self tests */
+ if (application) {
+ g_signal_emit_by_name (application,
+ "repository-changed",
+ repository);
+ }
+}
+
+static void
+gs_plugin_loader_open_plugin (GsPluginLoader *plugin_loader,
+ const gchar *filename)
+{
+ GsPlugin *plugin;
+ g_autoptr(GError) error = NULL;
+
+ /* create plugin from file */
+ plugin = gs_plugin_create (filename,
+ plugin_loader->session_bus_connection,
+ plugin_loader->system_bus_connection,
+ &error);
+ if (plugin == NULL) {
+ g_warning ("Failed to load %s: %s", filename, error->message);
+ return;
+ }
+ g_signal_connect (plugin, "updates-changed",
+ G_CALLBACK (gs_plugin_loader_job_updates_changed_cb),
+ plugin_loader);
+ g_signal_connect (plugin, "reload",
+ G_CALLBACK (gs_plugin_loader_reload_cb),
+ plugin_loader);
+ g_signal_connect (plugin, "status-changed",
+ G_CALLBACK (gs_plugin_loader_status_changed_cb),
+ plugin_loader);
+ g_signal_connect (plugin, "basic-auth-start",
+ G_CALLBACK (gs_plugin_loader_basic_auth_start_cb),
+ plugin_loader);
+ g_signal_connect (plugin, "report-event",
+ G_CALLBACK (gs_plugin_loader_report_event_cb),
+ plugin_loader);
+ g_signal_connect (plugin, "allow-updates",
+ G_CALLBACK (gs_plugin_loader_allow_updates_cb),
+ plugin_loader);
+ g_signal_connect (plugin, "repository-changed",
+ G_CALLBACK (gs_plugin_loader_repository_changed_cb),
+ plugin_loader);
+ g_signal_connect (plugin, "ask-untrusted",
+ G_CALLBACK (gs_plugin_loader_ask_untrusted_cb),
+ plugin_loader);
+ gs_plugin_set_language (plugin, plugin_loader->language);
+ gs_plugin_set_scale (plugin, gs_plugin_loader_get_scale (plugin_loader));
+ gs_plugin_set_network_monitor (plugin, plugin_loader->network_monitor);
+ g_debug ("opened plugin %s: %s", filename, gs_plugin_get_name (plugin));
+
+ /* add to array */
+ g_ptr_array_add (plugin_loader->plugins, plugin);
+}
+
+static void
+gs_plugin_loader_remove_all_plugins (GsPluginLoader *plugin_loader)
+{
+ for (guint i = 0; i < plugin_loader->plugins->len; i++) {
+ GsPlugin *plugin = GS_PLUGIN (plugin_loader->plugins->pdata[i]);
+ g_signal_handlers_disconnect_by_data (plugin, plugin_loader);
+ }
+
+ g_ptr_array_set_size (plugin_loader->plugins, 0);
+}
+
+void
+gs_plugin_loader_set_scale (GsPluginLoader *plugin_loader, guint scale)
+{
+ /* save globally, and update each plugin */
+ plugin_loader->scale = scale;
+ for (guint i = 0; i < plugin_loader->plugins->len; i++) {
+ GsPlugin *plugin = g_ptr_array_index (plugin_loader->plugins, i);
+ gs_plugin_set_scale (plugin, scale);
+ }
+}
+
+guint
+gs_plugin_loader_get_scale (GsPluginLoader *plugin_loader)
+{
+ return plugin_loader->scale;
+}
+
+void
+gs_plugin_loader_add_location (GsPluginLoader *plugin_loader, const gchar *location)
+{
+ for (guint i = 0; i < plugin_loader->locations->len; i++) {
+ const gchar *location_tmp = g_ptr_array_index (plugin_loader->locations, i);
+ if (g_strcmp0 (location_tmp, location) == 0)
+ return;
+ }
+ g_info ("adding plugin location %s", location);
+ g_ptr_array_add (plugin_loader->locations, g_strdup (location));
+}
+
+static gint
+gs_plugin_loader_plugin_sort_fn (gconstpointer a, gconstpointer b)
+{
+ GsPlugin *pa = *((GsPlugin **) a);
+ GsPlugin *pb = *((GsPlugin **) b);
+ if (gs_plugin_get_order (pa) < gs_plugin_get_order (pb))
+ return -1;
+ if (gs_plugin_get_order (pa) > gs_plugin_get_order (pb))
+ return 1;
+ return g_strcmp0 (gs_plugin_get_name (pa), gs_plugin_get_name (pb));
+}
+
+static void
+gs_plugin_loader_software_app_created_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data)
+{
+ GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (source_object);
+ g_autoptr(GsApp) app = NULL;
+ g_autoptr(GsPluginEvent) event = NULL;
+ g_autoptr(GError) error = NULL;
+
+ app = gs_plugin_loader_app_create_finish (plugin_loader, result, NULL);
+
+ g_set_error_literal (&error,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_RESTART_REQUIRED,
+ "A restart is required");
+ event = gs_plugin_event_new ("action", GS_PLUGIN_ACTION_UNKNOWN,
+ "app", app,
+ "error", error,
+ NULL);
+
+ gs_plugin_loader_add_event (plugin_loader, event);
+}
+
+static void
+gs_plugin_loader_plugin_dir_changed_cb (GFileMonitor *monitor,
+ GFile *file,
+ GFile *other_file,
+ GFileMonitorEvent event_type,
+ GsPluginLoader *plugin_loader)
+{
+ /* already triggered */
+ if (plugin_loader->plugin_dir_dirty)
+ return;
+
+ gs_plugin_loader_app_create_async (plugin_loader, "system/*/*/org.gnome.Software.desktop/*",
+ NULL, gs_plugin_loader_software_app_created_cb, NULL);
+
+ plugin_loader->plugin_dir_dirty = TRUE;
+}
+
+void
+gs_plugin_loader_clear_caches (GsPluginLoader *plugin_loader)
+{
+ for (guint i = 0; i < plugin_loader->plugins->len; i++) {
+ GsPlugin *plugin = g_ptr_array_index (plugin_loader->plugins, i);
+ gs_plugin_cache_invalidate (plugin);
+ }
+}
+
+static void
+gs_plugin_loader_remove_all_file_monitors (GsPluginLoader *plugin_loader)
+{
+ for (guint i = 0; i < plugin_loader->file_monitors->len; i++) {
+ GFileMonitor *file_monitor = G_FILE_MONITOR (plugin_loader->file_monitors->pdata[i]);
+
+ g_signal_handlers_disconnect_by_data (file_monitor, plugin_loader);
+ g_file_monitor_cancel (file_monitor);
+ }
+
+ g_ptr_array_set_size (plugin_loader->file_monitors, 0);
+}
+
+typedef struct {
+ GsPluginLoader *plugin_loader; /* (unowned) */
+ GMainContext *context; /* (owned) */
+ guint n_pending;
+} ShutdownData;
+
+static void plugin_shutdown_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data);
+
+/**
+ * gs_plugin_loader_shutdown:
+ * @plugin_loader: a #GsPluginLoader
+ * @cancellable: a #GCancellable, or %NULL
+ *
+ * Shut down the plugins.
+ *
+ * This blocks until the operation is complete. It may be refactored in future
+ * to be asynchronous.
+ *
+ * Since: 42
+ */
+void
+gs_plugin_loader_shutdown (GsPluginLoader *plugin_loader,
+ GCancellable *cancellable)
+{
+ ShutdownData shutdown_data;
+
+ shutdown_data.plugin_loader = plugin_loader;
+ shutdown_data.n_pending = 1; /* incremented until all operations have been started */
+ shutdown_data.context = g_main_context_new ();
+
+ g_main_context_push_thread_default (shutdown_data.context);
+
+ for (guint i = 0; i < plugin_loader->plugins->len; i++) {
+ GsPlugin *plugin = GS_PLUGIN (plugin_loader->plugins->pdata[i]);
+
+ if (!gs_plugin_get_enabled (plugin))
+ continue;
+
+ if (GS_PLUGIN_GET_CLASS (plugin)->shutdown_async != NULL) {
+ GS_PLUGIN_GET_CLASS (plugin)->shutdown_async (plugin, cancellable,
+ plugin_shutdown_cb, &shutdown_data);
+ shutdown_data.n_pending++;
+ }
+ }
+
+ /* Wait for shutdown to complete in all plugins. */
+ shutdown_data.n_pending--;
+
+ while (shutdown_data.n_pending > 0)
+ g_main_context_iteration (shutdown_data.context, TRUE);
+
+ g_main_context_pop_thread_default (shutdown_data.context);
+ g_clear_pointer (&shutdown_data.context, g_main_context_unref);
+
+ /* Clear some internal data structures. */
+ gs_plugin_loader_remove_all_plugins (plugin_loader);
+ gs_plugin_loader_remove_all_file_monitors (plugin_loader);
+ plugin_loader->setup_complete = FALSE;
+ g_clear_object (&plugin_loader->setup_complete_cancellable);
+ plugin_loader->setup_complete_cancellable = g_cancellable_new ();
+}
+
+static void
+plugin_shutdown_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data)
+{
+ GsPlugin *plugin = GS_PLUGIN (source_object);
+ ShutdownData *data = user_data;
+ g_autoptr(GError) local_error = NULL;
+
+ g_assert (GS_PLUGIN_GET_CLASS (plugin)->shutdown_finish != NULL);
+
+ if (!GS_PLUGIN_GET_CLASS (plugin)->shutdown_finish (plugin, result, &local_error)) {
+ g_debug ("disabling %s as shutdown failed: %s",
+ gs_plugin_get_name (plugin),
+ local_error->message);
+ gs_plugin_set_enabled (plugin, FALSE);
+ }
+
+ /* Indicate this plugin has finished shutting down. */
+ data->n_pending--;
+ g_main_context_wakeup (data->context);
+}
+
+static gint
+gs_plugin_loader_path_sort_fn (gconstpointer a, gconstpointer b)
+{
+ const gchar *sa = *((const gchar **) a);
+ const gchar *sb = *((const gchar **) b);
+ return g_strcmp0 (sa, sb);
+}
+
+static GPtrArray *
+gs_plugin_loader_find_plugins (const gchar *path, GError **error)
+{
+ const gchar *fn_tmp;
+ g_autoptr(GPtrArray) fns = g_ptr_array_new_with_free_func (g_free);
+ g_autoptr(GDir) dir = g_dir_open (path, 0, error);
+ if (dir == NULL)
+ return NULL;
+ while ((fn_tmp = g_dir_read_name (dir)) != NULL) {
+ if (!g_str_has_suffix (fn_tmp, ".so"))
+ continue;
+ g_ptr_array_add (fns, g_build_filename (path, fn_tmp, NULL));
+ }
+ g_ptr_array_sort (fns, gs_plugin_loader_path_sort_fn);
+ return g_steal_pointer (&fns);
+}
+
+typedef struct {
+ guint n_pending;
+ gchar **allowlist;
+ gchar **blocklist;
+#ifdef HAVE_SYSPROF
+ gint64 setup_begin_time_nsec;
+ gint64 plugins_begin_time_nsec;
+#endif
+} SetupData;
+
+static void
+setup_data_free (SetupData *data)
+{
+ g_clear_pointer (&data->allowlist, g_strfreev);
+ g_clear_pointer (&data->blocklist, g_strfreev);
+ g_free (data);
+}
+
+G_DEFINE_AUTOPTR_CLEANUP_FUNC (SetupData, setup_data_free)
+
+static void get_session_bus_cb (GObject *object,
+ GAsyncResult *result,
+ gpointer user_data);
+static void get_system_bus_cb (GObject *object,
+ GAsyncResult *result,
+ gpointer user_data);
+static void finish_setup_get_bus (GTask *task);
+static void plugin_setup_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data);
+static void finish_setup_op (GTask *task);
+static void finish_setup_install_queue_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data);
+
+/* Mark the asynchronous setup operation as complete. This will notify any
+ * waiting tasks by cancelling the #GCancellable. It’s safe to clear the
+ * #GCancellable as each waiting task holds its own reference. */
+static void
+notify_setup_complete (GsPluginLoader *plugin_loader)
+{
+ plugin_loader->setup_complete = TRUE;
+ g_cancellable_cancel (plugin_loader->setup_complete_cancellable);
+ g_clear_object (&plugin_loader->setup_complete_cancellable);
+}
+
+/**
+ * gs_plugin_loader_setup_async:
+ * @plugin_loader: a #GsPluginLoader
+ * @allowlist: list of plugin names, or %NULL
+ * @blocklist: list of plugin names, or %NULL
+ * @cancellable: A #GCancellable, or %NULL
+ * @callback: callback to indicate completion of the asynchronous operation
+ * @user_data: data to pass to @callback
+ *
+ * Sets up the plugin loader ready for use.
+ *
+ * Since: 42
+ */
+void
+gs_plugin_loader_setup_async (GsPluginLoader *plugin_loader,
+ const gchar * const *allowlist,
+ const gchar * const *blocklist,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data)
+{
+ SetupData *setup_data;
+ g_autoptr(SetupData) setup_data_owned = NULL;
+ g_autoptr(GTask) task = NULL;
+#ifdef HAVE_SYSPROF
+ gint64 begin_time_nsec G_GNUC_UNUSED = SYSPROF_CAPTURE_CURRENT_TIME;
+#endif
+
+ task = g_task_new (plugin_loader, cancellable, callback, user_data);
+ g_task_set_source_tag (task, gs_plugin_loader_setup_async);
+
+ /* If setup is already complete, return immediately. */
+ if (plugin_loader->setup_complete) {
+ g_task_return_boolean (task, TRUE);
+ return;
+ }
+
+ /* Setup data closure. */
+ setup_data = setup_data_owned = g_new0 (SetupData, 1);
+ setup_data->allowlist = g_strdupv ((gchar **) allowlist);
+ setup_data->blocklist = g_strdupv ((gchar **) blocklist);
+#ifdef HAVE_SYSPROF
+ setup_data->setup_begin_time_nsec = begin_time_nsec;
+#endif
+
+ g_task_set_task_data (task, g_steal_pointer (&setup_data_owned), (GDestroyNotify) setup_data_free);
+
+ /* Connect to D-Bus if connections haven’t been provided at construction
+ * time. */
+ if (plugin_loader->session_bus_connection == NULL)
+ g_bus_get (G_BUS_TYPE_SESSION, cancellable, get_session_bus_cb, g_object_ref (task));
+ if (plugin_loader->system_bus_connection == NULL)
+ g_bus_get (G_BUS_TYPE_SYSTEM, cancellable, get_system_bus_cb, g_object_ref (task));
+
+ finish_setup_get_bus (task);
+}
+
+static void
+get_session_bus_cb (GObject *object,
+ GAsyncResult *result,
+ gpointer user_data)
+{
+ g_autoptr(GTask) task = g_steal_pointer (&user_data);
+ GsPluginLoader *plugin_loader = g_task_get_source_object (task);
+ g_autoptr(GError) local_error = NULL;
+
+ plugin_loader->session_bus_connection = g_bus_get_finish (result, &local_error);
+ if (plugin_loader->session_bus_connection == NULL) {
+ notify_setup_complete (plugin_loader);
+ g_prefix_error_literal (&local_error, "Error getting session bus: ");
+ g_task_return_error (task, g_steal_pointer (&local_error));
+ return;
+ }
+
+ g_object_notify_by_pspec (G_OBJECT (plugin_loader), obj_props[PROP_SESSION_BUS_CONNECTION]);
+
+ finish_setup_get_bus (task);
+}
+
+static void
+get_system_bus_cb (GObject *object,
+ GAsyncResult *result,
+ gpointer user_data)
+{
+ g_autoptr(GTask) task = g_steal_pointer (&user_data);
+ GsPluginLoader *plugin_loader = g_task_get_source_object (task);
+ g_autoptr(GError) local_error = NULL;
+
+ plugin_loader->system_bus_connection = g_bus_get_finish (result, &local_error);
+ if (plugin_loader->system_bus_connection == NULL) {
+ notify_setup_complete (plugin_loader);
+ g_prefix_error_literal (&local_error, "Error getting system bus: ");
+ g_task_return_error (task, g_steal_pointer (&local_error));
+ return;
+ }
+
+ g_object_notify_by_pspec (G_OBJECT (plugin_loader), obj_props[PROP_SYSTEM_BUS_CONNECTION]);
+
+ finish_setup_get_bus (task);
+}
+
+static void
+finish_setup_get_bus (GTask *task)
+{
+ SetupData *data = g_task_get_task_data (task);
+ GsPluginLoader *plugin_loader = g_task_get_source_object (task);
+ GCancellable *cancellable = g_task_get_cancellable (task);
+ const gchar *plugin_name;
+ gboolean changes;
+ GPtrArray *deps;
+ GsPlugin *dep;
+ GsPlugin *plugin;
+ guint dep_loop_check = 0;
+ guint i;
+ guint j;
+ g_autoptr(GPtrArray) locations = NULL;
+ g_autoptr(GError) local_error = NULL;
+
+ /* Wait until we’ve got all the buses we need. */
+ if (plugin_loader->session_bus_connection == NULL ||
+ plugin_loader->system_bus_connection == NULL)
+ return;
+
+ /* use the default, but this requires a 'make install' */
+ if (plugin_loader->locations->len == 0) {
+ g_autofree gchar *filename = NULL;
+ filename = g_strdup_printf ("plugins-%s", GS_PLUGIN_API_VERSION);
+ locations = g_ptr_array_new_with_free_func (g_free);
+ g_ptr_array_add (locations, g_build_filename (LIBDIR, "gnome-software", filename, NULL));
+ } else {
+ locations = g_ptr_array_ref (plugin_loader->locations);
+ }
+
+ for (i = 0; i < locations->len; i++) {
+ GFileMonitor *monitor;
+ const gchar *location = g_ptr_array_index (locations, i);
+ g_autoptr(GFile) plugin_dir = g_file_new_for_path (location);
+ g_debug ("monitoring plugin location %s", location);
+ monitor = g_file_monitor_directory (plugin_dir,
+ G_FILE_MONITOR_NONE,
+ cancellable,
+ &local_error);
+ if (monitor == NULL) {
+ notify_setup_complete (plugin_loader);
+ g_task_return_error (task, g_steal_pointer (&local_error));
+ return;
+ }
+
+ g_signal_connect (monitor, "changed",
+ G_CALLBACK (gs_plugin_loader_plugin_dir_changed_cb), plugin_loader);
+ g_ptr_array_add (plugin_loader->file_monitors, monitor);
+ }
+
+ /* search for plugins */
+ for (i = 0; i < locations->len; i++) {
+ const gchar *location = g_ptr_array_index (locations, i);
+ g_autoptr(GPtrArray) fns = NULL;
+
+ /* search in the plugin directory for plugins */
+ g_debug ("searching for plugins in %s", location);
+ fns = gs_plugin_loader_find_plugins (location, &local_error);
+ if (fns == NULL) {
+ notify_setup_complete (plugin_loader);
+ g_task_return_error (task, g_steal_pointer (&local_error));
+ return;
+ }
+
+ for (j = 0; j < fns->len; j++) {
+ const gchar *fn = g_ptr_array_index (fns, j);
+ gs_plugin_loader_open_plugin (plugin_loader, fn);
+ }
+ }
+
+ /* optional allowlist */
+ if (data->allowlist != NULL) {
+ for (i = 0; i < plugin_loader->plugins->len; i++) {
+ gboolean ret;
+ plugin = g_ptr_array_index (plugin_loader->plugins, i);
+ if (!gs_plugin_get_enabled (plugin))
+ continue;
+ ret = g_strv_contains ((const gchar * const *) data->allowlist,
+ gs_plugin_get_name (plugin));
+ if (!ret) {
+ g_debug ("%s not in allowlist, disabling",
+ gs_plugin_get_name (plugin));
+ }
+ gs_plugin_set_enabled (plugin, ret);
+ }
+ }
+
+ /* optional blocklist */
+ if (data->blocklist != NULL) {
+ for (i = 0; i < plugin_loader->plugins->len; i++) {
+ gboolean ret;
+ plugin = g_ptr_array_index (plugin_loader->plugins, i);
+ if (!gs_plugin_get_enabled (plugin))
+ continue;
+ ret = g_strv_contains ((const gchar * const *) data->blocklist,
+ gs_plugin_get_name (plugin));
+ if (ret)
+ gs_plugin_set_enabled (plugin, FALSE);
+ }
+ }
+
+ /* order by deps */
+ do {
+ changes = FALSE;
+ for (i = 0; i < plugin_loader->plugins->len; i++) {
+ plugin = g_ptr_array_index (plugin_loader->plugins, i);
+ deps = gs_plugin_get_rules (plugin, GS_PLUGIN_RULE_RUN_AFTER);
+ for (j = 0; j < deps->len && !changes; j++) {
+ plugin_name = g_ptr_array_index (deps, j);
+ dep = gs_plugin_loader_find_plugin (plugin_loader,
+ plugin_name);
+ if (dep == NULL) {
+ g_debug ("cannot find plugin '%s' "
+ "requested by '%s'",
+ plugin_name,
+ gs_plugin_get_name (plugin));
+ continue;
+ }
+ if (!gs_plugin_get_enabled (dep))
+ continue;
+ if (gs_plugin_get_order (plugin) <= gs_plugin_get_order (dep)) {
+ gs_plugin_set_order (plugin, gs_plugin_get_order (dep) + 1);
+ changes = TRUE;
+ }
+ }
+ }
+ for (i = 0; i < plugin_loader->plugins->len; i++) {
+ plugin = g_ptr_array_index (plugin_loader->plugins, i);
+ deps = gs_plugin_get_rules (plugin, GS_PLUGIN_RULE_RUN_BEFORE);
+ for (j = 0; j < deps->len && !changes; j++) {
+ plugin_name = g_ptr_array_index (deps, j);
+ dep = gs_plugin_loader_find_plugin (plugin_loader,
+ plugin_name);
+ if (dep == NULL) {
+ g_debug ("cannot find plugin '%s' "
+ "requested by '%s'",
+ plugin_name,
+ gs_plugin_get_name (plugin));
+ continue;
+ }
+ if (!gs_plugin_get_enabled (dep))
+ continue;
+ if (gs_plugin_get_order (plugin) >= gs_plugin_get_order (dep)) {
+ gs_plugin_set_order (dep, gs_plugin_get_order (plugin) + 1);
+ changes = TRUE;
+ }
+ }
+ }
+
+ /* check we're not stuck */
+ if (dep_loop_check++ > 100) {
+ notify_setup_complete (plugin_loader);
+ g_task_return_new_error (task,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_PLUGIN_DEPSOLVE_FAILED,
+ "got stuck in dep loop");
+ return;
+ }
+ } while (changes);
+
+ /* check for conflicts */
+ for (i = 0; i < plugin_loader->plugins->len; i++) {
+ plugin = g_ptr_array_index (plugin_loader->plugins, i);
+ if (!gs_plugin_get_enabled (plugin))
+ continue;
+ deps = gs_plugin_get_rules (plugin, GS_PLUGIN_RULE_CONFLICTS);
+ for (j = 0; j < deps->len && !changes; j++) {
+ plugin_name = g_ptr_array_index (deps, j);
+ dep = gs_plugin_loader_find_plugin (plugin_loader,
+ plugin_name);
+ if (dep == NULL)
+ continue;
+ if (!gs_plugin_get_enabled (dep))
+ continue;
+ g_debug ("disabling %s as conflicts with %s",
+ gs_plugin_get_name (dep),
+ gs_plugin_get_name (plugin));
+ gs_plugin_set_enabled (dep, FALSE);
+ }
+ }
+
+ /* sort by order */
+ g_ptr_array_sort (plugin_loader->plugins,
+ gs_plugin_loader_plugin_sort_fn);
+
+ /* assign priority values */
+ do {
+ changes = FALSE;
+ for (i = 0; i < plugin_loader->plugins->len; i++) {
+ plugin = g_ptr_array_index (plugin_loader->plugins, i);
+ deps = gs_plugin_get_rules (plugin, GS_PLUGIN_RULE_BETTER_THAN);
+ for (j = 0; j < deps->len && !changes; j++) {
+ plugin_name = g_ptr_array_index (deps, j);
+ dep = gs_plugin_loader_find_plugin (plugin_loader,
+ plugin_name);
+ if (dep == NULL) {
+ g_debug ("cannot find plugin '%s' "
+ "requested by '%s'",
+ plugin_name,
+ gs_plugin_get_name (plugin));
+ continue;
+ }
+ if (!gs_plugin_get_enabled (dep))
+ continue;
+ if (gs_plugin_get_priority (plugin) <= gs_plugin_get_priority (dep)) {
+ gs_plugin_set_priority (plugin, gs_plugin_get_priority (dep) + 1);
+ changes = TRUE;
+ }
+ }
+ }
+
+ /* check we're not stuck */
+ if (dep_loop_check++ > 100) {
+ notify_setup_complete (plugin_loader);
+ g_task_return_new_error (task,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_PLUGIN_DEPSOLVE_FAILED,
+ "got stuck in priority loop");
+ return;
+ }
+ } while (changes);
+
+ /* run setup */
+ data->n_pending = 1; /* incremented until all operations have been started */
+#ifdef HAVE_SYSPROF
+ data->plugins_begin_time_nsec = SYSPROF_CAPTURE_CURRENT_TIME;
+#endif
+
+ for (i = 0; i < plugin_loader->plugins->len; i++) {
+ plugin = GS_PLUGIN (plugin_loader->plugins->pdata[i]);
+
+ if (!gs_plugin_get_enabled (plugin))
+ continue;
+
+ if (GS_PLUGIN_GET_CLASS (plugin)->setup_async != NULL) {
+ data->n_pending++;
+ GS_PLUGIN_GET_CLASS (plugin)->setup_async (plugin, cancellable,
+ plugin_setup_cb, g_object_ref (task));
+ }
+ }
+
+ finish_setup_op (task);
+}
+
+static void
+plugin_setup_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data)
+{
+ GsPlugin *plugin = GS_PLUGIN (source_object);
+ g_autoptr(GTask) task = g_steal_pointer (&user_data);
+ g_autoptr(GError) local_error = NULL;
+#ifdef HAVE_SYSPROF
+ GsPluginLoader *plugin_loader = g_task_get_source_object (task);
+ SetupData *data = g_task_get_task_data (task);
+#endif /* HAVE_SYSPROF */
+
+ g_assert (GS_PLUGIN_GET_CLASS (plugin)->setup_finish != NULL);
+
+ if (!GS_PLUGIN_GET_CLASS (plugin)->setup_finish (plugin, result, &local_error)) {
+ g_debug ("disabling %s as setup failed: %s",
+ gs_plugin_get_name (plugin),
+ local_error->message);
+ gs_plugin_set_enabled (plugin, FALSE);
+ }
+
+#ifdef HAVE_SYSPROF
+ if (plugin_loader->sysprof_writer != NULL) {
+ sysprof_capture_writer_add_mark (plugin_loader->sysprof_writer,
+ data->plugins_begin_time_nsec,
+ sched_getcpu (),
+ getpid (),
+ SYSPROF_CAPTURE_CURRENT_TIME - data->plugins_begin_time_nsec,
+ "gnome-software",
+ "setup-plugin",
+ NULL);
+ }
+#endif /* HAVE_SYSPROF */
+
+ /* Indicate this plugin has finished setting up. */
+ finish_setup_op (task);
+}
+
+static void
+finish_setup_op (GTask *task)
+{
+ SetupData *data = g_task_get_task_data (task);
+ GsPluginLoader *plugin_loader = g_task_get_source_object (task);
+ GCancellable *cancellable = g_task_get_cancellable (task);
+ g_autoptr(GsAppList) install_queue = NULL;
+ g_autoptr(GError) local_error = NULL;
+
+ g_assert (data->n_pending > 0);
+ data->n_pending--;
+
+ if (data->n_pending > 0)
+ return;
+
+ /* now we can load the install-queue */
+ install_queue = load_install_queue (plugin_loader, &local_error);
+ if (install_queue == NULL) {
+ notify_setup_complete (plugin_loader);
+ g_task_return_error (task, g_steal_pointer (&local_error));
+ return;
+ }
+
+ /* Mark setup as complete as it’s now safe for other jobs to be
+ * processed. Indeed, the final step in setup is to refine the install
+ * queue apps, which requires @setup_complete to be %TRUE. */
+ notify_setup_complete (plugin_loader);
+
+#ifdef HAVE_SYSPROF
+ if (plugin_loader->sysprof_writer != NULL) {
+ sysprof_capture_writer_add_mark (plugin_loader->sysprof_writer,
+ data->setup_begin_time_nsec,
+ sched_getcpu (),
+ getpid (),
+ SYSPROF_CAPTURE_CURRENT_TIME - data->setup_begin_time_nsec,
+ "gnome-software",
+ "setup",
+ NULL);
+ }
+#endif /* HAVE_SYSPROF */
+
+ /* Refine the install queue. */
+ if (gs_app_list_length (install_queue) > 0) {
+ g_autoptr(GsPluginJob) refine_job = NULL;
+
+ /* Require ID and Origin to get complete unique IDs */
+ refine_job = gs_plugin_job_refine_new (install_queue, GS_PLUGIN_REFINE_FLAGS_REQUIRE_ID |
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_ORIGIN |
+ GS_PLUGIN_REFINE_FLAGS_DISABLE_FILTERING);
+ gs_plugin_loader_job_process_async (plugin_loader, refine_job,
+ cancellable,
+ finish_setup_install_queue_cb,
+ g_object_ref (task));
+ } else {
+ g_task_return_boolean (task, TRUE);
+ }
+}
+
+static void gs_plugin_loader_maybe_flush_pending_install_queue (GsPluginLoader *plugin_loader);
+
+static void
+finish_setup_install_queue_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data)
+{
+ GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (source_object);
+ g_autoptr(GTask) task = g_steal_pointer (&user_data);
+ g_autoptr(GsAppList) new_list = NULL;
+ g_autoptr(GError) local_error = NULL;
+
+ new_list = gs_plugin_loader_job_process_finish (plugin_loader, result, &local_error);
+ if (new_list == NULL) {
+ g_task_return_error (task, g_steal_pointer (&local_error));
+ } else {
+ g_autoptr(GsAppList) old_pending_apps = NULL;
+ gboolean has_pending_apps = FALSE;
+ gboolean changed;
+ g_mutex_lock (&plugin_loader->pending_apps_mutex);
+ changed = plugin_loader->pending_apps != NULL;
+ /* Merge the existing and newly-loaded lists, in case pending apps were added
+ while the install-queue file was being loaded */
+ old_pending_apps = g_steal_pointer (&plugin_loader->pending_apps);
+ if (old_pending_apps != NULL && gs_app_list_length (new_list) > 0) {
+ g_autoptr(GHashTable) expected_unique_ids = g_hash_table_new (g_str_hash, g_str_equal);
+ for (guint i = 0; i < gs_app_list_length (old_pending_apps); i++) {
+ GsApp *app = gs_app_list_index (old_pending_apps, i);
+ if (gs_app_get_unique_id (app) != NULL)
+ g_hash_table_add (expected_unique_ids, (gpointer) gs_app_get_unique_id (app));
+ }
+ for (guint i = 0; i < gs_app_list_length (new_list); i++) {
+ GsApp *app = gs_app_list_index (new_list, i);
+ if (gs_app_get_state (app) == GS_APP_STATE_AVAILABLE &&
+ gs_app_get_unique_id (app) != NULL &&
+ g_hash_table_contains (expected_unique_ids, gs_app_get_unique_id (app))) {
+ if (plugin_loader->pending_apps == NULL)
+ plugin_loader->pending_apps = gs_app_list_new ();
+ gs_app_set_state (app, GS_APP_STATE_QUEUED_FOR_INSTALL);
+ gs_app_set_pending_action (app, GS_PLUGIN_ACTION_INSTALL);
+ gs_app_list_add (plugin_loader->pending_apps, app);
+ }
+ }
+ has_pending_apps = plugin_loader->pending_apps != NULL;
+ changed = TRUE;
+ }
+ g_mutex_unlock (&plugin_loader->pending_apps_mutex);
+ g_task_return_boolean (task, TRUE);
+
+ if (changed)
+ save_install_queue (plugin_loader);
+ if (has_pending_apps)
+ gs_plugin_loader_maybe_flush_pending_install_queue (plugin_loader);
+ }
+}
+
+/**
+ * gs_plugin_loader_setup_finish:
+ * @plugin_loader: a #GsPluginLoader
+ * @result: result of the asynchronous operation
+ * @error: return location for a #GError, or %NULL
+ *
+ * Finish an asynchronous setup operation started with
+ * gs_plugin_loader_setup_async().
+ *
+ * Returns: %TRUE on success, %FALSE otherwise
+ * Since: 42
+ */
+gboolean
+gs_plugin_loader_setup_finish (GsPluginLoader *plugin_loader,
+ GAsyncResult *result,
+ GError **error)
+{
+ g_return_val_if_fail (GS_IS_PLUGIN_LOADER (plugin_loader), FALSE);
+ g_return_val_if_fail (g_task_is_valid (result, plugin_loader), FALSE);
+ g_return_val_if_fail (g_async_result_is_tagged (result, gs_plugin_loader_setup_async), FALSE);
+
+ return g_task_propagate_boolean (G_TASK (result), error);
+}
+
+void
+gs_plugin_loader_dump_state (GsPluginLoader *plugin_loader)
+{
+ g_autoptr(GString) str_enabled = g_string_new (NULL);
+ g_autoptr(GString) str_disabled = g_string_new (NULL);
+
+ /* print what the priorities are if verbose */
+ for (guint i = 0; i < plugin_loader->plugins->len; i++) {
+ GsPlugin *plugin = g_ptr_array_index (plugin_loader->plugins, i);
+ GString *str = gs_plugin_get_enabled (plugin) ? str_enabled : str_disabled;
+ g_string_append_printf (str, "%s, ", gs_plugin_get_name (plugin));
+ g_debug ("[%s]\t%u\t->\t%s",
+ gs_plugin_get_enabled (plugin) ? "enabled" : "disabld",
+ gs_plugin_get_order (plugin),
+ gs_plugin_get_name (plugin));
+ }
+ if (str_enabled->len > 2)
+ g_string_truncate (str_enabled, str_enabled->len - 2);
+ if (str_disabled->len > 2)
+ g_string_truncate (str_disabled, str_disabled->len - 2);
+ g_info ("enabled plugins: %s", str_enabled->str);
+ g_info ("disabled plugins: %s", str_disabled->str);
+}
+
+static void
+gs_plugin_loader_get_property (GObject *object, guint prop_id,
+ GValue *value, GParamSpec *pspec)
+{
+ GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (object);
+
+ switch ((GsPluginLoaderProperty) prop_id) {
+ case PROP_EVENTS:
+ g_value_set_pointer (value, plugin_loader->events_by_id);
+ break;
+ case PROP_ALLOW_UPDATES:
+ g_value_set_boolean (value, gs_plugin_loader_get_allow_updates (plugin_loader));
+ break;
+ case PROP_NETWORK_AVAILABLE:
+ g_value_set_boolean (value, gs_plugin_loader_get_network_available (plugin_loader));
+ break;
+ case PROP_NETWORK_METERED:
+ g_value_set_boolean (value, gs_plugin_loader_get_network_metered (plugin_loader));
+ break;
+ case PROP_SESSION_BUS_CONNECTION:
+ g_value_set_object (value, plugin_loader->session_bus_connection);
+ break;
+ case PROP_SYSTEM_BUS_CONNECTION:
+ g_value_set_object (value, plugin_loader->system_bus_connection);
+ break;
+ default:
+ G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+ break;
+ }
+}
+
+static void
+gs_plugin_loader_set_property (GObject *object, guint prop_id,
+ const GValue *value, GParamSpec *pspec)
+{
+ GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (object);
+
+ switch ((GsPluginLoaderProperty) prop_id) {
+ case PROP_EVENTS:
+ case PROP_ALLOW_UPDATES:
+ case PROP_NETWORK_AVAILABLE:
+ case PROP_NETWORK_METERED:
+ /* Read only */
+ g_assert_not_reached ();
+ break;
+ case PROP_SESSION_BUS_CONNECTION:
+ /* Construct only */
+ g_assert (plugin_loader->session_bus_connection == NULL);
+ plugin_loader->session_bus_connection = g_value_dup_object (value);
+ break;
+ case PROP_SYSTEM_BUS_CONNECTION:
+ /* Construct only */
+ g_assert (plugin_loader->system_bus_connection == NULL);
+ plugin_loader->system_bus_connection = g_value_dup_object (value);
+ break;
+ default:
+ G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+ break;
+ }
+}
+
+static void
+gs_plugin_loader_dispose (GObject *object)
+{
+ GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (object);
+
+ g_cancellable_cancel (plugin_loader->pending_apps_cancellable);
+
+ if (plugin_loader->plugins != NULL) {
+ /* Shut down all the plugins first. */
+ gs_plugin_loader_shutdown (plugin_loader, NULL);
+
+ g_clear_pointer (&plugin_loader->plugins, g_ptr_array_unref);
+ }
+ if (plugin_loader->updates_changed_id != 0) {
+ g_source_remove (plugin_loader->updates_changed_id);
+ plugin_loader->updates_changed_id = 0;
+ }
+ if (plugin_loader->network_changed_handler != 0) {
+ g_signal_handler_disconnect (plugin_loader->network_monitor,
+ plugin_loader->network_changed_handler);
+ plugin_loader->network_changed_handler = 0;
+ }
+ if (plugin_loader->network_available_notify_handler != 0) {
+ g_signal_handler_disconnect (plugin_loader->network_monitor,
+ plugin_loader->network_available_notify_handler);
+ plugin_loader->network_available_notify_handler = 0;
+ }
+ if (plugin_loader->network_metered_notify_handler != 0) {
+ g_signal_handler_disconnect (plugin_loader->network_monitor,
+ plugin_loader->network_metered_notify_handler);
+ plugin_loader->network_metered_notify_handler = 0;
+ }
+ if (plugin_loader->queued_ops_pool != NULL) {
+ /* stop accepting more requests and wait until any currently
+ * running ones are finished */
+ g_thread_pool_free (plugin_loader->queued_ops_pool, TRUE, TRUE);
+ plugin_loader->queued_ops_pool = NULL;
+ }
+ g_clear_object (&plugin_loader->network_monitor);
+ g_clear_object (&plugin_loader->settings);
+ g_clear_object (&plugin_loader->pending_apps);
+ g_clear_object (&plugin_loader->category_manager);
+ g_clear_object (&plugin_loader->odrs_provider);
+ g_clear_object (&plugin_loader->setup_complete_cancellable);
+ g_clear_object (&plugin_loader->pending_apps_cancellable);
+
+#ifdef HAVE_SYSPROF
+ g_clear_pointer (&plugin_loader->sysprof_writer, sysprof_capture_writer_unref);
+#endif
+
+ g_clear_object (&plugin_loader->session_bus_connection);
+ g_clear_object (&plugin_loader->system_bus_connection);
+
+ G_OBJECT_CLASS (gs_plugin_loader_parent_class)->dispose (object);
+}
+
+static void
+gs_plugin_loader_finalize (GObject *object)
+{
+ GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (object);
+
+ g_strfreev (plugin_loader->compatible_projects);
+ g_ptr_array_unref (plugin_loader->locations);
+ g_free (plugin_loader->language);
+ g_ptr_array_unref (plugin_loader->file_monitors);
+ g_hash_table_unref (plugin_loader->events_by_id);
+ g_hash_table_unref (plugin_loader->disallow_updates);
+
+ g_mutex_clear (&plugin_loader->pending_apps_mutex);
+ g_mutex_clear (&plugin_loader->events_by_id_mutex);
+
+ G_OBJECT_CLASS (gs_plugin_loader_parent_class)->finalize (object);
+}
+
+static void
+gs_plugin_loader_class_init (GsPluginLoaderClass *klass)
+{
+ GObjectClass *object_class = G_OBJECT_CLASS (klass);
+
+ object_class->get_property = gs_plugin_loader_get_property;
+ object_class->set_property = gs_plugin_loader_set_property;
+ object_class->dispose = gs_plugin_loader_dispose;
+ object_class->finalize = gs_plugin_loader_finalize;
+
+ /**
+ * GsPluginLoader:events:
+ *
+ * Events added on the plugin loader using gs_plugin_loader_add_event().
+ */
+ obj_props[PROP_EVENTS] =
+ g_param_spec_string ("events", NULL, NULL,
+ NULL,
+ G_PARAM_READABLE | G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ /**
+ * GsPluginLoader:allow-updates:
+ *
+ * Whether updates and upgrades are managed by gnome-software.
+ *
+ * If not, the updates UI should be hidden and no automatic updates
+ * performed.
+ */
+ obj_props[PROP_ALLOW_UPDATES] =
+ g_param_spec_boolean ("allow-updates", NULL, NULL,
+ TRUE,
+ G_PARAM_READABLE | G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ /**
+ * GsPluginLoader:network-available:
+ *
+ * Whether the network is considered available.
+ *
+ * This has the same semantics as #GNetworkMonitor:network-available.
+ */
+ obj_props[PROP_NETWORK_AVAILABLE] =
+ g_param_spec_boolean ("network-available", NULL, NULL,
+ FALSE,
+ G_PARAM_READABLE | G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ /**
+ * GsPluginLoader:network-metered:
+ *
+ * Whether the network is considered metered.
+ *
+ * This has the same semantics as #GNetworkMonitor:network-metered.
+ */
+ obj_props[PROP_NETWORK_METERED] =
+ g_param_spec_boolean ("network-metered", NULL, NULL,
+ FALSE,
+ G_PARAM_READABLE | G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ /**
+ * GsPluginLoader:session-bus-connection: (nullable)
+ *
+ * A connection to the D-Bus session bus.
+ *
+ * This may be %NULL at construction time. If so, the default session
+ * bus connection will be used (and returned as the value of this
+ * property) after gs_plugin_loader_setup_async() is called.
+ *
+ * Since: 43
+ */
+ obj_props[PROP_SESSION_BUS_CONNECTION] =
+ g_param_spec_object ("session-bus-connection", NULL, NULL,
+ G_TYPE_DBUS_CONNECTION,
+ G_PARAM_CONSTRUCT_ONLY | G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ /**
+ * GsPluginLoader:system-bus-connection: (not nullable)
+ *
+ * A connection to the D-Bus system bus.
+ *
+ * This may be %NULL at construction time. If so, the default system
+ * bus connection will be used (and returned as the value of this
+ * property) after gs_plugin_loader_setup_async() is called.
+ *
+ * Since: 43
+ */
+ obj_props[PROP_SYSTEM_BUS_CONNECTION] =
+ g_param_spec_object ("system-bus-connection", NULL, NULL,
+ G_TYPE_DBUS_CONNECTION,
+ G_PARAM_CONSTRUCT_ONLY | G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ g_object_class_install_properties (object_class, G_N_ELEMENTS (obj_props), obj_props);
+
+ signals [SIGNAL_STATUS_CHANGED] =
+ g_signal_new ("status-changed",
+ G_TYPE_FROM_CLASS (object_class), G_SIGNAL_RUN_LAST,
+ 0, NULL, NULL, g_cclosure_marshal_generic,
+ G_TYPE_NONE, 2, G_TYPE_POINTER, G_TYPE_UINT);
+ signals [SIGNAL_PENDING_APPS_CHANGED] =
+ g_signal_new ("pending-apps-changed",
+ G_TYPE_FROM_CLASS (object_class), G_SIGNAL_RUN_LAST,
+ 0, NULL, NULL, g_cclosure_marshal_VOID__VOID,
+ G_TYPE_NONE, 0);
+ signals [SIGNAL_UPDATES_CHANGED] =
+ g_signal_new ("updates-changed",
+ G_TYPE_FROM_CLASS (object_class), G_SIGNAL_RUN_LAST,
+ 0, NULL, NULL, g_cclosure_marshal_VOID__VOID,
+ G_TYPE_NONE, 0);
+ signals [SIGNAL_RELOAD] =
+ g_signal_new ("reload",
+ G_TYPE_FROM_CLASS (object_class), G_SIGNAL_RUN_LAST,
+ 0, NULL, NULL, g_cclosure_marshal_VOID__VOID,
+ G_TYPE_NONE, 0);
+ signals [SIGNAL_BASIC_AUTH_START] =
+ g_signal_new ("basic-auth-start",
+ G_TYPE_FROM_CLASS (object_class), G_SIGNAL_RUN_LAST,
+ 0, NULL, NULL, g_cclosure_marshal_generic,
+ G_TYPE_NONE, 4, G_TYPE_STRING, G_TYPE_STRING, G_TYPE_POINTER, G_TYPE_POINTER);
+ signals [SIGNAL_ASK_UNTRUSTED] =
+ g_signal_new ("ask-untrusted",
+ G_TYPE_FROM_CLASS (object_class), G_SIGNAL_RUN_LAST,
+ 0, NULL, NULL, g_cclosure_marshal_generic,
+ G_TYPE_BOOLEAN, 4, G_TYPE_STRING, G_TYPE_STRING, G_TYPE_STRING, G_TYPE_STRING);
+}
+
+static void
+gs_plugin_loader_allow_updates_recheck (GsPluginLoader *plugin_loader)
+{
+ if (g_settings_get_boolean (plugin_loader->settings, "allow-updates")) {
+ g_hash_table_remove (plugin_loader->disallow_updates, plugin_loader);
+ } else {
+ g_hash_table_insert (plugin_loader->disallow_updates,
+ (gpointer) plugin_loader,
+ (gpointer) "GSettings");
+ }
+}
+
+static void
+gs_plugin_loader_settings_changed_cb (GSettings *settings,
+ const gchar *key,
+ GsPluginLoader *plugin_loader)
+{
+ if (g_strcmp0 (key, "allow-updates") == 0)
+ gs_plugin_loader_allow_updates_recheck (plugin_loader);
+}
+
+static gint
+get_max_parallel_ops (void)
+{
+ guint mem_total = gs_utils_get_memory_total ();
+ if (mem_total == 0)
+ return 8;
+ /* allow 1 op per GB of memory */
+ return (gint) MAX (round((gdouble) mem_total / 1024), 1.0);
+}
+
+static void
+gs_plugin_loader_init (GsPluginLoader *plugin_loader)
+{
+ const gchar *tmp;
+ gchar *match;
+ gchar **projects;
+ guint i;
+ g_autofree gchar *review_server = NULL;
+ g_autofree gchar *user_hash = NULL;
+ g_autoptr(GError) local_error = NULL;
+ const guint64 odrs_review_max_cache_age_secs = 237000; /* 1 week */
+ const guint odrs_review_n_results_max = 20;
+ const gchar *locale;
+
+#ifdef HAVE_SYSPROF
+ plugin_loader->sysprof_writer = sysprof_capture_writer_new_from_env (0);
+#endif /* HAVE_SYSPROF */
+
+ plugin_loader->setup_complete_cancellable = g_cancellable_new ();
+ plugin_loader->scale = 1;
+ plugin_loader->plugins = g_ptr_array_new_with_free_func (g_object_unref);
+ plugin_loader->pending_apps = NULL;
+ plugin_loader->queued_ops_pool = g_thread_pool_new (gs_plugin_loader_process_in_thread_pool_cb,
+ NULL,
+ get_max_parallel_ops (),
+ FALSE,
+ NULL);
+ plugin_loader->file_monitors = g_ptr_array_new_with_free_func (g_object_unref);
+ plugin_loader->locations = g_ptr_array_new_with_free_func (g_free);
+ plugin_loader->settings = g_settings_new ("org.gnome.software");
+ g_signal_connect (plugin_loader->settings, "changed",
+ G_CALLBACK (gs_plugin_loader_settings_changed_cb), plugin_loader);
+ plugin_loader->events_by_id = g_hash_table_new_full ((GHashFunc) as_utils_data_id_hash,
+ (GEqualFunc) as_utils_data_id_equal,
+ g_free,
+ (GDestroyNotify) g_object_unref);
+
+ /* get the category manager */
+ plugin_loader->category_manager = gs_category_manager_new ();
+
+ /* set up the ODRS provider */
+
+ /* get the machine+user ID hash value */
+ user_hash = gs_utils_get_user_hash (&local_error);
+ if (user_hash == NULL) {
+ g_warning ("Failed to get machine+user hash: %s", local_error->message);
+ plugin_loader->odrs_provider = NULL;
+ } else {
+ review_server = g_settings_get_string (plugin_loader->settings, "review-server");
+
+ if (review_server != NULL && *review_server != '\0') {
+ const gchar *distro = NULL;
+ g_autoptr(GsOsRelease) os_release = NULL;
+ g_autoptr(SoupSession) odrs_soup_session = NULL;
+
+ /* get the distro name (e.g. 'Fedora') but allow a fallback */
+ os_release = gs_os_release_new (&local_error);
+ if (os_release != NULL) {
+ distro = gs_os_release_get_name (os_release);
+ if (distro == NULL)
+ g_warning ("no distro name specified");
+ } else {
+ g_warning ("failed to get distro name: %s", local_error->message);
+ }
+
+ /* Fallback */
+ if (distro == NULL)
+ distro = C_("Distribution name", "Unknown");
+
+ odrs_soup_session = gs_build_soup_session ();
+ plugin_loader->odrs_provider = gs_odrs_provider_new (review_server,
+ user_hash,
+ distro,
+ odrs_review_max_cache_age_secs,
+ odrs_review_n_results_max,
+ odrs_soup_session);
+ }
+ }
+
+ /* the settings key sets the initial override */
+ plugin_loader->disallow_updates = g_hash_table_new (g_direct_hash, g_direct_equal);
+ gs_plugin_loader_allow_updates_recheck (plugin_loader);
+
+ /* get the language from the locale (i.e. strip the territory, codeset
+ * and modifier) */
+ locale = setlocale (LC_MESSAGES, NULL);
+ plugin_loader->language = g_strdup (locale);
+ match = strpbrk (plugin_loader->language, "._@");
+ if (match != NULL)
+ *match = '\0';
+
+ g_debug ("Using locale = %s, language = %s", locale, plugin_loader->language);
+
+ g_mutex_init (&plugin_loader->pending_apps_mutex);
+ g_mutex_init (&plugin_loader->events_by_id_mutex);
+
+ /* monitor the network as the many UI operations need the network */
+ gs_plugin_loader_monitor_network (plugin_loader);
+
+ /* by default we only show project-less apps or compatible projects */
+ tmp = g_getenv ("GNOME_SOFTWARE_COMPATIBLE_PROJECTS");
+ if (tmp == NULL) {
+ projects = g_settings_get_strv (plugin_loader->settings,
+ "compatible-projects");
+ } else {
+ projects = g_strsplit (tmp, ",", -1);
+ }
+ for (i = 0; projects[i] != NULL; i++)
+ g_debug ("compatible-project: %s", projects[i]);
+ plugin_loader->compatible_projects = projects;
+}
+
+/**
+ * gs_plugin_loader_new:
+ * @session_bus_connection: (nullable) (transfer none): a D-Bus session bus
+ * connection to use, or %NULL to use the default
+ * @system_bus_connection: (nullable) (transfer none): a D-Bus system bus
+ * connection to use, or %NULL to use the default
+ *
+ * Create a new #GsPluginLoader.
+ *
+ * The D-Bus connection arguments should typically be %NULL, and only be
+ * non-%NULL when doing unit tests.
+ *
+ * Return value: (transfer full) (not nullable): a new #GsPluginLoader
+ * Since: 43
+ **/
+GsPluginLoader *
+gs_plugin_loader_new (GDBusConnection *session_bus_connection,
+ GDBusConnection *system_bus_connection)
+{
+ g_return_val_if_fail (session_bus_connection == NULL || G_IS_DBUS_CONNECTION (session_bus_connection), NULL);
+ g_return_val_if_fail (system_bus_connection == NULL || G_IS_DBUS_CONNECTION (system_bus_connection), NULL);
+
+ return g_object_new (GS_TYPE_PLUGIN_LOADER,
+ "session-bus-connection", session_bus_connection,
+ "system-bus-connection", system_bus_connection,
+ NULL);
+}
+
+static void
+gs_plugin_loader_app_installed_cb (GObject *source,
+ GAsyncResult *res,
+ gpointer user_data)
+{
+ GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (source);
+ gboolean ret;
+ g_autoptr(GError) error = NULL;
+ g_autoptr(GsApp) app = GS_APP (user_data);
+
+ ret = gs_plugin_loader_job_action_finish (plugin_loader,
+ res,
+ &error);
+ remove_app_from_install_queue (plugin_loader, app);
+ if (!ret) {
+ gs_app_set_state_recover (app);
+ g_warning ("failed to install %s: %s",
+ gs_app_get_unique_id (app), error->message);
+ }
+}
+
+gboolean
+gs_plugin_loader_get_network_available (GsPluginLoader *plugin_loader)
+{
+ if (plugin_loader->network_monitor == NULL) {
+ g_debug ("no network monitor, so returning network-available=TRUE");
+ return TRUE;
+ }
+ return g_network_monitor_get_network_available (plugin_loader->network_monitor);
+}
+
+gboolean
+gs_plugin_loader_get_network_metered (GsPluginLoader *plugin_loader)
+{
+ if (plugin_loader->network_monitor == NULL) {
+ g_debug ("no network monitor, so returning network-metered=FALSE");
+ return FALSE;
+ }
+ return g_network_monitor_get_network_metered (plugin_loader->network_monitor);
+}
+
+static void
+gs_plugin_loader_pending_apps_refined_cb (GObject *source,
+ GAsyncResult *res,
+ gpointer user_data)
+{
+ GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (source);
+ g_autoptr(GsAppList) old_queue = GS_APP_LIST (user_data);
+ g_autoptr(GsAppList) refined_queue = NULL;
+ g_autoptr(GError) error = NULL;
+
+ refined_queue = gs_plugin_loader_job_process_finish (plugin_loader, res, &error);
+
+ if (refined_queue == NULL) {
+ if (!g_error_matches (error, G_IO_ERROR, G_IO_ERROR_CANCELLED) &&
+ !g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_CANCELLED)) {
+ g_debug ("failed to refine pending apps: %s", error->message);
+
+ g_mutex_lock (&plugin_loader->pending_apps_mutex);
+ g_clear_object (&plugin_loader->pending_apps);
+ g_mutex_unlock (&plugin_loader->pending_apps_mutex);
+
+ save_install_queue (plugin_loader);
+ }
+ return;
+ }
+
+ for (guint i = 0; i < gs_app_list_length (old_queue); i++) {
+ GsApp *app = gs_app_list_index (old_queue, i);
+
+ if (gs_app_list_lookup (refined_queue, gs_app_get_unique_id (app)) == NULL)
+ remove_app_from_install_queue (plugin_loader, app);
+ }
+
+ for (guint i = 0; i < gs_app_list_length (refined_queue); i++) {
+ GsApp *app = gs_app_list_index (refined_queue, i);
+ g_autoptr(GsPluginJob) plugin_job = NULL;
+
+ if (gs_app_get_kind (app) == AS_COMPONENT_KIND_REPOSITORY) {
+ plugin_job = gs_plugin_job_manage_repository_new (app,
+ GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_INTERACTIVE |
+ GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_INSTALL);
+ } else {
+ /* The 'interactive' is needed for credentials prompt, otherwise it just fails */
+ plugin_job = gs_plugin_job_newv (GS_PLUGIN_ACTION_INSTALL,
+ "app", app,
+ "interactive", TRUE,
+ NULL);
+ }
+
+ gs_plugin_loader_job_process_async (plugin_loader, plugin_job,
+ gs_app_get_cancellable (app),
+ gs_plugin_loader_app_installed_cb,
+ g_object_ref (app));
+ }
+
+ g_clear_object (&plugin_loader->pending_apps_cancellable);
+}
+
+static void
+gs_plugin_loader_maybe_flush_pending_install_queue (GsPluginLoader *plugin_loader)
+{
+ g_autoptr(GsPluginJob) plugin_job = NULL;
+ g_autoptr(GsAppList) obsolete = NULL;
+ g_autoptr(GsAppList) queue = NULL;
+
+ if (!gs_plugin_loader_get_network_available (plugin_loader) ||
+ gs_plugin_loader_get_network_metered (plugin_loader)) {
+ /* Print the debug message only when had anything to skip */
+ g_mutex_lock (&plugin_loader->pending_apps_mutex);
+ if (plugin_loader->pending_apps != NULL) {
+ g_debug ("Cannot flush pending install queue, because is %sonline and is %smetered",
+ !gs_plugin_loader_get_network_available (plugin_loader) ? "not " : "",
+ gs_plugin_loader_get_network_metered (plugin_loader) ? "" : "not ");
+ }
+ g_mutex_unlock (&plugin_loader->pending_apps_mutex);
+ return;
+ }
+
+ /* Already flushing pending queue */
+ if (plugin_loader->pending_apps_cancellable)
+ return;
+
+ queue = gs_app_list_new ();
+ obsolete = gs_app_list_new ();
+ g_mutex_lock (&plugin_loader->pending_apps_mutex);
+ for (guint i = 0; plugin_loader->pending_apps != NULL && i < gs_app_list_length (plugin_loader->pending_apps); i++) {
+ GsApp *app = gs_app_list_index (plugin_loader->pending_apps, i);
+ if (gs_app_get_state (app) == GS_APP_STATE_QUEUED_FOR_INSTALL) {
+ gs_app_set_state (app, GS_APP_STATE_AVAILABLE);
+ gs_app_list_add (queue, app);
+ } else {
+ gs_app_list_add (obsolete, app);
+ }
+ }
+ g_mutex_unlock (&plugin_loader->pending_apps_mutex);
+ for (guint i = 0; i < gs_app_list_length (obsolete); i++) {
+ GsApp *app = gs_app_list_index (obsolete, i);
+ remove_app_from_install_queue (plugin_loader, app);
+ }
+
+ plugin_loader->pending_apps_cancellable = g_cancellable_new ();
+
+ plugin_job = gs_plugin_job_refine_new (queue, GS_PLUGIN_REFINE_FLAGS_NONE);
+ gs_plugin_loader_job_process_async (plugin_loader, plugin_job,
+ plugin_loader->pending_apps_cancellable,
+ gs_plugin_loader_pending_apps_refined_cb,
+ g_steal_pointer (&queue));
+}
+
+static void
+gs_plugin_loader_network_changed_cb (GNetworkMonitor *monitor,
+ gboolean available,
+ GsPluginLoader *plugin_loader)
+{
+ gboolean metered = g_network_monitor_get_network_metered (plugin_loader->network_monitor);
+
+ g_debug ("network status change: %s [%s]",
+ available ? "online" : "offline",
+ metered ? "metered" : "unmetered");
+
+ g_object_notify_by_pspec (G_OBJECT (plugin_loader), obj_props[PROP_NETWORK_AVAILABLE]);
+ g_object_notify_by_pspec (G_OBJECT (plugin_loader), obj_props[PROP_NETWORK_METERED]);
+
+ gs_plugin_loader_maybe_flush_pending_install_queue (plugin_loader);
+}
+
+static void
+gs_plugin_loader_network_available_notify_cb (GObject *obj,
+ GParamSpec *pspec,
+ gpointer user_data)
+{
+ GNetworkMonitor *monitor = G_NETWORK_MONITOR (obj);
+ GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (user_data);
+
+ gs_plugin_loader_network_changed_cb (monitor, g_network_monitor_get_network_available (monitor), plugin_loader);
+}
+
+static void
+gs_plugin_loader_network_metered_notify_cb (GObject *obj,
+ GParamSpec *pspec,
+ gpointer user_data)
+{
+ GNetworkMonitor *monitor = G_NETWORK_MONITOR (obj);
+ GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (user_data);
+
+ gs_plugin_loader_network_changed_cb (monitor, g_network_monitor_get_network_available (monitor), plugin_loader);
+}
+
+static void
+gs_plugin_loader_monitor_network (GsPluginLoader *plugin_loader)
+{
+ GNetworkMonitor *network_monitor;
+
+ network_monitor = g_network_monitor_get_default ();
+ if (network_monitor == NULL || plugin_loader->network_changed_handler != 0)
+ return;
+ plugin_loader->network_monitor = g_object_ref (network_monitor);
+
+ plugin_loader->network_changed_handler =
+ g_signal_connect (plugin_loader->network_monitor, "network-changed",
+ G_CALLBACK (gs_plugin_loader_network_changed_cb), plugin_loader);
+ plugin_loader->network_available_notify_handler =
+ g_signal_connect (plugin_loader->network_monitor, "notify::network-available",
+ G_CALLBACK (gs_plugin_loader_network_available_notify_cb), plugin_loader);
+ plugin_loader->network_metered_notify_handler =
+ g_signal_connect (plugin_loader->network_monitor, "notify::network-metered",
+ G_CALLBACK (gs_plugin_loader_network_metered_notify_cb), plugin_loader);
+
+ gs_plugin_loader_network_changed_cb (plugin_loader->network_monitor,
+ g_network_monitor_get_network_available (plugin_loader->network_monitor),
+ plugin_loader);
+}
+
+/******************************************************************************/
+
+static void
+generic_update_cancelled_cb (GCancellable *cancellable, gpointer data)
+{
+ GCancellable *app_cancellable = G_CANCELLABLE (data);
+ g_cancellable_cancel (app_cancellable);
+}
+
+static gboolean
+gs_plugin_loader_generic_update (GsPluginLoader *plugin_loader,
+ GsPluginLoaderHelper *helper,
+ GCancellable *cancellable,
+ GError **error)
+{
+ guint cancel_handler_id = 0;
+ GsAppList *list;
+
+ /* run each plugin, per-app version */
+ list = gs_plugin_job_get_list (helper->plugin_job);
+ for (guint i = 0; i < plugin_loader->plugins->len; i++) {
+ GsPluginActionFunc plugin_app_func = NULL;
+ GsPlugin *plugin = g_ptr_array_index (plugin_loader->plugins, i);
+ if (g_cancellable_set_error_if_cancelled (cancellable, error)) {
+ gs_utils_error_convert_gio (error);
+ return FALSE;
+ }
+ plugin_app_func = gs_plugin_get_symbol (plugin, helper->function_name);
+ if (plugin_app_func == NULL)
+ continue;
+
+ /* for each app */
+ for (guint j = 0; j < gs_app_list_length (list); j++) {
+ GCancellable *app_cancellable;
+ GsApp *app = gs_app_list_index (list, j);
+ gboolean ret;
+ g_autoptr(GError) error_local = NULL;
+
+ /* if the whole operation should be cancelled */
+ if (g_cancellable_set_error_if_cancelled (cancellable, error))
+ return FALSE;
+
+ /* already installed? */
+ if (gs_app_get_state (app) == GS_APP_STATE_INSTALLED)
+ continue;
+
+ /* make sure that the app update is cancelled when the whole op is cancelled */
+ app_cancellable = gs_app_get_cancellable (app);
+ cancel_handler_id = g_cancellable_connect (cancellable,
+ G_CALLBACK (generic_update_cancelled_cb),
+ g_object_ref (app_cancellable),
+ g_object_unref);
+
+ gs_plugin_job_set_app (helper->plugin_job, app);
+ ret = plugin_app_func (plugin, app, app_cancellable, &error_local);
+ g_cancellable_disconnect (cancellable, cancel_handler_id);
+
+ if (!ret) {
+ if (!gs_plugin_error_handle_failure (helper,
+ plugin,
+ error_local,
+ error)) {
+ return FALSE;
+ }
+ }
+ }
+ helper->anything_ran = TRUE;
+ gs_plugin_status_update (plugin, NULL, GS_PLUGIN_STATUS_FINISHED);
+ }
+
+ if (gs_plugin_job_get_action (helper->plugin_job) == GS_PLUGIN_ACTION_UPDATE)
+ gs_utils_set_online_updates_timestamp (plugin_loader->settings);
+
+ return TRUE;
+}
+
+static void
+gs_plugin_loader_inherit_list_props (GsAppList *des_list,
+ GsAppList *src_list)
+{
+ if (gs_app_list_has_flag (src_list, GS_APP_LIST_FLAG_IS_TRUNCATED))
+ gs_app_list_add_flag (des_list, GS_APP_LIST_FLAG_IS_TRUNCATED);
+
+ gs_app_list_set_size_peak (des_list, gs_app_list_get_size_peak (src_list));
+}
+
+static void
+gs_plugin_loader_process_thread_cb (GTask *task,
+ gpointer object,
+ gpointer task_data,
+ GCancellable *cancellable)
+{
+ GError *error = NULL;
+ GsPluginLoaderHelper *helper = (GsPluginLoaderHelper *) task_data;
+ GsAppListFilterFlags dedupe_flags;
+ g_autoptr(GsAppList) list = g_object_ref (gs_plugin_job_get_list (helper->plugin_job));
+ GsPluginAction action = gs_plugin_job_get_action (helper->plugin_job);
+ GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (object);
+ gboolean add_to_pending_array = FALSE;
+ g_autoptr(GMainContext) context = g_main_context_new ();
+ g_autoptr(GMainContextPusher) pusher = g_main_context_pusher_new (context);
+ g_autofree gchar *job_debug = NULL;
+#ifdef HAVE_SYSPROF
+ gint64 begin_time_nsec G_GNUC_UNUSED = SYSPROF_CAPTURE_CURRENT_TIME;
+#endif
+
+ /* these change the pending count on the installed panel */
+ switch (action) {
+ case GS_PLUGIN_ACTION_INSTALL:
+ case GS_PLUGIN_ACTION_REMOVE:
+ add_to_pending_array = TRUE;
+ break;
+ default:
+ break;
+ }
+
+ /* add to pending list */
+ if (add_to_pending_array)
+ gs_plugin_loader_pending_apps_add (plugin_loader, helper);
+
+ /* run each plugin */
+ if (!GS_IS_PLUGIN_JOB_REFINE (helper->plugin_job)) {
+ if (!gs_plugin_loader_run_results (helper, cancellable, &error)) {
+ if (add_to_pending_array) {
+ gs_app_set_state_recover (gs_plugin_job_get_app (helper->plugin_job));
+ gs_plugin_loader_pending_apps_remove (plugin_loader, helper);
+ }
+ gs_utils_error_convert_gio (&error);
+ g_task_return_error (task, error);
+ return;
+ }
+
+ if (action == GS_PLUGIN_ACTION_URL_TO_APP) {
+ const gchar *search = gs_plugin_job_get_search (helper->plugin_job);
+ if (search && g_ascii_strncasecmp (search, "file://", 7) == 0 && (
+ gs_plugin_job_get_list (helper->plugin_job) == NULL ||
+ gs_app_list_length (gs_plugin_job_get_list (helper->plugin_job)) == 0)) {
+ g_autoptr(GError) local_error = NULL;
+ g_autoptr(GFile) file = NULL;
+ file = g_file_new_for_uri (search);
+ gs_plugin_job_set_action (helper->plugin_job, GS_PLUGIN_ACTION_FILE_TO_APP);
+ gs_plugin_job_set_file (helper->plugin_job, file);
+ helper->function_name = gs_plugin_action_to_function_name (GS_PLUGIN_ACTION_FILE_TO_APP);
+ if (gs_plugin_loader_run_results (helper, cancellable, &local_error)) {
+ for (guint j = 0; j < gs_app_list_length (list); j++) {
+ GsApp *app = gs_app_list_index (list, j);
+ if (gs_app_get_local_file (app) == NULL)
+ gs_app_set_local_file (app, gs_plugin_job_get_file (helper->plugin_job));
+ }
+ } else {
+ g_debug ("Failed to convert file:// URI to app using file-to-app action: %s", local_error->message);
+ }
+ gs_plugin_job_set_action (helper->plugin_job, GS_PLUGIN_ACTION_URL_TO_APP);
+ gs_plugin_job_set_file (helper->plugin_job, NULL);
+ }
+ }
+ }
+
+ /* run per-app version */
+ if (action == GS_PLUGIN_ACTION_UPDATE) {
+ helper->function_name = "gs_plugin_update_app";
+ if (!gs_plugin_loader_generic_update (plugin_loader, helper,
+ cancellable, &error)) {
+ gs_utils_error_convert_gio (&error);
+ g_task_return_error (task, error);
+ return;
+ }
+ } else if (action == GS_PLUGIN_ACTION_DOWNLOAD) {
+ helper->function_name = "gs_plugin_download_app";
+ if (!gs_plugin_loader_generic_update (plugin_loader, helper,
+ cancellable, &error)) {
+ gs_utils_error_convert_gio (&error);
+ g_task_return_error (task, error);
+ return;
+ }
+ }
+
+ if (action == GS_PLUGIN_ACTION_UPGRADE_TRIGGER)
+ gs_utils_set_online_updates_timestamp (plugin_loader->settings);
+
+ /* remove from pending list */
+ if (add_to_pending_array)
+ gs_plugin_loader_pending_apps_remove (plugin_loader, helper);
+
+ /* some functions are really required for proper operation */
+ switch (action) {
+ case GS_PLUGIN_ACTION_GET_UPDATES:
+ case GS_PLUGIN_ACTION_INSTALL:
+ case GS_PLUGIN_ACTION_DOWNLOAD:
+ case GS_PLUGIN_ACTION_LAUNCH:
+ case GS_PLUGIN_ACTION_REMOVE:
+ case GS_PLUGIN_ACTION_UPDATE:
+ if (!helper->anything_ran) {
+ g_set_error (&error,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_NOT_SUPPORTED,
+ "no plugin could handle %s",
+ gs_plugin_action_to_string (action));
+ g_task_return_error (task, error);
+ return;
+ }
+ break;
+ default:
+ if (!helper->anything_ran && !GS_IS_PLUGIN_JOB_REFINE (helper->plugin_job)) {
+ g_debug ("no plugin could handle %s",
+ gs_plugin_action_to_string (action));
+ }
+ break;
+ }
+
+ /* unstage addons */
+ if (add_to_pending_array) {
+ g_autoptr(GsAppList) addons = gs_app_dup_addons (gs_plugin_job_get_app (helper->plugin_job));
+
+ for (guint i = 0; addons != NULL && i < gs_app_list_length (addons); i++) {
+ GsApp *addon = gs_app_list_index (addons, i);
+ if (gs_app_get_to_be_installed (addon))
+ gs_app_set_to_be_installed (addon, FALSE);
+ }
+ }
+
+ /* filter to reduce to a sane set */
+ gs_plugin_loader_job_sorted_truncation (helper->plugin_job, list);
+
+ /* set the local file on any of the returned results */
+ switch (action) {
+ case GS_PLUGIN_ACTION_FILE_TO_APP:
+ for (guint j = 0; j < gs_app_list_length (list); j++) {
+ GsApp *app = gs_app_list_index (list, j);
+ if (gs_app_get_local_file (app) == NULL)
+ gs_app_set_local_file (app, gs_plugin_job_get_file (helper->plugin_job));
+ }
+ default:
+ break;
+ }
+
+ /* pick up new source id */
+ switch (action) {
+ case GS_PLUGIN_ACTION_INSTALL:
+ case GS_PLUGIN_ACTION_REMOVE:
+ gs_plugin_job_add_refine_flags (helper->plugin_job,
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_ORIGIN |
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_SETUP_ACTION);
+ break;
+ default:
+ break;
+ }
+
+ /* run refine() on each one if required */
+ if (gs_plugin_job_get_refine_flags (helper->plugin_job) != 0 &&
+ list != NULL &&
+ gs_app_list_length (list) > 0) {
+ g_autoptr(GsPluginJob) refine_job = NULL;
+ g_autoptr(GAsyncResult) refine_result = NULL;
+ g_autoptr(GsAppList) new_list = NULL;
+
+ refine_job = gs_plugin_job_refine_new (list, gs_plugin_job_get_refine_flags (helper->plugin_job) | GS_PLUGIN_REFINE_FLAGS_DISABLE_FILTERING);
+ gs_plugin_loader_job_process_async (plugin_loader, refine_job,
+ cancellable,
+ async_result_cb,
+ &refine_result);
+
+ /* FIXME: Make this sync until the enclosing function is
+ * refactored to be async. */
+ while (refine_result == NULL)
+ g_main_context_iteration (g_main_context_get_thread_default (), TRUE);
+
+ new_list = gs_plugin_loader_job_process_finish (plugin_loader, refine_result, &error);
+ if (new_list == NULL) {
+ gs_utils_error_convert_gio (&error);
+ g_task_return_error (task, g_steal_pointer (&error));
+ return;
+ }
+
+ gs_plugin_loader_inherit_list_props (new_list, list);
+
+ /* Update the app list in case the refine resolved any wildcards. */
+ g_set_object (&list, new_list);
+ } else {
+ g_debug ("no refine flags set for transaction");
+ }
+
+ /* check the local files have an icon set */
+ switch (action) {
+ case GS_PLUGIN_ACTION_URL_TO_APP:
+ case GS_PLUGIN_ACTION_FILE_TO_APP: {
+ g_autoptr(GsPluginJob) refine_job = NULL;
+ g_autoptr(GAsyncResult) refine_result = NULL;
+ g_autoptr(GsAppList) new_list = NULL;
+
+ for (guint j = 0; j < gs_app_list_length (list); j++) {
+ GsApp *app = gs_app_list_index (list, j);
+ if (gs_app_get_icons (app) == NULL) {
+ g_autoptr(GIcon) ic = NULL;
+ const gchar *icon_name;
+ if (gs_app_has_quirk (app, GS_APP_QUIRK_HAS_SOURCE))
+ icon_name = "x-package-repository";
+ else
+ icon_name = "system-component-application";
+ ic = g_themed_icon_new (icon_name);
+ gs_app_add_icon (app, ic);
+ }
+ }
+
+ refine_job = gs_plugin_job_refine_new (list, GS_PLUGIN_REFINE_FLAGS_REQUIRE_ICON | GS_PLUGIN_REFINE_FLAGS_DISABLE_FILTERING);
+ gs_plugin_loader_job_process_async (plugin_loader, refine_job,
+ cancellable,
+ async_result_cb,
+ &refine_result);
+
+ /* FIXME: Make this sync until the enclosing function is
+ * refactored to be async. */
+ while (refine_result == NULL)
+ g_main_context_iteration (g_main_context_get_thread_default (), TRUE);
+
+ new_list = gs_plugin_loader_job_process_finish (plugin_loader, refine_result, &error);
+ if (new_list == NULL) {
+ gs_utils_error_convert_gio (&error);
+ g_task_return_error (task, g_steal_pointer (&error));
+ return;
+ }
+
+ gs_plugin_loader_inherit_list_props (new_list, list);
+
+ /* Update the app list in case the refine resolved any wildcards. */
+ g_set_object (&list, new_list);
+
+ break;
+ }
+ default:
+ break;
+ }
+
+ /* filter package list */
+ switch (action) {
+ case GS_PLUGIN_ACTION_URL_TO_APP:
+ gs_app_list_filter (list, gs_plugin_loader_app_is_valid_filter, helper);
+ break;
+ case GS_PLUGIN_ACTION_GET_UPDATES:
+ gs_app_list_filter (list, gs_plugin_loader_app_is_valid_updatable, helper);
+ break;
+ default:
+ break;
+ }
+
+ /* only allow one result */
+ if (action == GS_PLUGIN_ACTION_URL_TO_APP ||
+ action == GS_PLUGIN_ACTION_FILE_TO_APP) {
+ if (gs_app_list_length (list) == 0) {
+ g_autofree gchar *str = gs_plugin_job_to_string (helper->plugin_job);
+ g_autoptr(GError) error_local = NULL;
+ g_set_error (&error_local,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_NOT_SUPPORTED,
+ "no application was created for %s", str);
+ if (!gs_plugin_job_get_propagate_error (helper->plugin_job))
+ gs_plugin_loader_claim_job_error (plugin_loader, NULL, helper->plugin_job, error_local);
+ g_task_return_error (task, g_steal_pointer (&error_local));
+ return;
+ }
+ if (gs_app_list_length (list) > 1) {
+ g_autofree gchar *str = gs_plugin_job_to_string (helper->plugin_job);
+ g_debug ("more than one application was created for %s", str);
+ }
+ }
+
+ /* filter duplicates with priority, taking into account the source name
+ * & version, so we combine available updates with the installed app */
+ dedupe_flags = gs_plugin_job_get_dedupe_flags (helper->plugin_job);
+ if (dedupe_flags != GS_APP_LIST_FILTER_FLAG_NONE)
+ gs_app_list_filter_duplicates (list, dedupe_flags);
+
+ /* sort these again as the refine may have added useful metadata */
+ gs_plugin_loader_job_sorted_truncation_again (helper->plugin_job, list);
+
+#ifdef HAVE_SYSPROF
+ if (plugin_loader->sysprof_writer != NULL) {
+ g_autofree gchar *sysprof_name = g_strconcat ("process-thread:", gs_plugin_action_to_string (action), NULL);
+ g_autofree gchar *sysprof_message = gs_plugin_job_to_string (helper->plugin_job);
+ sysprof_capture_writer_add_mark (plugin_loader->sysprof_writer,
+ begin_time_nsec,
+ sched_getcpu (),
+ getpid (),
+ SYSPROF_CAPTURE_CURRENT_TIME - begin_time_nsec,
+ "gnome-software",
+ sysprof_name,
+ sysprof_message);
+ }
+#endif /* HAVE_SYSPROF */
+
+ /* show elapsed time */
+ job_debug = gs_plugin_job_to_string (helper->plugin_job);
+ g_debug ("%s", job_debug);
+
+ /* success */
+ g_task_return_pointer (task, g_object_ref (list), (GDestroyNotify) g_object_unref);
+}
+
+static void
+gs_plugin_loader_process_in_thread_pool_cb (gpointer data,
+ gpointer user_data)
+{
+ GTask *task = data;
+ gpointer source_object = g_task_get_source_object (task);
+ gpointer task_data = g_task_get_task_data (task);
+ GCancellable *cancellable = g_task_get_cancellable (task);
+ GsPluginLoaderHelper *helper = g_task_get_task_data (task);
+ GsApp *app = gs_plugin_job_get_app (helper->plugin_job);
+ GsPluginAction action = gs_plugin_job_get_action (helper->plugin_job);
+
+ gs_ioprio_set (G_PRIORITY_LOW);
+
+ gs_plugin_loader_process_thread_cb (task, source_object, task_data, cancellable);
+
+ /* Clear any pending action set in gs_plugin_loader_schedule_task() */
+ if (app != NULL && gs_app_get_pending_action (app) == action)
+ gs_app_set_pending_action (app, GS_PLUGIN_ACTION_UNKNOWN);
+
+ g_object_unref (task);
+}
+
+static void
+gs_plugin_loader_cancelled_cb (GCancellable *cancellable,
+ gpointer user_data)
+{
+ GCancellable *child_cancellable = G_CANCELLABLE (user_data);
+
+ /* just proxy this forward */
+ g_debug ("Cancelling job with cancellable %p", child_cancellable);
+ g_cancellable_cancel (child_cancellable);
+}
+
+static void
+gs_plugin_loader_schedule_task (GsPluginLoader *plugin_loader,
+ GTask *task)
+{
+ GsPluginLoaderHelper *helper = g_task_get_task_data (task);
+ GsApp *app = gs_plugin_job_get_app (helper->plugin_job);
+
+ if (app != NULL) {
+ /* set the pending-action to the app */
+ GsPluginAction action = gs_plugin_job_get_action (helper->plugin_job);
+ gs_app_set_pending_action (app, action);
+
+ if (action == GS_PLUGIN_ACTION_INSTALL &&
+ gs_app_get_state (app) != GS_APP_STATE_AVAILABLE_LOCAL)
+ add_app_to_install_queue (plugin_loader, app);
+ }
+ g_thread_pool_push (plugin_loader->queued_ops_pool, g_object_ref (task), NULL);
+}
+
+static void
+run_job_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data)
+{
+ GsPluginJob *plugin_job = GS_PLUGIN_JOB (source_object);
+ GsPluginJobClass *job_class;
+ g_autoptr(GTask) task = g_steal_pointer (&user_data);
+ g_autoptr(GError) local_error = NULL;
+#ifdef HAVE_SYSPROF
+ GsPluginLoader *plugin_loader = g_task_get_source_object (task);
+ gint64 begin_time_nsec = GPOINTER_TO_SIZE (g_task_get_task_data (task));
+
+ if (plugin_loader->sysprof_writer != NULL) {
+ g_autofree gchar *sysprof_name = g_strconcat ("process-thread:", G_OBJECT_TYPE_NAME (plugin_job), NULL);
+ g_autofree gchar *sysprof_message = gs_plugin_job_to_string (plugin_job);
+ sysprof_capture_writer_add_mark (plugin_loader->sysprof_writer,
+ begin_time_nsec,
+ sched_getcpu (),
+ getpid (),
+ SYSPROF_CAPTURE_CURRENT_TIME - begin_time_nsec,
+ "gnome-software",
+ sysprof_name,
+ sysprof_message);
+ }
+#endif /* HAVE_SYSPROF */
+
+ /* FIXME: This will eventually go away when
+ * gs_plugin_loader_job_process_finish() is removed. */
+ job_class = GS_PLUGIN_JOB_GET_CLASS (plugin_job);
+
+ g_assert (job_class->run_finish != NULL);
+
+ if (!job_class->run_finish (plugin_job, result, &local_error)) {
+ g_task_return_error (task, g_steal_pointer (&local_error));
+ return;
+ }
+
+ if (GS_IS_PLUGIN_JOB_REFINE (plugin_job)) {
+ GsAppList *list = gs_plugin_job_refine_get_result_list (GS_PLUGIN_JOB_REFINE (plugin_job));
+ g_task_return_pointer (task, g_object_ref (list), (GDestroyNotify) g_object_unref);
+ return;
+ } else if (GS_IS_PLUGIN_JOB_LIST_APPS (plugin_job)) {
+ GsAppList *list = gs_plugin_job_list_apps_get_result_list (GS_PLUGIN_JOB_LIST_APPS (plugin_job));
+ g_task_return_pointer (task, g_object_ref (list), (GDestroyNotify) g_object_unref);
+ return;
+ } else if (GS_IS_PLUGIN_JOB_LIST_DISTRO_UPGRADES (plugin_job)) {
+ GsAppList *list = gs_plugin_job_list_distro_upgrades_get_result_list (GS_PLUGIN_JOB_LIST_DISTRO_UPGRADES (plugin_job));
+ g_task_return_pointer (task, g_object_ref (list), (GDestroyNotify) g_object_unref);
+ return;
+ } else if (GS_IS_PLUGIN_JOB_REFRESH_METADATA (plugin_job)) {
+ /* FIXME: For some reason, existing callers of refresh jobs
+ * expect a #GsAppList instance back, even though it’s empty and
+ * they don’t use its contents. It’s just used to distinguish
+ * against returning an error. This will go away when
+ * job_process_async() does. */
+ g_task_return_pointer (task, gs_app_list_new (), g_object_unref);
+ return;
+ } else if (GS_IS_PLUGIN_JOB_MANAGE_REPOSITORY (plugin_job) ||
+ GS_IS_PLUGIN_JOB_LIST_CATEGORIES (plugin_job)) {
+ /* FIXME: The gs_plugin_loader_job_action_finish() expects a #GsAppList
+ * pointer on success, thus return it. */
+ g_task_return_pointer (task, gs_app_list_new (), g_object_unref);
+ return;
+ }
+
+ g_assert_not_reached ();
+}
+
+typedef struct {
+ GWeakRef parent_cancellable_weak;
+ gulong handler_id;
+} CancellableData;
+
+static void
+cancellable_data_free (CancellableData *data)
+{
+ g_autoptr(GCancellable) parent_cancellable = g_weak_ref_get (&data->parent_cancellable_weak);
+
+ if (parent_cancellable != NULL)
+ g_cancellable_disconnect (parent_cancellable, data->handler_id);
+
+ g_weak_ref_clear (&data->parent_cancellable_weak);
+ g_free (data);
+}
+
+G_DEFINE_AUTOPTR_CLEANUP_FUNC (CancellableData, cancellable_data_free)
+
+static void
+plugin_loader_task_freed_cb (gpointer user_data,
+ GObject *freed_object)
+{
+ g_autoptr(GsPluginLoader) plugin_loader = user_data;
+ if (g_atomic_int_dec_and_test (&plugin_loader->active_jobs)) {
+ /* if the plugin used updates-changed during its job, actually schedule
+ * the signal emission now */
+ if (plugin_loader->updates_changed_cnt > 0)
+ gs_plugin_loader_updates_changed (plugin_loader);
+ }
+}
+
+static gboolean job_process_setup_complete_cb (GCancellable *cancellable,
+ gpointer user_data);
+static void job_process_cb (GTask *task);
+
+/**
+ * gs_plugin_loader_job_process_async:
+ * @plugin_loader: A #GsPluginLoader
+ * @plugin_job: job to process
+ * @cancellable: a #GCancellable, or %NULL
+ * @callback: function to call when complete
+ * @user_data: user data to pass to @callback
+ *
+ * This method calls all plugins.
+ *
+ * If the #GsPluginLoader is still being set up, this function will wait until
+ * setup is complete before running.
+ **/
+void
+gs_plugin_loader_job_process_async (GsPluginLoader *plugin_loader,
+ GsPluginJob *plugin_job,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data)
+{
+ GsPluginJobClass *job_class;
+ GsPluginAction action;
+ g_autoptr(GTask) task = NULL;
+ g_autoptr(GCancellable) cancellable_job = NULL;
+ g_autofree gchar *task_name = NULL;
+
+ g_return_if_fail (GS_IS_PLUGIN_LOADER (plugin_loader));
+ g_return_if_fail (GS_IS_PLUGIN_JOB (plugin_job));
+ g_return_if_fail (cancellable == NULL || G_IS_CANCELLABLE (cancellable));
+
+ job_class = GS_PLUGIN_JOB_GET_CLASS (plugin_job);
+ action = gs_plugin_job_get_action (plugin_job);
+
+ if (job_class->run_async != NULL) {
+ task_name = g_strdup_printf ("%s %s", G_STRFUNC, G_OBJECT_TYPE_NAME (plugin_job));
+ cancellable_job = (cancellable != NULL) ? g_object_ref (cancellable) : NULL;
+ } else {
+ task_name = g_strdup_printf ("%s %s", G_STRFUNC, gs_plugin_action_to_string (action));
+ cancellable_job = g_cancellable_new ();
+
+ /* Old-style jobs always have a valid cancellable, so proxy the caller */
+ g_debug ("Chaining cancellation from %p to %p", cancellable, cancellable_job);
+ if (cancellable != NULL) {
+ g_autoptr(CancellableData) cancellable_data = NULL;
+
+ cancellable_data = g_new0 (CancellableData, 1);
+ g_weak_ref_init (&cancellable_data->parent_cancellable_weak, cancellable);
+ cancellable_data->handler_id = g_cancellable_connect (cancellable,
+ G_CALLBACK (gs_plugin_loader_cancelled_cb),
+ cancellable_job, NULL);
+
+ g_object_set_data_full (G_OBJECT (cancellable_job),
+ "gs-cancellable-chain",
+ g_steal_pointer (&cancellable_data),
+ (GDestroyNotify) cancellable_data_free);
+ }
+ }
+
+ task = g_task_new (plugin_loader, cancellable_job, callback, user_data);
+ g_task_set_name (task, task_name);
+ g_task_set_task_data (task, g_object_ref (plugin_job), (GDestroyNotify) g_object_unref);
+
+ g_atomic_int_inc (&plugin_loader->active_jobs);
+ g_object_weak_ref (G_OBJECT (task),
+ plugin_loader_task_freed_cb, g_object_ref (plugin_loader));
+
+ /* Wait until the plugin has finished setting up.
+ *
+ * Do this using a #GCancellable. While we’re not using the #GCancellable
+ * to cancel anything, it is a reliable way to signal between threads
+ * without polling, waking up all waiting #GMainContexts when it’s
+ * ‘cancelled’. */
+ if (plugin_loader->setup_complete) {
+ job_process_cb (task);
+ } else {
+ g_autoptr(GSource) cancellable_source = g_cancellable_source_new (plugin_loader->setup_complete_cancellable);
+ g_task_attach_source (task, cancellable_source, G_SOURCE_FUNC (job_process_setup_complete_cb));
+ }
+}
+
+static gboolean
+job_process_setup_complete_cb (GCancellable *cancellable,
+ gpointer user_data)
+{
+ GTask *task = G_TASK (user_data);
+
+ job_process_cb (task);
+
+ return G_SOURCE_REMOVE;
+}
+
+static void
+job_process_cb (GTask *task)
+{
+ g_autoptr(GsPluginJob) plugin_job = g_object_ref (g_task_get_task_data (task));
+ GsPluginLoader *plugin_loader = g_task_get_source_object (task);
+ GCancellable *cancellable = g_task_get_cancellable (task);
+ GsPluginJobClass *job_class;
+ GsPluginAction action;
+ GsPluginLoaderHelper *helper;
+
+ job_class = GS_PLUGIN_JOB_GET_CLASS (plugin_job);
+ action = gs_plugin_job_get_action (plugin_job);
+
+ /* If the job provides a more specific async run function, use that.
+ *
+ * FIXME: This will eventually go away when
+ * gs_plugin_loader_job_process_async() is removed. */
+
+ if (job_class->run_async != NULL) {
+#ifdef HAVE_SYSPROF
+ gint64 begin_time_nsec G_GNUC_UNUSED = SYSPROF_CAPTURE_CURRENT_TIME;
+
+ g_task_set_task_data (task, GSIZE_TO_POINTER (begin_time_nsec), NULL);
+#endif
+
+ job_class->run_async (plugin_job, plugin_loader, cancellable,
+ run_job_cb, g_object_ref (task));
+ return;
+ }
+
+ /* check job has valid action */
+ if (action == GS_PLUGIN_ACTION_UNKNOWN) {
+ g_autofree gchar *job_str = gs_plugin_job_to_string (plugin_job);
+ g_task_return_new_error (task,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_NOT_SUPPORTED,
+ "job has no valid action: %s", job_str);
+ return;
+ }
+
+ /* deal with the install queue */
+ if (action == GS_PLUGIN_ACTION_REMOVE) {
+ if (remove_app_from_install_queue (plugin_loader, gs_plugin_job_get_app (plugin_job))) {
+ GsAppList *list = gs_plugin_job_get_list (plugin_job);
+ g_task_return_pointer (task, g_object_ref (list), (GDestroyNotify) g_object_unref);
+ return;
+ }
+ }
+
+ /* FIXME: the plugins should specify this, rather than hardcoding */
+ if (gs_plugin_job_has_refine_flags (plugin_job,
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_ORIGIN_UI)) {
+ gs_plugin_job_add_refine_flags (plugin_job,
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_ORIGIN);
+ }
+ if (gs_plugin_job_has_refine_flags (plugin_job,
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_ORIGIN_HOSTNAME)) {
+ gs_plugin_job_add_refine_flags (plugin_job,
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_ORIGIN);
+ }
+ if (gs_plugin_job_has_refine_flags (plugin_job,
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_SIZE)) {
+ gs_plugin_job_add_refine_flags (plugin_job,
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_RUNTIME);
+ }
+
+ /* FIXME: this is probably a bug */
+ if (action == GS_PLUGIN_ACTION_GET_SOURCES) {
+ gs_plugin_job_add_refine_flags (plugin_job,
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_SETUP_ACTION);
+ }
+
+ /* check required args */
+ switch (action) {
+ case GS_PLUGIN_ACTION_URL_TO_APP:
+ if (gs_plugin_job_get_search (plugin_job) == NULL) {
+ g_task_return_new_error (task,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_NOT_SUPPORTED,
+ "no valid search terms");
+ return;
+ }
+ break;
+ default:
+ break;
+ }
+
+ /* save helper */
+ helper = gs_plugin_loader_helper_new (plugin_loader, plugin_job);
+ g_task_set_task_data (task, helper, (GDestroyNotify) gs_plugin_loader_helper_free);
+
+ /* let the task cancel itself */
+ g_task_set_check_cancellable (task, FALSE);
+ g_task_set_return_on_cancel (task, FALSE);
+
+ switch (action) {
+ case GS_PLUGIN_ACTION_INSTALL:
+ case GS_PLUGIN_ACTION_UPDATE:
+ case GS_PLUGIN_ACTION_UPGRADE_DOWNLOAD:
+ /* these actions must be performed by the thread pool because we
+ * want to limit the number of them running in parallel */
+ gs_plugin_loader_schedule_task (plugin_loader, task);
+ return;
+ default:
+ break;
+ }
+
+ /* run in a thread */
+ g_task_run_in_thread (task, gs_plugin_loader_process_thread_cb);
+}
+
+/******************************************************************************/
+
+/**
+ * gs_plugin_loader_get_plugin_supported:
+ * @plugin_loader: A #GsPluginLoader
+ * @function_name: a function name
+ *
+ * This function returns TRUE if the symbol is found in any enabled plugin.
+ */
+gboolean
+gs_plugin_loader_get_plugin_supported (GsPluginLoader *plugin_loader,
+ const gchar *function_name)
+{
+ for (guint i = 0; i < plugin_loader->plugins->len; i++) {
+ GsPlugin *plugin = g_ptr_array_index (plugin_loader->plugins, i);
+ if (gs_plugin_get_symbol (plugin, function_name) != NULL)
+ return TRUE;
+ }
+ return FALSE;
+}
+
+/**
+ * gs_plugin_loader_get_plugins:
+ * @plugin_loader: a #GsPluginLoader
+ *
+ * Get the set of currently loaded plugins.
+ *
+ * This includes disabled plugins, which should be checked for using
+ * gs_plugin_get_enabled().
+ *
+ * This is intended to be used by internal gnome-software code. Plugin and UI
+ * code should typically use #GsPluginJob to run operations.
+ *
+ * Returns: (transfer none) (element-type GsPlugin): list of #GsPlugins
+ * Since: 42
+ */
+GPtrArray *
+gs_plugin_loader_get_plugins (GsPluginLoader *plugin_loader)
+{
+ g_return_val_if_fail (GS_IS_PLUGIN_LOADER (plugin_loader), NULL);
+
+ return plugin_loader->plugins;
+}
+
+static void app_create_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data);
+
+/**
+ * gs_plugin_loader_app_create_async:
+ * @plugin_loader: a #GsPluginLoader
+ * @unique_id: a unique_id
+ * @cancellable: a #GCancellable, or %NULL
+ * @callback: function to call when complete
+ * @user_data: user data to pass to @callback
+ *
+ * Create a #GsApp identified by @unique_id asynchronously.
+ * Finish the call with gs_plugin_loader_app_create_finish().
+ *
+ * If the #GsPluginLoader is still being set up, this function will wait until
+ * setup is complete before running.
+ *
+ * Since: 41
+ **/
+void
+gs_plugin_loader_app_create_async (GsPluginLoader *plugin_loader,
+ const gchar *unique_id,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data)
+{
+ g_autoptr(GTask) task = NULL;
+ g_autoptr(GsApp) app = NULL;
+ g_autoptr(GsAppList) list = gs_app_list_new ();
+ g_autoptr(GsPluginJob) refine_job = NULL;
+
+ g_return_if_fail (GS_IS_PLUGIN_LOADER (plugin_loader));
+ g_return_if_fail (unique_id != NULL);
+ g_return_if_fail (cancellable == NULL || G_IS_CANCELLABLE (cancellable));
+
+ task = g_task_new (plugin_loader, cancellable, callback, user_data);
+ g_task_set_source_tag (task, gs_plugin_loader_app_create_async);
+ g_task_set_task_data (task, g_strdup (unique_id), g_free);
+
+ /* use the plugin loader to convert a wildcard app */
+ app = gs_app_new (NULL);
+ gs_app_add_quirk (app, GS_APP_QUIRK_IS_WILDCARD);
+ gs_app_set_from_unique_id (app, unique_id, AS_COMPONENT_KIND_UNKNOWN);
+ gs_app_list_add (list, app);
+
+ /* Refine the wildcard app. */
+ refine_job = gs_plugin_job_refine_new (list, GS_PLUGIN_REFINE_FLAGS_REQUIRE_ID | GS_PLUGIN_REFINE_FLAGS_DISABLE_FILTERING);
+ gs_plugin_loader_job_process_async (plugin_loader, refine_job,
+ cancellable,
+ app_create_cb,
+ g_steal_pointer (&task));
+}
+
+static void
+app_create_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data)
+{
+ g_autoptr(GTask) task = g_steal_pointer (&user_data);
+ GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (g_task_get_source_object (task));
+ const gchar *unique_id = g_task_get_task_data (task);
+ g_autoptr(GsAppList) list = NULL;
+ g_autoptr(GError) local_error = NULL;
+
+ list = gs_plugin_loader_job_process_finish (plugin_loader, result, &local_error);
+ if (list == NULL) {
+ g_prefix_error (&local_error, "Failed to refine '%s': ", unique_id);
+ g_task_return_error (task, g_steal_pointer (&local_error));
+ return;
+ }
+
+ /* return the matching GsApp */
+ for (guint i = 0; i < gs_app_list_length (list); i++) {
+ GsApp *app_tmp = gs_app_list_index (list, i);
+ if (g_strcmp0 (unique_id, gs_app_get_unique_id (app_tmp)) == 0) {
+ g_task_return_pointer (task, g_object_ref (app_tmp), g_object_unref);
+ return;
+ }
+ }
+
+ /* return the first returned app that's not a wildcard */
+ for (guint i = 0; i < gs_app_list_length (list); i++) {
+ GsApp *app_tmp = gs_app_list_index (list, i);
+ if (!gs_app_has_quirk (app_tmp, GS_APP_QUIRK_IS_WILDCARD)) {
+ g_debug ("returning imperfect match: %s != %s",
+ unique_id, gs_app_get_unique_id (app_tmp));
+ g_task_return_pointer (task, g_object_ref (app_tmp), g_object_unref);
+ return;
+ }
+ }
+
+ /* does not exist */
+ g_task_return_new_error (task,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_FAILED,
+ "Failed to create an app for '%s'", unique_id);
+}
+
+/**
+ * gs_plugin_loader_app_create_finish:
+ * @plugin_loader: a #GsPluginLoader
+ * @res: a #GAsyncResult
+ * @error: A #GError, or %NULL
+ *
+ * Finishes call to gs_plugin_loader_app_create_async().
+ *
+ * Returns: (transfer full): a #GsApp, or %NULL on error.
+ *
+ * Since: 41
+ **/
+GsApp *
+gs_plugin_loader_app_create_finish (GsPluginLoader *plugin_loader,
+ GAsyncResult *res,
+ GError **error)
+{
+ GsApp *app;
+
+ g_return_val_if_fail (GS_IS_PLUGIN_LOADER (plugin_loader), NULL);
+ g_return_val_if_fail (G_IS_TASK (res), NULL);
+ g_return_val_if_fail (g_task_is_valid (res, plugin_loader), NULL);
+ g_return_val_if_fail (error == NULL || *error == NULL, NULL);
+
+ app = g_task_propagate_pointer (G_TASK (res), error);
+ gs_utils_error_convert_gio (error);
+ return app;
+}
+
+/**
+ * gs_plugin_loader_get_system_app_async:
+ * @plugin_loader: a #GsPluginLoader
+ * @cancellable: a #GCancellable, or %NULL
+ * @callback: function to call when complete
+ * @user_data: user data to pass to @callback
+ *
+ * Get the application that represents the currently installed OS
+ * asynchronously. Finish the call with gs_plugin_loader_get_system_app_finish().
+ *
+ * Since: 41
+ **/
+void
+gs_plugin_loader_get_system_app_async (GsPluginLoader *plugin_loader,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data)
+{
+ gs_plugin_loader_app_create_async (plugin_loader, "*/*/*/system/*", cancellable, callback, user_data);
+}
+
+/**
+ * gs_plugin_loader_get_system_app_finish:
+ * @plugin_loader: a #GsPluginLoader
+ * @res: a #GAsyncResult
+ * @error: A #GError, or %NULL
+ *
+ * Finishes call to gs_plugin_loader_get_system_app_async().
+ *
+ * Returns: (transfer full): a #GsApp, which represents
+ * the currently installed OS, or %NULL on error.
+ *
+ * Since: 41
+ **/
+GsApp *
+gs_plugin_loader_get_system_app_finish (GsPluginLoader *plugin_loader,
+ GAsyncResult *res,
+ GError **error)
+{
+ return gs_plugin_loader_app_create_finish (plugin_loader, res, error);
+}
+
+/**
+ * gs_plugin_loader_get_odrs_provider:
+ * @plugin_loader: a #GsPluginLoader
+ *
+ * Get the singleton #GsOdrsProvider which provides access to ratings and
+ * reviews data from ODRS.
+ *
+ * Returns: (transfer none) (nullable): a #GsOdrsProvider, or %NULL if disabled
+ * Since: 41
+ */
+GsOdrsProvider *
+gs_plugin_loader_get_odrs_provider (GsPluginLoader *plugin_loader)
+{
+ g_return_val_if_fail (GS_IS_PLUGIN_LOADER (plugin_loader), NULL);
+
+ return plugin_loader->odrs_provider;
+}
+
+/**
+ * gs_plugin_loader_set_max_parallel_ops:
+ * @plugin_loader: a #GsPluginLoader
+ * @max_ops: the maximum number of parallel operations
+ *
+ * Sets the number of maximum number of queued operations (install/update/upgrade-download)
+ * to be processed at a time. If @max_ops is 0, then it will set the default maximum number.
+ */
+void
+gs_plugin_loader_set_max_parallel_ops (GsPluginLoader *plugin_loader,
+ guint max_ops)
+{
+ g_autoptr(GError) error = NULL;
+ if (max_ops == 0)
+ max_ops = get_max_parallel_ops ();
+ if (!g_thread_pool_set_max_threads (plugin_loader->queued_ops_pool, max_ops, &error))
+ g_warning ("Failed to set the maximum number of ops in parallel: %s",
+ error->message);
+}
+
+/**
+ * gs_plugin_loader_get_category_manager:
+ * @plugin_loader: a #GsPluginLoader
+ *
+ * Get the category manager singleton.
+ *
+ * Returns: (transfer none): a category manager
+ * Since: 40
+ */
+GsCategoryManager *
+gs_plugin_loader_get_category_manager (GsPluginLoader *plugin_loader)
+{
+ g_return_val_if_fail (GS_IS_PLUGIN_LOADER (plugin_loader), NULL);
+
+ return plugin_loader->category_manager;
+}
+
+/**
+ * gs_plugin_loader_emit_updates_changed:
+ * @self: a #GsPluginLoader
+ *
+ * Emits the #GsPluginLoader:updates-changed signal in the nearest
+ * idle in the main thread.
+ *
+ * Since: 43
+ **/
+void
+gs_plugin_loader_emit_updates_changed (GsPluginLoader *self)
+{
+ g_return_if_fail (GS_IS_PLUGIN_LOADER (self));
+
+ if (self->updates_changed_id != 0)
+ g_source_remove (self->updates_changed_id);
+
+ self->updates_changed_id =
+ g_idle_add_full (G_PRIORITY_HIGH_IDLE,
+ gs_plugin_loader_job_updates_changed_delay_cb,
+ g_object_ref (self), g_object_unref);
+}
diff --git a/lib/gs-plugin-loader.h b/lib/gs-plugin-loader.h
new file mode 100644
index 0000000..93a44d6
--- /dev/null
+++ b/lib/gs-plugin-loader.h
@@ -0,0 +1,122 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2007-2017 Richard Hughes <richard@hughsie.com>
+ * Copyright (C) 2015-2020 Kalev Lember <klember@redhat.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#include <glib-object.h>
+
+#include "gs-app.h"
+#include "gs-category.h"
+#include "gs-category-manager.h"
+#include "gs-odrs-provider.h"
+#include "gs-plugin-event.h"
+#include "gs-plugin.h"
+
+G_BEGIN_DECLS
+
+#define GS_TYPE_PLUGIN_LOADER (gs_plugin_loader_get_type ())
+G_DECLARE_FINAL_TYPE (GsPluginLoader, gs_plugin_loader, GS, PLUGIN_LOADER, GObject)
+
+#include "gs-plugin-job.h"
+
+GsPluginLoader *gs_plugin_loader_new (GDBusConnection *session_bus_connection,
+ GDBusConnection *system_bus_connection);
+void gs_plugin_loader_job_process_async (GsPluginLoader *plugin_loader,
+ GsPluginJob *plugin_job,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data);
+GsAppList *gs_plugin_loader_job_process_finish (GsPluginLoader *plugin_loader,
+ GAsyncResult *res,
+ GError **error);
+gboolean gs_plugin_loader_job_action_finish (GsPluginLoader *plugin_loader,
+ GAsyncResult *res,
+ GError **error);
+void gs_plugin_loader_setup_async (GsPluginLoader *plugin_loader,
+ const gchar * const *allowlist,
+ const gchar * const *blocklist,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data);
+gboolean gs_plugin_loader_setup_finish (GsPluginLoader *plugin_loader,
+ GAsyncResult *result,
+ GError **error);
+
+void gs_plugin_loader_shutdown (GsPluginLoader *plugin_loader,
+ GCancellable *cancellable);
+
+void gs_plugin_loader_dump_state (GsPluginLoader *plugin_loader);
+gboolean gs_plugin_loader_get_enabled (GsPluginLoader *plugin_loader,
+ const gchar *plugin_name);
+void gs_plugin_loader_add_location (GsPluginLoader *plugin_loader,
+ const gchar *location);
+guint gs_plugin_loader_get_scale (GsPluginLoader *plugin_loader);
+void gs_plugin_loader_set_scale (GsPluginLoader *plugin_loader,
+ guint scale);
+GsAppList *gs_plugin_loader_get_pending (GsPluginLoader *plugin_loader);
+gboolean gs_plugin_loader_get_allow_updates (GsPluginLoader *plugin_loader);
+gboolean gs_plugin_loader_get_network_available (GsPluginLoader *plugin_loader);
+gboolean gs_plugin_loader_get_network_metered (GsPluginLoader *plugin_loader);
+gboolean gs_plugin_loader_get_plugin_supported (GsPluginLoader *plugin_loader,
+ const gchar *function_name);
+
+GPtrArray *gs_plugin_loader_get_plugins (GsPluginLoader *plugin_loader);
+
+void gs_plugin_loader_add_event (GsPluginLoader *plugin_loader,
+ GsPluginEvent *event);
+GPtrArray *gs_plugin_loader_get_events (GsPluginLoader *plugin_loader);
+GsPluginEvent *gs_plugin_loader_get_event_default (GsPluginLoader *plugin_loader);
+void gs_plugin_loader_remove_events (GsPluginLoader *plugin_loader);
+
+void gs_plugin_loader_app_create_async (GsPluginLoader *plugin_loader,
+ const gchar *unique_id,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data);
+GsApp *gs_plugin_loader_app_create_finish (GsPluginLoader *plugin_loader,
+ GAsyncResult *res,
+ GError **error);
+void gs_plugin_loader_get_system_app_async (GsPluginLoader *plugin_loader,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data);
+GsApp *gs_plugin_loader_get_system_app_finish (GsPluginLoader *plugin_loader,
+ GAsyncResult *res,
+ GError **error);
+GsOdrsProvider *gs_plugin_loader_get_odrs_provider (GsPluginLoader *plugin_loader);
+
+/* only useful from the self tests */
+void gs_plugin_loader_clear_caches (GsPluginLoader *plugin_loader);
+GsPlugin *gs_plugin_loader_find_plugin (GsPluginLoader *plugin_loader,
+ const gchar *plugin_name);
+void gs_plugin_loader_set_max_parallel_ops (GsPluginLoader *plugin_loader,
+ guint max_ops);
+
+GsCategoryManager *gs_plugin_loader_get_category_manager (GsPluginLoader *plugin_loader);
+void gs_plugin_loader_claim_error (GsPluginLoader *plugin_loader,
+ GsPlugin *plugin,
+ GsPluginAction action,
+ GsApp *app,
+ gboolean interactive,
+ const GError *error);
+void gs_plugin_loader_claim_job_error (GsPluginLoader *plugin_loader,
+ GsPlugin *plugin,
+ GsPluginJob *job,
+ const GError *error);
+
+gboolean gs_plugin_loader_app_is_valid (GsApp *app,
+ GsPluginRefineFlags flags);
+gboolean gs_plugin_loader_app_is_compatible (GsPluginLoader *plugin_loader,
+ GsApp *app);
+
+void gs_plugin_loader_run_adopt (GsPluginLoader *plugin_loader,
+ GsAppList *list);
+void gs_plugin_loader_emit_updates_changed (GsPluginLoader *self);
+
+G_END_DECLS
diff --git a/lib/gs-plugin-private.h b/lib/gs-plugin-private.h
new file mode 100644
index 0000000..3a44856
--- /dev/null
+++ b/lib/gs-plugin-private.h
@@ -0,0 +1,57 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2016 Richard Hughes <richard@hughsie.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#include <gio/gio.h>
+#include <glib-object.h>
+#include <gmodule.h>
+
+#include "gs-plugin.h"
+
+G_BEGIN_DECLS
+
+GsPlugin *gs_plugin_new (GDBusConnection *session_bus_connection,
+ GDBusConnection *system_bus_connection);
+GsPlugin *gs_plugin_create (const gchar *filename,
+ GDBusConnection *session_bus_connection,
+ GDBusConnection *system_bus_connection,
+ GError **error);
+const gchar *gs_plugin_error_to_string (GsPluginError error);
+const gchar *gs_plugin_action_to_string (GsPluginAction action);
+GsPluginAction gs_plugin_action_from_string (const gchar *action);
+const gchar *gs_plugin_action_to_function_name (GsPluginAction action);
+
+void gs_plugin_set_scale (GsPlugin *plugin,
+ guint scale);
+guint gs_plugin_get_order (GsPlugin *plugin);
+void gs_plugin_set_order (GsPlugin *plugin,
+ guint order);
+guint gs_plugin_get_priority (GsPlugin *plugin);
+void gs_plugin_set_priority (GsPlugin *plugin,
+ guint priority);
+void gs_plugin_set_name (GsPlugin *plugin,
+ const gchar *name);
+void gs_plugin_set_language (GsPlugin *plugin,
+ const gchar *language);
+void gs_plugin_set_auth_array (GsPlugin *plugin,
+ GPtrArray *auth_array);
+GPtrArray *gs_plugin_get_rules (GsPlugin *plugin,
+ GsPluginRule rule);
+gpointer gs_plugin_get_symbol (GsPlugin *plugin,
+ const gchar *function_name);
+void gs_plugin_interactive_inc (GsPlugin *plugin);
+void gs_plugin_interactive_dec (GsPlugin *plugin);
+gchar *gs_plugin_refine_flags_to_string (GsPluginRefineFlags refine_flags);
+void gs_plugin_set_network_monitor (GsPlugin *plugin,
+ GNetworkMonitor *monitor);
+
+GDBusConnection *gs_plugin_get_session_bus_connection (GsPlugin *self);
+GDBusConnection *gs_plugin_get_system_bus_connection (GsPlugin *self);
+
+G_END_DECLS
diff --git a/lib/gs-plugin-types.h b/lib/gs-plugin-types.h
new file mode 100644
index 0000000..59504be
--- /dev/null
+++ b/lib/gs-plugin-types.h
@@ -0,0 +1,319 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2012-2018 Richard Hughes <richard@hughsie.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#include <glib-object.h>
+
+G_BEGIN_DECLS
+
+/**
+ * GsPluginStatus:
+ * @GS_PLUGIN_STATUS_UNKNOWN: Unknown status
+ * @GS_PLUGIN_STATUS_WAITING: Waiting
+ * @GS_PLUGIN_STATUS_FINISHED: Finished
+ * @GS_PLUGIN_STATUS_SETUP: Setup in progress
+ * @GS_PLUGIN_STATUS_DOWNLOADING: Downloading in progress
+ * @GS_PLUGIN_STATUS_QUERYING: Querying in progress
+ * @GS_PLUGIN_STATUS_INSTALLING: Installing in progress
+ * @GS_PLUGIN_STATUS_REMOVING: Removing in progress
+ *
+ * The status of the plugin.
+ **/
+typedef enum {
+ GS_PLUGIN_STATUS_UNKNOWN,
+ GS_PLUGIN_STATUS_WAITING,
+ GS_PLUGIN_STATUS_FINISHED,
+ GS_PLUGIN_STATUS_SETUP,
+ GS_PLUGIN_STATUS_DOWNLOADING,
+ GS_PLUGIN_STATUS_QUERYING,
+ GS_PLUGIN_STATUS_INSTALLING,
+ GS_PLUGIN_STATUS_REMOVING,
+ GS_PLUGIN_STATUS_LAST /*< skip >*/
+} GsPluginStatus;
+
+/**
+ * GsPluginFlags:
+ * @GS_PLUGIN_FLAGS_NONE: No flags set
+ * @GS_PLUGIN_FLAGS_INTERACTIVE: User initiated the job
+ *
+ * The flags for the plugin at this point in time.
+ **/
+typedef enum {
+ GS_PLUGIN_FLAGS_NONE = 0,
+ GS_PLUGIN_FLAGS_INTERACTIVE = 1 << 4,
+} GsPluginFlags;
+
+/**
+ * GsPluginError:
+ * @GS_PLUGIN_ERROR_FAILED: Generic failure
+ * @GS_PLUGIN_ERROR_NOT_SUPPORTED: Action not supported
+ * @GS_PLUGIN_ERROR_CANCELLED: Action was cancelled
+ * @GS_PLUGIN_ERROR_NO_NETWORK: No network connection available
+ * @GS_PLUGIN_ERROR_NO_SECURITY: Security policy forbid action
+ * @GS_PLUGIN_ERROR_NO_SPACE: No disk space to allow action
+ * @GS_PLUGIN_ERROR_AUTH_REQUIRED: Authentication was required
+ * @GS_PLUGIN_ERROR_AUTH_INVALID: Provided authentication was invalid
+ * @GS_PLUGIN_ERROR_PLUGIN_DEPSOLVE_FAILED: The plugins installed are incompatible
+ * @GS_PLUGIN_ERROR_DOWNLOAD_FAILED: The download action failed
+ * @GS_PLUGIN_ERROR_WRITE_FAILED: The save-to-disk failed
+ * @GS_PLUGIN_ERROR_INVALID_FORMAT: The data format is invalid
+ * @GS_PLUGIN_ERROR_DELETE_FAILED: The delete action failed
+ * @GS_PLUGIN_ERROR_RESTART_REQUIRED: A restart is required
+ * @GS_PLUGIN_ERROR_AC_POWER_REQUIRED: AC power is required
+ * @GS_PLUGIN_ERROR_TIMED_OUT: The job timed out
+ * @GS_PLUGIN_ERROR_BATTERY_LEVEL_TOO_LOW: The system battery level is too low
+ *
+ * The failure error types.
+ **/
+typedef enum {
+ GS_PLUGIN_ERROR_FAILED,
+ GS_PLUGIN_ERROR_NOT_SUPPORTED,
+ GS_PLUGIN_ERROR_CANCELLED,
+ GS_PLUGIN_ERROR_NO_NETWORK,
+ GS_PLUGIN_ERROR_NO_SECURITY,
+ GS_PLUGIN_ERROR_NO_SPACE,
+ GS_PLUGIN_ERROR_AUTH_REQUIRED,
+ GS_PLUGIN_ERROR_AUTH_INVALID,
+ GS_PLUGIN_ERROR_PLUGIN_DEPSOLVE_FAILED,
+ GS_PLUGIN_ERROR_DOWNLOAD_FAILED,
+ GS_PLUGIN_ERROR_WRITE_FAILED,
+ GS_PLUGIN_ERROR_INVALID_FORMAT,
+ GS_PLUGIN_ERROR_DELETE_FAILED,
+ GS_PLUGIN_ERROR_RESTART_REQUIRED,
+ GS_PLUGIN_ERROR_AC_POWER_REQUIRED,
+ GS_PLUGIN_ERROR_TIMED_OUT,
+ GS_PLUGIN_ERROR_BATTERY_LEVEL_TOO_LOW,
+ GS_PLUGIN_ERROR_LAST /*< skip >*/
+} GsPluginError;
+
+/**
+ * GsPluginRefineFlags:
+ * @GS_PLUGIN_REFINE_FLAGS_NONE: No explicit flags set
+ * @GS_PLUGIN_REFINE_FLAGS_REQUIRE_ID: Require the app’s ID; this is the minimum possible requirement
+ * @GS_PLUGIN_REFINE_FLAGS_REQUIRE_LICENSE: Require the license
+ * @GS_PLUGIN_REFINE_FLAGS_REQUIRE_URL: Require the URL
+ * @GS_PLUGIN_REFINE_FLAGS_REQUIRE_DESCRIPTION: Require the long description
+ * @GS_PLUGIN_REFINE_FLAGS_REQUIRE_SIZE: Require the installed and download sizes
+ * @GS_PLUGIN_REFINE_FLAGS_REQUIRE_RATING: Require the rating
+ * @GS_PLUGIN_REFINE_FLAGS_REQUIRE_VERSION: Require the version
+ * @GS_PLUGIN_REFINE_FLAGS_REQUIRE_HISTORY: Require the history
+ * @GS_PLUGIN_REFINE_FLAGS_REQUIRE_SETUP_ACTION: Require enough to install or remove the package
+ * @GS_PLUGIN_REFINE_FLAGS_REQUIRE_UPDATE_DETAILS: Require update details
+ * @GS_PLUGIN_REFINE_FLAGS_REQUIRE_ORIGIN: Require the origin
+ * @GS_PLUGIN_REFINE_FLAGS_REQUIRE_RELATED: Require related packages
+ * @GS_PLUGIN_REFINE_FLAGS_REQUIRE_ADDONS: Require available addons
+ * @GS_PLUGIN_REFINE_FLAGS_ALLOW_PACKAGES: Allow packages to be returned
+ * @GS_PLUGIN_REFINE_FLAGS_REQUIRE_UPDATE_SEVERITY: Require update severity
+ * @GS_PLUGIN_REFINE_FLAGS_REQUIRE_UPGRADE_REMOVED: Require distro upgrades
+ * @GS_PLUGIN_REFINE_FLAGS_REQUIRE_PROVENANCE: Require the provenance
+ * @GS_PLUGIN_REFINE_FLAGS_REQUIRE_REVIEWS: Require user-reviews
+ * @GS_PLUGIN_REFINE_FLAGS_REQUIRE_REVIEW_RATINGS: Require user-ratings
+ * @GS_PLUGIN_REFINE_FLAGS_DISABLE_FILTERING: Normally the results of a refine are
+ * filtered to remove non-valid apps; if this flag is set, that won’t happen.
+ * This is intended to be used by internal #GsPluginLoader code. (Since: 42)
+ * @GS_PLUGIN_REFINE_FLAGS_REQUIRE_ICON: Require the icon to be loaded
+ * @GS_PLUGIN_REFINE_FLAGS_REQUIRE_PERMISSIONS: Require the needed permissions
+ * @GS_PLUGIN_REFINE_FLAGS_REQUIRE_ORIGIN_HOSTNAME: Require the origin hostname
+ * @GS_PLUGIN_REFINE_FLAGS_REQUIRE_ORIGIN_UI: Require the origin for UI
+ * @GS_PLUGIN_REFINE_FLAGS_REQUIRE_RUNTIME: Require the runtime
+ * @GS_PLUGIN_REFINE_FLAGS_REQUIRE_SCREENSHOTS: Require screenshot information
+ * @GS_PLUGIN_REFINE_FLAGS_REQUIRE_CATEGORIES: Require categories
+ * @GS_PLUGIN_REFINE_FLAGS_REQUIRE_PROJECT_GROUP: Require project group
+ * @GS_PLUGIN_REFINE_FLAGS_REQUIRE_DEVELOPER_NAME: Require developer name
+ * @GS_PLUGIN_REFINE_FLAGS_REQUIRE_KUDOS: Require kudos
+ * @GS_PLUGIN_REFINE_FLAGS_REQUIRE_CONTENT_RATING: Require content rating
+ * @GS_PLUGIN_REFINE_FLAGS_REQUIRE_SIZE_DATA: Require user and cache data sizes (Since: 41)
+ * @GS_PLUGIN_REFINE_FLAGS_MASK: All flags (Since: 40)
+ *
+ * The refine flags.
+ **/
+typedef enum {
+ GS_PLUGIN_REFINE_FLAGS_NONE = 0,
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_ID = 1 << 0,
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_LICENSE = 1 << 1,
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_URL = 1 << 2,
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_DESCRIPTION = 1 << 3,
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_SIZE = 1 << 4,
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_RATING = 1 << 5,
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_VERSION = 1 << 6,
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_HISTORY = 1 << 7,
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_SETUP_ACTION = 1 << 8,
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_UPDATE_DETAILS = 1 << 9,
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_ORIGIN = 1 << 10,
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_RELATED = 1 << 11,
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_SIZE_DATA = 1 << 12,
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_ADDONS = 1 << 13,
+ GS_PLUGIN_REFINE_FLAGS_ALLOW_PACKAGES = 1 << 14, /* TODO: move to request */
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_UPDATE_SEVERITY = 1 << 15,
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_UPGRADE_REMOVED = 1 << 16,
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_PROVENANCE = 1 << 17,
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_REVIEWS = 1 << 18,
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_REVIEW_RATINGS = 1 << 19,
+ GS_PLUGIN_REFINE_FLAGS_DISABLE_FILTERING = 1 << 20,
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_ICON = 1 << 21,
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_PERMISSIONS = 1 << 22,
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_ORIGIN_HOSTNAME = 1 << 23,
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_ORIGIN_UI = 1 << 24,
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_RUNTIME = 1 << 25,
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_SCREENSHOTS = 1 << 26,
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_CATEGORIES = 1 << 27,
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_PROJECT_GROUP = 1 << 28,
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_DEVELOPER_NAME = 1 << 29,
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_KUDOS = 1 << 30,
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_CONTENT_RATING = 1 << 31,
+ GS_PLUGIN_REFINE_FLAGS_MASK = ~0,
+} GsPluginRefineFlags;
+
+/**
+ * GsPluginListAppsFlags:
+ * @GS_PLUGIN_LIST_APPS_FLAGS_NONE: No flags set.
+ * @GS_PLUGIN_LIST_APPS_FLAGS_INTERACTIVE: User initiated the job.
+ *
+ * Flags for an operation to list apps matching a given query.
+ *
+ * Since: 43
+ */
+typedef enum {
+ GS_PLUGIN_LIST_APPS_FLAGS_NONE = 0,
+ GS_PLUGIN_LIST_APPS_FLAGS_INTERACTIVE = 1 << 0,
+} GsPluginListAppsFlags;
+
+/**
+ * GsPluginRefineCategoriesFlags:
+ * @GS_PLUGIN_REFINE_CATEGORIES_FLAGS_NONE: No flags set.
+ * @GS_PLUGIN_REFINE_CATEGORIES_FLAGS_INTERACTIVE: User initiated the job.
+ * @GS_PLUGIN_REFINE_CATEGORIES_FLAGS_SIZE: Work out the number of apps in each category.
+ *
+ * Flags for an operation to refine categories.
+ *
+ * Since: 43
+ */
+typedef enum {
+ GS_PLUGIN_REFINE_CATEGORIES_FLAGS_NONE = 0,
+ GS_PLUGIN_REFINE_CATEGORIES_FLAGS_INTERACTIVE = 1 << 0,
+ GS_PLUGIN_REFINE_CATEGORIES_FLAGS_SIZE = 1 << 1,
+} GsPluginRefineCategoriesFlags;
+
+/**
+ * GsPluginRefreshMetadataFlags:
+ * @GS_PLUGIN_REFRESH_METADATA_FLAGS_NONE: No flags set.
+ * @GS_PLUGIN_REFRESH_METADATA_FLAGS_INTERACTIVE: User initiated the job.
+ *
+ * Flags for an operation to refresh metadata.
+ *
+ * Since: 42
+ */
+typedef enum {
+ GS_PLUGIN_REFRESH_METADATA_FLAGS_NONE = 0,
+ GS_PLUGIN_REFRESH_METADATA_FLAGS_INTERACTIVE = 1 << 0,
+} GsPluginRefreshMetadataFlags;
+
+/**
+ * GsPluginListDistroUpgradesFlags:
+ * @GS_PLUGIN_LIST_DISTRO_UPGRADES_FLAGS_NONE: No flags set.
+ * @GS_PLUGIN_LIST_DISTRO_UPGRADES_FLAGS_INTERACTIVE: User initiated the job.
+ *
+ * Flags for an operation to list available distro upgrades.
+ *
+ * Since: 42
+ */
+typedef enum {
+ GS_PLUGIN_LIST_DISTRO_UPGRADES_FLAGS_NONE = 0,
+ GS_PLUGIN_LIST_DISTRO_UPGRADES_FLAGS_INTERACTIVE = 1 << 0,
+} GsPluginListDistroUpgradesFlags;
+
+/**
+ * GsPluginManageRepositoryFlags:
+ * @GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_NONE: No flags set.
+ * @GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_INTERACTIVE: User initiated the job.
+ * @GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_INSTALL: Install the repository.
+ * @GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_REMOVE: Remove the repository.
+ * @GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_ENABLE: Enable the repository.
+ * @GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_DISABLE: Disable the repository.
+ *
+ * Flags for an operation on a repository.
+ *
+ * Since: 42
+ */
+typedef enum {
+ GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_NONE = 0,
+ GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_INTERACTIVE = 1 << 0,
+ GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_INSTALL = 1 << 1,
+ GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_REMOVE = 1 << 2,
+ GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_ENABLE = 1 << 3,
+ GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_DISABLE = 1 << 4,
+} GsPluginManageRepositoryFlags;
+
+/**
+ * GsPluginRule:
+ * @GS_PLUGIN_RULE_CONFLICTS: The plugin conflicts with another
+ * @GS_PLUGIN_RULE_RUN_AFTER: Order the plugin after another
+ * @GS_PLUGIN_RULE_RUN_BEFORE: Order the plugin before another
+ * @GS_PLUGIN_RULE_BETTER_THAN: Results are better than another
+ *
+ * The rules used for ordering plugins.
+ * Plugins are expected to add rules in the init function for their #GsPlugin
+ * subclass.
+ **/
+typedef enum {
+ GS_PLUGIN_RULE_CONFLICTS,
+ GS_PLUGIN_RULE_RUN_AFTER,
+ GS_PLUGIN_RULE_RUN_BEFORE,
+ GS_PLUGIN_RULE_BETTER_THAN,
+ GS_PLUGIN_RULE_LAST /*< skip >*/
+} GsPluginRule;
+
+/**
+ * GsPluginAction:
+ * @GS_PLUGIN_ACTION_UNKNOWN: Action is unknown
+ * @GS_PLUGIN_ACTION_INSTALL: Install an application
+ * @GS_PLUGIN_ACTION_REMOVE: Remove an application
+ * @GS_PLUGIN_ACTION_UPDATE: Update an application
+ * @GS_PLUGIN_ACTION_UPGRADE_DOWNLOAD: Download a distro upgrade
+ * @GS_PLUGIN_ACTION_UPGRADE_TRIGGER: Trigger a distro upgrade
+ * @GS_PLUGIN_ACTION_LAUNCH: Launch an application
+ * @GS_PLUGIN_ACTION_UPDATE_CANCEL: Cancel the update
+ * @GS_PLUGIN_ACTION_GET_UPDATES: Get the list of updates
+ * @GS_PLUGIN_ACTION_GET_SOURCES: Get the list of sources
+ * @GS_PLUGIN_ACTION_FILE_TO_APP: Convert the file to an application
+ * @GS_PLUGIN_ACTION_URL_TO_APP: Convert the URI to an application
+ * @GS_PLUGIN_ACTION_GET_UPDATES_HISTORICAL: Get the list of historical updates
+ * @GS_PLUGIN_ACTION_DOWNLOAD: Download an application
+ * @GS_PLUGIN_ACTION_GET_LANGPACKS: Get appropriate language pack
+ * @GS_PLUGIN_ACTION_INSTALL_REPO: Install a repository (Since: 41)
+ * @GS_PLUGIN_ACTION_REMOVE_REPO: Remove a repository (Since: 41)
+ * @GS_PLUGIN_ACTION_ENABLE_REPO: Enable a repository (Since: 41)
+ * @GS_PLUGIN_ACTION_DISABLE_REPO: Disable a repository (Since: 41)
+ *
+ * The plugin action.
+ **/
+typedef enum {
+ GS_PLUGIN_ACTION_UNKNOWN,
+ GS_PLUGIN_ACTION_INSTALL,
+ GS_PLUGIN_ACTION_REMOVE,
+ GS_PLUGIN_ACTION_UPDATE,
+ GS_PLUGIN_ACTION_UPGRADE_DOWNLOAD,
+ GS_PLUGIN_ACTION_UPGRADE_TRIGGER,
+ GS_PLUGIN_ACTION_LAUNCH,
+ GS_PLUGIN_ACTION_UPDATE_CANCEL,
+ GS_PLUGIN_ACTION_GET_UPDATES,
+ GS_PLUGIN_ACTION_GET_SOURCES,
+ GS_PLUGIN_ACTION_FILE_TO_APP,
+ GS_PLUGIN_ACTION_URL_TO_APP,
+ GS_PLUGIN_ACTION_GET_UPDATES_HISTORICAL,
+ GS_PLUGIN_ACTION_DOWNLOAD,
+ GS_PLUGIN_ACTION_GET_LANGPACKS,
+ GS_PLUGIN_ACTION_INSTALL_REPO,
+ GS_PLUGIN_ACTION_REMOVE_REPO,
+ GS_PLUGIN_ACTION_ENABLE_REPO,
+ GS_PLUGIN_ACTION_DISABLE_REPO,
+ GS_PLUGIN_ACTION_LAST /*< skip >*/
+} GsPluginAction;
+
+G_END_DECLS
diff --git a/lib/gs-plugin-vfuncs.h b/lib/gs-plugin-vfuncs.h
new file mode 100644
index 0000000..39093f5
--- /dev/null
+++ b/lib/gs-plugin-vfuncs.h
@@ -0,0 +1,429 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2012-2017 Richard Hughes <richard@hughsie.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+/**
+ * SECTION:gs-plugin-vfuncs
+ * @title: GsPlugin Exports
+ * @include: gnome-software.h
+ * @stability: Unstable
+ * @short_description: Vfuncs that plugins can implement
+ */
+
+#include <appstream.h>
+#include <glib-object.h>
+#include <gmodule.h>
+#include <gio/gio.h>
+#include <libsoup/soup.h>
+
+#include "gs-app.h"
+#include "gs-app-list.h"
+#include "gs-category.h"
+
+G_BEGIN_DECLS
+
+/**
+ * gs_plugin_query_type:
+ *
+ * Returns the #GType for a subclass of #GsPlugin provided by this plugin
+ * module. It should not do any other computation.
+ *
+ * The init function for that type should initialize the plugin. If the plugin
+ * should not be run then gs_plugin_set_enabled() should be called from the
+ * init function.
+ *
+ * NOTE: Do not do any failable actions in the plugin class’ init function; use
+ * #GsPluginClass.setup_async instead.
+ *
+ * Since: 42
+ */
+GType gs_plugin_query_type (void);
+
+/**
+ * gs_plugin_adopt_app:
+ * @plugin: a #GsPlugin
+ * @app: a #GsApp
+ *
+ * Called when an #GsApp has not been claimed (i.e. a management plugin has not
+ * been set).
+ *
+ * A claimed application means other plugins will not try to perform actions
+ * such as install, remove or update. Most applications are claimed when they
+ * are created.
+ *
+ * If a plugin can adopt this application then it should call
+ * gs_app_set_management_plugin() on @app.
+ **/
+void gs_plugin_adopt_app (GsPlugin *plugin,
+ GsApp *app);
+
+/**
+ * gs_plugin_add_updates:
+ * @plugin: a #GsPlugin
+ * @list: a #GsAppList
+ * @cancellable: a #GCancellable, or %NULL
+ * @error: a #GError, or %NULL
+ *
+ * Get the list of updates.
+ *
+ * NOTE: Actually downloading the updates can be done in gs_plugin_download_app()
+ * or in gs_plugin_download().
+ *
+ * Plugins are expected to add new apps using gs_app_list_add().
+ *
+ * Returns: %TRUE for success or if not relevant
+ **/
+gboolean gs_plugin_add_updates (GsPlugin *plugin,
+ GsAppList *list,
+ GCancellable *cancellable,
+ GError **error);
+
+/**
+ * gs_plugin_add_sources:
+ * @plugin: a #GsPlugin
+ * @list: a #GsAppList
+ * @cancellable: a #GCancellable, or %NULL
+ * @error: a #GError, or %NULL
+ *
+ * Get the list of sources, for example the repos listed in /etc/yum.repos.d
+ * or the remotes configured in flatpak.
+ *
+ * Plugins are expected to add new apps using gs_app_list_add() of type
+ * %AS_COMPONENT_KIND_REPOSITORY.
+ *
+ * Returns: %TRUE for success or if not relevant
+ **/
+gboolean gs_plugin_add_sources (GsPlugin *plugin,
+ GsAppList *list,
+ GCancellable *cancellable,
+ GError **error);
+
+/**
+ * gs_plugin_add_updates_historical
+ * @plugin: a #GsPlugin
+ * @list: a #GsAppList
+ * @cancellable: a #GCancellable, or %NULL
+ * @error: a #GError, or %NULL
+ *
+ * Get the list of historical updates, i.e. the updates that have just been
+ * installed.
+ *
+ * Plugins are expected to add new apps using gs_app_list_add().
+ *
+ * Returns: %TRUE for success or if not relevant
+ **/
+gboolean gs_plugin_add_updates_historical (GsPlugin *plugin,
+ GsAppList *list,
+ GCancellable *cancellable,
+ GError **error);
+
+/**
+ * gs_plugin_launch:
+ * @plugin: a #GsPlugin
+ * @app: a #GsApp
+ * @cancellable: a #GCancellable, or %NULL
+ * @error: a #GError, or %NULL
+ *
+ * Launch the specified application using a plugin-specific method.
+ * This is normally setting some environment or launching a specific binary.
+ *
+ * Plugins can simply use gs_plugin_app_launch() if no plugin-specific
+ * functionality is required.
+ *
+ * Returns: %TRUE for success or if not relevant
+ **/
+gboolean gs_plugin_launch (GsPlugin *plugin,
+ GsApp *app,
+ GCancellable *cancellable,
+ GError **error);
+
+/**
+ * gs_plugin_update_cancel:
+ * @plugin: a #GsPlugin
+ * @app: a #GsApp
+ * @cancellable: a #GCancellable, or %NULL
+ * @error: a #GError, or %NULL
+ *
+ * Cancels the offline update of @app.
+ *
+ * Returns: %TRUE for success or if not relevant
+ **/
+gboolean gs_plugin_update_cancel (GsPlugin *plugin,
+ GsApp *app,
+ GCancellable *cancellable,
+ GError **error);
+
+/**
+ * gs_plugin_app_install:
+ * @plugin: a #GsPlugin
+ * @app: a #GsApp
+ * @cancellable: a #GCancellable, or %NULL
+ * @error: a #GError, or %NULL
+ *
+ * Install the application.
+ *
+ * Plugins are expected to send progress notifications to the UI using
+ * gs_app_set_progress() using the passed in @app.
+ *
+ * All functions can block, but should sent progress notifications, e.g. using
+ * gs_app_set_progress() if they will take more than tens of milliseconds
+ * to complete.
+ *
+ * On failure the error message returned will usually only be shown on the
+ * console, but they can also be retrieved using gs_plugin_loader_get_events().
+ *
+ * NOTE: Once the action is complete, the plugin must set the new state of @app
+ * to %GS_APP_STATE_INSTALLED.
+ *
+ * Returns: %TRUE for success or if not relevant
+ **/
+gboolean gs_plugin_app_install (GsPlugin *plugin,
+ GsApp *app,
+ GCancellable *cancellable,
+ GError **error);
+
+/**
+ * gs_plugin_app_remove:
+ * @plugin: a #GsPlugin
+ * @app: a #GsApp
+ * @cancellable: a #GCancellable, or %NULL
+ * @error: a #GError, or %NULL
+ *
+ * Remove the application.
+ *
+ * Plugins are expected to send progress notifications to the UI using
+ * gs_app_set_progress() using the passed in @app.
+ *
+ * All functions can block, but should sent progress notifications, e.g. using
+ * gs_app_set_progress() if they will take more than tens of milliseconds
+ * to complete.
+ *
+ * On failure the error message returned will usually only be shown on the
+ * console, but they can also be retrieved using gs_plugin_loader_get_events().
+ *
+ * NOTE: Once the action is complete, the plugin must set the new state of @app
+ * to %GS_APP_STATE_AVAILABLE or %GS_APP_STATE_UNKNOWN if not known.
+ *
+ * Returns: %TRUE for success or if not relevant
+ **/
+gboolean gs_plugin_app_remove (GsPlugin *plugin,
+ GsApp *app,
+ GCancellable *cancellable,
+ GError **error);
+
+/**
+ * gs_plugin_update_app:
+ * @plugin: a #GsPlugin
+ * @app: a #GsApp
+ * @cancellable: a #GCancellable, or %NULL
+ * @error: a #GError, or %NULL
+ *
+ * Update the application live.
+ *
+ * Plugins are expected to send progress notifications to the UI using
+ * gs_app_set_progress() using the passed in @app.
+ *
+ * All functions can block, but should sent progress notifications, e.g. using
+ * gs_app_set_progress() if they will take more than tens of milliseconds
+ * to complete.
+ *
+ * On failure the error message returned will usually only be shown on the
+ * console, but they can also be retrieved using gs_plugin_loader_get_events().
+ *
+ * NOTE: Once the action is complete, the plugin must set the new state of @app
+ * to %GS_APP_STATE_INSTALLED or %GS_APP_STATE_UNKNOWN if not known.
+ *
+ * If %GS_APP_QUIRK_IS_PROXY is set on the application then the actual #GsApp
+ * set in @app will be the related application of the parent. Plugins do not
+ * need to manually iterate on the related list of applications.
+ *
+ * Returns: %TRUE for success or if not relevant
+ **/
+gboolean gs_plugin_update_app (GsPlugin *plugin,
+ GsApp *app,
+ GCancellable *cancellable,
+ GError **error);
+
+/**
+ * gs_plugin_download_app:
+ * @plugin: a #GsPlugin
+ * @app: a #GsApp
+ * @cancellable: a #GCancellable, or %NULL
+ * @error: a #GError, or %NULL
+ *
+ * Downloads the application and any dependencies ready to be installed or
+ * updated.
+ *
+ * Plugins are expected to schedule downloads using the system download
+ * scheduler if appropriate (if the download is not guaranteed to be under a few
+ * hundred kilobytes, for example), so that the user’s metered data preferences
+ * are honoured.
+ *
+ * Plugins are expected to send progress notifications to the UI using
+ * gs_app_set_progress() using the passed in @app.
+ *
+ * All functions can block, but should sent progress notifications, e.g. using
+ * gs_app_set_progress() if they will take more than tens of milliseconds
+ * to complete.
+ *
+ * If the @app is already downloaded, do not return an error and return %TRUE.
+ *
+ * On failure the error message returned will usually only be shown on the
+ * console, but they can also be retrieved using gs_plugin_loader_get_events().
+ *
+ * Returns: %TRUE for success or if not relevant
+ **/
+gboolean gs_plugin_download_app (GsPlugin *plugin,
+ GsApp *app,
+ GCancellable *cancellable,
+ GError **error);
+
+/**
+ * gs_plugin_download:
+ * @plugin: a #GsPlugin
+ * @apps: a #GsAppList
+ * @cancellable: a #GCancellable, or %NULL
+ * @error: a #GError, or %NULL
+ *
+ * Downloads a list of applications ready to be installed or updated.
+ *
+ * Plugins are expected to schedule downloads using the system download
+ * scheduler if appropriate (if the download is not guaranteed to be under a few
+ * hundred kilobytes, for example), so that the user’s metered data preferences
+ * are honoured.
+ *
+ * Returns: %TRUE for success or if not relevant
+ **/
+gboolean gs_plugin_download (GsPlugin *plugin,
+ GsAppList *apps,
+ GCancellable *cancellable,
+ GError **error);
+
+/**
+ * gs_plugin_app_upgrade_download:
+ * @plugin: a #GsPlugin
+ * @app: a #GsApp, with kind %AS_COMPONENT_KIND_OPERATING_SYSTEM
+ * @cancellable: a #GCancellable, or %NULL
+ * @error: a #GError, or %NULL
+ *
+ * Starts downloading a distribution upgrade in the background.
+ *
+ * All functions can block, but should sent progress notifications, e.g. using
+ * gs_app_set_progress() if they will take more than tens of milliseconds
+ * to complete.
+ *
+ * Returns: %TRUE for success or if not relevant
+ **/
+gboolean gs_plugin_app_upgrade_download (GsPlugin *plugin,
+ GsApp *app,
+ GCancellable *cancellable,
+ GError **error);
+
+/**
+ * gs_plugin_app_upgrade_trigger:
+ * @plugin: a #GsPlugin
+ * @app: a #GsApp, with kind %AS_COMPONENT_KIND_OPERATING_SYSTEM
+ * @cancellable: a #GCancellable, or %NULL
+ * @error: a #GError, or %NULL
+ *
+ * Triggers the distribution upgrade to be installed on next boot.
+ *
+ * Returns: %TRUE for success or if not relevant
+ **/
+gboolean gs_plugin_app_upgrade_trigger (GsPlugin *plugin,
+ GsApp *app,
+ GCancellable *cancellable,
+ GError **error);
+
+/**
+ * gs_plugin_file_to_app:
+ * @plugin: a #GsPlugin
+ * @list: a #GsAppList
+ * @file: a #GFile
+ * @cancellable: a #GCancellable, or %NULL
+ * @error: a #GError, or %NULL
+ *
+ * Converts a local file to a #GsApp. It's expected that only one plugin will
+ * match the mimetype of @file and that a single #GsApp will be in the returned
+ * list. If no plugins can handle the file, the list will be empty.
+ *
+ * For example, the PackageKit plugin can turn a .rpm file into a application
+ * of kind %AS_COMPONENT_KIND_UNKNOWN but that in some cases it will be further refined
+ * into a %AS_COMPONENT_KIND_DESKTOP_APP (with all the extra metadata) by the appstream
+ * plugin.
+ *
+ * Plugins are expected to add new apps using gs_app_list_add().
+ *
+ * Returns: %TRUE for success or if not relevant
+ **/
+gboolean gs_plugin_file_to_app (GsPlugin *plugin,
+ GsAppList *list,
+ GFile *file,
+ GCancellable *cancellable,
+ GError **error);
+
+/**
+ * gs_plugin_url_to_app:
+ * @plugin: a #GsPlugin
+ * @list: a #GsAppList
+ * @url: a #URL, e.g. "apt://gimp"
+ * @cancellable: a #GCancellable, or %NULL
+ * @error: a #GError, or %NULL
+ *
+ * Converts a URL to a #GsApp. It's expected that only one plugin will
+ * match the scheme of @url and that a single #GsApp will be in the returned
+ * list. If no plugins can handle the file, the list will be empty.
+ *
+ * For example, the apt plugin can turn apt://gimp into a application.
+ *
+ * Plugins are expected to add new apps using gs_app_list_add().
+ *
+ * Returns: %TRUE for success or if not relevant
+ **/
+gboolean gs_plugin_url_to_app (GsPlugin *plugin,
+ GsAppList *list,
+ const gchar *url,
+ GCancellable *cancellable,
+ GError **error);
+
+/**
+ * gs_plugin_update:
+ * @plugin: a #GsPlugin
+ * @apps: a #GsAppList
+ * @cancellable: a #GCancellable, or %NULL
+ * @error: a #GError, or %NULL
+ *
+ * Updates a list of applications, typically scheduling them for offline update.
+ *
+ * Returns: %TRUE for success or if not relevant
+ **/
+gboolean gs_plugin_update (GsPlugin *plugin,
+ GsAppList *apps,
+ GCancellable *cancellable,
+ GError **error);
+
+/**
+ * gs_plugin_add_langpacks:
+ * @plugin: a #GsPlugin
+ * @list: a #GsAppList
+ * @locale: a #LANGUAGE_CODE or #LOCALE, e.g. "ja" or "ja_JP"
+ * @cancellable: a #GCancellable, or %NULL
+ * @error: a #GError, or %NULL
+ *
+ * Returns a list of language packs, as per input language code or locale.
+ *
+ * Returns: %TRUE for success or if not relevant
+ **/
+gboolean gs_plugin_add_langpacks (GsPlugin *plugin,
+ GsAppList *list,
+ const gchar *locale,
+ GCancellable *cancellable,
+ GError **error);
+
+G_END_DECLS
diff --git a/lib/gs-plugin.c b/lib/gs-plugin.c
new file mode 100644
index 0000000..51e0f7e
--- /dev/null
+++ b/lib/gs-plugin.c
@@ -0,0 +1,2187 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2013-2016 Richard Hughes <richard@hughsie.com>
+ * Copyright (C) 2014-2020 Kalev Lember <klember@redhat.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+/**
+ * SECTION:gs-plugin
+ * @title: GsPlugin Helpers
+ * @include: gnome-software.h
+ * @stability: Unstable
+ * @short_description: Runtime-loaded modules providing functionality
+ *
+ * Plugins are modules that are loaded at runtime to provide information
+ * about requests and to service user actions like installing, removing
+ * and updating.
+ * This allows different distributions to pick and choose how the
+ * application installer gathers data.
+ *
+ * Plugins also have a priority system where the largest number gets
+ * run first. That means if one plugin requires some property or
+ * metadata set by another plugin then it **must** depend on the other
+ * plugin to be run in the correct order.
+ *
+ * As a general rule, try to make plugins as small and self-contained
+ * as possible and remember to cache as much data as possible for speed.
+ * Memory is cheap, time less so.
+ */
+
+#include "config.h"
+
+#include <gio/gdesktopappinfo.h>
+#include <gdk/gdk.h>
+#include <string.h>
+
+#include "gs-app-list-private.h"
+#include "gs-download-utils.h"
+#include "gs-enums.h"
+#include "gs-os-release.h"
+#include "gs-plugin-private.h"
+#include "gs-plugin.h"
+#include "gs-utils.h"
+
+typedef struct
+{
+ GHashTable *cache;
+ GMutex cache_mutex;
+ GModule *module;
+ GsPluginFlags flags;
+ GPtrArray *rules[GS_PLUGIN_RULE_LAST];
+ GHashTable *vfuncs; /* string:pointer */
+ GMutex vfuncs_mutex;
+ gboolean enabled;
+ guint interactive_cnt;
+ GMutex interactive_mutex;
+ gchar *language; /* allow-none */
+ gchar *name;
+ gchar *appstream_id;
+ guint scale;
+ guint order;
+ guint priority;
+ guint timer_id;
+ GMutex timer_mutex;
+ GNetworkMonitor *network_monitor;
+
+ GDBusConnection *session_bus_connection; /* (owned) (not nullable) */
+ GDBusConnection *system_bus_connection; /* (owned) (not nullable) */
+} GsPluginPrivate;
+
+G_DEFINE_ABSTRACT_TYPE_WITH_PRIVATE (GsPlugin, gs_plugin, G_TYPE_OBJECT)
+
+G_DEFINE_QUARK (gs-plugin-error-quark, gs_plugin_error)
+
+typedef enum {
+ PROP_FLAGS = 1,
+ PROP_SESSION_BUS_CONNECTION,
+ PROP_SYSTEM_BUS_CONNECTION,
+} GsPluginProperty;
+
+static GParamSpec *obj_props[PROP_SYSTEM_BUS_CONNECTION + 1] = { NULL, };
+
+enum {
+ SIGNAL_UPDATES_CHANGED,
+ SIGNAL_STATUS_CHANGED,
+ SIGNAL_RELOAD,
+ SIGNAL_REPORT_EVENT,
+ SIGNAL_ALLOW_UPDATES,
+ SIGNAL_BASIC_AUTH_START,
+ SIGNAL_REPOSITORY_CHANGED,
+ SIGNAL_ASK_UNTRUSTED,
+ SIGNAL_LAST
+};
+
+static guint signals [SIGNAL_LAST] = { 0 };
+
+typedef const gchar **(*GsPluginGetDepsFunc) (GsPlugin *plugin);
+
+/**
+ * gs_plugin_status_to_string:
+ * @status: a #GsPluginStatus, e.g. %GS_PLUGIN_STATUS_DOWNLOADING
+ *
+ * Converts the #GsPluginStatus enum to a string.
+ *
+ * Returns: the string representation, or "unknown"
+ *
+ * Since: 3.22
+ **/
+const gchar *
+gs_plugin_status_to_string (GsPluginStatus status)
+{
+ if (status == GS_PLUGIN_STATUS_WAITING)
+ return "waiting";
+ if (status == GS_PLUGIN_STATUS_FINISHED)
+ return "finished";
+ if (status == GS_PLUGIN_STATUS_SETUP)
+ return "setup";
+ if (status == GS_PLUGIN_STATUS_DOWNLOADING)
+ return "downloading";
+ if (status == GS_PLUGIN_STATUS_QUERYING)
+ return "querying";
+ if (status == GS_PLUGIN_STATUS_INSTALLING)
+ return "installing";
+ if (status == GS_PLUGIN_STATUS_REMOVING)
+ return "removing";
+ return "unknown";
+}
+
+/**
+ * gs_plugin_set_name:
+ * @plugin: a #GsPlugin
+ * @name: a plugin name
+ *
+ * Sets the name of the plugin.
+ *
+ * Plugins are not required to set the plugin name as it is automatically set
+ * from the `.so` filename.
+ *
+ * Since: 3.26
+ **/
+void
+gs_plugin_set_name (GsPlugin *plugin, const gchar *name)
+{
+ GsPluginPrivate *priv = gs_plugin_get_instance_private (plugin);
+ if (priv->name != NULL)
+ g_free (priv->name);
+ priv->name = g_strdup (name);
+}
+
+/**
+ * gs_plugin_create:
+ * @filename: an absolute filename
+ * @session_bus_connection: (not nullable) (transfer none): a session bus
+ * connection to use
+ * @system_bus_connection: (not nullable) (transfer none): a system bus
+ * connection to use
+ * @error: a #GError, or %NULL
+ *
+ * Creates a new plugin from an external module.
+ *
+ * Returns: (transfer full): the #GsPlugin, or %NULL on error
+ *
+ * Since: 43
+ **/
+GsPlugin *
+gs_plugin_create (const gchar *filename,
+ GDBusConnection *session_bus_connection,
+ GDBusConnection *system_bus_connection,
+ GError **error)
+{
+ GsPlugin *plugin = NULL;
+ GsPluginPrivate *priv;
+ g_autofree gchar *basename = NULL;
+ GModule *module = NULL;
+ GType (*query_type_function) (void) = NULL;
+ GType plugin_type;
+
+ /* get the plugin name from the basename */
+ basename = g_path_get_basename (filename);
+ if (!g_str_has_prefix (basename, "libgs_plugin_")) {
+ g_set_error (error,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_FAILED,
+ "plugin filename has wrong prefix: %s",
+ filename);
+ return NULL;
+ }
+ g_strdelimit (basename, ".", '\0');
+
+ /* create new plugin */
+ module = g_module_open (filename, 0);
+ if (module == NULL ||
+ !g_module_symbol (module, "gs_plugin_query_type", (gpointer *) &query_type_function)) {
+ g_set_error (error,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_FAILED,
+ "failed to open plugin %s: %s",
+ filename, g_module_error ());
+ if (module != NULL)
+ g_module_close (module);
+ return NULL;
+ }
+
+ /* Make the module resident so it can’t be unloaded: without using a
+ * full #GTypePlugin implementation for the modules, it’s not safe to
+ * re-load a module and re-register its types with GObject, as that will
+ * confuse the GType system. */
+ g_module_make_resident (module);
+
+ plugin_type = query_type_function ();
+ g_assert (g_type_is_a (plugin_type, GS_TYPE_PLUGIN));
+
+ plugin = g_object_new (plugin_type,
+ "session-bus-connection", session_bus_connection,
+ "system-bus-connection", system_bus_connection,
+ NULL);
+ priv = gs_plugin_get_instance_private (plugin);
+ priv->module = g_steal_pointer (&module);
+
+ gs_plugin_set_name (plugin, basename + 13);
+ return plugin;
+}
+
+static void
+gs_plugin_dispose (GObject *object)
+{
+ GsPlugin *plugin = GS_PLUGIN (object);
+ GsPluginPrivate *priv = gs_plugin_get_instance_private (plugin);
+
+ g_clear_object (&priv->session_bus_connection);
+ g_clear_object (&priv->system_bus_connection);
+
+ G_OBJECT_CLASS (gs_plugin_parent_class)->dispose (object);
+}
+
+static void
+gs_plugin_finalize (GObject *object)
+{
+ GsPlugin *plugin = GS_PLUGIN (object);
+ GsPluginPrivate *priv = gs_plugin_get_instance_private (plugin);
+ guint i;
+
+ for (i = 0; i < GS_PLUGIN_RULE_LAST; i++)
+ g_ptr_array_unref (priv->rules[i]);
+
+ if (priv->timer_id > 0)
+ g_source_remove (priv->timer_id);
+ g_free (priv->name);
+ g_free (priv->appstream_id);
+ g_free (priv->language);
+ if (priv->network_monitor != NULL)
+ g_object_unref (priv->network_monitor);
+ g_hash_table_unref (priv->cache);
+ g_hash_table_unref (priv->vfuncs);
+ g_mutex_clear (&priv->cache_mutex);
+ g_mutex_clear (&priv->interactive_mutex);
+ g_mutex_clear (&priv->timer_mutex);
+ g_mutex_clear (&priv->vfuncs_mutex);
+ if (priv->module != NULL)
+ g_module_close (priv->module);
+
+ G_OBJECT_CLASS (gs_plugin_parent_class)->finalize (object);
+}
+
+/**
+ * gs_plugin_get_symbol: (skip)
+ * @plugin: a #GsPlugin
+ * @function_name: a symbol name
+ *
+ * Gets the symbol from the module that backs the plugin. If the plugin is not
+ * enabled then no symbol is returned.
+ *
+ * Returns: the pointer to the symbol, or %NULL
+ *
+ * Since: 3.22
+ **/
+gpointer
+gs_plugin_get_symbol (GsPlugin *plugin, const gchar *function_name)
+{
+ GsPluginPrivate *priv = gs_plugin_get_instance_private (plugin);
+ gpointer func = NULL;
+ g_autoptr(GMutexLocker) locker = g_mutex_locker_new (&priv->vfuncs_mutex);
+
+ g_return_val_if_fail (function_name != NULL, NULL);
+
+ /* disabled plugins shouldn't be checked */
+ if (!priv->enabled)
+ return NULL;
+
+ /* look up the symbol from the cache */
+ if (g_hash_table_lookup_extended (priv->vfuncs, function_name, NULL, &func))
+ return func;
+
+ /* look up the symbol using the elf headers */
+ g_module_symbol (priv->module, function_name, &func);
+ g_hash_table_insert (priv->vfuncs, g_strdup (function_name), func);
+
+ return func;
+}
+
+/**
+ * gs_plugin_get_enabled:
+ * @plugin: a #GsPlugin
+ *
+ * Gets if the plugin is enabled.
+ *
+ * Returns: %TRUE if enabled
+ *
+ * Since: 3.22
+ **/
+gboolean
+gs_plugin_get_enabled (GsPlugin *plugin)
+{
+ GsPluginPrivate *priv = gs_plugin_get_instance_private (plugin);
+ return priv->enabled;
+}
+
+/**
+ * gs_plugin_set_enabled:
+ * @plugin: a #GsPlugin
+ * @enabled: the enabled state
+ *
+ * Enables or disables a plugin.
+ * This is normally only called from the init function for a #GsPlugin instance.
+ *
+ * Since: 3.22
+ **/
+void
+gs_plugin_set_enabled (GsPlugin *plugin, gboolean enabled)
+{
+ GsPluginPrivate *priv = gs_plugin_get_instance_private (plugin);
+ priv->enabled = enabled;
+}
+
+void
+gs_plugin_interactive_inc (GsPlugin *plugin)
+{
+ GsPluginPrivate *priv = gs_plugin_get_instance_private (plugin);
+ g_autoptr(GMutexLocker) locker = g_mutex_locker_new (&priv->interactive_mutex);
+ priv->interactive_cnt++;
+ gs_plugin_add_flags (plugin, GS_PLUGIN_FLAGS_INTERACTIVE);
+}
+
+void
+gs_plugin_interactive_dec (GsPlugin *plugin)
+{
+ GsPluginPrivate *priv = gs_plugin_get_instance_private (plugin);
+ g_autoptr(GMutexLocker) locker = g_mutex_locker_new (&priv->interactive_mutex);
+ if (priv->interactive_cnt > 0)
+ priv->interactive_cnt--;
+ if (priv->interactive_cnt == 0)
+ gs_plugin_remove_flags (plugin, GS_PLUGIN_FLAGS_INTERACTIVE);
+}
+
+/**
+ * gs_plugin_get_name:
+ * @plugin: a #GsPlugin
+ *
+ * Gets the plugin name.
+ *
+ * Returns: a string, e.g. "fwupd"
+ *
+ * Since: 3.22
+ **/
+const gchar *
+gs_plugin_get_name (GsPlugin *plugin)
+{
+ GsPluginPrivate *priv = gs_plugin_get_instance_private (plugin);
+ return priv->name;
+}
+
+/**
+ * gs_plugin_get_appstream_id:
+ * @plugin: a #GsPlugin
+ *
+ * Gets the plugin AppStream ID.
+ *
+ * Returns: a string, e.g. `org.gnome.Software.Plugin.Epiphany`
+ *
+ * Since: 3.24
+ **/
+const gchar *
+gs_plugin_get_appstream_id (GsPlugin *plugin)
+{
+ GsPluginPrivate *priv = gs_plugin_get_instance_private (plugin);
+ return priv->appstream_id;
+}
+
+/**
+ * gs_plugin_set_appstream_id:
+ * @plugin: a #GsPlugin
+ * @appstream_id: an appstream ID, e.g. `org.gnome.Software.Plugin.Epiphany`
+ *
+ * Sets the plugin AppStream ID.
+ *
+ * Since: 3.24
+ **/
+void
+gs_plugin_set_appstream_id (GsPlugin *plugin, const gchar *appstream_id)
+{
+ GsPluginPrivate *priv = gs_plugin_get_instance_private (plugin);
+ g_free (priv->appstream_id);
+ priv->appstream_id = g_strdup (appstream_id);
+}
+
+/**
+ * gs_plugin_get_scale:
+ * @plugin: a #GsPlugin
+ *
+ * Gets the window scale factor.
+ *
+ * Returns: the factor, usually 1 for standard screens or 2 for HiDPI
+ *
+ * Since: 3.22
+ **/
+guint
+gs_plugin_get_scale (GsPlugin *plugin)
+{
+ GsPluginPrivate *priv = gs_plugin_get_instance_private (plugin);
+ return priv->scale;
+}
+
+/**
+ * gs_plugin_set_scale:
+ * @plugin: a #GsPlugin
+ * @scale: the window scale factor, usually 1 for standard screens or 2 for HiDPI
+ *
+ * Sets the window scale factor.
+ *
+ * Since: 3.22
+ **/
+void
+gs_plugin_set_scale (GsPlugin *plugin, guint scale)
+{
+ GsPluginPrivate *priv = gs_plugin_get_instance_private (plugin);
+ priv->scale = scale;
+}
+
+/**
+ * gs_plugin_get_order:
+ * @plugin: a #GsPlugin
+ *
+ * Gets the plugin order, where higher numbers are run after lower
+ * numbers.
+ *
+ * Returns: the integer value
+ *
+ * Since: 3.22
+ **/
+guint
+gs_plugin_get_order (GsPlugin *plugin)
+{
+ GsPluginPrivate *priv = gs_plugin_get_instance_private (plugin);
+ return priv->order;
+}
+
+/**
+ * gs_plugin_set_order:
+ * @plugin: a #GsPlugin
+ * @order: a integer value
+ *
+ * Sets the plugin order, where higher numbers are run after lower
+ * numbers.
+ *
+ * Since: 3.22
+ **/
+void
+gs_plugin_set_order (GsPlugin *plugin, guint order)
+{
+ GsPluginPrivate *priv = gs_plugin_get_instance_private (plugin);
+ priv->order = order;
+}
+
+/**
+ * gs_plugin_get_priority:
+ * @plugin: a #GsPlugin
+ *
+ * Gets the plugin priority, where higher values will be chosen where
+ * multiple #GsApp's match a specific rule.
+ *
+ * Returns: the integer value
+ *
+ * Since: 3.22
+ **/
+guint
+gs_plugin_get_priority (GsPlugin *plugin)
+{
+ GsPluginPrivate *priv = gs_plugin_get_instance_private (plugin);
+ return priv->priority;
+}
+
+/**
+ * gs_plugin_set_priority:
+ * @plugin: a #GsPlugin
+ * @priority: a integer value
+ *
+ * Sets the plugin priority, where higher values will be chosen where
+ * multiple #GsApp's match a specific rule.
+ *
+ * Since: 3.22
+ **/
+void
+gs_plugin_set_priority (GsPlugin *plugin, guint priority)
+{
+ GsPluginPrivate *priv = gs_plugin_get_instance_private (plugin);
+ priv->priority = priority;
+}
+
+/**
+ * gs_plugin_get_language:
+ * @plugin: a #GsPlugin
+ *
+ * Gets the user language from the locale. This is the first component of the
+ * locale.
+ *
+ * Typically you should use the full locale rather than the language, as the
+ * same language can be used quite differently in different territories.
+ *
+ * Returns: the language string, e.g. `fr`
+ *
+ * Since: 3.22
+ **/
+const gchar *
+gs_plugin_get_language (GsPlugin *plugin)
+{
+ GsPluginPrivate *priv = gs_plugin_get_instance_private (plugin);
+ return priv->language;
+}
+
+/**
+ * gs_plugin_set_language:
+ * @plugin: a #GsPlugin
+ * @language: a language string, e.g. "fr"
+ *
+ * Sets the plugin language.
+ *
+ * Since: 3.22
+ **/
+void
+gs_plugin_set_language (GsPlugin *plugin, const gchar *language)
+{
+ GsPluginPrivate *priv = gs_plugin_get_instance_private (plugin);
+ g_free (priv->language);
+ priv->language = g_strdup (language);
+}
+
+/**
+ * gs_plugin_set_network_monitor:
+ * @plugin: a #GsPlugin
+ * @monitor: a #GNetworkMonitor
+ *
+ * Sets the network monitor so that plugins can check the state of the network.
+ *
+ * Since: 3.28
+ **/
+void
+gs_plugin_set_network_monitor (GsPlugin *plugin, GNetworkMonitor *monitor)
+{
+ GsPluginPrivate *priv = gs_plugin_get_instance_private (plugin);
+ g_set_object (&priv->network_monitor, monitor);
+}
+
+/**
+ * gs_plugin_get_network_available:
+ * @plugin: a #GsPlugin
+ *
+ * Gets whether a network connectivity is available.
+ *
+ * Returns: %TRUE if a network is available.
+ *
+ * Since: 3.28
+ **/
+gboolean
+gs_plugin_get_network_available (GsPlugin *plugin)
+{
+ GsPluginPrivate *priv = gs_plugin_get_instance_private (plugin);
+ if (priv->network_monitor == NULL) {
+ g_debug ("no network monitor, so returning network-available=TRUE");
+ return TRUE;
+ }
+ return g_network_monitor_get_network_available (priv->network_monitor);
+}
+
+/**
+ * gs_plugin_has_flags:
+ * @plugin: a #GsPlugin
+ * @flags: a #GsPluginFlags, e.g. %GS_PLUGIN_FLAGS_INTERACTIVE
+ *
+ * Finds out if a plugin has a specific flag set.
+ *
+ * Returns: TRUE if the flag is set
+ *
+ * Since: 3.22
+ **/
+gboolean
+gs_plugin_has_flags (GsPlugin *plugin, GsPluginFlags flags)
+{
+ GsPluginPrivate *priv = gs_plugin_get_instance_private (plugin);
+ return (priv->flags & flags) > 0;
+}
+
+/**
+ * gs_plugin_add_flags:
+ * @plugin: a #GsPlugin
+ * @flags: a #GsPluginFlags, e.g. %GS_PLUGIN_FLAGS_INTERACTIVE
+ *
+ * Adds specific flags to the plugin.
+ *
+ * Since: 3.22
+ **/
+void
+gs_plugin_add_flags (GsPlugin *plugin, GsPluginFlags flags)
+{
+ GsPluginPrivate *priv = gs_plugin_get_instance_private (plugin);
+ priv->flags |= flags;
+ g_object_notify_by_pspec (G_OBJECT (plugin), obj_props[PROP_FLAGS]);
+}
+
+/**
+ * gs_plugin_remove_flags:
+ * @plugin: a #GsPlugin
+ * @flags: a #GsPluginFlags, e.g. %GS_PLUGIN_FLAGS_INTERACTIVE
+ *
+ * Removes specific flags from the plugin.
+ *
+ * Since: 3.22
+ **/
+void
+gs_plugin_remove_flags (GsPlugin *plugin, GsPluginFlags flags)
+{
+ GsPluginPrivate *priv = gs_plugin_get_instance_private (plugin);
+ priv->flags &= ~flags;
+ g_object_notify_by_pspec (G_OBJECT (plugin), obj_props[PROP_FLAGS]);
+}
+
+/**
+ * gs_plugin_add_rule:
+ * @plugin: a #GsPlugin
+ * @rule: a #GsPluginRule, e.g. %GS_PLUGIN_RULE_CONFLICTS
+ * @name: a plugin name, e.g. "appstream"
+ *
+ * If the plugin name is found, the rule will be used to sort the plugin list,
+ * for example the plugin specified by @name will be ordered after this plugin
+ * when %GS_PLUGIN_RULE_RUN_AFTER is used.
+ *
+ * NOTE: The depsolver is iterative and may not solve overly-complicated rules;
+ * If depsolving fails then gnome-software will not start.
+ *
+ * Since: 3.22
+ **/
+void
+gs_plugin_add_rule (GsPlugin *plugin, GsPluginRule rule, const gchar *name)
+{
+ GsPluginPrivate *priv = gs_plugin_get_instance_private (plugin);
+ g_ptr_array_add (priv->rules[rule], g_strdup (name));
+}
+
+/**
+ * gs_plugin_get_rules:
+ * @plugin: a #GsPlugin
+ * @rule: a #GsPluginRule, e.g. %GS_PLUGIN_RULE_CONFLICTS
+ *
+ * Gets the plugin IDs that should be run after this plugin.
+ *
+ * Returns: (element-type utf8) (transfer none): the list of plugin names, e.g. ['appstream']
+ *
+ * Since: 3.22
+ **/
+GPtrArray *
+gs_plugin_get_rules (GsPlugin *plugin, GsPluginRule rule)
+{
+ GsPluginPrivate *priv = gs_plugin_get_instance_private (plugin);
+ return priv->rules[rule];
+}
+
+/**
+ * gs_plugin_check_distro_id:
+ * @plugin: a #GsPlugin
+ * @distro_id: a distro ID, e.g. "fedora"
+ *
+ * Checks if the distro is compatible.
+ *
+ * Returns: %TRUE if compatible
+ *
+ * Since: 3.22
+ **/
+gboolean
+gs_plugin_check_distro_id (GsPlugin *plugin, const gchar *distro_id)
+{
+ g_autoptr(GError) error = NULL;
+ g_autoptr(GsOsRelease) os_release = NULL;
+ const gchar *id = NULL;
+
+ /* load /etc/os-release */
+ os_release = gs_os_release_new (&error);
+ if (os_release == NULL) {
+ g_debug ("could not parse os-release: %s", error->message);
+ return FALSE;
+ }
+
+ /* check that we are running on Fedora */
+ id = gs_os_release_get_id (os_release);
+ if (id == NULL) {
+ g_debug ("could not get distro ID");
+ return FALSE;
+ }
+ if (g_strcmp0 (id, distro_id) != 0)
+ return FALSE;
+ return TRUE;
+}
+
+typedef struct {
+ GWeakRef plugin_weak; /* (element-type GsPlugin) */
+ GsApp *app; /* (owned) */
+ GsPluginStatus status;
+ guint percentage;
+} GsPluginStatusHelper;
+
+static void
+gs_plugin_status_helper_free (GsPluginStatusHelper *helper)
+{
+ g_weak_ref_clear (&helper->plugin_weak);
+ g_clear_object (&helper->app);
+ g_slice_free (GsPluginStatusHelper, helper);
+}
+
+G_DEFINE_AUTOPTR_CLEANUP_FUNC (GsPluginStatusHelper, gs_plugin_status_helper_free)
+
+static gboolean
+gs_plugin_status_update_cb (gpointer user_data)
+{
+ GsPluginStatusHelper *helper = (GsPluginStatusHelper *) user_data;
+ g_autoptr(GsPlugin) plugin = NULL;
+
+ /* Does the plugin still exist? */
+ plugin = g_weak_ref_get (&helper->plugin_weak);
+
+ if (plugin != NULL)
+ g_signal_emit (plugin,
+ signals[SIGNAL_STATUS_CHANGED], 0,
+ helper->app,
+ helper->status);
+
+ return G_SOURCE_REMOVE;
+}
+
+/**
+ * gs_plugin_status_update:
+ * @plugin: a #GsPlugin
+ * @app: a #GsApp, or %NULL
+ * @status: a #GsPluginStatus, e.g. %GS_PLUGIN_STATUS_DOWNLOADING
+ *
+ * Update the state of the plugin so any UI can be updated.
+ *
+ * Since: 3.22
+ **/
+void
+gs_plugin_status_update (GsPlugin *plugin, GsApp *app, GsPluginStatus status)
+{
+ g_autoptr(GsPluginStatusHelper) helper = NULL;
+ g_autoptr(GSource) idle_source = NULL;
+
+ helper = g_slice_new0 (GsPluginStatusHelper);
+ g_weak_ref_init (&helper->plugin_weak, plugin);
+ helper->status = status;
+ if (app != NULL)
+ helper->app = g_object_ref (app);
+
+ idle_source = g_idle_source_new ();
+ g_source_set_callback (idle_source, gs_plugin_status_update_cb, g_steal_pointer (&helper), (GDestroyNotify) gs_plugin_status_helper_free);
+ g_source_attach (idle_source, NULL);
+}
+
+typedef struct {
+ GsPlugin *plugin;
+ gchar *remote;
+ gchar *realm;
+ GCallback callback;
+ gpointer user_data;
+} GsPluginBasicAuthHelper;
+
+static gboolean
+gs_plugin_basic_auth_start_cb (gpointer user_data)
+{
+ GsPluginBasicAuthHelper *helper = user_data;
+ g_signal_emit (helper->plugin,
+ signals[SIGNAL_BASIC_AUTH_START], 0,
+ helper->remote,
+ helper->realm,
+ helper->callback,
+ helper->user_data);
+ g_free (helper->remote);
+ g_free (helper->realm);
+ g_slice_free (GsPluginBasicAuthHelper, helper);
+ return FALSE;
+}
+
+/**
+ * gs_plugin_basic_auth_start:
+ * @plugin: a #GsPlugin
+ * @remote: a string
+ * @realm: a string
+ * @callback: callback to invoke to submit the user/password
+ * @user_data: callback data to pass to the callback
+ *
+ * Emit the basic-auth-start signal in the main thread.
+ *
+ * Since: 3.38
+ **/
+void
+gs_plugin_basic_auth_start (GsPlugin *plugin,
+ const gchar *remote,
+ const gchar *realm,
+ GCallback callback,
+ gpointer user_data)
+{
+ GsPluginBasicAuthHelper *helper;
+ g_autoptr(GSource) idle_source = NULL;
+
+ helper = g_slice_new0 (GsPluginBasicAuthHelper);
+ helper->plugin = plugin;
+ helper->remote = g_strdup (remote);
+ helper->realm = g_strdup (realm);
+ helper->callback = callback;
+ helper->user_data = user_data;
+
+ idle_source = g_idle_source_new ();
+ g_source_set_callback (idle_source, gs_plugin_basic_auth_start_cb, helper, NULL);
+ g_source_attach (idle_source, NULL);
+}
+
+static gboolean
+gs_plugin_app_launch_cb (gpointer user_data)
+{
+ GAppInfo *appinfo = (GAppInfo *) user_data;
+ GdkDisplay *display;
+ g_autoptr(GAppLaunchContext) context = NULL;
+ g_autoptr(GError) error = NULL;
+
+ display = gdk_display_get_default ();
+ context = G_APP_LAUNCH_CONTEXT (gdk_display_get_app_launch_context (display));
+ if (!g_app_info_launch (appinfo, NULL, context, &error))
+ g_warning ("Failed to launch: %s", error->message);
+
+ return G_SOURCE_REMOVE;
+}
+
+/**
+ * gs_plugin_app_launch:
+ * @plugin: a #GsPlugin
+ * @app: a #GsApp
+ * @error: a #GError, or %NULL
+ *
+ * Launches the application using #GAppInfo.
+ *
+ * Returns: %TRUE for success
+ *
+ * Since: 3.22
+ **/
+gboolean
+gs_plugin_app_launch (GsPlugin *plugin, GsApp *app, GError **error)
+{
+ const gchar *desktop_id;
+ g_autoptr(GAppInfo) appinfo = NULL;
+
+ desktop_id = gs_app_get_launchable (app, AS_LAUNCHABLE_KIND_DESKTOP_ID);
+ if (desktop_id == NULL)
+ desktop_id = gs_app_get_id (app);
+ if (desktop_id == NULL) {
+ g_set_error (error,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_NOT_SUPPORTED,
+ "no such desktop file: %s",
+ desktop_id);
+ return FALSE;
+ }
+ appinfo = G_APP_INFO (gs_utils_get_desktop_app_info (desktop_id));
+ if (appinfo == NULL) {
+ g_set_error (error,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_NOT_SUPPORTED,
+ "no such desktop file: %s",
+ desktop_id);
+ return FALSE;
+ }
+ g_idle_add_full (G_PRIORITY_DEFAULT,
+ gs_plugin_app_launch_cb,
+ g_object_ref (appinfo),
+ (GDestroyNotify) g_object_unref);
+ return TRUE;
+}
+
+static GDesktopAppInfo *
+check_directory_for_desktop_file (GsPlugin *plugin,
+ GsApp *app,
+ GsPluginPickDesktopFileCallback cb,
+ gpointer user_data,
+ const gchar *desktop_id,
+ const gchar *data_dir)
+{
+ g_autofree gchar *filename = NULL;
+ g_autoptr(GKeyFile) key_file = NULL;
+
+ filename = g_build_filename (data_dir, "applications", desktop_id, NULL);
+ key_file = g_key_file_new ();
+
+ if (g_key_file_load_from_file (key_file, filename, G_KEY_FILE_KEEP_TRANSLATIONS, NULL) &&
+ cb (plugin, app, filename, key_file)) {
+ g_autoptr(GDesktopAppInfo) appinfo = NULL;
+ appinfo = g_desktop_app_info_new_from_keyfile (key_file);
+ if (appinfo != NULL)
+ return g_steal_pointer (&appinfo);
+ g_debug ("Failed to load '%s' as a GDesktopAppInfo", filename);
+ return NULL;
+ }
+
+ if (!g_str_has_suffix (desktop_id, ".desktop")) {
+ g_autofree gchar *desktop_filename = g_strconcat (filename, ".desktop", NULL);
+ if (g_key_file_load_from_file (key_file, desktop_filename, G_KEY_FILE_KEEP_TRANSLATIONS, NULL) &&
+ cb (plugin, app, desktop_filename, key_file)) {
+ g_autoptr(GDesktopAppInfo) appinfo = NULL;
+ appinfo = g_desktop_app_info_new_from_keyfile (key_file);
+ if (appinfo != NULL)
+ return g_steal_pointer (&appinfo);
+ g_debug ("Failed to load '%s' as a GDesktopAppInfo", desktop_filename);
+ return NULL;
+ }
+ }
+
+ return NULL;
+}
+
+/**
+ * gs_plugin_app_launch_filtered:
+ * @plugin: a #GsPlugin
+ * @app: a #GsApp to launch
+ * @cb: a callback to pick the correct .desktop file
+ * @user_data: (closure cb) (scope call): user data for the @cb
+ * @error: a #GError, or %NULL
+ *
+ * Launches application @app, using the .desktop file picked by the @cb.
+ * This can help in case multiple versions of the @app are installed
+ * in the system (like a Flatpak and RPM versions).
+ *
+ * Returns: %TRUE on success
+ *
+ * Since: 43
+ **/
+gboolean
+gs_plugin_app_launch_filtered (GsPlugin *plugin,
+ GsApp *app,
+ GsPluginPickDesktopFileCallback cb,
+ gpointer user_data,
+ GError **error)
+{
+ const gchar *desktop_id;
+ g_autoptr(GDesktopAppInfo) appinfo = NULL;
+
+ g_return_val_if_fail (GS_IS_PLUGIN (plugin), FALSE);
+ g_return_val_if_fail (GS_IS_APP (app), FALSE);
+ g_return_val_if_fail (cb != NULL, FALSE);
+
+ desktop_id = gs_app_get_launchable (app, AS_LAUNCHABLE_KIND_DESKTOP_ID);
+ if (desktop_id == NULL)
+ desktop_id = gs_app_get_id (app);
+ if (desktop_id == NULL) {
+ g_set_error (error,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_NOT_SUPPORTED,
+ "no desktop file for app: %s",
+ gs_app_get_name (app));
+ return FALSE;
+ }
+
+ /* First, the configs. Highest priority: the user's ~/.config */
+ appinfo = check_directory_for_desktop_file (plugin, app, cb, user_data, desktop_id, g_get_user_config_dir ());
+
+ if (appinfo == NULL) {
+ /* Next, the system configs (/etc/xdg, and so on). */
+ const gchar * const *dirs;
+ dirs = g_get_system_config_dirs ();
+ for (guint i = 0; dirs[i] && appinfo == NULL; i++) {
+ appinfo = check_directory_for_desktop_file (plugin, app, cb, user_data, desktop_id, dirs[i]);
+ }
+ }
+
+ if (appinfo == NULL) {
+ /* Now the data. Highest priority: the user's ~/.local/share/applications */
+ appinfo = check_directory_for_desktop_file (plugin, app, cb, user_data, desktop_id, g_get_user_data_dir ());
+ }
+
+ if (appinfo == NULL) {
+ /* Following that, XDG_DATA_DIRS/applications, in order */
+ const gchar * const *dirs;
+ dirs = g_get_system_data_dirs ();
+ for (guint i = 0; dirs[i] && appinfo == NULL; i++) {
+ appinfo = check_directory_for_desktop_file (plugin, app, cb, user_data, desktop_id, dirs[i]);
+ }
+ }
+
+ if (appinfo == NULL) {
+ g_set_error (error,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_NOT_SUPPORTED,
+ "no appropriate desktop file found: %s",
+ desktop_id);
+ return FALSE;
+ }
+
+ g_idle_add_full (G_PRIORITY_DEFAULT,
+ gs_plugin_app_launch_cb,
+ g_object_ref (appinfo),
+ (GDestroyNotify) g_object_unref);
+
+ return TRUE;
+}
+
+static void
+weak_ref_free (GWeakRef *weak)
+{
+ g_weak_ref_clear (weak);
+ g_free (weak);
+}
+
+G_DEFINE_AUTOPTR_CLEANUP_FUNC (GWeakRef, weak_ref_free)
+
+/* @obj is a gpointer rather than a GObject* to avoid the need for casts */
+static GWeakRef *
+weak_ref_new (gpointer obj)
+{
+ g_autoptr(GWeakRef) weak = g_new0 (GWeakRef, 1);
+ g_weak_ref_init (weak, obj);
+ return g_steal_pointer (&weak);
+}
+
+static gboolean
+gs_plugin_updates_changed_cb (gpointer user_data)
+{
+ GWeakRef *plugin_weak = user_data;
+ g_autoptr(GsPlugin) plugin = NULL;
+
+ plugin = g_weak_ref_get (plugin_weak);
+ if (plugin != NULL)
+ g_signal_emit (plugin, signals[SIGNAL_UPDATES_CHANGED], 0);
+
+ return G_SOURCE_REMOVE;
+}
+
+/**
+ * gs_plugin_updates_changed:
+ * @plugin: a #GsPlugin
+ *
+ * Emit a signal that tells the plugin loader that the list of updates
+ * may have changed.
+ *
+ * Since: 3.22
+ **/
+void
+gs_plugin_updates_changed (GsPlugin *plugin)
+{
+ g_idle_add_full (G_PRIORITY_DEFAULT_IDLE, gs_plugin_updates_changed_cb,
+ weak_ref_new (plugin), (GDestroyNotify) weak_ref_free);
+}
+
+static gboolean
+gs_plugin_reload_cb (gpointer user_data)
+{
+ GWeakRef *plugin_weak = user_data;
+ g_autoptr(GsPlugin) plugin = NULL;
+
+ plugin = g_weak_ref_get (plugin_weak);
+ if (plugin != NULL)
+ g_signal_emit (plugin, signals[SIGNAL_RELOAD], 0);
+
+ return G_SOURCE_REMOVE;
+}
+
+/**
+ * gs_plugin_reload:
+ * @plugin: a #GsPlugin
+ *
+ * Plugins that call this function should expect that all panels will
+ * reload after a small delay, causing mush flashing, wailing and
+ * gnashing of teeth.
+ *
+ * Plugins should not call this unless absolutely required.
+ *
+ * Since: 3.22
+ **/
+void
+gs_plugin_reload (GsPlugin *plugin)
+{
+ g_debug ("emitting %s::reload in idle", gs_plugin_get_name (plugin));
+ g_idle_add_full (G_PRIORITY_DEFAULT_IDLE, gs_plugin_reload_cb,
+ weak_ref_new (plugin), (GDestroyNotify) weak_ref_free);
+}
+
+typedef struct {
+ GsPlugin *plugin;
+ GsApp *app;
+} GsPluginDownloadHelper;
+
+static void
+download_file_progress_cb (gsize total_written_bytes,
+ gsize total_download_size,
+ gpointer user_data)
+{
+ GsPluginDownloadHelper *helper = user_data;
+ guint percentage;
+
+ if (total_download_size > 0)
+ percentage = (guint) ((100 * total_written_bytes) / total_download_size);
+ else
+ percentage = 0;
+
+ g_debug ("%s progress: %u%%", gs_app_get_id (helper->app), percentage);
+ gs_app_set_progress (helper->app, percentage);
+ gs_plugin_status_update (helper->plugin,
+ helper->app,
+ GS_PLUGIN_STATUS_DOWNLOADING);
+
+}
+
+static void
+async_result_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data)
+{
+ GAsyncResult **result_out = user_data;
+
+ g_assert (*result_out == NULL);
+ *result_out = g_object_ref (result);
+ g_main_context_wakeup (g_main_context_get_thread_default ());
+}
+
+/**
+ * gs_plugin_download_file:
+ * @plugin: a #GsPlugin
+ * @app: a #GsApp, or %NULL
+ * @uri: a remote URI
+ * @filename: a local filename
+ * @cancellable: a #GCancellable, or %NULL
+ * @error: a #GError, or %NULL
+ *
+ * Downloads data and saves it to a file.
+ *
+ * Returns: %TRUE for success
+ *
+ * Since: 3.22
+ **/
+gboolean
+gs_plugin_download_file (GsPlugin *plugin,
+ GsApp *app,
+ const gchar *uri,
+ const gchar *filename,
+ GCancellable *cancellable,
+ GError **error)
+{
+ g_autoptr(SoupSession) soup_session = NULL;
+ g_autoptr(GFile) output_file = NULL;
+ g_autoptr(GAsyncResult) result = NULL;
+ g_autoptr(GMainContext) context = g_main_context_new ();
+ g_autoptr(GMainContextPusher) context_pusher = g_main_context_pusher_new (context);
+ GsPluginDownloadHelper helper;
+ g_autoptr(GError) local_error = NULL;
+
+ helper.plugin = plugin;
+ helper.app = app;
+
+ soup_session = gs_build_soup_session ();
+
+ /* Do the download. */
+ output_file = g_file_new_for_path (filename);
+ gs_download_file_async (soup_session, uri, output_file,
+ G_PRIORITY_LOW,
+ download_file_progress_cb, &helper,
+ cancellable, async_result_cb, &result);
+
+ while (result == NULL)
+ g_main_context_iteration (context, TRUE);
+
+ if (!gs_download_file_finish (soup_session, result, &local_error) &&
+ !g_error_matches (local_error, GS_DOWNLOAD_ERROR, GS_DOWNLOAD_ERROR_NOT_MODIFIED)) {
+ g_set_error_literal (error,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_DOWNLOAD_FAILED,
+ local_error->message);
+ return FALSE;
+ }
+
+ return TRUE;
+}
+
+static gchar *
+gs_plugin_download_rewrite_resource_uri (GsPlugin *plugin,
+ GsApp *app,
+ const gchar *uri,
+ GCancellable *cancellable,
+ GError **error)
+{
+ g_autofree gchar *cachefn = NULL;
+
+ /* local files */
+ if (g_str_has_prefix (uri, "file://"))
+ uri += 7;
+ if (g_str_has_prefix (uri, "/")) {
+ if (!g_file_test (uri, G_FILE_TEST_EXISTS)) {
+ g_set_error (error,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_NOT_SUPPORTED,
+ "Failed to find file: %s", uri);
+ return NULL;
+ }
+ return g_strdup (uri);
+ }
+
+ /* get cache location */
+ cachefn = gs_utils_get_cache_filename ("cssresource", uri,
+ GS_UTILS_CACHE_FLAG_WRITEABLE |
+ GS_UTILS_CACHE_FLAG_USE_HASH |
+ GS_UTILS_CACHE_FLAG_CREATE_DIRECTORY,
+ error);
+ if (cachefn == NULL)
+ return NULL;
+
+ /* already exists */
+ if (g_file_test (cachefn, G_FILE_TEST_EXISTS))
+ return g_steal_pointer (&cachefn);
+
+ /* download */
+ if (!gs_plugin_download_file (plugin, app, uri, cachefn,
+ cancellable, error)) {
+ return NULL;
+ }
+ return g_steal_pointer (&cachefn);
+}
+
+/**
+ * gs_plugin_download_rewrite_resource:
+ * @plugin: a #GsPlugin
+ * @app: a #GsApp, or %NULL
+ * @resource: the CSS resource
+ * @cancellable: a #GCancellable, or %NULL
+ * @error: a #GError, or %NULL
+ *
+ * Downloads remote assets and rewrites a CSS resource to use cached local URIs.
+ *
+ * Returns: %TRUE for success
+ *
+ * Since: 3.26
+ **/
+gchar *
+gs_plugin_download_rewrite_resource (GsPlugin *plugin,
+ GsApp *app,
+ const gchar *resource,
+ GCancellable *cancellable,
+ GError **error)
+{
+ guint start = 0;
+ g_autoptr(GString) resource_str = g_string_new (resource);
+ g_autoptr(GString) str = g_string_new (NULL);
+
+ g_return_val_if_fail (GS_IS_PLUGIN (plugin), NULL);
+ g_return_val_if_fail (resource != NULL, NULL);
+ g_return_val_if_fail (error == NULL || *error == NULL, NULL);
+
+ /* replace datadir */
+ as_gstring_replace (resource_str, "@datadir@", DATADIR);
+ resource = resource_str->str;
+
+ /* look in string for any url() links */
+ for (guint i = 0; resource[i] != '\0'; i++) {
+ if (i > 4 && strncmp (resource + i - 4, "url(", 4) == 0) {
+ start = i;
+ continue;
+ }
+ if (start == 0) {
+ g_string_append_c (str, resource[i]);
+ continue;
+ }
+ if (resource[i] == ')') {
+ guint len;
+ g_autofree gchar *cachefn = NULL;
+ g_autofree gchar *uri = NULL;
+
+ /* remove optional single quotes */
+ if (resource[start] == '\'' || resource[start] == '"')
+ start++;
+ len = i - start;
+ if (i > 0 && (resource[i - 1] == '\'' || resource[i - 1] == '"'))
+ len--;
+ uri = g_strndup (resource + start, len);
+
+ /* download them to per-user cache */
+ cachefn = gs_plugin_download_rewrite_resource_uri (plugin,
+ app,
+ uri,
+ cancellable,
+ error);
+ if (cachefn == NULL)
+ return NULL;
+ g_string_append_printf (str, "'file://%s'", cachefn);
+ g_string_append_c (str, resource[i]);
+ start = 0;
+ }
+ }
+ return g_strdup (str->str);
+}
+
+/**
+ * gs_plugin_cache_lookup:
+ * @plugin: a #GsPlugin
+ * @key: a string
+ *
+ * Looks up an application object from the per-plugin cache
+ *
+ * Returns: (transfer full) (nullable): the #GsApp, or %NULL
+ *
+ * Since: 3.22
+ **/
+GsApp *
+gs_plugin_cache_lookup (GsPlugin *plugin, const gchar *key)
+{
+ GsPluginPrivate *priv = gs_plugin_get_instance_private (plugin);
+ GsApp *app;
+ g_autoptr(GMutexLocker) locker = NULL;
+
+ g_return_val_if_fail (GS_IS_PLUGIN (plugin), NULL);
+ g_return_val_if_fail (key != NULL, NULL);
+
+ locker = g_mutex_locker_new (&priv->cache_mutex);
+ app = g_hash_table_lookup (priv->cache, key);
+ if (app == NULL)
+ return NULL;
+ return g_object_ref (app);
+}
+
+/**
+ * gs_plugin_cache_lookup_by_state:
+ * @plugin: a #GsPlugin
+ * @list: a #GsAppList to add applications to
+ * @state: a #GsAppState
+ *
+ * Adds each cached #GsApp with state @state into the @list.
+ * When the state is %GS_APP_STATE_UNKNOWN, then adds all
+ * cached applications.
+ *
+ * Since: 40
+ **/
+void
+gs_plugin_cache_lookup_by_state (GsPlugin *plugin,
+ GsAppList *list,
+ GsAppState state)
+{
+ GsPluginPrivate *priv;
+ GHashTableIter iter;
+ gpointer value;
+ g_autoptr(GMutexLocker) locker = NULL;
+
+ g_return_if_fail (GS_IS_PLUGIN (plugin));
+ g_return_if_fail (GS_IS_APP_LIST (list));
+
+ priv = gs_plugin_get_instance_private (plugin);
+ locker = g_mutex_locker_new (&priv->cache_mutex);
+
+ g_hash_table_iter_init (&iter, priv->cache);
+ while (g_hash_table_iter_next (&iter, NULL, &value)) {
+ GsApp *app = value;
+
+ if (state == GS_APP_STATE_UNKNOWN ||
+ state == gs_app_get_state (app))
+ gs_app_list_add (list, app);
+ }
+}
+
+/**
+ * gs_plugin_cache_remove:
+ * @plugin: a #GsPlugin
+ * @key: a key which matches
+ *
+ * Removes an application from the per-plugin cache.
+ *
+ * Since: 3.22
+ **/
+void
+gs_plugin_cache_remove (GsPlugin *plugin, const gchar *key)
+{
+ GsPluginPrivate *priv = gs_plugin_get_instance_private (plugin);
+ g_autoptr(GMutexLocker) locker = NULL;
+
+ g_return_if_fail (GS_IS_PLUGIN (plugin));
+ g_return_if_fail (key != NULL);
+
+ locker = g_mutex_locker_new (&priv->cache_mutex);
+ g_hash_table_remove (priv->cache, key);
+}
+
+/**
+ * gs_plugin_cache_add:
+ * @plugin: a #GsPlugin
+ * @key: a string, or %NULL if the unique ID should be used
+ * @app: a #GsApp
+ *
+ * Adds an application to the per-plugin cache. This is optional,
+ * and the plugin can use the cache however it likes.
+ *
+ * Since: 3.22
+ **/
+void
+gs_plugin_cache_add (GsPlugin *plugin, const gchar *key, GsApp *app)
+{
+ GsPluginPrivate *priv = gs_plugin_get_instance_private (plugin);
+ g_autoptr(GMutexLocker) locker = NULL;
+
+ g_return_if_fail (GS_IS_PLUGIN (plugin));
+ g_return_if_fail (GS_IS_APP (app));
+
+ locker = g_mutex_locker_new (&priv->cache_mutex);
+
+ /* the user probably doesn't want to do this */
+ if (gs_app_has_quirk (app, GS_APP_QUIRK_IS_WILDCARD)) {
+ g_warning ("adding wildcard app %s to plugin cache",
+ gs_app_get_unique_id (app));
+ }
+
+ /* default */
+ if (key == NULL)
+ key = gs_app_get_unique_id (app);
+
+ g_return_if_fail (key != NULL);
+
+ if (g_hash_table_lookup (priv->cache, key) == app)
+ return;
+ g_hash_table_insert (priv->cache, g_strdup (key), g_object_ref (app));
+}
+
+/**
+ * gs_plugin_cache_invalidate:
+ * @plugin: a #GsPlugin
+ *
+ * Invalidate the per-plugin cache by marking all entries as invalid.
+ * This is optional, and the plugin can evict the cache whenever it
+ * likes. Using this function may mean the front-end and the plugin
+ * may be operating on a different GsApp with the same cache ID.
+ *
+ * Most plugins do not need to call this function; if a suitable cache
+ * key is being used the old cache item can remain.
+ *
+ * Since: 3.22
+ **/
+void
+gs_plugin_cache_invalidate (GsPlugin *plugin)
+{
+ GsPluginPrivate *priv = gs_plugin_get_instance_private (plugin);
+ g_autoptr(GMutexLocker) locker = NULL;
+
+ g_return_if_fail (GS_IS_PLUGIN (plugin));
+
+ locker = g_mutex_locker_new (&priv->cache_mutex);
+ g_hash_table_remove_all (priv->cache);
+}
+
+/**
+ * gs_plugin_report_event:
+ * @plugin: a #GsPlugin
+ * @event: a #GsPluginEvent
+ *
+ * Report a non-fatal event to the UI. Plugins should not assume that a
+ * specific event is actually shown to the user as it may be ignored
+ * automatically.
+ *
+ * Since: 3.24
+ **/
+void
+gs_plugin_report_event (GsPlugin *plugin, GsPluginEvent *event)
+{
+ g_return_if_fail (GS_IS_PLUGIN (plugin));
+ g_return_if_fail (GS_IS_PLUGIN_EVENT (event));
+ g_signal_emit (plugin, signals[SIGNAL_REPORT_EVENT], 0, event);
+}
+
+/**
+ * gs_plugin_set_allow_updates:
+ * @plugin: a #GsPlugin
+ * @allow_updates: boolean
+ *
+ * This allows plugins to inhibit the showing of the updates panel.
+ * This will typically be used when the required permissions are not possible
+ * to obtain, or when a LiveUSB image is low on space.
+ *
+ * By default, the updates panel is shown so plugins do not need to call this
+ * function unless they called gs_plugin_set_allow_updates() with %FALSE.
+ *
+ * Since: 3.24
+ **/
+void
+gs_plugin_set_allow_updates (GsPlugin *plugin, gboolean allow_updates)
+{
+ g_return_if_fail (GS_IS_PLUGIN (plugin));
+ g_signal_emit (plugin, signals[SIGNAL_ALLOW_UPDATES], 0, allow_updates);
+}
+
+/**
+ * gs_plugin_error_to_string:
+ * @error: a #GsPluginError, e.g. %GS_PLUGIN_ERROR_NO_NETWORK
+ *
+ * Converts the enumerated error to a string.
+ *
+ * Returns: a string, or %NULL for invalid
+ **/
+const gchar *
+gs_plugin_error_to_string (GsPluginError error)
+{
+ if (error == GS_PLUGIN_ERROR_FAILED)
+ return "failed";
+ if (error == GS_PLUGIN_ERROR_NOT_SUPPORTED)
+ return "not-supported";
+ if (error == GS_PLUGIN_ERROR_CANCELLED)
+ return "cancelled";
+ if (error == GS_PLUGIN_ERROR_NO_NETWORK)
+ return "no-network";
+ if (error == GS_PLUGIN_ERROR_NO_SECURITY)
+ return "no-security";
+ if (error == GS_PLUGIN_ERROR_NO_SPACE)
+ return "no-space";
+ if (error == GS_PLUGIN_ERROR_AUTH_REQUIRED)
+ return "auth-required";
+ if (error == GS_PLUGIN_ERROR_AUTH_INVALID)
+ return "auth-invalid";
+ if (error == GS_PLUGIN_ERROR_PLUGIN_DEPSOLVE_FAILED)
+ return "plugin-depsolve-failed";
+ if (error == GS_PLUGIN_ERROR_DOWNLOAD_FAILED)
+ return "download-failed";
+ if (error == GS_PLUGIN_ERROR_WRITE_FAILED)
+ return "write-failed";
+ if (error == GS_PLUGIN_ERROR_INVALID_FORMAT)
+ return "invalid-format";
+ if (error == GS_PLUGIN_ERROR_DELETE_FAILED)
+ return "delete-failed";
+ if (error == GS_PLUGIN_ERROR_RESTART_REQUIRED)
+ return "restart-required";
+ if (error == GS_PLUGIN_ERROR_AC_POWER_REQUIRED)
+ return "ac-power-required";
+ if (error == GS_PLUGIN_ERROR_BATTERY_LEVEL_TOO_LOW)
+ return "battery-level-too-low";
+ if (error == GS_PLUGIN_ERROR_TIMED_OUT)
+ return "timed-out";
+ return NULL;
+}
+
+/**
+ * gs_plugin_action_to_function_name: (skip)
+ * @action: a #GsPluginAction, e.g. %GS_PLUGIN_ACTION_INSTALL
+ *
+ * Converts the enumerated action to the vfunc name.
+ *
+ * Returns: a string, or %NULL for invalid
+ **/
+const gchar *
+gs_plugin_action_to_function_name (GsPluginAction action)
+{
+ if (action == GS_PLUGIN_ACTION_INSTALL)
+ return "gs_plugin_app_install";
+ if (action == GS_PLUGIN_ACTION_REMOVE)
+ return "gs_plugin_app_remove";
+ if (action == GS_PLUGIN_ACTION_UPGRADE_DOWNLOAD)
+ return "gs_plugin_app_upgrade_download";
+ if (action == GS_PLUGIN_ACTION_UPGRADE_TRIGGER)
+ return "gs_plugin_app_upgrade_trigger";
+ if (action == GS_PLUGIN_ACTION_LAUNCH)
+ return "gs_plugin_launch";
+ if (action == GS_PLUGIN_ACTION_UPDATE_CANCEL)
+ return "gs_plugin_update_cancel";
+ if (action == GS_PLUGIN_ACTION_UPDATE)
+ return "gs_plugin_update";
+ if (action == GS_PLUGIN_ACTION_DOWNLOAD)
+ return "gs_plugin_download";
+ if (action == GS_PLUGIN_ACTION_FILE_TO_APP)
+ return "gs_plugin_file_to_app";
+ if (action == GS_PLUGIN_ACTION_URL_TO_APP)
+ return "gs_plugin_url_to_app";
+ if (action == GS_PLUGIN_ACTION_GET_SOURCES)
+ return "gs_plugin_add_sources";
+ if (action == GS_PLUGIN_ACTION_GET_UPDATES_HISTORICAL)
+ return "gs_plugin_add_updates_historical";
+ if (action == GS_PLUGIN_ACTION_GET_UPDATES)
+ return "gs_plugin_add_updates";
+ if (action == GS_PLUGIN_ACTION_GET_LANGPACKS)
+ return "gs_plugin_add_langpacks";
+ return NULL;
+}
+
+/**
+ * gs_plugin_action_to_string:
+ * @action: a #GsPluginAction, e.g. %GS_PLUGIN_ACTION_INSTALL
+ *
+ * Converts the enumerated action to a string.
+ *
+ * Returns: a string, or %NULL for invalid
+ **/
+const gchar *
+gs_plugin_action_to_string (GsPluginAction action)
+{
+ if (action == GS_PLUGIN_ACTION_UNKNOWN)
+ return "unknown";
+ if (action == GS_PLUGIN_ACTION_INSTALL)
+ return "install";
+ if (action == GS_PLUGIN_ACTION_DOWNLOAD)
+ return "download";
+ if (action == GS_PLUGIN_ACTION_REMOVE)
+ return "remove";
+ if (action == GS_PLUGIN_ACTION_UPDATE)
+ return "update";
+ if (action == GS_PLUGIN_ACTION_UPGRADE_DOWNLOAD)
+ return "upgrade-download";
+ if (action == GS_PLUGIN_ACTION_UPGRADE_TRIGGER)
+ return "upgrade-trigger";
+ if (action == GS_PLUGIN_ACTION_LAUNCH)
+ return "launch";
+ if (action == GS_PLUGIN_ACTION_UPDATE_CANCEL)
+ return "update-cancel";
+ if (action == GS_PLUGIN_ACTION_GET_UPDATES)
+ return "get-updates";
+ if (action == GS_PLUGIN_ACTION_GET_SOURCES)
+ return "get-sources";
+ if (action == GS_PLUGIN_ACTION_FILE_TO_APP)
+ return "file-to-app";
+ if (action == GS_PLUGIN_ACTION_URL_TO_APP)
+ return "url-to-app";
+ if (action == GS_PLUGIN_ACTION_GET_UPDATES_HISTORICAL)
+ return "get-updates-historical";
+ if (action == GS_PLUGIN_ACTION_GET_LANGPACKS)
+ return "get-langpacks";
+ if (action == GS_PLUGIN_ACTION_INSTALL_REPO)
+ return "repo-install";
+ if (action == GS_PLUGIN_ACTION_REMOVE_REPO)
+ return "repo-remove";
+ if (action == GS_PLUGIN_ACTION_ENABLE_REPO)
+ return "repo-enable";
+ if (action == GS_PLUGIN_ACTION_DISABLE_REPO)
+ return "repo-disable";
+ return NULL;
+}
+
+/**
+ * gs_plugin_action_from_string:
+ * @action: a #GsPluginAction, e.g. "install"
+ *
+ * Converts the string to an enumerated action.
+ *
+ * Returns: a GsPluginAction, e.g. %GS_PLUGIN_ACTION_INSTALL
+ *
+ * Since: 3.26
+ **/
+GsPluginAction
+gs_plugin_action_from_string (const gchar *action)
+{
+ if (g_strcmp0 (action, "install") == 0)
+ return GS_PLUGIN_ACTION_INSTALL;
+ if (g_strcmp0 (action, "download") == 0)
+ return GS_PLUGIN_ACTION_DOWNLOAD;
+ if (g_strcmp0 (action, "remove") == 0)
+ return GS_PLUGIN_ACTION_REMOVE;
+ if (g_strcmp0 (action, "update") == 0)
+ return GS_PLUGIN_ACTION_UPDATE;
+ if (g_strcmp0 (action, "upgrade-download") == 0)
+ return GS_PLUGIN_ACTION_UPGRADE_DOWNLOAD;
+ if (g_strcmp0 (action, "upgrade-trigger") == 0)
+ return GS_PLUGIN_ACTION_UPGRADE_TRIGGER;
+ if (g_strcmp0 (action, "launch") == 0)
+ return GS_PLUGIN_ACTION_LAUNCH;
+ if (g_strcmp0 (action, "update-cancel") == 0)
+ return GS_PLUGIN_ACTION_UPDATE_CANCEL;
+ if (g_strcmp0 (action, "get-updates") == 0)
+ return GS_PLUGIN_ACTION_GET_UPDATES;
+ if (g_strcmp0 (action, "get-sources") == 0)
+ return GS_PLUGIN_ACTION_GET_SOURCES;
+ if (g_strcmp0 (action, "file-to-app") == 0)
+ return GS_PLUGIN_ACTION_FILE_TO_APP;
+ if (g_strcmp0 (action, "url-to-app") == 0)
+ return GS_PLUGIN_ACTION_URL_TO_APP;
+ if (g_strcmp0 (action, "get-updates-historical") == 0)
+ return GS_PLUGIN_ACTION_GET_UPDATES_HISTORICAL;
+ if (g_strcmp0 (action, "get-langpacks") == 0)
+ return GS_PLUGIN_ACTION_GET_LANGPACKS;
+ if (g_strcmp0 (action, "repo-install") == 0)
+ return GS_PLUGIN_ACTION_INSTALL_REPO;
+ if (g_strcmp0 (action, "repo-remove") == 0)
+ return GS_PLUGIN_ACTION_REMOVE_REPO;
+ if (g_strcmp0 (action, "repo-enable") == 0)
+ return GS_PLUGIN_ACTION_ENABLE_REPO;
+ if (g_strcmp0 (action, "repo-disable") == 0)
+ return GS_PLUGIN_ACTION_DISABLE_REPO;
+ return GS_PLUGIN_ACTION_UNKNOWN;
+}
+
+/**
+ * gs_plugin_refine_flags_to_string:
+ * @refine_flags: some #GsPluginRefineFlags, e.g. %GS_PLUGIN_REFINE_FLAGS_REQUIRE_SIZE
+ *
+ * Converts the flags to a string.
+ *
+ * Returns: a string
+ **/
+gchar *
+gs_plugin_refine_flags_to_string (GsPluginRefineFlags refine_flags)
+{
+ g_autoptr(GPtrArray) cstrs = g_ptr_array_new ();
+#pragma GCC diagnostic push
+#pragma GCC diagnostic ignored "-Wdiscarded-qualifiers"
+ if (refine_flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_ID)
+ g_ptr_array_add (cstrs, "require-id");
+ if (refine_flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_LICENSE)
+ g_ptr_array_add (cstrs, "require-license");
+ if (refine_flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_URL)
+ g_ptr_array_add (cstrs, "require-url");
+ if (refine_flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_DESCRIPTION)
+ g_ptr_array_add (cstrs, "require-description");
+ if (refine_flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_SIZE)
+ g_ptr_array_add (cstrs, "require-size");
+ if (refine_flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_RATING)
+ g_ptr_array_add (cstrs, "require-rating");
+ if (refine_flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_VERSION)
+ g_ptr_array_add (cstrs, "require-version");
+ if (refine_flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_HISTORY)
+ g_ptr_array_add (cstrs, "require-history");
+ if (refine_flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_SETUP_ACTION)
+ g_ptr_array_add (cstrs, "require-setup-action");
+ if (refine_flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_UPDATE_DETAILS)
+ g_ptr_array_add (cstrs, "require-update-details");
+ if (refine_flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_ORIGIN)
+ g_ptr_array_add (cstrs, "require-origin");
+ if (refine_flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_RELATED)
+ g_ptr_array_add (cstrs, "require-related");
+ if (refine_flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_ADDONS)
+ g_ptr_array_add (cstrs, "require-addons");
+ if (refine_flags & GS_PLUGIN_REFINE_FLAGS_ALLOW_PACKAGES)
+ g_ptr_array_add (cstrs, "require-allow-packages");
+ if (refine_flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_UPDATE_SEVERITY)
+ g_ptr_array_add (cstrs, "require-update-severity");
+ if (refine_flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_UPGRADE_REMOVED)
+ g_ptr_array_add (cstrs, "require-upgrade-removed");
+ if (refine_flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_PROVENANCE)
+ g_ptr_array_add (cstrs, "require-provenance");
+ if (refine_flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_REVIEWS)
+ g_ptr_array_add (cstrs, "require-reviews");
+ if (refine_flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_REVIEW_RATINGS)
+ g_ptr_array_add (cstrs, "require-review-ratings");
+ if (refine_flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_ICON)
+ g_ptr_array_add (cstrs, "require-icon");
+ if (refine_flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_PERMISSIONS)
+ g_ptr_array_add (cstrs, "require-permissions");
+ if (refine_flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_ORIGIN_HOSTNAME)
+ g_ptr_array_add (cstrs, "require-origin-hostname");
+ if (refine_flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_ORIGIN_UI)
+ g_ptr_array_add (cstrs, "require-origin-ui");
+ if (refine_flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_RUNTIME)
+ g_ptr_array_add (cstrs, "require-runtime");
+ if (refine_flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_SCREENSHOTS)
+ g_ptr_array_add (cstrs, "require-screenshots");
+ if (refine_flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_CATEGORIES)
+ g_ptr_array_add (cstrs, "require-categories");
+ if (refine_flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_PROJECT_GROUP)
+ g_ptr_array_add (cstrs, "require-project-group");
+ if (refine_flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_DEVELOPER_NAME)
+ g_ptr_array_add (cstrs, "require-developer-name");
+ if (refine_flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_KUDOS)
+ g_ptr_array_add (cstrs, "require-kudos");
+ if (refine_flags & GS_PLUGIN_REFINE_FLAGS_REQUIRE_CONTENT_RATING)
+ g_ptr_array_add (cstrs, "content-rating");
+#pragma GCC diagnostic pop
+ if (cstrs->len == 0)
+ return g_strdup ("none");
+ g_ptr_array_add (cstrs, NULL);
+ return g_strjoinv (",", (gchar**) cstrs->pdata);
+}
+
+static void
+gs_plugin_constructed (GObject *object)
+{
+ GsPlugin *plugin = GS_PLUGIN (object);
+ GsPluginPrivate *priv = gs_plugin_get_instance_private (plugin);
+
+ G_OBJECT_CLASS (gs_plugin_parent_class)->constructed (object);
+
+ /* Check all required properties have been set. */
+ g_assert (priv->session_bus_connection != NULL);
+ g_assert (priv->system_bus_connection != NULL);
+}
+
+static void
+gs_plugin_set_property (GObject *object, guint prop_id, const GValue *value, GParamSpec *pspec)
+{
+ GsPlugin *plugin = GS_PLUGIN (object);
+ GsPluginPrivate *priv = gs_plugin_get_instance_private (plugin);
+
+ switch ((GsPluginProperty) prop_id) {
+ case PROP_FLAGS:
+ priv->flags = g_value_get_flags (value);
+ g_object_notify_by_pspec (G_OBJECT (plugin), obj_props[PROP_FLAGS]);
+ break;
+ case PROP_SESSION_BUS_CONNECTION:
+ /* Construct only */
+ g_assert (priv->session_bus_connection == NULL);
+ priv->session_bus_connection = g_value_dup_object (value);
+ break;
+ case PROP_SYSTEM_BUS_CONNECTION:
+ /* Construct only */
+ g_assert (priv->system_bus_connection == NULL);
+ priv->system_bus_connection = g_value_dup_object (value);
+ break;
+ default:
+ G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+ break;
+ }
+}
+
+static void
+gs_plugin_get_property (GObject *object, guint prop_id, GValue *value, GParamSpec *pspec)
+{
+ GsPlugin *plugin = GS_PLUGIN (object);
+ GsPluginPrivate *priv = gs_plugin_get_instance_private (plugin);
+
+ switch ((GsPluginProperty) prop_id) {
+ case PROP_FLAGS:
+ g_value_set_flags (value, priv->flags);
+ break;
+ case PROP_SESSION_BUS_CONNECTION:
+ g_value_set_object (value, priv->session_bus_connection);
+ break;
+ case PROP_SYSTEM_BUS_CONNECTION:
+ g_value_set_object (value, priv->system_bus_connection);
+ break;
+ default:
+ G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+ break;
+ }
+}
+
+static void
+gs_plugin_class_init (GsPluginClass *klass)
+{
+ GObjectClass *object_class = G_OBJECT_CLASS (klass);
+
+ object_class->constructed = gs_plugin_constructed;
+ object_class->set_property = gs_plugin_set_property;
+ object_class->get_property = gs_plugin_get_property;
+ object_class->dispose = gs_plugin_dispose;
+ object_class->finalize = gs_plugin_finalize;
+
+ /**
+ * GsPlugin:flags:
+ *
+ * Flags which indicate various boolean properties of the plugin.
+ *
+ * These may change during the plugin’s lifetime.
+ */
+ obj_props[PROP_FLAGS] =
+ g_param_spec_flags ("flags", NULL, NULL,
+ GS_TYPE_PLUGIN_FLAGS, GS_PLUGIN_FLAGS_NONE,
+ G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ /**
+ * GsPlugin:session-bus-connection: (not nullable)
+ *
+ * A connection to the D-Bus session bus.
+ *
+ * This must be set at construction time and will not be %NULL
+ * afterwards.
+ *
+ * Since: 43
+ */
+ obj_props[PROP_SESSION_BUS_CONNECTION] =
+ g_param_spec_object ("session-bus-connection", NULL, NULL,
+ G_TYPE_DBUS_CONNECTION,
+ G_PARAM_CONSTRUCT_ONLY | G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ /**
+ * GsPlugin:system-bus-connection: (not nullable)
+ *
+ * A connection to the D-Bus system bus.
+ *
+ * This must be set at construction time and will not be %NULL
+ * afterwards.
+ *
+ * Since: 43
+ */
+ obj_props[PROP_SYSTEM_BUS_CONNECTION] =
+ g_param_spec_object ("system-bus-connection", NULL, NULL,
+ G_TYPE_DBUS_CONNECTION,
+ G_PARAM_CONSTRUCT_ONLY | G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ g_object_class_install_properties (object_class, G_N_ELEMENTS (obj_props), obj_props);
+
+ signals [SIGNAL_UPDATES_CHANGED] =
+ g_signal_new ("updates-changed",
+ G_TYPE_FROM_CLASS (object_class), G_SIGNAL_RUN_LAST,
+ G_STRUCT_OFFSET (GsPluginClass, updates_changed),
+ NULL, NULL, g_cclosure_marshal_VOID__VOID,
+ G_TYPE_NONE, 0);
+
+ signals [SIGNAL_STATUS_CHANGED] =
+ g_signal_new ("status-changed",
+ G_TYPE_FROM_CLASS (object_class), G_SIGNAL_RUN_LAST,
+ G_STRUCT_OFFSET (GsPluginClass, status_changed),
+ NULL, NULL, g_cclosure_marshal_generic,
+ G_TYPE_NONE, 2, GS_TYPE_APP, G_TYPE_UINT);
+
+ signals [SIGNAL_RELOAD] =
+ g_signal_new ("reload",
+ G_TYPE_FROM_CLASS (object_class), G_SIGNAL_RUN_LAST,
+ G_STRUCT_OFFSET (GsPluginClass, reload),
+ NULL, NULL, g_cclosure_marshal_VOID__VOID,
+ G_TYPE_NONE, 0);
+
+ signals [SIGNAL_REPORT_EVENT] =
+ g_signal_new ("report-event",
+ G_TYPE_FROM_CLASS (object_class), G_SIGNAL_RUN_LAST,
+ G_STRUCT_OFFSET (GsPluginClass, report_event),
+ NULL, NULL, g_cclosure_marshal_generic,
+ G_TYPE_NONE, 1, GS_TYPE_PLUGIN_EVENT);
+
+ signals [SIGNAL_ALLOW_UPDATES] =
+ g_signal_new ("allow-updates",
+ G_TYPE_FROM_CLASS (object_class), G_SIGNAL_RUN_LAST,
+ G_STRUCT_OFFSET (GsPluginClass, allow_updates),
+ NULL, NULL, g_cclosure_marshal_VOID__BOOLEAN,
+ G_TYPE_NONE, 1, G_TYPE_BOOLEAN);
+
+ signals [SIGNAL_BASIC_AUTH_START] =
+ g_signal_new ("basic-auth-start",
+ G_TYPE_FROM_CLASS (object_class), G_SIGNAL_RUN_LAST,
+ G_STRUCT_OFFSET (GsPluginClass, basic_auth_start),
+ NULL, NULL, g_cclosure_marshal_generic,
+ G_TYPE_NONE, 4, G_TYPE_STRING, G_TYPE_STRING, G_TYPE_POINTER, G_TYPE_POINTER);
+
+ signals [SIGNAL_REPOSITORY_CHANGED] =
+ g_signal_new ("repository-changed",
+ G_TYPE_FROM_CLASS (object_class), G_SIGNAL_RUN_LAST,
+ G_STRUCT_OFFSET (GsPluginClass, repository_changed),
+ NULL, NULL, g_cclosure_marshal_generic,
+ G_TYPE_NONE, 1, GS_TYPE_APP);
+
+ signals [SIGNAL_ASK_UNTRUSTED] =
+ g_signal_new ("ask-untrusted",
+ G_TYPE_FROM_CLASS (object_class), G_SIGNAL_RUN_LAST,
+ G_STRUCT_OFFSET (GsPluginClass, ask_untrusted),
+ NULL, NULL, g_cclosure_marshal_generic,
+ G_TYPE_BOOLEAN, 4, G_TYPE_STRING, G_TYPE_STRING, G_TYPE_STRING, G_TYPE_STRING);
+}
+
+static void
+gs_plugin_init (GsPlugin *plugin)
+{
+ GsPluginPrivate *priv = gs_plugin_get_instance_private (plugin);
+ guint i;
+
+ for (i = 0; i < GS_PLUGIN_RULE_LAST; i++)
+ priv->rules[i] = g_ptr_array_new_with_free_func (g_free);
+
+ priv->enabled = TRUE;
+ priv->scale = 1;
+ priv->cache = g_hash_table_new_full ((GHashFunc) as_utils_data_id_hash,
+ (GEqualFunc) as_utils_data_id_equal,
+ g_free,
+ (GDestroyNotify) g_object_unref);
+ priv->vfuncs = g_hash_table_new_full (g_str_hash, g_str_equal,
+ g_free, NULL);
+ g_mutex_init (&priv->cache_mutex);
+ g_mutex_init (&priv->interactive_mutex);
+ g_mutex_init (&priv->timer_mutex);
+ g_mutex_init (&priv->vfuncs_mutex);
+}
+
+/**
+ * gs_plugin_new:
+ * @session_bus_connection: (not nullable) (transfer none): a session bus
+ * connection to use
+ * @system_bus_connection: (not nullable) (transfer none): a system bus
+ * connection to use
+ *
+ * Creates a new plugin.
+ *
+ * Returns: a #GsPlugin
+ *
+ * Since: 43
+ **/
+GsPlugin *
+gs_plugin_new (GDBusConnection *session_bus_connection,
+ GDBusConnection *system_bus_connection)
+{
+ g_return_val_if_fail (G_IS_DBUS_CONNECTION (session_bus_connection), NULL);
+ g_return_val_if_fail (G_IS_DBUS_CONNECTION (system_bus_connection), NULL);
+
+ return g_object_new (GS_TYPE_PLUGIN,
+ "session-bus-connection", session_bus_connection,
+ "system-bus-connection", system_bus_connection,
+ NULL);
+}
+
+typedef struct {
+ GWeakRef plugin_weak; /* (owned) (element-type GsPlugin) */
+ GsApp *repository; /* (owned) */
+} GsPluginRepositoryChangedHelper;
+
+static void
+gs_plugin_repository_changed_helper_free (GsPluginRepositoryChangedHelper *helper)
+{
+ g_clear_object (&helper->repository);
+ g_weak_ref_clear (&helper->plugin_weak);
+ g_slice_free (GsPluginRepositoryChangedHelper, helper);
+}
+
+G_DEFINE_AUTOPTR_CLEANUP_FUNC (GsPluginRepositoryChangedHelper, gs_plugin_repository_changed_helper_free)
+
+static gboolean
+gs_plugin_repository_changed_cb (gpointer user_data)
+{
+ GsPluginRepositoryChangedHelper *helper = user_data;
+ g_autoptr(GsPlugin) plugin = NULL;
+
+ plugin = g_weak_ref_get (&helper->plugin_weak);
+ if (plugin != NULL)
+ g_signal_emit (plugin,
+ signals[SIGNAL_REPOSITORY_CHANGED], 0,
+ helper->repository);
+
+ return G_SOURCE_REMOVE;
+}
+
+/**
+ * gs_plugin_repository_changed:
+ * @plugin: a #GsPlugin
+ * @repository: a #GsApp representing the repository
+ *
+ * Emit the "repository-changed" signal in the main thread.
+ *
+ * Since: 40
+ **/
+void
+gs_plugin_repository_changed (GsPlugin *plugin,
+ GsApp *repository)
+{
+ g_autoptr(GsPluginRepositoryChangedHelper) helper = NULL;
+ g_autoptr(GSource) idle_source = NULL;
+
+ g_return_if_fail (GS_IS_PLUGIN (plugin));
+ g_return_if_fail (GS_IS_APP (repository));
+
+ helper = g_slice_new0 (GsPluginRepositoryChangedHelper);
+ g_weak_ref_init (&helper->plugin_weak, plugin);
+ helper->repository = g_object_ref (repository);
+
+ idle_source = g_idle_source_new ();
+ g_source_set_callback (idle_source, gs_plugin_repository_changed_cb, g_steal_pointer (&helper), (GDestroyNotify) gs_plugin_repository_changed_helper_free);
+ g_source_attach (idle_source, NULL);
+}
+
+/**
+ * gs_plugin_update_cache_state_for_repository:
+ * @plugin: a #GsPlugin
+ * @repository: a #GsApp representing a repository, which changed
+ *
+ * Update state of the all cached #GsApp instances related
+ * to the @repository.
+ *
+ * Since: 40
+ **/
+void
+gs_plugin_update_cache_state_for_repository (GsPlugin *plugin,
+ GsApp *repository)
+{
+ GsPluginPrivate *priv;
+ GHashTableIter iter;
+ g_autoptr(GMutexLocker) locker = NULL;
+ g_autoptr(GsPlugin) repo_plugin = NULL;
+ gpointer value;
+ const gchar *repo_id;
+ GsAppState repo_state;
+
+ g_return_if_fail (GS_IS_PLUGIN (plugin));
+ g_return_if_fail (GS_IS_APP (repository));
+
+ priv = gs_plugin_get_instance_private (plugin);
+ repo_id = gs_app_get_id (repository);
+ repo_state = gs_app_get_state (repository);
+ repo_plugin = gs_app_dup_management_plugin (repository);
+
+ locker = g_mutex_locker_new (&priv->cache_mutex);
+
+ g_hash_table_iter_init (&iter, priv->cache);
+ while (g_hash_table_iter_next (&iter, NULL, &value)) {
+ GsApp *app = value;
+ GsAppState app_state = gs_app_get_state (app);
+ g_autoptr(GsPlugin) app_plugin = gs_app_dup_management_plugin (app);
+
+ if (app_plugin != repo_plugin ||
+ gs_app_get_scope (app) != gs_app_get_scope (repository) ||
+ gs_app_get_bundle_kind (app) != gs_app_get_bundle_kind (repository))
+ continue;
+
+ if (((app_state == GS_APP_STATE_AVAILABLE &&
+ repo_state != GS_APP_STATE_INSTALLED) ||
+ (app_state == GS_APP_STATE_UNAVAILABLE &&
+ repo_state == GS_APP_STATE_INSTALLED)) &&
+ g_strcmp0 (gs_app_get_origin (app), repo_id) == 0) {
+ /* First reset the state, because move from 'available' to 'unavailable' is not correct */
+ gs_app_set_state (app, GS_APP_STATE_UNKNOWN);
+ gs_app_set_state (app, repo_state == GS_APP_STATE_INSTALLED ? GS_APP_STATE_AVAILABLE : GS_APP_STATE_UNAVAILABLE);
+ }
+ }
+}
+
+/**
+ * gs_plugin_ask_untrusted:
+ * @plugin: a #GsPlugin
+ * @title: the title for the question
+ * @msg: the message for the question
+ * @details: (nullable): the detailed error message, or %NULL for none
+ * @accept_label: (nullable): a label of the 'accept' button, or %NULL to use 'Accept'
+ *
+ * Asks the user whether he/she accepts an untrusted package install/download/update,
+ * as described by @title and @msg, eventually with the @details.
+ *
+ * Note: This is a blocking call and can be called only from the main/GUI thread.
+ *
+ * Returns: whether the user accepted the question
+ *
+ * Since: 42
+ **/
+gboolean
+gs_plugin_ask_untrusted (GsPlugin *plugin,
+ const gchar *title,
+ const gchar *msg,
+ const gchar *details,
+ const gchar *accept_label)
+{
+ gboolean accepts = FALSE;
+ g_signal_emit (plugin,
+ signals[SIGNAL_ASK_UNTRUSTED], 0,
+ title,
+ msg,
+ details,
+ accept_label,
+ &accepts);
+ return accepts;
+}
+
+/**
+ * gs_plugin_get_session_bus_connection:
+ * @self: a #GsPlugin
+ *
+ * Get the D-Bus session bus connection in use by the plugin.
+ *
+ * Returns: (transfer none) (not nullable): a D-Bus connection
+ * Since: 43
+ */
+GDBusConnection *
+gs_plugin_get_session_bus_connection (GsPlugin *self)
+{
+ GsPluginPrivate *priv = gs_plugin_get_instance_private (self);
+
+ g_return_val_if_fail (GS_IS_PLUGIN (self), NULL);
+
+ return priv->session_bus_connection;
+}
+
+/**
+ * gs_plugin_get_system_bus_connection:
+ * @self: a #GsPlugin
+ *
+ * Get the D-Bus system bus connection in use by the plugin.
+ *
+ * Returns: (transfer none) (not nullable): a D-Bus connection
+ * Since: 43
+ */
+GDBusConnection *
+gs_plugin_get_system_bus_connection (GsPlugin *self)
+{
+ GsPluginPrivate *priv = gs_plugin_get_instance_private (self);
+
+ g_return_val_if_fail (GS_IS_PLUGIN (self), NULL);
+
+ return priv->system_bus_connection;
+}
diff --git a/lib/gs-plugin.h b/lib/gs-plugin.h
new file mode 100644
index 0000000..87dd858
--- /dev/null
+++ b/lib/gs-plugin.h
@@ -0,0 +1,331 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2012-2016 Richard Hughes <richard@hughsie.com>
+ * Copyright (C) 2020 Kalev Lember <klember@redhat.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#include <glib-object.h>
+#include <gmodule.h>
+#include <gio/gio.h>
+
+#include "gs-app.h"
+#include "gs-app-list.h"
+#include "gs-app-query.h"
+#include "gs-category.h"
+#include "gs-plugin-event.h"
+#include "gs-plugin-types.h"
+
+G_BEGIN_DECLS
+
+#define GS_TYPE_PLUGIN (gs_plugin_get_type ())
+
+G_DECLARE_DERIVABLE_TYPE (GsPlugin, gs_plugin, GS, PLUGIN, GObject)
+
+/**
+ * GsPluginClass:
+ * @setup_async: (nullable): Setup method for the plugin. This is called after
+ * the #GsPlugin object is constructed, before it’s used for anything. It
+ * should do any long-running setup operations which the plugin needs, such as
+ * file or network access. It may be %NULL if the plugin doesn’t need to be
+ * explicitly shut down. It is not called if the plugin is disabled during
+ * construction.
+ * @setup_finish: (nullable): Finish method for @setup_async. Must be
+ * implemented if @setup_async is implemented. If this returns an error, the
+ * plugin will be disabled.
+ * @shutdown_async: (nullable): Shutdown method for the plugin. This is called
+ * by the #GsPluginLoader when the process is terminating or the
+ * #GsPluginLoader is being destroyed. It should be used to cancel or stop any
+ * ongoing operations or threads in the plugin. It may be %NULL if the plugin
+ * doesn’t need to be explicitly shut down.
+ * @shutdown_finish: (nullable): Finish method for @shutdown_async. Must be
+ * implemented if @shutdown_async is implemented.
+ * @refine_async: (nullable): Refining looks up and adds data to #GsApps. The
+ * apps to refine are provided in a list, and the flags specify what data to
+ * look up and add. Refining certain kinds of data can be very expensive (for
+ * example, requiring network requests), which is why it’s not all loaded by
+ * default. By refining multiple applications at once, data requests can be
+ * batched by the plugin where possible. (Since: 43)
+ * @refine_finish: (nullable): Finish method for @refine_async. Must be
+ * implemented if @refine_async is implemented. (Since: 43)
+ * @list_apps_async: (nullable): List apps matching a given query. (Since: 43)
+ * @list_apps_finish: (nullable): Finish method for @list_apps_async. Must be
+ * implemented if @list_apps_async is implemented. (Since: 43)
+ * @refresh_metadata_async: (nullable): Refresh plugin metadata. (Since: 43)
+ * @refresh_metadata_finish: (nullable): Finish method for
+ * @refresh_metadata_async. Must be implemented if @refresh_metadata_async is
+ * implemented. (Since: 43)
+ * @list_distro_upgrades_async: (nullable): List available distro upgrades. (Since: 43)
+ * @list_distro_upgrades_finish: (nullable): Finish method for
+ * @list_distro_upgrades_async. Must be implemented if
+ * @list_distro_upgrades_async is implemented. (Since: 43)
+ * @install_repository_async: (nullable): Install repository. (Since: 43)
+ * @install_repository_finish: (nullable): Finish method for
+ * @install_repository_async. Must be implemented if
+ * @install_repository_async is implemented. (Since: 43)
+ * @remove_repository_async: (nullable): Remove repository. (Since: 43)
+ * @remove_repository_finish: (nullable): Finish method for
+ * @remove_repository_async. Must be implemented if
+ * @remove_repository_async is implemented. (Since: 43)
+ * @enable_repository_async: (nullable): Enable repository. (Since: 43)
+ * @enable_repository_finish: (nullable): Finish method for
+ * @enable_repository_async. Must be implemented if
+ * @enable_repository_async is implemented. (Since: 43)
+ * @disable_repository_async: (nullable): Disable repository. (Since: 43)
+ * @disable_repository_finish: (nullable): Finish method for
+ * @disable_repository_async. Must be implemented if
+ * @disable_repository_async is implemented. (Since: 43)
+ * @refine_categories_async: (nullable): Refining looks up and adds data to
+ * #GsCategorys. The categories to refine are provided in a list, and the
+ * flags specify what data to look up and add. Refining certain kinds of data
+ * can be very expensive (for example, requiring network requests), which is
+ * why it’s not all loaded by default. By refining multiple categories at
+ * once, data requests can be batched by the plugin where possible. (Since: 43)
+ * @refine_categories_finish: (nullable): Finish method for
+ * @refine_categories_async. Must be implemented if @refine_categories_async
+ * is implemented. (Since: 43)
+ *
+ * The class structure for a #GsPlugin. Virtual methods here should be
+ * implemented by plugin implementations derived from #GsPlugin to provide their
+ * plugin-specific behaviour.
+ */
+struct _GsPluginClass
+{
+ GObjectClass parent_class;
+ void (*updates_changed) (GsPlugin *plugin);
+ void (*status_changed) (GsPlugin *plugin,
+ GsApp *app,
+ guint status);
+ void (*reload) (GsPlugin *plugin);
+ void (*report_event) (GsPlugin *plugin,
+ GsPluginEvent *event);
+ void (*allow_updates) (GsPlugin *plugin,
+ gboolean allow_updates);
+ void (*basic_auth_start) (GsPlugin *plugin,
+ const gchar *remote,
+ const gchar *realm,
+ GCallback callback,
+ gpointer user_data);
+ void (*repository_changed) (GsPlugin *plugin,
+ GsApp *repository);
+ gboolean (*ask_untrusted) (GsPlugin *plugin,
+ const gchar *title,
+ const gchar *msg,
+ const gchar *details,
+ const gchar *accept_label);
+
+ void (*setup_async) (GsPlugin *plugin,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data);
+ gboolean (*setup_finish) (GsPlugin *plugin,
+ GAsyncResult *result,
+ GError **error);
+
+ void (*shutdown_async) (GsPlugin *plugin,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data);
+ gboolean (*shutdown_finish) (GsPlugin *plugin,
+ GAsyncResult *result,
+ GError **error);
+
+ void (*refine_async) (GsPlugin *plugin,
+ GsAppList *list,
+ GsPluginRefineFlags flags,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data);
+ gboolean (*refine_finish) (GsPlugin *plugin,
+ GAsyncResult *result,
+ GError **error);
+
+ void (*list_apps_async) (GsPlugin *plugin,
+ GsAppQuery *query,
+ GsPluginListAppsFlags flags,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data);
+ GsAppList * (*list_apps_finish) (GsPlugin *plugin,
+ GAsyncResult *result,
+ GError **error);
+
+ void (*refresh_metadata_async) (GsPlugin *plugin,
+ guint64 cache_age_secs,
+ GsPluginRefreshMetadataFlags flags,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data);
+ gboolean (*refresh_metadata_finish) (GsPlugin *plugin,
+ GAsyncResult *result,
+ GError **error);
+
+ void (*list_distro_upgrades_async) (GsPlugin *plugin,
+ GsPluginListDistroUpgradesFlags flags,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data);
+ GsAppList * (*list_distro_upgrades_finish) (GsPlugin *plugin,
+ GAsyncResult *result,
+ GError **error);
+
+ void (*install_repository_async) (GsPlugin *plugin,
+ GsApp *repository,
+ GsPluginManageRepositoryFlags flags,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data);
+ gboolean (*install_repository_finish) (GsPlugin *plugin,
+ GAsyncResult *result,
+ GError **error);
+ void (*remove_repository_async) (GsPlugin *plugin,
+ GsApp *repository,
+ GsPluginManageRepositoryFlags flags,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data);
+ gboolean (*remove_repository_finish) (GsPlugin *plugin,
+ GAsyncResult *result,
+ GError **error);
+ void (*enable_repository_async) (GsPlugin *plugin,
+ GsApp *repository,
+ GsPluginManageRepositoryFlags flags,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data);
+ gboolean (*enable_repository_finish) (GsPlugin *plugin,
+ GAsyncResult *result,
+ GError **error);
+ void (*disable_repository_async) (GsPlugin *plugin,
+ GsApp *repository,
+ GsPluginManageRepositoryFlags flags,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data);
+ gboolean (*disable_repository_finish) (GsPlugin *plugin,
+ GAsyncResult *result,
+ GError **error);
+
+ void (*refine_categories_async) (GsPlugin *plugin,
+ GPtrArray *list,
+ GsPluginRefineCategoriesFlags flags,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data);
+ gboolean (*refine_categories_finish) (GsPlugin *plugin,
+ GAsyncResult *result,
+ GError **error);
+
+ gpointer padding[23];
+};
+
+/* helpers */
+#define GS_PLUGIN_ERROR gs_plugin_error_quark ()
+
+GQuark gs_plugin_error_quark (void);
+
+/* public getters and setters */
+const gchar *gs_plugin_get_name (GsPlugin *plugin);
+const gchar *gs_plugin_get_appstream_id (GsPlugin *plugin);
+void gs_plugin_set_appstream_id (GsPlugin *plugin,
+ const gchar *appstream_id);
+gboolean gs_plugin_get_enabled (GsPlugin *plugin);
+void gs_plugin_set_enabled (GsPlugin *plugin,
+ gboolean enabled);
+gboolean gs_plugin_has_flags (GsPlugin *plugin,
+ GsPluginFlags flags);
+void gs_plugin_add_flags (GsPlugin *plugin,
+ GsPluginFlags flags);
+void gs_plugin_remove_flags (GsPlugin *plugin,
+ GsPluginFlags flags);
+guint gs_plugin_get_scale (GsPlugin *plugin);
+const gchar *gs_plugin_get_language (GsPlugin *plugin);
+void gs_plugin_add_rule (GsPlugin *plugin,
+ GsPluginRule rule,
+ const gchar *name);
+
+/* helpers */
+gboolean gs_plugin_download_file (GsPlugin *plugin,
+ GsApp *app,
+ const gchar *uri,
+ const gchar *filename,
+ GCancellable *cancellable,
+ GError **error);
+gchar *gs_plugin_download_rewrite_resource (GsPlugin *plugin,
+ GsApp *app,
+ const gchar *resource,
+ GCancellable *cancellable,
+ GError **error);
+
+gboolean gs_plugin_check_distro_id (GsPlugin *plugin,
+ const gchar *distro_id);
+GsApp *gs_plugin_cache_lookup (GsPlugin *plugin,
+ const gchar *key);
+void gs_plugin_cache_lookup_by_state (GsPlugin *plugin,
+ GsAppList *list,
+ GsAppState state);
+void gs_plugin_cache_add (GsPlugin *plugin,
+ const gchar *key,
+ GsApp *app);
+void gs_plugin_cache_remove (GsPlugin *plugin,
+ const gchar *key);
+void gs_plugin_cache_invalidate (GsPlugin *plugin);
+void gs_plugin_status_update (GsPlugin *plugin,
+ GsApp *app,
+ GsPluginStatus status);
+gboolean gs_plugin_app_launch (GsPlugin *plugin,
+ GsApp *app,
+ GError **error);
+typedef gboolean (* GsPluginPickDesktopFileCallback) (GsPlugin *plugin,
+ GsApp *app,
+ const gchar *filename,
+ GKeyFile *key_file);
+/**
+ * GsPluginPickDesktopFileCallback:
+ * @plugin: a #GsPlugin
+ * @app: a #GsApp
+ * @filename: a .desktop file name
+ * @key_file: a #GKeyFile with @filename loaded
+ *
+ * A callback used by gs_plugin_app_launch_filtered() to filter which
+ * of the candidate .desktop files should be used to launch the @app.
+ *
+ * Returns: %TRUE, when the @key_file should be used, %FALSE to continue
+ * searching.
+ *
+ * Since: 43
+ **/
+gboolean gs_plugin_app_launch_filtered (GsPlugin *plugin,
+ GsApp *app,
+ GsPluginPickDesktopFileCallback cb,
+ gpointer user_data,
+ GError **error);
+void gs_plugin_updates_changed (GsPlugin *plugin);
+void gs_plugin_reload (GsPlugin *plugin);
+const gchar *gs_plugin_status_to_string (GsPluginStatus status);
+void gs_plugin_report_event (GsPlugin *plugin,
+ GsPluginEvent *event);
+void gs_plugin_set_allow_updates (GsPlugin *plugin,
+ gboolean allow_updates);
+gboolean gs_plugin_get_network_available (GsPlugin *plugin);
+void gs_plugin_basic_auth_start (GsPlugin *plugin,
+ const gchar *remote,
+ const gchar *realm,
+ GCallback callback,
+ gpointer user_data);
+void gs_plugin_repository_changed (GsPlugin *plugin,
+ GsApp *repository);
+void gs_plugin_update_cache_state_for_repository
+ (GsPlugin *plugin,
+ GsApp *repository);
+gboolean gs_plugin_ask_untrusted (GsPlugin *plugin,
+ const gchar *title,
+ const gchar *msg,
+ const gchar *details,
+ const gchar *accept_label);
+
+G_END_DECLS
diff --git a/lib/gs-remote-icon.c b/lib/gs-remote-icon.c
new file mode 100644
index 0000000..84b071b
--- /dev/null
+++ b/lib/gs-remote-icon.c
@@ -0,0 +1,375 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2021 Endless OS Foundation, Inc
+ *
+ * Author: Philip Withnall <pwithnall@endlessos.org>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+/**
+ * SECTION:gs-remote-icon
+ * @short_description: A #GIcon implementation for remote icons
+ *
+ * #GsRemoteIcon is a #GIcon implementation which represents remote icons —
+ * icons which have an HTTP or HTTPS URI. It provides a well-known local filename
+ * for a cached copy of the icon, accessible as #GFileIcon:file, and a method
+ * to download the icon to the cache, gs_remote_icon_ensure_cached().
+ *
+ * Constructing a #GsRemoteIcon does not guarantee that the icon is cached. Call
+ * gs_remote_icon_ensure_cached() for that.
+ *
+ * #GsRemoteIcon is immutable after construction and hence is entirely thread
+ * safe.
+ *
+ * FIXME: Currently does no cache invalidation.
+ *
+ * Since: 40
+ */
+
+#include "config.h"
+
+#include <gio/gio.h>
+#include <glib.h>
+#include <glib-object.h>
+#include <glib/gstdio.h>
+#include <fcntl.h>
+#include <sys/types.h>
+#include <sys/stat.h>
+#include <libsoup/soup.h>
+
+#include "gs-remote-icon.h"
+#include "gs-utils.h"
+
+/* FIXME: Work around the fact that GFileIcon is not derivable, by deriving from
+ * it anyway by copying its `struct GFileIcon` definition inline here. This will
+ * work as long as the size of `struct GFileIcon` doesn’t change within GIO.
+ * There’s no way of knowing if that’s the case.
+ *
+ * See https://gitlab.gnome.org/GNOME/glib/-/issues/2345 for why this is
+ * necessary. */
+struct _GsRemoteIcon
+{
+ /* struct GFileIcon { */
+ GObject grandparent;
+ GFile *file;
+ /* } */
+
+ gchar *uri; /* (owned), immutable after construction */
+};
+
+G_DEFINE_TYPE (GsRemoteIcon, gs_remote_icon, G_TYPE_FILE_ICON)
+
+typedef enum {
+ PROP_URI = 1,
+} GsRemoteIconProperty;
+
+static GParamSpec *obj_props[PROP_URI + 1] = { NULL, };
+
+static void
+gs_remote_icon_get_property (GObject *object,
+ guint prop_id,
+ GValue *value,
+ GParamSpec *pspec)
+{
+ GsRemoteIcon *self = GS_REMOTE_ICON (object);
+
+ switch ((GsRemoteIconProperty) prop_id) {
+ case PROP_URI:
+ g_value_set_string (value, gs_remote_icon_get_uri (self));
+ break;
+ default:
+ G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+ break;
+ }
+}
+
+static void
+gs_remote_icon_set_property (GObject *object,
+ guint prop_id,
+ const GValue *value,
+ GParamSpec *pspec)
+{
+ GsRemoteIcon *self = GS_REMOTE_ICON (object);
+
+ switch ((GsRemoteIconProperty) prop_id) {
+ case PROP_URI:
+ /* Construct only */
+ g_assert (self->uri == NULL);
+ self->uri = g_value_dup_string (value);
+ g_assert (g_str_has_prefix (self->uri, "http:") ||
+ g_str_has_prefix (self->uri, "https:"));
+ break;
+ default:
+ G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+ break;
+ }
+}
+
+static void
+gs_remote_icon_finalize (GObject *object)
+{
+ GsRemoteIcon *self = GS_REMOTE_ICON (object);
+
+ g_free (self->uri);
+
+ G_OBJECT_CLASS (gs_remote_icon_parent_class)->finalize (object);
+}
+
+static void
+gs_remote_icon_class_init (GsRemoteIconClass *klass)
+{
+ GObjectClass *object_class = G_OBJECT_CLASS (klass);
+
+ object_class->get_property = gs_remote_icon_get_property;
+ object_class->set_property = gs_remote_icon_set_property;
+ object_class->finalize = gs_remote_icon_finalize;
+
+ /**
+ * GsRemoteIcon:uri: (not nullable)
+ *
+ * Remote URI of the icon. This must be an HTTP or HTTPS URI; it is a
+ * programmer error to provide other URI schemes.
+ *
+ * Since: 40
+ */
+ obj_props[PROP_URI] =
+ g_param_spec_string ("uri", NULL, NULL,
+ NULL,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS);
+
+ g_object_class_install_properties (object_class, G_N_ELEMENTS (obj_props), obj_props);
+}
+
+static void
+gs_remote_icon_init (GsRemoteIcon *self)
+{
+}
+
+/* Use a hash-prefixed filename to avoid cache clashes.
+ * This can only fail if @create_directory is %TRUE. */
+static gchar *
+gs_remote_icon_get_cache_filename (const gchar *uri,
+ gboolean create_directory,
+ GError **error)
+{
+ g_autofree gchar *uri_checksum = NULL;
+ g_autofree gchar *uri_basename = NULL;
+ g_autofree gchar *cache_basename = NULL;
+ GsUtilsCacheFlags flags;
+
+ uri_checksum = g_compute_checksum_for_string (G_CHECKSUM_SHA1,
+ uri,
+ -1);
+ uri_basename = g_path_get_basename (uri);
+
+ /* convert filename from jpg to png, as we always convert to PNG on
+ * download */
+ if (g_str_has_suffix (uri_basename, ".jpg"))
+ memcpy (uri_basename + strlen (uri_basename) - 4, ".png", 4);
+
+ cache_basename = g_strdup_printf ("%s-%s", uri_checksum, uri_basename);
+
+ flags = GS_UTILS_CACHE_FLAG_WRITEABLE;
+ if (create_directory)
+ flags |= GS_UTILS_CACHE_FLAG_CREATE_DIRECTORY;
+
+ return gs_utils_get_cache_filename ("icons",
+ cache_basename,
+ flags,
+ error);
+}
+
+/**
+ * gs_remote_icon_new:
+ * @uri: remote URI of the icon
+ *
+ * Create a new #GsRemoteIcon representing @uri. The #GFileIcon:file of the
+ * resulting icon will represent the local cache location for the icon.
+ *
+ * Returns: (transfer full): a new remote icon
+ * Since: 40
+ */
+GIcon *
+gs_remote_icon_new (const gchar *uri)
+{
+ g_autofree gchar *cache_filename = NULL;
+ g_autoptr(GFile) file = NULL;
+
+ g_return_val_if_fail (uri != NULL, NULL);
+
+ /* The file is the expected cached location of the icon, once it’s
+ * downloaded. By setting it as the #GFileIcon:file property, existing
+ * code (particularly in GTK) which operates on #GFileIcons will work
+ * transparently with this.
+ *
+ * Ideally, #GFileIcon would be an interface rather than a class, which
+ * would make this implementation cleaner, but this is what we’re stuck
+ * with.
+ *
+ * See https://gitlab.gnome.org/GNOME/glib/-/issues/2345 */
+ cache_filename = gs_remote_icon_get_cache_filename (uri, FALSE, NULL);
+ g_assert (cache_filename != NULL);
+ file = g_file_new_for_path (cache_filename);
+
+ return g_object_new (GS_TYPE_REMOTE_ICON,
+ "file", file,
+ "uri", uri,
+ NULL);
+}
+
+/**
+ * gs_remote_icon_get_uri:
+ * @self: a #GsRemoteIcon
+ *
+ * Gets the value of #GsRemoteIcon:uri.
+ *
+ * Returns: (not nullable): remote URI of the icon
+ * Since: 40
+ */
+const gchar *
+gs_remote_icon_get_uri (GsRemoteIcon *self)
+{
+ g_return_val_if_fail (GS_IS_REMOTE_ICON (self), NULL);
+
+ return self->uri;
+}
+
+static GdkPixbuf *
+gs_icon_download (SoupSession *session,
+ const gchar *uri,
+ const gchar *destination_path,
+ guint max_size,
+ GCancellable *cancellable,
+ GError **error)
+{
+ guint status_code;
+ g_autoptr(SoupMessage) msg = NULL;
+ g_autoptr(GInputStream) stream = NULL;
+ g_autoptr(GdkPixbuf) pixbuf = NULL;
+ g_autoptr(GdkPixbuf) scaled_pixbuf = NULL;
+
+ /* Create the request */
+ msg = soup_message_new (SOUP_METHOD_GET, uri);
+ if (msg == NULL) {
+ g_set_error_literal (error,
+ G_IO_ERROR,
+ G_IO_ERROR_INVALID_DATA,
+ "Icon has an invalid URL");
+ return NULL;
+ }
+
+ /* Send request synchronously and start reading the response. */
+ stream = soup_session_send (session, msg, cancellable, error);
+
+#if SOUP_CHECK_VERSION(3, 0, 0)
+ status_code = soup_message_get_status (msg);
+#else
+ status_code = msg->status_code;
+#endif
+ if (stream == NULL) {
+ return NULL;
+ } else if (status_code != SOUP_STATUS_OK) {
+ g_set_error (error,
+ G_IO_ERROR,
+ G_IO_ERROR_FAILED,
+ "Failed to download icon %s: %s",
+ uri, soup_status_get_phrase (status_code));
+ return NULL;
+ }
+
+ /* Typically these icons are 64x64px PNG files. If not, resize down
+ * so it’s at most @max_size square, to minimise the size of the on-disk
+ * cache.*/
+ pixbuf = gdk_pixbuf_new_from_stream (stream, cancellable, error);
+ if (pixbuf == NULL)
+ return NULL;
+
+ if ((guint) gdk_pixbuf_get_height (pixbuf) <= max_size &&
+ (guint) gdk_pixbuf_get_width (pixbuf) <= max_size) {
+ scaled_pixbuf = g_object_ref (pixbuf);
+ } else {
+ scaled_pixbuf = gdk_pixbuf_scale_simple (pixbuf, max_size, max_size,
+ GDK_INTERP_BILINEAR);
+ }
+
+ /* write file */
+ if (!gdk_pixbuf_save (scaled_pixbuf, destination_path, "png", error, NULL))
+ return NULL;
+
+ return g_steal_pointer (&scaled_pixbuf);
+}
+
+/**
+ * gs_remote_icon_ensure_cached:
+ * @self: a #GsRemoteIcon
+ * @soup_session: a #SoupSession to use to download the icon
+ * @maximum_icon_size: maximum size (in device pixels) of the icon to save
+ * @cancellable: (nullable): a #GCancellable, or %NULL
+ * @error: return location for a #GError, or %NULL
+ *
+ * Ensure the given icon is present in the local cache, potentially downloading
+ * it from its remote server if needed. This will do network and disk I/O.
+ *
+ * @maximum_icon_size specifies the maximum size (in device pixels) of the icon
+ * which should be saved to the cache. This is the maximum size that the icon
+ * can ever be used at, as icons can be downscaled but never upscaled. Typically
+ * this will be 160px multiplied by the device scale
+ * (`gtk_widget_get_scale_factor()`).
+ *
+ * This can be called from any thread, as #GsRemoteIcon is immutable and hence
+ * thread-safe.
+ *
+ * Returns: %TRUE on success, %FALSE otherwise
+ * Since: 40
+ */
+gboolean
+gs_remote_icon_ensure_cached (GsRemoteIcon *self,
+ SoupSession *soup_session,
+ guint maximum_icon_size,
+ GCancellable *cancellable,
+ GError **error)
+{
+ const gchar *uri;
+ g_autofree gchar *cache_filename = NULL;
+ g_autoptr(GdkPixbuf) cached_pixbuf = NULL;
+ GStatBuf stat_buf;
+
+ g_return_val_if_fail (GS_IS_REMOTE_ICON (self), FALSE);
+ g_return_val_if_fail (SOUP_IS_SESSION (soup_session), FALSE);
+ g_return_val_if_fail (maximum_icon_size > 0, FALSE);
+ g_return_val_if_fail (cancellable == NULL || G_IS_CANCELLABLE (cancellable), FALSE);
+ g_return_val_if_fail (error == NULL || *error == NULL, FALSE);
+
+ uri = gs_remote_icon_get_uri (self);
+
+ /* Work out cache filename. */
+ cache_filename = gs_remote_icon_get_cache_filename (uri, TRUE, error);
+ if (cache_filename == NULL)
+ return FALSE;
+
+ /* Already in cache and not older than 30 days */
+ if (g_stat (cache_filename, &stat_buf) != -1 &&
+ S_ISREG (stat_buf.st_mode) &&
+ (g_get_real_time () / G_USEC_PER_SEC) - stat_buf.st_mtim.tv_sec < (60 * 60 * 24 * 30)) {
+ gint width = 0, height = 0;
+ /* Ensure the downloaded image dimensions are stored on the icon */
+ if (!g_object_get_data (G_OBJECT (self), "width") &&
+ gdk_pixbuf_get_file_info (cache_filename, &width, &height)) {
+ g_object_set_data (G_OBJECT (self), "width", GINT_TO_POINTER (width));
+ g_object_set_data (G_OBJECT (self), "height", GINT_TO_POINTER (height));
+ }
+ return TRUE;
+ }
+
+ cached_pixbuf = gs_icon_download (soup_session, uri, cache_filename, maximum_icon_size, cancellable, error);
+ if (cached_pixbuf == NULL)
+ return FALSE;
+
+ /* Ensure the dimensions are set correctly on the icon. */
+ g_object_set_data (G_OBJECT (self), "width", GUINT_TO_POINTER (gdk_pixbuf_get_width (cached_pixbuf)));
+ g_object_set_data (G_OBJECT (self), "height", GUINT_TO_POINTER (gdk_pixbuf_get_height (cached_pixbuf)));
+
+ return TRUE;
+}
diff --git a/lib/gs-remote-icon.h b/lib/gs-remote-icon.h
new file mode 100644
index 0000000..82e246f
--- /dev/null
+++ b/lib/gs-remote-icon.h
@@ -0,0 +1,37 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2021 Endless OS Foundation, Inc
+ *
+ * Author: Philip Withnall <pwithnall@endlessos.org>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#include <gio/gio.h>
+#include <glib.h>
+#include <glib-object.h>
+#include <libsoup/soup.h>
+
+G_BEGIN_DECLS
+
+#define GS_TYPE_REMOTE_ICON (gs_remote_icon_get_type ())
+
+/* FIXME: This is actually derived from GFileIcon, but the GFileIconClass isn’t
+ * public, so we use GObjectClass instead (which is what GFileIconClass is — it
+ * doesn’t define any vfuncs). See the note in gs-remote-icon.c. */
+G_DECLARE_FINAL_TYPE (GsRemoteIcon, gs_remote_icon, GS, REMOTE_ICON, GObject)
+
+GIcon *gs_remote_icon_new (const gchar *uri);
+
+const gchar *gs_remote_icon_get_uri (GsRemoteIcon *self);
+
+gboolean gs_remote_icon_ensure_cached (GsRemoteIcon *self,
+ SoupSession *soup_session,
+ guint maximum_icon_size,
+ GCancellable *cancellable,
+ GError **error);
+
+G_END_DECLS
diff --git a/lib/gs-self-test.c b/lib/gs-self-test.c
new file mode 100644
index 0000000..c38f452
--- /dev/null
+++ b/lib/gs-self-test.c
@@ -0,0 +1,786 @@
+/* -*- 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) 2015-2018 Kalev Lember <klember@redhat.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#include "config.h"
+
+#include "gnome-software-private.h"
+
+#include "gs-debug.h"
+#include "gs-test.h"
+
+static gboolean
+gs_app_list_filter_cb (GsApp *app, gpointer user_data)
+{
+ if (g_strcmp0 (gs_app_get_id (app), "a") == 0)
+ return FALSE;
+ if (g_strcmp0 (gs_app_get_id (app), "c") == 0)
+ return FALSE;
+ return TRUE;
+}
+
+static void
+gs_utils_url_func (void)
+{
+ g_autofree gchar *path1 = NULL;
+ g_autofree gchar *path2 = NULL;
+ g_autofree gchar *path3 = NULL;
+ g_autofree gchar *scheme1 = NULL;
+ g_autofree gchar *scheme2 = NULL;
+
+ scheme1 = gs_utils_get_url_scheme ("appstream://gimp.desktop");
+ g_assert_cmpstr (scheme1, ==, "appstream");
+ scheme2 = gs_utils_get_url_scheme ("appstream:gimp.desktop");
+ g_assert_cmpstr (scheme2, ==, "appstream");
+
+ path1 = gs_utils_get_url_path ("appstream://gimp.desktop");
+ g_assert_cmpstr (path1, ==, "gimp.desktop");
+ path2 = gs_utils_get_url_path ("appstream:gimp.desktop");
+ g_assert_cmpstr (path2, ==, "gimp.desktop");
+ path3 = gs_utils_get_url_path ("apt:/gimp");
+ g_assert_cmpstr (path3, ==, "gimp");
+}
+
+static void
+gs_utils_wilson_func (void)
+{
+ g_assert_cmpint ((gint64) gs_utils_get_wilson_rating (0, 0, 0, 0, 0), ==, -1);
+ g_assert_cmpint ((gint64) gs_utils_get_wilson_rating (0, 0, 0, 0, 400), ==, 100);
+ g_assert_cmpint ((gint64) gs_utils_get_wilson_rating (10, 0, 0, 0, 400), ==, 98);
+ g_assert_cmpint ((gint64) gs_utils_get_wilson_rating (0, 0, 0, 0, 1), ==, 76);
+ g_assert_cmpint ((gint64) gs_utils_get_wilson_rating (5, 4, 20, 100, 400), ==, 93);
+}
+
+static void
+gs_os_release_func (void)
+{
+ g_autofree gchar *fn = NULL;
+ g_autoptr(GError) error = NULL;
+ g_autoptr(GsOsRelease) os_release = NULL;
+
+ fn = gs_test_get_filename (TESTDATADIR, "tests/os-release");
+ g_assert (fn != NULL);
+ g_setenv ("GS_SELF_TEST_OS_RELEASE_FILENAME", fn, TRUE);
+
+ os_release = gs_os_release_new (&error);
+ g_assert_no_error (error);
+ g_assert (os_release != NULL);
+ g_assert_cmpstr (gs_os_release_get_id (os_release), ==, "fedora");
+ g_assert_cmpstr (gs_os_release_get_name (os_release), ==, "Fedora");
+ g_assert_cmpstr (gs_os_release_get_version (os_release), ==, "25 (Workstation Edition)");
+ g_assert_cmpstr (gs_os_release_get_version_id (os_release), ==, "25");
+ g_assert_cmpstr (gs_os_release_get_pretty_name (os_release), ==, "Fedora 25 (Workstation Edition)");
+}
+
+static void
+gs_utils_append_kv_func (void)
+{
+ g_autoptr(GString) str = g_string_new (NULL);
+
+ /* normal */
+ gs_utils_append_key_value (str, 5, "key", "val");
+ g_assert_cmpstr (str->str, ==, "key: val\n");
+
+ /* oversize */
+ g_string_truncate (str, 0);
+ gs_utils_append_key_value (str, 5, "longkey", "val");
+ g_assert_cmpstr (str->str, ==, "longkey: val\n");
+
+ /* null key */
+ g_string_truncate (str, 0);
+ gs_utils_append_key_value (str, 5, NULL, "val");
+ g_assert_cmpstr (str->str, ==, " val\n");
+
+ /* zero align key */
+ g_string_truncate (str, 0);
+ gs_utils_append_key_value (str, 0, "key", "val");
+ g_assert_cmpstr (str->str, ==, "key: val\n");
+}
+
+static void
+gs_utils_cache_func (void)
+{
+ g_autofree gchar *fn1 = NULL;
+ g_autofree gchar *fn2 = NULL;
+ g_autoptr(GError) error = NULL;
+
+ fn1 = gs_utils_get_cache_filename ("test",
+ "http://www.foo.bar/baz",
+ GS_UTILS_CACHE_FLAG_WRITEABLE |
+ GS_UTILS_CACHE_FLAG_CREATE_DIRECTORY,
+ &error);
+ g_assert_no_error (error);
+ g_assert_cmpstr (fn1, !=, NULL);
+ g_assert (g_str_has_prefix (fn1, g_get_user_cache_dir ()));
+ g_assert (g_str_has_suffix (fn1, "test/baz"));
+
+ fn2 = gs_utils_get_cache_filename ("test",
+ "http://www.foo.bar/baz",
+ GS_UTILS_CACHE_FLAG_WRITEABLE |
+ GS_UTILS_CACHE_FLAG_USE_HASH |
+ GS_UTILS_CACHE_FLAG_CREATE_DIRECTORY,
+ &error);
+ g_assert_no_error (error);
+ g_assert_cmpstr (fn2, !=, NULL);
+ g_assert (g_str_has_prefix (fn2, g_get_user_cache_dir ()));
+ g_assert (g_str_has_suffix (fn2, "test/295099f59d12b3eb0b955325fcb699cd23792a89-baz"));
+}
+
+static void
+gs_utils_error_func (void)
+{
+ g_autofree gchar *app_id = NULL;
+ g_autofree gchar *origin_id = NULL;
+ g_autoptr(GError) error = NULL;
+ g_autoptr(GsApp) app = gs_app_new ("gimp.desktop");
+ g_autoptr(GsApp) origin = gs_app_new ("gimp-repo");
+
+ for (guint i = 0; i < GS_PLUGIN_ERROR_LAST; i++)
+ g_assert (gs_plugin_error_to_string (i) != NULL);
+
+ /* noop */
+ gs_utils_error_add_app_id (&error, app);
+ gs_utils_error_add_origin_id (&error, origin);
+
+ g_set_error (&error,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_DOWNLOAD_FAILED,
+ "failed");
+ g_assert_cmpstr (error->message, ==, "failed");
+ gs_utils_error_add_app_id (&error, app);
+ gs_utils_error_add_origin_id (&error, origin);
+ g_assert_cmpstr (error->message, ==, "[*/*/*/gimp-repo/*] {*/*/*/gimp.desktop/*} failed");
+
+ /* find and strip any unique IDs from the error message */
+ for (guint i = 0; i < 2; i++) {
+ if (app_id == NULL)
+ app_id = gs_utils_error_strip_app_id (error);
+ if (origin_id == NULL)
+ origin_id = gs_utils_error_strip_origin_id (error);
+ }
+
+ g_assert_cmpstr (app_id, ==, "*/*/*/gimp.desktop/*");
+ g_assert_cmpstr (origin_id, ==, "*/*/*/gimp-repo/*");
+ g_assert_cmpstr (error->message, ==, "failed");
+}
+
+static void
+gs_plugin_download_rewrite_func (void)
+{
+ g_autofree gchar *css = NULL;
+ g_autoptr(GError) error = NULL;
+ g_autoptr(GDBusConnection) bus_connection = NULL;
+ g_autoptr(GsPlugin) plugin = NULL;
+ const gchar *resource = "background:\n"
+ " url('file://" DATADIR "/gnome-software/featured-maps.png')\n"
+ " url('file://" DATADIR "/gnome-software/featured-maps-bg.png')\n"
+ " bottom center / contain no-repeat;\n";
+
+ /* only when installed */
+ if (!g_file_test (DATADIR "/gnome-software/featured-maps.png", G_FILE_TEST_EXISTS)) {
+ g_test_skip ("not installed");
+ return;
+ }
+
+ /* test rewrite */
+ bus_connection = g_bus_get_sync (G_BUS_TYPE_SESSION, NULL, &error);
+ g_assert_no_error (error);
+
+ plugin = gs_plugin_new (bus_connection, bus_connection);
+ gs_plugin_set_name (plugin, "self-test");
+ css = gs_plugin_download_rewrite_resource (plugin,
+ NULL, /* app */
+ resource,
+ NULL,
+ &error);
+ g_assert_no_error (error);
+ g_assert (css != NULL);
+}
+
+static void
+gs_plugin_func (void)
+{
+ GsAppList *list;
+ GsAppList *list_dup;
+ GsAppList *list_remove;
+ GsApp *app;
+
+ /* check enums converted */
+ for (guint i = 0; i < GS_PLUGIN_ACTION_LAST; i++) {
+ const gchar *tmp = gs_plugin_action_to_string (i);
+ if (tmp == NULL)
+ g_critical ("failed to convert %u", i);
+ g_assert_cmpint (gs_plugin_action_from_string (tmp), ==, i);
+ }
+ for (guint i = 1; i < GS_PLUGIN_ACTION_LAST; i++) {
+ const gchar *tmp = gs_plugin_action_to_function_name (i);
+ if (tmp == NULL) {
+ /* These do not have function, they exist only for better error messages. */
+ if (i == GS_PLUGIN_ACTION_INSTALL_REPO ||
+ i == GS_PLUGIN_ACTION_REMOVE_REPO ||
+ i == GS_PLUGIN_ACTION_ENABLE_REPO ||
+ i == GS_PLUGIN_ACTION_DISABLE_REPO)
+ continue;
+ g_critical ("failed to convert %u", i);
+ }
+ }
+
+ /* add a couple of duplicate IDs */
+ app = gs_app_new ("a");
+ list = gs_app_list_new ();
+ gs_app_list_add (list, app);
+ g_object_unref (app);
+
+ /* test refcounting */
+ g_assert_cmpstr (gs_app_get_id (gs_app_list_index (list, 0)), ==, "a");
+ list_dup = gs_app_list_copy (list);
+ g_object_unref (list);
+ g_assert_cmpint (gs_app_list_length (list_dup), ==, 1);
+ g_assert_cmpstr (gs_app_get_id (gs_app_list_index (list_dup, 0)), ==, "a");
+ g_object_unref (list_dup);
+
+ /* test removing objects */
+ app = gs_app_new ("a");
+ list_remove = gs_app_list_new ();
+ gs_app_list_add (list_remove, app);
+ g_object_unref (app);
+ app = gs_app_new ("b");
+ gs_app_list_add (list_remove, app);
+ g_object_unref (app);
+ app = gs_app_new ("c");
+ gs_app_list_add (list_remove, app);
+ g_object_unref (app);
+ g_assert_cmpint (gs_app_list_length (list_remove), ==, 3);
+ gs_app_list_filter (list_remove, gs_app_list_filter_cb, NULL);
+ g_assert_cmpint (gs_app_list_length (list_remove), ==, 1);
+ g_assert_cmpstr (gs_app_get_id (gs_app_list_index (list_remove, 0)), ==, "b");
+
+ /* test removing duplicates at runtime */
+ app = gs_app_new ("b");
+ gs_app_list_add (list_remove, app);
+ g_object_unref (app);
+ app = gs_app_new ("b");
+ gs_app_list_add (list_remove, app);
+ g_object_unref (app);
+ g_assert_cmpint (gs_app_list_length (list_remove), ==, 1);
+ g_assert_cmpstr (gs_app_get_id (gs_app_list_index (list_remove, 0)), ==, "b");
+ g_object_unref (list_remove);
+
+ /* test removing duplicates when lazy-loading */
+ list_remove = gs_app_list_new ();
+ app = gs_app_new (NULL);
+ gs_app_list_add (list_remove, app);
+ gs_app_set_id (app, "e");
+ g_object_unref (app);
+ app = gs_app_new (NULL);
+ gs_app_list_add (list_remove, app);
+ gs_app_set_id (app, "e");
+ g_object_unref (app);
+ g_assert_cmpint (gs_app_list_length (list_remove), ==, 2);
+ gs_app_list_filter_duplicates (list_remove, GS_APP_LIST_FILTER_FLAG_NONE);
+ g_assert_cmpint (gs_app_list_length (list_remove), ==, 1);
+ g_object_unref (list_remove);
+
+ /* test removing duplicates when some apps have no app ID */
+ list_remove = gs_app_list_new ();
+ app = gs_app_new (NULL);
+ gs_app_list_add (list_remove, app);
+ g_object_unref (app);
+ app = gs_app_new (NULL);
+ gs_app_list_add (list_remove, app);
+ g_object_unref (app);
+ app = gs_app_new (NULL);
+ gs_app_list_add (list_remove, app);
+ gs_app_set_id (app, "e");
+ g_object_unref (app);
+ g_assert_cmpint (gs_app_list_length (list_remove), ==, 3);
+ gs_app_list_filter_duplicates (list_remove, GS_APP_LIST_FILTER_FLAG_NONE);
+ g_assert_cmpint (gs_app_list_length (list_remove), ==, 3);
+ g_object_unref (list_remove);
+
+ /* remove lazy-loaded app */
+ list_remove = gs_app_list_new ();
+ app = gs_app_new (NULL);
+ gs_app_list_add (list_remove, app);
+ gs_app_list_remove (list_remove, app);
+ g_assert_cmpint (gs_app_list_length (list_remove), ==, 0);
+ g_object_unref (app);
+ g_object_unref (list_remove);
+
+ /* respect priority when deduplicating */
+ list = gs_app_list_new ();
+ app = gs_app_new ("e");
+ gs_app_set_unique_id (app, "user/foo/*/e/*");
+ gs_app_list_add (list, app);
+ gs_app_set_priority (app, 0);
+ g_object_unref (app);
+ app = gs_app_new ("e");
+ gs_app_set_unique_id (app, "user/bar/*/e/*");
+ gs_app_list_add (list, app);
+ gs_app_set_priority (app, 99);
+ g_object_unref (app);
+ app = gs_app_new ("e");
+ gs_app_set_unique_id (app, "user/baz/*/e/*");
+ gs_app_list_add (list, app);
+ gs_app_set_priority (app, 50);
+ g_object_unref (app);
+ g_assert_cmpint (gs_app_list_length (list), ==, 3);
+ gs_app_list_filter_duplicates (list, GS_APP_LIST_FILTER_FLAG_KEY_ID);
+ g_assert_cmpint (gs_app_list_length (list), ==, 1);
+ g_assert_cmpstr (gs_app_get_unique_id (gs_app_list_index (list, 0)), ==, "user/bar/*/e/*");
+ g_object_unref (list);
+
+ /* respect priority (using name and version) when deduplicating */
+ list = gs_app_list_new ();
+ app = gs_app_new ("e");
+ gs_app_add_source (app, "foo");
+ gs_app_set_version (app, "1.2.3");
+ gs_app_set_unique_id (app, "user/foo/repo/*/*");
+ gs_app_list_add (list, app);
+ gs_app_set_priority (app, 0);
+ g_object_unref (app);
+ app = gs_app_new ("e");
+ gs_app_add_source (app, "foo");
+ gs_app_set_version (app, "1.2.3");
+ gs_app_set_unique_id (app, "user/foo/repo-security/*/*");
+ gs_app_list_add (list, app);
+ gs_app_set_priority (app, 99);
+ g_object_unref (app);
+ app = gs_app_new ("e");
+ gs_app_add_source (app, "foo");
+ gs_app_set_version (app, "1.2.3");
+ gs_app_set_unique_id (app, "user/foo/repo-universe/*/*");
+ gs_app_list_add (list, app);
+ gs_app_set_priority (app, 50);
+ g_object_unref (app);
+ g_assert_cmpint (gs_app_list_length (list), ==, 3);
+ gs_app_list_filter_duplicates (list, GS_APP_LIST_FILTER_FLAG_KEY_ID |
+ GS_APP_LIST_FILTER_FLAG_KEY_SOURCE |
+ GS_APP_LIST_FILTER_FLAG_KEY_VERSION);
+ g_assert_cmpint (gs_app_list_length (list), ==, 1);
+ g_assert_cmpstr (gs_app_get_unique_id (gs_app_list_index (list, 0)), ==, "user/foo/repo-security/*/*");
+ g_object_unref (list);
+
+ /* prefer installed applications */
+ list = gs_app_list_new ();
+ app = gs_app_new ("e");
+ gs_app_set_state (app, GS_APP_STATE_INSTALLED);
+ gs_app_set_unique_id (app, "user/foo/*/e/*");
+ gs_app_set_priority (app, 0);
+ gs_app_list_add (list, app);
+ g_object_unref (app);
+ app = gs_app_new ("e");
+ gs_app_set_state (app, GS_APP_STATE_AVAILABLE);
+ gs_app_set_unique_id (app, "user/bar/*/e/*");
+ gs_app_set_priority (app, 100);
+ gs_app_list_add (list, app);
+ g_object_unref (app);
+ gs_app_list_filter_duplicates (list,
+ GS_APP_LIST_FILTER_FLAG_KEY_ID |
+ GS_APP_LIST_FILTER_FLAG_PREFER_INSTALLED);
+ g_assert_cmpint (gs_app_list_length (list), ==, 1);
+ g_assert_cmpstr (gs_app_get_unique_id (gs_app_list_index (list, 0)), ==, "user/foo/*/e/*");
+ g_object_unref (list);
+
+ /* use the provides ID to dedupe */
+ list = gs_app_list_new ();
+ app = gs_app_new ("gimp.desktop");
+ gs_app_set_unique_id (app, "user/fedora/*/gimp.desktop/*");
+ gs_app_set_priority (app, 0);
+ gs_app_list_add (list, app);
+ g_object_unref (app);
+ app = gs_app_new ("org.gimp.GIMP");
+ gs_app_add_provided_item (app,
+ AS_PROVIDED_KIND_ID,
+ "gimp.desktop");
+ gs_app_set_unique_id (app, "user/flathub/*/org.gimp.GIMP/*");
+ gs_app_set_priority (app, 100);
+ gs_app_list_add (list, app);
+ g_object_unref (app);
+ gs_app_list_filter_duplicates (list, GS_APP_LIST_FILTER_FLAG_KEY_ID_PROVIDES);
+ g_assert_cmpint (gs_app_list_length (list), ==, 1);
+ g_assert_cmpstr (gs_app_get_unique_id (gs_app_list_index (list, 0)), ==,
+ "user/flathub/*/org.gimp.GIMP/*");
+ g_object_unref (list);
+
+ /* use globs when adding */
+ list = gs_app_list_new ();
+ app = gs_app_new ("b");
+ gs_app_set_unique_id (app, "a/b/c/d/e");
+ gs_app_list_add (list, app);
+ g_object_unref (app);
+ app = gs_app_new ("b");
+ gs_app_set_unique_id (app, "a/b/c/*/e");
+ gs_app_list_add (list, app);
+ g_object_unref (app);
+ g_assert_cmpint (gs_app_list_length (list), ==, 1);
+ g_assert_cmpstr (gs_app_get_id (gs_app_list_index (list, 0)), ==, "b");
+ g_object_unref (list);
+
+ /* lookup with a wildcard */
+ list = gs_app_list_new ();
+ app = gs_app_new ("b");
+ gs_app_set_unique_id (app, "a/b/c/d/e");
+ gs_app_list_add (list, app);
+ g_object_unref (app);
+ g_assert (gs_app_list_lookup (list, "a/b/c/d/e") != NULL);
+ g_assert (gs_app_list_lookup (list, "a/b/c/d/*") != NULL);
+ g_assert (gs_app_list_lookup (list, "*/b/c/d/e") != NULL);
+ g_assert (gs_app_list_lookup (list, "x/x/x/x/x") == NULL);
+ g_object_unref (list);
+
+ /* allow duplicating a wildcard */
+ list = gs_app_list_new ();
+ app = gs_app_new ("gimp.desktop");
+ gs_app_add_quirk (app, GS_APP_QUIRK_IS_WILDCARD);
+ gs_app_list_add (list, app);
+ g_object_unref (app);
+ app = gs_app_new ("gimp.desktop");
+ gs_app_set_unique_id (app, "system/flatpak/*/gimp.desktop/stable");
+ gs_app_list_add (list, app);
+ g_object_unref (app);
+ g_assert_cmpint (gs_app_list_length (list), ==, 2);
+ g_object_unref (list);
+
+ /* allow duplicating a wildcard */
+ list = gs_app_list_new ();
+ app = gs_app_new ("gimp.desktop");
+ gs_app_add_quirk (app, GS_APP_QUIRK_IS_WILDCARD);
+ gs_app_list_add (list, app);
+ g_object_unref (app);
+ app = gs_app_new ("gimp.desktop");
+ gs_app_add_quirk (app, GS_APP_QUIRK_IS_WILDCARD);
+ gs_app_list_add (list, app);
+ g_object_unref (app);
+ g_assert_cmpint (gs_app_list_length (list), ==, 1);
+ g_object_unref (list);
+
+ /* add a list to a list */
+ list = gs_app_list_new ();
+ list_dup = gs_app_list_new ();
+ app = gs_app_new ("a");
+ gs_app_list_add (list, app);
+ g_object_unref (app);
+ app = gs_app_new ("b");
+ gs_app_list_add (list_dup, app);
+ g_object_unref (app);
+ gs_app_list_add_list (list, list_dup);
+ g_assert_cmpint (gs_app_list_length (list), ==, 2);
+ g_assert_cmpint (gs_app_list_length (list_dup), ==, 1);
+ g_object_unref (list);
+ g_object_unref (list_dup);
+
+ /* remove apps from the list */
+ list = gs_app_list_new ();
+ app = gs_app_new ("a");
+ gs_app_list_add (list, app);
+ gs_app_list_remove (list, app);
+ g_object_unref (app);
+ g_assert_cmpint (gs_app_list_length (list), ==, 0);
+ g_object_unref (list);
+
+ /* truncate list */
+ list = gs_app_list_new ();
+ app = gs_app_new ("a");
+ gs_app_list_add (list, app);
+ g_object_unref (app);
+ app = gs_app_new ("b");
+ gs_app_list_add (list, app);
+ g_object_unref (app);
+ app = gs_app_new ("c");
+ gs_app_list_add (list, app);
+ g_object_unref (app);
+ g_assert (!gs_app_list_has_flag (list, GS_APP_LIST_FLAG_IS_TRUNCATED));
+ g_assert_cmpint (gs_app_list_get_size_peak (list), ==, 3);
+ gs_app_list_truncate (list, 3);
+ g_assert_cmpint (gs_app_list_length (list), ==, 3);
+ g_assert (gs_app_list_has_flag (list, GS_APP_LIST_FLAG_IS_TRUNCATED));
+ g_assert_cmpint (gs_app_list_get_size_peak (list), ==, 3);
+ gs_app_list_truncate (list, 2);
+ g_assert_cmpint (gs_app_list_length (list), ==, 2);
+ gs_app_list_truncate (list, 1);
+ g_assert_cmpint (gs_app_list_length (list), ==, 1);
+ gs_app_list_truncate (list, 0);
+ g_assert_cmpint (gs_app_list_length (list), ==, 0);
+ g_assert_cmpint (gs_app_list_get_size_peak (list), ==, 3);
+ g_object_unref (list);
+}
+
+static gpointer
+gs_app_thread_cb (gpointer data)
+{
+ GsApp *app = GS_APP (data);
+ for (guint i = 0; i < 10000; i++) {
+ g_assert_cmpstr (gs_app_get_unique_id (app), !=, NULL);
+ gs_app_set_branch (app, "master");
+ g_assert_cmpstr (gs_app_get_unique_id (app), !=, NULL);
+ gs_app_set_branch (app, "stable");
+ }
+ return NULL;
+}
+
+static void
+gs_app_thread_func (gconstpointer user_data)
+{
+ GsDebug *debug = GS_DEBUG ((void *)user_data);
+ GThread *thread1;
+ GThread *thread2;
+ g_autoptr(GsApp) app = gs_app_new ("gimp.desktop");
+
+ /* try really hard to cause a threading problem */
+ gs_debug_set_verbose (debug, FALSE);
+ thread1 = g_thread_new ("thread1", gs_app_thread_cb, app);
+ thread2 = g_thread_new ("thread2", gs_app_thread_cb, app);
+ g_thread_join (thread1); /* consumes the reference */
+ g_thread_join (thread2);
+ gs_debug_set_verbose (debug, TRUE);
+}
+
+static void
+gs_app_unique_id_func (void)
+{
+ g_autoptr(GsApp) app = gs_app_new (NULL);
+ g_autofree gchar *data_id = NULL;
+ const gchar *unique_id;
+
+ unique_id = "system/flatpak/gnome/org.gnome.Software/master";
+ gs_app_set_from_unique_id (app, unique_id, AS_COMPONENT_KIND_DESKTOP_APP);
+ g_assert (GS_IS_APP (app));
+ g_assert_cmpint (gs_app_get_scope (app), ==, AS_COMPONENT_SCOPE_SYSTEM);
+ g_assert_cmpint (gs_app_get_bundle_kind (app), ==, AS_BUNDLE_KIND_FLATPAK);
+ g_assert_cmpstr (gs_app_get_origin (app), ==, "gnome");
+ g_assert_cmpint (gs_app_get_kind (app), ==, AS_COMPONENT_KIND_DESKTOP_APP);
+ g_assert_cmpstr (gs_app_get_id (app), ==, "org.gnome.Software");
+ g_assert_cmpstr (gs_app_get_branch (app), ==, "master");
+
+ /* test conversions from 6-part IDs */
+ data_id = gs_utils_unique_id_compat_convert (unique_id);
+ g_assert_cmpstr (data_id, ==, unique_id);
+ g_clear_pointer (&data_id, g_free);
+
+ data_id = gs_utils_unique_id_compat_convert ("not a unique ID");
+ g_assert_null (data_id);
+
+ data_id = gs_utils_unique_id_compat_convert ("system/flatpak/gnome/desktop-app/org.gnome.Software/master");
+ g_assert_cmpstr (data_id, ==, unique_id);
+ g_clear_pointer (&data_id, g_free);
+}
+
+static void
+gs_app_addons_func (void)
+{
+ g_autoptr(GsApp) app = gs_app_new ("test.desktop");
+ g_autoptr(GsApp) addon = NULL;
+ g_autoptr(GsAppList) addons_list = NULL;
+
+ /* create, add then drop ref, so @app has the only refcount of addon */
+ addon = gs_app_new ("test.desktop");
+ addons_list = gs_app_list_new ();
+ gs_app_list_add (addons_list, addon);
+
+ gs_app_add_addons (app, addons_list);
+
+ gs_app_remove_addon (app, addon);
+}
+
+static void
+gs_app_func (void)
+{
+ g_autoptr(GsApp) app = NULL;
+
+ app = gs_app_new ("gnome-software.desktop");
+ g_assert (GS_IS_APP (app));
+ g_assert_cmpstr (gs_app_get_id (app), ==, "gnome-software.desktop");
+
+ /* check we clean up the version, but not at the expense of having
+ * the same string as the update version */
+ gs_app_set_version (app, "2.8.6-3.fc20");
+ gs_app_set_update_version (app, "2.8.6-4.fc20");
+ g_assert_cmpstr (gs_app_get_version (app), ==, "2.8.6-3.fc20");
+ g_assert_cmpstr (gs_app_get_update_version (app), ==, "2.8.6-4.fc20");
+ g_assert_cmpstr (gs_app_get_version_ui (app), ==, "2.8.6-3");
+ g_assert_cmpstr (gs_app_get_update_version_ui (app), ==, "2.8.6-4");
+
+ /* check the quality stuff works */
+ gs_app_set_name (app, GS_APP_QUALITY_NORMAL, "dave");
+ g_assert_cmpstr (gs_app_get_name (app), ==, "dave");
+ gs_app_set_name (app, GS_APP_QUALITY_LOWEST, "brian");
+ g_assert_cmpstr (gs_app_get_name (app), ==, "dave");
+ gs_app_set_name (app, GS_APP_QUALITY_HIGHEST, "hugh");
+ g_assert_cmpstr (gs_app_get_name (app), ==, "hugh");
+
+ /* check non-transient state saving */
+ gs_app_set_state (app, GS_APP_STATE_INSTALLED);
+ g_assert_cmpint (gs_app_get_state (app), ==, GS_APP_STATE_INSTALLED);
+ gs_app_set_state (app, GS_APP_STATE_REMOVING);
+ g_assert_cmpint (gs_app_get_state (app), ==, GS_APP_STATE_REMOVING);
+ gs_app_set_state_recover (app); // simulate an error
+ g_assert_cmpint (gs_app_get_state (app), ==, GS_APP_STATE_INSTALLED);
+
+ /* try again */
+ gs_app_set_state (app, GS_APP_STATE_REMOVING);
+ g_assert_cmpint (gs_app_get_state (app), ==, GS_APP_STATE_REMOVING);
+ gs_app_set_state_recover (app); // simulate an error
+ g_assert_cmpint (gs_app_get_state (app), ==, GS_APP_STATE_INSTALLED);
+
+ /* correctly parse URL */
+ gs_app_set_origin_hostname (app, "https://mirrors.fedoraproject.org/metalink");
+ g_assert_cmpstr (gs_app_get_origin_hostname (app), ==, "fedoraproject.org");
+ gs_app_set_origin_hostname (app, "file:///home/hughsie");
+ g_assert_cmpstr (gs_app_get_origin_hostname (app), ==, "localhost");
+
+ /* check setting the progress */
+ gs_app_set_progress (app, 42);
+ g_assert_cmpuint (gs_app_get_progress (app), ==, 42);
+ gs_app_set_progress (app, 0);
+ g_assert_cmpuint (gs_app_get_progress (app), ==, 0);
+ gs_app_set_progress (app, GS_APP_PROGRESS_UNKNOWN);
+ g_assert_cmpuint (gs_app_get_progress (app), ==, GS_APP_PROGRESS_UNKNOWN);
+ g_assert_false ((gint) 0 <= (gint) GS_APP_PROGRESS_UNKNOWN && GS_APP_PROGRESS_UNKNOWN <= 100);
+
+ /* check pending action */
+ g_assert_cmpuint (gs_app_get_pending_action (app), ==, GS_PLUGIN_ACTION_UNKNOWN);
+ gs_app_set_state (app, GS_APP_STATE_UPDATABLE_LIVE);
+ gs_app_set_pending_action (app, GS_PLUGIN_ACTION_UPDATE);
+ g_assert_cmpuint (gs_app_get_pending_action (app), ==, GS_PLUGIN_ACTION_UPDATE);
+ gs_app_set_state (app, GS_APP_STATE_INSTALLING);
+ g_assert_cmpuint (gs_app_get_pending_action (app), ==, GS_PLUGIN_ACTION_UNKNOWN);
+ gs_app_set_state_recover (app);
+}
+
+static void
+gs_app_progress_clamping_func (void)
+{
+ g_autoptr(GsApp) app = NULL;
+
+ if (g_test_subprocess ()) {
+ app = gs_app_new ("gnome-software.desktop");
+ gs_app_set_progress (app, 142);
+ g_assert_cmpuint (gs_app_get_progress (app), ==, 100);
+ } else {
+ g_test_trap_subprocess (NULL, 0, 0);
+ g_test_trap_assert_failed ();
+ g_test_trap_assert_stderr ("*cannot set 142% for *, setting instead: 100%*");
+ }
+}
+
+static void
+gs_app_list_wildcard_dedupe_func (void)
+{
+ g_autoptr(GsAppList) list = gs_app_list_new ();
+ g_autoptr(GsApp) app1 = gs_app_new ("app");
+ g_autoptr(GsApp) app2 = gs_app_new ("app");
+
+ gs_app_add_quirk (app1, GS_APP_QUIRK_IS_WILDCARD);
+ gs_app_list_add (list, app1);
+ gs_app_add_quirk (app2, GS_APP_QUIRK_IS_WILDCARD);
+ gs_app_list_add (list, app2);
+ g_assert_cmpint (gs_app_list_length (list), ==, 1);
+}
+
+static void
+gs_app_list_func (void)
+{
+ g_autoptr(GsAppList) list = gs_app_list_new ();
+ g_autoptr(GsApp) app1 = gs_app_new ("app1");
+ g_autoptr(GsApp) app2 = gs_app_new ("app2");
+
+ /* turn on */
+ gs_app_list_add_flag (list, GS_APP_LIST_FLAG_WATCH_APPS);
+
+ g_assert_cmpint (gs_app_list_get_progress (list), ==, 0);
+ g_assert_cmpint (gs_app_list_get_state (list), ==, GS_APP_STATE_UNKNOWN);
+ gs_app_list_add (list, app1);
+ gs_app_set_progress (app1, 75);
+ gs_app_set_state (app1, GS_APP_STATE_AVAILABLE);
+ gs_app_set_state (app1, GS_APP_STATE_INSTALLING);
+ gs_test_flush_main_context ();
+ g_assert_cmpint (gs_app_list_get_progress (list), ==, 75);
+ g_assert_cmpint (gs_app_list_get_state (list), ==, GS_APP_STATE_INSTALLING);
+
+ gs_app_list_add (list, app2);
+ gs_app_set_progress (app2, 25);
+ gs_test_flush_main_context ();
+ g_assert_cmpint (gs_app_list_get_progress (list), ==, 50);
+ g_assert_cmpint (gs_app_list_get_state (list), ==, GS_APP_STATE_INSTALLING);
+
+ gs_app_list_remove (list, app1);
+ g_assert_cmpint (gs_app_list_get_progress (list), ==, 25);
+ g_assert_cmpint (gs_app_list_get_state (list), ==, GS_APP_STATE_UNKNOWN);
+}
+
+static void
+gs_app_list_performance_func (void)
+{
+ g_autoptr(GPtrArray) apps = g_ptr_array_new_with_free_func ((GDestroyNotify) g_object_unref);
+ g_autoptr(GsAppList) list = gs_app_list_new ();
+ g_autoptr(GTimer) timer = NULL;
+
+ /* create a few apps */
+ for (guint i = 0; i < 500; i++) {
+ g_autofree gchar *id = g_strdup_printf ("%03u.desktop", i);
+ g_ptr_array_add (apps, gs_app_new (id));
+ }
+
+ /* add them to the list */
+ timer = g_timer_new ();
+ for (guint i = 0; i < apps->len; i++) {
+ GsApp *app = g_ptr_array_index (apps, i);
+ gs_app_list_add (list, app);
+ }
+ g_print ("%.2fms ", g_timer_elapsed (timer, NULL) * 1000);
+}
+
+static void
+gs_app_list_related_func (void)
+{
+ g_autoptr(GsAppList) list = gs_app_list_new ();
+ g_autoptr(GsApp) app = gs_app_new ("app");
+ g_autoptr(GsApp) related = gs_app_new ("related");
+
+ /* turn on */
+ gs_app_list_add_flag (list,
+ GS_APP_LIST_FLAG_WATCH_APPS |
+ GS_APP_LIST_FLAG_WATCH_APPS_RELATED);
+ gs_app_add_related (app, related);
+ gs_app_list_add (list, app);
+
+ gs_app_set_progress (app, 75);
+ gs_app_set_progress (related, 25);
+ gs_test_flush_main_context ();
+ g_assert_cmpint (gs_app_list_get_progress (list), ==, 50);
+}
+
+int
+main (int argc, char **argv)
+{
+ g_autoptr(GsDebug) debug = gs_debug_new (NULL, TRUE, FALSE);
+
+ gs_test_init (&argc, &argv);
+
+ /* tests go here */
+ g_test_add_func ("/gnome-software/lib/utils{url}", gs_utils_url_func);
+ g_test_add_func ("/gnome-software/lib/utils{wilson}", gs_utils_wilson_func);
+ g_test_add_func ("/gnome-software/lib/utils{error}", gs_utils_error_func);
+ g_test_add_func ("/gnome-software/lib/utils{cache}", gs_utils_cache_func);
+ g_test_add_func ("/gnome-software/lib/utils{append-kv}", gs_utils_append_kv_func);
+ g_test_add_func ("/gnome-software/lib/os-release", gs_os_release_func);
+ g_test_add_func ("/gnome-software/lib/app", gs_app_func);
+ g_test_add_func ("/gnome-software/lib/app/progress-clamping", gs_app_progress_clamping_func);
+ g_test_add_func ("/gnome-software/lib/app{addons}", gs_app_addons_func);
+ g_test_add_func ("/gnome-software/lib/app{unique-id}", gs_app_unique_id_func);
+ g_test_add_data_func ("/gnome-software/lib/app{thread}", debug, gs_app_thread_func);
+ g_test_add_func ("/gnome-software/lib/app{list}", gs_app_list_func);
+ g_test_add_func ("/gnome-software/lib/app{list-wildcard-dedupe}", gs_app_list_wildcard_dedupe_func);
+ g_test_add_func ("/gnome-software/lib/app{list-performance}", gs_app_list_performance_func);
+ g_test_add_func ("/gnome-software/lib/app{list-related}", gs_app_list_related_func);
+ g_test_add_func ("/gnome-software/lib/plugin", gs_plugin_func);
+ g_test_add_func ("/gnome-software/lib/plugin{download-rewrite}", gs_plugin_download_rewrite_func);
+
+ return g_test_run ();
+}
diff --git a/lib/gs-test.c b/lib/gs-test.c
new file mode 100644
index 0000000..2a2e107
--- /dev/null
+++ b/lib/gs-test.c
@@ -0,0 +1,147 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2013-2016 Richard Hughes <richard@hughsie.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#include <stdlib.h>
+
+#include "gs-plugin-loader-sync.h"
+#include "gs-test.h"
+
+/**
+ * gs_test_init:
+ *
+ * Initializes the environment with the common settings for the test,
+ * as a replacement for the g_test_init(), which is called as well.
+ *
+ * Since: 42
+ **/
+void
+gs_test_init (gint *pargc,
+ gchar ***pargv)
+{
+ g_autoptr(GSettings) settings = NULL;
+
+ g_setenv ("GSETTINGS_BACKEND", "memory", FALSE);
+ g_setenv ("G_MESSAGES_DEBUG", "all", TRUE);
+
+ /* To not download ODRS data during the test */
+ settings = g_settings_new ("org.gnome.software");
+ g_settings_set_string (settings, "review-server", "");
+
+ g_test_init (pargc, pargv,
+ G_TEST_OPTION_ISOLATE_DIRS,
+ NULL);
+
+ /* only critical and error are fatal */
+ g_log_set_fatal_mask (NULL, G_LOG_LEVEL_ERROR | G_LOG_LEVEL_CRITICAL);
+}
+
+gchar *
+gs_test_get_filename (const gchar *testdatadir, const gchar *filename)
+{
+ gchar *tmp;
+ char full_tmp[PATH_MAX];
+ g_autofree gchar *path = NULL;
+ path = g_build_filename (testdatadir, filename, NULL);
+ g_debug ("looking in %s", path);
+ tmp = realpath (path, full_tmp);
+ if (tmp == NULL)
+ return NULL;
+ return g_strdup (full_tmp);
+}
+
+void
+gs_test_flush_main_context (void)
+{
+ guint cnt = 0;
+ while (g_main_context_iteration (NULL, FALSE)) {
+ if (cnt == 0)
+ g_debug ("clearing pending events...");
+ cnt++;
+ }
+ if (cnt > 0)
+ g_debug ("cleared %u events", cnt);
+}
+
+/**
+ * gs_test_expose_icon_theme_paths:
+ *
+ * Calculate and set the `GS_SELF_TEST_ICON_THEME_PATH` environment variable
+ * to include the current system icon theme paths. This is designed to be called
+ * before calling `gs_test_init()`, which will clear the system icon theme paths.
+ *
+ * As this function calls `g_setenv()`, it must not be called after threads have
+ * been spawned.
+ *
+ * Calling this function is an explicit acknowledgement that the code under test
+ * should be accessing the icon theme.
+ *
+ * Since: 3.38
+ */
+void
+gs_test_expose_icon_theme_paths (void)
+{
+ const gchar * const *data_dirs;
+ g_autoptr(GString) data_dirs_str = NULL;
+ g_autofree gchar *data_dirs_joined = NULL;
+
+ data_dirs = g_get_system_data_dirs ();
+ data_dirs_str = g_string_new ("");
+ for (gsize i = 0; data_dirs[i] != NULL; i++)
+ g_string_append_printf (data_dirs_str, "%s%s/icons",
+ (data_dirs_str->len > 0) ? ":" : "",
+ data_dirs[i]);
+ data_dirs_joined = g_string_free (g_steal_pointer (&data_dirs_str), FALSE);
+ g_setenv ("GS_SELF_TEST_ICON_THEME_PATH", data_dirs_joined, TRUE);
+}
+
+/**
+ * gs_test_reinitialise_plugin_loader:
+ * @plugin_loader: a #GsPluginLoader
+ *
+ * Calls setup on each plugin. This should only be used from the self tests
+ * and in a controlled way.
+ *
+ * Since: 42
+ */
+void
+gs_test_reinitialise_plugin_loader (GsPluginLoader *plugin_loader,
+ const gchar * const *allowlist,
+ const gchar * const *blocklist)
+{
+ g_autoptr(GError) local_error = NULL;
+#ifdef HAVE_SYSPROF
+ gint64 begin_time_nsec G_GNUC_UNUSED = SYSPROF_CAPTURE_CURRENT_TIME;
+#endif
+
+ /* Shut down */
+ gs_plugin_loader_shutdown (plugin_loader, NULL);
+
+ /* clear global cache */
+ gs_plugin_loader_clear_caches (plugin_loader);
+
+ /* remove any events */
+ gs_plugin_loader_remove_events (plugin_loader);
+
+ /* Start all the plugins setting up again in parallel. Use the blocking
+ * sync version of the function, just for the tests. */
+ gs_plugin_loader_setup (plugin_loader, allowlist, blocklist, NULL, &local_error);
+ g_assert_no_error (local_error);
+
+#ifdef HAVE_SYSPROF
+ if (plugin_loader->sysprof_writer != NULL) {
+ sysprof_capture_writer_add_mark (plugin_loader->sysprof_writer,
+ begin_time_nsec,
+ sched_getcpu (),
+ getpid (),
+ SYSPROF_CAPTURE_CURRENT_TIME - begin_time_nsec,
+ "gnome-software",
+ "setup-again",
+ NULL);
+ }
+#endif /* HAVE_SYSPROF */
+}
diff --git a/lib/gs-test.h b/lib/gs-test.h
new file mode 100644
index 0000000..8114c23
--- /dev/null
+++ b/lib/gs-test.h
@@ -0,0 +1,27 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2013-2016 Richard Hughes <richard@hughsie.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#include "gs-app.h"
+#include "gs-plugin-loader.h"
+
+G_BEGIN_DECLS
+
+void gs_test_init (gint *pargc,
+ gchar ***pargv);
+void gs_test_flush_main_context (void);
+gchar *gs_test_get_filename (const gchar *testdatadir,
+ const gchar *filename);
+void gs_test_expose_icon_theme_paths (void);
+
+void gs_test_reinitialise_plugin_loader (GsPluginLoader *plugin_loader,
+ const gchar * const *allowlist,
+ const gchar * const *blocklist);
+
+G_END_DECLS
diff --git a/lib/gs-utils.c b/lib/gs-utils.c
new file mode 100644
index 0000000..2c3fb5b
--- /dev/null
+++ b/lib/gs-utils.c
@@ -0,0 +1,1689 @@
+/* -*- 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) 2015-2018 Kalev Lember <klember@redhat.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+/**
+ * SECTION:gs-utils
+ * @title: GsUtils
+ * @include: gnome-software.h
+ * @stability: Unstable
+ * @short_description: Utilities that plugins can use
+ *
+ * These functions provide useful functionality that makes it easy to
+ * add new plugin functions.
+ */
+
+#include "config.h"
+
+#include <errno.h>
+#include <fnmatch.h>
+#include <math.h>
+#include <string.h>
+#include <glib/gstdio.h>
+#include <json-glib/json-glib.h>
+
+#if defined(__linux__)
+#include <sys/sysinfo.h>
+#elif defined(__FreeBSD__)
+#include <sys/types.h>
+#include <sys/sysctl.h>
+#endif
+
+#ifdef HAVE_POLKIT
+#include <polkit/polkit.h>
+#endif
+
+#include "gs-app.h"
+#include "gs-app-private.h"
+#include "gs-utils.h"
+#include "gs-plugin.h"
+
+#define MB_IN_BYTES (1024 * 1024)
+
+/**
+ * gs_mkdir_parent:
+ * @path: A full pathname
+ * @error: A #GError, or %NULL
+ *
+ * Creates any required directories, including any parent directories.
+ *
+ * Returns: %TRUE for success
+ **/
+gboolean
+gs_mkdir_parent (const gchar *path, GError **error)
+{
+ g_autofree gchar *parent = NULL;
+
+ parent = g_path_get_dirname (path);
+ if (g_mkdir_with_parents (parent, 0755) == -1) {
+ g_set_error (error,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_FAILED,
+ "Failed to create '%s': %s",
+ parent, g_strerror (errno));
+ return FALSE;
+ }
+ return TRUE;
+}
+
+/**
+ * gs_utils_get_file_age:
+ * @file: A #GFile
+ *
+ * Gets a file age.
+ *
+ * Returns: The time in seconds since the file was modified, or %G_MAXUINT64 for error
+ */
+guint64
+gs_utils_get_file_age (GFile *file)
+{
+ guint64 now;
+ guint64 mtime;
+ g_autoptr(GFileInfo) info = NULL;
+
+ info = g_file_query_info (file,
+ G_FILE_ATTRIBUTE_TIME_MODIFIED,
+ G_FILE_QUERY_INFO_NONE,
+ NULL,
+ NULL);
+ if (info == NULL)
+ return G_MAXUINT64;
+ mtime = g_file_info_get_attribute_uint64 (info, G_FILE_ATTRIBUTE_TIME_MODIFIED);
+ now = (guint64) g_get_real_time () / G_USEC_PER_SEC;
+ if (mtime > now)
+ return G_MAXUINT64;
+ if (now - mtime > G_MAXUINT64)
+ return G_MAXUINT64;
+ return (guint) (now - mtime);
+}
+
+static gchar *
+gs_utils_filename_array_return_newest (GPtrArray *array)
+{
+ const gchar *filename_best = NULL;
+ guint age_lowest = G_MAXUINT;
+ guint i;
+ for (i = 0; i < array->len; i++) {
+ const gchar *fn = g_ptr_array_index (array, i);
+ g_autoptr(GFile) file = g_file_new_for_path (fn);
+ guint64 age_tmp = gs_utils_get_file_age (file);
+ if (age_tmp < age_lowest) {
+ age_lowest = age_tmp;
+ filename_best = fn;
+ }
+ }
+ return g_strdup (filename_best);
+}
+
+/**
+ * gs_utils_get_cache_filename:
+ * @kind: A cache kind, e.g. "fwupd" or "screenshots/123x456"
+ * @resource: A resource, e.g. "system.bin" or "http://foo.bar/baz.bin"
+ * @flags: Some #GsUtilsCacheFlags, e.g. %GS_UTILS_CACHE_FLAG_WRITEABLE
+ * @error: A #GError, or %NULL
+ *
+ * Returns a filename that points into the cache.
+ * This may be per-system or per-user, the latter being more likely
+ * when %GS_UTILS_CACHE_FLAG_WRITEABLE is specified in @flags.
+ *
+ * If %GS_UTILS_CACHE_FLAG_USE_HASH is set in @flags then the returned filename
+ * will contain the hashed version of @resource.
+ *
+ * If there is more than one match, the file that has been modified last is
+ * returned.
+ *
+ * If a plugin requests a file to be saved in the cache it is the plugins
+ * responsibility to remove the file when it is no longer valid or is too old
+ * -- gnome-software will not ever clean the cache for the plugin.
+ * For this reason it is a good idea to use the plugin name as @kind.
+ *
+ * This function can only fail if %GS_UTILS_CACHE_FLAG_ENSURE_EMPTY or
+ * %GS_UTILS_CACHE_FLAG_CREATE_DIRECTORY are passed in @flags.
+ *
+ * Returns: The full path and filename, which may or may not exist, or %NULL
+ **/
+gchar *
+gs_utils_get_cache_filename (const gchar *kind,
+ const gchar *resource,
+ GsUtilsCacheFlags flags,
+ GError **error)
+{
+ const gchar *tmp;
+ g_autofree gchar *basename = NULL;
+ g_autofree gchar *cachedir = NULL;
+ g_autoptr(GFile) cachedir_file = NULL;
+ g_autoptr(GPtrArray) candidates = g_ptr_array_new_with_free_func (g_free);
+ g_autoptr(GError) local_error = NULL;
+
+ /* in the self tests */
+ tmp = g_getenv ("GS_SELF_TEST_CACHEDIR");
+ if (tmp != NULL) {
+ cachedir = g_build_filename (tmp, kind, NULL);
+ cachedir_file = g_file_new_for_path (cachedir);
+
+ if ((flags & GS_UTILS_CACHE_FLAG_CREATE_DIRECTORY) &&
+ !g_file_make_directory_with_parents (cachedir_file, NULL, &local_error) &&
+ !g_error_matches (local_error, G_IO_ERROR, G_IO_ERROR_EXISTS)) {
+ g_propagate_error (error, g_steal_pointer (&local_error));
+ return NULL;
+ }
+
+ return g_build_filename (cachedir, resource, NULL);;
+ }
+
+ /* get basename */
+ if (flags & GS_UTILS_CACHE_FLAG_USE_HASH) {
+ g_autofree gchar *basename_tmp = g_path_get_basename (resource);
+ g_autofree gchar *hash = g_compute_checksum_for_string (G_CHECKSUM_SHA1,
+ resource, -1);
+ basename = g_strdup_printf ("%s-%s", hash, basename_tmp);
+ } else {
+ basename = g_path_get_basename (resource);
+ }
+
+ /* not writable, so try the system cache first */
+ if (!(flags & GS_UTILS_CACHE_FLAG_WRITEABLE)) {
+ g_autofree gchar *cachefn = NULL;
+ cachefn = g_build_filename (LOCALSTATEDIR,
+ "cache",
+ "gnome-software",
+ kind,
+ basename,
+ NULL);
+ if (g_file_test (cachefn, G_FILE_TEST_EXISTS)) {
+ g_ptr_array_add (candidates,
+ g_steal_pointer (&cachefn));
+ }
+ }
+
+ /* create the cachedir in a per-release location, creating
+ * if it does not already exist */
+ cachedir = g_build_filename (g_get_user_cache_dir (),
+ "gnome-software",
+ kind,
+ NULL);
+ cachedir_file = g_file_new_for_path (cachedir);
+ if (g_file_query_exists (cachedir_file, NULL) &&
+ flags & GS_UTILS_CACHE_FLAG_ENSURE_EMPTY) {
+ if (!gs_utils_rmtree (cachedir, error))
+ return NULL;
+ }
+ if ((flags & GS_UTILS_CACHE_FLAG_CREATE_DIRECTORY) &&
+ !g_file_query_exists (cachedir_file, NULL) &&
+ !g_file_make_directory_with_parents (cachedir_file, NULL, error))
+ return NULL;
+ g_ptr_array_add (candidates, g_build_filename (cachedir, basename, NULL));
+
+ /* common case: we only have one option */
+ if (candidates->len == 1)
+ return g_strdup (g_ptr_array_index (candidates, 0));
+
+ /* return the newest (i.e. one with least age) */
+ return gs_utils_filename_array_return_newest (candidates);
+}
+
+/**
+ * gs_utils_get_user_hash:
+ * @error: A #GError, or %NULL
+ *
+ * This SHA1 hash is composed of the contents of machine-id and your
+ * username and is also salted with a hardcoded value.
+ *
+ * This provides an identifier that can be used to identify a specific
+ * user on a machine, allowing them to cast only one vote or perform
+ * one review on each application.
+ *
+ * There is no known way to calculate the machine ID or username from
+ * the machine hash and there should be no privacy issue.
+ *
+ * Returns: The user hash, or %NULL on error
+ */
+gchar *
+gs_utils_get_user_hash (GError **error)
+{
+ g_autofree gchar *data = NULL;
+ g_autofree gchar *salted = NULL;
+
+ if (!g_file_get_contents ("/etc/machine-id", &data, NULL, error))
+ return NULL;
+
+ salted = g_strdup_printf ("gnome-software[%s:%s]",
+ g_get_user_name (), data);
+ return g_compute_checksum_for_string (G_CHECKSUM_SHA1, salted, -1);
+}
+
+/**
+ * gs_utils_get_permission:
+ * @id: A PolicyKit ID, e.g. "org.gnome.Desktop"
+ * @cancellable: A #GCancellable, or %NULL
+ * @error: A #GError, or %NULL
+ *
+ * Gets a permission object for an ID.
+ *
+ * Returns: a #GPermission, or %NULL if this if not possible.
+ **/
+GPermission *
+gs_utils_get_permission (const gchar *id, GCancellable *cancellable, GError **error)
+{
+#ifdef HAVE_POLKIT
+ g_autoptr(GPermission) permission = NULL;
+ permission = polkit_permission_new_sync (id, NULL, cancellable, error);
+ if (permission == NULL) {
+ g_prefix_error (error, "failed to create permission %s: ", id);
+ gs_utils_error_convert_gio (error);
+ return NULL;
+ }
+ return g_steal_pointer (&permission);
+#else
+ g_set_error (error,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_NOT_SUPPORTED,
+ "no PolicyKit, so can't return GPermission for %s", id);
+ return NULL;
+#endif
+}
+
+/**
+ * gs_utils_get_permission_async:
+ * @id: a polkit action ID, for example `org.freedesktop.packagekit.trigger-offline-update`
+ * @cancellable: (nullable): a #GCancellable, or %NULL
+ * @callback: callback for when the asynchronous operation is complete
+ * @user_data: data to pass to @callback
+ *
+ * Asynchronously gets a #GPermission object representing the given polkit
+ * action @id.
+ *
+ * Since: 42
+ */
+void
+gs_utils_get_permission_async (const gchar *id,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data)
+{
+ g_return_if_fail (id != NULL);
+ g_return_if_fail (cancellable == NULL || G_IS_CANCELLABLE (cancellable));
+
+#ifdef HAVE_POLKIT
+ polkit_permission_new (id, NULL, cancellable, callback, user_data);
+#else
+ g_task_report_new_error (NULL, callback, user_data, gs_utils_get_permission_async,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_NOT_SUPPORTED,
+ "no PolicyKit, so can't return GPermission for %s", id);
+#endif
+}
+
+/**
+ * gs_utils_get_permission_finish:
+ * @result: result of the asynchronous operation
+ * @error: return location for a #GError, or %NULL
+ *
+ * Finish an asynchronous operation started with gs_utils_get_permission_async().
+ *
+ * Returns: (transfer full): a #GPermission representing the given action ID
+ * Since: 42
+ */
+GPermission *
+gs_utils_get_permission_finish (GAsyncResult *result,
+ GError **error)
+{
+ g_return_val_if_fail (G_IS_ASYNC_RESULT (result), NULL);
+ g_return_val_if_fail (error == NULL || *error == NULL, NULL);
+
+#ifdef HAVE_POLKIT
+ return polkit_permission_new_finish (result, error);
+#else
+ return g_task_propagate_pointer (G_TASK (result), error);
+#endif
+}
+
+/**
+ * gs_utils_get_content_type:
+ * @file: A GFile
+ * @cancellable: A #GCancellable, or %NULL
+ * @error: A #GError, or %NULL
+ *
+ * Gets the standard content type for a file.
+ *
+ * Returns: the content type, or %NULL, e.g. "text/plain"
+ */
+gchar *
+gs_utils_get_content_type (GFile *file,
+ GCancellable *cancellable,
+ GError **error)
+{
+ const gchar *tmp;
+ g_autoptr(GFileInfo) info = NULL;
+
+ /* get content type */
+ info = g_file_query_info (file,
+ G_FILE_ATTRIBUTE_STANDARD_CONTENT_TYPE,
+ G_FILE_QUERY_INFO_NONE,
+ cancellable,
+ error);
+ if (info == NULL)
+ return NULL;
+ tmp = g_file_info_get_attribute_string (info, G_FILE_ATTRIBUTE_STANDARD_CONTENT_TYPE);
+ if (tmp == NULL)
+ return NULL;
+ return g_strdup (tmp);
+}
+
+/**
+ * gs_utils_strv_fnmatch:
+ * @strv: A NUL-terminated list of strings
+ * @str: A string
+ *
+ * Matches a string against a list of globs.
+ *
+ * Returns: %TRUE if the list matches
+ */
+gboolean
+gs_utils_strv_fnmatch (gchar **strv, const gchar *str)
+{
+ guint i;
+
+ /* empty */
+ if (strv == NULL)
+ return FALSE;
+
+ /* look at each one */
+ for (i = 0; strv[i] != NULL; i++) {
+ if (fnmatch (strv[i], str, 0) == 0)
+ return TRUE;
+ }
+ return FALSE;
+}
+
+/**
+ * gs_utils_sort_key:
+ * @str: A string to convert to a sort key
+ *
+ * Useful to sort strings in a locale-sensitive, presentational way.
+ * Case is ignored and utf8 collation is used (e.g. accents are ignored).
+ *
+ * Returns: a newly allocated string sort key
+ */
+gchar *
+gs_utils_sort_key (const gchar *str)
+{
+ g_autofree gchar *casefolded = g_utf8_casefold (str, -1);
+ return g_utf8_collate_key (casefolded, -1);
+}
+
+/**
+ * gs_utils_sort_strcmp:
+ * @str1: (nullable): A string to compare
+ * @str2: (nullable): A string to compare
+ *
+ * Compares two strings in a locale-sensitive, presentational way.
+ * Case is ignored and utf8 collation is used (e.g. accents are ignored). %NULL
+ * is sorted before all non-%NULL strings, and %NULLs compare equal.
+ *
+ * Returns: < 0 if str1 is before str2, 0 if equal, > 0 if str1 is after str2
+ */
+gint
+gs_utils_sort_strcmp (const gchar *str1, const gchar *str2)
+{
+ g_autofree gchar *key1 = (str1 != NULL) ? gs_utils_sort_key (str1) : NULL;
+ g_autofree gchar *key2 = (str2 != NULL) ? gs_utils_sort_key (str2) : NULL;
+ return g_strcmp0 (key1, key2);
+}
+
+/**
+ * gs_utils_get_desktop_app_info:
+ * @id: A desktop ID, e.g. "gimp.desktop"
+ *
+ * Gets a a #GDesktopAppInfo taking into account the kde4- prefix.
+ * If the given @id doesn not have a ".desktop" suffix, it will add one to it
+ * for convenience.
+ *
+ * Returns: a #GDesktopAppInfo for a specific ID, or %NULL
+ */
+GDesktopAppInfo *
+gs_utils_get_desktop_app_info (const gchar *id)
+{
+ GDesktopAppInfo *app_info;
+ g_autofree gchar *desktop_id = NULL;
+
+ /* for convenience, if the given id doesn't have the required .desktop
+ * suffix, we add it here */
+ if (!g_str_has_suffix (id, ".desktop")) {
+ desktop_id = g_strconcat (id, ".desktop", NULL);
+ id = desktop_id;
+ }
+
+ /* try to get the standard app-id */
+ app_info = g_desktop_app_info_new (id);
+
+ /* KDE is a special project because it believes /usr/share/applications
+ * isn't KDE enough. For this reason we support falling back to the
+ * "kde4-" prefixed ID to avoid educating various self-righteous
+ * upstreams about the correct ID to use in the AppData file. */
+ if (app_info == NULL) {
+ g_autofree gchar *kde_id = NULL;
+ kde_id = g_strdup_printf ("%s-%s", "kde4", id);
+ app_info = g_desktop_app_info_new (kde_id);
+ }
+
+ return app_info;
+}
+
+/**
+ * gs_utils_symlink:
+ * @target: the full path of the symlink to create
+ * @linkpath: where the symlink should point to
+ * @error: A #GError, or %NULL
+ *
+ * Creates a symlink that can cross filesystem boundaries.
+ * Any parent directories needed for target to exist are also created.
+ *
+ * Returns: %TRUE for success
+ **/
+gboolean
+gs_utils_symlink (const gchar *target, const gchar *linkpath, GError **error)
+{
+ if (!gs_mkdir_parent (target, error))
+ return FALSE;
+ if (symlink (target, linkpath) != 0) {
+ g_set_error (error,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_WRITE_FAILED,
+ "failed to create symlink from %s to %s",
+ linkpath, target);
+ return FALSE;
+ }
+ return TRUE;
+}
+
+/**
+ * gs_utils_unlink:
+ * @filename: A full pathname to delete
+ * @error: A #GError, or %NULL
+ *
+ * Deletes a file from disk.
+ *
+ * Returns: %TRUE for success
+ **/
+gboolean
+gs_utils_unlink (const gchar *filename, GError **error)
+{
+ if (g_unlink (filename) != 0) {
+ g_set_error (error,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_DELETE_FAILED,
+ "failed to delete %s",
+ filename);
+ return FALSE;
+ }
+ return TRUE;
+}
+
+static gboolean
+gs_utils_rmtree_real (const gchar *directory, GError **error)
+{
+ const gchar *filename;
+ g_autoptr(GDir) dir = NULL;
+
+ /* try to open */
+ dir = g_dir_open (directory, 0, error);
+ if (dir == NULL)
+ return FALSE;
+
+ /* find each */
+ while ((filename = g_dir_read_name (dir))) {
+ g_autofree gchar *src = NULL;
+ src = g_build_filename (directory, filename, NULL);
+ if (g_file_test (src, G_FILE_TEST_IS_DIR) &&
+ !g_file_test (src, G_FILE_TEST_IS_SYMLINK)) {
+ if (!gs_utils_rmtree_real (src, error))
+ return FALSE;
+ } else {
+ if (g_unlink (src) != 0) {
+ g_set_error (error,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_DELETE_FAILED,
+ "Failed to delete: %s", src);
+ return FALSE;
+ }
+ }
+ }
+
+ if (g_rmdir (directory) != 0) {
+ g_set_error (error,
+ GS_PLUGIN_ERROR,
+ GS_PLUGIN_ERROR_DELETE_FAILED,
+ "Failed to remove: %s", directory);
+ return FALSE;
+ }
+ return TRUE;
+}
+
+/**
+ * gs_utils_rmtree:
+ * @directory: A full directory pathname to delete
+ * @error: A #GError, or %NULL
+ *
+ * Deletes a directory from disk and all its contents.
+ *
+ * Returns: %TRUE for success
+ **/
+gboolean
+gs_utils_rmtree (const gchar *directory, GError **error)
+{
+ g_debug ("recursively removing directory '%s'", directory);
+ return gs_utils_rmtree_real (directory, error);
+}
+
+static gdouble
+pnormaldist (gdouble qn)
+{
+ static gdouble b[11] = { 1.570796288, 0.03706987906, -0.8364353589e-3,
+ -0.2250947176e-3, 0.6841218299e-5, 0.5824238515e-5,
+ -0.104527497e-5, 0.8360937017e-7, -0.3231081277e-8,
+ 0.3657763036e-10, 0.6936233982e-12 };
+ gdouble w1, w3;
+ guint i;
+
+ if (qn < 0 || qn > 1)
+ return 0; // This is an error case
+ if (qn == 0.5)
+ return 0;
+
+ w1 = qn;
+ if (qn > 0.5)
+ w1 = 1.0 - w1;
+ w3 = -log (4.0 * w1 * (1.0 - w1));
+ w1 = b[0];
+ for (i = 1; i < 11; i++)
+ w1 = w1 + (b[i] * pow (w3, i));
+
+ if (qn > 0.5)
+ return sqrt (w1 * w3);
+ else
+ return -sqrt (w1 * w3);
+}
+
+static gdouble
+wilson_score (gdouble value, gdouble n, gdouble power)
+{
+ gdouble z, phat;
+ if (value == 0)
+ return 0;
+ z = pnormaldist (1 - power / 2);
+ phat = value / n;
+ return (phat + z * z / (2 * n) -
+ z * sqrt ((phat * (1 - phat) + z * z / (4 * n)) / n)) /
+ (1 + z * z / n);
+}
+
+/**
+ * gs_utils_get_wilson_rating:
+ * @star1: The number of 1 star reviews
+ * @star2: The number of 2 star reviews
+ * @star3: The number of 3 star reviews
+ * @star4: The number of 4 star reviews
+ * @star5: The number of 5 star reviews
+ *
+ * Returns the lower bound of Wilson score confidence interval for a
+ * Bernoulli parameter. This ensures small numbers of ratings don't give overly
+ * high scores.
+ * See https://en.wikipedia.org/wiki/Binomial_proportion_confidence_interval
+ * for details.
+ *
+ * Returns: Wilson rating percentage, or -1 for error
+ **/
+gint
+gs_utils_get_wilson_rating (guint64 star1,
+ guint64 star2,
+ guint64 star3,
+ guint64 star4,
+ guint64 star5)
+{
+ gdouble val;
+ guint64 star_sum = star1 + star2 + star3 + star4 + star5;
+ if (star_sum == 0)
+ return -1;
+
+ /* get score */
+ val = (wilson_score ((gdouble) star1, (gdouble) star_sum, 0.2) * -2);
+ val += (wilson_score ((gdouble) star2, (gdouble) star_sum, 0.2) * -1);
+ val += (wilson_score ((gdouble) star4, (gdouble) star_sum, 0.2) * 1);
+ val += (wilson_score ((gdouble) star5, (gdouble) star_sum, 0.2) * 2);
+
+ /* normalize from -2..+2 to 0..5 */
+ val += 3;
+
+ /* multiply to a percentage */
+ val *= 20;
+
+ /* return rounded up integer */
+ return (gint) ceil (val);
+}
+
+/**
+ * gs_utils_error_add_app_id:
+ * @error: a #GError
+ * @app: a #GsApp
+ *
+ * Adds app unique ID prefix to the error.
+ *
+ * Since: 3.30
+ **/
+void
+gs_utils_error_add_app_id (GError **error, GsApp *app)
+{
+ g_return_if_fail (GS_APP (app));
+ if (error == NULL || *error == NULL)
+ return;
+ g_prefix_error (error, "{%s} ", gs_app_get_unique_id (app));
+}
+
+/**
+ * gs_utils_error_add_origin_id:
+ * @error: a #GError
+ * @origin: a #GsApp
+ *
+ * Adds origin unique ID prefix to the error.
+ *
+ * Since: 3.30
+ **/
+void
+gs_utils_error_add_origin_id (GError **error, GsApp *origin)
+{
+ g_return_if_fail (GS_APP (origin));
+ if (error == NULL || *error == NULL)
+ return;
+ g_prefix_error (error, "[%s] ", gs_app_get_unique_id (origin));
+}
+
+/**
+ * gs_utils_error_strip_app_id:
+ * @error: a #GError
+ *
+ * Removes a possible app ID prefix from the error, and returns the removed
+ * app ID.
+ *
+ * Returns: A newly allocated string with the app ID
+ *
+ * Since: 3.30
+ **/
+gchar *
+gs_utils_error_strip_app_id (GError *error)
+{
+ g_autofree gchar *app_id = NULL;
+ g_autofree gchar *msg = NULL;
+
+ if (error == NULL || error->message == NULL)
+ return FALSE;
+
+ if (g_str_has_prefix (error->message, "{")) {
+ const gchar *endp = strstr (error->message + 1, "} ");
+ if (endp != NULL) {
+ app_id = g_strndup (error->message + 1,
+ endp - (error->message + 1));
+ msg = g_strdup (endp + 2);
+ }
+ }
+
+ if (msg != NULL) {
+ g_free (error->message);
+ error->message = g_steal_pointer (&msg);
+ }
+
+ return g_steal_pointer (&app_id);
+}
+
+/**
+ * gs_utils_error_strip_origin_id:
+ * @error: a #GError
+ *
+ * Removes a possible origin ID prefix from the error, and returns the removed
+ * origin ID.
+ *
+ * Returns: A newly allocated string with the origin ID
+ *
+ * Since: 3.30
+ **/
+gchar *
+gs_utils_error_strip_origin_id (GError *error)
+{
+ g_autofree gchar *origin_id = NULL;
+ g_autofree gchar *msg = NULL;
+
+ if (error == NULL || error->message == NULL)
+ return FALSE;
+
+ if (g_str_has_prefix (error->message, "[")) {
+ const gchar *endp = strstr (error->message + 1, "] ");
+ if (endp != NULL) {
+ origin_id = g_strndup (error->message + 1,
+ endp - (error->message + 1));
+ msg = g_strdup (endp + 2);
+ }
+ }
+
+ if (msg != NULL) {
+ g_free (error->message);
+ error->message = g_steal_pointer (&msg);
+ }
+
+ return g_steal_pointer (&origin_id);
+}
+
+/**
+ * gs_utils_error_convert_gdbus:
+ * @perror: a pointer to a #GError, or %NULL
+ *
+ * Converts the #GDBusError to an error with a GsPluginError domain.
+ *
+ * Returns: %TRUE if the error was converted, or already correct
+ **/
+gboolean
+gs_utils_error_convert_gdbus (GError **perror)
+{
+ GError *error = perror != NULL ? *perror : NULL;
+
+ /* not set */
+ if (error == NULL)
+ return FALSE;
+ if (error->domain == GS_PLUGIN_ERROR)
+ return TRUE;
+ if (error->domain != G_DBUS_ERROR)
+ return FALSE;
+ switch (error->code) {
+ case G_DBUS_ERROR_FAILED:
+ case G_DBUS_ERROR_NO_REPLY:
+ case G_DBUS_ERROR_TIMEOUT:
+ error->code = GS_PLUGIN_ERROR_FAILED;
+ break;
+ case G_DBUS_ERROR_IO_ERROR:
+ case G_DBUS_ERROR_NAME_HAS_NO_OWNER:
+ case G_DBUS_ERROR_NOT_SUPPORTED:
+ case G_DBUS_ERROR_SERVICE_UNKNOWN:
+ case G_DBUS_ERROR_UNKNOWN_INTERFACE:
+ case G_DBUS_ERROR_UNKNOWN_METHOD:
+ case G_DBUS_ERROR_UNKNOWN_OBJECT:
+ case G_DBUS_ERROR_UNKNOWN_PROPERTY:
+ error->code = GS_PLUGIN_ERROR_NOT_SUPPORTED;
+ break;
+ case G_DBUS_ERROR_NO_MEMORY:
+ error->code = GS_PLUGIN_ERROR_NO_SPACE;
+ break;
+ case G_DBUS_ERROR_ACCESS_DENIED:
+ case G_DBUS_ERROR_AUTH_FAILED:
+ error->code = GS_PLUGIN_ERROR_NO_SECURITY;
+ break;
+ case G_DBUS_ERROR_NO_NETWORK:
+ error->code = GS_PLUGIN_ERROR_NO_NETWORK;
+ break;
+ case G_DBUS_ERROR_INVALID_FILE_CONTENT:
+ error->code = GS_PLUGIN_ERROR_INVALID_FORMAT;
+ break;
+ default:
+ g_warning ("can't reliably fixup error code %i in domain %s",
+ error->code, g_quark_to_string (error->domain));
+ error->code = GS_PLUGIN_ERROR_FAILED;
+ break;
+ }
+ error->domain = GS_PLUGIN_ERROR;
+ return TRUE;
+}
+
+/**
+ * gs_utils_error_convert_gio:
+ * @perror: a pointer to a #GError, or %NULL
+ *
+ * Converts the #GIOError to an error with a GsPluginError domain.
+ *
+ * Returns: %TRUE if the error was converted, or already correct
+ **/
+gboolean
+gs_utils_error_convert_gio (GError **perror)
+{
+ GError *error = perror != NULL ? *perror : NULL;
+
+ /* not set */
+ if (error == NULL)
+ return FALSE;
+ if (error->domain == GS_PLUGIN_ERROR)
+ return TRUE;
+ if (error->domain != G_IO_ERROR)
+ return FALSE;
+ switch (error->code) {
+ case G_IO_ERROR_FAILED:
+ case G_IO_ERROR_NOT_FOUND:
+ case G_IO_ERROR_EXISTS:
+ error->code = GS_PLUGIN_ERROR_FAILED;
+ break;
+ case G_IO_ERROR_TIMED_OUT:
+ error->code = GS_PLUGIN_ERROR_TIMED_OUT;
+ break;
+ case G_IO_ERROR_NOT_SUPPORTED:
+ error->code = GS_PLUGIN_ERROR_NOT_SUPPORTED;
+ break;
+ case G_IO_ERROR_CANCELLED:
+ error->code = GS_PLUGIN_ERROR_CANCELLED;
+ break;
+ case G_IO_ERROR_NO_SPACE:
+ error->code = GS_PLUGIN_ERROR_NO_SPACE;
+ break;
+ case G_IO_ERROR_PERMISSION_DENIED:
+ error->code = GS_PLUGIN_ERROR_NO_SECURITY;
+ break;
+ case G_IO_ERROR_HOST_NOT_FOUND:
+ case G_IO_ERROR_HOST_UNREACHABLE:
+ case G_IO_ERROR_CONNECTION_REFUSED:
+ case G_IO_ERROR_PROXY_FAILED:
+ case G_IO_ERROR_PROXY_AUTH_FAILED:
+ case G_IO_ERROR_PROXY_NOT_ALLOWED:
+ error->code = GS_PLUGIN_ERROR_DOWNLOAD_FAILED;
+ break;
+ case G_IO_ERROR_NETWORK_UNREACHABLE:
+ error->code = GS_PLUGIN_ERROR_NO_NETWORK;
+ break;
+ default:
+ g_warning ("can't reliably fixup error code %i in domain %s",
+ error->code, g_quark_to_string (error->domain));
+ error->code = GS_PLUGIN_ERROR_FAILED;
+ break;
+ }
+ error->domain = GS_PLUGIN_ERROR;
+ return TRUE;
+}
+
+/**
+ * gs_utils_error_convert_gresolver:
+ * @perror: a pointer to a #GError, or %NULL
+ *
+ * Converts the #GResolverError to an error with a GsPluginError domain.
+ *
+ * Returns: %TRUE if the error was converted, or already correct
+ **/
+gboolean
+gs_utils_error_convert_gresolver (GError **perror)
+{
+ GError *error = perror != NULL ? *perror : NULL;
+
+ /* not set */
+ if (error == NULL)
+ return FALSE;
+ if (error->domain == GS_PLUGIN_ERROR)
+ return TRUE;
+ if (error->domain != G_RESOLVER_ERROR)
+ return FALSE;
+ switch (error->code) {
+ case G_RESOLVER_ERROR_INTERNAL:
+ error->code = GS_PLUGIN_ERROR_FAILED;
+ break;
+ case G_RESOLVER_ERROR_NOT_FOUND:
+ case G_RESOLVER_ERROR_TEMPORARY_FAILURE:
+ error->code = GS_PLUGIN_ERROR_DOWNLOAD_FAILED;
+ break;
+ default:
+ g_warning ("can't reliably fixup error code %i in domain %s",
+ error->code, g_quark_to_string (error->domain));
+ error->code = GS_PLUGIN_ERROR_FAILED;
+ break;
+ }
+ error->domain = GS_PLUGIN_ERROR;
+ return TRUE;
+}
+
+/**
+ * gs_utils_error_convert_gdk_pixbuf:
+ * @perror: a pointer to a #GError, or %NULL
+ *
+ * Converts the #GdkPixbufError to an error with a GsPluginError domain.
+ *
+ * Returns: %TRUE if the error was converted, or already correct
+ **/
+gboolean
+gs_utils_error_convert_gdk_pixbuf (GError **perror)
+{
+ GError *error = perror != NULL ? *perror : NULL;
+
+ /* not set */
+ if (error == NULL)
+ return FALSE;
+ if (error->domain == GS_PLUGIN_ERROR)
+ return TRUE;
+ if (error->domain != GDK_PIXBUF_ERROR)
+ return FALSE;
+ switch (error->code) {
+ case GDK_PIXBUF_ERROR_UNSUPPORTED_OPERATION:
+ case GDK_PIXBUF_ERROR_UNKNOWN_TYPE:
+ error->code = GS_PLUGIN_ERROR_NOT_SUPPORTED;
+ break;
+ case GDK_PIXBUF_ERROR_FAILED:
+ error->code = GS_PLUGIN_ERROR_FAILED;
+ break;
+ case GDK_PIXBUF_ERROR_CORRUPT_IMAGE:
+ error->code = GS_PLUGIN_ERROR_INVALID_FORMAT;
+ break;
+ default:
+ g_warning ("can't reliably fixup error code %i in domain %s",
+ error->code, g_quark_to_string (error->domain));
+ error->code = GS_PLUGIN_ERROR_FAILED;
+ break;
+ }
+ error->domain = GS_PLUGIN_ERROR;
+ return TRUE;
+}
+
+/**
+ * gs_utils_error_convert_appstream:
+ * @perror: a pointer to a #GError, or %NULL
+ *
+ * Converts the various AppStream error types to an error with a GsPluginError
+ * domain.
+ *
+ * Returns: %TRUE if the error was converted, or already correct
+ **/
+gboolean
+gs_utils_error_convert_appstream (GError **perror)
+{
+ GError *error = perror != NULL ? *perror : NULL;
+
+ /* not set */
+ if (error == NULL)
+ return FALSE;
+ if (error->domain == GS_PLUGIN_ERROR)
+ return TRUE;
+
+ /* custom to this plugin */
+ if (error->domain == AS_METADATA_ERROR) {
+ switch (error->code) {
+ case AS_METADATA_ERROR_PARSE:
+ case AS_METADATA_ERROR_FORMAT_UNEXPECTED:
+ case AS_METADATA_ERROR_NO_COMPONENT:
+ error->code = GS_PLUGIN_ERROR_INVALID_FORMAT;
+ break;
+ case AS_METADATA_ERROR_FAILED:
+ default:
+ error->code = GS_PLUGIN_ERROR_FAILED;
+ break;
+ }
+ } else if (error->domain == AS_POOL_ERROR) {
+ switch (error->code) {
+ case AS_POOL_ERROR_FAILED:
+ default:
+ error->code = GS_PLUGIN_ERROR_FAILED;
+ break;
+ }
+ } else if (error->domain == G_FILE_ERROR) {
+ switch (error->code) {
+ case G_FILE_ERROR_EXIST:
+ case G_FILE_ERROR_ACCES:
+ case G_FILE_ERROR_PERM:
+ error->code = GS_PLUGIN_ERROR_NO_SECURITY;
+ break;
+ case G_FILE_ERROR_NOSPC:
+ error->code = GS_PLUGIN_ERROR_NO_SPACE;
+ break;
+ default:
+ error->code = GS_PLUGIN_ERROR_FAILED;
+ break;
+ }
+ } else {
+ g_warning ("can't reliably fixup error from domain %s",
+ g_quark_to_string (error->domain));
+ error->code = GS_PLUGIN_ERROR_FAILED;
+ }
+ error->domain = GS_PLUGIN_ERROR;
+ return TRUE;
+}
+
+/**
+ * gs_utils_get_url_scheme:
+ * @url: A URL, e.g. "appstream://gimp.desktop"
+ *
+ * Gets the scheme from the URL string.
+ *
+ * Returns: the URL scheme, e.g. "appstream"
+ */
+gchar *
+gs_utils_get_url_scheme (const gchar *url)
+{
+ g_autoptr(GUri) uri = NULL;
+
+ /* no data */
+ if (url == NULL)
+ return NULL;
+
+ /* create URI from URL */
+ uri = g_uri_parse (url, SOUP_HTTP_URI_FLAGS, NULL);
+ if (!uri)
+ return NULL;
+
+ /* success */
+ return g_strdup (g_uri_get_scheme (uri));
+}
+
+/**
+ * gs_utils_get_url_path:
+ * @url: A URL, e.g. "appstream://gimp.desktop"
+ *
+ * Gets the path from the URL string, removing any leading slashes.
+ *
+ * Returns: the URL path, e.g. "gimp.desktop"
+ */
+gchar *
+gs_utils_get_url_path (const gchar *url)
+{
+ g_autoptr(GUri) uri = NULL;
+ const gchar *host;
+ const gchar *path;
+
+ uri = g_uri_parse (url, SOUP_HTTP_URI_FLAGS, NULL);
+ if (!uri)
+ return NULL;
+
+ /* foo://bar -> scheme: foo, host: bar, path: / */
+ /* foo:bar -> scheme: foo, host: (empty string), path: /bar */
+ host = g_uri_get_host (uri);
+ path = g_uri_get_path (uri);
+ if (host != NULL && *host != '\0')
+ path = host;
+
+ /* trim any leading slashes */
+ while (*path == '/')
+ path++;
+
+ /* success */
+ return g_strdup (path);
+}
+
+/**
+ * gs_user_agent:
+ *
+ * Gets the user agent to use for remote requests.
+ *
+ * Returns: the user-agent, e.g. "gnome-software/3.22.1"
+ */
+const gchar *
+gs_user_agent (void)
+{
+ return PACKAGE_NAME "/" PACKAGE_VERSION;
+}
+
+/**
+ * gs_utils_append_key_value:
+ * @str: A #GString
+ * @align_len: The alignment of the @value compared to the @key
+ * @key: The text to use as a title
+ * @value: The text to use as a value
+ *
+ * Adds a line to an existing string, padding the key to a set number of spaces.
+ *
+ * Since: 3.26
+ */
+void
+gs_utils_append_key_value (GString *str, gsize align_len,
+ const gchar *key, const gchar *value)
+{
+ gsize len = 0;
+
+ g_return_if_fail (str != NULL);
+ g_return_if_fail (value != NULL);
+
+ if (key != NULL) {
+ len = strlen (key) + 2;
+ g_string_append (str, key);
+ g_string_append (str, ": ");
+ }
+ for (gsize i = len; i < align_len + 1; i++)
+ g_string_append (str, " ");
+ g_string_append (str, value);
+ g_string_append (str, "\n");
+}
+
+guint
+gs_utils_get_memory_total (void)
+{
+#if defined(__linux__)
+ struct sysinfo si = { 0 };
+ sysinfo (&si);
+ if (si.mem_unit > 0)
+ return si.totalram / MB_IN_BYTES / si.mem_unit;
+ return 0;
+#elif defined(__FreeBSD__)
+ unsigned long physmem;
+ sysctl ((int[]){ CTL_HW, HW_PHYSMEM }, 2, &physmem, &(size_t){ sizeof (physmem) }, NULL, 0);
+ return physmem / MB_IN_BYTES;
+#else
+#error "Please implement gs_utils_get_memory_total for your system."
+#endif
+}
+
+/**
+ * gs_utils_set_online_updates_timestamp:
+ *
+ * Sets the value of online-updates-timestamp to current epoch. "online-updates-timestamp" represents
+ * the last time the system was online and got any updates.
+ **/
+void
+gs_utils_set_online_updates_timestamp (GSettings *settings)
+{
+ g_autoptr(GDateTime) now = NULL;
+
+ g_return_if_fail (settings != NULL);
+
+ now = g_date_time_new_now_local ();
+ g_settings_set (settings, "online-updates-timestamp", "x", g_date_time_to_unix (now));
+}
+
+/**
+ * gs_utils_unique_id_compat_convert:
+ * @data_id: (nullable): A string that may be a unique component ID
+ *
+ * Converts the unique ID string from its legacy 6-part form into
+ * a new-style 5-part AppStream data-id.
+ * Does nothing if the string is already valid.
+ *
+ * See !583 for the history of this conversion.
+ *
+ * Returns: (nullable): A newly allocated string with the new-style data-id, or %NULL if input was no valid ID.
+ *
+ * Since: 40
+ **/
+gchar*
+gs_utils_unique_id_compat_convert (const gchar *data_id)
+{
+ g_auto(GStrv) parts = NULL;
+ if (data_id == NULL)
+ return NULL;
+
+ /* check for the most common case first: data-id is already valid */
+ if (as_utils_data_id_valid (data_id))
+ return g_strdup (data_id);
+
+ parts = g_strsplit (data_id, "/", -1);
+ if (g_strv_length (parts) != 6)
+ return NULL;
+ return g_strdup_printf ("%s/%s/%s/%s/%s",
+ parts[0],
+ parts[1],
+ parts[2],
+ parts[4],
+ parts[5]);
+}
+
+static const gchar *
+_fix_data_id_part (const gchar *value)
+{
+ if (!value || !*value)
+ return "*";
+
+ return value;
+}
+
+/**
+ * gs_utils_build_unique_id:
+ * @scope: Scope of the metadata as #AsComponentScope e.g. %AS_COMPONENT_SCOPE_SYSTEM
+ * @bundle_kind: Bundling system providing this data, e.g. 'package' or 'flatpak'
+ * @origin: Origin string, e.g. 'os' or 'gnome-apps-nightly'
+ * @cid: AppStream component ID, e.g. 'org.freedesktop.appstream.cli'
+ * @branch: Branch, e.g. '3-20' or 'master'
+ *
+ * Builds an identifier string unique to the individual dataset using the supplied information.
+ * It's similar to as_utils_build_data_id(), except it respects the @origin for the packages.
+ *
+ * Returns: (transfer full): a unique ID, free with g_free(), when no longer needed.
+ *
+ * Since: 41
+ */
+gchar *
+gs_utils_build_unique_id (AsComponentScope scope,
+ AsBundleKind bundle_kind,
+ const gchar *origin,
+ const gchar *cid,
+ const gchar *branch)
+{
+ const gchar *scope_str = NULL;
+ const gchar *bundle_str = NULL;
+
+ if (scope != AS_COMPONENT_SCOPE_UNKNOWN)
+ scope_str = as_component_scope_to_string (scope);
+ if (bundle_kind != AS_BUNDLE_KIND_UNKNOWN)
+ bundle_str = as_bundle_kind_to_string (bundle_kind);
+
+ return g_strdup_printf ("%s/%s/%s/%s/%s",
+ _fix_data_id_part (scope_str),
+ _fix_data_id_part (bundle_str),
+ _fix_data_id_part (origin),
+ _fix_data_id_part (cid),
+ _fix_data_id_part (branch));
+}
+
+static void
+gs_pixbuf_blur_private (GdkPixbuf *src, GdkPixbuf *dest, guint radius, guint8 *div_kernel_size)
+{
+ gint width, height, src_rowstride, dest_rowstride, n_channels;
+ guchar *p_src, *p_dest, *c1, *c2;
+ gint x, y, i, i1, i2, width_minus_1, height_minus_1, radius_plus_1;
+ gint r, g, b;
+ guchar *p_dest_row, *p_dest_col;
+
+ width = gdk_pixbuf_get_width (src);
+ height = gdk_pixbuf_get_height (src);
+ n_channels = gdk_pixbuf_get_n_channels (src);
+ radius_plus_1 = radius + 1;
+
+ /* horizontal blur */
+ p_src = gdk_pixbuf_get_pixels (src);
+ p_dest = gdk_pixbuf_get_pixels (dest);
+ src_rowstride = gdk_pixbuf_get_rowstride (src);
+ dest_rowstride = gdk_pixbuf_get_rowstride (dest);
+ width_minus_1 = width - 1;
+ for (y = 0; y < height; y++) {
+
+ /* calc the initial sums of the kernel */
+ r = g = b = 0;
+ for (i = -radius; i <= (gint) radius; i++) {
+ c1 = p_src + (CLAMP (i, 0, width_minus_1) * n_channels);
+ r += c1[0];
+ g += c1[1];
+ b += c1[2];
+ }
+
+ p_dest_row = p_dest;
+ for (x = 0; x < width; x++) {
+ /* set as the mean of the kernel */
+ p_dest_row[0] = div_kernel_size[r];
+ p_dest_row[1] = div_kernel_size[g];
+ p_dest_row[2] = div_kernel_size[b];
+ p_dest_row += n_channels;
+
+ /* the pixel to add to the kernel */
+ i1 = x + radius_plus_1;
+ if (i1 > width_minus_1)
+ i1 = width_minus_1;
+ c1 = p_src + (i1 * n_channels);
+
+ /* the pixel to remove from the kernel */
+ i2 = x - radius;
+ if (i2 < 0)
+ i2 = 0;
+ c2 = p_src + (i2 * n_channels);
+
+ /* calc the new sums of the kernel */
+ r += c1[0] - c2[0];
+ g += c1[1] - c2[1];
+ b += c1[2] - c2[2];
+ }
+
+ p_src += src_rowstride;
+ p_dest += dest_rowstride;
+ }
+
+ /* vertical blur */
+ p_src = gdk_pixbuf_get_pixels (dest);
+ p_dest = gdk_pixbuf_get_pixels (src);
+ src_rowstride = gdk_pixbuf_get_rowstride (dest);
+ dest_rowstride = gdk_pixbuf_get_rowstride (src);
+ height_minus_1 = height - 1;
+ for (x = 0; x < width; x++) {
+
+ /* calc the initial sums of the kernel */
+ r = g = b = 0;
+ for (i = -radius; i <= (gint) radius; i++) {
+ c1 = p_src + (CLAMP (i, 0, height_minus_1) * src_rowstride);
+ r += c1[0];
+ g += c1[1];
+ b += c1[2];
+ }
+
+ p_dest_col = p_dest;
+ for (y = 0; y < height; y++) {
+ /* set as the mean of the kernel */
+
+ p_dest_col[0] = div_kernel_size[r];
+ p_dest_col[1] = div_kernel_size[g];
+ p_dest_col[2] = div_kernel_size[b];
+ p_dest_col += dest_rowstride;
+
+ /* the pixel to add to the kernel */
+ i1 = y + radius_plus_1;
+ if (i1 > height_minus_1)
+ i1 = height_minus_1;
+ c1 = p_src + (i1 * src_rowstride);
+
+ /* the pixel to remove from the kernel */
+ i2 = y - radius;
+ if (i2 < 0)
+ i2 = 0;
+ c2 = p_src + (i2 * src_rowstride);
+
+ /* calc the new sums of the kernel */
+ r += c1[0] - c2[0];
+ g += c1[1] - c2[1];
+ b += c1[2] - c2[2];
+ }
+
+ p_src += n_channels;
+ p_dest += n_channels;
+ }
+}
+
+/**
+ * gs_utils_pixbuf_blur:
+ * @src: the GdkPixbuf.
+ * @radius: the pixel radius for the gaussian blur, typical values are 1..3
+ * @iterations: Amount to blur the image, typical values are 1..5
+ *
+ * Blurs an image. Warning, this method is s..l..o..w... for large images.
+ **/
+void
+gs_utils_pixbuf_blur (GdkPixbuf *src, guint radius, guint iterations)
+{
+ gint kernel_size;
+ gint i;
+ g_autofree guchar *div_kernel_size = NULL;
+ g_autoptr(GdkPixbuf) tmp = NULL;
+
+ tmp = gdk_pixbuf_new (gdk_pixbuf_get_colorspace (src),
+ gdk_pixbuf_get_has_alpha (src),
+ gdk_pixbuf_get_bits_per_sample (src),
+ gdk_pixbuf_get_width (src),
+ gdk_pixbuf_get_height (src));
+ kernel_size = 2 * radius + 1;
+ div_kernel_size = g_new (guchar, 256 * kernel_size);
+ for (i = 0; i < 256 * kernel_size; i++)
+ div_kernel_size[i] = (guchar) (i / kernel_size);
+
+ while (iterations-- > 0)
+ gs_pixbuf_blur_private (src, tmp, radius, div_kernel_size);
+}
+
+/**
+ * gs_utils_get_file_size:
+ * @filename: a file name to get the size of; it can be a file or a directory
+ * @include_func: (nullable) (scope call): optional callback to limit what files to count
+ * @user_data: user data passed to the @include_func
+ * @cancellable: (nullable): an optional #GCancellable or %NULL
+ *
+ * Gets the size of the file or a directory identified by @filename.
+ *
+ * When the @include_func is not %NULL, it can limit which files are included
+ * in the resulting size. When it's %NULL, all files and subdirectories are included.
+ *
+ * Returns: disk size of the @filename; or 0 when not found
+ *
+ * Since: 41
+ **/
+guint64
+gs_utils_get_file_size (const gchar *filename,
+ GsFileSizeIncludeFunc include_func,
+ gpointer user_data,
+ GCancellable *cancellable)
+{
+ guint64 size = 0;
+
+ g_return_val_if_fail (filename != NULL, 0);
+
+ if (g_file_test (filename, G_FILE_TEST_IS_DIR)) {
+ GSList *dirs_to_do = NULL;
+ gsize base_len = strlen (filename);
+
+ /* The `include_func()` expects a path relative to the `filename`, without
+ a leading dir separator. As the `dirs_to_do` contains the full path,
+ constructed with `g_build_filename()`, the added dir separator needs
+ to be skipped, when it's not part of the `filename` already. */
+ if (!g_str_has_suffix (filename, G_DIR_SEPARATOR_S))
+ base_len++;
+
+ dirs_to_do = g_slist_prepend (dirs_to_do, g_strdup (filename));
+ while (dirs_to_do != NULL && !g_cancellable_is_cancelled (cancellable)) {
+ g_autofree gchar *path = NULL;
+ g_autoptr(GDir) dir = NULL;
+
+ /* Steal the top `path` out of the `dirs_to_do`. */
+ path = dirs_to_do->data;
+ dirs_to_do = g_slist_remove (dirs_to_do, path);
+
+ dir = g_dir_open (path, 0, NULL);
+ if (dir) {
+ const gchar *name;
+ while (name = g_dir_read_name (dir), name != NULL && !g_cancellable_is_cancelled (cancellable)) {
+ g_autofree gchar *full_path = g_build_filename (path, name, NULL);
+ GStatBuf st;
+
+ if (g_stat (full_path, &st) == 0 && (include_func == NULL ||
+ include_func (full_path + base_len,
+ g_file_test (full_path, G_FILE_TEST_IS_SYMLINK) ? G_FILE_TEST_IS_SYMLINK :
+ S_ISDIR (st.st_mode) ? G_FILE_TEST_IS_DIR :
+ G_FILE_TEST_IS_REGULAR,
+ user_data))) {
+ if (S_ISDIR (st.st_mode)) {
+ /* Skip symlinks, they can point to a shared storage */
+ if (!g_file_test (full_path, G_FILE_TEST_IS_SYMLINK))
+ dirs_to_do = g_slist_prepend (dirs_to_do, g_steal_pointer (&full_path));
+ } else {
+ size += st.st_size;
+ }
+ }
+ }
+ }
+ }
+ g_slist_free_full (dirs_to_do, g_free);
+ } else {
+ GStatBuf st;
+
+ if (g_stat (filename, &st) == 0)
+ size = st.st_size;
+ }
+
+ return size;
+}
+
+#define METADATA_ETAG_ATTRIBUTE "xattr::gnome-software::etag"
+
+/**
+ * gs_utils_get_file_etag:
+ * @file: a file to get the ETag for
+ * @last_modified_date_out: (out callee-allocates) (transfer full) (optional) (nullable):
+ * return location for the last modified date of the file (%NULL to ignore),
+ * or %NULL if unknown
+ * @cancellable: (nullable): an optional #GCancellable or %NULL
+ *
+ * Gets the ETag for the @file, previously stored by
+ * gs_utils_set_file_etag().
+ *
+ * Returns: (nullable) (transfer full): The ETag stored for the @file,
+ * or %NULL, when the file does not exist, no ETag is stored for it
+ * or other error occurs.
+ *
+ * Since: 43
+ **/
+gchar *
+gs_utils_get_file_etag (GFile *file,
+ GDateTime **last_modified_date_out,
+ GCancellable *cancellable)
+{
+ g_autoptr(GFileInfo) info = NULL;
+ const gchar *attributes;
+ g_autoptr(GError) local_error = NULL;
+
+ g_return_val_if_fail (G_IS_FILE (file), NULL);
+ g_return_val_if_fail (cancellable == NULL || G_IS_CANCELLABLE (cancellable), NULL);
+
+ if (last_modified_date_out == NULL)
+ attributes = METADATA_ETAG_ATTRIBUTE;
+ else
+ attributes = METADATA_ETAG_ATTRIBUTE "," G_FILE_ATTRIBUTE_TIME_MODIFIED;
+
+ info = g_file_query_info (file, attributes, G_FILE_QUERY_INFO_NONE, cancellable, &local_error);
+
+ if (info == NULL) {
+ g_debug ("Error getting attribute ‘%s’ for file ‘%s’: %s",
+ METADATA_ETAG_ATTRIBUTE, g_file_peek_path (file), local_error->message);
+
+ if (last_modified_date_out != NULL)
+ *last_modified_date_out = NULL;
+
+ return NULL;
+ }
+
+ if (last_modified_date_out != NULL)
+ *last_modified_date_out = g_file_info_get_modification_date_time (info);
+
+ return g_strdup (g_file_info_get_attribute_string (info, METADATA_ETAG_ATTRIBUTE));
+}
+
+/**
+ * gs_utils_set_file_etag:
+ * @file: a file to get the ETag for
+ * @etag: (nullable): an ETag to set
+ * @cancellable: (nullable): an optional #GCancellable or %NULL
+ *
+ * Sets the ETag for the @file. When the @etag is %NULL or an empty
+ * string, then unsets the ETag for the @file. The ETag can be read
+ * back with gs_utils_get_file_etag().
+ *
+ * The @file should exist, otherwise the function fails.
+ *
+ * Returns: whether succeeded.
+ *
+ * Since: 42
+ **/
+gboolean
+gs_utils_set_file_etag (GFile *file,
+ const gchar *etag,
+ GCancellable *cancellable)
+{
+ g_autoptr(GError) local_error = NULL;
+
+ g_return_val_if_fail (G_IS_FILE (file), FALSE);
+ g_return_val_if_fail (cancellable == NULL || G_IS_CANCELLABLE (cancellable), FALSE);
+
+ if (etag == NULL || *etag == '\0') {
+ if (!g_file_set_attribute (file, METADATA_ETAG_ATTRIBUTE, G_FILE_ATTRIBUTE_TYPE_INVALID,
+ NULL, G_FILE_QUERY_INFO_NONE, cancellable, &local_error)) {
+ g_debug ("Error clearing attribute ‘%s’ on file ‘%s’: %s",
+ METADATA_ETAG_ATTRIBUTE, g_file_peek_path (file), local_error->message);
+ return FALSE;
+ }
+
+ return TRUE;
+ }
+
+ if (!g_file_set_attribute_string (file, METADATA_ETAG_ATTRIBUTE, etag, G_FILE_QUERY_INFO_NONE, cancellable, &local_error)) {
+ g_debug ("Error setting attribute ‘%s’ to ‘%s’ on file ‘%s’: %s",
+ METADATA_ETAG_ATTRIBUTE, etag, g_file_peek_path (file), local_error->message);
+ return FALSE;
+ }
+
+ return TRUE;
+}
+
+/**
+ * gs_utils_get_upgrade_background:
+ * @version: (nullable): version string of the upgrade (which must be non-empty
+ * if provided), or %NULL if unknown
+ *
+ * Get the path to a background image to display as the background for a banner
+ * advertising an upgrade to the given @version.
+ *
+ * If a path is returned, it’s guaranteed to exist on the file system.
+ *
+ * Vendors can drop their customised backgrounds in this directory for them to
+ * be used by gnome-software. See `doc/vendor-customisation.md`.
+ *
+ * Returns: (transfer full) (type filename) (nullable): path to an upgrade
+ * background image to use, or %NULL if a suitable one didn’t exist
+ * Since: 42
+*/
+gchar *
+gs_utils_get_upgrade_background (const gchar *version)
+{
+ g_autofree gchar *filename = NULL;
+ g_autofree gchar *os_id = g_get_os_info (G_OS_INFO_KEY_ID);
+
+ g_return_val_if_fail (version == NULL || *version != '\0', NULL);
+
+ if (version != NULL) {
+ filename = g_strdup_printf (DATADIR "/gnome-software/backgrounds/%s-%s.png", os_id, version);
+ if (g_file_test (filename, G_FILE_TEST_EXISTS))
+ return g_steal_pointer (&filename);
+ g_clear_pointer (&filename, g_free);
+ }
+
+ filename = g_strdup_printf (DATADIR "/gnome-software/backgrounds/%s.png", os_id);
+ if (g_file_test (filename, G_FILE_TEST_EXISTS))
+ return g_steal_pointer (&filename);
+ g_clear_pointer (&filename, g_free);
+
+ return NULL;
+}
+
+/**
+ * gs_utils_app_sort_name:
+ * @app1: a #GsApp
+ * @app2: another #GsApp
+ * @user_data: data passed to the sort function
+ *
+ * Comparison function to sort apps in increasing alphabetical order of name.
+ *
+ * This is suitable for passing to gs_app_list_sort().
+ *
+ * Returns: a strcmp()-style sort value comparing @app1 to @app2
+ * Since: 43
+ */
+gint
+gs_utils_app_sort_name (GsApp *app1,
+ GsApp *app2,
+ gpointer user_data)
+{
+ return gs_utils_sort_strcmp (gs_app_get_name (app1), gs_app_get_name (app2));
+}
+
+/**
+ * gs_utils_app_sort_match_value:
+ * @app1: a #GsApp
+ * @app2: another #GsApp
+ * @user_data: data passed to the sort function
+ *
+ * Comparison function to sort apps in decreasing order of match value
+ * (#GsApp:match-value).
+ *
+ * This is suitable for passing to gs_app_list_sort().
+ *
+ * Returns: a strcmp()-style sort value comparing @app1 to @app2
+ * Since: 43
+ */
+gint
+gs_utils_app_sort_match_value (GsApp *app1,
+ GsApp *app2,
+ gpointer user_data)
+{
+ return gs_app_get_match_value (app2) - gs_app_get_match_value (app1);
+}
+
+/**
+ * gs_utils_app_sort_priority:
+ * @app1: a #GsApp
+ * @app2: another #GsApp
+ * @user_data: data passed to the sort function
+ *
+ * Comparison function to sort apps in increasing order of their priority
+ * (#GsApp:priority).
+ *
+ * This is suitable for passing to gs_app_list_sort().
+ *
+ * Returns: a strcmp()-style sort value comparing @app1 to @app2
+ * Since: 43
+ */
+gint
+gs_utils_app_sort_priority (GsApp *app1,
+ GsApp *app2,
+ gpointer user_data)
+{
+ return gs_app_compare_priority (app1, app2);
+}
diff --git a/lib/gs-utils.h b/lib/gs-utils.h
new file mode 100644
index 0000000..855e984
--- /dev/null
+++ b/lib/gs-utils.h
@@ -0,0 +1,155 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2013-2016 Richard Hughes <richard@hughsie.com>
+ * Copyright (C) 2018 Kalev Lember <klember@redhat.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#include <gio/gdesktopappinfo.h>
+#include <gtk/gtk.h>
+
+#include "gs-app.h"
+
+G_BEGIN_DECLS
+
+/**
+ * GsUtilsCacheFlags:
+ * @GS_UTILS_CACHE_FLAG_NONE: No flags set
+ * @GS_UTILS_CACHE_FLAG_WRITEABLE: A writable directory is required
+ * @GS_UTILS_CACHE_FLAG_USE_HASH: Prefix a hash to the filename
+ * @GS_UTILS_CACHE_FLAG_ENSURE_EMPTY: Clear existing cached items
+ * @GS_UTILS_CACHE_FLAG_CREATE_DIRECTORY: Create the cache directory (Since: 40)
+ *
+ * The cache flags.
+ **/
+typedef enum {
+ GS_UTILS_CACHE_FLAG_NONE = 0,
+ GS_UTILS_CACHE_FLAG_WRITEABLE = 1 << 0,
+ GS_UTILS_CACHE_FLAG_USE_HASH = 1 << 1,
+ GS_UTILS_CACHE_FLAG_ENSURE_EMPTY = 1 << 2,
+ GS_UTILS_CACHE_FLAG_CREATE_DIRECTORY = 1 << 3,
+ GS_UTILS_CACHE_FLAG_LAST /*< skip >*/
+} GsUtilsCacheFlags;
+
+guint64 gs_utils_get_file_age (GFile *file);
+gchar *gs_utils_get_content_type (GFile *file,
+ GCancellable *cancellable,
+ GError **error);
+gboolean gs_utils_symlink (const gchar *target,
+ const gchar *linkpath,
+ GError **error);
+gboolean gs_utils_unlink (const gchar *filename,
+ GError **error);
+gboolean gs_mkdir_parent (const gchar *path,
+ GError **error);
+gchar *gs_utils_get_cache_filename (const gchar *kind,
+ const gchar *resource,
+ GsUtilsCacheFlags flags,
+ GError **error);
+gchar *gs_utils_get_user_hash (GError **error);
+GPermission *gs_utils_get_permission (const gchar *id,
+ GCancellable *cancellable,
+ GError **error);
+void gs_utils_get_permission_async (const gchar *id,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data);
+GPermission *gs_utils_get_permission_finish (GAsyncResult *result,
+ GError **error);
+gboolean gs_utils_strv_fnmatch (gchar **strv,
+ const gchar *str);
+gchar *gs_utils_sort_key (const gchar *str);
+gint gs_utils_sort_strcmp (const gchar *str1,
+ const gchar *str2);
+GDesktopAppInfo *gs_utils_get_desktop_app_info (const gchar *id);
+gboolean gs_utils_rmtree (const gchar *directory,
+ GError **error);
+gint gs_utils_get_wilson_rating (guint64 star1,
+ guint64 star2,
+ guint64 star3,
+ guint64 star4,
+ guint64 star5);
+void gs_utils_error_add_app_id (GError **error,
+ GsApp *app);
+void gs_utils_error_add_origin_id (GError **error,
+ GsApp *origin);
+gchar *gs_utils_error_strip_app_id (GError *error);
+gchar *gs_utils_error_strip_origin_id (GError *error);
+gboolean gs_utils_error_convert_gio (GError **perror);
+gboolean gs_utils_error_convert_gresolver (GError **perror);
+gboolean gs_utils_error_convert_gdbus (GError **perror);
+gboolean gs_utils_error_convert_gdk_pixbuf(GError **perror);
+gboolean gs_utils_error_convert_appstream (GError **perror);
+
+gchar *gs_utils_get_url_scheme (const gchar *url);
+gchar *gs_utils_get_url_path (const gchar *url);
+const gchar *gs_user_agent (void);
+void gs_utils_append_key_value (GString *str,
+ gsize align_len,
+ const gchar *key,
+ const gchar *value);
+guint gs_utils_get_memory_total (void);
+gboolean gs_utils_parse_evr (const gchar *evr,
+ gchar **out_epoch,
+ gchar **out_version,
+ gchar **out_release);
+void gs_utils_set_online_updates_timestamp (GSettings *settings);
+
+gchar *gs_utils_unique_id_compat_convert (const gchar *data_id);
+
+gchar *gs_utils_build_unique_id (AsComponentScope scope,
+ AsBundleKind bundle_kind,
+ const gchar *origin,
+ const gchar *cid,
+ const gchar *branch);
+
+void gs_utils_pixbuf_blur (GdkPixbuf *src,
+ guint radius,
+ guint iterations);
+
+/**
+ * GsFileSizeIncludeFunc:
+ * @filename: file name to check
+ * @file_kind: the file kind, one of #GFileTest enums
+ * @user_data: a user data passed to the gs_utils_get_file_size()
+ *
+ * Check whether include the @filename in the size calculation.
+ * The @filename is a relative path to the file name passed to
+ * the #GsFileSizeIncludeFunc.
+ *
+ * Returns: Whether to include the @filename in the size calculation
+ *
+ * Since: 41
+ **/
+typedef gboolean (*GsFileSizeIncludeFunc) (const gchar *filename,
+ GFileTest file_kind,
+ gpointer user_data);
+
+guint64 gs_utils_get_file_size (const gchar *filename,
+ GsFileSizeIncludeFunc include_func,
+ gpointer user_data,
+ GCancellable *cancellable);
+gchar * gs_utils_get_file_etag (GFile *file,
+ GDateTime **last_modified_date_out,
+ GCancellable *cancellable);
+gboolean gs_utils_set_file_etag (GFile *file,
+ const gchar *etag,
+ GCancellable *cancellable);
+
+gchar *gs_utils_get_upgrade_background (const gchar *version);
+
+gint gs_utils_app_sort_name (GsApp *app1,
+ GsApp *app2,
+ gpointer user_data);
+gint gs_utils_app_sort_match_value (GsApp *app1,
+ GsApp *app2,
+ gpointer user_data);
+gint gs_utils_app_sort_priority (GsApp *app1,
+ GsApp *app2,
+ gpointer user_data);
+
+G_END_DECLS
diff --git a/lib/gs-worker-thread.c b/lib/gs-worker-thread.c
new file mode 100644
index 0000000..6e6686d
--- /dev/null
+++ b/lib/gs-worker-thread.c
@@ -0,0 +1,425 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2021 Endless OS Foundation LLC
+ *
+ * Author: Philip Withnall <pwithnall@endlessos.org>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+/**
+ * SECTION:gs-worker-thread
+ * @short_description: A worker thread which executes queued #GTasks until stopped
+ *
+ * #GsWorkerThread is a thread-safe wrapper around a #GTask queue and a single
+ * worker thread which executes tasks on that queue.
+ *
+ * Tasks can be added to the queue using gs_worker_thread_queue(). The worker
+ * thread (which is created when #GsWorkerThread is constructed) will execute
+ * them in (priority, queue order) order. Each #GTaskThreadFunc is responsible
+ * for calling `g_task_return_*()` on its #GTask to complete that task.
+ *
+ * The priority passed to gs_worker_thread_queue() will be used to adjust the
+ * worker thread’s I/O priority (using `ioprio_set()`) when executing that task.
+ *
+ * It is intended that gs_worker_thread_queue() is an alternative to using
+ * g_task_run_in_thread(). g_task_run_in_thread() queues tasks into a single
+ * process-wide thread pool, so they are mixed in with other tasks, and it can
+ * become hard to ensure the thread pool isn’t overwhelmed and that tasks are
+ * executed in the right order.
+ *
+ * The worker thread will continue executing tasks until
+ * gs_worker_thread_shutdown_async() is called. This must be called before the
+ * final reference to the #GsWorkerThread is dropped.
+ *
+ * Since: 42
+ */
+
+#include "config.h"
+
+#include <glib.h>
+#include <glib-object.h>
+
+#include "gs-ioprio.h"
+#include "gs-worker-thread.h"
+
+typedef enum {
+ GS_WORKER_THREAD_STATE_RUNNING = 0,
+ GS_WORKER_THREAD_STATE_SHUTTING_DOWN = 1,
+ GS_WORKER_THREAD_STATE_SHUT_DOWN = 2,
+} GsWorkerThreadState;
+
+struct _GsWorkerThread
+{
+ GObject parent;
+
+ gchar *name; /* (nullable) (owned) */
+
+ GsWorkerThreadState worker_state; /* (atomic) */
+ GMainContext *worker_context; /* (owned); may be NULL before setup or after shutdown */
+ GThread *worker_thread; /* (atomic); may be NULL before setup or after shutdown */
+};
+
+typedef enum {
+ PROP_NAME = 1,
+} GsWorkerThreadProperty;
+
+static GParamSpec *props[PROP_NAME + 1] = { NULL, };
+
+G_DEFINE_TYPE (GsWorkerThread, gs_worker_thread, G_TYPE_OBJECT)
+
+static void
+gs_worker_thread_get_property (GObject *object,
+ guint prop_id,
+ GValue *value,
+ GParamSpec *pspec)
+{
+ GsWorkerThread *self = GS_WORKER_THREAD (object);
+
+ switch ((GsWorkerThreadProperty) prop_id) {
+ case PROP_NAME:
+ g_value_set_string (value, self->name);
+ break;
+ default:
+ G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+ break;
+ }
+}
+
+static void
+gs_worker_thread_set_property (GObject *object,
+ guint prop_id,
+ const GValue *value,
+ GParamSpec *pspec)
+{
+ GsWorkerThread *self = GS_WORKER_THREAD (object);
+
+ switch ((GsWorkerThreadProperty) prop_id) {
+ case PROP_NAME:
+ /* Construct only */
+ g_assert (self->name == NULL);
+ self->name = g_value_dup_string (value);
+ break;
+ default:
+ G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+ break;
+ }
+}
+
+static void
+gs_worker_thread_dispose (GObject *object)
+{
+ GsWorkerThread *self = GS_WORKER_THREAD (object);
+
+ /* Should have stopped by now. */
+ g_assert (self->worker_thread == NULL);
+
+ g_clear_pointer (&self->name, g_free);
+ g_clear_pointer (&self->worker_context, g_main_context_unref);
+
+ G_OBJECT_CLASS (gs_worker_thread_parent_class)->dispose (object);
+}
+
+static gpointer thread_cb (gpointer data);
+
+static void
+gs_worker_thread_constructed (GObject *object)
+{
+ GsWorkerThread *self = GS_WORKER_THREAD (object);
+
+ G_OBJECT_CLASS (gs_worker_thread_parent_class)->constructed (object);
+
+ /* Start up a worker thread and its #GMainContext. The worker will run
+ * and process events on @worker_context until @worker_state changes
+ * from %GS_WORKER_THREAD_STATE_RUNNING. */
+ self->worker_state = GS_WORKER_THREAD_STATE_RUNNING;
+ self->worker_context = g_main_context_new ();
+ self->worker_thread = g_thread_new (self->name, thread_cb, self);
+}
+
+static void
+gs_worker_thread_class_init (GsWorkerThreadClass *klass)
+{
+ GObjectClass *object_class = G_OBJECT_CLASS (klass);
+
+ object_class->constructed = gs_worker_thread_constructed;
+ object_class->get_property = gs_worker_thread_get_property;
+ object_class->set_property = gs_worker_thread_set_property;
+ object_class->dispose = gs_worker_thread_dispose;
+
+ /**
+ * GsWorkerThread:name: (not nullable):
+ *
+ * Name for the worker thread to use in debug output. This must be set.
+ *
+ * Since: 42
+ */
+ props[PROP_NAME] =
+ g_param_spec_string ("name",
+ "Name",
+ "Name for the worker thread to use in debug output.",
+ NULL,
+ G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY);
+
+ g_object_class_install_properties (object_class, G_N_ELEMENTS (props), props);
+}
+
+static gpointer
+thread_cb (gpointer data)
+{
+ GsWorkerThread *self = GS_WORKER_THREAD (data);
+ g_autoptr(GMainContextPusher) pusher = g_main_context_pusher_new (self->worker_context);
+
+ while (g_atomic_int_get (&self->worker_state) != GS_WORKER_THREAD_STATE_SHUT_DOWN)
+ g_main_context_iteration (self->worker_context, TRUE);
+
+ return NULL;
+}
+
+static void
+gs_worker_thread_init (GsWorkerThread *self)
+{
+}
+
+/**
+ * gs_worker_thread_new:
+ * @name: (not nullable): name for the worker thread
+ *
+ * Create and start a new #GsWorkerThread.
+ *
+ * @name will be used to set the thread name and in debug output.
+ *
+ * Returns: (transfer full): a new #GsWorkerThread
+ * Since: 42
+ */
+GsWorkerThread *
+gs_worker_thread_new (const gchar *name)
+{
+ g_return_val_if_fail (name != NULL, NULL);
+
+ return g_object_new (GS_TYPE_WORKER_THREAD,
+ "name", name,
+ NULL);
+}
+
+/* Essentially a wrapper around these elements to avoid the caller having to
+ * return `G_SOURCE_REMOVE` from their `work_func` every time. */
+typedef struct {
+ GTaskThreadFunc work_func;
+ GTask *task; /* (owned) */
+ gint priority;
+} WorkData;
+
+static void
+work_data_free (WorkData *data)
+{
+ g_clear_object (&data->task);
+ g_free (data);
+}
+
+G_DEFINE_AUTOPTR_CLEANUP_FUNC (WorkData, work_data_free)
+
+static gboolean
+work_run_cb (gpointer _data)
+{
+ WorkData *data = _data;
+ GTask *task = data->task;
+ gpointer source_object = g_task_get_source_object (task);
+ gpointer task_data = g_task_get_task_data (task);
+ GCancellable *cancellable = g_task_get_cancellable (task);
+
+ /* Set the I/O priority of the thread to match the priority of the
+ * task. */
+ gs_ioprio_set (data->priority);
+
+ data->work_func (task, source_object, task_data, cancellable);
+
+ return G_SOURCE_REMOVE;
+}
+
+/**
+ * gs_worker_thread_queue:
+ * @self: a #GsWorkerThread
+ * @priority: (default G_PRIORITY_DEFAULT): priority to queue the task at,
+ * typically #G_PRIORITY_DEFAULT
+ * @work_func: (not nullable): function to run the task
+ * @task: (transfer full) (not nullable): the #GTask containing context data to
+ * pass to @work_func
+ *
+ * Queue @task to be run in the worker thread at the given @priority.
+ *
+ * This function takes ownership of @task.
+ *
+ * @priority sets the order of the task in the queue, and also affects the I/O
+ * priority of the worker thread when the task is executed — high priorities
+ * result in a high I/O priority, low priorities result in an idle I/O priority,
+ * as per `ioprio_set()`.
+ *
+ * When the task is run, @work_func will be executed and passed @task and the
+ * source object, task data and cancellable set on @task.
+ *
+ * @work_func is responsible for calling `g_task_return_*()` on @task once the
+ * task is complete.
+ *
+ * If a task is cancelled using its #GCancellable after it’s queued to the
+ * #GsWorkerThread, @work_func will still be executed. @work_func is responsible
+ * for checking whether the #GCancellable has been cancelled.
+ *
+ * It is an error to call this function after gs_worker_thread_shutdown_async()
+ * has called.
+ *
+ * Since: 42
+ */
+void
+gs_worker_thread_queue (GsWorkerThread *self,
+ gint priority,
+ GTaskThreadFunc work_func,
+ GTask *task)
+{
+ g_autoptr(WorkData) data = NULL;
+
+ g_return_if_fail (GS_IS_WORKER_THREAD (self));
+ g_return_if_fail (work_func != NULL);
+ g_return_if_fail (G_IS_TASK (task));
+
+ g_assert (g_atomic_int_get (&self->worker_state) == GS_WORKER_THREAD_STATE_RUNNING ||
+ g_task_get_source_tag (task) == gs_worker_thread_shutdown_async);
+
+ data = g_new0 (WorkData, 1);
+ data->work_func = work_func;
+ data->task = g_steal_pointer (&task);
+ data->priority = priority;
+
+ g_main_context_invoke_full (self->worker_context, priority,
+ work_run_cb, g_steal_pointer (&data), (GDestroyNotify) work_data_free);
+}
+
+/**
+ * gs_worker_thread_is_in_worker_context:
+ * @self: a #GsWorkerThread
+ *
+ * Returns whether the calling thread is the worker thread.
+ *
+ * This is intended to be used as a precondition check to ensure that worker
+ * code is not accidentally run from the wrong thread.
+ *
+ * |[
+ * static void
+ * do_work (MyPlugin *self)
+ * {
+ * g_assert (gs_worker_thread_is_in_worker_context (self->worker_thread));
+ *
+ * // do some work
+ * }
+ * ]|
+ *
+ * Returns: %TRUE if running in the worker context, %FALSE otherwise
+ * Since: 42
+ */
+gboolean
+gs_worker_thread_is_in_worker_context (GsWorkerThread *self)
+{
+ return g_main_context_is_owner (self->worker_context);
+}
+
+static void shutdown_cb (GTask *task,
+ gpointer source_object,
+ gpointer task_data,
+ GCancellable *cancellable);
+
+/**
+ * gs_worker_thread_shutdown_async:
+ * @self: a #GsWorkerThread
+ * @cancellable: (nullable): a #GCancellable, or %NULL
+ * @callback: callback for once the asynchronous operation is complete
+ * @user_data: data to pass to @callback
+ *
+ * Shut down the worker thread.
+ *
+ * The thread will finish processing whatever task it’s currently processing
+ * (if any), will return %G_IO_ERROR_CANCELLED for all remaining queued
+ * tasks, and will then join the main process.
+ *
+ * This is a no-op if called subsequently.
+ *
+ * Since: 42
+ */
+void
+gs_worker_thread_shutdown_async (GsWorkerThread *self,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data)
+{
+ g_autoptr(GTask) task = NULL;
+
+ g_return_if_fail (GS_IS_WORKER_THREAD (self));
+ g_return_if_fail (cancellable == NULL || G_IS_CANCELLABLE (cancellable));
+
+ task = g_task_new (self, cancellable, callback, user_data);
+ g_task_set_source_tag (task, gs_worker_thread_shutdown_async);
+
+ /* Already called? */
+ if (g_atomic_int_get (&self->worker_state) != GS_WORKER_THREAD_STATE_RUNNING) {
+ g_task_return_boolean (task, TRUE);
+ return;
+ }
+
+ /* Signal the worker thread to stop processing tasks. */
+ g_atomic_int_set (&self->worker_state, GS_WORKER_THREAD_STATE_SHUTTING_DOWN);
+ gs_worker_thread_queue (self, G_MAXINT /* lowest priority */,
+ shutdown_cb, g_steal_pointer (&task));
+}
+
+static void
+shutdown_cb (GTask *task,
+ gpointer source_object,
+ gpointer task_data,
+ GCancellable *cancellable)
+{
+ GsWorkerThread *self = GS_WORKER_THREAD (source_object);
+ gboolean updated_state;
+
+ updated_state = g_atomic_int_compare_and_exchange (&self->worker_state,
+ GS_WORKER_THREAD_STATE_SHUTTING_DOWN,
+ GS_WORKER_THREAD_STATE_SHUT_DOWN);
+ g_assert (updated_state);
+
+ /* Tidy up. We can’t join the thread here as this function is executing
+ * within the thread and that would deadlock. */
+ g_clear_pointer (&self->worker_context, g_main_context_unref);
+
+ g_task_return_boolean (task, TRUE);
+}
+
+/**
+ * gs_worker_thread_shutdown_finish:
+ * @self: a #GsWorkerThread
+ * @result: a #GAsyncResult
+ * @error: return location for a #GError, or %NULL
+ *
+ * Finish an asynchronous shutdown operation started with
+ * gs_worker_thread_shutdown_async();
+ *
+ * Returns: %TRUE on success, %FALSE otherwise
+ * Since: 42
+ */
+gboolean
+gs_worker_thread_shutdown_finish (GsWorkerThread *self,
+ GAsyncResult *result,
+ GError **error)
+{
+ gboolean success;
+
+ g_return_val_if_fail (GS_IS_WORKER_THREAD (self), FALSE);
+ g_return_val_if_fail (g_async_result_is_tagged (result, gs_worker_thread_shutdown_async), FALSE);
+ g_return_val_if_fail (g_task_is_valid (result, self), FALSE);
+ g_return_val_if_fail (error == NULL || *error == NULL, FALSE);
+
+ success = g_task_propagate_boolean (G_TASK (result), error);
+
+ if (success)
+ g_thread_join (g_steal_pointer (&self->worker_thread));
+
+ return success;
+}
diff --git a/lib/gs-worker-thread.h b/lib/gs-worker-thread.h
new file mode 100644
index 0000000..36b28df
--- /dev/null
+++ b/lib/gs-worker-thread.h
@@ -0,0 +1,41 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2021 Endless OS Foundation LLC
+ *
+ * Author: Philip Withnall <pwithnall@endlessos.org>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#pragma once
+
+#include <glib.h>
+#include <glib-object.h>
+#include <gio/gio.h>
+
+G_BEGIN_DECLS
+
+#define GS_TYPE_WORKER_THREAD (gs_worker_thread_get_type ())
+
+G_DECLARE_FINAL_TYPE (GsWorkerThread, gs_worker_thread, GS, WORKER_THREAD, GObject)
+
+GsWorkerThread *gs_worker_thread_new (const gchar *name);
+
+void gs_worker_thread_queue (GsWorkerThread *self,
+ gint priority,
+ GTaskThreadFunc work_func,
+ GTask *task);
+
+gboolean gs_worker_thread_is_in_worker_context (GsWorkerThread *self);
+
+void gs_worker_thread_shutdown_async (GsWorkerThread *self,
+ GCancellable *cancellable,
+ GAsyncReadyCallback callback,
+ gpointer user_data);
+
+gboolean gs_worker_thread_shutdown_finish (GsWorkerThread *self,
+ GAsyncResult *result,
+ GError **error);
+
+G_END_DECLS
diff --git a/lib/meson.build b/lib/meson.build
new file mode 100644
index 0000000..d591bca
--- /dev/null
+++ b/lib/meson.build
@@ -0,0 +1,205 @@
+cargs = ['-DG_LOG_DOMAIN="Gs"']
+cargs += ['-DLOCALPLUGINDIR=""']
+
+libgnomesoftware_public_headers = [
+ 'gnome-software.h',
+ 'gs-app.h',
+ 'gs-app-collation.h',
+ 'gs-app-list.h',
+ 'gs-app-permissions.h',
+ 'gs-app-query.h',
+ 'gs-appstream.h',
+ 'gs-category.h',
+ 'gs-category-manager.h',
+ 'gs-desktop-data.h',
+ 'gs-download-utils.h',
+ 'gs-external-appstream-utils.h',
+ 'gs-icon.h',
+ 'gs-ioprio.h',
+ 'gs-key-colors.h',
+ 'gs-metered.h',
+ 'gs-odrs-provider.h',
+ 'gs-os-release.h',
+ 'gs-plugin.h',
+ 'gs-plugin-event.h',
+ 'gs-plugin-helpers.h',
+ 'gs-plugin-job.h',
+ 'gs-plugin-job-list-apps.h',
+ 'gs-plugin-job-list-categories.h',
+ 'gs-plugin-job-list-distro-upgrades.h',
+ 'gs-plugin-job-manage-repository.h',
+ 'gs-plugin-job-refine.h',
+ 'gs-plugin-job-refresh-metadata.h',
+ 'gs-plugin-loader.h',
+ 'gs-plugin-loader-sync.h',
+ 'gs-plugin-types.h',
+ 'gs-plugin-vfuncs.h',
+ 'gs-remote-icon.h',
+ 'gs-test.h',
+ 'gs-utils.h',
+ 'gs-worker-thread.h',
+]
+
+install_headers(libgnomesoftware_public_headers,
+ subdir : 'gnome-software'
+)
+
+librarydeps = [
+ appstream,
+ gio_unix,
+ glib,
+ gmodule,
+ gtk,
+ json_glib,
+ libm,
+ libsoup,
+ libsysprof_capture_dep,
+ libxmlb,
+]
+
+if get_option('mogwai')
+ librarydeps += mogwai_schedule_client
+endif
+
+if get_option('polkit')
+ librarydeps += polkit
+endif
+
+gs_build_ident_h = vcs_tag(
+ fallback: meson.project_version(),
+ input: 'gs-build-ident.h.in',
+ output: 'gs-build-ident.h',
+)
+
+libgnomesoftware_enums = gnome.mkenums_simple('gs-enums',
+ sources : libgnomesoftware_public_headers,
+ install_header : true,
+ install_dir : join_paths(get_option('includedir'), 'gnome-software'),
+)
+
+libgnomesoftware_include_directories = [
+ include_directories('..'),
+]
+
+libgnomesoftware = library(
+ 'gnomesoftware',
+ sources : [
+ 'gs-app.c',
+ 'gs-app-list.c',
+ 'gs-app-permissions.c',
+ 'gs-app-query.c',
+ 'gs-appstream.c',
+ 'gs-category.c',
+ 'gs-category-manager.c',
+ 'gs-debug.c',
+ 'gs-desktop-data.c',
+ 'gs-download-utils.c',
+ 'gs-external-appstream-utils.c',
+ 'gs-fedora-third-party.c',
+ 'gs-icon.c',
+ 'gs-ioprio.c',
+ 'gs-ioprio.h',
+ 'gs-key-colors.c',
+ 'gs-metered.c',
+ 'gs-odrs-provider.c',
+ 'gs-os-release.c',
+ 'gs-plugin.c',
+ 'gs-plugin-event.c',
+ 'gs-plugin-helpers.c',
+ 'gs-plugin-job.c',
+ 'gs-plugin-job-list-apps.c',
+ 'gs-plugin-job-list-categories.c',
+ 'gs-plugin-job-list-distro-upgrades.c',
+ 'gs-plugin-job-manage-repository.c',
+ 'gs-plugin-job-refine.c',
+ 'gs-plugin-job-refresh-metadata.c',
+ 'gs-plugin-loader.c',
+ 'gs-plugin-loader-sync.c',
+ 'gs-remote-icon.c',
+ 'gs-test.c',
+ 'gs-utils.c',
+ 'gs-worker-thread.c',
+ ] + libgnomesoftware_enums + [gs_build_ident_h],
+ soversion: gs_plugin_api_version,
+ include_directories : libgnomesoftware_include_directories,
+ dependencies : librarydeps,
+ c_args : cargs,
+ install: true,
+ install_dir: gs_private_libdir,
+)
+
+libgnomesoftware_dep = declare_dependency(link_with : libgnomesoftware,
+ sources : libgnomesoftware_enums,
+ include_directories : libgnomesoftware_include_directories,
+ dependencies: librarydeps,
+)
+
+pkg = import('pkgconfig')
+
+pkg.generate(
+ libgnomesoftware,
+ description : 'GNOME Software is a software center for GNOME',
+ filebase : 'gnome-software',
+ name : 'gnome-software',
+ subdirs : 'gnome-software',
+ variables : [
+ 'gs_private_libdir=${libdir}/gnome-software',
+ 'plugindir=${gs_private_libdir}/plugins-' + gs_plugin_api_version,
+ 'soupapiversion=' + libsoupapiversion,
+ ],
+ install_dir : join_paths(get_option('prefix'), get_option('libdir'), 'pkgconfig'), # or it defaults to gs_private_libdir, which is wrong
+)
+
+executable(
+ 'gnome-software-cmd',
+ sources : [
+ 'gs-cmd.c',
+ ],
+ include_directories : [
+ include_directories('..'),
+ ],
+ dependencies : [
+ appstream,
+ gio_unix,
+ glib,
+ gmodule,
+ gtk,
+ json_glib,
+ libgnomesoftware_dep,
+ libm,
+ libsoup,
+ ],
+ c_args : cargs,
+ install : true,
+ install_dir : get_option('libexecdir'),
+ install_rpath : gs_private_libdir,
+)
+
+if get_option('tests')
+ cargs += ['-DTESTDATADIR="' + join_paths(meson.current_source_dir(), '..', 'data') + '"']
+ e = executable(
+ 'gs-self-test',
+ compiled_schemas,
+ sources : [
+ 'gs-self-test.c'
+ ],
+ include_directories : [
+ include_directories('..'),
+ ],
+ dependencies : [
+ appstream,
+ gio_unix,
+ glib,
+ gmodule,
+ gtk,
+ json_glib,
+ libgnomesoftware_dep,
+ libm,
+ libsoup
+ ],
+ c_args : cargs
+ )
+ test('gs-self-test-lib', e, suite: ['lib'], env: test_env, timeout : 120)
+endif
+
+subdir('tools')
diff --git a/lib/tools/meson.build b/lib/tools/meson.build
new file mode 100644
index 0000000..6844775
--- /dev/null
+++ b/lib/tools/meson.build
@@ -0,0 +1,24 @@
+# Test program to profile performance of the key-colors functions
+executable(
+ 'profile-key-colors',
+ sources : [
+ 'profile-key-colors.c',
+ '../gs-key-colors.c',
+ '../gs-key-colors.h',
+ ],
+ include_directories : [
+ include_directories('..'),
+ include_directories('../..'),
+ ],
+ dependencies : [
+ glib,
+ gtk,
+ gdk_pixbuf,
+ libm,
+ ],
+ c_args : [
+ '-Wall',
+ '-Wextra',
+ ],
+ install: false,
+)
diff --git a/lib/tools/profile-key-colors.c b/lib/tools/profile-key-colors.c
new file mode 100644
index 0000000..44df6a4
--- /dev/null
+++ b/lib/tools/profile-key-colors.c
@@ -0,0 +1,175 @@
+/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
+ * vi:set noexpandtab tabstop=8 shiftwidth=8:
+ *
+ * Copyright (C) 2021 Endless OS Foundation LLC
+ *
+ * Authors:
+ * - Philip Withnall <pwithnall@endlessos.org>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#include <glib.h>
+#include <gdk-pixbuf/gdk-pixbuf.h>
+#include <gdk/gdk.h>
+#include <locale.h>
+#include <math.h>
+
+#include "gs-key-colors.h"
+
+/* Test program which can be used to check the output and performance of the
+ * gs_calculate_key_colors() function. It is linked against libgnomesoftware, so
+ * will use the function implementation from there. It outputs a HTML page which
+ * lists each icon from the flathub appstream data in your home directory, along
+ * with its extracted key colors and how long extraction took. */
+
+static void
+print_colours (GString *html_output,
+ GArray *colours)
+{
+ g_string_append_printf (html_output, "<table class='colour-swatch'><tr>");
+ for (guint i = 0; i < colours->len; i++) {
+ GdkRGBA *rgba = &g_array_index (colours, GdkRGBA, i);
+
+ g_string_append_printf (html_output,
+ "<td style='background-color: rgb(%u, %u, %u)'></td>",
+ (guint) (rgba->red * 255),
+ (guint) (rgba->green * 255),
+ (guint) (rgba->blue * 255));
+
+ if (i % 3 == 2)
+ g_string_append (html_output, "</tr><tr>");
+ }
+ g_string_append_printf (html_output, "</tr></table>");
+}
+
+static void
+print_summary_statistics (GString *html_output,
+ GArray *durations /* (element-type gint64) */)
+{
+ gint64 sum = 0, min = G_MAXINT64, max = G_MININT64;
+ guint n_measurements = durations->len;
+ gint64 mean, stddev;
+ gint64 sum_of_square_deviations = 0;
+
+ for (guint i = 0; i < durations->len; i++) {
+ gint64 duration = g_array_index (durations, gint64, i);
+ sum += duration;
+ min = MIN (min, duration);
+ max = MAX (max, duration);
+ }
+
+ mean = sum / n_measurements;
+
+ for (guint i = 0; i < durations->len; i++) {
+ gint64 duration = g_array_index (durations, gint64, i);
+ gint64 diff = duration - mean;
+ sum_of_square_deviations += diff * diff;
+ }
+
+ stddev = sqrt (sum_of_square_deviations / n_measurements);
+
+ g_string_append_printf (html_output,
+ "[%" G_GINT64_FORMAT ", %" G_GINT64_FORMAT "]μs, mean %" G_GINT64_FORMAT "±%" G_GINT64_FORMAT "μs, n = %u",
+ min, max, mean, stddev, n_measurements);
+}
+
+int
+main (void)
+{
+ const gchar *icons_subdir = ".local/share/flatpak/appstream/flathub/x86_64/active/icons/128x128";
+ g_autofree gchar *icons_dir = g_build_filename (g_get_home_dir (), icons_subdir, NULL);
+ g_autoptr(GDir) dir = NULL;
+ const gchar *entry;
+ g_autoptr(GPtrArray) filenames = g_ptr_array_new_with_free_func (g_free);
+ g_autoptr(GPtrArray) pixbufs = g_ptr_array_new_with_free_func (g_object_unref);
+ g_autoptr(GString) html_output = g_string_new ("");
+ g_autoptr(GArray) durations = g_array_new (FALSE, FALSE, sizeof (gint64));
+
+ setlocale (LC_ALL, "");
+
+ /* Load pixbufs from the icons directory. */
+ dir = g_dir_open (icons_dir, 0, NULL);
+ if (dir == NULL)
+ return 1;
+
+ while ((entry = g_dir_read_name (dir)) != NULL) {
+ g_autofree gchar *filename = g_build_filename (icons_dir, entry, NULL);
+ g_autoptr(GdkPixbuf) pixbuf = gdk_pixbuf_new_from_file (filename, NULL);
+
+ if (pixbuf == NULL)
+ continue;
+
+ g_ptr_array_add (filenames, g_steal_pointer (&filename));
+ g_ptr_array_add (pixbufs, g_steal_pointer (&pixbuf));
+ }
+
+ if (!pixbufs->len)
+ return 2;
+
+ /* Set up an output page */
+ g_string_append (html_output,
+ "<!DOCTYPE html>\n"
+ "<html>\n"
+ " <head>\n"
+ " <meta charset='UTF-8'>\n"
+ " <style>\n"
+ " #main-table, #main-table th, #main-table td { border: 1px solid black; border-collapse: collapse }\n"
+ " #main-table th, #main-table td { padding: 4px }\n"
+ " td.number { text-align: right }\n"
+ " table.colour-swatch td { width: 30px; height: 30px }\n"
+ " .faster { background-color: rgb(190, 236, 57) }\n"
+ " .slower { background-color: red }\n"
+ " </style>\n"
+ " </head>\n"
+ " <body>\n"
+ " <table id='main-table'>\n"
+ " <thead>\n"
+ " <tr>\n"
+ " <td>Filename</td>\n"
+ " <td>Icon</td>\n"
+ " <td>Code duration (μs)</td>\n"
+ " <td>Code colours</td>\n"
+ " </tr>\n"
+ " </thead>\n");
+
+ /* For each pixbuf, run both algorithms. */
+ for (guint i = 0; i < pixbufs->len; i++) {
+ GdkPixbuf *pixbuf = pixbufs->pdata[i];
+ const gchar *filename = filenames->pdata[i];
+ g_autofree gchar *basename = g_path_get_basename (filename);
+ g_autoptr(GArray) colours = NULL;
+ gint64 start_time, duration;
+
+ g_message ("Processing %u of %u, %s", i + 1, pixbufs->len, filename);
+
+ start_time = g_get_real_time ();
+ colours = gs_calculate_key_colors (pixbuf);
+ duration = g_get_real_time () - start_time;
+
+ g_string_append_printf (html_output,
+ "<tr>\n"
+ "<th>%s</th>\n"
+ "<td><img src='file:%s'></td>\n"
+ "<td class='number'>%" G_GINT64_FORMAT "</td>\n"
+ "<td>",
+ basename, filename, duration);
+ print_colours (html_output, colours);
+ g_string_append (html_output,
+ "</td>\n"
+ "</tr>\n");
+
+ g_array_append_val (durations, duration);
+ }
+
+ /* Summary statistics for the timings. */
+ g_string_append (html_output, "<tfoot><tr><td></td><td></td><td>");
+ print_summary_statistics (html_output, durations);
+ g_string_append (html_output, "</td><td></td></tr></tfoot>");
+
+ g_string_append (html_output, "</table></body></html>");
+
+ g_print ("%s\n", html_output->str);
+
+ return 0;
+}