summaryrefslogtreecommitdiffstats
path: root/src/gs-details-page.c
diff options
context:
space:
mode:
Diffstat (limited to 'src/gs-details-page.c')
-rw-r--r--src/gs-details-page.c2896
1 files changed, 2896 insertions, 0 deletions
diff --git a/src/gs-details-page.c b/src/gs-details-page.c
new file mode 100644
index 0000000..0232925
--- /dev/null
+++ b/src/gs-details-page.c
@@ -0,0 +1,2896 @@
+/* -*- 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) 2013 Matthias Clasen <mclasen@redhat.com>
+ * Copyright (C) 2014-2019 Kalev Lember <klember@redhat.com>
+ *
+ * SPDX-License-Identifier: GPL-2.0+
+ */
+
+#include "config.h"
+
+#include <locale.h>
+#include <string.h>
+#include <glib/gi18n.h>
+
+#include "lib/gs-appstream.h"
+
+#include "gs-common.h"
+#include "gs-utils.h"
+
+#include "gs-details-page.h"
+#include "gs-app-addon-row.h"
+#include "gs-app-context-bar.h"
+#include "gs-app-reviews-dialog.h"
+#include "gs-app-translation-dialog.h"
+#include "gs-app-version-history-row.h"
+#include "gs-app-version-history-dialog.h"
+#include "gs-description-box.h"
+#include "gs-license-tile.h"
+#include "gs-origin-popover-row.h"
+#include "gs-progress-button.h"
+#include "gs-screenshot-carousel.h"
+#include "gs-star-widget.h"
+#include "gs-summary-tile.h"
+#include "gs-review-histogram.h"
+#include "gs-review-dialog.h"
+#include "gs-review-row.h"
+
+/* the number of reviews to show before clicking the 'More Reviews' button */
+#define SHOW_NR_REVIEWS_INITIAL 4
+
+/* How many other developer apps can be shown; should be divisible by 3 and 2,
+ to catch full width and smaller width without bottom gap */
+#define N_DEVELOPER_APPS 18
+
+#define GS_DETAILS_PAGE_REFINE_FLAGS GS_PLUGIN_REFINE_FLAGS_REQUIRE_ADDONS | \
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_CATEGORIES | \
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_CONTENT_RATING | \
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_DESCRIPTION | \
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_DEVELOPER_NAME | \
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_HISTORY | \
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_ICON | \
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_KUDOS | \
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_LICENSE | \
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_ORIGIN_HOSTNAME | \
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_PERMISSIONS | \
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_PROJECT_GROUP | \
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_PROVENANCE | \
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_RELATED | \
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_RUNTIME | \
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_SCREENSHOTS | \
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_SETUP_ACTION | \
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_SIZE | \
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_SIZE_DATA | \
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_URL | \
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_VERSION
+
+static void gs_details_page_refresh_addons (GsDetailsPage *self);
+static void gs_details_page_refresh_all (GsDetailsPage *self);
+static void gs_details_page_app_refine_cb (GObject *source, GAsyncResult *res, gpointer user_data);
+
+typedef enum {
+ GS_DETAILS_PAGE_STATE_LOADING,
+ GS_DETAILS_PAGE_STATE_READY,
+ GS_DETAILS_PAGE_STATE_FAILED
+} GsDetailsPageState;
+
+struct _GsDetailsPage
+{
+ GsPage parent_instance;
+
+ GsPluginLoader *plugin_loader;
+ GCancellable *cancellable;
+ GCancellable *app_cancellable;
+ GsApp *app;
+ GsApp *app_local_file;
+ GsShell *shell;
+ gboolean show_all_reviews;
+ GSettings *settings;
+ GsOdrsProvider *odrs_provider; /* (nullable) (owned), NULL if reviews are disabled */
+ GAppInfoMonitor *app_info_monitor; /* (owned) */
+ GHashTable *packaging_format_preference; /* gchar * ~> gint */
+ GtkWidget *app_reviews_dialog;
+ GtkCssProvider *origin_css_provider; /* (nullable) (owned) */
+ gboolean origin_by_packaging_format; /* when TRUE, change the 'app' to the most preferred
+ packaging format when the alternatives are found */
+ gboolean is_narrow;
+
+ GtkWidget *application_details_icon;
+ GtkWidget *application_details_summary;
+ GtkWidget *application_details_title;
+ GtkWidget *box_addons;
+ GtkWidget *box_details;
+ GtkWidget *box_details_description;
+ GtkWidget *box_details_header;
+ GtkWidget *box_details_header_not_icon;
+ GtkWidget *label_webapp_warning;
+ GtkWidget *star;
+ GtkWidget *label_review_count;
+ GtkWidget *screenshot_carousel;
+ GtkWidget *button_details_launch;
+ GtkStack *links_stack;
+ AdwActionRow *project_website_row;
+ AdwActionRow *donate_row;
+ AdwActionRow *translate_row;
+ AdwActionRow *report_an_issue_row;
+ AdwActionRow *help_row;
+ GtkWidget *button_install;
+ GtkWidget *button_update;
+ GtkWidget *button_remove;
+ GsProgressButton *button_cancel;
+ GtkWidget *infobar_details_app_norepo;
+ GtkWidget *infobar_details_app_repo;
+ GtkWidget *infobar_details_package_baseos;
+ GtkWidget *infobar_details_repo;
+ GtkWidget *label_progress_percentage;
+ GtkWidget *label_progress_status;
+ GtkWidget *label_addons_uninstalled_app;
+ GsAppContextBar *context_bar;
+ GtkLabel *developer_name_label;
+ GtkImage *developer_verified_image;
+ GtkWidget *label_failed;
+ GtkWidget *list_box_addons;
+ GtkWidget *list_box_featured_review;
+ GtkWidget *list_box_reviews_summary;
+ GtkWidget *list_box_version_history;
+ GtkWidget *row_latest_version;
+ GtkWidget *version_history_button;
+ GtkWidget *box_reviews;
+ GtkWidget *box_reviews_internal;
+ GtkWidget *histogram;
+ GtkWidget *histogram_row;
+ GtkWidget *button_review;
+ GtkWidget *scrolledwindow_details;
+ GtkWidget *spinner_details;
+ GtkWidget *stack_details;
+ GtkWidget *box_with_source;
+ GtkWidget *origin_popover;
+ GtkWidget *origin_popover_list_box;
+ GtkWidget *origin_box;
+ GtkWidget *origin_packaging_image;
+ GtkWidget *origin_packaging_label;
+ GtkWidget *box_license;
+ GsLicenseTile *license_tile;
+ GtkInfoBar *translation_infobar;
+ GtkButton *translation_infobar_button;
+ GtkWidget *developer_apps_heading;
+ GtkWidget *box_developer_apps;
+ gchar *last_developer_name;
+};
+
+G_DEFINE_TYPE (GsDetailsPage, gs_details_page, GS_TYPE_PAGE)
+
+enum {
+ SIGNAL_METAINFO_LOADED,
+ SIGNAL_APP_CLICKED,
+ SIGNAL_LAST
+};
+
+typedef enum {
+ PROP_ODRS_PROVIDER = 1,
+ PROP_IS_NARROW,
+ /* Override properties: */
+ PROP_TITLE,
+} GsDetailsPageProperty;
+
+static GParamSpec *obj_props[PROP_IS_NARROW + 1] = { NULL, };
+static guint signals[SIGNAL_LAST] = { 0 };
+
+static void
+gs_details_page_cancel_cb (GCancellable *cancellable,
+ GsDetailsPage *self)
+{
+ if (self->app_reviews_dialog) {
+ gtk_window_destroy (GTK_WINDOW (self->app_reviews_dialog));
+ g_clear_object (&self->app_reviews_dialog);
+ }
+}
+
+static GsDetailsPageState
+gs_details_page_get_state (GsDetailsPage *self)
+{
+ const gchar *visible_child_name = gtk_stack_get_visible_child_name (GTK_STACK (self->stack_details));
+
+ if (g_str_equal (visible_child_name, "spinner"))
+ return GS_DETAILS_PAGE_STATE_LOADING;
+ else if (g_str_equal (visible_child_name, "ready"))
+ return GS_DETAILS_PAGE_STATE_READY;
+ else if (g_str_equal (visible_child_name, "failed"))
+ return GS_DETAILS_PAGE_STATE_FAILED;
+ else
+ g_assert_not_reached ();
+}
+
+static void
+gs_details_page_set_state (GsDetailsPage *self,
+ GsDetailsPageState state)
+{
+ if (state == gs_details_page_get_state (self))
+ return;
+
+ /* spinner */
+ switch (state) {
+ case GS_DETAILS_PAGE_STATE_LOADING:
+ gtk_spinner_start (GTK_SPINNER (self->spinner_details));
+ break;
+ case GS_DETAILS_PAGE_STATE_READY:
+ case GS_DETAILS_PAGE_STATE_FAILED:
+ gtk_spinner_stop (GTK_SPINNER (self->spinner_details));
+ break;
+ default:
+ g_assert_not_reached ();
+ }
+
+ /* stack */
+ switch (state) {
+ case GS_DETAILS_PAGE_STATE_LOADING:
+ gtk_stack_set_visible_child_name (GTK_STACK (self->stack_details), "spinner");
+ break;
+ case GS_DETAILS_PAGE_STATE_READY:
+ gtk_stack_set_visible_child_name (GTK_STACK (self->stack_details), "ready");
+ break;
+ case GS_DETAILS_PAGE_STATE_FAILED:
+ gtk_stack_set_visible_child_name (GTK_STACK (self->stack_details), "failed");
+ break;
+ default:
+ g_assert_not_reached ();
+ }
+
+ /* the page title will have changed */
+ g_object_notify (G_OBJECT (self), "title");
+}
+
+static gboolean
+app_has_pending_action (GsApp *app)
+{
+ /* sanitize the pending state change by verifying we're in one of the
+ * expected states */
+ if (gs_app_get_state (app) != GS_APP_STATE_AVAILABLE &&
+ gs_app_get_state (app) != GS_APP_STATE_UPDATABLE_LIVE &&
+ gs_app_get_state (app) != GS_APP_STATE_UPDATABLE &&
+ gs_app_get_state (app) != GS_APP_STATE_QUEUED_FOR_INSTALL)
+ return FALSE;
+
+ return (gs_app_get_pending_action (app) != GS_PLUGIN_ACTION_UNKNOWN) ||
+ (gs_app_get_state (app) == GS_APP_STATE_QUEUED_FOR_INSTALL);
+}
+
+static void
+gs_details_page_update_origin_button (GsDetailsPage *self,
+ gboolean sensitive)
+{
+ const gchar *packaging_icon;
+ const gchar *packaging_base_css_color;
+ g_autofree gchar *css = NULL;
+ g_autofree gchar *origin_ui = NULL;
+
+ if (self->app == NULL ||
+ gs_shell_get_mode (self->shell) != GS_SHELL_MODE_DETAILS) {
+ gtk_widget_hide (self->origin_box);
+ return;
+ }
+
+ origin_ui = gs_app_dup_origin_ui (self->app, FALSE);
+ gtk_label_set_text (GTK_LABEL (self->origin_packaging_label), origin_ui != NULL ? origin_ui : "");
+
+ gtk_widget_set_sensitive (self->origin_box, sensitive);
+ gtk_widget_show (self->origin_box);
+
+ packaging_icon = gs_app_get_metadata_item (self->app, "GnomeSoftware::PackagingIcon");
+ if (packaging_icon == NULL)
+ packaging_icon = "package-x-generic-symbolic";
+
+ packaging_base_css_color = gs_app_get_metadata_item (self->app, "GnomeSoftware::PackagingBaseCssColor");
+
+ gtk_image_set_from_icon_name (GTK_IMAGE (self->origin_packaging_image), packaging_icon);
+
+ if (packaging_base_css_color != NULL)
+ css = g_strdup_printf ("color: @%s;\n", packaging_base_css_color);
+
+ gs_utils_widget_set_css (self->origin_packaging_image, &self->origin_css_provider, "packaging-color", css);
+}
+
+static void
+gs_details_page_switch_to (GsPage *page)
+{
+ GsDetailsPage *self = GS_DETAILS_PAGE (page);
+ GtkAdjustment *adj;
+
+ if (gs_shell_get_mode (self->shell) != GS_SHELL_MODE_DETAILS) {
+ g_warning ("Called switch_to(details) when in mode %s",
+ gs_shell_get_mode_string (self->shell));
+ return;
+ }
+
+ /* hide the alternates for now until the query is complete */
+ gtk_widget_hide (self->origin_box);
+
+ /* not set, perhaps file-to-app */
+ if (self->app == NULL)
+ return;
+
+ adj = gtk_scrolled_window_get_vadjustment (GTK_SCROLLED_WINDOW (self->scrolledwindow_details));
+ gtk_adjustment_set_value (adj, gtk_adjustment_get_lower (adj));
+
+ gs_grab_focus_when_mapped (self->scrolledwindow_details);
+}
+
+static void
+gs_details_page_refresh_progress (GsDetailsPage *self)
+{
+ guint percentage;
+ GsAppState state;
+
+ /* cancel button */
+ state = gs_app_get_state (self->app);
+ switch (state) {
+ case GS_APP_STATE_INSTALLING:
+ case GS_APP_STATE_REMOVING:
+ gtk_widget_set_visible (GTK_WIDGET (self->button_cancel), TRUE);
+ /* If the app is installing, the user can only cancel it if
+ * 1) They haven't already, and
+ * 2) the plugin hasn't said that they can't, for example if a
+ * package manager has already gone 'too far'
+ */
+ gtk_widget_set_sensitive (GTK_WIDGET (self->button_cancel),
+ !g_cancellable_is_cancelled (self->app_cancellable) &&
+ gs_app_get_allow_cancel (self->app));
+ break;
+ default:
+ gtk_widget_set_visible (GTK_WIDGET (self->button_cancel), FALSE);
+ break;
+ }
+ if (app_has_pending_action (self->app)) {
+ gtk_widget_set_visible (GTK_WIDGET (self->button_cancel), TRUE);
+ gtk_widget_set_sensitive (GTK_WIDGET (self->button_cancel),
+ !g_cancellable_is_cancelled (self->app_cancellable) &&
+ gs_app_get_allow_cancel (self->app));
+ }
+
+ /* progress status label */
+ switch (state) {
+ case GS_APP_STATE_REMOVING:
+ gtk_widget_set_visible (self->label_progress_status, TRUE);
+ gtk_label_set_label (GTK_LABEL (self->label_progress_status),
+ _("Removing…"));
+ break;
+ case GS_APP_STATE_INSTALLING:
+ gtk_widget_set_visible (self->label_progress_status, TRUE);
+ gtk_label_set_label (GTK_LABEL (self->label_progress_status),
+ _("Installing"));
+ break;
+ case GS_APP_STATE_PENDING_INSTALL:
+ gtk_widget_set_visible (self->label_progress_status, TRUE);
+ if (gs_app_has_quirk (self->app, GS_APP_QUIRK_NEEDS_REBOOT))
+ gtk_label_set_label (GTK_LABEL (self->label_progress_status), _("Requires restart to finish install"));
+ else
+ gtk_label_set_label (GTK_LABEL (self->label_progress_status), _("Pending install"));
+ break;
+ case GS_APP_STATE_PENDING_REMOVE:
+ gtk_widget_set_visible (self->label_progress_status, TRUE);
+ if (gs_app_has_quirk (self->app, GS_APP_QUIRK_NEEDS_REBOOT))
+ gtk_label_set_label (GTK_LABEL (self->label_progress_status), _("Requires restart to finish remove"));
+ else
+ gtk_label_set_label (GTK_LABEL (self->label_progress_status), _("Pending remove"));
+ break;
+
+ default:
+ gtk_widget_set_visible (self->label_progress_status, FALSE);
+ break;
+ }
+ if (app_has_pending_action (self->app)) {
+ GsPluginAction action = gs_app_get_pending_action (self->app);
+ gtk_widget_set_visible (self->label_progress_status, TRUE);
+ switch (action) {
+ case GS_PLUGIN_ACTION_INSTALL:
+ gtk_label_set_label (GTK_LABEL (self->label_progress_status),
+ /* TRANSLATORS: This is a label on top of the app's progress
+ * bar to inform the user that the app should be installed soon */
+ _("Pending installation…"));
+ break;
+ case GS_PLUGIN_ACTION_UPDATE:
+ case GS_PLUGIN_ACTION_UPGRADE_DOWNLOAD:
+ gtk_label_set_label (GTK_LABEL (self->label_progress_status),
+ /* TRANSLATORS: This is a label on top of the app's progress
+ * bar to inform the user that the app should be updated soon */
+ _("Pending update…"));
+ break;
+ default:
+ gtk_widget_set_visible (self->label_progress_status, FALSE);
+ break;
+ }
+ }
+
+ /* percentage bar */
+ switch (state) {
+ case GS_APP_STATE_INSTALLING:
+ case GS_APP_STATE_REMOVING:
+ percentage = gs_app_get_progress (self->app);
+ if (percentage == GS_APP_PROGRESS_UNKNOWN) {
+ if (state == GS_APP_STATE_INSTALLING) {
+ /* Translators: This string is shown when preparing to download and install an app. */
+ gtk_label_set_label (GTK_LABEL (self->label_progress_status), _("Preparing…"));
+ } else {
+ /* Translators: This string is shown when uninstalling an app. */
+ gtk_label_set_label (GTK_LABEL (self->label_progress_status), _("Uninstalling…"));
+ }
+
+ gtk_widget_set_visible (self->label_progress_status, TRUE);
+ gtk_widget_set_visible (self->label_progress_percentage, FALSE);
+ gs_progress_button_set_progress (self->button_cancel, percentage);
+ gs_progress_button_set_show_progress (self->button_cancel, TRUE);
+ break;
+ } else if (percentage <= 100) {
+ g_autofree gchar *str = g_strdup_printf ("%u%%", percentage);
+ gtk_label_set_label (GTK_LABEL (self->label_progress_percentage), str);
+ gtk_widget_set_visible (self->label_progress_percentage, TRUE);
+ gs_progress_button_set_progress (self->button_cancel, percentage);
+ gs_progress_button_set_show_progress (self->button_cancel, TRUE);
+ break;
+ }
+ /* FALLTHROUGH */
+ default:
+ gtk_widget_set_visible (self->label_progress_percentage, FALSE);
+ gs_progress_button_set_show_progress (self->button_cancel, FALSE);
+ gs_progress_button_set_progress (self->button_cancel, 0);
+ break;
+ }
+ if (app_has_pending_action (self->app)) {
+ gs_progress_button_set_progress (self->button_cancel, 0);
+ gs_progress_button_set_show_progress (self->button_cancel, TRUE);
+ }
+}
+
+static gboolean
+gs_details_page_refresh_progress_idle (gpointer user_data)
+{
+ GsDetailsPage *self = GS_DETAILS_PAGE (user_data);
+ gs_details_page_refresh_progress (self);
+ g_object_unref (self);
+ return G_SOURCE_REMOVE;
+}
+
+static void
+gs_details_page_progress_changed_cb (GsApp *app,
+ GParamSpec *pspec,
+ GsDetailsPage *self)
+{
+ g_idle_add (gs_details_page_refresh_progress_idle, g_object_ref (self));
+}
+
+static gboolean
+gs_details_page_allow_cancel_changed_idle (gpointer user_data)
+{
+ GsDetailsPage *self = GS_DETAILS_PAGE (user_data);
+ gtk_widget_set_sensitive (GTK_WIDGET (self->button_cancel),
+ gs_app_get_allow_cancel (self->app));
+ g_object_unref (self);
+ return G_SOURCE_REMOVE;
+}
+
+static void
+gs_details_page_allow_cancel_changed_cb (GsApp *app,
+ GParamSpec *pspec,
+ GsDetailsPage *self)
+{
+ g_idle_add (gs_details_page_allow_cancel_changed_idle,
+ g_object_ref (self));
+}
+
+static gboolean
+gs_details_page_refresh_idle (gpointer user_data)
+{
+ GsDetailsPage *self = GS_DETAILS_PAGE (user_data);
+
+ if (gs_shell_get_mode (self->shell) == GS_SHELL_MODE_DETAILS) {
+ /* update widgets */
+ gs_details_page_refresh_all (self);
+ }
+
+ g_object_unref (self);
+ return G_SOURCE_REMOVE;
+}
+
+static void
+gs_details_page_notify_state_changed_cb (GsApp *app,
+ GParamSpec *pspec,
+ GsDetailsPage *self)
+{
+ g_idle_add (gs_details_page_refresh_idle, g_object_ref (self));
+}
+
+static void
+gs_details_page_link_row_activated_cb (AdwActionRow *row, GsDetailsPage *self)
+{
+ gs_shell_show_uri (self->shell, adw_action_row_get_subtitle (row));
+}
+
+static void
+gs_details_page_license_tile_get_involved_activated_cb (GsLicenseTile *license_tile,
+ GsDetailsPage *self)
+{
+ const gchar *uri = NULL;
+
+ if (gs_app_get_license_is_free (self->app)) {
+#if AS_CHECK_VERSION(0, 15, 3)
+ uri = gs_app_get_url (self->app, AS_URL_KIND_CONTRIBUTE);
+#endif
+ if (uri == NULL)
+ uri = gs_app_get_url (self->app, AS_URL_KIND_HOMEPAGE);
+ } else {
+ /* Page to explain the differences between FOSS and proprietary
+ * software. This is a page on the gnome-software wiki for now,
+ * so that we can update the content independently of the release
+ * cycle. Likely, we will link to a more authoritative source
+ * to explain the differences.
+ * Ultimately, we could ship a user manual page to explain the
+ * differences (so that it’s available offline), but that’s too
+ * much work for right now. */
+ uri = "https://gitlab.gnome.org/GNOME/gnome-software/-/wikis/Software-licensing";
+ }
+
+ gs_shell_show_uri (self->shell, uri);
+}
+
+static void
+gs_details_page_translation_infobar_response_cb (GtkInfoBar *infobar,
+ int response,
+ gpointer user_data)
+{
+ GsDetailsPage *self = GS_DETAILS_PAGE (user_data);
+ GtkWindow *window;
+
+ window = GTK_WINDOW (gs_app_translation_dialog_new (self->app));
+ gs_shell_modal_dialog_present (self->shell, window);
+}
+
+static void
+gs_details_page_set_description (GsDetailsPage *self, const gchar *tmp)
+{
+ gs_description_box_set_text (GS_DESCRIPTION_BOX (self->box_details_description), tmp);
+ gs_description_box_set_collapsed (GS_DESCRIPTION_BOX (self->box_details_description), TRUE);
+ gtk_widget_set_visible (self->label_webapp_warning, gs_app_get_kind (self->app) == AS_COMPONENT_KIND_WEB_APP);
+}
+
+static gboolean
+app_origin_equal (GsApp *a,
+ GsApp *b)
+{
+ g_autofree gchar *a_origin_ui = NULL, *b_origin_ui = NULL;
+ GFile *a_local_file, *b_local_file;
+
+ if (a == b)
+ return TRUE;
+
+ a_origin_ui = gs_app_dup_origin_ui (a, TRUE);
+ b_origin_ui = gs_app_dup_origin_ui (b, TRUE);
+
+ a_local_file = gs_app_get_local_file (a);
+ b_local_file = gs_app_get_local_file (b);
+
+ /* Compare all the fields used in GsOriginPopoverRow. */
+ if (g_strcmp0 (a_origin_ui, b_origin_ui) != 0)
+ return FALSE;
+
+ if (!((a_local_file == NULL && b_local_file == NULL) ||
+ (a_local_file != NULL && b_local_file != NULL &&
+ g_file_equal (a_local_file, b_local_file))))
+ return FALSE;
+
+ if (g_strcmp0 (gs_app_get_origin_hostname (a),
+ gs_app_get_origin_hostname (b)) != 0)
+ return FALSE;
+
+ if (gs_app_get_bundle_kind (a) != gs_app_get_bundle_kind (b))
+ return FALSE;
+
+ if (gs_app_get_scope (a) != gs_app_get_scope (b))
+ return FALSE;
+
+ if (g_strcmp0 (gs_app_get_branch (a), gs_app_get_branch (b)) != 0)
+ return FALSE;
+
+ if (g_strcmp0 (gs_app_get_version (a), gs_app_get_version (b)) != 0)
+ return FALSE;
+
+ return TRUE;
+}
+
+static gint
+sort_by_packaging_format_preference (GsApp *app1,
+ GsApp *app2,
+ gpointer user_data)
+{
+ GHashTable *preference = user_data;
+ const gchar *packaging_format_raw1 = gs_app_get_packaging_format_raw (app1);
+ const gchar *packaging_format_raw2 = gs_app_get_packaging_format_raw (app2);
+ gint index1, index2;
+
+ if (g_strcmp0 (packaging_format_raw1, packaging_format_raw2) == 0)
+ return 0;
+
+ if (packaging_format_raw1 == NULL || packaging_format_raw2 == NULL)
+ return packaging_format_raw1 == NULL ? -1 : 1;
+
+ index1 = GPOINTER_TO_INT (g_hash_table_lookup (preference, packaging_format_raw1));
+ index2 = GPOINTER_TO_INT (g_hash_table_lookup (preference, packaging_format_raw2));
+
+ if (index1 == index2)
+ return 0;
+
+ /* Index 0 means unspecified packaging format in the preference array,
+ thus move these at the end. */
+ if (index1 == 0 || index2 == 0)
+ return index1 == 0 ? 1 : -1;
+
+ return index1 - index2;
+}
+
+static void _set_app (GsDetailsPage *self, GsApp *app);
+
+static void
+gs_details_page_get_alternates_cb (GObject *source_object,
+ GAsyncResult *res,
+ gpointer user_data)
+{
+ GsDetailsPage *self = GS_DETAILS_PAGE (user_data);
+ GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (source_object);
+ g_autoptr(GError) error = NULL;
+ g_autoptr(GsAppList) list = NULL;
+ gboolean instance_changed = FALSE;
+ gboolean origin_by_packaging_format = self->origin_by_packaging_format;
+ GtkWidget *first_row = NULL;
+ GtkWidget *select_row = NULL;
+ GtkWidget *origin_row_by_packaging_format = NULL;
+ gint origin_row_by_packaging_format_index = 0;
+ guint n_rows = 0;
+
+ self->origin_by_packaging_format = FALSE;
+ gs_widget_remove_all (self->origin_popover_list_box, (GsRemoveFunc) gtk_list_box_remove);
+
+ /* Did we switch away from the page in the meantime? */
+ if (!gs_page_is_active (GS_PAGE (self))) {
+ gtk_widget_hide (self->origin_box);
+ return;
+ }
+
+ list = gs_plugin_loader_job_process_finish (plugin_loader,
+ res,
+ &error);
+ if (list == NULL) {
+ if (!g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_CANCELLED) &&
+ !g_error_matches (error, G_IO_ERROR, G_IO_ERROR_CANCELLED))
+ g_warning ("failed to get alternates: %s", error->message);
+ gtk_widget_hide (self->origin_box);
+ return;
+ }
+
+ /* deduplicate the list; duplicates can get in the list if
+ * get_alternates() returns the old/new version of a renamed app, which
+ * happens to come from the same origin; see
+ * https://gitlab.gnome.org/GNOME/gnome-software/-/issues/1192
+ *
+ * This nested loop is OK as the origin list is normally only 2 or 3
+ * items long. */
+ for (guint i = 0; i < gs_app_list_length (list); i++) {
+ GsApp *i_app = gs_app_list_index (list, i);
+ gboolean did_remove = FALSE;
+
+ for (guint j = i + 1; j < gs_app_list_length (list);) {
+ GsApp *j_app = gs_app_list_index (list, j);
+
+ if (app_origin_equal (i_app, j_app)) {
+ gs_app_list_remove (list, j_app);
+ did_remove = TRUE;
+ } else {
+ j++;
+ }
+ }
+
+ /* Needed to catch cases when the same pointer is in the array multiple times,
+ interleaving with another pointer. The removal can skip the first occurrence
+ due to the g_ptr_array_remove() removing the first instance in the array,
+ which shifts the array content. */
+ if (did_remove)
+ i--;
+ }
+
+ /* add the local file to the list so that we can carry it over when
+ * switching between alternates */
+ if (self->app_local_file != NULL) {
+ if (gs_app_get_state (self->app_local_file) != GS_APP_STATE_INSTALLED) {
+ GtkWidget *row = gs_origin_popover_row_new (self->app_local_file);
+ gtk_widget_show (row);
+ gtk_list_box_append (GTK_LIST_BOX (self->origin_popover_list_box), row);
+ first_row = row;
+ select_row = row;
+ n_rows++;
+ }
+
+ /* Do not allow change of the app by the packaging format when it's a local file */
+ origin_by_packaging_format = FALSE;
+ }
+
+ /* Do not allow change of the app by the packaging format when it's installed */
+ origin_by_packaging_format = origin_by_packaging_format &&
+ self->app != NULL &&
+ gs_app_get_state (self->app) != GS_APP_STATE_INSTALLED &&
+ gs_app_get_state (self->app) != GS_APP_STATE_UPDATABLE &&
+ gs_app_get_state (self->app) != GS_APP_STATE_UPDATABLE_LIVE;
+
+ /* Sort the alternates by the user's packaging preferences */
+ if (g_hash_table_size (self->packaging_format_preference) > 0)
+ gs_app_list_sort (list, sort_by_packaging_format_preference, self->packaging_format_preference);
+
+ for (guint i = 0; i < gs_app_list_length (list); i++) {
+ GsApp *app = gs_app_list_index (list, i);
+ GtkWidget *row = gs_origin_popover_row_new (app);
+ gtk_widget_show (row);
+ n_rows++;
+ if (first_row == NULL)
+ first_row = row;
+ if (app == self->app || (
+ (gs_app_get_bundle_kind (app) == AS_BUNDLE_KIND_UNKNOWN ||
+ gs_app_get_bundle_kind (app) == gs_app_get_bundle_kind (self->app)) &&
+ (gs_app_get_scope (app) == AS_COMPONENT_SCOPE_UNKNOWN ||
+ gs_app_get_scope (app) == gs_app_get_scope (self->app)) &&
+ g_strcmp0 (gs_app_get_origin (app), gs_app_get_origin (self->app)) == 0 &&
+ g_strcmp0 (gs_app_get_branch (app), gs_app_get_branch (self->app)) == 0 &&
+ g_strcmp0 (gs_app_get_version (app), gs_app_get_version (self->app)) == 0)) {
+ /* This can happen on reload of the page */
+ if (app != self->app) {
+ _set_app (self, app);
+ instance_changed = TRUE;
+ }
+ select_row = row;
+ }
+ gtk_list_box_append (GTK_LIST_BOX (self->origin_popover_list_box), row);
+
+ if (origin_by_packaging_format) {
+ const gchar *packaging_format = gs_app_get_packaging_format_raw (app);
+ gint index = GPOINTER_TO_INT (g_hash_table_lookup (self->packaging_format_preference, packaging_format));
+ if (index > 0 && (index < origin_row_by_packaging_format_index || origin_row_by_packaging_format_index == 0)) {
+ origin_row_by_packaging_format_index = index;
+ origin_row_by_packaging_format = row;
+ }
+ }
+ }
+
+ if (origin_row_by_packaging_format) {
+ GsOriginPopoverRow *row = GS_ORIGIN_POPOVER_ROW (origin_row_by_packaging_format);
+ GsApp *app = gs_origin_popover_row_get_app (row);
+ select_row = origin_row_by_packaging_format;
+ if (app != self->app) {
+ _set_app (self, app);
+ instance_changed = TRUE;
+ }
+ }
+
+ if (select_row == NULL && first_row != NULL) {
+ GsOriginPopoverRow *row = GS_ORIGIN_POPOVER_ROW (first_row);
+ GsApp *app = gs_origin_popover_row_get_app (row);
+ select_row = first_row;
+ if (app != self->app) {
+ _set_app (self, app);
+ instance_changed = TRUE;
+ }
+ }
+
+ /* Do not show the "selected" check when there's only one app in the list */
+ if (select_row && n_rows > 1)
+ gs_origin_popover_row_set_selected (GS_ORIGIN_POPOVER_ROW (select_row), TRUE);
+ else if (select_row)
+ gtk_list_box_row_set_activatable (GTK_LIST_BOX_ROW (select_row), FALSE);
+
+ if (select_row != NULL)
+ gs_details_page_update_origin_button (self, TRUE);
+ else
+ gtk_widget_hide (self->origin_box);
+
+ if (instance_changed) {
+ g_autoptr(GsPluginJob) plugin_job = NULL;
+
+ /* Make sure the changed instance contains the reviews and such */
+ plugin_job = gs_plugin_job_refine_new_for_app (self->app,
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_RATING |
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_REVIEW_RATINGS |
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_REVIEWS |
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_SIZE);
+ gs_plugin_loader_job_process_async (self->plugin_loader, plugin_job,
+ self->cancellable,
+ gs_details_page_app_refine_cb,
+ self);
+
+ gs_details_page_refresh_all (self);
+ }
+}
+
+static gboolean
+gs_details_page_can_launch_app (GsDetailsPage *self)
+{
+ const gchar *desktop_id;
+ GDesktopAppInfo *desktop_appinfo;
+ g_autoptr(GAppInfo) appinfo = NULL;
+
+ if (!self->app)
+ return FALSE;
+
+ switch (gs_app_get_state (self->app)) {
+ case GS_APP_STATE_INSTALLED:
+ case GS_APP_STATE_UPDATABLE:
+ case GS_APP_STATE_UPDATABLE_LIVE:
+ break;
+ default:
+ return FALSE;
+ }
+
+ if (gs_app_has_quirk (self->app, GS_APP_QUIRK_NOT_LAUNCHABLE) ||
+ gs_app_has_quirk (self->app, GS_APP_QUIRK_PARENTAL_NOT_LAUNCHABLE))
+ return FALSE;
+
+ /* don't show the launch button if the app doesn't have a desktop ID */
+ if (gs_app_get_id (self->app) == NULL)
+ return FALSE;
+
+ desktop_id = gs_app_get_launchable (self->app, AS_LAUNCHABLE_KIND_DESKTOP_ID);
+ if (!desktop_id)
+ desktop_id = gs_app_get_id (self->app);
+ if (!desktop_id)
+ return FALSE;
+
+ desktop_appinfo = gs_utils_get_desktop_app_info (desktop_id);
+ if (!desktop_appinfo)
+ return FALSE;
+
+ appinfo = G_APP_INFO (desktop_appinfo);
+
+ return g_app_info_should_show (appinfo);
+}
+
+static void
+gs_details_page_refresh_buttons (GsDetailsPage *self)
+{
+ GsAppState state;
+ GtkWidget *buttons_in_order[] = {
+ self->button_details_launch,
+ self->button_install,
+ self->button_update,
+ self->button_remove,
+ };
+ GtkWidget *highlighted_button = NULL;
+
+ state = gs_app_get_state (self->app);
+
+ /* install button */
+ switch (state) {
+ case GS_APP_STATE_AVAILABLE:
+ case GS_APP_STATE_AVAILABLE_LOCAL:
+ gtk_widget_set_visible (self->button_install, TRUE);
+ /* TRANSLATORS: button text in the header when an application
+ * can be installed */
+ gtk_button_set_label (GTK_BUTTON (self->button_install), _("_Install"));
+ break;
+ case GS_APP_STATE_INSTALLING:
+ gtk_widget_set_visible (self->button_install, FALSE);
+ break;
+ case GS_APP_STATE_UNKNOWN:
+ case GS_APP_STATE_INSTALLED:
+ case GS_APP_STATE_REMOVING:
+ case GS_APP_STATE_UPDATABLE:
+ case GS_APP_STATE_QUEUED_FOR_INSTALL:
+ gtk_widget_set_visible (self->button_install, FALSE);
+ break;
+ case GS_APP_STATE_PENDING_INSTALL:
+ case GS_APP_STATE_PENDING_REMOVE:
+ if (gs_app_has_quirk (self->app, GS_APP_QUIRK_NEEDS_REBOOT)) {
+ gtk_widget_set_visible (self->button_install, TRUE);
+ gtk_button_set_label (GTK_BUTTON (self->button_install), _("_Restart"));
+ } else {
+ gtk_widget_set_visible (self->button_install, FALSE);
+ }
+ break;
+ case GS_APP_STATE_UPDATABLE_LIVE:
+ if (gs_app_get_kind (self->app) == AS_COMPONENT_KIND_FIRMWARE) {
+ gtk_widget_set_visible (self->button_install, TRUE);
+ /* TRANSLATORS: button text in the header when firmware
+ * can be live-installed */
+ gtk_button_set_label (GTK_BUTTON (self->button_install), _("_Install"));
+ } else {
+ gtk_widget_set_visible (self->button_install, FALSE);
+ }
+ break;
+ case GS_APP_STATE_UNAVAILABLE:
+ if (gs_app_get_url_missing (self->app) != NULL) {
+ gtk_widget_set_visible (self->button_install, FALSE);
+ } else {
+ gtk_widget_set_visible (self->button_install, TRUE);
+ /* TRANSLATORS: this is a button that allows the apps to
+ * be installed.
+ * The ellipsis indicates that further steps are required,
+ * e.g. enabling software repositories or the like */
+ gtk_button_set_label (GTK_BUTTON (self->button_install), _("_Install…"));
+ }
+ break;
+ default:
+ g_warning ("App unexpectedly in state %s",
+ gs_app_state_to_string (state));
+ g_assert_not_reached ();
+ }
+
+ /* update button */
+ switch (state) {
+ case GS_APP_STATE_UPDATABLE_LIVE:
+ if (gs_app_get_kind (self->app) == AS_COMPONENT_KIND_FIRMWARE) {
+ gtk_widget_set_visible (self->button_update, FALSE);
+ } else {
+ gtk_widget_set_visible (self->button_update, TRUE);
+ }
+ break;
+ default:
+ gtk_widget_set_visible (self->button_update, FALSE);
+ break;
+ }
+
+ /* launch button */
+ gtk_widget_set_visible (self->button_details_launch, gs_details_page_can_launch_app (self));
+
+ /* remove button */
+ if (gs_app_has_quirk (self->app, GS_APP_QUIRK_COMPULSORY) ||
+ gs_app_get_kind (self->app) == AS_COMPONENT_KIND_FIRMWARE) {
+ gtk_widget_set_visible (self->button_remove, FALSE);
+ } else {
+ switch (state) {
+ case GS_APP_STATE_INSTALLED:
+ case GS_APP_STATE_UPDATABLE:
+ case GS_APP_STATE_UPDATABLE_LIVE:
+ gtk_widget_set_visible (self->button_remove, TRUE);
+ gtk_widget_set_sensitive (self->button_remove, TRUE);
+ break;
+ case GS_APP_STATE_AVAILABLE_LOCAL:
+ case GS_APP_STATE_AVAILABLE:
+ case GS_APP_STATE_INSTALLING:
+ case GS_APP_STATE_REMOVING:
+ case GS_APP_STATE_UNAVAILABLE:
+ case GS_APP_STATE_UNKNOWN:
+ case GS_APP_STATE_QUEUED_FOR_INSTALL:
+ case GS_APP_STATE_PENDING_INSTALL:
+ case GS_APP_STATE_PENDING_REMOVE:
+ gtk_widget_set_visible (self->button_remove, FALSE);
+ break;
+ default:
+ g_warning ("App unexpectedly in state %s",
+ gs_app_state_to_string (state));
+ g_assert_not_reached ();
+ }
+ }
+
+ if (app_has_pending_action (self->app)) {
+ gtk_widget_set_visible (self->button_install, FALSE);
+ gtk_widget_set_visible (self->button_update, FALSE);
+ gtk_widget_set_visible (self->button_details_launch, FALSE);
+ gtk_widget_set_visible (self->button_remove, FALSE);
+ }
+
+ /* Update the styles so that the first visible button gets
+ * `suggested-action` or `destructive-action` and the rest are
+ * unstyled. This draws the user’s attention to the most likely
+ * action to perform. */
+ for (gsize i = 0; i < G_N_ELEMENTS (buttons_in_order); i++) {
+ if (highlighted_button != NULL) {
+ gtk_style_context_remove_class (gtk_widget_get_style_context (buttons_in_order[i]), "suggested-action");
+ gtk_style_context_remove_class (gtk_widget_get_style_context (buttons_in_order[i]), "destructive-action");
+ } else if (gtk_widget_get_visible (buttons_in_order[i])) {
+ highlighted_button = buttons_in_order[i];
+
+ if (buttons_in_order[i] == self->button_remove)
+ gtk_style_context_add_class (gtk_widget_get_style_context (buttons_in_order[i]), "destructive-action");
+ else
+ gtk_style_context_add_class (gtk_widget_get_style_context (buttons_in_order[i]), "suggested-action");
+ }
+ }
+}
+
+static gboolean
+update_action_row_from_link (AdwActionRow *row,
+ GsApp *app,
+ AsUrlKind url_kind)
+{
+ const gchar *url = gs_app_get_url (app, url_kind);
+
+#if ADW_CHECK_VERSION(1,2,0)
+ adw_preferences_row_set_use_markup (ADW_PREFERENCES_ROW (row), FALSE);
+
+ if (url != NULL)
+ adw_action_row_set_subtitle (row, url);
+#else
+ if (url != NULL) {
+ g_autofree gchar *escaped_url = g_markup_escape_text (url, -1);
+ adw_action_row_set_subtitle (row, escaped_url);
+ }
+#endif
+
+ gtk_widget_set_visible (GTK_WIDGET (row), url != NULL);
+
+ return (url != NULL);
+}
+
+static void
+gs_details_page_app_tile_clicked (GsAppTile *tile,
+ gpointer user_data)
+{
+ GsDetailsPage *self = GS_DETAILS_PAGE (user_data);
+ GsApp *app;
+
+ app = gs_app_tile_get_app (tile);
+ g_signal_emit (self, signals[SIGNAL_APP_CLICKED], 0, app);
+}
+
+/* Consider app IDs with and without the ".desktop" suffix being the same app */
+static gboolean
+gs_details_page_app_id_equal (GsApp *app1,
+ GsApp *app2)
+{
+ const gchar *id1, *id2;
+
+ id1 = gs_app_get_id (app1);
+ id2 = gs_app_get_id (app2);
+ if (g_strcmp0 (id1, id2) == 0)
+ return TRUE;
+
+ if (id1 == NULL || id2 == NULL)
+ return FALSE;
+
+ if (g_str_has_suffix (id1, ".desktop")) {
+ return !g_str_has_suffix (id2, ".desktop") &&
+ strlen (id1) == strlen (id2) + 8 /* strlen (".desktop") */ &&
+ g_str_has_prefix (id1, id2);
+ }
+
+ return g_str_has_suffix (id2, ".desktop") &&
+ !g_str_has_suffix (id1, ".desktop") &&
+ strlen (id2) == strlen (id1) + 8 /* strlen (".desktop") */ &&
+ g_str_has_prefix (id2, id1);
+}
+
+static void
+gs_details_page_search_developer_apps_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data)
+{
+ GsDetailsPage *self = GS_DETAILS_PAGE (user_data);
+ g_autoptr(GsAppList) list = NULL;
+ g_autoptr(GError) local_error = NULL;
+ guint n_added = 0;
+
+ list = gs_plugin_loader_job_process_finish (GS_PLUGIN_LOADER (source_object), result, &local_error);
+ if (list == NULL) {
+ if (g_error_matches (local_error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_CANCELLED) ||
+ g_error_matches (local_error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) {
+ g_debug ("search cancelled");
+ return;
+ }
+ g_warning ("failed to get other apps: %s", local_error->message);
+ return;
+ }
+
+ if (!self->app || !gs_page_is_active (GS_PAGE (self)))
+ return;
+
+ for (guint i = 0; i < gs_app_list_length (list); i++) {
+ GsApp *app = gs_app_list_index (list, i);
+ if (app != self->app && !gs_details_page_app_id_equal (app, self->app)) {
+ GtkWidget *tile = gs_summary_tile_new (app);
+ g_signal_connect (tile, "clicked", G_CALLBACK (gs_details_page_app_tile_clicked), self);
+ gtk_flow_box_insert (GTK_FLOW_BOX (self->box_developer_apps), tile, -1);
+
+ n_added++;
+ if (n_added == N_DEVELOPER_APPS)
+ break;
+ }
+ }
+
+ gtk_widget_set_visible (self->box_developer_apps, n_added > 0);
+}
+
+static void
+gs_details_page_refresh_all (GsDetailsPage *self)
+{
+ g_autoptr(GIcon) icon = NULL;
+ const gchar *tmp;
+ g_autofree gchar *origin = NULL;
+ g_autoptr(GPtrArray) version_history = NULL;
+ gboolean link_rows_visible;
+
+ /* change widgets */
+ tmp = gs_app_get_name (self->app);
+ if (tmp != NULL && tmp[0] != '\0') {
+ gtk_label_set_label (GTK_LABEL (self->application_details_title), tmp);
+ gtk_widget_set_visible (self->application_details_title, TRUE);
+ } else {
+ gtk_widget_set_visible (self->application_details_title, FALSE);
+ }
+ tmp = gs_app_get_summary (self->app);
+ if (tmp != NULL && tmp[0] != '\0') {
+ gtk_label_set_label (GTK_LABEL (self->application_details_summary), tmp);
+ gtk_widget_set_visible (self->application_details_summary, TRUE);
+ } else {
+ gtk_widget_set_visible (self->application_details_summary, FALSE);
+ }
+
+ /* refresh buttons */
+ gs_details_page_refresh_buttons (self);
+
+ /* Set up the translation infobar. Assume that translations can be
+ * contributed to if an app is FOSS and it has provided a link for
+ * contributing translations. */
+ gtk_widget_set_visible (GTK_WIDGET (self->translation_infobar_button),
+ gs_app_translation_dialog_app_has_url (self->app) &&
+ gs_app_get_license_is_free (self->app));
+ gtk_info_bar_set_revealed (self->translation_infobar,
+ gs_app_get_has_translations (self->app) &&
+ !gs_app_has_kudo (self->app, GS_APP_KUDO_MY_LANGUAGE));
+
+ /* set the description */
+ tmp = gs_app_get_description (self->app);
+ gs_details_page_set_description (self, tmp);
+
+ /* set the icon; fall back to 96px and 64px if 128px isn’t available,
+ * which sometimes happens at 2× scale factor (hi-DPI) */
+ {
+ const struct {
+ guint icon_size;
+ const gchar *fallback_icon_name; /* (nullable) */
+ } icon_fallbacks[] = {
+ { 128, NULL },
+ { 96, NULL },
+ { 64, NULL },
+ { 128, "system-component-application" },
+ };
+
+ for (gsize i = 0; i < G_N_ELEMENTS (icon_fallbacks) && icon == NULL; i++) {
+ icon = gs_app_get_icon_for_size (self->app,
+ icon_fallbacks[i].icon_size,
+ gtk_widget_get_scale_factor (self->application_details_icon),
+ icon_fallbacks[i].fallback_icon_name);
+ }
+ }
+
+ gtk_image_set_from_gicon (GTK_IMAGE (self->application_details_icon), icon);
+
+ /* Set various external links. If none are visible, show a fallback
+ * message instead. */
+ link_rows_visible = FALSE;
+ link_rows_visible = update_action_row_from_link (self->project_website_row, self->app, AS_URL_KIND_HOMEPAGE) || link_rows_visible;
+ link_rows_visible = update_action_row_from_link (self->donate_row, self->app, AS_URL_KIND_DONATION) || link_rows_visible;
+ link_rows_visible = update_action_row_from_link (self->translate_row, self->app, AS_URL_KIND_TRANSLATE) || link_rows_visible;
+ link_rows_visible = update_action_row_from_link (self->report_an_issue_row, self->app, AS_URL_KIND_BUGTRACKER) || link_rows_visible;
+ link_rows_visible = update_action_row_from_link (self->help_row, self->app, AS_URL_KIND_HELP) || link_rows_visible;
+
+ gtk_stack_set_visible_child_name (self->links_stack, link_rows_visible ? "links" : "empty");
+
+ tmp = gs_app_get_developer_name (self->app);
+ if (tmp != NULL) {
+ gtk_label_set_label (GTK_LABEL (self->developer_name_label), tmp);
+
+ if (g_strcmp0 (tmp, self->last_developer_name) != 0) {
+ g_autoptr(GsAppQuery) query = NULL;
+ g_autoptr(GsPluginJob) plugin_job = NULL;
+ g_autofree gchar *heading = NULL;
+ const gchar *names[2] = { NULL, NULL };
+
+ /* Hide the section, it will be shown only if any other app had been found */
+ gtk_widget_set_visible (self->box_developer_apps, FALSE);
+
+ g_clear_pointer (&self->last_developer_name, g_free);
+ self->last_developer_name = g_strdup (tmp);
+
+ /* Translators: the '%s' is replaced with a developer name or a project group */
+ heading = g_strdup_printf (_("Other Apps by %s"), self->last_developer_name);
+ gtk_label_set_label (GTK_LABEL (self->developer_apps_heading), heading);
+ gs_widget_remove_all (self->box_developer_apps, (GsRemoveFunc) gtk_flow_box_remove);
+
+ names[0] = self->last_developer_name;
+ query = gs_app_query_new ("developers", names,
+ "max-results", N_DEVELOPER_APPS * 3, /* Ask for more, some can be skipped */
+ "refine-flags", GS_PLUGIN_REFINE_FLAGS_REQUIRE_ICON,
+ "dedupe-flags", GS_APP_LIST_FILTER_FLAG_KEY_ID_PROVIDES,
+ NULL);
+
+ plugin_job = gs_plugin_job_list_apps_new (query, GS_PLUGIN_LIST_APPS_FLAGS_INTERACTIVE);
+
+ g_debug ("searching other apps for: '%s'", names[0]);
+ gs_plugin_loader_job_process_async (self->plugin_loader, plugin_job,
+ self->cancellable,
+ gs_details_page_search_developer_apps_cb,
+ self);
+ }
+ } else if (tmp == NULL) {
+ g_clear_pointer (&self->last_developer_name, g_free);
+ gs_widget_remove_all (self->box_developer_apps, (GsRemoveFunc) gtk_flow_box_remove);
+ gtk_widget_set_visible (self->box_developer_apps, FALSE);
+ }
+
+ gtk_widget_set_visible (GTK_WIDGET (self->developer_name_label), tmp != NULL);
+ gtk_widget_set_visible (GTK_WIDGET (self->developer_verified_image), gs_app_has_quirk (self->app, GS_APP_QUIRK_DEVELOPER_VERIFIED));
+
+ /* set version history */
+ version_history = gs_app_get_version_history (self->app);
+ if (version_history == NULL || version_history->len == 0) {
+ const gchar *version = gs_app_get_version_ui (self->app);
+ if (version == NULL || *version == '\0')
+ gtk_widget_set_visible (self->list_box_version_history, FALSE);
+ else
+ gs_app_version_history_row_set_info (GS_APP_VERSION_HISTORY_ROW (self->row_latest_version),
+ version, gs_app_get_release_date (self->app), NULL);
+ } else {
+ AsRelease *latest_version = g_ptr_array_index (version_history, 0);
+ const gchar *version = gs_app_get_version_ui (self->app);
+ if (version == NULL || *version == '\0') {
+ gs_app_version_history_row_set_info (GS_APP_VERSION_HISTORY_ROW (self->row_latest_version),
+ as_release_get_version (latest_version),
+ as_release_get_timestamp (latest_version),
+ as_release_get_description (latest_version));
+ } else {
+ gboolean same_version = g_strcmp0 (version, as_release_get_version (latest_version)) == 0;
+ /* Inherit the description from the release history, when the versions match */
+ gs_app_version_history_row_set_info (GS_APP_VERSION_HISTORY_ROW (self->row_latest_version),
+ version, gs_app_get_release_date (self->app),
+ same_version ? as_release_get_description (latest_version) : NULL);
+ }
+ }
+
+ gtk_widget_set_visible (self->version_history_button, version_history != NULL && version_history->len > 1);
+
+ /* are we trying to replace something in the baseos */
+ gtk_widget_set_visible (self->infobar_details_package_baseos,
+ gs_app_has_quirk (self->app, GS_APP_QUIRK_COMPULSORY) &&
+ gs_app_get_state (self->app) == GS_APP_STATE_AVAILABLE_LOCAL);
+
+ switch (gs_app_get_kind (self->app)) {
+ case AS_COMPONENT_KIND_DESKTOP_APP:
+ /* installing an app with a repo file */
+ gtk_widget_set_visible (self->infobar_details_app_repo,
+ gs_app_has_quirk (self->app,
+ GS_APP_QUIRK_HAS_SOURCE) &&
+ gs_app_get_state (self->app) == GS_APP_STATE_AVAILABLE_LOCAL);
+ gtk_widget_set_visible (self->infobar_details_repo, FALSE);
+ break;
+ case AS_COMPONENT_KIND_GENERIC:
+ /* installing a repo-release package */
+ gtk_widget_set_visible (self->infobar_details_app_repo, FALSE);
+ gtk_widget_set_visible (self->infobar_details_repo,
+ gs_app_has_quirk (self->app,
+ GS_APP_QUIRK_HAS_SOURCE) &&
+ gs_app_get_state (self->app) == GS_APP_STATE_AVAILABLE_LOCAL);
+ break;
+ default:
+ gtk_widget_set_visible (self->infobar_details_app_repo, FALSE);
+ gtk_widget_set_visible (self->infobar_details_repo, FALSE);
+ break;
+ }
+
+ /* installing a app without a repo file */
+ switch (gs_app_get_kind (self->app)) {
+ case AS_COMPONENT_KIND_DESKTOP_APP:
+ if (gs_app_get_kind (self->app) == AS_COMPONENT_KIND_FIRMWARE) {
+ gtk_widget_set_visible (self->infobar_details_app_norepo, FALSE);
+ } else {
+ gtk_widget_set_visible (self->infobar_details_app_norepo,
+ !gs_app_has_quirk (self->app,
+ GS_APP_QUIRK_HAS_SOURCE) &&
+ gs_app_get_state (self->app) == GS_APP_STATE_AVAILABLE_LOCAL);
+ }
+ break;
+ default:
+ gtk_widget_set_visible (self->infobar_details_app_norepo, FALSE);
+ break;
+ }
+
+ /* only show the "select addons" string if the app isn't yet installed */
+ switch (gs_app_get_state (self->app)) {
+ case GS_APP_STATE_INSTALLED:
+ case GS_APP_STATE_UPDATABLE:
+ case GS_APP_STATE_UPDATABLE_LIVE:
+ gtk_widget_set_visible (self->label_addons_uninstalled_app, FALSE);
+ break;
+ default:
+ gtk_widget_set_visible (self->label_addons_uninstalled_app, TRUE);
+ break;
+ }
+
+ /* update progress */
+ gs_details_page_refresh_progress (self);
+
+ gs_details_page_refresh_addons (self);
+}
+
+static gint
+list_sort_func (GtkListBoxRow *a,
+ GtkListBoxRow *b,
+ gpointer user_data)
+{
+ GsApp *a1 = gs_app_addon_row_get_addon (GS_APP_ADDON_ROW (a));
+ GsApp *a2 = gs_app_addon_row_get_addon (GS_APP_ADDON_ROW (b));
+
+ return gs_utils_sort_strcmp (gs_app_get_name (a1),
+ gs_app_get_name (a2));
+}
+
+static void
+addons_list_row_activated_cb (GtkListBox *list_box,
+ GtkListBoxRow *row,
+ GsDetailsPage *self)
+{
+ gboolean selected;
+
+ g_return_if_fail (GS_IS_APP_ADDON_ROW (row));
+
+ /* This would be racy if multithreaded but we're in the main thread */
+ selected = gs_app_addon_row_get_selected (GS_APP_ADDON_ROW (row));
+ gs_app_addon_row_set_selected (GS_APP_ADDON_ROW (row), !selected);
+}
+
+static void
+version_history_list_row_activated_cb (GtkListBox *list_box,
+ GtkListBoxRow *row,
+ GsDetailsPage *self)
+{
+ GtkWidget *dialog;
+
+ /* Only the row with the arrow is clickable */
+ if (GS_IS_APP_VERSION_HISTORY_ROW (row))
+ return;
+
+ dialog = gs_app_version_history_dialog_new (GTK_WINDOW (gtk_widget_get_ancestor (GTK_WIDGET (list_box), GTK_TYPE_WINDOW)),
+ self->app);
+ gs_shell_modal_dialog_present (self->shell, GTK_WINDOW (dialog));
+}
+
+static void gs_details_page_refresh_reviews (GsDetailsPage *self);
+
+static void
+app_reviews_dialog_destroy_cb (GsDetailsPage *self)
+{
+ self->app_reviews_dialog = NULL;
+}
+
+static void
+featured_review_list_row_activated_cb (GtkListBox *list_box,
+ GtkListBoxRow *row,
+ GsDetailsPage *self)
+{
+ /* Only the row with the arrow is clickable */
+ if (GS_IS_REVIEW_ROW (row))
+ return;
+
+ g_assert (GS_IS_ODRS_PROVIDER (self->odrs_provider));
+
+ if (self->app_reviews_dialog == NULL) {
+ GtkWindow *parent;
+
+ parent = GTK_WINDOW (gtk_widget_get_ancestor (GTK_WIDGET (list_box), GTK_TYPE_WINDOW));
+
+ self->app_reviews_dialog =
+ gs_app_reviews_dialog_new (parent, self->app,
+ self->odrs_provider, self->plugin_loader);
+ g_object_bind_property (self, "odrs-provider",
+ self->app_reviews_dialog, "odrs-provider", 0);
+ g_signal_connect_swapped (self->app_reviews_dialog, "reviews-updated",
+ G_CALLBACK (gs_details_page_refresh_reviews), self);
+ g_signal_connect_swapped (self->app_reviews_dialog, "destroy",
+ G_CALLBACK (app_reviews_dialog_destroy_cb), self);
+ }
+
+ gs_shell_modal_dialog_present (self->shell, GTK_WINDOW (self->app_reviews_dialog));
+}
+
+static void gs_details_page_addon_selected_cb (GsAppAddonRow *row, GParamSpec *pspec, GsDetailsPage *self);
+static void gs_details_page_addon_remove_cb (GsAppAddonRow *row, gpointer user_data);
+
+static void
+gs_details_page_refresh_addons (GsDetailsPage *self)
+{
+ g_autoptr(GsAppList) addons = NULL;
+ guint i, rows = 0;
+
+ gs_widget_remove_all (self->list_box_addons, (GsRemoveFunc) gtk_list_box_remove);
+
+ addons = gs_app_dup_addons (self->app);
+ for (i = 0; addons != NULL && i < gs_app_list_length (addons); i++) {
+ GsApp *addon;
+ GtkWidget *row;
+
+ addon = gs_app_list_index (addons, i);
+ if (gs_app_get_state (addon) == GS_APP_STATE_UNKNOWN ||
+ gs_app_get_state (addon) == GS_APP_STATE_UNAVAILABLE)
+ continue;
+
+ if (gs_app_has_quirk (addon, GS_APP_QUIRK_HIDE_EVERYWHERE))
+ continue;
+
+ row = gs_app_addon_row_new (addon);
+
+ g_signal_connect (row, "notify::selected",
+ G_CALLBACK (gs_details_page_addon_selected_cb),
+ self);
+ g_signal_connect (row, "remove-button-clicked",
+ G_CALLBACK (gs_details_page_addon_remove_cb),
+ self);
+
+ gtk_list_box_append (GTK_LIST_BOX (self->list_box_addons), row);
+
+ rows++;
+ }
+
+ gtk_widget_set_visible (self->box_addons, rows > 0);
+}
+
+static AsReview *
+get_featured_review (GPtrArray *reviews)
+{
+ AsReview *featured;
+ g_autoptr(GDateTime) now_utc = NULL;
+ g_autoptr(GDateTime) min_date = NULL;
+ gint featured_priority;
+
+ g_assert (reviews->len > 0);
+
+ now_utc = g_date_time_new_now_utc ();
+ min_date = g_date_time_add_months (now_utc, -6);
+
+ featured = g_ptr_array_index (reviews, 0);
+ featured_priority = as_review_get_priority (featured);
+
+ for (gsize i = 1; i < reviews->len; i++) {
+ AsReview *new = g_ptr_array_index (reviews, i);
+ gint new_priority = as_review_get_priority (new);
+
+ /* Skip reviews older than 6 months for the featured pick */
+ if (g_date_time_compare (as_review_get_date (new), min_date) < 0)
+ continue;
+
+ if (featured_priority > new_priority ||
+ (featured_priority == new_priority &&
+ g_date_time_compare (as_review_get_date (featured), as_review_get_date (new)) > 0)) {
+ featured = new;
+ featured_priority = new_priority;
+ }
+ }
+
+ return featured;
+}
+
+static void
+gs_details_page_refresh_reviews (GsDetailsPage *self)
+{
+ GArray *review_ratings = NULL;
+ GPtrArray *reviews;
+ gboolean show_review_button = TRUE;
+ gboolean show_reviews = FALSE;
+ guint n_reviews = 0;
+ guint i;
+ GtkWidget *child;
+
+ /* nothing to show */
+ if (self->app == NULL)
+ return;
+
+ /* show or hide the entire reviews section */
+ switch (gs_app_get_kind (self->app)) {
+ case AS_COMPONENT_KIND_DESKTOP_APP:
+ case AS_COMPONENT_KIND_FONT:
+ case AS_COMPONENT_KIND_INPUT_METHOD:
+ case AS_COMPONENT_KIND_WEB_APP:
+ /* don't show a missing rating on a local file */
+ if (gs_app_get_state (self->app) != GS_APP_STATE_AVAILABLE_LOCAL &&
+ self->odrs_provider != NULL)
+ show_reviews = TRUE;
+ break;
+ default:
+ break;
+ }
+
+ /* some apps are unreviewable */
+ if (gs_app_has_quirk (self->app, GS_APP_QUIRK_NOT_REVIEWABLE))
+ show_reviews = FALSE;
+
+ /* set the star rating */
+ if (show_reviews) {
+ gtk_widget_set_sensitive (self->star, gs_app_get_rating (self->app) >= 0);
+ gs_star_widget_set_rating (GS_STAR_WIDGET (self->star),
+ gs_app_get_rating (self->app));
+
+ review_ratings = gs_app_get_review_ratings (self->app);
+ if (review_ratings != NULL) {
+ gs_review_histogram_set_ratings (GS_REVIEW_HISTOGRAM (self->histogram),
+ gs_app_get_rating (self->app),
+ review_ratings);
+ }
+ if (review_ratings != NULL) {
+ for (i = 0; i < review_ratings->len; i++)
+ n_reviews += (guint) g_array_index (review_ratings, guint32, i);
+ } else if (gs_app_get_reviews (self->app) != NULL) {
+ n_reviews = gs_app_get_reviews (self->app)->len;
+ }
+ }
+
+ /* enable appropriate widgets */
+ gtk_widget_set_visible (self->star, show_reviews);
+ gtk_widget_set_visible (self->histogram_row, review_ratings != NULL && review_ratings->len > 0);
+ gtk_widget_set_visible (self->label_review_count, n_reviews > 0);
+
+ /* update the review label next to the star widget */
+ if (n_reviews > 0) {
+ g_autofree gchar *text = NULL;
+ gtk_widget_set_visible (self->label_review_count, TRUE);
+ text = g_strdup_printf ("(%u)", n_reviews);
+ gtk_label_set_text (GTK_LABEL (self->label_review_count), text);
+ }
+
+ /* no point continuing */
+ if (!show_reviews) {
+ gtk_widget_set_visible (self->box_reviews, FALSE);
+ return;
+ }
+
+ /* add all the reviews */
+ while ((child = gtk_widget_get_first_child (self->list_box_featured_review)) != NULL) {
+ if (GS_IS_REVIEW_ROW (child))
+ gtk_list_box_remove (GTK_LIST_BOX (self->list_box_featured_review), child);
+ else
+ break;
+ }
+
+ reviews = gs_app_get_reviews (self->app);
+ if (reviews->len > 0) {
+ AsReview *review = get_featured_review (reviews);
+ GtkWidget *row = gs_review_row_new (review);
+
+ gtk_list_box_row_set_activatable (GTK_LIST_BOX_ROW (row), FALSE);
+ gtk_list_box_prepend (GTK_LIST_BOX (self->list_box_featured_review), row);
+
+ gs_review_row_set_network_available (GS_REVIEW_ROW (row),
+ gs_plugin_loader_get_network_available (self->plugin_loader));
+ }
+
+ /* show the button only if the user never reviewed */
+ gtk_widget_set_visible (self->button_review, show_review_button);
+ if (gs_app_get_state (self->app) != GS_APP_STATE_INSTALLED) {
+ gtk_widget_set_visible (self->button_review, FALSE);
+ gtk_widget_set_sensitive (self->button_review, FALSE);
+ gtk_widget_set_sensitive (self->star, FALSE);
+ } else if (gs_plugin_loader_get_network_available (self->plugin_loader)) {
+ gtk_widget_set_sensitive (self->button_review, TRUE);
+ gtk_widget_set_sensitive (self->star, TRUE);
+ gtk_widget_set_tooltip_text (self->button_review, NULL);
+ } else {
+ gtk_widget_set_sensitive (self->button_review, FALSE);
+ gtk_widget_set_sensitive (self->star, FALSE);
+ gtk_widget_set_tooltip_text (self->button_review,
+ /* TRANSLATORS: we need a remote server to process */
+ _("You need internet access to write a review"));
+ }
+
+ gtk_widget_set_visible (self->list_box_featured_review, reviews->len > 0);
+
+ /* Update the overall container. */
+ gtk_widget_set_visible (self->list_box_reviews_summary,
+ show_reviews &&
+ (gtk_widget_get_visible (self->histogram_row) ||
+ gtk_widget_get_visible (self->button_review)));
+ gtk_widget_set_visible (self->box_reviews,
+ reviews->len > 0 ||
+ gtk_widget_get_visible (self->list_box_reviews_summary));
+}
+
+static void
+gs_details_page_app_refine_cb (GObject *source,
+ GAsyncResult *res,
+ gpointer user_data)
+{
+ GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (source);
+ GsDetailsPage *self = GS_DETAILS_PAGE (user_data);
+ g_autoptr(GError) error = NULL;
+ if (!gs_plugin_loader_job_action_finish (plugin_loader, res, &error)) {
+ g_warning ("failed to refine %s: %s",
+ gs_app_get_id (self->app),
+ error->message);
+ return;
+ }
+ gs_details_page_refresh_reviews (self);
+ gs_details_page_refresh_addons (self);
+}
+
+static void
+_set_app (GsDetailsPage *self, GsApp *app)
+{
+ /* do not show all the reviews by default */
+ self->show_all_reviews = FALSE;
+
+ /* disconnect the old handlers */
+ if (self->app != NULL) {
+ g_signal_handlers_disconnect_by_func (self->app, gs_details_page_notify_state_changed_cb, self);
+ g_signal_handlers_disconnect_by_func (self->app, gs_details_page_progress_changed_cb, self);
+ g_signal_handlers_disconnect_by_func (self->app, gs_details_page_allow_cancel_changed_cb,
+ self);
+ }
+
+ /* save app */
+ g_set_object (&self->app, app);
+
+ gs_app_context_bar_set_app (self->context_bar, app);
+ gs_license_tile_set_app (self->license_tile, app);
+
+ /* title/app name will have changed */
+ g_object_notify (G_OBJECT (self), "title");
+
+ if (self->app == NULL) {
+ /* switch away from the details view that failed to load */
+ gs_shell_set_mode (self->shell, GS_SHELL_MODE_OVERVIEW);
+ return;
+ }
+ g_set_object (&self->app_cancellable, gs_app_get_cancellable (app));
+ g_signal_connect_object (self->app, "notify::state",
+ G_CALLBACK (gs_details_page_notify_state_changed_cb),
+ self, 0);
+ g_signal_connect_object (self->app, "notify::size",
+ G_CALLBACK (gs_details_page_notify_state_changed_cb),
+ self, 0);
+ g_signal_connect_object (self->app, "notify::quirk",
+ G_CALLBACK (gs_details_page_notify_state_changed_cb),
+ self, 0);
+ g_signal_connect_object (self->app, "notify::progress",
+ G_CALLBACK (gs_details_page_progress_changed_cb),
+ self, 0);
+ g_signal_connect_object (self->app, "notify::allow-cancel",
+ G_CALLBACK (gs_details_page_allow_cancel_changed_cb),
+ self, 0);
+ g_signal_connect_object (self->app, "notify::pending-action",
+ G_CALLBACK (gs_details_page_notify_state_changed_cb),
+ self, 0);
+}
+
+static gboolean
+gs_details_page_filter_origin (GsApp *app,
+ gpointer user_data)
+{
+ /* Keep only local apps or those, which have an origin set */
+ return gs_app_get_state (app) == GS_APP_STATE_AVAILABLE_LOCAL ||
+ gs_app_get_local_file (app) != NULL ||
+ gs_app_get_origin (app) != NULL;
+}
+
+/* show the UI and do operations that should not block page load */
+static void
+gs_details_page_load_stage2 (GsDetailsPage *self,
+ gboolean continue_loading)
+{
+ g_autofree gchar *tmp = NULL;
+ g_autoptr(GsAppQuery) query = NULL;
+ g_autoptr(GsPluginJob) plugin_job1 = NULL;
+ g_autoptr(GsPluginJob) plugin_job2 = NULL;
+ gboolean is_online = gs_plugin_loader_get_network_available (self->plugin_loader);
+ gboolean has_screenshots;
+
+ /* print what we've got */
+ tmp = gs_app_to_string (self->app);
+ g_debug ("%s", tmp);
+
+ /* update UI */
+ gs_details_page_set_state (self, GS_DETAILS_PAGE_STATE_READY);
+ gs_screenshot_carousel_load_screenshots (GS_SCREENSHOT_CAROUSEL (self->screenshot_carousel), self->app, is_online, NULL);
+ has_screenshots = gs_screenshot_carousel_get_has_screenshots (GS_SCREENSHOT_CAROUSEL (self->screenshot_carousel));
+ gtk_widget_set_visible (self->screenshot_carousel, has_screenshots);
+ gs_details_page_refresh_reviews (self);
+ gs_details_page_refresh_all (self);
+ gs_details_page_update_origin_button (self, FALSE);
+
+ if (!continue_loading)
+ return;
+
+ /* if these tasks fail (e.g. because we have no networking) then it's
+ * of no huge importance if we don't get the required data */
+ plugin_job1 = gs_plugin_job_refine_new_for_app (self->app,
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_RATING |
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_REVIEW_RATINGS |
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_REVIEWS |
+ GS_PLUGIN_REFINE_FLAGS_REQUIRE_SIZE);
+
+ query = gs_app_query_new ("alternate-of", self->app,
+ "refine-flags", GS_DETAILS_PAGE_REFINE_FLAGS,
+ "dedupe-flags", GS_APP_LIST_FILTER_FLAG_NONE,
+ "filter-func", gs_details_page_filter_origin,
+ "sort-func", gs_utils_app_sort_priority,
+ NULL);
+ plugin_job2 = gs_plugin_job_list_apps_new (query, GS_PLUGIN_LIST_APPS_FLAGS_INTERACTIVE);
+
+ gs_plugin_loader_job_process_async (self->plugin_loader, plugin_job1,
+ self->cancellable,
+ gs_details_page_app_refine_cb,
+ self);
+ gs_plugin_loader_job_process_async (self->plugin_loader, plugin_job2,
+ self->cancellable,
+ gs_details_page_get_alternates_cb,
+ self);
+}
+
+static void
+gs_details_page_load_stage1_cb (GObject *source,
+ GAsyncResult *res,
+ gpointer user_data)
+{
+ GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (source);
+ GsDetailsPage *self = GS_DETAILS_PAGE (user_data);
+ g_autoptr(GError) error = NULL;
+
+ if (!gs_plugin_loader_job_action_finish (plugin_loader, res, &error)) {
+ g_warning ("failed to refine %s: %s",
+ gs_app_get_id (self->app),
+ error->message);
+ }
+ if (gs_app_get_kind (self->app) == AS_COMPONENT_KIND_UNKNOWN ||
+ gs_app_get_state (self->app) == GS_APP_STATE_UNKNOWN) {
+ g_autofree gchar *str = NULL;
+ const gchar *id = gs_app_get_id (self->app);
+ str = g_strdup_printf (_("Unable to find “%s”"), id == NULL ? gs_app_get_source_default (self->app) : id);
+ gtk_label_set_text (GTK_LABEL (self->label_failed), str);
+ gs_details_page_set_state (self, GS_DETAILS_PAGE_STATE_FAILED);
+ return;
+ }
+
+ /* Hide the app if it’s not suitable for the user, but only if it’s not
+ * already installed — a parent could have decided that a particular
+ * app *is* actually suitable for their child, despite its age rating.
+ *
+ * Make it look like the app doesn’t exist, to not tantalise the
+ * child. */
+ if (!gs_app_is_installed (self->app) &&
+ gs_app_has_quirk (self->app, GS_APP_QUIRK_PARENTAL_FILTER)) {
+ g_autofree gchar *str = NULL;
+ const gchar *id = gs_app_get_id (self->app);
+ str = g_strdup_printf (_("Unable to find “%s”"), id == NULL ? gs_app_get_source_default (self->app) : id);
+ gtk_label_set_text (GTK_LABEL (self->label_failed), str);
+ gs_details_page_set_state (self, GS_DETAILS_PAGE_STATE_FAILED);
+ return;
+ }
+
+ /* do 2nd stage refine */
+ gs_details_page_load_stage2 (self, TRUE);
+}
+
+static void
+gs_details_page_file_to_app_cb (GObject *source,
+ GAsyncResult *res,
+ gpointer user_data)
+{
+ GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (source);
+ GsDetailsPage *self = GS_DETAILS_PAGE (user_data);
+ g_autoptr(GsAppList) list = NULL;
+ g_autoptr(GError) error = NULL;
+
+ list = gs_plugin_loader_job_process_finish (plugin_loader,
+ res,
+ &error);
+ if (list == NULL) {
+ g_warning ("failed to convert file to GsApp: %s", error->message);
+ /* go back to the overview */
+ gs_shell_set_mode (self->shell, GS_SHELL_MODE_OVERVIEW);
+ } else {
+ GsApp *app = gs_app_list_index (list, 0);
+ g_set_object (&self->app_local_file, app);
+ _set_app (self, app);
+ gs_details_page_load_stage2 (self, TRUE);
+ }
+}
+
+static void
+gs_details_page_url_to_app_cb (GObject *source,
+ GAsyncResult *res,
+ gpointer user_data)
+{
+ GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (source);
+ GsDetailsPage *self = GS_DETAILS_PAGE (user_data);
+ g_autoptr(GsAppList) list = NULL;
+ g_autoptr(GError) error = NULL;
+
+ list = gs_plugin_loader_job_process_finish (plugin_loader,
+ res,
+ &error);
+ if (list == NULL) {
+ g_warning ("failed to convert URL to GsApp: %s", error->message);
+ /* go back to the overview */
+ gs_shell_set_mode (self->shell, GS_SHELL_MODE_OVERVIEW);
+ } else {
+ GsApp *app = gs_app_list_index (list, 0);
+ g_set_object (&self->app_local_file, app);
+ _set_app (self, app);
+ gs_details_page_load_stage2 (self, TRUE);
+ }
+}
+
+void
+gs_details_page_set_local_file (GsDetailsPage *self, GFile *file)
+{
+ g_autoptr(GsPluginJob) plugin_job = NULL;
+ gs_details_page_set_state (self, GS_DETAILS_PAGE_STATE_LOADING);
+ g_clear_object (&self->app_local_file);
+ g_clear_object (&self->app);
+ self->origin_by_packaging_format = FALSE;
+ plugin_job = gs_plugin_job_newv (GS_PLUGIN_ACTION_FILE_TO_APP,
+ "file", file,
+ "refine-flags", GS_DETAILS_PAGE_REFINE_FLAGS,
+ NULL);
+ gs_plugin_loader_job_process_async (self->plugin_loader, plugin_job,
+ self->cancellable,
+ gs_details_page_file_to_app_cb,
+ self);
+}
+
+void
+gs_details_page_set_url (GsDetailsPage *self, const gchar *url)
+{
+ g_autoptr(GsPluginJob) plugin_job = NULL;
+ gs_details_page_set_state (self, GS_DETAILS_PAGE_STATE_LOADING);
+ g_clear_object (&self->app_local_file);
+ g_clear_object (&self->app);
+ self->origin_by_packaging_format = FALSE;
+ plugin_job = gs_plugin_job_newv (GS_PLUGIN_ACTION_URL_TO_APP,
+ "search", url,
+ "refine-flags", GS_DETAILS_PAGE_REFINE_FLAGS |
+ GS_PLUGIN_REFINE_FLAGS_ALLOW_PACKAGES,
+ NULL);
+ gs_plugin_loader_job_process_async (self->plugin_loader, plugin_job,
+ self->cancellable,
+ gs_details_page_url_to_app_cb,
+ self);
+}
+
+/* refines a GsApp */
+static void
+gs_details_page_load_stage1 (GsDetailsPage *self)
+{
+ g_autoptr(GsPluginJob) plugin_job = NULL;
+ g_autoptr(GCancellable) cancellable = g_cancellable_new ();
+
+ /* update UI */
+ gs_page_switch_to (GS_PAGE (self));
+ gs_page_scroll_up (GS_PAGE (self));
+ gs_details_page_set_state (self, GS_DETAILS_PAGE_STATE_LOADING);
+
+ g_cancellable_cancel (self->cancellable);
+ g_set_object (&self->cancellable, cancellable);
+ g_cancellable_connect (self->cancellable, G_CALLBACK (gs_details_page_cancel_cb), self, NULL);
+
+ /* get extra details about the app */
+ plugin_job = gs_plugin_job_refine_new_for_app (self->app, GS_DETAILS_PAGE_REFINE_FLAGS);
+ gs_plugin_loader_job_process_async (self->plugin_loader, plugin_job,
+ self->cancellable,
+ gs_details_page_load_stage1_cb,
+ self);
+
+ /* update UI with loading page */
+ gs_details_page_refresh_all (self);
+}
+
+static void
+gs_details_page_reload (GsPage *page)
+{
+ GsDetailsPage *self = GS_DETAILS_PAGE (page);
+ if (self->app != NULL && gs_shell_get_mode (self->shell) == GS_SHELL_MODE_DETAILS) {
+ GsAppState state = gs_app_get_state (self->app);
+ /* Do not reload the page when the app is "doing something" */
+ if (state == GS_APP_STATE_INSTALLING ||
+ state == GS_APP_STATE_REMOVING ||
+ state == GS_APP_STATE_PURCHASING)
+ return;
+ gs_details_page_load_stage1 (self);
+ }
+}
+
+static gint
+origin_popover_list_sort_func (GtkListBoxRow *a,
+ GtkListBoxRow *b,
+ gpointer user_data)
+{
+ GsApp *a1 = gs_origin_popover_row_get_app (GS_ORIGIN_POPOVER_ROW (a));
+ GsApp *a2 = gs_origin_popover_row_get_app (GS_ORIGIN_POPOVER_ROW (b));
+ g_autofree gchar *a1_origin = gs_app_dup_origin_ui (a1, TRUE);
+ g_autofree gchar *a2_origin = gs_app_dup_origin_ui (a2, TRUE);
+
+ return gs_utils_sort_strcmp (a1_origin, a2_origin);
+}
+
+static void
+origin_popover_row_activated_cb (GtkListBox *list_box,
+ GtkListBoxRow *row,
+ gpointer user_data)
+{
+ GsDetailsPage *self = GS_DETAILS_PAGE (user_data);
+ GsApp *app;
+
+ gtk_popover_popdown (GTK_POPOVER (self->origin_popover));
+
+ app = gs_origin_popover_row_get_app (GS_ORIGIN_POPOVER_ROW (row));
+ if (app != self->app) {
+ _set_app (self, app);
+ gs_details_page_load_stage1 (self);
+ }
+}
+
+static void
+gs_details_page_read_packaging_format_preference (GsDetailsPage *self)
+{
+ g_auto(GStrv) preference = NULL;
+
+ if (self->packaging_format_preference == NULL)
+ return;
+
+ g_hash_table_remove_all (self->packaging_format_preference);
+
+ preference = g_settings_get_strv (self->settings, "packaging-format-preference");
+ if (preference == NULL || preference[0] == NULL)
+ return;
+
+ for (gsize ii = 0; preference[ii] != NULL; ii++) {
+ /* Using 'ii + 1' to easily distinguish between "not found" and "the first" index */
+ g_hash_table_insert (self->packaging_format_preference, g_strdup (preference[ii]), GINT_TO_POINTER (ii + 1));
+ }
+}
+
+static void
+settings_changed_cb (GsDetailsPage *self, const gchar *key, gpointer data)
+{
+ if (g_strcmp0 (key, "packaging-format-preference") == 0) {
+ gs_details_page_read_packaging_format_preference (self);
+ return;
+ }
+
+ if (self->app == NULL)
+ return;
+ if (g_strcmp0 (key, "show-nonfree-ui") == 0) {
+ gs_details_page_refresh_all (self);
+ }
+}
+
+static void
+gs_details_page_app_info_changed_cb (GAppInfoMonitor *monitor,
+ gpointer user_data)
+{
+ GsDetailsPage *self = user_data;
+
+ g_return_if_fail (GS_IS_DETAILS_PAGE (self));
+
+ if (!self->app || !gs_page_is_active (GS_PAGE (self)))
+ return;
+
+ gs_details_page_refresh_buttons (self);
+}
+
+/* this is being called from GsShell */
+void
+gs_details_page_set_app (GsDetailsPage *self, GsApp *app)
+{
+ g_return_if_fail (GS_IS_DETAILS_PAGE (self));
+ g_return_if_fail (GS_IS_APP (app));
+
+ /* clear old state */
+ g_clear_object (&self->app_local_file);
+
+ /* save GsApp */
+ _set_app (self, app);
+ self->origin_by_packaging_format = TRUE;
+ gs_details_page_load_stage1 (self);
+}
+
+GsApp *
+gs_details_page_get_app (GsDetailsPage *self)
+{
+ return self->app;
+}
+
+static void
+gs_details_page_remove_app (GsDetailsPage *self)
+{
+ g_set_object (&self->app_cancellable, gs_app_get_cancellable (self->app));
+ gs_page_remove_app (GS_PAGE (self), self->app, self->app_cancellable);
+}
+
+static void
+gs_details_page_app_remove_button_cb (GtkWidget *widget, GsDetailsPage *self)
+{
+ gs_details_page_remove_app (self);
+}
+
+static void
+gs_details_page_app_cancel_button_cb (GtkWidget *widget, GsDetailsPage *self)
+{
+ g_cancellable_cancel (self->app_cancellable);
+ gtk_widget_set_sensitive (widget, FALSE);
+
+ /* reset the pending-action from the app if needed */
+ gs_app_set_pending_action (self->app, GS_PLUGIN_ACTION_UNKNOWN);
+
+ /* FIXME: We should be able to revert the QUEUED_FOR_INSTALL without
+ * having to pretend to remove the app */
+ if (gs_app_get_state (self->app) == GS_APP_STATE_QUEUED_FOR_INSTALL)
+ gs_details_page_remove_app (self);
+}
+
+static void
+gs_details_page_app_install_button_cb (GtkWidget *widget, GsDetailsPage *self)
+{
+ GtkWidget *child;
+
+ switch (gs_app_get_state (self->app)) {
+ case GS_APP_STATE_PENDING_INSTALL:
+ case GS_APP_STATE_PENDING_REMOVE:
+ g_return_if_fail (gs_app_has_quirk (self->app, GS_APP_QUIRK_NEEDS_REBOOT));
+ gs_utils_invoke_reboot_async (NULL, NULL, NULL);
+ return;
+ default:
+ break;
+ }
+
+ /* Mark ticked addons to be installed together with the app */
+ for (child = gtk_widget_get_first_child (self->list_box_addons);
+ child != NULL;
+ child = gtk_widget_get_next_sibling (child)) {
+ GsAppAddonRow *row = GS_APP_ADDON_ROW (child);
+ if (gs_app_addon_row_get_selected (row)) {
+ GsApp *addon = gs_app_addon_row_get_addon (row);
+
+ if (gs_app_get_state (addon) == GS_APP_STATE_AVAILABLE)
+ gs_app_set_to_be_installed (addon, TRUE);
+ }
+ }
+
+ g_set_object (&self->app_cancellable, gs_app_get_cancellable (self->app));
+
+ if (gs_app_get_state (self->app) == GS_APP_STATE_UPDATABLE_LIVE) {
+ gs_page_update_app (GS_PAGE (self), self->app, self->app_cancellable);
+ return;
+ }
+
+ gs_page_install_app (GS_PAGE (self), self->app, GS_SHELL_INTERACTION_FULL,
+ self->app_cancellable);
+}
+
+static void
+gs_details_page_app_update_button_cb (GtkWidget *widget, GsDetailsPage *self)
+{
+ g_set_object (&self->app_cancellable, gs_app_get_cancellable (self->app));
+ gs_page_update_app (GS_PAGE (self), self->app, self->app_cancellable);
+}
+
+static void
+gs_details_page_addon_selected_cb (GsAppAddonRow *row,
+ GParamSpec *pspec,
+ GsDetailsPage *self)
+{
+ GsApp *addon;
+
+ addon = gs_app_addon_row_get_addon (row);
+
+ /* If the main app is already installed, ticking the addon checkbox
+ * triggers an immediate install. Otherwise we'll install the addon
+ * together with the main app. */
+ switch (gs_app_get_state (self->app)) {
+ case GS_APP_STATE_INSTALLED:
+ case GS_APP_STATE_UPDATABLE:
+ case GS_APP_STATE_UPDATABLE_LIVE:
+ if (gs_app_addon_row_get_selected (row)) {
+ g_set_object (&self->app_cancellable, gs_app_get_cancellable (addon));
+ gs_page_install_app (GS_PAGE (self), addon, GS_SHELL_INTERACTION_FULL,
+ self->app_cancellable);
+ } else {
+ g_set_object (&self->app_cancellable, gs_app_get_cancellable (addon));
+ gs_page_remove_app (GS_PAGE (self), addon, self->app_cancellable);
+ gs_details_page_refresh_all (self);
+ }
+ break;
+ default:
+ break;
+ }
+}
+
+static void
+gs_details_page_addon_remove_cb (GsAppAddonRow *row, gpointer user_data)
+{
+ GsApp *addon;
+ GsDetailsPage *self = GS_DETAILS_PAGE (user_data);
+
+ addon = gs_app_addon_row_get_addon (row);
+ gs_page_remove_app (GS_PAGE (self), addon, NULL);
+}
+
+static void
+gs_details_page_app_launch_button_cb (GtkWidget *widget, GsDetailsPage *self)
+{
+ g_autoptr(GCancellable) cancellable = g_cancellable_new ();
+
+ /* hide the notification */
+ g_application_withdraw_notification (g_application_get_default (),
+ "installed");
+
+ g_set_object (&self->cancellable, cancellable);
+ g_cancellable_connect (cancellable, G_CALLBACK (gs_details_page_cancel_cb), self, NULL);
+ gs_page_launch_app (GS_PAGE (self), self->app, self->cancellable);
+}
+
+static void
+gs_details_page_review_response_cb (GtkDialog *dialog,
+ gint response,
+ GsDetailsPage *self)
+{
+ g_autofree gchar *text = NULL;
+ g_autoptr(GDateTime) now = NULL;
+ g_autoptr(AsReview) review = NULL;
+ GsReviewDialog *rdialog = GS_REVIEW_DIALOG (dialog);
+ g_autoptr(GError) local_error = NULL;
+
+ /* not agreed */
+ if (response != GTK_RESPONSE_OK) {
+ gtk_window_destroy (GTK_WINDOW (dialog));
+ return;
+ }
+
+ review = as_review_new ();
+ as_review_set_summary (review, gs_review_dialog_get_summary (rdialog));
+ text = gs_review_dialog_get_text (rdialog);
+ as_review_set_description (review, text);
+ as_review_set_rating (review, gs_review_dialog_get_rating (rdialog));
+ as_review_set_version (review, gs_app_get_version (self->app));
+ now = g_date_time_new_now_local ();
+ as_review_set_date (review, now);
+
+ /* call into the plugins to set the new value */
+ /* FIXME: Make this async */
+ g_assert (self->odrs_provider != NULL);
+
+ gs_odrs_provider_submit_review (self->odrs_provider, self->app, review,
+ self->cancellable, &local_error);
+
+ if (local_error != NULL) {
+ g_warning ("failed to set review on %s: %s",
+ gs_app_get_id (self->app), local_error->message);
+ return;
+ }
+
+ gs_details_page_refresh_reviews (self);
+
+ /* unmap the dialog */
+ gtk_window_destroy (GTK_WINDOW (dialog));
+}
+
+static void
+gs_details_page_write_review (GsDetailsPage *self)
+{
+ GtkWidget *dialog;
+ dialog = gs_review_dialog_new ();
+ g_signal_connect (dialog, "response",
+ G_CALLBACK (gs_details_page_review_response_cb), self);
+ gs_shell_modal_dialog_present (self->shell, GTK_WINDOW (dialog));
+}
+
+static void
+gs_details_page_app_installed (GsPage *page, GsApp *app)
+{
+ GsDetailsPage *self = GS_DETAILS_PAGE (page);
+ g_autoptr(GsAppList) addons = NULL;
+ guint i;
+
+ /* if the app is just an addon, no need for a full refresh */
+ addons = gs_app_dup_addons (self->app);
+ for (i = 0; addons != NULL && i < gs_app_list_length (addons); i++) {
+ GsApp *addon;
+ addon = gs_app_list_index (addons, i);
+ if (addon == app)
+ return;
+ }
+
+ gs_details_page_reload (page);
+}
+
+static void
+gs_details_page_app_removed (GsPage *page, GsApp *app)
+{
+ gs_details_page_app_installed (page, app);
+}
+
+static void
+gs_details_page_network_available_notify_cb (GsPluginLoader *plugin_loader,
+ GParamSpec *pspec,
+ GsDetailsPage *self)
+{
+ gs_details_page_refresh_reviews (self);
+}
+
+static void
+gs_details_page_star_pressed_cb (GtkGestureClick *click,
+ gint n_press,
+ gdouble x,
+ gdouble y,
+ GsDetailsPage *self)
+{
+ gs_details_page_write_review (self);
+}
+
+static void
+gs_details_page_shell_allocation_width_cb (GObject *shell,
+ GParamSpec *pspec,
+ GsDetailsPage *self)
+{
+ gint allocation_width = 0;
+ GtkOrientation orientation;
+
+ g_object_get (shell, "allocation-width", &allocation_width, NULL);
+
+ if (allocation_width > 0 && allocation_width < 500)
+ orientation = GTK_ORIENTATION_VERTICAL;
+ else
+ orientation = GTK_ORIENTATION_HORIZONTAL;
+
+ if (orientation != gtk_orientable_get_orientation (GTK_ORIENTABLE (self->box_details_header_not_icon)))
+ gtk_orientable_set_orientation (GTK_ORIENTABLE (self->box_details_header_not_icon), orientation);
+}
+
+static gboolean
+gs_details_page_setup (GsPage *page,
+ GsShell *shell,
+ GsPluginLoader *plugin_loader,
+ GCancellable *cancellable,
+ GError **error)
+{
+ GsDetailsPage *self = GS_DETAILS_PAGE (page);
+
+ g_return_val_if_fail (GS_IS_DETAILS_PAGE (self), FALSE);
+
+ self->shell = shell;
+
+ self->plugin_loader = g_object_ref (plugin_loader);
+ self->cancellable = g_cancellable_new ();
+ g_cancellable_connect (cancellable, G_CALLBACK (gs_details_page_cancel_cb), self, NULL);
+
+ g_signal_connect_object (self->shell, "notify::allocation-width",
+ G_CALLBACK (gs_details_page_shell_allocation_width_cb),
+ self, 0);
+
+ /* hide some UI when offline */
+ g_signal_connect_object (self->plugin_loader, "notify::network-available",
+ G_CALLBACK (gs_details_page_network_available_notify_cb),
+ self, 0);
+
+ gtk_list_box_set_sort_func (GTK_LIST_BOX (self->origin_popover_list_box),
+ origin_popover_list_sort_func,
+ NULL, NULL);
+ return TRUE;
+}
+
+static guint
+gs_details_page_strcase_hash (gconstpointer key)
+{
+ const gchar *ptr;
+ guint hsh = 0, gg;
+
+ for (ptr = (const gchar *) key; *ptr != '\0'; ptr++) {
+ hsh = (hsh << 4) + g_ascii_toupper (*ptr);
+ if ((gg = hsh & 0xf0000000)) {
+ hsh = hsh ^ (gg >> 24);
+ hsh = hsh ^ gg;
+ }
+ }
+
+ return hsh;
+}
+
+static gboolean
+gs_details_page_strcase_equal (gconstpointer key1,
+ gconstpointer key2)
+{
+ return g_ascii_strcasecmp ((const gchar *) key1, (const gchar *) key2) == 0;
+}
+
+static void
+gs_details_page_get_property (GObject *object,
+ guint prop_id,
+ GValue *value,
+ GParamSpec *pspec)
+{
+ GsDetailsPage *self = GS_DETAILS_PAGE (object);
+
+ switch ((GsDetailsPageProperty) prop_id) {
+ case PROP_TITLE:
+ switch (gs_details_page_get_state (self)) {
+ case GS_DETAILS_PAGE_STATE_LOADING:
+ /* 'Loading' is shown in the page already, no need to repeat it in the title */
+ g_value_set_string (value, NULL);
+ break;
+ case GS_DETAILS_PAGE_STATE_READY:
+ g_value_set_string (value, gs_app_get_name (self->app));
+ break;
+ case GS_DETAILS_PAGE_STATE_FAILED:
+ g_value_set_string (value, NULL);
+ break;
+ default:
+ g_assert_not_reached ();
+ }
+ break;
+ case PROP_ODRS_PROVIDER:
+ g_value_set_object (value, gs_details_page_get_odrs_provider (self));
+ break;
+ case PROP_IS_NARROW:
+ g_value_set_boolean (value, gs_details_page_get_is_narrow (self));
+ break;
+ default:
+ G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+ break;
+ }
+}
+
+static void
+gs_details_page_set_property (GObject *object,
+ guint prop_id,
+ const GValue *value,
+ GParamSpec *pspec)
+{
+ GsDetailsPage *self = GS_DETAILS_PAGE (object);
+
+ switch ((GsDetailsPageProperty) prop_id) {
+ case PROP_TITLE:
+ /* Read only */
+ g_assert_not_reached ();
+ break;
+ case PROP_ODRS_PROVIDER:
+ gs_details_page_set_odrs_provider (self, g_value_get_object (value));
+ break;
+ case PROP_IS_NARROW:
+ gs_details_page_set_is_narrow (self, g_value_get_boolean (value));
+ break;
+ default:
+ G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
+ break;
+ }
+}
+
+static void
+gs_details_page_dispose (GObject *object)
+{
+ GsDetailsPage *self = GS_DETAILS_PAGE (object);
+
+ if (self->app != NULL) {
+ g_signal_handlers_disconnect_by_func (self->app, gs_details_page_notify_state_changed_cb, self);
+ g_signal_handlers_disconnect_by_func (self->app, gs_details_page_progress_changed_cb, self);
+ g_clear_object (&self->app);
+ }
+ if (self->packaging_format_preference) {
+ g_hash_table_unref (self->packaging_format_preference);
+ self->packaging_format_preference = NULL;
+ }
+ g_clear_object (&self->origin_css_provider);
+ g_clear_object (&self->app_local_file);
+ g_clear_object (&self->app_reviews_dialog);
+ g_clear_object (&self->plugin_loader);
+ g_clear_object (&self->cancellable);
+ g_clear_object (&self->app_cancellable);
+ g_clear_object (&self->odrs_provider);
+ g_clear_object (&self->app_info_monitor);
+ g_clear_pointer (&self->last_developer_name, g_free);
+
+ G_OBJECT_CLASS (gs_details_page_parent_class)->dispose (object);
+}
+
+static void
+gs_details_page_class_init (GsDetailsPageClass *klass)
+{
+ GObjectClass *object_class = G_OBJECT_CLASS (klass);
+ GsPageClass *page_class = GS_PAGE_CLASS (klass);
+ GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass);
+
+ object_class->get_property = gs_details_page_get_property;
+ object_class->set_property = gs_details_page_set_property;
+ object_class->dispose = gs_details_page_dispose;
+
+ page_class->app_installed = gs_details_page_app_installed;
+ page_class->app_removed = gs_details_page_app_removed;
+ page_class->switch_to = gs_details_page_switch_to;
+ page_class->reload = gs_details_page_reload;
+ page_class->setup = gs_details_page_setup;
+
+ /**
+ * GsDetailsPage:odrs-provider: (nullable)
+ *
+ * An ODRS provider to give access to ratings and reviews information
+ * for the app being displayed.
+ *
+ * If this is %NULL, ratings and reviews will be disabled.
+ *
+ * Since: 41
+ */
+ obj_props[PROP_ODRS_PROVIDER] =
+ g_param_spec_object ("odrs-provider", NULL, NULL,
+ GS_TYPE_ODRS_PROVIDER,
+ G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS);
+
+ /**
+ * GsDetailsPage:is-narrow:
+ *
+ * Whether the page is in narrow mode.
+ *
+ * In narrow mode, the page will take up less horizontal space, doing so
+ * by e.g. turning horizontal boxes into vertical ones. This is needed
+ * to keep the UI useable on small form-factors like smartphones.
+ *
+ * Since: 41
+ */
+ obj_props[PROP_IS_NARROW] =
+ g_param_spec_boolean ("is-narrow", NULL, NULL,
+ FALSE,
+ 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);
+
+ g_object_class_override_property (object_class, PROP_TITLE, "title");
+
+ /**
+ * GsDetailsPage::metainfo-loaded:
+ * @app: a #GsApp
+ *
+ * Emitted after a custom metainfo @app is loaded in the page, but before
+ * it's fully shown.
+ *
+ * Since: 42
+ */
+ signals[SIGNAL_METAINFO_LOADED] =
+ g_signal_new ("metainfo-loaded",
+ G_TYPE_FROM_CLASS (object_class), G_SIGNAL_RUN_LAST,
+ 0, NULL, NULL, g_cclosure_marshal_VOID__OBJECT,
+ G_TYPE_NONE, 1, GS_TYPE_APP);
+
+ /**
+ * GsDetailsPage::app-clicked:
+ * @app: the #GsApp which was clicked on
+ *
+ * Emitted when one of the app tiles is clicked. Typically the caller
+ * should display the details of the given app in the callback.
+ *
+ * Since: 43
+ */
+ signals[SIGNAL_APP_CLICKED] =
+ g_signal_new ("app-clicked",
+ G_TYPE_FROM_CLASS (object_class), G_SIGNAL_RUN_LAST,
+ 0, NULL, NULL, g_cclosure_marshal_VOID__OBJECT,
+ G_TYPE_NONE, 1, GS_TYPE_APP);
+
+ gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-details-page.ui");
+
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, application_details_icon);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, application_details_summary);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, application_details_title);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, box_addons);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, box_details);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, box_details_description);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, box_details_header);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, box_details_header_not_icon);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, label_webapp_warning);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, star);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, label_review_count);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, screenshot_carousel);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, button_details_launch);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, links_stack);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, project_website_row);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, donate_row);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, translate_row);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, report_an_issue_row);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, help_row);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, button_install);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, button_update);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, button_remove);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, button_cancel);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, infobar_details_app_norepo);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, infobar_details_app_repo);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, infobar_details_package_baseos);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, infobar_details_repo);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, label_addons_uninstalled_app);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, context_bar);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, label_progress_percentage);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, label_progress_status);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, developer_name_label);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, developer_verified_image);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, label_failed);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, list_box_addons);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, list_box_featured_review);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, list_box_reviews_summary);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, list_box_version_history);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, row_latest_version);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, version_history_button);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, box_reviews);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, box_reviews_internal);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, histogram);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, histogram_row);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, button_review);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, scrolledwindow_details);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, spinner_details);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, stack_details);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, box_with_source);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, origin_popover);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, origin_popover_list_box);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, origin_box);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, origin_packaging_image);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, origin_packaging_label);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, box_license);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, license_tile);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, translation_infobar);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, translation_infobar_button);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, developer_apps_heading);
+ gtk_widget_class_bind_template_child (widget_class, GsDetailsPage, box_developer_apps);
+
+ gtk_widget_class_bind_template_callback (widget_class, gs_details_page_link_row_activated_cb);
+ gtk_widget_class_bind_template_callback (widget_class, gs_details_page_license_tile_get_involved_activated_cb);
+ gtk_widget_class_bind_template_callback (widget_class, gs_details_page_translation_infobar_response_cb);
+ gtk_widget_class_bind_template_callback (widget_class, gs_details_page_star_pressed_cb);
+ gtk_widget_class_bind_template_callback (widget_class, gs_details_page_app_install_button_cb);
+ gtk_widget_class_bind_template_callback (widget_class, gs_details_page_app_update_button_cb);
+ gtk_widget_class_bind_template_callback (widget_class, gs_details_page_app_remove_button_cb);
+ gtk_widget_class_bind_template_callback (widget_class, gs_details_page_app_cancel_button_cb);
+ gtk_widget_class_bind_template_callback (widget_class, gs_details_page_app_launch_button_cb);
+ gtk_widget_class_bind_template_callback (widget_class, origin_popover_row_activated_cb);
+}
+
+static gboolean
+narrow_to_orientation (GBinding *binding, const GValue *from_value, GValue *to_value, gpointer user_data)
+{
+ if (g_value_get_boolean (from_value))
+ g_value_set_enum (to_value, GTK_ORIENTATION_VERTICAL);
+ else
+ g_value_set_enum (to_value, GTK_ORIENTATION_HORIZONTAL);
+
+ return TRUE;
+}
+
+static gboolean
+narrow_to_spacing (GBinding *binding, const GValue *from_value, GValue *to_value, gpointer user_data)
+{
+ if (g_value_get_boolean (from_value))
+ g_value_set_int (to_value, 12);
+ else
+ g_value_set_int (to_value, 24);
+
+ return TRUE;
+}
+
+static gboolean
+narrow_to_halign (GBinding *binding, const GValue *from_value, GValue *to_value, gpointer user_data)
+{
+ if (g_value_get_boolean (from_value))
+ g_value_set_enum (to_value, GTK_ALIGN_START);
+ else
+ g_value_set_enum (to_value, GTK_ALIGN_FILL);
+
+ return TRUE;
+}
+
+static void
+gs_details_page_init (GsDetailsPage *self)
+{
+ g_type_ensure (GS_TYPE_SCREENSHOT_CAROUSEL);
+
+ gtk_widget_init_template (GTK_WIDGET (self));
+
+ self->packaging_format_preference = g_hash_table_new_full (gs_details_page_strcase_hash, gs_details_page_strcase_equal, g_free, NULL);
+ self->settings = g_settings_new ("org.gnome.software");
+ g_signal_connect_swapped (self->settings, "changed",
+ G_CALLBACK (settings_changed_cb),
+ self);
+ self->app_info_monitor = g_app_info_monitor_get ();
+ g_signal_connect_object (self->app_info_monitor, "changed",
+ G_CALLBACK (gs_details_page_app_info_changed_cb), self, 0);
+
+ gtk_list_box_set_sort_func (GTK_LIST_BOX (self->list_box_addons),
+ list_sort_func,
+ self, NULL);
+
+ g_signal_connect (self->list_box_addons, "row-activated",
+ G_CALLBACK (addons_list_row_activated_cb), self);
+
+ g_signal_connect (self->list_box_version_history, "row-activated",
+ G_CALLBACK (version_history_list_row_activated_cb), self);
+
+ g_signal_connect_swapped (self->list_box_reviews_summary, "row-activated",
+ G_CALLBACK (gs_details_page_write_review), self);
+
+ g_signal_connect (self->list_box_featured_review, "row-activated",
+ G_CALLBACK (featured_review_list_row_activated_cb), self);
+
+ gs_details_page_read_packaging_format_preference (self);
+
+ g_object_bind_property_full (self, "is-narrow", self->box_details_header, "spacing", G_BINDING_SYNC_CREATE,
+ narrow_to_spacing, NULL, NULL, NULL);
+ g_object_bind_property_full (self, "is-narrow", self->box_with_source, "halign", G_BINDING_SYNC_CREATE,
+ narrow_to_halign, NULL, NULL, NULL);
+ g_object_bind_property_full (self, "is-narrow", self->box_license, "orientation", G_BINDING_SYNC_CREATE,
+ narrow_to_orientation, NULL, NULL, NULL);
+ g_object_bind_property_full (self, "is-narrow", self->context_bar, "orientation", G_BINDING_SYNC_CREATE,
+ narrow_to_orientation, NULL, NULL, NULL);
+ g_object_bind_property_full (self, "is-narrow", self->box_reviews_internal, "orientation", G_BINDING_SYNC_CREATE,
+ narrow_to_orientation, NULL, NULL, NULL);
+}
+
+GsDetailsPage *
+gs_details_page_new (void)
+{
+ return GS_DETAILS_PAGE (g_object_new (GS_TYPE_DETAILS_PAGE, NULL));
+}
+
+/**
+ * gs_details_page_get_odrs_provider:
+ * @self: a #GsDetailsPage
+ *
+ * Get the value of #GsDetailsPage:odrs-provider.
+ *
+ * Returns: (nullable) (transfer none): a #GsOdrsProvider, or %NULL if unset
+ * Since: 41
+ */
+GsOdrsProvider *
+gs_details_page_get_odrs_provider (GsDetailsPage *self)
+{
+ g_return_val_if_fail (GS_IS_DETAILS_PAGE (self), NULL);
+
+ return self->odrs_provider;
+}
+
+/**
+ * gs_details_page_set_odrs_provider:
+ * @self: a #GsDetailsPage
+ * @odrs_provider: (nullable) (transfer none): new #GsOdrsProvider or %NULL
+ *
+ * Set the value of #GsDetailsPage:odrs-provider.
+ *
+ * Since: 41
+ */
+void
+gs_details_page_set_odrs_provider (GsDetailsPage *self,
+ GsOdrsProvider *odrs_provider)
+{
+ g_return_if_fail (GS_IS_DETAILS_PAGE (self));
+ g_return_if_fail (odrs_provider == NULL || GS_IS_ODRS_PROVIDER (odrs_provider));
+
+ if (g_set_object (&self->odrs_provider, odrs_provider)) {
+ gs_details_page_refresh_reviews (self);
+ g_object_notify_by_pspec (G_OBJECT (self), obj_props[PROP_ODRS_PROVIDER]);
+ }
+}
+
+/**
+ * gs_details_page_get_is_narrow:
+ * @self: a #GsDetailsPage
+ *
+ * Get the value of #GsDetailsPage:is-narrow.
+ *
+ * Returns: %TRUE if the page is in narrow mode, %FALSE otherwise
+ *
+ * Since: 41
+ */
+gboolean
+gs_details_page_get_is_narrow (GsDetailsPage *self)
+{
+ g_return_val_if_fail (GS_IS_DETAILS_PAGE (self), FALSE);
+
+ return self->is_narrow;
+}
+
+/**
+ * gs_details_page_set_is_narrow:
+ * @self: a #GsDetailsPage
+ * @is_narrow: %TRUE to set the page in narrow mode, %FALSE otherwise
+ *
+ * Set the value of #GsDetailsPage:is-narrow.
+ *
+ * Since: 41
+ */
+void
+gs_details_page_set_is_narrow (GsDetailsPage *self, gboolean is_narrow)
+{
+ g_return_if_fail (GS_IS_DETAILS_PAGE (self));
+
+ is_narrow = !!is_narrow;
+
+ if (self->is_narrow == is_narrow)
+ return;
+
+ self->is_narrow = is_narrow;
+ g_object_notify_by_pspec (G_OBJECT (self), obj_props[PROP_IS_NARROW]);
+}
+
+static void
+gs_details_page_metainfo_ready_cb (GObject *source_object,
+ GAsyncResult *result,
+ gpointer user_data)
+{
+ GsDetailsPage *self = GS_DETAILS_PAGE (source_object);
+ g_autoptr(GsApp) app = NULL;
+ g_autoptr(GError) error = NULL;
+
+ app = g_task_propagate_pointer (G_TASK (result), &error);
+ if (error) {
+ gtk_label_set_text (GTK_LABEL (self->label_failed), error->message);
+ gs_details_page_set_state (self, GS_DETAILS_PAGE_STATE_FAILED);
+ return;
+ }
+
+ g_set_object (&self->app_local_file, app);
+ _set_app (self, app);
+ gs_details_page_load_stage2 (self, FALSE);
+
+ g_signal_emit (self, signals[SIGNAL_METAINFO_LOADED], 0, app);
+}
+
+static void
+gs_details_page_metainfo_thread (GTask *task,
+ gpointer source_object,
+ gpointer task_data,
+ GCancellable *cancellable)
+{
+ const gchar *const *locales;
+ g_autofree gchar *xml = NULL;
+ g_autofree gchar *path = NULL;
+ g_autofree gchar *icon_path = NULL;
+ g_autoptr(XbBuilder) builder = NULL;
+ g_autoptr(XbBuilderSource) builder_source = NULL;
+ g_autoptr(XbSilo) silo = NULL;
+ g_autoptr(GPtrArray) nodes = NULL;
+ g_autoptr(GError) error = NULL;
+ g_autoptr(GsApp) app = NULL;
+ g_autoptr(GFile) tmp_file = NULL;
+ GFile *file = task_data;
+ XbNode *component;
+
+ path = g_file_get_path (file);
+ if (path && strstr (path, ",icon=")) {
+ gchar *pos = strstr (path, ",icon=");
+
+ *pos = '\0';
+
+ tmp_file = g_file_new_for_path (path);
+ file = tmp_file;
+
+ pos += 6;
+ if (*pos)
+ icon_path = g_strdup (pos);
+ }
+ g_clear_pointer (&path, g_free);
+
+ builder_source = xb_builder_source_new ();
+ if (!xb_builder_source_load_file (builder_source, file, XB_BUILDER_SOURCE_FLAG_NONE, cancellable, &error)) {
+ g_task_return_error (task, g_steal_pointer (&error));
+ return;
+ }
+
+ builder = xb_builder_new ();
+ locales = g_get_language_names ();
+
+ /* add current locales */
+ for (guint i = 0; locales[i] != NULL; i++) {
+ xb_builder_add_locale (builder, locales[i]);
+ }
+
+ xb_builder_import_source (builder, builder_source);
+
+ silo = xb_builder_compile (builder, XB_BUILDER_COMPILE_FLAG_IGNORE_INVALID | XB_BUILDER_COMPILE_FLAG_SINGLE_LANG, cancellable, &error);
+ if (silo == NULL) {
+ g_task_return_error (task, g_steal_pointer (&error));
+ return;
+ }
+
+ nodes = xb_silo_query (silo, "component", 0, NULL);
+ if (nodes == NULL)
+ nodes = xb_silo_query (silo, "application", 0, NULL);
+ if (nodes == NULL) {
+ g_task_return_new_error (task, G_IO_ERROR, G_IO_ERROR_FAILED, "%s",
+ "Passed-in file doesn't have a 'component' (nor 'application') top-level element");
+ return;
+ }
+
+ if (nodes->len != 1) {
+ g_task_return_new_error (task, G_IO_ERROR, G_IO_ERROR_FAILED,
+ "Only one top-level element expected, received %u instead", nodes->len);
+ return;
+ }
+
+ component = g_ptr_array_index (nodes, 0);
+
+ app = gs_appstream_create_app (NULL, silo, component, &error);
+ if (app == NULL) {
+ g_task_return_error (task, g_steal_pointer (&error));
+ return;
+ }
+
+ if (!gs_appstream_refine_app (NULL, app, silo, component, GS_DETAILS_PAGE_REFINE_FLAGS, &error)) {
+ g_task_return_error (task, g_steal_pointer (&error));
+ return;
+ }
+
+ path = g_file_get_path (file);
+ gs_app_set_origin (app, path);
+
+ if (icon_path) {
+ g_autoptr(GFile) icon_file = g_file_new_for_path (icon_path);
+ g_autoptr(GIcon) icon = g_file_icon_new (icon_file);
+ gs_icon_set_width (icon, (guint) -1);
+ gs_app_add_icon (app, G_ICON (icon));
+ } else {
+ g_autoptr(SoupSession) soup_session = NULL;
+ guint maximum_icon_size;
+
+ /* Currently a 160px icon is needed for #GsFeatureTile, at most.
+ * The '2' is to pretend the hiDPI/GDK's scale factor is 2, to
+ * allow larger icons. The 'icons' plugin uses proper scale factor.
+ */
+ maximum_icon_size = 160 * 2;
+
+ soup_session = gs_build_soup_session ();
+ gs_app_ensure_icons_downloaded (app, soup_session, maximum_icon_size, cancellable);
+ }
+
+ gs_app_set_state (app, GS_APP_STATE_UNKNOWN);
+
+ g_task_return_pointer (task, g_steal_pointer (&app), g_object_unref);
+}
+
+/**
+ * gs_details_page_set_metainfo:
+ * @self: a #GsDetailsPage
+ * @file: path to a metainfo file to display
+ *
+ * Load and show the given metainfo @file on the details page.
+ *
+ * The file must be a single metainfo file, not an appstream file
+ * containing multiple components. It will be shown as if it came
+ * from a configured repository. This function is intended to be
+ * used by application developers wanting to test how their metainfo
+ * will appear to users.
+ *
+ * Since: 42
+ */
+void
+gs_details_page_set_metainfo (GsDetailsPage *self,
+ GFile *file)
+{
+ g_autoptr(GTask) task = NULL;
+
+ g_return_if_fail (GS_IS_DETAILS_PAGE (self));
+ g_return_if_fail (G_IS_FILE (file));
+ gs_details_page_set_state (self, GS_DETAILS_PAGE_STATE_LOADING);
+ g_clear_object (&self->app_local_file);
+ g_clear_object (&self->app);
+ self->origin_by_packaging_format = FALSE;
+ task = g_task_new (self, self->cancellable, gs_details_page_metainfo_ready_cb, NULL);
+ g_task_set_source_tag (task, gs_details_page_set_metainfo);
+ g_task_set_task_data (task, g_object_ref (file), g_object_unref);
+ g_task_run_in_thread (task, gs_details_page_metainfo_thread);
+}
+
+gdouble
+gs_details_page_get_vscroll_position (GsDetailsPage *self)
+{
+ GtkAdjustment *adj;
+
+ g_return_val_if_fail (GS_IS_DETAILS_PAGE (self), -1);
+
+ adj = gtk_scrolled_window_get_vadjustment (GTK_SCROLLED_WINDOW (self->scrolledwindow_details));
+ return gtk_adjustment_get_value (adj);
+}
+
+void
+gs_details_page_set_vscroll_position (GsDetailsPage *self,
+ gdouble value)
+{
+ GtkAdjustment *adj;
+
+ g_return_if_fail (GS_IS_DETAILS_PAGE (self));
+
+ adj = gtk_scrolled_window_get_vadjustment (GTK_SCROLLED_WINDOW (self->scrolledwindow_details));
+ if (value >= 0.0)
+ gtk_adjustment_set_value (adj, value);
+}