diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:57:27 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:57:27 +0000 |
commit | 6f0f7d1b40a8fa8d46a2d6f4317600001cdbbb18 (patch) | |
tree | d423850ae901365e582137bdf2b5cbdffd7ca266 /src | |
parent | Initial commit. (diff) | |
download | gnome-software-6f0f7d1b40a8fa8d46a2d6f4317600001cdbbb18.tar.xz gnome-software-6f0f7d1b40a8fa8d46a2d6f4317600001cdbbb18.zip |
Adding upstream version 43.5.upstream/43.5upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'src')
200 files changed, 49989 insertions, 0 deletions
diff --git a/src/gnome-software-local-file-flatpak.desktop.in b/src/gnome-software-local-file-flatpak.desktop.in new file mode 100644 index 0000000..803a581 --- /dev/null +++ b/src/gnome-software-local-file-flatpak.desktop.in @@ -0,0 +1,11 @@ +[Desktop Entry] +Name=Software Install +Comment=Install selected software on the system +Categories=System; +Exec=gnome-software --local-filename %f +Terminal=false +Type=Application +Icon=system-software-install +StartupNotify=true +NoDisplay=true +MimeType=application/vnd.flatpak;application/vnd.flatpak.repo;application/vnd.flatpak.ref; diff --git a/src/gnome-software-local-file-fwupd.desktop.in b/src/gnome-software-local-file-fwupd.desktop.in new file mode 100644 index 0000000..01a1d86 --- /dev/null +++ b/src/gnome-software-local-file-fwupd.desktop.in @@ -0,0 +1,11 @@ +[Desktop Entry] +Name=Software Install +Comment=Install selected software on the system +Categories=System; +Exec=gnome-software --local-filename %f +Terminal=false +Type=Application +Icon=system-software-install +StartupNotify=true +NoDisplay=true +MimeType=application/vnd.ms-cab-compressed; diff --git a/src/gnome-software-local-file-packagekit.desktop.in b/src/gnome-software-local-file-packagekit.desktop.in new file mode 100644 index 0000000..1dd0d56 --- /dev/null +++ b/src/gnome-software-local-file-packagekit.desktop.in @@ -0,0 +1,11 @@ +[Desktop Entry] +Name=Software Install +Comment=Install selected software on the system +Categories=System; +Exec=gnome-software --local-filename %f +Terminal=false +Type=Application +Icon=system-software-install +StartupNotify=true +NoDisplay=true +MimeType=application/x-rpm;application/x-redhat-package-manager;application/x-deb;application/x-app-package; diff --git a/src/gnome-software-local-file-snap.desktop.in b/src/gnome-software-local-file-snap.desktop.in new file mode 100644 index 0000000..d2ab5f6 --- /dev/null +++ b/src/gnome-software-local-file-snap.desktop.in @@ -0,0 +1,11 @@ +[Desktop Entry] +Name=Software Install +Comment=Install selected software on the system +Categories=System; +Exec=gnome-software --local-filename %f +Terminal=false +Type=Application +Icon=system-software-install +StartupNotify=true +NoDisplay=true +MimeType=application/vnd.snap; diff --git a/src/gnome-software.gresource.xml b/src/gnome-software.gresource.xml new file mode 100644 index 0000000..4efe369 --- /dev/null +++ b/src/gnome-software.gresource.xml @@ -0,0 +1,110 @@ +<?xml version="1.0" encoding="UTF-8"?> +<gresources> + <gresource prefix="/org/gnome/Software"> + <file preprocess="xml-stripblanks">gs-age-rating-context-dialog.ui</file> + <file preprocess="xml-stripblanks">gs-app-addon-row.ui</file> + <file preprocess="xml-stripblanks">gs-app-context-bar.ui</file> + <file preprocess="xml-stripblanks">gs-app-details-page.ui</file> + <file preprocess="xml-stripblanks">gs-app-reviews-dialog.ui</file> + <file preprocess="xml-stripblanks">gs-app-version-history-dialog.ui</file> + <file preprocess="xml-stripblanks">gs-app-version-history-row.ui</file> + <file preprocess="xml-stripblanks">gs-app-row.ui</file> + <file preprocess="xml-stripblanks">gs-app-translation-dialog.ui</file> + <file preprocess="xml-stripblanks">gs-basic-auth-dialog.ui</file> + <file preprocess="xml-stripblanks">gs-category-page.ui</file> + <file preprocess="xml-stripblanks">gs-category-tile.ui</file> + <file preprocess="xml-stripblanks">gs-context-dialog-row.ui</file> + <file preprocess="xml-stripblanks">gs-details-page.ui</file> + <file preprocess="xml-stripblanks">gs-extras-page.ui</file> + <file preprocess="xml-stripblanks">gs-feature-tile.ui</file> + <file preprocess="xml-stripblanks">gs-featured-carousel.ui</file> + <file preprocess="xml-stripblanks">gs-hardware-support-context-dialog.ui</file> + <file preprocess="xml-stripblanks">gs-info-bar.ui</file> + <file preprocess="xml-stripblanks">gs-info-window.ui</file> + <file preprocess="xml-stripblanks">gs-installed-page.ui</file> + <file preprocess="xml-stripblanks">gs-license-tile.ui</file> + <file preprocess="xml-stripblanks">gs-loading-page.ui</file> + <file preprocess="xml-stripblanks">gs-lozenge.ui</file> + <file preprocess="xml-stripblanks">gs-metered-data-dialog.ui</file> + <file preprocess="xml-stripblanks">gs-moderate-page.ui</file> + <file preprocess="xml-stripblanks">gs-overview-page.ui</file> + <file preprocess="xml-stripblanks">gs-origin-popover-row.ui</file> + <file preprocess="xml-stripblanks">gs-os-update-page.ui</file> + <file preprocess="xml-stripblanks">gs-prefs-dialog.ui</file> + <file preprocess="xml-stripblanks">gs-progress-button.ui</file> + <file preprocess="xml-stripblanks">gs-removal-dialog.ui</file> + <file preprocess="xml-stripblanks">gs-repo-row.ui</file> + <file preprocess="xml-stripblanks">gs-repos-dialog.ui</file> + <file preprocess="xml-stripblanks">gs-review-dialog.ui</file> + <file preprocess="xml-stripblanks">gs-review-histogram.ui</file> + <file preprocess="xml-stripblanks">gs-review-row.ui</file> + <file preprocess="xml-stripblanks">gs-safety-context-dialog.ui</file> + <file preprocess="xml-stripblanks">gs-screenshot-carousel.ui</file> + <file preprocess="xml-stripblanks">gs-screenshot-image.ui</file> + <file preprocess="xml-stripblanks">gs-search-page.ui</file> + <file preprocess="xml-stripblanks">gs-shell.ui</file> + <file preprocess="xml-stripblanks">gs-star-widget.ui</file> + <file preprocess="xml-stripblanks">gs-storage-context-dialog.ui</file> + <file preprocess="xml-stripblanks">gs-summary-tile.ui</file> + <file preprocess="xml-stripblanks">gs-update-dialog.ui</file> + <file preprocess="xml-stripblanks">gs-updates-page.ui</file> + <file preprocess="xml-stripblanks">gs-updates-section.ui</file> + <file preprocess="xml-stripblanks">gs-upgrade-banner.ui</file> + <file preprocess="xml-stripblanks">org.freedesktop.PackageKit.xml</file> + <file>style.css</file> + <file>style-dark.css</file> + <file>style-hc.css</file> + <file preprocess="xml-stripblanks" alias="up-to-date.svg">../data/assets/up-to-date.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/apps/system-component-addon.svg">../data/icons/system-component-addon.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/apps/system-component-application.svg">../data/icons/system-component-application.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/apps/system-component-codecs.svg">../data/icons/system-component-codecs.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/apps/system-component-driver.svg">../data/icons/system-component-driver.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/apps/system-component-firmware.svg">../data/icons/system-component-firmware.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/apps/system-component-input-sources.svg">../data/icons/system-component-input-sources.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/apps/system-component-language.svg">../data/icons/system-component-language.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/apps/system-component-os-updates.svg">../data/icons/system-component-os-updates.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/apps/system-component-runtime.svg">../data/icons/system-component-runtime.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/categories/org.gnome.Software.Create.svg">../data/icons/org.gnome.Software.Create.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/categories/org.gnome.Software.Develop.svg">../data/icons/org.gnome.Software.Develop.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/categories/org.gnome.Software.Learn.svg">../data/icons/org.gnome.Software.Learn.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/categories/org.gnome.Software.Play.svg">../data/icons/org.gnome.Software.Play.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/categories/org.gnome.Software.Socialize.svg">../data/icons/org.gnome.Software.Socialize.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/categories/org.gnome.Software.Work.svg">../data/icons/org.gnome.Software.Work.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/devices/adaptive-symbolic.svg">../data/icons/adaptive-symbolic.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/emblems/chat-none-symbolic.svg">../data/icons/chat-none-symbolic.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/emblems/chat-symbolic.svg">../data/icons/chat-symbolic.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/emblems/cigarette-none-symbolic.svg">../data/icons/cigarette-none-symbolic.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/emblems/cigarette-symbolic.svg">../data/icons/cigarette-symbolic.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/emblems/community-none-symbolic.svg">../data/icons/community-none-symbolic.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/emblems/community-symbolic.svg">../data/icons/community-symbolic.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/devices/desktop-symbolic.svg">../data/icons/desktop-symbolic.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/devices/emblem-synchronizing-symbolic.svg">../data/icons/emblem-synchronizing-symbolic.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/devices/explore-symbolic.svg">../data/icons/explore-symbolic.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/devices/external-link-symbolic.svg">../data/icons/external-link-symbolic.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/emblems/flag-outline-thin-symbolic.svg">../data/icons/flag-outline-thin-symbolic.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/emblems/graveyard-symbolic.svg">../data/icons/graveyard-symbolic.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/emblems/hand-open-symbolic.svg">../data/icons/hand-open-symbolic.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/devices/heart-filled-symbolic.svg">../data/icons/heart-filled-symbolic.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/emblems/help-link-symbolic.svg">../data/icons/help-link-symbolic.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/emblems/money-none-symbolic.svg">../data/icons/money-none-symbolic.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/emblems/money-symbolic.svg">../data/icons/money-symbolic.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/emblems/nudity-none-symbolic.svg">../data/icons/nudity-none-symbolic.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/emblems/nudity-symbolic.svg">../data/icons/nudity-symbolic.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/devices/app-installed-symbolic.svg">../data/icons/app-installed-symbolic.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/emblems/padlock-open-symbolic.svg">../data/icons/padlock-open-symbolic.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/devices/phone-symbolic.svg">../data/icons/phone-symbolic.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/emblems/pub-symbolic.svg">../data/icons/pub-symbolic.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/emblems/review-symbolic.svg">../data/icons/review-symbolic.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/emblems/safety-symbolic.svg">../data/icons/safety-symbolic.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/devices/sign-language-symbolic.svg">../data/icons/sign-language-symbolic.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/emblems/strong-language-none-symbolic.svg">../data/icons/strong-language-none-symbolic.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/emblems/strong-language-symbolic.svg">../data/icons/strong-language-symbolic.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/emblems/test-symbolic.svg">../data/icons/test-symbolic.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/emblems/violence-none-symbolic.svg">../data/icons/violence-none-symbolic.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/emblems/violence-symbolic.svg">../data/icons/violence-symbolic.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/emblems/webpage-symbolic.svg">../data/icons/webpage-symbolic.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/emblems/flatpak-symbolic.svg">../data/icons/flatpak-symbolic.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/emblems/package-x-generic-symbolic.svg">../data/icons/package-x-generic-symbolic.svg</file> + <file preprocess="xml-stripblanks" alias="icons/scalable/emblems/snap-symbolic.svg">../data/icons/snap-symbolic.svg</file> + </gresource> +</gresources> diff --git a/src/gnome-software.xml b/src/gnome-software.xml new file mode 100644 index 0000000..5f87829 --- /dev/null +++ b/src/gnome-software.xml @@ -0,0 +1,74 @@ +<?xml version='1.0'?> +<!DOCTYPE refentry PUBLIC "-//OASIS//DTD DocBook XML V4.2//EN" + "http://www.oasis-open.org/docbook/xml/4.2/docbookx.dtd"> + +<refentry id="gnome-software"> + + <refentryinfo> + <title>gnome-software</title> + <productname>GNOME</productname> + <author> + <contrib>Maintainer</contrib> + <firstname>Richard</firstname> + <surname>Hughes</surname> + <email>richard@hughsie.com</email> + </author> + <copyright> + <year>2013</year> + <holder>Richard Hughes</holder> + </copyright> + </refentryinfo> + + <refmeta> + <refentrytitle>gnome-software</refentrytitle> + <manvolnum>1</manvolnum> + <refmiscinfo class="manual">User Commands</refmiscinfo> + </refmeta> + + <refnamediv> + <refname>gnome-software</refname> + <refpurpose>Install applications</refpurpose> + </refnamediv> + + <refsynopsisdiv> + <cmdsynopsis> + <command>gnome-software</command> + <arg choice="opt" rep="repeat">OPTION</arg> + </cmdsynopsis> + </refsynopsisdiv> + + <refsect1> + <title>Description</title> + <para> + This manual page documents briefly the <command>gnome-software</command> command. + </para> + <para> + <command>gnome-software</command> allows you to add and remove + applications and update your system. + </para> + </refsect1> + + <refsect1> + <title>Options</title> + <variablelist> + <varlistentry> + <term><option>-?</option>, <option>--help</option></term> + <listitem><para>Prints a short help text and exits.</para></listitem> + </varlistentry> + <varlistentry> + <term><option>--version</option></term> + <listitem><para>Prints the program version and exits.</para></listitem> + </varlistentry> + <varlistentry> + <term><option>--mode</option> <replaceable>MODE</replaceable></term> + <listitem><para>Starts gnome-software in the given mode. <replaceable>MODE</replaceable> can be 'updates', 'updated', 'installed' or 'overview'. The default mode is 'overview'.</para></listitem> + </varlistentry> + </variablelist> + </refsect1> + + <refsect1> + <title>Author</title> + <para>This manual page was written by Richard Hughes <email>richard@hughsie.com</email>. + </para> + </refsect1> +</refentry> diff --git a/src/gs-age-rating-context-dialog.c b/src/gs-age-rating-context-dialog.c new file mode 100644 index 0000000..4ecf9c5 --- /dev/null +++ b/src/gs-age-rating-context-dialog.c @@ -0,0 +1,1253 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2021 Endless OS Foundation LLC + * + * Author: Philip Withnall <pwithnall@endlessos.org> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +/** + * SECTION:gs-age-rating-context-dialog + * @short_description: A dialog showing age rating information about an app + * + * #GsAgeRatingContextDialog is a dialog which shows detailed information + * about the suitability of the content in an app for different ages. It gives + * a breakdown of which content is more or less suitable for younger audiences. + * This information is derived from the `<content_rating>` element in the app’s + * appdata. + * + * It is designed to show a more detailed view of the information which the + * app’s age rating tile in #GsAppContextBar is derived from. + * + * The widget has no special appearance if the app is unset, so callers will + * typically want to hide the dialog in that case. + * + * Since: 41 + */ + +#include "config.h" + +#include <adwaita.h> +#include <glib.h> +#include <glib-object.h> +#include <glib/gi18n.h> +#include <gtk/gtk.h> +#include <locale.h> + +#include "gs-app.h" +#include "gs-common.h" +#include "gs-context-dialog-row.h" +#include "gs-age-rating-context-dialog.h" + +typedef enum { + GS_AGE_RATING_GROUP_TYPE_DRUGS, + GS_AGE_RATING_GROUP_TYPE_LANGUAGE, + GS_AGE_RATING_GROUP_TYPE_MONEY, + GS_AGE_RATING_GROUP_TYPE_SEX, + GS_AGE_RATING_GROUP_TYPE_SOCIAL, + GS_AGE_RATING_GROUP_TYPE_VIOLENCE, +} GsAgeRatingGroupType; + +#define GS_AGE_RATING_GROUP_TYPE_COUNT (GS_AGE_RATING_GROUP_TYPE_VIOLENCE+1) + +typedef struct { + gchar *id; + gchar *icon_name; + GsContextDialogRowImportance importance; + gchar *title; + gchar *description; +} GsAgeRatingAttribute; + +struct _GsAgeRatingContextDialog +{ + GsInfoWindow parent_instance; + + GsApp *app; /* (nullable) (owned) */ + gulong app_notify_handler_content_rating; + gulong app_notify_handler_name; + GsContextDialogRow *rows[GS_AGE_RATING_GROUP_TYPE_COUNT]; /* (unowned) */ + GList *attributes[GS_AGE_RATING_GROUP_TYPE_COUNT]; /* (element-type GsAgeRatingAttribute) */ + + GsLozenge *lozenge; + GtkLabel *title; + GtkListBox *attributes_list; /* (element-type GsContextDialogRow) */ +}; + +G_DEFINE_TYPE (GsAgeRatingContextDialog, gs_age_rating_context_dialog, GS_TYPE_INFO_WINDOW) + +typedef enum { + PROP_APP = 1, +} GsAgeRatingContextDialogProperty; + +static GParamSpec *obj_props[PROP_APP + 1] = { NULL, }; + +static GsAgeRatingAttribute * +gs_age_rating_attribute_new (const gchar *id, + const gchar *icon_name, + GsContextDialogRowImportance importance, + const gchar *title, + const gchar *description) +{ + GsAgeRatingAttribute *attributes; + + g_assert (icon_name != NULL); + g_assert (title != NULL); + g_assert (description != NULL); + + attributes = g_new0 (GsAgeRatingAttribute, 1); + attributes->id = g_strdup (id); + attributes->icon_name = g_strdup (icon_name); + attributes->importance = importance; + attributes->title = g_strdup (title); + attributes->description = g_strdup (description); + + return attributes; +} + +static void +gs_age_rating_attribute_free (GsAgeRatingAttribute *attributes) +{ + g_free (attributes->id); + g_free (attributes->icon_name); + g_free (attributes->title); + g_free (attributes->description); + g_free (attributes); +} + +/* FIXME: Ideally this data would move into libappstream, to be next to the + * other per-attribute strings and data which it already stores. */ +static const struct { + const gchar *id; /* (not nullable) */ + GsAgeRatingGroupType group_type; + const gchar *title; /* (not nullable) */ + const gchar *unknown_description; /* (not nullable) */ + const gchar *icon_name; /* (not nullable) */ + const gchar *icon_name_negative; /* (nullable) */ +} attribute_details[] = { + /* v1.0 */ + { + "violence-cartoon", + GS_AGE_RATING_GROUP_TYPE_VIOLENCE, + /* TRANSLATORS: content rating title, see https://hughsie.github.io/oars/ */ + N_("Cartoon Violence"), + /* TRANSLATORS: content rating description, see https://hughsie.github.io/oars/ */ + N_("No information regarding cartoon violence"), + "violence-symbolic", + "violence-none-symbolic", + }, + { + "violence-fantasy", + GS_AGE_RATING_GROUP_TYPE_VIOLENCE, + /* TRANSLATORS: content rating title, see https://hughsie.github.io/oars/ */ + N_("Fantasy Violence"), + /* TRANSLATORS: content rating description, see https://hughsie.github.io/oars/ */ + N_("No information regarding fantasy violence"), + "violence-symbolic", + "violence-none-symbolic", + }, + { + "violence-realistic", + GS_AGE_RATING_GROUP_TYPE_VIOLENCE, + /* TRANSLATORS: content rating title, see https://hughsie.github.io/oars/ */ + N_("Realistic Violence"), + /* TRANSLATORS: content rating description, see https://hughsie.github.io/oars/ */ + N_("No information regarding realistic violence"), + "violence-symbolic", + "violence-none-symbolic", + }, + { + "violence-bloodshed", + GS_AGE_RATING_GROUP_TYPE_VIOLENCE, + /* TRANSLATORS: content rating title, see https://hughsie.github.io/oars/ */ + N_("Violence Depicting Bloodshed"), + /* TRANSLATORS: content rating description, see https://hughsie.github.io/oars/ */ + N_("No information regarding bloodshed"), + "violence-symbolic", + "violence-none-symbolic", + }, + { + "violence-sexual", + GS_AGE_RATING_GROUP_TYPE_VIOLENCE, + /* TRANSLATORS: content rating title, see https://hughsie.github.io/oars/ */ + N_("Sexual Violence"), + /* TRANSLATORS: content rating description, see https://hughsie.github.io/oars/ */ + N_("No information regarding sexual violence"), + "violence-symbolic", + "violence-none-symbolic", + }, + { + "drugs-alcohol", + GS_AGE_RATING_GROUP_TYPE_DRUGS, + /* TRANSLATORS: content rating title, see https://hughsie.github.io/oars/ */ + N_("Alcohol"), + /* TRANSLATORS: content rating description, see https://hughsie.github.io/oars/ */ + N_("No information regarding references to alcohol"), + "pub-symbolic", + NULL, + }, + { + "drugs-narcotics", + GS_AGE_RATING_GROUP_TYPE_DRUGS, + /* TRANSLATORS: content rating title, see https://hughsie.github.io/oars/ */ + N_("Narcotics"), + /* TRANSLATORS: content rating description, see https://hughsie.github.io/oars/ */ + N_("No information regarding references to illicit drugs"), + "cigarette-symbolic", + "cigarette-none-symbolic", + }, + { + "drugs-tobacco", + GS_AGE_RATING_GROUP_TYPE_DRUGS, + /* TRANSLATORS: content rating title, see https://hughsie.github.io/oars/ */ + N_("Tobacco"), + /* TRANSLATORS: content rating description, see https://hughsie.github.io/oars/ */ + N_("No information regarding references to tobacco products"), + "cigarette-symbolic", + "cigarette-none-symbolic", + }, + { + "sex-nudity", + GS_AGE_RATING_GROUP_TYPE_SEX, + /* TRANSLATORS: content rating title, see https://hughsie.github.io/oars/ */ + N_("Nudity"), + /* TRANSLATORS: content rating description, see https://hughsie.github.io/oars/ */ + N_("No information regarding nudity of any sort"), + "nudity-symbolic", + "nudity-none-symbolic", + }, + { + "sex-themes", + GS_AGE_RATING_GROUP_TYPE_SEX, + /* TRANSLATORS: content rating title, see https://hughsie.github.io/oars/ */ + N_("Sexual Themes"), + /* TRANSLATORS: content rating description, see https://hughsie.github.io/oars/ */ + N_("No information regarding references to or depictions of sexual nature"), + "nudity-symbolic", + "nudity-none-symbolic", + }, + { + "language-profanity", + GS_AGE_RATING_GROUP_TYPE_LANGUAGE, + /* TRANSLATORS: content rating title, see https://hughsie.github.io/oars/ */ + N_("Profanity"), + /* TRANSLATORS: content rating description, see https://hughsie.github.io/oars/ */ + N_("No information regarding profanity of any kind"), + "strong-language-symbolic", + "strong-language-none-symbolic", + }, + { + "language-humor", + GS_AGE_RATING_GROUP_TYPE_LANGUAGE, + /* TRANSLATORS: content rating title, see https://hughsie.github.io/oars/ */ + N_("Inappropriate Humor"), + /* TRANSLATORS: content rating description, see https://hughsie.github.io/oars/ */ + N_("No information regarding inappropriate humor"), + "strong-language-symbolic", + "strong-language-none-symbolic", + }, + { + "language-discrimination", + GS_AGE_RATING_GROUP_TYPE_SOCIAL, + /* TRANSLATORS: content rating title, see https://hughsie.github.io/oars/ */ + N_("Discrimination"), + /* TRANSLATORS: content rating description, see https://hughsie.github.io/oars/ */ + N_("No information regarding discriminatory language of any kind"), + "chat-symbolic", + "chat-none-symbolic", + }, + { + "money-advertising", + GS_AGE_RATING_GROUP_TYPE_MONEY, + /* TRANSLATORS: content rating title, see https://hughsie.github.io/oars/ */ + N_("Advertising"), + /* TRANSLATORS: content rating description, see https://hughsie.github.io/oars/ */ + N_("No information regarding advertising of any kind"), + "money-symbolic", + "money-none-symbolic", + }, + { + "money-gambling", + GS_AGE_RATING_GROUP_TYPE_MONEY, + /* TRANSLATORS: content rating title, see https://hughsie.github.io/oars/ */ + N_("Gambling"), + /* TRANSLATORS: content rating description, see https://hughsie.github.io/oars/ */ + N_("No information regarding gambling of any kind"), + "money-symbolic", + "money-none-symbolic", + }, + { + "money-purchasing", + GS_AGE_RATING_GROUP_TYPE_MONEY, + /* TRANSLATORS: content rating title, see https://hughsie.github.io/oars/ */ + N_("Purchasing"), + /* TRANSLATORS: content rating description, see https://hughsie.github.io/oars/ */ + N_("No information regarding the ability to spend money"), + "money-symbolic", + "money-none-symbolic", + }, + { + "social-chat", + GS_AGE_RATING_GROUP_TYPE_SOCIAL, + /* TRANSLATORS: content rating title, see https://hughsie.github.io/oars/ */ + N_("Chat Between Users"), + /* TRANSLATORS: content rating description, see https://hughsie.github.io/oars/ */ + N_("No information regarding ways to chat with other users"), + "chat-symbolic", + "chat-none-symbolic", + }, + { + "social-audio", + GS_AGE_RATING_GROUP_TYPE_SOCIAL, + /* TRANSLATORS: content rating title, see https://hughsie.github.io/oars/ */ + N_("Audio Chat Between Users"), + /* TRANSLATORS: content rating description, see https://hughsie.github.io/oars/ */ + N_("No information regarding ways to talk with other users"), + "audio-headset-symbolic", + NULL, + }, + { + "social-contacts", + GS_AGE_RATING_GROUP_TYPE_SOCIAL, + /* TRANSLATORS: content rating title, see https://hughsie.github.io/oars/ */ + N_("Contact Details"), + /* TRANSLATORS: content rating description, see https://hughsie.github.io/oars/ */ + N_("No information regarding sharing of social network usernames or email addresses"), + "contact-new-symbolic", + NULL, + }, + { + "social-info", + GS_AGE_RATING_GROUP_TYPE_SOCIAL, + /* TRANSLATORS: content rating title, see https://hughsie.github.io/oars/ */ + N_("Identifying Information"), + /* TRANSLATORS: content rating description, see https://hughsie.github.io/oars/ */ + N_("No information regarding sharing of user information with third parties"), + "x-office-address-book-symbolic", + NULL, + }, + { + "social-location", + GS_AGE_RATING_GROUP_TYPE_SOCIAL, + /* TRANSLATORS: content rating title, see https://hughsie.github.io/oars/ */ + N_("Location Sharing"), + /* TRANSLATORS: content rating description, see https://hughsie.github.io/oars/ */ + N_("No information regarding sharing of physical location with other users"), + "location-services-active-symbolic", + "location-services-disabled-symbolic", + }, + + /* v1.1 */ + { + /* Why is there an OARS category which discriminates based on sexual orientation? + * It’s because there are, very unfortunately, still countries in the world in + * which homosexuality, or software which refers to it, is illegal. In order to be + * able to ship FOSS in those countries, there needs to be a mechanism for apps to + * describe whether they refer to anything illegal, and for ratings mechanisms in + * those countries to filter out any apps which describe themselves as such. + * + * As a counterpoint, it’s illegal in many more countries to discriminate on the + * basis of sexual orientation, so this category is treated exactly the same as + * sex-themes (once the intensities of the ratings levels for both categories are + * normalised) in those countries. + * + * The differences between countries are handled through handling #AsContentRatingSystem + * values differently. */ + "sex-homosexuality", + GS_AGE_RATING_GROUP_TYPE_SEX, + /* TRANSLATORS: content rating title, see https://hughsie.github.io/oars/ */ + N_("Homosexuality"), + /* TRANSLATORS: content rating description, see https://hughsie.github.io/oars/ */ + N_("No information regarding references to homosexuality"), + "nudity-symbolic", + "nudity-none-symbolic", + }, + { + "sex-prostitution", + GS_AGE_RATING_GROUP_TYPE_SEX, + /* TRANSLATORS: content rating title, see https://hughsie.github.io/oars/ */ + N_("Prostitution"), + /* TRANSLATORS: content rating description, see https://hughsie.github.io/oars/ */ + N_("No information regarding references to prostitution"), + "nudity-symbolic", + "nudity-none-symbolic", + }, + { + "sex-adultery", + GS_AGE_RATING_GROUP_TYPE_SEX, + /* TRANSLATORS: content rating title, see https://hughsie.github.io/oars/ */ + N_("Adultery"), + /* TRANSLATORS: content rating description, see https://hughsie.github.io/oars/ */ + N_("No information regarding references to adultery"), + "nudity-symbolic", + "nudity-none-symbolic", + }, + { + "sex-appearance", + GS_AGE_RATING_GROUP_TYPE_SEX, + /* TRANSLATORS: content rating title, see https://hughsie.github.io/oars/ */ + N_("Sexualized Characters"), + /* TRANSLATORS: content rating description, see https://hughsie.github.io/oars/ */ + N_("No information regarding sexualized characters"), + "nudity-symbolic", + "nudity-none-symbolic", + }, + { + "violence-worship", + GS_AGE_RATING_GROUP_TYPE_VIOLENCE, + /* TRANSLATORS: content rating title, see https://hughsie.github.io/oars/ */ + N_("Desecration"), + /* TRANSLATORS: content rating description, see https://hughsie.github.io/oars/ */ + N_("No information regarding references to desecration"), + "violence-symbolic", + "violence-none-symbolic", + }, + { + "violence-desecration", + GS_AGE_RATING_GROUP_TYPE_VIOLENCE, + /* TRANSLATORS: content rating title, see https://hughsie.github.io/oars/ */ + N_("Human Remains"), + /* TRANSLATORS: content rating description, see https://hughsie.github.io/oars/ */ + N_("No information regarding visible dead human remains"), + "graveyard-symbolic", + NULL, + }, + { + "violence-slavery", + GS_AGE_RATING_GROUP_TYPE_VIOLENCE, + /* TRANSLATORS: content rating title, see https://hughsie.github.io/oars/ */ + N_("Slavery"), + /* TRANSLATORS: content rating description, see https://hughsie.github.io/oars/ */ + N_("No information regarding references to slavery"), + "violence-symbolic", + "violence-none-symbolic", + }, +}; + +/* Get the `icon_name` (or, if @negative_version is %TRUE, the + * `icon_name_negative`) from @attribute_details for the given @attribute. + * If `icon_name_negative` is %NULL, fall back to returning `icon_name`. */ +static const gchar * +content_rating_attribute_get_icon_name (const gchar *attribute, + gboolean negative_version) +{ + for (gsize i = 0; i < G_N_ELEMENTS (attribute_details); i++) { + if (g_str_equal (attribute, attribute_details[i].id)) { + if (negative_version && attribute_details[i].icon_name_negative != NULL) + return attribute_details[i].icon_name_negative; + return attribute_details[i].icon_name; + } + } + + /* Attribute not handled */ + g_assert_not_reached (); +} + +/* Get the `title` from @attribute_details for the given @attribute. */ +static const gchar * +content_rating_attribute_get_title (const gchar *attribute) +{ + for (gsize i = 0; i < G_N_ELEMENTS (attribute_details); i++) { + if (g_str_equal (attribute, attribute_details[i].id)) { + return _(attribute_details[i].title); + } + } + + /* Attribute not handled */ + g_assert_not_reached (); +} + +/* Get the `unknown_description` from @attribute_details for the given @attribute. */ +static const gchar * +content_rating_attribute_get_unknown_description (const gchar *attribute) +{ + for (gsize i = 0; i < G_N_ELEMENTS (attribute_details); i++) { + if (g_str_equal (attribute, attribute_details[i].id)) { + return _(attribute_details[i].unknown_description); + } + } + + /* Attribute not handled */ + g_assert_not_reached (); +} + +/* Get the `title` from @attribute_details for the given @attribute. */ +static GsAgeRatingGroupType +content_rating_attribute_get_group_type (const gchar *attribute) +{ + for (gsize i = 0; i < G_N_ELEMENTS (attribute_details); i++) { + if (g_str_equal (attribute, attribute_details[i].id)) { + return attribute_details[i].group_type; + } + } + + /* Attribute not handled */ + g_assert_not_reached (); +} + +static const gchar * +content_rating_group_get_description (GsAgeRatingGroupType group_type) +{ + switch (group_type) { + case GS_AGE_RATING_GROUP_TYPE_DRUGS: + return _("Does not include references to drugs"); + case GS_AGE_RATING_GROUP_TYPE_LANGUAGE: + return _("Does not include swearing, profanity, and other kinds of strong language"); + case GS_AGE_RATING_GROUP_TYPE_MONEY: + return _("Does not include ads or monetary transactions"); + case GS_AGE_RATING_GROUP_TYPE_SEX: + return _("Does not include sex or nudity"); + case GS_AGE_RATING_GROUP_TYPE_SOCIAL: + return _("Does not include uncontrolled chat functionality"); + case GS_AGE_RATING_GROUP_TYPE_VIOLENCE: + return _("Does not include violence"); + default: + g_assert_not_reached (); + } +} + +static const gchar * +content_rating_group_get_icon_name (GsAgeRatingGroupType group_type, + gboolean negative_version) +{ + switch (group_type) { + case GS_AGE_RATING_GROUP_TYPE_DRUGS: + return negative_version ? "cigarette-none-symbolic" : "cigarette-symbolic"; + case GS_AGE_RATING_GROUP_TYPE_LANGUAGE: + return negative_version ? "strong-language-none-symbolic" : "strong-language-symbolic"; + case GS_AGE_RATING_GROUP_TYPE_MONEY: + return negative_version ? "money-none-symbolic" : "money-symbolic"; + case GS_AGE_RATING_GROUP_TYPE_SEX: + return negative_version ? "nudity-none-symbolic" : "nudity-symbolic"; + case GS_AGE_RATING_GROUP_TYPE_SOCIAL: + return negative_version ? "chat-none-symbolic" : "chat-symbolic"; + case GS_AGE_RATING_GROUP_TYPE_VIOLENCE: + return negative_version ? "violence-none-symbolic" : "violence-symbolic"; + default: + g_assert_not_reached (); + } +} + +static const gchar * +content_rating_group_get_title (GsAgeRatingGroupType group_type) +{ + switch (group_type) { + case GS_AGE_RATING_GROUP_TYPE_DRUGS: + return _("Drugs"); + case GS_AGE_RATING_GROUP_TYPE_LANGUAGE: + return _("Strong Language"); + case GS_AGE_RATING_GROUP_TYPE_MONEY: + return _("Money"); + case GS_AGE_RATING_GROUP_TYPE_SEX: + return _("Nudity"); + case GS_AGE_RATING_GROUP_TYPE_SOCIAL: + return _("Social"); + case GS_AGE_RATING_GROUP_TYPE_VIOLENCE: + return _("Violence"); + default: + g_assert_not_reached (); + } +} + +static GsContextDialogRowImportance +content_rating_value_get_importance (AsContentRatingValue value) +{ + switch (value) { + case AS_CONTENT_RATING_VALUE_NONE: + return GS_CONTEXT_DIALOG_ROW_IMPORTANCE_UNIMPORTANT; + case AS_CONTENT_RATING_VALUE_UNKNOWN: + return GS_CONTEXT_DIALOG_ROW_IMPORTANCE_NEUTRAL; + case AS_CONTENT_RATING_VALUE_MILD: + case AS_CONTENT_RATING_VALUE_MODERATE: + return GS_CONTEXT_DIALOG_ROW_IMPORTANCE_WARNING; + case AS_CONTENT_RATING_VALUE_INTENSE: + return GS_CONTEXT_DIALOG_ROW_IMPORTANCE_IMPORTANT; + default: + g_assert_not_reached (); + } +} + +static gint +attributes_compare (GsAgeRatingAttribute *attributes1, + GsAgeRatingAttribute *attributes2) +{ + if (attributes1->importance != attributes2->importance) { + /* Sort neutral attributes before unimportant ones. */ + if (attributes1->importance == GS_CONTEXT_DIALOG_ROW_IMPORTANCE_NEUTRAL && + attributes2->importance == GS_CONTEXT_DIALOG_ROW_IMPORTANCE_UNIMPORTANT) + return -1; + if (attributes1->importance == GS_CONTEXT_DIALOG_ROW_IMPORTANCE_UNIMPORTANT && + attributes2->importance == GS_CONTEXT_DIALOG_ROW_IMPORTANCE_NEUTRAL) + return 1; + + /* Important attributes come first */ + return attributes2->importance - attributes1->importance; + } else { + /* Sort by alphabetical ID order */ + return g_strcmp0 (attributes1->id, attributes2->id); + } +} + +static void +update_attribute_row (GsAgeRatingContextDialog *self, + GsAgeRatingGroupType group_type) +{ + const GsAgeRatingAttribute *first; + const gchar *group_icon_name; + const gchar *group_title; + const gchar *group_description; + g_autofree char *new_description = NULL; + + first = (GsAgeRatingAttribute *) self->attributes[group_type]->data; + + if (g_list_length (self->attributes[group_type]) == 1) { + g_object_set (self->rows[group_type], + "icon-name", first->icon_name, + "importance", first->importance, + "subtitle", first->description, + "title", first->title, + NULL); + + return; + } + + if (first->importance == GS_CONTEXT_DIALOG_ROW_IMPORTANCE_UNIMPORTANT) { + gboolean only_unimportant = TRUE; + + for (GList *l = self->attributes[group_type]->next; l; l = l->next) { + GsAgeRatingAttribute *attribute = (GsAgeRatingAttribute *) l->data; + + if (attribute->importance != GS_CONTEXT_DIALOG_ROW_IMPORTANCE_UNIMPORTANT) { + only_unimportant = FALSE; + break; + } + } + + if (only_unimportant) { + group_icon_name = content_rating_group_get_icon_name (group_type, first->importance == GS_CONTEXT_DIALOG_ROW_IMPORTANCE_UNIMPORTANT); + group_title = content_rating_group_get_title (group_type); + group_description = content_rating_group_get_description (group_type); + + g_object_set (self->rows[group_type], + "icon-name", group_icon_name, + "importance", first->importance, + "subtitle", group_description, + "title", group_title, + NULL); + + return; + } + + } + + group_icon_name = content_rating_group_get_icon_name (group_type, FALSE); + group_title = content_rating_group_get_title (group_type); + new_description = g_strdup (first->description); + + for (GList *l = self->attributes[group_type]->next; l; l = l->next) { + GsAgeRatingAttribute *attribute = (GsAgeRatingAttribute *) l->data; + char *s; + + if (attribute->importance == GS_CONTEXT_DIALOG_ROW_IMPORTANCE_UNIMPORTANT) + break; + + /* Translators: This is used to join two list items together in + * a compressed way of displaying a list of descriptions of age + * ratings for apps. The order of the items does not matter. */ + s = g_strdup_printf (_("%s • %s"), + new_description, + ((GsAgeRatingAttribute *) l->data)->description); + g_free (new_description); + new_description = s; + } + + g_object_set (self->rows[group_type], + "icon-name", group_icon_name, + "importance", first->importance, + "subtitle", new_description, + "title", group_title, + NULL); +} + +static void +add_attribute_row (GsAgeRatingContextDialog *self, + const gchar *attribute, + AsContentRatingValue value) +{ + GsAgeRatingGroupType group_type; + GsContextDialogRowImportance rating; + const gchar *icon_name, *title, *description; + GsAgeRatingAttribute *attributes; + + group_type = content_rating_attribute_get_group_type (attribute); + rating = content_rating_value_get_importance (value); + icon_name = content_rating_attribute_get_icon_name (attribute, value == AS_CONTENT_RATING_VALUE_NONE); + title = content_rating_attribute_get_title (attribute); + if (value == AS_CONTENT_RATING_VALUE_UNKNOWN) + description = content_rating_attribute_get_unknown_description (attribute); + else + description = as_content_rating_attribute_get_description (attribute, value); + + attributes = gs_age_rating_attribute_new (attribute, icon_name, rating, title, description); + + if (self->attributes[group_type] != NULL) { + self->attributes[group_type] = g_list_insert_sorted (self->attributes[group_type], + attributes, + (GCompareFunc) attributes_compare); + + update_attribute_row (self, group_type); + } else { + self->attributes[group_type] = g_list_prepend (self->attributes[group_type], attributes); + self->rows[group_type] = GS_CONTEXT_DIALOG_ROW (gs_context_dialog_row_new (icon_name, rating, title, description)); + gtk_list_box_append (self->attributes_list, GTK_WIDGET (self->rows[group_type])); + } +} + +/** + * gs_age_rating_context_dialog_process_attributes: + * @content_rating: content rating data from an app, retrieved using + * gs_app_dup_content_rating() + * @show_worst_only: %TRUE to only process the worst content rating attributes, + * %FALSE to process all of them + * @callback: callback to call for each attribute being processed + * @user_data: data to pass to @callback + * + * Loop through all the defined content rating attributes, and decide which ones + * are relevant to show to the user. For each of the relevant attributes, call + * @callback with the attribute name and value. + * + * If @show_worst_only is %TRUE, only the attributes which cause the overall + * rating of the app to be as high as it is are considered relevant. If it is + * %FALSE, all attributes are relevant. + * + * If the app has an overall age rating of 0, @callback is called exactly once, + * with the attribute name set to %NULL, to indicate that the app is suitable + * for all in every attribute. + * + * Since: 41 + */ +void +gs_age_rating_context_dialog_process_attributes (AsContentRating *content_rating, + gboolean show_worst_only, + GsAgeRatingContextDialogAttributeFunc callback, + gpointer user_data) +{ + g_autofree const gchar **rating_ids = as_content_rating_get_all_rating_ids (); + AsContentRatingValue value_bad = AS_CONTENT_RATING_VALUE_NONE; + guint age_bad = 0; + + /* Ordered from worst to best, these are all OARS 1.0/1.1 categories */ + const gchar * const violence_group[] = { + "violence-bloodshed", + "violence-realistic", + "violence-fantasy", + "violence-cartoon", + NULL + }; + const gchar * const social_group[] = { + "social-audio", + "social-chat", + "social-contacts", + "social-info", + NULL + }; + const gchar * const coalesce_groups[] = { + "sex-themes", + "sex-homosexuality", + NULL + }; + + /* Get the worst category. */ + for (gsize i = 0; rating_ids[i] != NULL; i++) { + guint rating_age; + AsContentRatingValue rating_value; + + rating_value = as_content_rating_get_value (content_rating, rating_ids[i]); + rating_age = as_content_rating_attribute_to_csm_age (rating_ids[i], rating_value); + + if (rating_age > age_bad) + age_bad = rating_age; + if (rating_value > value_bad) + value_bad = rating_value; + } + + /* If the worst category is nothing, great! Show a more specific message + * than a big listing of all the groups. */ + if (show_worst_only && (value_bad == AS_CONTENT_RATING_VALUE_NONE || age_bad == 0)) { + callback (NULL, AS_CONTENT_RATING_VALUE_UNKNOWN, user_data); + return; + } + + /* Add a description for each rating category which contributes to the + * @age_bad being as it is. Handle the groups separately. + * Intentionally coalesce some categories if they have the same values, + * to avoid confusion */ + for (gsize i = 0; rating_ids[i] != NULL; i++) { + guint rating_age; + AsContentRatingValue rating_value; + + if (g_strv_contains (violence_group, rating_ids[i]) || + g_strv_contains (social_group, rating_ids[i])) + continue; + + rating_value = as_content_rating_get_value (content_rating, rating_ids[i]); + rating_age = as_content_rating_attribute_to_csm_age (rating_ids[i], rating_value); + + if (show_worst_only && rating_age < age_bad) + continue; + + /* Coalesce down to the first element in @coalesce_groups, + * unless this group’s value differs. Currently only one + * coalesce group is supported. */ + if (g_strv_contains (coalesce_groups + 1, rating_ids[i]) && + as_content_rating_attribute_to_csm_age (coalesce_groups[0], + as_content_rating_get_value (content_rating, + coalesce_groups[0])) >= rating_age) + continue; + + callback (rating_ids[i], rating_value, user_data); + } + + for (gsize i = 0; violence_group[i] != NULL; i++) { + guint rating_age; + AsContentRatingValue rating_value; + + rating_value = as_content_rating_get_value (content_rating, violence_group[i]); + rating_age = as_content_rating_attribute_to_csm_age (violence_group[i], rating_value); + + if (show_worst_only && rating_age < age_bad) + continue; + + callback (violence_group[i], rating_value, user_data); + } + + for (gsize i = 0; social_group[i] != NULL; i++) { + guint rating_age; + AsContentRatingValue rating_value; + + rating_value = as_content_rating_get_value (content_rating, social_group[i]); + rating_age = as_content_rating_attribute_to_csm_age (social_group[i], rating_value); + + if (show_worst_only && rating_age < age_bad) + continue; + + callback (social_group[i], rating_value, user_data); + } +} + +static void +add_attribute_rows_cb (const gchar *attribute, + AsContentRatingValue value, + gpointer user_data) +{ + GsAgeRatingContextDialog *self = GS_AGE_RATING_CONTEXT_DIALOG (user_data); + + add_attribute_row (self, attribute, value); +} + +/* Wrapper around as_content_rating_system_format_age() which returns the short + * form of the content rating. This doesn’t make a difference for most ratings + * systems, but it does for ESRB which normally produces quite long strings. + * + * FIXME: This should probably be upstreamed into libappstream once it’s been in + * the GNOME 41 release and stabilised. */ +gchar * +gs_age_rating_context_dialog_format_age_short (AsContentRatingSystem system, + guint age) +{ + if (system == AS_CONTENT_RATING_SYSTEM_ESRB) { + if (age >= 18) + return g_strdup ("AO"); + if (age >= 17) + return g_strdup ("M"); + if (age >= 13) + return g_strdup ("T"); + if (age >= 10) + return g_strdup ("E10+"); + if (age >= 6) + return g_strdup ("E"); + + return g_strdup ("EC"); + } + + return as_content_rating_system_format_age (system, age); +} + +/** + * gs_age_rating_context_dialog_update_lozenge: + * @app: the #GsApp to rate + * @lozenge: a #GsLozenge widget + * @is_unknown_out: (out caller-allocates) (not optional): return location for + * a boolean indicating whether the age rating is unknown, rather than a + * specific age + * + * Update the @lozenge widget to indicate the overall age rating for @app. + * This involves changing its CSS class and label content. + * + * If the overall age rating for @app is unknown (because the app doesn’t + * provide a complete `<content_rating>` element in its appdata), the lozenge is + * set to show a question mark, and @is_unknown_out is set to %TRUE. + * + * Since: 41 + */ +void +gs_age_rating_context_dialog_update_lozenge (GsApp *app, + GsLozenge *lozenge, + gboolean *is_unknown_out) +{ + const gchar *css_class; + const gchar *locale; + AsContentRatingSystem system; + g_autoptr(AsContentRating) content_rating = NULL; + GtkStyleContext *context; + const gchar *css_age_classes[] = { + "details-rating-18", + "details-rating-15", + "details-rating-12", + "details-rating-5", + "details-rating-0", + }; + guint age = G_MAXUINT; + g_autofree gchar *age_text = NULL; + + g_return_if_fail (GS_IS_APP (app)); + g_return_if_fail (GS_IS_LOZENGE (lozenge)); + g_return_if_fail (is_unknown_out != NULL); + + /* get the content rating system from the locale */ + locale = setlocale (LC_MESSAGES, NULL); + system = as_content_rating_system_from_locale (locale); + g_debug ("content rating system is guessed as %s from %s", + as_content_rating_system_to_string (system), + locale); + + content_rating = gs_app_dup_content_rating (app); + if (content_rating != NULL) + age = as_content_rating_get_minimum_age (content_rating); + + if (age != G_MAXUINT) + age_text = gs_age_rating_context_dialog_format_age_short (system, age); + + /* Some ratings systems (PEGI) don’t start at age 0 */ + if (content_rating != NULL && age_text == NULL && age == 0) + /* Translators: The app is considered suitable to be run by all ages of people. + * This is displayed in a context tile, so the string should be short. */ + age_text = g_strdup (C_("Age rating", "All")); + + /* We currently only support OARS-1.0 and OARS-1.1 */ + if (age_text == NULL || + (content_rating != NULL && + g_strcmp0 (as_content_rating_get_kind (content_rating), "oars-1.0") != 0 && + g_strcmp0 (as_content_rating_get_kind (content_rating), "oars-1.1") != 0)) { + /* Translators: This app has no age rating information available. + * This string is displayed like an icon. Please use any + * similarly short punctuation character, word or acronym which + * will be widely understood in your region, in this context. + * This is displayed in a context tile, so the string should be short. */ + g_free (age_text); + age_text = g_strdup (_("?")); + css_class = "grey"; + *is_unknown_out = TRUE; + } else { + /* Update the CSS */ + if (age >= 18) + css_class = css_age_classes[0]; + else if (age >= 15) + css_class = css_age_classes[1]; + else if (age >= 12) + css_class = css_age_classes[2]; + else if (age >= 5) + css_class = css_age_classes[3]; + else + css_class = css_age_classes[4]; + + *is_unknown_out = FALSE; + } + + /* Update the UI. */ + gs_lozenge_set_text (lozenge, age_text); + + context = gtk_widget_get_style_context (GTK_WIDGET (lozenge)); + + for (gsize i = 0; i < G_N_ELEMENTS (css_age_classes); i++) + gtk_style_context_remove_class (context, css_age_classes[i]); + gtk_style_context_remove_class (context, "grey"); + + gtk_style_context_add_class (context, css_class); +} + +static void +update_attributes_list (GsAgeRatingContextDialog *self) +{ + g_autoptr(AsContentRating) content_rating = NULL; + gboolean is_unknown; + g_autofree gchar *title = NULL; + + /* Clear existing state. */ + gs_widget_remove_all (GTK_WIDGET (self->attributes_list), (GsRemoveFunc) gtk_list_box_remove); + + for (GsAgeRatingGroupType group_type = 0; group_type < GS_AGE_RATING_GROUP_TYPE_COUNT; group_type++) { + g_list_free_full (self->attributes[group_type], + (GDestroyNotify) gs_age_rating_attribute_free); + self->attributes[group_type] = NULL; + + self->rows[group_type] = NULL; + } + + /* UI state is undefined if app is not set. */ + if (self->app == NULL) + return; + + /* Update lozenge and title */ + content_rating = gs_app_dup_content_rating (self->app); + gs_age_rating_context_dialog_update_lozenge (self->app, + self->lozenge, + &is_unknown); + + /* Title */ + if (is_unknown) { + /* Translators: It’s unknown what age rating this app has. The + * placeholder is the app name. */ + title = g_strdup_printf (("%s has an unknown age rating"), gs_app_get_name (self->app)); + } else { + guint age; + + /* if content_rating is NULL, is_unknown should be TRUE */ + g_assert (content_rating != NULL); + age = as_content_rating_get_minimum_age (content_rating); + + if (age == 0) + /* Translators: This is a dialogue title which indicates that an app is suitable + * for all ages. The placeholder is the app name. */ + title = g_strdup_printf (_("%s is suitable for everyone"), gs_app_get_name (self->app)); + else if (age <= 3) + /* Translators: This is a dialogue title which indicates that an app is suitable + * for children up to around age 3. The placeholder is the app name. */ + title = g_strdup_printf (_("%s is suitable for toddlers"), gs_app_get_name (self->app)); + else if (age <= 5) + /* Translators: This is a dialogue title which indicates that an app is suitable + * for children up to around age 5. The placeholder is the app name. */ + title = g_strdup_printf (_("%s is suitable for young children"), gs_app_get_name (self->app)); + else if (age <= 12) + /* Translators: This is a dialogue title which indicates that an app is suitable + * for children up to around age 12. The placeholder is the app name. */ + title = g_strdup_printf (("%s is suitable for children"), gs_app_get_name (self->app)); + else if (age <= 18) + /* Translators: This is a dialogue title which indicates that an app is suitable + * for people up to around age 18. The placeholder is the app name. */ + title = g_strdup_printf (_("%s is suitable for teenagers"), gs_app_get_name (self->app)); + else if (age < G_MAXUINT) + /* Translators: This is a dialogue title which indicates that an app is suitable + * for people aged up to and over 18. The placeholder is the app name. */ + title = g_strdup_printf (_("%s is suitable for adults"), gs_app_get_name (self->app)); + else + /* Translators: This is a dialogue title which indicates that an app is suitable + * for a specified age group. The first placeholder is the app name, the second + * is the age group. */ + title = g_strdup_printf (_("%s is suitable for %s"), gs_app_get_name (self->app), + gs_lozenge_get_text (self->lozenge)); + } + + gtk_label_set_text (self->title, title); + + /* Update the rows */ + gs_age_rating_context_dialog_process_attributes (content_rating, + FALSE, + add_attribute_rows_cb, + self); +} + +static void +app_notify_cb (GObject *obj, + GParamSpec *pspec, + gpointer user_data) +{ + GsAgeRatingContextDialog *self = GS_AGE_RATING_CONTEXT_DIALOG (user_data); + + update_attributes_list (self); +} + +static gint +sort_cb (GtkListBoxRow *row1, + GtkListBoxRow *row2, + gpointer user_data) +{ + GsContextDialogRow *_row1 = GS_CONTEXT_DIALOG_ROW (row1); + GsContextDialogRow *_row2 = GS_CONTEXT_DIALOG_ROW (row2); + GsContextDialogRowImportance importance1, importance2; + const gchar *title1, *title2; + + importance1 = gs_context_dialog_row_get_importance (_row1); + importance2 = gs_context_dialog_row_get_importance (_row2); + + if (importance1 != importance2) + return importance2 - importance1; + + title1 = adw_preferences_row_get_title (ADW_PREFERENCES_ROW (_row1)); + title2 = adw_preferences_row_get_title (ADW_PREFERENCES_ROW (_row2)); + + return g_strcmp0 (title1, title2); +} + +static void +gs_age_rating_context_dialog_init (GsAgeRatingContextDialog *self) +{ + gtk_widget_init_template (GTK_WIDGET (self)); + + /* Sort the list so the most important rows are at the top. */ + gtk_list_box_set_sort_func (self->attributes_list, sort_cb, NULL, NULL); +} + +static void +gs_age_rating_context_dialog_get_property (GObject *object, + guint prop_id, + GValue *value, + GParamSpec *pspec) +{ + GsAgeRatingContextDialog *self = GS_AGE_RATING_CONTEXT_DIALOG (object); + + switch ((GsAgeRatingContextDialogProperty) prop_id) { + case PROP_APP: + g_value_set_object (value, gs_age_rating_context_dialog_get_app (self)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_age_rating_context_dialog_set_property (GObject *object, + guint prop_id, + const GValue *value, + GParamSpec *pspec) +{ + GsAgeRatingContextDialog *self = GS_AGE_RATING_CONTEXT_DIALOG (object); + + switch ((GsAgeRatingContextDialogProperty) prop_id) { + case PROP_APP: + gs_age_rating_context_dialog_set_app (self, g_value_get_object (value)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_age_rating_context_dialog_dispose (GObject *object) +{ + GsAgeRatingContextDialog *self = GS_AGE_RATING_CONTEXT_DIALOG (object); + + gs_age_rating_context_dialog_set_app (self, NULL); + + G_OBJECT_CLASS (gs_age_rating_context_dialog_parent_class)->dispose (object); +} + +static void +gs_age_rating_context_dialog_class_init (GsAgeRatingContextDialogClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + + object_class->get_property = gs_age_rating_context_dialog_get_property; + object_class->set_property = gs_age_rating_context_dialog_set_property; + object_class->dispose = gs_age_rating_context_dialog_dispose; + + /** + * GsAgeRatingContextDialog:app: (nullable) + * + * The app to display the age_rating context details for. + * + * This may be %NULL; if so, the content of the widget will be + * undefined. + * + * Since: 41 + */ + obj_props[PROP_APP] = + g_param_spec_object ("app", NULL, NULL, + GS_TYPE_APP, + G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS); + + g_object_class_install_properties (object_class, G_N_ELEMENTS (obj_props), obj_props); + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-age-rating-context-dialog.ui"); + + gtk_widget_class_bind_template_child (widget_class, GsAgeRatingContextDialog, lozenge); + gtk_widget_class_bind_template_child (widget_class, GsAgeRatingContextDialog, title); + gtk_widget_class_bind_template_child (widget_class, GsAgeRatingContextDialog, attributes_list); +} + +/** + * gs_age_rating_context_dialog_new: + * @app: (nullable): the app to display age_rating context information for, or %NULL + * + * Create a new #GsAgeRatingContextDialog and set its initial app to @app. + * + * Returns: (transfer full): a new #GsAgeRatingContextDialog + * Since: 41 + */ +GsAgeRatingContextDialog * +gs_age_rating_context_dialog_new (GsApp *app) +{ + g_return_val_if_fail (app == NULL || GS_IS_APP (app), NULL); + + return g_object_new (GS_TYPE_AGE_RATING_CONTEXT_DIALOG, + "app", app, + NULL); +} + +/** + * gs_age_rating_context_dialog_get_app: + * @self: a #GsAgeRatingContextDialog + * + * Gets the value of #GsAgeRatingContextDialog:app. + * + * Returns: (nullable) (transfer none): app whose age_rating context information is + * being displayed, or %NULL if none is set + * Since: 41 + */ +GsApp * +gs_age_rating_context_dialog_get_app (GsAgeRatingContextDialog *self) +{ + g_return_val_if_fail (GS_IS_AGE_RATING_CONTEXT_DIALOG (self), NULL); + + return self->app; +} + +/** + * gs_age_rating_context_dialog_set_app: + * @self: a #GsAgeRatingContextDialog + * @app: (nullable) (transfer none): the app to display age_rating context + * information for, or %NULL for none + * + * Set the value of #GsAgeRatingContextDialog:app. + * + * Since: 41 + */ +void +gs_age_rating_context_dialog_set_app (GsAgeRatingContextDialog *self, + GsApp *app) +{ + g_return_if_fail (GS_IS_AGE_RATING_CONTEXT_DIALOG (self)); + g_return_if_fail (app == NULL || GS_IS_APP (app)); + + if (app == self->app) + return; + + g_clear_signal_handler (&self->app_notify_handler_content_rating, self->app); + g_clear_signal_handler (&self->app_notify_handler_name, self->app); + + g_set_object (&self->app, app); + + if (self->app != NULL) { + self->app_notify_handler_content_rating = g_signal_connect (self->app, "notify::content-rating", G_CALLBACK (app_notify_cb), self); + self->app_notify_handler_name = g_signal_connect (self->app, "notify::name", G_CALLBACK (app_notify_cb), self); + } + + /* Update the UI. */ + update_attributes_list (self); + + g_object_notify_by_pspec (G_OBJECT (self), obj_props[PROP_APP]); +} diff --git a/src/gs-age-rating-context-dialog.h b/src/gs-age-rating-context-dialog.h new file mode 100644 index 0000000..f619004 --- /dev/null +++ b/src/gs-age-rating-context-dialog.h @@ -0,0 +1,49 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2021 Endless OS Foundation LLC + * + * Author: Philip Withnall <pwithnall@endlessos.org> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <glib.h> +#include <glib-object.h> +#include <gtk/gtk.h> + +#include "gs-app.h" +#include "gs-info-window.h" +#include "gs-lozenge.h" + +G_BEGIN_DECLS + +#define GS_TYPE_AGE_RATING_CONTEXT_DIALOG (gs_age_rating_context_dialog_get_type ()) + +G_DECLARE_FINAL_TYPE (GsAgeRatingContextDialog, gs_age_rating_context_dialog, GS, AGE_RATING_CONTEXT_DIALOG, GsInfoWindow) + +GsAgeRatingContextDialog *gs_age_rating_context_dialog_new (GsApp *app); + +GsApp *gs_age_rating_context_dialog_get_app (GsAgeRatingContextDialog *self); +void gs_age_rating_context_dialog_set_app (GsAgeRatingContextDialog *self, + GsApp *app); + +gchar *gs_age_rating_context_dialog_format_age_short (AsContentRatingSystem system, + guint age); +void gs_age_rating_context_dialog_update_lozenge (GsApp *app, + GsLozenge *lozenge, + gboolean *is_unknown_out); + + +typedef void (*GsAgeRatingContextDialogAttributeFunc) (const gchar *attribute, + AsContentRatingValue value, + gpointer user_data); + +void gs_age_rating_context_dialog_process_attributes (AsContentRating *content_rating, + gboolean show_worst_only, + GsAgeRatingContextDialogAttributeFunc callback, + gpointer user_data); + +G_END_DECLS diff --git a/src/gs-age-rating-context-dialog.ui b/src/gs-age-rating-context-dialog.ui new file mode 100644 index 0000000..d3b601e --- /dev/null +++ b/src/gs-age-rating-context-dialog.ui @@ -0,0 +1,80 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <requires lib="gtk+" version="3.10"/> + <template class="GsAgeRatingContextDialog" parent="GsInfoWindow"> + <property name="title" translatable="yes" comments="Translators: This is the title of the dialog which contains information about the suitability of an app for different ages.">Age Rating</property> + <child> + <object class="AdwPreferencesPage"> + <child> + <object class="AdwPreferencesGroup"> + + <child> + <object class="GtkBox"> + <property name="orientation">vertical</property> + <property name="spacing">8</property> + + <child> + <object class="GtkBox"> + <property name="margin-top">20</property> + <property name="margin-bottom">16</property> + <property name="margin-start">20</property> + <property name="margin-end">20</property> + <property name="orientation">vertical</property> + <property name="spacing">12</property> + + <child> + <object class="GsLozenge" id="lozenge"> + <property name="circular">True</property> + <style> + <class name="large"/> + <class name="grey"/> + </style> + <accessibility> + <relation name="labelled-by">title</relation> + </accessibility> + </object> + </child> + + <child> + <object class="GtkLabel" id="title"> + <!-- this is a placeholder: the text is actually set in code --> + <property name="justify">center</property> + <property name="label">Shortwave is appropriate for children</property> + <property name="wrap">True</property> + <property name="xalign">0.5</property> + <style> + <class name="title-2"/> + </style> + </object> + </child> + </object> + </child> + + <child> + <object class="GtkListBox" id="attributes_list"> + <property name="selection_mode">none</property> + <property name="halign">fill</property> + <property name="valign">start</property> + <style> + <class name="boxed-list"/> + </style> + <!-- Rows are added in code --> + <placeholder/> + </object> + </child> + + <child> + <object class="GtkLinkButton"> + <property name="label" translatable="yes">How to contribute missing information</property> + <property name="margin-top">16</property> + <property name="uri">https://gitlab.gnome.org/GNOME/gnome-software/-/wikis/software-metadata#age-rating</property> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + </template> +</interface> diff --git a/src/gs-app-addon-row.c b/src/gs-app-addon-row.c new file mode 100644 index 0000000..e43f1d7 --- /dev/null +++ b/src/gs-app-addon-row.c @@ -0,0 +1,315 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2012-2013 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2013 Matthias Clasen <mclasen@redhat.com> + * Copyright (C) 2014-2016 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include <glib/gi18n.h> + +#include "gs-app-addon-row.h" + +struct _GsAppAddonRow +{ + GtkListBoxRow parent_instance; + + GsApp *app; + GtkWidget *name_label; + GtkWidget *description_label; + GtkWidget *label; + GtkWidget *button_remove; + GtkWidget *checkbox; +}; + +G_DEFINE_TYPE (GsAppAddonRow, gs_app_addon_row, GTK_TYPE_LIST_BOX_ROW) + +enum { + PROP_ZERO, + PROP_SELECTED +}; + +enum { + SIGNAL_REMOVE_BUTTON_CLICKED, + SIGNAL_LAST +}; + +static guint signals [SIGNAL_LAST] = { 0 }; + +static void +checkbox_toggled (GtkWidget *widget, GsAppAddonRow *row) +{ + g_object_notify (G_OBJECT (row), "selected"); +} + +static void +app_addon_remove_button_cb (GtkWidget *widget, GsAppAddonRow *row) +{ + g_signal_emit (row, signals[SIGNAL_REMOVE_BUTTON_CLICKED], 0); +} + +/** + * gs_app_addon_row_get_summary: + * + * Return value: PangoMarkup + **/ +static GString * +gs_app_addon_row_get_summary (GsAppAddonRow *row) +{ + const gchar *tmp = NULL; + g_autofree gchar *escaped = NULL; + + /* try all these things in order */ + if (gs_app_get_state (row->app) == GS_APP_STATE_UNAVAILABLE) + tmp = gs_app_get_summary_missing (row->app); + if (tmp == NULL || (tmp != NULL && tmp[0] == '\0')) + tmp = gs_app_get_summary (row->app); + if (tmp == NULL || (tmp != NULL && tmp[0] == '\0')) + tmp = gs_app_get_description (row->app); + + escaped = g_markup_escape_text (tmp, -1); + return g_string_new (escaped); +} + +void +gs_app_addon_row_refresh (GsAppAddonRow *row) +{ + g_autoptr(GString) str = NULL; + + if (row->app == NULL) + return; + + /* join the lines */ + str = gs_app_addon_row_get_summary (row); + as_gstring_replace (str, "\n", " "); + gtk_label_set_markup (GTK_LABEL (row->description_label), str->str); + gtk_label_set_label (GTK_LABEL (row->name_label), + gs_app_get_name (row->app)); + + /* update the state label */ + switch (gs_app_get_state (row->app)) { + case GS_APP_STATE_QUEUED_FOR_INSTALL: + gtk_widget_set_visible (row->label, TRUE); + gtk_label_set_label (GTK_LABEL (row->label), _("Pending")); + break; + case GS_APP_STATE_PENDING_INSTALL: + gtk_widget_set_visible (row->label, TRUE); + gtk_label_set_label (GTK_LABEL (row->label), _("Pending install")); + break; + case GS_APP_STATE_PENDING_REMOVE: + gtk_widget_set_visible (row->label, TRUE); + gtk_label_set_label (GTK_LABEL (row->label), _("Pending remove")); + break; + case GS_APP_STATE_UPDATABLE: + case GS_APP_STATE_UPDATABLE_LIVE: + case GS_APP_STATE_INSTALLED: + gtk_widget_set_visible (row->label, TRUE); + gtk_label_set_label (GTK_LABEL (row->label), C_("Single app", "Installed")); + break; + case GS_APP_STATE_INSTALLING: + gtk_widget_set_visible (row->label, TRUE); + gtk_label_set_label (GTK_LABEL (row->label), _("Installing")); + break; + case GS_APP_STATE_REMOVING: + gtk_widget_set_visible (row->label, TRUE); + gtk_label_set_label (GTK_LABEL (row->label), _("Removing")); + break; + default: + gtk_widget_set_visible (row->label, FALSE); + break; + } + + /* update the checkbox, remove button, and activatable state */ + g_signal_handlers_block_by_func (row->checkbox, checkbox_toggled, row); + g_signal_handlers_block_by_func (row->checkbox, app_addon_remove_button_cb, row); + switch (gs_app_get_state (row->app)) { + case GS_APP_STATE_QUEUED_FOR_INSTALL: + gtk_widget_set_sensitive (row->checkbox, TRUE); + gtk_check_button_set_active (GTK_CHECK_BUTTON (row->checkbox), TRUE); + gtk_list_box_row_set_activatable (GTK_LIST_BOX_ROW (row), TRUE); + break; + case GS_APP_STATE_AVAILABLE: + case GS_APP_STATE_AVAILABLE_LOCAL: + gtk_widget_set_visible (row->checkbox, TRUE); + gtk_widget_set_sensitive (row->checkbox, TRUE); + gtk_check_button_set_active (GTK_CHECK_BUTTON (row->checkbox), FALSE); + gtk_widget_set_visible (row->button_remove, FALSE); + gtk_list_box_row_set_activatable (GTK_LIST_BOX_ROW (row), TRUE); + break; + case GS_APP_STATE_UPDATABLE: + case GS_APP_STATE_UPDATABLE_LIVE: + case GS_APP_STATE_INSTALLED: + gtk_widget_set_visible (row->checkbox, FALSE); + gtk_widget_set_visible (row->button_remove, !gs_app_has_quirk (row->app, GS_APP_QUIRK_COMPULSORY)); + gtk_widget_set_sensitive (row->button_remove, !gs_app_has_quirk (row->app, GS_APP_QUIRK_COMPULSORY)); + gtk_list_box_row_set_activatable (GTK_LIST_BOX_ROW (row), FALSE); + break; + case GS_APP_STATE_INSTALLING: + gtk_widget_set_sensitive (row->checkbox, FALSE); + gtk_check_button_set_active (GTK_CHECK_BUTTON (row->checkbox), TRUE); + gtk_list_box_row_set_activatable (GTK_LIST_BOX_ROW (row), FALSE); + break; + case GS_APP_STATE_REMOVING: + gtk_widget_set_visible (row->checkbox, FALSE); + gtk_widget_set_visible (row->button_remove, TRUE); + gtk_widget_set_sensitive (row->button_remove, FALSE); + gtk_list_box_row_set_activatable (GTK_LIST_BOX_ROW (row), FALSE); + break; + default: + gtk_widget_set_sensitive (row->checkbox, FALSE); + gtk_check_button_set_active (GTK_CHECK_BUTTON (row->checkbox), FALSE); + gtk_list_box_row_set_activatable (GTK_LIST_BOX_ROW (row), FALSE); + break; + } + g_signal_handlers_unblock_by_func (row->checkbox, checkbox_toggled, row); + g_signal_handlers_unblock_by_func (row->checkbox, app_addon_remove_button_cb, row); +} + +GsApp * +gs_app_addon_row_get_addon (GsAppAddonRow *row) +{ + g_return_val_if_fail (GS_IS_APP_ADDON_ROW (row), NULL); + return row->app; +} + +static gboolean +gs_app_addon_row_refresh_idle (gpointer user_data) +{ + GsAppAddonRow *row = GS_APP_ADDON_ROW (user_data); + + gs_app_addon_row_refresh (row); + + g_object_unref (row); + return G_SOURCE_REMOVE; +} + +static void +gs_app_addon_row_notify_props_changed_cb (GsApp *app, + GParamSpec *pspec, + GsAppAddonRow *row) +{ + g_idle_add (gs_app_addon_row_refresh_idle, g_object_ref (row)); +} + +static void +gs_app_addon_row_set_addon (GsAppAddonRow *row, GsApp *app) +{ + row->app = g_object_ref (app); + + g_signal_connect_object (row->app, "notify::state", + G_CALLBACK (gs_app_addon_row_notify_props_changed_cb), + row, 0); + gs_app_addon_row_refresh (row); +} + +static void +gs_app_addon_row_dispose (GObject *object) +{ + GsAppAddonRow *row = GS_APP_ADDON_ROW (object); + + if (row->app) + g_signal_handlers_disconnect_by_func (row->app, gs_app_addon_row_notify_props_changed_cb, row); + + g_clear_object (&row->app); + + G_OBJECT_CLASS (gs_app_addon_row_parent_class)->dispose (object); +} + +static void +gs_app_addon_row_set_property (GObject *object, guint prop_id, const GValue *value, GParamSpec *pspec) +{ + GsAppAddonRow *row = GS_APP_ADDON_ROW (object); + + switch (prop_id) { + case PROP_SELECTED: + gs_app_addon_row_set_selected (row, g_value_get_boolean (value)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_app_addon_row_get_property (GObject *object, guint prop_id, GValue *value, GParamSpec *pspec) +{ + GsAppAddonRow *row = GS_APP_ADDON_ROW (object); + + switch (prop_id) { + case PROP_SELECTED: + g_value_set_boolean (value, gs_app_addon_row_get_selected (row)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_app_addon_row_class_init (GsAppAddonRowClass *klass) +{ + GParamSpec *pspec; + GObjectClass *object_class = G_OBJECT_CLASS (klass); + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + + object_class->dispose = gs_app_addon_row_dispose; + object_class->set_property = gs_app_addon_row_set_property; + object_class->get_property = gs_app_addon_row_get_property; + + pspec = g_param_spec_boolean ("selected", NULL, NULL, + FALSE, G_PARAM_READWRITE); + g_object_class_install_property (object_class, PROP_SELECTED, pspec); + + signals [SIGNAL_REMOVE_BUTTON_CLICKED] = + g_signal_new ("remove-button-clicked", + G_TYPE_FROM_CLASS (object_class), G_SIGNAL_RUN_LAST, + 0, NULL, NULL, g_cclosure_marshal_VOID__VOID, + G_TYPE_NONE, 0); + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-app-addon-row.ui"); + + gtk_widget_class_bind_template_child (widget_class, GsAppAddonRow, name_label); + gtk_widget_class_bind_template_child (widget_class, GsAppAddonRow, description_label); + gtk_widget_class_bind_template_child (widget_class, GsAppAddonRow, label); + gtk_widget_class_bind_template_child (widget_class, GsAppAddonRow, checkbox); + gtk_widget_class_bind_template_child (widget_class, GsAppAddonRow, button_remove); +} + +static void +gs_app_addon_row_init (GsAppAddonRow *row) +{ + gtk_widget_init_template (GTK_WIDGET (row)); + + g_signal_connect (row->checkbox, "toggled", + G_CALLBACK (checkbox_toggled), row); + g_signal_connect (row->button_remove, "clicked", + G_CALLBACK (app_addon_remove_button_cb), row); +} + +void +gs_app_addon_row_set_selected (GsAppAddonRow *row, gboolean selected) +{ + gtk_check_button_set_active (GTK_CHECK_BUTTON (row->checkbox), selected); +} + +gboolean +gs_app_addon_row_get_selected (GsAppAddonRow *row) +{ + return gtk_check_button_get_active (GTK_CHECK_BUTTON (row->checkbox)); +} + +GtkWidget * +gs_app_addon_row_new (GsApp *app) +{ + GtkWidget *row; + + g_return_val_if_fail (GS_IS_APP (app), NULL); + + row = g_object_new (GS_TYPE_APP_ADDON_ROW, NULL); + gs_app_addon_row_set_addon (GS_APP_ADDON_ROW (row), app); + return row; +} diff --git a/src/gs-app-addon-row.h b/src/gs-app-addon-row.h new file mode 100644 index 0000000..257ef86 --- /dev/null +++ b/src/gs-app-addon-row.h @@ -0,0 +1,29 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2012 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2014-2015 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <gtk/gtk.h> + +#include "gnome-software-private.h" + +G_BEGIN_DECLS + +#define GS_TYPE_APP_ADDON_ROW (gs_app_addon_row_get_type ()) + +G_DECLARE_FINAL_TYPE (GsAppAddonRow, gs_app_addon_row, GS, APP_ADDON_ROW, GtkListBoxRow) + +GtkWidget *gs_app_addon_row_new (GsApp *app); +void gs_app_addon_row_refresh (GsAppAddonRow *row); +void gs_app_addon_row_set_selected (GsAppAddonRow *row, + gboolean selected); +gboolean gs_app_addon_row_get_selected (GsAppAddonRow *row); +GsApp *gs_app_addon_row_get_addon (GsAppAddonRow *row); + +G_END_DECLS diff --git a/src/gs-app-addon-row.ui b/src/gs-app-addon-row.ui new file mode 100644 index 0000000..27384ea --- /dev/null +++ b/src/gs-app-addon-row.ui @@ -0,0 +1,78 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <!-- interface-requires gtk+ 3.10 --> + <template class="GsAppAddonRow" parent="GtkListBoxRow"> + <property name="selectable">False</property> + <child> + <object class="GtkBox" id="box"> + <property name="margin-top">12</property> + <property name="margin-bottom">12</property> + <property name="margin-start">18</property> + <property name="margin-end">18</property> + <property name="orientation">horizontal</property> + <child> + <object class="GtkBox" id="name_box"> + <property name="margin-top">6</property> + <property name="margin-bottom">6</property> + <property name="orientation">vertical</property> + <property name="valign">start</property> + <property name="hexpand">True</property> + <child> + <object class="GtkLabel" id="name_label"> + <property name="margin-bottom">6</property> + <property name="wrap">True</property> + <property name="max_width_chars">20</property> + <property name="xalign">0.0</property> + <property name="yalign">0.5</property> + <attributes> + <attribute name="weight" value="bold"/> + </attributes> + </object> + </child> + <child> + <object class="GtkLabel" id="description_label"> + <property name="wrap">True</property> + <property name="max_width_chars">20</property> + <property name="xalign">0</property> + <property name="yalign">0.5</property> + <property name="vexpand">True</property> + <style> + <class name="dim-label"/> + </style> + </object> + </child> + </object> + </child> + <child> + <object class="GtkBox"> + <property name="orientation">horizontal</property> + <property name="valign">center</property> + <property name="hexpand">False</property> + <child> + <object class="GtkLabel" id="label"> + <property name="visible">False</property> + <property name="margin_start">12</property> + <property name="margin_end">12</property> + <property name="width_request">100</property> + <property name="xalign">1</property> + </object> + </child> + <child> + <object class="GtkButton" id="button_remove"> + <property name="visible">False</property> + <property name="use_underline">True</property> + <property name="label" translatable="yes">_Uninstall</property> + <property name="width_request">105</property> + <property name="can_focus">True</property> + </object> + </child> + <child> + <object class="GtkCheckButton" id="checkbox"> + </object> + </child> + </object> + </child> + </object> + </child> + </template> +</interface> diff --git a/src/gs-app-context-bar.c b/src/gs-app-context-bar.c new file mode 100644 index 0000000..2a21c87 --- /dev/null +++ b/src/gs-app-context-bar.c @@ -0,0 +1,988 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2021 Endless OS Foundation LLC + * + * Author: Philip Withnall <pwithnall@endlessos.org> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +/** + * SECTION:gs-app-context-bar + * @short_description: A bar containing context tiles describing an app + * + * #GsAppContextBar is a bar which contains ‘context tiles’ to describe some of + * the key features of an app. Each tile describes one aspect of the app, such + * as its download/installed size, hardware requirements, or content rating. + * Tiles are intended to convey the most pertinent information about aspects of + * the app, leaving further detail to be shown in a more detailed dialog. + * + * The widget has no special appearance if the app is unset, so callers will + * typically want to hide the bar in that case. + * + * Since: 41 + */ + +#include "config.h" + +#include <adwaita.h> +#include <glib.h> +#include <glib-object.h> +#include <glib/gi18n.h> +#include <gtk/gtk.h> +#include <locale.h> + +#include "gs-age-rating-context-dialog.h" +#include "gs-app.h" +#include "gs-app-context-bar.h" +#include "gs-common.h" +#include "gs-hardware-support-context-dialog.h" +#include "gs-lozenge.h" +#include "gs-safety-context-dialog.h" +#include "gs-storage-context-dialog.h" + +typedef struct +{ + GtkWidget *tile; + GtkWidget *lozenge; + GtkLabel *title; + GtkLabel *description; +} GsAppContextTile; + +typedef enum +{ + STORAGE_TILE, + SAFETY_TILE, + HARDWARE_SUPPORT_TILE, + AGE_RATING_TILE, +} GsAppContextTileType; +#define N_TILE_TYPES (AGE_RATING_TILE + 1) + +struct _GsAppContextBar +{ + GtkBox parent_instance; + + GsApp *app; /* (nullable) (owned) */ + gulong app_notify_handler; + + GsAppContextTile tiles[N_TILE_TYPES]; +}; + +G_DEFINE_TYPE (GsAppContextBar, gs_app_context_bar, GTK_TYPE_BOX) + +typedef enum { + PROP_APP = 1, +} GsAppContextBarProperty; + +static GParamSpec *obj_props[PROP_APP + 1] = { NULL, }; + +/* Certain tiles only make sense for applications which the user can run, and + * not for (say) fonts. + * + * Update the visibility of the tile’s parent box to hide it if both tiles + * are hidden. */ +static gboolean +show_tile_for_non_applications (GsAppContextBar *self, + GsAppContextTileType tile_type) +{ + GtkWidget *sibling; + GtkBox *parent_box; + gboolean any_siblings_visible; + AsComponentKind app_kind = gs_app_get_kind (self->app); + gboolean is_application = (app_kind == AS_COMPONENT_KIND_DESKTOP_APP || + app_kind == AS_COMPONENT_KIND_CONSOLE_APP || + app_kind == AS_COMPONENT_KIND_WEB_APP); + + gtk_widget_set_visible (self->tiles[tile_type].tile, is_application); + + parent_box = GTK_BOX (gtk_widget_get_parent (self->tiles[tile_type].tile)); + g_assert (GTK_IS_BOX (parent_box)); + + any_siblings_visible = FALSE; + + for (sibling = gtk_widget_get_first_child (GTK_WIDGET (parent_box)); + sibling != NULL; + sibling = gtk_widget_get_next_sibling (sibling)) { + g_assert (GTK_IS_BUTTON (sibling)); + any_siblings_visible |= gtk_widget_get_visible (sibling); + } + + gtk_widget_set_visible (GTK_WIDGET (parent_box), any_siblings_visible); + + return is_application; +} + +static void +update_storage_tile (GsAppContextBar *self) +{ + g_autofree gchar *lozenge_text = NULL; + gboolean lozenge_text_is_markup = FALSE; + const gchar *title; + g_autofree gchar *description = NULL; + guint64 size_bytes; + GsSizeType size_type; + + g_assert (self->app != NULL); + + if (gs_app_is_installed (self->app)) { + guint64 size_installed, size_user_data, size_cache_data; + GsSizeType size_installed_type, size_user_data_type, size_cache_data_type; + g_autofree gchar *size_user_data_str = NULL; + g_autofree gchar *size_cache_data_str = NULL; + + size_installed_type = gs_app_get_size_installed (self->app, &size_installed); + size_user_data_type = gs_app_get_size_user_data (self->app, &size_user_data); + size_cache_data_type = gs_app_get_size_cache_data (self->app, &size_cache_data); + + /* Treat `0` sizes as `unknown`, to not show `0 bytes` in the text. */ + if (size_user_data == 0) + size_user_data_type = GS_SIZE_TYPE_UNKNOWN; + if (size_cache_data == 0) + size_cache_data_type = GS_SIZE_TYPE_UNKNOWN; + + /* If any installed sizes are unknowable, ignore them. This + * means the stated installed size is a lower bound on the + * actual installed size. + * Don’t include dependencies in the stated installed size, + * because uninstalling the app won’t reclaim that space unless + * it’s the last app using those dependencies. */ + size_bytes = size_installed; + size_type = size_installed_type; + if (size_user_data_type == GS_SIZE_TYPE_VALID) + size_bytes += size_user_data; + if (size_cache_data_type == GS_SIZE_TYPE_VALID) + size_bytes += size_cache_data; + + size_user_data_str = g_format_size (size_user_data); + size_cache_data_str = g_format_size (size_cache_data); + + /* Translators: The disk usage of an application when installed. + * This is displayed in a context tile, so the string should be short. */ + title = _("Installed Size"); + + if (size_user_data_type == GS_SIZE_TYPE_VALID && size_cache_data_type == GS_SIZE_TYPE_VALID) + description = g_strdup_printf (_("Includes %s of data and %s of cache"), + size_user_data_str, size_cache_data_str); + else if (size_user_data_type == GS_SIZE_TYPE_VALID) + description = g_strdup_printf (_("Includes %s of data"), + size_user_data_str); + else if (size_cache_data_type == GS_SIZE_TYPE_VALID) + description = g_strdup_printf (_("Includes %s of cache"), + size_cache_data_str); + else + description = g_strdup (_("Cache and data usage unknown")); + } else { + guint64 app_download_size_bytes, dependencies_download_size_bytes; + GsSizeType app_download_size_type, dependencies_download_size_type; + + app_download_size_type = gs_app_get_size_download (self->app, &app_download_size_bytes); + dependencies_download_size_type = gs_app_get_size_download_dependencies (self->app, &dependencies_download_size_bytes); + + size_bytes = app_download_size_bytes; + size_type = app_download_size_type; + + /* Translators: The download size of an application. + * This is displayed in a context tile, so the string should be short. */ + title = _("Download Size"); + + if (dependencies_download_size_type == GS_SIZE_TYPE_VALID && + dependencies_download_size_bytes == 0) { + description = g_strdup (_("Needs no additional system downloads")); + } else if (dependencies_download_size_type != GS_SIZE_TYPE_VALID) { + description = g_strdup (_("Needs an unknown size of additional system downloads")); + } else { + g_autofree gchar *size = g_format_size (dependencies_download_size_bytes); + /* Translators: The placeholder is for a size string, + * such as ‘150 MB’ or ‘1.5 GB’. */ + description = g_strdup_printf (_("Needs %s of additional system downloads"), size); + } + } + + if (size_type != GS_SIZE_TYPE_VALID) { + /* Translators: This is displayed for the download size in an + * app’s context tile if the size is unknown. It should be short + * (at most a couple of characters wide). */ + lozenge_text = g_strdup (_("?")); + + g_free (description); + /* Translators: Displayed if the download or installed size of + * an app could not be determined. + * This is displayed in a context tile, so the string should be short. */ + description = g_strdup (_("Size is unknown")); + } else { + lozenge_text = gs_utils_format_size (size_bytes, &lozenge_text_is_markup); + } + + if (lozenge_text_is_markup) + gs_lozenge_set_markup (GS_LOZENGE (self->tiles[STORAGE_TILE].lozenge), lozenge_text); + else + gs_lozenge_set_text (GS_LOZENGE (self->tiles[STORAGE_TILE].lozenge), lozenge_text); + gtk_label_set_text (self->tiles[STORAGE_TILE].title, title); + gtk_label_set_text (self->tiles[STORAGE_TILE].description, description); +} + +typedef enum +{ + /* The code in this file relies on the fact that these enum values + * numerically increase as they get more unsafe. */ + SAFETY_SAFE, + SAFETY_POTENTIALLY_UNSAFE, + SAFETY_UNSAFE +} SafetyRating; + +static void +add_to_safety_rating (SafetyRating *chosen_rating, + GPtrArray *descriptions, + SafetyRating item_rating, + const gchar *item_description) +{ + /* Clear the existing descriptions and replace with @item_description if + * this item increases the @chosen_rating. This means the final list of + * @descriptions will only be the items which caused @chosen_rating to + * be so high. */ + if (item_rating > *chosen_rating) { + g_ptr_array_set_size (descriptions, 0); + *chosen_rating = item_rating; + } + + if (item_rating == *chosen_rating) + g_ptr_array_add (descriptions, (gpointer) item_description); +} + +static void +update_safety_tile (GsAppContextBar *self) +{ + const gchar *icon_name, *title, *css_class; + g_autofree gchar *description = NULL; + g_autoptr(GPtrArray) descriptions = g_ptr_array_new_with_free_func (NULL); + g_autoptr(GsAppPermissions) permissions = NULL; + GsAppPermissionsFlags perm_flags = GS_APP_PERMISSIONS_FLAGS_UNKNOWN; + GtkStyleContext *context; + + /* Treat everything as safe to begin with, and downgrade its safety + * based on app properties. */ + SafetyRating chosen_rating = SAFETY_SAFE; + + g_assert (self->app != NULL); + + permissions = gs_app_dup_permissions (self->app); + if (permissions != NULL) + perm_flags = gs_app_permissions_get_flags (permissions); + for (GsAppPermissionsFlags i = GS_APP_PERMISSIONS_FLAGS_NONE; i < GS_APP_PERMISSIONS_FLAGS_LAST; i <<= 1) { + if (!(perm_flags & i)) + continue; + + switch (i) { + case GS_APP_PERMISSIONS_FLAGS_NONE: + add_to_safety_rating (&chosen_rating, descriptions, + SAFETY_SAFE, + /* Translators: This indicates an app requires no permissions to run. + * It’s used in a context tile, so should be short. */ + _("No permissions")); + break; + case GS_APP_PERMISSIONS_FLAGS_NETWORK: + add_to_safety_rating (&chosen_rating, descriptions, + /* This isn’t actually safe (network access can expand a local + * vulnerability into a remotely exploitable one), but it’s + * needed commonly enough that marking it as + * %SAFETY_POTENTIALLY_UNSAFE is too noisy. */ + SAFETY_SAFE, + /* Translators: This indicates an app uses the network. + * It’s used in a context tile, so should be short. */ + _("Has network access")); + break; + case GS_APP_PERMISSIONS_FLAGS_SYSTEM_BUS: + add_to_safety_rating (&chosen_rating, descriptions, + SAFETY_POTENTIALLY_UNSAFE, + /* Translators: This indicates an app uses D-Bus system services. + * It’s used in a context tile, so should be short. */ + _("Uses system services")); + break; + case GS_APP_PERMISSIONS_FLAGS_SESSION_BUS: + add_to_safety_rating (&chosen_rating, descriptions, + SAFETY_UNSAFE, + /* Translators: This indicates an app uses D-Bus session services. + * It’s used in a context tile, so should be short. */ + _("Uses session services")); + break; + case GS_APP_PERMISSIONS_FLAGS_DEVICES: + add_to_safety_rating (&chosen_rating, descriptions, + SAFETY_POTENTIALLY_UNSAFE, + /* Translators: This indicates an app can access arbitrary hardware devices. + * It’s used in a context tile, so should be short. */ + _("Can access hardware devices")); + break; + case GS_APP_PERMISSIONS_FLAGS_HOME_FULL: + case GS_APP_PERMISSIONS_FLAGS_FILESYSTEM_FULL: + /* Don’t add twice. */ + if (i == GS_APP_PERMISSIONS_FLAGS_HOME_FULL && (perm_flags & GS_APP_PERMISSIONS_FLAGS_FILESYSTEM_FULL)) + break; + + add_to_safety_rating (&chosen_rating, descriptions, + SAFETY_UNSAFE, + /* Translators: This indicates an app can read/write to the user’s home or the entire filesystem. + * It’s used in a context tile, so should be short. */ + _("Can read/write all your data")); + break; + case GS_APP_PERMISSIONS_FLAGS_HOME_READ: + case GS_APP_PERMISSIONS_FLAGS_FILESYSTEM_READ: + /* Don’t add twice. */ + if (i == GS_APP_PERMISSIONS_FLAGS_HOME_READ && (perm_flags & GS_APP_PERMISSIONS_FLAGS_FILESYSTEM_READ)) + break; + + add_to_safety_rating (&chosen_rating, descriptions, + SAFETY_UNSAFE, + /* Translators: This indicates an app can read (but not write) from the user’s home or the entire filesystem. + * It’s used in a context tile, so should be short. */ + _("Can read all your data")); + break; + case GS_APP_PERMISSIONS_FLAGS_DOWNLOADS_FULL: + add_to_safety_rating (&chosen_rating, descriptions, + SAFETY_POTENTIALLY_UNSAFE, + /* Translators: This indicates an app can read/write to the user’s Downloads directory. + * It’s used in a context tile, so should be short. */ + _("Can read/write your downloads")); + break; + case GS_APP_PERMISSIONS_FLAGS_DOWNLOADS_READ: + add_to_safety_rating (&chosen_rating, descriptions, + SAFETY_POTENTIALLY_UNSAFE, + /* Translators: This indicates an app can read (but not write) from the user’s Downloads directory. + * It’s used in a context tile, so should be short. */ + _("Can read your downloads")); + break; + case GS_APP_PERMISSIONS_FLAGS_FILESYSTEM_OTHER: + add_to_safety_rating (&chosen_rating, descriptions, + SAFETY_POTENTIALLY_UNSAFE, + /* Translators: This indicates an app can access data in the system unknown to the Software. + * It’s used in a context tile, so should be short. */ + _("Can access arbitrary files")); + break; + case GS_APP_PERMISSIONS_FLAGS_SETTINGS: + add_to_safety_rating (&chosen_rating, descriptions, + SAFETY_POTENTIALLY_UNSAFE, + /* Translators: This indicates an app can access or change user settings. + * It’s used in a context tile, so should be short. */ + _("Can access and change user settings")); + break; + case GS_APP_PERMISSIONS_FLAGS_X11: + add_to_safety_rating (&chosen_rating, descriptions, + SAFETY_UNSAFE, + /* Translators: This indicates an app uses the X11 windowing system. + * It’s used in a context tile, so should be short. */ + _("Uses a legacy windowing system")); + break; + case GS_APP_PERMISSIONS_FLAGS_ESCAPE_SANDBOX: + add_to_safety_rating (&chosen_rating, descriptions, + SAFETY_UNSAFE, + /* Translators: This indicates an app can escape its sandbox. + * It’s used in a context tile, so should be short. */ + _("Can acquire arbitrary permissions")); + break; + default: + break; + } + } + + /* Unknown permissions typically come from non-sandboxed packaging + * systems like RPM or DEB. Telling the user the software has unknown + * permissions is unhelpful; it’s more relevant to say it’s not + * sandboxed but is (or is not) packaged by a trusted vendor. They will + * have (at least) done some basic checks to make sure the software is + * not overtly malware. That doesn’t protect the user from exploitable + * bugs in the software, but it does mean they’re not accidentally + * installing something which is actively malicious. + * + * FIXME: We could do better by potentially adding a ‘trusted’ state + * to indicate that something is probably safe, but isn’t sandboxed. + * See https://gitlab.gnome.org/GNOME/gnome-software/-/issues/1451 */ + if (perm_flags == GS_APP_PERMISSIONS_FLAGS_UNKNOWN && + gs_app_has_quirk (self->app, GS_APP_QUIRK_PROVENANCE)) + add_to_safety_rating (&chosen_rating, descriptions, + SAFETY_SAFE, + /* Translators: This indicates that an application has been packaged + * by the user’s distribution and is safe. + * It’s used in a context tile, so should be short. */ + _("Reviewed by your distribution")); + else if (perm_flags == GS_APP_PERMISSIONS_FLAGS_UNKNOWN) + add_to_safety_rating (&chosen_rating, descriptions, + SAFETY_POTENTIALLY_UNSAFE, + /* Translators: This indicates that an application has been packaged + * by someone other than the user’s distribution, so might not be safe. + * It’s used in a context tile, so should be short. */ + _("Provided by a third party")); + + /* Is the code FOSS and hence inspectable? This doesn’t distinguish + * between closed source and open-source-but-not-FOSS software, even + * though the code of the latter is technically publicly auditable. This + * is because I don’t want to get into the business of maintaining lists + * of ‘auditable’ source code licenses. */ + if (!gs_app_get_license_is_free (self->app)) + add_to_safety_rating (&chosen_rating, descriptions, + SAFETY_POTENTIALLY_UNSAFE, + /* Translators: This indicates an app is not licensed under a free software license. + * It’s used in a context tile, so should be short. */ + _("Proprietary code")); + else + add_to_safety_rating (&chosen_rating, descriptions, + SAFETY_SAFE, + /* Translators: This indicates an app’s source code is freely available, so can be audited for security. + * It’s used in a context tile, so should be short. */ + _("Auditable code")); + + if (gs_app_has_quirk (self->app, GS_APP_QUIRK_DEVELOPER_VERIFIED)) + add_to_safety_rating (&chosen_rating, descriptions, + SAFETY_SAFE, + /* Translators: This indicates an app was written and released by a developer who has been verified. + * It’s used in a context tile, so should be short. */ + _("Software developer is verified")); + + if (gs_app_get_metadata_item (self->app, "GnomeSoftware::EolReason") != NULL || ( + gs_app_get_runtime (self->app) != NULL && + gs_app_get_metadata_item (gs_app_get_runtime (self->app), "GnomeSoftware::EolReason") != NULL)) + add_to_safety_rating (&chosen_rating, descriptions, + SAFETY_UNSAFE, + /* Translators: This indicates an app or its runtime reached its end of life. + * It’s used in a context tile, so should be short. */ + _("Software no longer supported")); + + g_assert (descriptions->len > 0); + + g_ptr_array_add (descriptions, NULL); + /* Translators: This string is used to join various other translated + * strings into an inline list of reasons why an app has been marked as + * ‘safe’, ‘potentially safe’ or ‘unsafe’. For example: + * “App comes from a trusted source; Auditable code; No permissions” + * If concatenating strings as a list using a separator like this is not + * possible in your language, please file an issue against gnome-software: + * https://gitlab.gnome.org/GNOME/gnome-software/-/issues/new */ + description = g_strjoinv (_("; "), (gchar **) descriptions->pdata); + + /* Update the UI. */ + switch (chosen_rating) { + case SAFETY_SAFE: + icon_name = "safety-symbolic"; + /* Translators: The app is considered safe to install and run. + * This is displayed in a context tile, so the string should be short. */ + title = _("Safe"); + css_class = "green"; + break; + case SAFETY_POTENTIALLY_UNSAFE: + icon_name = "dialog-question-symbolic"; + /* Translators: The app is considered potentially unsafe to install and run. + * This is displayed in a context tile, so the string should be short. */ + title = _("Potentially Unsafe"); + css_class = "yellow"; + break; + case SAFETY_UNSAFE: + icon_name = "dialog-warning-symbolic"; + /* Translators: The app is considered unsafe to install and run. + * This is displayed in a context tile, so the string should be short. */ + title = _("Unsafe"); + css_class = "red"; + break; + default: + g_assert_not_reached (); + } + + gs_lozenge_set_icon_name (GS_LOZENGE (self->tiles[SAFETY_TILE].lozenge), icon_name); + gtk_label_set_text (self->tiles[SAFETY_TILE].title, title); + gtk_label_set_text (self->tiles[SAFETY_TILE].description, description); + + context = gtk_widget_get_style_context (self->tiles[SAFETY_TILE].lozenge); + + gtk_style_context_remove_class (context, "green"); + gtk_style_context_remove_class (context, "yellow"); + gtk_style_context_remove_class (context, "red"); + + gtk_style_context_add_class (context, css_class); +} + +typedef struct { + guint min; + guint max; +} Range; + +static void +update_hardware_support_tile (GsAppContextBar *self) +{ + g_autoptr(GPtrArray) relations = NULL; + AsRelationKind control_relations[AS_CONTROL_KIND_LAST] = { AS_RELATION_KIND_UNKNOWN, }; + GdkDisplay *display; + GdkMonitor *monitor = NULL; + gboolean any_control_relations_set; + const gchar *icon_name = NULL, *title = NULL, *description = NULL, *css_class = NULL; + gboolean has_touchscreen = FALSE, has_keyboard = FALSE, has_mouse = FALSE; + GtkStyleContext *context; + + g_assert (self->app != NULL); + + /* Don’t show the hardware support tile for non-desktop applications. */ + if (!show_tile_for_non_applications (self, HARDWARE_SUPPORT_TILE)) + return; + + relations = gs_app_get_relations (self->app); + + /* Extract the %AS_RELATION_ITEM_KIND_CONTROL relations and summarise + * them. */ + display = gtk_widget_get_display (GTK_WIDGET (self)); + gs_hardware_support_context_dialog_get_control_support (display, relations, + &any_control_relations_set, + control_relations, + &has_touchscreen, + &has_keyboard, + &has_mouse); + + /* Warn about screen size mismatches. Compare against the largest + * monitor associated with this widget’s #GdkDisplay, defaulting to + * the primary monitor. + * + * See https://www.freedesktop.org/software/appstream/docs/chap-Metadata.html#tag-requires-recommends-display_length + * for the semantics of the display length relations.*/ + if (display != NULL) + monitor = gs_hardware_support_context_dialog_get_largest_monitor (display); + + if (monitor != NULL) { + AsRelationKind desktop_relation_kind, mobile_relation_kind, current_relation_kind; + gboolean desktop_match, mobile_match, current_match; + + gs_hardware_support_context_dialog_get_display_support (monitor, relations, + NULL, + &desktop_match, &desktop_relation_kind, + &mobile_match, &mobile_relation_kind, + ¤t_match, ¤t_relation_kind); + + /* If the current screen size is not supported, try and + * summarise the restrictions into a single context tile. */ + if (!current_match && + !mobile_match && mobile_relation_kind == AS_RELATION_KIND_REQUIRES) { + icon_name = "phone-symbolic"; + title = _("Mobile Only"); + description = _("Only works on a small screen"); + css_class = "red"; + } else if (!current_match && + !desktop_match && desktop_relation_kind == AS_RELATION_KIND_REQUIRES) { + icon_name = "desktop-symbolic"; + title = _("Desktop Only"); + description = _("Only works on a large screen"); + css_class = "red"; + } else if (!current_match && current_relation_kind == AS_RELATION_KIND_REQUIRES) { + icon_name = "desktop-symbolic"; + title = _("Screen Size Mismatch"); + description = _("Doesn’t support your current screen size"); + css_class = "red"; + } + } + + /* Warn about missing touchscreen or keyboard support. There are some + * assumptions here that certain input devices are only available on + * certain platforms; they can change in future. + * + * As with the rest of the tile contents in this function, tile contents + * which are checked lower down in the function are only used if nothing + * more important has already been set earlier. + * + * The available information is being summarised to quite an extreme + * degree here, and it’s likely this code will have to evolve for + * corner cases in future. */ + if (icon_name == NULL && + control_relations[AS_CONTROL_KIND_TOUCH] == AS_RELATION_KIND_REQUIRES && + !has_touchscreen) { + icon_name = "phone-symbolic"; + title = _("Mobile Only"); + description = _("Requires a touchscreen"); + css_class = "red"; + } else if (icon_name == NULL && + control_relations[AS_CONTROL_KIND_KEYBOARD] == AS_RELATION_KIND_REQUIRES && + !has_keyboard) { + icon_name = "input-keyboard-symbolic"; + title = _("Desktop Only"); + description = _("Requires a keyboard"); + css_class = "red"; + } else if (icon_name == NULL && + control_relations[AS_CONTROL_KIND_POINTING] == AS_RELATION_KIND_REQUIRES && + !has_mouse) { + icon_name = "input-mouse-symbolic"; + title = _("Desktop Only"); + description = _("Requires a mouse"); + css_class = "red"; + } + + /* Say if the app requires a gamepad. We can’t reliably detect whether + * the computer has a gamepad, as it might be unplugged unless the user + * is currently playing a game. So this might be shown even if the user + * has a gamepad available. */ + if (icon_name == NULL && + control_relations[AS_CONTROL_KIND_GAMEPAD] == AS_RELATION_KIND_REQUIRES) { + icon_name = "input-gaming-symbolic"; + title = _("Gamepad Needed"); + description = _("Requires a gamepad to play"); + css_class = "yellow"; + } + + /* Otherwise, is it adaptive? Note that %AS_RELATION_KIND_RECOMMENDS + * means more like ‘supports’ than ‘recommends’. */ +#if AS_CHECK_VERSION(0, 15, 0) + if (icon_name == NULL && + (control_relations[AS_CONTROL_KIND_TOUCH] == AS_RELATION_KIND_RECOMMENDS || + control_relations[AS_CONTROL_KIND_TOUCH] == AS_RELATION_KIND_SUPPORTS) && + (control_relations[AS_CONTROL_KIND_KEYBOARD] == AS_RELATION_KIND_RECOMMENDS || + control_relations[AS_CONTROL_KIND_KEYBOARD] == AS_RELATION_KIND_SUPPORTS) && + (control_relations[AS_CONTROL_KIND_POINTING] == AS_RELATION_KIND_RECOMMENDS || + control_relations[AS_CONTROL_KIND_POINTING] == AS_RELATION_KIND_SUPPORTS)) { +#else + if (icon_name == NULL && + control_relations[AS_CONTROL_KIND_TOUCH] == AS_RELATION_KIND_RECOMMENDS && + control_relations[AS_CONTROL_KIND_KEYBOARD] == AS_RELATION_KIND_RECOMMENDS && + control_relations[AS_CONTROL_KIND_POINTING] == AS_RELATION_KIND_RECOMMENDS) { +#endif + icon_name = "adaptive-symbolic"; + /* Translators: This is used in a context tile to indicate that + * an app works on phones, tablets *and* desktops. It should be + * short and in title case. */ + title = _("Adaptive"); + description = _("Works on phones, tablets and desktops"); + css_class = "green"; + } + + /* Fallback. At the moment (June 2021) almost no apps have any metadata + * about hardware support, so this case will be hit most of the time. + * + * So in the absence of any other information, assume that all apps + * support desktop, and none support mobile. */ + if (icon_name == NULL) { + if (!has_keyboard || !has_mouse) { + icon_name = "desktop-symbolic"; + title = _("Desktop Only"); + description = _("Probably requires a keyboard or mouse"); + css_class = "yellow"; + } else { + icon_name = "desktop-symbolic"; + title = _("Desktop Only"); + description = _("Works on desktops and laptops"); + css_class = "grey"; + } + } + + /* Update the UI. The `adaptive-symbolic` icon needs a special size to + * be set, as it is wider than it is tall. Setting the size ensures it’s + * rendered at the right height. */ + gs_lozenge_set_icon_name (GS_LOZENGE (self->tiles[HARDWARE_SUPPORT_TILE].lozenge), icon_name); + gs_lozenge_set_pixel_size (GS_LOZENGE (self->tiles[HARDWARE_SUPPORT_TILE].lozenge), g_str_equal (icon_name, "adaptive-symbolic") ? 56 : -1); + + gtk_label_set_text (self->tiles[HARDWARE_SUPPORT_TILE].title, title); + gtk_label_set_text (self->tiles[HARDWARE_SUPPORT_TILE].description, description); + + context = gtk_widget_get_style_context (self->tiles[HARDWARE_SUPPORT_TILE].lozenge); + + gtk_style_context_remove_class (context, "green"); + gtk_style_context_remove_class (context, "yellow"); + gtk_style_context_remove_class (context, "red"); + + gtk_style_context_add_class (context, css_class); + + if (g_str_equal (icon_name, "adaptive-symbolic")) + gtk_style_context_add_class (context, "wide-image"); + else + gtk_style_context_remove_class (context, "wide-image"); +} + +static void +build_age_rating_description_cb (const gchar *attribute, + AsContentRatingValue value, + gpointer user_data) +{ + GPtrArray *descriptions = user_data; + const gchar *description; + + /* (attribute == NULL) is used by the caller to indicate that no + * attributes apply. This callback will be called at most once like + * that. */ + if (attribute == NULL) + /* Translators: This indicates that the content rating for an + * app says it can be used by all ages of people, as it contains + * no objectionable content. */ + description = _("Contains no age-inappropriate content"); + else + description = as_content_rating_attribute_get_description (attribute, value); + + g_ptr_array_add (descriptions, (gpointer) description); +} + +static gchar * +build_age_rating_description (AsContentRating *content_rating) +{ + g_autoptr(GPtrArray) descriptions = g_ptr_array_new_with_free_func (NULL); + + gs_age_rating_context_dialog_process_attributes (content_rating, + TRUE, + build_age_rating_description_cb, + descriptions); + + g_ptr_array_add (descriptions, NULL); + /* Translators: This string is used to join various other translated + * strings into an inline list of reasons why an app has been given a + * certain content rating. For example: + * “References to alcoholic beverages; Moderated chat functionality between users” + * If concatenating strings as a list using a separator like this is not + * possible in your language, please file an issue against gnome-software: + * https://gitlab.gnome.org/GNOME/gnome-software/-/issues/new */ + return g_strjoinv (_("; "), (gchar **) descriptions->pdata); +} + +static void +update_age_rating_tile (GsAppContextBar *self) +{ + g_autoptr(AsContentRating) content_rating = NULL; + gboolean is_unknown; + g_autofree gchar *description = NULL; + + g_assert (self->app != NULL); + + /* Don’t show the age rating tile for non-desktop applications. */ + if (!show_tile_for_non_applications (self, AGE_RATING_TILE)) + return; + + content_rating = gs_app_dup_content_rating (self->app); + gs_age_rating_context_dialog_update_lozenge (self->app, + GS_LOZENGE (self->tiles[AGE_RATING_TILE].lozenge), + &is_unknown); + + /* Description */ + if (content_rating == NULL || is_unknown) { + description = g_strdup (_("No age rating information available")); + } else { + description = build_age_rating_description (content_rating); + } + + gtk_label_set_text (self->tiles[AGE_RATING_TILE].description, description); + + /* Disable the button if no content rating information is available, as + * it would only show a dialogue full of rows saying ‘Unknown’ */ + gtk_widget_set_sensitive (self->tiles[AGE_RATING_TILE].tile, (content_rating != NULL)); +} + +static void +update_tiles (GsAppContextBar *self) +{ + if (self->app == NULL) + return; + + update_storage_tile (self); + update_safety_tile (self); + update_hardware_support_tile (self); + update_age_rating_tile (self); +} + +static void +app_notify_cb (GObject *obj, + GParamSpec *pspec, + gpointer user_data) +{ + GsAppContextBar *self = GS_APP_CONTEXT_BAR (user_data); + + update_tiles (self); +} + +static void +tile_clicked_cb (GtkWidget *widget, + gpointer user_data) +{ + GsAppContextBar *self = GS_APP_CONTEXT_BAR (user_data); + GtkWindow *dialog; + GtkRoot *root = gtk_widget_get_root (widget); + + if (GTK_IS_WINDOW (root)) { + if (widget == self->tiles[STORAGE_TILE].tile) + dialog = GTK_WINDOW (gs_storage_context_dialog_new (self->app)); + else if (widget == self->tiles[SAFETY_TILE].tile) + dialog = GTK_WINDOW (gs_safety_context_dialog_new (self->app)); + else if (widget == self->tiles[HARDWARE_SUPPORT_TILE].tile) + dialog = GTK_WINDOW (gs_hardware_support_context_dialog_new (self->app)); + else if (widget == self->tiles[AGE_RATING_TILE].tile) + dialog = GTK_WINDOW (gs_age_rating_context_dialog_new (self->app)); + else + g_assert_not_reached (); + + gtk_window_set_transient_for (dialog, GTK_WINDOW (root)); + gtk_widget_show (GTK_WIDGET (dialog)); + } +} + +static void +gs_app_context_bar_init (GsAppContextBar *self) +{ + gtk_widget_init_template (GTK_WIDGET (self)); +} + +static void +gs_app_context_bar_get_property (GObject *object, + guint prop_id, + GValue *value, + GParamSpec *pspec) +{ + GsAppContextBar *self = GS_APP_CONTEXT_BAR (object); + + switch ((GsAppContextBarProperty) prop_id) { + case PROP_APP: + g_value_set_object (value, gs_app_context_bar_get_app (self)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_app_context_bar_set_property (GObject *object, + guint prop_id, + const GValue *value, + GParamSpec *pspec) +{ + GsAppContextBar *self = GS_APP_CONTEXT_BAR (object); + + switch ((GsAppContextBarProperty) prop_id) { + case PROP_APP: + gs_app_context_bar_set_app (self, g_value_get_object (value)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_app_context_bar_dispose (GObject *object) +{ + GsAppContextBar *self = GS_APP_CONTEXT_BAR (object); + + if (self->app_notify_handler != 0) { + g_signal_handler_disconnect (self->app, self->app_notify_handler); + self->app_notify_handler = 0; + } + g_clear_object (&self->app); + + G_OBJECT_CLASS (gs_app_context_bar_parent_class)->dispose (object); +} + +static void +gs_app_context_bar_class_init (GsAppContextBarClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + + object_class->get_property = gs_app_context_bar_get_property; + object_class->set_property = gs_app_context_bar_set_property; + object_class->dispose = gs_app_context_bar_dispose; + + /** + * GsAppContextBar:app: (nullable) + * + * The app to display the context details for. + * + * This may be %NULL; if so, the content of the widget will be + * undefined. + * + * Since: 41 + */ + obj_props[PROP_APP] = + g_param_spec_object ("app", NULL, NULL, + GS_TYPE_APP, + G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS); + + g_object_class_install_properties (object_class, G_N_ELEMENTS (obj_props), obj_props); + + gtk_widget_class_set_css_name (widget_class, "app-context-bar"); + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-app-context-bar.ui"); + + gtk_widget_class_bind_template_child_full (widget_class, "storage_tile", FALSE, G_STRUCT_OFFSET (GsAppContextBar, tiles[STORAGE_TILE].tile)); + gtk_widget_class_bind_template_child_full (widget_class, "storage_tile_lozenge", FALSE, G_STRUCT_OFFSET (GsAppContextBar, tiles[STORAGE_TILE].lozenge)); + gtk_widget_class_bind_template_child_full (widget_class, "storage_tile_title", FALSE, G_STRUCT_OFFSET (GsAppContextBar, tiles[STORAGE_TILE].title)); + gtk_widget_class_bind_template_child_full (widget_class, "storage_tile_description", FALSE, G_STRUCT_OFFSET (GsAppContextBar, tiles[STORAGE_TILE].description)); + gtk_widget_class_bind_template_child_full (widget_class, "safety_tile", FALSE, G_STRUCT_OFFSET (GsAppContextBar, tiles[SAFETY_TILE].tile)); + gtk_widget_class_bind_template_child_full (widget_class, "safety_tile_lozenge", FALSE, G_STRUCT_OFFSET (GsAppContextBar, tiles[SAFETY_TILE].lozenge)); + gtk_widget_class_bind_template_child_full (widget_class, "safety_tile_title", FALSE, G_STRUCT_OFFSET (GsAppContextBar, tiles[SAFETY_TILE].title)); + gtk_widget_class_bind_template_child_full (widget_class, "safety_tile_description", FALSE, G_STRUCT_OFFSET (GsAppContextBar, tiles[SAFETY_TILE].description)); + gtk_widget_class_bind_template_child_full (widget_class, "hardware_support_tile", FALSE, G_STRUCT_OFFSET (GsAppContextBar, tiles[HARDWARE_SUPPORT_TILE].tile)); + gtk_widget_class_bind_template_child_full (widget_class, "hardware_support_tile_lozenge", FALSE, G_STRUCT_OFFSET (GsAppContextBar, tiles[HARDWARE_SUPPORT_TILE].lozenge)); + gtk_widget_class_bind_template_child_full (widget_class, "hardware_support_tile_title", FALSE, G_STRUCT_OFFSET (GsAppContextBar, tiles[HARDWARE_SUPPORT_TILE].title)); + gtk_widget_class_bind_template_child_full (widget_class, "hardware_support_tile_description", FALSE, G_STRUCT_OFFSET (GsAppContextBar, tiles[HARDWARE_SUPPORT_TILE].description)); + gtk_widget_class_bind_template_child_full (widget_class, "age_rating_tile", FALSE, G_STRUCT_OFFSET (GsAppContextBar, tiles[AGE_RATING_TILE].tile)); + gtk_widget_class_bind_template_child_full (widget_class, "age_rating_tile_lozenge", FALSE, G_STRUCT_OFFSET (GsAppContextBar, tiles[AGE_RATING_TILE].lozenge)); + gtk_widget_class_bind_template_child_full (widget_class, "age_rating_tile_title", FALSE, G_STRUCT_OFFSET (GsAppContextBar, tiles[AGE_RATING_TILE].title)); + gtk_widget_class_bind_template_child_full (widget_class, "age_rating_tile_description", FALSE, G_STRUCT_OFFSET (GsAppContextBar, tiles[AGE_RATING_TILE].description)); + gtk_widget_class_bind_template_callback (widget_class, tile_clicked_cb); +} + +/** + * gs_app_context_bar_new: + * @app: (nullable): the app to display context tiles for, or %NULL + * + * Create a new #GsAppContextBar and set its initial app to @app. + * + * Returns: (transfer full): a new #GsAppContextBar + * Since: 41 + */ +GtkWidget * +gs_app_context_bar_new (GsApp *app) +{ + g_return_val_if_fail (app == NULL || GS_IS_APP (app), NULL); + + return g_object_new (GS_TYPE_APP_CONTEXT_BAR, + "app", app, + NULL); +} + +/** + * gs_app_context_bar_get_app: + * @self: a #GsAppContextBar + * + * Gets the value of #GsAppContextBar:app. + * + * Returns: (nullable) (transfer none): app whose context tiles are being + * displayed, or %NULL if none is set + * Since: 41 + */ +GsApp * +gs_app_context_bar_get_app (GsAppContextBar *self) +{ + g_return_val_if_fail (GS_IS_APP_CONTEXT_BAR (self), NULL); + + return self->app; +} + +/** + * gs_app_context_bar_set_app: + * @self: a #GsAppContextBar + * @app: (nullable) (transfer none): the app to display context tiles for, + * or %NULL for none + * + * Set the value of #GsAppContextBar:app. + * + * Since: 41 + */ +void +gs_app_context_bar_set_app (GsAppContextBar *self, + GsApp *app) +{ + g_return_if_fail (GS_IS_APP_CONTEXT_BAR (self)); + g_return_if_fail (app == NULL || GS_IS_APP (app)); + + if (app == self->app) + return; + + if (self->app_notify_handler != 0) { + g_signal_handler_disconnect (self->app, self->app_notify_handler); + self->app_notify_handler = 0; + } + + g_set_object (&self->app, app); + + if (self->app != NULL) + self->app_notify_handler = g_signal_connect (self->app, "notify", G_CALLBACK (app_notify_cb), self); + + /* Update the tiles. */ + update_tiles (self); + + g_object_notify_by_pspec (G_OBJECT (self), obj_props[PROP_APP]); +} diff --git a/src/gs-app-context-bar.h b/src/gs-app-context-bar.h new file mode 100644 index 0000000..e016747 --- /dev/null +++ b/src/gs-app-context-bar.h @@ -0,0 +1,31 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2021 Endless OS Foundation LLC + * + * Author: Philip Withnall <pwithnall@endlessos.org> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <glib.h> +#include <glib-object.h> +#include <gtk/gtk.h> + +#include "gs-app.h" + +G_BEGIN_DECLS + +#define GS_TYPE_APP_CONTEXT_BAR (gs_app_context_bar_get_type ()) + +G_DECLARE_FINAL_TYPE (GsAppContextBar, gs_app_context_bar, GS, APP_CONTEXT_BAR, GtkBox) + +GtkWidget *gs_app_context_bar_new (GsApp *app); + +GsApp *gs_app_context_bar_get_app (GsAppContextBar *self); +void gs_app_context_bar_set_app (GsAppContextBar *self, + GsApp *app); + +G_END_DECLS diff --git a/src/gs-app-context-bar.ui b/src/gs-app-context-bar.ui new file mode 100644 index 0000000..db70912 --- /dev/null +++ b/src/gs-app-context-bar.ui @@ -0,0 +1,249 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <requires lib="gtk+" version="3.10"/> + <template class="GsAppContextBar" parent="GtkBox"> + <property name="homogeneous">True</property> + <property name="spacing">0</property> + <style> + <class name="card"/> + </style> + + <child> + <object class="GtkBox"> + <property name="homogeneous">True</property> + + <child> + <object class="GtkButton" id="storage_tile"> + <signal name="clicked" handler="tile_clicked_cb"/> + <style> + <class name="context-tile"/> + <class name="flat"/> + </style> + <child> + <object class="GtkBox"> + <property name="orientation">vertical</property> + <property name="spacing">8</property> + <child> + <object class="GsLozenge" id="storage_tile_lozenge"> + <property name="circular">False</property> + <style> + <class name="grey"/> + </style> + <accessibility> + <relation name="labelled-by">storage_tile_title</relation> + <relation name="details">storage_tile_description</relation> + </accessibility> + </object> + </child> + <child> + <object class="GtkLabel" id="storage_tile_title"> + <property name="justify">center</property> + <!-- this is a placeholder: the text is actually set in code --> + <property name="label">Download Size</property> + <property name="wrap">True</property> + <property name="xalign">0.5</property> + <style> + <class name="heading"/> + </style> + </object> + </child> + <child> + <object class="GtkLabel" id="storage_tile_description"> + <property name="justify">center</property> + <!-- this is a placeholder: the text is actually set in code --> + <property name="label">Needs 150 MB of additional system downloads</property> + <property name="wrap">True</property> + <property name="xalign">0.5</property> + <style> + <class name="caption"/> + </style> + </object> + </child> + </object> + </child> + </object> + </child> + + <child> + <object class="GtkButton" id="safety_tile"> + <signal name="clicked" handler="tile_clicked_cb"/> + <style> + <class name="context-tile"/> + <class name="flat"/> + </style> + <child> + <object class="GtkBox"> + <property name="orientation">vertical</property> + <property name="spacing">8</property> + <child> + <object class="GsLozenge" id="safety_tile_lozenge"> + <property name="circular">True</property> + <!-- this is a placeholder: the icon is actually set in code --> + <property name="icon-name">safety-symbolic</property> + <style> + <class name="green"/> + </style> + <accessibility> + <relation name="labelled-by">safety_tile_title</relation> + <relation name="details">safety_tile_description</relation> + </accessibility> + </object> + </child> + <child> + <object class="GtkLabel" id="safety_tile_title"> + <property name="justify">center</property> + <!-- this is a placeholder: the text is actually set in code --> + <property name="label">Safe</property> + <property name="wrap">True</property> + <property name="xalign">0.5</property> + <style> + <class name="heading"/> + </style> + </object> + </child> + <child> + <object class="GtkLabel" id="safety_tile_description"> + <property name="justify">center</property> + <!-- this is a placeholder: the text is actually set in code --> + <property name="label">Auditable, no tracking, few permissions</property> + <property name="wrap">True</property> + <property name="xalign">0.5</property> + <style> + <class name="caption"/> + </style> + </object> + </child> + </object> + </child> + </object> + </child> + + </object> + </child> + + <child> + <object class="GtkBox"> + <property name="homogeneous">True</property> + + <child> + <object class="GtkButton" id="hardware_support_tile"> + <signal name="clicked" handler="tile_clicked_cb"/> + <style> + <class name="context-tile"/> + <class name="flat"/> + </style> + <child> + <object class="GtkBox"> + <property name="orientation">vertical</property> + <property name="spacing">8</property> + <child> + <object class="GsLozenge" id="hardware_support_tile_lozenge"> + <property name="circular">False</property> + <!-- this is a placeholder: the icon is actually set in code --> + <property name="icon-name">adaptive-symbolic</property> + <property name="pixel-size">56</property> + <style> + <class name="green"/> + <class name="wide-image"/> + </style> + <accessibility> + <relation name="labelled-by">hardware_support_tile_title</relation> + <relation name="details">hardware_support_tile_description</relation> + </accessibility> + </object> + </child> + <child> + <object class="GtkLabel" id="hardware_support_tile_title"> + <property name="justify">center</property> + <!-- this is a placeholder: the text is actually set in code --> + <property name="label">Adaptive</property> + <property name="wrap">True</property> + <property name="xalign">0.5</property> + <style> + <class name="heading"/> + </style> + </object> + </child> + <child> + <object class="GtkLabel" id="hardware_support_tile_description"> + <property name="justify">center</property> + <!-- this is a placeholder: the text is actually set in code --> + <property name="label">Works on phones, tablets and desktops</property> + <property name="wrap">True</property> + <property name="xalign">0.5</property> + <style> + <class name="caption"/> + </style> + </object> + </child> + </object> + </child> + </object> + </child> + + <child> + <object class="GtkButton" id="age_rating_tile"> + <signal name="clicked" handler="tile_clicked_cb"/> + <style> + <class name="context-tile"/> + <class name="flat"/> + </style> + <child> + <object class="GtkBox"> + <property name="orientation">vertical</property> + <property name="spacing">8</property> + <child> + <object class="GsLozenge" id="age_rating_tile_lozenge"> + <property name="circular">True</property> + <style> + <class name="details-rating-18"/> + </style> + <accessibility> + <relation name="labelled-by">age_rating_tile_title</relation> + <relation name="details">age_rating_tile_description</relation> + </accessibility> + </object> + </child> + <child> + <object class="GtkLabel" id="age_rating_tile_title"> + <property name="justify">center</property> + <!-- this one’s not a placeholder --> + <property name="label" translatable="yes">Age Rating</property> + <property name="wrap">True</property> + <property name="xalign">0.5</property> + <style> + <class name="heading"/> + </style> + </object> + </child> + <child> + <object class="GtkLabel" id="age_rating_tile_description"> + <property name="justify">center</property> + <!-- this is a placeholder: the text is actually set in code --> + <property name="label">May contain sex, drugs, rock‘n’roll and more</property> + <property name="wrap">True</property> + <property name="xalign">0.5</property> + <style> + <class name="caption"/> + </style> + </object> + </child> + </object> + </child> + </object> + </child> + + </object> + </child> + </template> + + <object class="GtkSizeGroup" id="app_context_bar_size_group"> + <property name="mode">vertical</property> + <widgets> + <widget name="storage_tile_lozenge"/> + <widget name="safety_tile_lozenge"/> + <widget name="hardware_support_tile_lozenge"/> + <widget name="age_rating_tile_lozenge"/> + </widgets> + </object> +</interface> diff --git a/src/gs-app-details-page.c b/src/gs-app-details-page.c new file mode 100644 index 0000000..c224f2a --- /dev/null +++ b/src/gs-app-details-page.c @@ -0,0 +1,451 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013-2016 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2014-2018 Kalev Lember <klember@redhat.com> + * Copyright (C) 2021 Purism SPC + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +/** + * SECTION:gs-app-details-page + * @title: GsAppDetailsPage + * @include: gnome-software.h + * @stability: Stable + * @short_description: A small page showing an application's details + * + * This is a page from #GsUpdateDialog. + */ + +#include "config.h" + +#include <adwaita.h> +#include <glib/gi18n.h> + +#include "gs-app-details-page.h" +#include "gs-app-row.h" +#include "gs-update-list.h" +#include "gs-common.h" + +typedef enum { + PROP_APP = 1, + PROP_SHOW_BACK_BUTTON, + PROP_TITLE, +} GsAppDetailsPageProperty; + +enum { + SIGNAL_BACK_CLICKED, + SIGNAL_LAST +}; + +static GParamSpec *obj_props[PROP_TITLE + 1] = { NULL, }; + +static guint signals[SIGNAL_LAST] = { 0 }; + +struct _GsAppDetailsPage +{ + GtkBox parent_instance; + + GtkWidget *back_button; + GtkWidget *header_bar; + GtkWidget *label_details; + GtkWidget *permissions_section; + GtkWidget *permissions_section_list; + GtkWidget *status_page; + AdwWindowTitle *window_title; + + GsApp *app; /* (owned) (nullable) */ +}; + +G_DEFINE_TYPE (GsAppDetailsPage, gs_app_details_page, GTK_TYPE_BOX) + +static const struct { + GsAppPermissionsFlags permission; + const char *title; + const char *subtitle; +} permission_display_data[] = { + { GS_APP_PERMISSIONS_FLAGS_NETWORK, N_("Network"), N_("Can communicate over the network") }, + { GS_APP_PERMISSIONS_FLAGS_SYSTEM_BUS, N_("System Services"), N_("Can access D-Bus services on the system bus") }, + { GS_APP_PERMISSIONS_FLAGS_SESSION_BUS, N_("Session Services"), N_("Can access D-Bus services on the session bus") }, + { GS_APP_PERMISSIONS_FLAGS_DEVICES, N_("Devices"), N_("Can access system device files") }, + { GS_APP_PERMISSIONS_FLAGS_HOME_FULL, N_("Home folder"), N_("Can view, edit and create files") }, + { GS_APP_PERMISSIONS_FLAGS_HOME_READ, N_("Home folder"), N_("Can view files") }, + { GS_APP_PERMISSIONS_FLAGS_FILESYSTEM_FULL, N_("File system"), N_("Can view, edit and create files") }, + { GS_APP_PERMISSIONS_FLAGS_FILESYSTEM_READ, N_("File system"), N_("Can view files") }, + /* The GS_APP_PERMISSIONS_FLAGS_FILESYSTEM_OTHER is used only as a flag, with actual files being part of the read/full lists */ + { GS_APP_PERMISSIONS_FLAGS_DOWNLOADS_FULL, N_("Downloads folder"), N_("Can view, edit and create files") }, + { GS_APP_PERMISSIONS_FLAGS_DOWNLOADS_READ, N_("Downloads folder"), N_("Can view files") }, + { GS_APP_PERMISSIONS_FLAGS_SETTINGS, N_("Settings"), N_("Can view and change any settings") }, + { GS_APP_PERMISSIONS_FLAGS_X11, N_("Legacy display system"), N_("Uses an old, insecure display system") }, + { GS_APP_PERMISSIONS_FLAGS_ESCAPE_SANDBOX, N_("Sandbox escape"), N_("Can escape the sandbox and circumvent any other restrictions") }, +}; + +static void +add_permissions_row (GsAppDetailsPage *page, + const gchar *title, + const gchar *subtitle, + gboolean is_warning_row) +{ + GtkWidget *row, *image; + + row = adw_action_row_new (); + if (is_warning_row) + gtk_style_context_add_class (gtk_widget_get_style_context (row), "permission-row-warning"); + + image = gtk_image_new_from_icon_name ("dialog-warning-symbolic"); + if (!is_warning_row) + gtk_widget_set_opacity (image, 0); + +#if ADW_CHECK_VERSION(1,2,0) + adw_preferences_row_set_use_markup (ADW_PREFERENCES_ROW (row), FALSE); +#endif + adw_action_row_add_prefix (ADW_ACTION_ROW (row), image); + adw_preferences_row_set_title (ADW_PREFERENCES_ROW (row), title); + adw_action_row_set_subtitle (ADW_ACTION_ROW (row), subtitle); + + gtk_list_box_append (GTK_LIST_BOX (page->permissions_section_list), row); +} + +static void +populate_permissions_filesystem (GsAppDetailsPage *page, + const GPtrArray *titles, /* (element-type utf-8) */ + const gchar *subtitle, + gboolean is_warning_row) +{ + if (titles == NULL) + return; + + for (guint i = 0; i < titles->len; i++) { + const gchar *title = g_ptr_array_index (titles, i); + add_permissions_row (page, title, subtitle, is_warning_row); + } +} + +static void +populate_permissions_section (GsAppDetailsPage *page, + GsAppPermissions *permissions) +{ + GsAppPermissionsFlags flags = gs_app_permissions_get_flags (permissions); + + gs_widget_remove_all (page->permissions_section_list, (GsRemoveFunc) gtk_list_box_remove); + + for (gsize i = 0; i < G_N_ELEMENTS (permission_display_data); i++) { + if ((flags & permission_display_data[i].permission) == 0) + continue; + + add_permissions_row (page, + _(permission_display_data[i].title), + _(permission_display_data[i].subtitle), + (permission_display_data[i].permission & ~MEDIUM_PERMISSIONS) != 0); + } + + populate_permissions_filesystem (page, + gs_app_permissions_get_filesystem_read (permissions), + _("Can view files"), + (GS_APP_PERMISSIONS_FLAGS_FILESYSTEM_READ & ~MEDIUM_PERMISSIONS) != 0); + + populate_permissions_filesystem (page, + gs_app_permissions_get_filesystem_full (permissions), + _("Can view, edit and create files"), + (GS_APP_PERMISSIONS_FLAGS_FILESYSTEM_FULL & ~MEDIUM_PERMISSIONS) != 0); +} + +static void +set_updates_description_ui (GsAppDetailsPage *page, GsApp *app) +{ + g_autoptr(GIcon) icon = NULL; + guint icon_size; + const gchar *update_details; + GdkDisplay *display; + g_autoptr (GtkIconPaintable) paintable = NULL; + + /* FIXME support app == NULL */ + + /* set window title */ + adw_window_title_set_title (page->window_title, _("Update Details")); + g_object_notify_by_pspec (G_OBJECT (page), obj_props[PROP_TITLE]); + + /* set update header */ + update_details = gs_app_get_update_details_markup (app); + if (update_details == NULL) { + /* TRANSLATORS: this is where the packager did not write + * a description for the update */ + update_details = _("No update description available."); + } + gtk_label_set_markup (GTK_LABEL (page->label_details), update_details); + adw_status_page_set_title (ADW_STATUS_PAGE (page->status_page), gs_app_get_name (app)); + adw_status_page_set_description (ADW_STATUS_PAGE (page->status_page), gs_app_get_summary (app)); + + /* set the icon; fall back to 64px if 96px isn’t available, which sometimes + * happens at 2× scale factor (hi-DPI) */ + icon_size = 96; + icon = gs_app_get_icon_for_size (app, + icon_size, + gtk_widget_get_scale_factor (GTK_WIDGET (page)), + NULL); + if (icon == NULL) { + icon_size = 64; + icon = gs_app_get_icon_for_size (app, + icon_size, + gtk_widget_get_scale_factor (GTK_WIDGET (page)), + NULL); + } + if (icon == NULL) { + icon_size = 96; + icon = gs_app_get_icon_for_size (app, + icon_size, + gtk_widget_get_scale_factor (GTK_WIDGET (page)), + "system-component-application"); + } + + display = gdk_display_get_default (); + paintable = gtk_icon_theme_lookup_by_gicon (gtk_icon_theme_get_for_display (display), + icon, + icon_size, + gtk_widget_get_scale_factor (GTK_WIDGET (page)), + gtk_widget_get_direction (GTK_WIDGET (page)), + GTK_ICON_LOOKUP_FORCE_REGULAR); + adw_status_page_set_paintable (ADW_STATUS_PAGE (page->status_page), GDK_PAINTABLE (paintable)); + + if (gs_app_has_quirk (app, GS_APP_QUIRK_NEW_PERMISSIONS)) { + g_autoptr(GsAppPermissions) permissions = gs_app_dup_update_permissions (app); + gtk_widget_show (page->permissions_section); + populate_permissions_section (page, permissions); + } else { + gtk_widget_hide (page->permissions_section); + } +} + +/** + * gs_app_details_page_get_app: + * @page: a #GsAppDetailsPage + * + * Get the value of #GsAppDetailsPage:app. + * + * Returns: (nullable) (transfer none): the app + * + * Since: 41 + */ +GsApp * +gs_app_details_page_get_app (GsAppDetailsPage *page) +{ + g_return_val_if_fail (GS_IS_APP_DETAILS_PAGE (page), NULL); + return page->app; +} + +/** + * gs_app_details_page_set_app: + * @page: a #GsAppDetailsPage + * @app: (transfer none) (nullable): new app + * + * Set the value of #GsAppDetailsPage:app. + * + * Since: 41 + */ +void +gs_app_details_page_set_app (GsAppDetailsPage *page, GsApp *app) +{ + g_return_if_fail (GS_IS_APP_DETAILS_PAGE (page)); + g_return_if_fail (!app || GS_IS_APP (app)); + + if (page->app == app) + return; + + g_set_object (&page->app, app); + + set_updates_description_ui (page, app); + + g_object_notify_by_pspec (G_OBJECT (page), obj_props[PROP_APP]); +} + +/** + * gs_app_details_page_get_show_back_button: + * @page: a #GsAppDetailsPage + * + * Get the value of #GsAppDetailsPage:show-back-button. + * + * Returns: whether to show the back button + * + * Since: 41 + */ +gboolean +gs_app_details_page_get_show_back_button (GsAppDetailsPage *page) +{ + g_return_val_if_fail (GS_IS_APP_DETAILS_PAGE (page), FALSE); + return gtk_widget_get_visible (page->back_button); +} + +/** + * gs_app_details_page_set_show_back_button: + * @page: a #GsAppDetailsPage + * @show_back_button: whether to show the back button + * + * Set the value of #GsAppDetailsPage:show-back-button. + * + * Since: 41 + */ +void +gs_app_details_page_set_show_back_button (GsAppDetailsPage *page, gboolean show_back_button) +{ + g_return_if_fail (GS_IS_APP_DETAILS_PAGE (page)); + + show_back_button = !!show_back_button; + + if (gtk_widget_get_visible (page->back_button) == show_back_button) + return; + + gtk_widget_set_visible (page->back_button, show_back_button); + + g_object_notify_by_pspec (G_OBJECT (page), obj_props[PROP_SHOW_BACK_BUTTON]); +} + +static void +back_clicked_cb (GtkWidget *widget, GsAppDetailsPage *page) +{ + g_signal_emit (page, signals[SIGNAL_BACK_CLICKED], 0); +} + +static void +gs_app_details_page_dispose (GObject *object) +{ + GsAppDetailsPage *page = GS_APP_DETAILS_PAGE (object); + + g_clear_object (&page->app); + + G_OBJECT_CLASS (gs_app_details_page_parent_class)->dispose (object); +} + +static void +gs_app_details_page_get_property (GObject *object, guint prop_id, GValue *value, GParamSpec *pspec) +{ + GsAppDetailsPage *page = GS_APP_DETAILS_PAGE (object); + + switch ((GsAppDetailsPageProperty) prop_id) { + case PROP_APP: + g_value_set_object (value, gs_app_details_page_get_app (page)); + break; + case PROP_SHOW_BACK_BUTTON: + g_value_set_boolean (value, gs_app_details_page_get_show_back_button (page)); + break; + case PROP_TITLE: + g_value_set_string (value, adw_window_title_get_title (page->window_title)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_app_details_page_set_property (GObject *object, guint prop_id, const GValue *value, GParamSpec *pspec) +{ + GsAppDetailsPage *page = GS_APP_DETAILS_PAGE (object); + + switch ((GsAppDetailsPageProperty) prop_id) { + case PROP_APP: + gs_app_details_page_set_app (page, g_value_get_object (value)); + break; + case PROP_SHOW_BACK_BUTTON: + gs_app_details_page_set_show_back_button (page, g_value_get_boolean (value)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_app_details_page_init (GsAppDetailsPage *page) +{ + gtk_widget_init_template (GTK_WIDGET (page)); +} + +static void +gs_app_details_page_class_init (GsAppDetailsPageClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + + object_class->dispose = gs_app_details_page_dispose; + object_class->get_property = gs_app_details_page_get_property; + object_class->set_property = gs_app_details_page_set_property; + + /** + * GsAppDetailsPage:app: (nullable) + * + * The app to present. + * + * Since: 41 + */ + obj_props[PROP_APP] = + g_param_spec_object ("app", NULL, NULL, + GS_TYPE_APP, + G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY); + + /** + * GsAppDetailsPage:show-back-button + * + * Whether to show the back button. + * + * Since: 41 + */ + obj_props[PROP_SHOW_BACK_BUTTON] = + g_param_spec_boolean ("show-back-button", NULL, NULL, + TRUE, + G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY); + + /** + * GsAppDetailsPage:title + * + * Read-only window title. + * + * Since: 42 + */ + obj_props[PROP_TITLE] = + g_param_spec_string ("title", NULL, NULL, + NULL, + G_PARAM_READABLE | G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY); + + g_object_class_install_properties (object_class, G_N_ELEMENTS (obj_props), obj_props); + + /** + * GsAppDetailsPage:back-clicked: + * @app: a #GsApp + * + * Emitted when the back button got activated and the #GsUpdateDialog + * containing this page is expected to go back. + * + * Since: 41 + */ + signals[SIGNAL_BACK_CLICKED] = + g_signal_new ("back-clicked", + G_TYPE_FROM_CLASS (object_class), G_SIGNAL_RUN_LAST, + 0, NULL, NULL, g_cclosure_marshal_generic, + G_TYPE_NONE, 0); + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-app-details-page.ui"); + + gtk_widget_class_bind_template_child (widget_class, GsAppDetailsPage, back_button); + gtk_widget_class_bind_template_child (widget_class, GsAppDetailsPage, header_bar); + gtk_widget_class_bind_template_child (widget_class, GsAppDetailsPage, label_details); + gtk_widget_class_bind_template_child (widget_class, GsAppDetailsPage, permissions_section); + gtk_widget_class_bind_template_child (widget_class, GsAppDetailsPage, permissions_section_list); + gtk_widget_class_bind_template_child (widget_class, GsAppDetailsPage, status_page); + gtk_widget_class_bind_template_child (widget_class, GsAppDetailsPage, window_title); + gtk_widget_class_bind_template_callback (widget_class, back_clicked_cb); +} + +/** + * gs_app_details_page_new: + * + * Create a new #GsAppDetailsPage. + * + * Returns: (transfer full): a new #GsAppDetailsPage + * Since: 41 + */ +GtkWidget * +gs_app_details_page_new (void) +{ + return GTK_WIDGET (g_object_new (GS_TYPE_APP_DETAILS_PAGE, NULL)); +} diff --git a/src/gs-app-details-page.h b/src/gs-app-details-page.h new file mode 100644 index 0000000..60b823a --- /dev/null +++ b/src/gs-app-details-page.h @@ -0,0 +1,29 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2021 Purism SPC + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <gtk/gtk.h> + +#include "gnome-software-private.h" + +G_BEGIN_DECLS + +#define GS_TYPE_APP_DETAILS_PAGE (gs_app_details_page_get_type ()) + +G_DECLARE_FINAL_TYPE (GsAppDetailsPage, gs_app_details_page, GS, APP_DETAILS_PAGE, GtkBox) + +GtkWidget *gs_app_details_page_new (void); +GsApp *gs_app_details_page_get_app (GsAppDetailsPage *page); +void gs_app_details_page_set_app (GsAppDetailsPage *page, + GsApp *app); +gboolean gs_app_details_page_get_show_back_button (GsAppDetailsPage *page); +void gs_app_details_page_set_show_back_button (GsAppDetailsPage *page, + gboolean show_back_button); + +G_END_DECLS diff --git a/src/gs-app-details-page.ui b/src/gs-app-details-page.ui new file mode 100644 index 0000000..2cf1ad1 --- /dev/null +++ b/src/gs-app-details-page.ui @@ -0,0 +1,97 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <requires lib="gtk+" version="3.10"/> + <template class="GsAppDetailsPage" parent="GtkBox"> + <property name="orientation">vertical</property> + + <child> + <object class="AdwHeaderBar" id="header_bar"> + <property name="valign">start</property> + <property name="show_start_title_buttons">True</property> + <property name="show_end_title_buttons">True</property> + <property name="title-widget"> + <object class="AdwWindowTitle" id="window_title" /> + </property> + <child type="start"> + <object class="GtkButton" id="back_button"> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="icon_name">go-previous-symbolic</property> + <signal name="clicked" handler="back_clicked_cb"/> + <style> + <class name="image-button"/> + </style> + <accessibility> + <property name="label" translatable="yes">Go back</property> + </accessibility> + </object> + </child> + </object> + </child> + <child> + <object class="AdwStatusPage" id="status_page"> + <property name="icon_name">system-component-application</property> + <property name="title">Inkscape</property> + <property name="description">Vector based drawing program</property> + <property name="vexpand">True</property> + <style> + <class name="compact"/> + <class name="icon-dropshadow"/> + </style> + <child> + <object class="AdwClamp"> + <child> + <object class="GtkBox"> + <property name="orientation">vertical</property> + <child> + <object class="AdwPreferencesGroup" id="permissions_section"> + <property name="title" translatable="yes">Requires additional permissions</property> + <!-- We can't remove children from a AdwPreferencesGroup + without knowing them beforehand, so let's simply + include a GtkListBox and remove its children. --> + <child> + <object class="GtkListBox" id="permissions_section_list"> + <property name="selection-mode">none</property> + <style> + <class name="boxed-list"/> + </style> + </object> + </child> + </object> + </child> + <child> + <object class="GtkListBox"> + <property name="selection-mode">none</property> + <style> + <class name="boxed-list"/> + </style> + <child> + <object class="GtkListBoxRow"> + <property name="activatable">False</property> + <child> + <object class="GtkLabel" id="label_details"> + <property name="xalign">0</property> + <property name="yalign">0</property> + <property name="margin-top">18</property> + <property name="margin-bottom">18</property> + <property name="margin-start">18</property> + <property name="margin-end">18</property> + <property name="label">New in kmod 14-1 +* Moo +* bar</property> + <property name="wrap">True</property> + <property name="selectable">True</property> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + </template> +</interface> diff --git a/src/gs-app-reviews-dialog.c b/src/gs-app-reviews-dialog.c new file mode 100644 index 0000000..a00bef0 --- /dev/null +++ b/src/gs-app-reviews-dialog.c @@ -0,0 +1,559 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) Adrien Plazas <adrien.plazas@puri.sm> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include "gs-app-reviews-dialog.h" + +#include "gnome-software-private.h" +#include "gs-common.h" +#include "gs-review-row.h" +#include <glib/gi18n.h> + +struct _GsAppReviewsDialog +{ + GtkDialog parent_instance; + GtkWidget *listbox; + GtkWidget *stack; + + GsPluginLoader *plugin_loader; /* (owned) (nullable) */ + GsApp *app; /* (owned) (nullable) */ + GCancellable *cancellable; /* (owned) */ + GCancellable *refine_cancellable; /* (owned) (nullable) */ + GsOdrsProvider *odrs_provider; /* (nullable) (owned), NULL if reviews are disabled */ +}; + +G_DEFINE_TYPE (GsAppReviewsDialog, gs_app_reviews_dialog, GTK_TYPE_DIALOG) + +typedef enum { + PROP_APP = 1, + PROP_ODRS_PROVIDER, + PROP_PLUGIN_LOADER, +} GsAppReviewsDialogProperty; + +static GParamSpec *obj_props[PROP_PLUGIN_LOADER + 1] = { NULL, }; + +enum { + SIGNAL_REVIEWS_UPDATED, + SIGNAL_LAST +}; + +static guint signals[SIGNAL_LAST] = { 0 }; + +static void refresh_reviews (GsAppReviewsDialog *self); + +static gint +sort_reviews (AsReview **a, AsReview **b) +{ + return -g_date_time_compare (as_review_get_date (*a), as_review_get_date (*b)); +} + +static void +review_button_clicked_cb (GsReviewRow *row, + GsReviewAction action, + GsAppReviewsDialog *self) +{ + AsReview *review = gs_review_row_get_review (row); + g_autoptr(GError) local_error = NULL; + + g_assert (self->odrs_provider != NULL); + + /* FIXME: Make this async */ + switch (action) { + case GS_REVIEW_ACTION_UPVOTE: + gs_odrs_provider_upvote_review (self->odrs_provider, self->app, + review, self->cancellable, + &local_error); + break; + case GS_REVIEW_ACTION_DOWNVOTE: + gs_odrs_provider_downvote_review (self->odrs_provider, self->app, + review, self->cancellable, + &local_error); + break; + case GS_REVIEW_ACTION_REPORT: + gs_odrs_provider_report_review (self->odrs_provider, self->app, + review, self->cancellable, + &local_error); + break; + case GS_REVIEW_ACTION_REMOVE: + gs_odrs_provider_remove_review (self->odrs_provider, self->app, + review, self->cancellable, + &local_error); + break; + case GS_REVIEW_ACTION_DISMISS: + /* The dismiss action is only used from the moderate page. */ + default: + g_assert_not_reached (); + } + + if (local_error != NULL) { + g_warning ("failed to set review on %s: %s", + gs_app_get_id (self->app), local_error->message); + return; + } + + refresh_reviews (self); +} + +static void +populate_reviews (GsAppReviewsDialog *self) +{ + GPtrArray *reviews; + gboolean show_reviews = FALSE; + guint64 possible_actions = 0; + guint i; + GsReviewAction all_actions[] = { + GS_REVIEW_ACTION_UPVOTE, + GS_REVIEW_ACTION_DOWNVOTE, + GS_REVIEW_ACTION_REPORT, + GS_REVIEW_ACTION_REMOVE, + }; + + /* nothing to show */ + if (self->app == NULL) { + gtk_stack_set_visible_child_name (GTK_STACK (self->stack), "empty"); + + 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; + + /* check that reviews are available */ + reviews = gs_app_get_reviews (self->app); + if (reviews->len == 0) + show_reviews = FALSE; + + if (!show_reviews) { + gtk_stack_set_visible_child_name (GTK_STACK (self->stack), "empty"); + + return; + } + + /* find what the plugins support */ + for (i = 0; i < G_N_ELEMENTS (all_actions); i++) { + if (self->odrs_provider != NULL) + possible_actions |= (1u << all_actions[i]); + } + + /* add all the reviews */ + gs_widget_remove_all (self->listbox, (GsRemoveFunc) gtk_list_box_remove); + g_ptr_array_sort (reviews, (GCompareFunc) sort_reviews); + for (i = 0; i < reviews->len; i++) { + AsReview *review = g_ptr_array_index (reviews, i); + GtkWidget *row = gs_review_row_new (review); + guint64 actions; + + gtk_list_box_row_set_activatable (GTK_LIST_BOX_ROW (row), FALSE); + gtk_list_box_append (GTK_LIST_BOX (self->listbox), row); + + g_signal_connect (row, "button-clicked", + G_CALLBACK (review_button_clicked_cb), self); + if (as_review_get_flags (review) & AS_REVIEW_FLAG_SELF) + actions = possible_actions & (1 << GS_REVIEW_ACTION_REMOVE); + else + actions = possible_actions & ~(1u << GS_REVIEW_ACTION_REMOVE); + gs_review_row_set_actions (GS_REVIEW_ROW (row), actions); + gs_review_row_set_network_available (GS_REVIEW_ROW (row), + GS_IS_PLUGIN_LOADER (self->plugin_loader) && gs_plugin_loader_get_network_available (self->plugin_loader)); + } + + gtk_stack_set_visible_child_name (GTK_STACK (self->stack), "reviews"); +} + +static void +refresh_reviews (GsAppReviewsDialog *self) +{ + if (!gtk_widget_get_realized (GTK_WIDGET (self))) + return; + + populate_reviews (self); + + g_signal_emit (self, signals[SIGNAL_REVIEWS_UPDATED], 0); +} + +static void +gs_app_reviews_dialog_app_refine_cb (GObject *source, + GAsyncResult *res, + gpointer user_data) +{ + GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (source); + GsAppReviewsDialog *self = GS_APP_REVIEWS_DIALOG (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; + } + + refresh_reviews (self); +} + +static void +gs_app_reviews_dialog_app_refine (GsAppReviewsDialog *self) +{ + g_autoptr(GsPluginJob) plugin_job = NULL; + + if (self->refine_cancellable != NULL) { + g_cancellable_cancel (self->refine_cancellable); + g_clear_object (&self->refine_cancellable); + } + + if (self->plugin_loader == NULL || self->app == NULL) + return; + + self->refine_cancellable = g_cancellable_new (); + + /* If this task fails (e.g. because we have no networking) then + * it's of no huge importance if we don't get the required data. + */ + 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->refine_cancellable, + gs_app_reviews_dialog_app_refine_cb, + self); +} + +static void +gs_app_reviews_dialog_get_property (GObject *object, + guint prop_id, + GValue *value, + GParamSpec *pspec) +{ + GsAppReviewsDialog *self = GS_APP_REVIEWS_DIALOG (object); + + switch ((GsAppReviewsDialogProperty) prop_id) { + case PROP_APP: + g_value_set_object (value, gs_app_reviews_dialog_get_app (self)); + break; + case PROP_ODRS_PROVIDER: + g_value_set_object (value, gs_app_reviews_dialog_get_odrs_provider (self)); + break; + case PROP_PLUGIN_LOADER: + g_value_set_object (value, gs_app_reviews_dialog_get_plugin_loader (self)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_app_reviews_dialog_set_property (GObject *object, + guint prop_id, + const GValue *value, + GParamSpec *pspec) +{ + GsAppReviewsDialog *self = GS_APP_REVIEWS_DIALOG (object); + + switch ((GsAppReviewsDialogProperty) prop_id) { + case PROP_APP: + gs_app_reviews_dialog_set_app (self, g_value_get_object (value)); + break; + case PROP_ODRS_PROVIDER: + gs_app_reviews_dialog_set_odrs_provider (self, g_value_get_object (value)); + break; + case PROP_PLUGIN_LOADER: + gs_app_reviews_dialog_set_plugin_loader (self, g_value_get_object (value)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_app_reviews_dialog_dispose (GObject *object) +{ + GsAppReviewsDialog *self = GS_APP_REVIEWS_DIALOG (object); + + g_cancellable_cancel (self->cancellable); + g_clear_object (&self->cancellable); + + if (self->refine_cancellable != NULL) { + g_cancellable_cancel (self->refine_cancellable); + g_clear_object (&self->refine_cancellable); + } + + if (self->plugin_loader) + g_signal_handlers_disconnect_by_func (self->plugin_loader, + refresh_reviews, self); + g_clear_object (&self->plugin_loader); + + g_clear_object (&self->app); + g_clear_object (&self->odrs_provider); + + G_OBJECT_CLASS (gs_app_reviews_dialog_parent_class)->dispose (object); +} + +static void +gs_app_reviews_dialog_class_init (GsAppReviewsDialogClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + + object_class->get_property = gs_app_reviews_dialog_get_property; + object_class->set_property = gs_app_reviews_dialog_set_property; + object_class->dispose = gs_app_reviews_dialog_dispose; + + /** + * GsAppReviewsDialog:app: (nullable) + * + * An app whose reviews should be displayed. + * + * If this is %NULL, ratings and reviews will be disabled. + * + * Since: 42 + */ + obj_props[PROP_APP] = + g_param_spec_object ("app", NULL, NULL, + GS_TYPE_APP, + G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS); + + /** + * GsAppReviewsDialog: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: 42 + */ + 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); + + /** + * GsAppReviewsDialog:plugin-loader: (nullable) + * + * A plugin loader to provide network availability. + * + * If this is %NULL, ratings and reviews will be disabled. + * + * Since: 42 + */ + obj_props[PROP_PLUGIN_LOADER] = + g_param_spec_object ("plugin-loader", NULL, NULL, + GS_TYPE_PLUGIN_LOADER, + G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS); + + g_object_class_install_properties (object_class, G_N_ELEMENTS (obj_props), obj_props); + + /** + * GsAppReviewsDialog::reviews-updated: + * + * Emitted when reviews are updated. + * + * Since: 42 + */ + signals[SIGNAL_REVIEWS_UPDATED] = + g_signal_new ("reviews-updated", + G_TYPE_FROM_CLASS (object_class), G_SIGNAL_RUN_LAST, + 0, NULL, NULL, g_cclosure_marshal_generic, + G_TYPE_NONE, 0); + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-app-reviews-dialog.ui"); + + gtk_widget_class_bind_template_child (widget_class, GsAppReviewsDialog, listbox); + gtk_widget_class_bind_template_child (widget_class, GsAppReviewsDialog, stack); +} + +static void +gs_app_reviews_dialog_init (GsAppReviewsDialog *self) +{ + self->cancellable = g_cancellable_new (); + + gtk_widget_init_template (GTK_WIDGET (self)); + + g_signal_connect_swapped (self, "realize", + G_CALLBACK (refresh_reviews), self); +} + +/** + * gs_app_reviews_dialog_new: + * @parent: (nullable): a #GtkWindow, or %NULL + * @app: (nullable): a #GsApp, or %NULL + * @odrs_provider: (nullable): a #GsOdrsProvider, or %NULL + * @plugin_loader: (nullable): a #GsPluginLoader, or %NULL + * + * Create a new #GsAppReviewsDialog transient for @parent, and set its initial + * app, ODRS provider and plugin loader to @app, @odrs_provider and + * @plugin_loader respectively. + * + * Returns: (transfer full): a new #GsAppReviewsDialog + * Since: 42 + */ +GtkWidget * +gs_app_reviews_dialog_new (GtkWindow *parent, GsApp *app, GsOdrsProvider *odrs_provider, GsPluginLoader *plugin_loader) +{ + GsAppReviewsDialog *self; + + g_return_val_if_fail (parent == NULL || GTK_IS_WINDOW (parent), NULL); + g_return_val_if_fail (app == NULL || GS_IS_APP (app), NULL); + g_return_val_if_fail (odrs_provider == NULL || GS_IS_ODRS_PROVIDER (odrs_provider), NULL); + g_return_val_if_fail (plugin_loader == NULL ||GS_IS_PLUGIN_LOADER (plugin_loader), NULL); + + self = g_object_new (GS_TYPE_APP_REVIEWS_DIALOG, + "app", app, + "modal", TRUE, + "odrs-provider", odrs_provider, + "plugin-loader", plugin_loader, + "transient-for", parent, + "use-header-bar", TRUE, + NULL); + + return GTK_WIDGET (self); +} + +/** + * gs_app_reviews_dialog_get_app: + * @self: a #GsAppReviewsDialog + * + * Get the value of #GsAppReviewsDialog:app. + * + * Returns: (nullable) (transfer none): a #GsApp, or %NULL if unset + * Since: 42 + */ +GsApp * +gs_app_reviews_dialog_get_app (GsAppReviewsDialog *self) +{ + g_return_val_if_fail (GS_IS_APP_REVIEWS_DIALOG (self), NULL); + + return self->app; +} + +/** + * gs_app_reviews_dialog_set_app: + * @self: a #GsAppReviewsDialog + * @app: (nullable) (transfer none): new #GsApp or %NULL + * + * Set the value of #GsAppReviewsDialog:app. + * + * Since: 42 + */ +void +gs_app_reviews_dialog_set_app (GsAppReviewsDialog *self, + GsApp *app) +{ + g_return_if_fail (GS_IS_APP_REVIEWS_DIALOG (self)); + g_return_if_fail (app == NULL || GS_IS_APP (app)); + + if (g_set_object (&self->app, app)) { + gs_app_reviews_dialog_app_refine (self); + refresh_reviews (self); + g_object_notify_by_pspec (G_OBJECT (self), obj_props[PROP_APP]); + } +} + +/** + * gs_app_reviews_dialog_get_odrs_provider: + * @self: a #GsAppReviewsDialog + * + * Get the value of #GsAppReviewsDialog:odrs-provider. + * + * Returns: (nullable) (transfer none): a #GsOdrsProvider, or %NULL if unset + * Since: 42 + */ +GsOdrsProvider * +gs_app_reviews_dialog_get_odrs_provider (GsAppReviewsDialog *self) +{ + g_return_val_if_fail (GS_IS_APP_REVIEWS_DIALOG (self), NULL); + + return self->odrs_provider; +} + +/** + * gs_app_reviews_dialog_set_odrs_provider: + * @self: a #GsAppReviewsDialog + * @odrs_provider: (nullable) (transfer none): new #GsOdrsProvider or %NULL + * + * Set the value of #GsAppReviewsDialog:odrs-provider. + * + * Since: 42 + */ +void +gs_app_reviews_dialog_set_odrs_provider (GsAppReviewsDialog *self, + GsOdrsProvider *odrs_provider) +{ + g_return_if_fail (GS_IS_APP_REVIEWS_DIALOG (self)); + g_return_if_fail (odrs_provider == NULL || GS_IS_ODRS_PROVIDER (odrs_provider)); + + if (g_set_object (&self->odrs_provider, odrs_provider)) { + refresh_reviews (self); + g_object_notify_by_pspec (G_OBJECT (self), obj_props[PROP_ODRS_PROVIDER]); + } +} + +/** + * gs_app_reviews_dialog_get_plugin_loader: + * @self: a #GsAppReviewsDialog + * + * Get the value of #GsAppReviewsDialog:plugin-loader. + * + * Returns: (nullable) (transfer none): a #GsPluginLoader, or %NULL if unset + * Since: 42 + */ +GsPluginLoader * +gs_app_reviews_dialog_get_plugin_loader (GsAppReviewsDialog *self) +{ + g_return_val_if_fail (GS_IS_APP_REVIEWS_DIALOG (self), NULL); + + return self->plugin_loader; +} + +/** + * gs_app_reviews_dialog_set_plugin_loader: + * @self: a #GsAppReviewsDialog + * @plugin_loader: (nullable) (transfer none): new #GsPluginLoader or %NULL + * + * Set the value of #GsAppReviewsDialog:plugin-loader. + * + * Since: 42 + */ +void +gs_app_reviews_dialog_set_plugin_loader (GsAppReviewsDialog *self, + GsPluginLoader *plugin_loader) +{ + g_return_if_fail (GS_IS_APP_REVIEWS_DIALOG (self)); + g_return_if_fail (plugin_loader == NULL || GS_IS_PLUGIN_LOADER (plugin_loader)); + + if (self->plugin_loader) + g_signal_handlers_disconnect_by_func (self->plugin_loader, + refresh_reviews, self); + + if (g_set_object (&self->plugin_loader, plugin_loader)) { + gs_app_reviews_dialog_app_refine (self); + g_signal_connect_swapped (self->plugin_loader, "notify::network-available", + G_CALLBACK (refresh_reviews), self); + g_object_notify_by_pspec (G_OBJECT (self), obj_props[PROP_PLUGIN_LOADER]); + } +} diff --git a/src/gs-app-reviews-dialog.h b/src/gs-app-reviews-dialog.h new file mode 100644 index 0000000..76dd5dc --- /dev/null +++ b/src/gs-app-reviews-dialog.h @@ -0,0 +1,38 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2021 Adrien Plazas <adrien.plazas@puri.sm> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <gtk/gtk.h> + +#include "gnome-software-private.h" + +G_BEGIN_DECLS + +#define GS_TYPE_APP_REVIEWS_DIALOG (gs_app_reviews_dialog_get_type ()) + +G_DECLARE_FINAL_TYPE (GsAppReviewsDialog, gs_app_reviews_dialog, GS, APP_REVIEWS_DIALOG, GtkDialog) + +GtkWidget *gs_app_reviews_dialog_new (GtkWindow *parent, + GsApp *app, + GsOdrsProvider *odrs_provider, + GsPluginLoader *plugin_loader); + +GsApp *gs_app_reviews_dialog_get_app (GsAppReviewsDialog *self); +void gs_app_reviews_dialog_set_app (GsAppReviewsDialog *self, + GsApp *app); + +GsOdrsProvider *gs_app_reviews_dialog_get_odrs_provider (GsAppReviewsDialog *self); +void gs_app_reviews_dialog_set_odrs_provider (GsAppReviewsDialog *self, + GsOdrsProvider *odrs_provider); + +GsPluginLoader *gs_app_reviews_dialog_get_plugin_loader (GsAppReviewsDialog *self); +void gs_app_reviews_dialog_set_plugin_loader (GsAppReviewsDialog *self, + GsPluginLoader *plugin_loader); + +G_END_DECLS diff --git a/src/gs-app-reviews-dialog.ui b/src/gs-app-reviews-dialog.ui new file mode 100644 index 0000000..2ceee89 --- /dev/null +++ b/src/gs-app-reviews-dialog.ui @@ -0,0 +1,65 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <requires lib="gtk+" version="3.10"/> + <requires lib="handy" version="1.0"/> + <template class="GsAppReviewsDialog" parent="GtkDialog"> + <property name="title" translatable="yes">Reviews</property> + <property name="default_width">550</property> + <property name="default_height">600</property> + <property name="width_request">360</property> + <property name="height_request">400</property> + <property name="use_header_bar">1</property> + <child internal-child="headerbar"> + <object class="AdwHeaderBar"/> + </child> + <child internal-child="content_area"> + <object class="GtkBox"> + <property name="orientation">vertical</property> + <child> + <object class="GtkStack" id="stack"> + <child> + <object class="GtkStackPage"> + <property name="name">empty</property> + <property name="child"> + <object class="AdwStatusPage"> + <property name="description" translatable="yes">No reviews were found for this application.</property> + <property name="icon-name">review-symbolic</property> + <property name="title" translatable="yes">No Reviews</property> + </object> + </property> + </object> + </child> + <child> + <object class="GtkStackPage"> + <property name="name">reviews</property> + <property name="child"> + <object class="GtkScrolledWindow"> + <child> + <object class="AdwClamp"> + <property name="vexpand">True</property> + <property name="hexpand">False</property> + <property name="margin-start">12</property> + <property name="margin-end">12</property> + <property name="margin-bottom">18</property> + <property name="margin-top">18</property> + <child> + <object class="GtkListBox" id="listbox"> + <property name="selection-mode">none</property> + <property name="valign">start</property> + <style> + <class name="boxed-list"/> + </style> + </object> + </child> + </object> + </child> + </object> + </property> + </object> + </child> + </object> + </child> + </object> + </child> + </template> +</interface> diff --git a/src/gs-app-row.c b/src/gs-app-row.c new file mode 100644 index 0000000..0cc9895 --- /dev/null +++ b/src/gs-app-row.c @@ -0,0 +1,1240 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2012-2016 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2013 Matthias Clasen <mclasen@redhat.com> + * Copyright (C) 2014-2018 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include <glib/gi18n.h> + +#include "gs-app-row.h" +#include "gs-star-widget.h" +#include "gs-progress-button.h" +#include "gs-common.h" + +typedef struct +{ + GsApp *app; + GtkWidget *image; + GtkWidget *name_box; + GtkWidget *name_label; + GtkWidget *version_box; + GtkWidget *version_current_label; + GtkWidget *version_arrow_label; + GtkWidget *version_update_label; + GtkWidget *system_updates_label; /* Only for "System Updates" app */ + GtkWidget *star; + GtkWidget *description_label; + GtkWidget *button_box; + GtkWidget *button_revealer; + GtkWidget *button; + GtkWidget *spinner; + GtkWidget *label; + GtkWidget *box_tag; + GtkWidget *label_warning; + GtkWidget *label_origin; + GtkWidget *label_installed; + GtkWidget *label_app_size; + gboolean colorful; + gboolean show_buttons; + gboolean show_rating; + gboolean show_description; + gboolean show_source; + gboolean show_update; + gboolean show_installed_size; + gboolean show_installed; + guint pending_refresh_id; + guint unreveal_in_idle_id; + gboolean is_narrow; +} GsAppRowPrivate; + +G_DEFINE_TYPE_WITH_PRIVATE (GsAppRow, gs_app_row, GTK_TYPE_LIST_BOX_ROW) + +enum { + SIGNAL_BUTTON_CLICKED, + SIGNAL_UNREVEALED, + SIGNAL_LAST +}; + +static guint signals [SIGNAL_LAST] = { 0 }; + +typedef enum { + PROP_APP = 1, + PROP_COLORFUL, + PROP_SHOW_DESCRIPTION, + PROP_SHOW_SOURCE, + PROP_SHOW_BUTTONS, + PROP_SHOW_RATING, + PROP_SHOW_UPDATE, + PROP_SHOW_INSTALLED_SIZE, + PROP_SHOW_INSTALLED, + PROP_IS_NARROW, +} GsAppRowProperty; + +static GParamSpec *obj_props[PROP_IS_NARROW + 1] = { NULL, }; + +/* + * gs_app_row_get_description: + * + * Return value: PangoMarkup or text + */ +static GString * +gs_app_row_get_description (GsAppRow *app_row, + gboolean *out_is_markup) +{ + GsAppRowPrivate *priv = gs_app_row_get_instance_private (app_row); + const gchar *tmp = NULL; + + *out_is_markup = FALSE; + + /* convert the markdown update description into PangoMarkup */ + if (priv->show_update) { + tmp = gs_app_get_update_details_markup (priv->app); + if (tmp != NULL && tmp[0] != '\0') { + *out_is_markup = TRUE; + return g_string_new (tmp); + } + } + + /* if missing summary is set, return it without escaping in order to + * correctly show hyperlinks */ + if (gs_app_get_state (priv->app) == GS_APP_STATE_UNAVAILABLE) { + tmp = gs_app_get_summary_missing (priv->app); + if (tmp != NULL && tmp[0] != '\0') + return g_string_new (tmp); + } + + /* try all these things in order */ + if (tmp == NULL || (tmp != NULL && tmp[0] == '\0')) + tmp = gs_app_get_summary (priv->app); + if (tmp == NULL || (tmp != NULL && tmp[0] == '\0')) + tmp = gs_app_get_description (priv->app); + if (tmp == NULL || (tmp != NULL && tmp[0] == '\0')) + tmp = gs_app_get_name (priv->app); + if (tmp == NULL) + return NULL; + return g_string_new (tmp); +} + +static void +gs_app_row_update_button_reveal (GsAppRow *app_row) +{ + GsAppRowPrivate *priv = gs_app_row_get_instance_private (app_row); + gboolean sensitive = gtk_widget_get_sensitive (priv->button); + + gtk_widget_set_visible (priv->button_revealer, sensitive || !priv->is_narrow); +} + +static void +gs_app_row_refresh_button (GsAppRow *app_row, gboolean missing_search_result) +{ + GsAppRowPrivate *priv = gs_app_row_get_instance_private (app_row); + GtkStyleContext *context; + + /* disabled */ + if (!priv->show_buttons) { + gs_app_row_update_button_reveal (app_row); + gtk_widget_set_visible (priv->button, FALSE); + return; + } + + /* label */ + switch (gs_app_get_state (priv->app)) { + case GS_APP_STATE_UNAVAILABLE: + gtk_widget_set_visible (priv->button, TRUE); + if (missing_search_result) { + /* TRANSLATORS: this is a button next to the search results that + * allows the application to be easily installed */ + gs_progress_button_set_label (GS_PROGRESS_BUTTON (priv->button), _("Visit Website")); + gs_progress_button_set_icon_name (GS_PROGRESS_BUTTON (priv->button), NULL); + } else { + /* TRANSLATORS: this is a button next to the search results that + * allows the application to be easily installed. + * The ellipsis indicates that further steps are required */ + gs_progress_button_set_label (GS_PROGRESS_BUTTON (priv->button), _("Install…")); + gs_progress_button_set_icon_name (GS_PROGRESS_BUTTON (priv->button), NULL); + } + break; + case GS_APP_STATE_QUEUED_FOR_INSTALL: + gtk_widget_set_visible (priv->button, TRUE); + /* TRANSLATORS: this is a button next to the search results that + * allows to cancel a queued install of the application */ + gs_progress_button_set_label (GS_PROGRESS_BUTTON (priv->button), _("Cancel")); + gs_progress_button_set_icon_name (GS_PROGRESS_BUTTON (priv->button), "edit-delete-symbolic"); + break; + case GS_APP_STATE_AVAILABLE: + case GS_APP_STATE_AVAILABLE_LOCAL: + gtk_widget_set_visible (priv->button, TRUE); + /* TRANSLATORS: this is a button next to the search results that + * allows the application to be easily installed */ + gs_progress_button_set_label (GS_PROGRESS_BUTTON (priv->button), _("Install")); + gs_progress_button_set_icon_name (GS_PROGRESS_BUTTON (priv->button), "list-add-symbolic"); + break; + case GS_APP_STATE_UPDATABLE_LIVE: + gtk_widget_set_visible (priv->button, TRUE); + if (priv->show_update) { + /* TRANSLATORS: this is a button in the updates panel + * that allows the app to be easily updated live */ + gs_progress_button_set_label (GS_PROGRESS_BUTTON (priv->button), _("Update")); + gs_progress_button_set_icon_name (GS_PROGRESS_BUTTON (priv->button), "software-update-available-symbolic"); + } else { + /* TRANSLATORS: this is a button next to the search results that + * allows the application to be easily removed */ + gs_progress_button_set_label (GS_PROGRESS_BUTTON (priv->button), _("Uninstall")); + gs_progress_button_set_icon_name (GS_PROGRESS_BUTTON (priv->button), "app-remove-symbolic"); + } + break; + case GS_APP_STATE_UPDATABLE: + case GS_APP_STATE_INSTALLED: + if (!gs_app_has_quirk (priv->app, GS_APP_QUIRK_COMPULSORY)) + gtk_widget_set_visible (priv->button, TRUE); + /* TRANSLATORS: this is a button next to the search results that + * allows the application to be easily removed */ + gs_progress_button_set_label (GS_PROGRESS_BUTTON (priv->button), _("Uninstall")); + gs_progress_button_set_icon_name (GS_PROGRESS_BUTTON (priv->button), "app-remove-symbolic"); + break; + case GS_APP_STATE_INSTALLING: + gtk_widget_set_visible (priv->button, TRUE); + /* TRANSLATORS: this is a button next to the search results that + * shows the status of an application being installed */ + gs_progress_button_set_label (GS_PROGRESS_BUTTON (priv->button), _("Installing")); + gs_progress_button_set_icon_name (GS_PROGRESS_BUTTON (priv->button), NULL); + break; + case GS_APP_STATE_REMOVING: + gtk_widget_set_visible (priv->button, TRUE); + /* TRANSLATORS: this is a button next to the search results that + * shows the status of an application being erased */ + gs_progress_button_set_label (GS_PROGRESS_BUTTON (priv->button), _("Uninstalling")); + gs_progress_button_set_icon_name (GS_PROGRESS_BUTTON (priv->button), NULL); + break; + default: + break; + } + + /* visible */ + switch (gs_app_get_state (priv->app)) { + case GS_APP_STATE_UNAVAILABLE: + case GS_APP_STATE_QUEUED_FOR_INSTALL: + case GS_APP_STATE_AVAILABLE: + case GS_APP_STATE_AVAILABLE_LOCAL: + case GS_APP_STATE_UPDATABLE_LIVE: + case GS_APP_STATE_INSTALLING: + case GS_APP_STATE_REMOVING: + gtk_widget_set_visible (priv->button, TRUE); + break; + case GS_APP_STATE_UPDATABLE: + case GS_APP_STATE_INSTALLED: + gtk_widget_set_visible (priv->button, + !gs_app_has_quirk (priv->app, + GS_APP_QUIRK_COMPULSORY)); + break; + default: + gtk_widget_set_visible (priv->button, FALSE); + break; + } + + /* colorful */ + context = gtk_widget_get_style_context (priv->button); + if (!priv->colorful) { + gtk_style_context_remove_class (context, "destructive-action"); + } else { + switch (gs_app_get_state (priv->app)) { + case GS_APP_STATE_UPDATABLE: + case GS_APP_STATE_INSTALLED: + gtk_style_context_add_class (context, "destructive-action"); + break; + case GS_APP_STATE_UPDATABLE_LIVE: + if (priv->show_update) + gtk_style_context_remove_class (context, "destructive-action"); + else + gtk_style_context_add_class (context, "destructive-action"); + break; + default: + gtk_style_context_remove_class (context, "destructive-action"); + break; + } + } + + /* always insensitive when in selection mode */ + switch (gs_app_get_state (priv->app)) { + case GS_APP_STATE_INSTALLING: + case GS_APP_STATE_REMOVING: + gtk_widget_set_sensitive (priv->button, FALSE); + break; + default: + gtk_widget_set_sensitive (priv->button, TRUE); + break; + } + + gs_app_row_update_button_reveal (app_row); +} + +static void +gs_app_row_actually_refresh (GsAppRow *app_row) +{ + GsAppRowPrivate *priv = gs_app_row_get_instance_private (app_row); + GtkStyleContext *context; + GString *str = NULL; + const gchar *tmp; + gboolean missing_search_result; + gboolean is_markup = FALSE; + guint64 size_installed_bytes = 0; + GsSizeType size_installed_type = GS_SIZE_TYPE_UNKNOWN; + g_autoptr(GIcon) icon = NULL; + + if (priv->app == NULL) + return; + + /* is this a missing search result from the extras page? */ + missing_search_result = (gs_app_get_state (priv->app) == GS_APP_STATE_UNAVAILABLE && + gs_app_get_url_missing (priv->app) != NULL); + + /* do a fill bar for the current progress */ + switch (gs_app_get_state (priv->app)) { + case GS_APP_STATE_INSTALLING: + gs_progress_button_set_progress (GS_PROGRESS_BUTTON (priv->button), + gs_app_get_progress (priv->app)); + gs_progress_button_set_show_progress (GS_PROGRESS_BUTTON (priv->button), TRUE); + break; + default: + gs_progress_button_set_show_progress (GS_PROGRESS_BUTTON (priv->button), FALSE); + break; + } + + /* join the description lines */ + str = gs_app_row_get_description (app_row, &is_markup); + if (str != NULL) { + as_gstring_replace (str, "\n", " "); + if (is_markup) + gtk_label_set_markup (GTK_LABEL (priv->description_label), str->str); + else + gtk_label_set_label (GTK_LABEL (priv->description_label), str->str); + g_string_free (str, TRUE); + } else { + gtk_label_set_text (GTK_LABEL (priv->description_label), NULL); + } + + /* add warning */ + if (gs_app_has_quirk (priv->app, GS_APP_QUIRK_REMOVABLE_HARDWARE)) { + gtk_label_set_text (GTK_LABEL (priv->label_warning), + /* TRANSLATORS: during the update the device + * will restart into a special update-only mode */ + _("Device cannot be used during update.")); + gtk_widget_show (priv->label_warning); + } + + /* where did this app come from */ + if (priv->show_source) { + tmp = gs_app_get_origin_hostname (priv->app); + if (tmp != NULL) { + g_autofree gchar *origin_tmp = NULL; + /* TRANSLATORS: this refers to where the app came from */ + origin_tmp = g_strdup_printf (_("Source: %s"), tmp); + gtk_label_set_label (GTK_LABEL (priv->label_origin), origin_tmp); + } + gtk_widget_set_visible (priv->label_origin, tmp != NULL); + } else { + gtk_widget_set_visible (priv->label_origin, FALSE); + } + + /* installed tag */ + if (!priv->show_buttons) { + switch (gs_app_get_state (priv->app)) { + case GS_APP_STATE_UPDATABLE: + case GS_APP_STATE_UPDATABLE_LIVE: + case GS_APP_STATE_INSTALLED: + gtk_widget_set_visible (priv->label_installed, priv->show_installed); + break; + default: + gtk_widget_set_visible (priv->label_installed, FALSE); + break; + } + } else { + gtk_widget_set_visible (priv->label_installed, FALSE); + } + + /* name */ + gtk_label_set_label (GTK_LABEL (priv->name_label), + gs_app_get_name (priv->app)); + + if (priv->show_update) { + const gchar *version_current = NULL; + const gchar *version_update = NULL; + + /* current version */ + tmp = gs_app_get_version_ui (priv->app); + if (tmp != NULL && tmp[0] != '\0') { + version_current = tmp; + gtk_label_set_label (GTK_LABEL (priv->version_current_label), + version_current); + gtk_widget_show (priv->version_current_label); + } else { + gtk_widget_hide (priv->version_current_label); + } + + /* update version */ + tmp = gs_app_get_update_version_ui (priv->app); + if (tmp != NULL && tmp[0] != '\0' && + g_strcmp0 (tmp, version_current) != 0) { + version_update = tmp; + gtk_label_set_label (GTK_LABEL (priv->version_update_label), + version_update); + gtk_widget_show (priv->version_update_label); + } else { + gtk_widget_hide (priv->version_update_label); + } + + /* have both: show arrow */ + if (version_current != NULL && version_update != NULL && + g_strcmp0 (version_current, version_update) != 0) { + gtk_widget_show (priv->version_arrow_label); + } else { + gtk_widget_hide (priv->version_arrow_label); + } + + /* ensure the arrow is the right way round for the text direction, + * as arrows are not bidi-mirrored automatically + * See section 2 of http://www.unicode.org/L2/L2017/17438-bidi-math-fdbk.html */ + switch (gtk_widget_get_direction (priv->version_box)) { + case GTK_TEXT_DIR_RTL: + gtk_label_set_label (GTK_LABEL (priv->version_arrow_label), "←"); + break; + case GTK_TEXT_DIR_NONE: + case GTK_TEXT_DIR_LTR: + default: + gtk_label_set_label (GTK_LABEL (priv->version_arrow_label), "→"); + break; + } + + /* show the box if we have either of the versions */ + if (version_current != NULL || version_update != NULL) + gtk_widget_show (priv->version_box); + else + gtk_widget_hide (priv->version_box); + + gtk_widget_hide (priv->star); + } else { + gtk_widget_hide (priv->version_box); + if (missing_search_result || gs_app_get_rating (priv->app) <= 0 || !priv->show_rating) { + gtk_widget_hide (priv->star); + } else { + gtk_widget_show (priv->star); + gtk_widget_set_sensitive (priv->star, FALSE); + gs_star_widget_set_rating (GS_STAR_WIDGET (priv->star), + gs_app_get_rating (priv->app)); + } + } + + if (priv->show_update && gs_app_get_special_kind (priv->app) == GS_APP_SPECIAL_KIND_OS_UPDATE) { + gtk_label_set_label (GTK_LABEL (priv->system_updates_label), gs_app_get_summary (priv->app)); + gtk_widget_show (priv->system_updates_label); + } else { + gtk_widget_hide (priv->system_updates_label); + } + + /* pixbuf */ + icon = gs_app_get_icon_for_size (priv->app, + gtk_image_get_pixel_size (GTK_IMAGE (priv->image)), + gtk_widget_get_scale_factor (priv->image), + "system-component-application"); + gtk_image_set_from_gicon (GTK_IMAGE (priv->image), icon); + + context = gtk_widget_get_style_context (priv->image); + if (missing_search_result) + gtk_style_context_add_class (context, "dimmer-label"); + else + gtk_style_context_remove_class (context, "dimmer-label"); + + /* pending label */ + switch (gs_app_get_state (priv->app)) { + case GS_APP_STATE_QUEUED_FOR_INSTALL: + gtk_widget_set_visible (priv->label, TRUE); + gtk_label_set_label (GTK_LABEL (priv->label), _("Pending")); + break; + case GS_APP_STATE_PENDING_INSTALL: + gtk_widget_set_visible (priv->label, TRUE); + gtk_label_set_label (GTK_LABEL (priv->label), _("Pending install")); + break; + case GS_APP_STATE_PENDING_REMOVE: + gtk_widget_set_visible (priv->label, TRUE); + gtk_label_set_label (GTK_LABEL (priv->label), _("Pending remove")); + break; + default: + gtk_widget_set_visible (priv->label, FALSE); + break; + } + + /* spinner */ + switch (gs_app_get_state (priv->app)) { + case GS_APP_STATE_REMOVING: + gtk_spinner_start (GTK_SPINNER (priv->spinner)); + gtk_widget_set_visible (priv->spinner, TRUE); + break; + default: + gtk_widget_set_visible (priv->spinner, FALSE); + break; + } + + /* button */ + gs_app_row_refresh_button (app_row, missing_search_result); + + /* hide buttons in the update list, unless the app is live updatable */ + switch (gs_app_get_state (priv->app)) { + case GS_APP_STATE_UPDATABLE_LIVE: + case GS_APP_STATE_INSTALLING: + gtk_widget_set_visible (priv->button_box, TRUE); + break; + default: + gtk_widget_set_visible (priv->button_box, !priv->show_update); + break; + } + + /* show the right size */ + if (priv->show_installed_size) { + size_installed_type = gs_app_get_size_installed (priv->app, &size_installed_bytes); + } + if (size_installed_type == GS_SIZE_TYPE_VALID && size_installed_bytes > 0) { + g_autofree gchar *sizestr = NULL; + sizestr = g_format_size (size_installed_bytes); + gtk_label_set_label (GTK_LABEL (priv->label_app_size), sizestr); + gtk_widget_show (priv->label_app_size); + } else { + gtk_widget_hide (priv->label_app_size); + } + + /* add warning */ + if (priv->show_update) { + g_autoptr(GString) warning = g_string_new (NULL); + const gchar *renamed_from; + + if (gs_app_has_quirk (priv->app, GS_APP_QUIRK_NEW_PERMISSIONS)) + g_string_append (warning, _("Requires additional permissions")); + + renamed_from = gs_app_get_renamed_from (priv->app); + if (renamed_from && g_strcmp0 (renamed_from, gs_app_get_name (priv->app)) != 0) { + if (warning->len > 0) + g_string_append (warning, "\n"); + /* Translators: A message to indicate that an app has been renamed. The placeholder is the old human-readable name. */ + g_string_append_printf (warning, _("Renamed from %s"), renamed_from); + } + + if (warning->len > 0) { + gtk_label_set_text (GTK_LABEL (priv->label_warning), warning->str); + gtk_widget_show (priv->label_warning); + } + } + + gtk_widget_set_visible (priv->box_tag, + gtk_widget_get_visible (priv->label_origin) || + gtk_widget_get_visible (priv->label_installed) || + gtk_widget_get_visible (priv->label_warning)); + + gtk_label_set_max_width_chars (GTK_LABEL (priv->name_label), + gtk_widget_get_visible (priv->description_label) ? 20 : -1); +} + +static void +finish_unreveal (GsAppRow *app_row) +{ + gtk_widget_hide (GTK_WIDGET (app_row)); + + g_signal_emit (app_row, signals[SIGNAL_UNREVEALED], 0); +} + +static void +child_unrevealed (GObject *revealer, GParamSpec *pspec, gpointer user_data) +{ + GsAppRow *app_row = user_data; + GsAppRowPrivate *priv = gs_app_row_get_instance_private (app_row); + + /* return immediately if we are in destruction (this doesn't, however, + * catch the case where we are being removed from a container without + * having been destroyed first.) + */ + if (priv->app == NULL || !gtk_widget_get_mapped (GTK_WIDGET (app_row))) + return; + + finish_unreveal (app_row); +} + +static gboolean +child_unrevealed_unmapped_cb (gpointer user_data) +{ + GsAppRow *app_row = user_data; + GsAppRowPrivate *priv = gs_app_row_get_instance_private (app_row); + + priv->unreveal_in_idle_id = 0; + + finish_unreveal (app_row); + + return G_SOURCE_REMOVE; +} + +/** + * gs_app_row_unreveal: + * @app_row: a #GsAppRow + * + * Hide the row with an animation. Once the animation is done + * the GsAppRow:unrevealed signal is emitted. This handles + * the case when the widget is not mapped as well, in which case + * the GsAppRow:unrevealed signal is emitted from an idle + * callback, to ensure async nature of the function call and + * the signal emission. + * + * Calling the function multiple times has no effect. + **/ +void +gs_app_row_unreveal (GsAppRow *app_row) +{ + GtkWidget *child; + GtkWidget *revealer; + + g_return_if_fail (GS_IS_APP_ROW (app_row)); + + child = gtk_list_box_row_get_child (GTK_LIST_BOX_ROW (app_row)); + + /* This means the row is already hiding */ + if (GTK_IS_REVEALER (child)) + return; + + gtk_widget_set_sensitive (child, FALSE); + + /* Revealer does not animate when the widget is not mapped */ + if (!gtk_widget_get_mapped (GTK_WIDGET (app_row))) { + GsAppRowPrivate *priv = gs_app_row_get_instance_private (app_row); + if (priv->unreveal_in_idle_id == 0) + priv->unreveal_in_idle_id = g_idle_add_full (G_PRIORITY_HIGH, child_unrevealed_unmapped_cb, app_row, NULL); + return; + } + + revealer = gtk_revealer_new (); + gtk_revealer_set_reveal_child (GTK_REVEALER (revealer), TRUE); + gtk_widget_show (revealer); + + g_object_ref (child); + gtk_list_box_row_set_child (GTK_LIST_BOX_ROW (app_row), revealer); + gtk_revealer_set_child (GTK_REVEALER (revealer), child); + g_object_unref (child); + + g_signal_connect (revealer, "notify::child-revealed", + G_CALLBACK (child_unrevealed), app_row); + gtk_revealer_set_reveal_child (GTK_REVEALER (revealer), FALSE); +} + +GsApp * +gs_app_row_get_app (GsAppRow *app_row) +{ + GsAppRowPrivate *priv = gs_app_row_get_instance_private (app_row); + g_return_val_if_fail (GS_IS_APP_ROW (app_row), NULL); + return priv->app; +} + +static gboolean +gs_app_row_refresh_idle_cb (gpointer user_data) +{ + GsAppRow *app_row = GS_APP_ROW (user_data); + GsAppRowPrivate *priv = gs_app_row_get_instance_private (app_row); + priv->pending_refresh_id = 0; + gs_app_row_actually_refresh (app_row); + return G_SOURCE_REMOVE; +} + +/* Schedule an idle call to gs_app_row_actually_refresh() unless one’s already pending. */ +static void +gs_app_row_schedule_refresh (GsAppRow *app_row) +{ + GsAppRowPrivate *priv = gs_app_row_get_instance_private (app_row); + + if (priv->pending_refresh_id > 0) + return; + priv->pending_refresh_id = g_idle_add (gs_app_row_refresh_idle_cb, app_row); +} + +static void +gs_app_row_notify_props_changed_cb (GsApp *app, + GParamSpec *pspec, + GsAppRow *app_row) +{ + gs_app_row_schedule_refresh (app_row); +} + +static void +gs_app_row_set_app (GsAppRow *app_row, GsApp *app) +{ + GsAppRowPrivate *priv = gs_app_row_get_instance_private (app_row); + + priv->app = g_object_ref (app); + + g_signal_connect_object (priv->app, "notify::state", + G_CALLBACK (gs_app_row_notify_props_changed_cb), + app_row, 0); + g_signal_connect_object (priv->app, "notify::rating", + G_CALLBACK (gs_app_row_notify_props_changed_cb), + app_row, 0); + g_signal_connect_object (priv->app, "notify::progress", + G_CALLBACK (gs_app_row_notify_props_changed_cb), + app_row, 0); + g_signal_connect_object (priv->app, "notify::allow-cancel", + G_CALLBACK (gs_app_row_notify_props_changed_cb), + app_row, 0); + + gs_app_row_schedule_refresh (app_row); + g_object_notify_by_pspec (G_OBJECT (app_row), obj_props[PROP_APP]); +} + +static void +gs_app_row_get_property (GObject *object, guint prop_id, GValue *value, GParamSpec *pspec) +{ + GsAppRow *app_row = GS_APP_ROW (object); + GsAppRowPrivate *priv = gs_app_row_get_instance_private (app_row); + + switch ((GsAppRowProperty) prop_id) { + case PROP_APP: + g_value_set_object (value, priv->app); + break; + case PROP_COLORFUL: + g_value_set_boolean (value, priv->colorful); + break; + case PROP_SHOW_DESCRIPTION: + g_value_set_boolean (value, gs_app_row_get_show_description (app_row)); + break; + case PROP_SHOW_SOURCE: + g_value_set_boolean (value, priv->show_source); + break; + case PROP_SHOW_BUTTONS: + g_value_set_boolean (value, priv->show_buttons); + break; + case PROP_SHOW_RATING: + g_value_set_boolean (value, priv->show_rating); + break; + case PROP_SHOW_UPDATE: + g_value_set_boolean (value, priv->show_update); + break; + case PROP_SHOW_INSTALLED_SIZE: + g_value_set_boolean (value, priv->show_installed_size); + break; + case PROP_SHOW_INSTALLED: + g_value_set_boolean (value, priv->show_installed); + break; + case PROP_IS_NARROW: + g_value_set_boolean (value, gs_app_row_get_is_narrow (app_row)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_app_row_set_property (GObject *object, guint prop_id, const GValue *value, GParamSpec *pspec) +{ + GsAppRow *app_row = GS_APP_ROW (object); + + switch ((GsAppRowProperty) prop_id) { + case PROP_APP: + gs_app_row_set_app (app_row, g_value_get_object (value)); + break; + case PROP_COLORFUL: + gs_app_row_set_colorful (app_row, g_value_get_boolean (value)); + break; + case PROP_SHOW_DESCRIPTION: + gs_app_row_set_show_description (app_row, g_value_get_boolean (value)); + break; + case PROP_SHOW_SOURCE: + gs_app_row_set_show_source (app_row, g_value_get_boolean (value)); + break; + case PROP_SHOW_BUTTONS: + gs_app_row_set_show_buttons (app_row, g_value_get_boolean (value)); + break; + case PROP_SHOW_RATING: + gs_app_row_set_show_rating (app_row, g_value_get_boolean (value)); + break; + case PROP_SHOW_UPDATE: + gs_app_row_set_show_update (app_row, g_value_get_boolean (value)); + break; + case PROP_SHOW_INSTALLED_SIZE: + gs_app_row_set_show_installed_size (app_row, g_value_get_boolean (value)); + break; + case PROP_SHOW_INSTALLED: + gs_app_row_set_show_installed (app_row, g_value_get_boolean (value)); + break; + case PROP_IS_NARROW: + gs_app_row_set_is_narrow (app_row, g_value_get_boolean (value)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_app_row_dispose (GObject *object) +{ + GsAppRow *app_row = GS_APP_ROW (object); + GsAppRowPrivate *priv = gs_app_row_get_instance_private (app_row); + + if (priv->app) + g_signal_handlers_disconnect_by_func (priv->app, gs_app_row_notify_props_changed_cb, app_row); + + g_clear_object (&priv->app); + g_clear_handle_id (&priv->pending_refresh_id, g_source_remove); + g_clear_handle_id (&priv->unreveal_in_idle_id, g_source_remove); + + G_OBJECT_CLASS (gs_app_row_parent_class)->dispose (object); +} + +static void +gs_app_row_class_init (GsAppRowClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + + object_class->get_property = gs_app_row_get_property; + object_class->set_property = gs_app_row_set_property; + object_class->dispose = gs_app_row_dispose; + + /** + * GsAppRow:app: + * + * The #GsApp to show in this row. + * + * Since: 3.38 + */ + obj_props[PROP_APP] = + g_param_spec_object ("app", NULL, NULL, + GS_TYPE_APP, + G_PARAM_CONSTRUCT_ONLY | G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS); + + /** + * GsAppRow:colorful: + * + * Whether the buttons can be colorized in the row. + * + * Since: 42.1 + */ + obj_props[PROP_COLORFUL] = + g_param_spec_boolean ("colorful", NULL, NULL, + FALSE, + G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY); + + /** + * GsAppRow:show-description: + * + * Show the description of the app in the row. + * + * Since: 41 + */ + obj_props[PROP_SHOW_DESCRIPTION] = + g_param_spec_boolean ("show-description", NULL, NULL, + TRUE, + G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY); + + /** + * GsAppRow:show-source: + * + * Show the source of the app in the row. + * + * Since: 3.38 + */ + obj_props[PROP_SHOW_SOURCE] = + g_param_spec_boolean ("show-source", NULL, NULL, + FALSE, + G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY); + + /** + * GsAppRow:show-buttons: + * + * Show buttons (such as Install, Cancel or Update) in the app row. + * + * Since: 3.38 + */ + obj_props[PROP_SHOW_BUTTONS] = + g_param_spec_boolean ("show-buttons", NULL, NULL, + FALSE, + G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY); + + /** + * GsAppRow:show-rating: + * + * Show app rating in the app row. + * + * Since: 42.1 + */ + obj_props[PROP_SHOW_RATING] = + g_param_spec_boolean ("show-rating", NULL, NULL, + FALSE, + G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY); + + /** + * GsAppRow:show-update: + * + * Show update (version) information in the app row. + * + * Since: 42.1 + */ + obj_props[PROP_SHOW_UPDATE] = + g_param_spec_boolean ("show-update", NULL, NULL, + FALSE, + G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY); + + /** + * GsAppRow:show-installed: + * + * Show an "Installed" check in the app row, when the app is installed. + * + * Since: 42.1 + */ + obj_props[PROP_SHOW_INSTALLED] = + g_param_spec_boolean ("show-installed", NULL, NULL, + TRUE, + G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY); + + /** + * GsAppRow:show-installed-size: + * + * Show the installed size of the app in the row. + * + * Since: 3.38 + */ + obj_props[PROP_SHOW_INSTALLED_SIZE] = + g_param_spec_boolean ("show-installed-size", NULL, NULL, + FALSE, + G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY); + + /** + * GsAppRow:is-narrow: + * + * Whether the row is in narrow mode. + * + * In narrow mode, the row will take up less horizontal space, doing so + * by e.g. using icons rather than labels in buttons. 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); + + signals [SIGNAL_BUTTON_CLICKED] = + g_signal_new ("button-clicked", + G_TYPE_FROM_CLASS (object_class), G_SIGNAL_RUN_LAST, + G_STRUCT_OFFSET (GsAppRowClass, button_clicked), + NULL, NULL, g_cclosure_marshal_VOID__VOID, + G_TYPE_NONE, 0); + + signals [SIGNAL_UNREVEALED] = + g_signal_new ("unrevealed", + G_TYPE_FROM_CLASS (object_class), G_SIGNAL_RUN_LAST, + G_STRUCT_OFFSET (GsAppRowClass, unrevealed), + NULL, NULL, g_cclosure_marshal_VOID__VOID, + G_TYPE_NONE, 0); + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-app-row.ui"); + + gtk_widget_class_bind_template_child_private (widget_class, GsAppRow, image); + gtk_widget_class_bind_template_child_private (widget_class, GsAppRow, name_box); + gtk_widget_class_bind_template_child_private (widget_class, GsAppRow, name_label); + gtk_widget_class_bind_template_child_private (widget_class, GsAppRow, version_box); + gtk_widget_class_bind_template_child_private (widget_class, GsAppRow, version_current_label); + gtk_widget_class_bind_template_child_private (widget_class, GsAppRow, version_arrow_label); + gtk_widget_class_bind_template_child_private (widget_class, GsAppRow, version_update_label); + gtk_widget_class_bind_template_child_private (widget_class, GsAppRow, system_updates_label); + gtk_widget_class_bind_template_child_private (widget_class, GsAppRow, star); + gtk_widget_class_bind_template_child_private (widget_class, GsAppRow, description_label); + gtk_widget_class_bind_template_child_private (widget_class, GsAppRow, button_box); + gtk_widget_class_bind_template_child_private (widget_class, GsAppRow, button_revealer); + gtk_widget_class_bind_template_child_private (widget_class, GsAppRow, button); + gtk_widget_class_bind_template_child_private (widget_class, GsAppRow, spinner); + gtk_widget_class_bind_template_child_private (widget_class, GsAppRow, label); + gtk_widget_class_bind_template_child_private (widget_class, GsAppRow, box_tag); + gtk_widget_class_bind_template_child_private (widget_class, GsAppRow, label_warning); + gtk_widget_class_bind_template_child_private (widget_class, GsAppRow, label_origin); + gtk_widget_class_bind_template_child_private (widget_class, GsAppRow, label_installed); + gtk_widget_class_bind_template_child_private (widget_class, GsAppRow, label_app_size); +} + +static void +button_clicked (GtkWidget *widget, GsAppRow *app_row) +{ + g_signal_emit (app_row, signals[SIGNAL_BUTTON_CLICKED], 0); +} + +static void +gs_app_row_init (GsAppRow *app_row) +{ + GsAppRowPrivate *priv = gs_app_row_get_instance_private (app_row); + + priv->show_description = TRUE; + priv->show_installed = TRUE; + + gtk_widget_init_template (GTK_WIDGET (app_row)); + + g_signal_connect (priv->button, "clicked", + G_CALLBACK (button_clicked), app_row); + + /* A fix for this is included in 4.6.4, apply workaround, if not running with new-enough gtk. */ + if (gtk_get_major_version () < 4 || + (gtk_get_major_version () == 4 && gtk_get_minor_version () < 6) || + (gtk_get_major_version () == 4 && gtk_get_minor_version () == 6 && gtk_get_micro_version () < 4)) { + g_object_set (G_OBJECT (priv->name_label), + "wrap", FALSE, + "lines", 1, + NULL); + g_object_set (G_OBJECT (priv->description_label), + "wrap", FALSE, + "lines", 1, + NULL); + g_object_set (G_OBJECT (priv->label_warning), + "wrap", FALSE, + "lines", 1, + NULL); + g_object_set (G_OBJECT (priv->system_updates_label), + "wrap", FALSE, + "lines", 1, + NULL); + } +} + +void +gs_app_row_set_size_groups (GsAppRow *app_row, + GtkSizeGroup *name, + GtkSizeGroup *button_label, + GtkSizeGroup *button_image) +{ + GsAppRowPrivate *priv = gs_app_row_get_instance_private (app_row); + + g_return_if_fail (GS_IS_APP_ROW (app_row)); + + if (name != NULL) + gtk_size_group_add_widget (name, priv->name_box); + gs_progress_button_set_size_groups (GS_PROGRESS_BUTTON (priv->button), button_label, button_image); +} + +void +gs_app_row_set_colorful (GsAppRow *app_row, gboolean colorful) +{ + GsAppRowPrivate *priv = gs_app_row_get_instance_private (app_row); + + g_return_if_fail (GS_IS_APP_ROW (app_row)); + + if ((!priv->colorful) == (!colorful)) + return; + + priv->colorful = colorful; + gs_app_row_schedule_refresh (app_row); + g_object_notify_by_pspec (G_OBJECT (app_row), obj_props[PROP_COLORFUL]); +} + +void +gs_app_row_set_show_buttons (GsAppRow *app_row, gboolean show_buttons) +{ + GsAppRowPrivate *priv = gs_app_row_get_instance_private (app_row); + + g_return_if_fail (GS_IS_APP_ROW (app_row)); + + if ((!priv->show_buttons) == (!show_buttons)) + return; + + priv->show_buttons = show_buttons; + gs_app_row_schedule_refresh (app_row); + g_object_notify_by_pspec (G_OBJECT (app_row), obj_props[PROP_SHOW_BUTTONS]); +} + +void +gs_app_row_set_show_rating (GsAppRow *app_row, gboolean show_rating) +{ + GsAppRowPrivate *priv = gs_app_row_get_instance_private (app_row); + + g_return_if_fail (GS_IS_APP_ROW (app_row)); + + if ((!priv->show_rating) == (!show_rating)) + return; + + priv->show_rating = show_rating; + gs_app_row_schedule_refresh (app_row); + g_object_notify_by_pspec (G_OBJECT (app_row), obj_props[PROP_SHOW_RATING]); +} + +/** + * gs_app_row_get_show_description: + * @app_row: a #GsAppRow + * + * Get the value of #GsAppRow:show-description. + * + * Returns: %TRUE if the description is shown, %FALSE otherwise + * + * Since: 41 + */ +gboolean +gs_app_row_get_show_description (GsAppRow *app_row) +{ + GsAppRowPrivate *priv = gs_app_row_get_instance_private (app_row); + + g_return_val_if_fail (GS_IS_APP_ROW (app_row), FALSE); + + return priv->show_description; +} + +/** + * gs_app_row_set_show_description: + * @app_row: a #GsAppRow + * @show_description: %TRUE to show the description, %FALSE otherwise + * + * Set the value of #GsAppRow:show-description. + * + * Since: 41 + */ +void +gs_app_row_set_show_description (GsAppRow *app_row, gboolean show_description) +{ + GsAppRowPrivate *priv = gs_app_row_get_instance_private (app_row); + + g_return_if_fail (GS_IS_APP_ROW (app_row)); + + show_description = !!show_description; + + if (priv->show_description == show_description) + return; + + priv->show_description = show_description; + gs_app_row_schedule_refresh (app_row); + g_object_notify_by_pspec (G_OBJECT (app_row), obj_props[PROP_SHOW_DESCRIPTION]); +} + +void +gs_app_row_set_show_source (GsAppRow *app_row, gboolean show_source) +{ + GsAppRowPrivate *priv = gs_app_row_get_instance_private (app_row); + + g_return_if_fail (GS_IS_APP_ROW (app_row)); + + if ((!priv->show_source) == (!show_source)) + return; + + priv->show_source = show_source; + gs_app_row_schedule_refresh (app_row); + g_object_notify_by_pspec (G_OBJECT (app_row), obj_props[PROP_SHOW_SOURCE]); +} + +void +gs_app_row_set_show_installed_size (GsAppRow *app_row, gboolean show_size) +{ + GsAppRowPrivate *priv = gs_app_row_get_instance_private (app_row); + + g_return_if_fail (GS_IS_APP_ROW (app_row)); + + if ((!priv->show_installed_size) == (!show_size)) + return; + + priv->show_installed_size = show_size; + gs_app_row_schedule_refresh (app_row); + g_object_notify_by_pspec (G_OBJECT (app_row), obj_props[PROP_SHOW_INSTALLED_SIZE]); +} + +/** + * gs_app_row_get_is_narrow: + * @app_row: a #GsAppRow + * + * Get the value of #GsAppRow:is-narrow. + * + * Retruns: %TRUE if the row is in narrow mode, %FALSE otherwise + * + * Since: 41 + */ +gboolean +gs_app_row_get_is_narrow (GsAppRow *app_row) +{ + GsAppRowPrivate *priv = gs_app_row_get_instance_private (app_row); + + g_return_val_if_fail (GS_IS_APP_ROW (app_row), FALSE); + + return priv->is_narrow; +} + +/** + * gs_app_row_set_is_narrow: + * @app_row: a #GsAppRow + * @is_narrow: %TRUE to set the row in narrow mode, %FALSE otherwise + * + * Set the value of #GsAppRow:is-narrow. + * + * Since: 41 + */ +void +gs_app_row_set_is_narrow (GsAppRow *app_row, gboolean is_narrow) +{ + GsAppRowPrivate *priv = gs_app_row_get_instance_private (app_row); + + g_return_if_fail (GS_IS_APP_ROW (app_row)); + + is_narrow = !!is_narrow; + + if (priv->is_narrow == is_narrow) + return; + + priv->is_narrow = is_narrow; + gs_app_row_update_button_reveal (app_row); + g_object_notify_by_pspec (G_OBJECT (app_row), obj_props[PROP_IS_NARROW]); +} + +/** + * gs_app_row_set_show_update: + * + * Only really useful for the update panel to call + **/ +void +gs_app_row_set_show_update (GsAppRow *app_row, gboolean show_update) +{ + GsAppRowPrivate *priv = gs_app_row_get_instance_private (app_row); + + if ((!priv->show_update) == (!show_update)) + return; + + priv->show_update = show_update; + gs_app_row_schedule_refresh (app_row); + g_object_notify_by_pspec (G_OBJECT (app_row), obj_props[PROP_SHOW_UPDATE]); +} + +/** + * gs_app_row_set_show_installed: + * @app_row: a #GsAppRow + * @show_installed: value to set + * + * Set whether to show "installed" label. Default is %TRUE. This has effect only + * when not showing buttons (gs_app_row_set_show_buttons()). + * + * Since: 42.1 + **/ +void +gs_app_row_set_show_installed (GsAppRow *app_row, + gboolean show_installed) +{ + GsAppRowPrivate *priv = gs_app_row_get_instance_private (app_row); + + g_return_if_fail (GS_IS_APP_ROW (app_row)); + + if ((!show_installed) != (!priv->show_installed)) { + priv->show_installed = show_installed; + gs_app_row_schedule_refresh (app_row); + g_object_notify_by_pspec (G_OBJECT (app_row), obj_props[PROP_SHOW_INSTALLED]); + } +} + +GtkWidget * +gs_app_row_new (GsApp *app) +{ + g_return_val_if_fail (GS_IS_APP (app), NULL); + + return g_object_new (GS_TYPE_APP_ROW, + "app", app, + NULL); +} diff --git a/src/gs-app-row.h b/src/gs-app-row.h new file mode 100644 index 0000000..8d29a2f --- /dev/null +++ b/src/gs-app-row.h @@ -0,0 +1,57 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2012 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2014-2018 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <gtk/gtk.h> + +#include "gnome-software-private.h" + +G_BEGIN_DECLS + +#define GS_TYPE_APP_ROW (gs_app_row_get_type ()) + +G_DECLARE_DERIVABLE_TYPE (GsAppRow, gs_app_row, GS, APP_ROW, GtkListBoxRow) + +struct _GsAppRowClass +{ + GtkListBoxRowClass parent_class; + void (*button_clicked) (GsAppRow *app_row); + void (*unrevealed) (GsAppRow *app_row); +}; + +GtkWidget *gs_app_row_new (GsApp *app); +void gs_app_row_unreveal (GsAppRow *app_row); +void gs_app_row_set_colorful (GsAppRow *app_row, + gboolean colorful); +void gs_app_row_set_show_buttons (GsAppRow *app_row, + gboolean show_buttons); +void gs_app_row_set_show_rating (GsAppRow *app_row, + gboolean show_rating); +gboolean gs_app_row_get_show_description (GsAppRow *app_row); +void gs_app_row_set_show_description (GsAppRow *app_row, + gboolean show_description); +void gs_app_row_set_show_source (GsAppRow *app_row, + gboolean show_source); +void gs_app_row_set_show_update (GsAppRow *app_row, + gboolean show_update); +void gs_app_row_set_show_installed (GsAppRow *app_row, + gboolean show_installed); +GsApp *gs_app_row_get_app (GsAppRow *app_row); +void gs_app_row_set_size_groups (GsAppRow *app_row, + GtkSizeGroup *name, + GtkSizeGroup *button_label, + GtkSizeGroup *button_image); +void gs_app_row_set_show_installed_size (GsAppRow *app_row, + gboolean show_size); +gboolean gs_app_row_get_is_narrow (GsAppRow *app_row); +void gs_app_row_set_is_narrow (GsAppRow *app_row, + gboolean is_narrow); + +G_END_DECLS diff --git a/src/gs-app-row.ui b/src/gs-app-row.ui new file mode 100644 index 0000000..8df9c8e --- /dev/null +++ b/src/gs-app-row.ui @@ -0,0 +1,258 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <!-- interface-requires gtk+ 3.10 --> + <template class="GsAppRow" parent="GtkListBoxRow"> + <style> + <class name="app"/> + </style> + <child> + <object class="GtkBox" id="box"> + <property name="orientation">horizontal</property> + <style> + <class name="header"/> + </style> + <child> + <object class="GtkImage" id="image"> + <property name="pixel_size">64</property> + <property name="valign">center</property> + <style> + <class name="icon-dropshadow"/> + </style> + </object> + </child> + <child> + <object class="GtkBox" id="name_box"> + <property name="orientation">vertical</property> + <property name="valign">center</property> + <property name="hexpand">True</property> + <style> + <class name="title"/> + </style> + <child> + <object class="GtkLabel" id="name_label"> + <property name="wrap">True</property> + <property name="xalign">0.0</property> + <property name="yalign">0.5</property> + <property name="ellipsize">end</property> + <property name="lines">3</property> + <property name="wrap-mode">word-char</property> + <style> + <class name="title"/> + </style> + </object> + </child> + <child> + <object class="GtkLabel" id="description_label"> + <property name="visible" bind-source="GsAppRow" bind-property="show-description" bind-flags="sync-create"/> + <property name="valign">start</property> + <property name="vexpand">True</property> + <property name="wrap">True</property> + <property name="wrap-mode">word-char</property> + <property name="ellipsize">end</property> + <property name="lines">2</property> + <property name="xalign">0</property> + <style> + <class name="subtitle"/> + </style> + </object> + </child> + <child> + <object class="GtkBox" id="version_box"> + <property name="orientation">horizontal</property> + <property name="spacing">4</property> + <child> + <object class="GtkLabel" id="version_current_label"> + <property name="xalign">0.0</property> + <property name="yalign">0.5</property> + <property name="ellipsize">end</property> + <style> + <class name="subtitle"/> + </style> + </object> + </child> + <child> + <object class="GtkLabel" id="version_arrow_label"> + <property name="xalign">0.0</property> + <property name="yalign">0.5</property> + <property name="ellipsize">end</property> + <property name="label">→</property> + <style> + <class name="version-arrow-label"/> + <class name="subtitle"/> + </style> + </object> + </child> + <child> + <object class="GtkLabel" id="version_update_label"> + <property name="xalign">0.0</property> + <property name="yalign">0.5</property> + <property name="ellipsize">end</property> + <style> + <class name="subtitle"/> + </style> + </object> + </child> + </object> + </child> + <child> + <object class="GtkLabel" id="label_warning"> + <property name="visible">False</property> + <property name="label">warning-text</property> + <property name="halign">start</property> + <property name="wrap">True</property> + <property name="xalign">0</property> + <style> + <class name="title"/> + <class name="warning"/> + </style> + </object> + </child> + <child> + <object class="GtkBox" id="system_updates_box"> + <property name="orientation">horizontal</property> + <property name="spacing">4</property> + <property name="hexpand">True</property> + <property name="visible" bind-source="system_updates_label" bind-property="visible" bind-flags="sync-create"/> + <child> + <object class="GtkLabel" id="system_updates_label"> + <property name="hexpand">True</property> + <property name="visible">False</property> + <property name="xalign">0.0</property> + <property name="yalign">0.5</property> + <property name="wrap">True</property> + <property name="wrap-mode">word-char</property> + <property name="ellipsize">end</property> + <property name="lines">2</property> + <style> + <class name="subtitle"/> + </style> + </object> + </child> + </object> + </child> + <child> + <object class="GtkLabel" id="label_app_size"> + <property name="halign">start</property> + <property name="valign">center</property> + <property name="vexpand">True</property> + <property name="label">150 MB</property> + <style> + <class name="subtitle"/> + </style> + </object> + </child> + <child> + <object class="GsStarWidget" id="star"> + <property name="visible">False</property> + <property name="halign">start</property> + <property name="icon-size">12</property> + </object> + </child> + </object> + </child> + <child> + <object class="GtkBox" id="box_tag"> + <property name="orientation">vertical</property> + <property name="valign">center</property> + <child> + <object class="GtkBox" id="box_desc"> + <property name="orientation">horizontal</property> + <property name="vexpand">True</property> + <child> + <object class="GtkLabel" id="label_origin"> + <property name="xalign">0.0</property> + <property name="yalign">1.0</property> + <property name="halign">start</property> + <property name="hexpand">True</property> + <property name="ellipsize">end</property> + <style> + <class name="app-row-origin-text"/> + <class name="dim-label"/> + <class name="subtitle"/> + </style> + </object> + </child> + <child> + <object class="GtkBox" id="label_installed"> + <property name="visible">False</property> + <property name="orientation">horizontal</property> + <property name="halign">end</property> + <property name="hexpand">True</property> + <property name="valign">end</property> + <property name="spacing">6</property> + <child> + <object class="GtkImage" id="installed-icon"> + <property name="halign">center</property> + <property name="valign">center</property> + <property name="pixel-size">16</property> + <property name="icon-name">app-installed-symbolic</property> + <style> + <class name="success"/> + </style> + </object> + </child> + <child> + <object class="GtkLabel" id="installed-label"> + <property name="valign">center</property> + <property name="label" translatable="yes" context="Single app">Installed</property> + <style> + <class name="caption"/> + <class name="subtitle"/> + </style> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + <child> + <object class="GtkBox" id="vertical_box"> + <property name="orientation">vertical</property> + <property name="halign">center</property> + <property name="valign">center</property> + <child> + <object class="GtkBox" id="button_box"> + <property name="orientation">horizontal</property> + <property name="halign">end</property> + <property name="valign">center</property> + <child> + <object class="GtkRevealer" id="button_revealer"> + <property name="reveal-child">True</property> + <child> + <object class="GsProgressButton" id="button"> + <property name="visible">False</property> + <property name="halign">end</property> + <property name="show-icon" bind-source="GsAppRow" bind-property="is-narrow" bind-flags="sync-create"/> + <style> + <class name="list-button"/> + </style> + </object> + </child> + </object> + </child> + <child> + <object class="GtkSpinner" id="spinner"> + <property name="visible">False</property> + <property name="margin_start">12</property> + <property name="margin_end">12</property> + <property name="halign">end</property> + </object> + </child> + <child> + <object class="GtkLabel" id="label"> + <property name="visible">False</property> + <property name="margin_start">12</property> + <property name="margin_end">12</property> + <property name="halign">end</property> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + </template> +</interface> diff --git a/src/gs-app-tile.c b/src/gs-app-tile.c new file mode 100644 index 0000000..01449a7 --- /dev/null +++ b/src/gs-app-tile.c @@ -0,0 +1,176 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013 Matthias Clasen <mclasen@redhat.com> + * Copyright (C) 2019 Richard Hughes <richard@hughsie.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include "gs-app-tile.h" +#include "gs-common.h" + +typedef struct { + GsApp *app; + guint app_notify_idle_id; +} GsAppTilePrivate; + +G_DEFINE_ABSTRACT_TYPE_WITH_PRIVATE (GsAppTile, gs_app_tile, GTK_TYPE_BUTTON) + +typedef enum { + PROP_APP = 1, +} GsAppTileProperty; + +static GParamSpec *obj_props[PROP_APP + 1] = { NULL, }; + +static void +gs_app_tile_get_property (GObject *object, guint prop_id, GValue *value, GParamSpec *pspec) +{ + GsAppTile *self = GS_APP_TILE (object); + GsAppTilePrivate *priv = gs_app_tile_get_instance_private (self); + + switch ((GsAppTileProperty) prop_id) { + case PROP_APP: + g_value_set_object (value, priv->app); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_app_tile_set_property (GObject *object, guint prop_id, const GValue *value, GParamSpec *pspec) +{ + GsAppTile *self = GS_APP_TILE (object); + + switch ((GsAppTileProperty) prop_id) { + case PROP_APP: + gs_app_tile_set_app (self, g_value_get_object (value)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_app_tile_dispose (GObject *object) +{ + GsAppTile *self = GS_APP_TILE (object); + + gs_app_tile_set_app (self, NULL); + + G_OBJECT_CLASS (gs_app_tile_parent_class)->dispose (object); +} + +/** + * gs_app_tile_get_app: + * @self: a #GsAppTile + * + * Get the value of #GsAppTile:app. + * + * Returns: (nullable) (transfer none): the #GsAppTile:app property + */ +GsApp * +gs_app_tile_get_app (GsAppTile *self) +{ + GsAppTilePrivate *priv = gs_app_tile_get_instance_private (self); + g_return_val_if_fail (GS_IS_APP_TILE (self), NULL); + return priv->app; +} + +static gboolean +gs_app_tile_app_notify_idle_cb (gpointer user_data) +{ + GsAppTile *self = GS_APP_TILE (user_data); + GsAppTileClass *klass = GS_APP_TILE_GET_CLASS (self); + GsAppTilePrivate *priv = gs_app_tile_get_instance_private (self); + + priv->app_notify_idle_id = 0; + klass->refresh (self); + + return G_SOURCE_REMOVE; +} + +static void +gs_app_tile_app_notify_cb (GsApp *app, GParamSpec *pspec, GsAppTile *self) +{ + GsAppTilePrivate *priv = gs_app_tile_get_instance_private (self); + + /* Already pending */ + if (priv->app_notify_idle_id != 0) + return; + + priv->app_notify_idle_id = g_idle_add (gs_app_tile_app_notify_idle_cb, self); +} + +/** + * gs_app_tile_set_app: + * @self: a #GsAppTile + * @app: (transfer none) (nullable): the new value for #GsAppTile:app + * + * Set the value of #GsAppTile:app. + */ +void +gs_app_tile_set_app (GsAppTile *self, GsApp *app) +{ + GsAppTileClass *klass = GS_APP_TILE_GET_CLASS (self); + GsAppTilePrivate *priv = gs_app_tile_get_instance_private (self); + + g_return_if_fail (GS_IS_APP_TILE (self)); + g_return_if_fail (!app || GS_IS_APP (app)); + + /* cancel pending refresh */ + g_clear_handle_id (&priv->app_notify_idle_id, g_source_remove); + + /* disconnect old app */ + if (priv->app != NULL) + g_signal_handlers_disconnect_by_func (priv->app, gs_app_tile_app_notify_cb, self); + g_set_object (&priv->app, app); + + /* optional refresh */ + if (klass->refresh != NULL && priv->app != NULL) { + g_signal_connect (app, "notify", + G_CALLBACK (gs_app_tile_app_notify_cb), self); + klass->refresh (self); + } + + g_object_notify_by_pspec (G_OBJECT (self), obj_props[PROP_APP]); +} + +void +gs_app_tile_class_init (GsAppTileClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + + object_class->get_property = gs_app_tile_get_property; + object_class->set_property = gs_app_tile_set_property; + object_class->dispose = gs_app_tile_dispose; + + /** + * GsAppTile:app: (nullable) + * + * The app to display in this tile. + * + * Set this to %NULL to display a loading/empty tile. + * + * Since: 41 + */ + obj_props[PROP_APP] = + g_param_spec_object ("app", "App", + "The app to display in this tile.", + GS_TYPE_APP, + G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS); + + g_object_class_install_properties (object_class, G_N_ELEMENTS (obj_props), obj_props); +} + +void +gs_app_tile_init (GsAppTile *self) +{ + GsAppTilePrivate *priv = gs_app_tile_get_instance_private (self); + priv->app_notify_idle_id = 0; +} diff --git a/src/gs-app-tile.h b/src/gs-app-tile.h new file mode 100644 index 0000000..37c77ef --- /dev/null +++ b/src/gs-app-tile.h @@ -0,0 +1,32 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013 Matthias Clasen <mclasen@redhat.com> + * Copyright (C) 2019 Richard Hughes <richard@hughsie.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <gtk/gtk.h> + +#include "gnome-software-private.h" + +G_BEGIN_DECLS + +#define GS_TYPE_APP_TILE (gs_app_tile_get_type ()) + +G_DECLARE_DERIVABLE_TYPE (GsAppTile, gs_app_tile, GS, APP_TILE, GtkButton) + +struct _GsAppTileClass +{ + GtkButtonClass parent_class; + void (*refresh) (GsAppTile *self); +}; + +GsApp *gs_app_tile_get_app (GsAppTile *self); +void gs_app_tile_set_app (GsAppTile *self, + GsApp *app); + +G_END_DECLS diff --git a/src/gs-app-tile.ui b/src/gs-app-tile.ui new file mode 100644 index 0000000..8333843 --- /dev/null +++ b/src/gs-app-tile.ui @@ -0,0 +1,129 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <!-- interface-requires gtk+ 3.10 --> + <template class="GsAppTile" parent="GtkButton"> + <property name="hexpand">True</property> + <!-- This is the minimum (sic!) width of a tile when the GtkFlowBox parent container switches to 3 columns --> + <property name="preferred-width">270</property> + <style> + <class name="card"/> + </style> + <child> + <object class="GtkStack" id="stack"> + + <child> + <object class="GtkStackPage"> + <property name="name">waiting</property> + <property name="child"> + <object class="GtkImage" id="waiting"> + <property name="halign">center</property> + <property name="valign">center</property> + <property name="pixel-size">16</property> + <property name="icon-name">content-loading-symbolic</property> + <style> + <class name="dim-label"/> + </style> + </object> + </property> + </object> + </child> + + <child> + <object class="GtkStackPage"> + <property name="name">content</property> + <property name="child"> + <object class="GtkOverlay" id="overlay"> + <property name="halign">fill</property> + <property name="valign">fill</property> + <child type="overlay"> + <object class="AdwBin"> + <property name="visible">False</property> + <property name="halign">start</property> + <property name="valign">start</property> + <property name="margin-top">58</property> + <property name="margin-start">12</property> + <style> + <class name="installed-overlay-box"/> + </style> + <child> + <object class="GtkLabel" id="installed-label"> + <property name="label" translatable="yes" context="Single app">Installed</property> + <property name="margin-start">16</property> + <property name="margin-end">16</property> + <property name="margin-top">4</property> + <property name="margin-bottom">4</property> + </object> + </child> + </object> + </child> + <child> + <object class="GtkGrid" id="grid"> + <property name="margin-top">14</property> + <property name="margin-bottom">15</property> + <property name="margin-start">17</property> + <property name="margin-end">17</property> + <property name="row-spacing">3</property> + <property name="column-spacing">12</property> + <child> + <object class="GtkImage" id="image"> + <property name="width-request">64</property> + <property name="height-request">64</property> + <style> + <class name="icon-dropshadow"/> + </style> + <layout> + <property name="column">0</property> + <property name="row">0</property> + <property name="column-span">1</property> + <property name="row-span">3</property> + </layout> + </object> + </child> + <child> + <object class="GtkLabel" id="name"> + <property name="ellipsize">end</property> + <property name="xalign">0.0</property> + <attributes> + <attribute name="weight" value="bold"/> + </attributes> + <style> + <class name="app-tile-label"/> + </style> + <layout> + <property name="column">1</property> + <property name="row">0</property> + <property name="column-span">1</property> + <property name="row-span">1</property> + </layout> + </object> + </child> + <child> + <object class="GtkLabel" id="summary"> + <property name="ellipsize">end</property> + <property name="xalign">0.0</property> + <property name="yalign">0.0</property> + <property name="lines">2</property> + <property name="vexpand">True</property> + <property name="single-line-mode">True</property> + <style> + <class name="app-tile-label"/> + </style> + <layout> + <property name="column">1</property> + <property name="row">2</property> + <property name="column-span">1</property> + <property name="row-span">1</property> + </layout> + </object> + </child> + </object> + </child> + </object> + </property> + </object> + </child> + + </object> + </child> + </template> +</interface> diff --git a/src/gs-app-translation-dialog.c b/src/gs-app-translation-dialog.c new file mode 100644 index 0000000..81a8a44 --- /dev/null +++ b/src/gs-app-translation-dialog.c @@ -0,0 +1,271 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2021 Endless OS Foundation LLC + * + * Author: Philip Withnall <pwithnall@endlessos.org> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +/** + * SECTION:gs-app-translation-dialog + * @short_description: A dialog showing translation information about an app + * + * #GsAppTranslationDialog is a dialog which shows a message about the + * translation status of an app, and provides information and a link for how + * to contribute more translations to the app. + * + * It is intended to be shown if the app is not sufficiently translated to the + * current locale. + * + * The widget has no special appearance if the app is unset, so callers will + * typically want to hide the dialog in that case. + * + * Since: 41 + */ + +#include "config.h" + +#include <adwaita.h> +#include <glib.h> +#include <glib-object.h> +#include <glib/gi18n.h> +#include <gtk/gtk.h> +#include <locale.h> + +#include "gs-app.h" +#include "gs-app-translation-dialog.h" +#include "gs-common.h" + +struct _GsAppTranslationDialog +{ + GsInfoWindow parent_instance; + + GsApp *app; /* (not nullable) (owned) */ + gulong app_notify_name_handler; + + GtkLabel *title; + GtkLabel *description; +}; + +G_DEFINE_TYPE (GsAppTranslationDialog, gs_app_translation_dialog, GS_TYPE_INFO_WINDOW) + +typedef enum { + PROP_APP = 1, +} GsAppTranslationDialogProperty; + +static GParamSpec *obj_props[PROP_APP + 1] = { NULL, }; + +static void +update_labels (GsAppTranslationDialog *self) +{ + g_autofree gchar *title = NULL; + g_autofree gchar *description = NULL; + + /* Translators: The placeholder is an application name */ + title = g_strdup_printf (_("Help Translate %s"), gs_app_get_name (self->app)); + + /* Translators: The placeholder is an application name */ + description = g_strdup_printf (_("%s is designed, developed, and translated by an " + "international community of volunteers." + "\n\n" + "This means that while it’s not yet available in " + "your language, you can get involved and help " + "translate it yourself."), gs_app_get_name (self->app)); + + gtk_label_set_text (self->title, title); + gtk_label_set_text (self->description, description); +} + +static void +app_notify_cb (GObject *obj, + GParamSpec *pspec, + gpointer user_data) +{ + GsAppTranslationDialog *self = GS_APP_TRANSLATION_DIALOG (user_data); + + update_labels (self); +} + +static const gchar * +get_url_for_app (GsApp *app) +{ + const gchar *url; + + /* Try the translate URL, or a fallback */ + url = gs_app_get_url (app, AS_URL_KIND_TRANSLATE); +#if AS_CHECK_VERSION(0, 15, 3) + if (url == NULL) + url = gs_app_get_url (app, AS_URL_KIND_CONTRIBUTE); +#endif + if (url == NULL) + url = gs_app_get_url (app, AS_URL_KIND_BUGTRACKER); + + return url; +} + +static void +button_clicked_cb (GtkButton *button, + gpointer user_data) +{ + GsAppTranslationDialog *self = GS_APP_TRANSLATION_DIALOG (user_data); + const gchar *url = get_url_for_app (self->app); + + gtk_show_uri (GTK_WINDOW (self), url, GDK_CURRENT_TIME); +} + +static void +gs_app_translation_dialog_init (GsAppTranslationDialog *self) +{ + gtk_widget_init_template (GTK_WIDGET (self)); +} + +static void +gs_app_translation_dialog_get_property (GObject *object, + guint prop_id, + GValue *value, + GParamSpec *pspec) +{ + GsAppTranslationDialog *self = GS_APP_TRANSLATION_DIALOG (object); + + switch ((GsAppTranslationDialogProperty) prop_id) { + case PROP_APP: + g_value_set_object (value, gs_app_translation_dialog_get_app (self)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_app_translation_dialog_set_property (GObject *object, + guint prop_id, + const GValue *value, + GParamSpec *pspec) +{ + GsAppTranslationDialog *self = GS_APP_TRANSLATION_DIALOG (object); + + switch ((GsAppTranslationDialogProperty) prop_id) { + case PROP_APP: + /* Construct only */ + g_assert (self->app == NULL); + g_assert (self->app_notify_name_handler == 0); + + self->app = g_value_dup_object (value); + self->app_notify_name_handler = g_signal_connect (self->app, "notify::name", G_CALLBACK (app_notify_cb), self); + + /* Update the UI. */ + update_labels (self); + + g_object_notify_by_pspec (G_OBJECT (self), obj_props[PROP_APP]); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_app_translation_dialog_dispose (GObject *object) +{ + GsAppTranslationDialog *self = GS_APP_TRANSLATION_DIALOG (object); + + g_clear_signal_handler (&self->app_notify_name_handler, self->app); + g_clear_object (&self->app); + + G_OBJECT_CLASS (gs_app_translation_dialog_parent_class)->dispose (object); +} + +static void +gs_app_translation_dialog_class_init (GsAppTranslationDialogClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + + object_class->get_property = gs_app_translation_dialog_get_property; + object_class->set_property = gs_app_translation_dialog_set_property; + object_class->dispose = gs_app_translation_dialog_dispose; + + /** + * GsAppTranslationDialog:app: (not nullable) + * + * The app to display the translation details for. + * + * This must not be %NULL. + * + * Since: 41 + */ + obj_props[PROP_APP] = + g_param_spec_object ("app", NULL, NULL, + GS_TYPE_APP, + G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS); + + g_object_class_install_properties (object_class, G_N_ELEMENTS (obj_props), obj_props); + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-app-translation-dialog.ui"); + + gtk_widget_class_bind_template_child (widget_class, GsAppTranslationDialog, title); + gtk_widget_class_bind_template_child (widget_class, GsAppTranslationDialog, description); + + gtk_widget_class_bind_template_callback (widget_class, button_clicked_cb); +} + +/** + * gs_app_translation_dialog_new: + * @app: (not nullable): the app to display translation information for + * + * Create a new #GsAppTranslationDialog and set its initial app to @app. + * + * Returns: (transfer full): a new #GsAppTranslationDialog + * Since: 41 + */ +GsAppTranslationDialog * +gs_app_translation_dialog_new (GsApp *app) +{ + g_return_val_if_fail (GS_IS_APP (app), NULL); + + return g_object_new (GS_TYPE_APP_TRANSLATION_DIALOG, + "app", app, + NULL); +} + +/** + * gs_app_translation_dialog_get_app: + * @self: a #GsAppTranslationDialog + * + * Gets the value of #GsAppTranslationDialog:app. + * + * Returns: (not nullable) (transfer none): app whose translation information is + * being displayed + * Since: 41 + */ +GsApp * +gs_app_translation_dialog_get_app (GsAppTranslationDialog *self) +{ + g_return_val_if_fail (GS_IS_APP_TRANSLATION_DIALOG (self), NULL); + + return self->app; +} + +/** + * gs_app_translation_dialog_app_has_url: + * @app: a #GsApp + * + * Check @app to see if it has appropriate URLs set on it to allow the user + * to be linked to a page relevant to translating the app. + * + * Generally this should be used to work out whether to show a + * #GsAppTranslationDialog dialog for a given @app. + * + * Returns: %TRUE if an URL exists, %FALSE otherwise + * Since: 41 + */ +gboolean +gs_app_translation_dialog_app_has_url (GsApp *app) +{ + g_return_val_if_fail (GS_IS_APP (app), FALSE); + + return (get_url_for_app (app) != NULL); +} diff --git a/src/gs-app-translation-dialog.h b/src/gs-app-translation-dialog.h new file mode 100644 index 0000000..2c7353e --- /dev/null +++ b/src/gs-app-translation-dialog.h @@ -0,0 +1,32 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2021 Endless OS Foundation LLC + * + * Author: Philip Withnall <pwithnall@endlessos.org> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <glib.h> +#include <glib-object.h> +#include <gtk/gtk.h> + +#include "gs-app.h" +#include "gs-info-window.h" + +G_BEGIN_DECLS + +#define GS_TYPE_APP_TRANSLATION_DIALOG (gs_app_translation_dialog_get_type ()) + +G_DECLARE_FINAL_TYPE (GsAppTranslationDialog, gs_app_translation_dialog, GS, APP_TRANSLATION_DIALOG, GsInfoWindow) + +GsAppTranslationDialog *gs_app_translation_dialog_new (GsApp *app); + +GsApp *gs_app_translation_dialog_get_app (GsAppTranslationDialog *self); + +gboolean gs_app_translation_dialog_app_has_url (GsApp *app); + +G_END_DECLS diff --git a/src/gs-app-translation-dialog.ui b/src/gs-app-translation-dialog.ui new file mode 100644 index 0000000..4b5b41f --- /dev/null +++ b/src/gs-app-translation-dialog.ui @@ -0,0 +1,113 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <requires lib="gtk+" version="3.10"/> + <template class="GsAppTranslationDialog" parent="GsInfoWindow"> + <property name="title" translatable="yes">Translations</property> + <property name="default-width">480</property> + <property name="default-height">375</property> + <child> + <object class="AdwPreferencesPage"> + <child> + <object class="AdwPreferencesGroup"> + + <child> + <object class="GtkBox"> + <property name="margin-start">24</property> + <property name="margin-end">24</property> + <property name="orientation">vertical</property> + <property name="spacing">8</property> + + <child> + <object class="GtkBox"> + <property name="margin-top">20</property> + <property name="margin-bottom">20</property> + <property name="margin-start">20</property> + <property name="margin-end">20</property> + <property name="orientation">vertical</property> + <property name="spacing">12</property> + + <child> + <object class="GsLozenge" id="lozenge"> + <property name="circular">True</property> + <property name="icon-name">flag-outline-thin-symbolic</property> + <property name="pixel-size">24</property> + <style> + <class name="large"/> + <class name="blue"/> + </style> + <accessibility> + <relation name="labelled-by">title</relation> + </accessibility> + </object> + </child> + + <child> + <object class="GtkLabel" id="title"> + <property name="justify">center</property> + <!-- this is a placeholder: the text is actually set in code --> + <property name="label">Help Translate Shortwave</property> + <property name="wrap">True</property> + <property name="xalign">0.5</property> + <style> + <class name="title-2"/> + </style> + </object> + </child> + </object> + </child> + + <child> + <object class="GtkLabel" id="description"> + <property name="justify">center</property> + <!-- This is a placeholder: the actual label is set in code --> + <property name="label">Shortwave is designed, developed, and translated by an international community of volunteers.\n\nThis means that while it’s not yet available in your language, you can get involved and help translate it yourself.</property> + <property name="wrap">True</property> + <property name="xalign">0.5</property> + </object> + </child> + + <child> + <object class="GtkButton"> + <property name="halign">center</property> + <property name="margin-top">14</property> + <property name="margin-bottom">14</property> + <property name="margin-start">14</property> + <property name="margin-end">14</property> + <signal name="clicked" handler="button_clicked_cb"/> + <style> + <class name="suggested-action"/> + <class name="pill"/> + </style> + <child> + <object class="GtkBox"> + <property name="orientation">horizontal</property> + <property name="spacing">6</property> + <property name="halign">center</property> + <property name="margin-start">12</property> + <property name="margin-end">12</property> + <child> + <object class="GtkLabel"> + <property name="xalign">0</property> + <property name="yalign">0.5</property> + <property name="label" translatable="yes">_Translation Website</property> + <property name="use-underline">True</property> + </object> + </child> + <child> + <object class="GtkImage"> + <property name="icon-name">external-link-symbolic</property> + </object> + </child> + + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + </template> +</interface> diff --git a/src/gs-app-version-history-dialog.c b/src/gs-app-version-history-dialog.c new file mode 100644 index 0000000..e6e04a9 --- /dev/null +++ b/src/gs-app-version-history-dialog.c @@ -0,0 +1,93 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2021 Matthew Leeds <mwleeds@endlessos.org> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include "gs-app-version-history-dialog.h" + +#include "gnome-software-private.h" +#include "gs-common.h" +#include "gs-app-version-history-row.h" +#include <glib/gi18n.h> + +struct _GsAppVersionHistoryDialog +{ + GtkDialog parent_instance; + GsApp *app; + GtkWidget *listbox; +}; + +G_DEFINE_TYPE (GsAppVersionHistoryDialog, gs_app_version_history_dialog, GTK_TYPE_DIALOG) + +static void +populate_version_history (GsAppVersionHistoryDialog *dialog, + GsApp *app) +{ + g_autoptr(GPtrArray) version_history = NULL; + + /* remove previous */ + gs_widget_remove_all (dialog->listbox, (GsRemoveFunc) gtk_list_box_remove); + + version_history = gs_app_get_version_history (app); + if (version_history == NULL || version_history->len == 0) { + GtkWidget *row; + row = gs_app_version_history_row_new (); + gs_app_version_history_row_set_info (GS_APP_VERSION_HISTORY_ROW (row), + gs_app_get_version (app), + gs_app_get_release_date (app), NULL); + gtk_list_box_append (GTK_LIST_BOX (dialog->listbox), row); + gtk_widget_show (row); + return; + } + + /* add each */ + for (guint i = 0; i < version_history->len; i++) { + GtkWidget *row; + AsRelease *version = g_ptr_array_index (version_history, i); + + row = gs_app_version_history_row_new (); + gs_app_version_history_row_set_info (GS_APP_VERSION_HISTORY_ROW (row), + as_release_get_version (version), + as_release_get_timestamp (version), + as_release_get_description (version)); + + gtk_list_box_append (GTK_LIST_BOX (dialog->listbox), row); + gtk_widget_show (row); + } +} + +static void +gs_app_version_history_dialog_init (GsAppVersionHistoryDialog *dialog) +{ + gtk_widget_init_template (GTK_WIDGET (dialog)); +} + +static void +gs_app_version_history_dialog_class_init (GsAppVersionHistoryDialogClass *klass) +{ + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-app-version-history-dialog.ui"); + + gtk_widget_class_bind_template_child (widget_class, GsAppVersionHistoryDialog, listbox); +} + +GtkWidget * +gs_app_version_history_dialog_new (GtkWindow *parent, GsApp *app) +{ + GsAppVersionHistoryDialog *dialog; + + dialog = g_object_new (GS_TYPE_APP_VERSION_HISTORY_DIALOG, + "use-header-bar", TRUE, + "transient-for", parent, + "modal", TRUE, + NULL); + populate_version_history (dialog, app); + + return GTK_WIDGET (dialog); +} diff --git a/src/gs-app-version-history-dialog.h b/src/gs-app-version-history-dialog.h new file mode 100644 index 0000000..6abed18 --- /dev/null +++ b/src/gs-app-version-history-dialog.h @@ -0,0 +1,24 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2021 Matthew Leeds <mwleeds@endlessos.org> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <gtk/gtk.h> + +#include "gnome-software-private.h" + +G_BEGIN_DECLS + +#define GS_TYPE_APP_VERSION_HISTORY_DIALOG (gs_app_version_history_dialog_get_type ()) + +G_DECLARE_FINAL_TYPE (GsAppVersionHistoryDialog, gs_app_version_history_dialog, GS, APP_VERSION_HISTORY_DIALOG, GtkDialog) + +GtkWidget *gs_app_version_history_dialog_new (GtkWindow *parent, + GsApp *app); + +G_END_DECLS diff --git a/src/gs-app-version-history-dialog.ui b/src/gs-app-version-history-dialog.ui new file mode 100644 index 0000000..f673c78 --- /dev/null +++ b/src/gs-app-version-history-dialog.ui @@ -0,0 +1,44 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <requires lib="gtk+" version="3.10"/> + <requires lib="handy" version="1.0"/> + <template class="GsAppVersionHistoryDialog" parent="GtkDialog"> + <property name="title" translatable="yes">Version History</property> + <property name="default_width">550</property> + <property name="default_height">600</property> + <property name="width_request">360</property> + <property name="height_request">400</property> + <property name="use_header_bar">1</property> + <child internal-child="headerbar"> + <object class="AdwHeaderBar"/> + </child> + <child internal-child="content_area"> + <object class="GtkBox"> + <property name="orientation">vertical</property> + <child> + <object class="GtkScrolledWindow"> + <child> + <object class="AdwClamp"> + <property name="vexpand">True</property> + <property name="hexpand">False</property> + <property name="margin-start">12</property> + <property name="margin-end">12</property> + <property name="margin-bottom">18</property> + <property name="margin-top">18</property> + <child> + <object class="GtkListBox" id="listbox"> + <property name="selection-mode">none</property> + <property name="valign">start</property> + <style> + <class name="boxed-list"/> + </style> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + </template> +</interface> diff --git a/src/gs-app-version-history-row.c b/src/gs-app-version-history-row.c new file mode 100644 index 0000000..0c0dd9e --- /dev/null +++ b/src/gs-app-version-history-row.c @@ -0,0 +1,115 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2021 Matthew Leeds <mwleeds@endlessos.org> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include <glib/gi18n.h> + +#include "gs-app-version-history-row.h" + +#include "gs-common.h" + +struct _GsAppVersionHistoryRow +{ + GtkListBoxRow parent_instance; + + GtkWidget *version_number_label; + GtkWidget *version_date_label; + GtkWidget *version_description_label; +}; + +G_DEFINE_TYPE (GsAppVersionHistoryRow, gs_app_version_history_row, GTK_TYPE_LIST_BOX_ROW) + +static void +gs_app_version_history_row_class_init (GsAppVersionHistoryRowClass *klass) +{ + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-app-version-history-row.ui"); + + gtk_widget_class_bind_template_child (widget_class, GsAppVersionHistoryRow, version_number_label); + gtk_widget_class_bind_template_child (widget_class, GsAppVersionHistoryRow, version_date_label); + gtk_widget_class_bind_template_child (widget_class, GsAppVersionHistoryRow, version_description_label); +} + +static void +gs_app_version_history_row_init (GsAppVersionHistoryRow *row) +{ + gtk_widget_init_template (GTK_WIDGET (row)); +} + +/** + * gs_app_version_history_row_set_info: + * @row: a #GsAppVersionHistoryRow + * @version_number: (nullable): version number of the release, or %NULL if unknown + * @version_date: release date of the version, as seconds since the Unix epoch, + * or `0` if unknown + * @version_description: (nullable): Pango Markup for the full human readable + * description of the release, or %NULL if unknown + * + * Set information about the release represented by this version history row. + */ +void +gs_app_version_history_row_set_info (GsAppVersionHistoryRow *row, + const char *version_number, + guint64 version_date, + const char *version_description) +{ + g_autofree char *version_date_string = NULL; + g_autofree char *version_date_string_tooltip = NULL; + + if (version_number == NULL || *version_number == '\0') + return; + + if (version_description != NULL && *version_description != '\0') { + g_autofree char *version_tmp = NULL; + version_tmp = g_strdup_printf (_("New in Version %s"), version_number); + gtk_label_set_label (GTK_LABEL (row->version_number_label), version_tmp); + gtk_label_set_markup (GTK_LABEL (row->version_description_label), version_description); + gtk_style_context_remove_class (gtk_widget_get_style_context (row->version_description_label), "dim-label"); + } else { + g_autofree char *version_tmp = NULL; + const gchar *version_description_fallback; + version_tmp = g_strdup_printf (_("Version %s"), version_number); + gtk_label_set_label (GTK_LABEL (row->version_number_label), version_tmp); + version_description_fallback = _("No details for this release"); + gtk_label_set_label (GTK_LABEL (row->version_description_label), version_description_fallback); + gtk_style_context_add_class (gtk_widget_get_style_context (row->version_description_label), "dim-label"); + } + + if (version_date != 0) { + g_autoptr(GDateTime) date_time = NULL; + const gchar *format_string; + + /* this is the date in the form of "x weeks ago" or "y months ago" */ + version_date_string = gs_utils_time_to_string ((gint64) version_date); + + /* TRANSLATORS: This is the date string with: day number, month name, year. + i.e. "25 May 2012" */ + format_string = _("%e %B %Y"); + date_time = g_date_time_new_from_unix_local (version_date); + version_date_string_tooltip = g_date_time_format (date_time, format_string); + } + + if (version_date_string == NULL) + gtk_widget_set_visible (row->version_date_label, FALSE); + else + gtk_label_set_label (GTK_LABEL (row->version_date_label), version_date_string); + + if (version_date_string_tooltip != NULL) + gtk_widget_set_tooltip_text (row->version_date_label, version_date_string_tooltip); +} + +GtkWidget * +gs_app_version_history_row_new (void) +{ + GsAppVersionHistoryRow *row; + + row = g_object_new (GS_TYPE_APP_VERSION_HISTORY_ROW, NULL); + return GTK_WIDGET (row); +} diff --git a/src/gs-app-version-history-row.h b/src/gs-app-version-history-row.h new file mode 100644 index 0000000..0e91ac6 --- /dev/null +++ b/src/gs-app-version-history-row.h @@ -0,0 +1,27 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2021 Matthew Leeds <mwleeds@endlessos.org> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <gtk/gtk.h> + +#include "gnome-software-private.h" + +G_BEGIN_DECLS + +#define GS_TYPE_APP_VERSION_HISTORY_ROW (gs_app_version_history_row_get_type ()) + +G_DECLARE_FINAL_TYPE (GsAppVersionHistoryRow, gs_app_version_history_row, GS, APP_VERSION_HISTORY_ROW, GtkListBoxRow) + +GtkWidget *gs_app_version_history_row_new (void); +void gs_app_version_history_row_set_info (GsAppVersionHistoryRow *row, + const char *version_number, + guint64 version_date, + const char *version_description); + +G_END_DECLS diff --git a/src/gs-app-version-history-row.ui b/src/gs-app-version-history-row.ui new file mode 100644 index 0000000..e6d08ec --- /dev/null +++ b/src/gs-app-version-history-row.ui @@ -0,0 +1,55 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <!-- interface-requires gtk+ 3.10 --> + <template class="GsAppVersionHistoryRow" parent="GtkListBoxRow"> + <property name="selectable">False</property> + <property name="activatable">False</property> + <property name="focusable">False</property> + <child> + <object class="GtkBox"> + <property name="margin_start">15</property> + <property name="margin_top">15</property> + <property name="margin_bottom">15</property> + <property name="margin_end">15</property> + <property name="orientation">vertical</property> + <property name="valign">start</property> + <property name="hexpand">True</property> + <child> + <object class="GtkBox"> + <property name="margin_top">3</property> + <property name="margin_bottom">3</property> + <property name="orientation">horizontal</property> + <child> + <object class="GtkLabel" id="version_number_label"> + <property name="hexpand">True</property> + <property name="xalign">0</property> + <property name="yalign">0.5</property> + <attributes> + <attribute name="weight" value="bold"/> + </attributes> + </object> + </child> + <child> + <object class="GtkLabel" id="version_date_label"> + <property name="xalign">0</property> + <property name="yalign">0.5</property> + <style> + <class name="dim-label"/> + </style> + </object> + </child> + </object> + </child> + <child> + <object class="GtkLabel" id="version_description_label"> + <property name="margin_top">6</property> + <property name="wrap">True</property> + <property name="xalign">0</property> + <property name="yalign">0.5</property> + <property name="vexpand">True</property> + </object> + </child> + </object> + </child> + </template> +</interface> diff --git a/src/gs-application.c b/src/gs-application.c new file mode 100644 index 0000000..961c400 --- /dev/null +++ b/src/gs-application.c @@ -0,0 +1,1525 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013 Matthias Clasen <mclasen@redhat.com> + * Copyright (C) 2013-2018 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2014-2018 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include "gs-application.h" + +#include <stdlib.h> +#include <glib/gi18n.h> +#include <gio/gio.h> +#include <gio/gdesktopappinfo.h> + +#ifdef HAVE_PACKAGEKIT +#include "gs-dbus-helper.h" +#endif + +#include "gs-build-ident.h" +#include "gs-common.h" +#include "gs-debug.h" +#include "gs-shell.h" +#include "gs-update-monitor.h" +#include "gs-shell-search-provider.h" + +#define ENABLE_REPOS_DIALOG_CONF_KEY "enable-repos-dialog" + +struct _GsApplication { + AdwApplication parent; + GCancellable *cancellable; + GsPluginLoader *plugin_loader; + gint pending_apps; + GtkWindow *main_window; + GsShell *shell; + GsUpdateMonitor *update_monitor; +#ifdef HAVE_PACKAGEKIT + GsDbusHelper *dbus_helper; +#endif + GsShellSearchProvider *search_provider; /* (nullable) (owned) */ + GSettings *settings; + GSimpleActionGroup *action_map; + guint shell_loaded_handler_id; + GsDebug *debug; /* (owned) (not nullable) */ + + /* Created/freed on demand */ + GHashTable *withdraw_notifications; /* gchar *notification_id ~> GUINT_TO_POINTER (timeout_id) */ +}; + +G_DEFINE_TYPE (GsApplication, gs_application, ADW_TYPE_APPLICATION); + +typedef enum { + PROP_DEBUG = 1, +} GsApplicationProperty; + +static GParamSpec *obj_props[PROP_DEBUG + 1] = { NULL, }; + +enum { + INSTALL_RESOURCES_DONE, + REPOSITORY_CHANGED, + LAST_SIGNAL +}; + +static guint signals[LAST_SIGNAL]; + +static const char * +get_version (void) +{ + if (g_strcmp0 (BUILD_TYPE, "release") == 0) + return VERSION; + else + return GS_BUILD_IDENTIFIER; +} + +typedef struct { + GsApplication *app; + GSimpleAction *action; + GVariant *action_param; /* (nullable) */ +} GsActivationHelper; + +static GsActivationHelper * +gs_activation_helper_new (GsApplication *app, + GSimpleAction *action, + GVariant *parameter) +{ + GsActivationHelper *helper = g_slice_new0 (GsActivationHelper); + helper->app = app; + helper->action = G_SIMPLE_ACTION (action); + helper->action_param = (parameter != NULL) ? g_variant_ref_sink (parameter) : NULL; + + return helper; +} + +static void +gs_activation_helper_free (GsActivationHelper *helper) +{ + g_clear_pointer (&helper->action_param, g_variant_unref); + g_slice_free (GsActivationHelper, helper); +} + +gboolean +gs_application_has_active_window (GsApplication *application) +{ + GList *windows; + + windows = gtk_application_get_windows (GTK_APPLICATION (application)); + for (GList *l = windows; l != NULL; l = l->next) { + if (gtk_window_is_active (GTK_WINDOW (l->data))) + return TRUE; + } + return FALSE; +} + +static void +gs_application_init (GsApplication *application) +{ + const GOptionEntry options[] = { + { "mode", '\0', 0, G_OPTION_ARG_STRING, NULL, + /* TRANSLATORS: this is a command line option */ + _("Start up mode: either ‘updates’, ‘updated’, ‘installed’ or ‘overview’"), _("MODE") }, + { "search", '\0', 0, G_OPTION_ARG_STRING, NULL, + _("Search for applications"), _("SEARCH") }, + { "details", '\0', 0, G_OPTION_ARG_STRING, NULL, + _("Show application details (using application ID)"), _("ID") }, + { "details-pkg", '\0', 0, G_OPTION_ARG_STRING, NULL, + _("Show application details (using package name)"), _("PKGNAME") }, + { "install", '\0', 0, G_OPTION_ARG_STRING, NULL, + _("Install the application (using application ID)"), _("ID") }, + { "uninstall", '\0', 0, G_OPTION_ARG_STRING, NULL, + _("Uninstall the application (using application ID)"), _("ID") }, + { "local-filename", '\0', 0, G_OPTION_ARG_FILENAME, NULL, + _("Open a local package file"), _("FILENAME") }, + { "interaction", '\0', 0, G_OPTION_ARG_STRING, NULL, + _("The kind of interaction expected for this action: either " + "‘none’, ‘notify’, or ‘full’"), NULL }, + { "show-metainfo", '\0', 0, G_OPTION_ARG_FILENAME, NULL, + _("Show a local metainfo or appdata file"), _("FILENAME") }, + { "verbose", '\0', 0, G_OPTION_ARG_NONE, NULL, + _("Show verbose debugging information"), NULL }, + { "autoupdate", 0, 0, G_OPTION_ARG_NONE, NULL, + _("Installs any pending updates in the background"), NULL }, + { "prefs", 0, 0, G_OPTION_ARG_NONE, NULL, + _("Show update preferences"), NULL }, + { "quit", 0, 0, G_OPTION_ARG_NONE, NULL, + _("Quit the running instance"), NULL }, + { "prefer-local", '\0', 0, G_OPTION_ARG_NONE, NULL, + _("Prefer local file sources to AppStream"), NULL }, + { "version", 0, 0, G_OPTION_ARG_NONE, NULL, + _("Show version number"), NULL }, + { NULL } + }; + + g_application_add_main_option_entries (G_APPLICATION (application), options); +} + +static gboolean +gs_application_dbus_register (GApplication *application, + GDBusConnection *connection, + const gchar *object_path, + GError **error) +{ + GsApplication *app = GS_APPLICATION (application); + app->search_provider = gs_shell_search_provider_new (); + return gs_shell_search_provider_register (app->search_provider, connection, error); +} + +static void +gs_application_dbus_unregister (GApplication *application, + GDBusConnection *connection, + const gchar *object_path) +{ + GsApplication *app = GS_APPLICATION (application); + + if (app->search_provider != NULL) + gs_shell_search_provider_unregister (app->search_provider); +} + +static void +gs_application_shutdown (GApplication *application) +{ + GsApplication *app = GS_APPLICATION (application); + + g_cancellable_cancel (app->cancellable); + g_clear_object (&app->cancellable); + + g_clear_object (&app->shell); + + G_APPLICATION_CLASS (gs_application_parent_class)->shutdown (application); +} + +static void +gs_application_shell_loaded_cb (GsShell *shell, GsApplication *app) +{ + g_signal_handler_disconnect (app->shell, app->shell_loaded_handler_id); + app->shell_loaded_handler_id = 0; +} + +static void +gs_application_present_window (GsApplication *app, const gchar *startup_id) +{ + GList *windows; + GtkWindow *window; + + windows = gtk_application_get_windows (GTK_APPLICATION (app)); + if (windows) { + window = windows->data; + + if (startup_id != NULL) + gtk_window_set_startup_id (window, startup_id); + gtk_window_present (window); + } +} + +static void +sources_activated (GSimpleAction *action, + GVariant *parameter, + gpointer app) +{ + gs_shell_show_sources (GS_APPLICATION (app)->shell); +} + +static void +prefs_activated (GSimpleAction *action, GVariant *parameter, gpointer app) +{ + gs_shell_show_prefs (GS_APPLICATION (app)->shell); +} + +static void +about_activated (GSimpleAction *action, + GVariant *parameter, + gpointer user_data) +{ + GsApplication *app = GS_APPLICATION (user_data); + const gchar *developers[] = { + "Richard Hughes", + "Matthias Clasen", + "Kalev Lember", + "Allan Day", + "Ryan Lerch", + "William Jon McCann", + "Milan Crha", + "Joaquim Rocha", + "Robert Ancell", + "Philip Withnall", + NULL + }; + +#if ADW_CHECK_VERSION(1,2,0) + adw_show_about_window (app->main_window, + "application-name", g_get_application_name (), + "application-icon", APPLICATION_ID, + "developer-name", _("The GNOME Project"), + "version", get_version(), + "website", "https://wiki.gnome.org/Apps/Software", + "issue-url", "https://gitlab.gnome.org/GNOME/gnome-software/-/issues/new", + "developers", developers, + "copyright", _("Copyright \xc2\xa9 2016–2022 GNOME Software contributors"), + "license-type", GTK_LICENSE_GPL_2_0, + "translator-credits", _("translator-credits"), + NULL); +#else + GtkAboutDialog *dialog; + dialog = GTK_ABOUT_DIALOG (gtk_about_dialog_new ()); + gtk_about_dialog_set_authors (dialog, developers); + gtk_about_dialog_set_copyright (dialog, _("Copyright \xc2\xa9 2016–2022 GNOME Software contributors")); + gtk_about_dialog_set_license_type (dialog, GTK_LICENSE_GPL_2_0); + gtk_about_dialog_set_logo_icon_name (dialog, APPLICATION_ID); + gtk_about_dialog_set_translator_credits (dialog, _("translator-credits")); + gtk_about_dialog_set_version (dialog, get_version ()); + gtk_about_dialog_set_program_name (dialog, g_get_application_name ()); + + /* TRANSLATORS: this is the title of the about window */ + gtk_window_set_title (GTK_WINDOW (dialog), _("About Software")); + + /* TRANSLATORS: well, we seem to think so, anyway */ + gtk_about_dialog_set_comments (dialog, _("A nice way to manage the " + "software on your system.")); + + gs_shell_modal_dialog_present (app->shell, GTK_WINDOW (dialog)); +#endif +} + +static void +cancel_trigger_failed_cb (GObject *source, GAsyncResult *res, gpointer user_data) +{ + GsApplication *app = GS_APPLICATION (user_data); + g_autoptr(GError) error = NULL; + if (!gs_plugin_loader_job_action_finish (app->plugin_loader, res, &error)) { + g_warning ("failed to cancel trigger: %s", error->message); + return; + } +} + +static void +reboot_failed_cb (GObject *source, GAsyncResult *res, gpointer user_data) +{ + GsApplication *app = GS_APPLICATION (user_data); + g_autoptr(GError) error = NULL; + g_autoptr(GsPluginJob) plugin_job = NULL; + + /* get result */ + if (gs_utils_invoke_reboot_finish (source, res, &error)) + return; + + if (g_error_matches (error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) + g_debug ("Calling reboot had been cancelled"); + else if (error != NULL) + g_warning ("Calling reboot failed: %s", error->message); + + /* cancel trigger */ + plugin_job = gs_plugin_job_newv (GS_PLUGIN_ACTION_UPDATE_CANCEL, NULL); + gs_plugin_loader_job_process_async (app->plugin_loader, plugin_job, + app->cancellable, + cancel_trigger_failed_cb, + app); +} + +static void +reboot_activated (GSimpleAction *action, + GVariant *parameter, + gpointer data) +{ + gs_utils_invoke_reboot_async (NULL, NULL, NULL); +} + +static void +shutdown_activated (GSimpleAction *action, + GVariant *parameter, + gpointer data) +{ + GsApplication *app = GS_APPLICATION (data); + g_application_quit (G_APPLICATION (app)); +} + +static void offline_update_cb (GsPluginLoader *plugin_loader, + GAsyncResult *res, + GsApplication *app); + +static void +reboot_and_install (GSimpleAction *action, + GVariant *parameter, + gpointer data) +{ + GsApplication *app = GS_APPLICATION (data); + g_autoptr(GsPluginJob) plugin_job = NULL; + + plugin_job = gs_plugin_job_newv (GS_PLUGIN_ACTION_UPDATE, NULL); + gs_plugin_loader_job_process_async (app->plugin_loader, plugin_job, + app->cancellable, + (GAsyncReadyCallback) offline_update_cb, + app); +} + +static void +offline_update_cb (GsPluginLoader *plugin_loader, + GAsyncResult *res, + GsApplication *app) +{ + g_autoptr(GError) error = NULL; + if (!gs_plugin_loader_job_action_finish (plugin_loader, res, &error)) { + g_warning ("Failed to trigger offline update: %s", error->message); + return; + } + + gs_utils_invoke_reboot_async (NULL, reboot_failed_cb, app); +} + +static void +quit_activated (GSimpleAction *action, + GVariant *parameter, + gpointer app) +{ + GApplicationFlags flags; + GList *windows; + GtkWidget *window; + + flags = g_application_get_flags (app); + + if (flags & G_APPLICATION_IS_SERVICE) { + windows = gtk_application_get_windows (GTK_APPLICATION (app)); + if (windows) { + window = windows->data; + gtk_widget_hide (window); + } + + return; + } + + g_application_quit (G_APPLICATION (app)); +} + +static void +activate_on_shell_loaded_cb (GsActivationHelper *helper) +{ + GsApplication *app = helper->app; + + g_action_activate (G_ACTION (helper->action), helper->action_param); + + g_signal_handlers_disconnect_by_data (app->shell, helper); + gs_activation_helper_free (helper); +} + +static void +set_mode_activated (GSimpleAction *action, + GVariant *parameter, + gpointer data) +{ + GsApplication *app = GS_APPLICATION (data); + const gchar *mode; + + gs_application_present_window (app, NULL); + + gs_shell_reset_state (app->shell); + + mode = g_variant_get_string (parameter, NULL); + if (g_strcmp0 (mode, "updates") == 0) { + gs_shell_set_mode (app->shell, GS_SHELL_MODE_UPDATES); + } else if (g_strcmp0 (mode, "installed") == 0) { + gs_shell_set_mode (app->shell, GS_SHELL_MODE_INSTALLED); + } else if (g_strcmp0 (mode, "moderate") == 0) { + gs_shell_set_mode (app->shell, GS_SHELL_MODE_MODERATE); + } else if (g_strcmp0 (mode, "overview") == 0) { + gs_shell_set_mode (app->shell, GS_SHELL_MODE_OVERVIEW); + } else if (g_strcmp0 (mode, "updated") == 0) { + gs_shell_set_mode (app->shell, GS_SHELL_MODE_UPDATES); + gs_shell_show_installed_updates (app->shell); + } else { + g_warning ("Mode '%s' not recognised", mode); + } +} + +static void +search_activated (GSimpleAction *action, + GVariant *parameter, + gpointer data) +{ + GsApplication *app = GS_APPLICATION (data); + const gchar *search; + + gs_application_present_window (app, NULL); + + search = g_variant_get_string (parameter, NULL); + gs_shell_reset_state (app->shell); + gs_shell_show_search (app->shell, search); +} + +static void +_search_launchable_details_cb (GObject *source, GAsyncResult *res, gpointer user_data) +{ + GsApp *a; + GsApplication *app = GS_APPLICATION (user_data); + g_autoptr(GError) error = NULL; + g_autoptr(GsAppList) list = NULL; + + list = gs_plugin_loader_job_process_finish (app->plugin_loader, res, &error); + if (list == NULL) { + g_warning ("failed to find application: %s", error->message); + return; + } + if (gs_app_list_length (list) == 0) { + gs_shell_set_mode (app->shell, GS_SHELL_MODE_OVERVIEW); + gs_shell_show_notification (app->shell, + /* TRANSLATORS: we tried to show an app that did not exist */ + _("Sorry! There are no details for that application.")); + return; + } + a = gs_app_list_index (list, 0); + gs_shell_reset_state (app->shell); + gs_shell_show_app (app->shell, a); +} + +static void +gs_application_app_to_show_created_cb (GObject *source_object, + GAsyncResult *result, + gpointer user_data) +{ + GsApplication *gs_app = user_data; + g_autoptr(GsApp) app = NULL; + g_autoptr(GError) error = NULL; + + app = gs_plugin_loader_app_create_finish (GS_PLUGIN_LOADER (source_object), result, &error); + if (app == NULL) { + if (!g_error_matches (error, G_IO_ERROR, G_IO_ERROR_CANCELLED) && + !g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_CANCELLED)) + g_warning ("Failed to create application: %s", error->message); + } else { + g_return_if_fail (GS_IS_APPLICATION (gs_app)); + + gs_shell_reset_state (gs_app->shell); + gs_shell_show_app (gs_app->shell, app); + } +} + +static void +details_activated (GSimpleAction *action, + GVariant *parameter, + gpointer data) +{ + GsApplication *app = GS_APPLICATION (data); + const gchar *id; + const gchar *search; + + gs_application_present_window (app, NULL); + + g_variant_get (parameter, "(&s&s)", &id, &search); + g_debug ("trying to activate %s:%s for details", id, search); + if (search != NULL && search[0] != '\0') { + gs_shell_reset_state (app->shell); + gs_shell_show_search_result (app->shell, id, search); + } else { + g_autofree gchar *data_id = NULL; + g_autoptr(GsPluginJob) plugin_job = NULL; + g_autoptr(GsAppQuery) query = NULL; + const gchar *keywords[] = { id, NULL }; + + data_id = gs_utils_unique_id_compat_convert (id); + if (data_id != NULL) { + gs_plugin_loader_app_create_async (app->plugin_loader, data_id, app->cancellable, + gs_application_app_to_show_created_cb, app); + return; + } + + /* find by launchable */ + query = gs_app_query_new ("keywords", keywords, + "refine-flags", GS_PLUGIN_REFINE_FLAGS_REQUIRE_ICON, + "dedupe-flags", GS_APP_LIST_FILTER_FLAG_PREFER_INSTALLED | + GS_APP_LIST_FILTER_FLAG_KEY_ID_PROVIDES, + "sort-func", gs_utils_app_sort_match_value, + NULL); + plugin_job = gs_plugin_job_list_apps_new (query, GS_PLUGIN_LIST_APPS_FLAGS_NONE); + gs_plugin_loader_job_process_async (app->plugin_loader, plugin_job, + app->cancellable, + _search_launchable_details_cb, + app); + } +} + +static void +details_pkg_activated (GSimpleAction *action, + GVariant *parameter, + gpointer data) +{ + GsApplication *app = GS_APPLICATION (data); + const gchar *name; + const gchar *plugin_name; + g_autoptr (GsApp) a = NULL; + + gs_application_present_window (app, NULL); + + g_variant_get (parameter, "(&s&s)", &name, &plugin_name); + a = gs_app_new (NULL); + gs_app_add_source (a, name); + if (strcmp (plugin_name, "") != 0) { + GsPlugin *plugin = gs_plugin_loader_find_plugin (app->plugin_loader, plugin_name); + gs_app_set_management_plugin (a, plugin); + } + + gs_shell_reset_state (app->shell); + gs_shell_show_app (app->shell, a); +} + +static void +details_url_activated (GSimpleAction *action, + GVariant *parameter, + gpointer data) +{ + GsApplication *app = GS_APPLICATION (data); + const gchar *url; + g_autoptr (GsApp) a = NULL; + + gs_application_present_window (app, NULL); + + g_variant_get (parameter, "(&s)", &url); + + /* this is only used as a wrapper to transport the URL to + * the gs_shell_change_mode() function -- not in the GsAppList */ + a = gs_app_new (NULL); + gs_app_set_metadata (a, "GnomeSoftware::from-url", url); + gs_shell_reset_state (app->shell); + gs_shell_show_app (app->shell, a); +} + +typedef struct { + GWeakRef gs_app_weakref; + gchar *data_id; + GsShellInteraction interaction; +} InstallActivatedHelper; + +static void +gs_application_app_to_install_created_cb (GObject *source_object, + GAsyncResult *result, + gpointer user_data) +{ + InstallActivatedHelper *helper = user_data; + g_autoptr(GsApp) app = NULL; + g_autoptr(GError) error = NULL; + + app = gs_plugin_loader_app_create_finish (GS_PLUGIN_LOADER (source_object), result, &error); + if (app == NULL) { + if (!g_error_matches (error, G_IO_ERROR, G_IO_ERROR_CANCELLED) && + !g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_CANCELLED)) + g_warning ("Failed to create application '%s': %s", helper->data_id, error->message); + } else { + g_autoptr(GsApplication) gs_app = NULL; + + gs_app = g_weak_ref_get (&helper->gs_app_weakref); + if (gs_app != NULL) { + gs_shell_reset_state (gs_app->shell); + gs_shell_install (gs_app->shell, app, helper->interaction); + } + } + + g_weak_ref_clear (&helper->gs_app_weakref); + g_free (helper->data_id); + g_slice_free (InstallActivatedHelper, helper); +} + +static void +install_activated (GSimpleAction *action, + GVariant *parameter, + gpointer data) +{ + GsApplication *app = GS_APPLICATION (data); + const gchar *id; + GsShellInteraction interaction; + InstallActivatedHelper *helper; + g_autoptr (GsApp) a = NULL; + g_autofree gchar *data_id = NULL; + + g_variant_get (parameter, "(&su)", &id, &interaction); + data_id = gs_utils_unique_id_compat_convert (id); + if (data_id == NULL) { + g_warning ("Need to use a valid unique-id: %s", id); + return; + } + + if (interaction == GS_SHELL_INTERACTION_FULL) + gs_application_present_window (app, NULL); + + helper = g_slice_new0 (InstallActivatedHelper); + g_weak_ref_init (&helper->gs_app_weakref, app); + helper->data_id = g_strdup (data_id); + helper->interaction = interaction; + + gs_plugin_loader_app_create_async (app->plugin_loader, data_id, app->cancellable, + gs_application_app_to_install_created_cb, helper); +} + +typedef struct { + GWeakRef gs_application_weakref; + gchar *data_id; +} UninstallActivatedHelper; + +static void +gs_application_app_to_uninstall_created_cb (GObject *source_object, + GAsyncResult *result, + gpointer user_data) +{ + UninstallActivatedHelper *helper = user_data; + g_autoptr(GsApp) app = NULL; + g_autoptr(GError) error = NULL; + g_autoptr (GsApplication) self = g_weak_ref_get (&helper->gs_application_weakref); + + app = gs_plugin_loader_app_create_finish (GS_PLUGIN_LOADER (source_object), result, &error); + + if (app == NULL) { + if (!g_error_matches (error, G_IO_ERROR, G_IO_ERROR_CANCELLED) && + !g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_CANCELLED)) { + g_warning ("Failed to create application '%s': %s", helper->data_id, error->message); + } + } else { + if (self != NULL) { + gs_shell_reset_state (self->shell); + gs_shell_uninstall (self->shell, app); + } + } + + g_weak_ref_clear (&helper->gs_application_weakref); + g_free (helper->data_id); +} + + +static void +uninstall_activated (GSimpleAction *action, + GVariant *parameter, + gpointer data) +{ + GsApplication *self = GS_APPLICATION (data); + const gchar *id; + g_autofree gchar *data_id = NULL; + UninstallActivatedHelper *helper; + + g_variant_get (parameter, "&s", &id); + + data_id = gs_utils_unique_id_compat_convert (id); + if (data_id == NULL) { + g_warning ("Need to use a valid unique-id: %s", id); + return; + } + + gs_application_present_window (self, NULL); + + helper = g_slice_new0 (UninstallActivatedHelper); + g_weak_ref_init (&helper->gs_application_weakref, self); + helper->data_id = g_strdup (data_id); + + gs_plugin_loader_app_create_async (self->plugin_loader, data_id, self->cancellable, + gs_application_app_to_uninstall_created_cb, helper); +} + +static GFile * +_copy_file_to_cache (GFile *file_src, GError **error) +{ + g_autoptr(GFile) file_dest = NULL; + g_autofree gchar *cache_dir = NULL; + g_autofree gchar *cache_fn = NULL; + g_autofree gchar *basename = NULL; + + /* get destination location */ + cache_dir = g_dir_make_tmp ("gnome-software-XXXXXX", error); + if (cache_dir == NULL) + return NULL; + basename = g_file_get_basename (file_src); + cache_fn = g_build_filename (cache_dir, basename, NULL); + + /* copy file to cache */ + file_dest = g_file_new_for_path (cache_fn); + if (!g_file_copy (file_src, file_dest, + G_FILE_COPY_OVERWRITE, + NULL, /* cancellable */ + NULL, NULL, /* progress */ + error)) { + return NULL; + } + return g_steal_pointer (&file_dest); +} + +static void +filename_activated (GSimpleAction *action, + GVariant *parameter, + gpointer data) +{ + GsApplication *app = GS_APPLICATION (data); + const gchar *filename; + g_autoptr(GFile) file = NULL; + + g_variant_get (parameter, "(&s)", &filename); + + /* this could go away at any moment, so make a local copy */ + if (g_str_has_prefix (filename, "/tmp") || + g_str_has_prefix (filename, "/var/tmp")) { + g_autoptr(GError) error = NULL; + g_autoptr(GFile) file_src = g_file_new_for_path (filename); + file = _copy_file_to_cache (file_src, &error); + if (file == NULL) { + g_warning ("failed to copy file, falling back to %s: %s", + filename, error->message); + file = g_file_new_for_path (filename); + } + } else { + file = g_file_new_for_path (filename); + } + gs_shell_reset_state (app->shell); + gs_shell_show_local_file (app->shell, file); +} + +static void +show_metainfo_activated (GSimpleAction *action, + GVariant *parameter, + gpointer data) +{ + GsApplication *app = GS_APPLICATION (data); + const gchar *filename; + g_autoptr(GFile) file = NULL; + + g_variant_get (parameter, "(^&ay)", &filename); + + file = g_file_new_for_path (filename); + + gs_shell_reset_state (app->shell); + gs_shell_show_metainfo (app->shell, file); +} + +static void +launch_activated (GSimpleAction *action, + GVariant *parameter, + gpointer data) +{ + GsApplication *self = GS_APPLICATION (data); + GsApp *app = NULL; + const gchar *id, *management_plugin_name; + g_autoptr(GsAppList) list = NULL; + g_autoptr(GsPluginJob) search_job = NULL; + g_autoptr(GsPluginJob) launch_job = NULL; + g_autoptr(GError) error = NULL; + guint ii, len; + GsPlugin *management_plugin; + g_autoptr(GsAppQuery) query = NULL; + const gchar *keywords[2] = { NULL, }; + + g_variant_get (parameter, "(&s&s)", &id, &management_plugin_name); + + keywords[0] = id; + query = gs_app_query_new ("keywords", keywords, + "refine-flags", GS_PLUGIN_REFINE_FLAGS_REQUIRE_DESCRIPTION | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_PERMISSIONS | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_RUNTIME, + "dedupe-flags", GS_PLUGIN_JOB_DEDUPE_FLAGS_DEFAULT, + "sort-func", gs_utils_app_sort_match_value, + NULL); + search_job = gs_plugin_job_list_apps_new (query, GS_PLUGIN_LIST_APPS_FLAGS_NONE); + list = gs_plugin_loader_job_process (self->plugin_loader, search_job, self->cancellable, &error); + if (!list) { + g_warning ("Failed to search for application '%s' (from '%s'): %s", id, management_plugin_name, error ? error->message : "Unknown error"); + return; + } + + management_plugin = gs_plugin_loader_find_plugin (self->plugin_loader, management_plugin_name); + + len = gs_app_list_length (list); + for (ii = 0; ii < len && !app; ii++) { + GsApp *list_app = gs_app_list_index (list, ii); + + if (gs_app_is_installed (list_app) && + gs_app_has_management_plugin (list_app, management_plugin)) + app = list_app; + } + + if (!app) { + g_warning ("Did not find application '%s' from '%s'", id, management_plugin_name); + return; + } + + launch_job = gs_plugin_job_newv (GS_PLUGIN_ACTION_LAUNCH, + "app", app, + NULL); + if (!gs_plugin_loader_job_action (self->plugin_loader, launch_job, self->cancellable, &error)) { + g_warning ("Failed to launch app: %s", error->message); + return; + } +} + +static void +show_offline_updates_error (GSimpleAction *action, + GVariant *parameter, + gpointer data) +{ + GsApplication *app = GS_APPLICATION (data); + + gs_application_present_window (app, NULL); + + gs_shell_reset_state (app->shell); + gs_shell_set_mode (app->shell, GS_SHELL_MODE_UPDATES); + gs_update_monitor_show_error (app->update_monitor, app->main_window); +} + +static void +autoupdate_activated (GSimpleAction *action, GVariant *parameter, gpointer data) +{ + GsApplication *app = GS_APPLICATION (data); + gs_shell_reset_state (app->shell); + gs_shell_set_mode (app->shell, GS_SHELL_MODE_UPDATES); + gs_update_monitor_autoupdate (app->update_monitor); +} + +static void +install_resources_activated (GSimpleAction *action, + GVariant *parameter, + gpointer data) +{ + GsApplication *app = GS_APPLICATION (data); + const gchar *mode; + const gchar *startup_id; + const gchar *desktop_id; + const gchar *ident; + g_autofree gchar **resources = NULL; + + g_variant_get (parameter, "(&s^a&s&s&s&s)", &mode, &resources, &startup_id, &desktop_id, &ident); + + gs_application_present_window (app, startup_id); + + gs_shell_reset_state (app->shell); + gs_shell_show_extras_search (app->shell, mode, resources, desktop_id, ident); +} + +static void +verbose_activated (GSimpleAction *action, + GVariant *parameter, + gpointer data) +{ + GsApplication *app = GS_APPLICATION (data); + gs_debug_set_verbose (app->debug, TRUE); +} + +static GActionEntry actions[] = { + { "about", about_activated, NULL, NULL, NULL }, + { "quit", quit_activated, NULL, NULL, NULL }, + { "verbose", verbose_activated, NULL, NULL, NULL }, + { "nop", NULL, NULL, NULL } +}; + +static GActionEntry actions_after_loading[] = { + { "reboot-and-install", reboot_and_install, NULL, NULL, NULL }, + { "reboot", reboot_activated, NULL, NULL, NULL }, + { "shutdown", shutdown_activated, NULL, NULL, NULL }, + { "launch", launch_activated, "(ss)", NULL, NULL }, + { "show-offline-update-error", show_offline_updates_error, NULL, NULL, NULL }, + { "autoupdate", autoupdate_activated, NULL, NULL, NULL }, + { "sources", sources_activated, NULL, NULL, NULL }, + { "prefs", prefs_activated, NULL, NULL, NULL }, + { "set-mode", set_mode_activated, "s", NULL, NULL }, + { "search", search_activated, "s", NULL, NULL }, + { "details", details_activated, "(ss)", NULL, NULL }, + { "details-pkg", details_pkg_activated, "(ss)", NULL, NULL }, + { "details-url", details_url_activated, "(s)", NULL, NULL }, + { "install", install_activated, "(su)", NULL, NULL }, + { "uninstall", uninstall_activated, "s", NULL, NULL }, + { "filename", filename_activated, "(s)", NULL, NULL }, + { "install-resources", install_resources_activated, "(sassss)", NULL, NULL }, + { "show-metainfo", show_metainfo_activated, "(ay)", NULL, NULL }, + { "nop", NULL, NULL, NULL } +}; + +static void +gs_application_update_software_sources_presence (GApplication *self) +{ + GsApplication *app = GS_APPLICATION (self); + GSimpleAction *action; + gboolean enable_sources; + + action = G_SIMPLE_ACTION (g_action_map_lookup_action (G_ACTION_MAP (self), + "sources")); + enable_sources = g_settings_get_boolean (app->settings, + ENABLE_REPOS_DIALOG_CONF_KEY); + g_simple_action_set_enabled (action, enable_sources); +} + +static void +gs_application_settings_changed_cb (GApplication *self, + const gchar *key, + gpointer data) +{ + if (g_strcmp0 (key, ENABLE_REPOS_DIALOG_CONF_KEY) == 0) { + gs_application_update_software_sources_presence (self); + } +} + +static void +wrapper_action_activated_cb (GSimpleAction *action, + GVariant *parameter, + gpointer data) +{ + GsApplication *app = GS_APPLICATION (data); + const gchar *action_name = g_action_get_name (G_ACTION (action)); + GAction *real_action = g_action_map_lookup_action (G_ACTION_MAP (app->action_map), + action_name); + + if (app->shell_loaded_handler_id != 0) { + GsActivationHelper *helper = gs_activation_helper_new (app, + G_SIMPLE_ACTION (real_action), + parameter); + + g_signal_connect_swapped (app->shell, "loaded", + G_CALLBACK (activate_on_shell_loaded_cb), helper); + return; + } + + g_action_activate (real_action, parameter); +} + +static void +gs_application_add_wrapper_actions (GApplication *application) +{ + GsApplication *app = GS_APPLICATION (application); + GActionMap *map = NULL; + + app->action_map = g_simple_action_group_new (); + map = G_ACTION_MAP (app->action_map); + + /* add the real actions to a different map and add wrapper actions to the + * application instead; the wrapper actions will call the real ones but + * after the "loading state" has finished */ + + g_action_map_add_action_entries (G_ACTION_MAP (map), actions_after_loading, + G_N_ELEMENTS (actions_after_loading), + application); + + for (guint i = 0; i < G_N_ELEMENTS (actions_after_loading); ++i) { + const GActionEntry *entry = &actions_after_loading[i]; + GAction *action = g_action_map_lookup_action (map, entry->name); + g_autoptr (GSimpleAction) simple_action = NULL; + + simple_action = g_simple_action_new (g_action_get_name (action), + g_action_get_parameter_type (action)); + g_signal_connect (simple_action, "activate", + G_CALLBACK (wrapper_action_activated_cb), + application); + g_object_bind_property (simple_action, "enabled", action, + "enabled", G_BINDING_DEFAULT); + g_action_map_add_action (G_ACTION_MAP (application), + G_ACTION (simple_action)); + } +} + +static void startup_cb (GObject *source_object, + GAsyncResult *result, + gpointer user_data); + +static void +gs_application_startup (GApplication *application) +{ + GSettings *settings; + GsApplication *app = GS_APPLICATION (application); + g_auto(GStrv) plugin_blocklist = NULL; + g_auto(GStrv) plugin_allowlist = NULL; + const gchar *tmp; + g_autoptr(GAsyncResult) setup_result = NULL; + + G_APPLICATION_CLASS (gs_application_parent_class)->startup (application); + + gs_application_add_wrapper_actions (application); + + g_action_map_add_action_entries (G_ACTION_MAP (application), + actions, G_N_ELEMENTS (actions), + application); + + /* allow for debugging */ + tmp = g_getenv ("GNOME_SOFTWARE_PLUGINS_BLOCKLIST"); + if (tmp != NULL) + plugin_blocklist = g_strsplit (tmp, ",", -1); + tmp = g_getenv ("GNOME_SOFTWARE_PLUGINS_ALLOWLIST"); + if (tmp != NULL) + plugin_allowlist = g_strsplit (tmp, ",", -1); + + app->plugin_loader = gs_plugin_loader_new (g_application_get_dbus_connection (application), NULL); + if (g_file_test (LOCALPLUGINDIR, G_FILE_TEST_EXISTS)) + gs_plugin_loader_add_location (app->plugin_loader, LOCALPLUGINDIR); + + gs_shell_search_provider_setup (app->search_provider, app->plugin_loader); + +#ifdef HAVE_PACKAGEKIT + app->dbus_helper = gs_dbus_helper_new (g_application_get_dbus_connection (application)); +#endif + settings = g_settings_new ("org.gnome.software"); + app->settings = settings; + g_signal_connect_swapped (settings, "changed", + G_CALLBACK (gs_application_settings_changed_cb), + application); + + /* setup UI */ + app->shell = gs_shell_new (); + app->cancellable = g_cancellable_new (); + + app->shell_loaded_handler_id = g_signal_connect (app->shell, "loaded", + G_CALLBACK (gs_application_shell_loaded_cb), + app); + + app->main_window = GTK_WINDOW (app->shell); + gtk_application_add_window (GTK_APPLICATION (app), app->main_window); + + gs_application_update_software_sources_presence (application); + + /* Remove possibly obsolete notifications */ + g_application_withdraw_notification (application, "installed"); + g_application_withdraw_notification (application, "restart-required"); + g_application_withdraw_notification (application, "updates-available"); + g_application_withdraw_notification (application, "updates-installed"); + g_application_withdraw_notification (application, "upgrades-available"); + g_application_withdraw_notification (application, "offline-updates"); + g_application_withdraw_notification (application, "eol"); + + /* Set up the plugins. */ + gs_plugin_loader_setup_async (app->plugin_loader, + (const gchar * const *) plugin_allowlist, + (const gchar * const *) plugin_blocklist, + NULL, + startup_cb, + app); +} + +static void +startup_cb (GObject *source_object, + GAsyncResult *result, + gpointer user_data) +{ + GsApplication *app = GS_APPLICATION (user_data); + GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (source_object); + g_autoptr(GError) local_error = NULL; + + if (!gs_plugin_loader_setup_finish (plugin_loader, + result, + &local_error)) { + g_warning ("Failed to setup plugins: %s", local_error->message); + exit (1); + } + + /* show the priority of each plugin */ + gs_plugin_loader_dump_state (plugin_loader); + + app->update_monitor = gs_update_monitor_new (app, app->plugin_loader); + + /* Setup the shell only after the plugin loader finished its setup, + thus all plugins are loaded and ready for the jobs. */ + gs_shell_setup (app->shell, app->plugin_loader, app->cancellable); +} + +static void +gs_application_activate (GApplication *application) +{ + GsApplication *app = GS_APPLICATION (application); + + if (app->shell_loaded_handler_id == 0) + gs_shell_set_mode (app->shell, GS_SHELL_MODE_OVERVIEW); + + gs_shell_activate (GS_APPLICATION (application)->shell); +} + +static void +gs_application_constructed (GObject *object) +{ + GsApplication *self = GS_APPLICATION (object); + + G_OBJECT_CLASS (gs_application_parent_class)->constructed (object); + + /* This is needed when the the application's ID isn't + * org.gnome.Software, e.g. for the development profile (when + * `BUILD_PROFILE` is defined). Without this, icon resources can't + * be loaded appropriately. */ + g_application_set_resource_base_path (G_APPLICATION (self), + "/org/gnome/Software"); + + /* Check on our construct-only properties */ + g_assert (self->debug != NULL); +} + +static void +gs_application_get_property (GObject *object, + guint prop_id, + GValue *value, + GParamSpec *pspec) +{ + GsApplication *self = GS_APPLICATION (object); + + switch ((GsApplicationProperty) prop_id) { + case PROP_DEBUG: + g_value_set_object (value, self->debug); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_application_set_property (GObject *object, + guint prop_id, + const GValue *value, + GParamSpec *pspec) +{ + GsApplication *self = GS_APPLICATION (object); + + switch ((GsApplicationProperty) prop_id) { + case PROP_DEBUG: + /* Construct only */ + g_assert (self->debug == NULL); + self->debug = g_value_dup_object (value); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_application_dispose (GObject *object) +{ + GsApplication *app = GS_APPLICATION (object); + + g_clear_object (&app->search_provider); + g_clear_object (&app->plugin_loader); + g_clear_object (&app->update_monitor); +#ifdef HAVE_PACKAGEKIT + g_clear_object (&app->dbus_helper); +#endif + g_clear_object (&app->settings); + g_clear_object (&app->action_map); + g_clear_object (&app->debug); + g_clear_pointer (&app->withdraw_notifications, g_hash_table_unref); + + G_OBJECT_CLASS (gs_application_parent_class)->dispose (object); +} + +static GsShellInteraction +get_page_interaction_from_string (const gchar *interaction) +{ + if (g_strcmp0 (interaction, "notify") == 0) + return GS_SHELL_INTERACTION_NOTIFY; + else if (g_strcmp0 (interaction, "none") == 0) + return GS_SHELL_INTERACTION_NONE; + return GS_SHELL_INTERACTION_FULL; +} + +static int +gs_application_handle_local_options (GApplication *app, GVariantDict *options) +{ + GsApplication *self = GS_APPLICATION (app); + const gchar *id; + const gchar *pkgname; + const gchar *local_filename; + const gchar *mode; + const gchar *search; + gint rc = -1; + g_autoptr(GError) error = NULL; + + gs_debug_set_verbose (self->debug, g_variant_dict_contains (options, "verbose")); + + /* prefer local sources */ + if (g_variant_dict_contains (options, "prefer-local")) + g_setenv ("GNOME_SOFTWARE_PREFER_LOCAL", "true", TRUE); + + if (g_variant_dict_contains (options, "version")) { + g_print ("gnome-software %s\n", get_version()); + return 0; + } + + if (!g_application_register (app, NULL, &error)) { + g_printerr ("%s\n", error->message); + return 1; + } + + if (g_variant_dict_contains (options, "autoupdate")) { + g_action_group_activate_action (G_ACTION_GROUP (app), + "autoupdate", + NULL); + } + if (g_variant_dict_contains (options, "prefs")) { + g_action_group_activate_action (G_ACTION_GROUP (app), + "prefs", + NULL); + } + if (g_variant_dict_contains (options, "quit")) { + /* The 'quit' command-line option shuts down everything, + * including the backend service */ + g_action_group_activate_action (G_ACTION_GROUP (app), + "shutdown", + NULL); + return 0; + } + + if (g_variant_dict_contains (options, "verbose")) { + g_action_group_activate_action (G_ACTION_GROUP (app), + "verbose", + NULL); + } + + if (g_variant_dict_lookup (options, "mode", "&s", &mode)) { + g_action_group_activate_action (G_ACTION_GROUP (app), + "set-mode", + g_variant_new_string (mode)); + rc = 0; + } else if (g_variant_dict_lookup (options, "search", "&s", &search)) { + g_action_group_activate_action (G_ACTION_GROUP (app), + "search", + g_variant_new_string (search)); + rc = 0; + } else if (g_variant_dict_lookup (options, "details", "&s", &id)) { + g_action_group_activate_action (G_ACTION_GROUP (app), + "details", + g_variant_new ("(ss)", id, "")); + rc = 0; + } else if (g_variant_dict_lookup (options, "details-pkg", "&s", &pkgname)) { + g_action_group_activate_action (G_ACTION_GROUP (app), + "details-pkg", + g_variant_new ("(ss)", pkgname, "")); + rc = 0; + } else if (g_variant_dict_lookup (options, "install", "&s", &id)) { + GsShellInteraction interaction = GS_SHELL_INTERACTION_FULL; + const gchar *str_interaction = NULL; + + if (g_variant_dict_lookup (options, "interaction", "&s", + &str_interaction)) + interaction = get_page_interaction_from_string (str_interaction); + + g_action_group_activate_action (G_ACTION_GROUP (app), + "install", + g_variant_new ("(su)", id, + interaction)); + rc = 0; + } else if (g_variant_dict_lookup (options, "uninstall", "&s", &id)) { + g_action_group_activate_action (G_ACTION_GROUP (app), + "uninstall", + g_variant_new_string (id)); + rc = 0; + } else if (g_variant_dict_lookup (options, "local-filename", "^&ay", &local_filename)) { + g_autoptr(GFile) file = NULL; + g_autofree gchar *absolute_filename = NULL; + + file = g_file_new_for_path (local_filename); + absolute_filename = g_file_get_path (file); + g_action_group_activate_action (G_ACTION_GROUP (app), + "filename", + g_variant_new ("(s)", absolute_filename)); + rc = 0; + } else if (g_variant_dict_lookup (options, "show-metainfo", "^&ay", &local_filename)) { + g_autoptr(GFile) file = NULL; + g_autofree gchar *absolute_filename = NULL; + + file = g_file_new_for_path (local_filename); + absolute_filename = g_file_get_path (file); + g_action_group_activate_action (G_ACTION_GROUP (app), + "show-metainfo", + g_variant_new ("(^ay)", absolute_filename)); + rc = 0; + } + + return rc; +} + +static void +gs_application_open (GApplication *application, + GFile **files, + gint n_files, + const gchar *hint) +{ + GsApplication *app = GS_APPLICATION (application); + gint i; + + for (i = 0; i < n_files; i++) { + g_autofree gchar *str = g_file_get_uri (files[i]); + g_action_group_activate_action (G_ACTION_GROUP (app), + "details-url", + g_variant_new ("(s)", str)); + } +} + +static void +gs_application_class_init (GsApplicationClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + GApplicationClass *application_class = G_APPLICATION_CLASS (klass); + + object_class->constructed = gs_application_constructed; + object_class->get_property = gs_application_get_property; + object_class->set_property = gs_application_set_property; + object_class->dispose = gs_application_dispose; + + application_class->startup = gs_application_startup; + application_class->activate = gs_application_activate; + application_class->handle_local_options = gs_application_handle_local_options; + application_class->open = gs_application_open; + application_class->dbus_register = gs_application_dbus_register; + application_class->dbus_unregister = gs_application_dbus_unregister; + application_class->shutdown = gs_application_shutdown; + + /** + * GsApplication:debug: (nullable) + * + * A #GsDebug object to control debug and logging output from the + * application and everything within it. + * + * This may be %NULL if you don’t care about log output. + * + * Since: 40 + */ + obj_props[PROP_DEBUG] = + g_param_spec_object ("debug", NULL, NULL, + GS_TYPE_DEBUG, + G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS); + + g_object_class_install_properties (object_class, G_N_ELEMENTS (obj_props), obj_props); + + /** + * GsApplication::install-resources-done: + * @ident: Operation identificator, as string + * @op_error: (nullable): an install #GError, or %NULL on success + * + * Emitted after a resource installation operation identified by @ident + * had finished. The @op_error can hold eventual error message, when + * the installation failed. + */ + signals[INSTALL_RESOURCES_DONE] = g_signal_new ( + "install-resources-done", + G_TYPE_FROM_CLASS (klass), + G_SIGNAL_RUN_LAST | G_SIGNAL_NO_RECURSE, + 0, + NULL, NULL, + NULL, + G_TYPE_NONE, 2, + G_TYPE_STRING, G_TYPE_ERROR); + + /** + * GsApplication::repository-changed: + * @repository: a #GsApp of the repository + * + * Emitted when the repository changed, usually when it is enabled or disabled. + * + * Since: 40 + */ + signals[REPOSITORY_CHANGED] = g_signal_new ( + "repository-changed", + G_TYPE_FROM_CLASS (klass), + G_SIGNAL_ACTION | G_SIGNAL_NO_RECURSE, + 0, + NULL, NULL, + NULL, + G_TYPE_NONE, 1, + GS_TYPE_APP); +} + +/** + * gs_application_new: + * @debug: (transfer none) (not nullable): a #GsDebug for the application instance + * + * Create a new #GsApplication. + * + * Returns: (transfer full): a new #GsApplication + * Since: 40 + */ +GsApplication * +gs_application_new (GsDebug *debug) +{ + return g_object_new (GS_APPLICATION_TYPE, + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_HANDLES_OPEN, + "inactivity-timeout", 12000, + "debug", debug, + NULL); +} + +void +gs_application_emit_install_resources_done (GsApplication *application, + const gchar *ident, + const GError *op_error) +{ + g_signal_emit (application, signals[INSTALL_RESOURCES_DONE], 0, ident, op_error, NULL); +} + +static gboolean +gs_application_withdraw_notification_cb (gpointer user_data) +{ + GApplication *application = g_application_get_default (); + const gchar *notification_id = user_data; + + gs_application_withdraw_notification (GS_APPLICATION (application), notification_id); + + return G_SOURCE_REMOVE; +} + +/** + * gs_application_send_notification: + * @self: a #GsApplication + * @notification_id: the @notification ID + * @notification: a #GNotification + * @timeout_minutes: how many minutes to wait, before withdraw the notification; 0 for not withdraw + * + * Sends the @notification and schedules withdraw of it after + * @timeout_minutes. This is used to auto-hide notifications + * after certain period of time. The @timeout_minutes set to 0 + * means to not auto-withdraw it. + * + * Since: 43 + **/ +void +gs_application_send_notification (GsApplication *self, + const gchar *notification_id, + GNotification *notification, + guint timeout_minutes) +{ + guint timeout_id; + + g_return_if_fail (GS_IS_APPLICATION (self)); + g_return_if_fail (notification_id != NULL); + g_return_if_fail (G_IS_NOTIFICATION (notification)); + g_return_if_fail (timeout_minutes < G_MAXUINT / 60); + + g_application_send_notification (G_APPLICATION (self), notification_id, notification); + + if (timeout_minutes > 0) { + if (self->withdraw_notifications == NULL) + self->withdraw_notifications = g_hash_table_new_full (g_str_hash, g_str_equal, g_free, NULL); + + timeout_id = GPOINTER_TO_UINT (g_hash_table_lookup (self->withdraw_notifications, notification_id)); + if (timeout_id) + g_source_remove (timeout_id); + timeout_id = g_timeout_add_seconds_full (G_PRIORITY_DEFAULT, timeout_minutes * 60, + gs_application_withdraw_notification_cb, g_strdup (notification_id), g_free); + g_hash_table_insert (self->withdraw_notifications, g_strdup (notification_id), GUINT_TO_POINTER (timeout_id)); + } else if (self->withdraw_notifications != NULL) { + timeout_id = GPOINTER_TO_UINT (g_hash_table_lookup (self->withdraw_notifications, notification_id)); + if (timeout_id) { + g_source_remove (timeout_id); + g_hash_table_remove (self->withdraw_notifications, notification_id); + } + } +} + +/** + * gs_application_withdraw_notification: + * @self: a #GsApplication + * @notification_id: a #GNotification ID + * + * Immediately withdraws the notification @notification_id and + * removes any previously scheduled withdraw by gs_application_schedule_withdraw_notification(). + * + * Since: 43 + **/ +void +gs_application_withdraw_notification (GsApplication *self, + const gchar *notification_id) +{ + g_return_if_fail (GS_IS_APPLICATION (self)); + g_return_if_fail (notification_id != NULL); + + g_application_withdraw_notification (G_APPLICATION (self), notification_id); + + if (self->withdraw_notifications != NULL) { + g_hash_table_remove (self->withdraw_notifications, notification_id); + if (g_hash_table_size (self->withdraw_notifications) == 0) + g_clear_pointer (&self->withdraw_notifications, g_hash_table_unref); + } +} diff --git a/src/gs-application.h b/src/gs-application.h new file mode 100644 index 0000000..1c04a7b --- /dev/null +++ b/src/gs-application.h @@ -0,0 +1,31 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013 Matthias Clasen <mclasen@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <adwaita.h> + +#include "gnome-software-private.h" +#include "gs-debug.h" + +#define GS_APPLICATION_TYPE (gs_application_get_type ()) + +G_DECLARE_FINAL_TYPE (GsApplication, gs_application, GS, APPLICATION, AdwApplication) + +GsApplication *gs_application_new (GsDebug *debug); +gboolean gs_application_has_active_window (GsApplication *application); +void gs_application_emit_install_resources_done + (GsApplication *application, + const gchar *ident, + const GError *op_error); +void gs_application_send_notification (GsApplication *self, + const gchar *notification_id, + GNotification *notification, + guint timeout_minutes); +void gs_application_withdraw_notification (GsApplication *self, + const gchar *notification_id); diff --git a/src/gs-basic-auth-dialog.c b/src/gs-basic-auth-dialog.c new file mode 100644 index 0000000..cff8584 --- /dev/null +++ b/src/gs-basic-auth-dialog.c @@ -0,0 +1,131 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2020 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include "gs-basic-auth-dialog.h" + +#include <glib.h> +#include <glib/gi18n.h> +#include <gtk/gtk.h> + +struct _GsBasicAuthDialog +{ + GtkDialog parent_instance; + + GsBasicAuthCallback callback; + gpointer callback_data; + + /* template widgets */ + GtkButton *login_button; + GtkLabel *description_label; + GtkEntry *user_entry; + GtkEntry *password_entry; +}; + +G_DEFINE_TYPE (GsBasicAuthDialog, gs_basic_auth_dialog, GTK_TYPE_DIALOG) + +static void +cancel_button_clicked_cb (GsBasicAuthDialog *dialog) +{ + /* abort the basic auth request */ + dialog->callback (NULL, NULL, dialog->callback_data); + + gtk_dialog_response (GTK_DIALOG (dialog), GTK_RESPONSE_CANCEL); +} + +static void +login_button_clicked_cb (GsBasicAuthDialog *dialog) +{ + const gchar *user; + const gchar *password; + + user = gtk_editable_get_text (GTK_EDITABLE (dialog->user_entry)); + password = gtk_editable_get_text (GTK_EDITABLE (dialog->password_entry)); + + /* submit the user/password to basic auth */ + dialog->callback (user, password, dialog->callback_data); + + gtk_dialog_response (GTK_DIALOG (dialog), GTK_RESPONSE_ACCEPT); +} + +static void +dialog_validate (GsBasicAuthDialog *dialog) +{ + const gchar *user; + const gchar *password; + gboolean valid_user; + gboolean valid_password; + + /* require user */ + user = gtk_editable_get_text (GTK_EDITABLE (dialog->user_entry)); + valid_user = user != NULL && strlen (user) != 0; + + /* require password */ + password = gtk_editable_get_text (GTK_EDITABLE (dialog->password_entry)); + valid_password = password != NULL && strlen (password) != 0; + + gtk_widget_set_sensitive (GTK_WIDGET (dialog->login_button), valid_user && valid_password); +} + +static void +update_description (GsBasicAuthDialog *dialog, const gchar *remote, const gchar *realm) +{ + g_autofree gchar *description = NULL; + + /* TRANSLATORS: This is a description for entering user/password */ + description = g_strdup_printf (_("Login required remote %s (realm %s)"), + remote, realm); + gtk_label_set_text (dialog->description_label, description); +} + +static void +gs_basic_auth_dialog_init (GsBasicAuthDialog *dialog) +{ + gtk_widget_init_template (GTK_WIDGET (dialog)); +} + +static void +gs_basic_auth_dialog_class_init (GsBasicAuthDialogClass *klass) +{ + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-basic-auth-dialog.ui"); + + gtk_widget_class_bind_template_child (widget_class, GsBasicAuthDialog, login_button); + gtk_widget_class_bind_template_child (widget_class, GsBasicAuthDialog, description_label); + gtk_widget_class_bind_template_child (widget_class, GsBasicAuthDialog, user_entry); + gtk_widget_class_bind_template_child (widget_class, GsBasicAuthDialog, password_entry); + + gtk_widget_class_bind_template_callback (widget_class, dialog_validate); + gtk_widget_class_bind_template_callback (widget_class, cancel_button_clicked_cb); + gtk_widget_class_bind_template_callback (widget_class, login_button_clicked_cb); +} + +GtkWidget * +gs_basic_auth_dialog_new (GtkWindow *parent, + const gchar *remote, + const gchar *realm, + GsBasicAuthCallback callback, + gpointer callback_data) +{ + GsBasicAuthDialog *dialog; + + dialog = g_object_new (GS_TYPE_BASIC_AUTH_DIALOG, + "use-header-bar", TRUE, + "transient-for", parent, + "modal", TRUE, + NULL); + dialog->callback = callback; + dialog->callback_data = callback_data; + + update_description (dialog, remote, realm); + dialog_validate (dialog); + + return GTK_WIDGET (dialog); +} diff --git a/src/gs-basic-auth-dialog.h b/src/gs-basic-auth-dialog.h new file mode 100644 index 0000000..9c07778 --- /dev/null +++ b/src/gs-basic-auth-dialog.h @@ -0,0 +1,29 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2020 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <gtk/gtk.h> + +#include "gnome-software-private.h" + +G_BEGIN_DECLS + +typedef void (*GsBasicAuthCallback) (const gchar *user, const gchar *password, gpointer callback_data); + +#define GS_TYPE_BASIC_AUTH_DIALOG (gs_basic_auth_dialog_get_type ()) + +G_DECLARE_FINAL_TYPE (GsBasicAuthDialog, gs_basic_auth_dialog, GS, BASIC_AUTH_DIALOG, GtkDialog) + +GtkWidget *gs_basic_auth_dialog_new (GtkWindow *parent, + const gchar *remote, + const gchar *realm, + GsBasicAuthCallback callback, + gpointer callback_data); + +G_END_DECLS diff --git a/src/gs-basic-auth-dialog.ui b/src/gs-basic-auth-dialog.ui new file mode 100644 index 0000000..f7c887c --- /dev/null +++ b/src/gs-basic-auth-dialog.ui @@ -0,0 +1,184 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <template class="GsBasicAuthDialog" parent="GtkDialog"> + <property name="can_focus">False</property> + <property name="margin-top">5</property> + <property name="margin-bottom">5</property> + <property name="margin-start">5</property> + <property name="margin-end">5</property> + <property name="resizable">False</property> + <property name="modal">True</property> + <property name="destroy_with_parent">True</property> + <property name="type_hint">dialog</property> + <property name="title" translatable="yes">Login Required</property> + <property name="use_header_bar">1</property> + <child internal-child="headerbar"> + <object class="GtkHeaderBar"> + <property name="can_focus">False</property> + <property name="show_close_button">False</property> + <child> + <object class="GtkButton" id="cancel_button"> + <property name="label" translatable="yes">_Cancel</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="use_action_appearance">False</property> + <property name="use_underline">True</property> + <property name="valign">center</property> + <signal name="clicked" handler="cancel_button_clicked_cb" object="GsBasicAuthDialog" swapped="yes"/> + <style> + <class name="text-button"/> + </style> + </object> + </child> + <child> + <object class="GtkButton" id="login_button"> + <property name="label" translatable="yes">_Login</property> + <property name="can_focus">True</property> + <property name="has_default">True</property> + <property name="receives_default">True</property> + <property name="use_action_appearance">False</property> + <property name="use_underline">True</property> + <property name="valign">center</property> + <signal name="clicked" handler="login_button_clicked_cb" object="GsBasicAuthDialog" swapped="yes"/> + <style> + <class name="text-button"/> + <class name="suggested-action"/> + </style> + </object> + </child> + </object> + </child> + <child internal-child="vbox"> + <object class="GtkBox"> + <property name="can_focus">False</property> + <property name="orientation">vertical</property> + <child> + <object class="GtkGrid"> + <property name="can_focus">False</property> + <property name="hexpand">True</property> + <property name="row_spacing">8</property> + <property name="column_spacing">6</property> + <property name="margin-top">20</property> + <property name="margin-bottom">20</property> + <property name="margin-start">20</property> + <property name="margin-end">40</property> + <child> + <object class="GtkLabel" id="description_label"> + <property name="can_focus">False</property> + <property name="wrap">True</property> + <property name="wrap_mode">word-char</property> + <property name="margin_bottom">20</property> + <property name="max_width_chars">55</property> + <property name="xalign">0</property> + <style> + <class name="dim-label"/> + </style> + <layout> + <property name="column">0</property> + <property name="row">0</property> + <property name="column-span">2</property> + <property name="row-span">1</property> + </layout> + </object> + </child> + <child> + <object class="GtkLabel" id="user_label"> + <property name="can_focus">False</property> + <property name="xalign">1</property> + <property name="label" translatable="yes">_User</property> + <property name="use_underline">True</property> + <property name="mnemonic_widget">user_entry</property> + <property name="margin_start">20</property> + <style> + <class name="dim-label"/> + </style> + <layout> + <property name="column">0</property> + <property name="row">3</property> + <property name="column-span">1</property> + <property name="row-span">1</property> + </layout> + </object> + </child> + <child> + <object class="GtkLabel" id="password_label"> + <property name="can_focus">False</property> + <property name="xalign">1</property> + <property name="label" translatable="yes">_Password</property> + <property name="use_underline">True</property> + <property name="mnemonic_widget">password_entry</property> + <property name="margin_start">20</property> + <style> + <class name="dim-label"/> + </style> + <layout> + <property name="column">0</property> + <property name="row">4</property> + <property name="column-span">1</property> + <property name="row-span">1</property> + </layout> + </object> + </child> + <child> + <object class="GtkEntry" id="user_entry"> + <property name="can_focus">True</property> + <property name="has_focus">True</property> + <property name="hexpand">True</property> + <property name="invisible_char">●</property> + <property name="activates_default">True</property> + <property name="invisible_char_set">True</property> + <property name="input_purpose">password</property> + <signal name="changed" handler="dialog_validate" object="GsBasicAuthDialog" swapped="yes"/> + <signal name="activate" handler="dialog_validate" object="GsBasicAuthDialog" swapped="yes"/> + <layout> + <property name="column">1</property> + <property name="row">3</property> + <property name="column-span">1</property> + <property name="row-span">1</property> + </layout> + </object> + </child> + <child> + <object class="GtkEntry" id="password_entry"> + <property name="can_focus">True</property> + <property name="hexpand">True</property> + <property name="visibility">False</property> + <property name="invisible_char">●</property> + <property name="activates_default">True</property> + <property name="invisible_char_set">True</property> + <property name="input_purpose">password</property> + <signal name="changed" handler="dialog_validate" object="GsBasicAuthDialog" swapped="yes"/> + <signal name="activate" handler="dialog_validate" object="GsBasicAuthDialog" swapped="yes"/> + <layout> + <property name="column">1</property> + <property name="row">4</property> + <property name="column-span">1</property> + <property name="row-span">1</property> + </layout> + </object> + </child> + </object> + </child> + </object> + </child> + </template> + <object class="GtkSizeGroup"> + <widgets> + <widget name="user_label"/> + <widget name="password_label"/> + </widgets> + </object> + <object class="GtkSizeGroup"> + <widgets> + <widget name="user_entry"/> + <widget name="password_entry"/> + </widgets> + </object> + <object class="GtkSizeGroup"> + <property name="mode">horizontal</property> + <widgets> + <widget name="login_button"/> + <widget name="cancel_button"/> + </widgets> + </object> +</interface> diff --git a/src/gs-category-page.c b/src/gs-category-page.c new file mode 100644 index 0000000..ba57ed2 --- /dev/null +++ b/src/gs-category-page.c @@ -0,0 +1,693 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2013 Matthias Clasen <mclasen@redhat.com> + * Copyright (C) 2014-2018 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include <string.h> +#include <glib/gi18n.h> + +#include "gs-app-list-private.h" +#include "gs-common.h" +#include "gs-featured-carousel.h" +#include "gs-summary-tile.h" +#include "gs-category-page.h" +#include "gs-utils.h" + +struct _GsCategoryPage +{ + GsPage parent_instance; + + GsPluginLoader *plugin_loader; + GCancellable *cancellable; + GsCategory *category; + GsCategory *subcategory; + + GtkWidget *top_carousel; + GtkWidget *category_detail_box; + GtkWidget *scrolledwindow_category; + GtkWidget *featured_flow_box; + GtkWidget *recently_updated_flow_box; + GtkWidget *web_apps_flow_box; +}; + +G_DEFINE_TYPE (GsCategoryPage, gs_category_page, GS_TYPE_PAGE) + +#define MAX_RECENTLY_UPDATED_APPS 18 + +typedef enum { + PROP_CATEGORY = 1, + /* Override properties: */ + PROP_TITLE, +} GsCategoryPageProperty; + +static GParamSpec *obj_props[PROP_CATEGORY + 1] = { NULL, }; + +typedef enum { + SIGNAL_APP_CLICKED, +} GsCategoryPageSignal; + +static guint obj_signals[SIGNAL_APP_CLICKED + 1] = { 0, }; + +static void +app_tile_clicked (GsAppTile *tile, gpointer data) +{ + GsCategoryPage *self = GS_CATEGORY_PAGE (data); + GsApp *app; + + app = gs_app_tile_get_app (tile); + g_signal_emit (self, obj_signals[SIGNAL_APP_CLICKED], 0, app); +} + +static void +top_carousel_app_clicked_cb (GsFeaturedCarousel *carousel, + GsApp *app, + gpointer user_data) +{ + GsCategoryPage *self = GS_CATEGORY_PAGE (user_data); + + g_signal_emit (self, obj_signals[SIGNAL_APP_CLICKED], 0, app); +} + +static gint +_max_results_sort_cb (GsApp *app1, GsApp *app2, gpointer user_data) +{ + gint name_sort = gs_utils_sort_strcmp (gs_app_get_name (app1), gs_app_get_name (app2)); + + if (name_sort != 0) + return name_sort; + + return gs_app_get_rating (app1) - gs_app_get_rating (app2); +} + +static void +gs_category_page_add_placeholders (GsCategoryPage *self, + GtkFlowBox *flow_box, + guint n_placeholders) +{ + gs_widget_remove_all (GTK_WIDGET (flow_box), (GsRemoveFunc) gtk_flow_box_remove); + + for (guint i = 0; i < n_placeholders; ++i) { + GtkWidget *tile = gs_summary_tile_new (NULL); + gtk_flow_box_insert (flow_box, tile, -1); + gtk_widget_set_can_focus (gtk_widget_get_parent (tile), FALSE); + gtk_widget_remove_css_class (tile, "activatable"); + } + + gtk_widget_show (GTK_WIDGET (flow_box)); +} + +typedef struct { + GsCategoryPage *page; /* (owned) */ + GHashTable *featured_app_ids; /* (owned) (nullable) (element-type utf8 utf8) */ + gboolean get_featured_apps_finished; + GsAppList *apps; /* (owned) (nullable) */ + gboolean get_main_apps_finished; +} LoadCategoryData; + +static void +load_category_data_free (LoadCategoryData *data) +{ + g_clear_object (&data->page); + g_clear_pointer (&data->featured_app_ids, g_hash_table_unref); + g_clear_object (&data->apps); + g_free (data); +} + +static void load_category_finish (LoadCategoryData *data); + +static void +gs_category_page_get_featured_apps_cb (GObject *source_object, + GAsyncResult *res, + gpointer user_data) +{ + LoadCategoryData *data = user_data; + GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (source_object); + g_autoptr(GError) local_error = NULL; + g_autoptr(GsAppList) list = NULL; + g_autoptr(GHashTable) featured_app_ids = NULL; + + list = gs_plugin_loader_job_process_finish (plugin_loader, + res, + &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_warning ("failed to get featured apps for category apps: %s", local_error->message); + data->get_featured_apps_finished = TRUE; + load_category_finish (data); + return; + } + + featured_app_ids = g_hash_table_new_full (g_str_hash, g_str_equal, g_free, NULL); + + for (guint i = 0; i < gs_app_list_length (list); i++) { + GsApp *app = gs_app_list_index (list, i); + + g_hash_table_add (featured_app_ids, g_strdup (gs_app_get_id (app))); + } + + data->featured_app_ids = g_steal_pointer (&featured_app_ids); + data->get_featured_apps_finished = TRUE; + load_category_finish (data); +} + +static void +gs_category_page_get_apps_cb (GObject *source_object, + GAsyncResult *res, + gpointer user_data) +{ + LoadCategoryData *data = user_data; + GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (source_object); + g_autoptr(GError) local_error = NULL; + g_autoptr(GsAppList) list = NULL; + + list = gs_plugin_loader_job_process_finish (plugin_loader, + res, + &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_warning ("failed to get apps for category apps: %s", local_error->message); + data->get_main_apps_finished = TRUE; + load_category_finish (data); + return; + } + + data->apps = g_steal_pointer (&list); + data->get_main_apps_finished = TRUE; + load_category_finish (data); +} + +static gboolean +app_has_hi_res_icon (GsCategoryPage *self, + GsApp *app) +{ + g_autoptr(GIcon) icon = NULL; + + /* This is the minimum icon size needed by `GsFeatureTile`. */ + icon = gs_app_get_icon_for_size (app, + 128, + gtk_widget_get_scale_factor (GTK_WIDGET (self)), + NULL); + + /* Returning TRUE means to keep the app in the list */ + return (icon != NULL); +} + +static GsAppList * +choose_top_carousel_apps (LoadCategoryData *data, + guint64 recently_updated_cutoff_secs) +{ + const guint n_top_carousel_apps = 5; + g_autoptr(GPtrArray) candidates = g_ptr_array_new_with_free_func (NULL); + g_autoptr(GsAppList) top_carousel_apps = gs_app_list_new (); + guint top_carousel_seed; + g_autoptr(GRand) top_carousel_rand = NULL; + + /* The top carousel should contain @n_top_carousel_apps, taken from the + * set of featured or recently updated apps which have hi-res icons. + * + * The apps in the top carousel should be changed on a fixed schedule, + * once a week. + */ + top_carousel_seed = (g_get_real_time () / G_USEC_PER_SEC) / (7 * 24 * 60 * 60); + top_carousel_rand = g_rand_new_with_seed (top_carousel_seed); + g_debug ("Top carousel seed: %u", top_carousel_seed); + + for (guint i = 0; i < gs_app_list_length (data->apps); i++) { + GsApp *app = gs_app_list_index (data->apps, i); + gboolean is_featured, is_recently_updated, is_hi_res; + + is_featured = (data->featured_app_ids != NULL && + g_hash_table_contains (data->featured_app_ids, gs_app_get_id (app))); + is_recently_updated = (gs_app_get_release_date (app) > recently_updated_cutoff_secs); + is_hi_res = app_has_hi_res_icon (data->page, app); + + if ((is_featured || is_recently_updated) && is_hi_res) + g_ptr_array_add (candidates, app); + } + + /* If there aren’t enough candidate apps to populate the top carousel, + * return an empty app list. */ + if (candidates->len < n_top_carousel_apps) { + g_debug ("Only %u candidate apps for top carousel; returning empty", candidates->len); + goto out; + } + + /* Select @n_top_carousel_apps from @candidates uniformly randomly + * without replacement. */ + for (guint i = 0; i < n_top_carousel_apps; i++) { + guint random_index = g_rand_int_range (top_carousel_rand, 0, candidates->len); + GsApp *app = g_ptr_array_index (candidates, random_index); + + gs_app_list_add (top_carousel_apps, app); + g_ptr_array_remove_index_fast (candidates, random_index); + } + + out: + g_assert (gs_app_list_length (top_carousel_apps) == 0 || + gs_app_list_length (top_carousel_apps) == n_top_carousel_apps); + + return g_steal_pointer (&top_carousel_apps); +} + +static gint +compare_release_date_cb (gconstpointer aa, + gconstpointer bb) +{ + GsApp *app_a = gs_app_tile_get_app ((GsAppTile *) aa); + GsApp *app_b = gs_app_tile_get_app ((GsAppTile *) bb); + guint64 release_date_a = gs_app_get_release_date (app_a); + guint64 release_date_b = gs_app_get_release_date (app_b); + + if (release_date_a == release_date_b) + return g_utf8_collate (gs_app_get_name (app_a), gs_app_get_name (app_b)); + + return release_date_a < release_date_b ? -1 : 1; +} + +static void +load_category_finish (LoadCategoryData *data) +{ + GsCategoryPage *self = data->page; + guint64 recently_updated_cutoff_secs; + guint64 n_recently_updated = 0; + guint64 min_release_date = G_MAXUINT64; + GSList *recently_updated = NULL, *link; + g_autoptr(GsAppList) top_carousel_apps = NULL; + + if (!data->get_featured_apps_finished || + !data->get_main_apps_finished) + return; + + /* Remove the loading tiles. */ + gs_widget_remove_all (self->featured_flow_box, (GsRemoveFunc) gtk_flow_box_remove); + gs_widget_remove_all (self->recently_updated_flow_box, (GsRemoveFunc) gtk_flow_box_remove); + gs_widget_remove_all (self->web_apps_flow_box, (GsRemoveFunc) gtk_flow_box_remove); + gs_widget_remove_all (self->category_detail_box, (GsRemoveFunc) gtk_flow_box_remove); + + /* Last 30 days */ + recently_updated_cutoff_secs = g_get_real_time () / G_USEC_PER_SEC - 30 * 24 * 60 * 60; + + /* Apps to go in the top carousel */ + top_carousel_apps = choose_top_carousel_apps (data, recently_updated_cutoff_secs); + + for (guint i = 0; i < gs_app_list_length (data->apps); i++) { + GsApp *app = gs_app_list_index (data->apps, i); + gboolean is_featured, is_recently_updated; + guint64 release_date; + GtkWidget *flow_box = self->category_detail_box; + GtkWidget *tile; + + /* To be listed in the top carousel? */ + if (gs_app_list_lookup (top_carousel_apps, gs_app_get_unique_id (app)) != NULL) + continue; + + release_date = gs_app_get_release_date (app); + is_featured = (data->featured_app_ids != NULL && + g_hash_table_contains (data->featured_app_ids, gs_app_get_id (app))); + is_recently_updated = (release_date > recently_updated_cutoff_secs); + + tile = gs_summary_tile_new (app); + g_signal_connect (tile, "clicked", + G_CALLBACK (app_tile_clicked), self); + + if (is_featured) { + flow_box = self->featured_flow_box; + } else if (is_recently_updated) { + if (n_recently_updated < MAX_RECENTLY_UPDATED_APPS) { + recently_updated = g_slist_insert_sorted (recently_updated, tile, compare_release_date_cb); + n_recently_updated++; + if (min_release_date > release_date) + min_release_date = release_date; + flow_box = NULL; + } else if (release_date >= min_release_date) { + recently_updated = g_slist_insert_sorted (recently_updated, tile, compare_release_date_cb); + tile = recently_updated->data; + recently_updated = g_slist_remove (recently_updated, tile); + min_release_date = gs_app_get_release_date (gs_app_tile_get_app (GS_APP_TILE (recently_updated->data))); + } + } else if (gs_app_get_kind (app) == AS_COMPONENT_KIND_WEB_APP) { + flow_box = self->web_apps_flow_box; + } + + if (flow_box != NULL) { + gtk_flow_box_insert (GTK_FLOW_BOX (flow_box), tile, -1); + gtk_widget_set_can_focus (gtk_widget_get_parent (tile), FALSE); + } + } + + for (link = recently_updated; link != NULL; link = g_slist_next (link)) { + GtkWidget *tile = link->data; + gtk_flow_box_insert (GTK_FLOW_BOX (self->recently_updated_flow_box), tile, -1); + gtk_widget_set_can_focus (gtk_widget_get_parent (tile), FALSE); + } + + g_slist_free (recently_updated); + + gtk_widget_set_visible (self->top_carousel, gs_app_list_length (top_carousel_apps) > 0); + gs_featured_carousel_set_apps (GS_FEATURED_CAROUSEL (self->top_carousel), top_carousel_apps); + + /* Show each of the flow boxes if they have any children. */ + gtk_widget_set_visible (self->featured_flow_box, gtk_flow_box_get_child_at_index (GTK_FLOW_BOX (self->featured_flow_box), 0) != NULL); + gtk_widget_set_visible (self->recently_updated_flow_box, gtk_flow_box_get_child_at_index (GTK_FLOW_BOX (self->recently_updated_flow_box), 0) != NULL); + gtk_widget_set_visible (self->web_apps_flow_box, gtk_flow_box_get_child_at_index (GTK_FLOW_BOX (self->web_apps_flow_box), 0) != NULL); + gtk_widget_set_visible (self->category_detail_box, gtk_flow_box_get_child_at_index (GTK_FLOW_BOX (self->category_detail_box), 0) != NULL); + + load_category_data_free (data); +} + +static void +gs_category_page_load_category (GsCategoryPage *self) +{ + GsCategory *featured_subcat = NULL; + GtkAdjustment *adj = NULL; + g_autoptr(GsPluginJob) featured_plugin_job = NULL; + g_autoptr(GsAppQuery) main_query = NULL; + g_autoptr(GsPluginJob) main_plugin_job = NULL; + LoadCategoryData *load_data = NULL; + + g_assert (self->subcategory != NULL); + + featured_subcat = gs_category_find_child (self->category, "featured"); + + g_cancellable_cancel (self->cancellable); + g_clear_object (&self->cancellable); + self->cancellable = g_cancellable_new (); + + g_debug ("search using %s/%s", + gs_category_get_id (self->category), + gs_category_get_id (self->subcategory)); + + gs_featured_carousel_set_apps (GS_FEATURED_CAROUSEL (self->top_carousel), NULL); + gtk_widget_show (self->top_carousel); + gs_category_page_add_placeholders (self, GTK_FLOW_BOX (self->category_detail_box), + MIN (30, gs_category_get_size (self->subcategory))); + gs_category_page_add_placeholders (self, GTK_FLOW_BOX (self->recently_updated_flow_box), MAX_RECENTLY_UPDATED_APPS); + + if (gs_plugin_loader_get_enabled (self->plugin_loader, "epiphany")) + gs_category_page_add_placeholders (self, GTK_FLOW_BOX (self->web_apps_flow_box), 12); + + if (featured_subcat != NULL) { + /* set up the placeholders as having the featured category is a good + * indicator that there will be featured apps */ + gs_category_page_add_placeholders (self, GTK_FLOW_BOX (self->featured_flow_box), 6); + gtk_widget_show (self->top_carousel); + } else { + gs_widget_remove_all (self->featured_flow_box, (GsRemoveFunc) gtk_flow_box_remove); + gtk_widget_hide (self->featured_flow_box); + gtk_widget_hide (self->top_carousel); + } + + /* Load the list of apps in the category, and also the list of all + * featured apps, in parallel. + * + * The list of featured apps has to be loaded separately (we can’t just + * query each app for its featured status) since it’s provided by a + * separate appstream file (org.gnome.Software.Featured.xml) and hence + * produces separate `GsApp` instances with stub data. In particular, + * they don’t have enough category data to match the main category + * query. + * + * Once both queries have returned, turn the list of featured apps into + * a filter, and split the main list in four: + * - Featured apps + * - Recently updated apps + * - Web apps + * - Everything else + * Then populate the UI. + * + * The `featured_subcat` can be `NULL` when loading the special ‘addons’ + * category. + */ + load_data = g_new0 (LoadCategoryData, 1); + load_data->page = g_object_ref (self); + + if (featured_subcat != NULL) { + g_autoptr(GsAppQuery) featured_query = NULL; + + featured_query = gs_app_query_new ("category", featured_subcat, + "refine-flags", GS_PLUGIN_REFINE_FLAGS_REQUIRE_KUDOS, + "sort-func", gs_utils_app_sort_name, + NULL); + featured_plugin_job = gs_plugin_job_list_apps_new (featured_query, GS_PLUGIN_LIST_APPS_FLAGS_INTERACTIVE); + gs_plugin_loader_job_process_async (self->plugin_loader, + featured_plugin_job, + self->cancellable, + gs_category_page_get_featured_apps_cb, + load_data); + } else { + /* Skip it */ + load_data->get_featured_apps_finished = TRUE; + } + + main_query = gs_app_query_new ("category", self->subcategory, + "refine-flags", GS_PLUGIN_REFINE_FLAGS_REQUIRE_ICON | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_RATING | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_KUDOS, + "dedupe-flags", GS_APP_LIST_FILTER_FLAG_PREFER_INSTALLED | + GS_APP_LIST_FILTER_FLAG_KEY_ID_PROVIDES, + "sort-func", _max_results_sort_cb, + NULL); + main_plugin_job = gs_plugin_job_list_apps_new (main_query, GS_PLUGIN_LIST_APPS_FLAGS_INTERACTIVE); + gs_plugin_loader_job_process_async (self->plugin_loader, + main_plugin_job, + self->cancellable, + gs_category_page_get_apps_cb, + load_data); + + /* scroll the list of apps to the beginning, otherwise it will show + * with the previous scroll value */ + adj = gtk_scrolled_window_get_vadjustment (GTK_SCROLLED_WINDOW (self->scrolledwindow_category)); + gtk_adjustment_set_value (adj, gtk_adjustment_get_lower (adj)); +} + +static void +gs_category_page_reload (GsPage *page) +{ + GsCategoryPage *self = GS_CATEGORY_PAGE (page); + + if (self->subcategory == NULL) + return; + + gs_category_page_load_category (self); +} + +void +gs_category_page_set_category (GsCategoryPage *self, GsCategory *category) +{ + GsCategory *all_subcat = NULL; + + /* this means we've come from the app-view -> back */ + if (self->category == category) + return; + + /* set the category */ + all_subcat = gs_category_find_child (category, "all"); + + g_set_object (&self->category, category); + g_set_object (&self->subcategory, all_subcat); + + /* load the apps from it */ + if (all_subcat != NULL) + gs_category_page_load_category (self); + + /* notify of the updates — the category’s title will have changed too */ + g_object_notify (G_OBJECT (self), "category"); + g_object_notify (G_OBJECT (self), "title"); +} + +GsCategory * +gs_category_page_get_category (GsCategoryPage *self) +{ + return self->category; +} + +static gint +recently_updated_sort_cb (GtkFlowBoxChild *child1, + GtkFlowBoxChild *child2, + gpointer user_data) +{ + GsSummaryTile *tile1 = GS_SUMMARY_TILE (gtk_flow_box_child_get_child (child1)); + GsSummaryTile *tile2 = GS_SUMMARY_TILE (gtk_flow_box_child_get_child (child2)); + GsApp *app1 = gs_app_tile_get_app (GS_APP_TILE (tile1)); + GsApp *app2 = gs_app_tile_get_app (GS_APP_TILE (tile2)); + guint64 release_date1 = 0, release_date2 = 0; + + /* Placeholder tiles have no app. */ + if (app1 != NULL) + release_date1 = gs_app_get_release_date (app1); + if (app2 != NULL) + release_date2 = gs_app_get_release_date (app2); + + /* Don’t use the normal subtraction trick, as there’s the possibility + * for overflow in the conversion from guint64 to gint. */ + if (release_date1 > release_date2) + return -1; + else if (release_date2 > release_date1) + return 1; + else + return 0; +} + +static void +gs_category_page_init (GsCategoryPage *self) +{ + gtk_widget_init_template (GTK_WIDGET (self)); + + /* Sort the recently updated apps by update date. */ + gtk_flow_box_set_sort_func (GTK_FLOW_BOX (self->recently_updated_flow_box), + recently_updated_sort_cb, + NULL, + NULL); + + gs_featured_carousel_set_apps (GS_FEATURED_CAROUSEL (self->top_carousel), NULL); +} + +static void +gs_category_page_get_property (GObject *object, + guint prop_id, + GValue *value, + GParamSpec *pspec) +{ + GsCategoryPage *self = GS_CATEGORY_PAGE (object); + + switch ((GsCategoryPageProperty) prop_id) { + case PROP_TITLE: + if (self->category != NULL) + g_value_set_string (value, gs_category_get_name (self->category)); + else + g_value_set_string (value, NULL); + break; + case PROP_CATEGORY: + g_value_set_object (value, self->category); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_category_page_set_property (GObject *object, + guint prop_id, + const GValue *value, + GParamSpec *pspec) +{ + GsCategoryPage *self = GS_CATEGORY_PAGE (object); + + switch ((GsCategoryPageProperty) prop_id) { + case PROP_TITLE: + /* Read only */ + g_assert_not_reached (); + break; + case PROP_CATEGORY: + gs_category_page_set_category (self, g_value_get_object (value)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_category_page_dispose (GObject *object) +{ + GsCategoryPage *self = GS_CATEGORY_PAGE (object); + + g_cancellable_cancel (self->cancellable); + g_clear_object (&self->cancellable); + + g_clear_object (&self->category); + g_clear_object (&self->subcategory); + g_clear_object (&self->plugin_loader); + + G_OBJECT_CLASS (gs_category_page_parent_class)->dispose (object); +} + +static gboolean +gs_category_page_setup (GsPage *page, + GsShell *shell, + GsPluginLoader *plugin_loader, + GCancellable *cancellable, + GError **error) +{ + GsCategoryPage *self = GS_CATEGORY_PAGE (page); + + self->plugin_loader = g_object_ref (plugin_loader); + + return TRUE; +} + +static void +gs_category_page_class_init (GsCategoryPageClass *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_category_page_get_property; + object_class->set_property = gs_category_page_set_property; + object_class->dispose = gs_category_page_dispose; + + page_class->reload = gs_category_page_reload; + page_class->setup = gs_category_page_setup; + + /** + * GsCategoryPage:category: (nullable) + * + * The category to display the apps from. + * + * This may be %NULL if no category is selected. If so, the behaviour + * of the widget will be safe, but undefined. + * + * Since: 41 + */ + obj_props[PROP_CATEGORY] = + g_param_spec_object ("category", NULL, NULL, + GS_TYPE_CATEGORY, + G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS); + + g_object_class_install_properties (object_class, G_N_ELEMENTS (obj_props), obj_props); + + g_object_class_override_property (object_class, PROP_TITLE, "title"); + + /** + * GsCategoryPage::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: 41 + */ + obj_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-category-page.ui"); + + gtk_widget_class_bind_template_child (widget_class, GsCategoryPage, top_carousel); + gtk_widget_class_bind_template_child (widget_class, GsCategoryPage, category_detail_box); + gtk_widget_class_bind_template_child (widget_class, GsCategoryPage, scrolledwindow_category); + gtk_widget_class_bind_template_child (widget_class, GsCategoryPage, featured_flow_box); + gtk_widget_class_bind_template_child (widget_class, GsCategoryPage, recently_updated_flow_box); + gtk_widget_class_bind_template_child (widget_class, GsCategoryPage, web_apps_flow_box); + + gtk_widget_class_bind_template_callback (widget_class, top_carousel_app_clicked_cb); +} + +GsCategoryPage * +gs_category_page_new (void) +{ + return g_object_new (GS_TYPE_CATEGORY_PAGE, NULL); +} diff --git a/src/gs-category-page.h b/src/gs-category-page.h new file mode 100644 index 0000000..98bf197 --- /dev/null +++ b/src/gs-category-page.h @@ -0,0 +1,25 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2015-2017 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include "gs-page.h" + +G_BEGIN_DECLS + +#define GS_TYPE_CATEGORY_PAGE (gs_category_page_get_type ()) + +G_DECLARE_FINAL_TYPE (GsCategoryPage, gs_category_page, GS, CATEGORY_PAGE, GsPage) + +GsCategoryPage *gs_category_page_new (void); +void gs_category_page_set_category (GsCategoryPage *self, + GsCategory *category); +GsCategory *gs_category_page_get_category (GsCategoryPage *self); + +G_END_DECLS diff --git a/src/gs-category-page.ui b/src/gs-category-page.ui new file mode 100644 index 0000000..4fd72a3 --- /dev/null +++ b/src/gs-category-page.ui @@ -0,0 +1,155 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <requires lib="gtk+" version="3.10"/> + + <template class="GsCategoryPage" parent="GsPage"> + <child> + <object class="GtkScrolledWindow" id="scrolledwindow_category"> + <property name="hscrollbar_policy">never</property> + <property name="vscrollbar_policy">automatic</property> + <child> + <object class="GtkViewport" id="viewport3"> + <property name="scroll-to-focus">True</property> + <child> + <object class="AdwClamp"> + <!-- We use the same sizes as the overview page. --> + <property name="maximum-size">1000</property> + <property name="tightening-threshold">600</property> + <child> + <object class="GtkBox"> + <property name="orientation">vertical</property> + <property name="spacing">9</property> + <property name="valign">start</property> + <property name="margin-start">12</property> + <property name="margin-end">12</property> + <property name="margin-top">0</property><!-- top margin provided by headings --> + <property name="margin-bottom">24</property> + + <child> + <object class="GsFeaturedCarousel" id="top_carousel"> + <property name="height-request">318</property> + <property name="margin_top">24</property> + <signal name="app-clicked" handler="top_carousel_app_clicked_cb"/> + </object> + </child> + + <child> + <object class="GtkLabel" id="featured_heading"> + <property name="visible" bind-source="featured_flow_box" bind-property="visible" bind-flags="sync-create|bidirectional" /> + <property name="xalign">0</property> + <property name="margin_top">24</property> + <property name="label" translatable="yes" comments="Heading for featured apps on a category page">Editor’s Choice</property> + <style> + <class name="heading"/> + </style> + </object> + </child> + <child> + <object class="GtkFlowBox" id="featured_flow_box"> + <property name="visible">False</property> + <property name="column_spacing">14</property> + <property name="halign">fill</property> + <property name="row_spacing">14</property> + <property name="homogeneous">True</property> + <property name="hexpand">True</property> + <property name="vexpand">True</property> + <property name="valign">start</property> + <property name="selection-mode">none</property> + <accessibility> + <relation name="labelled-by">featured_heading</relation> + </accessibility> + </object> + </child> + + <child> + <object class="GtkLabel" id="recently_updated_heading"> + <property name="visible" bind-source="recently_updated_flow_box" bind-property="visible" bind-flags="sync-create|bidirectional" /> + <property name="xalign">0</property> + <property name="margin_top">24</property> + <property name="label" translatable="yes" comments="Heading for recently updated apps on a category page">New & Updated</property> + <style> + <class name="heading"/> + </style> + </object> + </child> + <child> + <object class="GtkFlowBox" id="recently_updated_flow_box"> + <property name="visible">False</property> + <property name="column_spacing">14</property> + <property name="halign">fill</property> + <property name="row_spacing">14</property> + <property name="homogeneous">True</property> + <property name="hexpand">True</property> + <property name="vexpand">True</property> + <property name="valign">start</property> + <property name="selection-mode">none</property> + <accessibility> + <relation name="labelled-by">recently_updated_heading</relation> + </accessibility> + </object> + </child> + + <child> + <object class="GtkLabel" id="web_apps_heading"> + <property name="visible" bind-source="web_apps_flow_box" bind-property="visible" bind-flags="sync-create|bidirectional" /> + <property name="xalign">0</property> + <property name="margin_top">24</property> + <property name="label" translatable="yes" comments="Heading for web apps on a category page">Picks from the Web</property> + <style> + <class name="heading"/> + </style> + </object> + </child> + <child> + <object class="GtkFlowBox" id="web_apps_flow_box"> + <property name="visible">False</property> + <property name="column_spacing">14</property> + <property name="halign">fill</property> + <property name="row_spacing">14</property> + <property name="homogeneous">True</property> + <property name="hexpand">True</property> + <property name="vexpand">True</property> + <property name="valign">start</property> + <property name="selection-mode">none</property> + <accessibility> + <relation name="labelled-by">web_apps_heading</relation> + </accessibility> + </object> + </child> + + <child> + <object class="GtkLabel" id="other_heading"> + <property name="visible" bind-source="category_detail_box" bind-property="visible" bind-flags="sync-create|bidirectional" /> + <property name="xalign">0</property> + <property name="margin_top">24</property> + <property name="label" translatable="yes" comments="Heading for the rest of the apps on a category page">Other Software</property> + <style> + <class name="heading"/> + </style> + </object> + </child> + <child> + <object class="GtkFlowBox" id="category_detail_box"> + <property name="halign">fill</property> + <property name="row_spacing">14</property> + <property name="column_spacing">14</property> + <property name="homogeneous">True</property> + <property name="hexpand">True</property> + <property name="vexpand">True</property> + <property name="valign">start</property> + <property name="selection-mode">none</property> + <accessibility> + <relation name="labelled-by">other_heading</relation> + </accessibility> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + </template> +</interface> diff --git a/src/gs-category-tile.c b/src/gs-category-tile.c new file mode 100644 index 0000000..34fced1 --- /dev/null +++ b/src/gs-category-tile.c @@ -0,0 +1,219 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013 Matthias Clasen <mclasen@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +/* + * SECTION:gs-category-tile + * @short_description: A UI tile for presenting a category + * + * #GsCategoryTile is a UI widget to show a category to the user. It’s generally + * aimed to be used in a list box, to provide navigation options to all the + * categories. + * + * It will display the category’s name, and potentially a background image which + * is styled to match the category’s content. + * + * Since: 41 + */ + +#include "config.h" + +#include "gs-category-tile.h" +#include "gs-common.h" + +struct _GsCategoryTile +{ + GtkButton parent_instance; + + GsCategory *category; /* (owned) (not nullable) */ + GtkWidget *label; + GtkWidget *image; + GtkBox *box; +}; + +G_DEFINE_TYPE (GsCategoryTile, gs_category_tile, GTK_TYPE_BUTTON) + +typedef enum { + PROP_CATEGORY = 1, +} GsCategoryTileProperty; + +static GParamSpec *obj_props[PROP_CATEGORY + 1] = { NULL, }; + +static void +gs_category_tile_get_property (GObject *object, guint prop_id, GValue *value, GParamSpec *pspec) +{ + GsCategoryTile *self = GS_CATEGORY_TILE (object); + + switch ((GsCategoryTileProperty) prop_id) { + case PROP_CATEGORY: + g_value_set_object (value, gs_category_tile_get_category (self)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_category_tile_set_property (GObject *object, guint prop_id, const GValue *value, GParamSpec *pspec) +{ + GsCategoryTile *self = GS_CATEGORY_TILE (object); + + switch ((GsCategoryTileProperty) prop_id) { + case PROP_CATEGORY: + gs_category_tile_set_category (self, g_value_get_object (value)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +/** + * gs_category_tile_get_category: + * @tile: a #GsCategoryTile + * + * Get the value of #GsCategoryTile:category. + * + * Returns: (transfer none) (not nullable): a category + * Since: 41 + */ +GsCategory * +gs_category_tile_get_category (GsCategoryTile *tile) +{ + g_return_val_if_fail (GS_IS_CATEGORY_TILE (tile), NULL); + + return tile->category; +} + +static void +gs_category_tile_refresh (GsCategoryTile *tile) +{ + GtkStyleContext *context; + const gchar *icon_name = gs_category_get_icon_name (tile->category); + + /* set labels */ + gtk_label_set_label (GTK_LABEL (tile->label), + gs_category_get_name (tile->category)); + + gtk_image_set_from_icon_name (GTK_IMAGE (tile->image), icon_name); + gtk_widget_set_visible (tile->image, icon_name != NULL); + + /* Update the icon class. */ + context = gtk_widget_get_style_context (GTK_WIDGET (tile)); + if (icon_name != NULL) + gtk_style_context_remove_class (context, "category-tile-iconless"); + else + gtk_style_context_add_class (context, "category-tile-iconless"); + + /* The label should be left-aligned for iconless categories and centred otherwise. */ + gtk_widget_set_halign (GTK_WIDGET (tile->box), + (icon_name != NULL) ? GTK_ALIGN_CENTER : GTK_ALIGN_START); +} + +/** + * gs_category_tile_set_category: + * @tile: a #GsCategoryTile + * @cat: (transfer none) (not nullable): a #GsCategory + * + * Set the value of #GsCategoryTile:category to @cat. + * + * Since: 41 + */ +void +gs_category_tile_set_category (GsCategoryTile *tile, GsCategory *cat) +{ + GtkStyleContext *context; + + g_return_if_fail (GS_IS_CATEGORY_TILE (tile)); + g_return_if_fail (GS_IS_CATEGORY (cat)); + + context = gtk_widget_get_style_context (GTK_WIDGET (tile)); + + /* Remove the old category ID. */ + if (tile->category != NULL) { + g_autofree gchar *class_name = g_strdup_printf ("category-%s", gs_category_get_id (tile->category)); + gtk_style_context_remove_class (context, class_name); + } + + if (g_set_object (&tile->category, cat)) { + g_autofree gchar *class_name = g_strdup_printf ("category-%s", gs_category_get_id (tile->category)); + + /* Add the new category’s ID as a CSS class, to get + * category-specific styling. */ + gtk_style_context_add_class (context, class_name); + + gs_category_tile_refresh (tile); + g_object_notify_by_pspec (G_OBJECT (tile), obj_props[PROP_CATEGORY]); + } +} + +static void +gs_category_tile_dispose (GObject *object) +{ + GsCategoryTile *tile = GS_CATEGORY_TILE (object); + + g_clear_object (&tile->category); + + G_OBJECT_CLASS (gs_category_tile_parent_class)->dispose (object); +} + +static void +gs_category_tile_init (GsCategoryTile *tile) +{ + gtk_widget_init_template (GTK_WIDGET (tile)); +} + +static void +gs_category_tile_class_init (GsCategoryTileClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + + object_class->get_property = gs_category_tile_get_property; + object_class->set_property = gs_category_tile_set_property; + object_class->dispose = gs_category_tile_dispose; + + /** + * GsCategoryTile:category: (not nullable) + * + * The category to display in this tile. + * + * This must not be %NULL. + * + * Since: 41 + */ + obj_props[PROP_CATEGORY] = + g_param_spec_object ("category", NULL, NULL, + GS_TYPE_CATEGORY, + G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS); + + g_object_class_install_properties (object_class, G_N_ELEMENTS (obj_props), obj_props); + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-category-tile.ui"); + + gtk_widget_class_bind_template_child (widget_class, GsCategoryTile, label); + gtk_widget_class_bind_template_child (widget_class, GsCategoryTile, image); + gtk_widget_class_bind_template_child (widget_class, GsCategoryTile, box); +} + +/** + * gs_category_tile_new: + * @cat: (transfer none) (not nullable): a #GsCategory + * + * Create a new #GsCategoryTile to represent @cat. + * + * Returns: (transfer full) (type GsCategoryTile): a new #GsCategoryTile + * Since: 41 + */ +GtkWidget * +gs_category_tile_new (GsCategory *cat) +{ + return g_object_new (GS_TYPE_CATEGORY_TILE, + "category", cat, + NULL); +} diff --git a/src/gs-category-tile.h b/src/gs-category-tile.h new file mode 100644 index 0000000..577587e --- /dev/null +++ b/src/gs-category-tile.h @@ -0,0 +1,26 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013 Matthias Clasen <mclasen@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <gtk/gtk.h> + +#include "gnome-software-private.h" + +G_BEGIN_DECLS + +#define GS_TYPE_CATEGORY_TILE (gs_category_tile_get_type ()) + +G_DECLARE_FINAL_TYPE (GsCategoryTile, gs_category_tile, GS, CATEGORY_TILE, GtkButton) + +GtkWidget *gs_category_tile_new (GsCategory *cat); +GsCategory *gs_category_tile_get_category (GsCategoryTile *tile); +void gs_category_tile_set_category (GsCategoryTile *tile, + GsCategory *cat); + +G_END_DECLS diff --git a/src/gs-category-tile.ui b/src/gs-category-tile.ui new file mode 100644 index 0000000..6b5b1a7 --- /dev/null +++ b/src/gs-category-tile.ui @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <!-- interface-requires gtk+ 3.10 --> + <template class="GsCategoryTile" parent="GtkButton"> + <style> + <class name="card"/> + <class name="category-tile"/> + </style> + <child> + <object class="GtkBox" id="box"> + <property name="halign">center</property> + <property name="orientation">horizontal</property> + <property name="spacing">10</property> + <child> + <object class="GtkImage" id="image"> + <!-- Placeholder; the actual icon is set in code --> + <property name="icon_name">folder-music-symbolic</property> + <property name="icon_size">large</property><!-- GTK_ICON_SIZE_LARGE --> + <style> + <class name="icon-dropshadow"/> + </style> + </object> + </child> + <child> + <object class="GtkLabel" id="label"> + <property name="xalign">0</property> + <property name="ellipsize">end</property> + </object> + </child> + </object> + </child> + </template> +</interface> diff --git a/src/gs-common.c b/src/gs-common.c new file mode 100644 index 0000000..7499d74 --- /dev/null +++ b/src/gs-common.c @@ -0,0 +1,1261 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013-2015 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2016-2019 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include <glib/gi18n.h> +#include <gio/gdesktopappinfo.h> + +#ifndef TESTDATADIR +#include "gs-application.h" +#endif + +#include "gs-common.h" + +#ifdef HAVE_GSETTINGS_DESKTOP_SCHEMAS +#include <gdesktop-enums.h> +#endif + +#include <langinfo.h> + +void +gs_widget_remove_all (GtkWidget *container, + GsRemoveFunc remove_func) +{ + GtkWidget *child; + while ((child = gtk_widget_get_first_child (container)) != NULL) { + if (remove_func) + remove_func (container, child); + else + gtk_widget_unparent (child); + } +} + +static void +grab_focus (GtkWidget *widget) +{ + g_signal_handlers_disconnect_by_func (widget, grab_focus, NULL); + gtk_widget_grab_focus (widget); +} + +void +gs_grab_focus_when_mapped (GtkWidget *widget) +{ + if (gtk_widget_get_mapped (widget)) + gtk_widget_grab_focus (widget); + else + g_signal_connect_after (widget, "map", + G_CALLBACK (grab_focus), NULL); +} + +void +gs_app_notify_installed (GsApp *app) +{ + g_autofree gchar *summary = NULL; + const gchar *body = NULL; + g_autoptr(GNotification) n = NULL; + + switch (gs_app_get_kind (app)) { + case AS_COMPONENT_KIND_DESKTOP_APP: + /* TRANSLATORS: this is the summary of a notification that an application + * has been successfully installed */ + summary = g_strdup_printf (_("%s is now installed"), gs_app_get_name (app)); + if (gs_app_has_quirk (app, GS_APP_QUIRK_NEEDS_REBOOT)) { + /* TRANSLATORS: an application has been installed, but + * needs a reboot to complete the installation */ + body = _("A restart is required for the changes to take effect."); + } else { + /* TRANSLATORS: this is the body of a notification that an application + * has been successfully installed */ + body = _("Application is ready to be used."); + } + break; + default: + if (gs_app_get_kind (app) == AS_COMPONENT_KIND_GENERIC && + gs_app_get_special_kind (app) == GS_APP_SPECIAL_KIND_OS_UPDATE) { + /* TRANSLATORS: this is the summary of a notification that OS updates + * have been successfully installed */ + summary = g_strdup (_("System updates are now installed")); + /* TRANSLATORS: this is the body of a notification that OS updates + * have been successfully installed */ + body = _("Recently installed updates are available to review"); + } else { + /* TRANSLATORS: this is the summary of a notification that a component + * has been successfully installed */ + summary = g_strdup_printf (_("%s is now installed"), gs_app_get_name (app)); + if (gs_app_has_quirk (app, GS_APP_QUIRK_NEEDS_REBOOT)) { + /* TRANSLATORS: an application has been installed, but + * needs a reboot to complete the installation */ + body = _("A restart is required for the changes to take effect."); + } + } + break; + } + n = g_notification_new (summary); + if (body != NULL) + g_notification_set_body (n, body); + + if (gs_app_has_quirk (app, GS_APP_QUIRK_NEEDS_REBOOT)) { + /* TRANSLATORS: button text */ + g_notification_add_button_with_target (n, _("Restart"), + "app.reboot", NULL); + } else if (gs_app_get_kind (app) == AS_COMPONENT_KIND_DESKTOP_APP) { + /* TRANSLATORS: this is button that opens the newly installed application */ + g_autoptr(GsPlugin) plugin = gs_app_dup_management_plugin (app); + const gchar *plugin_name = (plugin != NULL) ? gs_plugin_get_name (plugin) : ""; + g_notification_add_button_with_target (n, _("Launch"), + "app.launch", "(ss)", + gs_app_get_id (app), + plugin_name); + } + g_notification_set_default_action_and_target (n, "app.details", "(ss)", + gs_app_get_unique_id (app), ""); + #ifdef TESTDATADIR + g_application_send_notification (g_application_get_default (), "installed", n); + #else + gs_application_send_notification (GS_APPLICATION (g_application_get_default ()), "installed", n, 24 * 60); + #endif +} + +typedef enum { + GS_APP_LICENSE_FREE = 0, + GS_APP_LICENSE_NONFREE = 1, + GS_APP_LICENSE_PATENT_CONCERN = 2 +} GsAppLicenseHint; + +typedef struct +{ + gint response_id; + GMainLoop *loop; +} RunInfo; + +static void +shutdown_loop (RunInfo *run_info) +{ + if (g_main_loop_is_running (run_info->loop)) + g_main_loop_quit (run_info->loop); +} + +static void +unmap_cb (GtkDialog *dialog, + RunInfo *run_info) +{ + shutdown_loop (run_info); +} + +static void +response_cb (GtkDialog *dialog, + gint response_id, + RunInfo *run_info) +{ + run_info->response_id = response_id; + gtk_window_destroy (GTK_WINDOW (dialog)); + shutdown_loop (run_info); +} + +static gboolean +close_requested_cb (GtkDialog *dialog, + RunInfo *run_info) +{ + shutdown_loop (run_info); + return GDK_EVENT_PROPAGATE; +} + +GtkResponseType +gs_app_notify_unavailable (GsApp *app, GtkWindow *parent) +{ + GsAppLicenseHint hint = GS_APP_LICENSE_FREE; + GtkWidget *dialog; + const gchar *license; + gboolean already_enabled = FALSE; /* FIXME */ + g_autofree gchar *origin_ui = NULL; + guint i; + struct { + const gchar *str; + GsAppLicenseHint hint; + } keywords[] = { + { "NonFree", GS_APP_LICENSE_NONFREE }, + { "PatentConcern", GS_APP_LICENSE_PATENT_CONCERN }, + { "Proprietary", GS_APP_LICENSE_NONFREE }, + { NULL, 0 } + }; + g_autoptr(GSettings) settings = NULL; + g_autoptr(GString) body = NULL; + g_autoptr(GString) title = NULL; + + RunInfo run_info = { + GTK_RESPONSE_NONE, + NULL, + }; + + /* this is very crude */ + license = gs_app_get_license (app); + if (license != NULL) { + for (i = 0; keywords[i].str != NULL; i++) { + if (g_strstr_len (license, -1, keywords[i].str) != NULL) + hint |= keywords[i].hint; + } + } else { + /* use the worst-case assumption */ + hint = GS_APP_LICENSE_NONFREE | GS_APP_LICENSE_PATENT_CONCERN; + } + + /* check if the user has already dismissed */ + settings = g_settings_new ("org.gnome.software"); + if (!g_settings_get_boolean (settings, "prompt-for-nonfree")) + return GTK_RESPONSE_OK; + + title = g_string_new (""); + if (already_enabled) { + g_string_append_printf (title, "<b>%s</b>", + /* TRANSLATORS: window title */ + _("Install Third-Party Software?")); + } else { + g_string_append_printf (title, "<b>%s</b>", + /* TRANSLATORS: window title */ + _("Enable Third-Party Software Repository?")); + } + dialog = gtk_message_dialog_new (parent, + GTK_DIALOG_MODAL, + GTK_MESSAGE_QUESTION, + GTK_BUTTONS_CANCEL, + NULL); + gtk_message_dialog_set_markup (GTK_MESSAGE_DIALOG (dialog), title->str); + + body = g_string_new (""); + origin_ui = gs_app_dup_origin_ui (app, TRUE); + + if (hint & GS_APP_LICENSE_NONFREE) { + g_string_append_printf (body, + /* TRANSLATORS: the replacements are as follows: + * 1. Application name, e.g. "Firefox" + * 2. Software repository name, e.g. fedora-optional + */ + _("%s is not <a href=\"https://en.wikipedia.org/wiki/Free_and_open-source_software\">" + "free and open source software</a>, " + "and is provided by “%s”."), + gs_app_get_name (app), + origin_ui); + } else { + g_string_append_printf (body, + /* TRANSLATORS: the replacements are as follows: + * 1. Application name, e.g. "Firefox" + * 2. Software repository name, e.g. fedora-optional */ + _("%s is provided by “%s”."), + gs_app_get_name (app), + origin_ui); + } + + /* tell the use what needs to be done */ + if (!already_enabled) { + g_string_append (body, " "); + g_string_append (body, + _("This software repository must be " + "enabled to continue installation.")); + } + + /* be aware of patent clauses */ + if (hint & GS_APP_LICENSE_PATENT_CONCERN) { + g_string_append (body, "\n\n"); + if (gs_app_get_kind (app) != AS_COMPONENT_KIND_CODEC) { + g_string_append_printf (body, + /* TRANSLATORS: Laws are geographical, urgh... */ + _("It may be illegal to install " + "or use %s in some countries."), + gs_app_get_name (app)); + } else { + g_string_append (body, + /* TRANSLATORS: Laws are geographical, urgh... */ + _("It may be illegal to install or use " + "this codec in some countries.")); + } + } + + gtk_message_dialog_format_secondary_markup (GTK_MESSAGE_DIALOG (dialog), "%s", body->str); + /* TRANSLATORS: this is button text to not ask about non-free content again */ + if (0) gtk_dialog_add_button (GTK_DIALOG (dialog), _("Don’t Warn Again"), GTK_RESPONSE_YES); + if (already_enabled) { + gtk_dialog_add_button (GTK_DIALOG (dialog), + /* TRANSLATORS: button text */ + _("Install"), + GTK_RESPONSE_OK); + } else { + gtk_dialog_add_button (GTK_DIALOG (dialog), + /* TRANSLATORS: button text */ + _("Enable and Install"), + GTK_RESPONSE_OK); + } + + + /* Run */ + if (!gtk_widget_get_visible (dialog)) + gtk_window_present (GTK_WINDOW (dialog)); + + g_signal_connect (dialog, "close-request", G_CALLBACK (close_requested_cb), &run_info); + g_signal_connect (dialog, "response", G_CALLBACK (response_cb), &run_info); + g_signal_connect (dialog, "unmap", G_CALLBACK (unmap_cb), &run_info); + + run_info.loop = g_main_loop_new (NULL, FALSE); + g_main_loop_run (run_info.loop); + g_clear_pointer (&run_info.loop, g_main_loop_unref); + + if (run_info.response_id == GTK_RESPONSE_YES) { + run_info.response_id = GTK_RESPONSE_OK; + g_settings_set_boolean (settings, "prompt-for-nonfree", FALSE); + } + return run_info.response_id; +} + +gboolean +gs_utils_is_current_desktop (const gchar *name) +{ + const gchar *tmp; + g_auto(GStrv) names = NULL; + tmp = g_getenv ("XDG_CURRENT_DESKTOP"); + if (tmp == NULL) + return FALSE; + names = g_strsplit (tmp, ":", -1); + return g_strv_contains ((const gchar * const *) names, name); +} + +static void +gs_utils_widget_css_parsing_error_cb (GtkCssProvider *provider, + GtkCssSection *section, + GError *error, + gpointer user_data) +{ + const GtkCssLocation *start_location; + + start_location = gtk_css_section_get_start_location (section); + g_warning ("CSS parse error %" G_GSIZE_FORMAT ":%" G_GSIZE_FORMAT ": %s", + start_location->lines + 1, + start_location->line_chars, + error->message); +} + +/** + * gs_utils_set_key_colors_in_css: + * @css: some CSS + * @app: a #GsApp to get the key colors from + * + * Replace placeholders in @css with the key colors from @app, returning a copy + * of the CSS with the key colors inlined as `rgb()` literals. + * + * The key color placeholders are of the form `@keycolor-XX@`, where `XX` is a + * two digit counter. The first counter (`00`) will be replaced with the first + * key color in @app, the second counter (`01`) with the second, etc. + * + * CSS may be %NULL, in which case %NULL is returned. + * + * Returns: (transfer full): a copy of @css with the key color placeholders + * replaced, free with g_free() + * Since: 40 + */ +gchar * +gs_utils_set_key_colors_in_css (const gchar *css, + GsApp *app) +{ + GArray *key_colors; + g_autoptr(GString) css_new = NULL; + + if (css == NULL) + return NULL; + + key_colors = gs_app_get_key_colors (app); + + /* Do we not need to do any replacements? */ + if (key_colors->len == 0 || + g_strstr_len (css, -1, "@keycolor") == NULL) + return g_strdup (css); + + /* replace key color values */ + css_new = g_string_new (css); + for (guint j = 0; j < key_colors->len; j++) { + const GdkRGBA *color = &g_array_index (key_colors, GdkRGBA, j); + g_autofree gchar *key = NULL; + g_autofree gchar *value = NULL; + key = g_strdup_printf ("@keycolor-%02u@", j); + value = g_strdup_printf ("rgb(%.0f,%.0f,%.0f)", + color->red * 255.f, + color->green * 255.f, + color->blue * 255.f); + as_gstring_replace (css_new, key, value); + } + + return g_string_free (g_steal_pointer (&css_new), FALSE); +} + +/** + * gs_utils_widget_set_css: + * @widget: a widget + * @provider: (inout) (transfer full) (not optional) (nullable): pointer to a + * #GtkCssProvider to use + * @class_name: class name to use, without the leading `.` + * @css: (nullable): CSS to set on the widget, or %NULL to clear custom CSS + * + * Set custom CSS on the given @widget instance. This doesn’t affect any other + * instances of the same widget. The @class_name must be a static string to be + * used as a name for the @css. It doesn’t need to vary with @widget, but + * multiple values of @class_name can be used with the same @widget to control + * several independent snippets of custom CSS. + * + * @provider must be a pointer to a #GtkCssProvider pointer, typically within + * your widget’s private data struct. This function will return a + * #GtkCssProvider in the provided pointer, reusing any old @provider if + * possible. When your widget is destroyed, you must destroy the returned + * @provider. If @css is %NULL, this function will destroy the @provider. + */ +void +gs_utils_widget_set_css (GtkWidget *widget, GtkCssProvider **provider, const gchar *class_name, const gchar *css) +{ + GtkStyleContext *context; + g_autoptr(GString) str = NULL; + + g_return_if_fail (GTK_IS_WIDGET (widget)); + g_return_if_fail (provider != NULL); + g_return_if_fail (provider == NULL || *provider == NULL || GTK_IS_STYLE_PROVIDER (*provider)); + g_return_if_fail (class_name != NULL); + + context = gtk_widget_get_style_context (widget); + + /* remove custom class if NULL */ + if (css == NULL) { + if (*provider != NULL) + gtk_style_context_remove_provider (context, GTK_STYLE_PROVIDER (*provider)); + g_clear_object (provider); + gtk_style_context_remove_class (context, class_name); + return; + } + + str = g_string_sized_new (1024); + g_string_append_printf (str, ".%s {\n", class_name); + g_string_append_printf (str, "%s\n", css); + g_string_append (str, "}"); + + /* create a new provider if needed */ + if (*provider == NULL) { + *provider = gtk_css_provider_new (); + g_signal_connect (*provider, "parsing-error", + G_CALLBACK (gs_utils_widget_css_parsing_error_cb), NULL); + } + + /* set the custom CSS class */ + gtk_style_context_add_class (context, class_name); + + /* set up custom provider and store on the widget */ + gtk_css_provider_load_from_data (*provider, str->str, -1); + gtk_style_context_add_provider (context, GTK_STYLE_PROVIDER (*provider), + GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); +} + +static void +unset_focus (GtkWidget *widget, gpointer data) +{ + if (GTK_IS_WINDOW (widget)) + gtk_window_set_focus (GTK_WINDOW (widget), NULL); +} + +/** + * insert_details_widget: + * @dialog: the message dialog where the widget will be inserted + * @details: the detailed message text to display + * + * Inserts a widget displaying the detailed message into the message dialog. + */ +static void +insert_details_widget (GtkMessageDialog *dialog, + const gchar *details, + gboolean add_prefix) +{ + GtkWidget *message_area, *sw, *label; + GtkWidget *tv; + GtkWidget *child; + GtkTextBuffer *buffer; + g_autoptr(GString) msg = NULL; + + g_assert (GTK_IS_MESSAGE_DIALOG (dialog)); + g_assert (details != NULL); + + gtk_window_set_resizable (GTK_WINDOW (dialog), TRUE); + + if (add_prefix) { + msg = g_string_new (""); + g_string_append_printf (msg, "%s\n\n%s", + /* TRANSLATORS: these are show_detailed_error messages from the + * package manager no mortal is supposed to understand, + * but google might know what they mean */ + _("Detailed errors from the package manager follow:"), + details); + } + + message_area = gtk_message_dialog_get_message_area (dialog); + g_assert (GTK_IS_BOX (message_area)); + + /* Find the secondary label and set its width_chars. */ + /* Otherwise the label will tend to expand vertically. */ + child = gtk_widget_get_first_child (message_area); + if (child) { + GtkWidget *next = gtk_widget_get_next_sibling (child); + if (next && GTK_IS_LABEL (next)) + gtk_label_set_width_chars (GTK_LABEL (next), 40); + } + + label = gtk_label_new (_("Details")); + gtk_widget_set_halign (label, GTK_ALIGN_START); + gtk_widget_set_visible (label, TRUE); + gtk_box_append (GTK_BOX (message_area), label); + + sw = gtk_scrolled_window_new (); + gtk_scrolled_window_set_policy (GTK_SCROLLED_WINDOW (sw), + GTK_POLICY_NEVER, + GTK_POLICY_AUTOMATIC); + gtk_scrolled_window_set_min_content_height (GTK_SCROLLED_WINDOW (sw), 150); + gtk_widget_set_visible (sw, TRUE); + + tv = gtk_text_view_new (); + buffer = gtk_text_view_get_buffer (GTK_TEXT_VIEW (tv)); + gtk_text_view_set_editable (GTK_TEXT_VIEW (tv), FALSE); + gtk_text_view_set_wrap_mode (GTK_TEXT_VIEW (tv), GTK_WRAP_WORD); + gtk_style_context_add_class (gtk_widget_get_style_context (tv), + "update-failed-details"); + gtk_text_buffer_set_text (buffer, msg ? msg->str : details, -1); + gtk_widget_set_visible (tv, TRUE); + + gtk_scrolled_window_set_child (GTK_SCROLLED_WINDOW (sw), tv); + gtk_widget_set_vexpand (sw, TRUE); + gtk_box_append (GTK_BOX (message_area), sw); + + g_signal_connect (dialog, "map", G_CALLBACK (unset_focus), NULL); +} + +/** + * gs_utils_show_error_dialog: + * @parent: transient parent, or NULL for none + * @title: the title for the dialog + * @msg: the message for the dialog + * @details: (allow-none): the detailed error message, or NULL for none + * + * Shows a message dialog for displaying error messages. + */ +void +gs_utils_show_error_dialog (GtkWindow *parent, + const gchar *title, + const gchar *msg, + const gchar *details) +{ + GtkWidget *dialog; + + dialog = gtk_message_dialog_new_with_markup (parent, + 0, + GTK_MESSAGE_INFO, + GTK_BUTTONS_CLOSE, + "<big><b>%s</b></big>", title); + gtk_message_dialog_format_secondary_text (GTK_MESSAGE_DIALOG (dialog), + "%s", msg); + if (details != NULL) + insert_details_widget (GTK_MESSAGE_DIALOG (dialog), details, TRUE); + + g_signal_connect_swapped (dialog, "response", + G_CALLBACK (gtk_window_destroy), + dialog); + gtk_widget_show (dialog); +} + +/** + * gs_utils_ask_user_accepts: + * @parent: (nullable): modal parent, or %NULL for none + * @title: the title for the dialog + * @msg: the message for the dialog + * @details: (nullable): the detailed error message, or %NULL for none + * @accept_label: (nullable): a label of the 'accept' button, or %NULL to use 'Accept' + * + * Shows a modal question dialog for displaying an accept/cancel question to the user. + * + * Returns: whether the user accepted the question + * + * Since: 42 + **/ +gboolean +gs_utils_ask_user_accepts (GtkWindow *parent, + const gchar *title, + const gchar *msg, + const gchar *details, + const gchar *accept_label) +{ + GtkWidget *dialog; + RunInfo run_info; + + g_return_val_if_fail (parent == NULL || GTK_IS_WINDOW (parent), FALSE); + g_return_val_if_fail (title != NULL, FALSE); + g_return_val_if_fail (msg != NULL, FALSE); + + if (accept_label == NULL || *accept_label == '\0') { + /* Translators: an accept button label, in a Cancel/Accept dialog */ + accept_label = _("_Accept"); + } + + dialog = gtk_message_dialog_new_with_markup (parent, + GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT, + GTK_MESSAGE_QUESTION, + GTK_BUTTONS_NONE, + "<big><b>%s</b></big>", title); + gtk_message_dialog_format_secondary_text (GTK_MESSAGE_DIALOG (dialog), + "%s", msg); + if (details != NULL) + insert_details_widget (GTK_MESSAGE_DIALOG (dialog), details, FALSE); + gtk_dialog_add_button (GTK_DIALOG (dialog), _("_Cancel"), GTK_RESPONSE_CANCEL); + gtk_dialog_add_button (GTK_DIALOG (dialog), accept_label, GTK_RESPONSE_OK); + + run_info.response_id = GTK_RESPONSE_NONE; + run_info.loop = g_main_loop_new (NULL, FALSE); + + /* Run */ + if (!gtk_widget_get_visible (dialog)) + gtk_window_present (GTK_WINDOW (dialog)); + + g_signal_connect (dialog, "close-request", G_CALLBACK (close_requested_cb), &run_info); + g_signal_connect (dialog, "response", G_CALLBACK (response_cb), &run_info); + g_signal_connect (dialog, "unmap", G_CALLBACK (unmap_cb), &run_info); + + g_main_loop_run (run_info.loop); + g_clear_pointer (&run_info.loop, g_main_loop_unref); + + return run_info.response_id == GTK_RESPONSE_OK; +} + +/** + * gs_utils_get_error_value: + * @error: A GError + * + * Gets the machine-readable value stored in the error message. + * The machine readable string is after the first "@", e.g. + * message = "Requires authentication with @aaa" + * + * Returns: a string, or %NULL + */ +const gchar * +gs_utils_get_error_value (const GError *error) +{ + gchar *str; + if (error == NULL) + return NULL; + str = g_strstr_len (error->message, -1, "@"); + if (str == NULL) + return NULL; + return (const gchar *) str + 1; +} + +/** + * gs_utils_build_unique_id_kind: + * @kind: A #AsComponentKind + * @id: An application ID + * + * Converts the ID valid into a wildcard unique ID of a specific kind. + * If @id is already a unique ID, then it is returned unchanged. + * + * Returns: (transfer full): a unique ID, or %NULL + */ +gchar * +gs_utils_build_unique_id_kind (AsComponentKind kind, const gchar *id) +{ + if (as_utils_data_id_valid (id)) + return g_strdup (id); + return gs_utils_build_unique_id (AS_COMPONENT_SCOPE_UNKNOWN, + AS_BUNDLE_KIND_UNKNOWN, + NULL, + id, + NULL); +} + +/** + * gs_utils_list_has_component_fuzzy: + * @list: A #GsAppList + * @app: A #GsApp + * + * Finds out if any application in the list would match a given application, + * where the match is valid for a matching D-Bus bus name, + * the label in the UI or the same icon. + * + * This function is normally used to work out if the source should be shown + * in a GsAppRow. + * + * Returns: %TRUE if the app is visually the "same" + */ +gboolean +gs_utils_list_has_component_fuzzy (GsAppList *list, GsApp *app) +{ + guint i; + GsApp *tmp; + + for (i = 0; i < gs_app_list_length (list); i++) { + tmp = gs_app_list_index (list, i); + + /* ignore if the same object */ + if (app == tmp) + continue; + + /* ignore with the same source */ + if (g_strcmp0 (gs_app_get_origin_hostname (tmp), + gs_app_get_origin_hostname (app)) == 0) { + continue; + } + + /* same D-Bus ID */ + if (g_strcmp0 (gs_app_get_id (tmp), + gs_app_get_id (app)) == 0) { + return TRUE; + } + + /* same name */ + if (g_strcmp0 (gs_app_get_name (tmp), + gs_app_get_name (app)) == 0) { + return TRUE; + } + } + return FALSE; +} + +void +gs_utils_reboot_notify (GsAppList *list, + gboolean is_install) +{ + g_autoptr(GNotification) n = NULL; + g_autofree gchar *tmp = NULL; + const gchar *app_name = NULL; + const gchar *title; + const gchar *body; + + if (gs_app_list_length (list) == 1) { + GsApp *app = gs_app_list_index (list, 0); + if (gs_app_get_kind (app) == AS_COMPONENT_KIND_DESKTOP_APP) { + app_name = gs_app_get_name (app); + if (!*app_name) + app_name = NULL; + } + } + + if (is_install) { + if (app_name) { + /* TRANSLATORS: The '%s' is replaced with the application name */ + tmp = g_strdup_printf ("An application “%s” has been installed", app_name); + title = tmp; + } else { + /* TRANSLATORS: we've just live-updated some apps */ + title = ngettext ("An update has been installed", + "Updates have been installed", + gs_app_list_length (list)); + } + } else if (app_name) { + /* TRANSLATORS: The '%s' is replaced with the application name */ + tmp = g_strdup_printf ("An application “%s” has been removed", app_name); + title = tmp; + } else { + /* TRANSLATORS: we've just removed some apps */ + title = ngettext ("An application has been removed", + "Applications have been removed", + gs_app_list_length (list)); + } + + /* TRANSLATORS: the new apps will not be run until we restart */ + body = ngettext ("A restart is required for it to take effect.", + "A restart is required for them to take effect.", + gs_app_list_length (list)); + + n = g_notification_new (title); + g_notification_set_body (n, body); + /* TRANSLATORS: button text */ + g_notification_add_button (n, _("Not Now"), "app.nop"); + /* TRANSLATORS: button text */ + g_notification_add_button_with_target (n, _("Restart"), "app.reboot", NULL); + g_notification_set_default_action_and_target (n, "app.set-mode", "s", "updates"); + g_notification_set_priority (n, G_NOTIFICATION_PRIORITY_URGENT); + #ifdef TESTDATADIR + g_application_send_notification (g_application_get_default (), "restart-required", n); + #else + gs_application_send_notification (GS_APPLICATION (g_application_get_default ()), "restart-required", n, 0); + #endif +} + +/** + * gs_utils_split_time_difference: + * @unix_time_seconds: Time since the epoch in seconds + * @out_minutes_ago: (out) (nullable): how many minutes elapsed + * @out_hours_ago: (out) (nullable): how many hours elapsed + * @out_days_ago: (out) (nullable): how many days elapsed + * @out_weeks_ago: (out) (nullable): how many weeks elapsed + * @out_months_ago: (out) (nullable): how many months elapsed + * @out_years_ago: (out) (nullable): how many years elapsed + * + * Calculates the difference between the @unix_time_seconds and the current time + * and splits it into separate values. + * + * Returns: whether the out parameters had been set + * + * Since: 41 + **/ +gboolean +gs_utils_split_time_difference (gint64 unix_time_seconds, + gint *out_minutes_ago, + gint *out_hours_ago, + gint *out_days_ago, + gint *out_weeks_ago, + gint *out_months_ago, + gint *out_years_ago) +{ + gint minutes_ago, hours_ago, days_ago; + gint weeks_ago, months_ago, years_ago; + g_autoptr(GDateTime) date_time = NULL; + g_autoptr(GDateTime) now = NULL; + GTimeSpan timespan; + + if (unix_time_seconds <= 0) + return FALSE; + + date_time = g_date_time_new_from_unix_local (unix_time_seconds); + now = g_date_time_new_now_local (); + timespan = g_date_time_difference (now, date_time); + + minutes_ago = (gint) (timespan / G_TIME_SPAN_MINUTE); + hours_ago = (gint) (timespan / G_TIME_SPAN_HOUR); + days_ago = (gint) (timespan / G_TIME_SPAN_DAY); + weeks_ago = days_ago / 7; + months_ago = days_ago / 30; + years_ago = weeks_ago / 52; + + if (out_minutes_ago) + *out_minutes_ago = minutes_ago; + if (out_hours_ago) + *out_hours_ago = hours_ago; + if (out_days_ago) + *out_days_ago = days_ago; + if (out_weeks_ago) + *out_weeks_ago = weeks_ago; + if (out_months_ago) + *out_months_ago = months_ago; + if (out_years_ago) + *out_years_ago = years_ago; + + return TRUE; +} + +/** + * gs_utils_time_to_string: + * @unix_time_seconds: Time since the epoch in seconds + * + * Converts a time to a string such as "5 minutes ago" or "2 weeks ago" + * + * Returns: (transfer full): the time string, or %NULL if @unix_time_seconds is + * not valid + */ +gchar * +gs_utils_time_to_string (gint64 unix_time_seconds) +{ + gint minutes_ago, hours_ago, days_ago; + gint weeks_ago, months_ago, years_ago; + + if (!gs_utils_split_time_difference (unix_time_seconds, + &minutes_ago, &hours_ago, &days_ago, + &weeks_ago, &months_ago, &years_ago)) + return NULL; + + if (minutes_ago < 5) { + /* TRANSLATORS: something happened less than 5 minutes ago */ + return g_strdup (_("Just now")); + } else if (hours_ago < 1) + return g_strdup_printf (ngettext ("%d minute ago", + "%d minutes ago", minutes_ago), + minutes_ago); + else if (days_ago < 1) + return g_strdup_printf (ngettext ("%d hour ago", + "%d hours ago", hours_ago), + hours_ago); + else if (days_ago < 15) + return g_strdup_printf (ngettext ("%d day ago", + "%d days ago", days_ago), + days_ago); + else if (weeks_ago < 8) + return g_strdup_printf (ngettext ("%d week ago", + "%d weeks ago", weeks_ago), + weeks_ago); + else if (years_ago < 1) + return g_strdup_printf (ngettext ("%d month ago", + "%d months ago", months_ago), + months_ago); + else + return g_strdup_printf (ngettext ("%d year ago", + "%d years ago", years_ago), + years_ago); +} + +static void +gs_utils_reboot_call_done_cb (GObject *source, + GAsyncResult *res, + gpointer user_data) +{ + g_autoptr(GError) local_error = NULL; + + /* get result */ + if (gs_utils_invoke_reboot_finish (source, res, &local_error)) + return; + if (g_error_matches (local_error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) + g_debug ("Calling reboot had been cancelled"); + else if (local_error != NULL) + g_warning ("Calling reboot failed: %s", local_error->message); +} + +static void +gs_utils_invoke_reboot_ready3_cb (GObject *source_object, + GAsyncResult *result, + gpointer user_data) +{ + g_autoptr(GTask) task = user_data; + g_autoptr(GVariant) ret_val = NULL; + g_autoptr(GError) local_error = NULL; + + ret_val = g_dbus_connection_call_finish (G_DBUS_CONNECTION (source_object), result, &local_error); + if (ret_val != NULL) { + g_task_return_boolean (task, TRUE); + } else { + const gchar *method_name = g_task_get_task_data (task); + g_dbus_error_strip_remote_error (local_error); + g_prefix_error (&local_error, "Failed to call %s: ", method_name); + g_task_return_error (task, g_steal_pointer (&local_error)); + } +} + +static void +gs_utils_invoke_reboot_ready2_got_session_bus_cb (GObject *source_object, + GAsyncResult *result, + gpointer user_data) +{ + g_autoptr(GTask) task = user_data; + g_autoptr(GDBusConnection) bus = NULL; + g_autoptr(GError) local_error = NULL; + GCancellable *cancellable; + + bus = g_bus_get_finish (result, &local_error); + if (bus == NULL) { + g_dbus_error_strip_remote_error (local_error); + g_prefix_error_literal (&local_error, "Failed to get D-Bus session bus: "); + g_task_return_error (task, g_steal_pointer (&local_error)); + return; + } + + cancellable = g_task_get_cancellable (task); + + /* Make sure file buffers are written to the disk before invoking reboot */ + sync (); + + g_task_set_task_data (task, (gpointer) "org.gnome.SessionManager.Reboot", NULL); + g_dbus_connection_call (bus, + "org.gnome.SessionManager", + "/org/gnome/SessionManager", + "org.gnome.SessionManager", + "Reboot", + NULL, NULL, G_DBUS_CALL_FLAGS_NONE, + G_MAXINT, cancellable, + gs_utils_invoke_reboot_ready3_cb, + g_steal_pointer (&task)); +} + +static void +gs_utils_invoke_reboot_ready2_cb (GObject *source_object, + GAsyncResult *result, + gpointer user_data) +{ + g_autoptr(GTask) task = user_data; + g_autoptr(GVariant) ret_val = NULL; + g_autoptr(GError) local_error = NULL; + + ret_val = g_dbus_connection_call_finish (G_DBUS_CONNECTION (source_object), result, &local_error); + if (ret_val != NULL) { + g_task_return_boolean (task, TRUE); + } else { + g_autoptr(GDBusConnection) bus = NULL; + GCancellable *cancellable; + const gchar *method_name = g_task_get_task_data (task); + + g_dbus_error_strip_remote_error (local_error); + g_prefix_error (&local_error, "Failed to call %s: ", method_name); + + if (g_error_matches (local_error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) { + g_task_return_error (task, g_steal_pointer (&local_error)); + return; + } + + g_debug ("%s", local_error->message); + g_clear_error (&local_error); + + cancellable = g_task_get_cancellable (task); + + g_bus_get (G_BUS_TYPE_SESSION, cancellable, + gs_utils_invoke_reboot_ready2_got_session_bus_cb, + g_steal_pointer (&task)); + } +} + +static void +gs_utils_invoke_reboot_ready1_got_system_bus_cb (GObject *source_object, + GAsyncResult *result, + gpointer user_data) +{ + g_autoptr(GTask) task = user_data; + g_autoptr(GDBusConnection) bus = NULL; + g_autoptr(GError) local_error = NULL; + GCancellable *cancellable; + + bus = g_bus_get_finish (result, &local_error); + if (bus == NULL) { + g_dbus_error_strip_remote_error (local_error); + g_prefix_error_literal (&local_error, "Failed to get D-Bus system bus: "); + g_task_return_error (task, g_steal_pointer (&local_error)); + return; + } + + cancellable = g_task_get_cancellable (task); + + /* Make sure file buffers are written to the disk before invoking reboot */ + sync (); + + g_task_set_task_data (task, (gpointer) "org.freedesktop.login1.Manager.Reboot", NULL); + g_dbus_connection_call (bus, + "org.freedesktop.login1", + "/org/freedesktop/login1", + "org.freedesktop.login1.Manager", + "Reboot", + g_variant_new ("(b)", TRUE), /* interactive */ + NULL, G_DBUS_CALL_FLAGS_NONE, + G_MAXINT, cancellable, + gs_utils_invoke_reboot_ready2_cb, + g_steal_pointer (&task)); +} + +static void +gs_utils_invoke_reboot_ready1_cb (GObject *source_object, + GAsyncResult *result, + gpointer user_data) +{ + g_autoptr(GTask) task = user_data; + g_autoptr(GVariant) ret_val = NULL; + g_autoptr(GError) local_error = NULL; + + ret_val = g_dbus_connection_call_finish (G_DBUS_CONNECTION (source_object), result, &local_error); + if (ret_val != NULL) { + g_task_return_boolean (task, TRUE); + } else { + GCancellable *cancellable; + const gchar *method_name = g_task_get_task_data (task); + + g_dbus_error_strip_remote_error (local_error); + g_prefix_error (&local_error, "Failed to call %s: ", method_name); + + if (g_error_matches (local_error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) { + g_task_return_error (task, g_steal_pointer (&local_error)); + return; + } + + g_debug ("%s", local_error->message); + g_clear_error (&local_error); + + cancellable = g_task_get_cancellable (task); + + g_bus_get (G_BUS_TYPE_SYSTEM, cancellable, + gs_utils_invoke_reboot_ready1_got_system_bus_cb, + g_steal_pointer (&task)); + } +} + +static void +gs_utils_invoke_reboot_got_session_bus_cb (GObject *source_object, + GAsyncResult *result, + gpointer user_data) +{ + g_autoptr(GTask) task = user_data; + g_autoptr(GDBusConnection) bus = NULL; + g_autoptr(GError) local_error = NULL; + GCancellable *cancellable; + const gchar *xdg_desktop; + gboolean call_session_manager = FALSE; + + bus = g_bus_get_finish (result, &local_error); + if (bus == NULL) { + g_dbus_error_strip_remote_error (local_error); + g_prefix_error_literal (&local_error, "Failed to get D-Bus session bus: "); + g_task_return_error (task, g_steal_pointer (&local_error)); + return; + } + + /* Make sure file buffers are written to the disk before invoking reboot */ + sync (); + + cancellable = g_task_get_cancellable (task); + + xdg_desktop = g_getenv ("XDG_CURRENT_DESKTOP"); + if (xdg_desktop != NULL) { + if (strstr (xdg_desktop, "KDE")) { + g_task_set_task_data (task, (gpointer) "org.kde.Shutdown.logoutAndReboot", NULL); + g_dbus_connection_call (bus, + "org.kde.Shutdown", + "/Shutdown", + "org.kde.Shutdown", + "logoutAndReboot", + NULL, NULL, G_DBUS_CALL_FLAGS_NONE, + G_MAXINT, cancellable, + gs_utils_invoke_reboot_ready1_cb, + g_steal_pointer (&task)); + } else if (strstr (xdg_desktop, "LXDE")) { + g_task_set_task_data (task, (gpointer) "org.lxde.SessionManager.RequestReboot", NULL); + g_dbus_connection_call (bus, + "org.lxde.SessionManager", + "/org/lxde/SessionManager", + "org.lxde.SessionManager", + "RequestReboot", + NULL, NULL, G_DBUS_CALL_FLAGS_NONE, + G_MAXINT, cancellable, + gs_utils_invoke_reboot_ready1_cb, + g_steal_pointer (&task)); + } else if (strstr (xdg_desktop, "MATE")) { + g_task_set_task_data (task, (gpointer) "org.gnome.SessionManager.RequestReboot", NULL); + g_dbus_connection_call (bus, + "org.gnome.SessionManager", + "/org/gnome/SessionManager", + "org.gnome.SessionManager", + "RequestReboot", + NULL, NULL, G_DBUS_CALL_FLAGS_NONE, + G_MAXINT, cancellable, + gs_utils_invoke_reboot_ready1_cb, + g_steal_pointer (&task)); + } else if (strstr (xdg_desktop, "XFCE")) { + g_task_set_task_data (task, (gpointer) "org.xfce.Session.Manager.Restart", NULL); + g_dbus_connection_call (bus, + "org.xfce.SessionManager", + "/org/xfce/SessionManager", + "org.xfce.Session.Manager", + "Restart", + g_variant_new ("(b)", TRUE), /* allow_save */ + NULL, G_DBUS_CALL_FLAGS_NONE, + G_MAXINT, cancellable, + gs_utils_invoke_reboot_ready1_cb, + g_steal_pointer (&task)); + } else { + /* Let the "GNOME" and "X-Cinnamon" be the default */ + call_session_manager = TRUE; + } + } else { + call_session_manager = TRUE; + } + + if (call_session_manager) { + g_task_set_task_data (task, (gpointer) "org.gnome.SessionManager.Reboot", NULL); + g_dbus_connection_call (bus, + "org.gnome.SessionManager", + "/org/gnome/SessionManager", + "org.gnome.SessionManager", + "Reboot", + NULL, NULL, G_DBUS_CALL_FLAGS_NONE, + G_MAXINT, cancellable, + gs_utils_invoke_reboot_ready3_cb, + g_steal_pointer (&task)); + } +} + +/** + * gs_utils_invoke_reboot_async: + * @cancellable: (nullable): a %GCancellable for the call, or %NULL + * @ready_callback: (nullable): a callback to be called after the call is finished, or %NULL + * @user_data: user data for the @ready_callback + * + * Asynchronously invokes a reboot request. Finish the operation + * with gs_utils_invoke_reboot_finish(). + * + * When the @ready_callback is %NULL, a default callback is used, which shows + * a runtime warning (using g_warning) on the console when the call fails. + * + * Since: 41 + **/ +void +gs_utils_invoke_reboot_async (GCancellable *cancellable, + GAsyncReadyCallback ready_callback, + gpointer user_data) +{ + g_autoptr(GTask) task = NULL; + + if (!ready_callback) + ready_callback = gs_utils_reboot_call_done_cb; + + task = g_task_new (NULL, cancellable, ready_callback, user_data); + g_task_set_source_tag (task, gs_utils_invoke_reboot_async); + + g_bus_get (G_BUS_TYPE_SESSION, cancellable, + gs_utils_invoke_reboot_got_session_bus_cb, + g_steal_pointer (&task)); +} + +/** + * gs_utils_invoke_reboot_finish: + * @source_object: the source object provided in the ready callback + * @result: the result object provided in the ready callback + * @error: a #GError, or %NULL + * + * Finishes gs_utils_invoke_reboot_async() call. + * + * Returns: Whether succeeded. If failed, the @error is set. + * + * Since: 43 + **/ +gboolean +gs_utils_invoke_reboot_finish (GObject *source_object, + GAsyncResult *result, + GError **error) +{ + g_return_val_if_fail (G_IS_TASK (result), FALSE); + g_return_val_if_fail (g_task_is_valid (result, source_object), FALSE); + g_return_val_if_fail (g_task_get_source_tag (G_TASK (result)) == gs_utils_invoke_reboot_async, FALSE); + + return g_task_propagate_boolean (G_TASK (result), error); +} + +/** + * gs_utils_format_size: + * @size_bytes: size to format, in bytes + * @out_is_markup: (out) (not nullable): stores whther the returned string is a markup + * + * Similar to `g_format_size`, only splits the value and the unit into + * separate parts and draws the unit with a smaller font, in case + * the relevant code is available in GLib while compiling. + * + * The @out_is_markup is always set, providing the information about + * used format of the returned string. + * + * Returns: (transfer full): a new string, containing the @size_bytes formatted as string + * + * Since: 43 + **/ +gchar * +gs_utils_format_size (guint64 size_bytes, + gboolean *out_is_markup) +{ +#ifdef HAVE_G_FORMAT_SIZE_ONLY_VALUE + g_autofree gchar *value_str = g_format_size_full (size_bytes, G_FORMAT_SIZE_ONLY_VALUE); + g_autofree gchar *unit_str = g_format_size_full (size_bytes, G_FORMAT_SIZE_ONLY_UNIT); + g_autofree gchar *value_escaped = g_markup_escape_text (value_str, -1); + g_autofree gchar *unit_escaped = g_markup_printf_escaped ("<span font_size='x-small'>%s</span>", unit_str); + g_return_val_if_fail (out_is_markup != NULL, NULL); + *out_is_markup = TRUE; + /* Translators: This is to construct a disk size string consisting of the value and its unit, while + * the unit is drawn with a smaller font. If you need to flip the order, then you can use "%2$s %1$s". + * Make sure you'll preserve the no break space between the values. + * Example result: "13.0 MB" */ + return g_strdup_printf (C_("format-size", "%s\302\240%s"), value_escaped, unit_escaped); +#else /* HAVE_G_FORMAT_SIZE_ONLY_VALUE */ + g_return_val_if_fail (out_is_markup != NULL, NULL); + *out_is_markup = FALSE; + return g_format_size (size_bytes); +#endif /* HAVE_G_FORMAT_SIZE_ONLY_VALUE */ +} diff --git a/src/gs-common.h b/src/gs-common.h new file mode 100644 index 0000000..f7292e9 --- /dev/null +++ b/src/gs-common.h @@ -0,0 +1,71 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2016 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <gio/gdesktopappinfo.h> +#include <gtk/gtk.h> + +#include "gnome-software-private.h" + +G_BEGIN_DECLS + +typedef void (*GsRemoveFunc) (GtkWidget *container, + GtkWidget *child); + +void gs_widget_remove_all (GtkWidget *container, + GsRemoveFunc remove_func); +void gs_grab_focus_when_mapped (GtkWidget *widget); + +void gs_app_notify_installed (GsApp *app); +GtkResponseType + gs_app_notify_unavailable (GsApp *app, + GtkWindow *parent); + +gboolean gs_utils_is_current_desktop (const gchar *name); +gchar *gs_utils_set_key_colors_in_css (const gchar *css, + GsApp *app); +void gs_utils_widget_set_css (GtkWidget *widget, + GtkCssProvider **provider, + const gchar *class_name, + const gchar *css); +const gchar *gs_utils_get_error_value (const GError *error); +void gs_utils_show_error_dialog (GtkWindow *parent, + const gchar *title, + const gchar *msg, + const gchar *details); +gboolean gs_utils_ask_user_accepts (GtkWindow *parent, + const gchar *title, + const gchar *msg, + const gchar *details, + const gchar *accept_label); +gchar *gs_utils_build_unique_id_kind (AsComponentKind kind, + const gchar *id); +gboolean gs_utils_list_has_component_fuzzy (GsAppList *list, + GsApp *app); +void gs_utils_reboot_notify (GsAppList *list, + gboolean is_install); +gchar *gs_utils_time_to_string (gint64 unix_time_seconds); +void gs_utils_invoke_reboot_async (GCancellable *cancellable, + GAsyncReadyCallback ready_callback, + gpointer user_data); +gboolean gs_utils_invoke_reboot_finish (GObject *source_object, + GAsyncResult *result, + GError **error); +gboolean gs_utils_split_time_difference (gint64 unix_time_seconds, + gint *out_minutes_ago, + gint *out_hours_ago, + gint *out_days_ago, + gint *out_weeks_ago, + gint *out_months_ago, + gint *out_years_ago); +gchar *gs_utils_format_size (guint64 size_bytes, + gboolean *out_is_markup); + +G_END_DECLS diff --git a/src/gs-context-dialog-row.c b/src/gs-context-dialog-row.c new file mode 100644 index 0000000..420816f --- /dev/null +++ b/src/gs-context-dialog-row.c @@ -0,0 +1,379 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2021 Endless OS Foundation LLC + * + * Author: Philip Withnall <pwithnall@endlessos.org> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +/** + * SECTION:gs-context-dialog-row + * @short_description: A list box row for context dialogs + * + * #GsContextDialogRow is a #GtkListBox row designed to be used in context + * dialogs such as #GsHardwareSupportContextDialog. Each row indicates how well + * the app supports a certain feature, attribute or permission. Each row + * contains an image in a lozenge, a title, a description, and has an + * ‘importance’ which is primarily indicated through the colour of the image. + * + * Since: 41 + */ + +#include "config.h" + +#include <adwaita.h> +#include <glib.h> +#include <glib-object.h> +#include <glib/gi18n.h> +#include <gtk/gtk.h> + +#include "gs-context-dialog-row.h" +#include "gs-lozenge.h" +#include "gs-enums.h" + +struct _GsContextDialogRow +{ + AdwActionRow parent_instance; + + GsContextDialogRowImportance importance; + + GsLozenge *lozenge; /* (unowned) */ +}; + +G_DEFINE_TYPE (GsContextDialogRow, gs_context_dialog_row, ADW_TYPE_ACTION_ROW) + +typedef enum { + PROP_ICON_NAME = 1, + PROP_CONTENT, + PROP_IMPORTANCE, +} GsContextDialogRowProperty; + +static GParamSpec *obj_props[PROP_IMPORTANCE + 1] = { NULL, }; + +/* These match the CSS classes from gtk-style.css. */ +static const gchar * +css_class_for_importance (GsContextDialogRowImportance importance) +{ + switch (importance) { + case GS_CONTEXT_DIALOG_ROW_IMPORTANCE_NEUTRAL: + return "grey"; + case GS_CONTEXT_DIALOG_ROW_IMPORTANCE_UNIMPORTANT: + return "green"; + case GS_CONTEXT_DIALOG_ROW_IMPORTANCE_WARNING: + return "yellow"; + case GS_CONTEXT_DIALOG_ROW_IMPORTANCE_IMPORTANT: + return "red"; + default: + g_assert_not_reached (); + } +} + +static void +gs_context_dialog_row_init (GsContextDialogRow *self) +{ + gtk_widget_init_template (GTK_WIDGET (self)); + +#if ADW_CHECK_VERSION(1,2,0) + adw_preferences_row_set_use_markup (ADW_PREFERENCES_ROW (self), FALSE); +#endif +} + +static void +gs_context_dialog_row_get_property (GObject *object, + guint prop_id, + GValue *value, + GParamSpec *pspec) +{ + GsContextDialogRow *self = GS_CONTEXT_DIALOG_ROW (object); + + switch ((GsContextDialogRowProperty) prop_id) { + case PROP_ICON_NAME: + g_value_set_string (value, gs_context_dialog_row_get_icon_name (self)); + break; + case PROP_CONTENT: + g_value_set_string (value, gs_context_dialog_row_get_content (self)); + break; + case PROP_IMPORTANCE: + g_value_set_enum (value, gs_context_dialog_row_get_importance (self)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_context_dialog_row_set_property (GObject *object, + guint prop_id, + const GValue *value, + GParamSpec *pspec) +{ + GsContextDialogRow *self = GS_CONTEXT_DIALOG_ROW (object); + + switch ((GsContextDialogRowProperty) prop_id) { + case PROP_ICON_NAME: + gs_lozenge_set_icon_name (self->lozenge, g_value_get_string (value)); + break; + case PROP_CONTENT: + gs_lozenge_set_text (self->lozenge, g_value_get_string (value)); + break; + case PROP_IMPORTANCE: { + GtkStyleContext *context; + const gchar *css_class; + + self->importance = g_value_get_enum (value); + css_class = css_class_for_importance (self->importance); + + context = gtk_widget_get_style_context (GTK_WIDGET (self->lozenge)); + + gtk_style_context_remove_class (context, "green"); + gtk_style_context_remove_class (context, "yellow"); + gtk_style_context_remove_class (context, "red"); + gtk_style_context_remove_class (context, "grey"); + + gtk_style_context_add_class (context, css_class); + break; + } + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_context_dialog_row_class_init (GsContextDialogRowClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + + object_class->get_property = gs_context_dialog_row_get_property; + object_class->set_property = gs_context_dialog_row_set_property; + + /** + * GsContextDialogRow:icon-name: (nullable) + * + * Name of the icon to display in the row. + * + * This must be %NULL if #GsContextDialogRow:content is set, + * and non-%NULL otherwise. + * + * Since: 41 + */ + obj_props[PROP_ICON_NAME] = + g_param_spec_string ("icon-name", NULL, NULL, + NULL, + G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS); + + /** + * GsContextDialogRow:content: (nullable) + * + * Text content to display in the row. + * + * This must be %NULL if #GsContextDialogRow:icon-name is set, + * and non-%NULL otherwise. + * + * Since: 41 + */ + obj_props[PROP_CONTENT] = + g_param_spec_string ("content", NULL, NULL, + NULL, + G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS); + + /** + * GsContextDialogRow:importance: + * + * Importance of the information in the row to the user’s decision + * making about an app. This is primarily represented as the row’s + * colour. + * + * Since: 41 + */ + obj_props[PROP_IMPORTANCE] = + g_param_spec_enum ("importance", NULL, NULL, + GS_TYPE_CONTEXT_DIALOG_ROW_IMPORTANCE, GS_CONTEXT_DIALOG_ROW_IMPORTANCE_NEUTRAL, + G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS); + + g_object_class_install_properties (object_class, G_N_ELEMENTS (obj_props), obj_props); + + /* This uses the same CSS name as a standard #GtkListBoxRow in order to + * get the default styling from GTK. */ + gtk_widget_class_set_css_name (widget_class, "row"); + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-context-dialog-row.ui"); + + gtk_widget_class_bind_template_child (widget_class, GsContextDialogRow, lozenge); +} + +/** + * gs_context_dialog_row_new: + * @icon_name: (not nullable): name of the icon for the row + * @importance: importance of the information in the row + * @title: (not nullable): title for the row + * @description: (not nullable): description for the row + * + * Create a new #GsContextDialogRow with an icon inside the lozenge. + * + * Returns: (transfer full): a new #GsContextDialogRow + * Since: 41 + */ +GtkListBoxRow * +gs_context_dialog_row_new (const gchar *icon_name, + GsContextDialogRowImportance importance, + const gchar *title, + const gchar *description) +{ + g_return_val_if_fail (icon_name != NULL, NULL); + g_return_val_if_fail (title != NULL, NULL); + g_return_val_if_fail (description != NULL, NULL); + + return g_object_new (GS_TYPE_CONTEXT_DIALOG_ROW, + "icon-name", icon_name, + "importance", importance, + "title", title, + "subtitle", description, + NULL); +} + +/** + * gs_context_dialog_row_new_text: + * @content: (not nullable): text to put in the lozenge + * @importance: importance of the information in the row + * @title: (not nullable): title for the row + * @description: (not nullable): description for the row + * + * Create a new #GsContextDialogRow with text inside the lozenge. + * + * Returns: (transfer full): a new #GsContextDialogRow + * Since: 41 + */ +GtkListBoxRow * +gs_context_dialog_row_new_text (const gchar *content, + GsContextDialogRowImportance importance, + const gchar *title, + const gchar *description) +{ + g_return_val_if_fail (content != NULL, NULL); + g_return_val_if_fail (title != NULL, NULL); + g_return_val_if_fail (description != NULL, NULL); + + return g_object_new (GS_TYPE_CONTEXT_DIALOG_ROW, + "content", content, + "importance", importance, + "title", title, + "subtitle", description, + NULL); +} + +/** + * gs_context_dialog_row_get_icon_name: + * @self: a #GsContextDialogRow + * + * Get the value of #GsContextDialogRow:icon-name. + * + * Returns: the name of the icon used in the row + * Since: 41 + */ +const gchar * +gs_context_dialog_row_get_icon_name (GsContextDialogRow *self) +{ + g_return_val_if_fail (GS_IS_CONTEXT_DIALOG_ROW (self), NULL); + + return gs_lozenge_get_icon_name (self->lozenge); +} + +/** + * gs_context_dialog_row_get_content: + * @self: a #GsContextDialogRow + * + * Get the value of #GsContextDialogRow:content. + * + * Returns: the text content used in the row + * Since: 41 + */ +const gchar * +gs_context_dialog_row_get_content (GsContextDialogRow *self) +{ + g_return_val_if_fail (GS_IS_CONTEXT_DIALOG_ROW (self), NULL); + + return gs_lozenge_get_text (self->lozenge); +} + +/** + * gs_context_dialog_row_get_content_is_markup: + * @self: a #GsContextDialogRow + * + * Get whether the #GsContextDialogRow:content is markup. + * + * Returns: %TRUE when then content text is markup + * Since: 43 + */ +gboolean +gs_context_dialog_row_get_content_is_markup (GsContextDialogRow *self) +{ + g_return_val_if_fail (GS_IS_CONTEXT_DIALOG_ROW (self), FALSE); + + return gs_lozenge_get_use_markup (self->lozenge); +} + +/** + * gs_context_dialog_row_set_content_markup: + * @self: a #GsContextDialogRow + * @markup: markup to set + * + * Set the @markup content as markup. + * + * Since: 43 + */ +void +gs_context_dialog_row_set_content_markup (GsContextDialogRow *self, + const gchar *markup) +{ + g_return_if_fail (GS_IS_CONTEXT_DIALOG_ROW (self)); + + gs_lozenge_set_markup (self->lozenge, markup); +} + +/** + * gs_context_dialog_row_get_importance: + * @self: a #GsContextDialogRow + * + * Get the value of #GsContextDialogRow:importance. + * + * Returns: the importance of the information in the row + * Since: 41 + */ +GsContextDialogRowImportance +gs_context_dialog_row_get_importance (GsContextDialogRow *self) +{ + g_return_val_if_fail (GS_IS_CONTEXT_DIALOG_ROW (self), GS_CONTEXT_DIALOG_ROW_IMPORTANCE_NEUTRAL); + + return self->importance; +} + +/** + * gs_context_dialog_row_set_size_groups: + * @self: a #GsContextDialogRow + * @lozenge: (nullable) (transfer none): a #GtkSizeGroup for the lozenge, or %NULL + * @title: (nullable) (transfer none): a #GtkSizeGroup for the title, or %NULL + * @description: (nullable) (transfer none): a #GtkSizeGroup for the description, or %NULL + * + * Add widgets from the #GsContextDialogRow to the given size groups. If a size + * group is %NULL, the corresponding widget will not be changed. + * + * Since: 41 + */ +void +gs_context_dialog_row_set_size_groups (GsContextDialogRow *self, + GtkSizeGroup *lozenge, + GtkSizeGroup *title, + GtkSizeGroup *description) +{ + g_return_if_fail (GS_IS_CONTEXT_DIALOG_ROW (self)); + g_return_if_fail (lozenge == NULL || GTK_IS_SIZE_GROUP (lozenge)); + g_return_if_fail (title == NULL || GTK_IS_SIZE_GROUP (title)); + g_return_if_fail (description == NULL || GTK_IS_SIZE_GROUP (description)); + + if (lozenge != NULL) + gtk_size_group_add_widget (lozenge, GTK_WIDGET (self->lozenge)); +} diff --git a/src/gs-context-dialog-row.h b/src/gs-context-dialog-row.h new file mode 100644 index 0000000..d5d68fd --- /dev/null +++ b/src/gs-context-dialog-row.h @@ -0,0 +1,69 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2021 Endless OS Foundation LLC + * + * Author: Philip Withnall <pwithnall@endlessos.org> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <adwaita.h> +#include <glib.h> +#include <glib-object.h> +#include <gtk/gtk.h> + +G_BEGIN_DECLS + +/** + * GsContextDialogRowImportance: + * @GS_CONTEXT_DIALOG_ROW_IMPORTANCE_NEUTRAL: neutral or unknown importance + * @GS_CONTEXT_DIALOG_ROW_IMPORTANCE_UNIMPORTANT: unimportant + * @GS_CONTEXT_DIALOG_ROW_IMPORTANCE_WARNING: a bit important + * @GS_CONTEXT_DIALOG_ROW_IMPORTANCE_IMPORTANT: definitely important + * + * The importance of the information in a #GsContextDialogRow. The values + * increase from less important to more important. + * + * Since: 41 + */ +typedef enum +{ + /* The code in this file relies on the fact that these enum values + * numerically increase as they get more important. */ + GS_CONTEXT_DIALOG_ROW_IMPORTANCE_NEUTRAL, + GS_CONTEXT_DIALOG_ROW_IMPORTANCE_UNIMPORTANT, + GS_CONTEXT_DIALOG_ROW_IMPORTANCE_WARNING, + GS_CONTEXT_DIALOG_ROW_IMPORTANCE_IMPORTANT, +} GsContextDialogRowImportance; + +#define GS_TYPE_CONTEXT_DIALOG_ROW (gs_context_dialog_row_get_type ()) + +G_DECLARE_FINAL_TYPE (GsContextDialogRow, gs_context_dialog_row, GS, CONTEXT_DIALOG_ROW, AdwActionRow) + +GtkListBoxRow *gs_context_dialog_row_new (const gchar *icon_name, + GsContextDialogRowImportance importance, + const gchar *title, + const gchar *description); +GtkListBoxRow *gs_context_dialog_row_new_text (const gchar *content, + GsContextDialogRowImportance importance, + const gchar *title, + const gchar *description); + +const gchar *gs_context_dialog_row_get_icon_name (GsContextDialogRow *self); +const gchar *gs_context_dialog_row_get_content (GsContextDialogRow *self); +GsContextDialogRowImportance gs_context_dialog_row_get_importance (GsContextDialogRow *self); +gboolean gs_context_dialog_row_get_content_is_markup + (GsContextDialogRow *self); +void gs_context_dialog_row_set_content_markup + (GsContextDialogRow *self, + const gchar *markup); + +void gs_context_dialog_row_set_size_groups (GsContextDialogRow *self, + GtkSizeGroup *lozenge, + GtkSizeGroup *title, + GtkSizeGroup *description); + +G_END_DECLS diff --git a/src/gs-context-dialog-row.ui b/src/gs-context-dialog-row.ui new file mode 100644 index 0000000..47a78df --- /dev/null +++ b/src/gs-context-dialog-row.ui @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <requires lib="gtk+" version="3.10"/> + <template class="GsContextDialogRow" parent="AdwActionRow"> + <property name="activatable">False</property> + <property name="focusable">False</property> + + <child type="prefix"> + <object class="GsLozenge" id="lozenge"> + <property name="circular">True</property> + <property name="pixel-size">16</property> + <property name="margin-top">8</property> + <property name="margin-bottom">8</property> + <style> + <class name="grey"/> + </style> + </object> + </child> + </template> +</interface> diff --git a/src/gs-css.c b/src/gs-css.c new file mode 100644 index 0000000..ff81855 --- /dev/null +++ b/src/gs-css.c @@ -0,0 +1,308 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2017 Richard Hughes <richard@hughsie.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +/** + * SECTION:gs-css + * @title: GsCss + * @stability: Unstable + * @short_description: Parse, validate and rewrite CSS resources + */ + +#include "config.h" + +#include <gtk/gtk.h> +#include <appstream.h> + +#include "gs-css.h" + +struct _GsCss +{ + GObject parent_instance; + GHashTable *ids; + GsCssRewriteFunc rewrite_func; + gpointer rewrite_func_data; +}; + +G_DEFINE_TYPE (GsCss, gs_css, G_TYPE_OBJECT) + +static void +_cleanup_string (GString *str) +{ + /* remove leading newlines */ + while (g_str_has_prefix (str->str, "\n") || g_str_has_prefix (str->str, " ")) + g_string_erase (str, 0, 1); + + /* remove trailing newlines */ + while (g_str_has_suffix (str->str, "\n") || g_str_has_suffix (str->str, " ")) + g_string_truncate (str, str->len - 1); +} + +/** + * gs_css_parse: + * @self: a #GsCss + * @markup: come CSS, or %NULL + * @error: a #GError or %NULL + * + * Parses the CSS markup and does some basic validation checks on the input. + * + * Returns: %TRUE for success + */ +gboolean +gs_css_parse (GsCss *self, const gchar *markup, GError **error) +{ + g_auto(GStrv) parts = NULL; + g_autoptr(GString) markup_str = NULL; + + /* no data */ + if (markup == NULL || markup[0] == '\0') + return TRUE; + + /* old style, no IDs */ + markup_str = g_string_new (markup); + as_gstring_replace (markup_str, "@datadir@", DATADIR); + if (!g_str_has_prefix (markup_str->str, "#")) { + g_hash_table_insert (self->ids, + g_strdup ("tile"), + g_string_free (g_steal_pointer (&markup_str), FALSE)); + return TRUE; + } + + /* split up CSS into ID chunks, e.g. + * + * #tile {border-radius: 0;} + * #name {color: white;} + */ + parts = g_strsplit (markup_str->str + 1, "\n#", -1); + for (guint i = 0; parts[i] != NULL; i++) { + g_autoptr(GString) current_css = NULL; + g_autoptr(GString) current_key = NULL; + for (guint j = 1; parts[i][j] != '\0'; j++) { + const gchar ch = parts[i][j]; + if (ch == '{') { + if (current_key != NULL || current_css != NULL) { + g_set_error_literal (error, + G_IO_ERROR, + G_IO_ERROR_INVALID_DATA, + "invalid '{'"); + return FALSE; + } + current_key = g_string_new_len (parts[i], j); + current_css = g_string_new (NULL); + _cleanup_string (current_key); + + /* already added */ + if (g_hash_table_lookup (self->ids, current_key->str) != NULL) { + g_set_error (error, + G_IO_ERROR, + G_IO_ERROR_INVALID_DATA, + "duplicate ID '%s'", + current_key->str); + return FALSE; + } + continue; + } + if (ch == '}') { + if (current_key == NULL || current_css == NULL) { + g_set_error_literal (error, + G_IO_ERROR, + G_IO_ERROR_INVALID_DATA, + "invalid '}'"); + return FALSE; + } + _cleanup_string (current_css); + g_hash_table_insert (self->ids, + g_string_free (current_key, FALSE), + g_string_free (current_css, FALSE)); + current_key = NULL; + current_css = NULL; + continue; + } + if (current_css != NULL) + g_string_append_c (current_css, ch); + } + if (current_key != NULL || current_css != NULL) { + g_set_error_literal (error, + G_IO_ERROR, + G_IO_ERROR_INVALID_DATA, + "missing '}'"); + return FALSE; + } + } + + /* success */ + return TRUE; +} + +/** + * gs_css_get_markup_for_id: + * @self: a #GsCss + * @id: an ID, or %NULL for the default + * + * Gets the CSS markup for a specific ID. + * + * Returns: %TRUE for success + */ +const gchar * +gs_css_get_markup_for_id (GsCss *self, const gchar *id) +{ + if (id == NULL) + id = "tile"; + return g_hash_table_lookup (self->ids, id); +} + +static void +_css_parsing_error_cb (GtkCssProvider *provider, + GtkCssSection *section, + GError *error, + gpointer user_data) +{ + GError **error_parse = (GError **) user_data; + if (*error_parse != NULL) { + const GtkCssLocation *start_location; + + start_location = gtk_css_section_get_start_location (section); + g_warning ("ignoring parse error %" G_GSIZE_FORMAT ":%" G_GSIZE_FORMAT ": %s", + start_location->lines + 1, + start_location->line_chars, + error->message); + return; + } + *error_parse = g_error_copy (error); +} + +static gboolean +gs_css_validate_part (GsCss *self, const gchar *markup, GError **error) +{ + g_autofree gchar *markup_new = NULL; + g_autoptr(GError) error_parse = NULL; + g_autoptr(GString) str = NULL; + g_autoptr(GtkCssProvider) provider = NULL; + + /* nothing set */ + if (markup == NULL) + return TRUE; + + /* remove custom class if NULL */ + str = g_string_new (NULL); + g_string_append (str, ".themed-widget {"); + if (self->rewrite_func != NULL) { + markup_new = self->rewrite_func (self->rewrite_func_data, + markup, + error); + if (markup_new == NULL) + return FALSE; + } else { + markup_new = g_strdup (markup); + } + g_string_append (str, markup_new); + g_string_append (str, "}"); + + /* set up custom provider */ + provider = gtk_css_provider_new (); + g_signal_connect (provider, "parsing-error", + G_CALLBACK (_css_parsing_error_cb), &error_parse); + gtk_style_context_add_provider_for_display (gdk_display_get_default (), + GTK_STYLE_PROVIDER (provider), + GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); + gtk_css_provider_load_from_data (provider, str->str, -1); + if (error_parse != NULL) { + if (error != NULL) + *error = g_error_copy (error_parse); + return FALSE; + } + return TRUE; +} + +/** + * gs_css_validate: + * @self: a #GsCss + * @error: a #GError or %NULL + * + * Validates each part of the CSS markup. + * + * Returns: %TRUE for success + */ +gboolean +gs_css_validate (GsCss *self, GError **error) +{ + g_autoptr(GList) keys = NULL; + + /* check each CSS ID */ + keys = g_hash_table_get_keys (self->ids); + for (GList *l = keys; l != NULL; l = l->next) { + const gchar *tmp; + const gchar *id = l->data; + if (g_strcmp0 (id, "tile") != 0 && + g_strcmp0 (id, "name") != 0 && + g_strcmp0 (id, "summary") != 0) { + g_set_error (error, + G_IO_ERROR, + G_IO_ERROR_INVALID_DATA, + "Invalid CSS ID '%s'", + id); + return FALSE; + } + tmp = g_hash_table_lookup (self->ids, id); + if (!gs_css_validate_part (self, tmp, error)) + return FALSE; + } + + /* success */ + return TRUE; +} + +/** + * gs_css_set_rewrite_func: + * @self: a #GsCss + * @func: a #GsCssRewriteFunc or %NULL + * @user_data: user data to pass to @func + * + * Sets a function to be used when rewriting CSS before it is parsed. + * + * Returns: %TRUE for success + */ +void +gs_css_set_rewrite_func (GsCss *self, GsCssRewriteFunc func, gpointer user_data) +{ + self->rewrite_func = func; + self->rewrite_func_data = user_data; +} + +static void +gs_css_finalize (GObject *object) +{ + GsCss *self = GS_CSS (object); + g_hash_table_unref (self->ids); + G_OBJECT_CLASS (gs_css_parent_class)->finalize (object); +} + +static void +gs_css_class_init (GsCssClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + object_class->finalize = gs_css_finalize; +} + +static void +gs_css_init (GsCss *self) +{ + self->ids = g_hash_table_new_full (g_str_hash, g_str_equal, g_free, g_free); +} + +/** + * gs_css_new: + * + * Return value: a new #GsCss object. + **/ +GsCss * +gs_css_new (void) +{ + GsCss *self; + self = g_object_new (GS_TYPE_CSS, NULL); + return GS_CSS (self); +} diff --git a/src/gs-css.h b/src/gs-css.h new file mode 100644 index 0000000..d2041a9 --- /dev/null +++ b/src/gs-css.h @@ -0,0 +1,36 @@ + /* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2017 Richard Hughes <richard@hughsie.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <glib-object.h> +#include <gio/gio.h> + +G_BEGIN_DECLS + +#define GS_TYPE_CSS (gs_css_get_type ()) + +G_DECLARE_FINAL_TYPE (GsCss, gs_css, GS, CSS, GObject) + +typedef gchar *(*GsCssRewriteFunc) (gpointer user_data, + const gchar *markup, + GError **error); + +GsCss *gs_css_new (void); +const gchar *gs_css_get_markup_for_id (GsCss *self, + const gchar *id); +gboolean gs_css_parse (GsCss *self, + const gchar *markup, + GError **error); +gboolean gs_css_validate (GsCss *self, + GError **error); +void gs_css_set_rewrite_func (GsCss *self, + GsCssRewriteFunc func, + gpointer user_data); + +G_END_DECLS diff --git a/src/gs-dbus-helper.c b/src/gs-dbus-helper.c new file mode 100644 index 0000000..7f44ec0 --- /dev/null +++ b/src/gs-dbus-helper.c @@ -0,0 +1,970 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2015-2017 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include <gio/gdesktopappinfo.h> +#include <gio/gio.h> +#include <glib/gi18n.h> +#include <gtk/gtk.h> +#include <packagekit-glib2/packagekit.h> + +#include "gnome-software-private.h" + +#include "gs-application.h" +#include "gs-dbus-helper.h" +#include "gs-packagekit-generated.h" +#include "gs-packagekit-modify2-generated.h" +#include "gs-resources.h" +#include "gs-extras-page.h" + +struct _GsDbusHelper { + GObject parent; + GDBusInterfaceSkeleton *query_interface; + GDBusInterfaceSkeleton *modify_interface; + GDBusInterfaceSkeleton *modify2_interface; + PkTask *task; + guint dbus_own_name_id; + + GDBusConnection *bus_connection; /* (owned) (not nullable) */ +}; + +G_DEFINE_TYPE (GsDbusHelper, gs_dbus_helper, G_TYPE_OBJECT) + +typedef enum { + PROP_BUS_CONNECTION = 1, +} GsDbusHelperProperty; + +static GParamSpec *obj_props[PROP_BUS_CONNECTION + 1] = { NULL, }; + +typedef struct { + GDBusMethodInvocation *invocation; + GsDbusHelper *dbus_helper; + gboolean show_confirm_deps; + gboolean show_confirm_install; + gboolean show_confirm_search; + gboolean show_finished; + gboolean show_progress; + gboolean show_warning; +} GsDbusHelperTask; + +static void +gs_dbus_helper_task_free (GsDbusHelperTask *dtask) +{ + if (dtask->dbus_helper != NULL) + g_object_unref (dtask->dbus_helper); + + g_free (dtask); +} + +static void +gs_dbus_helper_task_set_interaction (GsDbusHelperTask *dtask, const gchar *interaction) +{ + guint i; + g_auto(GStrv) interactions = NULL; + + interactions = g_strsplit (interaction, ",", -1); + for (i = 0; interactions[i] != NULL; i++) { + if (g_strcmp0 (interactions[i], "show-warnings") == 0) + dtask->show_warning = TRUE; + else if (g_strcmp0 (interactions[i], "hide-warnings") == 0) + dtask->show_warning = FALSE; + else if (g_strcmp0 (interactions[i], "show-progress") == 0) + dtask->show_progress = TRUE; + else if (g_strcmp0 (interactions[i], "hide-progress") == 0) + dtask->show_progress = FALSE; + else if (g_strcmp0 (interactions[i], "show-finished") == 0) + dtask->show_finished = TRUE; + else if (g_strcmp0 (interactions[i], "hide-finished") == 0) + dtask->show_finished = FALSE; + else if (g_strcmp0 (interactions[i], "show-confirm-search") == 0) + dtask->show_confirm_search = TRUE; + else if (g_strcmp0 (interactions[i], "hide-confirm-search") == 0) + dtask->show_confirm_search = FALSE; + else if (g_strcmp0 (interactions[i], "show-confirm-install") == 0) + dtask->show_confirm_install = TRUE; + else if (g_strcmp0 (interactions[i], "hide-confirm-install") == 0) + dtask->show_confirm_install = FALSE; + else if (g_strcmp0 (interactions[i], "show-confirm-deps") == 0) + dtask->show_confirm_deps = TRUE; + else if (g_strcmp0 (interactions[i], "hide-confirm-deps") == 0) + dtask->show_confirm_deps = FALSE; + } +} + +static void +gs_dbus_helper_progress_cb (PkProgress *progress, PkProgressType type, gpointer data) +{ +} + +static void +gs_dbus_helper_query_is_installed_cb (GObject *source, GAsyncResult *res, gpointer data) +{ + GsDbusHelperTask *dtask = (GsDbusHelperTask *) data; + PkClient *client = PK_CLIENT (source); + g_autoptr(GError) error = NULL; + g_autoptr(PkError) error_code = NULL; + g_autoptr(PkResults) results = NULL; + g_autoptr(GPtrArray) array = NULL; + + /* get the results */ + results = pk_client_generic_finish (client, res, &error); + if (results == NULL) { + g_dbus_method_invocation_return_error (dtask->invocation, + G_IO_ERROR, + G_IO_ERROR_INVALID_ARGUMENT, + "failed to resolve: %s", + error->message); + goto out; + } + + /* check error code */ + error_code = pk_results_get_error_code (results); + if (error_code != NULL) { + g_dbus_method_invocation_return_error (dtask->invocation, + G_IO_ERROR, + G_IO_ERROR_INVALID_ARGUMENT, + "failed to resolve: %s", + pk_error_get_details (error_code)); + goto out; + } + + /* get results */ + array = pk_results_get_package_array (results); + gs_package_kit_query_complete_is_installed (GS_PACKAGE_KIT_QUERY (dtask->dbus_helper->query_interface), + dtask->invocation, + array->len > 0); +out: + gs_dbus_helper_task_free (dtask); +} + +static void +gs_dbus_helper_query_search_file_cb (GObject *source, GAsyncResult *res, gpointer data) +{ + g_autoptr(GError) error = NULL; + GsDbusHelperTask *dtask = (GsDbusHelperTask *) data; + PkClient *client = PK_CLIENT (source); + PkInfoEnum info; + PkPackage *item; + g_autoptr(GPtrArray) array = NULL; + g_autoptr(PkError) error_code = NULL; + g_autoptr(PkResults) results = NULL; + + /* get the results */ + results = pk_client_generic_finish (client, res, &error); + if (results == NULL) { + g_dbus_method_invocation_return_error (dtask->invocation, + G_IO_ERROR, + G_IO_ERROR_INVALID_ARGUMENT, + "failed to search: %s", + error->message); + return; + } + + /* check error code */ + error_code = pk_results_get_error_code (results); + if (error_code != NULL) { + g_dbus_method_invocation_return_error (dtask->invocation, + G_IO_ERROR, + G_IO_ERROR_INVALID_ARGUMENT, + "failed to search: %s", + pk_error_get_details (error_code)); + return; + } + + /* get results */ + array = pk_results_get_package_array (results); + if (array->len == 0) { + //TODO: org.freedesktop.PackageKit.Query.unknown + g_dbus_method_invocation_return_error (dtask->invocation, + G_IO_ERROR, + G_IO_ERROR_INVALID_ARGUMENT, + "failed to find any packages"); + return; + } + + /* get first item */ + item = g_ptr_array_index (array, 0); + info = pk_package_get_info (item); + gs_package_kit_query_complete_search_file (GS_PACKAGE_KIT_QUERY (dtask->dbus_helper->query_interface), + dtask->invocation, + info == PK_INFO_ENUM_INSTALLED, + pk_package_get_name (item)); +} + +static gboolean +handle_query_search_file (GsPackageKitQuery *skeleton, + GDBusMethodInvocation *invocation, + const gchar *file_name, + const gchar *interaction, + gpointer user_data) +{ + GsDbusHelper *dbus_helper = user_data; + GsDbusHelperTask *dtask; + g_auto(GStrv) names = NULL; + + g_debug ("****** SearchFile"); + + dtask = g_new0 (GsDbusHelperTask, 1); + dtask->dbus_helper = g_object_ref (dbus_helper); + dtask->invocation = invocation; + gs_dbus_helper_task_set_interaction (dtask, interaction); + names = g_strsplit (file_name, "&", -1); + pk_client_search_files_async (PK_CLIENT (dbus_helper->task), + pk_bitfield_value (PK_FILTER_ENUM_NEWEST), + names, NULL, + gs_dbus_helper_progress_cb, dtask, + gs_dbus_helper_query_search_file_cb, dtask); + + return TRUE; +} + +static gboolean +handle_query_is_installed (GsPackageKitQuery *skeleton, + GDBusMethodInvocation *invocation, + const gchar *package_name, + const gchar *interaction, + gpointer user_data) +{ + GsDbusHelper *dbus_helper = user_data; + GsDbusHelperTask *dtask; + g_auto(GStrv) names = NULL; + + g_debug ("****** IsInstalled"); + + dtask = g_new0 (GsDbusHelperTask, 1); + dtask->dbus_helper = g_object_ref (dbus_helper); + dtask->invocation = invocation; + gs_dbus_helper_task_set_interaction (dtask, interaction); + names = g_strsplit (package_name, "|", 1); + pk_client_resolve_async (PK_CLIENT (dbus_helper->task), + pk_bitfield_value (PK_FILTER_ENUM_INSTALLED), + names, NULL, + gs_dbus_helper_progress_cb, dtask, + gs_dbus_helper_query_is_installed_cb, dtask); + + return TRUE; +} + +static gboolean +is_show_confirm_search_set (const gchar *interaction) +{ + GsDbusHelperTask *dtask; + gboolean ret; + + dtask = g_new0 (GsDbusHelperTask, 1); + dtask->show_confirm_search = TRUE; + gs_dbus_helper_task_set_interaction (dtask, interaction); + ret = dtask->show_confirm_search; + gs_dbus_helper_task_free (dtask); + + return ret; +} + +static void +notify_search_resources (GsExtrasPageMode mode, + const gchar *desktop_id, + gchar **resources, + const gchar *ident) +{ + const gchar *app_name = NULL; + const gchar *mode_string; + const gchar *title = NULL; + g_autofree gchar *body = NULL; + g_autoptr(GDesktopAppInfo) app_info = NULL; + g_autoptr(GNotification) n = NULL; + + if (desktop_id != NULL) { + app_info = gs_utils_get_desktop_app_info (desktop_id); + if (app_info != NULL) + app_name = g_app_info_get_name (G_APP_INFO (app_info)); + } + + if (app_name == NULL) { + /* TRANSLATORS: this is a what we use in notifications if the app's name is unknown */ + app_name = _("An application"); + } + + switch (mode) { + case GS_EXTRAS_PAGE_MODE_INSTALL_MIME_TYPES: + /* TRANSLATORS: this is a notification displayed when an app needs additional MIME types. */ + body = g_strdup_printf (_("%s is requesting additional file format support."), app_name); + /* TRANSLATORS: notification title */ + title = _("Additional MIME Types Required"); + break; + case GS_EXTRAS_PAGE_MODE_INSTALL_FONTCONFIG_RESOURCES: + /* TRANSLATORS: this is a notification displayed when an app needs additional fonts. */ + body = g_strdup_printf (_("%s is requesting additional fonts."), app_name); + /* TRANSLATORS: notification title */ + title = _("Additional Fonts Required"); + break; + case GS_EXTRAS_PAGE_MODE_INSTALL_GSTREAMER_RESOURCES: + /* TRANSLATORS: this is a notification displayed when an app needs additional codecs. */ + body = g_strdup_printf (_("%s is requesting additional multimedia codecs."), app_name); + /* TRANSLATORS: notification title */ + title = _("Additional Multimedia Codecs Required"); + break; + case GS_EXTRAS_PAGE_MODE_INSTALL_PRINTER_DRIVERS: + /* TRANSLATORS: this is a notification displayed when an app needs additional printer drivers. */ + body = g_strdup_printf (_("%s is requesting additional printer drivers."), app_name); + /* TRANSLATORS: notification title */ + title = _("Additional Printer Drivers Required"); + break; + default: + /* TRANSLATORS: this is a notification displayed when an app wants to install additional packages. */ + body = g_strdup_printf (_("%s is requesting additional packages."), app_name); + /* TRANSLATORS: notification title */ + title = _("Additional Packages Required"); + break; + } + + mode_string = gs_extras_page_mode_to_string (mode); + + /* Make sure non-NULL values are used */ + if (desktop_id == NULL) + desktop_id = ""; + if (ident == NULL) + ident = ""; + + n = g_notification_new (title); + g_notification_set_body (n, body); + /* TRANSLATORS: this is a button that launches gnome-software */ + g_notification_add_button_with_target (n, _("Find in Software"), "app.install-resources", "(s^assss)", mode_string, resources, "", desktop_id, ident); + g_notification_set_default_action_and_target (n, "app.install-resources", "(s^assss)", mode_string, resources, "", desktop_id, ident); + gs_application_send_notification (GS_APPLICATION (g_application_get_default ()), "install-resources", n, 60); +} + +typedef struct _InstallResourcesData { + void (* done_func) (GsPackageKitModify2 *object, GDBusMethodInvocation *invocation); + GsPackageKitModify2 *object; + GDBusMethodInvocation *invocation; + gchar *ident; + gulong install_resources_done_id; +} InstallResourcesData; + +static void +install_resources_data_free (gpointer data, + GClosure *closure) +{ + InstallResourcesData *ird = data; + + if (ird) { + g_clear_object (&ird->object); + g_clear_object (&ird->invocation); + g_free (ird->ident); + g_slice_free (InstallResourcesData, ird); + } +} + +static void +install_resources_done_cb (GApplication *app, + const gchar *ident, + const GError *op_error, + gpointer user_data) +{ + InstallResourcesData *ird = user_data; + + g_return_if_fail (ird != NULL); + + if (!ident || g_strcmp0 (ird->ident, ident) == 0) { + if (op_error) + g_dbus_method_invocation_return_gerror (ird->invocation, op_error); + else + ird->done_func (ird->object, ird->invocation); + + g_signal_handler_disconnect (app, ird->install_resources_done_id); + } +} + +static void +install_resources (GsExtrasPageMode mode, + gchar **resources, + const gchar *interaction, + const gchar *desktop_id, + GVariant *platform_data, + void (* done_func) (GsPackageKitModify2 *object, GDBusMethodInvocation *invocation), + GsPackageKitModify2 *object, + GDBusMethodInvocation *invocation) +{ + GApplication *app; + const gchar *mode_string; + const gchar *startup_id = NULL; + gchar *ident = NULL; + + app = g_application_get_default (); + + if (done_func) { + InstallResourcesData *ird; + + ident = g_strdup_printf ("%p", invocation); + + ird = g_slice_new (InstallResourcesData); + ird->done_func = done_func; + ird->object = g_object_ref (object); + ird->invocation = g_object_ref (invocation); + ird->ident = ident; /* takes ownership */ + ird->install_resources_done_id = g_signal_connect_data (app, "install-resources-done", + G_CALLBACK (install_resources_done_cb), ird, + install_resources_data_free, 0); + } + + if (is_show_confirm_search_set (interaction)) { + notify_search_resources (mode, desktop_id, resources, ident); + return; + } + + if (platform_data != NULL) { + g_variant_lookup (platform_data, "desktop-startup-id", + "&s", &startup_id); + } + + /* Make sure non-NULL values are used */ + if (desktop_id == NULL) + desktop_id = ""; + if (startup_id == NULL) + startup_id = ""; + + mode_string = gs_extras_page_mode_to_string (mode); + g_action_group_activate_action (G_ACTION_GROUP (app), "install-resources", + g_variant_new ("(s^assss)", mode_string, resources, startup_id, desktop_id, ident ? ident : "")); +} + +static gboolean +handle_modify_install_package_files (GsPackageKitModify *object, + GDBusMethodInvocation *invocation, + guint arg_xid, + gchar **arg_files, + const gchar *arg_interaction, + gpointer user_data) +{ + g_debug ("****** Modify.InstallPackageFiles"); + + notify_search_resources (GS_EXTRAS_PAGE_MODE_INSTALL_PACKAGE_FILES, NULL, arg_files, NULL); + gs_package_kit_modify_complete_install_package_files (object, invocation); + + return TRUE; +} + +static gboolean +handle_modify_install_provide_files (GsPackageKitModify *object, + GDBusMethodInvocation *invocation, + guint arg_xid, + gchar **arg_files, + const gchar *arg_interaction, + gpointer user_data) +{ + g_debug ("****** Modify.InstallProvideFiles"); + + notify_search_resources (GS_EXTRAS_PAGE_MODE_INSTALL_PROVIDE_FILES, NULL, arg_files, NULL); + gs_package_kit_modify_complete_install_provide_files (object, invocation); + + return TRUE; +} + +static gboolean +handle_modify_install_package_names (GsPackageKitModify *object, + GDBusMethodInvocation *invocation, + guint arg_xid, + gchar **arg_package_names, + const gchar *arg_interaction, + gpointer user_data) +{ + g_debug ("****** Modify.InstallPackageNames"); + + notify_search_resources (GS_EXTRAS_PAGE_MODE_INSTALL_PACKAGE_NAMES, NULL, arg_package_names, NULL); + gs_package_kit_modify_complete_install_package_names (object, invocation); + + return TRUE; +} + +static gboolean +handle_modify_install_mime_types (GsPackageKitModify *object, + GDBusMethodInvocation *invocation, + guint arg_xid, + gchar **arg_mime_types, + const gchar *arg_interaction, + gpointer user_data) +{ + g_debug ("****** Modify.InstallMimeTypes"); + + notify_search_resources (GS_EXTRAS_PAGE_MODE_INSTALL_MIME_TYPES, NULL, arg_mime_types, NULL); + gs_package_kit_modify_complete_install_mime_types (object, invocation); + + return TRUE; +} + +static gboolean +handle_modify_install_fontconfig_resources (GsPackageKitModify *object, + GDBusMethodInvocation *invocation, + guint arg_xid, + gchar **arg_resources, + const gchar *arg_interaction, + gpointer user_data) +{ + g_debug ("****** Modify.InstallFontconfigResources"); + + notify_search_resources (GS_EXTRAS_PAGE_MODE_INSTALL_FONTCONFIG_RESOURCES, NULL, arg_resources, NULL); + gs_package_kit_modify_complete_install_fontconfig_resources (object, invocation); + + return TRUE; +} + +static gboolean +handle_modify_install_gstreamer_resources (GsPackageKitModify *object, + GDBusMethodInvocation *invocation, + guint arg_xid, + gchar **arg_resources, + const gchar *arg_interaction, + gpointer user_data) +{ + g_debug ("****** Modify.InstallGStreamerResources"); + + notify_search_resources (GS_EXTRAS_PAGE_MODE_INSTALL_GSTREAMER_RESOURCES, NULL, arg_resources, NULL); + gs_package_kit_modify_complete_install_gstreamer_resources (object, invocation); + + return TRUE; +} + +static gboolean +handle_modify_install_resources (GsPackageKitModify *object, + GDBusMethodInvocation *invocation, + guint arg_xid, + const gchar *arg_type, + gchar **arg_resources, + const gchar *arg_interaction, + gpointer user_data) +{ + gboolean ret; + + g_debug ("****** Modify.InstallResources"); + + if (g_strcmp0 (arg_type, "plasma-service") == 0) { + notify_search_resources (GS_EXTRAS_PAGE_MODE_INSTALL_PLASMA_RESOURCES, NULL, arg_resources, NULL); + ret = TRUE; + } else { + ret = FALSE; + } + gs_package_kit_modify_complete_install_resources (object, invocation); + + return ret; +} + +static gboolean +handle_modify_install_printer_drivers (GsPackageKitModify *object, + GDBusMethodInvocation *invocation, + guint arg_xid, + gchar **arg_device_ids, + const gchar *arg_interaction, + gpointer user_data) +{ + g_debug ("****** Modify.InstallPrinterDrivers"); + + notify_search_resources (GS_EXTRAS_PAGE_MODE_INSTALL_PRINTER_DRIVERS, NULL, arg_device_ids, NULL); + gs_package_kit_modify_complete_install_printer_drivers (object, invocation); + + return TRUE; +} + +static gboolean +handle_modify2_install_package_files (GsPackageKitModify2 *object, + GDBusMethodInvocation *invocation, + gchar **arg_files, + const gchar *arg_interaction, + const gchar *arg_desktop_id, + GVariant *arg_platform_data, + gpointer user_data) +{ + g_debug ("****** Modify2.InstallPackageFiles"); + + install_resources (GS_EXTRAS_PAGE_MODE_INSTALL_PACKAGE_FILES, arg_files, arg_interaction, arg_desktop_id, arg_platform_data, + gs_package_kit_modify2_complete_install_package_files, object, invocation); + + return TRUE; +} + +static gboolean +handle_modify2_install_provide_files (GsPackageKitModify2 *object, + GDBusMethodInvocation *invocation, + gchar **arg_files, + const gchar *arg_interaction, + const gchar *arg_desktop_id, + GVariant *arg_platform_data, + gpointer user_data) +{ + g_debug ("****** Modify2.InstallProvideFiles"); + + install_resources (GS_EXTRAS_PAGE_MODE_INSTALL_PROVIDE_FILES, arg_files, arg_interaction, arg_desktop_id, arg_platform_data, + gs_package_kit_modify2_complete_install_provide_files, object, invocation); + + return TRUE; +} + +static gboolean +handle_modify2_install_package_names (GsPackageKitModify2 *object, + GDBusMethodInvocation *invocation, + gchar **arg_package_names, + const gchar *arg_interaction, + const gchar *arg_desktop_id, + GVariant *arg_platform_data, + gpointer user_data) +{ + g_debug ("****** Modify2.InstallPackageNames"); + + install_resources (GS_EXTRAS_PAGE_MODE_INSTALL_PACKAGE_NAMES, arg_package_names, arg_interaction, arg_desktop_id, arg_platform_data, + gs_package_kit_modify2_complete_install_package_names, object, invocation); + + return TRUE; +} + +static gboolean +handle_modify2_install_mime_types (GsPackageKitModify2 *object, + GDBusMethodInvocation *invocation, + gchar **arg_mime_types, + const gchar *arg_interaction, + const gchar *arg_desktop_id, + GVariant *arg_platform_data, + gpointer user_data) +{ + g_debug ("****** Modify2.InstallMimeTypes"); + + install_resources (GS_EXTRAS_PAGE_MODE_INSTALL_MIME_TYPES, arg_mime_types, arg_interaction, arg_desktop_id, arg_platform_data, + gs_package_kit_modify2_complete_install_mime_types, object, invocation); + + return TRUE; +} + +static gboolean +handle_modify2_install_fontconfig_resources (GsPackageKitModify2 *object, + GDBusMethodInvocation *invocation, + gchar **arg_resources, + const gchar *arg_interaction, + const gchar *arg_desktop_id, + GVariant *arg_platform_data, + gpointer user_data) +{ + g_debug ("****** Modify2.InstallFontconfigResources"); + + install_resources (GS_EXTRAS_PAGE_MODE_INSTALL_FONTCONFIG_RESOURCES, arg_resources, arg_interaction, arg_desktop_id, arg_platform_data, + gs_package_kit_modify2_complete_install_fontconfig_resources, object, invocation); + + return TRUE; +} + +static gboolean +handle_modify2_install_gstreamer_resources (GsPackageKitModify2 *object, + GDBusMethodInvocation *invocation, + gchar **arg_resources, + const gchar *arg_interaction, + const gchar *arg_desktop_id, + GVariant *arg_platform_data, + gpointer user_data) +{ + g_debug ("****** Modify2.InstallGStreamerResources"); + + install_resources (GS_EXTRAS_PAGE_MODE_INSTALL_GSTREAMER_RESOURCES, arg_resources, arg_interaction, arg_desktop_id, arg_platform_data, + gs_package_kit_modify2_complete_install_gstreamer_resources, object, invocation); + + return TRUE; +} + +static gboolean +handle_modify2_install_resources (GsPackageKitModify2 *object, + GDBusMethodInvocation *invocation, + const gchar *arg_type, + gchar **arg_resources, + const gchar *arg_interaction, + const gchar *arg_desktop_id, + GVariant *arg_platform_data, + gpointer user_data) +{ + gboolean ret; + + g_debug ("****** Modify2.InstallResources"); + + if (g_strcmp0 (arg_type, "plasma-service") == 0) { + install_resources (GS_EXTRAS_PAGE_MODE_INSTALL_PLASMA_RESOURCES, arg_resources, arg_interaction, arg_desktop_id, arg_platform_data, + gs_package_kit_modify2_complete_install_resources, object, invocation); + ret = TRUE; + } else { + ret = FALSE; + gs_package_kit_modify2_complete_install_resources (object, invocation); + } + + return ret; +} + +static gboolean +handle_modify2_install_printer_drivers (GsPackageKitModify2 *object, + GDBusMethodInvocation *invocation, + gchar **arg_device_ids, + const gchar *arg_interaction, + const gchar *arg_desktop_id, + GVariant *arg_platform_data, + gpointer user_data) +{ + g_debug ("****** Modify2.InstallPrinterDrivers"); + + install_resources (GS_EXTRAS_PAGE_MODE_INSTALL_PRINTER_DRIVERS, arg_device_ids, arg_interaction, arg_desktop_id, arg_platform_data, + gs_package_kit_modify2_complete_install_printer_drivers, object, invocation); + + return TRUE; +} + +static void +gs_dbus_helper_name_acquired_cb (GDBusConnection *connection, + const gchar *name, + gpointer user_data) +{ + g_debug ("acquired session service"); +} + +static void +gs_dbus_helper_name_lost_cb (GDBusConnection *connection, + const gchar *name, + gpointer user_data) +{ + g_warning ("lost session service"); +} + +static void +export_objects (GsDbusHelper *dbus_helper) +{ + g_autoptr(GDesktopAppInfo) app_info = NULL; + g_autoptr(GError) error = NULL; + + /* Query interface */ + dbus_helper->query_interface = G_DBUS_INTERFACE_SKELETON (gs_package_kit_query_skeleton_new ()); + + g_signal_connect (dbus_helper->query_interface, "handle-is-installed", + G_CALLBACK (handle_query_is_installed), dbus_helper); + g_signal_connect (dbus_helper->query_interface, "handle-search-file", + G_CALLBACK (handle_query_search_file), dbus_helper); + + if (!g_dbus_interface_skeleton_export (dbus_helper->query_interface, + dbus_helper->bus_connection, + "/org/freedesktop/PackageKit", + &error)) { + g_warning ("Could not export dbus interface: %s", error->message); + return; + } + + /* Modify interface */ + dbus_helper->modify_interface = G_DBUS_INTERFACE_SKELETON (gs_package_kit_modify_skeleton_new ()); + + g_signal_connect (dbus_helper->modify_interface, "handle-install-package-files", + G_CALLBACK (handle_modify_install_package_files), dbus_helper); + g_signal_connect (dbus_helper->modify_interface, "handle-install-provide-files", + G_CALLBACK (handle_modify_install_provide_files), dbus_helper); + g_signal_connect (dbus_helper->modify_interface, "handle-install-package-names", + G_CALLBACK (handle_modify_install_package_names), dbus_helper); + g_signal_connect (dbus_helper->modify_interface, "handle-install-mime-types", + G_CALLBACK (handle_modify_install_mime_types), dbus_helper); + g_signal_connect (dbus_helper->modify_interface, "handle-install-fontconfig-resources", + G_CALLBACK (handle_modify_install_fontconfig_resources), dbus_helper); + g_signal_connect (dbus_helper->modify_interface, "handle-install-gstreamer-resources", + G_CALLBACK (handle_modify_install_gstreamer_resources), dbus_helper); + g_signal_connect (dbus_helper->modify_interface, "handle-install-resources", + G_CALLBACK (handle_modify_install_resources), dbus_helper); + g_signal_connect (dbus_helper->modify_interface, "handle-install-printer-drivers", + G_CALLBACK (handle_modify_install_printer_drivers), dbus_helper); + + if (!g_dbus_interface_skeleton_export (dbus_helper->modify_interface, + dbus_helper->bus_connection, + "/org/freedesktop/PackageKit", + &error)) { + g_warning ("Could not export dbus interface: %s", error->message); + return; + } + + /* Modify2 interface */ + dbus_helper->modify2_interface = G_DBUS_INTERFACE_SKELETON (gs_package_kit_modify2_skeleton_new ()); + + g_signal_connect (dbus_helper->modify2_interface, "handle-install-package-files", + G_CALLBACK (handle_modify2_install_package_files), dbus_helper); + g_signal_connect (dbus_helper->modify2_interface, "handle-install-provide-files", + G_CALLBACK (handle_modify2_install_provide_files), dbus_helper); + g_signal_connect (dbus_helper->modify2_interface, "handle-install-package-names", + G_CALLBACK (handle_modify2_install_package_names), dbus_helper); + g_signal_connect (dbus_helper->modify2_interface, "handle-install-mime-types", + G_CALLBACK (handle_modify2_install_mime_types), dbus_helper); + g_signal_connect (dbus_helper->modify2_interface, "handle-install-fontconfig-resources", + G_CALLBACK (handle_modify2_install_fontconfig_resources), dbus_helper); + g_signal_connect (dbus_helper->modify2_interface, "handle-install-gstreamer-resources", + G_CALLBACK (handle_modify2_install_gstreamer_resources), dbus_helper); + g_signal_connect (dbus_helper->modify2_interface, "handle-install-resources", + G_CALLBACK (handle_modify2_install_resources), dbus_helper); + g_signal_connect (dbus_helper->modify2_interface, "handle-install-printer-drivers", + G_CALLBACK (handle_modify2_install_printer_drivers), dbus_helper); + + /* Look up our own localized name and export it as a property on the bus */ + app_info = g_desktop_app_info_new ("org.gnome.Software.desktop"); + if (app_info != NULL) { + const gchar *app_name = g_app_info_get_name (G_APP_INFO (app_info)); + if (app_name != NULL) + g_object_set (G_OBJECT (dbus_helper->modify2_interface), + "display-name", app_name, + NULL); + } + + if (!g_dbus_interface_skeleton_export (dbus_helper->modify2_interface, + dbus_helper->bus_connection, + "/org/freedesktop/PackageKit", + &error)) { + g_warning ("Could not export dbus interface: %s", error->message); + return; + } + + dbus_helper->dbus_own_name_id = g_bus_own_name_on_connection (dbus_helper->bus_connection, + "org.freedesktop.PackageKit", + G_BUS_NAME_OWNER_FLAGS_NONE, + gs_dbus_helper_name_acquired_cb, + gs_dbus_helper_name_lost_cb, + NULL, NULL); +} + +static void +gs_dbus_helper_init (GsDbusHelper *dbus_helper) +{ + dbus_helper->task = pk_task_new (); +} + +static void +gs_dbus_helper_constructed (GObject *object) +{ + GsDbusHelper *dbus_helper = GS_DBUS_HELPER (object); + + G_OBJECT_CLASS (gs_dbus_helper_parent_class)->constructed (object); + + /* Check all required properties have been set. */ + g_assert (dbus_helper->bus_connection != NULL); + + /* Export the objects. + * + * FIXME: This is failable and asynchronous, so should really happen + * as the result of an explicit method call on some + * gs_dbus_helper_start_async() call or similar, but that can wait until + * a future refactoring. */ + export_objects (dbus_helper); +} + +static void +gs_dbus_helper_get_property (GObject *object, + guint prop_id, + GValue *value, + GParamSpec *pspec) +{ + GsDbusHelper *dbus_helper = GS_DBUS_HELPER (object); + + switch ((GsDbusHelperProperty) prop_id) { + case PROP_BUS_CONNECTION: + g_value_set_object (value, dbus_helper->bus_connection); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_dbus_helper_set_property (GObject *object, + guint prop_id, + const GValue *value, + GParamSpec *pspec) +{ + GsDbusHelper *dbus_helper = GS_DBUS_HELPER (object); + + switch ((GsDbusHelperProperty) prop_id) { + case PROP_BUS_CONNECTION: + /* Construct only */ + g_assert (dbus_helper->bus_connection == NULL); + dbus_helper->bus_connection = g_value_dup_object (value); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_dbus_helper_dispose (GObject *object) +{ + GsDbusHelper *dbus_helper = GS_DBUS_HELPER (object); + + if (dbus_helper->dbus_own_name_id != 0) { + g_bus_unown_name (dbus_helper->dbus_own_name_id); + dbus_helper->dbus_own_name_id = 0; + } + + if (dbus_helper->query_interface != NULL) { + g_dbus_interface_skeleton_unexport (dbus_helper->query_interface); + g_clear_object (&dbus_helper->query_interface); + } + + if (dbus_helper->modify_interface != NULL) { + g_dbus_interface_skeleton_unexport (dbus_helper->modify_interface); + g_clear_object (&dbus_helper->modify_interface); + } + + if (dbus_helper->modify2_interface != NULL) { + g_dbus_interface_skeleton_unexport (dbus_helper->modify2_interface); + g_clear_object (&dbus_helper->modify2_interface); + } + + g_clear_object (&dbus_helper->task); + g_clear_object (&dbus_helper->bus_connection); + + G_OBJECT_CLASS (gs_dbus_helper_parent_class)->dispose (object); +} + +static void +gs_dbus_helper_class_init (GsDbusHelperClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + + object_class->constructed = gs_dbus_helper_constructed; + object_class->get_property = gs_dbus_helper_get_property; + object_class->set_property = gs_dbus_helper_set_property; + object_class->dispose = gs_dbus_helper_dispose; + + /** + * GsDbusHelper:bus-connection: (not nullable) + * + * A connection to the D-Bus session bus. + * + * This must be set at construction time and will not be %NULL + * afterwards. + * + * Since: 43 + */ + obj_props[PROP_BUS_CONNECTION] = + g_param_spec_object ("bus-connection", NULL, NULL, + G_TYPE_DBUS_CONNECTION, + G_PARAM_CONSTRUCT_ONLY | G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY); + + g_object_class_install_properties (object_class, G_N_ELEMENTS (obj_props), obj_props); +} + +/** + * gs_dbus_helper_new: + * @bus_connection: a #GDBusConnection to export the helper methods on + * + * Create a new #GsDbusHelper and export it on @bus_connection. + * + * Returns: (transfer full): a new #GsDbusHelper + * Since: 43 + */ +GsDbusHelper * +gs_dbus_helper_new (GDBusConnection *bus_connection) +{ + g_return_val_if_fail (G_IS_DBUS_CONNECTION (bus_connection), NULL); + + return GS_DBUS_HELPER (g_object_new (GS_TYPE_DBUS_HELPER, + "bus-connection", bus_connection, + NULL)); +} diff --git a/src/gs-dbus-helper.h b/src/gs-dbus-helper.h new file mode 100644 index 0000000..12ea252 --- /dev/null +++ b/src/gs-dbus-helper.h @@ -0,0 +1,23 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2015 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <gio/gio.h> +#include <glib-object.h> + +G_BEGIN_DECLS + +#define GS_TYPE_DBUS_HELPER (gs_dbus_helper_get_type ()) + +G_DECLARE_FINAL_TYPE (GsDbusHelper, gs_dbus_helper, GS, DBUS_HELPER, GObject) + +GsDbusHelper *gs_dbus_helper_new (GDBusConnection *bus_connection); + +G_END_DECLS diff --git a/src/gs-description-box.c b/src/gs-description-box.c new file mode 100644 index 0000000..72acd06 --- /dev/null +++ b/src/gs-description-box.c @@ -0,0 +1,358 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2020 Red Hat <www.redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +/** + * SECTION:gs-desription-box + * @title: GsDescriptionBox + * @stability: Stable + * @short_description: Show description text in a way that can show more/less lines + * + * Show a description in an expandable form with "Show More" button when + * there are too many lines to be shown. The button is hidden when + * the description is short enough. The button changes to "Show Less" + * to be able to collapse it. + */ + +#include "config.h" + +#include <glib/gi18n.h> + +#include "gs-description-box.h" + +#define MAX_COLLAPSED_LINES 4 + +struct _GsDescriptionBox { + GtkWidget parent; + GtkWidget *box; + GtkLabel *label; + GtkButton *button; + gchar *text; + gboolean is_collapsed; + gboolean needs_recalc; + gint last_width; + gint last_height; + guint idle_update_id; +}; + +G_DEFINE_TYPE (GsDescriptionBox, gs_description_box, GTK_TYPE_WIDGET) + +static void +gs_description_box_update_content (GsDescriptionBox *box) +{ + GtkAllocation allocation; + PangoLayout *layout; + gint n_lines; + const gchar *text; + + if (!box->text || !*(box->text)) { + gtk_widget_hide (GTK_WIDGET (box)); + box->needs_recalc = TRUE; + return; + } + + gtk_widget_get_allocation (GTK_WIDGET (box), &allocation); + + if (!box->needs_recalc && box->last_width == allocation.width && box->last_height == allocation.height) + return; + + box->needs_recalc = allocation.width <= 1 || allocation.height <= 1; + box->last_width = allocation.width; + box->last_height = allocation.height; + + text = box->is_collapsed ? _("_Show More") : _("_Show Less"); + /* FIXME: Work around a flickering issue in GTK: + * https://gitlab.gnome.org/GNOME/gtk/-/merge_requests/3949 */ + if (g_strcmp0 (text, gtk_button_get_label (box->button)) != 0) + gtk_button_set_label (box->button, text); + + gtk_label_set_markup (box->label, box->text); + gtk_label_set_lines (box->label, -1); + gtk_label_set_ellipsize (box->label, PANGO_ELLIPSIZE_NONE); + + layout = gtk_label_get_layout (box->label); + n_lines = pango_layout_get_line_count (layout); + + gtk_widget_set_visible (GTK_WIDGET (box->button), n_lines > MAX_COLLAPSED_LINES); + + if (box->is_collapsed && n_lines > MAX_COLLAPSED_LINES) { + PangoLayoutLine *line; + GString *str; + GSList *opened_markup = NULL; + gint start_index, line_index, in_markup = 0; + + line = pango_layout_get_line_readonly (layout, MAX_COLLAPSED_LINES); + + line_index = line->start_index; + + /* Pango does not count markup in the text, thus calculate the position manually */ + for (start_index = 0; box->text[start_index] && line_index > 0; start_index++) { + if (box->text[start_index] == '<') { + if (box->text[start_index + 1] == '/') { + g_autofree gchar *value = opened_markup->data; + opened_markup = g_slist_remove (opened_markup, value); + } else { + const gchar *end = strchr (box->text + start_index, '>'); + opened_markup = g_slist_prepend (opened_markup, g_strndup (box->text + start_index + 1, end - (box->text + start_index) - 1)); + } + in_markup++; + } else if (box->text[start_index] == '>') { + g_warn_if_fail (in_markup > 0); + in_markup--; + } else if (!in_markup) { + /* Encoded characters count as one */ + if (box->text[start_index] == '&') { + const gchar *end = strchr (box->text + start_index, ';'); + if (end) + start_index += end - box->text - start_index; + } + + line_index--; + } + } + str = g_string_sized_new (start_index); + g_string_append_len (str, box->text, start_index); + + /* Cut white spaces from the end of the string, thus it doesn't look bad when it's ellipsized. */ + while (str->len > 0 && strchr ("\r\n\t ", str->str[str->len - 1])) { + str->len--; + } + + str->str[str->len] = '\0'; + + /* Close any opened tags after cutting the text */ + for (GSList *link = opened_markup; link; link = g_slist_next (link)) { + const gchar *tag = link->data; + g_string_append_printf (str, "</%s>", tag); + } + + gtk_label_set_lines (box->label, MAX_COLLAPSED_LINES); + gtk_label_set_ellipsize (box->label, PANGO_ELLIPSIZE_END); + gtk_label_set_markup (box->label, str->str); + + g_slist_free_full (opened_markup, g_free); + g_string_free (str, TRUE); + } +} + +static void +gs_description_box_read_button_clicked_cb (GtkButton *button, + gpointer user_data) +{ + GsDescriptionBox *box = user_data; + + g_return_if_fail (GS_IS_DESCRIPTION_BOX (box)); + + box->is_collapsed = !box->is_collapsed; + box->needs_recalc = TRUE; + + gs_description_box_update_content (box); +} + +static gboolean +update_description_in_idle_cb (gpointer data) +{ + GsDescriptionBox *box = GS_DESCRIPTION_BOX (data); + + gs_description_box_update_content (box); + box->idle_update_id = 0; + + return G_SOURCE_REMOVE; +} + +static void +gs_description_box_measure (GtkWidget *widget, + GtkOrientation orientation, + gint for_size, + gint *minimum, + gint *natural, + gint *minimum_baseline, + gint *natural_baseline) +{ + GsDescriptionBox *box = GS_DESCRIPTION_BOX (widget); + + gtk_widget_measure (box->box, orientation, + for_size, + minimum, natural, + minimum_baseline, + natural_baseline); + + if (!box->idle_update_id) + box->idle_update_id = g_idle_add (update_description_in_idle_cb, box); +} + +static void +gs_description_box_size_allocate (GtkWidget *widget, + int width, + int height, + int baseline) +{ + GsDescriptionBox *box = GS_DESCRIPTION_BOX (widget); + GtkAllocation allocation; + + allocation.x = 0; + allocation.y = 0; + allocation.width = width; + allocation.height = height; + + gtk_widget_size_allocate (box->box, &allocation, baseline); + + if (!box->idle_update_id) + box->idle_update_id = g_idle_add (update_description_in_idle_cb, box); +} + +static GtkSizeRequestMode +gs_description_box_get_request_mode (GtkWidget *widget) +{ + return gtk_widget_get_request_mode (GS_DESCRIPTION_BOX (widget)->box); +} + +static void +gs_description_box_dispose (GObject *object) +{ + GsDescriptionBox *box = GS_DESCRIPTION_BOX (object); + + g_clear_handle_id (&box->idle_update_id, g_source_remove); + g_clear_pointer (&box->box, gtk_widget_unparent); + + G_OBJECT_CLASS (gs_description_box_parent_class)->dispose (object); +} + +static void +gs_description_box_finalize (GObject *object) +{ + GsDescriptionBox *box = GS_DESCRIPTION_BOX (object); + + g_clear_pointer (&box->text, g_free); + + G_OBJECT_CLASS (gs_description_box_parent_class)->finalize (object); +} + +static void +gs_description_box_init (GsDescriptionBox *box) +{ + GtkStyleContext *style_context; + GtkWidget *widget; + + box->is_collapsed = TRUE; + + box->box = gtk_box_new (GTK_ORIENTATION_VERTICAL, 24); + gtk_widget_set_parent (GTK_WIDGET (box->box), GTK_WIDGET (box)); + + style_context = gtk_widget_get_style_context (GTK_WIDGET (box)); + gtk_style_context_add_class (style_context, "application-details-description"); + + widget = gtk_label_new (""); + g_object_set (G_OBJECT (widget), + "hexpand", TRUE, + "halign", GTK_ALIGN_FILL, + "vexpand", FALSE, + "valign", GTK_ALIGN_START, + "visible", TRUE, + "max-width-chars", 40, + "selectable", TRUE, + "wrap", TRUE, + "xalign", 0.0, + NULL); + + gtk_box_append (GTK_BOX (box->box), widget); + + style_context = gtk_widget_get_style_context (widget); + gtk_style_context_add_class (style_context, "label"); + + box->label = GTK_LABEL (widget); + + widget = gtk_button_new_with_mnemonic (_("_Show More")); + + g_object_set (G_OBJECT (widget), + "hexpand", FALSE, + "halign", GTK_ALIGN_CENTER, + "vexpand", FALSE, + "valign", GTK_ALIGN_CENTER, + "visible", TRUE, + NULL); + + gtk_box_append (GTK_BOX (box->box), widget); + + style_context = gtk_widget_get_style_context (widget); + gtk_style_context_add_class (style_context, "button"); + gtk_style_context_add_class (style_context, "circular"); + + box->button = GTK_BUTTON (widget); + + g_signal_connect (box->button, "clicked", + G_CALLBACK (gs_description_box_read_button_clicked_cb), box); +} + +static void +gs_description_box_class_init (GsDescriptionBoxClass *klass) +{ + GObjectClass *object_class; + GtkWidgetClass *widget_class; + + object_class = G_OBJECT_CLASS (klass); + object_class->dispose = gs_description_box_dispose; + object_class->finalize = gs_description_box_finalize; + + widget_class = GTK_WIDGET_CLASS (klass); + widget_class->get_request_mode = gs_description_box_get_request_mode; + widget_class->measure = gs_description_box_measure; + widget_class->size_allocate = gs_description_box_size_allocate; +} + +GtkWidget * +gs_description_box_new (void) +{ + return g_object_new (GS_TYPE_DESCRIPTION_BOX, NULL); +} + +const gchar * +gs_description_box_get_text (GsDescriptionBox *box) +{ + g_return_val_if_fail (GS_IS_DESCRIPTION_BOX (box), NULL); + + return box->text; +} + +void +gs_description_box_set_text (GsDescriptionBox *box, + const gchar *text) +{ + g_return_if_fail (GS_IS_DESCRIPTION_BOX (box)); + + if (g_strcmp0 (text, box->text) != 0) { + g_free (box->text); + box->text = g_strdup (text); + box->needs_recalc = TRUE; + + gtk_widget_set_visible (GTK_WIDGET (box), text && *text); + + gtk_widget_queue_resize (GTK_WIDGET (box)); + } +} + +gboolean +gs_description_box_get_collapsed (GsDescriptionBox *box) +{ + g_return_val_if_fail (GS_IS_DESCRIPTION_BOX (box), FALSE); + + return box->is_collapsed; +} + +void +gs_description_box_set_collapsed (GsDescriptionBox *box, + gboolean collapsed) +{ + g_return_if_fail (GS_IS_DESCRIPTION_BOX (box)); + + if ((collapsed ? 1 : 0) != (box->is_collapsed ? 1 : 0)) { + box->is_collapsed = collapsed; + box->needs_recalc = TRUE; + + gtk_widget_queue_resize (GTK_WIDGET (box)); + } +} diff --git a/src/gs-description-box.h b/src/gs-description-box.h new file mode 100644 index 0000000..667b214 --- /dev/null +++ b/src/gs-description-box.h @@ -0,0 +1,29 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2020 Red Hat <www.redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <gtk/gtk.h> + +G_BEGIN_DECLS + +#define GS_TYPE_DESCRIPTION_BOX (gs_description_box_get_type ()) + +G_DECLARE_FINAL_TYPE (GsDescriptionBox, gs_description_box, GS, DESCRIPTION_BOX, GtkWidget) + +GtkWidget *gs_description_box_new (void); +const gchar *gs_description_box_get_text (GsDescriptionBox *box); +void gs_description_box_set_text (GsDescriptionBox *box, + const gchar *text); +gboolean gs_description_box_get_collapsed + (GsDescriptionBox *box); +void gs_description_box_set_collapsed + (GsDescriptionBox *box, + gboolean collapsed); + +G_END_DECLS 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); +} diff --git a/src/gs-details-page.h b/src/gs-details-page.h new file mode 100644 index 0000000..7a8b531 --- /dev/null +++ b/src/gs-details-page.h @@ -0,0 +1,44 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2015-2017 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include "gs-page.h" + +G_BEGIN_DECLS + +#define GS_TYPE_DETAILS_PAGE (gs_details_page_get_type ()) + +G_DECLARE_FINAL_TYPE (GsDetailsPage, gs_details_page, GS, DETAILS_PAGE, GsPage) + +GsDetailsPage *gs_details_page_new (void); +void gs_details_page_set_app (GsDetailsPage *self, + GsApp *app); +void gs_details_page_set_local_file(GsDetailsPage *self, + GFile *file); +void gs_details_page_set_url (GsDetailsPage *self, + const gchar *url); +GsApp *gs_details_page_get_app (GsDetailsPage *self); + +GsOdrsProvider *gs_details_page_get_odrs_provider (GsDetailsPage *self); +void gs_details_page_set_odrs_provider (GsDetailsPage *self, + GsOdrsProvider *odrs_provider); + +gboolean gs_details_page_get_is_narrow (GsDetailsPage *self); +void gs_details_page_set_is_narrow (GsDetailsPage *self, + gboolean is_narrow); +void gs_details_page_set_metainfo (GsDetailsPage *self, + GFile *file); +gdouble gs_details_page_get_vscroll_position + (GsDetailsPage *self); +void gs_details_page_set_vscroll_position + (GsDetailsPage *self, + gdouble value); + +G_END_DECLS diff --git a/src/gs-details-page.ui b/src/gs-details-page.ui new file mode 100644 index 0000000..d3fad10 --- /dev/null +++ b/src/gs-details-page.ui @@ -0,0 +1,1105 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <requires lib="gtk+" version="3.10"/> + <requires lib="handy" version="1.0"/> + <template class="GsDetailsPage" parent="GsPage"> + <accessibility> + <property name="label" translatable="yes">Details page</property> + </accessibility> + <child> + <object class="GtkStack" id="stack_details"> + + <child> + <object class="GtkStackPage"> + <property name="name">spinner</property> + <property name="child"> + <object class="GtkBox" id="details_spinner_box"> + <property name="orientation">vertical</property> + <property name="spacing">12</property> + <property name="halign">center</property> + <property name="valign">center</property> + <property name="hexpand">True</property> + <property name="vexpand">True</property> + <style> + <class name="dim-label"/> + </style> + <child> + <object class="GtkSpinner" id="spinner_details"> + <property name="width_request">32</property> + <property name="height_request">32</property> + <property name="halign">center</property> + <property name="valign">center</property> + <property name="hexpand">True</property> + <property name="vexpand">True</property> + </object> + </child> + <child> + <object class="GtkLabel" id="loading_label"> + <property name="justify">center</property> + <property name="label" translatable="yes">Loading application details…</property> + <property name="wrap">True</property> + <attributes> + <attribute name="scale" value="1.4"/> + </attributes> + </object> + </child> + </object> + </property> + </object> + </child> + + <child> + <object class="GtkStackPage"> + <property name="name">ready</property> + <property name="child"> + <object class="GtkScrolledWindow" id="scrolledwindow_details"> + <property name="can_focus">True</property> + <property name="hscrollbar_policy">automatic</property> + <property name="vscrollbar_policy">automatic</property> + <child> + <object class="GtkViewport" id="viewport1"> + <property name="scroll-to-focus">True</property> + <child> + <object class="GtkBox"> + <property name="orientation">vertical</property> + <child> + <object class="GtkInfoBar" id="translation_infobar"> + <property name="revealed">False</property> + <property name="show-close-button">False</property> + <signal name="response" handler="gs_details_page_translation_infobar_response_cb"/> + <child> + <object class="GtkCenterBox"> + <property name="orientation">horizontal</property> + <property name="hexpand">True</property> + <child type="center"> + <object class="GtkLabel" id="translation_infobar_label"> + <property name="hexpand">True</property> + <property name="label" translatable="yes">This software is not available in your language and will appear in US English.</property> + <property name="wrap">True</property> + </object> + </child> + <child type="end"> + <object class="GtkButton" id="translation_infobar_button"> + <property name="label" translatable="yes">Help _Translate</property> + <property name="use-underline">True</property> + </object> + </child> + </object> + </child> + <action-widgets> + <action-widget response="GTK_RESPONSE_OK">translation_infobar_button</action-widget> + </action-widgets> + </object> + </child> + + <child> + <object class="GtkBox" id="box_details"> + <property name="orientation">vertical</property> + <property name="halign">fill</property> + <property name="valign">start</property> + <property name="spacing">18</property> + <property name="hexpand">False</property> + <style> + <class name="details-page"/> + </style> + <child> + <object class="AdwClamp"> + <property name="maximum-size">860</property> + <!-- ~⅔ of the maximum size. --> + <property name="tightening-threshold">576</property> + <property name="margin-start">12</property> + <property name="margin-end">12</property> + <child> + <object class="GtkBox" id="box_details_header"> + <property name="orientation">horizontal</property> + <child> + <object class="GtkImage" id="application_details_icon"> + <property name="halign">start</property> + <property name="pixel_size">128</property> + <style> + <class name="icon-dropshadow"/> + </style> + </object> + </child> + <child> + <object class="GtkBox" id="box_details_header_not_icon"> + <property name="orientation">horizontal</property> + <property name="margin-top">6</property> + <property name="spacing">6</property> + <child> + <object class="GtkBox" id="box_details_header2"> + <property name="orientation">vertical</property> + <property name="halign">fill</property> + <property name="valign">center</property> + <property name="spacing">6</property> + <child> + <object class="GtkLabel" id="application_details_title"> + <property name="halign">fill</property> + <property name="valign">start</property> + <property name="hexpand">True</property> + <property name="xalign">0</property> + <property name="selectable">True</property> + <property name="wrap">True</property> + <property name="max_width_chars">20</property> + <style> + <class name="app-title"/> + <class name="title-1"/> + </style> + </object> + </child> + <child> + <object class="GtkBox"> + <property name="visible" bind-source="developer_name_label" bind-property="visible" bind-flags="sync-create"/> + <property name="hexpand">True</property> + <property name="spacing">3</property> + <property name="orientation">horizontal</property> + <child> + <object class="GtkLabel" id="developer_name_label"> + <property name="ellipsize">end</property> + <!-- This is a placeholder; the real value is set in code --> + <property name="label">Yorba</property> + <property name="wrap">False</property> + <property name="selectable">True</property> + <property name="max-width-chars">100</property> + <property name="xalign">0</property> + <property name="yalign">0.5</property> + <property name="hexpand">False</property> + <style> + <class name="app-developer"/> + <class name="dim-label"/> + </style> + </object> + </child> + <child> + <object class="GtkImage" id="developer_verified_image"> + <property name="pixel-size">16</property> + <property name="icon-name">emblem-ok-symbolic</property> + </object> + </child> + </object> + </child> + <child> + <object class="GtkBox" id="star_box"> + <property name="valign">start</property> + <child> + <object class="GsStarWidget" id="star"> + <property name="halign">start</property> + <property name="valign">center</property> + <property name="icon-size">16</property> + <child> + <object class="GtkGestureClick"> + <!-- GDK_BUTTON_PRIMARY --> + <property name="button">1</property> + <signal name="pressed" handler="gs_details_page_star_pressed_cb" object="GsDetailsPage" swapped="no" /> + </object> + </child> + </object> + </child> + <child> + <object class="GtkLabel" id="label_review_count"> + <property name="margin_start">5</property> + <property name="halign">start</property> + <property name="valign">center</property> + <style> + <class name="dim-label"/> + </style> + </object> + </child> + </object> + </child> + </object> + </child> + + <child> + <object class="GtkBox" id="box_with_source"> + <property name="orientation">vertical</property> + <property name="valign">center</property> + <property name="margin-top">6</property> + <property name="spacing">6</property> + <child> + <object class="GtkBox" id="box_install_remove"> + <property name="orientation">horizontal</property> + <property name="halign">center</property> + <property name="spacing">9</property> + <child> + <object class="GtkButton" id="button_install"> + <property name="visible">False</property> + <property name="use_underline">True</property> + <property name="label" translatable="yes">_Install</property> + <property name="width_request">105</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="halign">start</property> + <property name="valign">center</property> + <signal name="clicked" handler="gs_details_page_app_install_button_cb"/> + <style> + <class name="suggested-action"/> + </style> + </object> + </child> + <child> + <object class="GtkButton" id="button_details_launch"> + <property name="can_focus">True</property> + <!-- TRANSLATORS: A label for a button to execute the selected application. --> + <property name="label" translatable="yes">_Open</property> + <property name="use_underline">True</property> + <property name="valign">center</property> + <signal name="clicked" handler="gs_details_page_app_launch_button_cb"/> + <style> + <class name="suggested-action"/> + </style> + </object> + </child> + <child> + <object class="GtkButton" id="button_update"> + <property name="visible">False</property> + <property name="use_underline">True</property> + <property name="label" translatable="yes">_Update</property> + <property name="width_request">105</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="halign">start</property> + <property name="valign">center</property> + <signal name="clicked" handler="gs_details_page_app_update_button_cb"/> + </object> + </child> + <child> + <object class="GtkButton" id="button_remove"> + <property name="visible">False</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="halign">start</property> + <property name="valign">center</property> + <signal name="clicked" handler="gs_details_page_app_remove_button_cb"/> + <child> + <object class="GtkImage"> + <property name="icon-name">user-trash-symbolic</property> + </object> + </child> + <accessibility> + <!-- TRANSLATORS: button text in the header when an application can be erased --> + <property name="label" translatable="yes">Uninstall</property> + </accessibility> + </object> + </child> + <child> + <object class="GtkCenterBox"> + <property name="visible" bind-source="button_cancel" bind-property="visible" bind-flags="sync-create"/> + <property name="orientation">vertical</property> + <property name="valign">center</property> + <child type="center"> + <object class="GsProgressButton" id="button_cancel"> + <property name="visible">False</property> + <property name="use_underline">True</property> + <property name="label" translatable="yes">_Cancel</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="valign">center</property> + <property name="margin-bottom">3</property> + <signal name="clicked" handler="gs_details_page_app_cancel_button_cb"/> + <style> + <class name="list-button"/> + </style> + </object> + </child> + <child type="end"> + <object class="GtkBox"> + <property name="visible" bind-source="label_progress_status" bind-property="visible" bind-flags="sync-create"/> + <property name="spacing">3</property> + <property name="halign">center</property> + <property name="valign">start</property> + <property name="vexpand">True</property> + <style> + <class name="dim-label"/> + <class name="install-progress-label"/> + </style> + <child> + <object class="GtkLabel" id="label_progress_status"> + <property name="valign">start</property> + <property name="label" translatable="yes">Downloading</property> + </object> + </child> + <child> + <object class="GtkLabel" id="label_progress_percentage"> + <property name="valign">start</property> + <property name="label">50%</property> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + + <child> + <object class="GtkBox" id="origin_box"> + <property name="orientation">horizontal</property> + <property name="spacing">4</property> + <property name="halign">center</property> + <property name="valign">center</property> + <property name="visible" bind-source="button_cancel" bind-property="visible" bind-flags="sync-create|invert-boolean"/> + <child> + <object class="GtkMenuButton" id="origin_button"> + <property name="can_focus">True</property> + <property name="sensitive">True</property> + <property name="always-show-arrow">True</property> + <property name="popover">origin_popover</property> + <style> + <class name="flat"/> + <class name="origin-button"/> + </style> + <child> + <object class="GtkBox" id="origin_menu_btn_box"> + <property name="orientation">horizontal</property> + <property name="spacing">4</property> + <property name="halign">center</property> + <property name="valign">center</property> + <child> + <object class="GtkImage" id="origin_packaging_image"> + <property name="pixel_size">16</property> + <property name="icon_name">package-x-generic-symbolic</property> + <property name="valign">center</property> + </object> + </child> + <child> + <object class="GtkLabel" id="origin_packaging_label"> + <property name="valign">center</property> + <property name="max-width-chars">12</property> + <property name="ellipsize">end</property> + <attributes> + <attribute name="weight" value="normal"/> + <attribute name="scale" value="0.85"/> + </attributes> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + + <child> + <object class="GsScreenshotCarousel" id="screenshot_carousel"> + </object> + </child> + + <child> + <object class="AdwClamp"> + <property name="maximum-size">860</property> + <!-- ~⅔ of the maximum size. --> + <property name="tightening-threshold">576</property> + <property name="margin-start">12</property> + <property name="margin-end">12</property> + <child> + <object class="GtkBox" id="application_details"> + <property name="orientation">vertical</property> + <property name="spacing">12</property> + <child> + <object class="GtkLabel" id="application_details_summary"> + <property name="halign">fill</property> + <property name="valign">start</property> + <property name="hexpand">True</property> + <property name="xalign">0</property> + <property name="selectable">True</property> + <property name="wrap">True</property> + <property name="max-width-chars">60</property> + <style> + <class name="title-2"/> + </style> + </object> + </child> + <child> + <object class="GsDescriptionBox" id="box_details_description"> + <property name="halign">fill</property> + <property name="hexpand">True</property> + <property name="valign">start</property> + <property name="visible">FALSE</property> + <property name="margin_bottom">14</property> + </object> + </child> + </object> + </child> + </object> + </child> + + <child> + <object class="AdwClamp"> + <property name="maximum-size">860</property> + <!-- ~⅔ of the maximum size. --> + <property name="tightening-threshold">576</property> + <property name="margin-start">12</property> + <property name="margin-end">12</property> + <child> + <object class="GtkBox" id="box_addons"> + <property name="orientation">vertical</property> + + <child> + <object class="GtkBox" id="box_addons_title"> + <property name="orientation">vertical</property> + <property name="margin_bottom">12</property> + <child> + <object class="GtkLabel" id="label_addons_title"> + <property name="halign">start</property> + <property name="valign">start</property> + <property name="hexpand">True</property> + <property name="xalign">0</property> + <property name="label" translatable="yes">Add-ons</property> + <style> + <class name="title-2"/> + </style> + </object> + </child> + <child> + <object class="GtkLabel" id="label_addons_uninstalled_app"> + <property name="xalign">0</property> + <property name="wrap">True</property> + <property name="max-width-chars">40</property> + <property name="label" translatable="yes">Selected add-ons will be installed with the application.</property> + </object> + </child> + </object> + </child> + <child> + <object class="GtkListBox" id="list_box_addons"> + <property name="selection_mode">none</property> + <property name="halign">fill</property> + <property name="valign">start</property> + <style> + <class name="boxed-list"/> + </style> + </object> + </child> + </object> + </child> + </object> + </child> + + <child> + <object class="AdwClamp"> + <property name="maximum-size">860</property> + <!-- ~⅔ of the maximum size. --> + <property name="tightening-threshold">576</property> + <property name="margin-start">12</property> + <property name="margin-end">12</property> + <child> + <object class="GsAppContextBar" id="context_bar"> + </object> + </child> + </object> + </child> + + <child> + <object class="AdwClamp"> + <property name="maximum-size">860</property> + <!-- ~⅔ of the maximum size. --> + <property name="tightening-threshold">576</property> + <property name="margin-start">12</property> + <property name="margin-end">12</property> + <child> + <object class="GtkListBox" id="list_box_version_history"> + <property name="selection_mode">none</property> + <property name="halign">fill</property> + <property name="valign">start</property> + <style> + <class name="boxed-list"/> + </style> + <child> + <object class="GsAppVersionHistoryRow" id="row_latest_version"> + </object> + </child> + <child> + <object class="GtkListBoxRow" id="version_history_button"> + <property name="can_focus">True</property> + <child> + <object class="GtkBox"> + <property name="orientation">horizontal</property> + <property name="halign">center</property> + <property name="margin_top">12</property> + <property name="margin_bottom">12</property> + <child> + <object class="GtkLabel"> + <property name="xalign">0</property> + <property name="yalign">0.5</property> + <property name="label" translatable="yes">Version History</property> + </object> + </child> + <child> + <object class="GtkImage"> + <property name="icon-name">go-next-symbolic</property> + <property name="margin_start">6</property> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + + <child> + <object class="AdwClamp"> + <property name="visible" bind-source="label_webapp_warning" bind-property="visible" bind-flags="sync-create"/> + <property name="maximum-size">860</property> + <!-- ~⅔ of the maximum size. --> + <property name="tightening-threshold">576</property> + <property name="margin-start">12</property> + <property name="margin-end">12</property> + <child> + <object class="GtkLabel" id="label_webapp_warning"> + <property name="visible">False</property> + <property name="halign">center</property> + <property name="hexpand">True</property> + <property name="valign">start</property> + <property name="xalign">0.0</property> + <property name="wrap">True</property> + <property name="label" translatable="yes">This application can only be used when there is an active internet connection.</property> + <style> + <class name="heading"/> + </style> + </object> + </child> + </object> + </child> + + <child> + <object class="AdwClamp"> + <property name="visible" bind-source="infobar_details_app_repo" bind-property="visible" bind-flags="sync-create"/> + <property name="maximum-size">860</property> + <!-- ~⅔ of the maximum size. --> + <property name="tightening-threshold">576</property> + <property name="margin-start">12</property> + <property name="margin-end">12</property> + <child> + <object class="GsInfoBar" id="infobar_details_app_repo"> + <property name="margin_top">20</property> + <property name="title" translatable="yes">Software Repository Included</property> + <property name="body" translatable="yes">This application includes a software repository which provides updates, as well as access to other software.</property> + </object> + </child> + </object> + </child> + + <child> + <object class="AdwClamp"> + <property name="visible" bind-source="infobar_details_app_norepo" bind-property="visible" bind-flags="sync-create"/> + <property name="maximum-size">860</property> + <!-- ~⅔ of the maximum size. --> + <property name="tightening-threshold">576</property> + <property name="margin-start">12</property> + <property name="margin-end">12</property> + <child> + <object class="GsInfoBar" id="infobar_details_app_norepo"> + <property name="margin_top">20</property> + <property name="title" translatable="yes">No Software Repository Included</property> + <property name="body" translatable="yes">This application does not include a software repository. It will not be updated with new versions.</property> + </object> + </child> + </object> + </child> + + <child> + <object class="AdwClamp"> + <property name="visible" bind-source="infobar_details_package_baseos" bind-property="visible" bind-flags="sync-create"/> + <property name="maximum-size">860</property> + <!-- ~⅔ of the maximum size. --> + <property name="tightening-threshold">576</property> + <property name="margin-start">12</property> + <property name="margin-end">12</property> + <child> + <object class="GsInfoBar" id="infobar_details_package_baseos"> + <property name="message_type">warning</property> + <property name="margin_top">20</property> + <property name="title" translatable="yes">This software is already provided by your distribution and should not be replaced.</property> + </object> + </child> + </object> + </child> + + <child> + <object class="AdwClamp"> + <property name="visible" bind-source="infobar_details_repo" bind-property="visible" bind-flags="sync-create"/> + <property name="maximum-size">860</property> + <!-- ~⅔ of the maximum size. --> + <property name="tightening-threshold">576</property> + <property name="margin-start">12</property> + <property name="margin-end">12</property> + <child> + <object class="GsInfoBar" id="infobar_details_repo"> + <property name="margin_top">20</property> + <property name="title" translatable="yes" comments="Translators: a repository file used for installing software has been discovered.">Software Repository Identified</property> + <property name="body" translatable="yes">Adding this software repository will give you access to additional software and upgrades.</property> + <property name="warning" translatable="yes">Only use software repositories that you trust.</property> + </object> + </child> + </object> + </child> + + <child> + <object class="AdwClamp"> + <property name="maximum-size">860</property> + <!-- ~⅔ of the maximum size. --> + <property name="tightening-threshold">576</property> + <property name="margin-start">12</property> + <property name="margin-end">12</property> + <child> + <object class="GtkBox" id="box_license"> + <property name="orientation">horizontal</property> + <property name="spacing">12</property> + <property name="homogeneous" bind-source="GsDetailsPage" bind-property="is-narrow" bind-flags="sync-create|invert-boolean"/> + + <child> + <object class="GsLicenseTile" id="license_tile"> + <property name="halign">fill</property> + <property name="valign">start</property> + <signal name="get-involved-activated" handler="gs_details_page_license_tile_get_involved_activated_cb"/> + </object> + </child> + + <child> + <object class="GtkStack" id="links_stack"> + <property name="hhomogeneous">False</property> + <property name="vhomogeneous">False</property> + + <child> + <object class="GtkStackPage"> + <property name="name">empty</property> + <property name="child"> + <object class="GtkBox"> + <property name="hexpand-set">True</property> + <style> + <class name="card"/> + </style> + + <child> + <object class="GtkBox"> + <property name="orientation">vertical</property> + <property name="spacing">8</property> + <property name="margin-top">14</property> + <property name="margin-bottom">14</property> + <property name="margin-start">14</property> + <property name="margin-end">14</property> + <property name="valign">center</property> + <property name="hexpand">True</property> + <style> + <class name="dim-label"/> + </style> + + <child> + <object class="GtkImage"> + <property name="icon-name">dialog-question-symbolic</property> + <property name="pixel-size">96</property> + <property name="margin-bottom">8</property> + </object> + </child> + <child> + <object class="GtkLabel"> + <property name="label" translatable="yes">No Metadata</property> + <attributes> + <attribute name="weight" value="bold"/> + </attributes> + </object> + </child> + <child> + <object class="GtkLabel"> + <property name="justify">center</property> + <property name="label" translatable="yes">This software doesn’t provide any links to a website, code repository or issue tracker.</property> + <property name="wrap">True</property> + <property name="xalign">0.5</property> + </object> + </child> + </object> + </child> + </object> + </property> + </object> + </child> + + <child> + <object class="GtkStackPage"> + <property name="name">links</property> + <property name="child"> + <object class="GtkListBox"> + <property name="selection_mode">none</property> + <property name="halign">fill</property> + <property name="valign">start</property> + <style> + <class name="boxed-list"/> + </style> + + <child> + <object class="AdwActionRow" id="project_website_row"> + <property name="activatable">True</property> + <property name="icon-name">webpage-symbolic</property> + <property name="title" translatable="yes">Project _Website</property> + <!-- This is a placeholder; the actual URI is set in code --> + <property name="subtitle">gnome.org</property> + <property name="use-underline">True</property> + <signal name="activated" handler="gs_details_page_link_row_activated_cb"/> + <child> + <object class="GtkImage"> + <property name="icon-name">external-link-symbolic</property> + </object> + </child> + </object> + </child> + + <child> + <object class="AdwActionRow" id="donate_row"> + <property name="activatable">True</property> + <property name="icon-name">money-symbolic</property> + <property name="title" translatable="yes">_Donate</property> + <!-- This is a placeholder; the actual URI is set in code --> + <property name="subtitle">gnome.org</property> + <property name="use-underline">True</property> + <signal name="activated" handler="gs_details_page_link_row_activated_cb"/> + <child> + <object class="GtkImage"> + <property name="icon-name">external-link-symbolic</property> + </object> + </child> + </object> + </child> + + <child> + <object class="AdwActionRow" id="translate_row"> + <property name="activatable">True</property> + <property name="icon-name">flag-outline-thin-symbolic</property> + <property name="title" translatable="yes">Contribute _Translations</property> + <!-- This is a placeholder; the actual URI is set in code --> + <property name="subtitle">gnome.org</property> + <property name="use-underline">True</property> + <signal name="activated" handler="gs_details_page_link_row_activated_cb"/> + <child> + <object class="GtkImage"> + <property name="icon-name">external-link-symbolic</property> + </object> + </child> + </object> + </child> + + <child> + <object class="AdwActionRow" id="report_an_issue_row"> + <property name="activatable">True</property> + <property name="icon-name">computer-fail-symbolic</property> + <property name="title" translatable="yes">_Report an Issue</property> + <!-- This is a placeholder; the actual URI is set in code --> + <property name="subtitle">gnome.org</property> + <property name="use-underline">True</property> + <signal name="activated" handler="gs_details_page_link_row_activated_cb"/> + <child> + <object class="GtkImage"> + <property name="icon-name">external-link-symbolic</property> + </object> + </child> + </object> + </child> + + <child> + <object class="AdwActionRow" id="help_row"> + <property name="activatable">True</property> + <property name="icon-name">help-link-symbolic</property> + <property name="title" translatable="yes">_Help</property> + <!-- This is a placeholder; the actual URI is set in code --> + <property name="subtitle">gnome.org</property> + <property name="use-underline">True</property> + <signal name="activated" handler="gs_details_page_link_row_activated_cb"/> + <child> + <object class="GtkImage"> + <property name="icon-name">external-link-symbolic</property> + </object> + </child> + </object> + </child> + </object> + </property> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + + <child> + <object class="AdwClamp"> + <property name="visible" bind-source="box_reviews" bind-property="visible" bind-flags="sync-create"/> + <property name="maximum-size">860</property> + <!-- ~⅔ of the maximum size. --> + <property name="tightening-threshold">576</property> + <property name="margin-start">12</property> + <property name="margin-end">12</property> + <child> + <object class="GtkBox" id="box_reviews"> + <property name="visible">False</property> + <property name="orientation">vertical</property> + <property name="spacing">18</property> + <child> + <object class="GtkLabel"> + <property name="halign">start</property> + <property name="valign">start</property> + <property name="hexpand">True</property> + <property name="xalign">0</property> + <property name="label" translatable="yes" comments="Translators: Header of the section with other users' opinions about the app.">Reviews</property> + <style> + <class name="title-2"/> + </style> + </object> + </child> + <child> + <object class="GtkBox" id="box_reviews_internal"> + <property name="orientation">horizontal</property> + <property name="spacing">12</property> + <property name="homogeneous" bind-source="GsDetailsPage" bind-property="is-narrow" bind-flags="sync-create|invert-boolean"/> + <child> + <object class="GtkListBox" id="list_box_reviews_summary"> + <property name="can_focus">True</property> + <property name="selection_mode">none</property> + <property name="valign">start</property> + <style> + <class name="boxed-list"/> + </style> + <child> + <object class="GtkListBoxRow" id="histogram_row"> + <property name="activatable">False</property> + <property name="visible">False</property> + <child> + <object class="AdwClamp"> + <property name="halign">start</property> + <property name="maximum-size">500</property> + <property name="tightening-threshold">500</property> + <property name="valign">center</property> + <child> + <object class="GsReviewHistogram" id="histogram"> + <property name="valign">center</property> + </object> + </child> + </object> + </child> + </object> + </child> + <child> + <object class="GtkListBoxRow" id="button_review"> + <property name="can_focus">True</property> + <child> + <object class="GtkBox"> + <property name="orientation">horizontal</property> + <property name="halign">center</property> + <property name="margin_top">12</property> + <property name="margin_bottom">12</property> + <child> + <object class="GtkLabel"> + <property name="xalign">0</property> + <property name="yalign">0.5</property> + <property name="use_underline">True</property> + <property name="label" translatable="yes" comments="Translators: Button opening a dialog where the users can write and publish their opinions about the apps.">_Write Review</property> + </object> + </child> + <child> + <object class="GtkImage"> + <property name="icon-name">go-next-symbolic</property> + <property name="margin_start">6</property> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + <child> + <object class="GtkListBox" id="list_box_featured_review"> + <property name="can_focus">True</property> + <property name="selection_mode">none</property> + <property name="valign">start</property> + <style> + <class name="boxed-list"/> + </style> + <child> + <object class="GtkListBoxRow" id="button_all_reviews"> + <property name="can_focus">True</property> + <child> + <object class="GtkBox"> + <property name="orientation">horizontal</property> + <property name="halign">center</property> + <property name="margin_top">12</property> + <property name="margin_bottom">12</property> + <child> + <object class="GtkLabel"> + <property name="xalign">0</property> + <property name="yalign">0.5</property> + <property name="use_underline">True</property> + <property name="label" translatable="yes" comments="Translators: Button opening a dialog showing all reviews for an app.">All Reviews</property> + </object> + </child> + <child> + <object class="GtkImage"> + <property name="icon-name">go-next-symbolic</property> + <property name="margin_start">6</property> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + <child> + <object class="AdwClamp"> + <property name="visible" bind-source="box_developer_apps" bind-property="visible" bind-flags="sync-create"/> + <property name="maximum-size">860</property> + <!-- ~⅔ of the maximum size. --> + <property name="tightening-threshold">576</property> + <property name="margin-start">12</property> + <property name="margin-end">12</property> + <child> + <object class="GtkBox"> + <property name="halign">center</property> + <property name="hexpand">False</property> + <property name="orientation">vertical</property> + <property name="valign">start</property> + <property name="spacing">6</property> + <child> + <object class="GtkLabel" id="developer_apps_heading"> + <property name="xalign">0</property> + <!-- the label is set in the code --> + <property name="label">Other Apps by ...</property> + <property name="margin-top">21</property> + <property name="margin-bottom">6</property> + <style> + <class name="heading"/> + </style> + </object> + </child> + <child> + <object class="GtkFlowBox" id="box_developer_apps"> + <property name="homogeneous">True</property> + <property name="column-spacing">14</property> + <property name="row-spacing">14</property> + <property name="valign">start</property> + <accessibility> + <relation name="labelled-by">developer_apps_heading</relation> + </accessibility> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </property> + </object> + </child> + + <child> + <object class="GtkStackPage"> + <property name="name">failed</property> + <property name="child"> + <object class="GtkBox" id="box_failed"> + <property name="orientation">vertical</property> + <property name="spacing">24</property> + <property name="halign">center</property> + <property name="valign">center</property> + <property name="hexpand">True</property> + <property name="vexpand">True</property> + <style> + <class name="dim-label"/> + </style> + <child> + <object class="GtkImage" id="image_failed"> + <property name="pixel_size">128</property> + <property name="icon_name">org.gnome.Software-symbolic</property> + </object> + </child> + <child> + <object class="GtkLabel" id="label_failed"> + <property name="wrap">True</property> + <property name="max-width-chars">60</property> + <property name="justify">center</property> + <attributes> + <attribute name="scale" value="1.4"/> + </attributes> + </object> + </child> + </object> + </property> + </object> + </child> + + </object> + </child> + </template> + <object class="GtkSizeGroup" id="sizegroup_install_remove"> + <widgets> + <widget name="button_install"/> + <widget name="button_cancel"/> + <widget name="button_update"/> + <widget name="button_details_launch"/> + <widget name="origin_button"/> + </widgets> + </object> + + <object class="GtkPopover" id="origin_popover"> + <property name="visible">False</property> + <style> + <class name="menu"/> + </style> + <child> + <object class="GtkScrolledWindow"> + <property name="propagate-natural-height">true</property> + <property name="propagate-natural-width">true</property> + <property name="max-content-height">600</property> + <property name="visible">true</property> + <child> + <object class="GtkListBox" id="origin_popover_list_box"> + <property name="selection-mode">none</property> + <property name="visible">true</property> + <property name="valign">start</property> + <signal name="row-activated" handler="origin_popover_row_activated_cb"/> + </object> + </child> + </object> + </child> + </object> + <object class="GtkSizeGroup"> + <property name="mode">horizontal</property> + <widgets> + <widget name="box_details_header"/> + <widget name="application_details"/> + <widget name="box_addons"/> + <widget name="context_bar"/> + <widget name="list_box_version_history"/> + <widget name="label_webapp_warning"/> + <widget name="infobar_details_app_repo"/> + <widget name="infobar_details_app_norepo"/> + <widget name="infobar_details_package_baseos"/> + <widget name="infobar_details_repo"/> + <widget name="box_license"/> + <widget name="box_reviews"/> + </widgets> + </object> +</interface> diff --git a/src/gs-extras-page.c b/src/gs-extras-page.c new file mode 100644 index 0000000..89f1a69 --- /dev/null +++ b/src/gs-extras-page.c @@ -0,0 +1,1361 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013-2017 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2015-2018 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include "gs-extras-page.h" + +#include "gs-app-row.h" +#include "gs-application.h" +#include "gs-language.h" +#include "gs-shell.h" +#include "gs-common.h" +#include "gs-utils.h" +#include "gs-vendor.h" + +#include <glib/gi18n.h> + +typedef enum { + GS_EXTRAS_PAGE_STATE_LOADING, + GS_EXTRAS_PAGE_STATE_READY, + GS_EXTRAS_PAGE_STATE_NO_RESULTS, + GS_EXTRAS_PAGE_STATE_FAILED +} GsExtrasPageState; + +typedef struct { + gchar *title; + gchar *search; + GsAppQueryProvidesType search_provides_type; + gchar *search_filename; + gchar *package_filename; + gchar *url_not_found; + GsExtrasPage *self; +} SearchData; + +struct _GsExtrasPage +{ + GsPage parent_instance; + + GsPluginLoader *plugin_loader; + GCancellable *search_cancellable; + GsShell *shell; + GsExtrasPageState state; + GtkSizeGroup *sizegroup_name; + GtkSizeGroup *sizegroup_button_label; + GtkSizeGroup *sizegroup_button_image; + GPtrArray *array_search_data; + GsExtrasPageMode mode; + GsLanguage *language; + GsVendor *vendor; + guint pending_search_cnt; + gchar *caller_app_name; + gchar *install_resources_ident; + + GtkWidget *label_failed; + GtkWidget *label_no_results; + GtkWidget *list_box_results; + GtkWidget *scrolledwindow; + GtkWidget *spinner; + GtkWidget *stack; +}; + +G_DEFINE_TYPE (GsExtrasPage, gs_extras_page, GS_TYPE_PAGE) + +typedef enum { + PROP_VADJUSTMENT = 1, + PROP_TITLE, +} GsExtrasPageProperty; + +static void +search_data_free (SearchData *search_data) +{ + if (search_data->self != NULL) + g_object_unref (search_data->self); + g_free (search_data->title); + g_free (search_data->search); + g_free (search_data->search_filename); + g_free (search_data->package_filename); + g_free (search_data->url_not_found); + g_slice_free (SearchData, search_data); +} + +static GsExtrasPageMode +gs_extras_page_mode_from_string (const gchar *str) +{ + if (g_strcmp0 (str, "install-package-files") == 0) + return GS_EXTRAS_PAGE_MODE_INSTALL_PACKAGE_FILES; + if (g_strcmp0 (str, "install-provide-files") == 0) + return GS_EXTRAS_PAGE_MODE_INSTALL_PROVIDE_FILES; + if (g_strcmp0 (str, "install-package-names") == 0) + return GS_EXTRAS_PAGE_MODE_INSTALL_PACKAGE_NAMES; + if (g_strcmp0 (str, "install-mime-types") == 0) + return GS_EXTRAS_PAGE_MODE_INSTALL_MIME_TYPES; + if (g_strcmp0 (str, "install-fontconfig-resources") == 0) + return GS_EXTRAS_PAGE_MODE_INSTALL_FONTCONFIG_RESOURCES; + if (g_strcmp0 (str, "install-gstreamer-resources") == 0) + return GS_EXTRAS_PAGE_MODE_INSTALL_GSTREAMER_RESOURCES; + if (g_strcmp0 (str, "install-plasma-resources") == 0) + return GS_EXTRAS_PAGE_MODE_INSTALL_PLASMA_RESOURCES; + if (g_strcmp0 (str, "install-printer-drivers") == 0) + return GS_EXTRAS_PAGE_MODE_INSTALL_PRINTER_DRIVERS; + + g_assert_not_reached (); +} + +const gchar * +gs_extras_page_mode_to_string (GsExtrasPageMode mode) +{ + if (mode == GS_EXTRAS_PAGE_MODE_INSTALL_PACKAGE_FILES) + return "install-package-files"; + if (mode == GS_EXTRAS_PAGE_MODE_INSTALL_PROVIDE_FILES) + return "install-provide-files"; + if (mode == GS_EXTRAS_PAGE_MODE_INSTALL_PACKAGE_NAMES) + return "install-package-names"; + if (mode == GS_EXTRAS_PAGE_MODE_INSTALL_MIME_TYPES) + return "install-mime-types"; + if (mode == GS_EXTRAS_PAGE_MODE_INSTALL_FONTCONFIG_RESOURCES) + return "install-fontconfig-resources"; + if (mode == GS_EXTRAS_PAGE_MODE_INSTALL_GSTREAMER_RESOURCES) + return "install-gstreamer-resources"; + if (mode == GS_EXTRAS_PAGE_MODE_INSTALL_PLASMA_RESOURCES) + return "install-plasma-resources"; + if (mode == GS_EXTRAS_PAGE_MODE_INSTALL_PRINTER_DRIVERS) + return "install-printer-drivers"; + + g_assert_not_reached (); +} + +static gchar * +build_comma_separated_list (gchar **items) +{ + guint len; + + len = g_strv_length (items); + if (len == 2) { + /* TRANSLATORS: separator for a list of items */ + return g_strjoinv (_(" and "), items); + } else { + /* TRANSLATORS: separator for a list of items */ + return g_strjoinv (_(", "), items); + } +} + +static gchar * +build_title (GsExtrasPage *self) +{ + guint i; + g_autofree gchar *titles = NULL; + g_autoptr(GPtrArray) title_array = NULL; + + title_array = g_ptr_array_new (); + for (i = 0; i < self->array_search_data->len; i++) { + SearchData *search_data; + + search_data = g_ptr_array_index (self->array_search_data, i); + g_ptr_array_add (title_array, search_data->title); + } + g_ptr_array_add (title_array, NULL); + + titles = build_comma_separated_list ((gchar **) title_array->pdata); + + switch (self->mode) { + case GS_EXTRAS_PAGE_MODE_INSTALL_FONTCONFIG_RESOURCES: + /* TRANSLATORS: Application window title for fonts installation. + %s will be replaced by name of the script we're searching for. */ + return g_strdup_printf (ngettext ("Available fonts for the %s script", + "Available fonts for the %s scripts", + self->array_search_data->len), + titles); + break; + default: + /* TRANSLATORS: Application window title for codec installation. + %s will be replaced by actual codec name(s) */ + return g_strdup_printf (ngettext ("Available software for %s", + "Available software for %s", + self->array_search_data->len), + titles); + break; + } +} + +static void +gs_extras_page_update_ui_state (GsExtrasPage *self) +{ + g_autofree gchar *title = NULL; + + if (gs_shell_get_mode (self->shell) != GS_SHELL_MODE_EXTRAS) + return; + + /* main spinner */ + switch (self->state) { + case GS_EXTRAS_PAGE_STATE_LOADING: + gtk_spinner_start (GTK_SPINNER (self->spinner)); + break; + case GS_EXTRAS_PAGE_STATE_READY: + case GS_EXTRAS_PAGE_STATE_NO_RESULTS: + case GS_EXTRAS_PAGE_STATE_FAILED: + gtk_spinner_stop (GTK_SPINNER (self->spinner)); + break; + default: + g_assert_not_reached (); + break; + } + + /* stack */ + switch (self->state) { + case GS_EXTRAS_PAGE_STATE_LOADING: + gtk_stack_set_visible_child_name (GTK_STACK (self->stack), "spinner"); + break; + case GS_EXTRAS_PAGE_STATE_READY: + gtk_stack_set_visible_child_name (GTK_STACK (self->stack), "results"); + break; + case GS_EXTRAS_PAGE_STATE_NO_RESULTS: + gtk_stack_set_visible_child_name (GTK_STACK (self->stack), "no-results"); + break; + case GS_EXTRAS_PAGE_STATE_FAILED: + gtk_stack_set_visible_child_name (GTK_STACK (self->stack), "failed"); + break; + default: + g_assert_not_reached (); + break; + } +} + +static void +gs_extras_page_maybe_emit_installed_resources_done (GsExtrasPage *self) +{ + if (self->install_resources_ident && ( + self->state == GS_EXTRAS_PAGE_STATE_LOADING || + self->state == GS_EXTRAS_PAGE_STATE_NO_RESULTS || + self->state == GS_EXTRAS_PAGE_STATE_FAILED)) { + GsApplication *application; + GError *op_error = NULL; + + /* When called during the LOADING state, it means the package is already installed */ + if (self->state == GS_EXTRAS_PAGE_STATE_NO_RESULTS) { + g_set_error_literal (&op_error, G_IO_ERROR, G_IO_ERROR_NOT_FOUND, _("Requested software not found")); + } else if (self->state == GS_EXTRAS_PAGE_STATE_FAILED) { + g_set_error_literal (&op_error, G_IO_ERROR, G_IO_ERROR_FAILED, _("Failed to find requested software")); + } + + application = GS_APPLICATION (g_application_get_default ()); + gs_application_emit_install_resources_done (application, self->install_resources_ident, op_error); + + g_clear_pointer (&self->install_resources_ident, g_free); + g_clear_error (&op_error); + } +} + +static void +gs_extras_page_set_state (GsExtrasPage *self, + GsExtrasPageState state) +{ + if (self->state == state) + return; + + self->state = state; + + g_object_notify (G_OBJECT (self), "title"); + gs_extras_page_update_ui_state (self); + gs_extras_page_maybe_emit_installed_resources_done (self); +} + +static void +app_row_button_clicked_cb (GsAppRow *app_row, + GsExtrasPage *self) +{ + GsApp *app = gs_app_row_get_app (app_row); + + if (gs_app_get_state (app) == GS_APP_STATE_UNAVAILABLE && + gs_app_get_url_missing (app) != NULL) { + gs_shell_show_uri (self->shell, + gs_app_get_url_missing (app)); + } else if (gs_app_get_state (app) == GS_APP_STATE_AVAILABLE || + gs_app_get_state (app) == GS_APP_STATE_AVAILABLE_LOCAL || + gs_app_get_state (app) == GS_APP_STATE_UNAVAILABLE) { + gs_page_install_app (GS_PAGE (self), app, GS_SHELL_INTERACTION_FULL, + self->search_cancellable); + } else if (gs_app_get_state (app) == GS_APP_STATE_INSTALLED) { + gs_page_remove_app (GS_PAGE (self), app, self->search_cancellable); + } else { + g_critical ("extras: app in unexpected state %u", gs_app_get_state (app)); + } +} + +static void +gs_extras_page_add_app (GsExtrasPage *self, GsApp *app, GsAppList *list, SearchData *search_data) +{ + GtkWidget *app_row, *child; + + /* Don't add same app twice */ + for (child = gtk_widget_get_first_child (self->list_box_results); + child != NULL; + child = gtk_widget_get_next_sibling (child)) { + GsApp *existing_app; + + /* Might be a separator from list_header_func(). */ + if (!GS_IS_APP_ROW (child)) + continue; + + existing_app = gs_app_row_get_app (GS_APP_ROW (child)); + if (app == existing_app) { + gtk_list_box_remove (GTK_LIST_BOX (self->list_box_results), child); + break; + } + } + + app_row = gs_app_row_new (app); + gs_app_row_set_colorful (GS_APP_ROW (app_row), TRUE); + gs_app_row_set_show_buttons (GS_APP_ROW (app_row), TRUE); + + g_object_set_data_full (G_OBJECT (app_row), "missing-title", g_strdup (search_data->title), g_free); + + g_signal_connect (app_row, "button-clicked", + G_CALLBACK (app_row_button_clicked_cb), + self); + + gtk_list_box_append (GTK_LIST_BOX (self->list_box_results), app_row); + gs_app_row_set_size_groups (GS_APP_ROW (app_row), + self->sizegroup_name, + self->sizegroup_button_label, + self->sizegroup_button_image); + gtk_widget_show (app_row); +} + +static GsApp * +create_missing_app (SearchData *search_data) +{ + GsExtrasPage *self = search_data->self; + GsApp *app; + GString *summary_missing; + g_autofree gchar *name = NULL; + g_autofree gchar *url = NULL; + + app = gs_app_new ("missing-codec"); + + /* TRANSLATORS: This string is used for codecs that weren't found */ + name = g_strdup_printf (_("%s not found"), search_data->title); + gs_app_set_name (app, GS_APP_QUALITY_HIGHEST, name); + + /* TRANSLATORS: hyperlink title */ + url = g_strdup_printf ("<a href=\"%s\">%s</a>", search_data->url_not_found, _("on the website")); + + summary_missing = g_string_new (""); + switch (self->mode) { + case GS_EXTRAS_PAGE_MODE_INSTALL_PACKAGE_FILES: + /* TRANSLATORS: this is when we know about an application or + * addon, but it can't be listed for some reason */ + g_string_append_printf (summary_missing, _("No applications are available that provide the file %s."), search_data->title); + g_string_append (summary_missing, "\n"); + /* TRANSLATORS: first %s is the codec name, and second %s is a + * hyperlink with the "on the website" text */ + g_string_append_printf (summary_missing, _("Information about %s, as well as options " + "for how to get missing applications " + "might be found %s."), search_data->title, url); + break; + case GS_EXTRAS_PAGE_MODE_INSTALL_PROVIDE_FILES: + /* TRANSLATORS: this is when we know about an application or + * addon, but it can't be listed for some reason */ + g_string_append_printf (summary_missing, _("No applications are available for %s support."), search_data->title); + g_string_append (summary_missing, "\n"); + /* TRANSLATORS: first %s is the codec name, and second %s is a + * hyperlink with the "on the website" text */ + g_string_append_printf (summary_missing, _("Information about %s, as well as options " + "for how to get missing applications " + "might be found %s."), search_data->title, url); + break; + case GS_EXTRAS_PAGE_MODE_INSTALL_PACKAGE_NAMES: + /* TRANSLATORS: this is when we know about an application or + * addon, but it can't be listed for some reason */ + g_string_append_printf (summary_missing, _("%s is not available."), search_data->title); + g_string_append (summary_missing, "\n"); + /* TRANSLATORS: first %s is the codec name, and second %s is a + * hyperlink with the "on the website" text */ + g_string_append_printf (summary_missing, _("Information about %s, as well as options " + "for how to get missing applications " + "might be found %s."), search_data->title, url); + break; + case GS_EXTRAS_PAGE_MODE_INSTALL_MIME_TYPES: + /* TRANSLATORS: this is when we know about an application or + * addon, but it can't be listed for some reason */ + g_string_append_printf (summary_missing, _("No applications are available for %s support."), search_data->title); + g_string_append (summary_missing, "\n"); + /* TRANSLATORS: first %s is the codec name, and second %s is a + * hyperlink with the "on the website" text */ + g_string_append_printf (summary_missing, _("Information about %s, as well as options " + "for how to get an application that can support this format " + "might be found %s."), search_data->title, url); + break; + case GS_EXTRAS_PAGE_MODE_INSTALL_FONTCONFIG_RESOURCES: + /* TRANSLATORS: this is when we know about an application or + * addon, but it can't be listed for some reason */ + g_string_append_printf (summary_missing, _("No fonts are available for the %s script support."), search_data->title); + g_string_append (summary_missing, "\n"); + /* TRANSLATORS: first %s is the codec name, and second %s is a + * hyperlink with the "on the website" text */ + g_string_append_printf (summary_missing, _("Information about %s, as well as options " + "for how to get additional fonts " + "might be found %s."), search_data->title, url); + break; + case GS_EXTRAS_PAGE_MODE_INSTALL_GSTREAMER_RESOURCES: + /* TRANSLATORS: this is when we know about an application or + * addon, but it can't be listed for some reason */ + g_string_append_printf (summary_missing, _("No addon codecs are available for the %s format."), search_data->title); + g_string_append (summary_missing, "\n"); + /* TRANSLATORS: first %s is the codec name, and second %s is a + * hyperlink with the "on the website" text */ + g_string_append_printf (summary_missing, _("Information about %s, as well as options " + "for how to get a codec that can play this format " + "might be found %s."), search_data->title, url); + break; + case GS_EXTRAS_PAGE_MODE_INSTALL_PLASMA_RESOURCES: + /* TRANSLATORS: this is when we know about an application or + * addon, but it can't be listed for some reason */ + g_string_append_printf (summary_missing, _("No Plasma resources are available for %s support."), search_data->title); + g_string_append (summary_missing, "\n"); + /* TRANSLATORS: first %s is the codec name, and second %s is a + * hyperlink with the "on the website" text */ + g_string_append_printf (summary_missing, _("Information about %s, as well as options " + "for how to get additional Plasma resources " + "might be found %s."), search_data->title, url); + break; + case GS_EXTRAS_PAGE_MODE_INSTALL_PRINTER_DRIVERS: + /* TRANSLATORS: this is when we know about an application or + * addon, but it can't be listed for some reason */ + g_string_append_printf (summary_missing, _("No printer drivers are available for %s."), search_data->title); + g_string_append (summary_missing, "\n"); + /* TRANSLATORS: first %s is the codec name, and second %s is a + * hyperlink with the "on the website" text */ + g_string_append_printf (summary_missing, _("Information about %s, as well as options " + "for how to get a driver that supports this printer " + "might be found %s."), search_data->title, url); + + break; + default: + g_assert_not_reached (); + break; + } + gs_app_set_summary_missing (app, g_string_free (summary_missing, FALSE)); + + gs_app_set_kind (app, AS_COMPONENT_KIND_GENERIC); + gs_app_set_state (app, GS_APP_STATE_UNAVAILABLE); + gs_app_set_url_missing (app, search_data->url_not_found); + + return app; +} + +static gchar * +build_no_results_label (GsExtrasPage *self) +{ + GsApp *app = NULL; + guint num = 0; + g_autofree gchar *codec_titles = NULL; + g_autofree gchar *url = NULL; + g_autoptr(GPtrArray) array = NULL; + GtkWidget *child; + + array = g_ptr_array_new (); + for (child = gtk_widget_get_first_child (self->list_box_results); + child != NULL; + child = gtk_widget_get_next_sibling (child)) { + /* Might be a separator from list_header_func(). */ + if (!GS_IS_APP_ROW (child)) + continue; + + app = gs_app_row_get_app (GS_APP_ROW (child)); + g_ptr_array_add (array, + g_object_get_data (G_OBJECT (child), "missing-title")); + num++; + } + g_ptr_array_add (array, NULL); + + url = g_strdup_printf ("<a href=\"%s\">%s</a>", + gs_app_get_url_missing (app), + /* TRANSLATORS: hyperlink title */ + _("the documentation")); + + codec_titles = build_comma_separated_list ((gchar **) array->pdata); + if (self->caller_app_name) { + /* TRANSLATORS: no codecs were found. The first %s will be replaced by actual codec name(s), + the second %s is the application name, which requested the codecs, the third %s is a link titled "the documentation" */ + return g_strdup_printf (ngettext ("Unable to find the %s requested by %s. Please see %s for more information.", + "Unable to find the %s requested by %s. Please see %s for more information.", + num), + codec_titles, + self->caller_app_name, + url); + } + + /* TRANSLATORS: no codecs were found. First %s will be replaced by actual codec name(s), second %s is a link titled "the documentation" */ + return g_strdup_printf (ngettext ("Unable to find the %s you were searching for. Please see %s for more information.", + "Unable to find the %s you were searching for. Please see %s for more information.", + num), + codec_titles, + url); +} + +static void +show_search_results (GsExtrasPage *self) +{ + GtkWidget *first_child, *child; + GsApp *app; + guint n_children; + guint n_missing; + + /* count the number of rows with missing codecs */ + n_children = n_missing = 0; + first_child = gtk_widget_get_first_child (self->list_box_results); + for (child = first_child; + child != NULL; + child = gtk_widget_get_next_sibling (child)) { + /* Might be a separator from list_header_func(). */ + if (!GS_IS_APP_ROW (child)) + continue; + + app = gs_app_row_get_app (GS_APP_ROW (child)); + if (g_strcmp0 (gs_app_get_id (app), "missing-codec") == 0) { + n_missing++; + } + n_children++; + } + + if (n_children == 0 || n_children == n_missing) { + g_autofree gchar *str = NULL; + + /* no results */ + g_debug ("extras: failed to find any results, %u", n_missing); + str = build_no_results_label (self); + gtk_label_set_label (GTK_LABEL (self->label_no_results), str); + gs_extras_page_set_state (self, GS_EXTRAS_PAGE_STATE_NO_RESULTS); + } else { + /* show what we got */ + g_debug ("extras: got %u search results, showing", n_children); + gs_extras_page_set_state (self, GS_EXTRAS_PAGE_STATE_READY); + + if (n_children == 1) { + /* switch directly to details view */ + g_debug ("extras: found one result, showing in details view"); + g_assert (first_child != NULL); + app = gs_app_row_get_app (GS_APP_ROW (first_child)); + gs_shell_show_app (self->shell, app); + if (gs_app_is_installed (app)) + gs_extras_page_maybe_emit_installed_resources_done (self); + } + } +} + +static void +search_files_cb (GObject *source_object, + GAsyncResult *res, + gpointer user_data) +{ + SearchData *search_data = (SearchData *) user_data; + GsExtrasPage *self = search_data->self; + g_autoptr(GsAppList) list = NULL; + guint i; + GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (source_object); + g_autoptr(GError) error = NULL; + + list = gs_plugin_loader_job_process_finish (plugin_loader, res, &error); + if (list == NULL) { + g_autofree gchar *str = 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_debug ("extras: search files cancelled"); + return; + } + g_warning ("failed to find any search results: %s", error->message); + str = g_strdup_printf (_("Failed to find any search results: %s"), error->message); + gtk_label_set_label (GTK_LABEL (self->label_failed), str); + gs_extras_page_set_state (self, GS_EXTRAS_PAGE_STATE_FAILED); + return; + } + + /* add missing item */ + if (gs_app_list_length (list) == 0) { + g_autoptr(GsApp) app = NULL; + g_debug ("extras: no search result for %s, showing as missing", + search_data->title); + app = create_missing_app (search_data); + gs_app_list_add (list, app); + } + + for (i = 0; i < gs_app_list_length (list); i++) { + GsApp *app = gs_app_list_index (list, i); + + g_debug ("%s\n\n", gs_app_to_string (app)); + gs_extras_page_add_app (self, app, list, search_data); + } + + self->pending_search_cnt--; + + /* have all searches finished? */ + if (self->pending_search_cnt == 0) + show_search_results (self); +} + +static void +file_to_app_cb (GObject *source_object, + GAsyncResult *res, + gpointer user_data) +{ + SearchData *search_data = (SearchData *) user_data; + GsExtrasPage *self = search_data->self; + GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (source_object); + g_autoptr(GError) error = NULL; + g_autoptr(GsApp) app = NULL; + g_autoptr(GsAppList) list = NULL; + + 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_debug ("extras: search what provides cancelled"); + return; + } + if (g_error_matches (error, + GS_PLUGIN_ERROR, + GS_PLUGIN_ERROR_FAILED)) { + g_debug ("extras: no search result for %s, showing as missing", search_data->title); + app = create_missing_app (search_data); + } else { + g_autofree gchar *str = NULL; + + g_warning ("failed to find any search results: %s", error->message); + str = g_strdup_printf (_("Failed to find any search results: %s"), error->message); + gtk_label_set_label (GTK_LABEL (self->label_failed), str); + gs_extras_page_set_state (self, GS_EXTRAS_PAGE_STATE_FAILED); + return; + } + } else { + app = g_object_ref (gs_app_list_index (list, 0)); + } + + g_debug ("%s\n\n", gs_app_to_string (app)); + gs_extras_page_add_app (self, app, list, search_data); + + self->pending_search_cnt--; + + /* have all searches finished? */ + if (self->pending_search_cnt == 0) + show_search_results (self); +} + +static void +get_search_what_provides_cb (GObject *source_object, + GAsyncResult *res, + gpointer user_data) +{ + SearchData *search_data = (SearchData *) user_data; + GsExtrasPage *self = search_data->self; + g_autoptr(GsAppList) list = NULL; + guint i; + GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (source_object); + g_autoptr(GError) error = NULL; + + list = gs_plugin_loader_job_process_finish (plugin_loader, res, &error); + if (list == NULL) { + g_autofree gchar *str = 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_debug ("extras: search what provides cancelled"); + return; + } + g_warning ("failed to find any search results: %s", error->message); + str = g_strdup_printf (_("Failed to find any search results: %s"), error->message); + gtk_label_set_label (GTK_LABEL (self->label_failed), str); + gs_extras_page_set_state (self, GS_EXTRAS_PAGE_STATE_FAILED); + return; + } + + /* add missing item */ + if (gs_app_list_length (list) == 0) { + g_autoptr(GsApp) app = NULL; + g_debug ("extras: no search result for %s, showing as missing", + search_data->title); + app = create_missing_app (search_data); + gs_app_list_add (list, app); + } + + for (i = 0; i < gs_app_list_length (list); i++) { + GsApp *app = gs_app_list_index (list, i); + + g_debug ("%s\n\n", gs_app_to_string (app)); + gs_extras_page_add_app (self, app, list, search_data); + } + + self->pending_search_cnt--; + + /* have all searches finished? */ + if (self->pending_search_cnt == 0) + show_search_results (self); +} + +static void +gs_extras_page_load (GsExtrasPage *self, GPtrArray *array_search_data) +{ + guint i; + + /* cancel any pending searches */ + g_cancellable_cancel (self->search_cancellable); + g_clear_object (&self->search_cancellable); + self->search_cancellable = g_cancellable_new (); + + if (array_search_data != NULL) { + if (self->array_search_data != NULL) + g_ptr_array_unref (self->array_search_data); + self->array_search_data = g_ptr_array_ref (array_search_data); + } + + self->pending_search_cnt = 0; + + /* remove old entries */ + gs_widget_remove_all (self->list_box_results, (GsRemoveFunc) gtk_list_box_remove); + + /* set state as loading */ + self->state = GS_EXTRAS_PAGE_STATE_LOADING; + + /* start new searches, separate one for each codec */ + for (i = 0; i < self->array_search_data->len; i++) { + GsPluginRefineFlags refine_flags; + SearchData *search_data; + + refine_flags = GS_PLUGIN_REFINE_FLAGS_REQUIRE_ICON | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_VERSION | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_HISTORY | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_ORIGIN_HOSTNAME | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_SETUP_ACTION | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_DESCRIPTION | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_LICENSE | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_RATING | + GS_PLUGIN_REFINE_FLAGS_ALLOW_PACKAGES; + + search_data = g_ptr_array_index (self->array_search_data, i); + if (search_data->search_filename != NULL) { + g_autoptr(GsPluginJob) plugin_job = NULL; + g_autoptr(GsAppQuery) query = NULL; + const gchar *provides_files[2] = { search_data->search_filename, NULL }; + + query = gs_app_query_new ("provides-files", provides_files, + "refine-flags", refine_flags, + NULL); + + plugin_job = gs_plugin_job_list_apps_new (query, GS_PLUGIN_LIST_APPS_FLAGS_INTERACTIVE); + + g_debug ("searching filename: '%s'", search_data->search_filename); + gs_plugin_loader_job_process_async (self->plugin_loader, + plugin_job, + self->search_cancellable, + search_files_cb, + search_data); + } else if (search_data->package_filename != NULL) { + g_autoptr (GFile) file = NULL; + g_autoptr(GsPluginJob) plugin_job = NULL; + file = g_file_new_for_path (search_data->package_filename); + plugin_job = gs_plugin_job_newv (GS_PLUGIN_ACTION_FILE_TO_APP, + "file", file, + "refine-flags", refine_flags, + NULL); + g_debug ("resolving filename to app: '%s'", search_data->package_filename); + gs_plugin_loader_job_process_async (self->plugin_loader, plugin_job, + self->search_cancellable, + file_to_app_cb, + search_data); + } else { + g_autoptr(GsPluginJob) plugin_job = NULL; + g_autoptr(GsAppQuery) query = NULL; + + query = gs_app_query_new ("provides-tag", search_data->search, + "provides-type", search_data->search_provides_type, + "refine-flags", refine_flags, + NULL); + + plugin_job = gs_plugin_job_list_apps_new (query, GS_PLUGIN_LIST_APPS_FLAGS_INTERACTIVE); + + g_debug ("searching what provides: '%s'", search_data->search); + gs_plugin_loader_job_process_async (self->plugin_loader, + plugin_job, + self->search_cancellable, + get_search_what_provides_cb, + search_data); + } + self->pending_search_cnt++; + } + + /* the page title will have changed */ + g_object_notify (G_OBJECT (self), "title"); +} + +static void +gs_extras_page_reload (GsPage *page) +{ + GsExtrasPage *self = GS_EXTRAS_PAGE (page); + if (self->array_search_data != NULL) + gs_extras_page_load (self, NULL); +} + +static void +gs_extras_page_search_package_files (GsExtrasPage *self, gchar **files) +{ + g_autoptr(GPtrArray) array_search_data = g_ptr_array_new_with_free_func ((GDestroyNotify) search_data_free); + guint i; + + for (i = 0; files[i] != NULL; i++) { + SearchData *search_data; + + search_data = g_slice_new0 (SearchData); + search_data->title = g_strdup (files[i]); + search_data->package_filename = g_strdup (files[i]); + search_data->url_not_found = gs_vendor_get_not_found_url (self->vendor, GS_VENDOR_URL_TYPE_DEFAULT); + search_data->self = g_object_ref (self); + g_ptr_array_add (array_search_data, search_data); + } + + gs_extras_page_load (self, array_search_data); +} + +static void +gs_extras_page_search_provide_files (GsExtrasPage *self, gchar **files) +{ + g_autoptr(GPtrArray) array_search_data = g_ptr_array_new_with_free_func ((GDestroyNotify) search_data_free); + guint i; + + for (i = 0; files[i] != NULL; i++) { + SearchData *search_data; + + search_data = g_slice_new0 (SearchData); + search_data->title = g_strdup (files[i]); + search_data->search_filename = g_strdup (files[i]); + search_data->url_not_found = gs_vendor_get_not_found_url (self->vendor, GS_VENDOR_URL_TYPE_DEFAULT); + search_data->self = g_object_ref (self); + g_ptr_array_add (array_search_data, search_data); + } + + gs_extras_page_load (self, array_search_data); +} + +static void +gs_extras_page_search_package_names (GsExtrasPage *self, gchar **package_names) +{ + g_autoptr(GPtrArray) array_search_data = g_ptr_array_new_with_free_func ((GDestroyNotify) search_data_free); + guint i; + + for (i = 0; package_names[i] != NULL; i++) { + SearchData *search_data; + + search_data = g_slice_new0 (SearchData); + search_data->title = g_strdup (package_names[i]); + search_data->search = g_strdup (package_names[i]); + search_data->search_provides_type = GS_APP_QUERY_PROVIDES_PACKAGE_NAME; + search_data->url_not_found = gs_vendor_get_not_found_url (self->vendor, GS_VENDOR_URL_TYPE_DEFAULT); + search_data->self = g_object_ref (self); + g_ptr_array_add (array_search_data, search_data); + } + + gs_extras_page_load (self, array_search_data); +} + +static void +gs_extras_page_search_mime_types (GsExtrasPage *self, gchar **mime_types) +{ + g_autoptr(GPtrArray) array_search_data = g_ptr_array_new_with_free_func ((GDestroyNotify) search_data_free); + guint i; + + for (i = 0; mime_types[i] != NULL; i++) { + SearchData *search_data; + + search_data = g_slice_new0 (SearchData); + search_data->title = g_strdup_printf (_("%s file format"), mime_types[i]); + search_data->search = g_strdup (mime_types[i]); + search_data->search_provides_type = GS_APP_QUERY_PROVIDES_MIME_HANDLER; + search_data->url_not_found = gs_vendor_get_not_found_url (self->vendor, GS_VENDOR_URL_TYPE_MIME); + search_data->self = g_object_ref (self); + g_ptr_array_add (array_search_data, search_data); + } + + gs_extras_page_load (self, array_search_data); +} + +static gchar * +font_tag_to_lang (const gchar *tag) +{ + if (g_str_has_prefix (tag, ":lang=")) + return g_strdup (tag + 6); + + return NULL; +} + +static gchar * +gs_extras_page_font_tag_to_localised_name (GsExtrasPage *self, const gchar *tag) +{ + gchar *name; + g_autofree gchar *lang = NULL; + g_autofree gchar *language = NULL; + + /* use fontconfig to get the language code */ + lang = font_tag_to_lang (tag); + if (lang == NULL) { + g_warning ("Could not parse language tag '%s'", tag); + return NULL; + } + + /* convert to localisable name */ + language = gs_language_iso639_to_language (self->language, lang); + if (language == NULL) { + g_warning ("Could not match language code '%s' to an ISO639 language", lang); + return NULL; + } + + /* get translation, or return untranslated string */ + name = g_strdup (dgettext("iso_639", language)); + if (name == NULL) + name = g_strdup (language); + + return name; +} + +static void +gs_extras_page_search_fontconfig_resources (GsExtrasPage *self, gchar **resources) +{ + g_autoptr(GPtrArray) array_search_data = g_ptr_array_new_with_free_func ((GDestroyNotify) search_data_free); + guint i; + + for (i = 0; resources[i] != NULL; i++) { + SearchData *search_data; + + search_data = g_slice_new0 (SearchData); + search_data->title = gs_extras_page_font_tag_to_localised_name (self, resources[i]); + search_data->search = g_strdup (resources[i]); + search_data->search_provides_type = GS_APP_QUERY_PROVIDES_FONT; + search_data->url_not_found = gs_vendor_get_not_found_url (self->vendor, GS_VENDOR_URL_TYPE_FONT); + search_data->self = g_object_ref (self); + g_ptr_array_add (array_search_data, search_data); + } + + gs_extras_page_load (self, array_search_data); +} + +static void +gs_extras_page_search_gstreamer_resources (GsExtrasPage *self, gchar **resources) +{ + g_autoptr(GPtrArray) array_search_data = g_ptr_array_new_with_free_func ((GDestroyNotify) search_data_free); + guint i; + + for (i = 0; resources[i] != NULL; i++) { + SearchData *search_data; + g_auto(GStrv) parts = NULL; + + parts = g_strsplit (resources[i], "|", 2); + + search_data = g_slice_new0 (SearchData); + search_data->title = g_strdup (parts[0]); + search_data->search = g_strdup (parts[1]); + search_data->search_provides_type = GS_APP_QUERY_PROVIDES_GSTREAMER; + search_data->url_not_found = gs_vendor_get_not_found_url (self->vendor, GS_VENDOR_URL_TYPE_CODEC); + search_data->self = g_object_ref (self); + g_ptr_array_add (array_search_data, search_data); + } + + gs_extras_page_load (self, array_search_data); +} + +static void +gs_extras_page_search_plasma_resources (GsExtrasPage *self, gchar **resources) +{ + g_autoptr(GPtrArray) array_search_data = g_ptr_array_new_with_free_func ((GDestroyNotify) search_data_free); + guint i; + + for (i = 0; resources[i] != NULL; i++) { + SearchData *search_data; + + search_data = g_slice_new0 (SearchData); + search_data->title = g_strdup (resources[i]); + search_data->search = g_strdup (resources[i]); + search_data->search_provides_type = GS_APP_QUERY_PROVIDES_PLASMA; + search_data->url_not_found = gs_vendor_get_not_found_url (self->vendor, GS_VENDOR_URL_TYPE_DEFAULT); + search_data->self = g_object_ref (self); + g_ptr_array_add (array_search_data, search_data); + } + + gs_extras_page_load (self, array_search_data); +} + +static void +gs_extras_page_search_printer_drivers (GsExtrasPage *self, gchar **device_ids) +{ + g_autoptr(GPtrArray) array_search_data = g_ptr_array_new_with_free_func ((GDestroyNotify) search_data_free); + guint i, j; + guint len; + + len = g_strv_length (device_ids); + if (len > 1) + /* hardcode for now as we only support one at a time */ + len = 1; + + /* make a list of provides tags */ + for (i = 0; i < len; i++) { + SearchData *search_data; + gchar *p; + guint n_fields; + g_autofree gchar *tag = NULL; + g_autofree gchar *mfg = NULL; + g_autofree gchar *mdl = NULL; + g_auto(GStrv) fields = NULL; + + fields = g_strsplit (device_ids[i], ";", 0); + n_fields = g_strv_length (fields); + mfg = mdl = NULL; + for (j = 0; j < n_fields && (!mfg || !mdl); j++) { + if (g_str_has_prefix (fields[j], "MFG:")) + mfg = g_strdup (fields[j] + 4); + else if (g_str_has_prefix (fields[j], "MDL:")) + mdl = g_strdup (fields[j] + 4); + } + + if (!mfg || !mdl) { + g_warning("invalid line '%s', missing field", + device_ids[i]); + continue; + } + + tag = g_strdup_printf ("%s;%s;", mfg, mdl); + + /* Replace spaces with underscores */ + for (p = tag; *p != '\0'; p++) + if (*p == ' ') + *p = '_'; + + search_data = g_slice_new0 (SearchData); + search_data->title = g_strdup_printf ("%s %s", mfg, mdl); + search_data->search = g_ascii_strdown (tag, -1); + search_data->search_provides_type = GS_APP_QUERY_PROVIDES_PS_DRIVER; + search_data->url_not_found = gs_vendor_get_not_found_url (self->vendor, GS_VENDOR_URL_TYPE_HARDWARE); + search_data->self = g_object_ref (self); + g_ptr_array_add (array_search_data, search_data); + } + + gs_extras_page_load (self, array_search_data); +} + +static gchar * +gs_extras_page_get_app_name (const gchar *desktop_id) +{ + g_autoptr(GDesktopAppInfo) app_info = NULL; + + if (!desktop_id || !*desktop_id) + return NULL; + + app_info = g_desktop_app_info_new (desktop_id); + if (!app_info) + return NULL; + + return g_strdup (g_app_info_get_display_name (G_APP_INFO (app_info))); +} + +void +gs_extras_page_search (GsExtrasPage *self, + const gchar *mode_str, + gchar **resources, + const gchar *desktop_id, + const gchar *ident) +{ + GsExtrasPageMode old_mode; + + old_mode = self->mode; + self->mode = gs_extras_page_mode_from_string (mode_str); + + if (old_mode != self->mode) + g_object_notify (G_OBJECT (self), "title"); + + g_clear_pointer (&self->caller_app_name, g_free); + self->caller_app_name = gs_extras_page_get_app_name (desktop_id); + g_clear_pointer (&self->install_resources_ident, g_free); + self->install_resources_ident = (ident && *ident) ? g_strdup (ident) : NULL; + + switch (self->mode) { + case GS_EXTRAS_PAGE_MODE_INSTALL_PACKAGE_FILES: + gs_extras_page_search_package_files (self, resources); + break; + case GS_EXTRAS_PAGE_MODE_INSTALL_PROVIDE_FILES: + gs_extras_page_search_provide_files (self, resources); + break; + case GS_EXTRAS_PAGE_MODE_INSTALL_PACKAGE_NAMES: + gs_extras_page_search_package_names (self, resources); + break; + case GS_EXTRAS_PAGE_MODE_INSTALL_MIME_TYPES: + gs_extras_page_search_mime_types (self, resources); + break; + case GS_EXTRAS_PAGE_MODE_INSTALL_FONTCONFIG_RESOURCES: + gs_extras_page_search_fontconfig_resources (self, resources); + break; + case GS_EXTRAS_PAGE_MODE_INSTALL_GSTREAMER_RESOURCES: + gs_extras_page_search_gstreamer_resources (self, resources); + break; + case GS_EXTRAS_PAGE_MODE_INSTALL_PLASMA_RESOURCES: + gs_extras_page_search_plasma_resources (self, resources); + break; + case GS_EXTRAS_PAGE_MODE_INSTALL_PRINTER_DRIVERS: + gs_extras_page_search_printer_drivers (self, resources); + break; + default: + g_assert_not_reached (); + break; + } +} + +static void +gs_extras_page_switch_to (GsPage *page) +{ + GsExtrasPage *self = GS_EXTRAS_PAGE (page); + + if (gs_shell_get_mode (self->shell) != GS_SHELL_MODE_EXTRAS) { + g_warning ("Called switch_to(codecs) when in mode %s", + gs_shell_get_mode_string (self->shell)); + return; + } + + gs_extras_page_update_ui_state (self); +} + +static void +row_activated_cb (GtkListBox *list_box, + GtkListBoxRow *row, + GsExtrasPage *self) +{ + GsApp *app; + + app = gs_app_row_get_app (GS_APP_ROW (row)); + + if (gs_app_get_state (app) == GS_APP_STATE_UNAVAILABLE && + gs_app_get_url_missing (app) != NULL) { + gs_shell_show_uri (self->shell, + gs_app_get_url_missing (app)); + } else { + gs_shell_show_app (self->shell, app); + } +} + +static gchar * +get_app_sort_key (GsApp *app) +{ + GString *key = NULL; + g_autofree gchar *sort_name = NULL; + + key = g_string_sized_new (64); + + /* sort missing applications as last */ + switch (gs_app_get_state (app)) { + case GS_APP_STATE_UNAVAILABLE: + g_string_append (key, "9:"); + break; + default: + g_string_append (key, "1:"); + break; + } + + /* finally, sort by short name */ + if (gs_app_get_name (app) != NULL) { + sort_name = gs_utils_sort_key (gs_app_get_name (app)); + g_string_append (key, sort_name); + } + + return g_string_free (key, FALSE); +} + +static gint +list_sort_func (GtkListBoxRow *a, + GtkListBoxRow *b, + gpointer user_data) +{ + GsApp *a1 = gs_app_row_get_app (GS_APP_ROW (a)); + GsApp *a2 = gs_app_row_get_app (GS_APP_ROW (b)); + g_autofree gchar *key1 = get_app_sort_key (a1); + g_autofree gchar *key2 = get_app_sort_key (a2); + + /* compare the keys according to the algorithm above */ + return g_strcmp0 (key1, key2); +} + +static void +list_header_func (GtkListBoxRow *row, + GtkListBoxRow *before, + gpointer user_data) +{ + GtkWidget *header; + + /* first entry */ + header = gtk_list_box_row_get_header (row); + if (before == NULL) { + gtk_list_box_row_set_header (row, NULL); + return; + } + + /* already set */ + if (header != NULL) + return; + + /* set new */ + header = gtk_separator_new (GTK_ORIENTATION_HORIZONTAL); + gtk_list_box_row_set_header (row, header); +} + +static gboolean +gs_extras_page_setup (GsPage *page, + GsShell *shell, + GsPluginLoader *plugin_loader, + GCancellable *cancellable, + GError **error) +{ + GsExtrasPage *self = GS_EXTRAS_PAGE (page); + + g_return_val_if_fail (GS_IS_EXTRAS_PAGE (self), TRUE); + + self->shell = shell; + + self->plugin_loader = g_object_ref (plugin_loader); + + g_signal_connect (self->list_box_results, "row-activated", + G_CALLBACK (row_activated_cb), self); + gtk_list_box_set_header_func (GTK_LIST_BOX (self->list_box_results), + list_header_func, + self, NULL); + gtk_list_box_set_sort_func (GTK_LIST_BOX (self->list_box_results), + list_sort_func, + self, NULL); + return TRUE; +} + +static void +gs_extras_page_get_property (GObject *object, + guint prop_id, + GValue *value, + GParamSpec *pspec) +{ + GsExtrasPage *self = GS_EXTRAS_PAGE (object); + + switch ((GsExtrasPageProperty) prop_id) { + case PROP_VADJUSTMENT: + g_value_set_object (value, gtk_scrolled_window_get_vadjustment (GTK_SCROLLED_WINDOW (self->scrolledwindow))); + break; + case PROP_TITLE: + switch (self->state) { + case GS_EXTRAS_PAGE_STATE_LOADING: + case GS_EXTRAS_PAGE_STATE_READY: + g_value_take_string (value, build_title (self)); + break; + case GS_EXTRAS_PAGE_STATE_NO_RESULTS: + case GS_EXTRAS_PAGE_STATE_FAILED: + g_value_set_string (value, _("Unable to Find Requested Software")); + break; + default: + g_assert_not_reached (); + break; + } + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_extras_page_set_property (GObject *object, + guint prop_id, + const GValue *value, + GParamSpec *pspec) +{ + switch ((GsExtrasPageProperty) prop_id) { + case PROP_VADJUSTMENT: + case PROP_TITLE: + /* Read-only */ + g_assert_not_reached (); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_extras_page_dispose (GObject *object) +{ + GsExtrasPage *self = GS_EXTRAS_PAGE (object); + + g_cancellable_cancel (self->search_cancellable); + g_clear_object (&self->search_cancellable); + + g_clear_object (&self->sizegroup_name); + g_clear_object (&self->sizegroup_button_label); + g_clear_object (&self->sizegroup_button_image); + g_clear_object (&self->language); + g_clear_object (&self->vendor); + g_clear_object (&self->plugin_loader); + + g_clear_pointer (&self->array_search_data, g_ptr_array_unref); + g_clear_pointer (&self->caller_app_name, g_free); + g_clear_pointer (&self->install_resources_ident, g_free); + + G_OBJECT_CLASS (gs_extras_page_parent_class)->dispose (object); +} + +static void +gs_extras_page_init (GsExtrasPage *self) +{ + g_autoptr(GError) error = NULL; + + gtk_widget_init_template (GTK_WIDGET (self)); + + self->state = GS_EXTRAS_PAGE_STATE_LOADING; + self->sizegroup_name = gtk_size_group_new (GTK_SIZE_GROUP_HORIZONTAL); + self->sizegroup_button_label = gtk_size_group_new (GTK_SIZE_GROUP_HORIZONTAL); + self->sizegroup_button_image = gtk_size_group_new (GTK_SIZE_GROUP_HORIZONTAL); + self->vendor = gs_vendor_new (); + + /* map ISO639 to language names */ + self->language = gs_language_new (); + gs_language_populate (self->language, &error); + if (error != NULL) + g_error ("Failed to map ISO639 to language names: %s", error->message); +} + +static void +gs_extras_page_class_init (GsExtrasPageClass *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_extras_page_get_property; + object_class->set_property = gs_extras_page_set_property; + object_class->dispose = gs_extras_page_dispose; + + page_class->switch_to = gs_extras_page_switch_to; + page_class->reload = gs_extras_page_reload; + page_class->setup = gs_extras_page_setup; + + g_object_class_override_property (object_class, PROP_VADJUSTMENT, "vadjustment"); + g_object_class_override_property (object_class, PROP_TITLE, "title"); + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-extras-page.ui"); + + gtk_widget_class_bind_template_child (widget_class, GsExtrasPage, label_failed); + gtk_widget_class_bind_template_child (widget_class, GsExtrasPage, label_no_results); + gtk_widget_class_bind_template_child (widget_class, GsExtrasPage, list_box_results); + gtk_widget_class_bind_template_child (widget_class, GsExtrasPage, scrolledwindow); + gtk_widget_class_bind_template_child (widget_class, GsExtrasPage, spinner); + gtk_widget_class_bind_template_child (widget_class, GsExtrasPage, stack); +} + +GsExtrasPage * +gs_extras_page_new (void) +{ + GsExtrasPage *self; + self = g_object_new (GS_TYPE_EXTRAS_PAGE, NULL); + return GS_EXTRAS_PAGE (self); +} diff --git a/src/gs-extras-page.h b/src/gs-extras-page.h new file mode 100644 index 0000000..25b0688 --- /dev/null +++ b/src/gs-extras-page.h @@ -0,0 +1,41 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2015-2017 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include "gs-page.h" + +G_BEGIN_DECLS + +#define GS_TYPE_EXTRAS_PAGE (gs_extras_page_get_type ()) + +G_DECLARE_FINAL_TYPE (GsExtrasPage, gs_extras_page, GS, EXTRAS_PAGE, GsPage) + +typedef enum { + GS_EXTRAS_PAGE_MODE_UNKNOWN, + GS_EXTRAS_PAGE_MODE_INSTALL_PACKAGE_FILES, + GS_EXTRAS_PAGE_MODE_INSTALL_PROVIDE_FILES, + GS_EXTRAS_PAGE_MODE_INSTALL_PACKAGE_NAMES, + GS_EXTRAS_PAGE_MODE_INSTALL_MIME_TYPES, + GS_EXTRAS_PAGE_MODE_INSTALL_FONTCONFIG_RESOURCES, + GS_EXTRAS_PAGE_MODE_INSTALL_GSTREAMER_RESOURCES, + GS_EXTRAS_PAGE_MODE_INSTALL_PLASMA_RESOURCES, + GS_EXTRAS_PAGE_MODE_INSTALL_PRINTER_DRIVERS, + GS_EXTRAS_PAGE_MODE_LAST +} GsExtrasPageMode; + +const gchar *gs_extras_page_mode_to_string (GsExtrasPageMode mode); +GsExtrasPage *gs_extras_page_new (void); +void gs_extras_page_search (GsExtrasPage *self, + const gchar *mode, + gchar **resources, + const gchar *desktop_id, + const gchar *ident); + +G_END_DECLS diff --git a/src/gs-extras-page.ui b/src/gs-extras-page.ui new file mode 100644 index 0000000..c294352 --- /dev/null +++ b/src/gs-extras-page.ui @@ -0,0 +1,146 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <requires lib="gtk+" version="3.10"/> + <template class="GsExtrasPage" parent="GsPage"> + <accessibility> + <property name="label" translatable="yes">Codecs page</property> + </accessibility> + <child> + <object class="GtkStack" id="stack"> + + <child> + <object class="GtkStackPage"> + <property name="name">spinner</property> + <property name="child"> + <object class="GtkBox" id="box_spinner"> + <property name="orientation">vertical</property> + <property name="spacing">12</property> + <property name="halign">center</property> + <property name="valign">center</property> + <property name="hexpand">True</property> + <property name="vexpand">True</property> + <style> + <class name="dim-label"/> + </style> + <child> + <object class="GtkSpinner" id="spinner"> + <property name="width_request">32</property> + <property name="height_request">32</property> + <property name="halign">center</property> + <property name="valign">center</property> + <property name="hexpand">True</property> + <property name="vexpand">True</property> + <style> + <class name="fade-in"/> + </style> + </object> + </child> + </object> + </property> + </object> + </child> + + <child> + <object class="GtkStackPage"> + <property name="name">results</property> + <property name="child"> + <object class="GtkScrolledWindow" id="scrolledwindow"> + <property name="can_focus">True</property> + <property name="hscrollbar_policy">never</property> + <property name="vscrollbar_policy">automatic</property> + <child> + <object class="AdwClamp"> + <property name="maximum-size">860</property> + <!-- ~⅔ of the maximum size. --> + <property name="tightening-threshold">576</property> + <child> + <object class="GtkBox" id="box_results"> + <property name="orientation">vertical</property> + <child> + <object class="GtkListBox" id="list_box_results"> + <property name="can_focus">True</property> + <property name="selection_mode">none</property> + <property name="valign">start</property> + </object> + </child> + <child> + <object class="GtkSeparator" id="separator_results"> + <property name="orientation">horizontal</property> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </property> + </object> + </child> + + <child> + <object class="GtkStackPage"> + <property name="name">no-results</property> + <property name="child"> + <object class="GtkBox" id="box_no_results"> + <property name="orientation">vertical</property> + <property name="spacing">24</property> + <property name="halign">center</property> + <property name="valign">center</property> + <property name="hexpand">True</property> + <property name="vexpand">True</property> + <child> + <object class="GtkImage" id="image_no_results"> + <property name="pixel_size">64</property> + <property name="icon_name">face-sad-symbolic</property> + </object> + </child> + <child> + <object class="GtkLabel" id="label_no_results"> + <property name="use_markup">True</property> + <property name="wrap">True</property> + <property name="max_width_chars">60</property> + </object> + </child> + </object> + </property> + </object> + </child> + + <child> + <object class="GtkStackPage"> + <property name="name">failed</property> + <property name="child"> + <object class="GtkBox" id="box_failed"> + <property name="orientation">vertical</property> + <property name="spacing">12</property> + <property name="halign">center</property> + <property name="valign">center</property> + <property name="hexpand">True</property> + <property name="vexpand">True</property> + <style> + <class name="dim-label"/> + </style> + <child> + <object class="GtkImage" id="image_failed"> + <property name="pixel_size">128</property> + <property name="icon_name">action-unavailable-symbolic</property> + </object> + </child> + <child> + <object class="GtkLabel" id="label_failed"> + <property name="wrap">True</property> + <property name="max-width-chars">60</property> + <attributes> + <attribute name="scale" value="1.4"/> + </attributes> + </object> + </child> + </object> + </property> + </object> + </child> + + </object> + </child> + </template> +</interface> diff --git a/src/gs-feature-tile.c b/src/gs-feature-tile.c new file mode 100644 index 0000000..bb8bed4 --- /dev/null +++ b/src/gs-feature-tile.c @@ -0,0 +1,621 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013 Matthias Clasen <mclasen@redhat.com> + * Copyright (C) 2019 Richard Hughes <richard@hughsie.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include <glib/gi18n.h> +#include <gtk/gtk.h> + +#include "gs-feature-tile.h" +#include "gs-layout-manager.h" +#include "gs-common.h" +#include "gs-css.h" + +#define GS_TYPE_FEATURE_TILE_LAYOUT (gs_feature_tile_layout_get_type ()) +G_DECLARE_FINAL_TYPE (GsFeatureTileLayout, gs_feature_tile_layout, GS, FEATURE_TILE_LAYOUT, GsLayoutManager) + +struct _GsFeatureTileLayout +{ + GsLayoutManager parent_instance; + + gboolean narrow_mode; +}; + +G_DEFINE_TYPE (GsFeatureTileLayout, gs_feature_tile_layout, GS_TYPE_LAYOUT_MANAGER) + +enum { + SIGNAL_NARROW_MODE_CHANGED, + SIGNAL_LAST +}; + +static guint signals [SIGNAL_LAST] = { 0 }; + +static void +gs_feature_tile_layout_allocate (GtkLayoutManager *layout_manager, + GtkWidget *widget, + gint width, + gint height, + gint baseline) +{ + GsFeatureTileLayout *self = GS_FEATURE_TILE_LAYOUT (layout_manager); + gboolean narrow_mode; + + GTK_LAYOUT_MANAGER_CLASS (gs_feature_tile_layout_parent_class)->allocate (layout_manager, + widget, width, height, baseline); + + /* Engage ‘narrow mode’ if the allocation becomes too narrow. The exact + * choice of width is arbitrary here. */ + narrow_mode = (width < 600); + if (self->narrow_mode != narrow_mode) { + self->narrow_mode = narrow_mode; + g_signal_emit (self, signals[SIGNAL_NARROW_MODE_CHANGED], 0, self->narrow_mode); + } +} + +static void +gs_feature_tile_layout_class_init (GsFeatureTileLayoutClass *klass) +{ + GtkLayoutManagerClass *layout_manager_class = GTK_LAYOUT_MANAGER_CLASS (klass); + GObjectClass *object_class = G_OBJECT_CLASS (klass); + + layout_manager_class->allocate = gs_feature_tile_layout_allocate; + + signals [SIGNAL_NARROW_MODE_CHANGED] = + g_signal_new ("narrow-mode-changed", + G_TYPE_FROM_CLASS (object_class), G_SIGNAL_RUN_LAST, + 0, + NULL, NULL, g_cclosure_marshal_generic, + G_TYPE_NONE, 1, G_TYPE_BOOLEAN); +} + +static void +gs_feature_tile_layout_init (GsFeatureTileLayout *self) +{ +} + +/* ********************************************************************* */ + +struct _GsFeatureTile +{ + GsAppTile parent_instance; + GtkWidget *stack; + GtkWidget *image; + GtkWidget *title; + GtkWidget *subtitle; + const gchar *markup_cache; /* (unowned) (nullable) */ + GtkCssProvider *tile_provider; /* (owned) (nullable) */ + GtkCssProvider *title_provider; /* (owned) (nullable) */ + GtkCssProvider *subtitle_provider; /* (owned) (nullable) */ + GArray *key_colors_cache; /* (unowned) (nullable) */ + gboolean narrow_mode; + guint refresh_id; +}; + +static void gs_feature_tile_refresh (GsAppTile *self); + +static gboolean +gs_feature_tile_refresh_idle_cb (gpointer user_data) +{ + GsFeatureTile *tile = user_data; + + tile->refresh_id = 0; + + gs_feature_tile_refresh (GS_APP_TILE (tile)); + + return G_SOURCE_REMOVE; +} + +static void +gs_feature_tile_layout_narrow_mode_changed_cb (GtkLayoutManager *layout_manager, + gboolean narrow_mode, + gpointer user_data) +{ + GsFeatureTile *self = GS_FEATURE_TILE (user_data); + + if (self->narrow_mode != narrow_mode && !self->refresh_id) { + self->narrow_mode = narrow_mode; + self->refresh_id = g_idle_add (gs_feature_tile_refresh_idle_cb, self); + } +} + +/* A colour represented in hue, saturation, brightness form; with an additional + * field for its contrast calculated with respect to some external colour. + * + * See https://en.wikipedia.org/wiki/HSL_and_HSV */ +typedef struct +{ + gfloat hue; /* [0.0, 1.0] */ + gfloat saturation; /* [0.0, 1.0] */ + gfloat brightness; /* [0.0, 1.0]; also known as lightness (HSL) or value (HSV) */ + gfloat contrast; /* (0.047, 21] */ +} GsHSBC; + +G_DEFINE_TYPE (GsFeatureTile, gs_feature_tile, GS_TYPE_APP_TILE) + +static void +gs_feature_tile_dispose (GObject *object) +{ + GsFeatureTile *tile = GS_FEATURE_TILE (object); + + if (tile->refresh_id) { + g_source_remove (tile->refresh_id); + tile->refresh_id = 0; + } + + g_clear_object (&tile->tile_provider); + g_clear_object (&tile->title_provider); + g_clear_object (&tile->subtitle_provider); + + G_OBJECT_CLASS (gs_feature_tile_parent_class)->dispose (object); +} + +/* These are subjectively chosen. See below. */ +static const gfloat min_valid_saturation = 0.5; +static const gfloat max_valid_saturation = 0.85; + +/* The minimum absolute contrast ratio between the foreground and background + * colours, from WCAG: + * https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html */ +static const gfloat min_abs_contrast = 4.5; + +/* Sort two candidate background colours for the feature tile, ranking them by + * suitability for being chosen as the background colour, with the most suitable + * first. + * + * There are several criteria being used here: + * 1. First, colours are sorted by whether their saturation is in the range + * [0.5, 0.85], which is a subjectively-chosen range of ‘light, but not too + * saturated’ colours. + * 2. Colours with saturation in that valid range are then sorted by contrast, + * with higher contrast being preferred. The contrast is calculated against + * an external colour by the caller. + * 3. Colours with saturation outside that valid range are sorted by their + * absolute distance from the range, so that colours which are nearer to + * having a valid saturation are preferred. This is useful in the case where + * none of the key colours in this array have valid saturations; the caller + * will want the one which is closest to being valid. + */ +static gboolean +saturation_is_valid (const GsHSBC *hsbc, + gfloat *distance_from_valid_range) +{ + *distance_from_valid_range = (hsbc->saturation > max_valid_saturation) ? hsbc->saturation - max_valid_saturation : min_valid_saturation - hsbc->saturation; + return (hsbc->saturation >= min_valid_saturation && hsbc->saturation <= max_valid_saturation); +} + +static gint +colors_sort_cb (gconstpointer a, + gconstpointer b) +{ + const GsHSBC *hsbc_a = a; + const GsHSBC *hsbc_b = b; + gfloat hsbc_a_distance_from_range, hsbc_b_distance_from_range; + gboolean hsbc_a_saturation_in_range = saturation_is_valid (hsbc_a, &hsbc_a_distance_from_range); + gboolean hsbc_b_saturation_in_range = saturation_is_valid (hsbc_b, &hsbc_b_distance_from_range); + + if (hsbc_a_saturation_in_range && !hsbc_b_saturation_in_range) + return -1; + else if (!hsbc_a_saturation_in_range && hsbc_b_saturation_in_range) + return 1; + else if (!hsbc_a_saturation_in_range && !hsbc_b_saturation_in_range) + return hsbc_a_distance_from_range - hsbc_b_distance_from_range; + else + return ABS (hsbc_b->contrast) - ABS (hsbc_a->contrast); +} + +static gint +colors_sort_contrast_cb (gconstpointer a, + gconstpointer b) +{ + const GsHSBC *hsbc_a = a; + const GsHSBC *hsbc_b = b; + + return hsbc_b->contrast - hsbc_a->contrast; +} + +/* Calculate the relative luminance of @colour. This is [0.0, 1.0], where 0.0 is + * the darkest black, and 1.0 is the lightest white. + * + * https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef */ +static gfloat +relative_luminance (const GsHSBC *colour) +{ + gfloat red, green, blue; + gfloat r, g, b; + gfloat luminance; + + /* Convert to sRGB */ + gtk_hsv_to_rgb (colour->hue, colour->saturation, colour->brightness, + &red, &green, &blue); + + r = (red <= 0.03928) ? red / 12.92 : pow ((red + 0.055) / 1.055, 2.4); + g = (green <= 0.03928) ? green / 12.92 : pow ((green + 0.055) / 1.055, 2.4); + b = (blue <= 0.03928) ? blue / 12.92 : pow ((blue + 0.055) / 1.055, 2.4); + + luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b; + g_assert (luminance >= 0.0 && luminance <= 1.0); + return luminance; +} + +/* Calculate the WCAG contrast ratio between the two colours. The returned ratio + * is in the range (0.047, 21]. + * + * https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html#contrast-ratiodef */ +static gfloat +wcag_contrast (const GsHSBC *foreground, + const GsHSBC *background) +{ + const GsHSBC *lighter, *darker; + gfloat ratio; + + if (foreground->brightness >= background->brightness) { + lighter = foreground; + darker = background; + } else { + lighter = background; + darker = foreground; + } + + ratio = (relative_luminance (lighter) + 0.05) / (relative_luminance (darker) + 0.05); + g_assert (ratio > 0.047 && ratio <= 21); + return ratio; +} + +/* Calculate a new brightness value for @background which improves its contrast + * (as calculated using wcag_contrast()) with @foreground to at least + * @desired_contrast. + * + * The return value is in the range [0.0, 1.0]. + */ +static gfloat +wcag_contrast_find_brightness (const GsHSBC *foreground, + const GsHSBC *background, + gfloat desired_contrast) +{ + GsHSBC modified_background; + + g_assert (desired_contrast > 0.047 && desired_contrast <= 21); + + /* This is an optimisation problem of modifying @background until + * the WCAG contrast is at least @desired_contrast. There might be a + * closed-form solution to this but I can’t be bothered to work it out + * right now. An optimisation loop should work. + * + * wcag_contrast() compares the lightest and darkest of the two colours, + * so ensure the background brightness is modified in the correct + * direction (increased or decreased) depending on whether the + * foreground colour is originally the brighter. This gives the largest + * search space for the background colour brightness, and ensures the + * optimisation works with dark and light themes. */ + for (modified_background = *background; + modified_background.brightness >= 0.0 && + modified_background.brightness <= 1.0 && + wcag_contrast (foreground, &modified_background) < desired_contrast; + modified_background.brightness += ((foreground->brightness > 0.5) ? -0.1 : 0.1)) { + /* Nothing to do here */ + } + + return CLAMP (modified_background.brightness, 0.0, 1.0); +} + +static void +gs_feature_tile_refresh (GsAppTile *self) +{ + GsFeatureTile *tile = GS_FEATURE_TILE (self); + GsApp *app = gs_app_tile_get_app (self); + const gchar *markup = NULL; + g_autofree gchar *name = NULL; + GtkStyleContext *context; + g_autoptr(GIcon) icon = NULL; + guint icon_size; + + if (app == NULL) + return; + + gtk_stack_set_visible_child_name (GTK_STACK (tile->stack), "content"); + + /* Set the narrow mode. */ + context = gtk_widget_get_style_context (GTK_WIDGET (self)); + if (tile->narrow_mode) + gtk_style_context_add_class (context, "narrow"); + else + gtk_style_context_remove_class (context, "narrow"); + + /* Update the icon. Try a 160px version if not in narrow mode, and it’s + * available; otherwise use 128px. */ + if (!tile->narrow_mode) { + icon = gs_app_get_icon_for_size (app, + 160, + gtk_widget_get_scale_factor (tile->image), + NULL); + icon_size = 160; + } + if (icon == NULL) { + icon = gs_app_get_icon_for_size (app, + 128, + gtk_widget_get_scale_factor (tile->image), + NULL); + icon_size = 128; + } + + if (icon != NULL) { + gtk_image_set_from_gicon (GTK_IMAGE (tile->image), icon); + gtk_image_set_pixel_size (GTK_IMAGE (tile->image), icon_size); + gtk_widget_show (tile->image); + } else { + gtk_widget_hide (tile->image); + } + + /* Update text and let it wrap if the widget is narrow. */ + gtk_label_set_label (GTK_LABEL (tile->title), gs_app_get_name (app)); + gtk_label_set_label (GTK_LABEL (tile->subtitle), gs_app_get_summary (app)); + + gtk_label_set_wrap (GTK_LABEL (tile->subtitle), tile->narrow_mode); + gtk_label_set_lines (GTK_LABEL (tile->subtitle), tile->narrow_mode ? 2 : 1); + + /* perhaps set custom css; cache it so that images don’t get reloaded + * unnecessarily. The custom CSS is direction-dependent, and will be + * reloaded when the direction changes. If RTL CSS isn’t set, fall back + * to the LTR CSS. */ + if (gtk_widget_get_direction (GTK_WIDGET (self)) == GTK_TEXT_DIR_RTL) + markup = gs_app_get_metadata_item (app, "GnomeSoftware::FeatureTile-css-rtl"); + if (markup == NULL) + markup = gs_app_get_metadata_item (app, "GnomeSoftware::FeatureTile-css"); + + if (tile->markup_cache != markup && markup != NULL) { + g_autoptr(GsCss) css = gs_css_new (); + g_autofree gchar *modified_markup = gs_utils_set_key_colors_in_css (markup, app); + if (modified_markup != NULL) + gs_css_parse (css, modified_markup, NULL); + gs_utils_widget_set_css (GTK_WIDGET (tile), &tile->tile_provider, "feature-tile", + gs_css_get_markup_for_id (css, "tile")); + gs_utils_widget_set_css (tile->title, &tile->title_provider, "feature-tile-name", + gs_css_get_markup_for_id (css, "name")); + gs_utils_widget_set_css (tile->subtitle, &tile->subtitle_provider, "feature-tile-subtitle", + gs_css_get_markup_for_id (css, "summary")); + tile->markup_cache = markup; + } else if (markup == NULL) { + GArray *key_colors = gs_app_get_key_colors (app); + g_autofree gchar *css = NULL; + + /* If there is no override CSS for the app, default to a solid + * background colour based on the app’s key colors. + * + * Choose an arbitrary key color from the app’s key colors, and + * ensure that it’s: + * - a light, not too saturated version of the dominant color + * of the icon + * - always light enough that grey text is visible on it + * + * Cache the result until the app’s key colours change, as the + * amount of calculation going on here is not entirely trivial. + */ + if (key_colors != tile->key_colors_cache) { + g_autoptr(GArray) colors = NULL; + GdkRGBA fg_rgba; + gboolean fg_rgba_valid; + GsHSBC fg_hsbc; + const GsHSBC *chosen_hsbc; + GsHSBC chosen_hsbc_modified; + gboolean use_chosen_hsbc = FALSE; + + /* Look up the foreground colour for the feature tile, + * which is the colour of the text. This should always + * be provided as a named colour by the theme. + * + * Knowing the foreground colour allows calculation of + * the contrast between candidate background colours and + * the foreground which will be rendered on top of them. + * + * We want to choose a background colour with at least + * @min_abs_contrast contrast with the foreground, so + * that the text is legible. + */ + fg_rgba_valid = gtk_style_context_lookup_color (context, "theme_fg_color", &fg_rgba); + g_assert (fg_rgba_valid); + + gtk_rgb_to_hsv (fg_rgba.red, fg_rgba.green, fg_rgba.blue, + &fg_hsbc.hue, &fg_hsbc.saturation, &fg_hsbc.brightness); + + g_debug ("FG color: RGB: (%f, %f, %f), HSB: (%f, %f, %f)", + fg_rgba.red, fg_rgba.green, fg_rgba.blue, + fg_hsbc.hue, fg_hsbc.saturation, fg_hsbc.brightness); + + /* Convert all the RGBA key colours to HSB, and + * calculate their contrast against the foreground + * colour. + * + * The contrast is calculated as the Weber contrast, + * which is valid for small amounts of foreground colour + * (i.e. text) against larger background areas. Contrast + * is strictly calculated using luminance, but it’s OK + * to subjectively calculate it using brightness, as + * brightness is the subjective impression of luminance. + */ + if (key_colors != NULL) + colors = g_array_sized_new (FALSE, FALSE, sizeof (GsHSBC), key_colors->len); + + g_debug ("Candidate background colors for %s:", gs_app_get_id (app)); + for (guint i = 0; key_colors != NULL && i < key_colors->len; i++) { + const GdkRGBA *rgba = &g_array_index (key_colors, GdkRGBA, i); + GsHSBC hsbc; + + gtk_rgb_to_hsv (rgba->red, rgba->green, rgba->blue, + &hsbc.hue, &hsbc.saturation, &hsbc.brightness); + hsbc.contrast = wcag_contrast (&fg_hsbc, &hsbc); + g_array_append_val (colors, hsbc); + + g_debug (" • RGB: (%f, %f, %f), HSB: (%f, %f, %f), contrast: %f", + rgba->red, rgba->green, rgba->blue, + hsbc.hue, hsbc.saturation, hsbc.brightness, + hsbc.contrast); + } + + /* Sort the candidate background colours to find the + * most appropriate one. */ + g_array_sort (colors, colors_sort_cb); + + /* If the developer/distro has provided override colours, + * use them. If there’s more than one override colour, + * use the one with the highest contrast with the + * foreground colour, unmodified. If there’s only one, + * modify it as below. + * + * If there are no override colours, take the top colour + * after sorting above. If it’s not good enough, modify + * its brightness to improve the contrast, and clamp its + * saturation to the valid range. + * + * If there are no colours, fall through and leave @css + * as %NULL. */ + if (gs_app_get_user_key_colors (app) && + colors != NULL && + colors->len > 1) { + g_array_sort (colors, colors_sort_contrast_cb); + + chosen_hsbc = &g_array_index (colors, GsHSBC, 0); + chosen_hsbc_modified = *chosen_hsbc; + + use_chosen_hsbc = TRUE; + } else if (colors != NULL && colors->len > 0) { + chosen_hsbc = &g_array_index (colors, GsHSBC, 0); + chosen_hsbc_modified = *chosen_hsbc; + + chosen_hsbc_modified.saturation = CLAMP (chosen_hsbc->saturation, min_valid_saturation, max_valid_saturation); + + if (chosen_hsbc->contrast >= -min_abs_contrast && + chosen_hsbc->contrast <= min_abs_contrast) + chosen_hsbc_modified.brightness = wcag_contrast_find_brightness (&fg_hsbc, &chosen_hsbc_modified, min_abs_contrast); + + use_chosen_hsbc = TRUE; + } + + if (use_chosen_hsbc) { + GdkRGBA chosen_rgba; + + gtk_hsv_to_rgb (chosen_hsbc_modified.hue, + chosen_hsbc_modified.saturation, + chosen_hsbc_modified.brightness, + &chosen_rgba.red, &chosen_rgba.green, &chosen_rgba.blue); + + g_debug ("Chosen background colour for %s (saturation %s, brightness %s): RGB: (%f, %f, %f), HSB: (%f, %f, %f)", + gs_app_get_id (app), + (chosen_hsbc_modified.saturation == chosen_hsbc->saturation) ? "not modified" : "modified", + (chosen_hsbc_modified.brightness == chosen_hsbc->brightness) ? "not modified" : "modified", + chosen_rgba.red, chosen_rgba.green, chosen_rgba.blue, + chosen_hsbc_modified.hue, chosen_hsbc_modified.saturation, chosen_hsbc_modified.brightness); + + css = g_strdup_printf ("background-color: rgb(%.0f,%.0f,%.0f);", + chosen_rgba.red * 255.f, + chosen_rgba.green * 255.f, + chosen_rgba.blue * 255.f); + } + + gs_utils_widget_set_css (GTK_WIDGET (tile), &tile->tile_provider, "feature-tile", css); + gs_utils_widget_set_css (tile->title, &tile->title_provider, "feature-tile-name", NULL); + gs_utils_widget_set_css (tile->subtitle, &tile->subtitle_provider, "feature-tile-subtitle", NULL); + + tile->key_colors_cache = key_colors; + } + } + + switch (gs_app_get_state (app)) { + case GS_APP_STATE_INSTALLED: + case GS_APP_STATE_REMOVING: + case GS_APP_STATE_UPDATABLE: + case GS_APP_STATE_UPDATABLE_LIVE: + name = g_strdup_printf ("%s (%s)", + gs_app_get_name (app), + C_("Single app", "Installed")); + break; + case GS_APP_STATE_AVAILABLE: + case GS_APP_STATE_INSTALLING: + default: + name = g_strdup (gs_app_get_name (app)); + break; + } + + if (name != NULL) { + gtk_accessible_update_property (GTK_ACCESSIBLE (tile), + GTK_ACCESSIBLE_PROPERTY_LABEL, name, + GTK_ACCESSIBLE_PROPERTY_DESCRIPTION, gs_app_get_summary (app), + -1); + } +} + +static void +gs_feature_tile_direction_changed (GtkWidget *widget, GtkTextDirection previous_direction) +{ + GsFeatureTile *tile = GS_FEATURE_TILE (widget); + + gs_feature_tile_refresh (GS_APP_TILE (tile)); +} + +static void +gs_feature_tile_css_changed (GtkWidget *widget, + GtkCssStyleChange *css_change) +{ + GsFeatureTile *tile = GS_FEATURE_TILE (widget); + + /* Clear the key colours cache, as the tile background colour will + * potentially need recalculating if the widget’s foreground colour has + * changed. */ + tile->key_colors_cache = NULL; + + gs_feature_tile_refresh (GS_APP_TILE (tile)); + + GTK_WIDGET_CLASS (gs_feature_tile_parent_class)->css_changed (widget, css_change); +} + +static void +gs_feature_tile_init (GsFeatureTile *tile) +{ + GtkLayoutManager *layout_manager; + + gtk_widget_init_template (GTK_WIDGET (tile)); + + layout_manager = gtk_widget_get_layout_manager (GTK_WIDGET (tile)); + g_warn_if_fail (layout_manager != NULL); + g_signal_connect_object (layout_manager, "narrow-mode-changed", + G_CALLBACK (gs_feature_tile_layout_narrow_mode_changed_cb), tile, 0); +} + +static void +gs_feature_tile_class_init (GsFeatureTileClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + GsAppTileClass *app_tile_class = GS_APP_TILE_CLASS (klass); + + object_class->dispose = gs_feature_tile_dispose; + + widget_class->css_changed = gs_feature_tile_css_changed; + widget_class->direction_changed = gs_feature_tile_direction_changed; + + app_tile_class->refresh = gs_feature_tile_refresh; + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-feature-tile.ui"); + + gtk_widget_class_bind_template_child (widget_class, GsFeatureTile, stack); + gtk_widget_class_bind_template_child (widget_class, GsFeatureTile, image); + gtk_widget_class_bind_template_child (widget_class, GsFeatureTile, title); + gtk_widget_class_bind_template_child (widget_class, GsFeatureTile, subtitle); + + gtk_widget_class_set_css_name (widget_class, "feature-tile"); + gtk_widget_class_set_layout_manager_type (widget_class, GS_TYPE_FEATURE_TILE_LAYOUT); +} + +GtkWidget * +gs_feature_tile_new (GsApp *app) +{ + return g_object_new (GS_TYPE_FEATURE_TILE, + "vexpand", FALSE, + "app", app, + NULL); +} diff --git a/src/gs-feature-tile.h b/src/gs-feature-tile.h new file mode 100644 index 0000000..e9b65ea --- /dev/null +++ b/src/gs-feature-tile.h @@ -0,0 +1,21 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013 Matthias Clasen <mclasen@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include "gs-app-tile.h" + +G_BEGIN_DECLS + +#define GS_TYPE_FEATURE_TILE (gs_feature_tile_get_type ()) + +G_DECLARE_FINAL_TYPE (GsFeatureTile, gs_feature_tile, GS, FEATURE_TILE, GsAppTile) + +GtkWidget *gs_feature_tile_new (GsApp *app); + +G_END_DECLS diff --git a/src/gs-feature-tile.ui b/src/gs-feature-tile.ui new file mode 100644 index 0000000..c9bb956 --- /dev/null +++ b/src/gs-feature-tile.ui @@ -0,0 +1,85 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <!-- interface-requires gtk+ 3.10 --> + <template class="GsFeatureTile" parent="GsAppTile"> + <property name="halign">fill</property> + <style> + <class name="featured-tile"/> + </style> + <child> + <object class="GtkStack" id="stack"> + + <child> + <object class="GtkStackPage"> + <property name="name">waiting</property> + <property name="child"> + <object class="GtkImage" id="waiting"> + <property name="halign">center</property> + <property name="valign">center</property> + <property name="pixel-size">16</property> + <property name="icon-name">content-loading-symbolic</property> + <style> + <class name="dim-label"/> + </style> + </object> + </property> + </object> + </child> + + <child> + <object class="GtkStackPage"> + <property name="name">content</property> + <property name="child"> + <object class="GtkBox" id="box"> + <property name="halign">center</property> + <property name="orientation">vertical</property> + <property name="margin-top">50</property> + <property name="margin-bottom">50</property> + <property name="margin-start">50</property> + <property name="margin-end">50</property> + <child> + <object class="GtkImage" id="image"> + <style> + <class name="icon-dropshadow"/> + </style> + </object> + </child> + <child> + <object class="GtkLabel" id="title"> + <property name="xalign">0.5</property> + <property name="halign">center</property> + <property name="valign">end</property> + <property name="vexpand">True</property> + <property name="ellipsize">end</property> + <style> + <class name="title-1"/> + </style> + </object> + </child> + <child> + <object class="AdwClamp"> + <property name="maximum-size">350</property> + <property name="tightening-threshold">350</property> + <child> + <object class="GtkLabel" id="subtitle"> + <property name="ellipsize">end</property> + <property name="xalign">0.5</property> + <property name="valign">start</property> + <property name="lines">1</property> + <property name="justify">center</property> + <style> + <class name="caption"/> + </style> + </object> + </child> + </object> + </child> + </object> + </property> + </object> + </child> + + </object> + </child> + </template> +</interface> diff --git a/src/gs-featured-carousel.c b/src/gs-featured-carousel.c new file mode 100644 index 0000000..4ce5687 --- /dev/null +++ b/src/gs-featured-carousel.c @@ -0,0 +1,402 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2021 Endless OS Foundation LLC + * + * Author: Philip Withnall <pwithnall@endlessos.org> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +/** + * SECTION:gs-featured-carousel + * @short_description: A carousel widget containing #GsFeatureTile instances + * + * #GsFeaturedCarousel is a carousel widget which rotates through a set of + * #GsFeatureTiles, displaying them to the user to advertise a given set of + * featured apps, set with gs_featured_carousel_set_apps(). + * + * The widget has no special appearance if the app list is empty, so callers + * will typically want to hide the carousel in that case. + * + * Since: 40 + */ + +#include "config.h" + +#include <adwaita.h> +#include <glib.h> +#include <glib-object.h> +#include <glib/gi18n.h> +#include <gtk/gtk.h> + +#include "gs-app-list.h" +#include "gs-common.h" +#include "gs-feature-tile.h" +#include "gs-featured-carousel.h" + +#define FEATURED_ROTATE_TIME 15 /* seconds */ + +struct _GsFeaturedCarousel +{ + GtkBox parent_instance; + + GsAppList *apps; /* (nullable) (owned) */ + guint rotation_timer_id; + + AdwCarousel *carousel; + GtkButton *next_button; + GtkButton *previous_button; +}; + +G_DEFINE_TYPE (GsFeaturedCarousel, gs_featured_carousel, GTK_TYPE_BOX) + +typedef enum { + PROP_APPS = 1, +} GsFeaturedCarouselProperty; + +static GParamSpec *obj_props[PROP_APPS + 1] = { NULL, }; + +typedef enum { + SIGNAL_APP_CLICKED, +} GsFeaturedCarouselSignal; + +static guint obj_signals[SIGNAL_APP_CLICKED + 1] = { 0, }; + +static GtkWidget * +get_nth_page_widget (GsFeaturedCarousel *self, + guint page_number) +{ + GtkWidget *page = gtk_widget_get_first_child (GTK_WIDGET (self->carousel)); + guint i = 0; + + while (page && i++ < page_number) + page = gtk_widget_get_next_sibling (page); + return page; +} + +static void +show_relative_page (GsFeaturedCarousel *self, + gint delta) +{ + gdouble current_page = adw_carousel_get_position (self->carousel); + guint n_pages = adw_carousel_get_n_pages (self->carousel); + gdouble new_page; + GtkWidget *new_page_widget; + gboolean animate = TRUE; + + if (n_pages == 0) + return; + + /* FIXME: This would be simpler if AdwCarousel had a way to scroll to + * a page by index, rather than by GtkWidget pointer. + * See https://gitlab.gnome.org/GNOME/libhandy/-/issues/413 */ + new_page = ((guint) current_page + delta + n_pages) % n_pages; + new_page_widget = get_nth_page_widget (self, new_page); + g_assert (new_page_widget != NULL); + + /* Don’t animate if we’re wrapping from the last page back to the first + * or from the first page to the last going backwards as it means rapidly + * spooling through all the pages, which looks confusing. */ + if ((new_page == 0.0 && delta > 0) || (new_page == n_pages - 1 && delta < 0)) + animate = FALSE; + + adw_carousel_scroll_to (self->carousel, new_page_widget, animate); +} + +static gboolean +rotate_cb (gpointer user_data) +{ + GsFeaturedCarousel *self = GS_FEATURED_CAROUSEL (user_data); + + show_relative_page (self, +1); + + return G_SOURCE_CONTINUE; +} + +static void +start_rotation_timer (GsFeaturedCarousel *self) +{ + if (self->rotation_timer_id == 0) { + self->rotation_timer_id = g_timeout_add_seconds (FEATURED_ROTATE_TIME, + rotate_cb, self); + } +} + +static void +stop_rotation_timer (GsFeaturedCarousel *self) +{ + if (self->rotation_timer_id != 0) { + g_source_remove (self->rotation_timer_id); + self->rotation_timer_id = 0; + } +} + +static void +carousel_notify_position_cb (GsFeaturedCarousel *self) +{ + /* Reset the rotation timer in case it’s about to fire. */ + stop_rotation_timer (self); + start_rotation_timer (self); +} + +static void +next_button_clicked_cb (GtkButton *button, + gpointer user_data) +{ + GsFeaturedCarousel *self = GS_FEATURED_CAROUSEL (user_data); + + show_relative_page (self, +1); +} + +static void +previous_button_clicked_cb (GtkButton *button, + gpointer user_data) +{ + GsFeaturedCarousel *self = GS_FEATURED_CAROUSEL (user_data); + + show_relative_page (self, -1); +} + +static void +app_tile_clicked_cb (GsAppTile *app_tile, + gpointer user_data) +{ + GsFeaturedCarousel *self = GS_FEATURED_CAROUSEL (user_data); + GsApp *app; + + app = gs_app_tile_get_app (app_tile); + g_signal_emit (self, obj_signals[SIGNAL_APP_CLICKED], 0, app); +} + +static void +gs_featured_carousel_init (GsFeaturedCarousel *self) +{ + gtk_widget_init_template (GTK_WIDGET (self)); + + /* Disable scrolling through the carousel, as it’s typically used + * in application pages which are themselves scrollable. */ + adw_carousel_set_allow_scroll_wheel (self->carousel, FALSE); +} + +static void +gs_featured_carousel_get_property (GObject *object, + guint prop_id, + GValue *value, + GParamSpec *pspec) +{ + GsFeaturedCarousel *self = GS_FEATURED_CAROUSEL (object); + + switch ((GsFeaturedCarouselProperty) prop_id) { + case PROP_APPS: + g_value_set_object (value, gs_featured_carousel_get_apps (self)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_featured_carousel_set_property (GObject *object, + guint prop_id, + const GValue *value, + GParamSpec *pspec) +{ + GsFeaturedCarousel *self = GS_FEATURED_CAROUSEL (object); + + switch ((GsFeaturedCarouselProperty) prop_id) { + case PROP_APPS: + gs_featured_carousel_set_apps (self, g_value_get_object (value)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_featured_carousel_dispose (GObject *object) +{ + GsFeaturedCarousel *self = GS_FEATURED_CAROUSEL (object); + + stop_rotation_timer (self); + g_clear_object (&self->apps); + + G_OBJECT_CLASS (gs_featured_carousel_parent_class)->dispose (object); +} + +static gboolean +key_pressed_cb (GtkEventControllerKey *controller, + guint keyval, + guint keycode, + GdkModifierType state, + GsFeaturedCarousel *self) +{ + if (gtk_widget_is_visible (GTK_WIDGET (self->previous_button)) && + gtk_widget_is_sensitive (GTK_WIDGET (self->previous_button)) && + ((gtk_widget_get_direction (GTK_WIDGET (self->previous_button)) == GTK_TEXT_DIR_LTR && keyval == GDK_KEY_Left) || + (gtk_widget_get_direction (GTK_WIDGET (self->previous_button)) == GTK_TEXT_DIR_RTL && keyval == GDK_KEY_Right))) { + gtk_widget_activate (GTK_WIDGET (self->previous_button)); + return GDK_EVENT_STOP; + } + + if (gtk_widget_is_visible (GTK_WIDGET (self->next_button)) && + gtk_widget_is_sensitive (GTK_WIDGET (self->next_button)) && + ((gtk_widget_get_direction (GTK_WIDGET (self->next_button)) == GTK_TEXT_DIR_LTR && keyval == GDK_KEY_Right) || + (gtk_widget_get_direction (GTK_WIDGET (self->next_button)) == GTK_TEXT_DIR_RTL && keyval == GDK_KEY_Left))) { + gtk_widget_activate (GTK_WIDGET (self->next_button)); + return GDK_EVENT_STOP; + } + + return GDK_EVENT_PROPAGATE; +} + +static void +gs_featured_carousel_class_init (GsFeaturedCarouselClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + + object_class->get_property = gs_featured_carousel_get_property; + object_class->set_property = gs_featured_carousel_set_property; + object_class->dispose = gs_featured_carousel_dispose; + + /** + * GsFeaturedCarousel:apps: (nullable) + * + * The list of featured apps to display in the carousel. This should + * typically be 4–8 apps. They will be displayed in the order listed, + * so the caller may want to randomise that order first, using + * gs_app_list_randomize(). + * + * This may be %NULL if no apps have been set. This is equivalent to + * an empty #GsAppList. + * + * Since: 40 + */ + obj_props[PROP_APPS] = + g_param_spec_object ("apps", NULL, NULL, + GS_TYPE_APP_LIST, + G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS); + + g_object_class_install_properties (object_class, G_N_ELEMENTS (obj_props), obj_props); + + /** + * GsFeaturedCarousel::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: 40 + */ + obj_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-featured-carousel.ui"); + gtk_widget_class_set_accessible_role (widget_class, GTK_ACCESSIBLE_ROLE_GROUP); + + gtk_widget_class_bind_template_child (widget_class, GsFeaturedCarousel, carousel); + gtk_widget_class_bind_template_child (widget_class, GsFeaturedCarousel, next_button); + gtk_widget_class_bind_template_child (widget_class, GsFeaturedCarousel, previous_button); + gtk_widget_class_bind_template_callback (widget_class, carousel_notify_position_cb); + gtk_widget_class_bind_template_callback (widget_class, next_button_clicked_cb); + gtk_widget_class_bind_template_callback (widget_class, previous_button_clicked_cb); + gtk_widget_class_bind_template_callback (widget_class, key_pressed_cb); +} + +/** + * gs_featured_carousel_new: + * @apps: (nullable): a list of apps to display in the carousel, or %NULL + * + * Create a new #GsFeaturedCarousel and set its initial app list to @apps. + * + * Returns: (transfer full): a new #GsFeaturedCarousel + * Since: 40 + */ +GtkWidget * +gs_featured_carousel_new (GsAppList *apps) +{ + g_return_val_if_fail (apps == NULL || GS_IS_APP_LIST (apps), NULL); + + return g_object_new (GS_TYPE_FEATURED_CAROUSEL, + "apps", apps, + NULL); +} + +/** + * gs_featured_carousel_get_apps: + * @self: a #GsFeaturedCarousel + * + * Gets the value of #GsFeaturedCarousel:apps. + * + * Returns: (nullable) (transfer none): list of apps in the carousel, or %NULL + * if none are set + * Since: 40 + */ +GsAppList * +gs_featured_carousel_get_apps (GsFeaturedCarousel *self) +{ + g_return_val_if_fail (GS_IS_FEATURED_CAROUSEL (self), NULL); + + return self->apps; +} + +/** + * gs_featured_carousel_set_apps: + * @self: a #GsFeaturedCarousel + * @apps: (nullable) (transfer none): list of apps to display in the carousel, + * or %NULL for none + * + * Set the value of #GsFeaturedCarousel:apps. + * + * Since: 40 + */ +void +gs_featured_carousel_set_apps (GsFeaturedCarousel *self, + GsAppList *apps) +{ + g_return_if_fail (GS_IS_FEATURED_CAROUSEL (self)); + g_return_if_fail (apps == NULL || GS_IS_APP_LIST (apps)); + + /* Need to cleanup the content also after the widget is created, + * thus always pass through for the NULL 'apps'. */ + if (apps != NULL && apps == self->apps) + return; + + stop_rotation_timer (self); + gs_widget_remove_all (GTK_WIDGET (self->carousel), (GsRemoveFunc) adw_carousel_remove); + + g_set_object (&self->apps, apps); + + if (apps != NULL) { + for (guint i = 0; i < gs_app_list_length (apps); i++) { + GsApp *app = gs_app_list_index (apps, i); + GtkWidget *tile = gs_feature_tile_new (app); + gtk_widget_set_hexpand (tile, TRUE); + gtk_widget_set_vexpand (tile, TRUE); + gtk_widget_set_can_focus (tile, FALSE); + g_signal_connect (tile, "clicked", + G_CALLBACK (app_tile_clicked_cb), self); + adw_carousel_append (self->carousel, tile); + } + } else { + GtkWidget *tile = gs_feature_tile_new (NULL); + gtk_widget_set_hexpand (tile, TRUE); + gtk_widget_set_vexpand (tile, TRUE); + gtk_widget_set_can_focus (tile, FALSE); + adw_carousel_append (self->carousel, tile); + } + + gtk_widget_set_visible (GTK_WIDGET (self->next_button), self->apps != NULL && gs_app_list_length (self->apps) > 1); + gtk_widget_set_visible (GTK_WIDGET (self->previous_button), self->apps != NULL && gs_app_list_length (self->apps) > 1); + + if (self->apps != NULL && gs_app_list_length (self->apps) > 0) + start_rotation_timer (self); + + g_object_notify_by_pspec (G_OBJECT (self), obj_props[PROP_APPS]); +} diff --git a/src/gs-featured-carousel.h b/src/gs-featured-carousel.h new file mode 100644 index 0000000..9b24f26 --- /dev/null +++ b/src/gs-featured-carousel.h @@ -0,0 +1,31 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2021 Endless OS Foundation LLC + * + * Author: Philip Withnall <pwithnall@endlessos.org> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <glib.h> +#include <glib-object.h> +#include <gtk/gtk.h> + +#include "gs-app-list.h" + +G_BEGIN_DECLS + +#define GS_TYPE_FEATURED_CAROUSEL (gs_featured_carousel_get_type ()) + +G_DECLARE_FINAL_TYPE (GsFeaturedCarousel, gs_featured_carousel, GS, FEATURED_CAROUSEL, GtkBox) + +GtkWidget *gs_featured_carousel_new (GsAppList *apps); + +GsAppList *gs_featured_carousel_get_apps (GsFeaturedCarousel *self); +void gs_featured_carousel_set_apps (GsFeaturedCarousel *self, + GsAppList *apps); + +G_END_DECLS diff --git a/src/gs-featured-carousel.ui b/src/gs-featured-carousel.ui new file mode 100644 index 0000000..3348686 --- /dev/null +++ b/src/gs-featured-carousel.ui @@ -0,0 +1,86 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <requires lib="gtk+" version="3.10"/> + <requires lib="handy" version="1.0"/> + <template class="GsFeaturedCarousel" parent="GtkBox"> + <property name="halign">fill</property> + <property name="orientation">vertical</property> + <property name="spacing">12</property> + <child> + <object class="GtkEventControllerKey"> + <signal name="key-pressed" handler="key_pressed_cb"/> + </object> + </child> + <style> + <class name="featured-carousel"/> + </style> + <child> + <object class="GtkOverlay" id="overlay"> + <property name="halign">fill</property> + <property name="valign">fill</property> + <child> + <object class="AdwCarousel" id="carousel"> + <signal name="notify::position" handler="carousel_notify_position_cb" swapped="yes"/> + <style> + <class name="card"/> + </style> + </object> + </child> + <child type="overlay"> + <object class="GtkButton" id="previous_button"> + <property name="use-underline">True</property> + <property name="halign">start</property> + <property name="valign">center</property> + <property name="width-request">64</property> + <property name="height-request">64</property> + <property name="margin-top">9</property> + <property name="margin-bottom">9</property> + <property name="margin-start">9</property> + <property name="margin-end">9</property> + <property name="icon-name">go-previous-symbolic</property> + <signal name="clicked" handler="previous_button_clicked_cb"/> + <accessibility> + <property name="label" translatable="yes">Previous</property> + </accessibility> + <style> + <class name="circular"/> + <class name="flat"/> + <class name="image-button"/> + </style> + </object> + </child> + <child type="overlay"> + <object class="GtkButton" id="next_button"> + <property name="use-underline">True</property> + <property name="halign">end</property> + <property name="valign">center</property> + <property name="width-request">64</property> + <property name="height-request">64</property> + <property name="margin-top">9</property> + <property name="margin-bottom">9</property> + <property name="margin-start">9</property> + <property name="margin-end">9</property> + <property name="icon_name">go-next-symbolic</property> + <signal name="clicked" handler="next_button_clicked_cb"/> + <accessibility> + <property name="label" translatable="yes">Next</property> + </accessibility> + <style> + <class name="circular"/> + <class name="flat"/> + <class name="image-button"/> + </style> + </object> + </child> + </object> + </child> + <child> + <object class="AdwCarouselIndicatorDots" id="dots"> + <property name="carousel">carousel</property> + </object> + </child> + <accessibility> + <property name="label" translatable="yes">Featured Apps List</property> + </accessibility> + </template> +</interface> diff --git a/src/gs-hardware-support-context-dialog.c b/src/gs-hardware-support-context-dialog.c new file mode 100644 index 0000000..589ea78 --- /dev/null +++ b/src/gs-hardware-support-context-dialog.c @@ -0,0 +1,920 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2021 Endless OS Foundation LLC + * + * Author: Philip Withnall <pwithnall@endlessos.org> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +/** + * SECTION:gs-hardware-support-context-dialog + * @short_description: A dialog showing hardware support information about an app + * + * #GsHardwareSupportContextDialog is a dialog which shows detailed information + * about what hardware an app requires or recommends to be used when running it. + * For example, what input devices it requires, and what display sizes it + * supports. This information is derived from the `<requires>`, + * `<recommends>` and `<supports>` elements in the app’s appdata. + * + * Currently, `<supports>` is treated as a synonym of `<recommends>` as it’s + * only just been introduced into the appstream standard, and many apps which + * should be using `<supports>` are still using `<recommends>`. + * + * It is designed to show a more detailed view of the information which the + * app’s hardware support tile in #GsAppContextBar is derived from. + * + * The widget has no special appearance if the app is unset, so callers will + * typically want to hide the dialog in that case. + * + * Since: 41 + */ + +#include "config.h" + +#include <adwaita.h> +#include <glib.h> +#include <glib-object.h> +#include <glib/gi18n.h> +#include <gtk/gtk.h> +#include <locale.h> + +#include "gs-app.h" +#include "gs-common.h" +#include "gs-context-dialog-row.h" +#include "gs-hardware-support-context-dialog.h" +#include "gs-lozenge.h" + +struct _GsHardwareSupportContextDialog +{ + GsInfoWindow parent_instance; + + GsApp *app; /* (nullable) (owned) */ + gulong app_notify_handler_relations; + gulong app_notify_handler_name; + + GtkWidget *lozenge; + GtkLabel *title; + GtkListBox *relations_list; +}; + +G_DEFINE_TYPE (GsHardwareSupportContextDialog, gs_hardware_support_context_dialog, GS_TYPE_INFO_WINDOW) + +typedef enum { + PROP_APP = 1, +} GsHardwareSupportContextDialogProperty; + +static GParamSpec *obj_props[PROP_APP + 1] = { NULL, }; + +typedef enum { + MATCH_STATE_NO_MATCH = 0, + MATCH_STATE_MATCH = 1, + MATCH_STATE_UNKNOWN, +} MatchState; + +/* The `icon_name_*`, `title_*` and `description_*` arguments are all nullable. + * If a row would be added with %NULL values, it is not added. */ +static void +add_relation_row (GtkListBox *list_box, + GsContextDialogRowImportance *chosen_rating, + AsRelationKind control_relation_kind, + MatchState match_state, + gboolean any_control_relations_set, + const gchar *icon_name_required_matches, + const gchar *title_required_matches, + const gchar *description_required_matches, + const gchar *icon_name_no_relation, + const gchar *title_no_relation, + const gchar *description_no_relation, + const gchar *icon_name_required_no_match, + const gchar *title_required_no_match, + const gchar *description_required_no_match, + const gchar *icon_name_recommends, + const gchar *title_recommends, + const gchar *description_recommends, + const gchar *icon_name_unsupported, + const gchar *title_unsupported, + const gchar *description_unsupported) +{ + GtkListBoxRow *row; + GsContextDialogRowImportance rating; + const gchar *icon_name, *title, *description; + + g_assert (control_relation_kind == AS_RELATION_KIND_UNKNOWN || any_control_relations_set); + + switch (control_relation_kind) { + case AS_RELATION_KIND_UNKNOWN: + if (!any_control_relations_set) { + rating = GS_CONTEXT_DIALOG_ROW_IMPORTANCE_NEUTRAL; + icon_name = icon_name_no_relation; + title = title_no_relation; + description = description_no_relation; + } else { + rating = GS_CONTEXT_DIALOG_ROW_IMPORTANCE_WARNING; + icon_name = icon_name_unsupported; + title = title_unsupported; + description = description_unsupported; + } + break; + case AS_RELATION_KIND_REQUIRES: + if (match_state == MATCH_STATE_MATCH) { + rating = GS_CONTEXT_DIALOG_ROW_IMPORTANCE_UNIMPORTANT; + icon_name = icon_name_required_matches; + title = title_required_matches; + description = description_required_matches; + } else { + rating = (match_state == MATCH_STATE_NO_MATCH) ? GS_CONTEXT_DIALOG_ROW_IMPORTANCE_IMPORTANT : GS_CONTEXT_DIALOG_ROW_IMPORTANCE_WARNING; + icon_name = icon_name_required_no_match; + title = title_required_no_match; + description = description_required_no_match; + } + break; + case AS_RELATION_KIND_RECOMMENDS: +#if AS_CHECK_VERSION(0, 15, 0) + case AS_RELATION_KIND_SUPPORTS: +#endif + rating = GS_CONTEXT_DIALOG_ROW_IMPORTANCE_UNIMPORTANT; + icon_name = icon_name_recommends; + title = title_recommends; + description = description_recommends; + break; + default: + g_assert_not_reached (); + } + + if (icon_name == NULL) + return; + + if (rating > *chosen_rating) + *chosen_rating = rating; + + row = gs_context_dialog_row_new (icon_name, rating, title, description); + gtk_list_box_append (list_box, GTK_WIDGET (row)); +} + +/** + * gs_hardware_support_context_dialog_get_largest_monitor: + * @display: a #GdkDisplay + * + * Get the largest monitor associated with @display, comparing the larger of the + * monitor’s width and height, and breaking ties between equally-large monitors + * using gdk_monitor_is_primary(). + * + * Returns: (nullable) (transfer none): the largest monitor from @display, or + * %NULL if no monitor information is available + * Since: 41 + */ +GdkMonitor * +gs_hardware_support_context_dialog_get_largest_monitor (GdkDisplay *display) +{ + GListModel *monitors; /* (unowned) */ + GdkMonitor *monitor; /* (unowned) */ + int monitor_max_dimension; + guint n_monitors; + + g_return_val_if_fail (GDK_IS_DISPLAY (display), NULL); + + monitors = gdk_display_get_monitors (display); + n_monitors = g_list_model_get_n_items (monitors); + monitor_max_dimension = 0; + monitor = NULL; + + for (guint i = 0; i < n_monitors; i++) { + g_autoptr(GdkMonitor) monitor2 = g_list_model_get_item (monitors, i); + GdkRectangle monitor_geometry; + int monitor2_max_dimension; + + if (monitor2 == NULL) + continue; + + gdk_monitor_get_geometry (monitor2, &monitor_geometry); + monitor2_max_dimension = MAX (monitor_geometry.width, monitor_geometry.height); + + if (monitor2_max_dimension > monitor_max_dimension) { + monitor = monitor2; + monitor_max_dimension = monitor2_max_dimension; + continue; + } + } + + return monitor; +} + +/* Unfortunately the integer values of #AsRelationKind don’t have the same order + * as we want. */ +static AsRelationKind +max_relation_kind (AsRelationKind kind1, + AsRelationKind kind2) +{ + /* cases are ordered from maximum to minimum */ + if (kind1 == AS_RELATION_KIND_REQUIRES || kind2 == AS_RELATION_KIND_REQUIRES) + return AS_RELATION_KIND_REQUIRES; + if (kind1 == AS_RELATION_KIND_RECOMMENDS || kind2 == AS_RELATION_KIND_RECOMMENDS) + return AS_RELATION_KIND_RECOMMENDS; +#if AS_CHECK_VERSION(0, 15, 0) + if (kind1 == AS_RELATION_KIND_SUPPORTS || kind2 == AS_RELATION_KIND_SUPPORTS) + return AS_RELATION_KIND_SUPPORTS; +#endif + return AS_RELATION_KIND_UNKNOWN; +} + +typedef struct { + guint min; + guint max; +} Range; + +/* + * evaluate_display_comparison: + * @comparand1: + * @comparator: + * @comparand2: + * + * Evaluate `comparand1 comparator comparand2` and return the result. For + * example, `comparand1 EQ comparand2` or `comparand1 GT comparand2`. + * + * Comparisons are done as ranges, so depending on @comparator, sometimes the + * #Range.min value of a comparand is compared, sometimes #Range.max, and + * sometimes both. See the code for details. + * + * Returns: %TRUE if the comparison is true, %FALSE otherwise + * Since: 41 + */ +static gboolean +evaluate_display_comparison (Range comparand1, + AsRelationCompare comparator, + Range comparand2) +{ + switch (comparator) { + case AS_RELATION_COMPARE_EQ: + return (comparand1.min == comparand2.min && + comparand1.max == comparand2.max); + case AS_RELATION_COMPARE_NE: + return (comparand1.min != comparand2.min || + comparand1.max != comparand2.max); + case AS_RELATION_COMPARE_LT: + return (comparand1.max < comparand2.min); + case AS_RELATION_COMPARE_GT: + return (comparand1.min > comparand2.max); + case AS_RELATION_COMPARE_LE: + return (comparand1.max <= comparand2.max); + case AS_RELATION_COMPARE_GE: + return (comparand1.min >= comparand2.min); + case AS_RELATION_COMPARE_UNKNOWN: + case AS_RELATION_COMPARE_LAST: + default: + g_assert_not_reached (); + } +} + +/** + * gs_hardware_support_context_dialog_get_control_support: + * @display: a #GdkDisplay + * @relations: (element-type AsRelation): relations retrieved from a #GsApp + * using gs_app_get_relations() + * @any_control_relations_set_out: (out caller-allocates) (optional): return + * location for a boolean indicating whether any control relations are set + * in @relations + * @control_relations: (out caller-allocates) (array length=AS_CONTROL_KIND_LAST): + * array mapping #AsControlKind to #AsRelationKind; must be at least + * %AS_CONTROL_KIND_LAST elements long, doesn’t need to be initialised + * @has_touchscreen_out: (out caller-allocates) (optional): return location for + * a boolean indicating whether @display has a touchscreen + * @has_keyboard_out: (out caller-allocates) (optional): return location for + * a boolean indicating whether @display has a keyboard + * @has_mouse_out: (out caller-allocates) (optional): return location for + * a boolean indicating whether @display has a mouse + * + * Query @display and @relations and summarise the information in the output + * arguments. + * + * Each element of @control_relations will be set to the highest type of + * relation seen for that type of control. So if the appdata represented by + * @relations contains `<requires><control>keyboard</control></requires>`, + * `control_relations[AS_CONTROL_KIND_KEYBOARD]` will be set to + * %AS_RELATION_KIND_REQUIRES. All elements of @control_relations are set to + * %AS_RELATION_KIND_UNKNOWN by default. + * + * @any_control_relations_set_out is set to %TRUE if any elements of + * @control_relations are changed from %AS_RELATION_KIND_UNKNOWN. + * + * @has_touchscreen_out, @has_keyboard_out and @has_mouse_out are set to %TRUE + * if the default seat attached to @display has the relevant input device + * (%GDK_SEAT_CAPABILITY_TOUCH, %GDK_SEAT_CAPABILITY_KEYBOARD, + * %GDK_SEAT_CAPABILITY_POINTER respectively). + * + * Since: 41 + */ +void +gs_hardware_support_context_dialog_get_control_support (GdkDisplay *display, + GPtrArray *relations, + gboolean *any_control_relations_set_out, + AsRelationKind *control_relations, + gboolean *has_touchscreen_out, + gboolean *has_keyboard_out, + gboolean *has_mouse_out) +{ + gboolean any_control_relations_set; + gboolean has_touchscreen, has_keyboard, has_mouse; + + g_return_if_fail (display == NULL || GDK_IS_DISPLAY (display)); + g_return_if_fail (control_relations != NULL); + + any_control_relations_set = FALSE; + has_touchscreen = FALSE; + has_keyboard = FALSE; + has_mouse = FALSE; + + /* Initialise @control_relations */ + for (gint i = 0; i < AS_CONTROL_KIND_LAST; i++) + control_relations[i] = AS_RELATION_KIND_UNKNOWN; + + /* Set @control_relations to the maximum relation kind found for each control */ + for (guint i = 0; relations != NULL && i < relations->len; i++) { + AsRelation *relation = AS_RELATION (g_ptr_array_index (relations, i)); + AsRelationKind kind = as_relation_get_kind (relation); + + if (as_relation_get_item_kind (relation) == AS_RELATION_ITEM_KIND_CONTROL) { + AsControlKind control_kind = as_relation_get_value_control_kind (relation); + control_relations[control_kind] = MAX (control_relations[control_kind], kind); + + if (kind == AS_RELATION_KIND_REQUIRES || +#if AS_CHECK_VERSION(0, 15, 0) + kind == AS_RELATION_KIND_SUPPORTS || +#endif + kind == AS_RELATION_KIND_RECOMMENDS) + any_control_relations_set = TRUE; + } + } + + /* Work out what input devices are available. */ + if (display != NULL) { + GdkSeat *seat = gdk_display_get_default_seat (display); + GdkSeatCapabilities seat_capabilities = gdk_seat_get_capabilities (seat); + + has_touchscreen = (seat_capabilities & GDK_SEAT_CAPABILITY_TOUCH); + has_keyboard = (seat_capabilities & GDK_SEAT_CAPABILITY_KEYBOARD); + has_mouse = (seat_capabilities & GDK_SEAT_CAPABILITY_POINTER); + } + + if (any_control_relations_set_out != NULL) + *any_control_relations_set_out = any_control_relations_set; + if (has_touchscreen_out != NULL) + *has_touchscreen_out = has_touchscreen; + if (has_keyboard_out != NULL) + *has_keyboard_out = has_keyboard; + if (has_mouse_out != NULL) + *has_mouse_out = has_mouse; +} + +/** + * gs_hardware_support_context_dialog_get_display_support: + * @monitor: the largest #GdkMonitor currently connected + * @relations: (element-type AsRelation): (element-type AsRelation): relations retrieved from a #GsApp + * using gs_app_get_relations() + * @any_display_relations_set_out: (out caller-allocates) (optional): return + * location for a boolean indicating whether any display relations are set + * in @relations + * @desktop_match_out: (out caller-allocates) (not optional): return location + * for a boolean indicating whether @relations claims support for desktop + * displays + * @desktop_relation_kind_out: (out caller-allocates) (not optional): return + * location for an #AsRelationKind indicating what kind of support the app + * has for desktop displays + * @mobile_match_out: (out caller-allocates) (not optional): return location + * for a boolean indicating whether @relations claims support for mobile + * displays (phones) + * @mobile_relation_kind_out: (out caller-allocates) (not optional): return + * location for an #AsRelationKind indicating what kind of support the app + * has for mobile displays + * @current_match_out: (out caller-allocates) (not optional): return location + * for a boolean indicating whether @relations claims support for the + * currently connected @monitor + * @current_relation_kind_out: (out caller-allocates) (not optional): return + * location for an #AsRelationKind indicating what kind of support the app + * has for the currently connected monitor + * + * Query @monitor and @relations and summarise the information in the output + * arguments. + * + * @any_display_relations_set_out is set to %TRUE if any elements of @relations + * have type %AS_RELATION_ITEM_KIND_DISPLAY_LENGTH, i.e. if the app has provided + * any information about what displays it supports/requires. + * + * @desktop_match_out is set to %TRUE if the display relations in @relations + * indicate that the app supports desktop displays (currently, larger than + * 1024 pixels). + * + * @desktop_relation_kind_out is set to the type of support the app has for + * desktop displays: whether they’re required (%AS_RELATION_KIND_REQUIRES), + * supported but not required (%AS_RELATION_KIND_RECOMMENDS or + * %AS_RELATION_KIND_SUPPORTS) or whether there’s no information + * (%AS_RELATION_KIND_UNKNOWN). + * + * @mobile_match_out and @mobile_relation_kind_out behave similarly, but for + * mobile displays (smaller than 768 pixels). + * + * @current_match_out and @current_relation_kind_out behave similarly, but for + * the dimensions of @monitor. + * + * Since: 41 + */ +void +gs_hardware_support_context_dialog_get_display_support (GdkMonitor *monitor, + GPtrArray *relations, + gboolean *any_display_relations_set_out, + gboolean *desktop_match_out, + AsRelationKind *desktop_relation_kind_out, + gboolean *mobile_match_out, + AsRelationKind *mobile_relation_kind_out, + gboolean *current_match_out, + AsRelationKind *current_relation_kind_out) +{ + GdkRectangle current_screen_size; + gboolean any_display_relations_set; + + g_return_if_fail (GDK_IS_MONITOR (monitor)); + g_return_if_fail (desktop_match_out != NULL); + g_return_if_fail (desktop_relation_kind_out != NULL); + g_return_if_fail (mobile_match_out != NULL); + g_return_if_fail (mobile_relation_kind_out != NULL); + g_return_if_fail (current_match_out != NULL); + g_return_if_fail (current_relation_kind_out != NULL); + + gdk_monitor_get_geometry (monitor, ¤t_screen_size); + + /* Set default output */ + any_display_relations_set = FALSE; + *desktop_match_out = FALSE; + *desktop_relation_kind_out = AS_RELATION_KIND_UNKNOWN; + *mobile_match_out = FALSE; + *mobile_relation_kind_out = AS_RELATION_KIND_UNKNOWN; + *current_match_out = FALSE; + *current_relation_kind_out = AS_RELATION_KIND_UNKNOWN; + + for (guint i = 0; relations != NULL && i < relations->len; i++) { + AsRelation *relation = AS_RELATION (g_ptr_array_index (relations, i)); + + /* All lengths here are in logical/application pixels, + * not device pixels. */ + if (as_relation_get_item_kind (relation) == AS_RELATION_ITEM_KIND_DISPLAY_LENGTH) { + AsRelationCompare comparator = as_relation_get_compare (relation); + Range current_display_comparand, relation_comparand; + + /* From https://www.freedesktop.org/software/appstream/docs/chap-Metadata.html#tag-requires-recommends-display_length */ + Range display_lengths[] = { + [AS_DISPLAY_LENGTH_KIND_XSMALL] = { 0, 360 }, + [AS_DISPLAY_LENGTH_KIND_SMALL] = { 360, 768 }, + [AS_DISPLAY_LENGTH_KIND_MEDIUM] = { 768, 1024 }, + [AS_DISPLAY_LENGTH_KIND_LARGE] = { 1024, 3840 }, + [AS_DISPLAY_LENGTH_KIND_XLARGE] = { 3840, G_MAXUINT }, + }; + + any_display_relations_set = TRUE; + + switch (as_relation_get_display_side_kind (relation)) { + case AS_DISPLAY_SIDE_KIND_SHORTEST: + current_display_comparand.min = current_display_comparand.max = MIN (current_screen_size.width, current_screen_size.height); + relation_comparand.min = relation_comparand.max = as_relation_get_value_px (relation); + break; + case AS_DISPLAY_SIDE_KIND_LONGEST: + current_display_comparand.min = current_display_comparand.max = MAX (current_screen_size.width, current_screen_size.height); + relation_comparand.min = relation_comparand.max = as_relation_get_value_px (relation); + break; + case AS_DISPLAY_SIDE_KIND_UNKNOWN: + case AS_DISPLAY_SIDE_KIND_LAST: + default: + current_display_comparand.min = current_display_comparand.max = MAX (current_screen_size.width, current_screen_size.height); + relation_comparand.min = display_lengths[as_relation_get_value_display_length_kind (relation)].min; + relation_comparand.max = display_lengths[as_relation_get_value_display_length_kind (relation)].max; + break; + } + + if (evaluate_display_comparison (display_lengths[AS_DISPLAY_LENGTH_KIND_SMALL], comparator, relation_comparand)) { + *mobile_relation_kind_out = max_relation_kind (*mobile_relation_kind_out, as_relation_get_kind (relation)); + *mobile_match_out = TRUE; + } + + if (evaluate_display_comparison (display_lengths[AS_DISPLAY_LENGTH_KIND_LARGE], comparator, relation_comparand)) { + *desktop_relation_kind_out = max_relation_kind (*desktop_relation_kind_out, as_relation_get_kind (relation)); + *desktop_match_out = TRUE; + } + + if (evaluate_display_comparison (current_display_comparand, comparator, relation_comparand)) { + *current_relation_kind_out = max_relation_kind (*current_relation_kind_out, as_relation_get_kind (relation)); + *current_match_out = TRUE; + } + } + } + + /* Output */ + if (any_display_relations_set_out != NULL) + *any_display_relations_set_out = any_display_relations_set; +} + +static void +update_relations_list (GsHardwareSupportContextDialog *self) +{ + const gchar *icon_name, *css_class; + g_autofree gchar *title = NULL; + g_autoptr(GPtrArray) relations = NULL; + AsRelationKind control_relations[AS_CONTROL_KIND_LAST] = { AS_RELATION_KIND_UNKNOWN, }; + GdkDisplay *display; + GdkMonitor *monitor = NULL; + GdkRectangle current_screen_size; + gboolean any_control_relations_set; + gboolean has_touchscreen = FALSE, has_keyboard = FALSE, has_mouse = FALSE; + GtkStyleContext *context; + GsContextDialogRowImportance chosen_rating; + + /* Treat everything as unknown to begin with, and downgrade its hardware + * support based on app properties. */ + chosen_rating = GS_CONTEXT_DIALOG_ROW_IMPORTANCE_NEUTRAL; + + gs_widget_remove_all (GTK_WIDGET (self->relations_list), (GsRemoveFunc) gtk_list_box_remove); + + /* UI state is undefined if app is not set. */ + if (self->app == NULL) + return; + + relations = gs_app_get_relations (self->app); + + /* Extract the %AS_RELATION_ITEM_KIND_CONTROL relations and summarise + * them. */ + display = gtk_widget_get_display (GTK_WIDGET (self)); + gs_hardware_support_context_dialog_get_control_support (display, relations, + &any_control_relations_set, + control_relations, + &has_touchscreen, + &has_keyboard, + &has_mouse); + + if (display != NULL) + monitor = gs_hardware_support_context_dialog_get_largest_monitor (display); + + if (monitor != NULL) + gdk_monitor_get_geometry (monitor, ¤t_screen_size); + + /* For each of the screen sizes we understand, add a row to the dialogue. + * In the unlikely case that (monitor == NULL), don’t bother providing + * fallback rows. */ + if (monitor != NULL) { + AsRelationKind desktop_relation_kind, mobile_relation_kind, current_relation_kind; + gboolean desktop_match, mobile_match, current_match; + gboolean any_display_relations_set; + + gs_hardware_support_context_dialog_get_display_support (monitor, relations, + &any_display_relations_set, + &desktop_match, &desktop_relation_kind, + &mobile_match, &mobile_relation_kind, + ¤t_match, ¤t_relation_kind); + + add_relation_row (self->relations_list, &chosen_rating, + desktop_relation_kind, + desktop_match ? MATCH_STATE_MATCH : MATCH_STATE_NO_MATCH, + any_display_relations_set, + "desktop-symbolic", + _("Desktop Support"), + _("Supports being used on a large screen"), + "dialog-question-symbolic", + _("Desktop Support Unknown"), + _("Not enough information to know if large screens are supported"), + "desktop-symbolic", + _("Desktop Only"), + _("Requires a large screen"), + "desktop-symbolic", + _("Desktop Support"), + _("Supports being used on a large screen"), + "desktop-symbolic", + _("Desktop Not Supported"), + _("Cannot be used on a large screen")); + + add_relation_row (self->relations_list, &chosen_rating, + mobile_relation_kind, + mobile_match ? MATCH_STATE_MATCH : MATCH_STATE_NO_MATCH, + any_display_relations_set, + "phone-symbolic", + _("Mobile Support"), + _("Supports being used on a small screen"), + "dialog-question-symbolic", + _("Mobile Support Unknown"), + _("Not enough information to know if small screens are supported"), + "phone-symbolic", + _("Mobile Only"), + _("Requires a small screen"), + "phone-symbolic", + _("Mobile Support"), + _("Supports being used on a small screen"), + "phone-symbolic", + _("Mobile Not Supported"), + _("Cannot be used on a small screen")); + + /* Other display relations should only be listed if they are a + * requirement. They will typically be for special apps. */ + add_relation_row (self->relations_list, &chosen_rating, + current_relation_kind, + current_match ? MATCH_STATE_MATCH : MATCH_STATE_NO_MATCH, + any_display_relations_set, + NULL, NULL, NULL, + NULL, NULL, NULL, + "video-joined-displays-symbolic", + _("Screen Size Mismatch"), + _("Doesn’t support your current screen size"), + NULL, NULL, NULL, + NULL, NULL, NULL); + } + + /* For each of the control devices we understand, add a row to the dialogue. */ + add_relation_row (self->relations_list, &chosen_rating, + control_relations[AS_CONTROL_KIND_KEYBOARD], + has_keyboard ? MATCH_STATE_MATCH : MATCH_STATE_NO_MATCH, + any_control_relations_set, + "input-keyboard-symbolic", + _("Keyboard Support"), + _("Requires a keyboard"), + "dialog-question-symbolic", + _("Keyboard Support Unknown"), + _("Not enough information to know if keyboards are supported"), + "input-keyboard-symbolic", + _("Keyboard Required"), + _("Requires a keyboard"), + "input-keyboard-symbolic", + _("Keyboard Support"), + _("Supports keyboards"), + "input-keyboard-symbolic", + _("Keyboard Not Supported"), + _("Cannot be used with a keyboard")); + + add_relation_row (self->relations_list, &chosen_rating, + control_relations[AS_CONTROL_KIND_POINTING], + has_mouse ? MATCH_STATE_MATCH : MATCH_STATE_NO_MATCH, + any_control_relations_set, + "input-mouse-symbolic", + _("Mouse Support"), + _("Requires a mouse or pointing device"), + "dialog-question-symbolic", + _("Mouse Support Unknown"), + _("Not enough information to know if mice or pointing devices are supported"), + "input-mouse-symbolic", + _("Mouse Required"), + _("Requires a mouse or pointing device"), + "input-mouse-symbolic", + _("Mouse Support"), + _("Supports mice and pointing devices"), + "input-mouse-symbolic", + _("Mouse Not Supported"), + _("Cannot be used with a mouse or pointing device")); + + add_relation_row (self->relations_list, &chosen_rating, + control_relations[AS_CONTROL_KIND_TOUCH], + has_touchscreen ? MATCH_STATE_MATCH : MATCH_STATE_NO_MATCH, + any_control_relations_set, + "phone-symbolic", + _("Touchscreen Support"), + _("Requires a touchscreen"), + "dialog-question-symbolic", + _("Touchscreen Support Unknown"), + _("Not enough information to know if touchscreens are supported"), + "phone-symbolic", + _("Touchscreen Required"), + _("Requires a touchscreen"), + "phone-symbolic", + _("Touchscreen Support"), + _("Supports touchscreens"), + "phone-symbolic", + _("Touchscreen Not Supported"), + _("Cannot be used with a touchscreen")); + + /* Gamepads are a little different; only show the row if the appdata + * explicitly mentions gamepads, and don’t vary the row based on whether + * a gamepad is plugged in, since users often leave their gamepads + * unplugged until they’re actually needed. */ + add_relation_row (self->relations_list, &chosen_rating, + control_relations[AS_CONTROL_KIND_GAMEPAD], + MATCH_STATE_UNKNOWN, + any_control_relations_set, + NULL, NULL, NULL, + NULL, NULL, NULL, + "input-gaming-symbolic", + _("Gamepad Required"), + _("Requires a gamepad"), + "input-gaming-symbolic", + _("Gamepad Support"), + _("Supports gamepads"), + NULL, NULL, NULL); + + /* Update the UI. */ + switch (chosen_rating) { + case GS_CONTEXT_DIALOG_ROW_IMPORTANCE_NEUTRAL: + icon_name = "desktop-symbolic"; + /* Translators: It’s unknown whether this app is supported on + * the current hardware. The placeholder is the app name. */ + title = g_strdup_printf (_("%s probably works on this device"), gs_app_get_name (self->app)); + css_class = "grey"; + break; + case GS_CONTEXT_DIALOG_ROW_IMPORTANCE_UNIMPORTANT: + icon_name = "app-installed-symbolic"; + /* Translators: The app will work on the current hardware. + * The placeholder is the app name. */ + title = g_strdup_printf (_("%s works on this device"), gs_app_get_name (self->app)); + css_class = "green"; + break; + case GS_CONTEXT_DIALOG_ROW_IMPORTANCE_WARNING: + icon_name = "dialog-question-symbolic"; + /* Translators: The app may not work fully on the current hardware. + * The placeholder is the app name. */ + title = g_strdup_printf (_("%s will not work properly on this device"), gs_app_get_name (self->app)); + css_class = "yellow"; + break; + case GS_CONTEXT_DIALOG_ROW_IMPORTANCE_IMPORTANT: + icon_name = "dialog-warning-symbolic"; + /* Translators: The app will not work properly on the current hardware. + * The placeholder is the app name. */ + title = g_strdup_printf (_("%s will not work on this device"), gs_app_get_name (self->app)); + css_class = "red"; + break; + default: + g_assert_not_reached (); + } + + gs_lozenge_set_icon_name (GS_LOZENGE (self->lozenge), icon_name); + gtk_label_set_text (self->title, title); + + context = gtk_widget_get_style_context (self->lozenge); + + gtk_style_context_remove_class (context, "green"); + gtk_style_context_remove_class (context, "yellow"); + gtk_style_context_remove_class (context, "red"); + gtk_style_context_remove_class (context, "grey"); + + gtk_style_context_add_class (context, css_class); +} + +static void +app_notify_cb (GObject *obj, + GParamSpec *pspec, + gpointer user_data) +{ + GsHardwareSupportContextDialog *self = GS_HARDWARE_SUPPORT_CONTEXT_DIALOG (user_data); + + update_relations_list (self); +} + +static void +gs_hardware_support_context_dialog_init (GsHardwareSupportContextDialog *self) +{ + gtk_widget_init_template (GTK_WIDGET (self)); +} + +static void +gs_hardware_support_context_dialog_get_property (GObject *object, + guint prop_id, + GValue *value, + GParamSpec *pspec) +{ + GsHardwareSupportContextDialog *self = GS_HARDWARE_SUPPORT_CONTEXT_DIALOG (object); + + switch ((GsHardwareSupportContextDialogProperty) prop_id) { + case PROP_APP: + g_value_set_object (value, gs_hardware_support_context_dialog_get_app (self)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_hardware_support_context_dialog_set_property (GObject *object, + guint prop_id, + const GValue *value, + GParamSpec *pspec) +{ + GsHardwareSupportContextDialog *self = GS_HARDWARE_SUPPORT_CONTEXT_DIALOG (object); + + switch ((GsHardwareSupportContextDialogProperty) prop_id) { + case PROP_APP: + gs_hardware_support_context_dialog_set_app (self, g_value_get_object (value)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_hardware_support_context_dialog_dispose (GObject *object) +{ + GsHardwareSupportContextDialog *self = GS_HARDWARE_SUPPORT_CONTEXT_DIALOG (object); + + gs_hardware_support_context_dialog_set_app (self, NULL); + + G_OBJECT_CLASS (gs_hardware_support_context_dialog_parent_class)->dispose (object); +} + +static void +gs_hardware_support_context_dialog_class_init (GsHardwareSupportContextDialogClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + + object_class->get_property = gs_hardware_support_context_dialog_get_property; + object_class->set_property = gs_hardware_support_context_dialog_set_property; + object_class->dispose = gs_hardware_support_context_dialog_dispose; + + /** + * GsHardwareSupportContextDialog:app: (nullable) + * + * The app to display the hardware support context details for. + * + * This may be %NULL; if so, the content of the widget will be + * undefined. + * + * Since: 41 + */ + obj_props[PROP_APP] = + g_param_spec_object ("app", NULL, NULL, + GS_TYPE_APP, + G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS); + + g_object_class_install_properties (object_class, G_N_ELEMENTS (obj_props), obj_props); + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-hardware-support-context-dialog.ui"); + + gtk_widget_class_bind_template_child (widget_class, GsHardwareSupportContextDialog, lozenge); + gtk_widget_class_bind_template_child (widget_class, GsHardwareSupportContextDialog, title); + gtk_widget_class_bind_template_child (widget_class, GsHardwareSupportContextDialog, relations_list); +} + +/** + * gs_hardware_support_context_dialog_new: + * @app: (nullable): the app to display hardware support context information for, or %NULL + * + * Create a new #GsHardwareSupportContextDialog and set its initial app to @app. + * + * Returns: (transfer full): a new #GsHardwareSupportContextDialog + * Since: 41 + */ +GsHardwareSupportContextDialog * +gs_hardware_support_context_dialog_new (GsApp *app) +{ + g_return_val_if_fail (app == NULL || GS_IS_APP (app), NULL); + + return g_object_new (GS_TYPE_HARDWARE_SUPPORT_CONTEXT_DIALOG, + "app", app, + NULL); +} + +/** + * gs_hardware_support_context_dialog_get_app: + * @self: a #GsHardwareSupportContextDialog + * + * Gets the value of #GsHardwareSupportContextDialog:app. + * + * Returns: (nullable) (transfer none): app whose hardware support context information is + * being displayed, or %NULL if none is set + * Since: 41 + */ +GsApp * +gs_hardware_support_context_dialog_get_app (GsHardwareSupportContextDialog *self) +{ + g_return_val_if_fail (GS_IS_HARDWARE_SUPPORT_CONTEXT_DIALOG (self), NULL); + + return self->app; +} + +/** + * gs_hardware_support_context_dialog_set_app: + * @self: a #GsHardwareSupportContextDialog + * @app: (nullable) (transfer none): the app to display hardware support context + * information for, or %NULL for none + * + * Set the value of #GsHardwareSupportContextDialog:app. + * + * Since: 41 + */ +void +gs_hardware_support_context_dialog_set_app (GsHardwareSupportContextDialog *self, + GsApp *app) +{ + g_return_if_fail (GS_IS_HARDWARE_SUPPORT_CONTEXT_DIALOG (self)); + g_return_if_fail (app == NULL || GS_IS_APP (app)); + + if (app == self->app) + return; + + g_clear_signal_handler (&self->app_notify_handler_relations, self->app); + g_clear_signal_handler (&self->app_notify_handler_name, self->app); + + g_set_object (&self->app, app); + + if (self->app != NULL) { + self->app_notify_handler_relations = g_signal_connect (self->app, "notify::relations", G_CALLBACK (app_notify_cb), self); + self->app_notify_handler_name = g_signal_connect (self->app, "notify::name", G_CALLBACK (app_notify_cb), self); + } + + /* Update the UI. */ + update_relations_list (self); + + g_object_notify_by_pspec (G_OBJECT (self), obj_props[PROP_APP]); +} diff --git a/src/gs-hardware-support-context-dialog.h b/src/gs-hardware-support-context-dialog.h new file mode 100644 index 0000000..8983c74 --- /dev/null +++ b/src/gs-hardware-support-context-dialog.h @@ -0,0 +1,51 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2021 Endless OS Foundation LLC + * + * Author: Philip Withnall <pwithnall@endlessos.org> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <glib.h> +#include <glib-object.h> +#include <gtk/gtk.h> + +#include "gs-app.h" +#include "gs-info-window.h" + +G_BEGIN_DECLS + +#define GS_TYPE_HARDWARE_SUPPORT_CONTEXT_DIALOG (gs_hardware_support_context_dialog_get_type ()) + +G_DECLARE_FINAL_TYPE (GsHardwareSupportContextDialog, gs_hardware_support_context_dialog, GS, HARDWARE_SUPPORT_CONTEXT_DIALOG, GsInfoWindow) + +GsHardwareSupportContextDialog *gs_hardware_support_context_dialog_new (GsApp *app); + +GsApp *gs_hardware_support_context_dialog_get_app (GsHardwareSupportContextDialog *self); +void gs_hardware_support_context_dialog_set_app (GsHardwareSupportContextDialog *self, + GsApp *app); + +void gs_hardware_support_context_dialog_get_control_support (GdkDisplay *display, + GPtrArray *relations, + gboolean *any_control_relations_set_out, + AsRelationKind *control_relations, + gboolean *has_touchscreen_out, + gboolean *has_keyboard_out, + gboolean *has_mouse_out); + +GdkMonitor *gs_hardware_support_context_dialog_get_largest_monitor (GdkDisplay *display); +void gs_hardware_support_context_dialog_get_display_support (GdkMonitor *monitor, + GPtrArray *relations, + gboolean *any_display_relations_set_out, + gboolean *desktop_match_out, + AsRelationKind *desktop_relation_kind_out, + gboolean *mobile_match_out, + AsRelationKind *mobile_relation_kind_out, + gboolean *other_match_out, + AsRelationKind *other_relation_kind_out); + +G_END_DECLS diff --git a/src/gs-hardware-support-context-dialog.ui b/src/gs-hardware-support-context-dialog.ui new file mode 100644 index 0000000..a5c0ed8 --- /dev/null +++ b/src/gs-hardware-support-context-dialog.ui @@ -0,0 +1,83 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <requires lib="gtk+" version="3.10"/> + <template class="GsHardwareSupportContextDialog" parent="GsInfoWindow"> + <property name="title" translatable="yes" comments="Translators: This is the title of the dialog which contains information about the hardware support/requirements of an app">Hardware Support</property> + <child> + <object class="AdwPreferencesPage"> + <child> + <object class="AdwPreferencesGroup"> + + <child> + <object class="GtkBox"> + <property name="orientation">vertical</property> + <property name="spacing">8</property> + + <child> + <object class="GtkBox"> + <property name="margin-top">20</property> + <property name="margin-bottom">16</property> + <property name="margin-start">20</property> + <property name="margin-end">20</property> + <property name="orientation">vertical</property> + <property name="spacing">12</property> + + <child> + <object class="GsLozenge" id="lozenge"> + <property name="circular">False</property> + <!-- this is a placeholder: the icon is actually set in code --> + <property name="icon-name">safety-symbolic</property> + <property name="pixel-size">24</property> + <style> + <class name="large"/> + <class name="grey"/> + </style> + <accessibility> + <relation name="labelled-by">title</relation> + </accessibility> + </object> + </child> + + <child> + <object class="GtkLabel" id="title"> + <!-- this is a placeholder: the text is actually set in code --> + <property name="justify">center</property> + <property name="label">Shortwave works on this device</property> + <property name="wrap">True</property> + <property name="xalign">0.5</property> + <style> + <class name="title-2"/> + </style> + </object> + </child> + </object> + </child> + + <child> + <object class="GtkListBox" id="relations_list"> + <property name="selection_mode">none</property> + <property name="halign">fill</property> + <property name="valign">start</property> + <style> + <class name="boxed-list"/> + </style> + <!-- Rows are added in code --> + <placeholder/> + </object> + </child> + + <child> + <object class="GtkLinkButton"> + <property name="label" translatable="yes">How to contribute missing information</property> + <property name="margin-top">16</property> + <property name="uri">https://gitlab.gnome.org/GNOME/gnome-software/-/wikis/software-metadata#hardware-support</property> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + </template> +</interface> diff --git a/src/gs-info-bar.c b/src/gs-info-bar.c new file mode 100644 index 0000000..e25e5d5 --- /dev/null +++ b/src/gs-info-bar.c @@ -0,0 +1,228 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2016 Rafał Lużyński <digitalfreak@lingonborough.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include "gs-common.h" +#include "gs-info-bar.h" + +struct _GsInfoBar +{ + GtkWidget parent_instance; + + GtkInfoBar *info_bar; + GtkWidget *label_title; + GtkWidget *label_body; + GtkWidget *label_warning; +}; + +G_DEFINE_TYPE (GsInfoBar, gs_info_bar, GTK_TYPE_WIDGET) + +enum { + PROP_0, + PROP_TITLE, + PROP_BODY, + PROP_WARNING, + PROP_MESSAGE_TYPE, +}; + +static void +gs_info_bar_get_property (GObject *object, + guint prop_id, + GValue *value, + GParamSpec *pspec) +{ + GsInfoBar *infobar = GS_INFO_BAR (object); + + switch (prop_id) { + case PROP_TITLE: + g_value_set_string (value, gs_info_bar_get_title (infobar)); + break; + case PROP_BODY: + g_value_set_string (value, gs_info_bar_get_body (infobar)); + break; + case PROP_WARNING: + g_value_set_string (value, gs_info_bar_get_warning (infobar)); + break; + case PROP_MESSAGE_TYPE: + g_value_set_enum (value, gtk_info_bar_get_message_type (infobar->info_bar)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_info_bar_set_property (GObject *object, + guint prop_id, + const GValue *value, + GParamSpec *pspec) +{ + GsInfoBar *infobar = GS_INFO_BAR (object); + + switch (prop_id) { + case PROP_TITLE: + gs_info_bar_set_title (infobar, g_value_get_string (value)); + break; + case PROP_BODY: + gs_info_bar_set_body (infobar, g_value_get_string (value)); + break; + case PROP_WARNING: + gs_info_bar_set_warning (infobar, g_value_get_string (value)); + break; + case PROP_MESSAGE_TYPE: + gtk_info_bar_set_message_type (infobar->info_bar, g_value_get_enum (value)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_info_bar_dispose (GObject *object) +{ + gs_widget_remove_all (GTK_WIDGET (object), NULL); + + G_OBJECT_CLASS (gs_info_bar_parent_class)->dispose (object); +} + +static void +gs_info_bar_init (GsInfoBar *infobar) +{ + gtk_widget_init_template (GTK_WIDGET (infobar)); +} + +static void +gs_info_bar_class_init (GsInfoBarClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + + object_class->get_property = gs_info_bar_get_property; + object_class->set_property = gs_info_bar_set_property; + object_class->dispose = gs_info_bar_dispose; + + g_object_class_install_property (object_class, PROP_TITLE, + g_param_spec_string ("title", + "Title Text", + "The title (header) text of the info bar", + "", + G_PARAM_READWRITE)); + + g_object_class_install_property (object_class, PROP_BODY, + g_param_spec_string ("body", + "Body Text", + "The body (main) text of the info bar", + NULL, + G_PARAM_READWRITE)); + + g_object_class_install_property (object_class, PROP_WARNING, + g_param_spec_string ("warning", + "Warning Text", + "The warning text of the info bar, below the body text", + NULL, + G_PARAM_READWRITE)); + + g_object_class_install_property (object_class, PROP_MESSAGE_TYPE, + g_param_spec_enum ("message-type", + "Message Type", + "The type of message", + GTK_TYPE_MESSAGE_TYPE, + GTK_MESSAGE_INFO, + G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS)); + + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-info-bar.ui"); + gtk_widget_class_set_layout_manager_type (widget_class, GTK_TYPE_BIN_LAYOUT); + + gtk_widget_class_bind_template_child (widget_class, + GsInfoBar, info_bar); + gtk_widget_class_bind_template_child (widget_class, + GsInfoBar, label_title); + gtk_widget_class_bind_template_child (widget_class, + GsInfoBar, label_body); + gtk_widget_class_bind_template_child (widget_class, + GsInfoBar, label_warning); +} + +GtkWidget * +gs_info_bar_new (void) +{ + GsInfoBar *infobar; + + infobar = g_object_new (GS_TYPE_INFO_BAR, NULL); + + return GTK_WIDGET (infobar); +} + +static const gchar * +retrieve_from_label (GtkWidget *label) +{ + if (!gtk_widget_get_visible (label)) + return NULL; + else + return gtk_label_get_label (GTK_LABEL (label)); +} + +static void +update_label (GtkWidget *label, const gchar *text) +{ + gtk_label_set_label (GTK_LABEL (label), text); + gtk_widget_set_visible (label, + text != NULL && *text != '\0'); +} + +const gchar * +gs_info_bar_get_title (GsInfoBar *bar) +{ + g_return_val_if_fail (GS_IS_INFO_BAR (bar), NULL); + + return retrieve_from_label (bar->label_title); +} + +void +gs_info_bar_set_title (GsInfoBar *bar, const gchar *text) +{ + g_return_if_fail (GS_IS_INFO_BAR (bar)); + + update_label (bar->label_title, text); +} + +const gchar * +gs_info_bar_get_body (GsInfoBar *bar) +{ + g_return_val_if_fail (GS_IS_INFO_BAR (bar), NULL); + + return retrieve_from_label (bar->label_body); +} + +void +gs_info_bar_set_body (GsInfoBar *bar, const gchar *text) +{ + g_return_if_fail (GS_IS_INFO_BAR (bar)); + + update_label (bar->label_body, text); +} + +const gchar * +gs_info_bar_get_warning (GsInfoBar *bar) +{ + g_return_val_if_fail (GS_IS_INFO_BAR (bar), NULL); + + return retrieve_from_label (bar->label_warning); +} + +void +gs_info_bar_set_warning (GsInfoBar *bar, const gchar *text) +{ + g_return_if_fail (GS_IS_INFO_BAR (bar)); + + update_label (bar->label_warning, text); +} diff --git a/src/gs-info-bar.h b/src/gs-info-bar.h new file mode 100644 index 0000000..832e692 --- /dev/null +++ b/src/gs-info-bar.h @@ -0,0 +1,29 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2016 Rafał Lużyński <digitalfreak@lingonborough.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <gtk/gtk.h> + +G_BEGIN_DECLS + +#define GS_TYPE_INFO_BAR (gs_info_bar_get_type ()) + +G_DECLARE_FINAL_TYPE (GsInfoBar, gs_info_bar, GS, INFO_BAR, GtkWidget) + +GtkWidget *gs_info_bar_new (void); +const gchar *gs_info_bar_get_title (GsInfoBar *bar); +void gs_info_bar_set_title (GsInfoBar *bar, + const gchar *text); +const gchar *gs_info_bar_get_body (GsInfoBar *bar); +void gs_info_bar_set_body (GsInfoBar *bar, + const gchar *text); +const gchar *gs_info_bar_get_warning (GsInfoBar *bar); +void gs_info_bar_set_warning (GsInfoBar *bar, + const gchar *text); +G_END_DECLS diff --git a/src/gs-info-bar.ui b/src/gs-info-bar.ui new file mode 100644 index 0000000..70e6332 --- /dev/null +++ b/src/gs-info-bar.ui @@ -0,0 +1,49 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <!-- interface-requires gtk+ 3.10 --> + <template class="GsInfoBar" parent="GtkWidget"> + <child> + <object class="GtkInfoBar" id="info_bar"> + <property name="message_type">info</property> + <style> + <class name="application-details-infobar"/> + </style> + <child> + <object class="GtkBox" id="content_area"> + <property name="spacing">0</property> + <property name="halign">fill</property> + <property name="hexpand">True</property> + <property name="orientation">vertical</property> + <child> + <object class="GtkLabel" id="label_title"> + <property name="justify">center</property> + <property name="wrap">True</property> + <property name="max_width_chars">30</property> + <property name="visible">False</property> + <attributes> + <attribute name="weight" value="bold"/> + </attributes> + </object> + </child> + <child> + <object class="GtkLabel" id="label_body"> + <property name="justify">center</property> + <property name="wrap">True</property> + <property name="max_width_chars">30</property> + <property name="visible">False</property> + </object> + </child> + <child> + <object class="GtkLabel" id="label_warning"> + <property name="justify">center</property> + <property name="wrap">True</property> + <property name="max_width_chars">30</property> + <property name="visible">False</property> + </object> + </child> + </object> + </child> + </object> + </child> + </template> +</interface> diff --git a/src/gs-info-window.c b/src/gs-info-window.c new file mode 100644 index 0000000..92b755f --- /dev/null +++ b/src/gs-info-window.c @@ -0,0 +1,124 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2021 Purism SPC + * + * Author: Adrien Plazas <adrien.plazas@puri.sm> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +/** + * SECTION:gs-info-window + * @short_description: A minimalist window designed to present information + * + * #GsInfoWindow is a window with floating window buttons which can be closed + * by pressing the Escape key. It is intended to present information and to not + * give the user many interaction possibilities. + * + * Since: 42 + */ + +#include "config.h" + +#include <glib.h> +#include <glib-object.h> +#include <glib/gi18n.h> +#include <gtk/gtk.h> +#include <adwaita.h> +#include <locale.h> + +#include "gs-common.h" +#include "gs-info-window.h" + +typedef struct +{ + GtkWidget *overlay; +} GsInfoWindowPrivate; + +static void gs_info_window_buildable_init (GtkBuildableIface *iface); + +G_DEFINE_TYPE_WITH_CODE (GsInfoWindow, gs_info_window, ADW_TYPE_WINDOW, + G_ADD_PRIVATE (GsInfoWindow) + G_IMPLEMENT_INTERFACE (GTK_TYPE_BUILDABLE, gs_info_window_buildable_init)) + +static GtkBuildableIface *parent_buildable_iface; + +static void +gs_info_window_init (GsInfoWindow *self) +{ + gtk_widget_init_template (GTK_WIDGET (self)); +} + +static void +gs_info_window_buildable_add_child (GtkBuildable *buildable, + GtkBuilder *builder, + GObject *child, + const char *type) +{ + GsInfoWindow *self = GS_INFO_WINDOW (buildable); + GsInfoWindowPrivate *priv = gs_info_window_get_instance_private (self); + + if (!priv->overlay) + parent_buildable_iface->add_child (buildable, builder, child, type); + else if (GTK_IS_WIDGET (child)) + gs_info_window_set_child (self, GTK_WIDGET (child)); + else + GTK_BUILDER_WARN_INVALID_CHILD_TYPE (buildable, type); +} + +static void +gs_info_window_buildable_init (GtkBuildableIface *iface) +{ + parent_buildable_iface = g_type_interface_peek_parent (iface); + + iface->add_child = gs_info_window_buildable_add_child; + +} + +static void +gs_info_window_class_init (GsInfoWindowClass *klass) +{ + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-info-window.ui"); + + gtk_widget_class_bind_template_child_private (widget_class, GsInfoWindow, overlay); + + gtk_widget_class_add_binding_action (widget_class, GDK_KEY_Escape, 0, "window.close", NULL); +} + +/** + * gs_info_window_new: + * + * Create a new #GsInfoWindow. + * + * Returns: (transfer full): a new #GsInfoWindow + * Since: 42 + */ +GsInfoWindow * +gs_info_window_new (void) +{ + return g_object_new (GS_TYPE_INFO_WINDOW, NULL); +} + +/** + * gs_info_window_set_child: + * @self: a #GsInfoWindow + * @widget: (nullable): the new child of @self + * + * Create a new #GsInfoWindow. + * + * Since: 42 + */ +void +gs_info_window_set_child (GsInfoWindow *self, + GtkWidget *widget) +{ + GsInfoWindowPrivate *priv; + g_return_if_fail (GS_IS_INFO_WINDOW (self)); + g_return_if_fail (widget == NULL || GTK_IS_WIDGET (widget)); + + priv = gs_info_window_get_instance_private (self); + gtk_overlay_set_child (GTK_OVERLAY (priv->overlay), widget); +} diff --git a/src/gs-info-window.h b/src/gs-info-window.h new file mode 100644 index 0000000..30c8f4f --- /dev/null +++ b/src/gs-info-window.h @@ -0,0 +1,33 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2021 Purism SPC + * + * Author: Adrien Plazas <adrien.plazas@puri.sm> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <glib.h> +#include <glib-object.h> +#include <gtk/gtk.h> +#include <adwaita.h> + +G_BEGIN_DECLS + +#define GS_TYPE_INFO_WINDOW (gs_info_window_get_type ()) + +G_DECLARE_DERIVABLE_TYPE (GsInfoWindow, gs_info_window, GS, INFO_WINDOW, AdwWindow) + +struct _GsInfoWindowClass +{ + AdwWindowClass parent_class; +}; + +GsInfoWindow *gs_info_window_new (void); + +void gs_info_window_set_child (GsInfoWindow *self, + GtkWidget *widget); +G_END_DECLS diff --git a/src/gs-info-window.ui b/src/gs-info-window.ui new file mode 100644 index 0000000..f910f82 --- /dev/null +++ b/src/gs-info-window.ui @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <requires lib="gtk+" version="3.10"/> + <template class="GsInfoWindow" parent="AdwWindow"> + <property name="modal">True</property> + <property name="destroy_with_parent">True</property> + <property name="icon_name">dialog-information</property> + <property name="default-width">640</property> + <property name="default-height">576</property> + <style> + <class name="info"/> + </style> + <child> + <object class="GtkOverlay" id="overlay"> + <child type="overlay"> + <object class="AdwHeaderBar"> + <property name="show_start_title_buttons">True</property> + <property name="show_end_title_buttons">True</property> + <property name="valign">start</property> + <style> + <class name="flat"/> + </style> + <property name="title-widget"> + <object class="AdwWindowTitle"> + <property name="visible">False</property> + </object> + </property> + </object> + </child> + </object> + </child> + </template> +</interface> diff --git a/src/gs-installed-page.c b/src/gs-installed-page.c new file mode 100644 index 0000000..4b2997d --- /dev/null +++ b/src/gs-installed-page.c @@ -0,0 +1,974 @@ +/* -*- 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-2018 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include <string.h> +#include <glib/gi18n.h> + +#include "gs-shell.h" +#include "gs-installed-page.h" +#include "gs-common.h" +#include "gs-app-row.h" +#include "gs-utils.h" + +struct _GsInstalledPage +{ + GsPage parent_instance; + + GsPluginLoader *plugin_loader; + GCancellable *cancellable; + GtkSizeGroup *sizegroup_name; + GtkSizeGroup *sizegroup_button_label; + GtkSizeGroup *sizegroup_button_image; + gboolean cache_valid; + gboolean waiting; + GsShell *shell; + GSettings *settings; + guint pending_apps_counter; + gboolean is_narrow; + + GtkWidget *group_install_in_progress; + GtkWidget *group_install_apps; + GtkWidget *group_install_system_apps; + GtkWidget *group_install_addons; + GtkWidget *group_install_web_apps; + + GtkWidget *list_box_install_in_progress; + GtkWidget *list_box_install_apps; + GtkWidget *list_box_install_system_apps; + GtkWidget *list_box_install_addons; + GtkWidget *list_box_install_web_apps; + GtkWidget *scrolledwindow_install; + GtkWidget *spinner_install; + GtkWidget *stack_install; +}; + +G_DEFINE_TYPE (GsInstalledPage, gs_installed_page, GS_TYPE_PAGE) + +typedef enum { + PROP_IS_NARROW = 1, + /* Overrides: */ + PROP_VADJUSTMENT, + PROP_TITLE, +} GsInstalledPageProperty; + +static GParamSpec *obj_props[PROP_IS_NARROW + 1] = { NULL, }; + +static void gs_installed_page_pending_apps_refined_cb (GObject *source, + GAsyncResult *res, + gpointer user_data); +static GsPluginRefineFlags gs_installed_page_get_refine_flags (GsInstalledPage *self); +static void gs_installed_page_notify_state_changed_cb (GsApp *app, + GParamSpec *pspec, + GsInstalledPage *self); + +typedef enum { + GS_UPDATE_LIST_SECTION_INSTALLING_AND_REMOVING, + GS_UPDATE_LIST_SECTION_REMOVABLE_APPS, + GS_UPDATE_LIST_SECTION_SYSTEM_APPS, + GS_UPDATE_LIST_SECTION_ADDONS, + GS_UPDATE_LIST_SECTION_WEB_APPS, + GS_UPDATE_LIST_SECTION_LAST +} GsInstalledPageSection; + +/* This must mostly mirror gs_installed_page_get_app_sort_key() otherwise apps + * will end up sorted into a section they don’t belong in. */ +static GsInstalledPageSection +gs_installed_page_get_app_section (GsApp *app) +{ + GsAppState state = gs_app_get_state (app); + AsComponentKind kind = gs_app_get_kind (app); + + if (state == GS_APP_STATE_INSTALLING || + state == GS_APP_STATE_QUEUED_FOR_INSTALL || + state == GS_APP_STATE_REMOVING) + return GS_UPDATE_LIST_SECTION_INSTALLING_AND_REMOVING; + + if (kind == AS_COMPONENT_KIND_DESKTOP_APP) { + if (gs_app_has_quirk (app, GS_APP_QUIRK_COMPULSORY)) + return GS_UPDATE_LIST_SECTION_SYSTEM_APPS; + return GS_UPDATE_LIST_SECTION_REMOVABLE_APPS; + } + + if (kind == AS_COMPONENT_KIND_WEB_APP) + return GS_UPDATE_LIST_SECTION_WEB_APPS; + + return GS_UPDATE_LIST_SECTION_ADDONS; +} + +static void +update_groups (GsInstalledPage *self) +{ + gtk_widget_set_visible (self->group_install_in_progress, + gtk_widget_get_first_child (self->list_box_install_in_progress) != NULL); + gtk_widget_set_visible (self->group_install_apps, + gtk_widget_get_first_child (self->list_box_install_apps) != NULL); + gtk_widget_set_visible (self->group_install_system_apps, + gtk_widget_get_first_child (self->list_box_install_system_apps) != NULL); + gtk_widget_set_visible (self->group_install_addons, + gtk_widget_get_first_child (self->list_box_install_addons) != NULL); + gtk_widget_set_visible (self->group_install_web_apps, + gtk_widget_get_first_child (self->list_box_install_web_apps) != NULL); +} + +static GsInstalledPageSection +gs_installed_page_get_row_section (GsInstalledPage *self, + GsAppRow *app_row) +{ + GtkWidget *parent; + + g_return_val_if_fail (GS_IS_INSTALLED_PAGE (self), GS_UPDATE_LIST_SECTION_LAST); + g_return_val_if_fail (GS_IS_APP_ROW (app_row), GS_UPDATE_LIST_SECTION_LAST); + + parent = gtk_widget_get_parent (GTK_WIDGET (app_row)); + if (parent == self->list_box_install_in_progress) + return GS_UPDATE_LIST_SECTION_INSTALLING_AND_REMOVING; + if (parent == self->list_box_install_apps) + return GS_UPDATE_LIST_SECTION_REMOVABLE_APPS; + if (parent == self->list_box_install_system_apps) + return GS_UPDATE_LIST_SECTION_SYSTEM_APPS; + if (parent == self->list_box_install_addons) + return GS_UPDATE_LIST_SECTION_ADDONS; + if (parent == self->list_box_install_web_apps) + return GS_UPDATE_LIST_SECTION_WEB_APPS; + + g_warn_if_reached (); + + return GS_UPDATE_LIST_SECTION_LAST; +} + +static void +gs_installed_page_invalidate (GsInstalledPage *self) +{ + self->cache_valid = FALSE; +} + +static void +gs_installed_page_app_row_activated_cb (GtkListBox *list_box, + GtkListBoxRow *row, + GsInstalledPage *self) +{ + GsApp *app; + app = gs_app_row_get_app (GS_APP_ROW (row)); + gs_shell_show_app (self->shell, app); +} + +static void +row_unrevealed (GObject *row, GParamSpec *pspec, gpointer data) +{ + GsInstalledPage *self = GS_INSTALLED_PAGE (gtk_widget_get_ancestor (GTK_WIDGET (row), + GS_TYPE_INSTALLED_PAGE)); + GtkWidget *list; + + list = gtk_widget_get_parent (GTK_WIDGET (row)); + if (list == NULL) + return; + gtk_list_box_remove (GTK_LIST_BOX (list), GTK_WIDGET (row)); + update_groups (self); +} + +static void +gs_installed_page_unreveal_row (GsAppRow *app_row) +{ + GsApp *app = gs_app_row_get_app (app_row); + if (app != NULL) { + g_signal_handlers_disconnect_matched (app, G_SIGNAL_MATCH_FUNC, 0, 0, NULL, + G_CALLBACK (gs_installed_page_notify_state_changed_cb), NULL); + } + + g_signal_connect (app_row, "unrevealed", + G_CALLBACK (row_unrevealed), NULL); + gs_app_row_unreveal (app_row); +} + +static GsAppRow * /* (transfer none) */ +gs_installed_page_find_app_row (GsInstalledPage *self, + GsApp *app) +{ + GtkWidget *lists[] = { + self->list_box_install_in_progress, + self->list_box_install_apps, + self->list_box_install_system_apps, + self->list_box_install_addons, + self->list_box_install_web_apps, + NULL + }; + + for (gsize i = 0; lists[i]; i++) { + for (GtkWidget *child = gtk_widget_get_first_child (lists[i]); + child != NULL; + child = gtk_widget_get_next_sibling (child)) { + GsAppRow *app_row = GS_APP_ROW (child); + if (gs_app_row_get_app (app_row) == app) { + return app_row; + } + } + } + + return NULL; +} + + +static void +gs_installed_page_app_removed (GsPage *page, GsApp *app) +{ + GsInstalledPage *self = GS_INSTALLED_PAGE (page); + GsAppRow *app_row = gs_installed_page_find_app_row (self, app); + if (app_row != NULL) + gs_installed_page_unreveal_row (app_row); +} + +static void +gs_installed_page_app_remove_cb (GsAppRow *app_row, + GsInstalledPage *self) +{ + GsApp *app; + + app = gs_app_row_get_app (app_row); + gs_page_remove_app (GS_PAGE (self), app, self->cancellable); +} + +static void +gs_installed_page_maybe_move_app_row (GsInstalledPage *self, + GsAppRow *app_row) +{ + GsInstalledPageSection current_section, expected_section; + + current_section = gs_installed_page_get_row_section (self, app_row); + g_return_if_fail (current_section != GS_UPDATE_LIST_SECTION_LAST); + + expected_section = gs_installed_page_get_app_section (gs_app_row_get_app (app_row)); + if (expected_section != current_section) { + GtkWidget *widget = GTK_WIDGET (app_row); + + g_object_ref (app_row); + gtk_list_box_remove (GTK_LIST_BOX (gtk_widget_get_parent (widget)), widget); + switch (expected_section) { + case GS_UPDATE_LIST_SECTION_INSTALLING_AND_REMOVING: + widget = self->list_box_install_in_progress; + break; + case GS_UPDATE_LIST_SECTION_REMOVABLE_APPS: + widget = self->list_box_install_apps; + break; + case GS_UPDATE_LIST_SECTION_SYSTEM_APPS: + widget = self->list_box_install_system_apps; + break; + case GS_UPDATE_LIST_SECTION_ADDONS: + widget = self->list_box_install_addons; + break; + case GS_UPDATE_LIST_SECTION_WEB_APPS: + widget = self->list_box_install_web_apps; + break; + default: + g_warn_if_reached (); + widget = NULL; + break; + } + + if (widget != NULL) + gtk_list_box_append (GTK_LIST_BOX (widget), GTK_WIDGET (app_row)); + + g_object_unref (app_row); + update_groups (self); + } +} + +static void +gs_installed_page_notify_state_changed_cb (GsApp *app, + GParamSpec *pspec, + GsInstalledPage *self) +{ + GsAppState state = gs_app_get_state (app); + GsAppRow *app_row = gs_installed_page_find_app_row (self, app); + + g_assert (app_row != NULL); + + gtk_list_box_row_changed (GTK_LIST_BOX_ROW (app_row)); + + /* Filter which applications can be shown in the installed page */ + if (state != GS_APP_STATE_INSTALLING && + state != GS_APP_STATE_INSTALLED && + state != GS_APP_STATE_REMOVING && + state != GS_APP_STATE_UPDATABLE && + state != GS_APP_STATE_UPDATABLE_LIVE) + gs_installed_page_unreveal_row (app_row); + else + gs_installed_page_maybe_move_app_row (self, app_row); +} + +static gboolean +should_show_installed_size (GsInstalledPage *self) +{ + return g_settings_get_boolean (self->settings, + "installed-page-show-size"); +} + +static gboolean +gs_installed_page_is_actual_app (GsApp *app) +{ + if (gs_app_get_description (app) != NULL) + return TRUE; + + /* special snowflake */ + if (g_strcmp0 (gs_app_get_id (app), "google-chrome.desktop") == 0) + return TRUE; + + /* web apps sometimes don't have descriptions */ + if (gs_app_get_kind (app) == AS_COMPONENT_KIND_WEB_APP) + return TRUE; + + g_debug ("%s is not an actual app", gs_app_get_unique_id (app)); + return FALSE; +} + +static void +gs_installed_page_add_app (GsInstalledPage *self, GsAppList *list, GsApp *app) +{ + GtkWidget *app_row; + + /* only show if is an actual application */ + if (!gs_installed_page_is_actual_app (app)) + return; + + app_row = g_object_new (GS_TYPE_APP_ROW, + "app", app, + "show-buttons", TRUE, + "show-source", gs_utils_list_has_component_fuzzy (list, app), + "show-installed-size", !gs_app_has_quirk (app, GS_APP_QUIRK_COMPULSORY) && should_show_installed_size (self), + NULL); + + g_signal_connect (app_row, "button-clicked", + G_CALLBACK (gs_installed_page_app_remove_cb), self); + g_signal_connect_object (app, "notify::state", + G_CALLBACK (gs_installed_page_notify_state_changed_cb), + self, 0); + + switch (gs_installed_page_get_app_section (app)) { + case GS_UPDATE_LIST_SECTION_INSTALLING_AND_REMOVING: + gtk_list_box_append (GTK_LIST_BOX (self->list_box_install_in_progress), app_row); + break; + case GS_UPDATE_LIST_SECTION_REMOVABLE_APPS: + gtk_list_box_append (GTK_LIST_BOX (self->list_box_install_apps), app_row); + break; + case GS_UPDATE_LIST_SECTION_SYSTEM_APPS: + gtk_list_box_append (GTK_LIST_BOX (self->list_box_install_system_apps), app_row); + break; + case GS_UPDATE_LIST_SECTION_ADDONS: + gtk_list_box_append (GTK_LIST_BOX (self->list_box_install_addons), app_row); + break; + case GS_UPDATE_LIST_SECTION_WEB_APPS: + gtk_list_box_append (GTK_LIST_BOX (self->list_box_install_web_apps), app_row); + break; + default: + g_assert_not_reached (); + } + + update_groups (self); + + gs_app_row_set_size_groups (GS_APP_ROW (app_row), + self->sizegroup_name, + self->sizegroup_button_label, + self->sizegroup_button_image); + + gs_app_row_set_show_description (GS_APP_ROW (app_row), FALSE); + gs_app_row_set_show_source (GS_APP_ROW (app_row), FALSE); + g_object_bind_property (self, "is-narrow", app_row, "is-narrow", G_BINDING_SYNC_CREATE); +} + +static void +gs_installed_page_get_installed_cb (GObject *source_object, + GAsyncResult *res, + gpointer user_data) +{ + guint i; + GsApp *app; + GsInstalledPage *self = GS_INSTALLED_PAGE (user_data); + GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (source_object); + g_autoptr(GError) error = NULL; + g_autoptr(GsAppList) list = NULL; + g_autoptr(GsAppList) pending = gs_plugin_loader_get_pending (plugin_loader); + g_autoptr(GsPluginJob) plugin_job = NULL; + + gtk_spinner_stop (GTK_SPINNER (self->spinner_install)); + gtk_stack_set_visible_child_name (GTK_STACK (self->stack_install), "view"); + + self->waiting = FALSE; + self->cache_valid = TRUE; + + 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 installed apps: %s", error->message); + goto out; + } + for (i = 0; i < gs_app_list_length (list); i++) { + app = gs_app_list_index (list, i); + gs_installed_page_add_app (self, list, app); + } +out: + if (gs_app_list_length (pending) > 0) { + plugin_job = gs_plugin_job_refine_new (pending, + gs_installed_page_get_refine_flags (self)); + gs_plugin_loader_job_process_async (self->plugin_loader, plugin_job, + self->cancellable, + gs_installed_page_pending_apps_refined_cb, + self); + + } +} + +static void +gs_installed_page_remove_all_cb (GtkWidget *container, + GtkWidget *child) +{ + if (GS_IS_APP_ROW (child)) { + GsApp *app = gs_app_row_get_app (GS_APP_ROW (child)); + if (app != NULL) { + g_signal_handlers_disconnect_matched (app, G_SIGNAL_MATCH_FUNC, 0, 0, NULL, + G_CALLBACK (gs_installed_page_notify_state_changed_cb), NULL); + } + } else { + g_warn_if_reached (); + } + + gtk_list_box_remove (GTK_LIST_BOX (container), child); +} + +static gboolean +filter_app_kinds_cb (GsApp *app, + gpointer user_data) +{ + /* Remove invalid apps. */ + if (!gs_plugin_loader_app_is_valid (app, GS_PLUGIN_REFINE_FLAGS_NONE)) + return FALSE; + + switch (gs_app_get_kind (app)) { + case AS_COMPONENT_KIND_OPERATING_SYSTEM: + case AS_COMPONENT_KIND_CODEC: + case AS_COMPONENT_KIND_FONT: + g_debug ("app invalid as %s: %s", + as_component_kind_to_string (gs_app_get_kind (app)), + gs_app_get_unique_id (app)); + return FALSE; + default: + return TRUE; + } +} + +static GsPluginRefineFlags +gs_installed_page_get_refine_flags (GsInstalledPage *self) +{ + GsPluginRefineFlags flags; + + flags = GS_PLUGIN_REFINE_FLAGS_REQUIRE_ICON | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_HISTORY | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_SETUP_ACTION | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_VERSION | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_PERMISSIONS | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_ORIGIN_HOSTNAME | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_PROVENANCE | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_DESCRIPTION | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_LICENSE | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_CATEGORIES | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_RATING; + + if (should_show_installed_size (self)) + flags |= GS_PLUGIN_REFINE_FLAGS_REQUIRE_SIZE; + + return flags; +} + +static void +gs_installed_page_load (GsInstalledPage *self) +{ + g_autoptr(GsAppQuery) query = NULL; + g_autoptr(GsPluginJob) plugin_job = NULL; + + if (self->waiting) + return; + self->waiting = TRUE; + + /* remove old entries */ + gs_widget_remove_all (self->list_box_install_in_progress, gs_installed_page_remove_all_cb); + gs_widget_remove_all (self->list_box_install_apps, gs_installed_page_remove_all_cb); + gs_widget_remove_all (self->list_box_install_system_apps, gs_installed_page_remove_all_cb); + gs_widget_remove_all (self->list_box_install_addons, gs_installed_page_remove_all_cb); + gs_widget_remove_all (self->list_box_install_web_apps, gs_installed_page_remove_all_cb); + update_groups (self); + + /* get installed apps */ + query = gs_app_query_new ("is-installed", GS_APP_QUERY_TRISTATE_TRUE, + "refine-flags", gs_installed_page_get_refine_flags (self), + "filter-func", filter_app_kinds_cb, + NULL); + plugin_job = gs_plugin_job_list_apps_new (query, GS_PLUGIN_LIST_APPS_FLAGS_INTERACTIVE); + gs_plugin_loader_job_process_async (self->plugin_loader, + plugin_job, + self->cancellable, + gs_installed_page_get_installed_cb, + self); + gtk_spinner_start (GTK_SPINNER (self->spinner_install)); + gtk_stack_set_visible_child_name (GTK_STACK (self->stack_install), "spinner"); +} + +static void +gs_installed_page_reload (GsPage *page) +{ + GsInstalledPage *self = GS_INSTALLED_PAGE (page); + gs_installed_page_invalidate (self); + gs_installed_page_load (self); +} + +static void +gs_installed_page_switch_to (GsPage *page) +{ + GsInstalledPage *self = GS_INSTALLED_PAGE (page); + + if (gs_shell_get_mode (self->shell) != GS_SHELL_MODE_INSTALLED) { + g_warning ("Called switch_to(installed) when in mode %s", + gs_shell_get_mode_string (self->shell)); + return; + } + + if (gs_shell_get_mode (self->shell) == GS_SHELL_MODE_INSTALLED) { + gs_grab_focus_when_mapped (self->scrolledwindow_install); + } + + /* no need to refresh */ + if (self->cache_valid) + return; + + gs_installed_page_load (self); +} + +/** + * gs_installed_page_get_app_sort_key: + * + * Get a sort key to achive this: + * + * 1. state:installing applications + * 2. state: applications queued for installing + * 3. state:removing applications + * 4. kind:normal applications + * 5. kind:system applications + * + * Within each of these groups, they are sorted by the install date and then + * by name. + **/ +static gchar * +gs_installed_page_get_app_sort_key (GsApp *app) +{ + GString *key; + g_autofree gchar *sort_name = NULL; + + key = g_string_sized_new (64); + + /* sort installed, removing, other */ + switch (gs_app_get_state (app)) { + case GS_APP_STATE_INSTALLING: + g_string_append (key, "1:"); + break; + case GS_APP_STATE_QUEUED_FOR_INSTALL: + g_string_append (key, "2:"); + break; + case GS_APP_STATE_REMOVING: + g_string_append (key, "3:"); + break; + default: + g_string_append (key, "4:"); + break; + } + + /* sort apps by kind */ + switch (gs_app_get_kind (app)) { + case AS_COMPONENT_KIND_DESKTOP_APP: + g_string_append (key, "2:"); + break; + case AS_COMPONENT_KIND_WEB_APP: + g_string_append (key, "3:"); + break; + case AS_COMPONENT_KIND_RUNTIME: + g_string_append (key, "4:"); + break; + case AS_COMPONENT_KIND_ADDON: + g_string_append (key, "5:"); + break; + case AS_COMPONENT_KIND_CODEC: + g_string_append (key, "6:"); + break; + case AS_COMPONENT_KIND_FONT: + g_string_append (key, "6:"); + break; + case AS_COMPONENT_KIND_INPUT_METHOD: + g_string_append (key, "7:"); + break; + default: + if (gs_app_get_special_kind (app) == GS_APP_SPECIAL_KIND_OS_UPDATE) + g_string_append (key, "1:"); + else + g_string_append (key, "8:"); + break; + } + + /* sort normal, compulsory */ + if (!gs_app_has_quirk (app, GS_APP_QUIRK_COMPULSORY)) + g_string_append (key, "1:"); + else + g_string_append (key, "2:"); + + /* finally, sort by short name */ + if (gs_app_get_name (app) != NULL) { + sort_name = gs_utils_sort_key (gs_app_get_name (app)); + g_string_append (key, sort_name); + } + + return g_string_free (key, FALSE); +} + +static gint +gs_installed_page_sort_func (GtkListBoxRow *a, + GtkListBoxRow *b, + gpointer user_data) +{ + GsApp *a1, *a2; + g_autofree gchar *key1 = NULL; + g_autofree gchar *key2 = NULL; + + a1 = gs_app_row_get_app (GS_APP_ROW (a)); + a2 = gs_app_row_get_app (GS_APP_ROW (b)); + key1 = gs_installed_page_get_app_sort_key (a1); + key2 = gs_installed_page_get_app_sort_key (a2); + + /* compare the keys according to the algorithm above */ + return g_strcmp0 (key1, key2); +} + +static gboolean +gs_installed_page_has_app (GsInstalledPage *self, + GsApp *app) +{ + GtkWidget *lists[] = { + self->list_box_install_in_progress, + self->list_box_install_apps, + self->list_box_install_system_apps, + self->list_box_install_addons, + self->list_box_install_web_apps, + NULL + }; + + for (gsize i = 0; lists[i]; i++) { + for (GtkWidget *child = gtk_widget_get_first_child (lists[i]); + child != NULL; + child = gtk_widget_get_next_sibling (child)) { + GsAppRow *app_row = GS_APP_ROW (child); + if (gs_app_row_get_app (app_row) == app) + return TRUE; + } + } + + return FALSE; +} + +static void +gs_installed_page_add_pending_apps (GsInstalledPage *self, + GsAppList *list, + gboolean should_install) +{ + guint pending_apps_count = 0; + + for (guint i = 0; i < gs_app_list_length (list); ++i) { + GsApp *app = gs_app_list_index (list, i); + if (gs_app_is_installed (app)) { + continue; + } + + /* never show OS upgrades, we handle the scheduling and + * cancellation in GsUpgradeBanner */ + if (gs_app_get_kind (app) == AS_COMPONENT_KIND_OPERATING_SYSTEM) + continue; + + if (gs_app_get_state (app) == GS_APP_STATE_AVAILABLE) + gs_app_set_state (app, GS_APP_STATE_QUEUED_FOR_INSTALL); + + if (should_install && + gs_app_get_state (app) == GS_APP_STATE_QUEUED_FOR_INSTALL && + gs_plugin_loader_get_network_available (self->plugin_loader) && + !gs_plugin_loader_get_network_metered (self->plugin_loader)) + gs_page_install_app (GS_PAGE (self), app, + GS_SHELL_INTERACTION_FULL, + gs_app_get_cancellable (app)); + + ++pending_apps_count; + if (!gs_installed_page_has_app (self, app)) + gs_installed_page_add_app (self, list, app); + } + + /* update the number of on-going operations */ + if (pending_apps_count != self->pending_apps_counter) { + self->pending_apps_counter = pending_apps_count; + g_object_notify (G_OBJECT (self), "counter"); + } +} + +static void +gs_installed_page_pending_apps_refined_cb (GObject *source, + GAsyncResult *res, + gpointer user_data) +{ + GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (source); + GsInstalledPage *self = GS_INSTALLED_PAGE (user_data); + g_autoptr(GError) error = NULL; + g_autoptr(GsAppList) 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_warning ("failed to refine pending apps: %s", error->message); + return; + } + + /* we add the pending apps and install them because this is called after we + * populate the page, and there may be pending apps coming from the saved list + * (i.e. after loading the saved pending apps from the disk) */ + gs_installed_page_add_pending_apps (self, list, TRUE); +} + +static void +gs_installed_page_pending_apps_changed_cb (GsPluginLoader *plugin_loader, + GsInstalledPage *self) +{ + g_autoptr(GsAppList) pending = gs_plugin_loader_get_pending (plugin_loader); + /* we don't call install every time the pending apps list changes because + * it may be queued in the plugin loader */ + gs_installed_page_add_pending_apps (self, pending, FALSE); +} + +static gboolean +gs_installed_page_setup (GsPage *page, + GsShell *shell, + GsPluginLoader *plugin_loader, + GCancellable *cancellable, + GError **error) +{ + GsInstalledPage *self = GS_INSTALLED_PAGE (page); + + g_return_val_if_fail (GS_IS_INSTALLED_PAGE (self), TRUE); + + self->shell = shell; + self->plugin_loader = g_object_ref (plugin_loader); + g_signal_connect (self->plugin_loader, "pending-apps-changed", + G_CALLBACK (gs_installed_page_pending_apps_changed_cb), + self); + + self->cancellable = g_object_ref (cancellable); + + /* setup installed */ + gtk_list_box_set_sort_func (GTK_LIST_BOX (self->list_box_install_in_progress), + gs_installed_page_sort_func, + self, NULL); + gtk_list_box_set_sort_func (GTK_LIST_BOX (self->list_box_install_apps), + gs_installed_page_sort_func, + self, NULL); + gtk_list_box_set_sort_func (GTK_LIST_BOX (self->list_box_install_system_apps), + gs_installed_page_sort_func, + self, NULL); + gtk_list_box_set_sort_func (GTK_LIST_BOX (self->list_box_install_addons), + gs_installed_page_sort_func, + self, NULL); + gtk_list_box_set_sort_func (GTK_LIST_BOX (self->list_box_install_web_apps), + gs_installed_page_sort_func, + self, NULL); + return TRUE; +} + +static void +gs_installed_page_get_property (GObject *object, + guint prop_id, + GValue *value, + GParamSpec *pspec) +{ + GsInstalledPage *self = GS_INSTALLED_PAGE (object); + + switch ((GsInstalledPageProperty) prop_id) { + case PROP_IS_NARROW: + g_value_set_boolean (value, gs_installed_page_get_is_narrow (self)); + break; + case PROP_VADJUSTMENT: + g_value_set_object (value, gtk_scrolled_window_get_vadjustment (GTK_SCROLLED_WINDOW (self->scrolledwindow_install))); + break; + case PROP_TITLE: + /* Translators: This is in the context of a list of apps which are installed on the system. */ + g_value_set_string (value, C_("List of installed apps", "Installed")); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_installed_page_set_property (GObject *object, + guint prop_id, + const GValue *value, + GParamSpec *pspec) +{ + GsInstalledPage *self = GS_INSTALLED_PAGE (object); + + switch ((GsInstalledPageProperty) prop_id) { + case PROP_IS_NARROW: + gs_installed_page_set_is_narrow (self, g_value_get_boolean (value)); + break; + case PROP_VADJUSTMENT: + case PROP_TITLE: + /* Read only. */ + g_assert_not_reached (); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_installed_page_dispose (GObject *object) +{ + GsInstalledPage *self = GS_INSTALLED_PAGE (object); + + g_clear_object (&self->sizegroup_name); + g_clear_object (&self->sizegroup_button_label); + g_clear_object (&self->sizegroup_button_image); + + g_clear_object (&self->plugin_loader); + g_clear_object (&self->cancellable); + g_clear_object (&self->settings); + + G_OBJECT_CLASS (gs_installed_page_parent_class)->dispose (object); +} + +static void +gs_installed_page_class_init (GsInstalledPageClass *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_installed_page_get_property; + object_class->set_property = gs_installed_page_set_property; + object_class->dispose = gs_installed_page_dispose; + + page_class->app_removed = gs_installed_page_app_removed; + page_class->switch_to = gs_installed_page_switch_to; + page_class->reload = gs_installed_page_reload; + page_class->setup = gs_installed_page_setup; + + /** + * GsInstalledPage: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. using icons rather than labels in buttons. 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_VADJUSTMENT, "vadjustment"); + g_object_class_override_property (object_class, PROP_TITLE, "title"); + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-installed-page.ui"); + + gtk_widget_class_bind_template_child (widget_class, GsInstalledPage, group_install_in_progress); + gtk_widget_class_bind_template_child (widget_class, GsInstalledPage, group_install_apps); + gtk_widget_class_bind_template_child (widget_class, GsInstalledPage, group_install_system_apps); + gtk_widget_class_bind_template_child (widget_class, GsInstalledPage, group_install_addons); + gtk_widget_class_bind_template_child (widget_class, GsInstalledPage, group_install_web_apps); + gtk_widget_class_bind_template_child (widget_class, GsInstalledPage, list_box_install_in_progress); + gtk_widget_class_bind_template_child (widget_class, GsInstalledPage, list_box_install_apps); + gtk_widget_class_bind_template_child (widget_class, GsInstalledPage, list_box_install_system_apps); + gtk_widget_class_bind_template_child (widget_class, GsInstalledPage, list_box_install_addons); + gtk_widget_class_bind_template_child (widget_class, GsInstalledPage, list_box_install_web_apps); + gtk_widget_class_bind_template_child (widget_class, GsInstalledPage, scrolledwindow_install); + gtk_widget_class_bind_template_child (widget_class, GsInstalledPage, spinner_install); + gtk_widget_class_bind_template_child (widget_class, GsInstalledPage, stack_install); + + gtk_widget_class_bind_template_callback (widget_class, gs_installed_page_app_row_activated_cb); +} + +static void +gs_installed_page_init (GsInstalledPage *self) +{ + gtk_widget_init_template (GTK_WIDGET (self)); + + self->sizegroup_name = gtk_size_group_new (GTK_SIZE_GROUP_HORIZONTAL); + self->sizegroup_button_label = gtk_size_group_new (GTK_SIZE_GROUP_HORIZONTAL); + self->sizegroup_button_image = gtk_size_group_new (GTK_SIZE_GROUP_HORIZONTAL); + + self->settings = g_settings_new ("org.gnome.software"); +} + +/** + * gs_installed_page_get_is_narrow: + * @self: a #GsInstalledPage + * + * Get the value of #GsInstalledPage:is-narrow. + * + * Returns: %TRUE if the page is in narrow mode, %FALSE otherwise + * + * Since: 41 + */ +gboolean +gs_installed_page_get_is_narrow (GsInstalledPage *self) +{ + g_return_val_if_fail (GS_IS_INSTALLED_PAGE (self), FALSE); + + return self->is_narrow; +} + +/** + * gs_installed_page_set_is_narrow: + * @self: a #GsInstalledPage + * @is_narrow: %TRUE to set the page in narrow mode, %FALSE otherwise + * + * Set the value of #GsInstalledPage:is-narrow. + * + * Since: 41 + */ +void +gs_installed_page_set_is_narrow (GsInstalledPage *self, gboolean is_narrow) +{ + g_return_if_fail (GS_IS_INSTALLED_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]); +} + +GsInstalledPage * +gs_installed_page_new (void) +{ + GsInstalledPage *self; + self = g_object_new (GS_TYPE_INSTALLED_PAGE, NULL); + return GS_INSTALLED_PAGE (self); +} diff --git a/src/gs-installed-page.h b/src/gs-installed-page.h new file mode 100644 index 0000000..f37efa5 --- /dev/null +++ b/src/gs-installed-page.h @@ -0,0 +1,26 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2017 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include "gs-page.h" + +G_BEGIN_DECLS + +#define GS_TYPE_INSTALLED_PAGE (gs_installed_page_get_type ()) + +G_DECLARE_FINAL_TYPE (GsInstalledPage, gs_installed_page, GS, INSTALLED_PAGE, GsPage) + +GsInstalledPage *gs_installed_page_new (void); + +gboolean gs_installed_page_get_is_narrow (GsInstalledPage *self); +void gs_installed_page_set_is_narrow (GsInstalledPage *self, + gboolean is_narrow); + +G_END_DECLS diff --git a/src/gs-installed-page.ui b/src/gs-installed-page.ui new file mode 100644 index 0000000..ff0bfb7 --- /dev/null +++ b/src/gs-installed-page.ui @@ -0,0 +1,166 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <requires lib="gtk+" version="3.10"/> + <template class="GsInstalledPage" parent="GsPage"> + <accessibility> + <property name="label" translatable="yes">Installed page</property> + </accessibility> + <child> + <object class="GtkStack" id="stack_install"> + + <child> + <object class="GtkStackPage"> + <property name="name">spinner</property> + <property name="child"> + <object class="GtkSpinner" id="spinner_install"> + <property name="width_request">32</property> + <property name="height_request">32</property> + <property name="halign">center</property> + <property name="valign">center</property> + <property name="hexpand">True</property> + <property name="vexpand">True</property> + <style> + <class name="fade-in"/> + </style> + </object> + </property> + </object> + </child> + + <child> + <object class="GtkStackPage"> + <property name="name">view</property> + <property name="child"> + <object class="GtkBox" id="box_install"> + <property name="orientation">vertical</property> + <child> + <object class="GtkScrolledWindow" id="scrolledwindow_install"> + <property name="can_focus">True</property> + <property name="hscrollbar_policy">never</property> + <property name="vscrollbar_policy">automatic</property> + <property name="vexpand">True</property> + <style> + <class name="list-page"/> + </style> + <child> + <object class="AdwClamp"> + <property name="maximum-size">600</property> + <property name="tightening-threshold">400</property> + <child> + <object class="GtkBox"> + <property name="orientation">vertical</property> + <child> + <object class="AdwPreferencesGroup" id="group_install_in_progress"> + <property name="visible">False</property> + <property name="title" translatable="yes">In Progress</property> + <style> + <class name="section"/> + </style> + <child> + <object class="GtkListBox" id="list_box_install_in_progress"> + <property name="can_focus">True</property> + <property name="selection_mode">none</property> + <property name="valign">start</property> + <signal name="row-activated" handler="gs_installed_page_app_row_activated_cb"/> + <style> + <class name="boxed-list"/> + </style> + </object> + </child> + </object> + </child> + <child> + <object class="AdwPreferencesGroup" id="group_install_apps"> + <property name="visible">False</property> + <property name="title" translatable="yes">Applications</property> + <style> + <class name="section"/> + </style> + <child> + <object class="GtkListBox" id="list_box_install_apps"> + <property name="can_focus">True</property> + <property name="selection_mode">none</property> + <property name="valign">start</property> + <signal name="row-activated" handler="gs_installed_page_app_row_activated_cb"/> + <style> + <class name="boxed-list"/> + </style> + </object> + </child> + </object> + </child> + <child> + <object class="AdwPreferencesGroup" id="group_install_web_apps"> + <property name="visible">False</property> + <property name="title" translatable="yes">Web Applications</property> + <style> + <class name="section"/> + </style> + <child> + <object class="GtkListBox" id="list_box_install_web_apps"> + <property name="can_focus">True</property> + <property name="selection_mode">none</property> + <property name="valign">start</property> + <signal name="row-activated" handler="gs_installed_page_app_row_activated_cb"/> + <style> + <class name="boxed-list"/> + </style> + </object> + </child> + </object> + </child> + <child> + <object class="AdwPreferencesGroup" id="group_install_system_apps"> + <property name="visible">False</property> + <property name="title" translatable="yes">System Applications</property> + <style> + <class name="section"/> + </style> + <child> + <object class="GtkListBox" id="list_box_install_system_apps"> + <property name="can_focus">True</property> + <property name="selection_mode">none</property> + <property name="valign">start</property> + <signal name="row-activated" handler="gs_installed_page_app_row_activated_cb"/> + <style> + <class name="boxed-list"/> + </style> + </object> + </child> + </object> + </child> + <child> + <object class="AdwPreferencesGroup" id="group_install_addons"> + <property name="visible">False</property> + <property name="title" translatable="yes">Add-ons</property> + <style> + <class name="section"/> + </style> + <child> + <object class="GtkListBox" id="list_box_install_addons"> + <property name="can_focus">True</property> + <property name="selection_mode">none</property> + <property name="valign">start</property> + <signal name="row-activated" handler="gs_installed_page_app_row_activated_cb"/> + <style> + <class name="boxed-list"/> + </style> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </property> + </object> + </child> + + </object> + </child> + </template> +</interface> diff --git a/src/gs-language.c b/src/gs-language.c new file mode 100644 index 0000000..d696b37 --- /dev/null +++ b/src/gs-language.c @@ -0,0 +1,163 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2008 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2015-2016 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include <string.h> + +#include "gs-language.h" + +struct _GsLanguage +{ + GObject parent_instance; + + GHashTable *hash; +}; + +G_DEFINE_TYPE (GsLanguage, gs_language, G_TYPE_OBJECT) + +static void +gs_language_parser_start_element (GMarkupParseContext *context, const gchar *element_name, + const gchar **attribute_names, const gchar **attribute_values, + gpointer user_data, GError **error) +{ + guint i, len; + const gchar *code1 = NULL; + const gchar *code2b = NULL; + const gchar *name = NULL; + GsLanguage *language = user_data; + + if (strcmp (element_name, "iso_639_entry") != 0) + return; + + /* find data */ + len = g_strv_length ((gchar**)attribute_names); + for (i=0; i<len; i++) { + if (strcmp (attribute_names[i], "iso_639_1_code") == 0) + code1 = attribute_values[i]; + if (strcmp (attribute_names[i], "iso_639_2B_code") == 0) + code2b = attribute_values[i]; + if (strcmp (attribute_names[i], "name") == 0) + name = attribute_values[i]; + } + + /* not valid entry */ + if (name == NULL) + return; + + /* add both to hash */ + if (code1 != NULL) + g_hash_table_insert (language->hash, g_strdup (code1), g_strdup (name)); + if (code2b != NULL) + g_hash_table_insert (language->hash, g_strdup (code2b), g_strdup (name)); +} + +/* trivial parser */ +static const GMarkupParser gs_language_markup_parser = +{ + gs_language_parser_start_element, + NULL, /* end_element */ + NULL, /* characters */ + NULL, /* passthrough */ + NULL /* error */ +}; + +/** + * gs_language_populate: + * + * <iso_639_entry iso_639_2B_code="hun" iso_639_2T_code="hun" iso_639_1_code="hu" name="Hungarian" /> + **/ +gboolean +gs_language_populate (GsLanguage *language, GError **error) +{ + gsize size; + g_autofree gchar *contents = NULL; + g_autofree gchar *filename = NULL; + g_autoptr(GMarkupParseContext) context = NULL; + + /* find filename */ + filename = g_build_filename (DATADIR, "xml", "iso-codes", "iso_639.xml", NULL); + if (!g_file_test (filename, G_FILE_TEST_EXISTS)) { + g_free (filename); + filename = g_build_filename ("/usr", "share", "xml", "iso-codes", "iso_639.xml", NULL); + } + /* FreeBSD and OpenBSD ports */ + if (!g_file_test (filename, G_FILE_TEST_EXISTS)) { + g_free (filename); + filename = g_build_filename ("/usr", "local", "share", "xml", "iso-codes", "iso_639.xml", NULL); + } + /* NetBSD pkgsrc */ + if (!g_file_test (filename, G_FILE_TEST_EXISTS)) { + g_free (filename); + filename = g_build_filename ("/usr", "pkg", "share", "xml", "iso-codes", "iso_639.xml", NULL); + } + if (!g_file_test (filename, G_FILE_TEST_EXISTS)) { + g_set_error (error, 1, 0, "cannot find source file : '%s'", filename); + return FALSE; + } + + /* get contents */ + if (!g_file_get_contents (filename, &contents, &size, error)) + return FALSE; + + /* create parser */ + context = g_markup_parse_context_new (&gs_language_markup_parser, G_MARKUP_PREFIX_ERROR_POSITION, language, NULL); + + /* parse data */ + if (!g_markup_parse_context_parse (context, contents, (gssize) size, error)) + return FALSE; + + return TRUE;; +} + +gchar * +gs_language_iso639_to_language (GsLanguage *language, const gchar *iso639) +{ + return g_strdup (g_hash_table_lookup (language->hash, iso639)); +} + +static void +gs_language_finalize (GObject *object) +{ + GsLanguage *language; + + g_return_if_fail (GS_IS_LANGUAGE (object)); + + language = GS_LANGUAGE (object); + + g_hash_table_unref (language->hash); + + G_OBJECT_CLASS (gs_language_parent_class)->finalize (object); +} + +static void +gs_language_init (GsLanguage *language) +{ + language->hash = g_hash_table_new_full (g_str_hash, g_str_equal, (GDestroyNotify) g_free, (GDestroyNotify) g_free); +} + +static void +gs_language_class_init (GsLanguageClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + object_class->finalize = gs_language_finalize; +} + +/** + * gs_language_new: + * + * Return value: a new GsLanguage object. + **/ +GsLanguage * +gs_language_new (void) +{ + GsLanguage *language; + language = g_object_new (GS_TYPE_LANGUAGE, NULL); + return GS_LANGUAGE (language); +} diff --git a/src/gs-language.h b/src/gs-language.h new file mode 100644 index 0000000..04688f8 --- /dev/null +++ b/src/gs-language.h @@ -0,0 +1,26 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2008 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2015 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <glib-object.h> + +G_BEGIN_DECLS + +#define GS_TYPE_LANGUAGE (gs_language_get_type ()) + +G_DECLARE_FINAL_TYPE (GsLanguage, gs_language, GS, LANGUAGE, GObject) + +GsLanguage *gs_language_new (void); +gboolean gs_language_populate (GsLanguage *language, + GError **error); +gchar *gs_language_iso639_to_language (GsLanguage *language, + const gchar *iso639); + +G_END_DECLS diff --git a/src/gs-layout-manager.c b/src/gs-layout-manager.c new file mode 100644 index 0000000..37fcf49 --- /dev/null +++ b/src/gs-layout-manager.c @@ -0,0 +1,110 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2021 Endless OS Foundation LLC + * + * Author: Georges Basile Stavracas Neto <georges.stavracas@gmail.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include "gs-layout-manager.h" + +/* + * The GsLayoutManager is a copy of the GtkBoxLayout, only + * declared as a derivable class, to avoid code duplication. + */ + +G_DEFINE_TYPE (GsLayoutManager, gs_layout_manager, GTK_TYPE_LAYOUT_MANAGER) + +static void +gs_layout_manager_measure (GtkLayoutManager *layout_manager, + GtkWidget *widget, + GtkOrientation orientation, + gint for_size, + gint *minimum, + gint *natural, + gint *minimum_baseline, + gint *natural_baseline) +{ + GtkWidget *child; + gint min = 0; + gint nat = 0; + + for (child = gtk_widget_get_first_child (widget); + child != NULL; + child = gtk_widget_get_next_sibling (child)) { + gint child_min_baseline = -1; + gint child_nat_baseline = -1; + gint child_min = 0; + gint child_nat = 0; + + if (!gtk_widget_should_layout (child)) + continue; + + gtk_widget_measure (child, orientation, + for_size, + &child_min, &child_nat, + &child_min_baseline, + &child_nat_baseline); + + min = MAX (min, child_min); + nat = MAX (nat, child_nat); + + if (child_min_baseline > -1) + *minimum_baseline = MAX (*minimum_baseline, child_min_baseline); + if (child_nat_baseline > -1) + *natural_baseline = MAX (*natural_baseline, child_nat_baseline); + } + + *minimum = min; + *natural = nat; +} + +static void +gs_layout_manager_allocate (GtkLayoutManager *layout_manager, + GtkWidget *widget, + gint width, + gint height, + gint baseline) +{ + GtkWidget *child; + + for (child = gtk_widget_get_first_child (widget); + child != NULL; + child = gtk_widget_get_next_sibling (child)) { + if (child && gtk_widget_should_layout (child)) + gtk_widget_allocate (child, width, height, baseline, NULL); + } +} + +static void +gs_layout_manager_class_init (GsLayoutManagerClass *klass) +{ + GtkLayoutManagerClass *layout_manager_class = GTK_LAYOUT_MANAGER_CLASS (klass); + + layout_manager_class->measure = gs_layout_manager_measure; + layout_manager_class->allocate = gs_layout_manager_allocate; +} + +static void +gs_layout_manager_init (GsLayoutManager *self) +{ +} + +/** + * gs_layout_manager_new: + * + * Create a new #GsLayoutManager. + * + * Returns: (transfer full): a new #GsLayoutManager + * + * Since: 43 + **/ +GtkLayoutManager * +gs_layout_manager_new (void) +{ + return g_object_new (GS_TYPE_LAYOUT_MANAGER, NULL); +} diff --git a/src/gs-layout-manager.h b/src/gs-layout-manager.h new file mode 100644 index 0000000..bbb661f --- /dev/null +++ b/src/gs-layout-manager.h @@ -0,0 +1,29 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2021 Endless OS Foundation LLC + * + * Author: Georges Basile Stavracas Neto <georges.stavracas@gmail.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <glib.h> +#include <glib-object.h> +#include <gtk/gtk.h> + +G_BEGIN_DECLS + +#define GS_TYPE_LAYOUT_MANAGER (gs_layout_manager_get_type ()) +G_DECLARE_DERIVABLE_TYPE (GsLayoutManager, gs_layout_manager, GS, LAYOUT_MANAGER, GtkLayoutManager) + +struct _GsLayoutManagerClass { + GtkLayoutManagerClass parent_class; +}; + +GtkLayoutManager * + gs_layout_manager_new (void); + +G_END_DECLS diff --git a/src/gs-license-tile.c b/src/gs-license-tile.c new file mode 100644 index 0000000..ffac3ba --- /dev/null +++ b/src/gs-license-tile.c @@ -0,0 +1,334 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2021 Endless OS Foundation LLC + * + * Author: Philip Withnall <pwithnall@endlessos.org> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +/** + * SECTION:gs-license-tile + * @short_description: A tile for displaying license information about an app + * + * #GsLicenseTile is a tile which displays high-level license information about + * an app. Broadly, whether it is FOSS or proprietary. + * + * It checks the license information in the provided #GsApp. If + * #GsLicenseTile:app is %NULL, the behaviour of the widget is undefined. + * + * Since: 41 + */ + +#include "config.h" + +#include <glib.h> +#include <glib-object.h> +#include <glib/gi18n.h> +#include <gtk/gtk.h> + +#include "gs-common.h" +#include "gs-license-tile.h" +#include "gs-lozenge.h" + +struct _GsLicenseTile +{ + GtkWidget parent_instance; + + GsApp *app; /* (nullable) (owned) */ + gulong notify_license_handler; + gulong notify_urls_handler; + + GtkWidget *lozenges[3]; + GtkLabel *title_label; + GtkLabel *description_label; + GtkLabel *get_involved_label; +}; + +G_DEFINE_TYPE (GsLicenseTile, gs_license_tile, GTK_TYPE_WIDGET) + +typedef enum { + PROP_APP = 1, +} GsLicenseTileProperty; + +static GParamSpec *obj_props[PROP_APP + 1] = { NULL, }; + +typedef enum { + SIGNAL_GET_INVOLVED_ACTIVATED, +} GsFeaturedCarouselSignal; + +static guint obj_signals[SIGNAL_GET_INVOLVED_ACTIVATED + 1] = { 0, }; + +static void +gs_license_tile_init (GsLicenseTile *self) +{ + gtk_widget_init_template (GTK_WIDGET (self)); +} + +static void +gs_license_tile_row_activated_cb (GtkListBox *box, + GtkListBoxRow *row, + gpointer user_data) +{ + GsLicenseTile *self = GS_LICENSE_TILE (user_data); + + /* The ‘Get Involved’ row is the only activatable one */ + g_signal_emit (self, obj_signals[SIGNAL_GET_INVOLVED_ACTIVATED], 0); +} + +static void +gs_license_tile_refresh (GsLicenseTile *self) +{ + const gchar *title, *css_class; + const gchar *lozenge_icon_names[3]; + g_autofree gchar *description = NULL; + gboolean get_involved_visible; + const gchar *get_involved_label; + + /* Widget behaviour is undefined if the app is unspecified. */ + if (self->app == NULL) + return; + + if (gs_app_get_license_is_free (self->app)) { + const gchar *license_spdx, *license_url; + + title = _("Community Built"); + css_class = "green"; + lozenge_icon_names[0] = "heart-filled-symbolic"; + lozenge_icon_names[1] = "community-symbolic"; + lozenge_icon_names[2] = "sign-language-symbolic"; +#if AS_CHECK_VERSION(0, 15, 3) + get_involved_visible = (gs_app_get_url (self->app, AS_URL_KIND_HOMEPAGE) != NULL || + gs_app_get_url (self->app, AS_URL_KIND_CONTRIBUTE) != NULL); +#else + get_involved_visible = (gs_app_get_url (self->app, AS_URL_KIND_HOMEPAGE) != NULL); +#endif + get_involved_label = _("_Get Involved"); + + license_spdx = gs_app_get_license (self->app); + license_url = as_get_license_url (license_spdx); + + if (license_url != NULL) { + /* Translators: The first placeholder here is a link to information about the license, and the second placeholder here is the name of a software license. */ + description = g_strdup_printf (_("This software is developed in the open by a community of volunteers, and released under the <a href=\"%s\">%s license</a>." + "\n\n" + "You can contribute and help make it even better."), + license_url, + license_spdx); + } else { + /* Translators: The placeholder here is the name of a software license. */ + description = g_strdup_printf (_("This software is developed in the open by a community of volunteers, and released under the %s license." + "\n\n" + "You can contribute and help make it even better."), + license_spdx); + } + } else { + title = _("Proprietary"); + css_class = "yellow"; + lozenge_icon_names[0] = "hand-open-symbolic"; + lozenge_icon_names[1] = "dialog-warning-symbolic"; + lozenge_icon_names[2] = "community-none-symbolic"; + get_involved_visible = TRUE; + get_involved_label = _("_Learn More"); + + description = g_strdup (_("This software is not developed in the open, so only its developers know how it works. It may be insecure in ways that are hard to detect, and it may change without oversight." + "\n\n" + "You may not be able to contribute to this software.")); + } + + for (gsize i = 0; i < G_N_ELEMENTS (self->lozenges); i++) { + GtkStyleContext *context = gtk_widget_get_style_context (self->lozenges[i]); + gtk_style_context_remove_class (context, "green"); + gtk_style_context_remove_class (context, "yellow"); + gtk_style_context_add_class (context, css_class); + gs_lozenge_set_icon_name (GS_LOZENGE (self->lozenges[i]), lozenge_icon_names[i]); + } + + gtk_label_set_label (self->title_label, title); + gtk_label_set_label (self->description_label, description); + gtk_widget_set_visible (GTK_WIDGET (self->get_involved_label), get_involved_visible); + gtk_label_set_label (self->get_involved_label, get_involved_label); +} + +static void +notify_license_or_urls_cb (GObject *object, + GParamSpec *pspec, + gpointer user_data) +{ + gs_license_tile_refresh (GS_LICENSE_TILE (user_data)); +} + +static void +gs_license_tile_get_property (GObject *object, + guint prop_id, + GValue *value, + GParamSpec *pspec) +{ + GsLicenseTile *self = GS_LICENSE_TILE (object); + + switch ((GsLicenseTileProperty) prop_id) { + case PROP_APP: + g_value_set_object (value, gs_license_tile_get_app (self)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_license_tile_set_property (GObject *object, + guint prop_id, + const GValue *value, + GParamSpec *pspec) +{ + GsLicenseTile *self = GS_LICENSE_TILE (object); + + switch ((GsLicenseTileProperty) prop_id) { + case PROP_APP: + gs_license_tile_set_app (self, g_value_get_object (value)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_license_tile_dispose (GObject *object) +{ + GsLicenseTile *self = GS_LICENSE_TILE (object); + + gs_license_tile_set_app (self, NULL); + gs_widget_remove_all (GTK_WIDGET (self), NULL); + + G_OBJECT_CLASS (gs_license_tile_parent_class)->dispose (object); +} + +static void +gs_license_tile_class_init (GsLicenseTileClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + + object_class->get_property = gs_license_tile_get_property; + object_class->set_property = gs_license_tile_set_property; + object_class->dispose = gs_license_tile_dispose; + + /** + * GsLicenseTile:app: (nullable) + * + * Application to display license information for. + * + * If this is %NULL, the state of the widget is undefined. + * + * Since: 41 + */ + obj_props[PROP_APP] = + g_param_spec_object ("app", NULL, NULL, + GS_TYPE_APP, + G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS); + + g_object_class_install_properties (object_class, G_N_ELEMENTS (obj_props), obj_props); + + /** + * GsLicenseTile::get-involved-activated: + * + * Emitted when the ‘Get Involved’ button is clicked, for a #GsApp which + * is FOSS licensed. + * + * Typically the caller should open the app’s ‘get involved’ link or + * homepage when this signal is emitted. + * + * Since: 41 + */ + obj_signals[SIGNAL_GET_INVOLVED_ACTIVATED] = + g_signal_new ("get-involved-activated", + G_TYPE_FROM_CLASS (object_class), G_SIGNAL_RUN_LAST, + 0, NULL, NULL, g_cclosure_marshal_VOID__VOID, + G_TYPE_NONE, 0); + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-license-tile.ui"); + + gtk_widget_class_bind_template_child_full (widget_class, "lozenge0", FALSE, G_STRUCT_OFFSET (GsLicenseTile, lozenges[0])); + gtk_widget_class_bind_template_child_full (widget_class, "lozenge1", FALSE, G_STRUCT_OFFSET (GsLicenseTile, lozenges[1])); + gtk_widget_class_bind_template_child_full (widget_class, "lozenge2", FALSE, G_STRUCT_OFFSET (GsLicenseTile, lozenges[2])); + gtk_widget_class_bind_template_child (widget_class, GsLicenseTile, title_label); + gtk_widget_class_bind_template_child (widget_class, GsLicenseTile, description_label); + gtk_widget_class_bind_template_child (widget_class, GsLicenseTile, get_involved_label); + + gtk_widget_class_bind_template_callback (widget_class, gs_license_tile_row_activated_cb); + + gtk_widget_class_set_layout_manager_type (widget_class, GTK_TYPE_BIN_LAYOUT); +} + +/** + * gs_license_tile_new: + * @app: (nullable) (transfer none): app to display the license information for + * + * Create a new #GsLicenseTile. + * + * Returns: (transfer full) (type GsLicenseTile): a new #GsLicenseTile + * Since: 41 + */ +GtkWidget * +gs_license_tile_new (GsApp *app) +{ + g_return_val_if_fail (app == NULL || GS_IS_APP (app), NULL); + + return g_object_new (GS_TYPE_LICENSE_TILE, + "app", app, + NULL); +} + +/** + * gs_license_tile_get_app: + * @self: a #GsLicenseTile + * + * Get the value of #GsLicenseTile:app. + * + * Returns: (transfer none) (nullable): the app being displayed in the tile + * Since: 41 + */ +GsApp * +gs_license_tile_get_app (GsLicenseTile *self) +{ + g_return_val_if_fail (GS_IS_LICENSE_TILE (self), NULL); + + return self->app; +} + +/** + * gs_license_tile_set_app: + * @self: a #GsLicenseTile + * @app: (nullable) (transfer none): new app to display in the tile + * + * Set the value of #GsLicenseTile:app to @app. + * + * Since: 41 + */ +void +gs_license_tile_set_app (GsLicenseTile *self, + GsApp *app) +{ + g_return_if_fail (GS_IS_LICENSE_TILE (self)); + g_return_if_fail (app == NULL || GS_IS_APP (app)); + + if (self->app == app) + return; + + g_clear_signal_handler (&self->notify_license_handler, self->app); + g_clear_signal_handler (&self->notify_urls_handler, self->app); + + g_set_object (&self->app, app); + + if (self->app != NULL) { + self->notify_license_handler = g_signal_connect (self->app, "notify::license", G_CALLBACK (notify_license_or_urls_cb), self); + self->notify_urls_handler = g_signal_connect (self->app, "notify::urls", G_CALLBACK (notify_license_or_urls_cb), self); + } + + gs_license_tile_refresh (self); + + g_object_notify_by_pspec (G_OBJECT (self), obj_props[PROP_APP]); +} diff --git a/src/gs-license-tile.h b/src/gs-license-tile.h new file mode 100644 index 0000000..11f42e1 --- /dev/null +++ b/src/gs-license-tile.h @@ -0,0 +1,31 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2021 Endless OS Foundation LLC + * + * Author: Philip Withnall <pwithnall@endlessos.org> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <glib.h> +#include <glib-object.h> +#include <gtk/gtk.h> + +#include "gs-app.h" + +G_BEGIN_DECLS + +#define GS_TYPE_LICENSE_TILE (gs_license_tile_get_type ()) + +G_DECLARE_FINAL_TYPE (GsLicenseTile, gs_license_tile, GS, LICENSE_TILE, GtkWidget) + +GtkWidget *gs_license_tile_new (GsApp *app); + +GsApp *gs_license_tile_get_app (GsLicenseTile *self); +void gs_license_tile_set_app (GsLicenseTile *self, + GsApp *app); + +G_END_DECLS diff --git a/src/gs-license-tile.ui b/src/gs-license-tile.ui new file mode 100644 index 0000000..e69581e --- /dev/null +++ b/src/gs-license-tile.ui @@ -0,0 +1,115 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <requires lib="gtk+" version="3.10"/> + <template class="GsLicenseTile" parent="GtkWidget"> + <child> + <object class="GtkListBox"> + <property name="selection_mode">none</property> + <property name="valign">start</property> + <signal name="row-activated" handler="gs_license_tile_row_activated_cb"/> + <style> + <class name="boxed-list"/> + </style> + + <child> + <object class="GtkListBoxRow"> + <property name="activatable">False</property> + <property name="focusable">False</property> + <child> + <object class="GtkBox"> + <property name="orientation">vertical</property> + <property name="spacing">8</property> + <property name="margin-top">14</property> + <property name="margin-bottom">14</property> + <property name="margin-start">14</property> + <property name="margin-end">14</property> + + <child> + <object class="GtkBox"> + <property name="halign">center</property> + <property name="orientation">horizontal</property> + <property name="spacing">8</property> + + <child> + <object class="GsLozenge" id="lozenge0"> + <property name="circular">True</property> + <property name="icon-name">heart-filled-symbolic</property> + <style> + <class name="green"/> + </style> + </object> + </child> + <child> + <object class="GsLozenge" id="lozenge1"> + <property name="circular">True</property> + <property name="icon-name">community-symbolic</property> + <style> + <class name="green"/> + </style> + </object> + </child> + <child> + <object class="GsLozenge" id="lozenge2"> + <property name="circular">True</property> + <property name="icon-name">sign-language-symbolic</property> + <style> + <class name="green"/> + </style> + </object> + </child> + </object> + </child> + <child> + <object class="GtkLabel" id="title_label"> + <!-- This text is a placeholder and will be set dynamically --> + <property name="label">Community Built</property> + <attributes> + <attribute name="weight" value="bold"/> + </attributes> + </object> + </child> + <child> + <object class="GtkLabel" id="description_label"> + <!-- This text is a placeholder and will be set dynamically --> + <property name="label">This software is developed in the open by a community of volunteers, and released under the GNU GPL v3 license.\n\nYou can contribute and help make it even better.</property> + <property name="use-markup">True</property> + <property name="wrap">True</property> + </object> + </child> + </object> + </child> + </object> + </child> + + <child> + <object class="GtkListBoxRow"> + <property name="activatable">True</property> + <property name="visible" bind-source="get_involved_label" bind-property="visible" bind-flags="sync-create"/> + <child> + <object class="GtkBox"> + <property name="orientation">horizontal</property> + <property name="halign">center</property> + <property name="margin-top">12</property> + <property name="margin-bottom">12</property> + <child> + <object class="GtkLabel" id="get_involved_label"> + <property name="xalign">0</property> + <property name="yalign">0.5</property> + <property name="label" translatable="yes">_Get Involved</property> + <property name="use-underline">True</property> + </object> + </child> + <child> + <object class="GtkImage"> + <property name="icon-name">external-link-symbolic</property> + <property name="margin-start">6</property> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + </template> +</interface> diff --git a/src/gs-loading-page.c b/src/gs-loading-page.c new file mode 100644 index 0000000..517d13a --- /dev/null +++ b/src/gs-loading-page.c @@ -0,0 +1,215 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2016 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2017 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include <glib/gi18n.h> + +#include "gs-shell.h" +#include "gs-loading-page.h" + +typedef struct { + GsPluginLoader *plugin_loader; + GCancellable *cancellable; + GsShell *shell; + + GtkWidget *progressbar; + GtkWidget *status_page; + guint progress_pulse_id; +} GsLoadingPagePrivate; + +G_DEFINE_TYPE_WITH_PRIVATE (GsLoadingPage, gs_loading_page, GS_TYPE_PAGE) + +enum { + SIGNAL_REFRESHED, + SIGNAL_LAST +}; + +static guint signals [SIGNAL_LAST] = { 0 }; + +static gboolean +_pulse_cb (gpointer user_data) +{ + GsLoadingPage *self = GS_LOADING_PAGE (user_data); + GsLoadingPagePrivate *priv = gs_loading_page_get_instance_private (self); + gtk_progress_bar_pulse (GTK_PROGRESS_BAR (priv->progressbar)); + return TRUE; +} + +static void +gs_loading_page_job_progress_cb (GsPluginJobRefreshMetadata *plugin_job, + guint progress_percent, + gpointer user_data) +{ + GsLoadingPage *self = GS_LOADING_PAGE (user_data); + GsLoadingPagePrivate *priv = gs_loading_page_get_instance_private (self); + + /* update title */ + adw_status_page_set_title (ADW_STATUS_PAGE (priv->status_page), + /* TRANSLATORS: initial start */ + _("Downloading software catalog")); + + /* update progressbar */ + if (priv->progress_pulse_id != 0) { + g_source_remove (priv->progress_pulse_id); + priv->progress_pulse_id = 0; + } + + if (progress_percent == G_MAXUINT) { + priv->progress_pulse_id = g_timeout_add (50, _pulse_cb, self); + } else { + gtk_progress_bar_set_fraction (GTK_PROGRESS_BAR (priv->progressbar), + (gdouble) progress_percent / 100.0f); + } +} + +static void +gs_loading_page_refresh_cb (GObject *source_object, GAsyncResult *res, gpointer user_data) +{ + GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (source_object); + GsLoadingPage *self = GS_LOADING_PAGE (user_data); + GsLoadingPagePrivate *priv = gs_loading_page_get_instance_private (self); + g_autoptr(GError) error = NULL; + + /* no longer care */ + g_signal_handlers_disconnect_by_data (plugin_loader, self); + + /* not sure how to handle this */ + if (!gs_plugin_loader_job_action_finish (plugin_loader, res, &error)) { + g_warning ("failed to load metadata: %s", error->message); + } + + /* no more pulsing */ + if (priv->progress_pulse_id != 0) { + g_source_remove (priv->progress_pulse_id); + priv->progress_pulse_id = 0; + } + + /* UI is good to go */ + g_signal_emit (self, signals[SIGNAL_REFRESHED], 0); +} + +static void +gs_loading_page_load (GsLoadingPage *self) +{ + GsLoadingPagePrivate *priv = gs_loading_page_get_instance_private (self); + g_autoptr(GsPluginJob) plugin_job = NULL; + g_autoptr(GSettings) settings = NULL; + guint64 cache_age_secs; + + /* Ensure that at least some metadata of any age is present, and also + * spin up the plugins enough as to prime caches. If this is the first + * run of gnome-software, set the cache age to 24h to ensure that the + * metadata is refreshed if, for example, this is the first boot of a + * computer which has been in storage (after manufacture) for a while. + * Otherwise, set the cache age to the maximum, to only refresh if we’re + * completely missing app data — otherwise, we want to start up as fast + * as possible. */ + settings = g_settings_new ("org.gnome.software"); + if (g_settings_get_boolean (settings, "first-run")) { + g_settings_set_boolean (settings, "first-run", FALSE); + cache_age_secs = 60 * 60 * 24; /* 24 hours */ + } else + cache_age_secs = G_MAXUINT64; + + plugin_job = gs_plugin_job_refresh_metadata_new (cache_age_secs, + GS_PLUGIN_REFRESH_METADATA_FLAGS_NONE); + g_signal_connect (plugin_job, "progress", G_CALLBACK (gs_loading_page_job_progress_cb), self); + gs_plugin_loader_job_process_async (priv->plugin_loader, plugin_job, + priv->cancellable, + gs_loading_page_refresh_cb, + self); +} + +static void +gs_loading_page_switch_to (GsPage *page) +{ + GsLoadingPage *self = GS_LOADING_PAGE (page); + GsLoadingPagePrivate *priv = gs_loading_page_get_instance_private (self); + + if (gs_shell_get_mode (priv->shell) != GS_SHELL_MODE_LOADING) { + g_warning ("Called switch_to(loading) when in mode %s", + gs_shell_get_mode_string (priv->shell)); + return; + } + gs_loading_page_load (self); +} + +static gboolean +gs_loading_page_setup (GsPage *page, + GsShell *shell, + GsPluginLoader *plugin_loader, + GCancellable *cancellable, + GError **error) +{ + GsLoadingPage *self = GS_LOADING_PAGE (page); + GsLoadingPagePrivate *priv = gs_loading_page_get_instance_private (self); + + g_return_val_if_fail (GS_IS_LOADING_PAGE (self), TRUE); + + priv->shell = shell; + priv->plugin_loader = g_object_ref (plugin_loader); + priv->cancellable = g_object_ref (cancellable); + return TRUE; +} + +static void +gs_loading_page_dispose (GObject *object) +{ + GsLoadingPage *self = GS_LOADING_PAGE (object); + GsLoadingPagePrivate *priv = gs_loading_page_get_instance_private (self); + + if (priv->progress_pulse_id != 0) { + g_source_remove (priv->progress_pulse_id); + priv->progress_pulse_id = 0; + } + + g_clear_object (&priv->plugin_loader); + g_clear_object (&priv->cancellable); + + G_OBJECT_CLASS (gs_loading_page_parent_class)->dispose (object); +} + +static void +gs_loading_page_class_init (GsLoadingPageClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + GsPageClass *page_class = GS_PAGE_CLASS (klass); + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + + object_class->dispose = gs_loading_page_dispose; + page_class->switch_to = gs_loading_page_switch_to; + page_class->setup = gs_loading_page_setup; + + signals [SIGNAL_REFRESHED] = + g_signal_new ("refreshed", + G_TYPE_FROM_CLASS (object_class), G_SIGNAL_RUN_LAST, + G_STRUCT_OFFSET (GsLoadingPageClass, refreshed), + NULL, NULL, g_cclosure_marshal_VOID__VOID, + G_TYPE_NONE, 0); + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-loading-page.ui"); + + gtk_widget_class_bind_template_child_private (widget_class, GsLoadingPage, progressbar); + gtk_widget_class_bind_template_child_private (widget_class, GsLoadingPage, status_page); +} + +static void +gs_loading_page_init (GsLoadingPage *self) +{ + gtk_widget_init_template (GTK_WIDGET (self)); +} + +GsLoadingPage * +gs_loading_page_new (void) +{ + GsLoadingPage *self; + self = g_object_new (GS_TYPE_LOADING_PAGE, NULL); + return GS_LOADING_PAGE (self); +} diff --git a/src/gs-loading-page.h b/src/gs-loading-page.h new file mode 100644 index 0000000..7add3d9 --- /dev/null +++ b/src/gs-loading-page.h @@ -0,0 +1,29 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2016 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2017 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include "gs-page.h" + +G_BEGIN_DECLS + +#define GS_TYPE_LOADING_PAGE (gs_loading_page_get_type ()) + +G_DECLARE_DERIVABLE_TYPE (GsLoadingPage, gs_loading_page, GS, LOADING_PAGE, GsPage) + +struct _GsLoadingPageClass +{ + GsPageClass parent_class; + + void (*refreshed) (GsLoadingPage *self); +}; + +GsLoadingPage *gs_loading_page_new (void); + +G_END_DECLS diff --git a/src/gs-loading-page.ui b/src/gs-loading-page.ui new file mode 100644 index 0000000..f2ea1cf --- /dev/null +++ b/src/gs-loading-page.ui @@ -0,0 +1,32 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <requires lib="gtk+" version="3.10"/> + <template class="GsLoadingPage" parent="GsPage"> + <accessibility> + <property name="label" translatable="yes">Loading page</property> + </accessibility> + <child> + <object class="AdwStatusPage" id="status_page"> + <property name="icon_name">org.gnome.Software</property> + <property name="title" translatable="yes">Starting up…</property> + <style> + <class name="icon-dropshadow"/> + </style> + <child> + <object class="AdwClamp"> + <property name="maximum-size">400</property> + <child> + <object class="GtkProgressBar" id="progressbar"> + <property name="fraction">0.0</property> + <property name="margin_bottom">12</property> + <style> + <class name="upgrade-progressbar"/> + </style> + </object> + </child> + </object> + </child> + </object> + </child> + </template> +</interface> diff --git a/src/gs-lozenge.c b/src/gs-lozenge.c new file mode 100644 index 0000000..6e7cff2 --- /dev/null +++ b/src/gs-lozenge.c @@ -0,0 +1,470 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2022 Red Hat (www.redhat.com) + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include "gs-lozenge.h" +#include "gs-layout-manager.h" + +#define GS_TYPE_LOZENGE_LAYOUT (gs_lozenge_layout_get_type ()) +G_DECLARE_FINAL_TYPE (GsLozengeLayout, gs_lozenge_layout, GS, LOZENGE_LAYOUT, GsLayoutManager) + +struct _GsLozengeLayout +{ + GsLayoutManager parent_instance; + + gboolean circular; +}; + +G_DEFINE_TYPE (GsLozengeLayout, gs_lozenge_layout, GS_TYPE_LAYOUT_MANAGER) + +static void +gs_lozenge_layout_measure (GtkLayoutManager *layout_manager, + GtkWidget *widget, + GtkOrientation orientation, + gint for_size, + gint *minimum, + gint *natural, + gint *minimum_baseline, + gint *natural_baseline) +{ + GsLozengeLayout *self = GS_LOZENGE_LAYOUT (layout_manager); + + GTK_LAYOUT_MANAGER_CLASS (gs_lozenge_layout_parent_class)->measure (layout_manager, + widget, orientation, for_size, minimum, natural, minimum_baseline, natural_baseline); + + if (self->circular) { + *minimum = MAX (for_size, *minimum); + *natural = *minimum; + *natural_baseline = *minimum_baseline; + } + + if (*natural_baseline > *natural) + *natural_baseline = *natural; + if (*minimum_baseline > *minimum) + *minimum_baseline = *minimum; +} + +static void +gs_lozenge_layout_class_init (GsLozengeLayoutClass *klass) +{ + GtkLayoutManagerClass *layout_manager_class = GTK_LAYOUT_MANAGER_CLASS (klass); + + layout_manager_class->measure = gs_lozenge_layout_measure; +} + +static void +gs_lozenge_layout_init (GsLozengeLayout *self) +{ +} + +/* ********************************************************************* */ + +struct _GsLozenge +{ + GtkBox parent_instance; + + GtkWidget *image; /* (unowned) */ + GtkWidget *label; /* (unowned) */ + + gchar *icon_name; + gchar *text; + gchar *markup; + gboolean circular; + gint pixel_size; +}; + +G_DEFINE_TYPE (GsLozenge, gs_lozenge, GTK_TYPE_BOX) + +typedef enum { + PROP_CIRCULAR = 1, + PROP_ICON_NAME, + PROP_PIXEL_SIZE, + PROP_TEXT, + PROP_MARKUP +} GsLozengeProperty; + +static GParamSpec *obj_props[PROP_MARKUP + 1] = { NULL, }; + +static void +gs_lozenge_get_property (GObject *object, + guint prop_id, + GValue *value, + GParamSpec *pspec) +{ + GsLozenge *self = GS_LOZENGE (object); + + switch ((GsLozengeProperty) prop_id) { + case PROP_CIRCULAR: + g_value_set_boolean (value, gs_lozenge_get_circular (self)); + break; + case PROP_ICON_NAME: + g_value_set_string (value, gs_lozenge_get_icon_name (self)); + break; + case PROP_PIXEL_SIZE: + g_value_set_int (value, gs_lozenge_get_pixel_size (self)); + break; + case PROP_TEXT: + g_value_set_string (value, gs_lozenge_get_text (self)); + break; + case PROP_MARKUP: + g_value_set_string (value, gs_lozenge_get_markup (self)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_lozenge_set_property (GObject *object, + guint prop_id, + const GValue *value, + GParamSpec *pspec) +{ + GsLozenge *self = GS_LOZENGE (object); + + switch ((GsLozengeProperty) prop_id) { + case PROP_CIRCULAR: + gs_lozenge_set_circular (self, g_value_get_boolean (value)); + break; + case PROP_ICON_NAME: + gs_lozenge_set_icon_name (self, g_value_get_string (value)); + break; + case PROP_PIXEL_SIZE: + gs_lozenge_set_pixel_size (self, g_value_get_int (value)); + break; + case PROP_TEXT: + gs_lozenge_set_text (self, g_value_get_string (value)); + break; + case PROP_MARKUP: + gs_lozenge_set_markup (self, g_value_get_string (value)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_lozenge_dispose (GObject *object) +{ + GsLozenge *self = GS_LOZENGE (object); + + g_clear_pointer (&self->icon_name, g_free); + g_clear_pointer (&self->text, g_free); + g_clear_pointer (&self->markup, g_free); + + G_OBJECT_CLASS (gs_lozenge_parent_class)->dispose (object); +} + +static void +gs_lozenge_class_init (GsLozengeClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + + object_class->get_property = gs_lozenge_get_property; + object_class->set_property = gs_lozenge_set_property; + object_class->dispose = gs_lozenge_dispose; + + /** + * GsLozenge:circular: + * + * Whether the lozenge should be circular/square widget. + * + * Since: 43 + */ + obj_props[PROP_CIRCULAR] = + g_param_spec_boolean ("circular", NULL, NULL, + FALSE, + G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS); + + /** + * GsLozenge:icon-name: + * + * An icon name for the lozenge. Setting this property turns + * the lozenge into the icon mode, which mean showing the icon, + * not the markup. + * + * Since: 43 + */ + obj_props[PROP_ICON_NAME] = + g_param_spec_string ("icon-name", NULL, NULL, + NULL, + G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS); + + /** + * GsLozenge:pixel-size: + * + * An icon pixel size for the lozenge. + * + * Since: 43 + */ + obj_props[PROP_PIXEL_SIZE] = + g_param_spec_int ("pixel-size", NULL, NULL, + 0, G_MAXINT, 16, + G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS); + + /** + * GsLozenge:text: + * + * A plain text for the lozenge. Setting this property turns + * the lozenge into the text mode, which mean showing the text, + * not the icon. + * + * Since: 43 + */ + obj_props[PROP_TEXT] = + g_param_spec_string ("text", NULL, NULL, + NULL, + G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS); + + /** + * GsLozenge:markup: + * + * A markup text for the lozenge. Setting this property turns + * the lozenge into the text mode, which mean showing the markup, + * not the icon. + * + * Since: 43 + */ + obj_props[PROP_MARKUP] = + g_param_spec_string ("markup", NULL, NULL, + NULL, + G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS); + + g_object_class_install_properties (object_class, G_N_ELEMENTS (obj_props), obj_props); + + gtk_widget_class_set_layout_manager_type (widget_class, GS_TYPE_LOZENGE_LAYOUT); + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-lozenge.ui"); + + gtk_widget_class_bind_template_child (widget_class, GsLozenge, image); + gtk_widget_class_bind_template_child (widget_class, GsLozenge, label); +} + +static void +gs_lozenge_init (GsLozenge *self) +{ + gtk_widget_init_template (GTK_WIDGET (self)); + + self->pixel_size = 16; +} + +/** + * gs_lozenge_new: + * + * Returns: (transfer full): a new #GsLozenge + * + * Since: 43 + **/ +GtkWidget * +gs_lozenge_new (void) +{ + return g_object_new (GS_TYPE_LOZENGE, NULL); +} + +const gchar * +gs_lozenge_get_icon_name (GsLozenge *self) +{ + g_return_val_if_fail (GS_IS_LOZENGE (self), NULL); + + return self->icon_name; +} + +gboolean +gs_lozenge_get_circular (GsLozenge *self) +{ + g_return_val_if_fail (GS_IS_LOZENGE (self), FALSE); + + return self->circular; +} + +void +gs_lozenge_set_circular (GsLozenge *self, + gboolean value) +{ + GtkLayoutManager *layout_manager; + + g_return_if_fail (GS_IS_LOZENGE (self)); + + if ((!self->circular) == (!value)) + return; + + self->circular = value; + + layout_manager = gtk_widget_get_layout_manager (GTK_WIDGET (self)); + GS_LOZENGE_LAYOUT (layout_manager)->circular = self->circular; + gtk_layout_manager_layout_changed (layout_manager); + + g_object_notify_by_pspec (G_OBJECT (self), obj_props[PROP_CIRCULAR]); +} + +void +gs_lozenge_set_icon_name (GsLozenge *self, + const gchar *value) +{ + g_return_if_fail (GS_IS_LOZENGE (self)); + + if (value != NULL && *value == '\0') + value = NULL; + + if (g_strcmp0 (self->icon_name, value) == 0) + return; + + g_clear_pointer (&self->icon_name, g_free); + self->icon_name = g_strdup (value); + + if (self->icon_name == NULL) { + gtk_widget_hide (self->image); + gtk_widget_show (self->label); + } else { + gtk_image_set_from_icon_name (GTK_IMAGE (self->image), self->icon_name); + gtk_widget_hide (self->label); + gtk_widget_show (self->image); + } + + /* Clean up the other properties before notifying of the changed property name */ + + if (self->text != NULL) { + g_clear_pointer (&self->text, g_free); + g_object_notify_by_pspec (G_OBJECT (self), obj_props[PROP_TEXT]); + } + + if (self->markup != NULL) { + g_clear_pointer (&self->markup, g_free); + g_object_notify_by_pspec (G_OBJECT (self), obj_props[PROP_MARKUP]); + } + + g_object_notify_by_pspec (G_OBJECT (self), obj_props[PROP_ICON_NAME]); +} + +gint +gs_lozenge_get_pixel_size (GsLozenge *self) +{ + g_return_val_if_fail (GS_IS_LOZENGE (self), 0); + + return self->pixel_size; +} + +void +gs_lozenge_set_pixel_size (GsLozenge *self, + gint value) +{ + g_return_if_fail (GS_IS_LOZENGE (self)); + + if (self->pixel_size == value) + return; + + self->pixel_size = value; + + gtk_image_set_pixel_size (GTK_IMAGE (self->image), self->pixel_size); + + g_object_notify_by_pspec (G_OBJECT (self), obj_props[PROP_PIXEL_SIZE]); +} + +gboolean +gs_lozenge_get_use_markup (GsLozenge *self) +{ + g_return_val_if_fail (GS_IS_LOZENGE (self), FALSE); + return gtk_label_get_use_markup (GTK_LABEL (self->label)); +} + +const gchar * +gs_lozenge_get_text (GsLozenge *self) +{ + g_return_val_if_fail (GS_IS_LOZENGE (self), NULL); + + return self->text; +} + +void +gs_lozenge_set_text (GsLozenge *self, + const gchar *value) +{ + g_return_if_fail (GS_IS_LOZENGE (self)); + + if (value != NULL && *value == '\0') + value = NULL; + + if (g_strcmp0 (self->text, value) == 0) + return; + + g_clear_pointer (&self->text, g_free); + self->text = g_strdup (value); + + if (self->text == NULL) { + gtk_widget_hide (self->label); + gtk_widget_show (self->image); + } else { + gtk_label_set_text (GTK_LABEL (self->label), self->text); + gtk_widget_hide (self->image); + gtk_widget_show (self->label); + } + + /* Clean up the other properties before notifying of the changed property name */ + + if (self->icon_name != NULL) { + g_clear_pointer (&self->icon_name, g_free); + g_object_notify_by_pspec (G_OBJECT (self), obj_props[PROP_ICON_NAME]); + } + + if (self->markup != NULL) { + g_clear_pointer (&self->markup, g_free); + g_object_notify_by_pspec (G_OBJECT (self), obj_props[PROP_MARKUP]); + } + + g_object_notify_by_pspec (G_OBJECT (self), obj_props[PROP_TEXT]); +} + +const gchar * +gs_lozenge_get_markup (GsLozenge *self) +{ + g_return_val_if_fail (GS_IS_LOZENGE (self), NULL); + + return self->markup; +} + +void +gs_lozenge_set_markup (GsLozenge *self, + const gchar *value) +{ + g_return_if_fail (GS_IS_LOZENGE (self)); + + if (value != NULL && *value == '\0') + value = NULL; + + if (g_strcmp0 (self->markup, value) == 0) + return; + + g_clear_pointer (&self->markup, g_free); + self->markup = g_strdup (value); + + if (self->markup == NULL) { + gtk_widget_hide (self->label); + gtk_widget_show (self->image); + } else { + gtk_label_set_markup (GTK_LABEL (self->label), self->markup); + gtk_widget_hide (self->image); + gtk_widget_show (self->label); + } + + /* Clean up the other properties before notifying of the changed property name */ + + if (self->icon_name != NULL) { + g_clear_pointer (&self->icon_name, g_free); + g_object_notify_by_pspec (G_OBJECT (self), obj_props[PROP_ICON_NAME]); + } + + if (self->text != NULL) { + g_clear_pointer (&self->text, g_free); + g_object_notify_by_pspec (G_OBJECT (self), obj_props[PROP_TEXT]); + } + + g_object_notify_by_pspec (G_OBJECT (self), obj_props[PROP_MARKUP]); +} diff --git a/src/gs-lozenge.h b/src/gs-lozenge.h new file mode 100644 index 0000000..d331976 --- /dev/null +++ b/src/gs-lozenge.h @@ -0,0 +1,38 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2022 Red Hat (www.redhat.com) + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <glib.h> +#include <glib-object.h> +#include <gtk/gtk.h> + +G_BEGIN_DECLS + +#define GS_TYPE_LOZENGE (gs_lozenge_get_type ()) +G_DECLARE_FINAL_TYPE (GsLozenge, gs_lozenge, GS, LOZENGE, GtkBox) + +GtkWidget * gs_lozenge_new (void); +gboolean gs_lozenge_get_circular (GsLozenge *self); +void gs_lozenge_set_circular (GsLozenge *self, + gboolean value); +const gchar * gs_lozenge_get_icon_name (GsLozenge *self); +void gs_lozenge_set_icon_name (GsLozenge *self, + const gchar *value); +gint gs_lozenge_get_pixel_size (GsLozenge *self); +void gs_lozenge_set_pixel_size (GsLozenge *self, + gint value); +gboolean gs_lozenge_get_use_markup (GsLozenge *self); +const gchar * gs_lozenge_get_text (GsLozenge *self); +void gs_lozenge_set_text (GsLozenge *self, + const gchar *value); +const gchar * gs_lozenge_get_markup (GsLozenge *self); +void gs_lozenge_set_markup (GsLozenge *self, + const gchar *value); + +G_END_DECLS diff --git a/src/gs-lozenge.ui b/src/gs-lozenge.ui new file mode 100644 index 0000000..545d803 --- /dev/null +++ b/src/gs-lozenge.ui @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <requires lib="gtk+" version="3.10"/> + <template class="GsLozenge" parent="GtkBox"> + <property name="halign">center</property> + <property name="hexpand">False</property> + <property name="valign">center</property> + <style> + <class name="context-tile-lozenge"/> + </style> + <child> + <object class="GtkImage" id="image"> + <property name="halign">center</property> + <property name="hexpand">True</property> + <!-- this is a placeholder: the icon is actually set in code --> + <property name="icon-name">safety-symbolic</property> + <property name="pixel-size">16</property> + <property name="visible">False</property> + </object> + </child> + <child> + <object class="GtkLabel" id="label"> + <property name="halign">center</property> + <property name="hexpand">True</property> + <!-- this is a placeholder: the text is actually set in code --> + <property name="label">20 MB</property> + <property name="visible">False</property> + <property name="xalign">0.5</property> + <property name="visible">False</property> + </object> + </child> + </template> +</interface> diff --git a/src/gs-main.c b/src/gs-main.c new file mode 100644 index 0000000..03e8c91 --- /dev/null +++ b/src/gs-main.c @@ -0,0 +1,51 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2012-2013 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2013 Matthias Clasen <mclasen@redhat.com> + * Copyright (C) 2015 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include <glib/gi18n.h> +#include <gio/gio.h> +#include <gio/gdesktopappinfo.h> +#include <gtk/gtk.h> +#include <locale.h> +#include <sys/stat.h> + +#include "gs-application.h" +#include "gs-debug.h" + +int +main (int argc, char **argv) +{ + int status = 0; + g_autoptr(GDesktopAppInfo) appinfo = NULL; + g_autoptr(GsApplication) application = NULL; + g_autoptr(GsDebug) debug = gs_debug_new_from_environment (); + + g_set_prgname ("org.gnome.Software"); + setlocale (LC_ALL, ""); + + bindtextdomain (GETTEXT_PACKAGE, LOCALEDIR); + bind_textdomain_codeset (GETTEXT_PACKAGE, "UTF-8"); + textdomain (GETTEXT_PACKAGE); + + /* Override the umask to 022 to make it possible to share files between + * the gnome-software process and flatpak system helper process. + * Ideally this should be set when needed in the flatpak plugin, but + * umask is thread-unsafe so there is really no local way to fix this. + */ + umask (022); + + /* redirect logs */ + application = gs_application_new (debug); + appinfo = g_desktop_app_info_new ("org.gnome.Software.desktop"); + g_set_application_name (g_app_info_get_name (G_APP_INFO (appinfo))); + status = g_application_run (G_APPLICATION (application), argc, argv); + return status; +} diff --git a/src/gs-metered-data-dialog.c b/src/gs-metered-data-dialog.c new file mode 100644 index 0000000..7e8a35b --- /dev/null +++ b/src/gs-metered-data-dialog.c @@ -0,0 +1,67 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright © 2020 Endless Mobile, Inc. + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include <glib.h> +#include <glib-object.h> +#include <gtk/gtk.h> + +#include "gs-metered-data-dialog.h" + +struct _GsMeteredDataDialog +{ + GsInfoWindow parent_instance; + + GtkWidget *button_network_settings; +}; + +G_DEFINE_TYPE (GsMeteredDataDialog, gs_metered_data_dialog, GS_TYPE_INFO_WINDOW) + +static void +button_network_settings_clicked_cb (GtkButton *button, + gpointer user_data) +{ + g_autoptr(GError) error_local = NULL; + + if (!g_spawn_command_line_async ("gnome-control-center wifi", &error_local)) { + g_warning ("Error opening GNOME Control Center: %s", + error_local->message); + return; + } +} + +static void +gs_metered_data_dialog_init (GsMeteredDataDialog *dialog) +{ + gtk_widget_init_template (GTK_WIDGET (dialog)); +} + +static void +gs_metered_data_dialog_class_init (GsMeteredDataDialogClass *klass) +{ + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-metered-data-dialog.ui"); + + gtk_widget_class_bind_template_child (widget_class, GsMeteredDataDialog, button_network_settings); + + gtk_widget_class_bind_template_callback (widget_class, button_network_settings_clicked_cb); +} + +GtkWidget * +gs_metered_data_dialog_new (GtkWindow *parent) +{ + GsMeteredDataDialog *dialog; + + dialog = g_object_new (GS_TYPE_METERED_DATA_DIALOG, + "transient-for", parent, + NULL); + + return GTK_WIDGET (dialog); +} diff --git a/src/gs-metered-data-dialog.h b/src/gs-metered-data-dialog.h new file mode 100644 index 0000000..676783d --- /dev/null +++ b/src/gs-metered-data-dialog.h @@ -0,0 +1,25 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright © 2020 Endless Mobile, Inc. + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <glib.h> +#include <glib-object.h> +#include <gtk/gtk.h> + +#include "gs-info-window.h" + +G_BEGIN_DECLS + +#define GS_TYPE_METERED_DATA_DIALOG (gs_metered_data_dialog_get_type ()) + +G_DECLARE_FINAL_TYPE (GsMeteredDataDialog, gs_metered_data_dialog, GS, METERED_DATA_DIALOG, GsInfoWindow) + +GtkWidget *gs_metered_data_dialog_new (GtkWindow *parent); + +G_END_DECLS diff --git a/src/gs-metered-data-dialog.ui b/src/gs-metered-data-dialog.ui new file mode 100644 index 0000000..02c2bdb --- /dev/null +++ b/src/gs-metered-data-dialog.ui @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <requires lib="gtk+" version="3.10"/> + <template class="GsMeteredDataDialog" parent="GsInfoWindow"> + <property name="title" translatable="yes">Automatic Updates Paused</property> + <child> + <object class="AdwStatusPage"> + <property name="title" bind-source="GsMeteredDataDialog" bind-property="title" bind-flags="sync-create"/> + <property name="description" translatable="yes">The current network is metered. Metered connections have data limits or charges associated with them. To save data, automatic updates have therefore been paused. + +Automatic updates will be resumed when an unmetered network becomes available. Until then, it is still possible to manually install updates. + +Alternatively, if the current network has been incorrectly identified as being metered, this setting can be changed.</property> + <property name="icon-name">network-cellular-signal-excellent-symbolic</property> + <child> + <object class="GtkButton" id="button_network_settings"> + <property name="label" translatable="yes">Open Network _Settings</property> + <property name="halign">center</property> + <property name="receives-default">True</property> + <property name="use-underline">True</property> + <signal name="clicked" handler="button_network_settings_clicked_cb"/> + <style> + <class name="pill"/> + </style> + </object> + </child> + </object> + </child> + </template> +</interface> diff --git a/src/gs-moderate-page.c b/src/gs-moderate-page.c new file mode 100644 index 0000000..5461791 --- /dev/null +++ b/src/gs-moderate-page.c @@ -0,0 +1,471 @@ +/* -*- 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) 2016-2018 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include <glib.h> +#include <glib/gi18n.h> +#include <string.h> + +#include "gs-app-row.h" +#include "gs-review-row.h" +#include "gs-shell.h" +#include "gs-moderate-page.h" +#include "gs-common.h" + +struct _GsModeratePage +{ + GsPage parent_instance; + + GsPluginLoader *plugin_loader; + GCancellable *cancellable; + GtkSizeGroup *sizegroup_name; + GtkSizeGroup *sizegroup_button_label; + GtkSizeGroup *sizegroup_button_image; + GsShell *shell; + GsOdrsProvider *odrs_provider; + + GtkWidget *list_box_install; + GtkWidget *scrolledwindow_install; + GtkWidget *spinner_install; + GtkWidget *stack_install; +}; + +G_DEFINE_TYPE (GsModeratePage, gs_moderate_page, GS_TYPE_PAGE) + +typedef enum { + PROP_ODRS_PROVIDER = 1, +} GsModeratePageProperty; + +static GParamSpec *obj_props[PROP_ODRS_PROVIDER + 1] = { NULL, }; + +static void +gs_moderate_page_perhaps_hide_app_row (GsModeratePage *self, GsApp *app) +{ + GtkWidget *child; + GsAppRow *app_row = NULL; + gboolean is_visible = FALSE; + + for (child = gtk_widget_get_first_child (self->list_box_install); + child != NULL; + child = gtk_widget_get_next_sibling (child)) { + if (!gtk_widget_get_visible (child)) + continue; + if (GS_IS_APP_ROW (child)) { + GsApp *app_tmp = gs_app_row_get_app (GS_APP_ROW (child)); + if (g_strcmp0 (gs_app_get_id (app), + gs_app_get_id (app_tmp)) == 0) { + app_row = GS_APP_ROW (child); + continue; + } + } + if (GS_IS_REVIEW_ROW (child)) { + GsApp *app_tmp = g_object_get_data (G_OBJECT (child), "GsApp"); + if (g_strcmp0 (gs_app_get_id (app), + gs_app_get_id (app_tmp)) == 0) { + is_visible = TRUE; + break; + } + } + } + if (!is_visible && app_row != NULL) + gs_app_row_unreveal (app_row); +} + +static void +gs_moderate_page_review_clicked_cb (GsReviewRow *row, + GsReviewAction action, + GsModeratePage *self) +{ + GsApp *app = g_object_get_data (G_OBJECT (row), "GsApp"); + AsReview *review = gs_review_row_get_review (row); + g_autoptr(GError) local_error = NULL; + + g_assert (self->odrs_provider != NULL); + + /* FIXME: Make this async */ + switch (action) { + case GS_REVIEW_ACTION_UPVOTE: + gs_odrs_provider_upvote_review (self->odrs_provider, app, + review, self->cancellable, + &local_error); + break; + case GS_REVIEW_ACTION_DOWNVOTE: + gs_odrs_provider_downvote_review (self->odrs_provider, app, + review, self->cancellable, + &local_error); + break; + case GS_REVIEW_ACTION_REPORT: + gs_odrs_provider_report_review (self->odrs_provider, app, + review, self->cancellable, + &local_error); + break; + case GS_REVIEW_ACTION_DISMISS: + gs_odrs_provider_dismiss_review (self->odrs_provider, app, + review, self->cancellable, + &local_error); + break; + case GS_REVIEW_ACTION_REMOVE: + gs_odrs_provider_remove_review (self->odrs_provider, app, + review, self->cancellable, + &local_error); + break; + default: + g_assert_not_reached (); + } + + gtk_widget_set_visible (GTK_WIDGET (row), FALSE); + + /* if there are no more visible rows, hide the app */ + gs_moderate_page_perhaps_hide_app_row (self, app); + + if (local_error != NULL) { + g_warning ("failed to set review on %s: %s", + gs_app_get_id (app), local_error->message); + return; + } +} + +static void +gs_moderate_page_selection_changed_cb (GtkListBox *listbox, + GsAppRow *app_row, + GsModeratePage *self) +{ + g_autofree gchar *tmp = NULL; + tmp = gs_app_to_string (gs_app_row_get_app (app_row)); + g_print ("%s", tmp); +} + +static void +gs_moderate_page_add_app (GsModeratePage *self, GsApp *app) +{ + GPtrArray *reviews; + GtkWidget *app_row; + guint i; + + /* this hides the action button */ + gs_app_add_quirk (app, GS_APP_QUIRK_COMPULSORY); + + /* add top level app */ + app_row = gs_app_row_new (app); + gs_app_row_set_show_buttons (GS_APP_ROW (app_row), TRUE); + gtk_list_box_append (GTK_LIST_BOX (self->list_box_install), app_row); + gs_app_row_set_size_groups (GS_APP_ROW (app_row), + self->sizegroup_name, + self->sizegroup_button_label, + self->sizegroup_button_image); + + /* add reviews */ + reviews = gs_app_get_reviews (app); + for (i = 0; i < reviews->len; i++) { + AsReview *review = g_ptr_array_index (reviews, i); + GtkWidget *row = gs_review_row_new (review); + gtk_widget_set_margin_start (row, 250); + gtk_widget_set_margin_end (row, 250); + gs_review_row_set_actions (GS_REVIEW_ROW (row), + 1 << GS_REVIEW_ACTION_UPVOTE | + 1 << GS_REVIEW_ACTION_DOWNVOTE | + 1 << GS_REVIEW_ACTION_DISMISS | + 1 << GS_REVIEW_ACTION_REPORT); + g_signal_connect (row, "button-clicked", + G_CALLBACK (gs_moderate_page_review_clicked_cb), self); + g_object_set_data_full (G_OBJECT (row), "GsApp", + g_object_ref (app), + (GDestroyNotify) g_object_unref); + gtk_list_box_append (GTK_LIST_BOX (self->list_box_install), row); + } + gtk_widget_show (app_row); +} + +static void +gs_moderate_page_refine_unvoted_reviews_cb (GObject *source_object, + GAsyncResult *res, + gpointer user_data) +{ + guint i; + GsApp *app; + GsModeratePage *self = GS_MODERATE_PAGE (user_data); + GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (source_object); + g_autoptr(GError) error = NULL; + g_autoptr(GsAppList) list = NULL; + + gtk_spinner_stop (GTK_SPINNER (self->spinner_install)); + gtk_stack_set_visible_child_name (GTK_STACK (self->stack_install), "view"); + + 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 moderate apps: %s", error->message); + return; + } + + /* no results */ + if (gs_app_list_length (list) == 0) { + gtk_stack_set_visible_child_name (GTK_STACK (self->stack_install), + "uptodate"); + return; + } + + for (i = 0; i < gs_app_list_length (list); i++) { + app = gs_app_list_index (list, i); + gs_moderate_page_add_app (self, app); + } +} + +static void +gs_moderate_page_load (GsModeratePage *self) +{ + g_autoptr(GsPluginJob) plugin_job = NULL; + g_autoptr(GsAppList) list = gs_app_list_new (); + g_autoptr(GError) local_error = NULL; + + /* remove old entries */ + gs_widget_remove_all (self->list_box_install, (GsRemoveFunc) gtk_list_box_remove); + + /* get unvoted reviews as apps */ + if (!gs_odrs_provider_add_unvoted_reviews (self->odrs_provider, list, + self->cancellable, &local_error)) { + 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_warning ("failed to get moderate apps: %s", local_error->message); + return; + } + + plugin_job = gs_plugin_job_refine_new (list, + GS_PLUGIN_REFINE_FLAGS_REQUIRE_ICON | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_SETUP_ACTION | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_VERSION | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_PROVENANCE | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_DESCRIPTION | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_LICENSE | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_REVIEWS); + gs_plugin_job_set_interactive (plugin_job, TRUE); + gs_plugin_loader_job_process_async (self->plugin_loader, plugin_job, + self->cancellable, + gs_moderate_page_refine_unvoted_reviews_cb, + self); + gtk_spinner_start (GTK_SPINNER (self->spinner_install)); + gtk_stack_set_visible_child_name (GTK_STACK (self->stack_install), "spinner"); +} + +static void +gs_moderate_page_reload (GsPage *page) +{ + GsModeratePage *self = GS_MODERATE_PAGE (page); + if (gs_shell_get_mode (self->shell) == GS_SHELL_MODE_MODERATE) + gs_moderate_page_load (self); +} + +static void +gs_moderate_page_switch_to (GsPage *page) +{ + GsModeratePage *self = GS_MODERATE_PAGE (page); + + if (gs_shell_get_mode (self->shell) != GS_SHELL_MODE_MODERATE) { + g_warning ("Called switch_to(moderate) when in mode %s", + gs_shell_get_mode_string (self->shell)); + return; + } + if (gs_shell_get_mode (self->shell) == GS_SHELL_MODE_MODERATE) + gs_grab_focus_when_mapped (self->scrolledwindow_install); + gs_moderate_page_load (self); +} + +static void +gs_moderate_page_list_header_func (GtkListBoxRow *row, + GtkListBoxRow *before, + gpointer user_data) +{ + GtkWidget *header; + gtk_list_box_row_set_header (row, NULL); + if (before == NULL) + return; + if (GS_IS_REVIEW_ROW (before) && GS_IS_APP_ROW (row)) { + header = gtk_separator_new (GTK_ORIENTATION_HORIZONTAL); + gtk_list_box_row_set_header (row, header); + } +} + +static gboolean +gs_moderate_page_setup (GsPage *page, + GsShell *shell, + GsPluginLoader *plugin_loader, + GCancellable *cancellable, + GError **error) +{ + GsModeratePage *self = GS_MODERATE_PAGE (page); + + g_return_val_if_fail (GS_IS_MODERATE_PAGE (self), TRUE); + + self->shell = shell; + self->plugin_loader = g_object_ref (plugin_loader); + self->cancellable = g_object_ref (cancellable); + + gtk_list_box_set_header_func (GTK_LIST_BOX (self->list_box_install), + gs_moderate_page_list_header_func, + self, NULL); + + return TRUE; +} + +static void +gs_moderate_page_get_property (GObject *object, + guint prop_id, + GValue *value, + GParamSpec *pspec) +{ + GsModeratePage *self = GS_MODERATE_PAGE (object); + + switch ((GsModeratePageProperty) prop_id) { + case PROP_ODRS_PROVIDER: + g_value_set_object (value, gs_moderate_page_get_odrs_provider (self)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_moderate_page_set_property (GObject *object, + guint prop_id, + const GValue *value, + GParamSpec *pspec) +{ + GsModeratePage *self = GS_MODERATE_PAGE (object); + + switch ((GsModeratePageProperty) prop_id) { + case PROP_ODRS_PROVIDER: + gs_moderate_page_set_odrs_provider (self, g_value_get_object (value)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_moderate_page_dispose (GObject *object) +{ + GsModeratePage *self = GS_MODERATE_PAGE (object); + + g_clear_object (&self->sizegroup_name); + g_clear_object (&self->sizegroup_button_label); + g_clear_object (&self->sizegroup_button_image); + + g_clear_object (&self->plugin_loader); + g_clear_object (&self->cancellable); + g_clear_object (&self->odrs_provider); + + G_OBJECT_CLASS (gs_moderate_page_parent_class)->dispose (object); +} + +static void +gs_moderate_page_class_init (GsModeratePageClass *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_moderate_page_get_property; + object_class->set_property = gs_moderate_page_set_property; + object_class->dispose = gs_moderate_page_dispose; + page_class->switch_to = gs_moderate_page_switch_to; + page_class->reload = gs_moderate_page_reload; + page_class->setup = gs_moderate_page_setup; + + /** + * GsModeratePage:odrs-provider: (nullable) + * + * An ODRS provider to give access to ratings and reviews information + * for the apps being displayed. + * + * If this is %NULL, ratings and reviews will be disabled and the page + * will be effectively useless. + * + * 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); + + g_object_class_install_properties (object_class, G_N_ELEMENTS (obj_props), obj_props); + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-moderate-page.ui"); + + gtk_widget_class_bind_template_child (widget_class, GsModeratePage, list_box_install); + gtk_widget_class_bind_template_child (widget_class, GsModeratePage, scrolledwindow_install); + gtk_widget_class_bind_template_child (widget_class, GsModeratePage, spinner_install); + gtk_widget_class_bind_template_child (widget_class, GsModeratePage, stack_install); +} + +static void +gs_moderate_page_init (GsModeratePage *self) +{ + gtk_widget_init_template (GTK_WIDGET (self)); + + g_signal_connect (self->list_box_install, "row-activated", + G_CALLBACK (gs_moderate_page_selection_changed_cb), self); + + self->sizegroup_name = gtk_size_group_new (GTK_SIZE_GROUP_HORIZONTAL); + self->sizegroup_button_label = gtk_size_group_new (GTK_SIZE_GROUP_HORIZONTAL); + self->sizegroup_button_image = gtk_size_group_new (GTK_SIZE_GROUP_HORIZONTAL); +} + +GsModeratePage * +gs_moderate_page_new (void) +{ + GsModeratePage *self; + self = g_object_new (GS_TYPE_MODERATE_PAGE, NULL); + return GS_MODERATE_PAGE (self); +} + +/** + * gs_moderate_page_get_odrs_provider: + * @self: a #GsModeratePage + * + * Get the value of #GsModeratePage:odrs-provider. + * + * Returns: (nullable) (transfer none): a #GsOdrsProvider, or %NULL if unset + * Since: 41 + */ +GsOdrsProvider * +gs_moderate_page_get_odrs_provider (GsModeratePage *self) +{ + g_return_val_if_fail (GS_IS_MODERATE_PAGE (self), NULL); + + return self->odrs_provider; +} + +/** + * gs_moderate_page_set_odrs_provider: + * @self: a #GsModeratePage + * @odrs_provider: (nullable) (transfer none): new #GsOdrsProvider or %NULL + * + * Set the value of #GsModeratePage:odrs-provider. + * + * Since: 41 + */ +void +gs_moderate_page_set_odrs_provider (GsModeratePage *self, + GsOdrsProvider *odrs_provider) +{ + g_return_if_fail (GS_IS_MODERATE_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_moderate_page_reload (GS_PAGE (self)); + g_object_notify_by_pspec (G_OBJECT (self), obj_props[PROP_ODRS_PROVIDER]); + } +} diff --git a/src/gs-moderate-page.h b/src/gs-moderate-page.h new file mode 100644 index 0000000..12a11af --- /dev/null +++ b/src/gs-moderate-page.h @@ -0,0 +1,26 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2017 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include "gs-page.h" + +G_BEGIN_DECLS + +#define GS_TYPE_MODERATE_PAGE (gs_moderate_page_get_type ()) + +G_DECLARE_FINAL_TYPE (GsModeratePage, gs_moderate_page, GS, MODERATE_PAGE, GsPage) + +GsModeratePage *gs_moderate_page_new (void); + +GsOdrsProvider *gs_moderate_page_get_odrs_provider (GsModeratePage *self); +void gs_moderate_page_set_odrs_provider (GsModeratePage *self, + GsOdrsProvider *odrs_provider); + +G_END_DECLS diff --git a/src/gs-moderate-page.ui b/src/gs-moderate-page.ui new file mode 100644 index 0000000..0f8bd22 --- /dev/null +++ b/src/gs-moderate-page.ui @@ -0,0 +1,80 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <requires lib="gtk+" version="3.10"/> + <template class="GsModeratePage" parent="GsPage"> + <accessibility> + <property name="label" translatable="yes">Moderate page</property> + </accessibility> + <child> + <object class="GtkStack" id="stack_install"> + + <child> + <object class="GtkStackPage"> + <property name="name">spinner</property> + <property name="child"> + <object class="GtkSpinner" id="spinner_install"> + <property name="width_request">32</property> + <property name="height_request">32</property> + <property name="halign">center</property> + <property name="valign">center</property> + <property name="hexpand">True</property> + <property name="vexpand">True</property> + <style> + <class name="fade-in"/> + </style> + </object> + </property> + </object> + </child> + + <child> + <object class="GtkStackPage"> + <property name="name">view</property> + <property name="child"> + <object class="GtkBox" id="box_install"> + <property name="orientation">vertical</property> + <child> + <object class="GtkScrolledWindow" id="scrolledwindow_install"> + <property name="can_focus">True</property> + <property name="hscrollbar_policy">never</property> + <property name="vscrollbar_policy">automatic</property> + <property name="vexpand">True</property> + <child> + <object class="AdwClamp"> + <property name="maximum-size">860</property> + <!-- ~⅔ of the maximum size. --> + <property name="tightening-threshold">576</property> + <child> + <object class="GtkListBox" id="list_box_install"> + <property name="can_focus">True</property> + <property name="selection_mode">none</property> + <property name="valign">start</property> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </property> + </object> + </child> + + <child> + <object class="GtkStackPage"> + <property name="name">uptodate</property> + <property name="child"> + <object class="AdwStatusPage" id="updates_uptodate_box"> + <property name="hexpand">True</property> + <property name="vexpand">True</property> + <property name="icon_name">object-select-symbolic</property> + <property name="title" translatable="yes">There are no reviews to moderate</property> + </object> + </property> + </object> + </child> + + </object> + </child> + </template> +</interface> diff --git a/src/gs-origin-popover-row.c b/src/gs-origin-popover-row.c new file mode 100644 index 0000000..adfb539 --- /dev/null +++ b/src/gs-origin-popover-row.c @@ -0,0 +1,204 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2018 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include "gs-common.h" + +#include "gs-origin-popover-row.h" + +#include <glib/gi18n.h> + +typedef struct +{ + GsApp *app; + GtkCssProvider *css_provider; + GtkWidget *name_label; + GtkWidget *info_label; + GtkWidget *installed_image; + GtkWidget *packaging_box; + GtkWidget *packaging_image; + GtkWidget *packaging_label; + GtkWidget *beta_box; + GtkWidget *user_scope_box; + GtkWidget *selected_image; +} GsOriginPopoverRowPrivate; + +G_DEFINE_TYPE_WITH_PRIVATE (GsOriginPopoverRow, gs_origin_popover_row, GTK_TYPE_LIST_BOX_ROW) + +static void +refresh_ui (GsOriginPopoverRow *row) +{ + GsOriginPopoverRowPrivate *priv = gs_origin_popover_row_get_instance_private (row); + const gchar *packaging_base_css_color, *packaging_icon; + g_autofree gchar *packaging_format = NULL; + g_autofree gchar *info = NULL; + g_autofree gchar *css = NULL; + g_autofree gchar *origin_ui = NULL; + g_autofree gchar *url = NULL; + + g_assert (GS_IS_ORIGIN_POPOVER_ROW (row)); + g_assert (GS_IS_APP (priv->app)); + + origin_ui = gs_app_dup_origin_ui (priv->app, FALSE); + if (origin_ui != NULL) + gtk_label_set_text (GTK_LABEL (priv->name_label), origin_ui); + + if (gs_app_get_state (priv->app) == GS_APP_STATE_AVAILABLE_LOCAL || + gs_app_get_local_file (priv->app) != NULL) { + GFile *local_file = gs_app_get_local_file (priv->app); + url = g_file_get_basename (local_file); + } else { + url = g_strdup (gs_app_get_origin_hostname (priv->app)); + } + + if (gs_app_get_bundle_kind (priv->app) == AS_BUNDLE_KIND_SNAP) { + const gchar *branch = NULL, *version = NULL; + const gchar *order[3]; + const gchar *items[7] = { NULL, }; + guint index = 0; + + branch = gs_app_get_branch (priv->app); + version = gs_app_get_version (priv->app); + + if (gtk_widget_get_direction (GTK_WIDGET (row)) == GTK_TEXT_DIR_RTL) { + order[0] = version; + order[1] = branch; + order[2] = url; + } else { + order[0] = url; + order[1] = branch; + order[2] = version; + } + + for (guint ii = 0; ii < G_N_ELEMENTS (order); ii++) { + const gchar *value = order[ii]; + + if (value != NULL) { + if (index > 0) { + items[index] = "•"; + index++; + } + items[index] = value; + index++; + } + } + + if (index > 0) { + g_assert (index + 1 < G_N_ELEMENTS (items)); + items[index] = NULL; + + info = g_strjoinv (" ", (gchar **) items); + } + } else { + info = g_steal_pointer (&url); + } + + if (info != NULL) + gtk_label_set_text (GTK_LABEL (priv->info_label), info); + else + gtk_label_set_text (GTK_LABEL (priv->info_label), _("Unknown source")); + + gtk_widget_set_visible (priv->installed_image, gs_app_is_installed (priv->app)); + gtk_widget_set_visible (priv->beta_box, gs_app_has_quirk (priv->app, GS_APP_QUIRK_DEVELOPMENT_SOURCE)); + + if (gs_app_get_bundle_kind (priv->app) == AS_BUNDLE_KIND_FLATPAK && + gs_app_get_scope (priv->app) != AS_COMPONENT_SCOPE_UNKNOWN) { + AsComponentScope scope = gs_app_get_scope (priv->app); + gtk_widget_set_visible (priv->user_scope_box, scope == AS_COMPONENT_SCOPE_USER); + } else { + gtk_widget_hide (priv->user_scope_box); + } + + packaging_base_css_color = gs_app_get_metadata_item (priv->app, "GnomeSoftware::PackagingBaseCssColor"); + packaging_icon = gs_app_get_metadata_item (priv->app, "GnomeSoftware::PackagingIcon"); + packaging_format = gs_app_get_packaging_format (priv->app); + + gtk_label_set_text (GTK_LABEL (priv->packaging_label), packaging_format); + + if (packaging_icon != NULL) + gtk_image_set_from_icon_name (GTK_IMAGE (priv->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 (priv->packaging_box, &priv->css_provider, "packaging-color", css); +} + +static void +gs_origin_popover_row_set_app (GsOriginPopoverRow *row, GsApp *app) +{ + GsOriginPopoverRowPrivate *priv = gs_origin_popover_row_get_instance_private (row); + + g_assert (priv->app == NULL); + + priv->app = g_object_ref (app); + refresh_ui (row); +} + +GsApp * +gs_origin_popover_row_get_app (GsOriginPopoverRow *row) +{ + GsOriginPopoverRowPrivate *priv = gs_origin_popover_row_get_instance_private (row); + return priv->app; +} + +void +gs_origin_popover_row_set_selected (GsOriginPopoverRow *row, gboolean selected) +{ + GsOriginPopoverRowPrivate *priv = gs_origin_popover_row_get_instance_private (row); + + gtk_widget_set_visible (priv->selected_image, selected); +} + +static void +gs_origin_popover_row_dispose (GObject *object) +{ + GsOriginPopoverRow *row = GS_ORIGIN_POPOVER_ROW (object); + GsOriginPopoverRowPrivate *priv = gs_origin_popover_row_get_instance_private (row); + + g_clear_object (&priv->app); + g_clear_object (&priv->css_provider); + + G_OBJECT_CLASS (gs_origin_popover_row_parent_class)->dispose (object); +} + +static void +gs_origin_popover_row_init (GsOriginPopoverRow *row) +{ + gtk_widget_init_template (GTK_WIDGET (row)); +} + +static void +gs_origin_popover_row_class_init (GsOriginPopoverRowClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + + object_class->dispose = gs_origin_popover_row_dispose; + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-origin-popover-row.ui"); + + gtk_widget_class_bind_template_child_private (widget_class, GsOriginPopoverRow, name_label); + gtk_widget_class_bind_template_child_private (widget_class, GsOriginPopoverRow, info_label); + gtk_widget_class_bind_template_child_private (widget_class, GsOriginPopoverRow, installed_image); + gtk_widget_class_bind_template_child_private (widget_class, GsOriginPopoverRow, packaging_box); + gtk_widget_class_bind_template_child_private (widget_class, GsOriginPopoverRow, packaging_image); + gtk_widget_class_bind_template_child_private (widget_class, GsOriginPopoverRow, packaging_label); + gtk_widget_class_bind_template_child_private (widget_class, GsOriginPopoverRow, beta_box); + gtk_widget_class_bind_template_child_private (widget_class, GsOriginPopoverRow, user_scope_box); + gtk_widget_class_bind_template_child_private (widget_class, GsOriginPopoverRow, selected_image); +} + +GtkWidget * +gs_origin_popover_row_new (GsApp *app) +{ + GsOriginPopoverRow *row = g_object_new (GS_TYPE_ORIGIN_POPOVER_ROW, NULL); + gs_origin_popover_row_set_app (row, app); + return GTK_WIDGET (row); +} diff --git a/src/gs-origin-popover-row.h b/src/gs-origin-popover-row.h new file mode 100644 index 0000000..be928ee --- /dev/null +++ b/src/gs-origin-popover-row.h @@ -0,0 +1,30 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2018 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include "gnome-software-private.h" +#include <gtk/gtk.h> + +G_BEGIN_DECLS + +#define GS_TYPE_ORIGIN_POPOVER_ROW (gs_origin_popover_row_get_type ()) + +G_DECLARE_DERIVABLE_TYPE (GsOriginPopoverRow, gs_origin_popover_row, GS, ORIGIN_POPOVER_ROW, GtkListBoxRow) + +struct _GsOriginPopoverRowClass +{ + GtkListBoxRowClass parent_class; +}; + +GtkWidget *gs_origin_popover_row_new (GsApp *app); +GsApp *gs_origin_popover_row_get_app (GsOriginPopoverRow *row); +void gs_origin_popover_row_set_selected (GsOriginPopoverRow *row, + gboolean selected); + +G_END_DECLS diff --git a/src/gs-origin-popover-row.ui b/src/gs-origin-popover-row.ui new file mode 100644 index 0000000..bee4d80 --- /dev/null +++ b/src/gs-origin-popover-row.ui @@ -0,0 +1,166 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <template class="GsOriginPopoverRow" parent="GtkListBoxRow"> + <child> + <object class="GtkBox"> + <property name="orientation">horizontal</property> + <property name="width-request">200</property> + <child> + <object class="GtkBox" id="row_vbox"> + <property name="orientation">vertical</property> + <property name="spacing">6</property> + <child> + <object class="GtkBox" id="top_hbox"> + <property name="orientation">horizontal</property> + <property name="spacing">6</property> + <child> + <object class="GtkBox" id="vbox"> + <property name="orientation">vertical</property> + <property name="spacing">0</property> + <child> + <object class="GtkLabel" id="name_label"> + <property name="halign">start</property> + <property name="ellipsize">end</property> + <attributes> + <attribute name="weight" value="bold"/> + </attributes> + </object> + </child> + <child> + <object class="GtkLabel" id="info_label"> + <property name="halign">start</property> + <property name="ellipsize">end</property> + <style> + <class name="app-row-origin-text"/> + <class name="dim-label"/> + </style> + </object> + </child> + </object> + </child> + <child> + <object class="GtkImage" id="installed_image"> + <property name="visible">False</property> + <property name="pixel_size">16</property> + <property name="icon_name">app-installed-symbolic</property> + <property name="margin-start">6</property> + <property name="valign">center</property> + <style> + <class name="success"/> + </style> + </object> + </child> + </object> + </child> + <child> + <object class="GtkBox" id="bottom_hbox"> + <property name="orientation">horizontal</property> + <property name="spacing">6</property> + <child> + <object class="GtkBox" id="packaging_box"> + <property name="orientation">horizontal</property> + <property name="spacing">4</property> + <property name="valign">center</property> + <style> + <class name="origin-rounded-box"/> + </style> + <child> + <object class="GtkImage" id="packaging_image"> + <property name="pixel_size">16</property> + <property name="icon_name">package-x-generic-symbolic</property> + <property name="valign">center</property> + </object> + </child> + <child> + <object class="GtkLabel" id="packaging_label"> + <property name="halign">start</property> + <property name="ellipsize">none</property> + <property name="margin-top">1</property> + <property name="margin-end">2</property> + <attributes> + <attribute name="weight" value="bold"/> + <attribute name="variant" value="all-small-caps"/> + </attributes> + </object> + </child> + </object> + </child> + <child> + <object class="GtkBox" id="beta_box"> + <property name="orientation">horizontal</property> + <property name="spacing">4</property> + <property name="valign">center</property> + <style> + <class name="origin-rounded-box"/> + <class name="origin-beta"/> + </style> + <child> + <object class="GtkImage" id="beta_image"> + <property name="pixel_size">16</property> + <property name="icon_name">test-symbolic</property> + <property name="valign">center</property> + </object> + </child> + <child> + <object class="GtkLabel" id="beta_label"> + <property name="halign">start</property> + <property name="ellipsize">none</property> + <property name="label" translatable="yes" comments="Translators: It's like a beta version of the software, a test version">Beta</property> + <property name="margin-top">1</property> + <property name="margin-end">2</property> + <attributes> + <attribute name="weight" value="bold"/> + <attribute name="variant" value="all-small-caps"/> + </attributes> + </object> + </child> + </object> + </child> + <child> + <object class="GtkBox" id="user_scope_box"> + <property name="orientation">horizontal</property> + <property name="spacing">4</property> + <property name="valign">center</property> + <style> + <class name="origin-rounded-box"/> + </style> + <child> + <object class="GtkImage" id="user_scope_image"> + <property name="pixel_size">16</property> + <property name="icon_name">avatar-default-symbolic</property> + <property name="valign">center</property> + </object> + </child> + <child> + <object class="GtkLabel" id="user_scope_label"> + <property name="halign">start</property> + <property name="ellipsize">none</property> + <property name="label" translatable="yes" comments="Translators: It's an origin scope, 'User' or 'System' installation">User</property> + <property name="margin-top">1</property> + <property name="margin-end">2</property> + <attributes> + <attribute name="weight" value="bold"/> + <attribute name="variant" value="all-small-caps"/> + </attributes> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + <child> + <object class="GtkImage" id="selected_image"> + <property name="visible">False</property> + <property name="margin-start">18</property> + <property name="halign">end</property> + <property name="hexpand">True</property> + <property name="pixel_size">16</property> + <property name="icon_name">object-select-symbolic</property> + </object> + </child> + </object> + </child> + </template> +</interface> diff --git a/src/gs-os-update-page.c b/src/gs-os-update-page.c new file mode 100644 index 0000000..3b5b979 --- /dev/null +++ b/src/gs-os-update-page.c @@ -0,0 +1,613 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013-2016 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2014-2018 Kalev Lember <klember@redhat.com> + * Copyright (C) 2021 Purism SPC + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +/** + * SECTION:gs-os-update-page + * @title: GsOsUpdatePage + * @include: gnome-software.h + * @stability: Stable + * @short_description: A small page showing OS updates + * + * This is a page from #GsUpdateDialog. + */ + +#include "config.h" + +#include <adwaita.h> +#include <glib/gi18n.h> + +#include "gs-os-update-page.h" +#include "gs-common.h" + +typedef enum { + GS_OS_UPDATE_PAGE_SECTION_ADDITIONS, + GS_OS_UPDATE_PAGE_SECTION_REMOVALS, + GS_OS_UPDATE_PAGE_SECTION_UPDATES, + GS_OS_UPDATE_PAGE_SECTION_DOWNGRADES, + GS_OS_UPDATE_PAGE_SECTION_LAST, +} GsOsUpdatePageSection; + +typedef enum { + PROP_APP = 1, + PROP_SHOW_BACK_BUTTON, + PROP_TITLE, +} GsOsUpdatePageProperty; + +enum { + SIGNAL_APP_ACTIVATED, + SIGNAL_BACK_CLICKED, + SIGNAL_LAST +}; + +static GParamSpec *obj_props[PROP_TITLE + 1] = { NULL, }; + +static guint signals[SIGNAL_LAST] = { 0 }; + +struct _GsOsUpdatePage +{ + GtkBox parent_instance; + + GtkWidget *back_button; + GtkWidget *box; + GtkWidget *group; + GtkWidget *header_bar; + AdwWindowTitle *window_title; + + GsApp *app; /* (owned) (nullable) */ + GtkWidget *list_boxes[GS_OS_UPDATE_PAGE_SECTION_LAST]; +}; + +G_DEFINE_TYPE (GsOsUpdatePage, gs_os_update_page, GTK_TYPE_BOX) + +static void +row_activated_cb (GtkListBox *list_box, + GtkListBoxRow *row, + GsOsUpdatePage *page) +{ + GsApp *app; + + app = GS_APP (g_object_get_data (G_OBJECT (gtk_list_box_row_get_child (row)), "app")); + g_assert (app != NULL); + + g_signal_emit (page, signals[SIGNAL_APP_ACTIVATED], 0, app); +} + +static gchar * +format_version_update (GsApp *app, GtkTextDirection direction) +{ + const gchar *tmp; + const gchar *version_current = NULL; + const gchar *version_update = NULL; + + /* current version */ + tmp = gs_app_get_version (app); + if (tmp != NULL && tmp[0] != '\0') + version_current = tmp; + + /* update version */ + tmp = gs_app_get_update_version (app); + if (tmp != NULL && tmp[0] != '\0') + version_update = tmp; + + /* have both */ + if (version_current != NULL && version_update != NULL && + g_strcmp0 (version_current, version_update) != 0) { + switch (direction) { + case GTK_TEXT_DIR_RTL: + /* ensure the arrow is the right way round for the text direction, + * as arrows are not bidi-mirrored automatically + * See section 2 of http://www.unicode.org/L2/L2017/17438-bidi-math-fdbk.html */ + return g_strdup_printf ("%s ← %s", + version_update, + version_current); + case GTK_TEXT_DIR_NONE: + case GTK_TEXT_DIR_LTR: + default: + return g_strdup_printf ("%s → %s", + version_current, + version_update); + } + } + + /* just update */ + if (version_update) + return g_strdup (version_update); + + /* we have nothing, nada, zilch */ + return NULL; +} + +static GtkWidget * +create_app_row (GsApp *app) +{ + GtkWidget *row, *label; + + row = gtk_box_new (GTK_ORIENTATION_HORIZONTAL, 12); + g_object_set_data_full (G_OBJECT (row), + "app", + g_object_ref (app), + g_object_unref); + label = gtk_label_new (gs_app_get_source_default (app)); + g_object_set (label, + "margin-start", 20, + "margin-end", 0, + "margin-top", 6, + "margin-bottom", 6, + "xalign", 0.0, + "ellipsize", PANGO_ELLIPSIZE_END, + NULL); + gtk_widget_set_halign (label, GTK_ALIGN_START); + gtk_widget_set_hexpand (label, TRUE); + gtk_widget_set_valign (label, GTK_ALIGN_CENTER); + gtk_box_append (GTK_BOX (row), label); + if (gs_app_get_state (app) == GS_APP_STATE_UPDATABLE || + gs_app_get_state (app) == GS_APP_STATE_UPDATABLE_LIVE) { + g_autofree gchar *verstr = format_version_update (app, gtk_widget_get_direction (row)); + label = gtk_label_new (verstr); + } else { + label = gtk_label_new (gs_app_get_version (app)); + } + g_object_set (label, + "margin-start", 0, + "margin-end", 20, + "margin-top", 6, + "margin-bottom", 6, + "xalign", 1.0, + "ellipsize", PANGO_ELLIPSIZE_END, + NULL); + gtk_widget_set_halign (label, GTK_ALIGN_END); + gtk_widget_set_valign (label, GTK_ALIGN_CENTER); + gtk_box_append (GTK_BOX (row), label); + + return row; +} + +static gboolean +is_downgrade (const gchar *evr1, + const gchar *evr2) +{ + gint rc; + + if (evr1 == NULL || evr2 == NULL) + return FALSE; + + rc = as_vercmp (evr1, evr2, AS_VERCMP_FLAG_IGNORE_EPOCH); + if (rc != 0) + return rc > 0; + + return FALSE; +} + +static GsOsUpdatePageSection +get_app_section (GsApp *app) +{ + GsOsUpdatePageSection section; + + /* Sections: + * 1. additions + * 2. removals + * 3. updates + * 4. downgrades */ + switch (gs_app_get_state (app)) { + case GS_APP_STATE_AVAILABLE: + section = GS_OS_UPDATE_PAGE_SECTION_ADDITIONS; + break; + case GS_APP_STATE_UNAVAILABLE: + case GS_APP_STATE_INSTALLED: + section = GS_OS_UPDATE_PAGE_SECTION_REMOVALS; + break; + case GS_APP_STATE_UPDATABLE: + case GS_APP_STATE_UPDATABLE_LIVE: + if (is_downgrade (gs_app_get_version (app), + gs_app_get_update_version (app))) + section = GS_OS_UPDATE_PAGE_SECTION_DOWNGRADES; + else + section = GS_OS_UPDATE_PAGE_SECTION_UPDATES; + break; + default: + g_warning ("get_app_section: unhandled state %s for %s", + gs_app_state_to_string (gs_app_get_state (app)), + gs_app_get_unique_id (app)); + section = GS_OS_UPDATE_PAGE_SECTION_UPDATES; + break; + } + + return section; +} + +static gint +os_updates_sort_func (GtkListBoxRow *a, + GtkListBoxRow *b, + gpointer user_data) +{ + GObject *o1 = G_OBJECT (gtk_list_box_row_get_child (a)); + GObject *o2 = G_OBJECT (gtk_list_box_row_get_child (b)); + GsApp *a1 = g_object_get_data (o1, "app"); + GsApp *a2 = g_object_get_data (o2, "app"); + const gchar *key1 = gs_app_get_source_default (a1); + const gchar *key2 = gs_app_get_source_default (a2); + + return g_strcmp0 (key1, key2); +} + +static GtkWidget * +get_section_header (GsOsUpdatePage *page, GsOsUpdatePageSection section) +{ + GtkWidget *header; + GtkWidget *label; + + /* get labels and buttons for everything */ + if (section == GS_OS_UPDATE_PAGE_SECTION_ADDITIONS) { + /* TRANSLATORS: This is the header for package additions during + * a system update */ + label = gtk_label_new (_("Additions")); + } else if (section == GS_OS_UPDATE_PAGE_SECTION_REMOVALS) { + /* TRANSLATORS: This is the header for package removals during + * a system update */ + label = gtk_label_new (_("Removals")); + } else if (section == GS_OS_UPDATE_PAGE_SECTION_UPDATES) { + /* TRANSLATORS: This is the header for package updates during + * a system update */ + label = gtk_label_new (C_("Packages to be updated during a system upgrade", "Updates")); + } else if (section == GS_OS_UPDATE_PAGE_SECTION_DOWNGRADES) { + /* TRANSLATORS: This is the header for package downgrades during + * a system update */ + label = gtk_label_new (_("Downgrades")); + } else { + g_assert_not_reached (); + } + + /* create header */ + header = gtk_box_new (GTK_ORIENTATION_HORIZONTAL, 3); + gtk_widget_add_css_class (header, "app-listbox-header"); + + /* put label into the header */ + gtk_widget_set_hexpand (label, TRUE); + gtk_box_append (GTK_BOX (header), label); + gtk_widget_set_margin_start (label, 16); + gtk_label_set_xalign (GTK_LABEL (label), 0.0); + gtk_widget_add_css_class (label, "heading"); + + /* success */ + return header; +} + +static void +list_header_func (GtkListBoxRow *row, + GtkListBoxRow *before, + gpointer user_data) +{ + GsOsUpdatePage *page = (GsOsUpdatePage *) user_data; + GObject *o = G_OBJECT (gtk_list_box_row_get_child (row)); + GsApp *app = g_object_get_data (o, "app"); + GtkWidget *header = NULL; + + if (before == NULL) + header = get_section_header (page, get_app_section (app)); + else + header = gtk_separator_new (GTK_ORIENTATION_HORIZONTAL); + gtk_list_box_row_set_header (row, header); +} + +static void +create_section (GsOsUpdatePage *page, GsOsUpdatePageSection section) +{ + GtkWidget *previous = NULL; + + page->list_boxes[section] = gtk_list_box_new (); + gtk_list_box_set_selection_mode (GTK_LIST_BOX (page->list_boxes[section]), + GTK_SELECTION_NONE); + gtk_list_box_set_sort_func (GTK_LIST_BOX (page->list_boxes[section]), + os_updates_sort_func, + page, NULL); + gtk_list_box_set_header_func (GTK_LIST_BOX (page->list_boxes[section]), + list_header_func, + page, NULL); + g_signal_connect (GTK_LIST_BOX (page->list_boxes[section]), "row-activated", + G_CALLBACK (row_activated_cb), page); + gtk_box_append (GTK_BOX (page->box), page->list_boxes[section]); + gtk_widget_set_margin_top (page->list_boxes[section], 24); + + /* reorder the children */ + for (guint i = 0; i < GS_OS_UPDATE_PAGE_SECTION_LAST; i++) { + if (page->list_boxes[i] == NULL) + continue; + gtk_box_reorder_child_after (GTK_BOX (page->box), + page->list_boxes[i], + previous); + previous = page->list_boxes[i]; + } + + /* make rounded edges */ + gtk_widget_set_overflow (page->list_boxes[section], GTK_OVERFLOW_HIDDEN); + gtk_widget_add_css_class (page->list_boxes[section], "card"); +} + +/** + * gs_os_update_page_get_app: + * @page: a #GsOsUpdatePage + * + * Get the value of #GsOsUpdatePage:app. + * + * Returns: (nullable) (transfer none): the app + * + * Since: 41 + */ +GsApp * +gs_os_update_page_get_app (GsOsUpdatePage *page) +{ + g_return_val_if_fail (GS_IS_OS_UPDATE_PAGE (page), NULL); + return page->app; +} + +/** + * gs_os_update_page_set_app: + * @page: a #GsOsUpdatePage + * @app: (transfer none) (nullable): new app + * + * Set the value of #GsOsUpdatePage:app. + * + * Since: 41 + */ +void +gs_os_update_page_set_app (GsOsUpdatePage *page, GsApp *app) +{ + GsAppList *related; + GsApp *app_related; + GsOsUpdatePageSection section; + GtkWidget *row; + + g_return_if_fail (GS_IS_OS_UPDATE_PAGE (page)); + g_return_if_fail (!app || GS_IS_APP (app)); + + if (page->app == app) + return; + + g_set_object (&page->app, app); + + /* clear existing data */ + for (guint i = 0; i < GS_OS_UPDATE_PAGE_SECTION_LAST; i++) { + if (page->list_boxes[i] == NULL) + continue; + gs_widget_remove_all (page->list_boxes[i], (GsRemoveFunc) gtk_list_box_remove); + } + + if (app) { + adw_window_title_set_title (page->window_title, gs_app_get_name (app)); + adw_preferences_group_set_description (ADW_PREFERENCES_GROUP (page->group), + gs_app_get_description (app)); + + /* add new apps */ + related = gs_app_get_related (app); + for (guint i = 0; i < gs_app_list_length (related); i++) { + app_related = gs_app_list_index (related, i); + + section = get_app_section (app_related); + if (page->list_boxes[section] == NULL) + create_section (page, section); + + row = create_app_row (app_related); + gtk_list_box_append (GTK_LIST_BOX (page->list_boxes[section]), row); + } + } else { + adw_window_title_set_title (page->window_title, NULL); + adw_preferences_group_set_description (ADW_PREFERENCES_GROUP (page->group), NULL); + } + + g_object_notify_by_pspec (G_OBJECT (page), obj_props[PROP_APP]); + g_object_notify_by_pspec (G_OBJECT (page), obj_props[PROP_TITLE]); +} + +/** + * gs_os_update_page_get_show_back_button: + * @page: a #GsOsUpdatePage + * + * Get the value of #GsOsUpdatePage:show-back-button. + * + * Returns: whether to show the back button + * + * Since: 42.1 + */ +gboolean +gs_os_update_page_get_show_back_button (GsOsUpdatePage *page) +{ + g_return_val_if_fail (GS_IS_OS_UPDATE_PAGE (page), FALSE); + return gtk_widget_get_visible (page->back_button); +} + +/** + * gs_os_update_page_set_show_back_button: + * @page: a #GsOsUpdatePage + * @show_back_button: whether to show the back button + * + * Set the value of #GsOsUpdatePage:show-back-button. + * + * Since: 42.1 + */ +void +gs_os_update_page_set_show_back_button (GsOsUpdatePage *page, + gboolean show_back_button) +{ + g_return_if_fail (GS_IS_OS_UPDATE_PAGE (page)); + + show_back_button = !!show_back_button; + + if (gtk_widget_get_visible (page->back_button) == show_back_button) + return; + + gtk_widget_set_visible (page->back_button, show_back_button); + + g_object_notify_by_pspec (G_OBJECT (page), obj_props[PROP_SHOW_BACK_BUTTON]); +} + +static void +back_clicked_cb (GtkWidget *widget, + GsOsUpdatePage *page) +{ + g_signal_emit (page, signals[SIGNAL_BACK_CLICKED], 0); +} + +static void +gs_os_update_page_dispose (GObject *object) +{ + GsOsUpdatePage *page = GS_OS_UPDATE_PAGE (object); + + g_clear_object (&page->app); + + G_OBJECT_CLASS (gs_os_update_page_parent_class)->dispose (object); +} + +static void +gs_os_update_page_get_property (GObject *object, guint prop_id, GValue *value, GParamSpec *pspec) +{ + GsOsUpdatePage *page = GS_OS_UPDATE_PAGE (object); + + switch ((GsOsUpdatePageProperty) prop_id) { + case PROP_APP: + g_value_set_object (value, gs_os_update_page_get_app (page)); + break; + case PROP_SHOW_BACK_BUTTON: + g_value_set_boolean (value, gs_os_update_page_get_show_back_button (page)); + break; + case PROP_TITLE: + g_value_set_string (value, adw_window_title_get_title (page->window_title)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_os_update_page_set_property (GObject *object, guint prop_id, const GValue *value, GParamSpec *pspec) +{ + GsOsUpdatePage *page = GS_OS_UPDATE_PAGE (object); + + switch ((GsOsUpdatePageProperty) prop_id) { + case PROP_APP: + gs_os_update_page_set_app (page, g_value_get_object (value)); + break; + case PROP_SHOW_BACK_BUTTON: + gs_os_update_page_set_show_back_button (page, g_value_get_boolean (value)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_os_update_page_init (GsOsUpdatePage *page) +{ + gtk_widget_init_template (GTK_WIDGET (page)); +} + +static void +gs_os_update_page_class_init (GsOsUpdatePageClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + + object_class->dispose = gs_os_update_page_dispose; + object_class->get_property = gs_os_update_page_get_property; + object_class->set_property = gs_os_update_page_set_property; + + /** + * GsOsUpdatePage:app: (nullable) + * + * The app to present. + * + * Since: 41 + */ + obj_props[PROP_APP] = + g_param_spec_object ("app", NULL, NULL, + GS_TYPE_APP, + G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY); + + /** + * GsOsUpdatePage:show-back-button + * + * Whether to show the back button. + * + * Since: 42.1 + */ + obj_props[PROP_SHOW_BACK_BUTTON] = + g_param_spec_boolean ("show-back-button", NULL, NULL, + FALSE, + G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY); + + /** + * GsOsUpdatePage:title + * + * Read-only window title. + * + * Since: 42 + */ + obj_props[PROP_TITLE] = + g_param_spec_string ("title", NULL, NULL, + NULL, + G_PARAM_READABLE | G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY); + + g_object_class_install_properties (object_class, G_N_ELEMENTS (obj_props), obj_props); + + /** + * GsOsUpdatePage:app-activated: + * @app: a #GsApp + * + * Emitted when an app listed in this page got activated and the + * #GsUpdateDialog containing this page is expected to present its + * details via a #GsAppDetailsPage. + * + * Since: 41 + */ + signals[SIGNAL_APP_ACTIVATED] = + g_signal_new ("app-activated", + G_TYPE_FROM_CLASS (object_class), G_SIGNAL_RUN_LAST, + 0, NULL, NULL, g_cclosure_marshal_generic, + G_TYPE_NONE, 1, GS_TYPE_APP); + + /** + * GsOsUpdatePage::back-clicked: + * @self: a #GsOsUpdatePage + * + * Emitted when the back button got activated and the #GsUpdateDialog + * containing this page is expected to go back. + * + * Since: 42.1 + */ + signals[SIGNAL_BACK_CLICKED] = + g_signal_new ("back-clicked", + G_TYPE_FROM_CLASS (object_class), G_SIGNAL_RUN_LAST, + 0, NULL, NULL, g_cclosure_marshal_generic, + G_TYPE_NONE, 0); + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-os-update-page.ui"); + + gtk_widget_class_bind_template_child (widget_class, GsOsUpdatePage, back_button); + gtk_widget_class_bind_template_child (widget_class, GsOsUpdatePage, box); + gtk_widget_class_bind_template_child (widget_class, GsOsUpdatePage, group); + gtk_widget_class_bind_template_child (widget_class, GsOsUpdatePage, header_bar); + gtk_widget_class_bind_template_child (widget_class, GsOsUpdatePage, window_title); + gtk_widget_class_bind_template_callback (widget_class, back_clicked_cb); +} + +/** + * gs_os_update_page_new: + * + * Create a new #GsOsUpdatePage. + * + * Returns: (transfer full): a new #GsOsUpdatePage + * Since: 41 + */ +GtkWidget * +gs_os_update_page_new (void) +{ + return GTK_WIDGET (g_object_new (GS_TYPE_OS_UPDATE_PAGE, NULL)); +} diff --git a/src/gs-os-update-page.h b/src/gs-os-update-page.h new file mode 100644 index 0000000..dbe4ce3 --- /dev/null +++ b/src/gs-os-update-page.h @@ -0,0 +1,31 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2021 Purism SPC + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <gtk/gtk.h> + +#include "gnome-software-private.h" + +G_BEGIN_DECLS + +#define GS_TYPE_OS_UPDATE_PAGE (gs_os_update_page_get_type ()) + +G_DECLARE_FINAL_TYPE (GsOsUpdatePage, gs_os_update_page, GS, OS_UPDATE_PAGE, GtkBox) + +GtkWidget *gs_os_update_page_new (void); +GsApp *gs_os_update_page_get_app (GsOsUpdatePage *page); +void gs_os_update_page_set_app (GsOsUpdatePage *page, + GsApp *app); +gboolean gs_os_update_page_get_show_back_button + (GsOsUpdatePage *page); +void gs_os_update_page_set_show_back_button + (GsOsUpdatePage *page, + gboolean show_back_button); + +G_END_DECLS diff --git a/src/gs-os-update-page.ui b/src/gs-os-update-page.ui new file mode 100644 index 0000000..a0ec36e --- /dev/null +++ b/src/gs-os-update-page.ui @@ -0,0 +1,45 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <requires lib="gtk+" version="3.10"/> + <template class="GsOsUpdatePage" parent="GtkBox"> + <property name="orientation">vertical</property> + + <child> + <object class="AdwHeaderBar" id="header_bar"> + <property name="show_start_title_buttons">True</property> + <property name="show_end_title_buttons">True</property> + <property name="title-widget"> + <object class="AdwWindowTitle" id="window_title" /> + </property> + <child type="start"> + <object class="GtkButton" id="back_button"> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="icon_name">go-previous-symbolic</property> + <property name="visible">False</property> + <signal name="clicked" handler="back_clicked_cb"/> + <style> + <class name="image-button"/> + </style> + <accessibility> + <property name="label" translatable="yes">Go back</property> + </accessibility> + </object> + </child> + </object> + </child> + <child> + <object class="AdwPreferencesPage"> + <child> + <object class="AdwPreferencesGroup" id="group"> + <child> + <object class="GtkBox" id="box"> + <property name="orientation">vertical</property> + </object> + </child> + </object> + </child> + </object> + </child> + </template> +</interface> diff --git a/src/gs-overview-page.c b/src/gs-overview-page.c new file mode 100644 index 0000000..7a3d766 --- /dev/null +++ b/src/gs-overview-page.c @@ -0,0 +1,1050 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013-2017 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2014-2018 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include <adwaita.h> +#include <glib/gi18n.h> +#include <math.h> + +#include "gs-shell.h" +#include "gs-overview-page.h" +#include "gs-app-list-private.h" +#include "gs-featured-carousel.h" +#include "gs-category-tile.h" +#include "gs-common.h" +#include "gs-summary-tile.h" + +/* Chosen as it has 2 and 3 as factors, so will form an even 2-column and + * 3-column layout. */ +#define N_TILES 12 + +struct _GsOverviewPage +{ + GsPage parent_instance; + + GsPluginLoader *plugin_loader; + GCancellable *cancellable; + gboolean cache_valid; + GsShell *shell; + gint action_cnt; + gboolean loading_featured; + gboolean loading_curated; + gboolean loading_deployment_featured; + gboolean loading_recent; + gboolean loading_categories; + gboolean empty; + gboolean featured_overwritten; + GHashTable *category_hash; /* id : GsCategory */ + GsFedoraThirdParty *third_party; + gboolean third_party_needs_question; + gchar **deployment_featured; + + GtkWidget *infobar_third_party; + GtkWidget *label_third_party; + GtkWidget *featured_carousel; + GtkWidget *box_overview; + GtkWidget *box_curated; + GtkWidget *box_recent; + GtkWidget *box_deployment_featured; + GtkWidget *flowbox_categories; + GtkWidget *flowbox_iconless_categories; + GtkWidget *iconless_categories_heading; + GtkWidget *curated_heading; + GtkWidget *recent_heading; + GtkWidget *deployment_featured_heading; + GtkWidget *scrolledwindow_overview; + GtkWidget *stack_overview; +}; + +G_DEFINE_TYPE (GsOverviewPage, gs_overview_page, GS_TYPE_PAGE) + +typedef enum { + PROP_VADJUSTMENT = 1, + PROP_TITLE, +} GsOverviewPageProperty; + +enum { + SIGNAL_REFRESHED, + SIGNAL_LAST +}; + +static guint signals [SIGNAL_LAST] = { 0 }; + +static void +gs_overview_page_invalidate (GsOverviewPage *self) +{ + self->cache_valid = FALSE; +} + +static void +app_tile_clicked (GsAppTile *tile, gpointer data) +{ + GsOverviewPage *self = GS_OVERVIEW_PAGE (data); + GsApp *app; + + app = gs_app_tile_get_app (tile); + gs_shell_show_app (self->shell, app); +} + +static void +featured_carousel_app_clicked_cb (GsFeaturedCarousel *carousel, + GsApp *app, + gpointer user_data) +{ + GsOverviewPage *self = GS_OVERVIEW_PAGE (user_data); + + gs_shell_show_app (self->shell, app); +} + +static void +gs_overview_page_decrement_action_cnt (GsOverviewPage *self) +{ + /* every job increments this */ + if (self->action_cnt == 0) { + g_warning ("action_cnt already zero!"); + return; + } + if (--self->action_cnt > 0) + return; + + /* all done */ + self->cache_valid = TRUE; + g_signal_emit (self, signals[SIGNAL_REFRESHED], 0); + self->loading_categories = FALSE; + self->loading_deployment_featured = FALSE; + self->loading_featured = FALSE; + self->loading_curated = FALSE; + self->loading_recent = FALSE; +} + +static void +gs_overview_page_get_curated_cb (GObject *source_object, + GAsyncResult *res, + gpointer user_data) +{ + GsOverviewPage *self = GS_OVERVIEW_PAGE (user_data); + GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (source_object); + guint i; + GsApp *app; + GtkWidget *tile; + g_autoptr(GError) error = NULL; + g_autoptr(GsAppList) list = NULL; + + /* get curated apps */ + 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 curated apps: %s", error->message); + goto out; + } + + /* not enough to show */ + if (gs_app_list_length (list) < N_TILES) { + g_warning ("Only %u apps for curated list, hiding", + gs_app_list_length (list)); + gtk_widget_set_visible (self->box_curated, FALSE); + gtk_widget_set_visible (self->curated_heading, FALSE); + goto out; + } + + g_assert (gs_app_list_length (list) == N_TILES); + + gs_widget_remove_all (self->box_curated, (GsRemoveFunc) gtk_flow_box_remove); + + for (i = 0; i < gs_app_list_length (list); i++) { + app = gs_app_list_index (list, i); + tile = gs_summary_tile_new (app); + g_signal_connect (tile, "clicked", + G_CALLBACK (app_tile_clicked), self); + gtk_flow_box_insert (GTK_FLOW_BOX (self->box_curated), tile, -1); + } + gtk_widget_set_visible (self->box_curated, TRUE); + gtk_widget_set_visible (self->curated_heading, TRUE); + + self->empty = FALSE; + +out: + gs_overview_page_decrement_action_cnt (self); +} + +static gint +gs_overview_page_sort_recent_cb (GsApp *app1, + GsApp *app2, + gpointer user_data) +{ + if (gs_app_get_release_date (app1) < gs_app_get_release_date (app2)) + return 1; + if (gs_app_get_release_date (app1) == gs_app_get_release_date (app2)) + return g_strcmp0 (gs_app_get_name (app1), gs_app_get_name (app2)); + return -1; +} + +static gboolean +gs_overview_page_filter_recent_cb (GsApp *app, + gpointer user_data) +{ + return (!gs_app_has_quirk (app, GS_APP_QUIRK_COMPULSORY) && + gs_app_get_kind (app) == AS_COMPONENT_KIND_DESKTOP_APP); +} + +static void +gs_overview_page_get_recent_cb (GObject *source_object, GAsyncResult *res, gpointer user_data) +{ + GsOverviewPage *self = GS_OVERVIEW_PAGE (user_data); + GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (source_object); + guint i; + GsApp *app; + GtkWidget *tile; + GtkWidget *child; + g_autoptr(GError) error = NULL; + g_autoptr(GsAppList) list = NULL; + + /* get recent apps */ + 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 recent apps: %s", error->message); + goto out; + } + + /* not enough to show */ + if (gs_app_list_length (list) < N_TILES) { + g_warning ("Only %u apps for recent list, hiding", + gs_app_list_length (list)); + gtk_widget_set_visible (self->box_recent, FALSE); + gtk_widget_set_visible (self->recent_heading, FALSE); + goto out; + } + + g_assert (gs_app_list_length (list) <= N_TILES); + + gs_widget_remove_all (self->box_recent, (GsRemoveFunc) gtk_flow_box_remove); + + for (i = 0; i < gs_app_list_length (list); i++) { + app = gs_app_list_index (list, i); + tile = gs_summary_tile_new (app); + g_signal_connect (tile, "clicked", + G_CALLBACK (app_tile_clicked), self); + child = gtk_flow_box_child_new (); + /* Manually creating the child is needed to avoid having it be + * focusable but non activatable, and then have the child + * focusable and activatable, which is annoying and confusing. + */ + gtk_widget_set_can_focus (child, FALSE); + gtk_widget_show (child); + gtk_flow_box_child_set_child (GTK_FLOW_BOX_CHILD (child), tile); + gtk_flow_box_insert (GTK_FLOW_BOX (self->box_recent), child, -1); + } + gtk_widget_set_visible (self->box_recent, TRUE); + gtk_widget_set_visible (self->recent_heading, TRUE); + + self->empty = FALSE; + +out: + gs_overview_page_decrement_action_cnt (self); +} + +static gboolean +filter_hi_res_icon (GsApp *app, gpointer user_data) +{ + g_autoptr(GIcon) icon = NULL; + GtkWidget *overview_page = GTK_WIDGET (user_data); + + /* This is the minimum icon size needed by `GsFeatureTile`. */ + icon = gs_app_get_icon_for_size (app, + 128, + gtk_widget_get_scale_factor (overview_page), + NULL); + + /* Returning TRUE means to keep the app in the list */ + return (icon != NULL); +} + +static void +gs_overview_page_get_featured_cb (GObject *source_object, + GAsyncResult *res, + gpointer user_data) +{ + GsOverviewPage *self = GS_OVERVIEW_PAGE (user_data); + GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (source_object); + g_autoptr(GError) error = NULL; + g_autoptr(GsAppList) list = NULL; + + list = gs_plugin_loader_job_process_finish (plugin_loader, res, &error); + if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_CANCELLED) || + g_error_matches (error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) + goto out; + + if (self->featured_overwritten) { + g_debug ("Skipping set of featured apps, because being overwritten"); + goto out; + } + + if (list == NULL || gs_app_list_length (list) == 0) { + g_warning ("failed to get featured apps: %s", + (error != NULL) ? error->message : "no apps to show"); + gtk_widget_set_visible (self->featured_carousel, FALSE); + goto out; + } + + gtk_widget_set_visible (self->featured_carousel, gs_app_list_length (list) > 0); + gs_featured_carousel_set_apps (GS_FEATURED_CAROUSEL (self->featured_carousel), list); + + self->empty = self->empty && (gs_app_list_length (list) == 0); + +out: + gs_overview_page_decrement_action_cnt (self); +} + +static void +gs_overview_page_get_deployment_featured_cb (GObject *source_object, + GAsyncResult *res, + gpointer user_data) +{ + GsOverviewPage *self = GS_OVERVIEW_PAGE (user_data); + GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (source_object); + guint i; + GsApp *app; + GtkWidget *tile; + g_autoptr(GError) error = NULL; + g_autoptr(GsAppList) list = NULL; + + /* get deployment-featured apps */ + 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_warning ("failed to get deployment-featured apps: %s", error->message); + goto out; + } + + /* not enough to show */ + if (gs_app_list_length (list) < N_TILES) { + g_warning ("Only %u apps for deployment-featured list, hiding", + gs_app_list_length (list)); + gtk_widget_set_visible (self->box_deployment_featured, FALSE); + gtk_widget_set_visible (self->deployment_featured_heading, FALSE); + goto out; + } + + g_assert (gs_app_list_length (list) == N_TILES); + gs_widget_remove_all (self->box_deployment_featured, (GsRemoveFunc) gtk_flow_box_remove); + + for (i = 0; i < gs_app_list_length (list); i++) { + app = gs_app_list_index (list, i); + tile = gs_summary_tile_new (app); + g_signal_connect (tile, "clicked", + G_CALLBACK (app_tile_clicked), self); + gtk_flow_box_insert (GTK_FLOW_BOX (self->box_deployment_featured), tile, -1); + } + gtk_widget_set_visible (self->box_deployment_featured, TRUE); + gtk_widget_set_visible (self->deployment_featured_heading, TRUE); + + self->empty = FALSE; + + out: + gs_overview_page_decrement_action_cnt (self); +} + +static void +category_tile_clicked (GsCategoryTile *tile, gpointer data) +{ + GsOverviewPage *self = GS_OVERVIEW_PAGE (data); + GsCategory *category; + + category = gs_category_tile_get_category (tile); + gs_shell_show_category (self->shell, category); +} + +typedef struct { + GsOverviewPage *page; /* (unowned) */ + GsPluginJobListCategories *job; /* (owned) */ +} GetCategoriesData; + +static void +get_categories_data_free (GetCategoriesData *data) +{ + g_clear_object (&data->job); + g_free (data); +} + +G_DEFINE_AUTOPTR_CLEANUP_FUNC (GetCategoriesData, get_categories_data_free) + +static void +gs_overview_page_get_categories_cb (GObject *source_object, + GAsyncResult *res, + gpointer user_data) +{ + g_autoptr(GetCategoriesData) data = g_steal_pointer (&user_data); + GsOverviewPage *self = GS_OVERVIEW_PAGE (data->page); + GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (source_object); + guint i; + GsCategory *cat; + GtkFlowBox *flowbox; + GtkWidget *tile; + guint added_cnt = 0; + g_autoptr(GError) error = NULL; + GPtrArray *list = NULL; /* (element-type GsCategory) */ + + if (!gs_plugin_loader_job_action_finish (plugin_loader, res, &error)) { + 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 categories: %s", error->message); + goto out; + } + + list = gs_plugin_job_list_categories_get_result_list (data->job); + + gs_widget_remove_all (self->flowbox_categories, (GsRemoveFunc) gtk_flow_box_remove); + gs_widget_remove_all (self->flowbox_iconless_categories, (GsRemoveFunc) gtk_flow_box_remove); + + /* Add categories to the flowboxes. Categories with icons are deemed to + * be visually important, and are listed near the top of the page. + * Categories without icons are listed in a separate flowbox at the + * bottom of the page. Typically they are addons. */ + for (i = 0; i < list->len; i++) { + cat = GS_CATEGORY (g_ptr_array_index (list, i)); + if (gs_category_get_size (cat) == 0) + continue; + tile = gs_category_tile_new (cat); + g_signal_connect (tile, "clicked", + G_CALLBACK (category_tile_clicked), self); + + if (gs_category_get_icon_name (cat) != NULL) + flowbox = GTK_FLOW_BOX (self->flowbox_categories); + else + flowbox = GTK_FLOW_BOX (self->flowbox_iconless_categories); + + gtk_flow_box_insert (flowbox, tile, -1); + gtk_widget_set_can_focus (gtk_widget_get_parent (tile), FALSE); + added_cnt++; + + /* we save these for the 'More...' buttons */ + g_hash_table_insert (self->category_hash, + g_strdup (gs_category_get_id (cat)), + g_object_ref (cat)); + } + +out: + /* Show the heading for the iconless categories iff there are any. */ + gtk_widget_set_visible (self->iconless_categories_heading, + gtk_flow_box_get_child_at_index (GTK_FLOW_BOX (self->flowbox_iconless_categories), 0) != NULL); + + if (added_cnt > 0) + self->empty = FALSE; + + gs_overview_page_decrement_action_cnt (self); +} + +static void +refresh_third_party_repo (GsOverviewPage *self) +{ + gtk_widget_set_visible (self->infobar_third_party, self->third_party_needs_question); +} + +static gboolean +is_fedora (void) +{ + const gchar *id = NULL; + g_autoptr(GsOsRelease) os_release = NULL; + + os_release = gs_os_release_new (NULL); + if (os_release == NULL) + return FALSE; + + id = gs_os_release_get_id (os_release); + if (g_strcmp0 (id, "fedora") == 0) + return TRUE; + + return FALSE; +} + +static void +fedora_third_party_query_done_cb (GObject *source_object, + GAsyncResult *result, + gpointer user_data) +{ + GsFedoraThirdPartyState state = GS_FEDORA_THIRD_PARTY_STATE_UNKNOWN; + g_autoptr(GsOverviewPage) self = user_data; + g_autoptr(GError) error = NULL; + + if (!gs_fedora_third_party_query_finish (GS_FEDORA_THIRD_PARTY (source_object), result, &state, &error)) { + if (g_error_matches (error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) + return; + g_warning ("Failed to query 'fedora-third-party': %s", error->message); + } else { + self->third_party_needs_question = state == GS_FEDORA_THIRD_PARTY_STATE_ASK; + } + + refresh_third_party_repo (self); +} + +static void +reload_third_party_repo (GsOverviewPage *self) +{ + /* Fedora-specific functionality */ + if (!is_fedora ()) + return; + + if (!gs_fedora_third_party_is_available (self->third_party)) + return; + + gs_fedora_third_party_query (self->third_party, self->cancellable, fedora_third_party_query_done_cb, g_object_ref (self)); +} + +static void +fedora_third_party_enable_done_cb (GObject *source_object, + GAsyncResult *result, + gpointer user_data) +{ + g_autoptr(GsOverviewPage) self = user_data; + g_autoptr(GError) error = NULL; + + if (!gs_fedora_third_party_switch_finish (GS_FEDORA_THIRD_PARTY (source_object), result, &error)) { + if (g_error_matches (error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) + return; + g_warning ("Failed to enable 'fedora-third-party': %s", error->message); + } + + refresh_third_party_repo (self); +} + +static void +fedora_third_party_enable (GsOverviewPage *self) +{ + gs_fedora_third_party_switch (self->third_party, TRUE, FALSE, self->cancellable, fedora_third_party_enable_done_cb, g_object_ref (self)); +} + +static void +fedora_third_party_disable_done_cb (GObject *source_object, + GAsyncResult *result, + gpointer user_data) +{ + g_autoptr(GsOverviewPage) self = user_data; + g_autoptr(GError) error = NULL; + + if (!gs_fedora_third_party_opt_out_finish (GS_FEDORA_THIRD_PARTY (source_object), result, &error)) { + if (g_error_matches (error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) + return; + g_warning ("Failed to disable 'fedora-third-party': %s", error->message); + } + + refresh_third_party_repo (self); +} + +static void +fedora_third_party_disable (GsOverviewPage *self) +{ + gs_fedora_third_party_opt_out (self->third_party, self->cancellable, fedora_third_party_disable_done_cb, g_object_ref (self)); +} + +static gchar * +gs_overview_page_dup_deployment_featured_filename (void) +{ + g_autofree gchar *filename = NULL; + const gchar * const *sys_dirs; + + #define FILENAME "deployment-featured.ini" + + filename = g_build_filename (SYSCONFDIR, "gnome-software", FILENAME, NULL); + if (g_file_test (filename, G_FILE_TEST_IS_REGULAR)) { + g_debug ("Found '%s'", filename); + return g_steal_pointer (&filename); + } + g_debug ("File '%s' does not exist, trying next", filename); + g_clear_pointer (&filename, g_free); + + sys_dirs = g_get_system_config_dirs (); + + for (guint i = 0; sys_dirs != NULL && sys_dirs[i]; i++) { + g_autofree gchar *tmp = g_build_filename (sys_dirs[i], "gnome-software", FILENAME, NULL); + if (g_file_test (tmp, G_FILE_TEST_IS_REGULAR)) { + g_debug ("Found '%s'", tmp); + return g_steal_pointer (&tmp); + } + g_debug ("File '%s' does not exist, trying next", tmp); + } + + sys_dirs = g_get_system_data_dirs (); + + for (guint i = 0; sys_dirs != NULL && sys_dirs[i]; i++) { + g_autofree gchar *tmp = g_build_filename (sys_dirs[i], "gnome-software", FILENAME, NULL); + if (g_file_test (tmp, G_FILE_TEST_IS_REGULAR)) { + g_debug ("Found '%s'", tmp); + return g_steal_pointer (&tmp); + } + g_debug ("File '%s' does not exist, %s", tmp, sys_dirs[i + 1] ? "trying next" : "no more files to try"); + } + + #undef FILENAME + + return NULL; +} + +static gboolean +gs_overview_page_read_deployment_featured_keys (gchar **out_label, + gchar ***out_deployment_featured) +{ + g_autoptr(GKeyFile) key_file = NULL; + g_autoptr(GPtrArray) array = NULL; + g_auto(GStrv) selector = NULL; + g_autoptr(GError) error = NULL; + g_autofree gchar *filename = NULL; + + filename = gs_overview_page_dup_deployment_featured_filename (); + + if (filename == NULL) + return FALSE; + + key_file = g_key_file_new (); + if (!g_key_file_load_from_file (key_file, filename, G_KEY_FILE_NONE, &error)) { + g_debug ("Failed to read '%s': %s", filename, error->message); + return FALSE; + } + + *out_label = g_key_file_get_locale_string (key_file, "Deployment Featured Apps", "Title", NULL, NULL); + + if (*out_label == NULL || **out_label == '\0') { + g_clear_pointer (out_label, g_free); + return FALSE; + } + + selector = g_key_file_get_string_list (key_file, "Deployment Featured Apps", "Selector", NULL, NULL); + + /* Sanitize the content */ + if (selector == NULL) { + g_clear_pointer (out_label, g_free); + return FALSE; + } + + array = g_ptr_array_sized_new (g_strv_length (selector) + 1); + + for (guint i = 0; selector[i] != NULL; i++) { + const gchar *value = g_strstrip (selector[i]); + if (*value != '\0') + g_ptr_array_add (array, g_strdup (value)); + } + + if (array->len == 0) { + g_clear_pointer (out_label, g_free); + return FALSE; + } + + g_ptr_array_add (array, NULL); + + *out_deployment_featured = (gchar **) g_ptr_array_free (g_steal_pointer (&array), FALSE); + + return TRUE; +} + +static void +gs_overview_page_load (GsOverviewPage *self) +{ + self->empty = TRUE; + + if (!self->loading_featured) { + g_autoptr(GsPluginJob) plugin_job = NULL; + g_autoptr(GsAppQuery) query = NULL; + GsPluginListAppsFlags flags = GS_PLUGIN_LIST_APPS_FLAGS_INTERACTIVE; + + query = gs_app_query_new ("is-featured", GS_APP_QUERY_TRISTATE_TRUE, + "max-results", 5, + "refine-flags", GS_PLUGIN_REFINE_FLAGS_REQUIRE_ICON, + "dedupe-flags", GS_APP_LIST_FILTER_FLAG_PREFER_INSTALLED | + GS_APP_LIST_FILTER_FLAG_KEY_ID_PROVIDES, + "filter-func", filter_hi_res_icon, + "filter-user-data", self, + NULL); + + plugin_job = gs_plugin_job_list_apps_new (query, flags); + + self->loading_featured = TRUE; + gs_plugin_loader_job_process_async (self->plugin_loader, + plugin_job, + self->cancellable, + gs_overview_page_get_featured_cb, + self); + self->action_cnt++; + } + + if (!self->loading_deployment_featured && self->deployment_featured != NULL) { + g_autoptr(GsPluginJob) plugin_job = NULL; + g_autoptr(GsAppQuery) query = NULL; + GsPluginListAppsFlags flags = GS_PLUGIN_LIST_APPS_FLAGS_INTERACTIVE; + + self->loading_deployment_featured = TRUE; + + query = gs_app_query_new ("deployment-featured", self->deployment_featured, + "max-results", N_TILES, + "refine-flags", GS_PLUGIN_REFINE_FLAGS_REQUIRE_RATING | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_CATEGORIES | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_ICON, + "dedupe-flags", GS_APP_LIST_FILTER_FLAG_PREFER_INSTALLED | + GS_APP_LIST_FILTER_FLAG_KEY_ID_PROVIDES, + NULL); + + plugin_job = gs_plugin_job_list_apps_new (query, flags); + + gs_plugin_loader_job_process_async (self->plugin_loader, + plugin_job, + self->cancellable, + gs_overview_page_get_deployment_featured_cb, + self); + self->action_cnt++; + } + + if (!self->loading_curated) { + g_autoptr(GsPluginJob) plugin_job = NULL; + g_autoptr(GsAppQuery) query = NULL; + GsPluginListAppsFlags flags = GS_PLUGIN_LIST_APPS_FLAGS_INTERACTIVE; + + query = gs_app_query_new ("is-curated", GS_APP_QUERY_TRISTATE_TRUE, + "max-results", N_TILES, + "refine-flags", GS_PLUGIN_REFINE_FLAGS_REQUIRE_RATING | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_CATEGORIES | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_ICON, + "dedupe-flags", GS_APP_LIST_FILTER_FLAG_PREFER_INSTALLED | + GS_APP_LIST_FILTER_FLAG_KEY_ID_PROVIDES, + NULL); + + plugin_job = gs_plugin_job_list_apps_new (query, flags); + + self->loading_curated = TRUE; + gs_plugin_loader_job_process_async (self->plugin_loader, + plugin_job, + self->cancellable, + gs_overview_page_get_curated_cb, + self); + self->action_cnt++; + } + + if (!self->loading_recent) { + g_autoptr(GsPluginJob) plugin_job = NULL; + g_autoptr(GDateTime) now = NULL; + g_autoptr(GDateTime) released_since = NULL; + g_autoptr(GsAppQuery) query = NULL; + GsPluginListAppsFlags flags = GS_PLUGIN_LIST_APPS_FLAGS_INTERACTIVE; + + now = g_date_time_new_now_local (); + released_since = g_date_time_add_seconds (now, -(60 * 60 * 24 * 30)); + query = gs_app_query_new ("released-since", released_since, + "max-results", N_TILES, + "refine-flags", GS_PLUGIN_REFINE_FLAGS_REQUIRE_RATING | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_ICON, + "dedupe-flags", GS_APP_LIST_FILTER_FLAG_KEY_ID | + GS_APP_LIST_FILTER_FLAG_PREFER_INSTALLED | + GS_APP_LIST_FILTER_FLAG_KEY_ID_PROVIDES, + "sort-func", gs_overview_page_sort_recent_cb, + "filter-func", gs_overview_page_filter_recent_cb, + NULL); + + plugin_job = gs_plugin_job_list_apps_new (query, flags); + + self->loading_recent = TRUE; + gs_plugin_loader_job_process_async (self->plugin_loader, + plugin_job, + self->cancellable, + gs_overview_page_get_recent_cb, + self); + self->action_cnt++; + } + + if (!self->loading_categories) { + g_autoptr(GsPluginJob) plugin_job = NULL; + GsPluginRefineCategoriesFlags flags = GS_PLUGIN_REFINE_CATEGORIES_FLAGS_INTERACTIVE | + GS_PLUGIN_REFINE_CATEGORIES_FLAGS_SIZE; + g_autoptr(GetCategoriesData) data = NULL; + + self->loading_categories = TRUE; + plugin_job = gs_plugin_job_list_categories_new (flags); + + data = g_new0 (GetCategoriesData, 1); + data->page = self; + data->job = g_object_ref (GS_PLUGIN_JOB_LIST_CATEGORIES (plugin_job)); + + gs_plugin_loader_job_process_async (self->plugin_loader, plugin_job, + self->cancellable, gs_overview_page_get_categories_cb, + g_steal_pointer (&data)); + self->action_cnt++; + } + + reload_third_party_repo (self); +} + +static void +gs_overview_page_reload (GsPage *page) +{ + GsOverviewPage *self = GS_OVERVIEW_PAGE (page); + self->featured_overwritten = FALSE; + gs_overview_page_invalidate (self); + gs_overview_page_load (self); +} + +static void +gs_overview_page_switch_to (GsPage *page) +{ + GsOverviewPage *self = GS_OVERVIEW_PAGE (page); + + if (gs_shell_get_mode (self->shell) != GS_SHELL_MODE_OVERVIEW) { + g_warning ("Called switch_to(overview) when in mode %s", + gs_shell_get_mode_string (self->shell)); + return; + } + + gs_grab_focus_when_mapped (self->scrolledwindow_overview); + + if (self->cache_valid || self->action_cnt > 0) + return; + gs_overview_page_load (self); +} + +static void +gs_overview_page_refresh_cb (GsPluginLoader *plugin_loader, + GAsyncResult *result, + GsOverviewPage *self) +{ + gboolean success; + g_autoptr(GError) error = NULL; + + success = gs_plugin_loader_job_action_finish (plugin_loader, result, &error); + if (!success && + !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 refresh: %s", error->message); + + if (success) + g_signal_emit_by_name (self->plugin_loader, "reload", 0, NULL); +} + +static void +third_party_response_cb (GtkInfoBar *info_bar, + gint response_id, + GsOverviewPage *self) +{ + g_autoptr(GsPluginJob) plugin_job = NULL; + + if (response_id == GTK_RESPONSE_YES) + fedora_third_party_enable (self); + else + fedora_third_party_disable (self); + + self->third_party_needs_question = FALSE; + refresh_third_party_repo (self); + + plugin_job = gs_plugin_job_refresh_metadata_new (1, + GS_PLUGIN_REFRESH_METADATA_FLAGS_NONE); + gs_plugin_loader_job_process_async (self->plugin_loader, plugin_job, + self->cancellable, + (GAsyncReadyCallback) gs_overview_page_refresh_cb, + self); +} + +static gboolean +gs_overview_page_setup (GsPage *page, + GsShell *shell, + GsPluginLoader *plugin_loader, + GCancellable *cancellable, + GError **error) +{ + GsOverviewPage *self = GS_OVERVIEW_PAGE (page); + GtkWidget *tile; + gint i; + g_autofree gchar *text = NULL; + g_autofree gchar *link = NULL; + + g_return_val_if_fail (GS_IS_OVERVIEW_PAGE (self), TRUE); + + self->plugin_loader = g_object_ref (plugin_loader); + self->third_party = gs_fedora_third_party_new (); + self->cancellable = g_object_ref (cancellable); + self->category_hash = g_hash_table_new_full (g_str_hash, g_str_equal, + g_free, (GDestroyNotify) g_object_unref); + + link = g_strdup_printf ("<a href=\"%s\">%s</a>", + "https://docs.fedoraproject.org/en-US/workstation-working-group/third-party-repos/", + /* Translators: This is a clickable link on the third party repositories info bar. It's + part of a constructed sentence: "Provides access to additional software from [selected external sources]. + Some proprietary software is included." */ + _("selected external sources")); + /* Translators: This is the third party repositories info bar. The %s is replaced with "selected external sources" link. */ + text = g_strdup_printf (_("Provides access to additional software from %s. Some proprietary software is included."), + link); + gtk_label_set_markup (GTK_LABEL (self->label_third_party), text); + + /* create info bar if not already dismissed in initial-setup */ + refresh_third_party_repo (self); + reload_third_party_repo (self); + gtk_info_bar_add_button (GTK_INFO_BAR (self->infobar_third_party), + /* TRANSLATORS: button to turn on third party software repositories */ + _("Enable"), GTK_RESPONSE_YES); + g_signal_connect (self->infobar_third_party, "response", + G_CALLBACK (third_party_response_cb), self); + + /* avoid a ref cycle */ + self->shell = shell; + + for (i = 0; i < N_TILES; i++) { + tile = gs_summary_tile_new (NULL); + gtk_flow_box_insert (GTK_FLOW_BOX (self->box_curated), tile, -1); + } + + for (i = 0; i < N_TILES; i++) { + tile = gs_summary_tile_new (NULL); + gtk_flow_box_insert (GTK_FLOW_BOX (self->box_recent), tile, -1); + } + + return TRUE; +} + +static void +refreshed_cb (GsOverviewPage *self, gpointer user_data) +{ + if (self->empty) { + gtk_stack_set_visible_child_name (GTK_STACK (self->stack_overview), "no-results"); + } else { + gtk_stack_set_visible_child_name (GTK_STACK (self->stack_overview), "overview"); + } +} + +static void +gs_overview_page_init (GsOverviewPage *self) +{ + g_autofree gchar *tmp_label = NULL; + + gtk_widget_init_template (GTK_WIDGET (self)); + + gs_featured_carousel_set_apps (GS_FEATURED_CAROUSEL (self->featured_carousel), NULL); + + g_signal_connect (self, "refreshed", G_CALLBACK (refreshed_cb), self); + + if (gs_overview_page_read_deployment_featured_keys (&tmp_label, &self->deployment_featured)) + gtk_label_set_text (GTK_LABEL (self->deployment_featured_heading), tmp_label); +} + +static void +gs_overview_page_get_property (GObject *object, + guint prop_id, + GValue *value, + GParamSpec *pspec) +{ + GsOverviewPage *self = GS_OVERVIEW_PAGE (object); + + switch ((GsOverviewPageProperty) prop_id) { + case PROP_VADJUSTMENT: + g_value_set_object (value, gtk_scrolled_window_get_vadjustment (GTK_SCROLLED_WINDOW (self->scrolledwindow_overview))); + break; + case PROP_TITLE: + /* Translators: This is the title of the main page of the UI. */ + g_value_set_string (value, _("Explore")); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_overview_page_set_property (GObject *object, + guint prop_id, + const GValue *value, + GParamSpec *pspec) +{ + switch ((GsOverviewPageProperty) prop_id) { + case PROP_VADJUSTMENT: + case PROP_TITLE: + /* Read only. */ + g_assert_not_reached (); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_overview_page_dispose (GObject *object) +{ + GsOverviewPage *self = GS_OVERVIEW_PAGE (object); + + g_clear_object (&self->plugin_loader); + g_clear_object (&self->cancellable); + g_clear_object (&self->third_party); + g_clear_pointer (&self->category_hash, g_hash_table_unref); + g_clear_pointer (&self->deployment_featured, g_strfreev); + + G_OBJECT_CLASS (gs_overview_page_parent_class)->dispose (object); +} + +static void +gs_overview_page_class_init (GsOverviewPageClass *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_overview_page_get_property; + object_class->set_property = gs_overview_page_set_property; + object_class->dispose = gs_overview_page_dispose; + + page_class->switch_to = gs_overview_page_switch_to; + page_class->reload = gs_overview_page_reload; + page_class->setup = gs_overview_page_setup; + + g_object_class_override_property (object_class, PROP_VADJUSTMENT, "vadjustment"); + g_object_class_override_property (object_class, PROP_TITLE, "title"); + + signals [SIGNAL_REFRESHED] = + g_signal_new ("refreshed", + G_TYPE_FROM_CLASS (object_class), G_SIGNAL_RUN_LAST, + 0, NULL, NULL, g_cclosure_marshal_VOID__VOID, + G_TYPE_NONE, 0); + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-overview-page.ui"); + + gtk_widget_class_bind_template_child (widget_class, GsOverviewPage, infobar_third_party); + gtk_widget_class_bind_template_child (widget_class, GsOverviewPage, label_third_party); + gtk_widget_class_bind_template_child (widget_class, GsOverviewPage, featured_carousel); + gtk_widget_class_bind_template_child (widget_class, GsOverviewPage, box_overview); + gtk_widget_class_bind_template_child (widget_class, GsOverviewPage, box_curated); + gtk_widget_class_bind_template_child (widget_class, GsOverviewPage, box_recent); + gtk_widget_class_bind_template_child (widget_class, GsOverviewPage, box_deployment_featured); + gtk_widget_class_bind_template_child (widget_class, GsOverviewPage, flowbox_categories); + gtk_widget_class_bind_template_child (widget_class, GsOverviewPage, flowbox_iconless_categories); + gtk_widget_class_bind_template_child (widget_class, GsOverviewPage, iconless_categories_heading); + gtk_widget_class_bind_template_child (widget_class, GsOverviewPage, curated_heading); + gtk_widget_class_bind_template_child (widget_class, GsOverviewPage, recent_heading); + gtk_widget_class_bind_template_child (widget_class, GsOverviewPage, deployment_featured_heading); + gtk_widget_class_bind_template_child (widget_class, GsOverviewPage, scrolledwindow_overview); + gtk_widget_class_bind_template_child (widget_class, GsOverviewPage, stack_overview); + gtk_widget_class_bind_template_callback (widget_class, featured_carousel_app_clicked_cb); +} + +GsOverviewPage * +gs_overview_page_new (void) +{ + return GS_OVERVIEW_PAGE (g_object_new (GS_TYPE_OVERVIEW_PAGE, NULL)); +} + +void +gs_overview_page_override_featured (GsOverviewPage *self, + GsApp *app) +{ + g_autoptr(GsAppList) list = NULL; + + g_return_if_fail (GS_IS_OVERVIEW_PAGE (self)); + g_return_if_fail (GS_IS_APP (app)); + + self->featured_overwritten = TRUE; + + list = gs_app_list_new (); + gs_app_list_add (list, app); + gs_featured_carousel_set_apps (GS_FEATURED_CAROUSEL (self->featured_carousel), list); +} diff --git a/src/gs-overview-page.h b/src/gs-overview-page.h new file mode 100644 index 0000000..717502b --- /dev/null +++ b/src/gs-overview-page.h @@ -0,0 +1,25 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2015-2017 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include "gs-page.h" + +G_BEGIN_DECLS + +#define GS_TYPE_OVERVIEW_PAGE (gs_overview_page_get_type ()) + +G_DECLARE_FINAL_TYPE (GsOverviewPage, gs_overview_page, GS, OVERVIEW_PAGE, GsPage) + +GsOverviewPage *gs_overview_page_new (void); +void gs_overview_page_override_featured + (GsOverviewPage *self, + GsApp *app); + +G_END_DECLS diff --git a/src/gs-overview-page.ui b/src/gs-overview-page.ui new file mode 100644 index 0000000..54c9fe8 --- /dev/null +++ b/src/gs-overview-page.ui @@ -0,0 +1,230 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <requires lib="gtk+" version="3.10"/> + <requires lib="handy" version="1.0"/> + <template class="GsOverviewPage" parent="GsPage"> + <accessibility> + <property name="label" translatable="yes">Overview page</property> + </accessibility> + <child> + <object class="GtkStack" id="stack_overview"> + + <child> + <object class="GtkStackPage"> + <property name="name">overview</property> + <property name="child"> + <object class="GtkBox"> + <property name="orientation">vertical</property> + + <child> + <object class="GtkInfoBar" id="infobar_third_party"> + <property name="show_close_button">True</property> + <child> + <object class="GtkBox"> + <property name="orientation">vertical</property> + <property name="spacing">3</property> + <property name="margin-top">6</property> + <property name="margin-bottom">6</property> + <property name="margin-start">6</property> + <property name="margin-end">6</property> + <property name="halign">start</property> + <property name="hexpand">True</property> + <child> + <object class="GtkLabel" id="label_third_party_title"> + <property name="halign">start</property> + <property name="label" translatable="yes">Enable Third Party Software Repositories?</property> + <attributes> + <attribute name="weight" value="bold"/> + </attributes> + </object> + </child> + <child> + <object class="GtkLabel" id="label_third_party"> + <property name="halign">start</property> + <property name="label">Provides access to additional software.</property> + <property name="wrap">True</property> + <property name="wrap_mode">word-char</property> + <property name="xalign">0</property> + </object> + </child> + </object> + </child> + </object> + </child> + + <child> + <object class="GtkScrolledWindow" id="scrolledwindow_overview"> + <property name="can_focus">True</property> + <property name="hscrollbar_policy">never</property> + <property name="vscrollbar_policy">automatic</property> + <child> + <object class="GtkViewport" id="viewport_overview"> + <property name="scroll-to-focus">True</property> + <property name="hexpand">True</property> + <property name="vexpand">True</property> + <child> + <object class="AdwClamp"> + <!-- We use the same sizes as the category page. --> + <property name="maximum-size">1000</property> + <property name="tightening-threshold">600</property> + <child> + <object class="GtkBox" id="box_overview"> + <property name="halign">center</property> + <property name="hexpand">False</property> + <property name="orientation">vertical</property> + <property name="margin-top">24</property> + <property name="margin-bottom">36</property> + <property name="margin-start">12</property> + <property name="margin-end">12</property> + <property name="valign">start</property> + <property name="spacing">6</property> + + <child> + <object class="GsFeaturedCarousel" id="featured_carousel"> + <property name="height-request">318</property> + <property name="valign">start</property> + <signal name="app-clicked" handler="featured_carousel_app_clicked_cb"/> + </object> + </child> + + <child> + <object class="GtkFlowBox" id="flowbox_categories"> + <property name="margin-top">21</property> + <property name="margin-bottom">6</property> + <property name="row_spacing">14</property> + <property name="column_spacing">14</property> + <property name="homogeneous">True</property> + <property name="min_children_per_line">2</property> + <property name="max_children_per_line">3</property> + <property name="selection_mode">none</property> + </object> + </child> + + <child> + <object class="GtkLabel" id="curated_heading"> + <property name="xalign">0</property> + <property name="label" translatable="yes" comments="Translators: This is a heading for software which has been featured ('picked') by the distribution.">Editor’s Choice</property> + <property name="margin-top">21</property> + <property name="margin-bottom">6</property> + <style> + <class name="heading"/> + </style> + </object> + </child> + <child> + <object class="GtkFlowBox" id="box_curated"> + <property name="homogeneous">True</property> + <property name="column-spacing">14</property> + <property name="row-spacing">14</property> + <property name="valign">start</property> + <accessibility> + <relation name="labelled-by">curated_heading</relation> + </accessibility> + </object> + </child> + + <child> + <object class="GtkLabel" id="recent_heading"> + <property name="xalign">0</property> + <property name="label" translatable="yes" comments="Translators: This is a heading for software which has been recently released upstream.">New & Updated</property> + <property name="margin-top">21</property> + <property name="margin-bottom">6</property> + <style> + <class name="heading"/> + </style> + </object> + </child> + <child> + <object class="GtkFlowBox" id="box_recent"> + <property name="homogeneous">True</property> + <property name="column-spacing">14</property> + <property name="row-spacing">14</property> + <property name="valign">start</property> + <property name="selection-mode">none</property> + <accessibility> + <relation name="labelled-by">recent_heading</relation> + </accessibility> + </object> + </child> + + <child> + <object class="GtkLabel" id="deployment_featured_heading"> + <property name="visible">False</property> + <property name="xalign">0</property> + <property name="label">Available for Deployment</property> <!-- placeholder, set in the code --> + <property name="margin-top">21</property> + <property name="margin-bottom">6</property> + <style> + <class name="heading"/> + </style> + </object> + </child> + <child> + <object class="GtkFlowBox" id="box_deployment_featured"> + <property name="visible">False</property> + <property name="row_spacing">14</property> + <property name="column_spacing">14</property> + <property name="homogeneous">True</property> + <property name="min_children_per_line">2</property> + <property name="max_children_per_line">3</property> + <property name="selection_mode">none</property> + <accessibility> + <relation name="labelled-by">deployment_featured_heading</relation> + </accessibility> + </object> + </child> + + <child> + <object class="GtkLabel" id="iconless_categories_heading"> + <property name="xalign">0</property> + <property name="label" translatable="yes" comments="Translators: This is a heading for a list of categories.">Other Categories</property> + <property name="margin-top">21</property> + <property name="margin-bottom">6</property> + <style> + <class name="heading"/> + </style> + </object> + </child> + <child> + <object class="GtkFlowBox" id="flowbox_iconless_categories"> + <property name="row_spacing">14</property> + <property name="column_spacing">14</property> + <property name="homogeneous">True</property> + <property name="min_children_per_line">2</property> + <property name="max_children_per_line">3</property> + <property name="selection_mode">none</property> + <accessibility> + <relation name="labelled-by">iconless_categories_heading</relation> + </accessibility> + </object> + </child> + </object> + </child> + + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </property> + </object> + </child> + + <child> + <object class="GtkStackPage"> + <property name="name">no-results</property> + <property name="child"> + <object class="AdwStatusPage" id="noresults_grid_overview"> + <property name="icon_name">org.gnome.Software-symbolic</property> + <property name="title" translatable="yes">No Application Data Found</property> + </object> + </property> + </object> + </child> + + </object> + </child> + </template> +</interface> diff --git a/src/gs-page.c b/src/gs-page.c new file mode 100644 index 0000000..7a465be --- /dev/null +++ b/src/gs-page.c @@ -0,0 +1,857 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2015-2016 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include <string.h> +#include <glib/gi18n.h> + +#include "gs-application.h" +#include "gs-download-utils.h" +#include "gs-page.h" +#include "gs-common.h" +#include "gs-screenshot-image.h" + +typedef struct +{ + GsPluginLoader *plugin_loader; + GsShell *shell; + GtkWidget *header_start_widget; + GtkWidget *header_end_widget; + gboolean is_active; +} GsPagePrivate; + +G_DEFINE_ABSTRACT_TYPE_WITH_PRIVATE (GsPage, gs_page, GTK_TYPE_WIDGET) + +typedef enum { + PROP_TITLE = 1, + PROP_COUNTER, + PROP_VADJUSTMENT, +} GsPageProperty; + +static GParamSpec *obj_props[PROP_VADJUSTMENT + 1] = { NULL, }; + +GsShell * +gs_page_get_shell (GsPage *page) +{ + GsPagePrivate *priv = gs_page_get_instance_private (page); + return priv->shell; +} + +typedef struct { + GsApp *app; + GsPage *page; + GCancellable *cancellable; + gulong notify_quirk_id; + GtkWidget *button_install; + GsPluginAction action; + GsShellInteraction interaction; + gboolean propagate_error; +} GsPageHelper; + +static void +gs_page_helper_free (GsPageHelper *helper) +{ + if (helper->notify_quirk_id > 0) + g_signal_handler_disconnect (helper->app, helper->notify_quirk_id); + if (helper->app != NULL) + g_object_unref (helper->app); + if (helper->page != NULL) + g_object_unref (helper->page); + if (helper->cancellable != NULL) + g_object_unref (helper->cancellable); + g_slice_free (GsPageHelper, helper); +} + +G_DEFINE_AUTOPTR_CLEANUP_FUNC(GsPageHelper, gs_page_helper_free); + +static void +gs_page_update_app_response_close_cb (GtkDialog *dialog, gint response, gpointer user_data) +{ + gtk_window_destroy (GTK_WINDOW (dialog)); +} + +static void +gs_page_show_update_message (GsPageHelper *helper, AsScreenshot *ss) +{ + GsPagePrivate *priv = gs_page_get_instance_private (helper->page); + GPtrArray *images; + GtkWidget *dialog; + g_autofree gchar *escaped = NULL; + + dialog = gtk_message_dialog_new (GTK_WINDOW (gtk_widget_get_ancestor (GTK_WIDGET (helper->page), GTK_TYPE_WINDOW)), + GTK_DIALOG_MODAL | + GTK_DIALOG_USE_HEADER_BAR, + GTK_MESSAGE_INFO, + GTK_BUTTONS_OK, + "%s", gs_app_get_name (helper->app)); + escaped = g_markup_escape_text (as_screenshot_get_caption (ss), -1); + gtk_message_dialog_format_secondary_text (GTK_MESSAGE_DIALOG (dialog), + "%s", escaped); + + /* image is optional */ + images = as_screenshot_get_images (ss); + if (images->len) { + GtkWidget *content_area; + GtkWidget *ssimg; + g_autoptr(SoupSession) soup_session = NULL; + + /* load screenshot */ + soup_session = gs_build_soup_session (); + ssimg = gs_screenshot_image_new (soup_session); + gs_screenshot_image_set_screenshot (GS_SCREENSHOT_IMAGE (ssimg), ss); + gs_screenshot_image_set_size (GS_SCREENSHOT_IMAGE (ssimg), 400, 225); + gs_screenshot_image_load_async (GS_SCREENSHOT_IMAGE (ssimg), + helper->cancellable); + gtk_widget_set_margin_start (ssimg, 24); + gtk_widget_set_margin_end (ssimg, 24); + content_area = gtk_dialog_get_content_area (GTK_DIALOG (dialog)); + gtk_box_append (GTK_BOX (content_area), ssimg); + } + + /* handle this async */ + g_signal_connect (dialog, "response", + G_CALLBACK (gs_page_update_app_response_close_cb), helper); + gs_shell_modal_dialog_present (priv->shell, GTK_WINDOW (dialog)); +} + +static void +gs_page_app_installed_cb (GObject *source, + GAsyncResult *res, + gpointer user_data) +{ + g_autoptr(GsPageHelper) helper = (GsPageHelper *) user_data; + GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (source); + GsPage *page = helper->page; + gboolean ret; + g_autoptr(GError) error = NULL; + + ret = gs_plugin_loader_job_action_finish (plugin_loader, + res, + &error); + + gs_application_emit_install_resources_done (GS_APPLICATION (g_application_get_default ()), NULL, error); + + if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_CANCELLED) || + g_error_matches (error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) { + g_debug ("App install cancelled with error: %s", error->message); + return; + } + if (!ret) { + if (helper->propagate_error) { + gs_plugin_loader_claim_error (plugin_loader, + NULL, + helper->action, + helper->app, + helper->interaction == GS_SHELL_INTERACTION_FULL, + error); + } else { + g_warning ("failed to install %s: %s", gs_app_get_id (helper->app), error->message); + } + return; + } + + /* the single update needs system reboot, e.g. for firmware */ + if (gs_app_has_quirk (helper->app, GS_APP_QUIRK_NEEDS_REBOOT)) { + g_autoptr(GsAppList) list = gs_app_list_new (); + gs_app_list_add (list, helper->app); + gs_utils_reboot_notify (list, TRUE); + } + + /* tell the user what they have to do */ + if (gs_app_get_kind (helper->app) == AS_COMPONENT_KIND_FIRMWARE && + gs_app_has_quirk (helper->app, GS_APP_QUIRK_NEEDS_USER_ACTION)) { + AsScreenshot *ss = gs_app_get_action_screenshot (helper->app); + if (ss != NULL && as_screenshot_get_caption (ss) != NULL) + gs_page_show_update_message (helper, ss); + } + + /* only show this if the window is not active */ + if (gs_app_is_installed (helper->app) && + helper->action == GS_PLUGIN_ACTION_INSTALL && + !gtk_window_is_active (GTK_WINDOW (gtk_widget_get_ancestor (GTK_WIDGET (helper->page), GTK_TYPE_WINDOW))) && + ((helper->interaction) & GS_SHELL_INTERACTION_NOTIFY) != 0) + gs_app_notify_installed (helper->app); + + if (gs_app_is_installed (helper->app) && + GS_PAGE_GET_CLASS (page)->app_installed != NULL) { + GS_PAGE_GET_CLASS (page)->app_installed (page, helper->app); + } +} + +static void +gs_page_app_removed_cb (GObject *source, + GAsyncResult *res, + gpointer user_data) +{ + g_autoptr(GsPageHelper) helper = (GsPageHelper *) user_data; + GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (source); + GsPage *page = helper->page; + gboolean ret; + g_autoptr(GError) error = NULL; + + ret = gs_plugin_loader_job_action_finish (plugin_loader, + res, + &error); + if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_CANCELLED) || + g_error_matches (error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) { + g_debug ("%s", error->message); + return; + } + if (!ret) { + g_warning ("failed to uninstall: %s", error->message); + return; + } + + /* the app removal needs system reboot, e.g. for rpm-ostree */ + if (gs_app_has_quirk (helper->app, GS_APP_QUIRK_NEEDS_REBOOT)) { + g_autoptr(GsAppList) list = gs_app_list_new (); + gs_app_list_add (list, helper->app); + gs_utils_reboot_notify (list, FALSE); + } + + if (!gs_app_is_installed (helper->app) && + GS_PAGE_GET_CLASS (page)->app_removed != NULL) { + GS_PAGE_GET_CLASS (page)->app_removed (page, helper->app); + } +} + +GtkWidget * +gs_page_get_header_start_widget (GsPage *page) +{ + GsPagePrivate *priv = gs_page_get_instance_private (page); + + return priv->header_start_widget; +} + +void +gs_page_set_header_start_widget (GsPage *page, GtkWidget *widget) +{ + GsPagePrivate *priv = gs_page_get_instance_private (page); + + g_set_object (&priv->header_start_widget, widget); +} + +GtkWidget * +gs_page_get_header_end_widget (GsPage *page) +{ + GsPagePrivate *priv = gs_page_get_instance_private (page); + + return priv->header_end_widget; +} + +void +gs_page_set_header_end_widget (GsPage *page, GtkWidget *widget) +{ + GsPagePrivate *priv = gs_page_get_instance_private (page); + + g_set_object (&priv->header_end_widget, widget); +} + +void +gs_page_install_app (GsPage *page, + GsApp *app, + GsShellInteraction interaction, + GCancellable *cancellable) +{ + GsPagePrivate *priv = gs_page_get_instance_private (page); + GsPageHelper *helper; + g_autoptr(GsPluginJob) plugin_job = NULL; + + /* probably non-free */ + if (gs_app_get_state (app) == GS_APP_STATE_UNAVAILABLE) { + GtkResponseType response; + + response = gs_app_notify_unavailable (app, GTK_WINDOW (gtk_widget_get_ancestor (GTK_WIDGET (page), GTK_TYPE_WINDOW))); + if (response != GTK_RESPONSE_OK) { + g_autoptr(GError) error_local = NULL; + g_set_error_literal (&error_local, G_IO_ERROR, G_IO_ERROR_CANCELLED, _("User declined installation")); + gs_application_emit_install_resources_done (GS_APPLICATION (g_application_get_default ()), NULL, error_local); + return; + } + } + + helper = g_slice_new0 (GsPageHelper); + helper->app = g_object_ref (app); + helper->page = g_object_ref (page); + helper->cancellable = g_object_ref (cancellable); + helper->interaction = interaction; + helper->propagate_error = TRUE; + + if (gs_app_get_kind (app) == AS_COMPONENT_KIND_REPOSITORY) { + helper->action = GS_PLUGIN_ACTION_INSTALL_REPO; + plugin_job = gs_plugin_job_manage_repository_new (helper->app, + GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_INSTALL | + ((interaction == GS_SHELL_INTERACTION_FULL) ? + GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_INTERACTIVE : 0)); + gs_plugin_job_set_propagate_error (plugin_job, helper->propagate_error); + } else { + helper->action = GS_PLUGIN_ACTION_INSTALL; + plugin_job = gs_plugin_job_newv (helper->action, + "interactive", (interaction == GS_SHELL_INTERACTION_FULL), + "propagate-error", helper->propagate_error, + "app", helper->app, + NULL); + } + + gs_plugin_loader_job_process_async (priv->plugin_loader, + plugin_job, + helper->cancellable, + gs_page_app_installed_cb, + helper); +} + +static void +gs_page_update_app_response_cb (GtkDialog *dialog, + gint response, + gpointer user_data) +{ + g_autoptr(GsPageHelper) helper = (GsPageHelper *) user_data; + GsPagePrivate *priv = gs_page_get_instance_private (helper->page); + g_autoptr(GsPluginJob) plugin_job = NULL; + + /* unmap the dialog */ + gtk_window_destroy (GTK_WINDOW (dialog)); + + /* not agreed */ + if (response != GTK_RESPONSE_OK) + return; + + g_debug ("update %s", gs_app_get_id (helper->app)); + plugin_job = gs_plugin_job_newv (GS_PLUGIN_ACTION_UPDATE, + "interactive", TRUE, + "propagate-error", helper->propagate_error, + "app", helper->app, + NULL); + gs_plugin_loader_job_process_async (priv->plugin_loader, + plugin_job, + helper->cancellable, + gs_page_app_installed_cb, + helper); + g_steal_pointer (&helper); +} + +static void +gs_page_notify_quirk_cb (GsApp *app, GParamSpec *pspec, GsPageHelper *helper) +{ + gtk_widget_set_sensitive (helper->button_install, + !gs_app_has_quirk (helper->app, + GS_APP_QUIRK_NEEDS_USER_ACTION)); +} + +static void +gs_page_needs_user_action (GsPageHelper *helper, AsScreenshot *ss) +{ + GtkWidget *content_area; + GtkWidget *dialog; + g_autoptr(SoupSession) soup_session = NULL; + GtkWidget *ssimg; + g_autofree gchar *escaped = NULL; + GsPagePrivate *priv = gs_page_get_instance_private (helper->page); + + dialog = gtk_message_dialog_new (GTK_WINDOW (gtk_widget_get_ancestor (GTK_WIDGET (helper->page), GTK_TYPE_WINDOW)), + GTK_DIALOG_MODAL | + GTK_DIALOG_USE_HEADER_BAR, + GTK_MESSAGE_INFO, + GTK_BUTTONS_CANCEL, + /* TRANSLATORS: this is a prompt message, and + * '%s' is an application summary, e.g. 'GNOME Clocks' */ + _("Prepare %s"), + gs_app_get_name (helper->app)); + escaped = g_markup_escape_text (as_screenshot_get_caption (ss), -1); + gtk_message_dialog_format_secondary_markup (GTK_MESSAGE_DIALOG (dialog), + "%s", escaped); + + /* this will be enabled when the device is in the right mode */ + helper->button_install = gtk_dialog_add_button (GTK_DIALOG (dialog), + /* TRANSLATORS: update the fw */ + _("Install"), + GTK_RESPONSE_OK); + helper->notify_quirk_id = + g_signal_connect (helper->app, "notify::quirk", + G_CALLBACK (gs_page_notify_quirk_cb), + helper); + gtk_widget_set_sensitive (helper->button_install, FALSE); + + /* load screenshot */ + soup_session = gs_build_soup_session (); + ssimg = gs_screenshot_image_new (soup_session); + gs_screenshot_image_set_screenshot (GS_SCREENSHOT_IMAGE (ssimg), ss); + gs_screenshot_image_set_size (GS_SCREENSHOT_IMAGE (ssimg), 400, 225); + gs_screenshot_image_load_async (GS_SCREENSHOT_IMAGE (ssimg), + helper->cancellable); + gtk_widget_set_margin_start (ssimg, 24); + gtk_widget_set_margin_end (ssimg, 24); + content_area = gtk_dialog_get_content_area (GTK_DIALOG (dialog)); + gtk_box_append (GTK_BOX (content_area), ssimg); + + /* handle this async */ + g_signal_connect (dialog, "response", + G_CALLBACK (gs_page_update_app_response_cb), helper); + gs_shell_modal_dialog_present (priv->shell, GTK_WINDOW (dialog)); +} + +void +gs_page_update_app (GsPage *page, GsApp *app, GCancellable *cancellable) +{ + GsPagePrivate *priv = gs_page_get_instance_private (page); + GsPageHelper *helper; + g_autoptr(GsPluginJob) plugin_job = NULL; + + /* non-firmware applications do not have to be prepared */ + helper = g_slice_new0 (GsPageHelper); + helper->action = GS_PLUGIN_ACTION_UPDATE; + helper->app = g_object_ref (app); + helper->page = g_object_ref (page); + helper->cancellable = g_object_ref (cancellable); + helper->propagate_error = TRUE; + + /* tell the user what they have to do */ + if (gs_app_get_kind (app) == AS_COMPONENT_KIND_FIRMWARE && + gs_app_has_quirk (app, GS_APP_QUIRK_NEEDS_USER_ACTION)) { + AsScreenshot *ss = gs_app_get_action_screenshot (app); + if (ss != NULL && as_screenshot_get_caption (ss) != NULL) { + gs_page_needs_user_action (helper, ss); + return; + } + } + + /* generic fallback */ + plugin_job = gs_plugin_job_newv (helper->action, + "interactive", TRUE, + "propagate-error", helper->propagate_error, + "app", app, + NULL); + gs_plugin_loader_job_process_async (priv->plugin_loader, plugin_job, + helper->cancellable, + gs_page_app_installed_cb, + helper); +} + +static void +gs_page_remove_app_response_cb (GtkDialog *dialog, + gint response, + gpointer user_data) +{ + g_autoptr(GsPageHelper) helper = (GsPageHelper *) user_data; + GsPagePrivate *priv = gs_page_get_instance_private (helper->page); + g_autoptr(GsPluginJob) plugin_job = NULL; + + /* unmap the dialog */ + gtk_window_destroy (GTK_WINDOW (dialog)); + + /* not agreed */ + if (response != GTK_RESPONSE_OK) + return; + + g_debug ("uninstall %s", gs_app_get_id (helper->app)); + if (gs_app_get_kind (helper->app) == AS_COMPONENT_KIND_REPOSITORY) { + helper->action = GS_PLUGIN_ACTION_REMOVE_REPO; + plugin_job = gs_plugin_job_manage_repository_new (helper->app, + GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_REMOVE | + GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_INTERACTIVE); + } else { + plugin_job = gs_plugin_job_newv (helper->action, + "interactive", TRUE, + "app", helper->app, + NULL); + } + gs_plugin_loader_job_process_async (priv->plugin_loader, plugin_job, + helper->cancellable, + gs_page_app_removed_cb, + helper); + g_steal_pointer (&helper); +} + +void +gs_page_remove_app (GsPage *page, GsApp *app, GCancellable *cancellable) +{ + GsPagePrivate *priv = gs_page_get_instance_private (page); + GsPageHelper *helper; + GtkWidget *dialog; + g_autofree gchar *message = NULL; + g_autofree gchar *title = NULL; + GtkWidget *remove_button; + GtkStyleContext *context; + + /* Is the app actually removable? */ + if (gs_app_has_quirk (app, GS_APP_QUIRK_COMPULSORY)) + return; + + /* pending install */ + helper = g_slice_new0 (GsPageHelper); + helper->action = GS_PLUGIN_ACTION_REMOVE; + helper->app = g_object_ref (app); + helper->page = g_object_ref (page); + helper->cancellable = cancellable != NULL ? g_object_ref (cancellable) : NULL; + if (gs_app_get_state (app) == GS_APP_STATE_QUEUED_FOR_INSTALL) { + g_autoptr(GsPluginJob) plugin_job = NULL; + + /* cancel any ongoing job, this allows to e.g. cancel pending + * installations, updates, or other ops that may have been queued + * in the plugin loader (due to reaching the max parallel ops allowed) */ + g_cancellable_cancel (gs_app_get_cancellable (app)); + + plugin_job = gs_plugin_job_newv (GS_PLUGIN_ACTION_REMOVE, + "interactive", TRUE, + "app", app, + NULL); + g_debug ("uninstall %s", gs_app_get_id (app)); + gs_plugin_loader_job_process_async (priv->plugin_loader, plugin_job, + helper->cancellable, + gs_page_app_removed_cb, + helper); + return; + } + + /* use different name and summary */ + switch (gs_app_get_kind (app)) { + case AS_COMPONENT_KIND_REPOSITORY: + /* TRANSLATORS: this is a prompt message, and '%s' is an + * repository name, e.g. 'GNOME Nightly' */ + title = g_strdup_printf (_("Are you sure you want to remove " + "the %s repository?"), + gs_app_get_name (app)); + /* TRANSLATORS: longer dialog text */ + message = g_strdup_printf (_("All applications from %s will be " + "uninstalled, and you will have to " + "re-install the repository to use them again."), + gs_app_get_name (app)); + break; + default: + /* TRANSLATORS: this is a prompt message, and '%s' is an + * application summary, e.g. 'GNOME Clocks' */ + title = g_strdup_printf (_("Are you sure you want to uninstall %s?"), + gs_app_get_name (app)); + /* TRANSLATORS: longer dialog text */ + message = g_strdup_printf (_("%s will be uninstalled, and you will " + "have to install it to use it again."), + gs_app_get_name (app)); + break; + } + + /* ask for confirmation */ + dialog = gtk_message_dialog_new (GTK_WINDOW (gtk_widget_get_ancestor (GTK_WIDGET (page), GTK_TYPE_WINDOW)), + GTK_DIALOG_MODAL, + GTK_MESSAGE_QUESTION, + GTK_BUTTONS_CANCEL, + "%s", title); + gtk_message_dialog_format_secondary_text (GTK_MESSAGE_DIALOG (dialog), + "%s", message); + + /* TRANSLATORS: this is button text to remove the application */ + remove_button = gtk_dialog_add_button (GTK_DIALOG (dialog), _("Uninstall"), GTK_RESPONSE_OK); + context = gtk_widget_get_style_context (remove_button); + gtk_style_context_add_class (context, "destructive-action"); + + /* handle this async */ + g_signal_connect (dialog, "response", + G_CALLBACK (gs_page_remove_app_response_cb), helper); + gs_shell_modal_dialog_present (priv->shell, GTK_WINDOW (dialog)); +} + +static void +gs_page_app_launched_cb (GObject *source, + GAsyncResult *res, + gpointer user_data) +{ + GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (source); + g_autoptr(GError) error = NULL; + if (!gs_plugin_loader_job_action_finish (plugin_loader, res, &error)) { + g_warning ("failed to launch GsApp: %s", error->message); + return; + } +} + +void +gs_page_launch_app (GsPage *page, GsApp *app, GCancellable *cancellable) +{ + GsPagePrivate *priv = gs_page_get_instance_private (page); + g_autoptr(GsPluginJob) plugin_job = NULL; + plugin_job = gs_plugin_job_newv (GS_PLUGIN_ACTION_LAUNCH, + "interactive", TRUE, + "app", app, + NULL); + gs_plugin_loader_job_process_async (priv->plugin_loader, plugin_job, + cancellable, + gs_page_app_launched_cb, + NULL); +} + +gboolean +gs_page_is_active (GsPage *page) +{ + GsPagePrivate *priv = gs_page_get_instance_private (page); + g_return_val_if_fail (GS_IS_PAGE (page), FALSE); + return priv->is_active; +} + +/** + * gs_page_get_title: + * @page: a #GsPage + * + * Get the value of #GsPage:title. + * + * Returns: (nullable): human readable title for the page, or %NULL if one isn’t set + * + * Since: 40 + */ +const gchar * +gs_page_get_title (GsPage *page) +{ + g_auto(GValue) value = G_VALUE_INIT; + + g_return_val_if_fail (GS_IS_PAGE (page), NULL); + + /* The property is typically overridden by subclasses; the + * implementation in #GsPage itself is just a placeholder. */ + g_object_get_property (G_OBJECT (page), "title", &value); + + return g_value_get_string (&value); +} + +/** + * gs_page_get_counter: + * @page: a #GsPage + * + * Get the value of #GsPage:counter. + * + * Returns: a counter of the number of available updates, installed packages, + * etc. on this page + * + * Since: 40 + */ +guint +gs_page_get_counter (GsPage *page) +{ + g_auto(GValue) value = G_VALUE_INIT; + + g_return_val_if_fail (GS_IS_PAGE (page), 0); + + /* The property is typically overridden by subclasses; the + * implementation in #GsPage itself is just a placeholder. */ + g_object_get_property (G_OBJECT (page), "counter", &value); + + return g_value_get_uint (&value); +} + +/** + * gs_page_get_vadjustment: + * @page: a #GsPage + * + * Get the #GtkAdjustment used for vertical scrolling. + * + * Returns: (nullable) (transfer none): the #GtkAdjustment used for vertical scrolling + * + * Since: 41 + */ +GtkAdjustment * +gs_page_get_vadjustment (GsPage *page) +{ + g_auto(GValue) value = G_VALUE_INIT; + + g_return_val_if_fail (GS_IS_PAGE (page), NULL); + + /* The property is typically overridden by subclasses; the + * implementation in #GsPage itself is just a placeholder. */ + g_object_get_property (G_OBJECT (page), "vadjustment", &value); + + return g_value_get_object (&value); +} + +/** + * gs_page_switch_to: + * + * Pure virtual method that subclasses have to override to show page specific + * widgets. + */ +void +gs_page_switch_to (GsPage *page) +{ + GsPageClass *klass = GS_PAGE_GET_CLASS (page); + GsPagePrivate *priv = gs_page_get_instance_private (page); + priv->is_active = TRUE; + if (klass->switch_to != NULL) + klass->switch_to (page); +} + +/** + * gs_page_switch_from: + * + * Pure virtual method that subclasses have to override to show page specific + * widgets. + */ +void +gs_page_switch_from (GsPage *page) +{ + GsPageClass *klass = GS_PAGE_GET_CLASS (page); + GsPagePrivate *priv = gs_page_get_instance_private (page); + priv->is_active = FALSE; + if (klass->switch_from != NULL) + klass->switch_from (page); +} + +/** + * gs_page_scroll_up: + * @page: a #GsPage + * + * Scroll the page to the top of its content, if it supports scrolling. + * + * If it doesn’t support scrolling, this is a no-op. + * + * Since: 40 + */ +void +gs_page_scroll_up (GsPage *page) +{ + GtkAdjustment *adj; + + g_return_if_fail (GS_IS_PAGE (page)); + + adj = gs_page_get_vadjustment (page); + if (adj) + gtk_adjustment_set_value (adj, gtk_adjustment_get_lower (adj)); +} + +void +gs_page_reload (GsPage *page) +{ + GsPageClass *klass; + g_return_if_fail (GS_IS_PAGE (page)); + klass = GS_PAGE_GET_CLASS (page); + if (klass->reload != NULL) + klass->reload (page); +} + +gboolean +gs_page_setup (GsPage *page, + GsShell *shell, + GsPluginLoader *plugin_loader, + GCancellable *cancellable, + GError **error) +{ + GsPageClass *klass; + GsPagePrivate *priv = gs_page_get_instance_private (page); + + g_return_val_if_fail (GS_IS_PAGE (page), FALSE); + + klass = GS_PAGE_GET_CLASS (page); + g_assert (klass->setup != NULL); + + priv->plugin_loader = g_object_ref (plugin_loader); + priv->shell = shell; + + return klass->setup (page, shell, plugin_loader, cancellable, error); +} + +static void +gs_page_get_property (GObject *object, + guint prop_id, + GValue *value, + GParamSpec *pspec) +{ + switch ((GsPageProperty) prop_id) { + case PROP_TITLE: + /* Should be overridden by subclasses. */ + g_value_set_string (value, NULL); + break; + case PROP_COUNTER: + /* Should be overridden by subclasses. */ + g_value_set_uint (value, 0); + break; + case PROP_VADJUSTMENT: + /* Should be overridden by subclasses. */ + g_value_set_object (value, NULL); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_page_dispose (GObject *object) +{ + GsPage *page = GS_PAGE (object); + GsPagePrivate *priv = gs_page_get_instance_private (page); + + gs_widget_remove_all (GTK_WIDGET (page), NULL); + + g_clear_object (&priv->plugin_loader); + g_clear_object (&priv->header_start_widget); + g_clear_object (&priv->header_end_widget); + + G_OBJECT_CLASS (gs_page_parent_class)->dispose (object); +} + +static void +gs_page_init (GsPage *page) +{ + gtk_widget_set_hexpand (GTK_WIDGET (page), TRUE); + gtk_widget_set_vexpand (GTK_WIDGET (page), TRUE); +} + +static void +gs_page_class_init (GsPageClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + + object_class->get_property = gs_page_get_property; + object_class->dispose = gs_page_dispose; + + /** + * GsPage:title: (nullable) + * + * A human readable title for this page, or %NULL if one isn’t set or + * doesn’t make sense. + * + * Since: 40 + */ + obj_props[PROP_TITLE] = + g_param_spec_string ("title", NULL, NULL, + NULL, + G_PARAM_READABLE | G_PARAM_STATIC_STRINGS); + + /** + * GsPage:counter: + * + * A counter indicating the number of installed packages, available + * updates, etc. on this page. + * + * Since: 40 + */ + obj_props[PROP_COUNTER] = + g_param_spec_uint ("counter", NULL, NULL, + 0, G_MAXUINT, 0, + G_PARAM_READABLE | G_PARAM_STATIC_STRINGS); + + /** + * GsPage:vadjustment: (nullable) + * + * The #GtkAdjustment used for vertical scrolling. + * This will be %NULL if the page is not vertically scrollable. + * + * Since: 41 + */ + obj_props[PROP_VADJUSTMENT] = + g_param_spec_object ("vadjustment", NULL, NULL, + GTK_TYPE_ADJUSTMENT, + G_PARAM_READABLE | G_PARAM_STATIC_STRINGS); + + g_object_class_install_properties (object_class, G_N_ELEMENTS (obj_props), obj_props); + + gtk_widget_class_set_layout_manager_type (widget_class, GTK_TYPE_BIN_LAYOUT); +} + +GsPage * +gs_page_new (void) +{ + return GS_PAGE (g_object_new (GS_TYPE_PAGE, NULL)); +} diff --git a/src/gs-page.h b/src/gs-page.h new file mode 100644 index 0000000..a42034b --- /dev/null +++ b/src/gs-page.h @@ -0,0 +1,74 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2015-2016 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include "gs-shell.h" + +G_BEGIN_DECLS + +#define GS_TYPE_PAGE (gs_page_get_type ()) + +G_DECLARE_DERIVABLE_TYPE (GsPage, gs_page, GS, PAGE, GtkWidget) + +struct _GsPageClass +{ + GtkWidgetClass parent_class; + + void (*app_installed) (GsPage *page, + GsApp *app); + void (*app_removed) (GsPage *page, + GsApp *app); + void (*switch_to) (GsPage *page); + void (*switch_from) (GsPage *page); + void (*reload) (GsPage *page); + gboolean (*setup) (GsPage *page, + GsShell *shell, + GsPluginLoader *plugin_loader, + GCancellable *cancellable, + GError **error); +}; + +GsPage *gs_page_new (void); +GsShell *gs_page_get_shell (GsPage *page); +GtkWidget *gs_page_get_header_start_widget (GsPage *page); +void gs_page_set_header_start_widget (GsPage *page, + GtkWidget *widget); +GtkWidget *gs_page_get_header_end_widget (GsPage *page); +void gs_page_set_header_end_widget (GsPage *page, + GtkWidget *widget); +void gs_page_install_app (GsPage *page, + GsApp *app, + GsShellInteraction interaction, + GCancellable *cancellable); +void gs_page_remove_app (GsPage *page, + GsApp *app, + GCancellable *cancellable); +void gs_page_update_app (GsPage *page, + GsApp *app, + GCancellable *cancellable); +void gs_page_launch_app (GsPage *page, + GsApp *app, + GCancellable *cancellable); +void gs_page_switch_to (GsPage *page); +void gs_page_switch_from (GsPage *page); +void gs_page_scroll_up (GsPage *page); +void gs_page_reload (GsPage *page); +gboolean gs_page_setup (GsPage *page, + GsShell *shell, + GsPluginLoader *plugin_loader, + GCancellable *cancellable, + GError **error); +gboolean gs_page_is_active (GsPage *page); + +const gchar *gs_page_get_title (GsPage *page); +guint gs_page_get_counter (GsPage *page); +GtkAdjustment *gs_page_get_vadjustment (GsPage *page); + +G_END_DECLS diff --git a/src/gs-prefs-dialog.c b/src/gs-prefs-dialog.c new file mode 100644 index 0000000..df49aa0 --- /dev/null +++ b/src/gs-prefs-dialog.c @@ -0,0 +1,94 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2018 Richard Hughes <richard@hughsie.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include "gs-prefs-dialog.h" + +#include "gnome-software-private.h" +#include "gs-common.h" +#include "gs-os-release.h" +#include "gs-repo-row.h" +#include <glib/gi18n.h> + +struct _GsPrefsDialog +{ + AdwPreferencesWindow parent_instance; + GSettings *settings; + + GCancellable *cancellable; + GsPluginLoader *plugin_loader; + GtkWidget *switch_updates; + GtkWidget *switch_updates_notify; + AdwActionRow *automatic_updates_row; + AdwActionRow *automatic_update_notifications_row; +}; + +G_DEFINE_TYPE (GsPrefsDialog, gs_prefs_dialog, ADW_TYPE_PREFERENCES_WINDOW) + +static void +gs_prefs_dialog_dispose (GObject *object) +{ + GsPrefsDialog *dialog = GS_PREFS_DIALOG (object); + g_clear_object (&dialog->plugin_loader); + g_cancellable_cancel (dialog->cancellable); + g_clear_object (&dialog->cancellable); + g_clear_object (&dialog->settings); + + G_OBJECT_CLASS (gs_prefs_dialog_parent_class)->dispose (object); +} + +static void +gs_prefs_dialog_init (GsPrefsDialog *dialog) +{ + gtk_widget_init_template (GTK_WIDGET (dialog)); + + dialog->cancellable = g_cancellable_new (); + dialog->settings = g_settings_new ("org.gnome.software"); + g_settings_bind (dialog->settings, + "download-updates-notify", + dialog->switch_updates_notify, + "active", + G_SETTINGS_BIND_DEFAULT); + g_settings_bind (dialog->settings, + "download-updates", + dialog->switch_updates, + "active", + G_SETTINGS_BIND_DEFAULT); + +#if ADW_CHECK_VERSION(1,2,0) + adw_preferences_row_set_use_markup (ADW_PREFERENCES_ROW (dialog->automatic_updates_row), FALSE); + adw_preferences_row_set_use_markup (ADW_PREFERENCES_ROW (dialog->automatic_update_notifications_row), FALSE); +#endif +} + +static void +gs_prefs_dialog_class_init (GsPrefsDialogClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + + object_class->dispose = gs_prefs_dialog_dispose; + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-prefs-dialog.ui"); + gtk_widget_class_bind_template_child (widget_class, GsPrefsDialog, switch_updates); + gtk_widget_class_bind_template_child (widget_class, GsPrefsDialog, switch_updates_notify); + gtk_widget_class_bind_template_child (widget_class, GsPrefsDialog, automatic_updates_row); + gtk_widget_class_bind_template_child (widget_class, GsPrefsDialog, automatic_update_notifications_row); +} + +GtkWidget * +gs_prefs_dialog_new (GtkWindow *parent, GsPluginLoader *plugin_loader) +{ + GsPrefsDialog *dialog; + dialog = g_object_new (GS_TYPE_PREFS_DIALOG, + "transient-for", parent, + NULL); + dialog->plugin_loader = g_object_ref (plugin_loader); + return GTK_WIDGET (dialog); +} diff --git a/src/gs-prefs-dialog.h b/src/gs-prefs-dialog.h new file mode 100644 index 0000000..207ba26 --- /dev/null +++ b/src/gs-prefs-dialog.h @@ -0,0 +1,25 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2018 Richard Hughes <richard@hughsie.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <adwaita.h> +#include <gtk/gtk.h> + +#include "gnome-software-private.h" + +G_BEGIN_DECLS + +#define GS_TYPE_PREFS_DIALOG (gs_prefs_dialog_get_type ()) + +G_DECLARE_FINAL_TYPE (GsPrefsDialog, gs_prefs_dialog, GS, PREFS_DIALOG, AdwPreferencesWindow) + +GtkWidget *gs_prefs_dialog_new (GtkWindow *parent, + GsPluginLoader *plugin_loader); + +G_END_DECLS diff --git a/src/gs-prefs-dialog.ui b/src/gs-prefs-dialog.ui new file mode 100644 index 0000000..3fcb8c1 --- /dev/null +++ b/src/gs-prefs-dialog.ui @@ -0,0 +1,48 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <requires lib="gtk+" version="3.10"/> + <template class="GsPrefsDialog" parent="AdwPreferencesWindow"> + <property name="title" translatable="yes">Update Preferences</property> + <property name="default_width">610</property> + <property name="default_height">300</property> + <property name="search_enabled">False</property> + <style> + <class name="update-preferences"/> + </style> + <child> + <object class="AdwPreferencesPage"> + <child> + <object class="AdwPreferencesGroup"> + <property name="description" translatable="yes">To avoid charges and network caps, software updates are not automatically downloaded on mobile or metered connections.</property> + <child> + <object class="AdwActionRow" id="automatic_updates_row"> + <property name="title" translatable="yes">Automatic Updates</property> + <property name="subtitle" translatable="yes">Downloads and installs software updates in the background, when possible.</property> + <property name="subtitle_lines">0</property> + <property name="activatable_widget">switch_updates</property> + <child> + <object class="GtkSwitch" id="switch_updates"> + <property name="valign">center</property> + </object> + </child> + </object> + </child> + <child> + <object class="AdwActionRow" id="automatic_update_notifications_row"> + <property name="title" translatable="yes">Automatic Update Notifications</property> + <property name="subtitle" translatable="yes">Show notifications when updates have been automatically installed.</property> + <property name="subtitle_lines">0</property> + <property name="activatable_widget">switch_updates_notify</property> + <child> + <object class="GtkSwitch" id="switch_updates_notify"> + <property name="valign">center</property> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + </template> +</interface> diff --git a/src/gs-progress-button.c b/src/gs-progress-button.c new file mode 100644 index 0000000..13fb782 --- /dev/null +++ b/src/gs-progress-button.c @@ -0,0 +1,361 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013-2014 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2015 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include "gs-app.h" +#include "gs-progress-button.h" + +struct _GsProgressButton +{ + GtkButton parent_instance; + + GtkWidget *image; + GtkWidget *label; + GtkWidget *stack; + + GtkCssProvider *css_provider; + char *label_text; + char *icon_name; + gboolean show_icon; +}; + +G_DEFINE_TYPE (GsProgressButton, gs_progress_button, GTK_TYPE_BUTTON) + +typedef enum { + PROP_ICON_NAME = 1, + PROP_SHOW_ICON, + /* Overrides: */ + PROP_LABEL, +} GsProgressButtonProperty; + +static GParamSpec *obj_props[PROP_SHOW_ICON + 1] = { NULL, }; + +void +gs_progress_button_set_progress (GsProgressButton *button, guint percentage) +{ + gchar tmp[64]; /* Large enough to hold the string below. */ + const gchar *css; + + if (percentage == GS_APP_PROGRESS_UNKNOWN) { + css = ".install-progress {\n" + " background-size: 25%;\n" + " animation: install-progress-unknown-move infinite linear 2s;\n" + "}\n"; + } else { + percentage = MIN (percentage, 100); /* No need to clamp an unsigned to 0, it produces errors. */ + g_assert ((gsize) g_snprintf (tmp, sizeof (tmp), ".install-progress { background-size: %u%%; }", percentage) < sizeof (tmp)); + css = tmp; + } + + gtk_css_provider_load_from_data (button->css_provider, css, -1); +} + +void +gs_progress_button_set_show_progress (GsProgressButton *button, gboolean show_progress) +{ + GtkStyleContext *context; + + context = gtk_widget_get_style_context (GTK_WIDGET (button)); + if (show_progress) + gtk_style_context_add_class (context, "install-progress"); + else + gtk_style_context_remove_class (context, "install-progress"); +} + +/** + * gs_progress_button_get_label: + * @button: a #GsProgressButton + * + * Get the label of @button. + * + * It should be used rather than gtk_button_get_label() as it can only retrieve + * the text from the label set by gtk_button_set_label(), which also cannot be + * used. + * + * Returns: the label of @button + * + * Since: 41 + */ +const gchar * +gs_progress_button_get_label (GsProgressButton *button) +{ + g_return_val_if_fail (GS_IS_PROGRESS_BUTTON (button), NULL); + + return button->label_text; +} + +/** + * gs_progress_button_set_label: + * @button: a #GsProgressButton + * @label: a string + * + * Set the label of @button. + * + * It should be used rather than gtk_button_set_label() as it will replace the + * content of @button by a new label, breaking it. + * + * Since: 41 + */ +void +gs_progress_button_set_label (GsProgressButton *button, const gchar *label) +{ + g_return_if_fail (GS_IS_PROGRESS_BUTTON (button)); + + if (g_strcmp0 (button->label_text, label) == 0) + return; + + g_free (button->label_text); + button->label_text = g_strdup (label); + + g_object_notify (G_OBJECT (button), "label"); +} + +/** + * gs_progress_button_get_icon_name: + * @button: a #GsProgressButton + * + * Get the value of #GsProgressButton:icon-name. + * + * Returns: (nullable): the name of the icon + * + * Since: 41 + */ +const gchar * +gs_progress_button_get_icon_name (GsProgressButton *button) +{ + g_return_val_if_fail (GS_IS_PROGRESS_BUTTON (button), NULL); + + return button->icon_name; +} + +/** + * gs_progress_button_set_icon_name: + * @button: a #GsProgressButton + * @icon_name: (nullable): the name of the icon + * + * Set the value of #GsProgressButton:icon-name. + * + * Since: 41 + */ +void +gs_progress_button_set_icon_name (GsProgressButton *button, const gchar *icon_name) +{ + g_return_if_fail (GS_IS_PROGRESS_BUTTON (button)); + + if (g_strcmp0 (button->icon_name, icon_name) == 0) + return; + + g_free (button->icon_name); + button->icon_name = g_strdup (icon_name); + + g_object_notify_by_pspec (G_OBJECT (button), obj_props[PROP_ICON_NAME]); +} + +/** + * gs_progress_button_get_show_icon: + * @button: a #GsProgressButton + * + * Get the value of #GsProgressButton:show-icon. + * + * Returns: %TRUE if the icon is shown, %FALSE if the label is shown + * + * Since: 41 + */ +gboolean +gs_progress_button_get_show_icon (GsProgressButton *button) +{ + g_return_val_if_fail (GS_IS_PROGRESS_BUTTON (button), FALSE); + + return button->show_icon; +} + +/** + * gs_progress_button_set_show_icon: + * @button: a #GsProgressButton + * @show_icon: %TRUE to set show the icon, %FALSE to show the label + * + * Set the value of #GsProgressButton:show-icon. + * + * Since: 41 + */ +void +gs_progress_button_set_show_icon (GsProgressButton *button, gboolean show_icon) +{ + GtkStyleContext *style; + + g_return_if_fail (GS_IS_PROGRESS_BUTTON (button)); + + show_icon = !!show_icon; + + if (button->show_icon == show_icon) + return; + + button->show_icon = show_icon; + + style = gtk_widget_get_style_context (GTK_WIDGET (button)); + if (show_icon) { + gtk_stack_set_visible_child (GTK_STACK (button->stack), button->image); + gtk_style_context_remove_class (style, "text-button"); + gtk_style_context_add_class (style, "image-button"); + } else { + gtk_stack_set_visible_child (GTK_STACK (button->stack), button->label); + gtk_style_context_remove_class (style, "image-button"); + gtk_style_context_add_class (style, "text-button"); + } + + g_object_notify_by_pspec (G_OBJECT (button), obj_props[PROP_SHOW_ICON]); +} + +/** + * gs_progress_button_set_size_groups: + * @button: a #GsProgressButton + * @label: the #GtkSizeGroup for the label + * @image: the #GtkSizeGroup for the image + * + * Groups the size of different buttons while keeping adaptiveness. + * + * Since: 41 + */ +void +gs_progress_button_set_size_groups (GsProgressButton *button, GtkSizeGroup *label, GtkSizeGroup *image) +{ + g_return_if_fail (GS_IS_PROGRESS_BUTTON (button)); + + if (label != NULL) + gtk_size_group_add_widget (label, button->label); + if (image != NULL) + gtk_size_group_add_widget (image, button->image); +} + +static void +gs_progress_button_page_get_property (GObject *object, guint prop_id, GValue *value, GParamSpec *pspec) +{ + GsProgressButton *self = GS_PROGRESS_BUTTON (object); + + switch ((GsProgressButtonProperty) prop_id) { + case PROP_LABEL: + g_value_set_string (value, gs_progress_button_get_label (self)); + break; + case PROP_ICON_NAME: + g_value_set_string (value, gs_progress_button_get_icon_name (self)); + break; + case PROP_SHOW_ICON: + g_value_set_boolean (value, gs_progress_button_get_show_icon (self)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_progress_button_page_set_property (GObject *object, guint prop_id, const GValue *value, GParamSpec *pspec) +{ + GsProgressButton *self = GS_PROGRESS_BUTTON (object); + + switch ((GsProgressButtonProperty) prop_id) { + case PROP_LABEL: + gs_progress_button_set_label (self, g_value_get_string (value)); + break; + case PROP_ICON_NAME: + gs_progress_button_set_icon_name (self, g_value_get_string (value)); + break; + case PROP_SHOW_ICON: + gs_progress_button_set_show_icon (self, g_value_get_boolean (value)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_progress_button_dispose (GObject *object) +{ + GsProgressButton *button = GS_PROGRESS_BUTTON (object); + + g_clear_object (&button->css_provider); + + G_OBJECT_CLASS (gs_progress_button_parent_class)->dispose (object); +} + +static void +gs_progress_button_finalize (GObject *object) +{ + GsProgressButton *button = GS_PROGRESS_BUTTON (object); + + g_clear_pointer (&button->label_text, g_free); + g_clear_pointer (&button->icon_name, g_free); + + G_OBJECT_CLASS (gs_progress_button_parent_class)->finalize (object); +} + +static void +gs_progress_button_init (GsProgressButton *button) +{ + gtk_widget_init_template (GTK_WIDGET (button)); + + button->css_provider = gtk_css_provider_new (); + gtk_style_context_add_provider (gtk_widget_get_style_context (GTK_WIDGET (button)), + GTK_STYLE_PROVIDER (button->css_provider), + GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); +} + +static void +gs_progress_button_class_init (GsProgressButtonClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + + object_class->get_property = gs_progress_button_page_get_property; + object_class->set_property = gs_progress_button_page_set_property; + object_class->dispose = gs_progress_button_dispose; + object_class->finalize = gs_progress_button_finalize; + + /** + * GsProgressButton:icon-name: (nullable): + * + * The name of the icon for the button. + * + * Since: 41 + */ + obj_props[PROP_ICON_NAME] = + g_param_spec_string ("icon-name", NULL, NULL, + NULL, + G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY); + + /** + * GsProgressButton:show-icon: + * + * Whether to show the icon in place of the label. + * + * Since: 41 + */ + obj_props[PROP_SHOW_ICON] = + g_param_spec_boolean ("show-icon", 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_LABEL, "label"); + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-progress-button.ui"); + + gtk_widget_class_bind_template_child (widget_class, GsProgressButton, image); + gtk_widget_class_bind_template_child (widget_class, GsProgressButton, label); + gtk_widget_class_bind_template_child (widget_class, GsProgressButton, stack); +} + +GtkWidget * +gs_progress_button_new (void) +{ + return g_object_new (GS_TYPE_PROGRESS_BUTTON, NULL); +} diff --git a/src/gs-progress-button.h b/src/gs-progress-button.h new file mode 100644 index 0000000..a2d89cb --- /dev/null +++ b/src/gs-progress-button.h @@ -0,0 +1,38 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013-2014 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2015 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <gtk/gtk.h> + +G_BEGIN_DECLS + +#define GS_TYPE_PROGRESS_BUTTON (gs_progress_button_get_type ()) + +G_DECLARE_FINAL_TYPE (GsProgressButton, gs_progress_button, GS, PROGRESS_BUTTON, GtkButton) + +GtkWidget *gs_progress_button_new (void); +const gchar *gs_progress_button_get_label (GsProgressButton *button); +void gs_progress_button_set_label (GsProgressButton *button, + const gchar *label); +const gchar *gs_progress_button_get_icon_name (GsProgressButton *button); +void gs_progress_button_set_icon_name (GsProgressButton *button, + const gchar *icon_name); +gboolean gs_progress_button_get_show_icon (GsProgressButton *button); +void gs_progress_button_set_show_icon (GsProgressButton *button, + gboolean show_icon); +void gs_progress_button_set_progress (GsProgressButton *button, + guint percentage); +void gs_progress_button_set_show_progress (GsProgressButton *button, + gboolean show_progress); +void gs_progress_button_set_size_groups (GsProgressButton *button, + GtkSizeGroup *label, + GtkSizeGroup *image); + +G_END_DECLS diff --git a/src/gs-progress-button.ui b/src/gs-progress-button.ui new file mode 100644 index 0000000..bd344d0 --- /dev/null +++ b/src/gs-progress-button.ui @@ -0,0 +1,35 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <template class="GsProgressButton" parent="GtkButton"> + <child> + <object class="GtkStack" id="stack"> + <property name="hhomogeneous">False</property> + <property name="vhomogeneous">False</property> + <property name="interpolate-size">True</property> + <property name="transition-type">crossfade</property> + + <child> + <object class="GtkStackPage"> + <property name="child"> + <object class="GtkLabel" id="label"> + <property name="label" bind-source="GsProgressButton" bind-property="label" bind-flags="sync-create"/> + <property name="use-underline" bind-source="GsProgressButton" bind-property="use-underline" bind-flags="sync-create"/> + </object> + </property> + </object> + </child> + + <child> + <object class="GtkStackPage"> + <property name="child"> + <object class="GtkImage" id="image"> + <property name="icon-name" bind-source="GsProgressButton" bind-property="icon-name" bind-flags="sync-create"/> + </object> + </property> + </object> + </child> + + </object> + </child> + </template> +</interface> diff --git a/src/gs-removal-dialog.c b/src/gs-removal-dialog.c new file mode 100644 index 0000000..e26b702 --- /dev/null +++ b/src/gs-removal-dialog.c @@ -0,0 +1,154 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2016 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include "gs-removal-dialog.h" +#include "gs-utils.h" + +#include <glib/gi18n.h> +#include <gtk/gtk.h> + +struct _GsRemovalDialog +{ + GtkDialog parent_instance; + GtkLabel *label; + GtkWidget *listbox; + GtkLabel *secondary_label; +}; + +G_DEFINE_TYPE (GsRemovalDialog, gs_removal_dialog, GTK_TYPE_DIALOG) + +static gint +list_sort_func (GtkListBoxRow *a, + GtkListBoxRow *b, + gpointer user_data) +{ + GObject *o1 = G_OBJECT (gtk_list_box_row_get_child (a)); + GObject *o2 = G_OBJECT (gtk_list_box_row_get_child (b)); + const gchar *key1 = g_object_get_data (o1, "sort"); + const gchar *key2 = g_object_get_data (o2, "sort"); + return g_strcmp0 (key1, key2); +} + +static void +add_app (GtkListBox *listbox, GsApp *app) +{ + GtkWidget *box; + GtkWidget *widget; + GtkWidget *row; + g_autofree gchar *sort_key = NULL; + + box = gtk_box_new (GTK_ORIENTATION_VERTICAL, 6); + gtk_widget_set_margin_top (box, 12); + gtk_widget_set_margin_start (box, 12); + gtk_widget_set_margin_bottom (box, 12); + gtk_widget_set_margin_end (box, 12); + + widget = gtk_label_new (gs_app_get_name (app)); + gtk_widget_set_halign (widget, GTK_ALIGN_START); + gtk_widget_set_tooltip_text (widget, gs_app_get_name (app)); + gtk_label_set_ellipsize (GTK_LABEL (widget), PANGO_ELLIPSIZE_END); + gtk_box_append (GTK_BOX (box), widget); + + if (gs_app_get_name (app) != NULL) { + sort_key = gs_utils_sort_key (gs_app_get_name (app)); + } + + g_object_set_data_full (G_OBJECT (box), + "sort", + g_steal_pointer (&sort_key), + g_free); + + gtk_list_box_prepend (listbox, box); + gtk_widget_show (widget); + gtk_widget_show (box); + + row = gtk_widget_get_parent (box); + gtk_list_box_row_set_activatable (GTK_LIST_BOX_ROW (row), FALSE); +} + +void +gs_removal_dialog_show_upgrade_removals (GsRemovalDialog *self, + GsApp *upgrade) +{ + GsAppList *removals; + g_autofree gchar *secondary_text = NULL; + g_autofree gchar *name_version = NULL; + + name_version = g_strdup_printf ("%s %s", + gs_app_get_name (upgrade), + gs_app_get_version (upgrade)); + /* TRANSLATORS: This is a text displayed during a distro upgrade. %s + will be replaced by the name and version of distro, e.g. 'Fedora 23'. */ + secondary_text = g_strdup_printf (_("Some of the currently installed software is not compatible with %s. " + "If you continue, the following will be automatically removed during the upgrade:"), + name_version); + + gtk_widget_add_css_class (GTK_WIDGET (self->label), "title"); + gtk_widget_show (GTK_WIDGET (self->secondary_label)); + gtk_label_set_text (self->secondary_label, secondary_text); + + removals = gs_app_get_related (upgrade); + for (guint i = 0; i < gs_app_list_length (removals); i++) { + GsApp *app = gs_app_list_index (removals, i); + g_autofree gchar *tmp = NULL; + + if (gs_app_get_state (app) != GS_APP_STATE_UNAVAILABLE) + continue; + tmp = gs_app_to_string (app); + g_debug ("removal %u: %s", i, tmp); + add_app (GTK_LIST_BOX (self->listbox), app); + } +} + +static void +gs_removal_dialog_init (GsRemovalDialog *self) +{ + GtkWidget *action_area; + GtkSettings *settings; + gboolean use_caret; + + gtk_widget_init_template (GTK_WIDGET (self)); + + action_area = gtk_dialog_get_content_area (GTK_DIALOG (self)); + action_area = gtk_widget_get_next_sibling (action_area); + gtk_widget_set_halign (action_area, GTK_ALIGN_FILL); + gtk_box_set_homogeneous (GTK_BOX (action_area), TRUE); + + settings = gtk_widget_get_settings (GTK_WIDGET (self)); + g_object_get (settings, "gtk-keynav-use-caret", &use_caret, NULL); + gtk_label_set_selectable (self->label, use_caret); + gtk_label_set_selectable (self->secondary_label, use_caret); + + gtk_list_box_set_sort_func (GTK_LIST_BOX (self->listbox), + list_sort_func, + self, NULL); +} + +static void +gs_removal_dialog_class_init (GsRemovalDialogClass *klass) +{ + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-removal-dialog.ui"); + + gtk_widget_class_bind_template_child (widget_class, GsRemovalDialog, label); + gtk_widget_class_bind_template_child (widget_class, GsRemovalDialog, listbox); + gtk_widget_class_bind_template_child (widget_class, GsRemovalDialog, secondary_label); +} + +GtkWidget * +gs_removal_dialog_new (void) +{ + GsRemovalDialog *dialog; + + dialog = g_object_new (GS_TYPE_REMOVAL_DIALOG, + NULL); + return GTK_WIDGET (dialog); +} diff --git a/src/gs-removal-dialog.h b/src/gs-removal-dialog.h new file mode 100644 index 0000000..b603ace --- /dev/null +++ b/src/gs-removal-dialog.h @@ -0,0 +1,25 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2016 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <gtk/gtk.h> + +#include "gnome-software-private.h" + +G_BEGIN_DECLS + +#define GS_TYPE_REMOVAL_DIALOG (gs_removal_dialog_get_type ()) + +G_DECLARE_FINAL_TYPE (GsRemovalDialog, gs_removal_dialog, GS, REMOVAL_DIALOG, GtkDialog) + +GtkWidget *gs_removal_dialog_new (void); +void gs_removal_dialog_show_upgrade_removals (GsRemovalDialog *self, + GsApp *upgrade); + +G_END_DECLS diff --git a/src/gs-removal-dialog.ui b/src/gs-removal-dialog.ui new file mode 100644 index 0000000..0cff132 --- /dev/null +++ b/src/gs-removal-dialog.ui @@ -0,0 +1,93 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <template class="GsRemovalDialog" parent="GtkDialog"> + <property name="title" translatable="yes">Incompatible Software</property> + <property name="modal">True</property> + <property name="destroy_with_parent">True</property> + <child internal-child="headerbar"> + <object class="GtkHeaderBar"> + <property name="show-title-buttons">False</property> + </object> + </child> + <style> + <class name="message" /> + </style> + <child type="action"> + <object class="GtkButton" id="button_cancel"> + <property name="label" translatable="yes">_Cancel</property> + <property name="use_underline">True</property> + </object> + </child> + <child type="action"> + <object class="GtkButton" id="button_continue"> + <property name="label" translatable="yes">_Continue</property> + <property name="use_underline">True</property> + <property name="receives_default">True</property> + </object> + </child> + <action-widgets> + <action-widget response="accept" default="true">button_continue</action-widget> + <action-widget response="cancel">button_cancel</action-widget> + </action-widgets> + <child internal-child="content_area"> + <object class="GtkBox" id="dialog-vbox1"> + <property name="orientation">vertical</property> + <property name="spacing">20</property> + <child> + <object class="GtkBox" id="box"> + <property name="margin-start">30</property> + <property name="margin-end">30</property> + <property name="spacing">30</property> + <child> + <object class="GtkBox" id="message_area"> + <property name="orientation">vertical</property> + <property name="spacing">10</property> + <property name="hexpand">1</property> + <child> + <object class="GtkLabel" id="label"> + <property name="halign">center</property> + <property name="valign">start</property> + <property name="wrap">1</property> + <property name="width-chars">40</property> + <property name="max-width-chars">40</property> + </object> + </child> + <child> + <object class="GtkLabel" id="secondary_label"> + <property name="visible">False</property> + <property name="margin-bottom">2</property> + <property name="halign">center</property> + <property name="valign">start</property> + <property name="vexpand">1</property> + <property name="wrap">1</property> + <property name="width-chars">40</property> + <property name="max-width-chars">40</property> + </object> + </child> + <child> + <object class="GtkScrolledWindow"> + <property name="can_focus">True</property> + <property name="min_content_height">160</property> + <property name="hscrollbar_policy">never</property> + <property name="vscrollbar_policy">automatic</property> + <child> + <object class="GtkListBox" id="listbox"> + <property name="halign">fill</property> + <property name="valign">start</property> + <property name="selection_mode">none</property> + <property name="valign">start</property> + <style> + <class name="boxed-list" /> + </style> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + </template> +</interface> diff --git a/src/gs-repo-row.c b/src/gs-repo-row.c new file mode 100644 index 0000000..0a3a3b9 --- /dev/null +++ b/src/gs-repo-row.c @@ -0,0 +1,463 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2015-2018 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include <glib/gi18n.h> +#include <libsoup/soup.h> + +#include "gs-repo-row.h" + +typedef struct +{ + GsApp *repo; + GtkWidget *name_label; + GtkWidget *hostname_label; + GtkWidget *comment_label; + GtkWidget *remove_button; + GtkWidget *disable_switch; + gulong switch_handler_id; + guint refresh_idle_id; + guint busy_counter; + gboolean supports_remove; + gboolean supports_enable_disable; + gboolean always_allow_enable_disable; +} GsRepoRowPrivate; + +G_DEFINE_TYPE_WITH_PRIVATE (GsRepoRow, gs_repo_row, GTK_TYPE_LIST_BOX_ROW) + +enum { + SIGNAL_REMOVE_CLICKED, + SIGNAL_SWITCH_CLICKED, + SIGNAL_LAST +}; + +static guint signals [SIGNAL_LAST] = { 0 }; + +static void +refresh_ui (GsRepoRow *row) +{ + GsRepoRowPrivate *priv = gs_repo_row_get_instance_private (row); + GtkListBox *listbox; + gboolean active = FALSE; + gboolean state_sensitive = FALSE; + gboolean busy = priv->busy_counter> 0; + gboolean is_provenance; + gboolean is_compulsory; + + if (priv->repo == NULL) { + gtk_widget_set_sensitive (priv->disable_switch, FALSE); + gtk_switch_set_active (GTK_SWITCH (priv->disable_switch), FALSE); + return; + } + + g_signal_handler_block (priv->disable_switch, priv->switch_handler_id); + gtk_widget_set_sensitive (priv->disable_switch, TRUE); + + switch (gs_app_get_state (priv->repo)) { + case GS_APP_STATE_AVAILABLE: + case GS_APP_STATE_AVAILABLE_LOCAL: + active = FALSE; + state_sensitive = TRUE; + break; + case GS_APP_STATE_INSTALLED: + active = TRUE; + break; + case GS_APP_STATE_INSTALLING: + active = TRUE; + busy = TRUE; + break; + case GS_APP_STATE_REMOVING: + active = FALSE; + busy = TRUE; + break; + case GS_APP_STATE_UNAVAILABLE: + g_signal_handler_unblock (priv->disable_switch, priv->switch_handler_id); + listbox = GTK_LIST_BOX (gtk_widget_get_parent (GTK_WIDGET (row))); + g_assert (listbox != NULL); + gtk_list_box_remove (listbox, GTK_WIDGET (row)); + return; + default: + state_sensitive = TRUE; + break; + } + + is_provenance = gs_app_has_quirk (priv->repo, GS_APP_QUIRK_PROVENANCE); + is_compulsory = gs_app_has_quirk (priv->repo, GS_APP_QUIRK_COMPULSORY); + + /* Disable for the system repos, if installed */ + gtk_widget_set_sensitive (priv->disable_switch, priv->supports_enable_disable && (state_sensitive || !is_compulsory || priv->always_allow_enable_disable)); + gtk_widget_set_visible (priv->remove_button, priv->supports_remove && !is_provenance && !is_compulsory); + + /* Set only the 'state' to visually indicate the state is not saved yet */ + if (busy) + gtk_switch_set_state (GTK_SWITCH (priv->disable_switch), active); + else + gtk_switch_set_active (GTK_SWITCH (priv->disable_switch), active); + + g_signal_handler_unblock (priv->disable_switch, priv->switch_handler_id); +} + +static gboolean +refresh_idle (gpointer user_data) +{ + g_autoptr(GsRepoRow) row = (GsRepoRow *) user_data; + GsRepoRowPrivate *priv = gs_repo_row_get_instance_private (row); + + priv->refresh_idle_id = 0; + + refresh_ui (row); + + return G_SOURCE_REMOVE; +} + +static void +repo_state_changed_cb (GsApp *repo, GParamSpec *pspec, GsRepoRow *row) +{ + GsRepoRowPrivate *priv = gs_repo_row_get_instance_private (row); + + if (priv->refresh_idle_id > 0) + return; + priv->refresh_idle_id = g_idle_add (refresh_idle, g_object_ref (row)); +} + +static gchar * +get_repo_installed_text (GsApp *repo) +{ + GsAppList *related; + guint cnt_addon = 0; + guint cnt_apps = 0; + g_autofree gchar *addons_text = NULL; + g_autofree gchar *apps_text = NULL; + + related = gs_app_get_related (repo); + for (guint i = 0; i < gs_app_list_length (related); i++) { + GsApp *app_tmp = gs_app_list_index (related, i); + switch (gs_app_get_kind (app_tmp)) { + case AS_COMPONENT_KIND_WEB_APP: + case AS_COMPONENT_KIND_DESKTOP_APP: + cnt_apps++; + break; + case AS_COMPONENT_KIND_FONT: + case AS_COMPONENT_KIND_CODEC: + case AS_COMPONENT_KIND_INPUT_METHOD: + case AS_COMPONENT_KIND_ADDON: + cnt_addon++; + break; + default: + break; + } + } + + if (cnt_addon == 0) { + /* TRANSLATORS: This string is used to construct the 'X applications + installed' sentence, describing a software repository. */ + return g_strdup_printf (ngettext ("%u application installed", + "%u applications installed", + cnt_apps), cnt_apps); + } + if (cnt_apps == 0) { + /* TRANSLATORS: This string is used to construct the 'X add-ons + installed' sentence, describing a software repository. */ + return g_strdup_printf (ngettext ("%u add-on installed", + "%u add-ons installed", + cnt_addon), cnt_addon); + } + + /* TRANSLATORS: This string is used to construct the 'X applications + and y add-ons installed' sentence, describing a software repository. + The correct form here depends on the number of applications. */ + apps_text = g_strdup_printf (ngettext ("%u application", + "%u applications", + cnt_apps), cnt_apps); + /* TRANSLATORS: This string is used to construct the 'X applications + and y add-ons installed' sentence, describing a software repository. + The correct form here depends on the number of add-ons. */ + addons_text = g_strdup_printf (ngettext ("%u add-on", + "%u add-ons", + cnt_addon), cnt_addon); + /* TRANSLATORS: This string is used to construct the 'X applications + and y add-ons installed' sentence, describing a software repository. + The correct form here depends on the total number of + applications and add-ons. */ + return g_strdup_printf (ngettext ("%s and %s installed", + "%s and %s installed", + cnt_apps + cnt_addon), + apps_text, addons_text); +} + +static void +gs_repo_row_set_repo (GsRepoRow *self, GsApp *repo) +{ + GsRepoRowPrivate *priv = gs_repo_row_get_instance_private (self); + g_autoptr(GsPlugin) plugin = NULL; + g_autofree gchar *comment = NULL; + const gchar *tmp; + + g_assert (priv->repo == NULL); + + priv->repo = g_object_ref (repo); + g_signal_connect_object (priv->repo, "notify::state", + G_CALLBACK (repo_state_changed_cb), + self, 0); + + plugin = gs_app_dup_management_plugin (repo); + if (plugin) { + GsPluginClass *plugin_class = GS_PLUGIN_GET_CLASS (plugin); + priv->supports_remove = plugin_class != NULL && plugin_class->remove_repository_async != NULL; + priv->supports_enable_disable = plugin_class != NULL && + plugin_class->enable_repository_async != NULL && + plugin_class->disable_repository_async != NULL; + } else { + priv->supports_remove = FALSE; + priv->supports_enable_disable = FALSE; + } + + gtk_label_set_label (GTK_LABEL (priv->name_label), gs_app_get_name (repo)); + + gtk_widget_set_visible (priv->hostname_label, FALSE); + + tmp = gs_app_get_url (repo, AS_URL_KIND_HOMEPAGE); + if (tmp != NULL && *tmp != '\0') { + g_autoptr(GUri) uri = NULL; + + uri = g_uri_parse (tmp, SOUP_HTTP_URI_FLAGS, NULL); + if (uri && g_uri_get_host (uri) != NULL && *g_uri_get_host (uri) != '\0') { + gtk_label_set_label (GTK_LABEL (priv->hostname_label), g_uri_get_host (uri)); + gtk_widget_set_visible (priv->hostname_label, TRUE); + } + } + + comment = get_repo_installed_text (repo); + tmp = gs_app_get_metadata_item (priv->repo, "GnomeSoftware::InstallationKind"); + if (tmp != NULL && *tmp != '\0') { + gchar *cnt; + + /* Translators: The first '%s' is replaced with a text like '10 applications installed', + the second '%s' is replaced with installation kind, like in case of Flatpak 'User Installation'. */ + cnt = g_strdup_printf (C_("repo-row", "%s • %s"), comment, tmp); + g_clear_pointer (&comment, g_free); + comment = cnt; + } + + gtk_label_set_label (GTK_LABEL (priv->comment_label), comment); + + refresh_ui (self); +} + +GsApp * +gs_repo_row_get_repo (GsRepoRow *self) +{ + GsRepoRowPrivate *priv = gs_repo_row_get_instance_private (self); + g_return_val_if_fail (GS_IS_REPO_ROW (self), NULL); + return priv->repo; +} + +static void +disable_switch_clicked_cb (GtkWidget *widget, + GParamSpec *param, + GsRepoRow *row) +{ + g_return_if_fail (GS_IS_REPO_ROW (row)); + gs_repo_row_emit_switch_clicked (row); +} + +static void +gs_repo_row_remove_button_clicked_cb (GtkWidget *button, + GsRepoRow *row) +{ + GsRepoRowPrivate *priv = gs_repo_row_get_instance_private (row); + + g_return_if_fail (GS_IS_REPO_ROW (row)); + + if (priv->repo == NULL || priv->busy_counter) + return; + + g_signal_emit (row, signals[SIGNAL_REMOVE_CLICKED], 0); +} + +static void +gs_repo_row_dispose (GObject *object) +{ + GsRepoRow *self = GS_REPO_ROW (object); + GsRepoRowPrivate *priv = gs_repo_row_get_instance_private (self); + + if (priv->repo != NULL) { + g_signal_handlers_disconnect_by_func (priv->repo, repo_state_changed_cb, self); + g_clear_object (&priv->repo); + } + + if (priv->refresh_idle_id != 0) { + g_source_remove (priv->refresh_idle_id); + priv->refresh_idle_id = 0; + } + + G_OBJECT_CLASS (gs_repo_row_parent_class)->dispose (object); +} + +static void +gs_repo_row_init (GsRepoRow *self) +{ + GsRepoRowPrivate *priv = gs_repo_row_get_instance_private (self); + GtkWidget *image; + + gtk_widget_init_template (GTK_WIDGET (self)); + priv->switch_handler_id = g_signal_connect (priv->disable_switch, "notify::active", + G_CALLBACK (disable_switch_clicked_cb), self); + image = gtk_image_new_from_icon_name ("user-trash-symbolic"); + gtk_button_set_child (GTK_BUTTON (priv->remove_button), image); + g_signal_connect (priv->remove_button, "clicked", + G_CALLBACK (gs_repo_row_remove_button_clicked_cb), self); +} + +static void +gs_repo_row_class_init (GsRepoRowClass *klass) +{ + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + GObjectClass *object_class = G_OBJECT_CLASS (klass); + + object_class->dispose = gs_repo_row_dispose; + + signals [SIGNAL_REMOVE_CLICKED] = + g_signal_new ("remove-clicked", + G_TYPE_FROM_CLASS (object_class), G_SIGNAL_RUN_LAST, + G_STRUCT_OFFSET (GsRepoRowClass, remove_clicked), + NULL, NULL, g_cclosure_marshal_VOID__VOID, + G_TYPE_NONE, 0, G_TYPE_NONE); + + signals [SIGNAL_SWITCH_CLICKED] = + g_signal_new ("switch-clicked", + G_TYPE_FROM_CLASS (object_class), G_SIGNAL_RUN_LAST, + G_STRUCT_OFFSET (GsRepoRowClass, switch_clicked), + NULL, NULL, g_cclosure_marshal_VOID__VOID, + G_TYPE_NONE, 0, G_TYPE_NONE); + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-repo-row.ui"); + + gtk_widget_class_bind_template_child_private (widget_class, GsRepoRow, name_label); + gtk_widget_class_bind_template_child_private (widget_class, GsRepoRow, hostname_label); + gtk_widget_class_bind_template_child_private (widget_class, GsRepoRow, comment_label); + gtk_widget_class_bind_template_child_private (widget_class, GsRepoRow, remove_button); + gtk_widget_class_bind_template_child_private (widget_class, GsRepoRow, disable_switch); +} + +/* + * gs_repo_row_new: + * @repo: a #GsApp to represent the repo in the new row + * @always_allow_enable_disable: always allow enabled/disable of the @repo + * + * The @always_allow_enable_disable, when %TRUE, means that the @repo in this row + * can be always enabled/disabled by the user, if supported by the related plugin, + * regardless of the other heuristics, which can avoid the repo enable/disable. + * + * Returns: (transfer full): a newly created #GsRepoRow + */ +GtkWidget * +gs_repo_row_new (GsApp *repo, + gboolean always_allow_enable_disable) +{ + GsRepoRow *row = g_object_new (GS_TYPE_REPO_ROW, NULL); + GsRepoRowPrivate *priv = gs_repo_row_get_instance_private (row); + priv->always_allow_enable_disable = always_allow_enable_disable; + gs_repo_row_set_repo (row, repo); + return GTK_WIDGET (row); +} + +static void +gs_repo_row_change_busy (GsRepoRow *self, + gboolean value) +{ + GsRepoRowPrivate *priv = gs_repo_row_get_instance_private (self); + + g_return_if_fail (GS_IS_REPO_ROW (self)); + + if (value) + g_return_if_fail (priv->busy_counter + 1 > priv->busy_counter); + else + g_return_if_fail (priv->busy_counter > 0); + + priv->busy_counter += (value ? 1 : -1); + + if (value && priv->busy_counter == 1) + gtk_widget_set_sensitive (priv->disable_switch, FALSE); + else if (!value && !priv->busy_counter) + refresh_ui (self); +} + +/** + * gs_repo_row_mark_busy: + * @row: a #GsRepoRow + * + * Mark the @row as busy, that is the @row has pending operation(s). + * Unmark the @row as busy with gs_repo_row_unmark_busy() once + * the operation is done. This can be called mutliple times, only call + * the gs_repo_row_unmark_busy() as many times as this function had + * been called. + * + * Since: 41 + **/ +void +gs_repo_row_mark_busy (GsRepoRow *row) +{ + gs_repo_row_change_busy (row, TRUE); +} + +/** + * gs_repo_row_unmark_busy: + * @row: a #GsRepoRow + * + * A pair function for gs_repo_row_mark_busy(). + * + * Since: 41 + **/ +void +gs_repo_row_unmark_busy (GsRepoRow *row) +{ + gs_repo_row_change_busy (row, FALSE); +} + +/** + * gs_repo_row_get_is_busy: + * @row: a #GsRepoRow + * + * Returns: %TRUE, when there is any pending operation for the @row + * + * Since: 41 + **/ +gboolean +gs_repo_row_get_is_busy (GsRepoRow *row) +{ + GsRepoRowPrivate *priv = gs_repo_row_get_instance_private (row); + + g_return_val_if_fail (GS_IS_REPO_ROW (row), FALSE); + + return priv->busy_counter > 0; +} + +/** + * gs_repo_row_emit_switch_clicked: + * @self: a #GsRepoRow + * + * Emits the GsRepoRow:switch-clicked signal, if applicable. + * + * Since: 41 + **/ +void +gs_repo_row_emit_switch_clicked (GsRepoRow *self) +{ + GsRepoRowPrivate *priv = gs_repo_row_get_instance_private (self); + + g_return_if_fail (GS_IS_REPO_ROW (self)); + + if (priv->repo == NULL || priv->busy_counter > 0 || + !gtk_widget_get_visible (priv->disable_switch) || + !gtk_widget_get_sensitive (priv->disable_switch)) + return; + + g_signal_emit (self, signals[SIGNAL_SWITCH_CLICKED], 0); +} diff --git a/src/gs-repo-row.h b/src/gs-repo-row.h new file mode 100644 index 0000000..0cbbdc6 --- /dev/null +++ b/src/gs-repo-row.h @@ -0,0 +1,35 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2015-2018 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include "gnome-software-private.h" +#include <gtk/gtk.h> + +G_BEGIN_DECLS + +#define GS_TYPE_REPO_ROW (gs_repo_row_get_type ()) + +G_DECLARE_DERIVABLE_TYPE (GsRepoRow, gs_repo_row, GS, REPO_ROW, GtkListBoxRow) + +struct _GsRepoRowClass +{ + GtkListBoxRowClass parent_class; + void (*remove_clicked) (GsRepoRow *row); + void (*switch_clicked) (GsRepoRow *row); +}; + +GtkWidget *gs_repo_row_new (GsApp *repo, + gboolean always_allow_enable_disable); +GsApp *gs_repo_row_get_repo (GsRepoRow *row); +void gs_repo_row_mark_busy (GsRepoRow *row); +void gs_repo_row_unmark_busy (GsRepoRow *row); +gboolean gs_repo_row_get_is_busy (GsRepoRow *row); +void gs_repo_row_emit_switch_clicked (GsRepoRow *self); + +G_END_DECLS diff --git a/src/gs-repo-row.ui b/src/gs-repo-row.ui new file mode 100644 index 0000000..cffd586 --- /dev/null +++ b/src/gs-repo-row.ui @@ -0,0 +1,98 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <!-- interface-requires gtk+ 3.10 --> + <template class="GsRepoRow" parent="GtkListBoxRow"> + <child> + <object class="GtkGrid"> + <property name="margin-top">12</property> + <property name="margin-bottom">12</property> + <property name="margin-start">12</property> + <property name="margin-end">12</property> + <property name="row-spacing">6</property> + <property name="column-spacing">12</property> + <child> + <object class="GtkLabel" id="name_label"> + <property name="halign">start</property> + <property name="hexpand">True</property> + <property name="ellipsize">end</property> + <property name="xalign">0</property> + <layout> + <property name="column">0</property> + <property name="row">0</property> + <property name="column-span">1</property> + <property name="row-span">1</property> + </layout> + </object> + </child> + <child> + <object class="GtkLabel" id="hostname_label"> + <property name="halign">start</property> + <property name="hexpand">True</property> + <property name="ellipsize">end</property> + <property name="xalign">0</property> + <attributes> + <attribute name="scale" value="0.8"/> + </attributes> + <layout> + <property name="column">0</property> + <property name="row">1</property> + <property name="column-span">1</property> + <property name="row-span">1</property> + </layout> + </object> + </child> + <child> + <object class="GtkLabel" id="comment_label"> + <property name="halign">start</property> + <property name="hexpand">True</property> + <property name="ellipsize">end</property> + <property name="xalign">0</property> + <property name="wrap">True</property> + <property name="lines">2</property> + <attributes> + <attribute name="scale" value="0.8"/> + </attributes> + <style> + <class name="dim-label"/> + </style> + <layout> + <property name="column">0</property> + <property name="row">2</property> + <property name="column-span">1</property> + <property name="row-span">1</property> + </layout> + </object> + </child> + <child> + <object class="GtkButton" id="remove_button"> + <property name="visible">False</property> + <property name="can_focus">True</property> + <property name="halign">end</property> + <property name="valign">center</property> + <property name="hexpand">False</property> + <layout> + <property name="column">1</property> + <property name="row">0</property> + <property name="column-span">1</property> + <property name="row-span">3</property> + </layout> + </object> + </child> + <child> + <object class="GtkSwitch" id="disable_switch"> + <property name="can_focus">True</property> + <property name="halign">end</property> + <property name="valign">center</property> + <property name="hexpand">False</property> + <layout> + <property name="column">2</property> + <property name="row">0</property> + <property name="column-span">1</property> + <property name="row-span">3</property> + </layout> + </object> + </child> + </object> + </child> + </template> +</interface> diff --git a/src/gs-repos-dialog.c b/src/gs-repos-dialog.c new file mode 100644 index 0000000..81a1471 --- /dev/null +++ b/src/gs-repos-dialog.c @@ -0,0 +1,766 @@ +/* -*- 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-2018 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include "gs-repos-dialog.h" + +#include "gnome-software-private.h" +#include "gs-common.h" +#include "gs-os-release.h" +#include "gs-repo-row.h" +#include "gs-repos-section.h" +#include "gs-utils.h" +#include <glib/gi18n.h> + +struct _GsReposDialog +{ + AdwWindow parent_instance; + GSettings *settings; + GsFedoraThirdParty *third_party; + gboolean third_party_enabled; + GHashTable *third_party_repos; /* (nullable) (owned), mapping from owned repo ID → owned plugin name */ + GHashTable *sections; /* gchar * ~> GsReposSection * */ + + GCancellable *cancellable; + GsPluginLoader *plugin_loader; + GtkWidget *status_empty; + GtkWidget *content_page; + GtkWidget *spinner; + GtkWidget *stack; +}; + +G_DEFINE_TYPE (GsReposDialog, gs_repos_dialog, ADW_TYPE_WINDOW) + +static void reload_third_party_repos (GsReposDialog *dialog); + +typedef struct { + GsReposDialog *dialog; + GsApp *repo; + GWeakRef row_weakref; + GsPluginManageRepositoryFlags operation; +} InstallRemoveData; + +static void +install_remove_data_free (InstallRemoveData *data) +{ + g_clear_object (&data->dialog); + g_clear_object (&data->repo); + g_weak_ref_clear (&data->row_weakref); + g_slice_free (InstallRemoveData, data); +} + +G_DEFINE_AUTOPTR_CLEANUP_FUNC(InstallRemoveData, install_remove_data_free); + +static void +repo_enabled_cb (GObject *source, + GAsyncResult *res, + gpointer user_data) +{ + GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (source); + g_autoptr(InstallRemoveData) install_remove_data = (InstallRemoveData *) user_data; + g_autoptr(GError) error = NULL; + g_autoptr(GsRepoRow) row = NULL; + const gchar *operation_str; + + operation_str = install_remove_data->operation == GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_INSTALL ? "install" : + install_remove_data->operation == GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_REMOVE ? "remove" : + install_remove_data->operation == GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_ENABLE ? "enable" : + install_remove_data->operation == GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_DISABLE ? "disable" : NULL; + g_assert (operation_str != NULL); + + row = g_weak_ref_get (&install_remove_data->row_weakref); + if (row) + gs_repo_row_unmark_busy (row); + + if (!gs_plugin_loader_job_action_finish (plugin_loader, res, &error)) { + if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_CANCELLED) || + g_error_matches (error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) { + g_debug ("repo %s cancelled", operation_str); + return; + } + + g_warning ("failed to %s repo: %s", operation_str, error->message); + return; + } + + g_debug ("finished %s repo %s", operation_str, gs_app_get_id (install_remove_data->repo)); +} + +static void +_enable_repo (InstallRemoveData *install_data) +{ + GsReposDialog *dialog = install_data->dialog; + g_autoptr(GsPluginJob) plugin_job = NULL; + g_debug ("enabling repo %s", gs_app_get_id (install_data->repo)); + plugin_job = gs_plugin_job_manage_repository_new (install_data->repo, + install_data->operation | GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_INTERACTIVE); + gs_plugin_loader_job_process_async (dialog->plugin_loader, plugin_job, + dialog->cancellable, + repo_enabled_cb, + install_data); +} + +static void +enable_repo_response_cb (GtkDialog *confirm_dialog, + gint response, + gpointer user_data) +{ + g_autoptr(InstallRemoveData) install_data = (InstallRemoveData *) user_data; + + /* unmap the dialog */ + gtk_window_destroy (GTK_WINDOW (confirm_dialog)); + + /* not agreed */ + if (response != GTK_RESPONSE_OK) { + g_autoptr(GsRepoRow) row = g_weak_ref_get (&install_data->row_weakref); + if (row) + gs_repo_row_unmark_busy (row); + return; + } + + _enable_repo (g_steal_pointer (&install_data)); +} + +static void +enable_repo (GsReposDialog *dialog, + GsRepoRow *row, + GsApp *repo) +{ + g_autoptr(InstallRemoveData) install_data = NULL; + + install_data = g_slice_new0 (InstallRemoveData); + install_data->operation = GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_ENABLE; + install_data->dialog = g_object_ref (dialog); + install_data->repo = g_object_ref (repo); + g_weak_ref_init (&install_data->row_weakref, row); + + gs_repo_row_mark_busy (row); + + /* user needs to confirm acceptance of an agreement */ + if (gs_app_get_agreement (repo) != NULL) { + GtkWidget *confirm_dialog; + g_autofree gchar *message = NULL; + g_autoptr(GError) error = NULL; + + /* convert from AppStream markup */ + message = as_markup_convert_simple (gs_app_get_agreement (repo), &error); + if (message == NULL) { + /* failed, so just try and show the original markup */ + message = g_strdup (gs_app_get_agreement (repo)); + g_warning ("Failed to process AppStream markup: %s", + error->message); + } + + /* ask for confirmation */ + confirm_dialog = gtk_message_dialog_new (GTK_WINDOW (dialog), + GTK_DIALOG_MODAL, + GTK_MESSAGE_QUESTION, + GTK_BUTTONS_CANCEL, + /* TRANSLATORS: window title */ + "%s", _("Enable Third-Party Software Repository?")); + gtk_message_dialog_format_secondary_text (GTK_MESSAGE_DIALOG (confirm_dialog), + "%s", message); + + /* TRANSLATORS: button to accept the agreement */ + gtk_dialog_add_button (GTK_DIALOG (confirm_dialog), _("Enable"), + GTK_RESPONSE_OK); + + /* handle this async */ + g_signal_connect (confirm_dialog, "response", + G_CALLBACK (enable_repo_response_cb), + g_steal_pointer (&install_data)); + + gtk_window_set_modal (GTK_WINDOW (confirm_dialog), TRUE); + gtk_window_present (GTK_WINDOW (confirm_dialog)); + return; + } + + /* no prompt required */ + _enable_repo (g_steal_pointer (&install_data)); +} + +static void +remove_repo_response_cb (GtkDialog *confirm_dialog, + gint response, + gpointer user_data) +{ + g_autoptr(InstallRemoveData) remove_data = (InstallRemoveData *) user_data; + GsReposDialog *dialog = remove_data->dialog; + g_autoptr(GsPluginJob) plugin_job = NULL; + + /* unmap the dialog */ + gtk_window_destroy (GTK_WINDOW (confirm_dialog)); + + /* not agreed */ + if (response != GTK_RESPONSE_OK) { + g_autoptr(GsRepoRow) row = g_weak_ref_get (&remove_data->row_weakref); + if (row) + gs_repo_row_unmark_busy (row); + return; + } + + g_debug ("removing repo %s", gs_app_get_id (remove_data->repo)); + plugin_job = gs_plugin_job_manage_repository_new (remove_data->repo, + remove_data->operation | + GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_INTERACTIVE); + gs_plugin_loader_job_process_async (dialog->plugin_loader, plugin_job, + dialog->cancellable, + repo_enabled_cb, + g_steal_pointer (&remove_data)); +} + +static void +remove_confirm_repo (GsReposDialog *dialog, + GsRepoRow *row, + GsApp *repo, + GsPluginManageRepositoryFlags operation) +{ + InstallRemoveData *remove_data; + GtkWidget *confirm_dialog; + g_autofree gchar *message = NULL; + GtkWidget *button; + GtkStyleContext *context; + + remove_data = g_slice_new0 (InstallRemoveData); + remove_data->operation = operation; + remove_data->dialog = g_object_ref (dialog); + remove_data->repo = g_object_ref (repo); + g_weak_ref_init (&remove_data->row_weakref, row); + + /* TRANSLATORS: The '%s' is replaced with a repository name, like "Fedora Modular - x86_64" */ + message = g_strdup_printf (_("Software that has been installed from “%s” will cease to receive updates."), + gs_app_get_name (repo)); + + /* ask for confirmation */ + confirm_dialog = gtk_message_dialog_new (GTK_WINDOW (dialog), + GTK_DIALOG_MODAL, + GTK_MESSAGE_QUESTION, + GTK_BUTTONS_CANCEL, + "%s", + operation == GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_DISABLE ? _("Disable Repository?") : _("Remove Repository?")); + gtk_message_dialog_format_secondary_text (GTK_MESSAGE_DIALOG (confirm_dialog), + "%s", message); + + if (operation == GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_DISABLE) { + /* TRANSLATORS: this is button text to disable a repo */ + button = gtk_dialog_add_button (GTK_DIALOG (confirm_dialog), _("_Disable"), GTK_RESPONSE_OK); + } else { + /* TRANSLATORS: this is button text to remove a repo */ + button = gtk_dialog_add_button (GTK_DIALOG (confirm_dialog), _("_Remove"), GTK_RESPONSE_OK); + } + context = gtk_widget_get_style_context (button); + gtk_style_context_add_class (context, "destructive-action"); + + /* handle this async */ + g_signal_connect (confirm_dialog, "response", + G_CALLBACK (remove_repo_response_cb), remove_data); + + gtk_window_set_modal (GTK_WINDOW (confirm_dialog), TRUE); + gtk_window_present (GTK_WINDOW (confirm_dialog)); + + gs_repo_row_mark_busy (row); +} + +static void +repo_section_switch_clicked_cb (GsReposSection *section, + GsRepoRow *row, + GsReposDialog *dialog) +{ + GsApp *repo; + + repo = gs_repo_row_get_repo (row); + + switch (gs_app_get_state (repo)) { + case GS_APP_STATE_AVAILABLE: + case GS_APP_STATE_AVAILABLE_LOCAL: + enable_repo (dialog, row, repo); + break; + case GS_APP_STATE_INSTALLED: + remove_confirm_repo (dialog, row, repo, GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_DISABLE); + break; + default: + g_warning ("repo %s button clicked in unexpected state %s", + gs_app_get_id (repo), + gs_app_state_to_string (gs_app_get_state (repo))); + break; + } +} + +static void +repo_section_remove_clicked_cb (GsReposSection *section, + GsRepoRow *row, + GsReposDialog *dialog) +{ + GsApp *repo = gs_repo_row_get_repo (row); + remove_confirm_repo (dialog, row, repo, GS_PLUGIN_MANAGE_REPOSITORY_FLAGS_REMOVE); +} + +static void +fedora_third_party_switch_done_cb (GObject *source_object, + GAsyncResult *result, + gpointer user_data) +{ + g_autoptr(GsReposDialog) self = user_data; + g_autoptr(GError) error = NULL; + + if (!gs_fedora_third_party_switch_finish (GS_FEDORA_THIRD_PARTY (source_object), result, &error)) { + if (g_error_matches (error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) + return; + g_warning ("Failed to switch 'fedora-third-party' config: %s", error->message); + } + + /* Reload the state, because the user could dismiss the authentication prompt + or the repos could change their state. */ + reload_third_party_repos (self); +} + +static void +fedora_third_party_repos_switch_notify_cb (GObject *object, + GParamSpec *param, + gpointer user_data) +{ + GsReposDialog *self = user_data; + + gs_fedora_third_party_switch (self->third_party, + gtk_switch_get_active (GTK_SWITCH (object)), + TRUE, + self->cancellable, + fedora_third_party_switch_done_cb, + g_object_ref (self)); +} + +static gboolean +is_third_party_repo (GsReposDialog *dialog, + GsApp *repo) +{ + g_autoptr(GsPlugin) plugin = gs_app_dup_management_plugin (repo); + const gchar *plugin_name = (plugin != NULL) ? gs_plugin_get_name (plugin) : NULL; + + return gs_app_get_scope (repo) == AS_COMPONENT_SCOPE_SYSTEM && + gs_fedora_third_party_util_is_third_party_repo (dialog->third_party_repos, + gs_app_get_id (repo), + plugin_name); +} + +static void +add_repo (GsReposDialog *dialog, + GsApp *repo, + GSList **third_party_repos) +{ + GsAppState state; + GtkWidget *section; + g_autofree gchar *origin_ui = NULL; + + state = gs_app_get_state (repo); + if (!(state == GS_APP_STATE_AVAILABLE || + state == GS_APP_STATE_AVAILABLE_LOCAL || + state == GS_APP_STATE_INSTALLED || + state == GS_APP_STATE_INSTALLING || + state == GS_APP_STATE_REMOVING)) { + g_warning ("repo %s in invalid state %s", + gs_app_get_id (repo), + gs_app_state_to_string (state)); + return; + } + + if (third_party_repos && is_third_party_repo (dialog, repo)) { + *third_party_repos = g_slist_prepend (*third_party_repos, repo); + return; + } + + origin_ui = gs_app_dup_origin_ui (repo, TRUE); + if (!origin_ui) + origin_ui = gs_app_get_packaging_format (repo); + if (!origin_ui) { + g_autoptr(GsPlugin) plugin = gs_app_dup_management_plugin (repo); + origin_ui = (plugin != NULL) ? g_strdup (gs_plugin_get_name (plugin)) : NULL; + } + + section = g_hash_table_lookup (dialog->sections, origin_ui); + if (section == NULL) { + section = gs_repos_section_new (FALSE); + adw_preferences_group_set_title (ADW_PREFERENCES_GROUP (section), + origin_ui); + g_signal_connect_object (section, "remove-clicked", + G_CALLBACK (repo_section_remove_clicked_cb), dialog, 0); + g_signal_connect_object (section, "switch-clicked", + G_CALLBACK (repo_section_switch_clicked_cb), dialog, 0); + g_hash_table_insert (dialog->sections, g_steal_pointer (&origin_ui), section); + gtk_widget_show (section); + } + + gs_repos_section_add_repo (GS_REPOS_SECTION (section), repo); +} + +static gint +repos_dialog_compare_sections_cb (gconstpointer aa, + gconstpointer bb) +{ + GsReposSection *section_a = (GsReposSection *) aa; + GsReposSection *section_b = (GsReposSection *) bb; + const gchar *section_sort_key_a; + const gchar *section_sort_key_b; + g_autofree gchar *title_sort_key_a = NULL; + g_autofree gchar *title_sort_key_b = NULL; + gint res; + + section_sort_key_a = gs_repos_section_get_sort_key (section_a); + section_sort_key_b = gs_repos_section_get_sort_key (section_b); + + res = g_strcmp0 (section_sort_key_a, section_sort_key_b); + if (res != 0) + return res; + + title_sort_key_a = gs_utils_sort_key (adw_preferences_group_get_title (ADW_PREFERENCES_GROUP (section_a))); + title_sort_key_b = gs_utils_sort_key (adw_preferences_group_get_title (ADW_PREFERENCES_GROUP (section_b))); + + return g_strcmp0 (title_sort_key_a, title_sort_key_b); +} + +static void +get_sources_cb (GsPluginLoader *plugin_loader, + GAsyncResult *res, + GsReposDialog *dialog) +{ + GsApp *app; + g_autoptr(GError) error = NULL; + g_autoptr(GsAppList) list = NULL; + g_autoptr(GSList) other_repos = NULL; + g_autoptr(GList) sections = NULL; + AdwPreferencesGroup *added_section; + GHashTableIter iter; + + /* get the results */ + 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_debug ("get sources cancelled"); + return; + } else { + g_warning ("failed to get sources: %s", error->message); + } + gtk_stack_set_visible_child_name (GTK_STACK (dialog->stack), "empty"); + return; + } + + /* remove previous */ + g_hash_table_iter_init (&iter, dialog->sections); + while (g_hash_table_iter_next (&iter, NULL, (gpointer *)&added_section)) { + adw_preferences_page_remove (ADW_PREFERENCES_PAGE (dialog->content_page), + added_section); + g_hash_table_iter_remove (&iter); + } + + /* stop the spinner */ + gtk_spinner_stop (GTK_SPINNER (dialog->spinner)); + + /* no results */ + if (gs_app_list_length (list) == 0) { + g_debug ("no sources to show"); + gtk_stack_set_visible_child_name (GTK_STACK (dialog->stack), "empty"); + return; + } + + /* add each */ + gtk_stack_set_visible_child_name (GTK_STACK (dialog->stack), "sources"); + for (guint i = 0; i < gs_app_list_length (list); i++) { + app = gs_app_list_index (list, i); + add_repo (dialog, app, &other_repos); + } + + sections = g_hash_table_get_values (dialog->sections); + sections = g_list_sort (sections, repos_dialog_compare_sections_cb); + for (GList *link = sections; link; link = g_list_next (link)) { + AdwPreferencesGroup *section = link->data; + adw_preferences_page_add (ADW_PREFERENCES_PAGE (dialog->content_page), section); + } + + gtk_widget_set_visible (dialog->content_page, sections != NULL); + + if (other_repos) { + GsReposSection *section; + GtkWidget *widget; + GtkWidget *row; + g_autofree gchar *anchor = NULL; + g_autofree gchar *hint = NULL; + g_autofree gchar *section_id = NULL; + + widget = gtk_switch_new (); + gtk_widget_set_valign (widget, GTK_ALIGN_CENTER); + gtk_switch_set_active (GTK_SWITCH (widget), dialog->third_party_enabled); + g_signal_connect_object (widget, "notify::active", + G_CALLBACK (fedora_third_party_repos_switch_notify_cb), dialog, 0); + gtk_widget_show (widget); + + row = adw_action_row_new (); +#if ADW_CHECK_VERSION(1,2,0) + adw_preferences_row_set_use_markup (ADW_PREFERENCES_ROW (row), FALSE); +#endif + adw_preferences_row_set_title (ADW_PREFERENCES_ROW (row), _("Enable New Repositories")); + adw_action_row_set_subtitle (ADW_ACTION_ROW (row), _("Turn on new repositories when they are added.")); + adw_action_row_set_activatable_widget (ADW_ACTION_ROW (row), widget); + adw_action_row_add_suffix (ADW_ACTION_ROW (row), widget); + gtk_widget_show (row); + + anchor = g_strdup_printf ("<a href=\"%s\">%s</a>", + "https://docs.fedoraproject.org/en-US/workstation-working-group/third-party-repos/", + /* TRANSLATORS: this is the clickable + * link on the third party repositories info bar */ + _("more information")); + hint = g_strdup_printf ( + /* TRANSLATORS: this is the third party repositories info bar. The '%s' is replaced + with a link consisting a text "more information", which constructs a sentence: + "Additional repositories from selected third parties - more information."*/ + _("Additional repositories from selected third parties — %s."), + anchor); + + widget = adw_preferences_group_new (); + adw_preferences_group_set_title (ADW_PREFERENCES_GROUP (widget), + _("Fedora Third Party Repositories")); + + adw_preferences_group_set_description (ADW_PREFERENCES_GROUP (widget), hint); + + gtk_widget_show (widget); + adw_preferences_group_add (ADW_PREFERENCES_GROUP (widget), row); + adw_preferences_page_add (ADW_PREFERENCES_PAGE (dialog->content_page), + ADW_PREFERENCES_GROUP (widget)); + + /* use something unique, not clashing with the other section names */ + section_id = g_strdup_printf ("fedora-third-party::1::%p", widget); + g_hash_table_insert (dialog->sections, g_steal_pointer (§ion_id), widget); + + section = GS_REPOS_SECTION (gs_repos_section_new (TRUE)); + gs_repos_section_set_sort_key (section, "900"); + g_signal_connect_object (section, "switch-clicked", + G_CALLBACK (repo_section_switch_clicked_cb), dialog, 0); + gtk_widget_show (GTK_WIDGET (section)); + + for (GSList *link = other_repos; link; link = g_slist_next (link)) { + GsApp *repo = link->data; + gs_repos_section_add_repo (section, repo); + } + + /* use something unique, not clashing with the other section names */ + section_id = g_strdup_printf ("fedora-third-party::2::%p", section); + g_hash_table_insert (dialog->sections, g_steal_pointer (§ion_id), section); + + adw_preferences_page_add (ADW_PREFERENCES_PAGE (dialog->content_page), + ADW_PREFERENCES_GROUP (section)); + } +} + +static void +reload_sources (GsReposDialog *dialog) +{ + g_autoptr(GsPluginJob) plugin_job = NULL; + + /* get the list of non-core software repositories */ + plugin_job = gs_plugin_job_newv (GS_PLUGIN_ACTION_GET_SOURCES, + "refine-flags", GS_PLUGIN_REFINE_FLAGS_REQUIRE_RELATED | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_ORIGIN_HOSTNAME | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_PROVENANCE, + "dedupe-flags", GS_APP_LIST_FILTER_FLAG_NONE, + NULL); + gs_plugin_loader_job_process_async (dialog->plugin_loader, plugin_job, + dialog->cancellable, + (GAsyncReadyCallback) get_sources_cb, + dialog); +} + +static void +fedora_third_party_list_repos_done_cb (GObject *source_object, + GAsyncResult *result, + gpointer user_data) +{ + g_autoptr(GsReposDialog) self = user_data; + g_autoptr(GHashTable) repos = NULL; + g_autoptr(GError) error = NULL; + + if (!gs_fedora_third_party_list_finish (GS_FEDORA_THIRD_PARTY (source_object), result, &repos, &error)) { + if (g_error_matches (error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) + return; + g_warning ("Failed to list 'fedora-third-party' repos: %s", error->message); + } else { + self->third_party_repos = g_steal_pointer (&repos); + } + + reload_sources (self); +} + +static void +fedora_third_party_query_done_cb (GObject *source_object, + GAsyncResult *result, + gpointer user_data) +{ + GsFedoraThirdPartyState state = GS_FEDORA_THIRD_PARTY_STATE_UNKNOWN; + g_autoptr(GsReposDialog) self = user_data; + g_autoptr(GError) error = NULL; + + if (!gs_fedora_third_party_query_finish (GS_FEDORA_THIRD_PARTY (source_object), result, &state, &error)) { + if (g_error_matches (error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) + return; + g_warning ("Failed to query 'fedora-third-party': %s", error->message); + } else { + self->third_party_enabled = state == GS_FEDORA_THIRD_PARTY_STATE_ENABLED; + } + + gs_fedora_third_party_list (self->third_party, self->cancellable, + fedora_third_party_list_repos_done_cb, g_object_ref (self)); +} + +static gboolean +is_fedora (void) +{ + const gchar *id = NULL; + g_autoptr(GsOsRelease) os_release = NULL; + + os_release = gs_os_release_new (NULL); + if (os_release == NULL) + return FALSE; + + id = gs_os_release_get_id (os_release); + if (g_strcmp0 (id, "fedora") == 0) + return TRUE; + + return FALSE; +} + +static void +reload_third_party_repos (GsReposDialog *dialog) +{ + /* Fedora-specific functionality */ + if (!is_fedora ()) { + reload_sources (dialog); + return; + } + + gs_fedora_third_party_invalidate (dialog->third_party); + + if (!gs_fedora_third_party_is_available (dialog->third_party)) { + reload_sources (dialog); + return; + } + + g_clear_pointer (&dialog->third_party_repos, g_hash_table_unref); + + gs_fedora_third_party_query (dialog->third_party, dialog->cancellable, fedora_third_party_query_done_cb, g_object_ref (dialog)); +} + +static gchar * +get_os_name (void) +{ + gchar *name = NULL; + g_autoptr(GsOsRelease) os_release = NULL; + + os_release = gs_os_release_new (NULL); + if (os_release != NULL) + name = g_strdup (gs_os_release_get_name (os_release)); + if (name == NULL) { + /* TRANSLATORS: this is the fallback text we use if we can't + figure out the name of the operating system */ + name = g_strdup (_("the operating system")); + } + + return name; +} + +static void +reload_cb (GsPluginLoader *plugin_loader, GsReposDialog *dialog) +{ + reload_third_party_repos (dialog); +} + +static void +set_plugin_loader (GsReposDialog *dialog, GsPluginLoader *plugin_loader) +{ + dialog->plugin_loader = g_object_ref (plugin_loader); + g_signal_connect (dialog->plugin_loader, "reload", + G_CALLBACK (reload_cb), dialog); +} + +static void +gs_repos_dialog_dispose (GObject *object) +{ + GsReposDialog *dialog = GS_REPOS_DIALOG (object); + + if (dialog->plugin_loader != NULL) { + g_signal_handlers_disconnect_by_func (dialog->plugin_loader, reload_cb, dialog); + g_clear_object (&dialog->plugin_loader); + } + + g_cancellable_cancel (dialog->cancellable); + g_clear_pointer (&dialog->third_party_repos, g_hash_table_unref); + g_clear_pointer (&dialog->sections, g_hash_table_unref); + g_clear_object (&dialog->third_party); + g_clear_object (&dialog->cancellable); + g_clear_object (&dialog->settings); + + G_OBJECT_CLASS (gs_repos_dialog_parent_class)->dispose (object); +} + +static void +gs_repos_dialog_init (GsReposDialog *dialog) +{ + g_autofree gchar *label_empty_text = NULL; + g_autofree gchar *os_name = NULL; + g_autoptr(GString) str = g_string_new (NULL); + + gtk_widget_init_template (GTK_WIDGET (dialog)); + + dialog->third_party = gs_fedora_third_party_new (); + dialog->cancellable = g_cancellable_new (); + dialog->settings = g_settings_new ("org.gnome.software"); + dialog->sections = g_hash_table_new_full (g_str_hash, g_str_equal, g_free, NULL); + + os_name = get_os_name (); + + /* TRANSLATORS: This is the description text displayed in the Software Repositories dialog. + %s gets replaced by the name of the actual distro, e.g. Fedora. */ + label_empty_text = g_strdup_printf (_("These repositories supplement the default software provided by %s."), + os_name); + adw_status_page_set_description (ADW_STATUS_PAGE (dialog->status_empty), label_empty_text); +} + +static void +gs_repos_dialog_class_init (GsReposDialogClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + + object_class->dispose = gs_repos_dialog_dispose; + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-repos-dialog.ui"); + + gtk_widget_class_bind_template_child (widget_class, GsReposDialog, status_empty); + gtk_widget_class_bind_template_child (widget_class, GsReposDialog, content_page); + gtk_widget_class_bind_template_child (widget_class, GsReposDialog, spinner); + gtk_widget_class_bind_template_child (widget_class, GsReposDialog, stack); + + gtk_widget_class_add_binding_action (widget_class, GDK_KEY_Escape, 0, "window.close", NULL); +} + +GtkWidget * +gs_repos_dialog_new (GtkWindow *parent, GsPluginLoader *plugin_loader) +{ + GsReposDialog *dialog; + + dialog = g_object_new (GS_TYPE_REPOS_DIALOG, + "transient-for", parent, + "modal", TRUE, + NULL); + set_plugin_loader (dialog, plugin_loader); + gtk_stack_set_visible_child_name (GTK_STACK (dialog->stack), "waiting"); + gtk_spinner_start (GTK_SPINNER (dialog->spinner)); + reload_third_party_repos (dialog); + + return GTK_WIDGET (dialog); +} diff --git a/src/gs-repos-dialog.h b/src/gs-repos-dialog.h new file mode 100644 index 0000000..c7b4e75 --- /dev/null +++ b/src/gs-repos-dialog.h @@ -0,0 +1,26 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2014-2018 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <adwaita.h> +#include <gtk/gtk.h> + +#include "gnome-software-private.h" + +G_BEGIN_DECLS + +#define GS_TYPE_REPOS_DIALOG (gs_repos_dialog_get_type ()) + +G_DECLARE_FINAL_TYPE (GsReposDialog, gs_repos_dialog, GS, REPOS_DIALOG, AdwWindow) + +GtkWidget *gs_repos_dialog_new (GtkWindow *parent, + GsPluginLoader *plugin_loader); + +G_END_DECLS diff --git a/src/gs-repos-dialog.ui b/src/gs-repos-dialog.ui new file mode 100644 index 0000000..c28fca0 --- /dev/null +++ b/src/gs-repos-dialog.ui @@ -0,0 +1,75 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <requires lib="gtk+" version="3.10"/> + <template class="GsReposDialog" parent="AdwWindow"> + <property name="modal">True</property> + <property name="destroy_with_parent">True</property> + <property name="icon_name">dialog-information</property> + <property name="title" translatable="yes">Software Repositories</property> + <property name="default-width">640</property> + <property name="default-height">576</property> + + <child> + <object class="GtkBox"> + <property name="orientation">vertical</property> + <property name="width-request">360</property> + <child> + <object class="AdwHeaderBar"> + <property name="show_start_title_buttons">True</property> + <property name="show_end_title_buttons">True</property> + <property name="title-widget"> + <object class="AdwWindowTitle"> + <property name="title" bind-source="GsReposDialog" bind-property="title" bind-flags="sync-create"/> + </object> + </property> + </object> + </child> + <child> + <object class="GtkStack" id="stack"> + <property name="vexpand">True</property> + + <child> + <object class="GtkStackPage"> + <property name="name">waiting</property> + <property name="child"> + <object class="GtkSpinner" id="spinner"> + <property name="width_request">32</property> + <property name="height_request">32</property> + <property name="halign">center</property> + <property name="valign">center</property> + <property name="hexpand">True</property> + <property name="vexpand">True</property> + </object> + </property> + </object> + </child> + + <child> + <object class="GtkStackPage"> + <property name="name">empty</property> + <property name="child"> + <object class="AdwStatusPage" id="status_empty"> + <property name="icon_name">org.gnome.Software-symbolic</property> + <property name="title" translatable="yes">No Repositories</property> + </object> + </property> + </object> + </child> + + + <child> + <object class="GtkStackPage"> + <property name="name">sources</property> + <property name="child"> + <object class="AdwPreferencesPage" id="content_page"> + </object> + </property> + </object> + </child> + + </object> + </child> + </object> + </child> + </template> +</interface> diff --git a/src/gs-repos-section.c b/src/gs-repos-section.c new file mode 100644 index 0000000..3e5d684 --- /dev/null +++ b/src/gs-repos-section.c @@ -0,0 +1,201 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2021 Red Hat <www.redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include <gio/gio.h> + +#include "gs-repo-row.h" +#include "gs-repos-section.h" + +struct _GsReposSection +{ + AdwPreferencesGroup parent_instance; + GtkWidget *title; + GtkListBox *list; + gchar *sort_key; + gboolean always_allow_enable_disable; +}; + +G_DEFINE_TYPE (GsReposSection, gs_repos_section, ADW_TYPE_PREFERENCES_GROUP) + +enum { + SIGNAL_REMOVE_CLICKED, + SIGNAL_SWITCH_CLICKED, + SIGNAL_LAST +}; + +static guint signals [SIGNAL_LAST] = { 0 }; + +static void +repo_remove_clicked_cb (GsRepoRow *row, + GsReposSection *section) +{ + g_signal_emit (section, signals[SIGNAL_REMOVE_CLICKED], 0, row); +} + +static void +repo_switch_clicked_cb (GsRepoRow *row, + GsReposSection *section) +{ + g_signal_emit (section, signals[SIGNAL_SWITCH_CLICKED], 0, row); +} + +static void +gs_repos_section_row_activated_cb (GtkListBox *box, + GtkListBoxRow *row, + gpointer user_data) +{ + GsReposSection *section = user_data; + g_return_if_fail (GS_IS_REPOS_SECTION (section)); + gs_repo_row_emit_switch_clicked (GS_REPO_ROW (row)); +} + +static gchar * +_get_app_sort_key (GsApp *app) +{ + if (gs_app_get_name (app) != NULL) + return gs_utils_sort_key (gs_app_get_name (app)); + + return NULL; +} + +static gint +_list_sort_func (GtkListBoxRow *a, GtkListBoxRow *b, gpointer user_data) +{ + GsApp *a1 = gs_repo_row_get_repo (GS_REPO_ROW (a)); + GsApp *a2 = gs_repo_row_get_repo (GS_REPO_ROW (b)); + g_autofree gchar *key1 = _get_app_sort_key (a1); + g_autofree gchar *key2 = _get_app_sort_key (a2); + + return g_strcmp0 (key1, key2); +} + +static void +gs_repos_section_finalize (GObject *object) +{ + GsReposSection *self = GS_REPOS_SECTION (object); + + g_free (self->sort_key); + + G_OBJECT_CLASS (gs_repos_section_parent_class)->finalize (object); +} + +static void +gs_repos_section_class_init (GsReposSectionClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + + object_class->finalize = gs_repos_section_finalize; + + signals [SIGNAL_REMOVE_CLICKED] = + g_signal_new ("remove-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_REPO_ROW); + + signals [SIGNAL_SWITCH_CLICKED] = + g_signal_new ("switch-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_REPO_ROW); +} + +static void +gs_repos_section_init (GsReposSection *self) +{ + GtkStyleContext *style_context; + + self->list = GTK_LIST_BOX (gtk_list_box_new ()); + g_object_set (G_OBJECT (self->list), + "visible", TRUE, + "selection-mode", GTK_SELECTION_NONE, + NULL); + gtk_list_box_set_sort_func (self->list, _list_sort_func, self, NULL); + + style_context = gtk_widget_get_style_context (GTK_WIDGET (self->list)); + gtk_style_context_add_class (style_context, "boxed-list"); + + adw_preferences_group_add (ADW_PREFERENCES_GROUP (self), GTK_WIDGET (self->list)); + + g_signal_connect (self->list, "row-activated", + G_CALLBACK (gs_repos_section_row_activated_cb), self); +} + +/* + * gs_repos_section_new: + * @always_allow_enable_disable: always allow enable/disable of the repos in this section + * + * Creates a new #GsReposSection. @always_allow_enable_disable is passed to each + * #GsRepoRow. + * + * The @always_allow_enable_disable, when %TRUE, means that every repo in this section + * can be enabled/disabled by the user, if supported by the related plugin, regardless + * of the other heuristics, which can avoid the repo enable/disable. + * + * Returns: (transfer full): a newly created #GsReposSection + */ +GtkWidget * +gs_repos_section_new (gboolean always_allow_enable_disable) +{ + GsReposSection *self; + + self = g_object_new (GS_TYPE_REPOS_SECTION, NULL); + + self->always_allow_enable_disable = always_allow_enable_disable; + + return GTK_WIDGET (self); +} + +void +gs_repos_section_add_repo (GsReposSection *self, + GsApp *repo) +{ + GtkWidget *row; + + g_return_if_fail (GS_IS_REPOS_SECTION (self)); + g_return_if_fail (GS_IS_APP (repo)); + + /* Derive the sort key from the repository. All repositories of the same kind + should have set the same sort key. It's because there's no other way to provide + the section sort key by the plugin without breaking the abstraction. */ + if (!self->sort_key) + self->sort_key = g_strdup (gs_app_get_metadata_item (repo, "GnomeSoftware::SortKey")); + + row = gs_repo_row_new (repo, self->always_allow_enable_disable); + + g_signal_connect (row, "remove-clicked", + G_CALLBACK (repo_remove_clicked_cb), self); + g_signal_connect (row, "switch-clicked", + G_CALLBACK (repo_switch_clicked_cb), self); + + gtk_list_box_prepend (self->list, row); + gtk_widget_show (row); +} + +const gchar * +gs_repos_section_get_sort_key (GsReposSection *self) +{ + g_return_val_if_fail (GS_IS_REPOS_SECTION (self), NULL); + + return self->sort_key; +} + +void +gs_repos_section_set_sort_key (GsReposSection *self, + const gchar *sort_key) +{ + g_return_if_fail (GS_IS_REPOS_SECTION (self)); + + if (g_strcmp0 (sort_key, self->sort_key) != 0) { + g_free (self->sort_key); + self->sort_key = g_strdup (sort_key); + } +} diff --git a/src/gs-repos-section.h b/src/gs-repos-section.h new file mode 100644 index 0000000..e26189f --- /dev/null +++ b/src/gs-repos-section.h @@ -0,0 +1,31 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2021 Red Hat <www.redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <adwaita.h> +#include <gtk/gtk.h> + +#include "gnome-software-private.h" +#include "gs-app.h" + +G_BEGIN_DECLS + +#define GS_TYPE_REPOS_SECTION (gs_repos_section_get_type ()) + +G_DECLARE_FINAL_TYPE (GsReposSection, gs_repos_section, GS, REPOS_SECTION, AdwPreferencesGroup) + +GtkWidget *gs_repos_section_new (gboolean always_allow_enable_disable); +void gs_repos_section_add_repo (GsReposSection *self, + GsApp *repo); +const gchar *gs_repos_section_get_title (GsReposSection *self); +const gchar *gs_repos_section_get_sort_key (GsReposSection *self); +void gs_repos_section_set_sort_key (GsReposSection *self, + const gchar *sort_key); + +G_END_DECLS diff --git a/src/gs-restarter.c b/src/gs-restarter.c new file mode 100644 index 0000000..f4c0b47 --- /dev/null +++ b/src/gs-restarter.c @@ -0,0 +1,216 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2017 Richard Hughes <richard@hughsie.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include <gio/gio.h> +#include <stdlib.h> + +#define GS_BINARY_NAME "gnome-software" +#define GS_DBUS_BUS_NAME "org.gnome.Software" +#define GS_DBUS_OBJECT_PATH "/org/gnome/Software" +#define GS_DBUS_INTERFACE_NAME "org.gtk.Actions" + +typedef struct { + GMainLoop *loop; + GDBusConnection *connection; + gboolean is_running; + gboolean timed_out; +} GsRestarterPrivate; + +static void +gs_restarter_on_name_appeared_cb (GDBusConnection *connection, + const gchar *name, + const gchar *name_owner, + gpointer user_data) +{ + GsRestarterPrivate *priv = (GsRestarterPrivate *) user_data; + priv->is_running = TRUE; + g_debug ("%s appeared", GS_DBUS_BUS_NAME); + if (g_main_loop_is_running (priv->loop)) + g_main_loop_quit (priv->loop); +} + +static void +gs_restarter_on_name_vanished_cb (GDBusConnection *connection, + const gchar *name, + gpointer user_data) +{ + GsRestarterPrivate *priv = (GsRestarterPrivate *) user_data; + priv->is_running = FALSE; + g_debug ("%s vanished", GS_DBUS_BUS_NAME); + if (g_main_loop_is_running (priv->loop)) + g_main_loop_quit (priv->loop); +} + +static gboolean +gs_restarter_loop_timeout_cb (gpointer user_data) +{ + GsRestarterPrivate *priv = (GsRestarterPrivate *) user_data; + priv->timed_out = TRUE; + g_main_loop_quit (priv->loop); + return TRUE; +} + +static gboolean +gs_restarter_wait_for_timeout (GsRestarterPrivate *priv, + guint timeout_ms, + GError **error) +{ + guint timer_id; + priv->timed_out = FALSE; + timer_id = g_timeout_add (timeout_ms, gs_restarter_loop_timeout_cb, priv); + g_main_loop_run (priv->loop); + g_source_remove (timer_id); + if (priv->timed_out) { + g_set_error (error, + G_IO_ERROR, + G_IO_ERROR_TIMED_OUT, + "Waited for %ums", timeout_ms); + return FALSE; + } + return TRUE; +} + +static GsRestarterPrivate * +gs_restarter_private_new (void) +{ + GsRestarterPrivate *priv = g_new0 (GsRestarterPrivate, 1); + priv->loop = g_main_loop_new (NULL, FALSE); + return priv; +} + +static void +gs_restarter_private_free (GsRestarterPrivate *priv) +{ + if (priv->connection != NULL) + g_object_unref (priv->connection); + g_main_loop_unref (priv->loop); + g_free (priv); +} + +G_DEFINE_AUTOPTR_CLEANUP_FUNC(GsRestarterPrivate, gs_restarter_private_free) + +static gboolean +gs_restarter_create_new_process (GsRestarterPrivate *priv, GError **error) +{ + g_autofree gchar *binary_filename = NULL; + + /* start executable */ + binary_filename = g_build_filename (BINDIR, GS_BINARY_NAME, NULL); + g_debug ("starting new binary %s", binary_filename); + if (!g_spawn_command_line_async (binary_filename, error)) + return FALSE; + + /* wait for the bus name to appear */ + if (!gs_restarter_wait_for_timeout (priv, 15000, error)) { + g_prefix_error (error, "%s did not appear: ", GS_DBUS_BUS_NAME); + return FALSE; + } + + return TRUE; +} + +static gboolean +gs_restarter_destroy_old_process (GsRestarterPrivate *priv, GError **error) +{ + GVariantBuilder args_params; + GVariantBuilder args_platform_data; + g_autoptr(GVariant) reply = NULL; + + /* call a GtkAction */ + g_variant_builder_init (&args_params, g_variant_type_new ("av")); + g_variant_builder_init (&args_platform_data, g_variant_type_new ("a{sv}")); + reply = g_dbus_connection_call_sync (priv->connection, + GS_DBUS_BUS_NAME, + GS_DBUS_OBJECT_PATH, + GS_DBUS_INTERFACE_NAME, + "Activate", + g_variant_new ("(sava{sv})", + "shutdown", + &args_params, + &args_platform_data), + NULL, + G_DBUS_CALL_FLAGS_NO_AUTO_START, + 5000, + NULL, + error); + if (reply == NULL) { + g_prefix_error (error, "Failed to send RequestShutdown: "); + return FALSE; + } + + /* wait for the name to disappear from the bus */ + if (!gs_restarter_wait_for_timeout (priv, 30000, error)) { + g_prefix_error (error, "Failed to see %s vanish: ", GS_DBUS_BUS_NAME); + return FALSE; + } + + return TRUE; +} + +static gboolean +gs_restarter_setup_watcher (GsRestarterPrivate *priv, GError **error) +{ + /* watch the name appear and vanish */ + priv->connection = g_bus_get_sync (G_BUS_TYPE_SESSION, NULL, error); + if (priv->connection == NULL) { + g_prefix_error (error, "Failed to get D-Bus connection: "); + return FALSE; + } + g_bus_watch_name_on_connection (priv->connection, + GS_DBUS_BUS_NAME, + G_BUS_NAME_WATCHER_FLAGS_NONE, + gs_restarter_on_name_appeared_cb, + gs_restarter_on_name_vanished_cb, + priv, + NULL); + + /* wait for one of the callbacks to be called */ + if (!gs_restarter_wait_for_timeout (priv, 50, error)) { + g_prefix_error (error, "Failed to watch %s: ", GS_DBUS_BUS_NAME); + return FALSE; + } + + return TRUE; +} + +int +main (int argc, char **argv) +{ + g_autoptr(GsRestarterPrivate) priv = NULL; + g_autoptr(GError) error = NULL; + + /* show all debugging */ + g_setenv ("G_MESSAGES_DEBUG", "all", TRUE); + + /* set up the watcher */ + priv = gs_restarter_private_new (); + if (!gs_restarter_setup_watcher (priv, &error)) { + g_warning ("Failed to set up: %s", error->message); + return EXIT_FAILURE; + } + + /* kill the old process */ + if (priv->is_running) { + if (!gs_restarter_destroy_old_process (priv, &error)) { + g_warning ("Failed to quit service: %s", error->message); + return EXIT_FAILURE; + } + } + + /* start a new process */ + if (!gs_restarter_create_new_process (priv, &error)) { + g_warning ("Failed to start service: %s", error->message); + return EXIT_FAILURE; + } + + /* success */ + g_debug ("%s process successfully restarted", GS_DBUS_BUS_NAME); + return EXIT_SUCCESS; +} diff --git a/src/gs-review-bar.c b/src/gs-review-bar.c new file mode 100644 index 0000000..8e65449 --- /dev/null +++ b/src/gs-review-bar.c @@ -0,0 +1,72 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2016 Canonical Ltd. + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include <math.h> + +#include "gs-review-bar.h" + +struct _GsReviewBar +{ + GtkWidget parent_instance; + gdouble fraction; +}; + +G_DEFINE_TYPE (GsReviewBar, gs_review_bar, GTK_TYPE_WIDGET) + +void +gs_review_bar_set_fraction (GsReviewBar *bar, gdouble fraction) +{ + g_return_if_fail (GS_IS_REVIEW_BAR (bar)); + bar->fraction = fraction; +} + +static void +gs_review_bar_init (GsReviewBar *bar) +{ +} + +static void +gs_review_bar_snapshot (GtkWidget *widget, + GtkSnapshot *snapshot) +{ + gdouble bar_width, bar_height; + GdkRGBA color; + + gtk_style_context_get_color (gtk_widget_get_style_context (widget), &color); + + bar_width = round (GS_REVIEW_BAR (widget)->fraction * gtk_widget_get_width (widget)); + bar_height = gtk_widget_get_height (widget); + + gtk_snapshot_append_color (snapshot, + &color, + &GRAPHENE_RECT_INIT (0, + 0, + bar_width, + bar_height)); + + GTK_WIDGET_CLASS (gs_review_bar_parent_class)->snapshot (widget, snapshot); +} + +static void +gs_review_bar_class_init (GsReviewBarClass *klass) +{ + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + widget_class->snapshot = gs_review_bar_snapshot; + + gtk_widget_class_set_css_name (widget_class, "review-bar"); +} + +GtkWidget * +gs_review_bar_new (void) +{ + GsReviewBar *bar; + bar = g_object_new (GS_TYPE_REVIEW_BAR, NULL); + return GTK_WIDGET (bar); +} diff --git a/src/gs-review-bar.h b/src/gs-review-bar.h new file mode 100644 index 0000000..7fab0b3 --- /dev/null +++ b/src/gs-review-bar.h @@ -0,0 +1,24 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2016 Canonical Ltd. + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <gtk/gtk.h> + +G_BEGIN_DECLS + +#define GS_TYPE_REVIEW_BAR (gs_review_bar_get_type ()) + +G_DECLARE_FINAL_TYPE (GsReviewBar, gs_review_bar, GS, REVIEW_BAR, GtkWidget) + +GtkWidget *gs_review_bar_new (void); + +void gs_review_bar_set_fraction (GsReviewBar *bar, + gdouble fraction); + +G_END_DECLS diff --git a/src/gs-review-dialog.c b/src/gs-review-dialog.c new file mode 100644 index 0000000..178bef5 --- /dev/null +++ b/src/gs-review-dialog.c @@ -0,0 +1,215 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2016 Canonical Ltd. + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include <glib/gi18n.h> +#include <gtk/gtk.h> + +#include "gs-review-dialog.h" +#include "gs-star-widget.h" + +#define DESCRIPTION_LENGTH_MAX 3000 /* chars */ +#define DESCRIPTION_LENGTH_MIN 15 /* chars */ +#define SUMMARY_LENGTH_MAX 70 /* chars */ +#define SUMMARY_LENGTH_MIN 3 /* chars */ +#define WRITING_TIME_MIN 5 /* seconds */ + +struct _GsReviewDialog +{ + GtkDialog parent_instance; + + GtkWidget *star; + GtkWidget *label_rating_desc; + GtkWidget *summary_entry; + GtkWidget *post_button; + GtkWidget *text_view; + guint timer_id; +}; + +G_DEFINE_TYPE (GsReviewDialog, gs_review_dialog, GTK_TYPE_DIALOG) + +gint +gs_review_dialog_get_rating (GsReviewDialog *dialog) +{ + return gs_star_widget_get_rating (GS_STAR_WIDGET (dialog->star)); +} + +void +gs_review_dialog_set_rating (GsReviewDialog *dialog, gint rating) +{ + gs_star_widget_set_rating (GS_STAR_WIDGET (dialog->star), rating); +} + +const gchar * +gs_review_dialog_get_summary (GsReviewDialog *dialog) +{ + return gtk_editable_get_text (GTK_EDITABLE (dialog->summary_entry)); +} + +gchar * +gs_review_dialog_get_text (GsReviewDialog *dialog) +{ + GtkTextBuffer *buffer; + GtkTextIter start, end; + + buffer = gtk_text_view_get_buffer (GTK_TEXT_VIEW (dialog->text_view)); + gtk_text_buffer_get_start_iter (buffer, &start); + gtk_text_buffer_get_end_iter (buffer, &end); + return gtk_text_buffer_get_text (buffer, &start, &end, FALSE); +} + +static void +gs_review_dialog_update_review_comment (GsReviewDialog *dialog) +{ + const gchar *msg = NULL; + gint perc; + + /* update the rating description */ + perc = gs_star_widget_get_rating (GS_STAR_WIDGET (dialog->star)); + if (perc == 20) { + /* TRANSLATORS: lighthearted star rating description; + * A really bad application */ + msg = _("Hate it"); + } else if (perc == 40) { + /* TRANSLATORS: lighthearted star rating description; + * Not a great application */ + msg = _("Don’t like it"); + } else if (perc == 60) { + /* TRANSLATORS: lighthearted star rating description; + * A fairly-good application */ + msg = _("It’s OK"); + } else if (perc == 80) { + /* TRANSLATORS: lighthearted star rating description; + * A good application */ + msg = _("Like it"); + } else if (perc == 100) { + /* TRANSLATORS: lighthearted star rating description; + * A really awesome application */ + msg = _("Love it"); + } else { + /* just reserve space */ + msg = ""; + } + gtk_label_set_label (GTK_LABEL (dialog->label_rating_desc), msg); +} + +static void +gs_review_dialog_changed_cb (GsReviewDialog *dialog) +{ + GtkTextBuffer *buffer; + gboolean all_okay = TRUE; + const gchar *msg = NULL; + glong summary_length; + + /* update review text */ + gs_review_dialog_update_review_comment (dialog); + + /* require rating, summary and long review */ + buffer = gtk_text_view_get_buffer (GTK_TEXT_VIEW (dialog->text_view)); + summary_length = g_utf8_strlen (gtk_editable_get_text (GTK_EDITABLE (dialog->summary_entry)), -1); + if (dialog->timer_id != 0) { + /* TRANSLATORS: the review can't just be copied and pasted */ + msg = _("Please take more time writing the review"); + all_okay = FALSE; + } else if (gs_star_widget_get_rating (GS_STAR_WIDGET (dialog->star)) == 0) { + /* TRANSLATORS: the review is not acceptable */ + msg = _("Please choose a star rating"); + all_okay = FALSE; + } else if (summary_length < SUMMARY_LENGTH_MIN) { + /* TRANSLATORS: the review is not acceptable */ + msg = _("The summary is too short"); + all_okay = FALSE; + } else if (summary_length > SUMMARY_LENGTH_MAX) { + /* TRANSLATORS: the review is not acceptable */ + msg = _("The summary is too long"); + all_okay = FALSE; + } else if (gtk_text_buffer_get_char_count (buffer) < DESCRIPTION_LENGTH_MIN) { + /* TRANSLATORS: the review is not acceptable */ + msg = _("The description is too short"); + all_okay = FALSE; + } else if (gtk_text_buffer_get_char_count (buffer) > DESCRIPTION_LENGTH_MAX) { + /* TRANSLATORS: the review is not acceptable */ + msg = _("The description is too long"); + all_okay = FALSE; + } + + /* tell the user what's happening */ + gtk_widget_set_tooltip_text (dialog->post_button, msg); + + /* can the user submit this? */ + gtk_dialog_set_response_sensitive (GTK_DIALOG (dialog), GTK_RESPONSE_OK, all_okay); +} + +static gboolean +gs_review_dialog_timeout_cb (gpointer user_data) +{ + GsReviewDialog *dialog = GS_REVIEW_DIALOG (user_data); + dialog->timer_id = 0; + gs_review_dialog_changed_cb (dialog); + return FALSE; +} + +static void +gs_review_dialog_init (GsReviewDialog *dialog) +{ + GtkTextBuffer *buffer; + gtk_widget_init_template (GTK_WIDGET (dialog)); + + /* require the user to spend at least 30 seconds on writing a review */ + dialog->timer_id = g_timeout_add_seconds (WRITING_TIME_MIN, + gs_review_dialog_timeout_cb, + dialog); + + /* update UI */ + g_signal_connect_swapped (dialog->star, "rating-changed", + G_CALLBACK (gs_review_dialog_changed_cb), dialog); + g_signal_connect_swapped (dialog->summary_entry, "notify::text", + G_CALLBACK (gs_review_dialog_changed_cb), dialog); + buffer = gtk_text_view_get_buffer (GTK_TEXT_VIEW (dialog->text_view)); + g_signal_connect_swapped (buffer, "changed", + G_CALLBACK (gs_review_dialog_changed_cb), dialog); + + gtk_dialog_set_response_sensitive (GTK_DIALOG (dialog), GTK_RESPONSE_OK, FALSE); +} + +static void +gs_review_row_dispose (GObject *object) +{ + GsReviewDialog *dialog = GS_REVIEW_DIALOG (object); + if (dialog->timer_id > 0) { + g_source_remove (dialog->timer_id); + dialog->timer_id = 0; + } + G_OBJECT_CLASS (gs_review_dialog_parent_class)->dispose (object); +} + +static void +gs_review_dialog_class_init (GsReviewDialogClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + + object_class->dispose = gs_review_row_dispose; + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-review-dialog.ui"); + + gtk_widget_class_bind_template_child (widget_class, GsReviewDialog, star); + gtk_widget_class_bind_template_child (widget_class, GsReviewDialog, label_rating_desc); + gtk_widget_class_bind_template_child (widget_class, GsReviewDialog, summary_entry); + gtk_widget_class_bind_template_child (widget_class, GsReviewDialog, text_view); + gtk_widget_class_bind_template_child (widget_class, GsReviewDialog, post_button); +} + +GtkWidget * +gs_review_dialog_new (void) +{ + return GTK_WIDGET (g_object_new (GS_TYPE_REVIEW_DIALOG, + "use-header-bar", TRUE, + NULL)); +} diff --git a/src/gs-review-dialog.h b/src/gs-review-dialog.h new file mode 100644 index 0000000..33d9992 --- /dev/null +++ b/src/gs-review-dialog.h @@ -0,0 +1,26 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2016 Canonical Ltd. + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <gtk/gtk.h> + +G_BEGIN_DECLS + +#define GS_TYPE_REVIEW_DIALOG (gs_review_dialog_get_type ()) + +G_DECLARE_FINAL_TYPE (GsReviewDialog, gs_review_dialog, GS, REVIEW_DIALOG, GtkDialog) + +GtkWidget *gs_review_dialog_new (void); +gint gs_review_dialog_get_rating (GsReviewDialog *dialog); +void gs_review_dialog_set_rating (GsReviewDialog *dialog, + gint rating); +const gchar *gs_review_dialog_get_summary (GsReviewDialog *dialog); +gchar *gs_review_dialog_get_text (GsReviewDialog *dialog); + +G_END_DECLS diff --git a/src/gs-review-dialog.ui b/src/gs-review-dialog.ui new file mode 100644 index 0000000..4ca28fa --- /dev/null +++ b/src/gs-review-dialog.ui @@ -0,0 +1,179 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- Generated with glade 3.18.3 --> +<interface> + <requires lib="gtk+" version="3.10"/> + <template class="GsReviewDialog" parent="GtkDialog"> + <action-widgets> + <action-widget response="cancel">cancel_button</action-widget> + <action-widget response="ok" default="true">post_button</action-widget> + </action-widgets> + <property name="title" translatable="yes" comments="Translators: Title of the dialog box where the users can write and publish their opinions about the apps.">Post Review</property> + <property name="modal">True</property> + <property name="default_width">600</property> + <property name="default_height">300</property> + <property name="destroy_with_parent">True</property> + <property name="use_header_bar">1</property> + <child type="action"> + <object class="GtkButton" id="cancel_button"> + <property name="label" translatable="yes">_Cancel</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="use_underline">True</property> + </object> + </child> + <child type="action"> + <object class="GtkButton" id="post_button"> + <property name="label" translatable="yes" comments="Translators: A button to publish the user's opinion about the app.">_Post</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="use_underline">True</property> + <property name="sensitive">False</property> + </object> + </child> + <child internal-child="content_area"> + <object class="GtkBox" id="dialog-vbox"> + <property name="margin_start">40</property> + <property name="margin_end">40</property> + <property name="margin_top">25</property> + <property name="margin_bottom">25</property> + <property name="orientation">vertical</property> + <property name="spacing">9</property> + <child internal-child="action_area"> + <object class="GtkBox" id="dialog-action_area1"> + </object> + </child> + <child> + <object class="GtkBox" id="box1"> + <property name="orientation">vertical</property> + <property name="spacing">20</property> + <property name="vexpand">True</property> + <child> + <object class="GtkBox" id="box4"> + <property name="orientation">vertical</property> + <property name="spacing">6</property> + <child> + <object class="GtkLabel" id="label4"> + <property name="label" translatable="yes">Rating</property> + <property name="xalign">0</property> + <attributes> + <attribute name="weight" value="bold"/> + </attributes> + </object> + </child> + <child> + <object class="GsStarWidget" id="star"> + <property name="halign">center</property> + <property name="icon-size">32</property> + <property name="interactive">True</property> + </object> + </child> + <child> + <object class="GtkLabel" id="label_rating_desc"> + <property name="height_request">30</property> + <property name="label"></property> + <property name="xalign">0.5</property> + <style> + <class name="dim-label"/> + </style> + </object> + </child> + </object> + </child> + <child> + <object class="GtkBox" id="box2"> + <property name="orientation">vertical</property> + <property name="spacing">6</property> + <child> + <object class="GtkLabel" id="label1"> + <property name="label" translatable="yes">Summary</property> + <property name="xalign">0</property> + <attributes> + <attribute name="weight" value="bold"/> + </attributes> + </object> + </child> + <child> + <object class="GtkLabel" id="label2"> + <property name="label" translatable="yes">Give a short summary of your review, for example: “Great app, would recommend”.</property> + <property name="wrap">True</property> + <property name="xalign">0</property> + <style> + <class name="dim-label"/> + </style> + </object> + </child> + <child> + <object class="GtkEntry" id="summary_entry"> + <property name="can_focus">True</property> + </object> + </child> + </object> + </child> + <child> + <object class="GtkBox" id="box3"> + <property name="orientation">vertical</property> + <property name="spacing">6</property> + <property name="vexpand">True</property> + <child> + <object class="GtkLabel" id="label3"> + <property name="label" translatable="yes" context="app review" comments="Translators: This is where the users enter their opinions about the apps.">Review</property> + <property name="xalign">0</property> + <attributes> + <attribute name="weight" value="bold"/> + </attributes> + </object> + </child> + <child> + <object class="GtkLabel" id="label5"> + <property name="label" translatable="yes">What do you think of the app? Try to give reasons for your views.</property> + <property name="wrap">True</property> + <property name="xalign">0</property> + <style> + <class name="dim-label"/> + </style> + </object> + </child> + <child> + <object class="GtkScrolledWindow" id="text_view_scroll"> + <property name="can_focus">True</property> + <property name="hscrollbar_policy">automatic</property> + <property name="vscrollbar_policy">automatic</property> + <property name="vexpand">True</property> + <child> + <object class="GtkTextView" id="text_view"> + <property name="can_focus">True</property> + <property name="height-request">120</property> + <property name="wrap-mode">word-char</property> + <style> + <class name="review-textbox"/> + </style> + </object> + </child> + </object> + </child> + <child> + <object class="GtkLabel" id="label6"> + <property name="label" translatable="yes">Find what data is sent in our <a href="https://odrs.gnome.org/privacy">privacy policy</a>. The full name attached to your account will be shown publicly.</property> + <property name="wrap">True</property> + <property name="xalign">0</property> + <property name="use-markup">True</property> + <style> + <class name="dim-label"/> + </style> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + </template> + <object class="GtkSizeGroup" id="sizegroup_folder_buttons"> + <property name="mode">horizontal</property> + <widgets> + <widget name="cancel_button"/> + <widget name="post_button"/> + </widgets> + </object> +</interface> diff --git a/src/gs-review-histogram.c b/src/gs-review-histogram.c new file mode 100644 index 0000000..0b74034 --- /dev/null +++ b/src/gs-review-histogram.c @@ -0,0 +1,135 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2016 Canonical Ltd. + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include <math.h> + +#include "gs-common.h" +#include "gs-review-histogram.h" +#include "gs-review-bar.h" +#include "gs-star-image.h" + +typedef struct +{ + GtkWidget *bar1; + GtkWidget *bar2; + GtkWidget *bar3; + GtkWidget *bar4; + GtkWidget *bar5; + GtkWidget *label_value; + GtkWidget *label_total; + GtkWidget *star_value_1; + GtkWidget *star_value_2; + GtkWidget *star_value_3; + GtkWidget *star_value_4; + GtkWidget *star_value_5; +} GsReviewHistogramPrivate; + +G_DEFINE_TYPE_WITH_PRIVATE (GsReviewHistogram, gs_review_histogram, GTK_TYPE_WIDGET) + +void +gs_review_histogram_set_ratings (GsReviewHistogram *histogram, + gint rating_percent, + GArray *review_ratings) +{ + GsReviewHistogramPrivate *priv = gs_review_histogram_get_instance_private (histogram); + g_autofree gchar *text = NULL; + gdouble fraction[6] = { 0.0f }; + guint32 max = 0; + guint32 total = 0; + + g_return_if_fail (GS_IS_REVIEW_HISTOGRAM (histogram)); + + /* sanity check */ + if (review_ratings->len != 6) { + g_warning ("ratings data incorrect expected 012345"); + return; + } + + /* idx 0 is '0 stars' which we don't support in the UI */ + for (guint i = 1; i < review_ratings->len; i++) { + guint32 c = g_array_index (review_ratings, guint32, i); + max = MAX (c, max); + } + for (guint i = 1; i < review_ratings->len; i++) { + guint32 c = g_array_index (review_ratings, guint32, i); + fraction[i] = max > 0 ? (gdouble) c / (gdouble) max : 0.f; + total += c; + } + + gs_review_bar_set_fraction (GS_REVIEW_BAR (priv->bar5), fraction[5]); + gs_review_bar_set_fraction (GS_REVIEW_BAR (priv->bar4), fraction[4]); + gs_review_bar_set_fraction (GS_REVIEW_BAR (priv->bar3), fraction[3]); + gs_review_bar_set_fraction (GS_REVIEW_BAR (priv->bar2), fraction[2]); + gs_review_bar_set_fraction (GS_REVIEW_BAR (priv->bar1), fraction[1]); + + text = g_strdup_printf (g_dngettext (GETTEXT_PACKAGE, "%u review total", "%u reviews total", total), total); + gtk_label_set_text (GTK_LABEL (priv->label_total), text); + + g_clear_pointer (&text, g_free); + + /* Round explicitly, to avoid rounding inside the printf() call and to use + the same value also for the stars fraction. */ + fraction[0] = total > 0 ? round (((gdouble) rating_percent ) * 50.0 / 100.0) / 10.0 : 0.0; + text = g_strdup_printf ("%.01f", fraction[0]); + gtk_label_set_text (GTK_LABEL (priv->label_value), text); + + gs_star_image_set_fraction (GS_STAR_IMAGE (priv->star_value_1), CLAMP (fraction[0], 0.0, 1.0)); + gs_star_image_set_fraction (GS_STAR_IMAGE (priv->star_value_2), CLAMP (fraction[0], 1.0, 2.0) - 1.0); + gs_star_image_set_fraction (GS_STAR_IMAGE (priv->star_value_3), CLAMP (fraction[0], 2.0, 3.0) - 2.0); + gs_star_image_set_fraction (GS_STAR_IMAGE (priv->star_value_4), CLAMP (fraction[0], 3.0, 4.0) - 3.0); + gs_star_image_set_fraction (GS_STAR_IMAGE (priv->star_value_5), CLAMP (fraction[0], 4.0, 5.0) - 4.0); +} + +static void +gs_review_histogram_dispose (GObject *object) +{ + gs_widget_remove_all (GTK_WIDGET (object), NULL); + + G_OBJECT_CLASS (gs_review_histogram_parent_class)->dispose (object); +} + +static void +gs_review_histogram_init (GsReviewHistogram *histogram) +{ + gtk_widget_init_template (GTK_WIDGET (histogram)); +} + +static void +gs_review_histogram_class_init (GsReviewHistogramClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + + object_class->dispose = gs_review_histogram_dispose; + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-review-histogram.ui"); + gtk_widget_class_set_layout_manager_type (widget_class, GTK_TYPE_BIN_LAYOUT); + + gtk_widget_class_bind_template_child_private (widget_class, GsReviewHistogram, bar5); + gtk_widget_class_bind_template_child_private (widget_class, GsReviewHistogram, bar4); + gtk_widget_class_bind_template_child_private (widget_class, GsReviewHistogram, bar3); + gtk_widget_class_bind_template_child_private (widget_class, GsReviewHistogram, bar2); + gtk_widget_class_bind_template_child_private (widget_class, GsReviewHistogram, bar1); + gtk_widget_class_bind_template_child_private (widget_class, GsReviewHistogram, label_value); + gtk_widget_class_bind_template_child_private (widget_class, GsReviewHistogram, label_total); + gtk_widget_class_bind_template_child_private (widget_class, GsReviewHistogram, star_value_1); + gtk_widget_class_bind_template_child_private (widget_class, GsReviewHistogram, star_value_2); + gtk_widget_class_bind_template_child_private (widget_class, GsReviewHistogram, star_value_3); + gtk_widget_class_bind_template_child_private (widget_class, GsReviewHistogram, star_value_4); + gtk_widget_class_bind_template_child_private (widget_class, GsReviewHistogram, star_value_5); +} + +GtkWidget * +gs_review_histogram_new (void) +{ + GsReviewHistogram *histogram; + histogram = g_object_new (GS_TYPE_REVIEW_HISTOGRAM, NULL); + return GTK_WIDGET (histogram); +} diff --git a/src/gs-review-histogram.h b/src/gs-review-histogram.h new file mode 100644 index 0000000..0e9dafc --- /dev/null +++ b/src/gs-review-histogram.h @@ -0,0 +1,30 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2016 Canonical Ltd. + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <gtk/gtk.h> + +G_BEGIN_DECLS + +#define GS_TYPE_REVIEW_HISTOGRAM (gs_review_histogram_get_type ()) + +G_DECLARE_DERIVABLE_TYPE (GsReviewHistogram, gs_review_histogram, GS, REVIEW_HISTOGRAM, GtkWidget) + +struct _GsReviewHistogramClass +{ + GtkWidgetClass parent_class; +}; + +GtkWidget *gs_review_histogram_new (void); + +void gs_review_histogram_set_ratings (GsReviewHistogram *histogram, + gint rating_percent, + GArray *review_ratings); + +G_END_DECLS diff --git a/src/gs-review-histogram.ui b/src/gs-review-histogram.ui new file mode 100644 index 0000000..b6dda39 --- /dev/null +++ b/src/gs-review-histogram.ui @@ -0,0 +1,410 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <!-- interface-requires gtk+ 3.10 --> + <template class="GsReviewHistogram" parent="GtkWidget"> + <child> + <object class="GtkGrid" id="grid1"> + <property name="row-spacing">6</property> + <property name="column-spacing">6</property> + <property name="margin-top">12</property> + <property name="margin-bottom">12</property> + <property name="margin-start">12</property> + <property name="margin-end">12</property> + <child> + <object class="GtkLabel" id="label_value"> + <property name="halign">start</property> + <property name="valign">center</property> + <property name="label">0</property> + <attributes> + <attribute name="scale" value="5.0"/> + <attribute name="weight" value="light"/> + <attribute name="line-height" value="0.75"/> + </attributes> + <layout> + <property name="column">0</property> + <property name="row">0</property> + <property name="column-span">1</property> + <property name="row-span">1</property> + </layout> + </object> + </child> + <child> + <object class="GtkBox" id="value_vbox"> + <property name="orientation">vertical</property> + <property name="spacing">8</property> + <property name="halign">start</property> + <property name="valign">center</property> + <property name="hexpand">True</property> + <property name="vexpand">True</property> + <property name="margin-start">6</property> + <property name="margin-top">8</property> + <child> + <object class="GtkBox" id="star_value_box"> + <property name="orientation">horizontal</property> + <property name="spacing">6</property> + <property name="halign">start</property> + <property name="valign">center</property> + <property name="hexpand">True</property> + <property name="vexpand">True</property> + <style> + <class name="reviewstarvalue"/> + </style> + <child> + <object class="GsStarImage" id="star_value_1"> + <property name="sensitive">False</property> + <property name="height-request">24</property> + <property name="width-request">24</property> + </object> + </child> + <child> + <object class="GsStarImage" id="star_value_2"> + <property name="sensitive">False</property> + <property name="height-request">24</property> + <property name="width-request">24</property> + </object> + </child> + <child> + <object class="GsStarImage" id="star_value_3"> + <property name="sensitive">False</property> + <property name="height-request">24</property> + <property name="width-request">24</property> + </object> + </child> + <child> + <object class="GsStarImage" id="star_value_4"> + <property name="sensitive">False</property> + <property name="height-request">24</property> + <property name="width-request">24</property> + </object> + </child> + <child> + <object class="GsStarImage" id="star_value_5"> + <property name="sensitive">False</property> + <property name="height-request">24</property> + <property name="width-request">24</property> + </object> + </child> + </object> + </child> + <child> + <object class="GtkLabel" id="label_n_stars"> + <property name="halign">start</property> + <property name="valign">center</property> + <property name="label" translatable="yes">out of 5 stars</property> + <attributes> + <attribute name="scale" value="0.8"/> + </attributes> + </object> + </child> + <layout> + <property name="column">1</property> + <property name="row">0</property> + <property name="column-span">1</property> + <property name="row-span">1</property> + </layout> + </object> + </child> + <child> + <object class="GtkGrid" id="grid2"> + <property name="row-spacing">2</property> + <property name="column-spacing">2</property> + <style> + <class name="review-histogram"/> + </style> + <child> + <object class="GsReviewBar" id="bar5"> + <property name="margin-end">5</property> + <property name="halign">fill</property> + <property name="hexpand">true</property> + <property name="valign">center</property> + <property name="height-request">6</property> + <layout> + <property name="column">0</property> + <property name="row">0</property> + <property name="column-span">1</property> + <property name="row-span">1</property> + </layout> + </object> + </child> + <child> + <object class="GsStarImage" id="star5_1"> + <property name="sensitive">False</property> + <property name="height-request">10</property> + <property name="width-request">10</property> + <layout> + <property name="column">1</property> + <property name="row">0</property> + <property name="column-span">1</property> + <property name="row-span">1</property> + </layout> + </object> + </child> + <child> + <object class="GsStarImage" id="star5_2"> + <property name="sensitive">False</property> + <property name="height-request">10</property> + <property name="width-request">10</property> + <layout> + <property name="column">2</property> + <property name="row">0</property> + <property name="column-span">1</property> + <property name="row-span">1</property> + </layout> + </object> + </child> + <child> + <object class="GsStarImage" id="star5_3"> + <property name="sensitive">False</property> + <property name="height-request">10</property> + <property name="width-request">10</property> + <layout> + <property name="column">3</property> + <property name="row">0</property> + <property name="column-span">1</property> + <property name="row-span">1</property> + </layout> + </object> + </child> + <child> + <object class="GsStarImage" id="star5_4"> + <property name="sensitive">False</property> + <property name="height-request">10</property> + <property name="width-request">10</property> + <layout> + <property name="column">4</property> + <property name="row">0</property> + <property name="column-span">1</property> + <property name="row-span">1</property> + </layout> + </object> + </child> + <child> + <object class="GsStarImage" id="star5_5"> + <property name="sensitive">False</property> + <property name="height-request">10</property> + <property name="width-request">10</property> + <layout> + <property name="column">5</property> + <property name="row">0</property> + <property name="column-span">1</property> + <property name="row-span">1</property> + </layout> + </object> + </child> + <child> + <object class="GsReviewBar" id="bar4"> + <property name="margin-end">5</property> + <property name="halign">fill</property> + <property name="hexpand">true</property> + <property name="valign">center</property> + <property name="height-request">6</property> + <layout> + <property name="column">0</property> + <property name="row">1</property> + <property name="column-span">1</property> + <property name="row-span">1</property> + </layout> + </object> + </child> + <child> + <object class="GsStarImage" id="star4_1"> + <property name="sensitive">False</property> + <property name="height-request">10</property> + <property name="width-request">10</property> + <layout> + <property name="column">1</property> + <property name="row">1</property> + <property name="column-span">1</property> + <property name="row-span">1</property> + </layout> + </object> + </child> + <child> + <object class="GsStarImage" id="star4_2"> + <property name="sensitive">False</property> + <property name="height-request">10</property> + <property name="width-request">10</property> + <layout> + <property name="column">2</property> + <property name="row">1</property> + <property name="column-span">1</property> + <property name="row-span">1</property> + </layout> + </object> + </child> + <child> + <object class="GsStarImage" id="star4_3"> + <property name="sensitive">False</property> + <property name="height-request">10</property> + <property name="width-request">10</property> + <layout> + <property name="column">3</property> + <property name="row">1</property> + <property name="column-span">1</property> + <property name="row-span">1</property> + </layout> + </object> + </child> + <child> + <object class="GsStarImage" id="star4_4"> + <property name="sensitive">False</property> + <property name="height-request">10</property> + <property name="width-request">10</property> + <layout> + <property name="column">4</property> + <property name="row">1</property> + <property name="column-span">1</property> + <property name="row-span">1</property> + </layout> + </object> + </child> + <child> + <object class="GsReviewBar" id="bar3"> + <property name="margin-end">5</property> + <property name="halign">fill</property> + <property name="hexpand">true</property> + <property name="valign">center</property> + <property name="height-request">6</property> + <layout> + <property name="column">0</property> + <property name="row">2</property> + <property name="column-span">1</property> + <property name="row-span">1</property> + </layout> + </object> + </child> + <child> + <object class="GsStarImage" id="star3_1"> + <property name="sensitive">False</property> + <property name="height-request">10</property> + <property name="width-request">10</property> + <layout> + <property name="column">1</property> + <property name="row">2</property> + <property name="column-span">1</property> + <property name="row-span">1</property> + </layout> + </object> + </child> + <child> + <object class="GsStarImage" id="star3_2"> + <property name="sensitive">False</property> + <property name="height-request">10</property> + <property name="width-request">10</property> + <layout> + <property name="column">2</property> + <property name="row">2</property> + <property name="column-span">1</property> + <property name="row-span">1</property> + </layout> + </object> + </child> + <child> + <object class="GsStarImage" id="star3_3"> + <property name="sensitive">False</property> + <property name="height-request">10</property> + <property name="width-request">10</property> + <layout> + <property name="column">3</property> + <property name="row">2</property> + <property name="column-span">1</property> + <property name="row-span">1</property> + </layout> + </object> + </child> + <child> + <object class="GsReviewBar" id="bar2"> + <property name="margin-end">5</property> + <property name="halign">fill</property> + <property name="hexpand">true</property> + <property name="valign">center</property> + <property name="height-request">6</property> + <layout> + <property name="column">0</property> + <property name="row">3</property> + <property name="column-span">1</property> + <property name="row-span">1</property> + </layout> + </object> + </child> + <child> + <object class="GsStarImage" id="star2_1"> + <property name="sensitive">False</property> + <property name="height-request">10</property> + <property name="width-request">10</property> + <layout> + <property name="column">1</property> + <property name="row">3</property> + <property name="column-span">1</property> + <property name="row-span">1</property> + </layout> + </object> + </child> + <child> + <object class="GsStarImage" id="star2_2"> + <property name="sensitive">False</property> + <property name="height-request">10</property> + <property name="width-request">10</property> + <layout> + <property name="column">2</property> + <property name="row">3</property> + <property name="column-span">1</property> + <property name="row-span">1</property> + </layout> + </object> + </child> + <child> + <object class="GsReviewBar" id="bar1"> + <property name="margin-end">5</property> + <property name="halign">fill</property> + <property name="hexpand">true</property> + <property name="valign">center</property> + <property name="height-request">6</property> + <layout> + <property name="column">0</property> + <property name="row">4</property> + <property name="column-span">1</property> + <property name="row-span">1</property> + </layout> + </object> + </child> + <child> + <object class="GsStarImage" id="star1_1"> + <property name="sensitive">False</property> + <property name="height-request">10</property> + <property name="width-request">10</property> + <layout> + <property name="column">1</property> + <property name="row">4</property> + <property name="column-span">1</property> + <property name="row-span">1</property> + </layout> + </object> + </child> + <layout> + <property name="column">0</property> + <property name="row">1</property> + <property name="column-span">2</property> + <property name="row-span">1</property> + </layout> + </object> + </child> + <child> + <object class="GtkLabel" id="label_total"> + <property name="halign">start</property> + <property name="valign">center</property> + <property name="hexpand">True</property> + <property name="label">0</property> + <property name="margin-top">6</property> + <layout> + <property name="column">0</property> + <property name="row">2</property> + <property name="column-span">2</property> + <property name="row-span">1</property> + </layout> + </object> + </child> + </object> + </child> + </template> +</interface> diff --git a/src/gs-review-row.c b/src/gs-review-row.c new file mode 100644 index 0000000..9afb484 --- /dev/null +++ b/src/gs-review-row.c @@ -0,0 +1,323 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2016 Canonical Ltd. + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include <glib/gi18n.h> + +#include "gs-review-row.h" +#include "gs-star-widget.h" + +typedef struct +{ + AsReview *review; + gboolean network_available; + guint64 actions; + GtkWidget *stars; + GtkWidget *summary_label; + GtkWidget *author_label; + GtkWidget *date_label; + GtkWidget *text_label; + GtkWidget *button_yes; + GtkWidget *button_no; + GtkWidget *button_dismiss; + GtkWidget *button_report; + GtkWidget *button_remove; + GtkWidget *box_voting; +} GsReviewRowPrivate; + +enum { + SIGNAL_BUTTON_CLICKED, + SIGNAL_LAST +}; + +static guint signals [SIGNAL_LAST] = { 0 }; + +G_DEFINE_TYPE_WITH_PRIVATE (GsReviewRow, gs_review_row, GTK_TYPE_LIST_BOX_ROW) + +static void +gs_review_row_refresh (GsReviewRow *row) +{ + GsReviewRowPrivate *priv = gs_review_row_get_instance_private (row); + const gchar *reviewer; + GDateTime *date; + g_autofree gchar *text = NULL; + + gs_star_widget_set_rating (GS_STAR_WIDGET (priv->stars), + as_review_get_rating (priv->review)); + reviewer = as_review_get_reviewer_name (priv->review); + if (reviewer == NULL) { + /* TRANSLATORS: this is when a user doesn't specify a name */ + reviewer = C_("Reviewer name", "Unknown"); + } + gtk_label_set_text (GTK_LABEL (priv->author_label), reviewer); + date = as_review_get_date (priv->review); + if (date != NULL) + /* TRANSLATORS: This is the date string with: day number, month name, year. + i.e. "25 May 2012" */ + text = g_date_time_format (date, _("%e %B %Y")); + else + text = g_strdup (""); + gtk_label_set_text (GTK_LABEL (priv->date_label), text); + gtk_label_set_text (GTK_LABEL (priv->summary_label), + as_review_get_summary (priv->review)); + gtk_label_set_text (GTK_LABEL (priv->text_label), + as_review_get_description (priv->review)); + + /* if we voted, we can't do any actions */ + if (as_review_get_flags (priv->review) & AS_REVIEW_FLAG_VOTED) + priv->actions = 0; + + /* set actions up */ + if ((priv->actions & (1 << GS_REVIEW_ACTION_UPVOTE | + 1 << GS_REVIEW_ACTION_DOWNVOTE | + 1 << GS_REVIEW_ACTION_DISMISS)) == 0) { + gtk_widget_set_visible (priv->box_voting, FALSE); + } else { + gtk_widget_set_visible (priv->box_voting, TRUE); + gtk_widget_set_visible (priv->button_yes, + priv->actions & 1 << GS_REVIEW_ACTION_UPVOTE); + gtk_widget_set_visible (priv->button_no, + priv->actions & 1 << GS_REVIEW_ACTION_DOWNVOTE); + gtk_widget_set_visible (priv->button_dismiss, + priv->actions & 1 << GS_REVIEW_ACTION_DISMISS); + } + gtk_widget_set_visible (priv->button_remove, + priv->actions & 1 << GS_REVIEW_ACTION_REMOVE); + gtk_widget_set_visible (priv->button_report, + priv->actions & 1 << GS_REVIEW_ACTION_REPORT); + + /* mark insensitive if no network */ + if (priv->network_available) { + gtk_widget_set_sensitive (priv->button_yes, TRUE); + gtk_widget_set_sensitive (priv->button_no, TRUE); + gtk_widget_set_sensitive (priv->button_remove, TRUE); + gtk_widget_set_sensitive (priv->button_report, TRUE); + } else { + gtk_widget_set_sensitive (priv->button_yes, FALSE); + gtk_widget_set_sensitive (priv->button_no, FALSE); + gtk_widget_set_sensitive (priv->button_remove, FALSE); + gtk_widget_set_sensitive (priv->button_report, FALSE); + } +} + +void +gs_review_row_set_network_available (GsReviewRow *review_row, gboolean network_available) +{ + GsReviewRowPrivate *priv = gs_review_row_get_instance_private (review_row); + priv->network_available = network_available; + gs_review_row_refresh (review_row); +} + +static gboolean +gs_review_row_refresh_idle (gpointer user_data) +{ + GsReviewRow *row = GS_REVIEW_ROW (user_data); + + gs_review_row_refresh (row); + + g_object_unref (row); + return G_SOURCE_REMOVE; +} + +static void +gs_review_row_notify_props_changed_cb (GsApp *app, + GParamSpec *pspec, + GsReviewRow *row) +{ + g_idle_add (gs_review_row_refresh_idle, g_object_ref (row)); +} + +static void +gs_review_row_init (GsReviewRow *row) +{ + GsReviewRowPrivate *priv = gs_review_row_get_instance_private (row); + priv->network_available = TRUE; + gtk_widget_init_template (GTK_WIDGET (row)); +} + +static void +gs_review_row_dispose (GObject *object) +{ + GsReviewRow *row = GS_REVIEW_ROW (object); + GsReviewRowPrivate *priv = gs_review_row_get_instance_private (row); + + g_clear_object (&priv->review); + + G_OBJECT_CLASS (gs_review_row_parent_class)->dispose (object); +} + +static void +gs_review_row_class_init (GsReviewRowClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + + object_class->dispose = gs_review_row_dispose; + + signals [SIGNAL_BUTTON_CLICKED] = + g_signal_new ("button-clicked", + G_TYPE_FROM_CLASS (object_class), G_SIGNAL_RUN_LAST, + G_STRUCT_OFFSET (GsReviewRowClass, button_clicked), + NULL, NULL, g_cclosure_marshal_VOID__UINT, + G_TYPE_NONE, 1, G_TYPE_UINT); + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-review-row.ui"); + + gtk_widget_class_bind_template_child_private (widget_class, GsReviewRow, stars); + gtk_widget_class_bind_template_child_private (widget_class, GsReviewRow, summary_label); + gtk_widget_class_bind_template_child_private (widget_class, GsReviewRow, author_label); + gtk_widget_class_bind_template_child_private (widget_class, GsReviewRow, date_label); + gtk_widget_class_bind_template_child_private (widget_class, GsReviewRow, text_label); + gtk_widget_class_bind_template_child_private (widget_class, GsReviewRow, button_yes); + gtk_widget_class_bind_template_child_private (widget_class, GsReviewRow, button_no); + gtk_widget_class_bind_template_child_private (widget_class, GsReviewRow, button_dismiss); + gtk_widget_class_bind_template_child_private (widget_class, GsReviewRow, button_report); + gtk_widget_class_bind_template_child_private (widget_class, GsReviewRow, button_remove); + gtk_widget_class_bind_template_child_private (widget_class, GsReviewRow, box_voting); +} + +static void +gs_review_row_button_clicked_upvote_cb (GtkButton *button, GsReviewRow *row) +{ + g_signal_emit (row, signals[SIGNAL_BUTTON_CLICKED], 0, + GS_REVIEW_ACTION_UPVOTE); +} + +static void +gs_review_row_button_clicked_downvote_cb (GtkButton *button, GsReviewRow *row) +{ + g_signal_emit (row, signals[SIGNAL_BUTTON_CLICKED], 0, + GS_REVIEW_ACTION_DOWNVOTE); +} + +static void +gs_review_row_confirm_cb (GtkDialog *dialog, gint response_id, GsReviewRow *row) +{ + if (response_id == GTK_RESPONSE_YES) { + g_signal_emit (row, signals[SIGNAL_BUTTON_CLICKED], 0, + GS_REVIEW_ACTION_REPORT); + } + gtk_window_destroy (GTK_WINDOW (dialog)); +} + +static void +gs_review_row_button_clicked_report_cb (GtkButton *button, GsReviewRow *row) +{ + GtkWidget *dialog; + GtkRoot *root; + GtkWidget *widget; + g_autoptr(GString) str = NULL; + + str = g_string_new (""); + + /* TRANSLATORS: we explain what the action is going to do */ + g_string_append (str, _("You can report reviews for abusive, rude, or " + "discriminatory behavior.")); + g_string_append (str, " "); + + /* TRANSLATORS: we ask the user if they really want to do this */ + g_string_append (str, _("Once reported, a review will be hidden until " + "it has been checked by an administrator.")); + + root = gtk_widget_get_root (GTK_WIDGET (button)); + dialog = gtk_message_dialog_new (GTK_WINDOW (root), + GTK_DIALOG_MODAL | + GTK_DIALOG_DESTROY_WITH_PARENT | + GTK_DIALOG_USE_HEADER_BAR, + GTK_MESSAGE_QUESTION, + GTK_BUTTONS_CANCEL, + "%s", + /* TRANSLATORS: window title when + * reporting a user-submitted review + * for moderation */ + _("Report Review?")); + widget = gtk_dialog_add_button (GTK_DIALOG (dialog), + /* TRANSLATORS: button text when + * sending a review for moderation */ + _("Report"), + GTK_RESPONSE_YES); + gtk_style_context_add_class (gtk_widget_get_style_context (widget), + "destructive-action"); + gtk_message_dialog_format_secondary_text (GTK_MESSAGE_DIALOG (dialog), + "%s", str->str); + g_signal_connect (dialog, "response", + G_CALLBACK (gs_review_row_confirm_cb), row); + gtk_window_present (GTK_WINDOW (dialog)); +} + +static void +gs_review_row_button_clicked_dismiss_cb (GtkButton *button, GsReviewRow *row) +{ + g_signal_emit (row, signals[SIGNAL_BUTTON_CLICKED], 0, + GS_REVIEW_ACTION_DISMISS); +} + +static void +gs_review_row_button_clicked_remove_cb (GtkButton *button, GsReviewRow *row) +{ + g_signal_emit (row, signals[SIGNAL_BUTTON_CLICKED], 0, + GS_REVIEW_ACTION_REMOVE); +} + +AsReview * +gs_review_row_get_review (GsReviewRow *review_row) +{ + GsReviewRowPrivate *priv = gs_review_row_get_instance_private (review_row); + return priv->review; +} + +void +gs_review_row_set_actions (GsReviewRow *review_row, guint64 actions) +{ + GsReviewRowPrivate *priv = gs_review_row_get_instance_private (review_row); + priv->actions = actions; + gs_review_row_refresh (review_row); +} + +/** + * gs_review_row_new: + * @review: The review to show + * + * Create a widget suitable for showing an application review. + * + * Return value: A new @GsReviewRow. + **/ +GtkWidget * +gs_review_row_new (AsReview *review) +{ + GsReviewRow *row; + GsReviewRowPrivate *priv; + + g_return_val_if_fail (AS_IS_REVIEW (review), NULL); + + row = g_object_new (GS_TYPE_REVIEW_ROW, NULL); + priv = gs_review_row_get_instance_private (row); + priv->review = g_object_ref (review); + g_signal_connect_object (priv->review, "notify::state", + G_CALLBACK (gs_review_row_notify_props_changed_cb), + row, 0); + g_signal_connect_object (priv->button_yes, "clicked", + G_CALLBACK (gs_review_row_button_clicked_upvote_cb), + row, 0); + g_signal_connect_object (priv->button_no, "clicked", + G_CALLBACK (gs_review_row_button_clicked_downvote_cb), + row, 0); + g_signal_connect_object (priv->button_dismiss, "clicked", + G_CALLBACK (gs_review_row_button_clicked_dismiss_cb), + row, 0); + g_signal_connect_object (priv->button_report, "clicked", + G_CALLBACK (gs_review_row_button_clicked_report_cb), + row, 0); + g_signal_connect_object (priv->button_remove, "clicked", + G_CALLBACK (gs_review_row_button_clicked_remove_cb), + row, 0); + gs_review_row_refresh (row); + + return GTK_WIDGET (row); +} diff --git a/src/gs-review-row.h b/src/gs-review-row.h new file mode 100644 index 0000000..48139ce --- /dev/null +++ b/src/gs-review-row.h @@ -0,0 +1,56 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2016 Canonical Ltd. + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <gtk/gtk.h> + +#include "gnome-software-private.h" + +G_BEGIN_DECLS + +/** + * GsReviewAction: + * @GS_REVIEW_ACTION_UPVOTE: Add a vote to the review. + * @GS_REVIEW_ACTION_DOWNVOTE: Remove a vote from the review. + * @GS_REVIEW_ACTION_DISMISS: Dismiss (ignore) the review when moderating. + * @GS_REVIEW_ACTION_REPORT: Report the review for inappropriate content. + * @GS_REVIEW_ACTION_REMOVE: Remove one of your own reviews. + * + * Actions which can be performed on a review. + * + * Since: 41 + */ +typedef enum +{ + GS_REVIEW_ACTION_UPVOTE, + GS_REVIEW_ACTION_DOWNVOTE, + GS_REVIEW_ACTION_DISMISS, + GS_REVIEW_ACTION_REPORT, + GS_REVIEW_ACTION_REMOVE, +} GsReviewAction; + +#define GS_TYPE_REVIEW_ROW (gs_review_row_get_type ()) + +G_DECLARE_DERIVABLE_TYPE (GsReviewRow, gs_review_row, GS, REVIEW_ROW, GtkListBoxRow) + +struct _GsReviewRowClass +{ + GtkListBoxRowClass parent_class; + void (*button_clicked) (GsReviewRow *review_row, + GsPluginAction action); +}; + +GtkWidget *gs_review_row_new (AsReview *review); +AsReview *gs_review_row_get_review (GsReviewRow *review_row); +void gs_review_row_set_actions (GsReviewRow *review_row, + guint64 actions); +void gs_review_row_set_network_available (GsReviewRow *review_row, + gboolean network_available); + +G_END_DECLS diff --git a/src/gs-review-row.ui b/src/gs-review-row.ui new file mode 100644 index 0000000..e0d1aca --- /dev/null +++ b/src/gs-review-row.ui @@ -0,0 +1,164 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <!-- interface-requires gtk+ 3.10 --> + <template class="GsReviewRow" parent="GtkListBoxRow"> + <property name="activatable">False</property> + <style> + <class name="review-row"/> + </style> + <child> + <object class="GtkBox"> + <property name="orientation">vertical</property> + <property name="spacing">6</property> + <property name="hexpand">True</property> + <property name="vexpand">True</property> + <child> + <object class="GtkBox"> + <property name="spacing">10</property> + <property name="hexpand">True</property> + <child> + <object class="GsStarWidget" id="stars"> + <property name="halign">start</property> + <property name="sensitive">False</property> + </object> + </child> + <child> + <object class="GtkLabel" id="summary_label"> + <property name="halign">start</property> + <property name="label">Steep learning curve, but worth it</property> + <property name="ellipsize">end</property> + <property name="hexpand">True</property> + <property name="selectable">True</property> + <style> + <class name="heading"/> + </style> + </object> + </child> + <child> + <object class="GtkLabel" id="date_label"> + <property name="halign">end</property> + <property name="label">3 January 2016</property> + <property name="hexpand">True</property> + <property name="selectable">True</property> + <style> + <class name="dim-label"/> + </style> + </object> + </child> + </object> + </child> + <child> + <object class="GtkLabel" id="author_label"> + <property name="halign">start</property> + <property name="label">Angela Avery</property> + <property name="ellipsize">end</property> + <property name="selectable">True</property> + <style> + <class name="dim-label"/> + </style> + </object> + </child> + <child> + <object class="GtkLabel" id="text_label"> + <property name="halign">start</property> + <property name="margin_top">10</property> + <property name="margin_bottom">8</property> + <property name="label">Best overall 3D application I've ever used overall 3D application I've ever used. Best overall 3D application I've ever used overall 3D application I've ever used. Best overall 3D application I've ever used overall 3D application I've ever used. Best overall 3D application I've ever used overall 3D application I've ever used.</property> + <property name="wrap">True</property> + <property name="max_width_chars">80</property> + <property name="xalign">0</property> + <property name="wrap-mode">word-char</property> + <property name="selectable">True</property> + </object> + </child> + <child> + <object class="GtkBox"> + <property name="visible">True</property> + <property name="spacing">9</property> + <child> + <object class="GtkBox" id="box_voting"> + <property name="visible">False</property> + <property name="spacing">9</property> + <child> + <object class="GtkLabel"> + <property name="label" translatable="yes" comments="Translators: Users can express their opinions about other users' opinions about the apps.">Was this review useful to you?</property> + <property name="wrap">True</property> + <property name="xalign">0</property> + <style> + <class name="dim-label"/> + </style> + </object> + </child> + <child> + <object class="GtkBox" id="box_vote_buttons"> + <property name="spacing">0</property> + <style> + <class name="vote-buttons"/> + </style> + <child> + <object class="GtkButton" id="button_yes"> + <property name="label" translatable="yes">Yes</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="has-frame">False</property> + </object> + </child> + <child> + <object class="GtkButton" id="button_no"> + <property name="label" translatable="yes">No</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="has-frame">False</property> + </object> + </child> + <child> + <object class="GtkButton" id="button_dismiss"> + <property name="label" translatable="yes" comments="Translators: Button text for indifference, only used when moderating">Meh</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="has-frame">False</property> + </object> + </child> + </object> + </child> + </object> + </child> + <child> + <object class="GtkButton" id="button_report"> + <property name="label" translatable="yes">Report…</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="halign">end</property> + <property name="has-frame">False</property> + </object> + </child> + <child> + <object class="GtkButton" id="button_remove"> + <property name="label" translatable="yes">Remove…</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="halign">end</property> + <property name="has-frame">False</property> + </object> + </child> + </object> + </child> + </object> + </child> + + </template> + + <object class="GtkSizeGroup" id="action_sizegroup"> + <widgets> + <widget name="button_report"/> + <widget name="button_remove"/> + </widgets> + </object> + <object class="GtkSizeGroup" id="useful_sizegroup"> + <widgets> + <widget name="button_yes"/> + <widget name="button_no"/> + </widgets> + </object> + +</interface> diff --git a/src/gs-safety-context-dialog.c b/src/gs-safety-context-dialog.c new file mode 100644 index 0000000..ef6c054 --- /dev/null +++ b/src/gs-safety-context-dialog.c @@ -0,0 +1,651 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2021 Endless OS Foundation LLC + * + * Author: Philip Withnall <pwithnall@endlessos.org> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +/** + * SECTION:gs-safety-context-dialog + * @short_description: A dialog showing safety information about an app + * + * #GsSafetyContextDialog is a dialog which shows detailed information about + * how safe or trustworthy an app is. This information is derived from the + * permissions the app requires to run, its runtime, origin, and various other + * sources. + * + * It is designed to show a more detailed view of the information which the + * app’s safety tile in #GsAppContextBar is derived from. + * + * The widget has no special appearance if the app is unset, so callers will + * typically want to hide the dialog in that case. + * + * Since: 41 + */ + +#include "config.h" + +#include <adwaita.h> +#include <glib.h> +#include <glib-object.h> +#include <glib/gi18n.h> +#include <gtk/gtk.h> +#include <locale.h> + +#include "gs-app.h" +#include "gs-common.h" +#include "gs-context-dialog-row.h" +#include "gs-lozenge.h" +#include "gs-safety-context-dialog.h" + +struct _GsSafetyContextDialog +{ + GsInfoWindow parent_instance; + + GsApp *app; /* (nullable) (owned) */ + gulong app_notify_handler_permissions; + gulong app_notify_handler_name; + gulong app_notify_handler_quirk; + gulong app_notify_handler_license; + gulong app_notify_handler_related; + + GtkWidget *lozenge; + GtkLabel *title; + GtkListBox *permissions_list; + + GtkLabel *license_label; + GBinding *license_label_binding; /* (owned) (nullable) */ + GtkLabel *source_label; + GBinding *source_label_binding; /* (owned) (nullable) */ + GtkLabel *sdk_label; + GtkImage *sdk_eol_image; + GtkWidget *sdk_row; +}; + +G_DEFINE_TYPE (GsSafetyContextDialog, gs_safety_context_dialog, GS_TYPE_INFO_WINDOW) + +typedef enum { + PROP_APP = 1, +} GsSafetyContextDialogProperty; + +static GParamSpec *obj_props[PROP_APP + 1] = { NULL, }; + +/* @icon_name_without_permission, @title_without_permission and + * @description_without_permission are all nullable. If they are NULL, no row + * is added if @has_permission is false. */ +static void +add_permission_row (GtkListBox *list_box, + GsContextDialogRowImportance *chosen_rating, + gboolean has_permission, + GsContextDialogRowImportance item_rating, + const gchar *icon_name_with_permission, + const gchar *title_with_permission, + const gchar *description_with_permission, + const gchar *icon_name_without_permission, + const gchar *title_without_permission, + const gchar *description_without_permission) +{ + GtkListBoxRow *row; + + if (has_permission && item_rating > *chosen_rating) + *chosen_rating = item_rating; + + if (!has_permission && title_without_permission == NULL) + return; + + row = gs_context_dialog_row_new (has_permission ? icon_name_with_permission : icon_name_without_permission, + has_permission ? item_rating : GS_CONTEXT_DIALOG_ROW_IMPORTANCE_UNIMPORTANT, + has_permission ? title_with_permission : title_without_permission, + has_permission ? description_with_permission : description_without_permission); + gtk_list_box_append (list_box, GTK_WIDGET (row)); +} + +static void +update_permissions_list (GsSafetyContextDialog *self) +{ + const gchar *icon_name, *css_class; + g_autofree gchar *title = NULL; + g_autofree gchar *description = NULL; + g_autoptr(GPtrArray) descriptions = g_ptr_array_new_with_free_func (NULL); + g_autoptr(GsAppPermissions) permissions = NULL; + GsAppPermissionsFlags perm_flags = GS_APP_PERMISSIONS_FLAGS_UNKNOWN; + GtkStyleContext *context; + GsContextDialogRowImportance chosen_rating; + + /* Treat everything as safe to begin with, and downgrade its safety + * based on app properties. */ + chosen_rating = GS_CONTEXT_DIALOG_ROW_IMPORTANCE_UNIMPORTANT; + + gs_widget_remove_all (GTK_WIDGET (self->permissions_list), (GsRemoveFunc) gtk_list_box_remove); + + /* UI state is undefined if app is not set. */ + if (self->app == NULL) + return; + + permissions = gs_app_dup_permissions (self->app); + if (permissions != NULL) + perm_flags = gs_app_permissions_get_flags (permissions); + + /* Handle unknown permissions. This means the application isn’t + * sandboxed, so we can only really base decisions on whether it was + * packaged by an organisation we trust or not. + * + * FIXME: See the comment for GS_APP_PERMISSIONS_FLAGS_UNKNOWN in + * gs-app-context-bar.c. */ + if (perm_flags == GS_APP_PERMISSIONS_FLAGS_UNKNOWN) { + add_permission_row (self->permissions_list, &chosen_rating, + !gs_app_has_quirk (self->app, GS_APP_QUIRK_PROVENANCE), + GS_CONTEXT_DIALOG_ROW_IMPORTANCE_WARNING, + "channel-insecure-symbolic", + _("Provided by a third party"), + _("Check that you trust the vendor, as the application isn’t sandboxed"), + "channel-secure-symbolic", + _("Reviewed by your distribution"), + _("Application isn’t sandboxed but the distribution has checked that it is not malicious")); + } else { + const GPtrArray *filesystem_read, *filesystem_full; + + filesystem_read = gs_app_permissions_get_filesystem_read (permissions); + filesystem_full = gs_app_permissions_get_filesystem_full (permissions); + + add_permission_row (self->permissions_list, &chosen_rating, + (perm_flags & GS_APP_PERMISSIONS_FLAGS_NONE) != 0 && + filesystem_read == NULL && filesystem_full == NULL, + GS_CONTEXT_DIALOG_ROW_IMPORTANCE_UNIMPORTANT, + "folder-documents-symbolic", + /* Translators: This refers to permissions (for example, from flatpak) which an app requests from the user. */ + _("No Permissions"), + _("App is fully sandboxed"), + NULL, NULL, NULL); + add_permission_row (self->permissions_list, &chosen_rating, + (perm_flags & GS_APP_PERMISSIONS_FLAGS_NETWORK) != 0, + /* This isn’t actually unimportant (network access can expand a local + * vulnerability into a remotely exploitable one), but it’s + * needed commonly enough that marking it as + * %GS_CONTEXT_DIALOG_ROW_IMPORTANCE_WARNING is too noisy. */ + GS_CONTEXT_DIALOG_ROW_IMPORTANCE_NEUTRAL, + "network-wireless-symbolic", + /* Translators: This refers to permissions (for example, from flatpak) which an app requests from the user. */ + _("Network Access"), + _("Can access the internet"), + "network-wireless-disabled-symbolic", + /* Translators: This refers to permissions (for example, from flatpak) which an app requests from the user. */ + _("No Network Access"), + _("Cannot access the internet")); + add_permission_row (self->permissions_list, &chosen_rating, + (perm_flags & GS_APP_PERMISSIONS_FLAGS_SYSTEM_BUS) != 0, + GS_CONTEXT_DIALOG_ROW_IMPORTANCE_WARNING, + "emblem-system-symbolic", + /* Translators: This refers to permissions (for example, from flatpak) which an app requests from the user. */ + _("Uses System Services"), + _("Can request data from system services"), + NULL, NULL, NULL); + add_permission_row (self->permissions_list, &chosen_rating, + (perm_flags & GS_APP_PERMISSIONS_FLAGS_SESSION_BUS) != 0, + GS_CONTEXT_DIALOG_ROW_IMPORTANCE_IMPORTANT, + "emblem-system-symbolic", + /* Translators: This refers to permissions (for example, from flatpak) which an app requests from the user. */ + _("Uses Session Services"), + _("Can request data from session services"), + NULL, NULL, NULL); + add_permission_row (self->permissions_list, &chosen_rating, + (perm_flags & GS_APP_PERMISSIONS_FLAGS_DEVICES) != 0, + GS_CONTEXT_DIALOG_ROW_IMPORTANCE_WARNING, + "camera-photo-symbolic", + /* Translators: This refers to permissions (for example, from flatpak) which an app requests from the user. */ + _("Device Access"), + _("Can access devices such as webcams or gaming controllers"), + "camera-disabled-symbolic", + /* Translators: This refers to permissions (for example, from flatpak) which an app requests from the user. */ + _("No Device Access"), + _("Cannot access devices such as webcams or gaming controllers")); + add_permission_row (self->permissions_list, &chosen_rating, + (perm_flags & GS_APP_PERMISSIONS_FLAGS_X11) != 0, + GS_CONTEXT_DIALOG_ROW_IMPORTANCE_IMPORTANT, + "desktop-symbolic", + /* Translators: This refers to permissions (for example, from flatpak) which an app requests from the user. */ + _("Legacy Windowing System"), + _("Uses a legacy windowing system"), + NULL, NULL, NULL); + add_permission_row (self->permissions_list, &chosen_rating, + (perm_flags & GS_APP_PERMISSIONS_FLAGS_ESCAPE_SANDBOX) != 0, + GS_CONTEXT_DIALOG_ROW_IMPORTANCE_IMPORTANT, + "dialog-warning-symbolic", + /* Translators: This refers to permissions (for example, from flatpak) which an app requests from the user. */ + _("Arbitrary Permissions"), + _("Can acquire arbitrary permissions"), + NULL, NULL, NULL); + add_permission_row (self->permissions_list, &chosen_rating, + (perm_flags & GS_APP_PERMISSIONS_FLAGS_SETTINGS) != 0, + GS_CONTEXT_DIALOG_ROW_IMPORTANCE_WARNING, + "preferences-system-symbolic", + /* Translators: This refers to permissions (for example, from flatpak) which an app requests from the user. */ + _("User Settings"), + _("Can access and change user settings"), + NULL, NULL, NULL); + + /* File system permissions are a bit more complex, since there are + * varying scopes of what’s readable/writable, and a difference between + * read-only and writable access. */ + add_permission_row (self->permissions_list, &chosen_rating, + (perm_flags & GS_APP_PERMISSIONS_FLAGS_FILESYSTEM_FULL) != 0, + GS_CONTEXT_DIALOG_ROW_IMPORTANCE_IMPORTANT, + "folder-documents-symbolic", + /* Translators: This refers to permissions (for example, from flatpak) which an app requests from the user. */ + _("Full File System Read/Write Access"), + _("Can read and write all data on the file system"), + NULL, NULL, NULL); + add_permission_row (self->permissions_list, &chosen_rating, + ((perm_flags & GS_APP_PERMISSIONS_FLAGS_HOME_FULL) != 0 && + !(perm_flags & GS_APP_PERMISSIONS_FLAGS_FILESYSTEM_FULL)), + GS_CONTEXT_DIALOG_ROW_IMPORTANCE_IMPORTANT, + "user-home-symbolic", + /* Translators: This refers to permissions (for example, from flatpak) which an app requests from the user. */ + _("Home Folder Read/Write Access"), + _("Can read and write all data in your home directory"), + NULL, NULL, NULL); + add_permission_row (self->permissions_list, &chosen_rating, + ((perm_flags & GS_APP_PERMISSIONS_FLAGS_FILESYSTEM_READ) != 0 && + !(perm_flags & GS_APP_PERMISSIONS_FLAGS_FILESYSTEM_FULL)), + GS_CONTEXT_DIALOG_ROW_IMPORTANCE_IMPORTANT, + "folder-documents-symbolic", + /* Translators: This refers to permissions (for example, from flatpak) which an app requests from the user. */ + _("Full File System Read Access"), + _("Can read all data on the file system"), + NULL, NULL, NULL); + add_permission_row (self->permissions_list, &chosen_rating, + ((perm_flags & GS_APP_PERMISSIONS_FLAGS_HOME_READ) != 0 && + !(perm_flags & (GS_APP_PERMISSIONS_FLAGS_FILESYSTEM_FULL | + GS_APP_PERMISSIONS_FLAGS_FILESYSTEM_READ))), + GS_CONTEXT_DIALOG_ROW_IMPORTANCE_IMPORTANT, + "user-home-symbolic", + /* Translators: This refers to permissions (for example, from flatpak) which an app requests from the user. */ + _("Home Folder Read Access"), + _("Can read all data in your home directory"), + NULL, NULL, NULL); + add_permission_row (self->permissions_list, &chosen_rating, + ((perm_flags & GS_APP_PERMISSIONS_FLAGS_DOWNLOADS_FULL) != 0 && + !(perm_flags & (GS_APP_PERMISSIONS_FLAGS_FILESYSTEM_FULL | + GS_APP_PERMISSIONS_FLAGS_HOME_FULL))), + GS_CONTEXT_DIALOG_ROW_IMPORTANCE_WARNING, + "folder-download-symbolic", + /* Translators: This refers to permissions (for example, from flatpak) which an app requests from the user. */ + _("Download Folder Read/Write Access"), + _("Can read and write all data in your downloads directory"), + NULL, NULL, NULL); + add_permission_row (self->permissions_list, &chosen_rating, + ((perm_flags & GS_APP_PERMISSIONS_FLAGS_DOWNLOADS_READ) != 0 && + !(perm_flags & (GS_APP_PERMISSIONS_FLAGS_FILESYSTEM_FULL | + GS_APP_PERMISSIONS_FLAGS_FILESYSTEM_READ | + GS_APP_PERMISSIONS_FLAGS_HOME_FULL | + GS_APP_PERMISSIONS_FLAGS_HOME_READ))), + GS_CONTEXT_DIALOG_ROW_IMPORTANCE_WARNING, + "folder-download-symbolic", + /* Translators: This refers to permissions (for example, from flatpak) which an app requests from the user. */ + _("Download Folder Read Access"), + _("Can read all data in your downloads directory"), + NULL, NULL, NULL); + + for (guint i = 0; filesystem_full != NULL && i < filesystem_full->len; i++) { + const gchar *fs_title = g_ptr_array_index (filesystem_full, i); + add_permission_row (self->permissions_list, &chosen_rating, + TRUE, + GS_CONTEXT_DIALOG_ROW_IMPORTANCE_WARNING, + "folder-documents-symbolic", + fs_title, + _("Can read and write all data in the directory"), + NULL, NULL, NULL); + } + + for (guint i = 0; filesystem_read != NULL && i < filesystem_read->len; i++) { + const gchar *fs_title = g_ptr_array_index (filesystem_read, i); + add_permission_row (self->permissions_list, &chosen_rating, + TRUE, + GS_CONTEXT_DIALOG_ROW_IMPORTANCE_WARNING, + "folder-documents-symbolic", + fs_title, + _("Can read all data in the directory"), + NULL, NULL, NULL); + } + + add_permission_row (self->permissions_list, &chosen_rating, + !(perm_flags & (GS_APP_PERMISSIONS_FLAGS_FILESYSTEM_FULL | + GS_APP_PERMISSIONS_FLAGS_FILESYSTEM_READ | + GS_APP_PERMISSIONS_FLAGS_FILESYSTEM_OTHER | + GS_APP_PERMISSIONS_FLAGS_HOME_FULL | + GS_APP_PERMISSIONS_FLAGS_HOME_READ | + GS_APP_PERMISSIONS_FLAGS_DOWNLOADS_FULL | + GS_APP_PERMISSIONS_FLAGS_DOWNLOADS_READ)) && + filesystem_read == NULL && filesystem_full == NULL, + GS_CONTEXT_DIALOG_ROW_IMPORTANCE_UNIMPORTANT, + "folder-documents-symbolic", + /* Translators: This refers to permissions (for example, from flatpak) which an app requests from the user. */ + _("No File System Access"), + _("Cannot access the file system at all"), + NULL, NULL, NULL); + } + + /* Is the code FOSS and hence inspectable? This doesn’t distinguish + * between closed source and open-source-but-not-FOSS software, even + * though the code of the latter is technically publicly auditable. This + * is because I don’t want to get into the business of maintaining lists + * of ‘auditable’ source code licenses. */ + add_permission_row (self->permissions_list, &chosen_rating, + !gs_app_get_license_is_free (self->app), + GS_CONTEXT_DIALOG_ROW_IMPORTANCE_WARNING, + "dialog-warning-symbolic", + /* Translators: This refers to permissions (for example, from flatpak) which an app requests from the user. */ + _("Proprietary Code"), + _("The source code is not public, so it cannot be independently audited and might be unsafe"), + "app-installed-symbolic", + /* Translators: This refers to permissions (for example, from flatpak) which an app requests from the user. */ + _("Auditable Code"), + _("The source code is public and can be independently audited, which makes the app more likely to be safe")); + + add_permission_row (self->permissions_list, &chosen_rating, + gs_app_has_quirk (self->app, GS_APP_QUIRK_DEVELOPER_VERIFIED), + GS_CONTEXT_DIALOG_ROW_IMPORTANCE_UNIMPORTANT, + "app-installed-symbolic", + /* Translators: This indicates an app was written and released by a developer who has been verified. + * It’s used in a context tile, so should be short. */ + _("App developer is verified"), + _("The developer of this app has been verified to be who they say they are"), + NULL, NULL, NULL); + + add_permission_row (self->permissions_list, &chosen_rating, + gs_app_get_metadata_item (self->app, "GnomeSoftware::EolReason") != NULL || ( + gs_app_get_runtime (self->app) != NULL && + gs_app_get_metadata_item (gs_app_get_runtime (self->app), "GnomeSoftware::EolReason") != NULL), + GS_CONTEXT_DIALOG_ROW_IMPORTANCE_IMPORTANT, + "dialog-warning-symbolic", + /* Translators: This indicates an app uses an outdated SDK. + * It’s used in a context tile, so should be short. */ + _("Insecure Dependencies"), + _("Software or its dependencies are no longer supported and may be insecure"), + NULL, NULL, NULL); + + /* Update the UI. */ + switch (chosen_rating) { + case GS_CONTEXT_DIALOG_ROW_IMPORTANCE_UNIMPORTANT: + icon_name = "safety-symbolic"; + /* Translators: The app is considered safe to install and run. + * The placeholder is the app name. */ + title = g_strdup_printf (_("%s is safe"), gs_app_get_name (self->app)); + css_class = "green"; + break; + case GS_CONTEXT_DIALOG_ROW_IMPORTANCE_WARNING: + icon_name = "dialog-question-symbolic"; + /* Translators: The app is considered potentially unsafe to install and run. + * The placeholder is the app name. */ + title = g_strdup_printf (_("%s is potentially unsafe"), gs_app_get_name (self->app)); + css_class = "yellow"; + break; + case GS_CONTEXT_DIALOG_ROW_IMPORTANCE_IMPORTANT: + icon_name = "dialog-warning-symbolic"; + /* Translators: The app is considered unsafe to install and run. + * The placeholder is the app name. */ + title = g_strdup_printf (_("%s is unsafe"), gs_app_get_name (self->app)); + css_class = "red"; + break; + default: + g_assert_not_reached (); + } + + gs_lozenge_set_icon_name (GS_LOZENGE (self->lozenge), icon_name); + gtk_label_set_text (self->title, title); + + context = gtk_widget_get_style_context (self->lozenge); + + gtk_style_context_remove_class (context, "green"); + gtk_style_context_remove_class (context, "yellow"); + gtk_style_context_remove_class (context, "red"); + + gtk_style_context_add_class (context, css_class); +} + +static void +app_notify_cb (GObject *obj, + GParamSpec *pspec, + gpointer user_data) +{ + GsSafetyContextDialog *self = GS_SAFETY_CONTEXT_DIALOG (user_data); + + update_permissions_list (self); +} + +static void +update_sdk (GsSafetyContextDialog *self) +{ + GsApp *runtime; + + /* UI state is undefined if app is not set. */ + if (self->app == NULL) + return; + + runtime = gs_app_get_runtime (self->app); + + if (runtime != NULL) { + GtkStyleContext *context; + g_autofree gchar *label = NULL; + const gchar *version = gs_app_get_version_ui (runtime); + gboolean is_eol = gs_app_get_metadata_item (runtime, "GnomeSoftware::EolReason") != NULL; + + if (version != NULL) { + /* Translators: The first placeholder is an app runtime + * name, the second is its version number. */ + label = g_strdup_printf (_("%s (%s)"), + gs_app_get_name (runtime), + version); + } else { + label = g_strdup (gs_app_get_name (runtime)); + } + + gtk_label_set_label (self->sdk_label, label); + + context = gtk_widget_get_style_context (GTK_WIDGET (self->sdk_label)); + + if (is_eol) { + gtk_style_context_add_class (context, "eol-red"); + gtk_style_context_remove_class (context, "dim-label"); + } else { + gtk_style_context_add_class (context, "dim-label"); + gtk_style_context_remove_class (context, "eol-red"); + } + + gtk_widget_set_visible (GTK_WIDGET (self->sdk_eol_image), is_eol); + } + + /* Only show the row if a runtime was found. */ + gtk_widget_set_visible (self->sdk_row, (runtime != NULL)); +} + +static void +app_notify_related_cb (GObject *obj, + GParamSpec *pspec, + gpointer user_data) +{ + GsSafetyContextDialog *self = GS_SAFETY_CONTEXT_DIALOG (user_data); + + update_sdk (self); +} + +static void +gs_safety_context_dialog_init (GsSafetyContextDialog *self) +{ + gtk_widget_init_template (GTK_WIDGET (self)); +} + +static void +gs_safety_context_dialog_get_property (GObject *object, + guint prop_id, + GValue *value, + GParamSpec *pspec) +{ + GsSafetyContextDialog *self = GS_SAFETY_CONTEXT_DIALOG (object); + + switch ((GsSafetyContextDialogProperty) prop_id) { + case PROP_APP: + g_value_set_object (value, gs_safety_context_dialog_get_app (self)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_safety_context_dialog_set_property (GObject *object, + guint prop_id, + const GValue *value, + GParamSpec *pspec) +{ + GsSafetyContextDialog *self = GS_SAFETY_CONTEXT_DIALOG (object); + + switch ((GsSafetyContextDialogProperty) prop_id) { + case PROP_APP: + gs_safety_context_dialog_set_app (self, g_value_get_object (value)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_safety_context_dialog_dispose (GObject *object) +{ + GsSafetyContextDialog *self = GS_SAFETY_CONTEXT_DIALOG (object); + + gs_safety_context_dialog_set_app (self, NULL); + + G_OBJECT_CLASS (gs_safety_context_dialog_parent_class)->dispose (object); +} + +static void +gs_safety_context_dialog_class_init (GsSafetyContextDialogClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + + object_class->get_property = gs_safety_context_dialog_get_property; + object_class->set_property = gs_safety_context_dialog_set_property; + object_class->dispose = gs_safety_context_dialog_dispose; + + /** + * GsSafetyContextDialog:app: (nullable) + * + * The app to display the safety context details for. + * + * This may be %NULL; if so, the content of the widget will be + * undefined. + * + * Since: 41 + */ + obj_props[PROP_APP] = + g_param_spec_object ("app", NULL, NULL, + GS_TYPE_APP, + G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS); + + g_object_class_install_properties (object_class, G_N_ELEMENTS (obj_props), obj_props); + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-safety-context-dialog.ui"); + + gtk_widget_class_bind_template_child (widget_class, GsSafetyContextDialog, lozenge); + gtk_widget_class_bind_template_child (widget_class, GsSafetyContextDialog, title); + gtk_widget_class_bind_template_child (widget_class, GsSafetyContextDialog, permissions_list); + gtk_widget_class_bind_template_child (widget_class, GsSafetyContextDialog, license_label); + gtk_widget_class_bind_template_child (widget_class, GsSafetyContextDialog, source_label); + gtk_widget_class_bind_template_child (widget_class, GsSafetyContextDialog, sdk_label); + gtk_widget_class_bind_template_child (widget_class, GsSafetyContextDialog, sdk_eol_image); + gtk_widget_class_bind_template_child (widget_class, GsSafetyContextDialog, sdk_row); +} + +/** + * gs_safety_context_dialog_new: + * @app: (nullable): the app to display safety context information for, or %NULL + * + * Create a new #GsSafetyContextDialog and set its initial app to @app. + * + * Returns: (transfer full): a new #GsSafetyContextDialog + * Since: 41 + */ +GsSafetyContextDialog * +gs_safety_context_dialog_new (GsApp *app) +{ + g_return_val_if_fail (app == NULL || GS_IS_APP (app), NULL); + + return g_object_new (GS_TYPE_SAFETY_CONTEXT_DIALOG, + "app", app, + NULL); +} + +/** + * gs_safety_context_dialog_get_app: + * @self: a #GsSafetyContextDialog + * + * Gets the value of #GsSafetyContextDialog:app. + * + * Returns: (nullable) (transfer none): app whose safety context information is + * being displayed, or %NULL if none is set + * Since: 41 + */ +GsApp * +gs_safety_context_dialog_get_app (GsSafetyContextDialog *self) +{ + g_return_val_if_fail (GS_IS_SAFETY_CONTEXT_DIALOG (self), NULL); + + return self->app; +} + +/** + * gs_safety_context_dialog_set_app: + * @self: a #GsSafetyContextDialog + * @app: (nullable) (transfer none): the app to display safety context + * information for, or %NULL for none + * + * Set the value of #GsSafetyContextDialog:app. + * + * Since: 41 + */ +void +gs_safety_context_dialog_set_app (GsSafetyContextDialog *self, + GsApp *app) +{ + g_return_if_fail (GS_IS_SAFETY_CONTEXT_DIALOG (self)); + g_return_if_fail (app == NULL || GS_IS_APP (app)); + + if (app == self->app) + return; + + g_clear_signal_handler (&self->app_notify_handler_permissions, self->app); + g_clear_signal_handler (&self->app_notify_handler_name, self->app); + g_clear_signal_handler (&self->app_notify_handler_quirk, self->app); + g_clear_signal_handler (&self->app_notify_handler_license, self->app); + g_clear_signal_handler (&self->app_notify_handler_related, self->app); + + g_clear_object (&self->license_label_binding); + g_clear_object (&self->source_label_binding); + + g_set_object (&self->app, app); + + if (self->app != NULL) { + self->app_notify_handler_permissions = g_signal_connect (self->app, "notify::permissions", G_CALLBACK (app_notify_cb), self); + self->app_notify_handler_name = g_signal_connect (self->app, "notify::name", G_CALLBACK (app_notify_cb), self); + self->app_notify_handler_quirk = g_signal_connect (self->app, "notify::quirk", G_CALLBACK (app_notify_cb), self); + self->app_notify_handler_license = g_signal_connect (self->app, "notify::license", G_CALLBACK (app_notify_cb), self); + + self->app_notify_handler_related = g_signal_connect (self->app, "notify::related", G_CALLBACK (app_notify_related_cb), self); + + self->license_label_binding = g_object_bind_property (self->app, "license", self->license_label, "label", G_BINDING_SYNC_CREATE); + self->source_label_binding = g_object_bind_property (self->app, "origin-ui", self->source_label, "label", G_BINDING_SYNC_CREATE); + } + + /* Update the UI. */ + update_permissions_list (self); + update_sdk (self); + + g_object_notify_by_pspec (G_OBJECT (self), obj_props[PROP_APP]); +} diff --git a/src/gs-safety-context-dialog.h b/src/gs-safety-context-dialog.h new file mode 100644 index 0000000..f37e277 --- /dev/null +++ b/src/gs-safety-context-dialog.h @@ -0,0 +1,32 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2021 Endless OS Foundation LLC + * + * Author: Philip Withnall <pwithnall@endlessos.org> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <glib.h> +#include <glib-object.h> +#include <gtk/gtk.h> + +#include "gs-app.h" +#include "gs-info-window.h" + +G_BEGIN_DECLS + +#define GS_TYPE_SAFETY_CONTEXT_DIALOG (gs_safety_context_dialog_get_type ()) + +G_DECLARE_FINAL_TYPE (GsSafetyContextDialog, gs_safety_context_dialog, GS, SAFETY_CONTEXT_DIALOG, GsInfoWindow) + +GsSafetyContextDialog *gs_safety_context_dialog_new (GsApp *app); + +GsApp *gs_safety_context_dialog_get_app (GsSafetyContextDialog *self); +void gs_safety_context_dialog_set_app (GsSafetyContextDialog *self, + GsApp *app); + +G_END_DECLS diff --git a/src/gs-safety-context-dialog.ui b/src/gs-safety-context-dialog.ui new file mode 100644 index 0000000..4e129ca --- /dev/null +++ b/src/gs-safety-context-dialog.ui @@ -0,0 +1,242 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <requires lib="gtk+" version="3.10"/> + <template class="GsSafetyContextDialog" parent="GsInfoWindow"> + <property name="title" translatable="yes" comments="Translators: This is the title of the dialog which contains information about the permissions of an app">Safety</property> + <child> + <object class="AdwPreferencesPage"> + <child> + <object class="AdwPreferencesGroup"> + + <child> + <object class="GtkBox"> + <property name="orientation">vertical</property> + <property name="spacing">8</property> + + <child> + <object class="GtkBox"> + <property name="margin-top">20</property> + <property name="margin-bottom">16</property> + <property name="margin-start">20</property> + <property name="margin-end">20</property> + <property name="orientation">vertical</property> + <property name="spacing">12</property> + + <child> + <object class="GsLozenge" id="lozenge"> + <property name="circular">True</property> + <!-- this is a placeholder: the icon is actually set in code --> + <property name="icon-name">safety-symbolic</property> + <property name="pixel-size">24</property> + <style> + <class name="large"/> + <class name="grey"/> + </style> + <accessibility> + <relation name="labelled-by">title</relation> + </accessibility> + </object> + </child> + + <child> + <object class="GtkLabel" id="title"> + <!-- this is a placeholder: the text is actually set in code --> + <property name="justify">center</property> + <property name="label">Shortwave is safe</property> + <property name="wrap">True</property> + <property name="xalign">0.5</property> + <style> + <class name="title-2"/> + </style> + </object> + </child> + </object> + </child> + + <child> + <object class="GtkListBox" id="permissions_list"> + <property name="selection_mode">none</property> + <property name="halign">fill</property> + <property name="valign">start</property> + <style> + <class name="boxed-list"/> + </style> + <!-- Rows are added in code --> + <placeholder/> + </object> + </child> + + <child> + <object class="AdwPreferencesGroup"> + <property name="margin-top">20</property> + <property name="title" translatable="yes">Details</property> + + <child> + <object class="GtkListBox" id="details_list"> + <property name="selection_mode">none</property> + <property name="halign">fill</property> + <property name="valign">start</property> + <style> + <class name="boxed-list"/> + </style> + + <child> + <object class="GtkListBoxRow"> + <property name="activatable">False</property> + <property name="focusable">False</property> + <child> + <object class="GtkBox"> + <property name="margin-top">12</property> + <property name="margin-bottom">12</property> + <property name="margin-start">12</property> + <property name="margin-end">12</property> + <property name="orientation">horizontal</property> + <property name="spacing">12</property> + <child> + <object class="GtkLabel" id="license_title"> + <property name="ellipsize">end</property> + <property name="hexpand">True</property> + <property name="xalign">0</property> + <property name="yalign">0.5</property> + <property name="label" translatable="yes">License</property> + </object> + </child> + <child> + <object class="GtkLabel" id="license_label"> + <property name="ellipsize">end</property> + <property name="valign">center</property> + <!-- This is a placeholder. The label is set in code. --> + <property name="label">GNU GPL v3+</property> + <style> + <class name="dim-label"/> + </style> + <accessibility> + <relation name="labelled-by">license_title</relation> + </accessibility> + </object> + </child> + </object> + </child> + </object> + </child> + + <child> + <object class="GtkListBoxRow"> + <property name="activatable">False</property> + <property name="focusable">False</property> + <child> + <object class="GtkBox"> + <property name="margin-top">12</property> + <property name="margin-bottom">12</property> + <property name="margin-start">12</property> + <property name="margin-end">12</property> + <property name="orientation">horizontal</property> + <property name="spacing">12</property> + <child> + <object class="GtkLabel" id="source_title"> + <property name="ellipsize">end</property> + <property name="hexpand">True</property> + <property name="xalign">0</property> + <property name="yalign">0.5</property> + <property name="label" translatable="yes" comments="Translators: This is a heading for a row showing the origin/source of an app (such as ‘flathub’).">Source</property> + </object> + </child> + <child> + <object class="GtkLabel" id="source_label"> + <property name="ellipsize">end</property> + <property name="valign">center</property> + <!-- This is a placeholder. The label is set in code. --> + <property name="label">flathub.org</property> + <style> + <class name="dim-label"/> + </style> + <accessibility> + <relation name="labelled-by">source_title</relation> + </accessibility> + </object> + </child> + </object> + </child> + </object> + </child> + + <child> + <object class="GtkListBoxRow" id="sdk_row"> + <property name="activatable">False</property> + <property name="focusable">False</property> + <child> + <object class="GtkBox"> + <property name="margin-top">12</property> + <property name="margin-bottom">12</property> + <property name="margin-start">12</property> + <property name="margin-end">12</property> + <property name="orientation">horizontal</property> + <property name="spacing">12</property> + <child> + <object class="GtkLabel" id="sdk_title"> + <property name="ellipsize">end</property> + <property name="hexpand">True</property> + <property name="xalign">0</property> + <property name="yalign">0.5</property> + <property name="label" translatable="yes">SDK</property> + </object> + </child> + <child> + <object class="GtkLabel" id="sdk_label"> + <property name="ellipsize">end</property> + <property name="valign">center</property> + <!-- This is a placeholder. The label is set in code. --> + <property name="label">GNOME 3.36.12</property> + <style> + <class name="dim-label"/> + </style> + <accessibility> + <relation name="labelled-by">sdk_title</relation> + </accessibility> + </object> + </child> + <child> + <object class="GtkImage" id="sdk_eol_image"> + <property name="icon-name">dialog-warning-symbolic</property> + <style> + <class name="eol-red"/> + </style> + <accessibility> + <property name="label" translatable="yes">Outdated SDK version</property> + <relation name="labelled-by">sdk_title</relation> + </accessibility> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + + <child> + <object class="GtkLinkButton"> + <property name="label" translatable="yes">How to contribute missing information</property> + <property name="margin-top">16</property> + <property name="uri">https://gitlab.gnome.org/GNOME/gnome-software/-/wikis/software-metadata#safety</property> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + </template> + + <object class="GtkSizeGroup" id="details_size_group"> + <property name="mode">horizontal</property> + <widgets> + <widget name="license_title"/> + <widget name="source_title"/> + <widget name="sdk_title"/> + </widgets> + </object> +</interface> diff --git a/src/gs-screenshot-carousel.c b/src/gs-screenshot-carousel.c new file mode 100644 index 0000000..d3111af --- /dev/null +++ b/src/gs-screenshot-carousel.c @@ -0,0 +1,365 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013-2016 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2013 Matthias Clasen <mclasen@redhat.com> + * Copyright (C) 2015-2019 Kalev Lember <klember@redhat.com> + * Copyright (C) 2019 Joaquim Rocha <jrocha@endlessm.com> + * Copyright (C) 2021 Adrien Plazas <adrien.plazas@puri.sm> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +/** + * SECTION:gs-screenshot-carousel + * @short_description: A carousel presenting the screenshots of a #GsApp + * + * #GsScreenshotCarousel loads screenshots from a #GsApp and present them to the + * users. + * + * If the carousel doesn't have any screenshot to display, an empty state + * fallback will be presented, and it will be considered to have screenshots as + * long as it is trying to load some. + * + * Since: 41 + */ + +#include "config.h" + +#include <adwaita.h> +#include <glib/gi18n.h> +#include <locale.h> +#include <math.h> +#include <string.h> + +#include "gs-common.h" +#include "gs-download-utils.h" +#include "gs-utils.h" + +#include "gs-screenshot-carousel.h" +#include "gs-screenshot-image.h" + +struct _GsScreenshotCarousel +{ + GtkWidget parent_instance; + + SoupSession *session; /* (owned) (not nullable) */ + gboolean has_screenshots; + + GtkWidget *button_next; + GtkWidget *button_next_revealer; + GtkWidget *button_previous; + GtkWidget *button_previous_revealer; + GtkWidget *carousel; + GtkWidget *carousel_indicator; + GtkStack *stack; +}; + +typedef enum { + PROP_HAS_SCREENSHOTS = 1, +} GsScreenshotCarouselProperty; + +static GParamSpec *obj_props[PROP_HAS_SCREENSHOTS + 1] = { NULL, }; + +G_DEFINE_TYPE (GsScreenshotCarousel, gs_screenshot_carousel, GTK_TYPE_WIDGET) + +static void +_set_state (GsScreenshotCarousel *self, guint length, gboolean allow_fallback, gboolean is_online) +{ + gboolean has_screenshots; + + gtk_widget_set_visible (self->carousel_indicator, length > 1); + gtk_stack_set_visible_child_name (self->stack, length > 0 ? "carousel" : "fallback"); + + has_screenshots = length > 0 || (allow_fallback && is_online); + if (self->has_screenshots != has_screenshots) { + self->has_screenshots = has_screenshots; + g_object_notify_by_pspec (G_OBJECT (self), obj_props[PROP_HAS_SCREENSHOTS]); + } +} + +static void +gs_screenshot_carousel_img_clicked_cb (GtkWidget *ssimg, + gpointer user_data) +{ + GsScreenshotCarousel *self = user_data; + adw_carousel_scroll_to (ADW_CAROUSEL (self->carousel), ssimg, TRUE); +} + +/** + * gs_screenshot_carousel_load_screenshots: + * @self: a #GsScreenshotCarousel + * @app: app to load the screenshots for + * @is_online: %TRUE if the network is expected to work to load screenshots, %FALSE otherwise + * + * Clear the existing set of screenshot images, and load the + * screenshots for @app instead. Display them, or display a + * fallback if no screenshots could be loaded (and the fallback + * is enabled). + * + * This will start some asynchronous network requests to download + * screenshots. Those requests may continue after this function + * call returns. + * + * Since: 41 + */ +void +gs_screenshot_carousel_load_screenshots (GsScreenshotCarousel *self, GsApp *app, gboolean is_online, GCancellable *cancellable) +{ + GPtrArray *screenshots; + gboolean allow_fallback; + guint num_screenshots_loaded = 0; + + g_return_if_fail (GS_IS_SCREENSHOT_CAROUSEL (self)); + g_return_if_fail (GS_IS_APP (app)); + + /* fallback warning */ + screenshots = gs_app_get_screenshots (app); + switch (gs_app_get_kind (app)) { + case AS_COMPONENT_KIND_GENERIC: + case AS_COMPONENT_KIND_CODEC: + case AS_COMPONENT_KIND_ADDON: + case AS_COMPONENT_KIND_REPOSITORY: + case AS_COMPONENT_KIND_FIRMWARE: + case AS_COMPONENT_KIND_DRIVER: + case AS_COMPONENT_KIND_INPUT_METHOD: + case AS_COMPONENT_KIND_LOCALIZATION: + case AS_COMPONENT_KIND_RUNTIME: + allow_fallback = FALSE; + break; + default: + allow_fallback = TRUE; + break; + } + + /* reset screenshots */ + gs_widget_remove_all (self->carousel, (GsRemoveFunc) adw_carousel_remove); + + for (guint i = 0; i < screenshots->len && !g_cancellable_is_cancelled (cancellable); i++) { + AsScreenshot *ss = g_ptr_array_index (screenshots, i); + GtkWidget *ssimg = gs_screenshot_image_new (self->session); + gtk_widget_set_can_focus (gtk_widget_get_first_child (ssimg), FALSE); + gs_screenshot_image_set_screenshot (GS_SCREENSHOT_IMAGE (ssimg), ss); + gs_screenshot_image_set_size (GS_SCREENSHOT_IMAGE (ssimg), + AS_IMAGE_NORMAL_WIDTH, + AS_IMAGE_NORMAL_HEIGHT); + gtk_style_context_add_class (gtk_widget_get_style_context (ssimg), + "screenshot-image-main"); + gs_screenshot_image_load_async (GS_SCREENSHOT_IMAGE (ssimg), cancellable); + + /* when we're offline, the load will be immediate, so we + * can check if it succeeded, and just skip it and its + * thumbnails otherwise */ + if (!is_online && + !gs_screenshot_image_is_showing (GS_SCREENSHOT_IMAGE (ssimg))) { + g_object_ref_sink (ssimg); + g_object_unref (ssimg); + continue; + } + + g_signal_connect_object (ssimg, "clicked", + G_CALLBACK (gs_screenshot_carousel_img_clicked_cb), self, 0); + + adw_carousel_append (ADW_CAROUSEL (self->carousel), ssimg); + gtk_widget_show (ssimg); + gs_screenshot_image_set_description (GS_SCREENSHOT_IMAGE (ssimg), + as_screenshot_get_caption (ss)); + ++num_screenshots_loaded; + } + + _set_state (self, num_screenshots_loaded, allow_fallback, is_online); +} + +/** + * gs_screenshot_carousel_get_has_screenshots: + * @self: a #GsScreenshotCarousel + * + * Get whether the carousel contains any screenshots. + * + * Returns: %TRUE if there are screenshots, %FALSE otherwise + * + * Since: 41 + */ +gboolean +gs_screenshot_carousel_get_has_screenshots (GsScreenshotCarousel *self) +{ + g_return_val_if_fail (GS_IS_SCREENSHOT_CAROUSEL (self), FALSE); + + return self->has_screenshots; +} + +static void +_carousel_navigate (AdwCarousel *carousel, AdwNavigationDirection direction) +{ + g_autoptr (GList) children = NULL; + GtkWidget *child; + gdouble position; + guint n_children; + + n_children = 0; + for (child = gtk_widget_get_first_child (GTK_WIDGET (carousel)); + child != NULL; + child = gtk_widget_get_next_sibling (child)) { + children = g_list_prepend (children, child); + n_children++; + } + children = g_list_reverse (children); + + position = adw_carousel_get_position (carousel); + position += (direction == ADW_NAVIGATION_DIRECTION_BACK) ? -1 : 1; + /* Round the position to the closest integer in the valid range. */ + position = round (position); + position = MIN (position, n_children - 1); + position = MAX (0, position); + + child = g_list_nth_data (children, position); + if (child) + adw_carousel_scroll_to (carousel, child, TRUE); +} + +static void +gs_screenshot_carousel_update_buttons (GsScreenshotCarousel *self) +{ + gdouble position = adw_carousel_get_position (ADW_CAROUSEL (self->carousel)); + guint n_pages = adw_carousel_get_n_pages (ADW_CAROUSEL (self->carousel)); + gtk_revealer_set_reveal_child (GTK_REVEALER (self->button_previous_revealer), position >= 0.5); + gtk_revealer_set_reveal_child (GTK_REVEALER (self->button_next_revealer), position < n_pages - 1.5); +} + +static void +gs_screenshot_carousel_notify_n_pages_cb (GsScreenshotCarousel *self) +{ + gs_screenshot_carousel_update_buttons (self); +} + +static void +gs_screenshot_carousel_notify_position_cb (GsScreenshotCarousel *self) +{ + gs_screenshot_carousel_update_buttons (self); +} + +static void +gs_screenshot_carousel_button_previous_clicked_cb (GsScreenshotCarousel *self) +{ + _carousel_navigate (ADW_CAROUSEL (self->carousel), + ADW_NAVIGATION_DIRECTION_BACK); +} + +static void +gs_screenshot_carousel_button_next_clicked_cb (GsScreenshotCarousel *self) +{ + _carousel_navigate (ADW_CAROUSEL (self->carousel), + ADW_NAVIGATION_DIRECTION_FORWARD); +} + +static void +gs_screenshot_carousel_get_property (GObject *object, guint prop_id, GValue *value, GParamSpec *pspec) +{ + GsScreenshotCarousel *self = GS_SCREENSHOT_CAROUSEL (object); + + switch ((GsScreenshotCarouselProperty) prop_id) { + case PROP_HAS_SCREENSHOTS: + g_value_set_boolean (value, self->has_screenshots); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_screenshot_carousel_set_property (GObject *object, guint prop_id, const GValue *value, GParamSpec *pspec) +{ + switch ((GsScreenshotCarouselProperty) prop_id) { + case PROP_HAS_SCREENSHOTS: + /* Read only */ + g_assert_not_reached (); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_screenshot_carousel_dispose (GObject *object) +{ + GsScreenshotCarousel *self = GS_SCREENSHOT_CAROUSEL (object); + + gs_widget_remove_all (GTK_WIDGET (self), NULL); + + g_clear_object (&self->session); + + G_OBJECT_CLASS (gs_screenshot_carousel_parent_class)->dispose (object); +} + +static void +gs_screenshot_carousel_class_init (GsScreenshotCarouselClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + + object_class->dispose = gs_screenshot_carousel_dispose; + object_class->get_property = gs_screenshot_carousel_get_property; + object_class->set_property = gs_screenshot_carousel_set_property; + + /** + * GsScreenshotCarousel:has-screenshots: + * + * Whether the carousel contains any screenshots. + * + * Since: 41 + */ + obj_props[PROP_HAS_SCREENSHOTS] = + g_param_spec_boolean ("has-screenshots", NULL, NULL, + FALSE, + G_PARAM_READABLE | G_PARAM_STATIC_STRINGS); + + g_object_class_install_properties (object_class, G_N_ELEMENTS (obj_props), obj_props); + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-screenshot-carousel.ui"); + + gtk_widget_class_bind_template_child (widget_class, GsScreenshotCarousel, button_next); + gtk_widget_class_bind_template_child (widget_class, GsScreenshotCarousel, button_next_revealer); + gtk_widget_class_bind_template_child (widget_class, GsScreenshotCarousel, button_previous); + gtk_widget_class_bind_template_child (widget_class, GsScreenshotCarousel, button_previous_revealer); + gtk_widget_class_bind_template_child (widget_class, GsScreenshotCarousel, carousel); + gtk_widget_class_bind_template_child (widget_class, GsScreenshotCarousel, carousel_indicator); + gtk_widget_class_bind_template_child (widget_class, GsScreenshotCarousel, stack); + + gtk_widget_class_bind_template_callback (widget_class, gs_screenshot_carousel_notify_n_pages_cb); + gtk_widget_class_bind_template_callback (widget_class, gs_screenshot_carousel_notify_position_cb); + gtk_widget_class_bind_template_callback (widget_class, gs_screenshot_carousel_button_previous_clicked_cb); + gtk_widget_class_bind_template_callback (widget_class, gs_screenshot_carousel_button_next_clicked_cb); + + gtk_widget_class_set_layout_manager_type (widget_class, GTK_TYPE_BIN_LAYOUT); + gtk_widget_class_set_css_name (widget_class, "screenshot-carousel"); +} + +static void +gs_screenshot_carousel_init (GsScreenshotCarousel *self) +{ + gtk_widget_init_template (GTK_WIDGET (self)); + + /* Disable scrolling through the carousel, as it’s typically used + * in application pages which are themselves scrollable. */ + adw_carousel_set_allow_scroll_wheel (ADW_CAROUSEL (self->carousel), FALSE); + + /* setup networking */ + self->session = gs_build_soup_session (); +} + +/** + * gs_screenshot_carousel_new: + * + * Create a new #GsScreenshotCarousel. + * + * Returns: (transfer full): a new #GsScreenshotCarousel + * + * Since: 41 + */ +GsScreenshotCarousel * +gs_screenshot_carousel_new (void) +{ + return GS_SCREENSHOT_CAROUSEL (g_object_new (GS_TYPE_SCREENSHOT_CAROUSEL, NULL)); +} diff --git a/src/gs-screenshot-carousel.h b/src/gs-screenshot-carousel.h new file mode 100644 index 0000000..460d88d --- /dev/null +++ b/src/gs-screenshot-carousel.h @@ -0,0 +1,27 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2021 Adrien Plazas <adrien.plazas@puri.sm> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <gtk/gtk.h> +#include "gs-app.h" + +G_BEGIN_DECLS + +#define GS_TYPE_SCREENSHOT_CAROUSEL (gs_screenshot_carousel_get_type ()) + +G_DECLARE_FINAL_TYPE (GsScreenshotCarousel, gs_screenshot_carousel, GS, SCREENSHOT_CAROUSEL, GtkWidget) + +GsScreenshotCarousel *gs_screenshot_carousel_new (void); +void gs_screenshot_carousel_load_screenshots (GsScreenshotCarousel *self, + GsApp *app, + gboolean is_online, + GCancellable *cancellable); +gboolean gs_screenshot_carousel_get_has_screenshots (GsScreenshotCarousel *self); + +G_END_DECLS diff --git a/src/gs-screenshot-carousel.ui b/src/gs-screenshot-carousel.ui new file mode 100644 index 0000000..acffc91 --- /dev/null +++ b/src/gs-screenshot-carousel.ui @@ -0,0 +1,139 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <requires lib="gtk+" version="3.10"/> + <requires lib="handy" version="1.0"/> + <template class="GsScreenshotCarousel" parent="GtkWidget"> + <property name="visible">False</property> + <child> + <object class="GtkStack" id="stack"> + + <child> + <object class="GtkStackPage"> + <property name="name">carousel</property> + <property name="child"> + <object class="GtkBox"> + <property name="orientation">vertical</property> + <style> + <class name="frame"/> + <class name="view"/> + </style> + <child> + <object class="GtkOverlay"> + <child> + <object class="AdwCarousel" id="carousel"> + <property name="vexpand">True</property> + <signal name="notify::n-pages" handler="gs_screenshot_carousel_notify_n_pages_cb" swapped="yes"/> + <signal name="notify::position" handler="gs_screenshot_carousel_notify_position_cb" swapped="yes"/> + </object> + </child> + <child type="overlay"> + <object class="GtkRevealer" id="button_previous_revealer"> + <property name="halign">start</property> + <property name="transition-type">crossfade</property> + <property name="valign">center</property> + <child> + <object class="GtkButton" id="button_previous"> + <property name="width-request">64</property> + <property name="height-request">64</property> + <property name="margin-top">9</property> + <property name="margin-bottom">9</property> + <property name="margin-start">9</property> + <property name="margin-end">9</property> + <property name="icon-name">go-previous-symbolic</property> + <signal name="clicked" handler="gs_screenshot_carousel_button_previous_clicked_cb" swapped="yes"/> + <accessibility> + <!-- Translators: This is the accessible description for a button to go to the previous screenshot in the screenshot carousel. --> + <property name="label" translatable="yes">Previous Screenshot</property> + </accessibility> + <style> + <class name="circular"/> + <class name="image-button"/> + <class name="osd"/> + </style> + </object> + </child> + </object> + </child> + <child type="overlay"> + <object class="GtkRevealer" id="button_next_revealer"> + <property name="halign">end</property> + <property name="transition-type">crossfade</property> + <property name="valign">center</property> + <child> + <object class="GtkButton" id="button_next"> + <property name="width-request">64</property> + <property name="height-request">64</property> + <property name="margin-top">9</property> + <property name="margin-bottom">9</property> + <property name="margin-start">9</property> + <property name="margin-end">9</property> + <property name="icon-name">go-next-symbolic</property> + <signal name="clicked" handler="gs_screenshot_carousel_button_next_clicked_cb" swapped="yes"/> + <accessibility> + <!-- Translators: This is the accessible description for a button to go to the next screenshot in the screenshot carousel. --> + <property name="label" translatable="yes">Next Screenshot</property> + </accessibility> + <style> + <class name="circular"/> + <class name="image-button"/> + <class name="osd"/> + </style> + </object> + </child> + </object> + </child> + </object> + </child> + <child> + <object class="AdwCarouselIndicatorDots" id="carousel_indicator"> + <property name="carousel">carousel</property> + </object> + </child> + </object> + </property> + </object> + </child> + + <child> + <object class="GtkStackPage"> + <property name="name">fallback</property> + <property name="child"> + <object class="GtkBox"> + <property name="orientation">vertical</property> + <property name="spacing">6</property> + <!-- Arbitrary size chosen to tile together at 16:9; + see https://blogs.gnome.org/hughsie/2014/07/02/blurry-screenshots-in-gnome-software/ --> + <property name="height_request">423</property> + <property name="hexpand">True</property> + <property name="halign">fill</property> + <style> + <class name="screenshot-image"/> + <class name="frame"/> + <class name="view"/> + </style> + <child> + <object class="GtkImage"> + <property name="pixel_size">64</property> + <property name="icon_name">camera-photo-symbolic</property> + <property name="valign">end</property> + <property name="vexpand">True</property> + </object> + </child> + <child> + <object class="GtkLabel"> + <property name="halign">center</property> + <property name="valign">start</property> + <property name="vexpand">True</property> + <property name="label" translatable="yes">No screenshot provided</property> + </object> + </child> + </object> + + </property> + </object> + </child> + + </object> + </child> + </template> +</interface> diff --git a/src/gs-screenshot-image.c b/src/gs-screenshot-image.c new file mode 100644 index 0000000..a2e3bb9 --- /dev/null +++ b/src/gs-screenshot-image.c @@ -0,0 +1,952 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013-2016 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2013 Matthias Clasen <mclasen@redhat.com> + * Copyright (C) 2014-2018 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include <glib/gi18n.h> + +#include "gs-screenshot-image.h" +#include "gs-common.h" + +#define SPINNER_TIMEOUT_SECS 2 + +struct _GsScreenshotImage +{ + GtkWidget parent_instance; + + AsScreenshot *screenshot; + GtkWidget *spinner; + GtkWidget *stack; + GtkWidget *box_error; + GtkWidget *image1; + GtkWidget *image2; + GtkWidget *video; + GtkWidget *label_error; + GSettings *settings; + SoupSession *session; + SoupMessage *message; + GCancellable *cancellable; + gchar *filename; + const gchar *current_image; + guint width; + guint height; + guint scale; + guint load_timeout_id; + gboolean showing_image; +}; + +G_DEFINE_TYPE (GsScreenshotImage, gs_screenshot_image, GTK_TYPE_WIDGET) + +enum { + SIGNAL_CLICKED, + SIGNAL_LAST +}; + +static guint signals [SIGNAL_LAST] = { 0 }; + +static void +gs_screenshot_image_clicked_cb (GtkGestureClick *gesture, + gint n_press, + gdouble x, + gdouble y, + gpointer user_data) +{ + GsScreenshotImage *self = user_data; + if (n_press == 1) + g_signal_emit (self, signals[SIGNAL_CLICKED], 0); +} + +AsScreenshot * +gs_screenshot_image_get_screenshot (GsScreenshotImage *ssimg) +{ + g_return_val_if_fail (GS_IS_SCREENSHOT_IMAGE (ssimg), NULL); + return ssimg->screenshot; +} + +static void +gs_screenshot_image_start_spinner (GsScreenshotImage *ssimg) +{ + gtk_widget_show (ssimg->spinner); + gtk_spinner_start (GTK_SPINNER (ssimg->spinner)); +} + +static void +gs_screenshot_image_stop_spinner (GsScreenshotImage *ssimg) +{ + gtk_spinner_stop (GTK_SPINNER (ssimg->spinner)); + gtk_widget_hide (ssimg->spinner); +} + +static void +gs_screenshot_image_set_error (GsScreenshotImage *ssimg, const gchar *message) +{ + gint width, height; + + gtk_stack_set_visible_child_name (GTK_STACK (ssimg->stack), "error"); + gtk_label_set_label (GTK_LABEL (ssimg->label_error), message); + gtk_widget_get_size_request (ssimg->stack, &width, &height); + if (width < 200) + gtk_widget_hide (ssimg->label_error); + else + gtk_widget_show (ssimg->label_error); + ssimg->showing_image = FALSE; + gs_screenshot_image_stop_spinner (ssimg); +} + +static void +as_screenshot_show_image (GsScreenshotImage *ssimg) +{ + if (as_screenshot_get_media_kind (ssimg->screenshot) == AS_SCREENSHOT_MEDIA_KIND_VIDEO) { + gtk_video_set_filename (GTK_VIDEO (ssimg->video), ssimg->filename); + ssimg->current_image = "video"; + } else { + g_autoptr(GdkPixbuf) pixbuf = NULL; + + /* no need to composite */ + if (ssimg->width == G_MAXUINT || ssimg->height == G_MAXUINT) { + pixbuf = gdk_pixbuf_new_from_file (ssimg->filename, NULL); + } else { + /* this is always going to have alpha */ + pixbuf = gdk_pixbuf_new_from_file_at_scale (ssimg->filename, + (gint) (ssimg->width * ssimg->scale), + (gint) (ssimg->height * ssimg->scale), + FALSE, NULL); + } + + /* show icon */ + if (g_strcmp0 (ssimg->current_image, "image1") == 0) { + if (pixbuf != NULL) + gtk_picture_set_pixbuf (GTK_PICTURE (ssimg->image2), pixbuf); + ssimg->current_image = "image2"; + } else { + if (pixbuf != NULL) + gtk_picture_set_pixbuf (GTK_PICTURE (ssimg->image1), pixbuf); + ssimg->current_image = "image1"; + } + } + + gtk_stack_set_visible_child_name (GTK_STACK (ssimg->stack), ssimg->current_image); + + gtk_widget_show (GTK_WIDGET (ssimg)); + ssimg->showing_image = TRUE; + + gs_screenshot_image_stop_spinner (ssimg); +} + +static GdkPixbuf * +gs_pixbuf_resample (GdkPixbuf *original, + guint width, + guint height, + gboolean blurred) +{ + g_autoptr(GdkPixbuf) pixbuf = NULL; + guint tmp_height; + guint tmp_width; + guint pixbuf_height; + guint pixbuf_width; + g_autoptr(GdkPixbuf) pixbuf_tmp = NULL; + + /* never set */ + if (original == NULL) + return NULL; + + /* 0 means 'default' */ + if (width == 0) + width = (guint) gdk_pixbuf_get_width (original); + if (height == 0) + height = (guint) gdk_pixbuf_get_height (original); + + /* don't do anything to an image with the correct size */ + pixbuf_width = (guint) gdk_pixbuf_get_width (original); + pixbuf_height = (guint) gdk_pixbuf_get_height (original); + if (width == pixbuf_width && height == pixbuf_height) + return g_object_ref (original); + + /* is the aspect ratio of the source perfectly 16:9 */ + if ((pixbuf_width / 16) * 9 == pixbuf_height) { + pixbuf = gdk_pixbuf_scale_simple (original, + (gint) width, (gint) height, + GDK_INTERP_HYPER); + if (blurred) + gs_utils_pixbuf_blur (pixbuf, 5, 3); + return g_steal_pointer (&pixbuf); + } + + /* create new 16:9 pixbuf with alpha padding */ + pixbuf = gdk_pixbuf_new (GDK_COLORSPACE_RGB, + TRUE, 8, + (gint) width, + (gint) height); + gdk_pixbuf_fill (pixbuf, 0x00000000); + /* check the ratio to see which property needs to be fitted and which needs + * to be reduced */ + if (pixbuf_width * 9 > pixbuf_height * 16) { + tmp_width = width; + tmp_height = width * pixbuf_height / pixbuf_width; + } else { + tmp_width = height * pixbuf_width / pixbuf_height; + tmp_height = height; + } + pixbuf_tmp = gdk_pixbuf_scale_simple (original, + (gint) tmp_width, + (gint) tmp_height, + GDK_INTERP_HYPER); + if (blurred) + gs_utils_pixbuf_blur (pixbuf_tmp, 5, 3); + gdk_pixbuf_copy_area (pixbuf_tmp, + 0, 0, /* of src */ + (gint) tmp_width, + (gint) tmp_height, + pixbuf, + (gint) (width - tmp_width) / 2, + (gint) (height - tmp_height) / 2); + return g_steal_pointer (&pixbuf); +} + +static gboolean +gs_pixbuf_save_filename (GdkPixbuf *pixbuf, + const gchar *filename, + guint width, + guint height, + GError **error) +{ + g_autoptr(GdkPixbuf) pb = NULL; + + /* resample & save pixbuf */ + pb = gs_pixbuf_resample (pixbuf, width, height, FALSE); + return gdk_pixbuf_save (pb, + filename, + "png", + error, + NULL); +} + +static void +gs_screenshot_image_show_blurred (GsScreenshotImage *ssimg, + const gchar *filename_thumb) +{ + g_autoptr(GdkPixbuf) pb_src = NULL; + g_autoptr(GdkPixbuf) pb = NULL; + + pb_src = gdk_pixbuf_new_from_file (filename_thumb, NULL); + if (pb_src == NULL) + return; + pb = gs_pixbuf_resample (pb_src, + ssimg->width * ssimg->scale, + ssimg->height * ssimg->scale, + TRUE /* blurred */); + if (pb == NULL) + return; + + if (g_strcmp0 (ssimg->current_image, "video") == 0) { + ssimg->current_image = "image1"; + gtk_stack_set_visible_child_name (GTK_STACK (ssimg->stack), ssimg->current_image); + } + + if (g_strcmp0 (ssimg->current_image, "image1") == 0) { + gtk_picture_set_pixbuf (GTK_PICTURE (ssimg->image1), pb); + } else { + gtk_picture_set_pixbuf (GTK_PICTURE (ssimg->image2), pb); + } +} + +static gboolean +gs_screenshot_image_save_downloaded_img (GsScreenshotImage *ssimg, + GdkPixbuf *pixbuf, + GError **error) +{ + gboolean ret; + const GPtrArray *images; + g_autoptr(GError) error_local = NULL; + g_autofree char *filename = NULL; + g_autofree char *size_dir = NULL; + g_autofree char *cache_kind = NULL; + g_autofree char *basename = NULL; + guint width = ssimg->width; + guint height = ssimg->height; + + ret = gs_pixbuf_save_filename (pixbuf, ssimg->filename, + ssimg->width * ssimg->scale, + ssimg->height * ssimg->scale, + error); + + if (!ret) + return FALSE; + + if (ssimg->screenshot == NULL) + return TRUE; + + images = as_screenshot_get_images (ssimg->screenshot); + if (images->len > 1) + return TRUE; + + if (width == AS_IMAGE_THUMBNAIL_WIDTH && + height == AS_IMAGE_THUMBNAIL_HEIGHT) { + width = AS_IMAGE_NORMAL_WIDTH; + height = AS_IMAGE_NORMAL_HEIGHT; + } else { + width = AS_IMAGE_THUMBNAIL_WIDTH; + height = AS_IMAGE_THUMBNAIL_HEIGHT; + } + + width *= ssimg->scale; + height *= ssimg->scale; + basename = g_path_get_basename (ssimg->filename); + size_dir = g_strdup_printf ("%ux%u", width, height); + cache_kind = g_build_filename ("screenshots", size_dir, NULL); + filename = gs_utils_get_cache_filename (cache_kind, basename, + GS_UTILS_CACHE_FLAG_WRITEABLE | + GS_UTILS_CACHE_FLAG_CREATE_DIRECTORY, + &error_local); + + if (filename == NULL) { + /* if we cannot get a cache filename, warn about that but do not + * set a user's visible error because this is a complementary + * operation */ + g_warning ("Failed to get cache filename for counterpart " + "screenshot '%s' in folder '%s': %s", basename, + cache_kind, error_local->message); + return TRUE; + } + + ret = gs_pixbuf_save_filename (pixbuf, filename, + width, height, + &error_local); + + if (!ret) { + /* if we cannot save this screenshot, warn about that but do not + * set a user's visible error because this is a complementary + * operation */ + g_warning ("Failed to save screenshot '%s': %s", filename, + error_local->message); + } + + return TRUE; +} + +static void +#if SOUP_CHECK_VERSION(3, 0, 0) +gs_screenshot_image_complete_cb (GObject *source_object, + GAsyncResult *result, + gpointer user_data) +#else +gs_screenshot_image_complete_cb (SoupSession *session, + SoupMessage *msg, + gpointer user_data) +#endif +{ + g_autoptr(GsScreenshotImage) ssimg = GS_SCREENSHOT_IMAGE (user_data); + gboolean ret; + g_autoptr(GError) error = NULL; + g_autoptr(GdkPixbuf) pixbuf = NULL; + g_autoptr(GInputStream) stream = NULL; + guint status_code; + +#if SOUP_CHECK_VERSION(3, 0, 0) + SoupMessage *msg; + + stream = soup_session_send_finish (SOUP_SESSION (source_object), result, &error); + if (stream == NULL) { + if (!g_error_matches (error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) { + g_warning ("Failed to download screenshot: %s", error->message); + /* Reset the width request, thus the image shrinks when the window width is small */ + gtk_widget_set_size_request (ssimg->stack, -1, (gint) ssimg->height); + gs_screenshot_image_stop_spinner (ssimg); + gs_screenshot_image_set_error (ssimg, _("Screenshot not found")); + } + return; + } + + msg = soup_session_get_async_result_message (SOUP_SESSION (source_object), result); + status_code = soup_message_get_status (msg); +#else + status_code = msg->status_code; +#endif + if (ssimg->load_timeout_id) { + g_source_remove (ssimg->load_timeout_id); + ssimg->load_timeout_id = 0; + } + + /* return immediately if the message was cancelled or if we're in destruction */ +#if SOUP_CHECK_VERSION(3, 0, 0) + if (ssimg->session == NULL) +#else + if (status_code == SOUP_STATUS_CANCELLED || ssimg->session == NULL) +#endif + return; + + /* Reset the width request, thus the image shrinks when the window width is small */ + gtk_widget_set_size_request (ssimg->stack, -1, (gint) ssimg->height); + + if (status_code == SOUP_STATUS_NOT_MODIFIED) { + g_debug ("screenshot has not been modified"); + as_screenshot_show_image (ssimg); + gs_screenshot_image_stop_spinner (ssimg); + return; + } + if (status_code != SOUP_STATUS_OK) { + /* Ignore failures due to being offline */ +#if SOUP_CHECK_VERSION(3, 0, 0) + if (!g_error_matches (error, G_IO_ERROR, G_IO_ERROR_HOST_UNREACHABLE) && + !g_error_matches (error, G_IO_ERROR, G_IO_ERROR_NETWORK_UNREACHABLE)) { +#else + if (status_code != SOUP_STATUS_CANT_RESOLVE) { +#endif + const gchar *reason_phrase; +#if SOUP_CHECK_VERSION(3, 0, 0) + reason_phrase = soup_message_get_reason_phrase (msg); +#else + reason_phrase = msg->reason_phrase; +#endif + g_warning ("Result of screenshot downloading attempt with " + "status code '%u': %s", status_code, + reason_phrase); + } + gs_screenshot_image_stop_spinner (ssimg); + /* if we're already showing an image, then don't set the error + * as having an image (even if outdated) is better */ + if (ssimg->showing_image) + return; + /* TRANSLATORS: this is when we try to download a screenshot and + * we get back 404 */ + gs_screenshot_image_set_error (ssimg, _("Screenshot not found")); + return; + } + +#if !SOUP_CHECK_VERSION(3, 0, 0) + /* create a buffer with the data */ + stream = g_memory_input_stream_new_from_data (msg->response_body->data, + msg->response_body->length, + NULL); + if (stream == NULL) { + gs_screenshot_image_stop_spinner (ssimg); + return; + } +#endif + + /* load the image */ + pixbuf = gdk_pixbuf_new_from_stream (stream, NULL, NULL); + if (pixbuf == NULL) { + /* TRANSLATORS: possibly image file corrupt or not an image */ + gs_screenshot_image_set_error (ssimg, _("Failed to load image")); + return; + } + + /* is image size destination size unknown or exactly the correct size */ + if (ssimg->width == G_MAXUINT || ssimg->height == G_MAXUINT || + (ssimg->width * ssimg->scale == (guint) gdk_pixbuf_get_width (pixbuf) && + ssimg->height * ssimg->scale == (guint) gdk_pixbuf_get_height (pixbuf))) { + ret = gs_pixbuf_save_filename (pixbuf, ssimg->filename, + gdk_pixbuf_get_width (pixbuf), + gdk_pixbuf_get_height (pixbuf), + &error); + if (!ret) { + gs_screenshot_image_set_error (ssimg, error->message); + return; + } + } else if (!gs_screenshot_image_save_downloaded_img (ssimg, pixbuf, + &error)) { + gs_screenshot_image_set_error (ssimg, error->message); + return; + } + + /* got image, so show */ + as_screenshot_show_image (ssimg); +} + +void +gs_screenshot_image_set_screenshot (GsScreenshotImage *ssimg, + AsScreenshot *screenshot) +{ + g_return_if_fail (GS_IS_SCREENSHOT_IMAGE (ssimg)); + g_return_if_fail (AS_IS_SCREENSHOT (screenshot)); + + if (ssimg->screenshot == screenshot) + return; + if (ssimg->screenshot) + g_object_unref (ssimg->screenshot); + ssimg->screenshot = g_object_ref (screenshot); + + /* we reset this flag here too because it referred to the previous + * screenshot, and thus avoids potentially assuming that the new + * screenshot is shown when it is the previous one instead */ + ssimg->showing_image = FALSE; +} + +void +gs_screenshot_image_set_size (GsScreenshotImage *ssimg, + guint width, guint height) +{ + g_return_if_fail (GS_IS_SCREENSHOT_IMAGE (ssimg)); + g_return_if_fail (width != 0); + g_return_if_fail (height != 0); + + ssimg->width = width; + ssimg->height = height; + /* Reset the width request, thus the image shrinks when the window width is small */ + gtk_widget_set_size_request (ssimg->stack, -1, (gint) height); +} + +static gchar * +gs_screenshot_get_cachefn_for_url (const gchar *url) +{ + g_autofree gchar *basename = NULL; + g_autofree gchar *checksum = NULL; + checksum = g_compute_checksum_for_string (G_CHECKSUM_SHA256, url, -1); + basename = g_path_get_basename (url); + return g_strdup_printf ("%s-%s", checksum, basename); +} + +static void +gs_screenshot_soup_msg_set_modified_request (SoupMessage *msg, GFile *file) +{ +#ifndef GLIB_VERSION_2_62 + GTimeVal time_val; +#endif + g_autoptr(GDateTime) date_time = NULL; + g_autoptr(GFileInfo) info = NULL; + g_autofree gchar *mod_date = NULL; + + info = g_file_query_info (file, + G_FILE_ATTRIBUTE_TIME_MODIFIED, + G_FILE_QUERY_INFO_NONE, + NULL, + NULL); + if (info == NULL) + return; +#ifdef GLIB_VERSION_2_62 + date_time = g_file_info_get_modification_date_time (info); +#else + g_file_info_get_modification_time (info, &time_val); + date_time = g_date_time_new_from_timeval_local (&time_val); +#endif + mod_date = g_date_time_format (date_time, "%a, %d %b %Y %H:%M:%S %Z"); + soup_message_headers_append ( +#if SOUP_CHECK_VERSION(3, 0, 0) + soup_message_get_request_headers (msg), +#else + msg->request_headers, +#endif + "If-Modified-Since", + mod_date); +} + +static gboolean +gs_screenshot_show_spinner_cb (gpointer user_data) +{ + GsScreenshotImage *ssimg = user_data; + + ssimg->load_timeout_id = 0; + gs_screenshot_image_start_spinner (ssimg); + + return FALSE; +} + +static const gchar * +gs_screenshot_image_get_url (GsScreenshotImage *ssimg) +{ + const gchar *url = NULL; + + /* load an image according to the scale factor */ + ssimg->scale = (guint) gtk_widget_get_scale_factor (GTK_WIDGET (ssimg)); + + if (as_screenshot_get_media_kind (ssimg->screenshot) == AS_SCREENSHOT_MEDIA_KIND_VIDEO) { + GPtrArray *videos; + AsVideo *best_video = NULL; + gint64 best_size = G_MAXINT64; + gint64 wh = (gint64) ssimg->width * ssimg->scale * ssimg->height * ssimg->scale; + + videos = as_screenshot_get_videos (ssimg->screenshot); + for (guint i = 0; videos != NULL && i < videos->len; i++) { + AsVideo *adept = g_ptr_array_index (videos, i); + gint64 tmp; + + tmp = ABS (wh - (gint64) (as_video_get_width (adept) * as_video_get_height (adept))); + if (tmp < best_size) { + best_size = tmp; + best_video = adept; + if (!tmp) + break; + } + } + + if (best_video) + url = as_video_get_url (best_video); + } else if (as_screenshot_get_media_kind (ssimg->screenshot) == AS_SCREENSHOT_MEDIA_KIND_IMAGE) { + AsImage *im; + + im = as_screenshot_get_image (ssimg->screenshot, + ssimg->width * ssimg->scale, + ssimg->height * ssimg->scale); + + /* if we've failed to load a HiDPI image, fallback to LoDPI */ + if (im == NULL && ssimg->scale > 1) { + ssimg->scale = 1; + im = as_screenshot_get_image (ssimg->screenshot, + ssimg->width, + ssimg->height); + } + + if (im) + url = as_image_get_url (im); + } + + return url; +} + +static void +gs_screenshot_video_downloaded_cb (GObject *source_object, + GAsyncResult *result, + gpointer user_data) +{ + g_autoptr(GsScreenshotImage) ssimg = user_data; + g_autoptr(GError) error = NULL; + + if (gs_download_file_finish (ssimg->session, result, &error) || + g_error_matches (error, GS_DOWNLOAD_ERROR, GS_DOWNLOAD_ERROR_NOT_MODIFIED)) { + gs_screenshot_image_stop_spinner (ssimg); + as_screenshot_show_image (ssimg); + + g_clear_object (&ssimg->cancellable); + } else if (!g_error_matches (error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) { + g_debug ("Failed to download screenshot video: %s", error->message); + /* Reset the width request, thus the image shrinks when the window width is small */ + gtk_widget_set_size_request (ssimg->stack, -1, (gint) ssimg->height); + gs_screenshot_image_stop_spinner (ssimg); + gs_screenshot_image_set_error (ssimg, _("Screenshot not found")); + } +} + +void +gs_screenshot_image_load_async (GsScreenshotImage *ssimg, + GCancellable *cancellable) +{ + const gchar *url; + g_autofree gchar *basename = NULL; + g_autofree gchar *cache_kind = NULL; + g_autofree gchar *cachefn_thumb = NULL; + g_autofree gchar *sizedir = NULL; + g_autoptr(GUri) base_uri = NULL; + + g_return_if_fail (GS_IS_SCREENSHOT_IMAGE (ssimg)); + + g_return_if_fail (AS_IS_SCREENSHOT (ssimg->screenshot)); + g_return_if_fail (ssimg->width != 0); + g_return_if_fail (ssimg->height != 0); + + /* Reset the width request, thus the image shrinks when the window width is small */ + gtk_widget_set_size_request (ssimg->stack, -1, (gint) ssimg->height); + + url = gs_screenshot_image_get_url (ssimg); + if (url == NULL) { + /* TRANSLATORS: this is when we request a screenshot size that + * the generator did not create or the parser did not add */ + gs_screenshot_image_set_error (ssimg, _("Screenshot size not found")); + return; + } + + /* check if the URL points to a local file */ + if (g_str_has_prefix (url, "file://")) { + g_free (ssimg->filename); + ssimg->filename = g_strdup (url + 7); + if (g_file_test (ssimg->filename, G_FILE_TEST_EXISTS)) { + as_screenshot_show_image (ssimg); + return; + } + } + + basename = gs_screenshot_get_cachefn_for_url (url); + if (ssimg->width == G_MAXUINT || ssimg->height == G_MAXUINT) { + sizedir = g_strdup ("unknown"); + } else { + sizedir = g_strdup_printf ("%ux%u", ssimg->width * ssimg->scale, ssimg->height * ssimg->scale); + } + cache_kind = g_build_filename ("screenshots", sizedir, NULL); + g_free (ssimg->filename); + ssimg->filename = gs_utils_get_cache_filename (cache_kind, + basename, + GS_UTILS_CACHE_FLAG_NONE, + NULL); + g_assert (ssimg->filename != NULL); + + /* does local file already exist and has recently been downloaded */ + if (g_file_test (ssimg->filename, G_FILE_TEST_EXISTS)) { + guint64 age_max; + g_autoptr(GFile) file = NULL; + + /* show the image we have in cache while we're checking for the + * new screenshot (which probably won't have changed) */ + as_screenshot_show_image (ssimg); + + /* verify the cache age against the maximum allowed */ + age_max = g_settings_get_uint (ssimg->settings, + "screenshot-cache-age-maximum"); + file = g_file_new_for_path (ssimg->filename); + /* image new enough, not re-requesting from server */ + if (age_max > 0 && gs_utils_get_file_age (file) < age_max) + return; + } + + /* if we're not showing a full-size image, we try loading a blurred + * smaller version of it straight away */ + if (!ssimg->showing_image && + as_screenshot_get_media_kind (ssimg->screenshot) == AS_SCREENSHOT_MEDIA_KIND_IMAGE && + ssimg->width > AS_IMAGE_THUMBNAIL_WIDTH && + ssimg->height > AS_IMAGE_THUMBNAIL_HEIGHT) { + const gchar *url_thumb; + g_autofree gchar *basename_thumb = NULL; + g_autofree gchar *cache_kind_thumb = NULL; + AsImage *im; + im = as_screenshot_get_image (ssimg->screenshot, + AS_IMAGE_THUMBNAIL_WIDTH * ssimg->scale, + AS_IMAGE_THUMBNAIL_HEIGHT * ssimg->scale); + url_thumb = as_image_get_url (im); + basename_thumb = gs_screenshot_get_cachefn_for_url (url_thumb); + cache_kind_thumb = g_build_filename ("screenshots", "112x63", NULL); + cachefn_thumb = gs_utils_get_cache_filename (cache_kind_thumb, + basename_thumb, + GS_UTILS_CACHE_FLAG_NONE, + NULL); + g_assert (cachefn_thumb != NULL); + if (g_file_test (cachefn_thumb, G_FILE_TEST_EXISTS)) + gs_screenshot_image_show_blurred (ssimg, cachefn_thumb); + } + + /* re-request the cache filename, which might be different as it needs + * to be writable this time */ + g_free (ssimg->filename); + ssimg->filename = gs_utils_get_cache_filename (cache_kind, + basename, + GS_UTILS_CACHE_FLAG_WRITEABLE | + GS_UTILS_CACHE_FLAG_CREATE_DIRECTORY, + NULL); + if (ssimg->filename == NULL) { + /* TRANSLATORS: this is when we try create the cache directory + * but we were out of space or permission was denied */ + gs_screenshot_image_set_error (ssimg, _("Could not create cache")); + return; + } + + /* download file */ + g_debug ("downloading %s to %s", url, ssimg->filename); + base_uri = g_uri_parse (url, SOUP_HTTP_URI_FLAGS, NULL); + if (base_uri == NULL || + (g_strcmp0 (g_uri_get_scheme (base_uri), "http") != 0 && + g_strcmp0 (g_uri_get_scheme (base_uri), "https") != 0) || + g_uri_get_host (base_uri) == NULL || + g_uri_get_path (base_uri) == NULL) { + /* TRANSLATORS: this is when we try to download a screenshot + * that was not a valid URL */ + gs_screenshot_image_set_error (ssimg, _("Screenshot not valid")); + return; + } + + if (ssimg->load_timeout_id) { + g_source_remove (ssimg->load_timeout_id); + ssimg->load_timeout_id = 0; + } + + /* cancel any previous messages */ + if (ssimg->cancellable != NULL) { + g_cancellable_cancel (ssimg->cancellable); + g_clear_object (&ssimg->cancellable); + } + + if (ssimg->message != NULL) { +#if !SOUP_CHECK_VERSION(3, 0, 0) + soup_session_cancel_message (ssimg->session, + ssimg->message, + SOUP_STATUS_CANCELLED); +#endif + g_clear_object (&ssimg->message); + } + + if (as_screenshot_get_media_kind (ssimg->screenshot) == AS_SCREENSHOT_MEDIA_KIND_VIDEO) { + g_autofree gchar *uri_str = g_uri_to_string (base_uri); + g_autoptr(GFile) output_file = NULL; + + ssimg->cancellable = g_cancellable_new (); + output_file = g_file_new_for_path (ssimg->filename); + + /* Make sure the spinner takes approximately the size the screenshot will use */ + gtk_widget_set_size_request (ssimg->stack, (gint) ssimg->width, (gint) ssimg->height); + + gs_download_file_async (ssimg->session, uri_str, output_file, G_PRIORITY_DEFAULT, NULL, NULL, + ssimg->cancellable, gs_screenshot_video_downloaded_cb, g_object_ref (ssimg)); + + return; + } + +#if SOUP_CHECK_VERSION(3, 0, 0) + ssimg->message = soup_message_new_from_uri (SOUP_METHOD_GET, base_uri); +#else + { + g_autofree gchar *uri_str = g_uri_to_string (base_uri); + ssimg->message = soup_message_new (SOUP_METHOD_GET, uri_str); + } +#endif + if (ssimg->message == NULL) { + /* TRANSLATORS: this is when networking is not available */ + gs_screenshot_image_set_error (ssimg, _("Screenshot not available")); + return; + } + + /* not all servers support If-Modified-Since, but worst case we just + * re-download the entire file again every 30 days */ + if (g_file_test (ssimg->filename, G_FILE_TEST_EXISTS)) { + g_autoptr(GFile) file = g_file_new_for_path (ssimg->filename); + gs_screenshot_soup_msg_set_modified_request (ssimg->message, file); + } + + ssimg->load_timeout_id = g_timeout_add_seconds (SPINNER_TIMEOUT_SECS, + gs_screenshot_show_spinner_cb, ssimg); + + /* send async */ +#if SOUP_CHECK_VERSION(3, 0, 0) + ssimg->cancellable = g_cancellable_new (); + soup_session_send_async (ssimg->session, ssimg->message, G_PRIORITY_DEFAULT, ssimg->cancellable, + gs_screenshot_image_complete_cb, g_object_ref (ssimg)); +#else + soup_session_queue_message (ssimg->session, + g_object_ref (ssimg->message) /* transfer full */, + gs_screenshot_image_complete_cb, + g_object_ref (ssimg)); +#endif +} + +gboolean +gs_screenshot_image_is_showing (GsScreenshotImage *ssimg) +{ + return ssimg->showing_image; +} + +void +gs_screenshot_image_set_description (GsScreenshotImage *ssimg, + const gchar *description) +{ + gtk_accessible_update_property (GTK_ACCESSIBLE (ssimg->image1), + GTK_ACCESSIBLE_PROPERTY_DESCRIPTION, description, + -1); + gtk_accessible_update_property (GTK_ACCESSIBLE (ssimg->image2), + GTK_ACCESSIBLE_PROPERTY_DESCRIPTION, description, + -1); +} + +static void +gs_screenshot_image_dispose (GObject *object) +{ + GsScreenshotImage *ssimg = GS_SCREENSHOT_IMAGE (object); + + if (ssimg->load_timeout_id) { + g_source_remove (ssimg->load_timeout_id); + ssimg->load_timeout_id = 0; + } + + if (ssimg->cancellable != NULL) { + g_cancellable_cancel (ssimg->cancellable); + g_clear_object (&ssimg->cancellable); + } + + if (ssimg->message != NULL) { +#if !SOUP_CHECK_VERSION(3, 0, 0) + soup_session_cancel_message (ssimg->session, + ssimg->message, + SOUP_STATUS_CANCELLED); +#endif + g_clear_object (&ssimg->message); + } + gs_widget_remove_all (GTK_WIDGET (ssimg), NULL); + g_clear_object (&ssimg->screenshot); + g_clear_object (&ssimg->session); + g_clear_object (&ssimg->settings); + + g_clear_pointer (&ssimg->filename, g_free); + + G_OBJECT_CLASS (gs_screenshot_image_parent_class)->dispose (object); +} + +static void +gs_screenshot_image_init (GsScreenshotImage *ssimg) +{ + GtkGesture *gesture; + + ssimg->settings = g_settings_new ("org.gnome.software"); + ssimg->showing_image = FALSE; + + gtk_widget_init_template (GTK_WIDGET (ssimg)); + + gesture = gtk_gesture_click_new (); + g_signal_connect_object (gesture, "released", + G_CALLBACK (gs_screenshot_image_clicked_cb), ssimg, 0); + gtk_widget_add_controller (GTK_WIDGET (ssimg), GTK_EVENT_CONTROLLER (gesture)); +} + +static void +gs_screenshot_image_snapshot (GtkWidget *widget, + GtkSnapshot *snapshot) +{ + GtkStyleContext *context; + + context = gtk_widget_get_style_context (widget); + gtk_snapshot_render_frame (snapshot, + context, + 0.0, 0.0, + gtk_widget_get_width (widget), + gtk_widget_get_height (widget)); + + GTK_WIDGET_CLASS (gs_screenshot_image_parent_class)->snapshot (widget, snapshot); +} + +static void +gs_screenshot_image_class_init (GsScreenshotImageClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + + object_class->dispose = gs_screenshot_image_dispose; + + widget_class->snapshot = gs_screenshot_image_snapshot; + + gtk_widget_class_set_template_from_resource (widget_class, + "/org/gnome/Software/gs-screenshot-image.ui"); + gtk_widget_class_set_layout_manager_type (widget_class, GTK_TYPE_BIN_LAYOUT); + gtk_widget_class_set_accessible_role (widget_class, GTK_ACCESSIBLE_ROLE_IMG); + + gtk_widget_class_bind_template_child (widget_class, GsScreenshotImage, spinner); + gtk_widget_class_bind_template_child (widget_class, GsScreenshotImage, stack); + gtk_widget_class_bind_template_child (widget_class, GsScreenshotImage, image1); + gtk_widget_class_bind_template_child (widget_class, GsScreenshotImage, image2); + gtk_widget_class_bind_template_child (widget_class, GsScreenshotImage, video); + gtk_widget_class_bind_template_child (widget_class, GsScreenshotImage, box_error); + gtk_widget_class_bind_template_child (widget_class, GsScreenshotImage, label_error); + + /** + * GsScreenshotImage::clicked: + * + * Emitted when the screenshot is clicked. + * + * Since: 43 + */ + signals [SIGNAL_CLICKED] = + g_signal_new ("clicked", + G_TYPE_FROM_CLASS (object_class), G_SIGNAL_RUN_LAST, + 0, + NULL, NULL, g_cclosure_marshal_VOID__VOID, + G_TYPE_NONE, 0); +} + +GtkWidget * +gs_screenshot_image_new (SoupSession *session) +{ + GsScreenshotImage *ssimg; + ssimg = g_object_new (GS_TYPE_SCREENSHOT_IMAGE, NULL); + ssimg->session = g_object_ref (session); + return GTK_WIDGET (ssimg); +} diff --git a/src/gs-screenshot-image.h b/src/gs-screenshot-image.h new file mode 100644 index 0000000..418db4c --- /dev/null +++ b/src/gs-screenshot-image.h @@ -0,0 +1,38 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2013 Matthias Clasen <mclasen@redhat.com> + * Copyright (C) 2015 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <gtk/gtk.h> +#include <libsoup/soup.h> + +#include "gnome-software-private.h" + +G_BEGIN_DECLS + +#define GS_TYPE_SCREENSHOT_IMAGE (gs_screenshot_image_get_type ()) + +G_DECLARE_FINAL_TYPE (GsScreenshotImage, gs_screenshot_image, GS, SCREENSHOT_IMAGE, GtkWidget) + +GtkWidget *gs_screenshot_image_new (SoupSession *session); + +AsScreenshot *gs_screenshot_image_get_screenshot (GsScreenshotImage *ssimg); +void gs_screenshot_image_set_screenshot (GsScreenshotImage *ssimg, + AsScreenshot *screenshot); +void gs_screenshot_image_set_size (GsScreenshotImage *ssimg, + guint width, + guint height); +void gs_screenshot_image_load_async (GsScreenshotImage *ssimg, + GCancellable *cancellable); +gboolean gs_screenshot_image_is_showing (GsScreenshotImage *ssimg); +void gs_screenshot_image_set_description (GsScreenshotImage *ssimg, + const gchar *description); + +G_END_DECLS diff --git a/src/gs-screenshot-image.ui b/src/gs-screenshot-image.ui new file mode 100644 index 0000000..a108ba7 --- /dev/null +++ b/src/gs-screenshot-image.ui @@ -0,0 +1,99 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <!-- interface-requires gtk+ 3.10 --> + <template class="GsScreenshotImage" parent="GtkWidget"> + <accessibility> + <property name="label" translatable="yes">Screenshot</property> + </accessibility> + <style> + <class name="screenshot-image"/> + </style> + <child> + <object class="GtkOverlay" id="overlay"> + <property name="halign">fill</property> + <property name="valign">fill</property> + <child type="overlay"> + <object class="GtkSpinner" id="spinner"> + <property name="width_request">32</property> + <property name="height_request">32</property> + <property name="halign">center</property> + <property name="valign">center</property> + <property name="hexpand">True</property> + <property name="vexpand">True</property> + <style> + <class name="fade-in"/> + </style> + </object> + </child> + <child> + <object class="GtkStack" id="stack"> + <property name="transition-type">crossfade</property> + + <child> + <object class="GtkStackPage"> + <property name="name">image1</property> + <property name="child"> + <object class="GtkPicture" id="image1"> + <style> + <class name="image1"/> + </style> + </object> + </property> + </object> + </child> + + <child> + <object class="GtkStackPage"> + <property name="name">image2</property> + <property name="child"> + <object class="GtkPicture" id="image2"> + <style> + <class name="image2"/> + </style> + </object> + </property> + </object> + </child> + + <child> + <object class="GtkStackPage"> + <property name="name">video</property> + <property name="child"> + <object class="GtkVideo" id="video"> + <style> + <class name="video"/> + </style> + </object> + </property> + </object> + </child> + + <child> + <object class="GtkStackPage"> + <property name="name">error</property> + <property name="child"> + <object class="GtkBox" id="box_error"> + <property name="halign">center</property> + <property name="valign">center</property> + <property name="orientation">vertical</property> + <property name="spacing">4</property> + <child> + <object class="GtkImage" id="image_error"> + <property name="icon-name">dialog-error-symbolic</property> + <property name="pixel-size">48</property> + </object> + </child> + <child> + <object class="GtkLabel" id="label_error"/> + </child> + </object> + </property> + </object> + </child> + + </object> + </child> + </object> + </child> + </template> +</interface> diff --git a/src/gs-search-page.c b/src/gs-search-page.c new file mode 100644 index 0000000..3b54c2c --- /dev/null +++ b/src/gs-search-page.c @@ -0,0 +1,566 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013-2016 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2014-2018 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include <string.h> +#include <glib/gi18n.h> + +#include "gs-search-page.h" +#include "gs-shell.h" +#include "gs-common.h" +#include "gs-app-row.h" + +#define GS_SEARCH_PAGE_MAX_RESULTS 50 + +struct _GsSearchPage +{ + GsPage parent_instance; + + GsPluginLoader *plugin_loader; + GCancellable *cancellable; + GCancellable *search_cancellable; + GtkSizeGroup *sizegroup_name; + GtkSizeGroup *sizegroup_button_label; + GtkSizeGroup *sizegroup_button_image; + GsShell *shell; + gchar *appid_to_show; + gchar *value; + guint waiting_id; + guint max_results; + guint stamp; + gboolean changed; + + GtkWidget *list_box_search; + GtkWidget *scrolledwindow_search; + GtkWidget *spinner_search; + GtkWidget *stack_search; +}; + +G_DEFINE_TYPE (GsSearchPage, gs_search_page, GS_TYPE_PAGE) + +typedef enum { + PROP_VADJUSTMENT = 1, +} GsSearchPageProperty; + +static void +gs_search_page_app_row_clicked_cb (GsAppRow *app_row, + GsSearchPage *self) +{ + GsApp *app; + app = gs_app_row_get_app (app_row); + if (gs_app_get_state (app) == GS_APP_STATE_AVAILABLE) + gs_page_install_app (GS_PAGE (self), app, GS_SHELL_INTERACTION_FULL, + self->cancellable); + else if (gs_app_get_state (app) == GS_APP_STATE_INSTALLED) + gs_page_remove_app (GS_PAGE (self), app, self->cancellable); + else if (gs_app_get_state (app) == GS_APP_STATE_UNAVAILABLE) { + if (gs_app_get_url_missing (app) == NULL) { + gs_page_install_app (GS_PAGE (self), app, + GS_SHELL_INTERACTION_FULL, + self->cancellable); + return; + } + gs_shell_show_uri (self->shell, + gs_app_get_url_missing (app)); + } +} + +static void +gs_search_page_waiting_cancel (GsSearchPage *self) +{ + if (self->waiting_id > 0) + g_source_remove (self->waiting_id); + self->waiting_id = 0; +} + +static void +gs_search_page_app_to_show_created_cb (GObject *source_object, + GAsyncResult *result, + gpointer user_data) +{ + GsSearchPage *self = user_data; + g_autoptr(GsApp) app = NULL; + g_autoptr(GError) error = NULL; + + app = gs_plugin_loader_app_create_finish (GS_PLUGIN_LOADER (source_object), result, &error); + if (app != NULL) { + g_return_if_fail (GS_IS_SEARCH_PAGE (self)); + + gs_shell_show_app (self->shell, app); + } +} + +typedef struct { + GsSearchPage *self; + guint stamp; +} GetSearchData; + +static void +gs_search_page_get_search_cb (GObject *source_object, + GAsyncResult *res, + gpointer user_data) +{ + guint i; + g_autofree GetSearchData *search_data = user_data; + GsApp *app; + GsSearchPage *self = search_data->self; + GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (source_object); + GtkWidget *app_row; + g_autoptr(GError) error = NULL; + g_autoptr(GsAppList) list = NULL; + + /* different stamps means another search had been started before this one finished */ + if (search_data->stamp != self->stamp) + return; + + /* don't do the delayed spinner */ + gs_search_page_waiting_cancel (self); + + 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_debug ("search cancelled"); + return; + } + g_warning ("failed to get search apps: %s", error->message); + gtk_spinner_stop (GTK_SPINNER (self->spinner_search)); + if (self->value && self->value[0]) + gtk_stack_set_visible_child_name (GTK_STACK (self->stack_search), "no-results"); + else + gtk_stack_set_visible_child_name (GTK_STACK (self->stack_search), "no-search"); + return; + } + + /* no results */ + if (gs_app_list_length (list) == 0) { + g_debug ("no search results to show"); + if (self->value && self->value[0]) + gtk_stack_set_visible_child_name (GTK_STACK (self->stack_search), "no-results"); + else + gtk_stack_set_visible_child_name (GTK_STACK (self->stack_search), "no-search"); + return; + } + + /* remove old entries */ + gs_widget_remove_all (self->list_box_search, (GsRemoveFunc) gtk_list_box_remove); + + gtk_spinner_stop (GTK_SPINNER (self->spinner_search)); + gtk_stack_set_visible_child_name (GTK_STACK (self->stack_search), "results"); + for (i = 0; i < gs_app_list_length (list); i++) { + app = gs_app_list_index (list, i); + app_row = gs_app_row_new (app); + gs_app_row_set_show_rating (GS_APP_ROW (app_row), TRUE); + g_signal_connect (app_row, "button-clicked", + G_CALLBACK (gs_search_page_app_row_clicked_cb), + self); + gtk_list_box_append (GTK_LIST_BOX (self->list_box_search), app_row); + gs_app_row_set_size_groups (GS_APP_ROW (app_row), + self->sizegroup_name, + self->sizegroup_button_label, + self->sizegroup_button_image); + gtk_widget_show (app_row); + } + + /* too many results */ + if (gs_app_list_has_flag (list, GS_APP_LIST_FLAG_IS_TRUNCATED)) { + GtkStyleContext *context; + GtkWidget *w = gtk_label_new (NULL); + g_autofree gchar *str = NULL; + + /* TRANSLATORS: this is when there are too many search results + * to show in in the search page */ + str = g_strdup_printf (ngettext("%u more match", + "%u more matches", + gs_app_list_get_size_peak (list) - gs_app_list_length (list)), + gs_app_list_get_size_peak (list) - gs_app_list_length (list)); + gtk_label_set_label (GTK_LABEL (w), str); + gtk_widget_set_margin_bottom (w, 20); + gtk_widget_set_margin_top (w, 20); + gtk_widget_set_margin_start (w, 20); + gtk_widget_set_margin_end (w, 20); + context = gtk_widget_get_style_context (w); + gtk_style_context_add_class (context, "dim-label"); + gtk_list_box_append (GTK_LIST_BOX (self->list_box_search), w); + gtk_widget_show (w); + } else { + /* reset to default */ + self->max_results = GS_SEARCH_PAGE_MAX_RESULTS; + } + + if (self->appid_to_show != NULL) { + g_autoptr (GsApp) a = NULL; + if (as_utils_data_id_valid (self->appid_to_show)) { + gs_plugin_loader_app_create_async (self->plugin_loader, self->appid_to_show, self->cancellable, + gs_search_page_app_to_show_created_cb, self); + } else { + a = gs_app_new (self->appid_to_show); + } + + if (a) + gs_shell_show_app (self->shell, a); + + g_clear_pointer (&self->appid_to_show, g_free); + } +} + +static gboolean +gs_search_page_waiting_show_cb (gpointer user_data) +{ + GsSearchPage *self = GS_SEARCH_PAGE (user_data); + + /* show spinner */ + gtk_stack_set_visible_child_name (GTK_STACK (self->stack_search), "spinner"); + gtk_spinner_start (GTK_SPINNER (self->spinner_search)); + gs_search_page_waiting_cancel (self); + return FALSE; +} + +static gchar * +gs_search_page_get_app_sort_key (GsApp *app) +{ + GString *key = g_string_sized_new (64); + + /* sort apps before runtimes and extensions */ + switch (gs_app_get_kind (app)) { + case AS_COMPONENT_KIND_DESKTOP_APP: + g_string_append (key, "9:"); + break; + default: + g_string_append (key, "1:"); + break; + } + + /* sort missing codecs before applications */ + switch (gs_app_get_state (app)) { + case GS_APP_STATE_UNAVAILABLE: + g_string_append (key, "9:"); + break; + default: + g_string_append (key, "1:"); + break; + } + + /* sort by the search key */ + g_string_append_printf (key, "%05x:", gs_app_get_match_value (app)); + + /* sort by rating */ + g_string_append_printf (key, "%03i:", gs_app_get_rating (app)); + + /* sort by kudos */ + g_string_append_printf (key, "%03u:", gs_app_get_kudos_percentage (app)); + + return g_string_free (key, FALSE); +} + +static gint +gs_search_page_sort_cb (GsApp *app1, GsApp *app2, gpointer user_data) +{ + g_autofree gchar *key1 = NULL; + g_autofree gchar *key2 = NULL; + key1 = gs_search_page_get_app_sort_key (app1); + key2 = gs_search_page_get_app_sort_key (app2); + return g_strcmp0 (key2, key1); +} + +static void +gs_search_page_load (GsSearchPage *self) +{ + g_autoptr(GsPluginJob) plugin_job = NULL; + g_autoptr(GsAppQuery) query = NULL; + const gchar *keywords[2] = { NULL, }; + g_autofree GetSearchData *search_data = NULL; + + self->changed = FALSE; + + /* cancel any pending searches */ + g_cancellable_cancel (self->search_cancellable); + g_clear_object (&self->search_cancellable); + self->search_cancellable = g_cancellable_new (); + self->stamp++; + + /* search for apps */ + gs_search_page_waiting_cancel (self); + self->waiting_id = g_timeout_add (250, gs_search_page_waiting_show_cb, self); + + search_data = g_new0 (GetSearchData, 1); + search_data->self = self; + search_data->stamp = self->stamp; + + keywords[0] = self->value; + query = gs_app_query_new ("keywords", keywords, + "refine-flags", GS_PLUGIN_REFINE_FLAGS_REQUIRE_ICON | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_VERSION | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_HISTORY | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_SETUP_ACTION | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_REVIEW_RATINGS | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_DESCRIPTION | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_LICENSE | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_PERMISSIONS | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_RATING, + "dedupe-flags", GS_APP_LIST_FILTER_FLAG_PREFER_INSTALLED | + GS_APP_LIST_FILTER_FLAG_KEY_ID_PROVIDES, + "max-results", self->max_results, + "sort-func", gs_search_page_sort_cb, + "sort-user-data", self, + NULL); + plugin_job = gs_plugin_job_list_apps_new (query, GS_PLUGIN_LIST_APPS_FLAGS_NONE); + gs_plugin_loader_job_process_async (self->plugin_loader, plugin_job, + self->search_cancellable, + gs_search_page_get_search_cb, + g_steal_pointer (&search_data)); +} + +static void +gs_search_page_app_row_activated_cb (GtkListBox *list_box, + GtkListBoxRow *row, + GsSearchPage *self) +{ + GsApp *app; + + /* increase the maximum allowed, and re-request the search */ + if (!GS_IS_APP_ROW (row)) { + self->max_results *= 4; + gs_search_page_load (self); + return; + } + + app = gs_app_row_get_app (GS_APP_ROW (row)); + gs_shell_show_app (self->shell, app); +} + +static void +gs_search_page_reload (GsPage *page) +{ + GsSearchPage *self = GS_SEARCH_PAGE (page); + if (self->value != NULL) + gs_search_page_load (self); +} + +/** + * gs_search_page_set_appid_to_show: + * + * Switch to the specified app id after loading the search results. + **/ +void +gs_search_page_set_appid_to_show (GsSearchPage *self, const gchar *appid) +{ + if (appid == self->appid_to_show || + g_strcmp0 (appid, self->appid_to_show) == 0) + return; + + g_free (self->appid_to_show); + self->appid_to_show = g_strdup (appid); + + self->changed = TRUE; +} + +const gchar * +gs_search_page_get_text (GsSearchPage *self) +{ + return self->value; +} + +void +gs_search_page_set_text (GsSearchPage *self, const gchar *value) +{ + if (value == self->value || + g_strcmp0 (value, self->value) == 0) + return; + + g_free (self->value); + self->value = g_strdup (value); + + /* Load immediately, when the page is active */ + if (self->value && gs_page_is_active (GS_PAGE (self))) + gs_search_page_load (self); + else + self->changed = TRUE; +} + +static void +gs_search_page_switch_to (GsPage *page) +{ + GsSearchPage *self = GS_SEARCH_PAGE (page); + + if (gs_shell_get_mode (self->shell) != GS_SHELL_MODE_SEARCH) { + g_warning ("Called switch_to(search) when in mode %s", + gs_shell_get_mode_string (self->shell)); + return; + } + + if (self->value && self->changed) + gs_search_page_load (self); +} + +static void +gs_search_page_switch_from (GsPage *page) +{ + GsSearchPage *self = GS_SEARCH_PAGE (page); + + g_cancellable_cancel (self->search_cancellable); + g_clear_object (&self->search_cancellable); +} + +static void +gs_search_page_cancel_cb (GCancellable *cancellable, + GsSearchPage *self) +{ + g_cancellable_cancel (self->search_cancellable); +} + +static void +gs_search_page_app_installed (GsPage *page, GsApp *app) +{ + gs_search_page_reload (page); +} + +static void +gs_search_page_app_removed (GsPage *page, GsApp *app) +{ + gs_search_page_reload (page); +} + +static gboolean +gs_search_page_setup (GsPage *page, + GsShell *shell, + GsPluginLoader *plugin_loader, + GCancellable *cancellable, + GError **error) +{ + GsSearchPage *self = GS_SEARCH_PAGE (page); + + g_return_val_if_fail (GS_IS_SEARCH_PAGE (self), TRUE); + + self->plugin_loader = g_object_ref (plugin_loader); + self->cancellable = g_object_ref (cancellable); + self->shell = shell; + + /* connect the cancellables */ + g_cancellable_connect (self->cancellable, + G_CALLBACK (gs_search_page_cancel_cb), + self, NULL); + + /* setup search */ + g_signal_connect (self->list_box_search, "row-activated", + G_CALLBACK (gs_search_page_app_row_activated_cb), self); + return TRUE; +} + +static void +gs_search_page_get_property (GObject *object, + guint prop_id, + GValue *value, + GParamSpec *pspec) +{ + GsSearchPage *self = GS_SEARCH_PAGE (object); + + switch ((GsSearchPageProperty) prop_id) { + case PROP_VADJUSTMENT: + g_value_set_object (value, gtk_scrolled_window_get_vadjustment (GTK_SCROLLED_WINDOW (self->scrolledwindow_search))); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_search_page_set_property (GObject *object, + guint prop_id, + const GValue *value, + GParamSpec *pspec) +{ + switch ((GsSearchPageProperty) prop_id) { + case PROP_VADJUSTMENT: + /* Not supported yet */ + g_assert_not_reached (); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_search_page_dispose (GObject *object) +{ + GsSearchPage *self = GS_SEARCH_PAGE (object); + + g_clear_object (&self->sizegroup_name); + g_clear_object (&self->sizegroup_button_label); + g_clear_object (&self->sizegroup_button_image); + + g_clear_object (&self->plugin_loader); + g_clear_object (&self->cancellable); + g_clear_object (&self->search_cancellable); + + G_OBJECT_CLASS (gs_search_page_parent_class)->dispose (object); +} + +static void +gs_search_page_finalize (GObject *object) +{ + GsSearchPage *self = GS_SEARCH_PAGE (object); + + g_free (self->appid_to_show); + g_free (self->value); + + G_OBJECT_CLASS (gs_search_page_parent_class)->finalize (object); +} + +static void +gs_search_page_class_init (GsSearchPageClass *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_search_page_get_property; + object_class->set_property = gs_search_page_set_property; + object_class->dispose = gs_search_page_dispose; + object_class->finalize = gs_search_page_finalize; + + page_class->app_installed = gs_search_page_app_installed; + page_class->app_removed = gs_search_page_app_removed; + page_class->switch_to = gs_search_page_switch_to; + page_class->switch_from = gs_search_page_switch_from; + page_class->reload = gs_search_page_reload; + page_class->setup = gs_search_page_setup; + + g_object_class_override_property (object_class, PROP_VADJUSTMENT, "vadjustment"); + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-search-page.ui"); + + gtk_widget_class_bind_template_child (widget_class, GsSearchPage, list_box_search); + gtk_widget_class_bind_template_child (widget_class, GsSearchPage, scrolledwindow_search); + gtk_widget_class_bind_template_child (widget_class, GsSearchPage, spinner_search); + gtk_widget_class_bind_template_child (widget_class, GsSearchPage, stack_search); +} + +static void +gs_search_page_init (GsSearchPage *self) +{ + gtk_widget_init_template (GTK_WIDGET (self)); + + self->sizegroup_name = gtk_size_group_new (GTK_SIZE_GROUP_HORIZONTAL); + self->sizegroup_button_label = gtk_size_group_new (GTK_SIZE_GROUP_HORIZONTAL); + self->sizegroup_button_image = gtk_size_group_new (GTK_SIZE_GROUP_HORIZONTAL); + + self->max_results = GS_SEARCH_PAGE_MAX_RESULTS; +} + +GsSearchPage * +gs_search_page_new (void) +{ + GsSearchPage *self; + self = g_object_new (GS_TYPE_SEARCH_PAGE, NULL); + return GS_SEARCH_PAGE (self); +} diff --git a/src/gs-search-page.h b/src/gs-search-page.h new file mode 100644 index 0000000..f955a21 --- /dev/null +++ b/src/gs-search-page.h @@ -0,0 +1,27 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2015-2017 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include "gs-page.h" + +G_BEGIN_DECLS + +#define GS_TYPE_SEARCH_PAGE (gs_search_page_get_type ()) + +G_DECLARE_FINAL_TYPE (GsSearchPage, gs_search_page, GS, SEARCH_PAGE, GsPage) + +GsSearchPage *gs_search_page_new (void); +void gs_search_page_set_appid_to_show (GsSearchPage *self, + const gchar *appid); +const gchar *gs_search_page_get_text (GsSearchPage *self); +void gs_search_page_set_text (GsSearchPage *self, + const gchar *value); + +G_END_DECLS diff --git a/src/gs-search-page.ui b/src/gs-search-page.ui new file mode 100644 index 0000000..7d2768e --- /dev/null +++ b/src/gs-search-page.ui @@ -0,0 +1,91 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <requires lib="gtk+" version="3.10"/> + <template class="GsSearchPage" parent="GsPage"> + <accessibility> + <property name="label" translatable="yes">Search page</property> + </accessibility> + <child> + <object class="GtkStack" id="stack_search"> + + <child> + <object class="GtkStackPage"> + <property name="name">no-search</property> + <property name="child"> + <object class="AdwStatusPage"> + <property name="icon_name">org.gnome.Software-symbolic</property> + <property name="title" translatable="yes">Search for Apps</property> + </object> + </property> + </object> + </child> + + <child> + <object class="GtkStackPage"> + <property name="name">spinner</property> + <property name="child"> + <object class="GtkSpinner" id="spinner_search"> + <property name="width_request">32</property> + <property name="height_request">32</property> + <property name="halign">center</property> + <property name="valign">center</property> + <style> + <class name="fade-in"/> + </style> + </object> + </property> + </object> + </child> + + <child> + <object class="GtkStackPage"> + <property name="name">no-results</property> + <property name="child"> + <object class="AdwStatusPage" id="noresults_grid_search"> + <property name="icon_name">org.gnome.Software-symbolic</property> + <property name="title" translatable="yes">No Application Found</property> + </object> + </property> + </object> + </child> + + <child> + <object class="GtkStackPage"> + <property name="name">results</property> + <property name="child"> + <object class="GtkScrolledWindow" id="scrolledwindow_search"> + <property name="can_focus">True</property> + <property name="hscrollbar_policy">never</property> + <property name="vscrollbar_policy">automatic</property> + <style> + <class name="list-page"/> + </style> + <child> + <object class="AdwClamp"> + <child> + <object class="GtkListBox" id="list_box_search"> + <property name="can_focus">True</property> + <property name="valign">start</property> + <property name="selection_mode">none</property> + <property name="margin-top">24</property> + <property name="margin-bottom">36</property> + <property name="margin-start">12</property> + <property name="margin-end">12</property> + <property name="valign">start</property> + <style> + <class name="boxed-list"/> + <class name="section"/> + </style> + </object> + </child> + </object> + </child> + </object> + </property> + </object> + </child> + + </object> + </child> + </template> +</interface> diff --git a/src/gs-self-test.c b/src/gs-self-test.c new file mode 100644 index 0000000..83fce99 --- /dev/null +++ b/src/gs-self-test.c @@ -0,0 +1,52 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2017 Richard Hughes <richard@hughsie.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include "gnome-software-private.h" + +#include "gs-css.h" +#include "gs-test.h" + +static void +gs_css_func (void) +{ + const gchar *tmp; + gboolean ret; + g_autoptr(GError) error = NULL; + g_autoptr(GsCss) css = gs_css_new (); + + /* no IDs */ + ret = gs_css_parse (css, "border: 0;", &error); + g_assert_no_error (error); + g_assert (ret); + tmp = gs_css_get_markup_for_id (css, "tile"); + g_assert_cmpstr (tmp, ==, "border: 0;"); + + /* with IDs */ + ret = gs_css_parse (css, "#tile2{\nborder: 0;}\n#name {color: white;\n}", &error); + g_assert_no_error (error); + g_assert (ret); + tmp = gs_css_get_markup_for_id (css, "NotGoingToExist"); + g_assert_cmpstr (tmp, ==, NULL); + tmp = gs_css_get_markup_for_id (css, "tile2"); + g_assert_cmpstr (tmp, ==, "border: 0;"); + tmp = gs_css_get_markup_for_id (css, "name"); + g_assert_cmpstr (tmp, ==, "color: white;"); +} + +int +main (int argc, char **argv) +{ + gs_test_init (&argc, &argv); + + /* tests go here */ + g_test_add_func ("/gnome-software/src/css", gs_css_func); + + return g_test_run (); +} diff --git a/src/gs-shell-search-provider.c b/src/gs-shell-search-provider.c new file mode 100644 index 0000000..5003a5b --- /dev/null +++ b/src/gs-shell-search-provider.c @@ -0,0 +1,409 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * gs-shell-search-provider.c - Implementation of a GNOME Shell + * search provider + * + * Copyright (C) 2013 Matthias Clasen + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include <config.h> + +#include <gio/gio.h> +#include <glib/gi18n.h> +#include <string.h> + +#include "gs-shell-search-provider-generated.h" +#include "gs-shell-search-provider.h" +#include "gs-common.h" + +#define GS_SHELL_SEARCH_PROVIDER_MAX_RESULTS 20 + +typedef struct { + GsShellSearchProvider *provider; + GDBusMethodInvocation *invocation; +} PendingSearch; + +struct _GsShellSearchProvider { + GObject parent; + + GsShellSearchProvider2 *skeleton; + GsPluginLoader *plugin_loader; + GCancellable *cancellable; + + GHashTable *metas_cache; + GsAppList *search_results; +}; + +G_DEFINE_TYPE (GsShellSearchProvider, gs_shell_search_provider, G_TYPE_OBJECT) + +static void +pending_search_free (PendingSearch *search) +{ + g_object_unref (search->invocation); + g_slice_free (PendingSearch, search); +} + +static gint +search_sort_by_kudo_cb (GsApp *app1, GsApp *app2, gpointer user_data) +{ + guint pa, pb; + pa = gs_app_get_kudos_percentage (app1); + pb = gs_app_get_kudos_percentage (app2); + if (pa < pb) + return 1; + else if (pa > pb) + return -1; + return 0; +} + +static void +search_done_cb (GObject *source, + GAsyncResult *res, + gpointer user_data) +{ + PendingSearch *search = user_data; + GsShellSearchProvider *self = search->provider; + guint i; + GVariantBuilder builder; + g_autoptr(GsAppList) list = NULL; + + /* cache no longer valid */ + gs_app_list_remove_all (self->search_results); + + list = gs_plugin_loader_job_process_finish (self->plugin_loader, res, NULL); + if (list == NULL) { + g_dbus_method_invocation_return_value (search->invocation, g_variant_new ("(as)", NULL)); + pending_search_free (search); + g_application_release (g_application_get_default ()); + return; + } + + /* sort by kudos, as there is no ratings data by default */ + gs_app_list_sort (list, search_sort_by_kudo_cb, NULL); + + g_variant_builder_init (&builder, G_VARIANT_TYPE ("as")); + for (i = 0; i < gs_app_list_length (list); i++) { + GsApp *app = gs_app_list_index (list, i); + g_variant_builder_add (&builder, "s", gs_app_get_unique_id (app)); + + /* cache this in case we need the app in GetResultMetas */ + gs_app_list_add (self->search_results, app); + } + g_dbus_method_invocation_return_value (search->invocation, g_variant_new ("(as)", &builder)); + + pending_search_free (search); + g_application_release (g_application_get_default ()); +} + +static gchar * +gs_shell_search_provider_get_app_sort_key (GsApp *app) +{ + GString *key = g_string_sized_new (64); + + /* sort available apps before installed ones */ + switch (gs_app_get_state (app)) { + case GS_APP_STATE_AVAILABLE: + g_string_append (key, "9:"); + break; + default: + g_string_append (key, "1:"); + break; + } + + /* sort apps before runtimes and extensions */ + switch (gs_app_get_kind (app)) { + case AS_COMPONENT_KIND_DESKTOP_APP: + g_string_append (key, "9:"); + break; + default: + g_string_append (key, "1:"); + break; + } + + /* sort by the search key */ + g_string_append_printf (key, "%05x:", gs_app_get_match_value (app)); + + /* tie-break with id */ + g_string_append (key, gs_app_get_unique_id (app)); + + return g_string_free (key, FALSE); +} + +static gint +gs_shell_search_provider_sort_cb (GsApp *app1, GsApp *app2, gpointer user_data) +{ + g_autofree gchar *key1 = NULL; + g_autofree gchar *key2 = NULL; + key1 = gs_shell_search_provider_get_app_sort_key (app1); + key2 = gs_shell_search_provider_get_app_sort_key (app2); + return g_strcmp0 (key2, key1); +} + +static void +execute_search (GsShellSearchProvider *self, + GDBusMethodInvocation *invocation, + gchar **terms) +{ + PendingSearch *pending_search; + g_autoptr(GsPluginJob) plugin_job = NULL; + g_autoptr(GsAppQuery) query = NULL; + + g_cancellable_cancel (self->cancellable); + g_clear_object (&self->cancellable); + + /* don't attempt searches for a single character */ + if (g_strv_length (terms) == 1 && + g_utf8_strlen (terms[0], -1) == 1) { + g_dbus_method_invocation_return_value (invocation, g_variant_new ("(as)", NULL)); + return; + } + + pending_search = g_slice_new (PendingSearch); + pending_search->provider = self; + pending_search->invocation = g_object_ref (invocation); + + g_application_hold (g_application_get_default ()); + self->cancellable = g_cancellable_new (); + + query = gs_app_query_new ("keywords", terms, + "refine-flags", GS_PLUGIN_REFINE_FLAGS_REQUIRE_ICON | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_ORIGIN_HOSTNAME, + "dedupe-flags", GS_APP_LIST_FILTER_FLAG_PREFER_INSTALLED | + GS_APP_LIST_FILTER_FLAG_KEY_ID_PROVIDES, + "max-results", GS_SHELL_SEARCH_PROVIDER_MAX_RESULTS, + "sort-func", gs_shell_search_provider_sort_cb, + "sort-user-data", self, + NULL); + plugin_job = gs_plugin_job_list_apps_new (query, GS_PLUGIN_LIST_APPS_FLAGS_NONE); + + gs_plugin_loader_job_process_async (self->plugin_loader, plugin_job, + self->cancellable, + search_done_cb, + pending_search); +} + +static gboolean +handle_get_initial_result_set (GsShellSearchProvider2 *skeleton, + GDBusMethodInvocation *invocation, + gchar **terms, + gpointer user_data) +{ + GsShellSearchProvider *self = user_data; + + g_debug ("****** GetInitialResultSet"); + execute_search (self, invocation, terms); + return TRUE; +} + +static gboolean +handle_get_subsearch_result_set (GsShellSearchProvider2 *skeleton, + GDBusMethodInvocation *invocation, + gchar **previous_results, + gchar **terms, + gpointer user_data) +{ + GsShellSearchProvider *self = user_data; + + g_debug ("****** GetSubSearchResultSet"); + execute_search (self, invocation, terms); + return TRUE; +} + +static gboolean +handle_get_result_metas (GsShellSearchProvider2 *skeleton, + GDBusMethodInvocation *invocation, + gchar **results, + gpointer user_data) +{ + GsShellSearchProvider *self = user_data; + GVariantBuilder meta; + GVariant *meta_variant; + gint i; + GVariantBuilder builder; + + g_debug ("****** GetResultMetas"); + + for (i = 0; results[i]; i++) { + GsApp *app; + g_autoptr(GIcon) icon = NULL; + g_autofree gchar *description = NULL; + + /* already built */ + if (g_hash_table_lookup (self->metas_cache, results[i]) != NULL) + continue; + + /* get previously found app */ + app = gs_app_list_lookup (self->search_results, results[i]); + if (app == NULL) { + g_warning ("failed to refine find app %s in cache", results[i]); + continue; + } + + g_variant_builder_init (&meta, G_VARIANT_TYPE ("a{sv}")); + g_variant_builder_add (&meta, "{sv}", "id", g_variant_new_string (gs_app_get_unique_id (app))); + g_variant_builder_add (&meta, "{sv}", "name", g_variant_new_string (gs_app_get_name (app))); + + /* ICON_SIZE is defined as 24px in js/ui/search.js in gnome-shell */ + icon = gs_app_get_icon_for_size (app, 24, 1, NULL); + if (icon != NULL) { + g_autofree gchar *icon_str = g_icon_to_string (icon); + if (icon_str != NULL) { + g_variant_builder_add (&meta, "{sv}", "gicon", g_variant_new_string (icon_str)); + } else { + g_autoptr(GVariant) icon_serialized = g_icon_serialize (icon); + g_variant_builder_add (&meta, "{sv}", "icon", icon_serialized); + } + } + + if (gs_utils_list_has_component_fuzzy (self->search_results, app) && + gs_app_get_origin_hostname (app) != NULL) { + /* TRANSLATORS: this refers to where the app came from */ + g_autofree gchar *source_text = g_strdup_printf (_("Source: %s"), + gs_app_get_origin_hostname (app)); + description = g_strdup_printf ("%s %s", + gs_app_get_summary (app), + source_text); + } else { + description = g_strdup (gs_app_get_summary (app)); + } + g_variant_builder_add (&meta, "{sv}", "description", g_variant_new_string (description)); + + meta_variant = g_variant_builder_end (&meta); + g_hash_table_insert (self->metas_cache, + g_strdup (gs_app_get_unique_id (app)), + g_variant_ref_sink (meta_variant)); + + } + + g_variant_builder_init (&builder, G_VARIANT_TYPE ("aa{sv}")); + for (i = 0; results[i]; i++) { + meta_variant = (GVariant*)g_hash_table_lookup (self->metas_cache, results[i]); + if (meta_variant == NULL) + continue; + g_variant_builder_add_value (&builder, meta_variant); + } + + g_dbus_method_invocation_return_value (invocation, g_variant_new ("(aa{sv})", &builder)); + + return TRUE; +} + +static gboolean +handle_activate_result (GsShellSearchProvider2 *skeleton, + GDBusMethodInvocation *invocation, + gchar *result, + gchar **terms, + guint32 timestamp, + gpointer user_data) +{ + GApplication *app = g_application_get_default (); + g_autofree gchar *string = NULL; + + string = g_strjoinv (" ", terms); + + g_action_group_activate_action (G_ACTION_GROUP (app), "details", + g_variant_new ("(ss)", result, string)); + + gs_shell_search_provider2_complete_activate_result (skeleton, invocation); + return TRUE; +} + +static gboolean +handle_launch_search (GsShellSearchProvider2 *skeleton, + GDBusMethodInvocation *invocation, + gchar **terms, + guint32 timestamp, + gpointer user_data) +{ + GApplication *app = g_application_get_default (); + g_autofree gchar *string = g_strjoinv (" ", terms); + + g_action_group_activate_action (G_ACTION_GROUP (app), "search", + g_variant_new ("s", string)); + + gs_shell_search_provider2_complete_launch_search (skeleton, invocation); + return TRUE; +} + +gboolean +gs_shell_search_provider_register (GsShellSearchProvider *self, + GDBusConnection *connection, + GError **error) +{ + return g_dbus_interface_skeleton_export (G_DBUS_INTERFACE_SKELETON (self->skeleton), + connection, + "/org/gnome/Software/SearchProvider", error); +} + +void +gs_shell_search_provider_unregister (GsShellSearchProvider *self) +{ + g_dbus_interface_skeleton_unexport (G_DBUS_INTERFACE_SKELETON (self->skeleton)); +} + +static void +search_provider_dispose (GObject *obj) +{ + GsShellSearchProvider *self = GS_SHELL_SEARCH_PROVIDER (obj); + + g_cancellable_cancel (self->cancellable); + g_clear_object (&self->cancellable); + + if (self->metas_cache != NULL) { + g_hash_table_destroy (self->metas_cache); + self->metas_cache = NULL; + } + + g_clear_object (&self->search_results); + g_clear_object (&self->plugin_loader); + g_clear_object (&self->skeleton); + + G_OBJECT_CLASS (gs_shell_search_provider_parent_class)->dispose (obj); +} + +static void +gs_shell_search_provider_init (GsShellSearchProvider *self) +{ + self->metas_cache = g_hash_table_new_full ((GHashFunc) as_utils_data_id_hash, + (GEqualFunc) as_utils_data_id_equal, + g_free, + (GDestroyNotify) g_variant_unref); + + self->search_results = gs_app_list_new (); + self->skeleton = gs_shell_search_provider2_skeleton_new (); + + g_signal_connect (self->skeleton, "handle-get-initial-result-set", + G_CALLBACK (handle_get_initial_result_set), self); + g_signal_connect (self->skeleton, "handle-get-subsearch-result-set", + G_CALLBACK (handle_get_subsearch_result_set), self); + g_signal_connect (self->skeleton, "handle-get-result-metas", + G_CALLBACK (handle_get_result_metas), self); + g_signal_connect (self->skeleton, "handle-activate-result", + G_CALLBACK (handle_activate_result), self); + g_signal_connect (self->skeleton, "handle-launch-search", + G_CALLBACK (handle_launch_search), self); +} + +static void +gs_shell_search_provider_class_init (GsShellSearchProviderClass *klass) +{ + GObjectClass *oclass = G_OBJECT_CLASS (klass); + + oclass->dispose = search_provider_dispose; +} + +GsShellSearchProvider * +gs_shell_search_provider_new (void) +{ + return g_object_new (gs_shell_search_provider_get_type (), NULL); +} + +void +gs_shell_search_provider_setup (GsShellSearchProvider *provider, + GsPluginLoader *loader) +{ + provider->plugin_loader = g_object_ref (loader); +} diff --git a/src/gs-shell-search-provider.h b/src/gs-shell-search-provider.h new file mode 100644 index 0000000..f3bbbd1 --- /dev/null +++ b/src/gs-shell-search-provider.h @@ -0,0 +1,26 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * gs-shell-search-provider.h - Implementation of a GNOME Shell + * search provider + * + * Copyright (C) 2013 Matthias Clasen <mclasen@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include "gnome-software-private.h" + +#define GS_TYPE_SHELL_SEARCH_PROVIDER gs_shell_search_provider_get_type() + +G_DECLARE_FINAL_TYPE (GsShellSearchProvider, gs_shell_search_provider, GS, SHELL_SEARCH_PROVIDER, GObject) + +gboolean gs_shell_search_provider_register (GsShellSearchProvider *self, + GDBusConnection *connection, + GError **error); +void gs_shell_search_provider_unregister (GsShellSearchProvider *self); +GsShellSearchProvider *gs_shell_search_provider_new (void); +void gs_shell_search_provider_setup (GsShellSearchProvider *provider, + GsPluginLoader *loader); diff --git a/src/gs-shell.c b/src/gs-shell.c new file mode 100644 index 0000000..91946ee --- /dev/null +++ b/src/gs-shell.c @@ -0,0 +1,2725 @@ +/* -*- 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-2020 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include <adwaita.h> +#include <malloc.h> +#include <string.h> +#include <glib/gi18n.h> + +#ifdef HAVE_MOGWAI +#include <libmogwai-schedule-client/scheduler.h> +#endif + +#include "gs-common.h" +#include "gs-shell.h" +#include "gs-basic-auth-dialog.h" +#include "gs-details-page.h" +#include "gs-installed-page.h" +#include "gs-metered-data-dialog.h" +#include "gs-moderate-page.h" +#include "gs-loading-page.h" +#include "gs-search-page.h" +#include "gs-overview-page.h" +#include "gs-updates-page.h" +#include "gs-category-page.h" +#include "gs-extras-page.h" +#include "gs-repos-dialog.h" +#include "gs-prefs-dialog.h" +#include "gs-update-dialog.h" +#include "gs-update-monitor.h" +#include "gs-utils.h" + +#define NARROW_WIDTH_THRESHOLD 800 + +static const gchar *page_name[] = { + "unknown", + "overview", + "installed", + "search", + "updates", + "details", + "category", + "extras", + "moderate", + "loading", +}; + +typedef struct { + GsShellMode mode; + GtkWidget *focus; + GsCategory *category; + gchar *search; + GsApp *app; + gdouble vscroll_position; +} BackEntry; + +struct _GsShell +{ + AdwApplicationWindow parent_object; + + GSettings *settings; + GCancellable *cancellable; + GsPluginLoader *plugin_loader; + GtkWidget *header_start_widget; + GtkWidget *header_end_widget; + GQueue *back_entry_stack; + GPtrArray *modal_dialogs; + gchar *events_info_uri; + AdwLeaflet *main_leaflet; + AdwLeaflet *details_leaflet; + AdwViewStack *stack_loading; + AdwViewStack *stack_main; + AdwViewStack *stack_sub; + GsPage *page; + + GBinding *sub_page_header_title_binding; + +#ifdef HAVE_MOGWAI + MwscScheduler *scheduler; + gboolean scheduler_held; + gulong scheduler_invalidated_handler; +#endif /* HAVE_MOGWAI */ + + GtkWidget *main_header; + GtkWidget *details_header; + GtkWidget *metered_updates_bar; + GtkWidget *search_button; + GtkWidget *entry_search; + GtkWidget *search_bar; + GtkWidget *button_back; + GtkWidget *button_back2; + GtkWidget *notification_event; + GtkWidget *button_events_sources; + GtkWidget *button_events_no_space; + GtkWidget *button_events_network_settings; + GtkWidget *button_events_restart_required; + GtkWidget *button_events_more_info; + GtkWidget *button_events_dismiss; + GtkWidget *label_events; + GtkWidget *primary_menu; + GtkWidget *sub_page_header_title; + + gboolean activate_after_setup; + gboolean is_narrow; + gint allocation_width; + guint allocation_changed_cb_id; + + GsPage *pages[GS_SHELL_MODE_LAST]; +}; + +G_DEFINE_TYPE (GsShell, gs_shell, ADW_TYPE_APPLICATION_WINDOW) + +typedef enum { + PROP_IS_NARROW = 1, + PROP_ALLOCATION_WIDTH, +} GsShellProperty; + +enum { + SIGNAL_LOADED, + SIGNAL_LAST +}; + +static GParamSpec *obj_props[PROP_ALLOCATION_WIDTH + 1] = { NULL, }; + +static guint signals [SIGNAL_LAST] = { 0 }; + +static void +modal_dialog_unmapped_cb (GtkWidget *dialog, + GsShell *shell) +{ + g_debug ("modal dialog %p unmapped", dialog); + g_ptr_array_remove (shell->modal_dialogs, dialog); +} + +void +gs_shell_modal_dialog_present (GsShell *shell, GtkWindow *window) +{ + GtkWindow *parent; + + /* show new modal on top of old modal */ + if (shell->modal_dialogs->len > 0) { + parent = g_ptr_array_index (shell->modal_dialogs, + shell->modal_dialogs->len - 1); + g_debug ("using old modal %p as parent", parent); + } else { + parent = GTK_WINDOW (shell); + g_debug ("using main window"); + } + gtk_window_set_transient_for (window, parent); + + /* add to stack, transfer ownership to here */ + g_ptr_array_add (shell->modal_dialogs, window); + g_signal_connect (GTK_WIDGET (window), "unmap", + G_CALLBACK (modal_dialog_unmapped_cb), shell); + + /* present the new one */ + gtk_window_set_modal (window, TRUE); + gtk_window_present (window); +} + +void +gs_shell_activate (GsShell *shell) +{ + /* Waiting for plugin loader to setup first */ + if (shell->plugin_loader == NULL) { + shell->activate_after_setup = TRUE; + return; + } + + gtk_widget_show (GTK_WIDGET (shell)); + gtk_window_present (GTK_WINDOW (shell)); +} + +static void +gs_shell_set_header_start_widget (GsShell *shell, GtkWidget *widget) +{ + GtkWidget *old_widget; + + old_widget = shell->header_start_widget; + + if (shell->header_start_widget == widget) + return; + + if (widget != NULL) { + g_object_ref (widget); + adw_header_bar_pack_start (ADW_HEADER_BAR (shell->main_header), widget); + } + + shell->header_start_widget = widget; + + if (old_widget != NULL) { + adw_header_bar_remove (ADW_HEADER_BAR (shell->main_header), old_widget); + g_object_unref (old_widget); + } +} + +static void +gs_shell_set_header_end_widget (GsShell *shell, GtkWidget *widget) +{ + GtkWidget *old_widget; + + old_widget = shell->header_end_widget; + + if (shell->header_end_widget == widget) + return; + + if (widget != NULL) { + g_object_ref (widget); + adw_header_bar_pack_end (ADW_HEADER_BAR (shell->main_header), widget); + } + + shell->header_end_widget = widget; + + if (old_widget != NULL) { + adw_header_bar_remove (ADW_HEADER_BAR (shell->main_header), old_widget); + g_object_unref (old_widget); + } +} + +static void +gs_shell_refresh_auto_updates_ui (GsShell *shell) +{ + gboolean automatic_updates_paused; + gboolean automatic_updates_enabled; + + automatic_updates_enabled = g_settings_get_boolean (shell->settings, "download-updates"); + +#ifdef HAVE_MOGWAI + automatic_updates_paused = (shell->scheduler == NULL || !mwsc_scheduler_get_allow_downloads (shell->scheduler)); +#else + automatic_updates_paused = gs_plugin_loader_get_network_metered (shell->plugin_loader); +#endif + + gtk_info_bar_set_revealed (GTK_INFO_BAR (shell->metered_updates_bar), + gs_shell_get_mode (shell) != GS_SHELL_MODE_LOADING && + automatic_updates_enabled && + automatic_updates_paused); + gtk_info_bar_set_default_response (GTK_INFO_BAR (shell->metered_updates_bar), GTK_RESPONSE_OK); +} + +static void +gs_shell_metered_updates_bar_response_cb (GtkInfoBar *info_bar, + gint response_id, + gpointer user_data) +{ + GsShell *shell = GS_SHELL (user_data); + GtkWidget *dialog; + + dialog = gs_metered_data_dialog_new (GTK_WINDOW (shell)); + gs_shell_modal_dialog_present (shell, GTK_WINDOW (dialog)); +} + +static void +gs_shell_download_updates_changed_cb (GSettings *settings, + const gchar *key, + gpointer user_data) +{ + GsShell *shell = user_data; + + gs_shell_refresh_auto_updates_ui (shell); +} + +static void +gs_shell_network_metered_notify_cb (GsPluginLoader *plugin_loader, + GParamSpec *pspec, + gpointer user_data) +{ +#ifndef HAVE_MOGWAI + GsShell *shell = user_data; + + /* @automatic_updates_paused only depends on network-metered if we’re + * compiled without Mogwai. */ + gs_shell_refresh_auto_updates_ui (shell); +#endif +} + +#ifdef HAVE_MOGWAI +static void +scheduler_invalidated_cb (GsShell *shell) +{ + /* The scheduler shouldn’t normally be invalidated, since we Hold() it + * until we’re done with it. However, if the scheduler is stopped by + * systemd (`systemctl stop mogwai-scheduled`) this signal will be + * emitted. It may also be invalidated while our main window is hidden, + * as we release our Hold() then. */ + g_signal_handler_disconnect (shell->scheduler, + shell->scheduler_invalidated_handler); + shell->scheduler_invalidated_handler = 0; + shell->scheduler_held = FALSE; + + g_clear_object (&shell->scheduler); +} + +static void +scheduler_allow_downloads_changed_cb (GsShell *shell) +{ + gs_shell_refresh_auto_updates_ui (shell); +} + +static void +scheduler_hold_cb (GObject *source_object, + GAsyncResult *result, + gpointer data) +{ + g_autoptr(GError) error_local = NULL; + MwscScheduler *scheduler = (MwscScheduler *) source_object; + g_autoptr(GsShell) shell = data; /* reference added when starting the async operation */ + + if (mwsc_scheduler_hold_finish (scheduler, result, &error_local)) { + shell->scheduler_held = TRUE; + } else if (!g_error_matches (error_local, G_DBUS_ERROR, G_DBUS_ERROR_FAILED)) { + g_warning ("Couldn't hold the Mogwai Scheduler daemon: %s", + error_local->message); + } + + g_clear_error (&error_local); + + shell->scheduler_invalidated_handler = + g_signal_connect_swapped (scheduler, "invalidated", + (GCallback) scheduler_invalidated_cb, + shell); + + g_signal_connect_object (scheduler, "notify::allow-downloads", + (GCallback) scheduler_allow_downloads_changed_cb, + shell, + G_CONNECT_SWAPPED); + + g_assert (shell->scheduler == NULL); + shell->scheduler = scheduler; + + /* Update the UI accordingly. */ + gs_shell_refresh_auto_updates_ui (shell); +} + +static void +scheduler_release_cb (GObject *source_object, + GAsyncResult *result, + gpointer data) +{ + MwscScheduler *scheduler = (MwscScheduler *) source_object; + g_autoptr(GsShell) shell = data; /* reference added when starting the async operation */ + g_autoptr(GError) error_local = NULL; + + if (!mwsc_scheduler_release_finish (scheduler, result, &error_local)) + g_warning ("Couldn't release the Mogwai Scheduler daemon: %s", + error_local->message); + + shell->scheduler_held = FALSE; + g_clear_object (&shell->scheduler); +} + +static void +scheduler_ready_cb (GObject *source_object, + GAsyncResult *result, + gpointer data) +{ + MwscScheduler *scheduler; + g_autoptr(GError) error_local = NULL; + g_autoptr(GsShell) shell = data; /* reference added when starting the async operation */ + + scheduler = mwsc_scheduler_new_finish (result, &error_local); + + if (scheduler == NULL) { + g_warning ("%s: Error getting Mogwai Scheduler: %s", G_STRFUNC, + error_local->message); + return; + } + + mwsc_scheduler_hold_async (scheduler, + "monitoring allow-downloads property", + NULL, + scheduler_hold_cb, + g_object_ref (shell)); +} +#endif /* HAVE_MOGWAI */ + +static void +gs_shell_basic_auth_start_cb (GsPluginLoader *plugin_loader, + const gchar *remote, + const gchar *realm, + GsBasicAuthCallback callback, + gpointer callback_data, + GsShell *shell) +{ + GtkWidget *dialog; + + dialog = gs_basic_auth_dialog_new (GTK_WINDOW (shell), remote, realm, callback, callback_data); + gs_shell_modal_dialog_present (shell, GTK_WINDOW (dialog)); + + /* just destroy */ + g_signal_connect_swapped (dialog, "response", + G_CALLBACK (gtk_window_destroy), dialog); +} + +static gboolean +gs_shell_ask_untrusted_cb (GsPluginLoader *plugin_loader, + const gchar *title, + const gchar *msg, + const gchar *details, + const gchar *accept_label, + GsShell *shell) +{ + return gs_utils_ask_user_accepts (GTK_WINDOW (shell), title, msg, details, accept_label); +} + +static void +free_back_entry (BackEntry *entry) +{ + if (entry->focus != NULL) + g_object_remove_weak_pointer (G_OBJECT (entry->focus), + (gpointer *) &entry->focus); + g_clear_object (&entry->category); + g_clear_object (&entry->app); + g_free (entry->search); + g_free (entry); +} + +static void +gs_shell_clean_back_entry_stack (GsShell *shell) +{ + BackEntry *entry; + + while ((entry = g_queue_pop_head (shell->back_entry_stack)) != NULL) { + free_back_entry (entry); + } +} + +static gboolean +gs_shell_get_mode_is_main (GsShellMode mode) +{ + switch (mode) { + case GS_SHELL_MODE_OVERVIEW: + case GS_SHELL_MODE_INSTALLED: + case GS_SHELL_MODE_SEARCH: + case GS_SHELL_MODE_UPDATES: + case GS_SHELL_MODE_LOADING: + return TRUE; + case GS_SHELL_MODE_DETAILS: + case GS_SHELL_MODE_CATEGORY: + case GS_SHELL_MODE_EXTRAS: + case GS_SHELL_MODE_MODERATE: + return FALSE; + default: + return TRUE; + } +} + +static void search_bar_search_mode_enabled_changed_cb (GtkSearchBar *search_bar, + GParamSpec *pspec, + GsShell *shell); +static void gs_overview_page_button_cb (GtkWidget *widget, GsShell *shell); + +static void +update_header_widgets (GsShell *shell) +{ + GsShellMode mode = gs_shell_get_mode (shell); + + /* only show the search button in overview and search pages */ + g_signal_handlers_block_by_func (shell->search_bar, search_bar_search_mode_enabled_changed_cb, shell); + + /* hide unless we're going to search */ + gtk_search_bar_set_search_mode (GTK_SEARCH_BAR (shell->search_bar), + mode == GS_SHELL_MODE_SEARCH); + + g_signal_handlers_unblock_by_func (shell->search_bar, search_bar_search_mode_enabled_changed_cb, shell); +} + +static void +stack_notify_visible_child_cb (GObject *object, + GParamSpec *pspec, + gpointer user_data) +{ + GsShell *shell = GS_SHELL (user_data); + GsPage *page; + GtkWidget *widget; + GsShellMode mode = gs_shell_get_mode (shell); + gsize i; + + update_header_widgets (shell); + + /* do action for mode */ + page = shell->pages[mode]; + + if (mode == GS_SHELL_MODE_OVERVIEW || + mode == GS_SHELL_MODE_INSTALLED || + mode == GS_SHELL_MODE_UPDATES) + gs_shell_clean_back_entry_stack (shell); + + if (shell->page != NULL) + gs_page_switch_from (shell->page); + g_set_object (&shell->page, page); + gs_page_switch_to (page); + + /* update header bar widgets */ + switch (mode) { + case GS_SHELL_MODE_OVERVIEW: + case GS_SHELL_MODE_INSTALLED: + case GS_SHELL_MODE_SEARCH: + gtk_widget_show (shell->search_button); + break; + case GS_SHELL_MODE_UPDATES: + gtk_widget_hide (shell->search_button); + break; + default: + /* We don't care about changing the visibility of the search + * button in modes appearing in sub-pages. */ + break; + } + + widget = gs_page_get_header_start_widget (page); + switch (mode) { + case GS_SHELL_MODE_OVERVIEW: + case GS_SHELL_MODE_INSTALLED: + case GS_SHELL_MODE_UPDATES: + case GS_SHELL_MODE_SEARCH: + gs_shell_set_header_start_widget (shell, widget); + break; + default: + g_assert (widget == NULL); + break; + } + + widget = gs_page_get_header_end_widget (page); + switch (mode) { + case GS_SHELL_MODE_OVERVIEW: + case GS_SHELL_MODE_INSTALLED: + case GS_SHELL_MODE_UPDATES: + case GS_SHELL_MODE_SEARCH: + gs_shell_set_header_end_widget (shell, widget); + break; + default: + g_assert (widget == NULL); + break; + } + + g_clear_object (&shell->sub_page_header_title_binding); + shell->sub_page_header_title_binding = g_object_bind_property (adw_view_stack_get_visible_child (shell->stack_sub), "title", + shell->sub_page_header_title, "label", + G_BINDING_SYNC_CREATE); + + /* refresh the updates bar when moving out of the loading mode, but only + * if the Mogwai scheduler state is already known, to avoid spuriously + * showing the updates bar */ +#ifdef HAVE_MOGWAI + if (shell->scheduler != NULL) +#else + if (TRUE) +#endif + gs_shell_refresh_auto_updates_ui (shell); + + /* destroy any existing modals */ + if (shell->modal_dialogs != NULL) { + /* block signal emission of 'unmapped' since that will + * call g_ptr_array_remove_index. The unmapped signal may + * be emitted whilst running unref handlers for + * g_ptr_array_set_size */ + for (i = 0; i < shell->modal_dialogs->len; ++i) { + GtkWidget *dialog = g_ptr_array_index (shell->modal_dialogs, i); + g_signal_handlers_disconnect_by_func (dialog, + modal_dialog_unmapped_cb, + shell); + gtk_window_destroy (GTK_WINDOW (dialog)); + } + g_ptr_array_set_size (shell->modal_dialogs, 0); + } +} + +void +gs_shell_change_mode (GsShell *shell, + GsShellMode mode, + gpointer data, + gboolean scroll_up) +{ + GsApp *app; + GsPage *page; + gboolean mode_is_main = gs_shell_get_mode_is_main (mode); + + if (gs_shell_get_mode (shell) == mode && + (mode != GS_SHELL_MODE_DETAILS || + data == gs_details_page_get_app (GS_DETAILS_PAGE (shell->pages[mode])))) { + return; + } + + /* switch page */ + if (mode == GS_SHELL_MODE_LOADING) { + adw_view_stack_set_visible_child_name (shell->stack_loading, "loading"); + return; + } + + adw_view_stack_set_visible_child_name (shell->stack_loading, "main"); + if (mode == GS_SHELL_MODE_DETAILS) { + adw_leaflet_set_visible_child_name (shell->details_leaflet, "details"); + } else { + adw_leaflet_set_visible_child_name (shell->details_leaflet, "main"); + /* We only change the main leaflet when not reaching the details + * page to preserve the navigation history in the UI's state. + * First change the page, then the leaflet, to avoid load of + * the previously shown page, which will be changed shortly after. */ + adw_view_stack_set_visible_child_name (mode_is_main ? shell->stack_main : shell->stack_sub, page_name[mode]); + adw_leaflet_set_visible_child_name (shell->main_leaflet, mode_is_main ? "main" : "sub"); + } + + /* do any mode-specific actions */ + page = shell->pages[mode]; + + if (mode == GS_SHELL_MODE_SEARCH) { + gs_search_page_set_text (GS_SEARCH_PAGE (page), data); + gtk_editable_set_text (GTK_EDITABLE (shell->entry_search), data); + gtk_editable_set_position (GTK_EDITABLE (shell->entry_search), -1); + } else if (mode == GS_SHELL_MODE_DETAILS) { + app = GS_APP (data); + if (gs_app_get_metadata_item (app, "GnomeSoftware::show-metainfo") != NULL) { + gs_details_page_set_metainfo (GS_DETAILS_PAGE (page), + gs_app_get_local_file (app)); + } else if (gs_app_get_local_file (app) != NULL) { + gs_details_page_set_local_file (GS_DETAILS_PAGE (page), + gs_app_get_local_file (app)); + } else if (gs_app_get_metadata_item (app, "GnomeSoftware::from-url") != NULL) { + gs_details_page_set_url (GS_DETAILS_PAGE (page), + gs_app_get_metadata_item (app, "GnomeSoftware::from-url")); + } else { + gs_details_page_set_app (GS_DETAILS_PAGE (page), data); + } + } else if (mode == GS_SHELL_MODE_CATEGORY) { + gs_category_page_set_category (GS_CATEGORY_PAGE (page), + GS_CATEGORY (data)); + } + + if (scroll_up) + gs_page_scroll_up (page); +} + +static gboolean +overlay_get_child_position_cb (GtkOverlay *overlay, + GtkWidget *widget, + GdkRectangle *allocation, + gpointer user_data) +{ + GsShell *self = GS_SHELL (user_data); + GtkRequisition overlay_natural_size; + + /* Override the default position of the in-app notification overlay + * to position it below the header bar. The overlay can’t easily be + * moved in the widget hierarchy so it doesn’t have the header bar as + * a child, since there are several header bars in different pages of + * a AdwLeaflet. */ + g_assert (gtk_widget_is_ancestor (self->main_header, GTK_WIDGET (overlay))); + + gtk_widget_get_preferred_size (widget, NULL, &overlay_natural_size); + + allocation->width = overlay_natural_size.width; + allocation->height = overlay_natural_size.height; + + allocation->x = gtk_widget_get_allocated_width (GTK_WIDGET (overlay)) / 2 - overlay_natural_size.width / 2; + allocation->y = gtk_widget_get_allocated_height (GTK_WIDGET (self->main_header)); + + return TRUE; +} + +static void +gs_overview_page_button_cb (GtkWidget *widget, GsShell *shell) +{ + GsShellMode mode; + mode = GPOINTER_TO_INT (g_object_get_data (G_OBJECT (widget), + "gnome-software::overview-mode")); + gs_shell_change_mode (shell, mode, NULL, TRUE); +} + +static void +save_back_entry (GsShell *shell) +{ + BackEntry *entry; + + entry = g_new0 (BackEntry, 1); + entry->mode = gs_shell_get_mode (shell); + + entry->focus = gtk_window_get_focus (GTK_WINDOW (shell)); + if (entry->focus != NULL) + g_object_add_weak_pointer (G_OBJECT (entry->focus), + (gpointer *) &entry->focus); + + switch (entry->mode) { + case GS_SHELL_MODE_CATEGORY: + entry->category = gs_category_page_get_category (GS_CATEGORY_PAGE (shell->pages[GS_SHELL_MODE_CATEGORY])); + g_object_ref (entry->category); + g_debug ("pushing back entry for %s with %s", + page_name[entry->mode], + gs_category_get_id (entry->category)); + break; + case GS_SHELL_MODE_SEARCH: + entry->search = g_strdup (gs_search_page_get_text (GS_SEARCH_PAGE (shell->pages[GS_SHELL_MODE_SEARCH]))); + g_debug ("pushing back entry for %s with %s", + page_name[entry->mode], entry->search); + break; + case GS_SHELL_MODE_DETAILS: + entry->app = g_object_ref (gs_details_page_get_app (GS_DETAILS_PAGE (shell->pages[GS_SHELL_MODE_DETAILS]))); + entry->vscroll_position = gs_details_page_get_vscroll_position (GS_DETAILS_PAGE (shell->pages[GS_SHELL_MODE_DETAILS])); + break; + default: + g_debug ("pushing back entry for %s", page_name[entry->mode]); + break; + } + + g_queue_push_head (shell->back_entry_stack, entry); +} + +static void +gs_shell_plugin_events_sources_cb (GtkWidget *widget, GsShell *shell) +{ + gs_shell_show_sources (shell); +} + +static void +gs_shell_plugin_events_no_space_cb (GtkWidget *widget, GsShell *shell) +{ + g_autoptr(GError) error = NULL; + if (!g_spawn_command_line_async ("baobab", &error)) + g_warning ("failed to exec baobab: %s", error->message); +} + +static void +gs_shell_plugin_events_network_settings_cb (GtkWidget *widget, GsShell *shell) +{ + g_autoptr(GError) error = NULL; + if (!g_spawn_command_line_async ("gnome-control-center network", &error)) + g_warning ("failed to exec gnome-control-center: %s", error->message); +} + +static void +gs_shell_plugin_events_more_info_cb (GtkWidget *widget, GsShell *shell) +{ + g_autoptr(GError) error = NULL; + if (!g_app_info_launch_default_for_uri (shell->events_info_uri, NULL, &error)) { + g_warning ("failed to launch URI %s: %s", + shell->events_info_uri, error->message); + } +} + +static void +gs_shell_plugin_events_restart_required_cb (GtkWidget *widget, GsShell *shell) +{ + g_autoptr(GError) error = NULL; + if (!g_spawn_command_line_async (LIBEXECDIR "/gnome-software-restarter", &error)) + g_warning ("failed to restart: %s", error->message); +} + +/* this is basically a workaround for GtkSearchEntry. Due to delayed emission of the search-changed + * signal it can't be blocked during insertion of text into the entry. Therefore we block the + * precursor of that signal to be able to add text to the entry without firing the handlers + * connected to "search-changed" + */ +static void +block_changed (GtkEditable *editable, + gpointer user_data) +{ + g_signal_stop_emission_by_name (editable, "changed"); +} + +static void +block_changed_signal (GtkSearchEntry *entry) +{ + g_signal_connect (entry, "changed", G_CALLBACK (block_changed), NULL); +} + +static void +unblock_changed_signal (GtkSearchEntry *entry) +{ + g_signal_handlers_disconnect_by_func (entry, G_CALLBACK (block_changed), NULL); +} + +static void +gs_shell_go_back (GsShell *shell) +{ + BackEntry *entry; + + /* nothing to do */ + if (g_queue_is_empty (shell->back_entry_stack)) { + g_debug ("no back stack, showing overview"); + gs_shell_change_mode (shell, GS_SHELL_MODE_OVERVIEW, NULL, FALSE); + return; + } + + entry = g_queue_pop_head (shell->back_entry_stack); + + switch (entry->mode) { + case GS_SHELL_MODE_UNKNOWN: + case GS_SHELL_MODE_LOADING: + /* happens when using --search, --details, --install, etc. options */ + g_debug ("popping back entry for %s", page_name[entry->mode]); + gs_shell_change_mode (shell, GS_SHELL_MODE_OVERVIEW, NULL, FALSE); + break; + case GS_SHELL_MODE_CATEGORY: + g_debug ("popping back entry for %s with %s", + page_name[entry->mode], + gs_category_get_id (entry->category)); + gs_shell_change_mode (shell, entry->mode, entry->category, FALSE); + break; + case GS_SHELL_MODE_SEARCH: + g_debug ("popping back entry for %s with %s", + page_name[entry->mode], entry->search); + + /* set the text in the entry and move cursor to the end */ + block_changed_signal (GTK_SEARCH_ENTRY (shell->entry_search)); + gtk_editable_set_text (GTK_EDITABLE (shell->entry_search), entry->search); + gtk_editable_set_position (GTK_EDITABLE (shell->entry_search), -1); + unblock_changed_signal (GTK_SEARCH_ENTRY (shell->entry_search)); + + /* set the mode directly */ + gs_shell_change_mode (shell, entry->mode, + (gpointer) entry->search, FALSE); + break; + case GS_SHELL_MODE_DETAILS: + g_debug ("popping back entry for %s with app %s and vscroll position %f", + page_name[entry->mode], + gs_app_get_unique_id (entry->app), + entry->vscroll_position); + gs_shell_change_mode (shell, entry->mode, entry->app, FALSE); + gs_details_page_set_vscroll_position (GS_DETAILS_PAGE (shell->pages[GS_SHELL_MODE_DETAILS]), entry->vscroll_position); + break; + default: + g_debug ("popping back entry for %s", page_name[entry->mode]); + gs_shell_change_mode (shell, entry->mode, NULL, FALSE); + break; + } + + if (entry->focus != NULL) + gtk_widget_grab_focus (entry->focus); + + free_back_entry (entry); +} + +static void +gs_shell_details_back_button_cb (GtkWidget *widget, GsShell *shell) +{ + gs_shell_go_back (shell); +} + +static void +gs_shell_back_button_cb (GtkWidget *widget, GsShell *shell) +{ + gs_shell_go_back (shell); +} + +static void +gs_shell_reload_cb (GsPluginLoader *plugin_loader, GsShell *shell) +{ + for (gsize i = 0; i < G_N_ELEMENTS (shell->pages); i++) { + GsPage *page = shell->pages[i]; + if (page != NULL) + gs_page_reload (page); + } +} + +static void +gs_shell_details_page_metainfo_loaded_cb (GtkWidget *details_page, + GsApp *app, + GsShell *self) +{ + g_return_if_fail (GS_IS_APP (app)); + g_return_if_fail (GS_IS_SHELL (self)); + + /* If the user has manually loaded some metainfo to + * preview, override the featured carousel with it too, + * so they can see how it looks in the carousel. */ + gs_overview_page_override_featured (GS_OVERVIEW_PAGE (self->pages[GS_SHELL_MODE_OVERVIEW]), app); +} + +static gboolean +change_mode_idle (gpointer user_data) +{ + GsShell *shell = user_data; + + gs_page_reload (GS_PAGE (shell->pages[GS_SHELL_MODE_UPDATES])); + gs_page_reload (GS_PAGE (shell->pages[GS_SHELL_MODE_INSTALLED])); + + /* Switch only when still on the loading page, otherwise the page + could be changed from the command line or such, which would mean + hiding the chosen page. */ + if (gs_shell_get_mode (shell) == GS_SHELL_MODE_LOADING) + gs_shell_change_mode (shell, GS_SHELL_MODE_OVERVIEW, NULL, TRUE); + + return G_SOURCE_REMOVE; +} + +static void +overview_page_refresh_done (GsOverviewPage *overview_page, gpointer data) +{ + GsShell *shell = data; + + g_signal_handlers_disconnect_by_func (overview_page, overview_page_refresh_done, data); + + /* now that we're finished with the loading page, connect the reload signal handler */ + g_signal_connect (shell->plugin_loader, "reload", + G_CALLBACK (gs_shell_reload_cb), shell); + + /* schedule to change the mode in an idle callback, since it can take a + * while and this callback handler is typically called at the end of a + * long main context iteration already */ + g_idle_add (change_mode_idle, shell); +} + +static void +initial_refresh_done (GsLoadingPage *loading_page, gpointer data) +{ + GsShell *shell = data; + gboolean been_overview; + + g_signal_handlers_disconnect_by_func (loading_page, initial_refresh_done, data); + + been_overview = gs_shell_get_mode (shell) == GS_SHELL_MODE_OVERVIEW; + + g_signal_emit (shell, signals[SIGNAL_LOADED], 0); + + /* if the "loaded" signal handler didn't change the mode, kick off async + * overview page refresh, and switch to the page once done */ + if (gs_shell_get_mode (shell) == GS_SHELL_MODE_LOADING || been_overview) { + g_signal_connect (shell->pages[GS_SHELL_MODE_OVERVIEW], "refreshed", + G_CALLBACK (overview_page_refresh_done), shell); + gs_page_reload (GS_PAGE (shell->pages[GS_SHELL_MODE_OVERVIEW])); + return; + } + + /* now that we're finished with the loading page, connect the reload signal handler */ + g_signal_connect (shell->plugin_loader, "reload", + G_CALLBACK (gs_shell_reload_cb), shell); +} + +static gboolean +window_keypress_handler (GtkEventControllerKey *key_controller, + guint keyval, + guint keycode, + GdkModifierType state, + GsShell *shell) +{ + /* handle ctrl+f shortcut */ + if ((state & GDK_CONTROL_MASK) > 0 && keyval == GDK_KEY_f) { + if (!gtk_search_bar_get_search_mode (GTK_SEARCH_BAR (shell->search_bar))) { + GsShellMode mode = gs_shell_get_mode (shell); + + gtk_search_bar_set_search_mode (GTK_SEARCH_BAR (shell->search_bar), TRUE); + gtk_widget_grab_focus (shell->entry_search); + + /* If the mode doesn't have a search button, + * switch to the search page right away, + * otherwise we would show the search bar + * without a button to toggle it. */ + switch (mode) { + case GS_SHELL_MODE_OVERVIEW: + case GS_SHELL_MODE_INSTALLED: + case GS_SHELL_MODE_SEARCH: + break; + default: + gs_shell_show_search (shell, ""); + break; + } + } else { + gtk_search_bar_set_search_mode (GTK_SEARCH_BAR (shell->search_bar), FALSE); + } + return GDK_EVENT_STOP; + } + + return GDK_EVENT_PROPAGATE; +} + +static void +search_changed_handler (GObject *entry, GsShell *shell) +{ + g_autofree gchar *text = NULL; + + text = g_strdup (gtk_editable_get_text (GTK_EDITABLE (entry))); + if (strlen (text) >= 2) { + if (gs_shell_get_mode (shell) != GS_SHELL_MODE_SEARCH) { + save_back_entry (shell); + gs_shell_change_mode (shell, GS_SHELL_MODE_SEARCH, + (gpointer) text, TRUE); + } else { + gs_search_page_set_text (GS_SEARCH_PAGE (shell->pages[GS_SHELL_MODE_SEARCH]), text); + gs_page_switch_to (shell->pages[GS_SHELL_MODE_SEARCH]); + gs_page_scroll_up (shell->pages[GS_SHELL_MODE_SEARCH]); + } + } +} + +static void +search_bar_search_mode_enabled_changed_cb (GtkSearchBar *search_bar, + GParamSpec *pspec, + GsShell *shell) +{ + /* go back when exiting the search view */ + if (gs_shell_get_mode (shell) == GS_SHELL_MODE_SEARCH && + !gtk_search_bar_get_search_mode (search_bar)) + gs_shell_go_back (shell); +} + +static void +go_back (GsShell *shell) +{ + if (adw_leaflet_get_adjacent_child (shell->details_leaflet, + ADW_NAVIGATION_DIRECTION_BACK)) { + gtk_widget_activate (shell->button_back2); + } else { + gtk_widget_activate (shell->button_back); + } +} + +static gboolean +window_key_pressed_cb (GtkEventControllerKey *key_controller, + guint keyval, + guint keycode, + GdkModifierType state, + GsShell *shell) +{ + gboolean is_rtl = gtk_widget_get_direction (shell->button_back) == GTK_TEXT_DIR_RTL; + gboolean is_alt = (state & (GDK_SHIFT_MASK | GDK_CONTROL_MASK | GDK_ALT_MASK)) == GDK_ALT_MASK; + + if ((!is_rtl && is_alt && keyval == GDK_KEY_Left) || + (is_rtl && is_alt && keyval == GDK_KEY_Right) || + keyval == GDK_KEY_Back) { + go_back (shell); + return GDK_EVENT_STOP; + } + + return GDK_EVENT_PROPAGATE; +} + +static void +window_button_pressed_cb (GtkGestureClick *click_gesture, + gint n_press, + gdouble x, + gdouble y, + GsShell *shell) +{ + go_back (shell); + + gtk_gesture_set_state (GTK_GESTURE (click_gesture), GTK_EVENT_SEQUENCE_CLAIMED); +} + +static gboolean +main_window_closed_cb (GtkWidget *dialog, gpointer user_data) +{ + GsShell *shell = user_data; + + /* hide any notifications */ + g_application_withdraw_notification (g_application_get_default (), + "installed"); + g_application_withdraw_notification (g_application_get_default (), + "install-resources"); + + /* clear any in-app notification */ + gtk_revealer_set_reveal_child (GTK_REVEALER (shell->notification_event), FALSE); + + /* release our hold on the download scheduler */ +#ifdef HAVE_MOGWAI + if (shell->scheduler != NULL) { + if (shell->scheduler_invalidated_handler > 0) + g_signal_handler_disconnect (shell->scheduler, + shell->scheduler_invalidated_handler); + shell->scheduler_invalidated_handler = 0; + + if (shell->scheduler_held) + mwsc_scheduler_release_async (shell->scheduler, + NULL, + scheduler_release_cb, + g_object_ref (shell)); + else + g_clear_object (&shell->scheduler); + } +#endif /* HAVE_MOGWAI */ + + gs_shell_clean_back_entry_stack (shell); + gtk_widget_hide (dialog); + +#ifdef __GLIBC__ + /* Free unused memory with GNU extension of malloc.h */ + malloc_trim (0); +#endif + + return TRUE; +} + +static void +gs_shell_main_window_mapped_cb (GtkWidget *widget, GsShell *shell) +{ + gs_plugin_loader_set_scale (shell->plugin_loader, + (guint) gtk_widget_get_scale_factor (widget)); + + /* Set up the updates bar. Do this here rather than in gs_shell_setup() + * since we only want to hold the scheduler open while the gnome-software + * main window is visible, and not while we’re running in the background. */ +#ifdef HAVE_MOGWAI + if (shell->scheduler == NULL) + mwsc_scheduler_new_async (shell->cancellable, + (GAsyncReadyCallback) scheduler_ready_cb, + g_object_ref (shell)); +#else + gs_shell_refresh_auto_updates_ui (shell); +#endif /* HAVE_MOGWAI */ +} + +static void +gs_shell_main_window_realized_cb (GtkWidget *widget, GsShell *shell) +{ + GdkRectangle geometry; + GdkSurface *surface; + GdkDisplay *display; + GdkMonitor *monitor; + + display = gtk_widget_get_display (GTK_WIDGET (shell)); + surface = gtk_native_get_surface (GTK_NATIVE (shell)); + monitor = gdk_display_get_monitor_at_surface (display, surface); + + /* adapt the window for low and medium resolution screens */ + if (monitor != NULL) { + gdk_monitor_get_geometry (monitor, &geometry); + if (geometry.width < 800 || geometry.height < 600) { + } else if (geometry.width < 1366 || geometry.height < 768) { + gtk_window_set_default_size (GTK_WINDOW (shell), 1050, 600); + } + } +} + +typedef enum { + GS_SHELL_EVENT_BUTTON_NONE = 0, + GS_SHELL_EVENT_BUTTON_SOURCES = 1 << 0, + GS_SHELL_EVENT_BUTTON_NO_SPACE = 1 << 1, + GS_SHELL_EVENT_BUTTON_NETWORK_SETTINGS = 1 << 2, + GS_SHELL_EVENT_BUTTON_MORE_INFO = 1 << 3, + GS_SHELL_EVENT_BUTTON_RESTART_REQUIRED = 1 << 4, + GS_SHELL_EVENT_BUTTON_LAST +} GsShellEventButtons; + +static gboolean +gs_shell_has_disk_examination_app (void) +{ + g_autofree gchar *baobab = g_find_program_in_path ("baobab"); + return (baobab != NULL); +} + +static void +gs_shell_show_event_app_notify (GsShell *shell, + const gchar *title, + GsShellEventButtons buttons) +{ + /* set visible */ + gtk_revealer_set_reveal_child (GTK_REVEALER (shell->notification_event), TRUE); + + /* sources button */ + gtk_widget_set_visible (shell->button_events_sources, + (buttons & GS_SHELL_EVENT_BUTTON_SOURCES) > 0); + + /* no-space button */ + gtk_widget_set_visible (shell->button_events_no_space, + (buttons & GS_SHELL_EVENT_BUTTON_NO_SPACE) > 0 && + gs_shell_has_disk_examination_app()); + + /* network settings button */ + gtk_widget_set_visible (shell->button_events_network_settings, + (buttons & GS_SHELL_EVENT_BUTTON_NETWORK_SETTINGS) > 0); + + /* restart button */ + gtk_widget_set_visible (shell->button_events_restart_required, + (buttons & GS_SHELL_EVENT_BUTTON_RESTART_REQUIRED) > 0); + + /* more-info button */ + gtk_widget_set_visible (shell->button_events_more_info, + (buttons & GS_SHELL_EVENT_BUTTON_MORE_INFO) > 0); + + /* dismiss button */ + gtk_widget_set_visible (shell->button_events_dismiss, + (buttons & GS_SHELL_EVENT_BUTTON_RESTART_REQUIRED) == 0); + + /* set title */ + gtk_label_set_markup (GTK_LABEL (shell->label_events), title); + gtk_widget_set_visible (shell->label_events, title != NULL); +} + +void +gs_shell_show_notification (GsShell *shell, const gchar *title) +{ + gs_shell_show_event_app_notify (shell, title, GS_SHELL_EVENT_BUTTON_NONE); +} + +static gchar * +gs_shell_get_title_from_origin (GsApp *app) +{ + /* get a title, falling back */ + if (gs_app_get_origin_hostname (app) != NULL) { + /* TRANSLATORS: this is part of the in-app notification, + * where the %s is the truncated hostname, e.g. + * 'alt.fedoraproject.org' */ + return g_strdup_printf (_("“%s”"), gs_app_get_origin_hostname (app)); + } + if (gs_app_get_origin (app) != NULL) { + /* TRANSLATORS: this is part of the in-app notification, + * where the %s is the origin id, e.g. 'fedora' */ + return g_strdup_printf (_("“%s”"), gs_app_get_origin (app)); + } + return g_strdup_printf ("“%s”", gs_app_get_id (app)); +} + +/* return a name for the app, using quotes if the name is more than one word */ +static gchar * +gs_shell_get_title_from_app (GsApp *app) +{ + const gchar *tmp = gs_app_get_name (app); + if (tmp != NULL) { + if (g_strstr_len (tmp, -1, " ") != NULL) { + /* TRANSLATORS: this is part of the in-app notification, + * where the %s is a multi-word localised app name + * e.g. 'Getting things GNOME!" */ + return g_strdup_printf (_("“%s”"), tmp); + } + return g_strdup (tmp); + } + return g_strdup_printf (_("“%s”"), gs_app_get_id (app)); +} + +static gchar * +get_first_lines (const gchar *str) +{ + const gchar *end = str; + /* Some errors can have an "introduction", thus pick few initial lines, not only the first. */ + for (guint lines = 0; end != NULL && lines < 7; lines++) { + end = strchr (end, '\n'); + if (end != NULL) + end++; + } + if (end != NULL) { + g_autofree gchar *tmp = g_strndup (str, end - str); + /* Translators: The '%s' is replaced with an error message, which had been shortened. + The dots at the end are there to highlight that to the user. */ + return g_strdup_printf (_("%s…"), tmp); + } + return g_strdup (str); +} + +static void +gs_shell_append_detailed_error (GsShell *shell, GString *str, const GError *error) +{ + g_autofree gchar *text = get_first_lines (error->message); + if (text != NULL) { + g_autofree gchar *escaped = g_markup_escape_text (text, -1); + g_string_append_printf (str, ":\n%s", escaped); + } +} + +static gboolean +gs_shell_show_event_refresh (GsShell *shell, GsPluginEvent *event) +{ + GsApp *origin = gs_plugin_event_get_origin (event); + GsShellEventButtons buttons = GS_SHELL_EVENT_BUTTON_NONE; + const GError *error = gs_plugin_event_get_error (event); + GsPluginAction action = gs_plugin_event_get_action (event); + g_autofree gchar *str_origin = NULL; + g_autoptr(GString) str = g_string_new (NULL); + + /* ignore any errors from background downloads */ + if (!gs_plugin_event_has_flag (event, GS_PLUGIN_EVENT_FLAG_INTERACTIVE)) + return FALSE; + + if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_DOWNLOAD_FAILED)) { + if (origin != NULL) { + str_origin = gs_shell_get_title_from_origin (origin); + if (gs_app_get_bundle_kind (origin) == AS_BUNDLE_KIND_CABINET) { + /* TRANSLATORS: failure text for the in-app notification, + * where the %s is the source (e.g. "alt.fedoraproject.org") */ + g_string_append_printf (str, _("Unable to download " + "firmware updates from %s"), + str_origin); + } else { + /* TRANSLATORS: failure text for the in-app notification, + * where the %s is the source (e.g. "alt.fedoraproject.org") */ + g_string_append_printf (str, _("Unable to download updates from %s"), + str_origin); + if (!gs_app_has_management_plugin (origin, NULL)) + buttons |= GS_SHELL_EVENT_BUTTON_SOURCES; + } + } else { + /* TRANSLATORS: failure text for the in-app notification */ + g_string_append (str, _("Unable to download updates")); + } + gs_shell_append_detailed_error (shell, str, error); + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_NO_NETWORK)) { + /* TRANSLATORS: failure text for the in-app notification */ + g_string_append (str, _("Unable to download updates: " + "internet access was required but wasn’t available")); + buttons |= GS_SHELL_EVENT_BUTTON_NETWORK_SETTINGS; + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_NO_SPACE)) { + if (origin != NULL) { + str_origin = gs_shell_get_title_from_origin (origin); + /* TRANSLATORS: failure text for the in-app notification, + * where the %s is the source (e.g. "alt.fedoraproject.org") */ + g_string_append_printf (str, _("Unable to download updates " + "from %s: not enough disk space"), + str_origin); + } else { + /* TRANSLATORS: failure text for the in-app notification */ + g_string_append (str, _("Unable to download updates: " + "not enough disk space")); + } + buttons |= GS_SHELL_EVENT_BUTTON_NO_SPACE; + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_AUTH_REQUIRED)) { + /* TRANSLATORS: failure text for the in-app notification */ + g_string_append (str, _("Unable to download updates: " + "authentication was required")); + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_AUTH_INVALID)) { + /* TRANSLATORS: failure text for the in-app notification */ + g_string_append (str, _("Unable to download updates: " + "authentication was invalid")); + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_NO_SECURITY)) { + /* TRANSLATORS: failure text for the in-app notification */ + g_string_append (str, _("Unable to download updates: you do not have" + " permission to install software")); + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_CANCELLED) || + g_error_matches (error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) { + /* Do nothing. */ + } else { + if (action == GS_PLUGIN_ACTION_DOWNLOAD) { + /* TRANSLATORS: failure text for the in-app notification */ + g_string_append (str, _("Unable to download updates")); + } else { + /* TRANSLATORS: failure text for the in-app notification */ + g_string_append (str, _("Unable to get list of updates")); + } + gs_shell_append_detailed_error (shell, str, error); + } + + if (str->len == 0) + return FALSE; + + /* add more-info button */ + if (origin != NULL) { + const gchar *uri = gs_app_get_url (origin, AS_URL_KIND_HELP); + if (uri != NULL) { + g_free (shell->events_info_uri); + shell->events_info_uri = g_strdup (uri); + buttons |= GS_SHELL_EVENT_BUTTON_MORE_INFO; + } + } + + /* show in-app notification */ + gs_shell_show_event_app_notify (shell, str->str, buttons); + return TRUE; +} + +static gboolean +gs_shell_show_event_install (GsShell *shell, GsPluginEvent *event) +{ + GsApp *app = gs_plugin_event_get_app (event); + GsApp *origin = gs_plugin_event_get_origin (event); + GsShellEventButtons buttons = GS_SHELL_EVENT_BUTTON_NONE; + const GError *error = gs_plugin_event_get_error (event); + g_autofree gchar *str_app = NULL; + g_autofree gchar *str_origin = NULL; + g_autoptr(GString) str = g_string_new (NULL); + + str_app = gs_shell_get_title_from_app (app); + + if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_DOWNLOAD_FAILED)) { + if (origin != NULL) { + str_origin = gs_shell_get_title_from_origin (origin); + /* TRANSLATORS: failure text for the in-app notification, + * where the first %s is the application name (e.g. "GIMP") and + * the second %s is the origin, e.g. "Fedora Project [fedoraproject.org]" */ + g_string_append_printf (str, _("Unable to install %s as " + "download failed from %s"), + str_app, str_origin); + } else { + /* TRANSLATORS: failure text for the in-app notification, + * where the %s is the application name (e.g. "GIMP") */ + g_string_append_printf (str, _("Unable to install %s " + "as download failed"), + str_app); + } + gs_shell_append_detailed_error (shell, str, error); + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_NOT_SUPPORTED)) { + if (origin != NULL) { + str_origin = gs_shell_get_title_from_origin (origin); + /* TRANSLATORS: failure text for the in-app notification, + * where the first %s is the application name (e.g. "GIMP") + * and the second %s is the name of the runtime, e.g. + * "GNOME SDK [flatpak.gnome.org]" */ + g_string_append_printf (str, _("Unable to install %s as " + "runtime %s not available"), + str_app, str_origin); + } else { + /* TRANSLATORS: failure text for the in-app notification, + * where the %s is the application name (e.g. "GIMP") */ + g_string_append_printf (str, _("Unable to install %s " + "as not supported"), + str_app); + } + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_NO_NETWORK)) { + /* TRANSLATORS: failure text for the in-app notification */ + g_string_append (str, _("Unable to install: internet access was " + "required but wasn’t available")); + buttons |= GS_SHELL_EVENT_BUTTON_NETWORK_SETTINGS; + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_INVALID_FORMAT)) { + /* TRANSLATORS: failure text for the in-app notification */ + g_string_append (str, _("Unable to install: the application has an invalid format")); + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_NO_SPACE)) { + /* TRANSLATORS: failure text for the in-app notification, + * where the %s is the application name (e.g. "GIMP") */ + g_string_append_printf (str, _("Unable to install %s: " + "not enough disk space"), + str_app); + buttons |= GS_SHELL_EVENT_BUTTON_NO_SPACE; + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_AUTH_REQUIRED)) { + /* TRANSLATORS: failure text for the in-app notification */ + g_string_append_printf (str, _("Unable to install %s: " + "authentication was required"), + str_app); + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_AUTH_INVALID)) { + /* TRANSLATORS: failure text for the in-app notification, + * where the %s is the application name (e.g. "GIMP") */ + g_string_append_printf (str, _("Unable to install %s: " + "authentication was invalid"), + str_app); + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_NO_SECURITY)) { + /* TRANSLATORS: failure text for the in-app notification, + * where the %s is the application name (e.g. "GIMP") */ + g_string_append_printf (str, _("Unable to install %s: " + "you do not have permission to " + "install software"), + str_app); + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_AC_POWER_REQUIRED)) { + /* TRANSLATORS: failure text for the in-app notification, + * where the %s is the application name (e.g. "Dell XPS 13") */ + g_string_append_printf (str, _("Unable to install %s: " + "AC power is required"), + str_app); + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_BATTERY_LEVEL_TOO_LOW)) { + /* TRANSLATORS: failure text for the in-app notification, + * where the %s is the application name (e.g. "Dell XPS 13") */ + g_string_append_printf (str, _("Unable to install %s: " + "The battery level is too low"), + str_app); + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_CANCELLED) || + g_error_matches (error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) { + /* Do nothing. */ + } else { + /* TRANSLATORS: failure text for the in-app notification, + * where the %s is the application name (e.g. "GIMP") */ + g_string_append_printf (str, _("Unable to install %s"), str_app); + gs_shell_append_detailed_error (shell, str, error); + } + + if (str->len == 0) + return FALSE; + + /* add more-info button */ + if (origin != NULL) { + const gchar *uri = gs_app_get_url (origin, AS_URL_KIND_HELP); + if (uri != NULL) { + g_free (shell->events_info_uri); + shell->events_info_uri = g_strdup (uri); + buttons |= GS_SHELL_EVENT_BUTTON_MORE_INFO; + } + } + + /* show in-app notification */ + gs_shell_show_event_app_notify (shell, str->str, buttons); + return TRUE; +} + +static gboolean +gs_shell_show_event_update (GsShell *shell, GsPluginEvent *event) +{ + GsApp *app = gs_plugin_event_get_app (event); + GsApp *origin = gs_plugin_event_get_origin (event); + GsShellEventButtons buttons = GS_SHELL_EVENT_BUTTON_NONE; + const GError *error = gs_plugin_event_get_error (event); + g_autofree gchar *str_app = NULL; + g_autofree gchar *str_origin = NULL; + g_autoptr(GString) str = g_string_new (NULL); + + /* ignore any errors from background downloads */ + if (!gs_plugin_event_has_flag (event, GS_PLUGIN_EVENT_FLAG_INTERACTIVE)) + return FALSE; + + if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_DOWNLOAD_FAILED)) { + if (app != NULL && origin != NULL) { + str_app = gs_shell_get_title_from_app (app); + str_origin = gs_shell_get_title_from_origin (origin); + /* TRANSLATORS: failure text for the in-app notification, + * where the first %s is the app name (e.g. "GIMP") and + * the second %s is the origin, e.g. "Fedora" or + * "Fedora Project [fedoraproject.org]" */ + g_string_append_printf (str, _("Unable to update %s from %s as download failed"), + str_app, str_origin); + buttons = TRUE; + } else if (app != NULL) { + str_app = gs_shell_get_title_from_app (app); + /* TRANSLATORS: failure text for the in-app notification, + * where the %s is the application name (e.g. "GIMP") */ + g_string_append_printf (str, _("Unable to update %s as download failed"), + str_app); + } else if (origin != NULL) { + str_origin = gs_shell_get_title_from_origin (origin); + /* TRANSLATORS: failure text for the in-app notification, + * where the %s is the origin, e.g. "Fedora" or + * "Fedora Project [fedoraproject.org]" */ + g_string_append_printf (str, _("Unable to install updates from %s as download failed"), + str_origin); + } else { + /* TRANSLATORS: failure text for the in-app notification */ + g_string_append_printf (str, _("Unable to install updates as download failed")); + } + gs_shell_append_detailed_error (shell, str, error); + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_NO_NETWORK)) { + /* TRANSLATORS: failure text for the in-app notification */ + g_string_append (str, _("Unable to update: " + "internet access was required but " + "wasn’t available")); + buttons |= GS_SHELL_EVENT_BUTTON_NETWORK_SETTINGS; + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_NO_SPACE)) { + if (app != NULL) { + str_app = gs_shell_get_title_from_app (app); + /* TRANSLATORS: failure text for the in-app notification, + * where the %s is the application name (e.g. "GIMP") */ + g_string_append_printf (str, _("Unable to update %s: " + "not enough disk space"), + str_app); + } else { + /* TRANSLATORS: failure text for the in-app notification */ + g_string_append_printf (str, _("Unable to install updates: " + "not enough disk space")); + } + buttons |= GS_SHELL_EVENT_BUTTON_NO_SPACE; + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_AUTH_REQUIRED)) { + if (app != NULL) { + str_app = gs_shell_get_title_from_app (app); + /* TRANSLATORS: failure text for the in-app notification, + * where the %s is the application name (e.g. "GIMP") */ + g_string_append_printf (str, _("Unable to update %s: " + "authentication was required"), + str_app); + } else { + /* TRANSLATORS: failure text for the in-app notification */ + g_string_append_printf (str, _("Unable to install updates: " + "authentication was required")); + } + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_AUTH_INVALID)) { + if (app != NULL) { + str_app = gs_shell_get_title_from_app (app); + /* TRANSLATORS: failure text for the in-app notification, + * where the %s is the application name (e.g. "GIMP") */ + g_string_append_printf (str, _("Unable to update %s: " + "authentication was invalid"), + str_app); + } else { + /* TRANSLATORS: failure text for the in-app notification */ + g_string_append_printf (str, _("Unable to install updates: " + "authentication was invalid")); + } + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_NO_SECURITY)) { + if (app != NULL) { + str_app = gs_shell_get_title_from_app (app); + /* TRANSLATORS: failure text for the in-app notification, + * where the %s is the application name (e.g. "GIMP") */ + g_string_append_printf (str, _("Unable to update %s: " + "you do not have permission to " + "update software"), + str_app); + } else { + /* TRANSLATORS: failure text for the in-app notification */ + g_string_append_printf (str, _("Unable to install updates: " + "you do not have permission to " + "update software")); + } + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_AC_POWER_REQUIRED)) { + if (app != NULL) { + str_app = gs_shell_get_title_from_app (app); + /* TRANSLATORS: failure text for the in-app notification, + * where the %s is the application name (e.g. "Dell XPS 13") */ + g_string_append_printf (str, _("Unable to update %s: " + "AC power is required"), + str_app); + } else { + /* TRANSLATORS: failure text for the in-app notification, + * where the %s is the application name (e.g. "Dell XPS 13") */ + g_string_append_printf (str, _("Unable to install updates: " + "AC power is required")); + } + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_BATTERY_LEVEL_TOO_LOW)) { + if (app != NULL) { + str_app = gs_shell_get_title_from_app (app); + /* TRANSLATORS: failure text for the in-app notification, + * where the %s is the application name (e.g. "Dell XPS 13") */ + g_string_append_printf (str, _("Unable to update %s: " + "The battery level is too low"), + str_app); + } else { + /* TRANSLATORS: failure text for the in-app notification, + * where the %s is the application name (e.g. "Dell XPS 13") */ + g_string_append_printf (str, _("Unable to install updates: " + "The battery level is too low")); + } + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_CANCELLED) || + g_error_matches (error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) { + /* Do nothing. */ + } else { + if (app != NULL) { + str_app = gs_shell_get_title_from_app (app); + /* TRANSLATORS: failure text for the in-app notification, + * where the %s is the application name (e.g. "GIMP") */ + g_string_append_printf (str, _("Unable to update %s"), str_app); + } else { + /* TRANSLATORS: failure text for the in-app notification */ + g_string_append_printf (str, _("Unable to install updates")); + } + gs_shell_append_detailed_error (shell, str, error); + } + + if (str->len == 0) + return FALSE; + + /* add more-info button */ + if (origin != NULL) { + const gchar *uri = gs_app_get_url (origin, AS_URL_KIND_HELP); + if (uri != NULL) { + g_free (shell->events_info_uri); + shell->events_info_uri = g_strdup (uri); + buttons |= GS_SHELL_EVENT_BUTTON_MORE_INFO; + } + } + + /* show in-app notification */ + gs_shell_show_event_app_notify (shell, str->str, buttons); + return TRUE; +} + +static gboolean +gs_shell_show_event_upgrade (GsShell *shell, GsPluginEvent *event) +{ + GsApp *app = gs_plugin_event_get_app (event); + GsApp *origin = gs_plugin_event_get_origin (event); + GsShellEventButtons buttons = GS_SHELL_EVENT_BUTTON_NONE; + const GError *error = gs_plugin_event_get_error (event); + g_autoptr(GString) str = g_string_new (NULL); + g_autofree gchar *str_app = NULL; + g_autofree gchar *str_origin = NULL; + + str_app = g_strdup_printf ("%s %s", gs_app_get_name (app), gs_app_get_version (app)); + + if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_DOWNLOAD_FAILED)) { + if (origin != NULL) { + str_origin = gs_shell_get_title_from_origin (origin); + /* TRANSLATORS: failure text for the in-app notification, + * where the first %s is the distro name (e.g. "Fedora 25") and + * the second %s is the origin, e.g. "Fedora Project [fedoraproject.org]" */ + g_string_append_printf (str, _("Unable to upgrade to %s from %s"), + str_app, str_origin); + } else { + /* TRANSLATORS: failure text for the in-app notification, + * where the %s is the app name (e.g. "GIMP") */ + g_string_append_printf (str, _("Unable to upgrade to %s " + "as download failed"), + str_app); + } + gs_shell_append_detailed_error (shell, str, error); + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_NO_NETWORK)) { + /* TRANSLATORS: failure text for the in-app notification, + * where the %s is the distro name (e.g. "Fedora 25") */ + g_string_append_printf (str, _("Unable to upgrade to %s: " + "internet access was required but " + "wasn’t available"), + str_app); + buttons |= GS_SHELL_EVENT_BUTTON_NETWORK_SETTINGS; + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_NO_SPACE)) { + /* TRANSLATORS: failure text for the in-app notification, + * where the %s is the distro name (e.g. "Fedora 25") */ + g_string_append_printf (str, _("Unable to upgrade to %s: " + "not enough disk space"), + str_app); + buttons |= GS_SHELL_EVENT_BUTTON_NO_SPACE; + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_AUTH_REQUIRED)) { + /* TRANSLATORS: failure text for the in-app notification, + * where the %s is the distro name (e.g. "Fedora 25") */ + g_string_append_printf (str, _("Unable to upgrade to %s: " + "authentication was required"), + str_app); + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_AUTH_INVALID)) { + /* TRANSLATORS: failure text for the in-app notification, + * where the %s is the distro name (e.g. "Fedora 25") */ + g_string_append_printf (str, _("Unable to upgrade to %s: " + "authentication was invalid"), + str_app); + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_NO_SECURITY)) { + /* TRANSLATORS: failure text for the in-app notification, + * where the %s is the distro name (e.g. "Fedora 25") */ + g_string_append_printf (str, _("Unable to upgrade to %s: " + "you do not have permission to upgrade"), + str_app); + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_AC_POWER_REQUIRED)) { + /* TRANSLATORS: failure text for the in-app notification, + * where the %s is the distro name (e.g. "Fedora 25") */ + g_string_append_printf (str, _("Unable to upgrade to %s: " + "AC power is required"), + str_app); + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_BATTERY_LEVEL_TOO_LOW)) { + /* TRANSLATORS: failure text for the in-app notification, + * where the %s is the distro name (e.g. "Fedora 25") */ + g_string_append_printf (str, _("Unable to upgrade to %s: " + "The battery level is too low"), + str_app); + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_CANCELLED) || + g_error_matches (error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) { + /* Do nothing. */ + } else { + /* TRANSLATORS: failure text for the in-app notification, + * where the %s is the distro name (e.g. "Fedora 25") */ + g_string_append_printf (str, _("Unable to upgrade to %s"), str_app); + gs_shell_append_detailed_error (shell, str, error); + } + + if (str->len == 0) + return FALSE; + + /* add more-info button */ + if (origin != NULL) { + const gchar *uri = gs_app_get_url (origin, AS_URL_KIND_HELP); + if (uri != NULL) { + g_free (shell->events_info_uri); + shell->events_info_uri = g_strdup (uri); + buttons |= GS_SHELL_EVENT_BUTTON_MORE_INFO; + } + } + + /* show in-app notification */ + gs_shell_show_event_app_notify (shell, str->str, buttons); + return TRUE; +} + +static gboolean +gs_shell_show_event_remove (GsShell *shell, GsPluginEvent *event) +{ + GsApp *app = gs_plugin_event_get_app (event); + GsApp *origin = gs_plugin_event_get_origin (event); + GsShellEventButtons buttons = GS_SHELL_EVENT_BUTTON_NONE; + const GError *error = gs_plugin_event_get_error (event); + g_autoptr(GString) str = g_string_new (NULL); + g_autofree gchar *str_app = NULL; + + str_app = gs_shell_get_title_from_app (app); + + if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_AUTH_REQUIRED)) { + /* TRANSLATORS: failure text for the in-app notification, + * where the %s is the application name (e.g. "GIMP") */ + g_string_append_printf (str, _("Unable to remove %s: authentication was required"), + str_app); + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_AUTH_INVALID)) { + /* TRANSLATORS: failure text for the in-app notification, + * where the %s is the application name (e.g. "GIMP") */ + g_string_append_printf (str, _("Unable to remove %s: authentication was invalid"), + str_app); + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_NO_SECURITY)) { + /* TRANSLATORS: failure text for the in-app notification, + * where the %s is the application name (e.g. "GIMP") */ + g_string_append_printf (str, _("Unable to remove %s: you do not have" + " permission to remove software"), + str_app); + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_AC_POWER_REQUIRED)) { + /* TRANSLATORS: failure text for the in-app notification, + * where the %s is the application name (e.g. "GIMP") */ + g_string_append_printf (str, _("Unable to remove %s: " + "AC power is required"), + str_app); + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_BATTERY_LEVEL_TOO_LOW)) { + /* TRANSLATORS: failure text for the in-app notification, + * where the %s is the application name (e.g. "GIMP") */ + g_string_append_printf (str, _("Unable to remove %s: " + "The battery level is too low"), + str_app); + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_CANCELLED) || + g_error_matches (error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) { + /* Do nothing. */ + } else { + /* non-interactive generic */ + if (!gs_plugin_event_has_flag (event, GS_PLUGIN_EVENT_FLAG_INTERACTIVE)) + return FALSE; + /* TRANSLATORS: failure text for the in-app notification, + * where the %s is the application name (e.g. "GIMP") */ + g_string_append_printf (str, _("Unable to remove %s"), str_app); + gs_shell_append_detailed_error (shell, str, error); + } + + if (str->len == 0) + return FALSE; + + /* add more-info button */ + if (origin != NULL) { + const gchar *uri = gs_app_get_url (origin, AS_URL_KIND_HELP); + if (uri != NULL) { + g_free (shell->events_info_uri); + shell->events_info_uri = g_strdup (uri); + buttons |= GS_SHELL_EVENT_BUTTON_MORE_INFO; + } + } + + /* show in-app notification */ + gs_shell_show_event_app_notify (shell, str->str, buttons); + return TRUE; +} + +static gboolean +gs_shell_show_event_launch (GsShell *shell, GsPluginEvent *event) +{ + GsApp *app = gs_plugin_event_get_app (event); + GsApp *origin = gs_plugin_event_get_origin (event); + GsShellEventButtons buttons = GS_SHELL_EVENT_BUTTON_NONE; + const GError *error = gs_plugin_event_get_error (event); + g_autoptr(GString) str = g_string_new (NULL); + g_autofree gchar *str_app = NULL; + g_autofree gchar *str_origin = NULL; + + if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_NOT_SUPPORTED)) { + if (app != NULL && origin != NULL) { + str_app = gs_shell_get_title_from_app (app); + str_origin = gs_shell_get_title_from_origin (origin); + /* TRANSLATORS: failure text for the in-app notification, + * where the first %s is the application name (e.g. "GIMP") + * and the second %s is the name of the runtime, e.g. + * "GNOME SDK [flatpak.gnome.org]" */ + g_string_append_printf (str, _("Unable to launch %s: %s is not installed"), + str_app, + str_origin); + } else { + /* non-interactive generic */ + if (!gs_plugin_event_has_flag (event, GS_PLUGIN_EVENT_FLAG_INTERACTIVE)) + return FALSE; + /* TRANSLATORS: we failed to get a proper error code */ + g_string_append (str, _("Sorry, something went wrong")); + gs_shell_append_detailed_error (shell, str, error); + } + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_NO_SPACE)) { + /* TRANSLATORS: failure text for the in-app notification */ + g_string_append (str, _("Not enough disk space — free up some space " + "and try again")); + buttons |= GS_SHELL_EVENT_BUTTON_NO_SPACE; + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_CANCELLED) || + g_error_matches (error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) { + /* Do nothing. */ + } else { + /* non-interactive generic */ + if (!gs_plugin_event_has_flag (event, GS_PLUGIN_EVENT_FLAG_INTERACTIVE)) + return FALSE; + /* TRANSLATORS: we failed to get a proper error code */ + g_string_append (str, _("Sorry, something went wrong")); + gs_shell_append_detailed_error (shell, str, error); + } + + if (str->len == 0) + return FALSE; + + /* add more-info button */ + if (origin != NULL) { + const gchar *uri = gs_app_get_url (origin, AS_URL_KIND_HELP); + if (uri != NULL) { + g_free (shell->events_info_uri); + shell->events_info_uri = g_strdup (uri); + buttons |= GS_SHELL_EVENT_BUTTON_MORE_INFO; + } + } + + /* show in-app notification */ + gs_shell_show_event_app_notify (shell, str->str, buttons); + return TRUE; +} + +static gboolean +gs_shell_show_event_file_to_app (GsShell *shell, GsPluginEvent *event) +{ + GsShellEventButtons buttons = GS_SHELL_EVENT_BUTTON_NONE; + const GError *error = gs_plugin_event_get_error (event); + g_autoptr(GString) str = g_string_new (NULL); + + if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_NOT_SUPPORTED)) { + /* TRANSLATORS: failure text for the in-app notification */ + g_string_append (str, _("Failed to install file: not supported")); + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_NO_SECURITY)) { + /* TRANSLATORS: failure text for the in-app notification */ + g_string_append (str, _("Failed to install file: authentication failed")); + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_NO_SPACE)) { + /* TRANSLATORS: failure text for the in-app notification */ + g_string_append (str, _("Not enough disk space — free up some space " + "and try again")); + buttons |= GS_SHELL_EVENT_BUTTON_NO_SPACE; + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_CANCELLED) || + g_error_matches (error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) { + /* Do nothing. */ + } else { + /* non-interactive generic */ + if (!gs_plugin_event_has_flag (event, GS_PLUGIN_EVENT_FLAG_INTERACTIVE)) + return FALSE; + /* TRANSLATORS: we failed to get a proper error code */ + g_string_append (str, _("Sorry, something went wrong")); + gs_shell_append_detailed_error (shell, str, error); + } + + if (str->len == 0) + return FALSE; + + /* show in-app notification */ + gs_shell_show_event_app_notify (shell, str->str, buttons); + return TRUE; +} + +static gboolean +gs_shell_show_event_url_to_app (GsShell *shell, GsPluginEvent *event) +{ + GsShellEventButtons buttons = GS_SHELL_EVENT_BUTTON_NONE; + const GError *error = gs_plugin_event_get_error (event); + g_autoptr(GString) str = g_string_new (NULL); + + if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_NOT_SUPPORTED)) { + /* TRANSLATORS: failure text for the in-app notification */ + g_string_append (str, _("Failed to install: not supported")); + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_NO_SECURITY)) { + /* TRANSLATORS: failure text for the in-app notification */ + g_string_append (str, _("Failed to install: authentication failed")); + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_NO_SPACE)) { + /* TRANSLATORS: failure text for the in-app notification */ + g_string_append (str, _("Not enough disk space — free up some space " + "and try again")); + buttons |= GS_SHELL_EVENT_BUTTON_NO_SPACE; + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_CANCELLED) || + g_error_matches (error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) { + /* Do nothing. */ + } else { + /* non-interactive generic */ + if (!gs_plugin_event_has_flag (event, GS_PLUGIN_EVENT_FLAG_INTERACTIVE)) + return FALSE; + /* TRANSLATORS: we failed to get a proper error code */ + g_string_append (str, _("Sorry, something went wrong")); + gs_shell_append_detailed_error (shell, str, error); + } + + if (str->len == 0) + return FALSE; + + /* show in-app notification */ + gs_shell_show_event_app_notify (shell, str->str, buttons); + return TRUE; +} + +static gboolean +gs_shell_show_event_fallback (GsShell *shell, GsPluginEvent *event) +{ + GsApp *origin = gs_plugin_event_get_origin (event); + GsShellEventButtons buttons = GS_SHELL_EVENT_BUTTON_NONE; + const GError *error = gs_plugin_event_get_error (event); + g_autoptr(GString) str = g_string_new (NULL); + g_autofree gchar *str_origin = NULL; + + if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_DOWNLOAD_FAILED)) { + if (origin != NULL) { + str_origin = gs_shell_get_title_from_origin (origin); + /* TRANSLATORS: failure text for the in-app notification, + * the %s is the origin, e.g. "Fedora" or + * "Fedora Project [fedoraproject.org]" */ + g_string_append_printf (str, _("Unable to contact %s"), + str_origin); + } + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_NO_SPACE)) { + /* TRANSLATORS: failure text for the in-app notification */ + g_string_append (str, _("Not enough disk space — free up some space " + "and try again")); + buttons |= GS_SHELL_EVENT_BUTTON_NO_SPACE; + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_RESTART_REQUIRED)) { + /* TRANSLATORS: failure text for the in-app notification, where the 'Software' means this application, aka 'GNOME Software'. */ + g_string_append (str, _("Software needs to be restarted to use new plugins.")); + buttons |= GS_SHELL_EVENT_BUTTON_RESTART_REQUIRED; + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_AC_POWER_REQUIRED)) { + /* TRANSLATORS: need to be connected to the AC power */ + g_string_append (str, _("AC power is required")); + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_BATTERY_LEVEL_TOO_LOW)) { + /* TRANSLATORS: not enough juice to do this safely */ + g_string_append (str, _("The battery level is too low")); + } else if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_CANCELLED) || + g_error_matches (error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) { + /* Do nothing. */ + } else { + /* non-interactive generic */ + if (!gs_plugin_event_has_flag (event, GS_PLUGIN_EVENT_FLAG_INTERACTIVE)) + return FALSE; + /* TRANSLATORS: we failed to get a proper error code */ + g_string_append (str, _("Sorry, something went wrong")); + gs_shell_append_detailed_error (shell, str, error); + } + + if (str->len == 0) + return FALSE; + + /* add more-info button */ + if (origin != NULL) { + const gchar *uri = gs_app_get_url (origin, AS_URL_KIND_HELP); + if (uri != NULL) { + g_free (shell->events_info_uri); + shell->events_info_uri = g_strdup (uri); + buttons |= GS_SHELL_EVENT_BUTTON_MORE_INFO; + } + } + + /* show in-app notification */ + gs_shell_show_event_app_notify (shell, str->str, buttons); + return TRUE; +} + +static gboolean +gs_shell_show_event (GsShell *shell, GsPluginEvent *event) +{ + const GError *error; + GsPluginAction action; + GsPluginJob *job; + + /* get error */ + error = gs_plugin_event_get_error (event); + if (error == NULL) + return FALSE; + + /* name and shame the plugin */ + if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_TIMED_OUT)) { + gs_shell_show_event_app_notify (shell, error->message, + GS_SHELL_EVENT_BUTTON_NONE); + return TRUE; + } + + job = gs_plugin_event_get_job (event); + if (GS_IS_PLUGIN_JOB_REFRESH_METADATA (job)) + return gs_shell_show_event_refresh (shell, event); + + /* split up the events by action */ + action = gs_plugin_event_get_action (event); + switch (action) { + case GS_PLUGIN_ACTION_DOWNLOAD: + return gs_shell_show_event_refresh (shell, event); + case GS_PLUGIN_ACTION_INSTALL: + case GS_PLUGIN_ACTION_INSTALL_REPO: + case GS_PLUGIN_ACTION_ENABLE_REPO: + return gs_shell_show_event_install (shell, event); + case GS_PLUGIN_ACTION_UPDATE: + return gs_shell_show_event_update (shell, event); + case GS_PLUGIN_ACTION_UPGRADE_DOWNLOAD: + return gs_shell_show_event_upgrade (shell, event); + case GS_PLUGIN_ACTION_REMOVE: + case GS_PLUGIN_ACTION_REMOVE_REPO: + case GS_PLUGIN_ACTION_DISABLE_REPO: + return gs_shell_show_event_remove (shell, event); + case GS_PLUGIN_ACTION_LAUNCH: + return gs_shell_show_event_launch (shell, event); + case GS_PLUGIN_ACTION_FILE_TO_APP: + return gs_shell_show_event_file_to_app (shell, event); + case GS_PLUGIN_ACTION_URL_TO_APP: + return gs_shell_show_event_url_to_app (shell, event); + default: + break; + } + + /* capture some warnings every time */ + return gs_shell_show_event_fallback (shell, event); +} + +static void +gs_shell_rescan_events (GsShell *shell) +{ + g_autoptr(GsPluginEvent) event = NULL; + + /* find the first active event and show it */ + event = gs_plugin_loader_get_event_default (shell->plugin_loader); + if (event != NULL) { + if (!gs_shell_show_event (shell, event)) { + GsPluginAction action = gs_plugin_event_get_action (event); + const GError *error = gs_plugin_event_get_error (event); + if (error != NULL && + !g_error_matches (error, + GS_PLUGIN_ERROR, + GS_PLUGIN_ERROR_CANCELLED) && + !g_error_matches (error, + G_IO_ERROR, + G_IO_ERROR_CANCELLED)) { + g_warning ("not handling error %s for action %s: %s", + gs_plugin_error_to_string (error->code), + gs_plugin_action_to_string (action), + error->message); + } + gs_plugin_event_add_flag (event, GS_PLUGIN_EVENT_FLAG_INVALID); + return; + } + gs_plugin_event_add_flag (event, GS_PLUGIN_EVENT_FLAG_VISIBLE); + return; + } + + /* nothing to show */ + gtk_revealer_set_reveal_child (GTK_REVEALER (shell->notification_event), FALSE); +} + +static void +gs_shell_events_notify_cb (GsPluginLoader *plugin_loader, + GParamSpec *pspec, + GsShell *shell) +{ + gs_shell_rescan_events (shell); +} + +static void +gs_shell_plugin_event_dismissed_cb (GtkButton *button, GsShell *shell) +{ + guint i; + g_autoptr(GPtrArray) events = NULL; + + /* mark any events currently showing as invalid */ + events = gs_plugin_loader_get_events (shell->plugin_loader); + for (i = 0; i < events->len; i++) { + GsPluginEvent *event = g_ptr_array_index (events, i); + if (gs_plugin_event_has_flag (event, GS_PLUGIN_EVENT_FLAG_VISIBLE)) { + gs_plugin_event_add_flag (event, GS_PLUGIN_EVENT_FLAG_INVALID); + gs_plugin_event_remove_flag (event, GS_PLUGIN_EVENT_FLAG_VISIBLE); + } + } + + /* show the next event */ + gs_shell_rescan_events (shell); +} + +static void +gs_shell_setup_pages (GsShell *shell) +{ + for (gsize i = 0; i < G_N_ELEMENTS (shell->pages); i++) { + g_autoptr(GError) error = NULL; + GsPage *page = shell->pages[i]; + if (page != NULL && + !gs_page_setup (page, shell, + shell->plugin_loader, + shell->cancellable, + &error)) { + g_warning ("Failed to setup panel: %s", error->message); + } + } +} + +static void +gs_shell_add_about_menu_item (GsShell *shell) +{ + g_autoptr(GMenuItem) menu_item = NULL; + + /* TRANSLATORS: this is the menu item that opens the about window */ + menu_item = g_menu_item_new (_("About Software"), "app.about"); + g_menu_append_item (G_MENU (shell->primary_menu), menu_item); +} + +static void +updates_page_notify_counter_cb (GObject *obj, + GParamSpec *pspec, + gpointer user_data) +{ + GsPage *page = GS_PAGE (obj); + GsShell *shell = GS_SHELL (user_data); + AdwViewStackPage *stack_page; + gboolean needs_attention; + + /* Update the needs-attention child property of the page in the + * AdwViewStack. There’s no need to account for whether it’s the currently + * visible page, as the CSS rules do that for us. This can’t be a simple + * property binding, though, as it’s a binding between an object + * property and a child property. */ + needs_attention = (gs_page_get_counter (page) > 0); + + stack_page = adw_view_stack_get_page (shell->stack_main, GTK_WIDGET (page)); + adw_view_stack_page_set_needs_attention (stack_page, needs_attention); +} + +static void +category_page_app_clicked_cb (GsCategoryPage *page, + GsApp *app, + gpointer user_data) +{ + GsShell *shell = GS_SHELL (user_data); + + gs_shell_show_app (shell, app); +} + +static void +details_page_app_clicked_cb (GsDetailsPage *page, + GsApp *app, + gpointer user_data) +{ + GsShell *shell = GS_SHELL (user_data); + + gs_shell_show_app (shell, app); +} + +void +gs_shell_setup (GsShell *shell, GsPluginLoader *plugin_loader, GCancellable *cancellable) +{ + GsOdrsProvider *odrs_provider; + + g_return_if_fail (GS_IS_SHELL (shell)); + + shell->plugin_loader = g_object_ref (plugin_loader); + g_signal_connect_object (shell->plugin_loader, "notify::events", + G_CALLBACK (gs_shell_events_notify_cb), + shell, 0); + g_signal_connect_object (shell->plugin_loader, "notify::network-metered", + G_CALLBACK (gs_shell_network_metered_notify_cb), + shell, 0); + g_signal_connect_object (shell->plugin_loader, "basic-auth-start", + G_CALLBACK (gs_shell_basic_auth_start_cb), + shell, 0); + g_signal_connect_object (shell->plugin_loader, "ask-untrusted", + G_CALLBACK (gs_shell_ask_untrusted_cb), + shell, 0); + + g_object_bind_property (shell->plugin_loader, "allow-updates", + shell->pages[GS_SHELL_MODE_UPDATES], "visible", + G_BINDING_SYNC_CREATE); + + shell->cancellable = g_object_ref (cancellable); + + shell->settings = g_settings_new ("org.gnome.software"); + + /* set up pages */ + gs_shell_setup_pages (shell); + + /* set up the metered data info bar and mogwai */ + g_signal_connect (shell->settings, "changed::download-updates", + (GCallback) gs_shell_download_updates_changed_cb, shell); + + odrs_provider = gs_plugin_loader_get_odrs_provider (shell->plugin_loader); + gs_details_page_set_odrs_provider (GS_DETAILS_PAGE (shell->pages[GS_SHELL_MODE_DETAILS]), odrs_provider); + gs_moderate_page_set_odrs_provider (GS_MODERATE_PAGE (shell->pages[GS_SHELL_MODE_MODERATE]), odrs_provider); + + /* coldplug */ + gs_shell_rescan_events (shell); + + /* primary menu */ + gs_shell_add_about_menu_item (shell); + + if (g_settings_get_boolean (shell->settings, "download-updates")) { + /* show loading page, which triggers the initial refresh */ + gs_shell_change_mode (shell, GS_SHELL_MODE_LOADING, NULL, TRUE); + } else { + g_debug ("Skipped refresh of the repositories due to 'download-updates' disabled"); + initial_refresh_done (GS_LOADING_PAGE (shell->pages[GS_SHELL_MODE_LOADING]), shell); + + if (g_settings_get_boolean (shell->settings, "first-run")) + g_settings_set_boolean (shell->settings, "first-run", FALSE); + } + + if (shell->activate_after_setup) { + shell->activate_after_setup = FALSE; + gs_shell_activate (shell); + } +} + +void +gs_shell_reset_state (GsShell *shell) +{ + /* reset to overview, unless we're in the loading state which advances + * to overview on its own */ + if (gs_shell_get_mode (shell) != GS_SHELL_MODE_LOADING) + gs_shell_change_mode (shell, GS_SHELL_MODE_OVERVIEW, NULL, TRUE); + + gs_shell_clean_back_entry_stack (shell); +} + +void +gs_shell_set_mode (GsShell *shell, GsShellMode mode) +{ + gs_shell_change_mode (shell, mode, NULL, TRUE); +} + +GsShellMode +gs_shell_get_mode (GsShell *shell) +{ + const gchar *name; + + if (g_strcmp0 (adw_view_stack_get_visible_child_name (shell->stack_loading), "loading") == 0) + return GS_SHELL_MODE_LOADING; + + if (g_strcmp0 (adw_leaflet_get_visible_child_name (shell->details_leaflet), "details") == 0) + return GS_SHELL_MODE_DETAILS; + + if (g_strcmp0 (adw_leaflet_get_visible_child_name (shell->main_leaflet), "main") == 0) + name = adw_view_stack_get_visible_child_name (shell->stack_main); + else + name = adw_view_stack_get_visible_child_name (shell->stack_sub); + + for (gsize i = 0; i < G_N_ELEMENTS (page_name); i++) + if (g_strcmp0 (page_name[i], name) == 0) + return i; + + g_assert_not_reached (); +} + +const gchar * +gs_shell_get_mode_string (GsShell *shell) +{ + GsShellMode mode = gs_shell_get_mode (shell); + return page_name[mode]; +} + +void +gs_shell_install (GsShell *shell, GsApp *app, GsShellInteraction interaction) +{ + save_back_entry (shell); + gs_shell_change_mode (shell, GS_SHELL_MODE_DETAILS, + (gpointer) app, TRUE); + gs_page_install_app (shell->pages[GS_SHELL_MODE_DETAILS], app, interaction, shell->cancellable); +} + +void +gs_shell_uninstall (GsShell *shell, GsApp *app) +{ + save_back_entry (shell); + gs_shell_change_mode (shell, GS_SHELL_MODE_DETAILS, (gpointer) app, TRUE); + gs_page_remove_app (shell->pages[GS_SHELL_MODE_DETAILS], app, shell->cancellable); +} + +void +gs_shell_show_installed_updates (GsShell *shell) +{ + GtkWidget *dialog; + + dialog = gs_update_dialog_new (shell->plugin_loader); + + gs_shell_modal_dialog_present (shell, GTK_WINDOW (dialog)); +} + +void +gs_shell_show_sources (GsShell *shell) +{ + GtkWidget *dialog; + + /* use if available */ + if (g_spawn_command_line_async ("software-properties-gtk", NULL)) + return; + + dialog = gs_repos_dialog_new (GTK_WINDOW (shell), shell->plugin_loader); + gs_shell_modal_dialog_present (shell, GTK_WINDOW (dialog)); +} + +void +gs_shell_show_prefs (GsShell *shell) +{ + GtkWidget *dialog; + + dialog = gs_prefs_dialog_new (GTK_WINDOW (shell), shell->plugin_loader); + gs_shell_modal_dialog_present (shell, GTK_WINDOW (dialog)); +} + +void +gs_shell_show_app (GsShell *shell, GsApp *app) +{ + save_back_entry (shell); + gs_shell_change_mode (shell, GS_SHELL_MODE_DETAILS, app, TRUE); + gs_shell_activate (shell); +} + +void +gs_shell_show_category (GsShell *shell, GsCategory *category) +{ + save_back_entry (shell); + gs_shell_change_mode (shell, GS_SHELL_MODE_CATEGORY, category, TRUE); +} + +void gs_shell_show_extras_search (GsShell *shell, const gchar *mode, gchar **resources, const gchar *desktop_id, const gchar *ident) +{ + save_back_entry (shell); + gs_extras_page_search (GS_EXTRAS_PAGE (shell->pages[GS_SHELL_MODE_EXTRAS]), mode, resources, desktop_id, ident); + gs_shell_change_mode (shell, GS_SHELL_MODE_EXTRAS, NULL, TRUE); + gs_shell_activate (shell); +} + +void +gs_shell_show_search (GsShell *shell, const gchar *search) +{ + save_back_entry (shell); + gs_shell_change_mode (shell, GS_SHELL_MODE_SEARCH, + (gpointer) search, TRUE); +} + +void +gs_shell_show_local_file (GsShell *shell, GFile *file) +{ + g_autoptr(GsApp) app = gs_app_new (NULL); + save_back_entry (shell); + gs_app_set_local_file (app, file); + gs_shell_change_mode (shell, GS_SHELL_MODE_DETAILS, + (gpointer) app, TRUE); + gs_shell_activate (shell); +} + +/** + * gs_shell_show_metainfo: + * @shell: a #GsShell + * @file: path to a metainfo file to display + * + * Open a metainfo file and display it on the details page as if it were + * published in a repository configured on the system. + * + * This is intended for app developers to be able to test their metainfo files + * locally. + * + * Since: 42 + */ +void +gs_shell_show_metainfo (GsShell *shell, GFile *file) +{ + g_autoptr(GsApp) app = gs_app_new (NULL); + + g_return_if_fail (GS_IS_SHELL (shell)); + g_return_if_fail (G_IS_FILE (file)); + save_back_entry (shell); + gs_app_set_metadata (app, "GnomeSoftware::show-metainfo", "1"); + gs_app_set_local_file (app, file); + gs_shell_change_mode (shell, GS_SHELL_MODE_DETAILS, + (gpointer) app, TRUE); + gs_shell_activate (shell); +} + +void +gs_shell_show_search_result (GsShell *shell, const gchar *id, const gchar *search) +{ + save_back_entry (shell); + gs_search_page_set_appid_to_show (GS_SEARCH_PAGE (shell->pages[GS_SHELL_MODE_SEARCH]), id); + gs_shell_change_mode (shell, GS_SHELL_MODE_SEARCH, + (gpointer) search, TRUE); +} + +void +gs_shell_show_uri (GsShell *shell, const gchar *url) +{ + gtk_show_uri (GTK_WINDOW (shell), url, GDK_CURRENT_TIME); +} + +/** + * gs_shell_get_is_narrow: + * @shell: a #GsShell + * + * Get the value of #GsShell:is-narrow. + * + * Returns: %TRUE if the window is in narrow mode, %FALSE otherwise + * + * Since: 41 + */ +gboolean +gs_shell_get_is_narrow (GsShell *shell) +{ + g_return_val_if_fail (GS_IS_SHELL (shell), FALSE); + + return shell->is_narrow; +} + +static gint +gs_shell_get_allocation_width (GsShell *self) +{ + return self->allocation_width; +} + +static void +gs_shell_get_property (GObject *object, guint prop_id, GValue *value, GParamSpec *pspec) +{ + GsShell *shell = GS_SHELL (object); + + switch ((GsShellProperty) prop_id) { + case PROP_IS_NARROW: + g_value_set_boolean (value, gs_shell_get_is_narrow (shell)); + break; + case PROP_ALLOCATION_WIDTH: + g_value_set_int (value, gs_shell_get_allocation_width (shell)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_shell_set_property (GObject *object, guint prop_id, const GValue *value, GParamSpec *pspec) +{ + switch ((GsShellProperty) prop_id) { + case PROP_IS_NARROW: + case PROP_ALLOCATION_WIDTH: + /* Read only. */ + g_assert_not_reached (); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_shell_dispose (GObject *object) +{ + GsShell *shell = GS_SHELL (object); + + g_clear_object (&shell->sub_page_header_title_binding); + + if (shell->back_entry_stack != NULL) { + g_queue_free_full (shell->back_entry_stack, (GDestroyNotify) free_back_entry); + shell->back_entry_stack = NULL; + } + g_clear_object (&shell->cancellable); + g_clear_object (&shell->plugin_loader); + g_clear_object (&shell->header_start_widget); + g_clear_object (&shell->header_end_widget); + g_clear_object (&shell->page); + g_clear_pointer (&shell->events_info_uri, g_free); + g_clear_pointer (&shell->modal_dialogs, g_ptr_array_unref); + g_clear_object (&shell->settings); + +#ifdef HAVE_MOGWAI + if (shell->scheduler != NULL) { + if (shell->scheduler_invalidated_handler > 0) + g_signal_handler_disconnect (shell->scheduler, + shell->scheduler_invalidated_handler); + + if (shell->scheduler_held) + mwsc_scheduler_release_async (shell->scheduler, + NULL, + scheduler_release_cb, + g_object_ref (shell)); + else + g_clear_object (&shell->scheduler); + } +#endif /* HAVE_MOGWAI */ + + G_OBJECT_CLASS (gs_shell_parent_class)->dispose (object); +} + +static gboolean +allocation_changed_cb (gpointer user_data) +{ + GsShell *shell = GS_SHELL (user_data); + GtkAllocation allocation; + gboolean is_narrow; + GtkStyleContext *context; + + gtk_widget_get_allocation (GTK_WIDGET (shell), &allocation); + + is_narrow = allocation.width <= NARROW_WIDTH_THRESHOLD; + + if (shell->is_narrow != is_narrow) { + shell->is_narrow = is_narrow; + g_object_notify_by_pspec (G_OBJECT (shell), obj_props[PROP_IS_NARROW]); + } + + if (shell->allocation_width != allocation.width) { + shell->allocation_width = allocation.width; + g_object_notify_by_pspec (G_OBJECT (shell), obj_props[PROP_ALLOCATION_WIDTH]); + } + + shell->allocation_changed_cb_id = 0; + + context = gtk_widget_get_style_context (GTK_WIDGET (shell)); + + if (is_narrow) + gtk_style_context_add_class (context, "narrow"); + else + gtk_style_context_remove_class (context, "narrow"); + + return G_SOURCE_REMOVE; +} + +static void +gs_shell_size_allocate (GtkWidget *widget, + gint width, + gint height, + gint baseline) +{ + GsShell *shell = GS_SHELL (widget); + + GTK_WIDGET_CLASS (gs_shell_parent_class)->size_allocate (widget, + width, + height, + baseline); + + /* Delay updating is-narrow so children can adapt to it, which isn't + * possible during the widget's allocation phase as it would break their + * size request. + */ + if (shell->allocation_changed_cb_id == 0) + shell->allocation_changed_cb_id = g_idle_add (allocation_changed_cb, shell); +} + +static void +gs_shell_class_init (GsShellClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + + object_class->get_property = gs_shell_get_property; + object_class->set_property = gs_shell_set_property; + object_class->dispose = gs_shell_dispose; + + widget_class->size_allocate = gs_shell_size_allocate; + + /** + * GsShell:is-narrow: + * + * Whether the window is in narrow mode. + * + * Pages can track this property to adapt to the available width. + * + * Since: 41 + */ + obj_props[PROP_IS_NARROW] = + g_param_spec_boolean ("is-narrow", NULL, NULL, + FALSE, + G_PARAM_READABLE | G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY); + + /** + * GsShell:allocation-width: + * + * The last allocation width for the window. + * + * The pages can track this property, possibly in combination with the #GsShell:is-narrow, + * to adapt its content to the available width. + * + * Since: 43 + */ + obj_props[PROP_ALLOCATION_WIDTH] = + g_param_spec_int ("allocation-width", NULL, NULL, + G_MININT, G_MAXINT, 0, + G_PARAM_READABLE | G_PARAM_STATIC_STRINGS | G_PARAM_EXPLICIT_NOTIFY); + + g_object_class_install_properties (object_class, G_N_ELEMENTS (obj_props), obj_props); + + signals [SIGNAL_LOADED] = + g_signal_new ("loaded", + G_TYPE_FROM_CLASS (object_class), G_SIGNAL_RUN_LAST, + 0, NULL, NULL, g_cclosure_marshal_VOID__VOID, + G_TYPE_NONE, 0); + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-shell.ui"); + + gtk_widget_class_bind_template_child (widget_class, GsShell, main_header); + gtk_widget_class_bind_template_child (widget_class, GsShell, main_leaflet); + gtk_widget_class_bind_template_child (widget_class, GsShell, details_header); + gtk_widget_class_bind_template_child (widget_class, GsShell, details_leaflet); + gtk_widget_class_bind_template_child (widget_class, GsShell, stack_loading); + gtk_widget_class_bind_template_child (widget_class, GsShell, stack_main); + gtk_widget_class_bind_template_child (widget_class, GsShell, stack_sub); + gtk_widget_class_bind_template_child (widget_class, GsShell, metered_updates_bar); + gtk_widget_class_bind_template_child (widget_class, GsShell, search_button); + gtk_widget_class_bind_template_child (widget_class, GsShell, entry_search); + gtk_widget_class_bind_template_child (widget_class, GsShell, search_bar); + gtk_widget_class_bind_template_child (widget_class, GsShell, button_back); + gtk_widget_class_bind_template_child (widget_class, GsShell, button_back2); + gtk_widget_class_bind_template_child (widget_class, GsShell, notification_event); + gtk_widget_class_bind_template_child (widget_class, GsShell, button_events_sources); + gtk_widget_class_bind_template_child (widget_class, GsShell, button_events_no_space); + gtk_widget_class_bind_template_child (widget_class, GsShell, button_events_network_settings); + gtk_widget_class_bind_template_child (widget_class, GsShell, button_events_restart_required); + gtk_widget_class_bind_template_child (widget_class, GsShell, button_events_more_info); + gtk_widget_class_bind_template_child (widget_class, GsShell, button_events_dismiss); + gtk_widget_class_bind_template_child (widget_class, GsShell, label_events); + gtk_widget_class_bind_template_child (widget_class, GsShell, primary_menu); + gtk_widget_class_bind_template_child (widget_class, GsShell, sub_page_header_title); + + gtk_widget_class_bind_template_child_full (widget_class, "overview_page", FALSE, G_STRUCT_OFFSET (GsShell, pages[GS_SHELL_MODE_OVERVIEW])); + gtk_widget_class_bind_template_child_full (widget_class, "updates_page", FALSE, G_STRUCT_OFFSET (GsShell, pages[GS_SHELL_MODE_UPDATES])); + gtk_widget_class_bind_template_child_full (widget_class, "installed_page", FALSE, G_STRUCT_OFFSET (GsShell, pages[GS_SHELL_MODE_INSTALLED])); + gtk_widget_class_bind_template_child_full (widget_class, "moderate_page", FALSE, G_STRUCT_OFFSET (GsShell, pages[GS_SHELL_MODE_MODERATE])); + gtk_widget_class_bind_template_child_full (widget_class, "loading_page", FALSE, G_STRUCT_OFFSET (GsShell, pages[GS_SHELL_MODE_LOADING])); + gtk_widget_class_bind_template_child_full (widget_class, "search_page", FALSE, G_STRUCT_OFFSET (GsShell, pages[GS_SHELL_MODE_SEARCH])); + gtk_widget_class_bind_template_child_full (widget_class, "details_page", FALSE, G_STRUCT_OFFSET (GsShell, pages[GS_SHELL_MODE_DETAILS])); + gtk_widget_class_bind_template_child_full (widget_class, "category_page", FALSE, G_STRUCT_OFFSET (GsShell, pages[GS_SHELL_MODE_CATEGORY])); + gtk_widget_class_bind_template_child_full (widget_class, "extras_page", FALSE, G_STRUCT_OFFSET (GsShell, pages[GS_SHELL_MODE_EXTRAS])); + + gtk_widget_class_bind_template_callback (widget_class, gs_shell_main_window_mapped_cb); + gtk_widget_class_bind_template_callback (widget_class, gs_shell_main_window_realized_cb); + gtk_widget_class_bind_template_callback (widget_class, main_window_closed_cb); + gtk_widget_class_bind_template_callback (widget_class, window_key_pressed_cb); + gtk_widget_class_bind_template_callback (widget_class, window_keypress_handler); + gtk_widget_class_bind_template_callback (widget_class, window_button_pressed_cb); + gtk_widget_class_bind_template_callback (widget_class, gs_shell_details_back_button_cb); + gtk_widget_class_bind_template_callback (widget_class, gs_shell_back_button_cb); + gtk_widget_class_bind_template_callback (widget_class, gs_overview_page_button_cb); + gtk_widget_class_bind_template_callback (widget_class, updates_page_notify_counter_cb); + gtk_widget_class_bind_template_callback (widget_class, category_page_app_clicked_cb); + gtk_widget_class_bind_template_callback (widget_class, search_bar_search_mode_enabled_changed_cb); + gtk_widget_class_bind_template_callback (widget_class, search_changed_handler); + gtk_widget_class_bind_template_callback (widget_class, gs_shell_plugin_events_sources_cb); + gtk_widget_class_bind_template_callback (widget_class, gs_shell_plugin_events_no_space_cb); + gtk_widget_class_bind_template_callback (widget_class, gs_shell_plugin_events_network_settings_cb); + gtk_widget_class_bind_template_callback (widget_class, gs_shell_plugin_events_restart_required_cb); + gtk_widget_class_bind_template_callback (widget_class, gs_shell_plugin_events_more_info_cb); + gtk_widget_class_bind_template_callback (widget_class, gs_shell_plugin_event_dismissed_cb); + gtk_widget_class_bind_template_callback (widget_class, gs_shell_metered_updates_bar_response_cb); + gtk_widget_class_bind_template_callback (widget_class, stack_notify_visible_child_cb); + gtk_widget_class_bind_template_callback (widget_class, initial_refresh_done); + gtk_widget_class_bind_template_callback (widget_class, overlay_get_child_position_cb); + gtk_widget_class_bind_template_callback (widget_class, gs_shell_details_page_metainfo_loaded_cb); + gtk_widget_class_bind_template_callback (widget_class, details_page_app_clicked_cb); + + gtk_widget_class_add_binding_action (widget_class, GDK_KEY_q, GDK_CONTROL_MASK, "window.close", NULL); +} + +static void +gs_shell_init (GsShell *shell) +{ + gtk_widget_init_template (GTK_WIDGET (shell)); + + gtk_search_bar_connect_entry (GTK_SEARCH_BAR (shell->search_bar), GTK_EDITABLE (shell->entry_search)); + + shell->back_entry_stack = g_queue_new (); + shell->modal_dialogs = g_ptr_array_new (); +} + +GsShell * +gs_shell_new (void) +{ + return GS_SHELL (g_object_new (GS_TYPE_SHELL, NULL)); +} diff --git a/src/gs-shell.h b/src/gs-shell.h new file mode 100644 index 0000000..7689777 --- /dev/null +++ b/src/gs-shell.h @@ -0,0 +1,92 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2014-2017 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <adwaita.h> +#include <gtk/gtk.h> + +#include "gnome-software-private.h" + +G_BEGIN_DECLS + +#define GS_TYPE_SHELL (gs_shell_get_type ()) + +G_DECLARE_FINAL_TYPE (GsShell, gs_shell, GS, SHELL, AdwApplicationWindow) + +typedef enum { + GS_SHELL_MODE_UNKNOWN, + GS_SHELL_MODE_OVERVIEW, + GS_SHELL_MODE_INSTALLED, + GS_SHELL_MODE_SEARCH, + GS_SHELL_MODE_UPDATES, + GS_SHELL_MODE_DETAILS, + GS_SHELL_MODE_CATEGORY, + GS_SHELL_MODE_EXTRAS, + GS_SHELL_MODE_MODERATE, + GS_SHELL_MODE_LOADING, + GS_SHELL_MODE_LAST +} GsShellMode; + +typedef enum { + GS_SHELL_INTERACTION_NONE = (0u), + GS_SHELL_INTERACTION_NOTIFY = (1u << 0), + GS_SHELL_INTERACTION_FULL = (1u << 1) | GS_SHELL_INTERACTION_NOTIFY, + GS_SHELL_INTERACTION_LAST +} GsShellInteraction; + +GsShell *gs_shell_new (void); +void gs_shell_activate (GsShell *shell); +void gs_shell_change_mode (GsShell *shell, + GsShellMode mode, + gpointer data, + gboolean scroll_up); +void gs_shell_reset_state (GsShell *shell); +void gs_shell_set_mode (GsShell *shell, + GsShellMode mode); +void gs_shell_modal_dialog_present (GsShell *shell, + GtkWindow *window); +GsShellMode gs_shell_get_mode (GsShell *shell); +const gchar *gs_shell_get_mode_string (GsShell *shell); +void gs_shell_install (GsShell *shell, + GsApp *app, + GsShellInteraction interaction); +void gs_shell_uninstall (GsShell *shell, + GsApp *app); +void gs_shell_show_installed_updates(GsShell *shell); +void gs_shell_show_sources (GsShell *shell); +void gs_shell_show_prefs (GsShell *shell); +void gs_shell_show_app (GsShell *shell, + GsApp *app); +void gs_shell_show_category (GsShell *shell, + GsCategory *category); +void gs_shell_show_search (GsShell *shell, + const gchar *search); +void gs_shell_show_local_file (GsShell *shell, + GFile *file); +void gs_shell_show_search_result (GsShell *shell, + const gchar *id, + const gchar *search); +void gs_shell_show_extras_search (GsShell *shell, + const gchar *mode, + gchar **resources, + const gchar *desktop_id, + const gchar *ident); +void gs_shell_show_uri (GsShell *shell, + const gchar *url); +void gs_shell_setup (GsShell *shell, + GsPluginLoader *plugin_loader, + GCancellable *cancellable); +void gs_shell_show_notification (GsShell *shell, + const gchar *title); +gboolean gs_shell_get_is_narrow (GsShell *shell); +void gs_shell_show_metainfo (GsShell *shell, + GFile *file); + +G_END_DECLS diff --git a/src/gs-shell.ui b/src/gs-shell.ui new file mode 100644 index 0000000..52e4a43 --- /dev/null +++ b/src/gs-shell.ui @@ -0,0 +1,521 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <requires lib="gtk+" version="3.10"/> + <requires lib="handy" version="1.0"/> + <menu id="primary_menu"> + <item> + <attribute name="label" translatable="yes">_Software Repositories</attribute> + <attribute name="action">app.sources</attribute> + <attribute name="hidden-when">action-disabled</attribute> + </item> + <item> + <attribute name="label" translatable="yes">_Update Preferences</attribute> + <attribute name="action">app.prefs</attribute> + </item> + </menu> + + <template class="GsShell" parent="AdwApplicationWindow"> + <property name="visible">False</property> + <property name="default-width">1200</property> + <property name="default-height">800</property> + <property name="title" translatable="yes">Software</property> + <property name="icon_name">org.gnome.Software</property> + <signal name="map" handler="gs_shell_main_window_mapped_cb"/> + <signal name="realize" handler="gs_shell_main_window_realized_cb"/> + <signal name="close-request" handler="main_window_closed_cb"/> + <child> + <object class="GtkEventControllerKey"> + <property name="propagation-phase">capture</property> + <signal name="key-pressed" handler="window_keypress_handler"/> + </object> + </child> + <child> + <object class="GtkEventControllerKey"> + <signal name="key-pressed" handler="window_key_pressed_cb"/> + </object> + </child> + <child> + <object class="GtkGestureClick"> + <!-- Mouse hardware back button --> + <property name="button">8</property> + <signal name="pressed" handler="window_button_pressed_cb"/> + </object> + </child> + <child> + <object class="AdwViewStack" id="stack_loading"> + <property name="width-request">360</property> + <signal name="notify::visible-child" handler="stack_notify_visible_child_cb"/> + <child> + <object class="AdwViewStackPage"> + <property name="name">main</property> + <property name="child"> + <object class="GtkOverlay" id="overlay"> + <property name="halign">fill</property> + <property name="valign">fill</property> + <property name="vexpand">True</property> + <signal name="get-child-position" handler="overlay_get_child_position_cb"/> + <child type="overlay"> + <object class="GtkRevealer" id="notification_event"> + <property name="halign">GTK_ALIGN_CENTER</property> + <property name="valign">GTK_ALIGN_START</property> + <child> + <object class="GtkBox"> + <property name="orientation">horizontal</property> + <property name="spacing">6</property> + <style> + <class name="app-notification"/> + </style> + <child> + <object class="GtkLabel" id="label_events"> + <property name="halign">fill</property> + <property name="hexpand">True</property> + <property name="label">Some Title</property> + <property name="wrap">True</property> + <property name="wrap_mode">word-char</property> + <property name="max_width_chars">60</property> + <property name="margin_start">9</property> + <property name="margin_end">9</property> + <attributes> + <attribute name="weight" value="bold"/> + </attributes> + </object> + </child> + <child> + <object class="GtkBox"> + <child> + <object class="GtkButton" id="button_events_sources"> + <property name="visible">False</property> + <property name="label" translatable="yes" comments="button in the info bar">Software Repositories</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="valign">center</property> + <signal name="clicked" handler="gs_shell_plugin_events_sources_cb"/> + </object> + </child> + <child> + <object class="GtkButton" id="button_events_no_space"> + <property name="visible">False</property> + <property name="label" translatable="yes" comments="button in the info bar">Examine Disk</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="valign">center</property> + <signal name="clicked" handler="gs_shell_plugin_events_no_space_cb"/> + </object> + </child> + <child> + <object class="GtkButton" id="button_events_network_settings"> + <property name="visible">False</property> + <property name="label" translatable="yes" comments="button in the info bar">Network Settings</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="valign">center</property> + <signal name="clicked" handler="gs_shell_plugin_events_network_settings_cb"/> + </object> + </child> + <child> + <object class="GtkButton" id="button_events_restart_required"> + <property name="visible">False</property> + <property name="label" translatable="yes" comments="button in the info bar">Restart Now</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="valign">center</property> + <signal name="clicked" handler="gs_shell_plugin_events_restart_required_cb"/> + </object> + </child> + <child> + <object class="GtkButton" id="button_events_more_info"> + <property name="visible">False</property> + <property name="label" translatable="yes" comments="button in the info bar">More Information</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="valign">center</property> + <signal name="clicked" handler="gs_shell_plugin_events_more_info_cb"/> + </object> + </child> + </object> + </child> + <child> + <object class="GtkButton" id="button_events_dismiss"> + <property name="halign">end</property> + <property name="valign">start</property> + <signal name="clicked" handler="gs_shell_plugin_event_dismissed_cb"/> + <style> + <class name="flat"/> + </style> + <child> + <object class="GtkImage"> + <property name="icon_name">window-close-symbolic</property> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + <child> + <object class="AdwLeaflet" id="details_leaflet"> + <property name="can-navigate-back">True</property> + <property name="can-unfold">False</property> + <signal name="notify::visible-child" handler="stack_notify_visible_child_cb"/> + + + <child> + <object class="AdwLeafletPage"> + <property name="name">main</property> + <property name="child"> + <object class="AdwLeaflet" id="main_leaflet"> + <property name="can-navigate-back">True</property> + <property name="can-unfold">False</property> + <signal name="notify::visible-child" handler="stack_notify_visible_child_cb"/> + + <child> + <object class="AdwLeafletPage"> + <property name="name">main</property> + <property name="child"> + <object class="GtkBox" id="main_box"> + <property name="orientation">vertical</property> + <child> + <object class="AdwHeaderBar" id="main_header"> + <property name="hexpand">True</property> + <property name="show-end-title-buttons">True</property> + <property name="centering-policy">strict</property> + <child type="start"> + <object class="GtkToggleButton" id="search_button"> + <property name="can_focus">True</property> + <property name="icon_name">edit-find-symbolic</property> + <property name="active" bind-source="search_bar" bind-property="search-mode-enabled" bind-flags="sync-create|bidirectional"/> + <accessibility> + <property name="label" translatable="yes">Search</property> + </accessibility> + <style> + <class name="image-button"/> + </style> + </object> + </child> + <child type="end"> + <object class="GtkMenuButton" id="menu_button"> + <property name="can_focus">True</property> + <property name="sensitive">True</property> + <property name="primary">True</property> + <property name="icon_name">open-menu-symbolic</property> + <property name="menu_model">primary_menu</property> + <accessibility> + <property name="label" translatable="yes">Primary Menu</property> + </accessibility> + <style> + <class name="image-button"/> + </style> + </object> + </child> + <child type="title"> + <object class="AdwViewSwitcherTitle" id="title_switcher"> + <property name="stack">stack_main</property> + <property name="title" bind-source="GsShell" bind-property="title" bind-flags="sync-create"/> + </object> + </child> + </object> + </child> + + <child> + <object class="GtkSearchBar" id="search_bar"> + <property name="key-capture-widget">GsShell</property> + <signal name="notify::search-mode-enabled" handler="search_bar_search_mode_enabled_changed_cb"/> + <child> + <object class="AdwClamp"> + <property name="hexpand">True</property> + <property name="maximum_size">500</property> + <property name="tightening_threshold">500</property> + <child> + <object class="GtkSearchEntry" id="entry_search"> + <property name="can_focus">True</property> + <property name="activates_default">True</property> + <signal name="search-changed" handler="search_changed_handler"/> + </object> + </child> + </object> + </child> + </object> + </child> + <child> + <object class="GtkInfoBar" id="metered_updates_bar"> + <property name="message-type">GTK_MESSAGE_INFO</property> + <property name="show-close-button">False</property> + <property name="revealed">False</property> + <signal name="response" handler="gs_shell_metered_updates_bar_response_cb"/> + <child> + <object class="GtkBox"> + <property name="orientation">vertical</property> + <property name="spacing">6</property> + <property name="margin_top">6</property> + <property name="margin_start">6</property> + <property name="margin_bottom">6</property> + <child> + <object class="GtkLabel"> + <property name="can_focus">False</property> + <property name="label" translatable="yes">Automatic Updates Paused</property> + <property name="vexpand">True</property> + <property name="xalign">0.0</property> + <property name="wrap">True</property> + <attributes> + <attribute name="weight" value="PANGO_WEIGHT_BOLD"/> + </attributes> + </object> + </child> + </object> + </child> + <child type="action"> + <object class="GtkBox"> + <property name="margin_end">6</property> + <child> + <object class="GtkButton" id="metered_updates_button"> + <property name="can_focus">True</property> + <property name="use_underline">True</property> + <property name="label" translatable="yes">Find Out _More</property> + </object> + </child> + </object> + </child> + <action-widgets> + <action-widget response="GTK_RESPONSE_OK">metered_updates_button</action-widget> + </action-widgets> + </object> + </child> + <child> + <object class="AdwViewStack" id="stack_main"> + <property name="hexpand">True</property> + <property name="vexpand">True</property> + <property name="hhomogeneous">False</property> + <property name="vhomogeneous">False</property> + <signal name="notify::visible-child" handler="stack_notify_visible_child_cb"/> + <child> + <object class="AdwViewStackPage"> + <property name="name">overview</property> + <property name="title" translatable="yes" comments="Translators: A label for a button to show all available software.">Explore</property> + <property name="icon-name">explore-symbolic</property> + <property name="child"> + <object class="GsOverviewPage" id="overview_page"> + </object> + </property> + </object> + </child> + <child> + <object class="AdwViewStackPage"> + <property name="name">installed</property> + <!-- FIXME: Add mnemonics support when it’s supported in GTK (same for the other pages). + See https://gitlab.gnome.org/GNOME/gtk/-/issues/3134 --> + <property name="title" translatable="yes" comments="Translators: A label for a button to show only software which is already installed." context="List of installed apps">Installed</property> + <property name="icon-name">app-installed-symbolic</property> + <property name="child"> + <object class="GsInstalledPage" id="installed_page"> + <property name="is-narrow" bind-source="GsShell" bind-property="is-narrow" bind-flags="sync-create"/> + </object> + </property> + </object> + </child> + <child> + <object class="AdwViewStackPage"> + <property name="name">search</property> + <property name="child"> + <object class="GsSearchPage" id="search_page"> + </object> + </property> + </object> + </child> + <child> + <object class="AdwViewStackPage"> + <property name="name">updates</property> + <property name="title" translatable="yes" comments="Translators: A label for a button to show only updates which are available to install." context="Header bar button for list of apps to be updated">Updates</property> + <property name="icon-name">emblem-synchronizing-symbolic</property> + <property name="badge-number" bind-source="updates_page" bind-property="counter" bind-flags="sync-create"/> + <property name="child"> + <object class="GsUpdatesPage" id="updates_page"> + <property name="is-narrow" bind-source="GsShell" bind-property="is-narrow" bind-flags="sync-create"/> + <signal name="notify::counter" handler="updates_page_notify_counter_cb"/> + </object> + </property> + </object> + </child> + </object> + </child> + <child> + <object class="AdwViewSwitcherBar" id="sidebar_switcher"> + <property name="reveal" bind-source="title_switcher" bind-property="title-visible" bind-flags="sync-create"/> + <property name="stack">stack_main</property> + </object> + </child> + </object> + </property> + </object> + </child> + + <child> + <object class="AdwLeafletPage"> + <property name="name">sub</property> + <property name="child"> + <object class="GtkBox" id="sub_box"> + <property name="orientation">vertical</property> + <child> + <object class="AdwHeaderBar" id="sub_header"> + <property name="show-end-title-buttons">True</property> + <property name="hexpand">True</property> + <child> + <object class="GtkButton" id="button_back"> + <property name="can_focus">True</property> + <signal name="clicked" handler="gs_shell_back_button_cb"/> + <accessibility> + <property name="label" translatable="yes">Go back</property> + </accessibility> + <style> + <class name="image-button"/> + </style> + <child> + <object class="GtkImage" id="back_image"> + <property name="icon_name">go-previous-symbolic</property> + <property name="icon-size">normal</property> + </object> + </child> + </object> + </child> + <child type="title"> + <object class="GtkLabel" id="sub_page_header_title"> + <property name="selectable">False</property> + <property name="ellipsize">end</property> + <style> + <class name="title"/> + </style> + </object> + </child> + </object> + </child> + <child> + <object class="AdwViewStack" id="stack_sub"> + <property name="hexpand">True</property> + <property name="vexpand">True</property> + <property name="hhomogeneous">False</property> + <property name="vhomogeneous">False</property> + <signal name="notify::visible-child" handler="stack_notify_visible_child_cb"/> + <child> + <object class="AdwViewStackPage"> + <property name="name">moderate</property> + <property name="child"> + <object class="GsModeratePage" id="moderate_page"> + </object> + </property> + </object> + </child> + <child> + <object class="AdwViewStackPage"> + <property name="name">category</property> + <property name="child"> + <object class="GsCategoryPage" id="category_page"> + <signal name="app-clicked" handler="category_page_app_clicked_cb"/> + </object> + </property> + </object> + </child> + <child> + <object class="AdwViewStackPage"> + <property name="name">extras</property> + <property name="child"> + <object class="GsExtrasPage" id="extras_page"> + </object> + </property> + </object> + </child> + </object> + </child> + </object> + </property> + </object> + </child> + + </object> + </property> + </object> + </child> + + <child> + <object class="AdwLeafletPage"> + <property name="name">details</property> + <property name="child"> + <object class="GtkBox" id="details_box"> + <property name="orientation">vertical</property> + <child> + <object class="AdwHeaderBar" id="details_header"> + <property name="hexpand">True</property> + <property name="show-end-title-buttons">True</property> + <property name="title-widget"> + <object class="AdwWindowTitle"> + <property name="title" bind-source="details_page" bind-property="title" bind-flags="sync-create"/> + </object> + </property> + <child> + <object class="GtkButton" id="button_back2"> + <property name="can_focus">True</property> + <signal name="clicked" handler="gs_shell_details_back_button_cb"/> + <accessibility> + <property name="label" translatable="yes">Go back</property> + </accessibility> + <style> + <class name="image-button"/> + </style> + <child> + <object class="GtkImage"> + <property name="icon_name">go-previous-symbolic</property> + <property name="icon-size">normal</property> + </object> + </child> + </object> + </child> + </object> + </child> + <child> + <object class="GsDetailsPage" id="details_page"> + <property name="is-narrow" bind-source="GsShell" bind-property="is-narrow" bind-flags="sync-create"/> + <signal name="metainfo-loaded" handler="gs_shell_details_page_metainfo_loaded_cb"/> + <signal name="app-clicked" handler="details_page_app_clicked_cb"/> + </object> + </child> + </object> + </property> + </object> + </child> + + </object> + </child> + </object> + </property> + </object> + </child> + <child> + <object class="AdwViewStackPage"> + <property name="name">loading</property> + <property name="child"> + <object class="GtkOverlay"> + <child type="overlay"> + <object class="GtkHeaderBar"> + <property name="show_title_buttons">True</property> + <property name="valign">start</property> + <style> + <class name="flat"/> + </style> + </object> + </child> + <child> + <object class="GtkWindowHandle"> + <child> + <object class="GsLoadingPage" id="loading_page"> + <signal name="refreshed" handler="initial_refresh_done"/> + </object> + </child> + </object> + </child> + </object> + </property> + </object> + </child> + </object> + </child> + </template> +</interface> diff --git a/src/gs-star-image.c b/src/gs-star-image.c new file mode 100644 index 0000000..f5fb14f --- /dev/null +++ b/src/gs-star-image.c @@ -0,0 +1,254 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +/** + * SECTION:gs-star-image + * @title: GsStarImage + * @stability: Unstable + * @short_description: Draw a star image, which can be partially filled + * + * Depending on the #GsStarImage:fraction property, the star image can be + * drawn as filled only partially or fully or not at all. This is accomplished + * by using a `color` style property for the filled part. The unfilled part of + * the star currently uses a hardcoded colour. + * The `background` style property controls the area outside the star. + * + * Since: 41 + */ + +#include "config.h" + +#include "gs-star-image.h" + +#include <adwaita.h> + +struct _GsStarImage +{ + GtkWidget parent_instance; + + gdouble fraction; +}; + +G_DEFINE_TYPE (GsStarImage, gs_star_image, GTK_TYPE_WIDGET) + +enum { + PROP_FRACTION = 1 +}; + +static void +gs_star_image_outline_star (cairo_t *cr, + gint x, + gint y, + gint radius, + gint *out_min_x, + gint *out_max_x) +{ + /* Coordinates of the vertices of the star, + * where (0, 0) is the centre of the star. + * These range from -1 to +1 in both dimensions, + * and will be scaled to @radius when drawn. */ + const struct _points { + gdouble x, y; + } small_points[] = { + { 0.000000, -1.000000 }, + { -1.000035, -0.424931 }, + { -0.668055, 0.850680 }, + { 0.668055, 0.850680 }, + { 1.000035, -0.424931 } + }, large_points[] = { + { 0.000000, -1.000000 }, + { -1.000035, -0.325033 }, + { -0.618249, 0.850948 }, + { 0.618249, 0.850948 }, + { 1.000035, -0.325033 } + }, *points; + gint ii, nn = G_N_ELEMENTS (small_points), xx, yy; + + /* Safety check */ + G_STATIC_ASSERT (G_N_ELEMENTS (small_points) == G_N_ELEMENTS (large_points)); + + if (radius <= 0) + return; + + /* An arbitrary number, since which the math-precise star looks fine, + * while it looks odd for lower sizes. */ + if (radius * 2 > 20) + points = large_points; + else + points = small_points; + + cairo_translate (cr, radius, radius); + + xx = points[0].x * radius; + yy = points[0].y * radius; + + if (out_min_x) + *out_min_x = xx; + + if (out_max_x) + *out_max_x = xx; + + cairo_move_to (cr, xx, yy); + + for (ii = 2; ii <= 2 * nn; ii += 2) { + xx = points[ii % nn].x * radius; + yy = points[ii % nn].y * radius; + + if (out_min_x && *out_min_x > xx) + *out_min_x = xx; + + if (out_max_x && *out_max_x < xx) + *out_max_x = xx; + + cairo_line_to (cr, xx, yy); + } +} + +static void +gs_star_image_get_property (GObject *object, + guint param_id, + GValue *value, + GParamSpec *pspec) +{ + switch (param_id) { + case PROP_FRACTION: + g_value_set_double (value, gs_star_image_get_fraction (GS_STAR_IMAGE (object))); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, param_id, pspec); + break; + } +} + +static void +gs_star_image_set_property (GObject *object, + guint param_id, + const GValue *value, + GParamSpec *pspec) +{ + switch (param_id) { + case PROP_FRACTION: + gs_star_image_set_fraction (GS_STAR_IMAGE (object), g_value_get_double (value)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, param_id, pspec); + break; + } +} + +static void +gs_star_image_snapshot (GtkWidget *widget, + GtkSnapshot *snapshot) +{ + GtkAllocation allocation; + cairo_t *cr; + gdouble fraction; + gint radius; + + fraction = gs_star_image_get_fraction (GS_STAR_IMAGE (widget)); + + gtk_widget_get_allocation (widget, &allocation); + + radius = MIN (allocation.width, allocation.height) / 2; + + cr = gtk_snapshot_append_cairo (snapshot, + &GRAPHENE_RECT_INIT (0, 0, + gtk_widget_get_width (widget), + gtk_widget_get_height (widget))); + + if (radius > 0) { + GtkStyleContext *style_context; + GdkRGBA star_bg = { 1, 1, 1, 1 }; + GdkRGBA star_fg; + gint min_x = -radius, max_x = radius; + + style_context = gtk_widget_get_style_context (widget); + gtk_style_context_get_color (style_context, &star_fg); + + gtk_style_context_lookup_color (style_context, "card_fg_color", &star_bg); + if (adw_style_manager_get_high_contrast (adw_style_manager_get_default ())) + star_bg.alpha *= 0.4; + else + star_bg.alpha *= 0.2; + + cairo_save (cr); + gs_star_image_outline_star (cr, allocation.x, allocation.y, radius, &min_x, &max_x); + cairo_clip (cr); + gdk_cairo_set_source_rgba (cr, &star_bg); + cairo_rectangle (cr, -radius, -radius, 2 * radius, 2 * radius); + cairo_fill (cr); + + gdk_cairo_set_source_rgba (cr, &star_fg); + if (gtk_widget_get_direction (widget) == GTK_TEXT_DIR_RTL) + cairo_rectangle (cr, max_x - (max_x - min_x) * fraction, -radius, (max_x - min_x) * fraction, 2 * radius); + else + cairo_rectangle (cr, min_x, -radius, (max_x - min_x) * fraction, 2 * radius); + cairo_fill (cr); + cairo_restore (cr); + } + + cairo_destroy (cr); +} + +static void +gs_star_image_class_init (GsStarImageClass *klass) +{ + GObjectClass *object_class; + GtkWidgetClass *widget_class; + + object_class = G_OBJECT_CLASS (klass); + object_class->get_property = gs_star_image_get_property; + object_class->set_property = gs_star_image_set_property; + + widget_class = GTK_WIDGET_CLASS (klass); + widget_class->snapshot = gs_star_image_snapshot; + + g_object_class_install_property (object_class, + PROP_FRACTION, + g_param_spec_double ("fraction", NULL, NULL, + 0.0, 1.0, 1.0, + G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY)); + + gtk_widget_class_set_css_name (widget_class, "star-image"); +} + +static void +gs_star_image_init (GsStarImage *self) +{ + self->fraction = 1.0; + + gtk_widget_set_size_request (GTK_WIDGET (self), 16, 16); +} + +GtkWidget * +gs_star_image_new (void) +{ + return g_object_new (GS_TYPE_STAR_IMAGE, NULL); +} + +void +gs_star_image_set_fraction (GsStarImage *self, + gdouble fraction) +{ + g_return_if_fail (GS_IS_STAR_IMAGE (self)); + + if (self->fraction == fraction) + return; + + self->fraction = fraction; + + g_object_notify (G_OBJECT (self), "fraction"); + + gtk_widget_queue_draw (GTK_WIDGET (self)); +} + +gdouble +gs_star_image_get_fraction (GsStarImage *self) +{ + g_return_val_if_fail (GS_IS_STAR_IMAGE (self), -1.0); + + return self->fraction; +} diff --git a/src/gs-star-image.h b/src/gs-star-image.h new file mode 100644 index 0000000..f1f99fe --- /dev/null +++ b/src/gs-star-image.h @@ -0,0 +1,22 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <gtk/gtk.h> + +G_BEGIN_DECLS + +#define GS_TYPE_STAR_IMAGE (gs_star_image_get_type ()) + +G_DECLARE_FINAL_TYPE (GsStarImage, gs_star_image, GS, STAR_IMAGE, GtkWidget) + +GtkWidget * gs_star_image_new (void); +void gs_star_image_set_fraction (GsStarImage *self, + gdouble fraction); +gdouble gs_star_image_get_fraction (GsStarImage *self); + +G_END_DECLS diff --git a/src/gs-star-widget.c b/src/gs-star-widget.c new file mode 100644 index 0000000..1d41705 --- /dev/null +++ b/src/gs-star-widget.c @@ -0,0 +1,337 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2014-2015 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include <math.h> + +#include "gs-common.h" +#include "gs-star-image.h" +#include "gs-star-widget.h" + +typedef struct +{ + gboolean interactive; + gint rating; + guint icon_size; + GtkWidget *box1; + GtkWidget *images[5]; +} GsStarWidgetPrivate; + +G_DEFINE_TYPE_WITH_PRIVATE (GsStarWidget, gs_star_widget, GTK_TYPE_WIDGET) + +typedef enum { + PROP_ICON_SIZE = 1, + PROP_INTERACTIVE, + PROP_RATING, +} GsStarWidgetProperty; + +enum { + RATING_CHANGED, + SIGNAL_LAST +}; + +static GParamSpec *properties[PROP_RATING + 1] = { 0, }; +static guint signals [SIGNAL_LAST] = { 0 }; + +const gint rate_to_star[] = {20, 40, 60, 80, 100, -1}; + +static void gs_star_widget_refresh (GsStarWidget *star); + +gint +gs_star_widget_get_rating (GsStarWidget *star) +{ + GsStarWidgetPrivate *priv; + g_return_val_if_fail (GS_IS_STAR_WIDGET (star), -1); + priv = gs_star_widget_get_instance_private (star); + return priv->rating; +} + +void +gs_star_widget_set_icon_size (GsStarWidget *star, guint pixel_size) +{ + GsStarWidgetPrivate *priv; + g_return_if_fail (GS_IS_STAR_WIDGET (star)); + priv = gs_star_widget_get_instance_private (star); + + if (priv->icon_size == pixel_size) + return; + + priv->icon_size = pixel_size; + g_object_notify_by_pspec (G_OBJECT (star), properties[PROP_ICON_SIZE]); + gs_star_widget_refresh (star); +} + +static void +gs_star_widget_button_clicked_cb (GtkButton *button, GsStarWidget *star) +{ + GsStarWidgetPrivate *priv; + gint rating; + + priv = gs_star_widget_get_instance_private (star); + if (!priv->interactive) + return; + + rating = GPOINTER_TO_INT (g_object_get_data (G_OBJECT (button), + "GsStarWidget::value")); + gs_star_widget_set_rating (star, rating); + g_signal_emit (star, signals[RATING_CHANGED], 0, priv->rating); +} + +/* Round to one digit, the same as the GsReviewHistogram */ +#define GS_ROUND(x) (round (((gdouble) (x)) * 10.0) / 10.0) + +/* Update the star styles to display the new rating */ +static void +gs_star_widget_refresh_rating (GsStarWidget *star) +{ + GsStarWidgetPrivate *priv = gs_star_widget_get_instance_private (star); + + if (!gtk_widget_get_realized (GTK_WIDGET (star))) + return; + + for (guint i = 0; i < G_N_ELEMENTS (priv->images); i++) { + GtkWidget *im = GTK_WIDGET (priv->images[i]); + gdouble fraction; + + if (priv->rating >= rate_to_star[i]) + fraction = 1.0; + else if (!i) + fraction = GS_ROUND (priv->rating / 20.0); + else if (priv->rating > rate_to_star[i - 1]) + fraction = GS_ROUND ((priv->rating - rate_to_star[i - 1]) / 20.0); + else + fraction = 0.0; + + gs_star_image_set_fraction (GS_STAR_IMAGE (im), fraction); + } +} + +static void +gs_star_widget_refresh (GsStarWidget *star) +{ + GsStarWidgetPrivate *priv = gs_star_widget_get_instance_private (star); + + if (!gtk_widget_get_realized (GTK_WIDGET (star))) + return; + + /* remove all existing widgets */ + gs_widget_remove_all (priv->box1, (GsRemoveFunc) gtk_box_remove); + + for (guint i = 0; i < G_N_ELEMENTS (priv->images); i++) { + GtkWidget *w; + GtkWidget *im; + + /* create image */ + im = gs_star_image_new (); + gtk_widget_set_size_request (im, (gint) priv->icon_size, (gint) priv->icon_size); + + priv->images[i] = im; + + /* create button */ + if (priv->interactive) { + w = gtk_button_new (); + g_signal_connect (w, "clicked", + G_CALLBACK (gs_star_widget_button_clicked_cb), star); + g_object_set_data (G_OBJECT (w), + "GsStarWidget::value", + GINT_TO_POINTER (rate_to_star[i])); + gtk_button_set_child (GTK_BUTTON (w), im); + gtk_widget_set_visible (im, TRUE); + } else { + w = im; + } + gtk_widget_set_sensitive (w, priv->interactive); + gtk_style_context_add_class (gtk_widget_get_style_context (w), "star"); + gtk_widget_set_visible (w, TRUE); + gtk_box_append (GTK_BOX (priv->box1), w); + } + + gs_star_widget_refresh_rating (star); +} + +void +gs_star_widget_set_interactive (GsStarWidget *star, gboolean interactive) +{ + GsStarWidgetPrivate *priv; + g_return_if_fail (GS_IS_STAR_WIDGET (star)); + priv = gs_star_widget_get_instance_private (star); + + if (priv->interactive == interactive) + return; + + priv->interactive = interactive; + g_object_notify_by_pspec (G_OBJECT (star), properties[PROP_INTERACTIVE]); + gs_star_widget_refresh (star); +} + +void +gs_star_widget_set_rating (GsStarWidget *star, + gint rating) +{ + GsStarWidgetPrivate *priv; + g_return_if_fail (GS_IS_STAR_WIDGET (star)); + priv = gs_star_widget_get_instance_private (star); + + if (priv->rating == rating) + return; + + priv->rating = rating; + g_object_notify_by_pspec (G_OBJECT (star), properties[PROP_RATING]); + gs_star_widget_refresh_rating (star); +} + +static void +gs_star_widget_get_property (GObject *object, + guint prop_id, + GValue *value, + GParamSpec *pspec) +{ + GsStarWidget *self = GS_STAR_WIDGET (object); + GsStarWidgetPrivate *priv = gs_star_widget_get_instance_private (self); + + switch ((GsStarWidgetProperty) prop_id) { + case PROP_ICON_SIZE: + g_value_set_uint (value, priv->icon_size); + break; + case PROP_INTERACTIVE: + g_value_set_boolean (value, priv->interactive); + break; + case PROP_RATING: + g_value_set_int (value, priv->rating); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_star_widget_set_property (GObject *object, + guint prop_id, + const GValue *value, + GParamSpec *pspec) +{ + GsStarWidget *self = GS_STAR_WIDGET (object); + + switch ((GsStarWidgetProperty) prop_id) { + case PROP_ICON_SIZE: + gs_star_widget_set_icon_size (self, g_value_get_uint (value)); + break; + case PROP_INTERACTIVE: + gs_star_widget_set_interactive (self, g_value_get_boolean (value)); + break; + case PROP_RATING: + gs_star_widget_set_rating (self, g_value_get_int (value)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_star_widget_realize (GtkWidget *widget) +{ + GTK_WIDGET_CLASS (gs_star_widget_parent_class)->realize (widget); + + /* Create child widgets. */ + gs_star_widget_refresh (GS_STAR_WIDGET (widget)); +} + +static void +gs_star_widget_dispose (GObject *object) +{ + gs_widget_remove_all (GTK_WIDGET (object), NULL); + + G_OBJECT_CLASS (gs_star_widget_parent_class)->dispose (object); +} + +static void +gs_star_widget_init (GsStarWidget *star) +{ + gtk_widget_init_template (GTK_WIDGET (star)); +} + +static void +gs_star_widget_class_init (GsStarWidgetClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + + object_class->dispose = gs_star_widget_dispose; + + widget_class->realize = gs_star_widget_realize; + object_class->get_property = gs_star_widget_get_property; + object_class->set_property = gs_star_widget_set_property; + + /** + * GsStarWidget:icon-size: + * + * Size of the star icons to use in the widget, in pixels. + * + * Since: 3.38 + */ + properties[PROP_ICON_SIZE] = + g_param_spec_uint ("icon-size", + "Icon Size", + "Size of icons to use, in pixels", + 0, G_MAXUINT, 12, + G_PARAM_READWRITE | G_PARAM_CONSTRUCT); + + /** + * GsStarWidget:interactive: + * + * Whether the widget accepts user input to change #GsStarWidget:rating. + * + * Since: 3.38 + */ + properties[PROP_INTERACTIVE] = + g_param_spec_boolean ("interactive", + "Interactive", + "Whether the rating is interactive", + FALSE, + G_PARAM_READWRITE); + + /** + * GsStarWidget:rating: + * + * The rating to display on the widget, as a percentage. `-1` indicates + * that the rating is unknown. + * + * Since: 3.38 + */ + properties[PROP_RATING] = + g_param_spec_int ("rating", + "Rating", + "Rating, out of 100%, or -1 for unknown", + -1, 100, -1, + G_PARAM_READWRITE); + + signals [RATING_CHANGED] = + g_signal_new ("rating-changed", + G_TYPE_FROM_CLASS (object_class), G_SIGNAL_RUN_LAST, + G_STRUCT_OFFSET (GsStarWidgetClass, rating_changed), + NULL, NULL, g_cclosure_marshal_VOID__UINT, + G_TYPE_NONE, 1, G_TYPE_UINT); + + g_object_class_install_properties (object_class, G_N_ELEMENTS (properties), properties); + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-star-widget.ui"); + gtk_widget_class_set_layout_manager_type (widget_class, GTK_TYPE_BIN_LAYOUT); + gtk_widget_class_bind_template_child_private (widget_class, GsStarWidget, box1); +} + +GtkWidget * +gs_star_widget_new (void) +{ + GsStarWidget *star; + star = g_object_new (GS_TYPE_STAR_WIDGET, NULL); + return GTK_WIDGET (star); +} diff --git a/src/gs-star-widget.h b/src/gs-star-widget.h new file mode 100644 index 0000000..cea1557 --- /dev/null +++ b/src/gs-star-widget.h @@ -0,0 +1,38 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2015 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <gtk/gtk.h> + +#include "gnome-software-private.h" + +G_BEGIN_DECLS + +#define GS_TYPE_STAR_WIDGET (gs_star_widget_get_type ()) + +G_DECLARE_DERIVABLE_TYPE (GsStarWidget, gs_star_widget, GS, STAR_WIDGET, GtkWidget) + +struct _GsStarWidgetClass +{ + GtkWidgetClass parent_class; + + void (*rating_changed) (GsStarWidget *star); +}; + +GtkWidget *gs_star_widget_new (void); +gint gs_star_widget_get_rating (GsStarWidget *star); +void gs_star_widget_set_rating (GsStarWidget *star, + gint rating); +void gs_star_widget_set_icon_size (GsStarWidget *star, + guint pixel_size); +void gs_star_widget_set_interactive (GsStarWidget *star, + gboolean interactive); + +G_END_DECLS diff --git a/src/gs-star-widget.ui b/src/gs-star-widget.ui new file mode 100644 index 0000000..d738e30 --- /dev/null +++ b/src/gs-star-widget.ui @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <!-- interface-requires gtk+ 3.10 --> + <template class="GsStarWidget" parent="GtkWidget"> + <child> + <object class="GtkBox" id="box1"> + <property name="halign">center</property> + <property name="valign">center</property> + <property name="spacing">2</property> + <style> + <class name="star"/> + </style> + <child> + <placeholder/> + </child> + </object> + </child> + </template> +</interface> diff --git a/src/gs-storage-context-dialog.c b/src/gs-storage-context-dialog.c new file mode 100644 index 0000000..e0ca377 --- /dev/null +++ b/src/gs-storage-context-dialog.c @@ -0,0 +1,412 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2021 Endless OS Foundation LLC + * + * Author: Philip Withnall <pwithnall@endlessos.org> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +/** + * SECTION:gs-storage-context-dialog + * @short_description: A dialog showing storage information about an app + * + * #GsStorageContextDialog is a dialog which shows detailed information + * about the download size of an uninstalled app, or the storage usage of + * an installed one. It shows how those sizes are broken down into components + * such as user data, cached data, or dependencies, where possible. + * + * It is designed to show a more detailed view of the information which the + * app’s storage tile in #GsAppContextBar is derived from. + * + * The widget has no special appearance if the app is unset, so callers will + * typically want to hide the dialog in that case. + * + * Since: 41 + */ + +#include "config.h" + +#include <adwaita.h> +#include <glib.h> +#include <glib-object.h> +#include <glib/gi18n.h> +#include <gtk/gtk.h> +#include <locale.h> + +#include "gs-app.h" +#include "gs-common.h" +#include "gs-context-dialog-row.h" +#include "gs-lozenge.h" +#include "gs-storage-context-dialog.h" + +struct _GsStorageContextDialog +{ + GsInfoWindow parent_instance; + + GsApp *app; /* (nullable) (owned) */ + gulong app_notify_handler; + + GtkSizeGroup *lozenge_size_group; + GtkWidget *lozenge; + GtkLabel *title; + GtkListBox *sizes_list; + GtkLabel *manage_storage_label; +}; + +G_DEFINE_TYPE (GsStorageContextDialog, gs_storage_context_dialog, GS_TYPE_INFO_WINDOW) + +typedef enum { + PROP_APP = 1, +} GsStorageContextDialogProperty; + +static GParamSpec *obj_props[PROP_APP + 1] = { NULL, }; + +typedef enum { + MATCH_STATE_NO_MATCH = 0, + MATCH_STATE_MATCH = 1, + MATCH_STATE_UNKNOWN, +} MatchState; + +/* The arguments are all non-nullable. */ +static void +add_size_row (GtkListBox *list_box, + GtkSizeGroup *lozenge_size_group, + GsSizeType size_type, + guint64 size_bytes, + const gchar *title, + const gchar *description) +{ + GtkListBoxRow *row; + g_autofree gchar *size_bytes_str = NULL; + gboolean is_markup = FALSE; + + if (size_type != GS_SIZE_TYPE_VALID) + /* Translators: This is shown in a bubble if the storage + * size of an application is not known. The bubble is small, + * so the string should be as short as possible. */ + size_bytes_str = g_strdup (_("?")); + else if (size_bytes == 0) + /* Translators: This is shown in a bubble to represent a 0 byte + * storage size, so its context is “storage size: none”. The + * bubble is small, so the string should be as short as + * possible. */ + size_bytes_str = g_strdup (_("None")); + else + size_bytes_str = gs_utils_format_size (size_bytes, &is_markup); + + row = gs_context_dialog_row_new_text (size_bytes_str, GS_CONTEXT_DIALOG_ROW_IMPORTANCE_NEUTRAL, + title, description); + if (is_markup) + gs_context_dialog_row_set_content_markup (GS_CONTEXT_DIALOG_ROW (row), size_bytes_str); + gs_context_dialog_row_set_size_groups (GS_CONTEXT_DIALOG_ROW (row), lozenge_size_group, NULL, NULL); + gtk_list_box_append (list_box, GTK_WIDGET (row)); +} + +static void +update_sizes_list (GsStorageContextDialog *self) +{ + GsSizeType title_size_type; + guint64 title_size_bytes; + g_autofree gchar *title_size_bytes_str = NULL; + const gchar *title; + gboolean cache_row_added = FALSE; + gboolean is_markup = FALSE; + + gs_widget_remove_all (GTK_WIDGET (self->sizes_list), (GsRemoveFunc) gtk_list_box_remove); + + /* UI state is undefined if app is not set. */ + if (self->app == NULL) + return; + + if (gs_app_is_installed (self->app)) { + guint64 size_installed_bytes, size_user_data_bytes, size_cache_data_bytes; + GsSizeType size_installed_type, size_user_data_type, size_cache_data_type; + + /* Don’t list the size of the dependencies as that space likely + * won’t be reclaimed unless many other apps are removed. */ + size_installed_type = gs_app_get_size_installed (self->app, &size_installed_bytes); + size_user_data_type = gs_app_get_size_user_data (self->app, &size_user_data_bytes); + size_cache_data_type = gs_app_get_size_cache_data (self->app, &size_cache_data_bytes); + + title = _("Installed Size"); + title_size_bytes = size_installed_bytes; + title_size_type = size_installed_type; + + add_size_row (self->sizes_list, self->lozenge_size_group, + size_installed_type, size_installed_bytes, + _("Application Data"), + _("Data needed for the application to run")); + + if (size_user_data_type == GS_SIZE_TYPE_VALID) { + add_size_row (self->sizes_list, self->lozenge_size_group, + size_user_data_type, size_user_data_bytes, + _("User Data"), + _("Data created by you in the application")); + title_size_bytes += size_user_data_bytes; + } + + if (size_cache_data_type == GS_SIZE_TYPE_VALID) { + add_size_row (self->sizes_list, self->lozenge_size_group, + size_cache_data_type, size_cache_data_bytes, + _("Cache Data"), + _("Temporary cached data")); + title_size_bytes += size_cache_data_bytes; + cache_row_added = TRUE; + } + } else { + guint64 size_download_bytes, size_download_dependencies_bytes; + GsSizeType size_download_type, size_download_dependencies_type; + + size_download_type = gs_app_get_size_download (self->app, &size_download_bytes); + size_download_dependencies_type = gs_app_get_size_download_dependencies (self->app, &size_download_dependencies_bytes); + + title = _("Download Size"); + title_size_bytes = size_download_bytes; + title_size_type = size_download_type; + + add_size_row (self->sizes_list, self->lozenge_size_group, + size_download_type, size_download_bytes, + gs_app_get_name (self->app), + _("The application itself")); + + if (size_download_dependencies_type == GS_SIZE_TYPE_VALID) { + add_size_row (self->sizes_list, self->lozenge_size_group, + size_download_dependencies_type, size_download_dependencies_bytes, + _("Required Dependencies"), + _("Shared system components required by this application")); + title_size_bytes += size_download_dependencies_bytes; + } + + /* FIXME: Addons, Potential Additional Downloads */ + } + + if (title_size_type == GS_SIZE_TYPE_VALID) + title_size_bytes_str = gs_utils_format_size (title_size_bytes, &is_markup); + else + title_size_bytes_str = g_strdup (C_("Download size", "Unknown")); + + if (is_markup) + gs_lozenge_set_markup (GS_LOZENGE (self->lozenge), title_size_bytes_str); + else + gs_lozenge_set_text (GS_LOZENGE (self->lozenge), title_size_bytes_str); + + gtk_label_set_text (self->title, title); + + /* Update the Manage Storage label. */ + gtk_widget_set_visible (GTK_WIDGET (self->manage_storage_label), cache_row_added); +} + +static void +app_notify_cb (GObject *obj, + GParamSpec *pspec, + gpointer user_data) +{ + GsStorageContextDialog *self = GS_STORAGE_CONTEXT_DIALOG (user_data); + GQuark pspec_name_quark = g_param_spec_get_name_quark (pspec); + + if (pspec_name_quark == g_quark_from_static_string ("state") || + pspec_name_quark == g_quark_from_static_string ("size-installed") || + pspec_name_quark == g_quark_from_static_string ("size-installed-dependencies") || + pspec_name_quark == g_quark_from_static_string ("size-download") || + pspec_name_quark == g_quark_from_static_string ("size-download-dependencies") || + pspec_name_quark == g_quark_from_static_string ("size-cache-data") || + pspec_name_quark == g_quark_from_static_string ("size-user-data")) + update_sizes_list (self); +} + +static gboolean +manage_storage_activate_link_cb (GtkLabel *label, + const gchar *uri, + gpointer user_data) +{ + GsStorageContextDialog *self = GS_STORAGE_CONTEXT_DIALOG (user_data); + g_autoptr(GError) local_error = NULL; + const gchar *desktop_id; + const gchar *argv[] = { + "gnome-control-center", + "applications", + "", /* application ID */ + NULL + }; + + /* Button shouldn’t have been sensitive if the launchable ID isn’t available. */ + desktop_id = gs_app_get_launchable (self->app, AS_LAUNCHABLE_KIND_DESKTOP_ID); + g_assert (desktop_id != NULL); + + argv[2] = desktop_id; + + if (!g_spawn_async (NULL, (gchar **) argv, NULL, + G_SPAWN_SEARCH_PATH | + G_SPAWN_STDOUT_TO_DEV_NULL | + G_SPAWN_STDERR_TO_DEV_NULL | + G_SPAWN_CLOEXEC_PIPES, + NULL, NULL, NULL, &local_error)) { + g_warning ("Error opening GNOME Control Center: %s", + local_error->message); + return TRUE; + } + + return TRUE; +} + +static void +gs_storage_context_dialog_init (GsStorageContextDialog *self) +{ + gtk_widget_init_template (GTK_WIDGET (self)); +} + +static void +gs_storage_context_dialog_get_property (GObject *object, + guint prop_id, + GValue *value, + GParamSpec *pspec) +{ + GsStorageContextDialog *self = GS_STORAGE_CONTEXT_DIALOG (object); + + switch ((GsStorageContextDialogProperty) prop_id) { + case PROP_APP: + g_value_set_object (value, gs_storage_context_dialog_get_app (self)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_storage_context_dialog_set_property (GObject *object, + guint prop_id, + const GValue *value, + GParamSpec *pspec) +{ + GsStorageContextDialog *self = GS_STORAGE_CONTEXT_DIALOG (object); + + switch ((GsStorageContextDialogProperty) prop_id) { + case PROP_APP: + gs_storage_context_dialog_set_app (self, g_value_get_object (value)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_storage_context_dialog_dispose (GObject *object) +{ + GsStorageContextDialog *self = GS_STORAGE_CONTEXT_DIALOG (object); + + gs_storage_context_dialog_set_app (self, NULL); + + G_OBJECT_CLASS (gs_storage_context_dialog_parent_class)->dispose (object); +} + +static void +gs_storage_context_dialog_class_init (GsStorageContextDialogClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + + object_class->get_property = gs_storage_context_dialog_get_property; + object_class->set_property = gs_storage_context_dialog_set_property; + object_class->dispose = gs_storage_context_dialog_dispose; + + /** + * GsStorageContextDialog:app: (nullable) + * + * The app to display the storage context details for. + * + * This may be %NULL; if so, the content of the widget will be + * undefined. + * + * Since: 41 + */ + obj_props[PROP_APP] = + g_param_spec_object ("app", NULL, NULL, + GS_TYPE_APP, + G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS); + + g_object_class_install_properties (object_class, G_N_ELEMENTS (obj_props), obj_props); + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-storage-context-dialog.ui"); + + gtk_widget_class_bind_template_child (widget_class, GsStorageContextDialog, lozenge_size_group); + gtk_widget_class_bind_template_child (widget_class, GsStorageContextDialog, lozenge); + gtk_widget_class_bind_template_child (widget_class, GsStorageContextDialog, title); + gtk_widget_class_bind_template_child (widget_class, GsStorageContextDialog, sizes_list); + gtk_widget_class_bind_template_child (widget_class, GsStorageContextDialog, manage_storage_label); + + gtk_widget_class_bind_template_callback (widget_class, manage_storage_activate_link_cb); +} + +/** + * gs_storage_context_dialog_new: + * @app: (nullable): the app to display storage context information for, or %NULL + * + * Create a new #GsStorageContextDialog and set its initial app to @app. + * + * Returns: (transfer full): a new #GsStorageContextDialog + * Since: 41 + */ +GsStorageContextDialog * +gs_storage_context_dialog_new (GsApp *app) +{ + g_return_val_if_fail (app == NULL || GS_IS_APP (app), NULL); + + return g_object_new (GS_TYPE_STORAGE_CONTEXT_DIALOG, + "app", app, + NULL); +} + +/** + * gs_storage_context_dialog_get_app: + * @self: a #GsStorageContextDialog + * + * Gets the value of #GsStorageContextDialog:app. + * + * Returns: (nullable) (transfer none): app whose storage context information is + * being displayed, or %NULL if none is set + * Since: 41 + */ +GsApp * +gs_storage_context_dialog_get_app (GsStorageContextDialog *self) +{ + g_return_val_if_fail (GS_IS_STORAGE_CONTEXT_DIALOG (self), NULL); + + return self->app; +} + +/** + * gs_storage_context_dialog_set_app: + * @self: a #GsStorageContextDialog + * @app: (nullable) (transfer none): the app to display storage context + * information for, or %NULL for none + * + * Set the value of #GsStorageContextDialog:app. + * + * Since: 41 + */ +void +gs_storage_context_dialog_set_app (GsStorageContextDialog *self, + GsApp *app) +{ + g_return_if_fail (GS_IS_STORAGE_CONTEXT_DIALOG (self)); + g_return_if_fail (app == NULL || GS_IS_APP (app)); + + if (app == self->app) + return; + + g_clear_signal_handler (&self->app_notify_handler, self->app); + + g_set_object (&self->app, app); + + if (self->app != NULL) + self->app_notify_handler = g_signal_connect (self->app, "notify", G_CALLBACK (app_notify_cb), self); + + /* Update the UI. */ + update_sizes_list (self); + + g_object_notify_by_pspec (G_OBJECT (self), obj_props[PROP_APP]); +} diff --git a/src/gs-storage-context-dialog.h b/src/gs-storage-context-dialog.h new file mode 100644 index 0000000..e1ff22b --- /dev/null +++ b/src/gs-storage-context-dialog.h @@ -0,0 +1,32 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2021 Endless OS Foundation LLC + * + * Author: Philip Withnall <pwithnall@endlessos.org> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <glib.h> +#include <glib-object.h> +#include <gtk/gtk.h> + +#include "gs-app.h" +#include "gs-info-window.h" + +G_BEGIN_DECLS + +#define GS_TYPE_STORAGE_CONTEXT_DIALOG (gs_storage_context_dialog_get_type ()) + +G_DECLARE_FINAL_TYPE (GsStorageContextDialog, gs_storage_context_dialog, GS, STORAGE_CONTEXT_DIALOG, GsInfoWindow) + +GsStorageContextDialog *gs_storage_context_dialog_new (GsApp *app); + +GsApp *gs_storage_context_dialog_get_app (GsStorageContextDialog *self); +void gs_storage_context_dialog_set_app (GsStorageContextDialog *self, + GsApp *app); + +G_END_DECLS diff --git a/src/gs-storage-context-dialog.ui b/src/gs-storage-context-dialog.ui new file mode 100644 index 0000000..de2f589 --- /dev/null +++ b/src/gs-storage-context-dialog.ui @@ -0,0 +1,87 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <requires lib="gtk+" version="3.10"/> + <template class="GsStorageContextDialog" parent="GsInfoWindow"> + <property name="title" translatable="yes" comments="Translators: This is the title of the dialog which contains information about the storage or download size needed for an app">Storage</property> + <child> + <object class="AdwPreferencesPage"> + <child> + <object class="AdwPreferencesGroup"> + + <child> + <object class="GtkBox"> + <property name="orientation">vertical</property> + <property name="spacing">8</property> + + <child> + <object class="GtkBox"> + <property name="margin-top">20</property> + <property name="margin-bottom">16</property> + <property name="margin-start">20</property> + <property name="margin-end">20</property> + <property name="orientation">vertical</property> + <property name="spacing">12</property> + + <child> + <object class="GsLozenge" id="lozenge"> + <property name="circular">False</property> + <style> + <class name="large"/> + <class name="grey"/> + </style> + <accessibility> + <relation name="labelled-by">title</relation> + </accessibility> + </object> + </child> + + <child> + <object class="GtkLabel" id="title"> + <property name="justify">center</property> + <!-- this is a placeholder: the text is actually set in code --> + <property name="label">Shortwave works on this device</property> + <property name="wrap">True</property> + <property name="xalign">0.5</property> + <style> + <class name="title-2"/> + </style> + </object> + </child> + </object> + </child> + + <child> + <object class="GtkListBox" id="sizes_list"> + <property name="selection_mode">none</property> + <property name="halign">fill</property> + <property name="valign">start</property> + <style> + <class name="boxed-list"/> + </style> + <!-- Rows are added in code --> + <placeholder/> + </object> + </child> + + <child> + <object class="GtkLabel" id="manage_storage_label"> + <!-- The ‘dummy’ URI is ignored in the activate-link handler, but needs to be specified as otherwise GTK complains that no href is set --> + <property name="label" translatable="yes" comments="Translators: Please do not translate the markup or link href">Cached data can be cleared from the <a href="dummy">_application settings</a>.</property> + <property name="margin-top">16</property> + <property name="use-markup">True</property> + <property name="use-underline">True</property> + <signal name="activate-link" handler="manage_storage_activate_link_cb"/> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + </template> + + <object class="GtkSizeGroup" id="lozenge_size_group"> + <property name="mode">horizontal</property> + </object> +</interface> diff --git a/src/gs-summary-tile.c b/src/gs-summary-tile.c new file mode 100644 index 0000000..dcd977b --- /dev/null +++ b/src/gs-summary-tile.c @@ -0,0 +1,253 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013 Matthias Clasen <mclasen@redhat.com> + * Copyright (C) 2019 Richard Hughes <richard@hughsie.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include <glib/gi18n.h> + +#include "gs-summary-tile.h" +#include "gs-layout-manager.h" +#include "gs-common.h" + +#define GS_TYPE_SUMMARY_TILE_LAYOUT (gs_summary_tile_layout_get_type ()) +G_DECLARE_FINAL_TYPE (GsSummaryTileLayout, gs_summary_tile_layout, GS, SUMMARY_TILE_LAYOUT, GsLayoutManager) + +struct _GsSummaryTileLayout +{ + GsLayoutManager parent_instance; + + gint preferred_width; +}; + +G_DEFINE_TYPE (GsSummaryTileLayout, gs_summary_tile_layout, GS_TYPE_LAYOUT_MANAGER) + +static void +gs_summary_tile_layout_measure (GtkLayoutManager *layout_manager, + GtkWidget *widget, + GtkOrientation orientation, + gint for_size, + gint *minimum, + gint *natural, + gint *minimum_baseline, + gint *natural_baseline) +{ + GsSummaryTileLayout *self = GS_SUMMARY_TILE_LAYOUT (layout_manager); + + GTK_LAYOUT_MANAGER_CLASS (gs_summary_tile_layout_parent_class)->measure (layout_manager, + widget, orientation, for_size, minimum, natural, minimum_baseline, natural_baseline); + + /* Limit the natural width */ + if (self->preferred_width > 0 && orientation == GTK_ORIENTATION_HORIZONTAL) + *natural = MAX (*minimum, self->preferred_width); +} + +static void +gs_summary_tile_layout_class_init (GsSummaryTileLayoutClass *klass) +{ + GtkLayoutManagerClass *layout_manager_class = GTK_LAYOUT_MANAGER_CLASS (klass); + layout_manager_class->measure = gs_summary_tile_layout_measure; +} + +static void +gs_summary_tile_layout_init (GsSummaryTileLayout *self) +{ +} + +/* ********************************************************************* */ + +struct _GsSummaryTile +{ + GsAppTile parent_instance; + + GtkWidget *image; + GtkWidget *name; + GtkWidget *summary; + GtkWidget *bin; + GtkWidget *stack; + gint preferred_width; +}; + +G_DEFINE_TYPE (GsSummaryTile, gs_summary_tile, GS_TYPE_APP_TILE) + +typedef enum { + PROP_PREFERRED_WIDTH = 1, +} GsSummaryTileProperty; + +static GParamSpec *obj_props[PROP_PREFERRED_WIDTH + 1] = { NULL, }; + +static void +gs_summary_tile_refresh (GsAppTile *self) +{ + GsSummaryTile *tile = GS_SUMMARY_TILE (self); + GsApp *app = gs_app_tile_get_app (self); + g_autoptr(GIcon) icon = NULL; + gboolean installed; + g_autofree gchar *name = NULL; + const gchar *summary; + + if (app == NULL) + return; + + gtk_image_set_pixel_size (GTK_IMAGE (tile->image), 64); + gtk_stack_set_visible_child_name (GTK_STACK (tile->stack), "content"); + + /* set name */ + gtk_label_set_label (GTK_LABEL (tile->name), gs_app_get_name (app)); + + summary = gs_app_get_summary (app); + gtk_label_set_label (GTK_LABEL (tile->summary), summary); + gtk_widget_set_visible (tile->summary, summary && summary[0]); + + icon = gs_app_get_icon_for_size (app, + gtk_image_get_pixel_size (GTK_IMAGE (tile->image)), + gtk_widget_get_scale_factor (tile->image), + "system-component-application"); + gtk_image_set_from_gicon (GTK_IMAGE (tile->image), icon); + + switch (gs_app_get_state (app)) { + case GS_APP_STATE_INSTALLED: + case GS_APP_STATE_UPDATABLE: + case GS_APP_STATE_UPDATABLE_LIVE: + installed = TRUE; + name = g_strdup_printf (_("%s (Installed)"), + gs_app_get_name (app)); + break; + case GS_APP_STATE_INSTALLING: + installed = FALSE; + name = g_strdup_printf (_("%s (Installing)"), + gs_app_get_name (app)); + break; + case GS_APP_STATE_REMOVING: + installed = TRUE; + name = g_strdup_printf (_("%s (Removing)"), + gs_app_get_name (app)); + break; + case GS_APP_STATE_QUEUED_FOR_INSTALL: + case GS_APP_STATE_AVAILABLE: + default: + installed = FALSE; + name = g_strdup (gs_app_get_name (app)); + break; + } + + gtk_widget_set_visible (tile->bin, installed); + + if (name != NULL) { + gtk_accessible_update_property (GTK_ACCESSIBLE (tile), + GTK_ACCESSIBLE_PROPERTY_LABEL, name, + GTK_ACCESSIBLE_PROPERTY_DESCRIPTION, gs_app_get_summary (app), + -1); + } +} + +static void +gs_summary_tile_init (GsSummaryTile *tile) +{ + tile->preferred_width = -1; + gtk_widget_init_template (GTK_WIDGET (tile)); +} + +static void +gs_summary_tile_get_property (GObject *object, + guint prop_id, + GValue *value, + GParamSpec *pspec) +{ + GsSummaryTile *app_tile = GS_SUMMARY_TILE (object); + + switch ((GsSummaryTileProperty) prop_id) { + case PROP_PREFERRED_WIDTH: + g_value_set_int (value, app_tile->preferred_width); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_summary_tile_set_property (GObject *object, + guint prop_id, + const GValue *value, + GParamSpec *pspec) +{ + GsSummaryTile *app_tile = GS_SUMMARY_TILE (object); + GtkLayoutManager *layout_manager; + + switch ((GsSummaryTileProperty) prop_id) { + case PROP_PREFERRED_WIDTH: + app_tile->preferred_width = g_value_get_int (value); + layout_manager = gtk_widget_get_layout_manager (GTK_WIDGET (app_tile)); + GS_SUMMARY_TILE_LAYOUT (layout_manager)->preferred_width = app_tile->preferred_width; + gtk_layout_manager_layout_changed (layout_manager); + g_object_notify_by_pspec (object, obj_props[PROP_PREFERRED_WIDTH]); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_summary_tile_class_init (GsSummaryTileClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + GsAppTileClass *tile_class = GS_APP_TILE_CLASS (klass); + + object_class->get_property = gs_summary_tile_get_property; + object_class->set_property = gs_summary_tile_set_property; + + tile_class->refresh = gs_summary_tile_refresh; + + /** + * GsAppTile:preferred-width: + * + * The only purpose of this property is to be retrieved as the + * natural width by gtk_widget_get_preferred_width() fooling the + * parent #GtkFlowBox container and making it switch to more columns + * (children per row) if it is able to place n+1 children in a row + * having this specified width. If this value is less than a minimum + * width of this app tile then the minimum is returned instead. Set + * this property to -1 to turn off this feature and return the default + * natural width instead. + */ + obj_props[PROP_PREFERRED_WIDTH] = + g_param_spec_int ("preferred-width", + "Preferred width", + "The preferred width of this widget, its only purpose is to trick the parent container", + -1, G_MAXINT, -1, + G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS); + + g_object_class_install_properties (object_class, G_N_ELEMENTS (obj_props), obj_props); + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-summary-tile.ui"); + gtk_widget_class_set_layout_manager_type (widget_class, GS_TYPE_SUMMARY_TILE_LAYOUT); + /* Override the 'button' class name, to be able to turn off hover states */ + gtk_widget_class_set_css_name (widget_class, "gs-summary-tile"); + + gtk_widget_class_bind_template_child (widget_class, GsSummaryTile, + image); + gtk_widget_class_bind_template_child (widget_class, GsSummaryTile, + name); + gtk_widget_class_bind_template_child (widget_class, GsSummaryTile, + summary); + gtk_widget_class_bind_template_child (widget_class, GsSummaryTile, + bin); + gtk_widget_class_bind_template_child (widget_class, GsSummaryTile, + stack); +} + +GtkWidget * +gs_summary_tile_new (GsApp *app) +{ + return g_object_new (GS_TYPE_SUMMARY_TILE, + "app", app, + NULL); +} diff --git a/src/gs-summary-tile.h b/src/gs-summary-tile.h new file mode 100644 index 0000000..c9c16ef --- /dev/null +++ b/src/gs-summary-tile.h @@ -0,0 +1,21 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013 Matthias Clasen <mclasen@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include "gs-app-tile.h" + +G_BEGIN_DECLS + +#define GS_TYPE_SUMMARY_TILE (gs_summary_tile_get_type ()) + +G_DECLARE_FINAL_TYPE (GsSummaryTile, gs_summary_tile, GS, SUMMARY_TILE, GsAppTile) + +GtkWidget *gs_summary_tile_new (GsApp *app); + +G_END_DECLS diff --git a/src/gs-summary-tile.ui b/src/gs-summary-tile.ui new file mode 100644 index 0000000..b7016d1 --- /dev/null +++ b/src/gs-summary-tile.ui @@ -0,0 +1,130 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <!-- interface-requires gtk+ 3.10 --> + <template class="GsSummaryTile" parent="GsAppTile"> + <property name="hexpand">True</property> + <!-- This is the minimum (sic!) width of a tile when the GtkFlowBox parent container switches to 3 columns --> + <property name="preferred-width">270</property> + <style> + <class name="card"/> + <class name="activatable"/> + </style> + <child> + <object class="GtkStack" id="stack"> + + <child> + <object class="GtkStackPage"> + <property name="name">waiting</property> + <property name="child"> + <object class="GtkImage" id="waiting"> + <property name="halign">center</property> + <property name="valign">center</property> + <property name="pixel-size">16</property> + <property name="icon-name">content-loading-symbolic</property> + <style> + <class name="dim-label"/> + </style> + </object> + </property> + </object> + </child> + + <child> + <object class="GtkStackPage"> + <property name="name">content</property> + <property name="child"> + <object class="GtkOverlay" id="overlay"> + <property name="halign">fill</property> + <property name="valign">fill</property> + <child type="overlay"> + <object class="AdwBin" id="bin"> + <property name="visible">False</property> + <property name="halign">end</property> + <property name="valign">start</property> + <child> + <object class="GtkImage" id="installed-icon"> + <property name="halign">center</property> + <property name="valign">center</property> + <property name="pixel-size">16</property> + <property name="margin-top">9</property> + <property name="margin-end">9</property> + <property name="icon-name">app-installed-symbolic</property> + <style> + <class name="success"/> + </style> + </object> + </child> + </object> + </child> + <child> + <object class="GtkGrid" id="grid"> + <property name="margin-top">17</property> + <property name="margin-bottom">17</property> + <property name="margin-start">17</property> + <property name="margin-end">17</property> + <property name="row-spacing">1</property> + <property name="column-spacing">14</property> + <child> + <object class="GtkImage" id="image"> + <property name="pixel-size">64</property> + <style> + <class name="icon-dropshadow"/> + </style> + <layout> + <property name="column">0</property> + <property name="row">0</property> + <property name="column-span">1</property> + <property name="row-span">1</property> + </layout> + </object> + </child> + <child> + <object class="GtkBox" id="box"> + <property name="valign">center</property> + <property name="orientation">vertical</property> + <property name="spacing">6</property> + <child> + <object class="GtkLabel" id="name"> + <property name="ellipsize">end</property> + <property name="xalign">0.0</property> + <attributes> + <attribute name="weight" value="bold"/> + </attributes> + <style> + <class name="app-tile-label"/> + </style> + </object> + </child> + <child> + <object class="GtkLabel" id="summary"> + <property name="ellipsize">end</property> + <property name="xalign">0.0</property> + <property name="yalign">0.0</property> + <property name="lines">2</property> + <property name="vexpand">True</property> + <property name="single-line-mode">True</property> + <property name="wrap">True</property> + <style> + <class name="app-tile-label"/> + </style> + </object> + </child> + <layout> + <property name="column">1</property> + <property name="row">0</property> + <property name="column-span">1</property> + <property name="row-span">1</property> + </layout> + </object> + </child> + </object> + </child> + </object> + </property> + </object> + </child> + + </object> + </child> + </template> +</interface> diff --git a/src/gs-update-dialog.c b/src/gs-update-dialog.c new file mode 100644 index 0000000..fc14f2b --- /dev/null +++ b/src/gs-update-dialog.c @@ -0,0 +1,391 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013-2016 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2014-2018 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include <glib/gi18n.h> + +#include "gs-update-dialog.h" +#include "gs-app-details-page.h" +#include "gs-app-row.h" +#include "gs-os-update-page.h" +#include "gs-update-list.h" +#include "gs-common.h" + +struct _GsUpdateDialog +{ + AdwWindow parent_instance; + + GCancellable *cancellable; + GsPluginLoader *plugin_loader; + GsApp *app; + GtkWidget *leaflet; + GtkWidget *list_box_installed_updates; + GtkWidget *spinner; + GtkWidget *stack; + AdwWindowTitle *window_title; + gboolean showing_installed_updates; +}; + +G_DEFINE_TYPE (GsUpdateDialog, gs_update_dialog, ADW_TYPE_WINDOW) + +typedef enum { + PROP_PLUGIN_LOADER = 1, + PROP_APP, +} GsUpdateDialogProperty; + +static GParamSpec *obj_props[PROP_APP + 1] = { NULL, }; + +static void gs_update_dialog_show_installed_updates (GsUpdateDialog *dialog); +static void gs_update_dialog_show_update_details (GsUpdateDialog *dialog, GsApp *app); + +static void +leaflet_child_transition_cb (AdwLeaflet *leaflet, GParamSpec *pspec, GsUpdateDialog *dialog) +{ + GtkWidget *child; + + if (adw_leaflet_get_child_transition_running (leaflet)) + return; + + while ((child = adw_leaflet_get_adjacent_child (leaflet, ADW_NAVIGATION_DIRECTION_FORWARD))) + adw_leaflet_remove (leaflet, child); + + child = adw_leaflet_get_visible_child (leaflet); + if (child != NULL && g_object_class_find_property (G_OBJECT_CLASS (GTK_WIDGET_GET_CLASS (child)), "title") != NULL) { + g_autofree gchar *title = NULL; + g_object_get (G_OBJECT (child), "title", &title, NULL); + gtk_window_set_title (GTK_WINDOW (dialog), title); + } else if (dialog->showing_installed_updates) { + gtk_window_set_title (GTK_WINDOW (dialog), _("Installed Updates")); + } else { + gtk_window_set_title (GTK_WINDOW (dialog), ""); + } +} + +static void +installed_updates_row_activated_cb (GsUpdateList *update_list, + GsApp *app, + GsUpdateDialog *dialog) +{ + gs_update_dialog_show_update_details (dialog, app); +} + +static void +get_installed_updates_cb (GsPluginLoader *plugin_loader, + GAsyncResult *res, + GsUpdateDialog *dialog) +{ + guint i; + guint64 install_date; + g_autoptr(GsAppList) list = NULL; + g_autoptr(GError) error = NULL; + + /* get the results */ + list = gs_plugin_loader_job_process_finish (plugin_loader, res, &error); + + /* if we're in teardown, short-circuit and return immediately without + * dereferencing priv variables */ + if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_CANCELLED) || + g_error_matches (error, G_IO_ERROR, G_IO_ERROR_CANCELLED) || + dialog->spinner == NULL) { + g_debug ("get installed updates cancelled"); + return; + } + + gtk_spinner_stop (GTK_SPINNER (dialog->spinner)); + + /* error */ + if (list == NULL) { + g_warning ("failed to get installed updates: %s", error->message); + gtk_stack_set_visible_child_name (GTK_STACK (dialog->stack), "empty"); + return; + } + + /* no results */ + if (gs_app_list_length (list) == 0) { + g_debug ("no installed updates to show"); + gtk_stack_set_visible_child_name (GTK_STACK (dialog->stack), "empty"); + return; + } + + /* set the header title using any one of the applications */ + install_date = gs_app_get_install_date (gs_app_list_index (list, 0)); + if (install_date > 0) { + g_autoptr(GDateTime) date = NULL; + g_autofree gchar *date_str = NULL; + g_autofree gchar *subtitle = NULL; + + date = g_date_time_new_from_unix_utc ((gint64) install_date); + date_str = g_date_time_format (date, "%x"); + + /* TRANSLATORS: this is the subtitle of the installed updates dialog window. + %s will be replaced by the date when the updates were installed. + The date format is defined by the locale's preferred date representation + ("%x" in strftime.) */ + subtitle = g_strdup_printf (_("Installed on %s"), date_str); + adw_window_title_set_subtitle (dialog->window_title, subtitle); + } + + gtk_stack_set_visible_child_name (GTK_STACK (dialog->stack), "installed-updates-list"); + + gs_update_list_remove_all (GS_UPDATE_LIST (dialog->list_box_installed_updates)); + for (i = 0; i < gs_app_list_length (list); i++) { + gs_update_list_add_app (GS_UPDATE_LIST (dialog->list_box_installed_updates), + gs_app_list_index (list, i)); + } +} + +static void +gs_update_dialog_show_installed_updates (GsUpdateDialog *dialog) +{ + g_autoptr(GsPluginJob) plugin_job = NULL; + + dialog->showing_installed_updates = TRUE; + + /* TRANSLATORS: this is the title of the installed updates dialog window */ + gtk_window_set_title (GTK_WINDOW (dialog), _("Installed Updates")); + + gtk_spinner_start (GTK_SPINNER (dialog->spinner)); + gtk_stack_set_visible_child_name (GTK_STACK (dialog->stack), "spinner"); + + plugin_job = gs_plugin_job_newv (GS_PLUGIN_ACTION_GET_UPDATES_HISTORICAL, + "refine-flags", GS_PLUGIN_REFINE_FLAGS_REQUIRE_UPDATE_DETAILS | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_ICON | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_VERSION, + NULL); + gs_plugin_loader_job_process_async (dialog->plugin_loader, plugin_job, + dialog->cancellable, + (GAsyncReadyCallback) get_installed_updates_cb, + dialog); +} + +static void +unset_focus (GtkWidget *widget) +{ + GtkWidget *focus; + + focus = gtk_window_get_focus (GTK_WINDOW (widget)); + if (GTK_IS_LABEL (focus)) + gtk_label_select_region (GTK_LABEL (focus), 0, 0); +} + +static void +back_clicked_cb (GtkWidget *widget, GsUpdateDialog *dialog) +{ + adw_leaflet_navigate (ADW_LEAFLET (dialog->leaflet), ADW_NAVIGATION_DIRECTION_BACK); +} + +static void +app_activated_cb (GtkWidget *widget, GsApp *app, GsUpdateDialog *page) +{ + gs_update_dialog_show_update_details (page, app); +} + +static void +gs_update_dialog_show_update_details (GsUpdateDialog *dialog, GsApp *app) +{ + GtkWidget *page; + AsComponentKind kind; + g_autofree gchar *str = NULL; + + /* debug */ + str = gs_app_to_string (app); + g_debug ("%s", str); + + /* workaround a gtk+ issue where the dialog comes up with a label selected, + * https://bugzilla.gnome.org/show_bug.cgi?id=734033 */ + unset_focus (GTK_WIDGET (dialog)); + + /* set update description */ + kind = gs_app_get_kind (app); + if (kind == AS_COMPONENT_KIND_GENERIC && + gs_app_get_special_kind (app) == GS_APP_SPECIAL_KIND_OS_UPDATE) { + page = gs_os_update_page_new (); + gs_os_update_page_set_app (GS_OS_UPDATE_PAGE (page), app); + g_signal_connect (page, "app-activated", + G_CALLBACK (app_activated_cb), dialog); + gs_os_update_page_set_show_back_button (GS_OS_UPDATE_PAGE (page), dialog->showing_installed_updates); + } else { + page = gs_app_details_page_new (); + gs_app_details_page_set_app (GS_APP_DETAILS_PAGE (page), app); + } + + g_signal_connect (page, "back-clicked", + G_CALLBACK (back_clicked_cb), dialog); + + gtk_widget_show (page); + + adw_leaflet_append (ADW_LEAFLET (dialog->leaflet), page); + adw_leaflet_set_visible_child (ADW_LEAFLET (dialog->leaflet), page); +} + +static void +gs_update_dialog_get_property (GObject *object, + guint prop_id, + GValue *value, + GParamSpec *pspec) +{ + GsUpdateDialog *dialog = GS_UPDATE_DIALOG (object); + + switch ((GsUpdateDialogProperty) prop_id) { + case PROP_PLUGIN_LOADER: + g_value_set_object (value, dialog->plugin_loader); + break; + case PROP_APP: + g_value_set_object (value, dialog->app); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_update_dialog_set_property (GObject *object, + guint prop_id, + const GValue *value, + GParamSpec *pspec) +{ + GsUpdateDialog *dialog = GS_UPDATE_DIALOG (object); + + switch ((GsUpdateDialogProperty) prop_id) { + case PROP_PLUGIN_LOADER: + dialog->plugin_loader = g_object_ref (g_value_get_object (value)); + break; + case PROP_APP: + dialog->app = g_value_get_object (value); + if (dialog->app != NULL) + g_object_ref (dialog->app); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_update_dialog_constructed (GObject *object) +{ + GsUpdateDialog *dialog = GS_UPDATE_DIALOG (object); + + g_assert (dialog->plugin_loader); + + if (dialog->app) { + GtkWidget *child; + + child = adw_leaflet_get_visible_child (ADW_LEAFLET (dialog->leaflet)); + adw_leaflet_remove (ADW_LEAFLET (dialog->leaflet), child); + + gs_update_dialog_show_update_details (dialog, dialog->app); + + child = adw_leaflet_get_visible_child (ADW_LEAFLET (dialog->leaflet)); + /* It can be either the app details page or the OS update page */ + if (GS_IS_APP_DETAILS_PAGE (child)) + gs_app_details_page_set_show_back_button (GS_APP_DETAILS_PAGE (child), FALSE); + } else { + gs_update_dialog_show_installed_updates (dialog); + } + + G_OBJECT_CLASS (gs_update_dialog_parent_class)->constructed (object); +} + +static void +gs_update_dialog_dispose (GObject *object) +{ + GsUpdateDialog *dialog = GS_UPDATE_DIALOG (object); + + g_cancellable_cancel (dialog->cancellable); + g_clear_object (&dialog->cancellable); + + g_clear_object (&dialog->plugin_loader); + g_clear_object (&dialog->app); + + G_OBJECT_CLASS (gs_update_dialog_parent_class)->dispose (object); +} + +static void +gs_update_dialog_init (GsUpdateDialog *dialog) +{ + gtk_widget_init_template (GTK_WIDGET (dialog)); + + dialog->cancellable = g_cancellable_new (); + + g_signal_connect (dialog->list_box_installed_updates, "show-update", + G_CALLBACK (installed_updates_row_activated_cb), dialog); + + g_signal_connect_after (dialog, "show", G_CALLBACK (unset_focus), NULL); +} + +static void +gs_update_dialog_class_init (GsUpdateDialogClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + + object_class->get_property = gs_update_dialog_get_property; + object_class->set_property = gs_update_dialog_set_property; + object_class->constructed = gs_update_dialog_constructed; + object_class->dispose = gs_update_dialog_dispose; + + /** + * GsUpdateDialog:plugin-loader + * + * The plugin loader of the dialog. + * + * Since: 41 + */ + obj_props[PROP_PLUGIN_LOADER] = + g_param_spec_object ("plugin-loader", NULL, NULL, + GS_TYPE_PLUGIN_LOADER, + G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS); + + /** + * GsUpdateDialog:app: (nullable) + * + * The app whose details to display. + * + * If none is set, the intalled updates will be displayed. + * + * Since: 41 + */ + obj_props[PROP_APP] = + g_param_spec_object ("app", NULL, NULL, + GS_TYPE_APP, + G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS); + + g_object_class_install_properties (object_class, G_N_ELEMENTS (obj_props), obj_props); + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-update-dialog.ui"); + + gtk_widget_class_bind_template_child (widget_class, GsUpdateDialog, leaflet); + gtk_widget_class_bind_template_child (widget_class, GsUpdateDialog, list_box_installed_updates); + gtk_widget_class_bind_template_child (widget_class, GsUpdateDialog, spinner); + gtk_widget_class_bind_template_child (widget_class, GsUpdateDialog, stack); + gtk_widget_class_bind_template_child (widget_class, GsUpdateDialog, window_title); + gtk_widget_class_bind_template_callback (widget_class, leaflet_child_transition_cb); + + gtk_widget_class_add_binding_action (widget_class, GDK_KEY_Escape, 0, "window.close", NULL); +} + +GtkWidget * +gs_update_dialog_new (GsPluginLoader *plugin_loader) +{ + return GTK_WIDGET (g_object_new (GS_TYPE_UPDATE_DIALOG, + "plugin-loader", plugin_loader, + NULL)); +} + +GtkWidget * +gs_update_dialog_new_for_app (GsPluginLoader *plugin_loader, GsApp *app) +{ + return GTK_WIDGET (g_object_new (GS_TYPE_UPDATE_DIALOG, + "plugin-loader", plugin_loader, + "app", app, + NULL)); +} diff --git a/src/gs-update-dialog.h b/src/gs-update-dialog.h new file mode 100644 index 0000000..5e177eb --- /dev/null +++ b/src/gs-update-dialog.h @@ -0,0 +1,27 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2014-2015 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <adwaita.h> +#include <gtk/gtk.h> + +#include "gnome-software-private.h" + +G_BEGIN_DECLS + +#define GS_TYPE_UPDATE_DIALOG (gs_update_dialog_get_type ()) + +G_DECLARE_FINAL_TYPE (GsUpdateDialog, gs_update_dialog, GS, UPDATE_DIALOG, AdwWindow) + +GtkWidget *gs_update_dialog_new (GsPluginLoader *plugin_loader); +GtkWidget *gs_update_dialog_new_for_app (GsPluginLoader *plugin_loader, + GsApp *app); + +G_END_DECLS diff --git a/src/gs-update-dialog.ui b/src/gs-update-dialog.ui new file mode 100644 index 0000000..9dc9f21 --- /dev/null +++ b/src/gs-update-dialog.ui @@ -0,0 +1,108 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <requires lib="gtk+" version="3.10"/> + <template class="GsUpdateDialog" parent="AdwWindow"> + <property name="modal">True</property> + <property name="destroy_with_parent">True</property> + <property name="icon_name">dialog-information</property> + <property name="title"></property> + <property name="default-width">640</property> + <property name="default-height">576</property> + + <child> + <object class="AdwLeaflet" id="leaflet"> + <property name="can-navigate-back">True</property> + <property name="can-unfold">False</property> + <!-- We need both signals to support the animations being disabled, as + notify::child-transition-running isn't emitted in that case. --> + <signal name="notify::visible-child" handler="leaflet_child_transition_cb" swapped="no"/> + <signal name="notify::child-transition-running" handler="leaflet_child_transition_cb" swapped="no"/> + <child> + <object class="GtkBox"> + <property name="orientation">vertical</property> + <child> + <object class="AdwHeaderBar"> + <property name="valign">start</property> + <property name="show_start_title_buttons">True</property> + <property name="show_end_title_buttons">True</property> + <property name="title-widget"> + <object class="AdwWindowTitle" id="window_title"> + <property name="title" bind-source="GsUpdateDialog" bind-property="title" bind-flags="sync-create"/> + </object> + </property> + </object> + </child> + <child> + <object class="GtkStack" id="stack"> + <property name="transition_duration">300</property> + + <child> + <object class="GtkStackPage"> + <property name="name">spinner</property> + <property name="child"> + <object class="GtkBox" id="box_spinner"> + <property name="orientation">vertical</property> + <property name="spacing">12</property> + <property name="halign">center</property> + <property name="valign">center</property> + <property name="hexpand">True</property> + <property name="vexpand">True</property> + <style> + <class name="dim-label"/> + </style> + <child> + <object class="GtkSpinner" id="spinner"> + <property name="width_request">32</property> + <property name="height_request">32</property> + <property name="halign">center</property> + <property name="valign">center</property> + <property name="hexpand">True</property> + <property name="vexpand">True</property> + <style> + <class name="fade-in"/> + </style> + </object> + </child> + </object> + </property> + </object> + </child> + + <child> + <object class="GtkStackPage"> + <property name="name">empty</property> + <property name="child"> + <object class="AdwStatusPage"> + <property name="icon_name">org.gnome.Software-symbolic</property> + <property name="title" translatable="yes">No Updates Installed</property> + </object> + </property> + </object> + </child> + + <child> + <object class="GtkStackPage"> + <property name="name">installed-updates-list</property> + <property name="child"> + <object class="AdwPreferencesPage"> + <child> + <object class="AdwPreferencesGroup"> + <child> + <object class="GsUpdateList" id="list_box_installed_updates"> + </object> + </child> + </object> + </child> + </object> + </property> + </object> + </child> + + </object> + </child> + </object> + </child> + </object> + </child> + </template> +</interface> diff --git a/src/gs-update-list.c b/src/gs-update-list.c new file mode 100644 index 0000000..5e9562c --- /dev/null +++ b/src/gs-update-list.c @@ -0,0 +1,150 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013-2017 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2014-2018 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include <glib/gi18n.h> + +#include "gs-update-list.h" + +#include "gs-app-row.h" +#include "gs-common.h" + +typedef struct +{ + GtkSizeGroup *sizegroup_name; + GtkListBox *listbox; +} GsUpdateListPrivate; + +G_DEFINE_TYPE_WITH_PRIVATE (GsUpdateList, gs_update_list, GTK_TYPE_WIDGET) + +enum { + SIGNAL_SHOW_UPDATE, +}; + +static guint signals [SIGNAL_SHOW_UPDATE + 1] = { 0 }; + +static void +installed_updates_row_activated_cb (GtkListBox *list_box, + GtkListBoxRow *row, + GsUpdateList *self) +{ + GsApp *app = gs_app_row_get_app (GS_APP_ROW (row)); + + g_signal_emit (self, signals[SIGNAL_SHOW_UPDATE], 0, app); +} + +static void +gs_update_list_app_state_notify_cb (GsApp *app, GParamSpec *pspec, gpointer user_data) +{ + if (gs_app_get_state (app) == GS_APP_STATE_INSTALLED) { + GsAppRow *app_row = GS_APP_ROW (user_data); + gs_app_row_unreveal (app_row); + } +} + +void +gs_update_list_add_app (GsUpdateList *update_list, GsApp *app) +{ + GsUpdateListPrivate *priv = gs_update_list_get_instance_private (update_list); + GtkWidget *app_row; + + app_row = gs_app_row_new (app); + gs_app_row_set_show_description (GS_APP_ROW (app_row), FALSE); + gs_app_row_set_show_update (GS_APP_ROW (app_row), FALSE); + gs_app_row_set_show_buttons (GS_APP_ROW (app_row), FALSE); + gs_app_row_set_show_installed (GS_APP_ROW (app_row), FALSE); + gtk_list_box_append (priv->listbox, app_row); + gs_app_row_set_size_groups (GS_APP_ROW (app_row), + priv->sizegroup_name, + NULL, + NULL); + g_signal_connect_object (app, "notify::state", + G_CALLBACK (gs_update_list_app_state_notify_cb), + app_row, 0); + gtk_widget_show (app_row); +} + +static gint +list_sort_func (GtkListBoxRow *a, + GtkListBoxRow *b, + gpointer user_data) +{ + GsApp *a1 = gs_app_row_get_app (GS_APP_ROW (a)); + GsApp *b1 = gs_app_row_get_app (GS_APP_ROW (b)); + return g_strcmp0 (gs_app_get_name (a1), gs_app_get_name (b1)); +} + +static void +gs_update_list_dispose (GObject *object) +{ + GsUpdateList *update_list = GS_UPDATE_LIST (object); + GsUpdateListPrivate *priv = gs_update_list_get_instance_private (update_list); + + if (priv->listbox != NULL) { + gtk_widget_unparent (GTK_WIDGET (priv->listbox)); + priv->listbox = NULL; + } + + g_clear_object (&priv->sizegroup_name); + + G_OBJECT_CLASS (gs_update_list_parent_class)->dispose (object); +} + +static void +gs_update_list_init (GsUpdateList *update_list) +{ + GsUpdateListPrivate *priv = gs_update_list_get_instance_private (update_list); + priv->sizegroup_name = gtk_size_group_new (GTK_SIZE_GROUP_HORIZONTAL); + + priv->listbox = GTK_LIST_BOX (gtk_list_box_new ()); + gtk_list_box_set_selection_mode (priv->listbox, GTK_SELECTION_NONE); + gtk_widget_set_parent (GTK_WIDGET (priv->listbox), GTK_WIDGET (update_list)); + gtk_list_box_set_sort_func (priv->listbox, list_sort_func, update_list, NULL); + gtk_style_context_add_class (gtk_widget_get_style_context (GTK_WIDGET (priv->listbox)), "boxed-list"); + + g_signal_connect (priv->listbox, "row-activated", + G_CALLBACK (installed_updates_row_activated_cb), update_list); +} + +static void +gs_update_list_class_init (GsUpdateListClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + + object_class->dispose = gs_update_list_dispose; + + signals [SIGNAL_SHOW_UPDATE] = + g_signal_new ("show-update", + 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_layout_manager_type (widget_class, GTK_TYPE_BIN_LAYOUT); +} + +GtkWidget * +gs_update_list_new (void) +{ + GsUpdateList *update_list; + update_list = g_object_new (GS_TYPE_UPDATE_LIST, NULL); + return GTK_WIDGET (update_list); +} + +void +gs_update_list_remove_all (GsUpdateList *update_list) +{ + GsUpdateListPrivate *priv; + + g_return_if_fail (GS_IS_UPDATE_LIST (update_list)); + + priv = gs_update_list_get_instance_private (update_list); + gs_widget_remove_all (GTK_WIDGET (priv->listbox), (GsRemoveFunc) gtk_list_box_remove); +} diff --git a/src/gs-update-list.h b/src/gs-update-list.h new file mode 100644 index 0000000..eda8c49 --- /dev/null +++ b/src/gs-update-list.h @@ -0,0 +1,32 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013-2017 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2014-2015 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <gtk/gtk.h> + +#include "gnome-software-private.h" + +G_BEGIN_DECLS + +#define GS_TYPE_UPDATE_LIST (gs_update_list_get_type ()) + +G_DECLARE_DERIVABLE_TYPE (GsUpdateList, gs_update_list, GS, UPDATE_LIST, GtkWidget) + +struct _GsUpdateListClass +{ + GtkWidgetClass parent_class; +}; + +GtkWidget *gs_update_list_new (void); +void gs_update_list_remove_all (GsUpdateList *update_list); +void gs_update_list_add_app (GsUpdateList *update_list, + GsApp *app); + +G_END_DECLS diff --git a/src/gs-update-monitor.c b/src/gs-update-monitor.c new file mode 100644 index 0000000..d46f6d0 --- /dev/null +++ b/src/gs-update-monitor.c @@ -0,0 +1,1476 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013-2018 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2013 Matthias Clasen <mclasen@redhat.com> + * Copyright (C) 2014-2018 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include <string.h> +#include <glib/gi18n.h> +#include <gsettings-desktop-schemas/gdesktop-enums.h> +#include <locale.h> + +#include "gs-update-monitor.h" +#include "gs-common.h" + +#define SECONDS_IN_AN_HOUR (60 * 60) +#define SECONDS_IN_A_DAY (SECONDS_IN_AN_HOUR * 24) +#define MINUTES_IN_A_DAY (SECONDS_IN_A_DAY / 60) + +struct _GsUpdateMonitor { + GObject parent; + + GsApplication *application; + + /* We use three cancellables: + * - @shutdown_cancellable is cancelled only during shutdown/dispose of + * the #GsUpdateMonitor, to avoid long-running operations keeping the + * monitor alive. + * - @update_cancellable is for update/upgrade operations, and is + * cancelled if they should be cancelled, such as if the computer has + * to start trying to save power. + * - @refresh_cancellable is for refreshes and other inconsequential + * operations which can be cancelled more readily than + * @update_cancellable with fewer consequences. It’s cancelled if the + * computer is going into low power mode, or if network connectivity + * changes. + */ + GCancellable *shutdown_cancellable; /* (owned) (not nullable) */ + GCancellable *update_cancellable; /* (owned) (not nullable) */ + GCancellable *refresh_cancellable; /* (owned) (not nullable) */ + + GSettings *settings; + GsPluginLoader *plugin_loader; + GDBusProxy *proxy_upower; + GError *last_offline_error; + + GNetworkMonitor *network_monitor; + guint network_changed_handler; + +#if GLIB_CHECK_VERSION(2, 69, 1) + GPowerProfileMonitor *power_profile_monitor; /* (owned) (nullable) */ + gulong power_profile_changed_handler; +#endif + + guint cleanup_notifications_id; /* at startup */ + guint check_startup_id; /* 60s after startup */ + guint check_hourly_id; /* and then every hour */ + guint check_daily_id; /* every 3rd day */ + + gint64 last_notification_time_usec; /* to notify once per day only */ +}; + +G_DEFINE_TYPE (GsUpdateMonitor, gs_update_monitor, G_TYPE_OBJECT) + +typedef struct { + GsUpdateMonitor *monitor; +} DownloadUpdatesData; + +static void +download_updates_data_free (DownloadUpdatesData *data) +{ + g_clear_object (&data->monitor); + g_slice_free (DownloadUpdatesData, data); +} + +G_DEFINE_AUTOPTR_CLEANUP_FUNC(DownloadUpdatesData, download_updates_data_free); + +typedef struct { + GsUpdateMonitor *monitor; + GsApp *app; +} WithAppData; + +static WithAppData * +with_app_data_new (GsUpdateMonitor *monitor, + GsApp *app) +{ + WithAppData *data; + data = g_slice_new0 (WithAppData); + data->monitor = g_object_ref (monitor); + data->app = g_object_ref (app); + return data; +} + +static void +with_app_data_free (WithAppData *data) +{ + g_clear_object (&data->monitor); + g_clear_object (&data->app); + g_slice_free (WithAppData, data); +} + +G_DEFINE_AUTOPTR_CLEANUP_FUNC(WithAppData, with_app_data_free); + +static void +check_updates_kind (GsAppList *apps, + gboolean *out_has_important, + gboolean *out_all_downloaded, + gboolean *out_any_downloaded) +{ + gboolean has_important, all_downloaded, any_downloaded; + guint ii, len; + GsApp *app; + + len = gs_app_list_length (apps); + has_important = FALSE; + all_downloaded = len > 0; + any_downloaded = FALSE; + + for (ii = 0; ii < len && (!has_important || all_downloaded || !any_downloaded); ii++) { + gboolean is_important; + + app = gs_app_list_index (apps, ii); + + is_important = gs_app_get_update_urgency (app) == AS_URGENCY_KIND_CRITICAL; + has_important = has_important || is_important; + + if (gs_app_is_downloaded (app)) + any_downloaded = TRUE; + else + all_downloaded = FALSE; + } + + *out_has_important = has_important; + *out_all_downloaded = all_downloaded; + *out_any_downloaded = any_downloaded; +} + +static gboolean +get_timestamp_difference_days (GsUpdateMonitor *monitor, const gchar *timestamp, gint64 *out_days) +{ + gint64 tmp; + g_autoptr(GDateTime) last_update = NULL; + g_autoptr(GDateTime) now = NULL; + + g_return_val_if_fail (out_days != NULL, FALSE); + + g_settings_get (monitor->settings, timestamp, "x", &tmp); + if (tmp == 0) + return FALSE; + + last_update = g_date_time_new_from_unix_local (tmp); + if (last_update == NULL) { + g_warning ("failed to set timestamp %" G_GINT64_FORMAT, tmp); + return FALSE; + } + + now = g_date_time_new_now_local (); + + *out_days = g_date_time_difference (now, last_update) / G_TIME_SPAN_DAY; + + return TRUE; +} + +static gboolean +check_if_timestamp_more_than_days_ago (GsUpdateMonitor *monitor, const gchar *timestamp, guint days) +{ + gint64 timestamp_days; + + if (!get_timestamp_difference_days (monitor, timestamp, ×tamp_days)) + return TRUE; + + return timestamp_days >= days; +} + +static gboolean +should_download_updates (GsUpdateMonitor *monitor) +{ +#ifdef HAVE_MOGWAI + return TRUE; +#else + return g_settings_get_boolean (monitor->settings, "download-updates"); +#endif +} + +/* The days below are discussed at https://gitlab.gnome.org/GNOME/gnome-software/-/issues/947 + and https://wiki.gnome.org/Design/Apps/Software/Updates#Tentative_Design */ +static gboolean +should_notify_about_pending_updates (GsUpdateMonitor *monitor, + GsAppList *apps, + const gchar **out_title, + const gchar **out_body) +{ + gboolean has_important = FALSE, all_downloaded = FALSE, any_downloaded = FALSE; + gboolean should_download, res = FALSE; + gint64 timestamp_days; + + if (!get_timestamp_difference_days (monitor, "update-notification-timestamp", ×tamp_days)) { + /* Large-enough number to succeed for the initial test */ + timestamp_days = 365; + } + + should_download = should_download_updates (monitor); + check_updates_kind (apps, &has_important, &all_downloaded, &any_downloaded); + + if (!gs_app_list_length (apps)) { + /* Notify only when the download is disabled and it's the 4th day or it's more than 7 days */ + if (!should_download && (timestamp_days >= 7 || timestamp_days == 4)) { + *out_title = _("Software Updates Are Out of Date"); + *out_body = _("Please check for software updates."); + res = TRUE; + } + } else if (has_important) { + if (timestamp_days >= 1) { + if (all_downloaded) { + *out_title = _("Critical Software Update Ready to Install"); + *out_body = _("An important software update is ready to be installed."); + res = TRUE; + } else if (!should_download) { + *out_title = _("Critical Software Updates Available to Download"); + *out_body = _("Important: critical software updates are waiting."); + res = TRUE; + } + } + } else if (all_downloaded) { + if (timestamp_days >= 3) { + *out_title = _("Software Updates Ready to Install"); + *out_body = _("Software updates are waiting and ready to be installed."); + res = TRUE; + } + /* To not hide downloaded updates for 14 days when new updates were discovered meanwhile. + Never show "Available to Download" when it's supposed to download the updates. */ + } else if (!should_download && timestamp_days >= 14) { + *out_title = _("Software Updates Available to Download"); + *out_body = _("Please download waiting software updates."); + res = TRUE; + } + + g_debug ("%s: last_test_days:%" G_GINT64_FORMAT " n-apps:%u should_download:%d has_important:%d " + "all_downloaded:%d any_downloaded:%d res:%d%s%s%s%s", G_STRFUNC, + timestamp_days, gs_app_list_length (apps), should_download, has_important, + all_downloaded, any_downloaded, res, + res ? " reason:" : "", + res ? *out_title : "", + res ? "|" : "", + res ? *out_body : ""); + + return res; +} + +static void +reset_update_notification_timestamp (GsUpdateMonitor *monitor) +{ + g_autoptr(GDateTime) now = NULL; + + now = g_date_time_new_now_local (); + g_settings_set (monitor->settings, "update-notification-timestamp", "x", + g_date_time_to_unix (now)); +} + +static void +notify_about_pending_updates (GsUpdateMonitor *monitor, + GsAppList *apps) +{ + const gchar *title = NULL, *body = NULL; + gint64 time_diff_sec; + g_autoptr(GNotification) nn = NULL; + + time_diff_sec = (g_get_real_time () - monitor->last_notification_time_usec) / G_USEC_PER_SEC; + if (time_diff_sec < SECONDS_IN_A_DAY) { + g_debug ("Skipping update notification daily check, because made one only %" G_GINT64_FORMAT "s ago", + time_diff_sec); + return; + } + + if (!should_notify_about_pending_updates (monitor, apps, &title, &body)) { + g_debug ("No update notification needed"); + return; + } + + /* To force reload of the Updates page, thus it reflects what + the update-monitor notifies about */ + gs_plugin_loader_emit_updates_changed (monitor->plugin_loader); + + monitor->last_notification_time_usec = g_get_real_time (); + + g_debug ("Notify about update: '%s'", title); + + nn = g_notification_new (title); + g_notification_set_body (nn, body); + g_notification_set_default_action_and_target (nn, "app.set-mode", "s", "updates"); + gs_application_send_notification (monitor->application, "updates-available", nn, MINUTES_IN_A_DAY); + + /* Keep the old notification time when there are no updates and the update download is disabled, + to notify the user every day after 7 days of no update check */ + if (gs_app_list_length (apps) || + should_download_updates (monitor)) + reset_update_notification_timestamp (monitor); +} + +static gboolean +_filter_by_app_kind (GsApp *app, gpointer user_data) +{ + AsComponentKind kind = GPOINTER_TO_UINT (user_data); + return gs_app_get_kind (app) == kind; +} + +static gboolean +_sort_by_rating_cb (GsApp *app1, GsApp *app2, gpointer user_data) +{ + if (gs_app_get_rating (app1) < gs_app_get_rating (app2)) + return -1; + if (gs_app_get_rating (app1) > gs_app_get_rating (app2)) + return 1; + return 0; +} + +static GNotification * +_build_autoupdated_notification (GsUpdateMonitor *monitor, GsAppList *list) +{ + guint need_restart_cnt = 0; + g_autoptr(GsAppList) list_apps = NULL; + g_autoptr(GNotification) n = NULL; + g_autoptr(GString) body = g_string_new (NULL); + g_autofree gchar *title = NULL; + + /* filter out apps */ + list_apps = gs_app_list_copy (list); + gs_app_list_filter (list_apps, + _filter_by_app_kind, + GUINT_TO_POINTER(AS_COMPONENT_KIND_DESKTOP_APP)); + gs_app_list_sort (list_apps, _sort_by_rating_cb, NULL); + /* FIXME: add the applications that are currently active that use one + * of the updated runtimes */ + if (gs_app_list_length (list_apps) == 0) { + g_debug ("no desktop apps in updated list, ignoring"); + return NULL; + } + + /* how many apps needs updating */ + for (guint i = 0; i < gs_app_list_length (list_apps); i++) { + GsApp *app = gs_app_list_index (list_apps, i); + if (gs_app_has_quirk (app, GS_APP_QUIRK_NEEDS_REBOOT)) + need_restart_cnt++; + } + + /* >1 app updated */ + if (gs_app_list_length (list_apps) > 0) { + if (need_restart_cnt > 0) { + /* TRANSLATORS: apps were auto-updated and restart is required */ + title = g_strdup_printf (ngettext ("%u Application Updated — Restart Required", + "%u Applications Updated — Restart Required", + gs_app_list_length (list_apps)), + gs_app_list_length (list_apps)); + } else { + /* TRANSLATORS: apps were auto-updated */ + title = g_strdup_printf (ngettext ("%u Application Updated", + "%u Applications Updated", + gs_app_list_length (list_apps)), + gs_app_list_length (list_apps)); + } + } + + /* 1 app updated */ + if (gs_app_list_length (list_apps) == 1) { + GsApp *app = gs_app_list_index (list_apps, 0); + /* TRANSLATORS: %1 is an application name, e.g. Firefox */ + g_string_append_printf (body, _("%s has been updated."), gs_app_get_name (app)); + if (need_restart_cnt > 0) { + /* TRANSLATORS: the app needs restarting */ + g_string_append_printf (body, " %s", _("Please restart the application.")); + } + + /* 2 apps updated */ + } else if (gs_app_list_length (list_apps) == 2) { + GsApp *app1 = gs_app_list_index (list_apps, 0); + GsApp *app2 = gs_app_list_index (list_apps, 1); + /* TRANSLATORS: %1 and %2 are both application names, e.g. Firefox */ + g_string_append_printf (body, _("%s and %s have been updated."), + gs_app_get_name (app1), + gs_app_get_name (app2)); + if (need_restart_cnt > 0) { + g_string_append (body, " "); + /* TRANSLATORS: at least one application needs restarting */ + g_string_append_printf (body, ngettext ("%u application requires a restart.", + "%u applications require a restart.", + need_restart_cnt), + need_restart_cnt); + } + + /* 3+ apps */ + } else if (gs_app_list_length (list_apps) >= 3) { + GsApp *app1 = gs_app_list_index (list_apps, 0); + GsApp *app2 = gs_app_list_index (list_apps, 1); + GsApp *app3 = gs_app_list_index (list_apps, 2); + /* TRANSLATORS: %1, %2 and %3 are all application names, e.g. Firefox */ + g_string_append_printf (body, _("Includes %s, %s and %s."), + gs_app_get_name (app1), + gs_app_get_name (app2), + gs_app_get_name (app3)); + if (need_restart_cnt > 0) { + g_string_append (body, " "); + /* TRANSLATORS: at least one application needs restarting */ + g_string_append_printf (body, ngettext ("%u application requires a restart.", + "%u applications require a restart.", + need_restart_cnt), + need_restart_cnt); + } + } + + /* create the notification */ + n = g_notification_new (title); + if (body->len > 0) + g_notification_set_body (n, body->str); + g_notification_set_default_action_and_target (n, "app.set-mode", "s", "updated"); + return g_steal_pointer (&n); +} + +static void +update_finished_cb (GObject *object, GAsyncResult *res, gpointer data) +{ + GsUpdateMonitor *monitor = GS_UPDATE_MONITOR (data); + GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (object); + g_autoptr(GError) error = NULL; + g_autoptr(GsAppList) list = NULL; + + /* get result */ + list = gs_plugin_loader_job_process_finish (plugin_loader, res, &error); + if (list == NULL) { + gs_plugin_loader_claim_error (plugin_loader, + NULL, + GS_PLUGIN_ACTION_UPDATE, + NULL, + TRUE, + error); + return; + } + + /* notifications are optional */ + if (g_settings_get_boolean (monitor->settings, "download-updates-notify")) { + g_autoptr(GNotification) n = NULL; + gs_application_withdraw_notification (monitor->application, "updates-installed"); + n = _build_autoupdated_notification (monitor, list); + if (n != NULL) + gs_application_send_notification (monitor->application, "updates-installed", n, MINUTES_IN_A_DAY); + } +} + +static gboolean +_should_auto_update (GsApp *app) +{ + if (gs_app_get_state (app) != GS_APP_STATE_UPDATABLE_LIVE) + return FALSE; + if (gs_app_has_quirk (app, GS_APP_QUIRK_NEW_PERMISSIONS)) + return FALSE; + if (gs_app_has_quirk (app, GS_APP_QUIRK_DO_NOT_AUTO_UPDATE)) + return FALSE; + return TRUE; +} + +static void +download_finished_cb (GObject *object, GAsyncResult *res, gpointer data) +{ + GsUpdateMonitor *monitor = GS_UPDATE_MONITOR (data); + GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (object); + g_autoptr(GError) error = NULL; + g_autoptr(GsAppList) list = NULL; + g_autoptr(GsAppList) update_online = NULL; + g_autoptr(GsAppList) update_offline = NULL; + + /* get result */ + list = gs_plugin_loader_job_process_finish (plugin_loader, res, &error); + if (list == NULL) { + gs_plugin_loader_claim_error (plugin_loader, + NULL, + GS_PLUGIN_ACTION_DOWNLOAD, + NULL, + TRUE, + error); + return; + } + + update_online = gs_app_list_new (); + update_offline = gs_app_list_new (); + for (guint i = 0; i < gs_app_list_length (list); i++) { + GsApp *app = gs_app_list_index (list, i); + if (_should_auto_update (app)) { + g_debug ("auto-updating %s", gs_app_get_unique_id (app)); + gs_app_list_add (update_online, app); + } else { + gs_app_list_add (update_offline, app); + } + } + + /* install any apps that can be installed LIVE */ + if (gs_app_list_length (update_online) > 0) { + g_autoptr(GsPluginJob) plugin_job = NULL; + plugin_job = gs_plugin_job_newv (GS_PLUGIN_ACTION_UPDATE, + "list", update_online, + "propagate-error", TRUE, + NULL); + gs_plugin_loader_job_process_async (monitor->plugin_loader, + plugin_job, + monitor->update_cancellable, + update_finished_cb, + monitor); + } + + /* show a notification for offline updates */ + if (gs_app_list_length (update_offline) > 0) + notify_about_pending_updates (monitor, update_offline); +} + +static void +get_updates_finished_cb (GObject *object, GAsyncResult *res, gpointer data) +{ + g_autoptr(DownloadUpdatesData) download_updates_data = (DownloadUpdatesData *) data; + GsUpdateMonitor *monitor = download_updates_data->monitor; + guint64 security_timestamp = 0; + guint64 security_timestamp_old = 0; + g_autoptr(GError) error = NULL; + g_autoptr(GsAppList) apps = NULL; + gboolean should_download; + + /* get result */ + apps = gs_plugin_loader_job_process_finish (GS_PLUGIN_LOADER (object), res, &error); + if (apps == 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 updates: %s", error->message); + return; + } + + /* no updates */ + if (gs_app_list_length (apps) == 0) { + g_debug ("no updates; withdrawing updates-available notification"); + gs_application_withdraw_notification (monitor->application, "updates-available"); + return; + } + + /* find security updates, or clear timestamp if there are now none */ + g_settings_get (monitor->settings, + "security-timestamp", "x", &security_timestamp_old); + for (guint i = 0; i < gs_app_list_length (apps); i++) { + GsApp *app = gs_app_list_index (apps, i); + guint64 size_download_bytes; + GsSizeType size_download_type = gs_app_get_size_download (app, &size_download_bytes); + + if (gs_app_get_update_urgency (app) == AS_URGENCY_KIND_CRITICAL && + size_download_type == GS_SIZE_TYPE_VALID && + size_download_bytes > 0) { + security_timestamp = (guint64) g_get_monotonic_time (); + break; + } + } + if (security_timestamp_old != security_timestamp) { + g_settings_set (monitor->settings, + "security-timestamp", "x", security_timestamp); + } + + g_debug ("got %u updates", gs_app_list_length (apps)); + + should_download = should_download_updates (monitor); + + if (should_download && + (security_timestamp_old != security_timestamp || + check_if_timestamp_more_than_days_ago (monitor, "install-timestamp", 14))) { + g_autoptr(GsPluginJob) plugin_job = NULL; + + /* download any updates; individual plugins are responsible for deciding + * whether it’s appropriate to unconditionally download the updates, or + * to schedule the download in accordance with the user’s metered data + * preferences */ + plugin_job = gs_plugin_job_newv (GS_PLUGIN_ACTION_DOWNLOAD, + "list", apps, + "propagate-error", TRUE, + NULL); + g_debug ("Getting updates"); + gs_plugin_loader_job_process_async (monitor->plugin_loader, + plugin_job, + monitor->refresh_cancellable, + download_finished_cb, + monitor); + } else { + g_autoptr(GsAppList) update_online = NULL; + g_autoptr(GsAppList) update_offline = NULL; + GsAppList *notify_list; + + update_online = gs_app_list_new (); + update_offline = gs_app_list_new (); + for (guint i = 0; i < gs_app_list_length (apps); i++) { + GsApp *app = gs_app_list_index (apps, i); + if (_should_auto_update (app)) { + g_debug ("download for auto-update %s", gs_app_get_unique_id (app)); + gs_app_list_add (update_online, app); + } else { + gs_app_list_add (update_offline, app); + } + } + + g_debug ("Received %u apps to update, %u are online and %u offline updates; will%s download online updates", + gs_app_list_length (apps), + gs_app_list_length (update_online), + gs_app_list_length (update_offline), + should_download ? "" : " not"); + + if (should_download && gs_app_list_length (update_online) > 0) { + g_autoptr(GsPluginJob) plugin_job = NULL; + + plugin_job = gs_plugin_job_newv (GS_PLUGIN_ACTION_DOWNLOAD, + "list", update_online, + "propagate-error", TRUE, + NULL); + g_debug ("Getting %u online updates", gs_app_list_length (update_online)); + gs_plugin_loader_job_process_async (monitor->plugin_loader, + plugin_job, + monitor->refresh_cancellable, + download_finished_cb, + monitor); + } + + if (should_download) + notify_list = update_offline; + else + notify_list = apps; + + notify_about_pending_updates (monitor, notify_list); + } +} + +static gboolean +should_show_upgrade_notification (GsUpdateMonitor *monitor) +{ + return check_if_timestamp_more_than_days_ago (monitor, "upgrade-notification-timestamp", 7); +} + +static void +get_system_finished_cb (GObject *object, GAsyncResult *res, gpointer data) +{ + GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (object); + GsUpdateMonitor *monitor = data; + g_autoptr(GError) error = NULL; + g_autoptr(GNotification) n = NULL; + g_autoptr(GsApp) app = NULL; + + /* get result */ + app = gs_plugin_loader_get_system_app_finish (plugin_loader, res, &error); + if (app == 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 system: %s", error->message); + return; + } + + /* might be already showing, so just withdraw it and re-issue it */ + gs_application_withdraw_notification (monitor->application, "eol"); + + /* do not show when the main window is active */ + if (gs_application_has_active_window (monitor->application)) + return; + + /* is not EOL */ + if (gs_app_get_state (app) != GS_APP_STATE_UNAVAILABLE) + return; + + /* TRANSLATORS: this is when the current operating system version goes end-of-life */ + n = g_notification_new (_("Operating System Updates Unavailable")); + /* TRANSLATORS: this is the message dialog for the distro EOL notice */ + g_notification_set_body (n, _("Upgrade to continue receiving security updates.")); + g_notification_set_default_action_and_target (n, "app.set-mode", "s", "updates"); + gs_application_send_notification (monitor->application, "eol", n, MINUTES_IN_A_DAY); +} + +static void +get_upgrades_finished_cb (GObject *object, + GAsyncResult *res, + gpointer data) +{ + GsUpdateMonitor *monitor = GS_UPDATE_MONITOR (data); + GsApp *app; + g_autofree gchar *body = NULL; + g_autoptr(GDateTime) now = NULL; + g_autoptr(GError) error = NULL; + g_autoptr(GNotification) n = NULL; + g_autoptr(GsAppList) apps = NULL; + + /* get result */ + apps = gs_plugin_loader_job_process_finish (GS_PLUGIN_LOADER (object), res, &error); + if (apps == 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 upgrades: %s", + error->message); + } + return; + } + + /* no results */ + if (gs_app_list_length (apps) == 0) { + g_debug ("no upgrades; withdrawing upgrades-available notification"); + gs_application_withdraw_notification (monitor->application, "upgrades-available"); + return; + } + + /* do not show if gnome-software is already open */ + if (gs_application_has_active_window (monitor->application)) + return; + + /* only nag about upgrades once per week */ + if (!should_show_upgrade_notification (monitor)) + return; + + g_debug ("showing distro upgrade notification"); + now = g_date_time_new_now_local (); + g_settings_set (monitor->settings, "upgrade-notification-timestamp", "x", + g_date_time_to_unix (now)); + + /* rely on the app list already being sorted with the + * chronologically newest release last */ + app = gs_app_list_index (apps, gs_app_list_length (apps) - 1); + + /* TRANSLATORS: this is a distro upgrade, the replacement would be the + * distro name, e.g. 'Fedora' */ + body = g_strdup_printf (_("A new version of %s is available to install"), + gs_app_get_name (app)); + + /* TRANSLATORS: this is a distro upgrade */ + n = g_notification_new (_("Software Upgrade Available")); + g_notification_set_body (n, body); + g_notification_set_default_action_and_target (n, "app.set-mode", "s", "updates"); + gs_application_send_notification (monitor->application, "upgrades-available", n, MINUTES_IN_A_DAY); +} + +static void +get_updates (GsUpdateMonitor *monitor) +{ + g_autoptr(GsPluginJob) plugin_job = NULL; + g_autoptr(DownloadUpdatesData) download_updates_data = NULL; + + /* disabled in gsettings or from a plugin */ + if (!gs_plugin_loader_get_allow_updates (monitor->plugin_loader)) { + g_debug ("not getting updates as not enabled"); + return; + } + + download_updates_data = g_slice_new0 (DownloadUpdatesData); + download_updates_data->monitor = g_object_ref (monitor); + + /* NOTE: this doesn't actually do any network access */ + g_debug ("Getting updates"); + plugin_job = gs_plugin_job_newv (GS_PLUGIN_ACTION_GET_UPDATES, + "refine-flags", GS_PLUGIN_REFINE_FLAGS_REQUIRE_UPDATE_DETAILS | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_UPDATE_SEVERITY, + NULL); + gs_plugin_loader_job_process_async (monitor->plugin_loader, + plugin_job, + monitor->update_cancellable, + get_updates_finished_cb, + g_steal_pointer (&download_updates_data)); +} + +void +gs_update_monitor_autoupdate (GsUpdateMonitor *monitor) +{ + get_updates (monitor); +} + +static void +get_upgrades (GsUpdateMonitor *monitor) +{ + g_autoptr(GsPluginJob) plugin_job = NULL; + + /* disabled in gsettings or from a plugin */ + if (!gs_plugin_loader_get_allow_updates (monitor->plugin_loader)) { + g_debug ("not getting upgrades as not enabled"); + return; + } + + /* NOTE: this doesn't actually do any network access, it relies on the + * AppStream data being up to date, either by the appstream-data + * package being up-to-date, or the metadata being auto-downloaded */ + g_debug ("Getting upgrades"); + plugin_job = gs_plugin_job_list_distro_upgrades_new (GS_PLUGIN_LIST_DISTRO_UPGRADES_FLAGS_NONE, + GS_PLUGIN_REFINE_FLAGS_NONE); + gs_plugin_loader_job_process_async (monitor->plugin_loader, + plugin_job, + monitor->update_cancellable, + get_upgrades_finished_cb, + monitor); +} + +static void +get_system (GsUpdateMonitor *monitor) +{ + g_autoptr(GsApp) app = NULL; + + g_debug ("Getting system"); + gs_plugin_loader_get_system_app_async (monitor->plugin_loader, monitor->update_cancellable, + get_system_finished_cb, monitor); +} + +static void +refresh_cache_finished_cb (GObject *object, + GAsyncResult *res, + gpointer data) +{ + GsUpdateMonitor *monitor = data; + g_autoptr(GDateTime) now = NULL; + g_autoptr(GError) error = NULL; + + if (!gs_plugin_loader_job_action_finish (GS_PLUGIN_LOADER (object), res, &error)) { + 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 refresh the cache: %s", error->message); + return; + } + + /* update the last checked timestamp */ + now = g_date_time_new_now_local (); + g_settings_set (monitor->settings, "check-timestamp", "x", + g_date_time_to_unix (now)); + + get_updates (monitor); +} + +typedef enum { + UP_DEVICE_LEVEL_UNKNOWN, + UP_DEVICE_LEVEL_NONE, + UP_DEVICE_LEVEL_DISCHARGING, + UP_DEVICE_LEVEL_LOW, + UP_DEVICE_LEVEL_CRITICAL, + UP_DEVICE_LEVEL_ACTION, + UP_DEVICE_LEVEL_LAST +} UpDeviceLevel; + +static void +install_language_pack_cb (GObject *object, GAsyncResult *res, gpointer data) +{ + g_autoptr(GError) error = NULL; + g_autoptr(WithAppData) with_app_data = data; + + if (!gs_plugin_loader_job_action_finish (GS_PLUGIN_LOADER (object), res, &error)) { + if (!g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_CANCELLED) && + !g_error_matches (error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) + g_debug ("failed to install language pack: %s", error->message); + return; + } else { + g_debug ("language pack for %s installed", + gs_app_get_name (with_app_data->app)); + } +} + +static void +get_language_pack_cb (GObject *object, GAsyncResult *res, gpointer data) +{ + GsUpdateMonitor *monitor = GS_UPDATE_MONITOR (data); + GsApp *app; + g_autoptr(GError) error = NULL; + g_autoptr(GsAppList) app_list = NULL; + + app_list = gs_plugin_loader_job_process_finish (GS_PLUGIN_LOADER (object), res, &error); + if (app_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_debug ("failed to find language pack: %s", error->message); + return; + } + + /* none found */ + if (gs_app_list_length (app_list) == 0) { + g_debug ("no language pack found"); + return; + } + + /* there should be one langpack for a given locale */ + app = g_object_ref (gs_app_list_index (app_list, 0)); + if (!gs_app_is_installed (app)) { + WithAppData *with_app_data; + g_autoptr(GsPluginJob) plugin_job = NULL; + + with_app_data = with_app_data_new (monitor, app); + + plugin_job = gs_plugin_job_newv (GS_PLUGIN_ACTION_INSTALL, + "app", app, + NULL); + gs_plugin_loader_job_process_async (monitor->plugin_loader, + plugin_job, + monitor->update_cancellable, + install_language_pack_cb, + with_app_data); + } +} + +/* + * determines active locale and looks for langpacks + * installs located language pack, if not already + */ +static void +check_language_pack (GsUpdateMonitor *monitor) { + + const gchar *locale; + g_autoptr(GsPluginJob) plugin_job = NULL; + + locale = setlocale (LC_MESSAGES, NULL); + plugin_job = gs_plugin_job_newv (GS_PLUGIN_ACTION_GET_LANGPACKS, + "search", locale, + "refine-flags", GS_PLUGIN_REFINE_FLAGS_REQUIRE_ICON, + NULL); + gs_plugin_loader_job_process_async (monitor->plugin_loader, + plugin_job, + monitor->update_cancellable, + get_language_pack_cb, + monitor); +} + +static void +check_updates (GsUpdateMonitor *monitor) +{ + gint64 tmp; + gboolean refresh_on_metered; + g_autoptr(GDateTime) last_refreshed = NULL; + g_autoptr(GsPluginJob) plugin_job = NULL; + + /* never check for updates when offline */ + if (!gs_plugin_loader_get_network_available (monitor->plugin_loader)) + return; + + /* check for language pack */ + check_language_pack (monitor); + +#ifdef HAVE_MOGWAI + refresh_on_metered = TRUE; +#else + refresh_on_metered = g_settings_get_boolean (monitor->settings, + "refresh-when-metered"); +#endif + + if (!refresh_on_metered && + gs_plugin_loader_get_network_metered (monitor->plugin_loader)) + return; + + /* never refresh when the battery is low */ + if (monitor->proxy_upower != NULL) { + g_autoptr(GVariant) val = NULL; + val = g_dbus_proxy_get_cached_property (monitor->proxy_upower, + "WarningLevel"); + if (val != NULL) { + guint32 level = g_variant_get_uint32 (val); + if (level >= UP_DEVICE_LEVEL_LOW) { + g_debug ("not getting updates on low power"); + return; + } + } + } else { + g_debug ("no UPower support, so not doing power level checks"); + } + +#if GLIB_CHECK_VERSION(2, 69, 1) + /* never refresh when in power saver mode */ + if (monitor->power_profile_monitor != NULL) { + if (g_power_profile_monitor_get_power_saver_enabled (monitor->power_profile_monitor)) { + g_debug ("Not getting updates with power saver enabled"); + return; + } + } else { + g_debug ("No power profile monitor support, so not doing power profile checks"); + } +#endif + + g_settings_get (monitor->settings, "check-timestamp", "x", &tmp); + last_refreshed = g_date_time_new_from_unix_local (tmp); + if (last_refreshed != NULL) { + gint now_year, now_month, now_day, now_hour; + gint year, month, day; + g_autoptr(GDateTime) now = NULL; + + now = g_date_time_new_now_local (); + + g_date_time_get_ymd (now, &now_year, &now_month, &now_day); + now_hour = g_date_time_get_hour (now); + + g_date_time_get_ymd (last_refreshed, &year, &month, &day); + + /* check that it is the next day */ + if (!((now_year > year) || + (now_year == year && now_month > month) || + (now_year == year && now_month == month && now_day > day))) + return; + + /* ...and past 6am */ + if (!(now_hour >= 6)) + return; + } + + if (!should_download_updates (monitor)) { + get_updates (monitor); + return; + } + + g_debug ("Daily update check due"); + plugin_job = gs_plugin_job_refresh_metadata_new (60 * 60 * 24, + GS_PLUGIN_REFRESH_METADATA_FLAGS_NONE); + gs_plugin_loader_job_process_async (monitor->plugin_loader, plugin_job, + monitor->refresh_cancellable, + refresh_cache_finished_cb, + monitor); +} + +static gboolean +check_hourly_cb (gpointer data) +{ + GsUpdateMonitor *monitor = data; + + g_debug ("Hourly updates check"); + check_updates (monitor); + + return G_SOURCE_CONTINUE; +} + +static gboolean +check_thrice_daily_cb (gpointer data) +{ + GsUpdateMonitor *monitor = data; + + g_debug ("Daily upgrades check"); + get_upgrades (monitor); + get_system (monitor); + + return G_SOURCE_CONTINUE; +} + +static void +stop_upgrades_check (GsUpdateMonitor *monitor) +{ + if (monitor->check_daily_id == 0) + return; + + g_source_remove (monitor->check_daily_id); + monitor->check_daily_id = 0; +} + +static void +restart_upgrades_check (GsUpdateMonitor *monitor) +{ + stop_upgrades_check (monitor); + get_upgrades (monitor); + + monitor->check_daily_id = g_timeout_add_seconds (SECONDS_IN_A_DAY / 3, + check_thrice_daily_cb, + monitor); +} + +static void +stop_updates_check (GsUpdateMonitor *monitor) +{ + if (monitor->check_hourly_id == 0) + return; + + g_source_remove (monitor->check_hourly_id); + monitor->check_hourly_id = 0; +} + +static void +restart_updates_check (GsUpdateMonitor *monitor) +{ + stop_updates_check (monitor); + check_updates (monitor); + + monitor->check_hourly_id = g_timeout_add_seconds (SECONDS_IN_AN_HOUR, check_hourly_cb, + monitor); +} + +static gboolean +check_updates_on_startup_cb (gpointer data) +{ + GsUpdateMonitor *monitor = data; + + g_debug ("First hourly updates check"); + restart_updates_check (monitor); + + if (gs_plugin_loader_get_allow_updates (monitor->plugin_loader)) + restart_upgrades_check (monitor); + + monitor->check_startup_id = 0; + return G_SOURCE_REMOVE; +} + +static void +check_updates_upower_changed_cb (GDBusProxy *proxy, + GParamSpec *pspec, + GsUpdateMonitor *monitor) +{ + g_debug ("upower changed updates check"); + check_updates (monitor); +} + +static void +network_available_notify_cb (GsPluginLoader *plugin_loader, + GParamSpec *pspec, + GsUpdateMonitor *monitor) +{ + check_updates (monitor); +} + +static void +get_updates_historical_cb (GObject *object, GAsyncResult *res, gpointer data) +{ + GsUpdateMonitor *monitor = data; + GsApp *app; + const gchar *message; + const gchar *title; + guint64 time_last_notified; + g_autoptr(GError) error = NULL; + g_autoptr(GsAppList) apps = NULL; + g_autoptr(GNotification) notification = NULL; + + /* get result */ + apps = gs_plugin_loader_job_process_finish (GS_PLUGIN_LOADER (object), res, &error); + if (apps == 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_debug ("Failed to get historical updates: %s", error->message); + g_clear_error (&monitor->last_offline_error); + return; + } + + /* save this in case the user clicks the + * 'Show Details' button from the notification below */ + g_clear_error (&monitor->last_offline_error); + monitor->last_offline_error = g_error_copy (error); + + /* TRANSLATORS: title when we offline updates have failed */ + notification = g_notification_new (_("Software Updates Failed")); + /* TRANSLATORS: message when we offline updates have failed */ + g_notification_set_body (notification, _("An important operating system update failed to be installed.")); + g_notification_add_button (notification, _("Show Details"), "app.show-offline-update-error"); + g_notification_set_default_action (notification, "app.show-offline-update-error"); + gs_application_send_notification (monitor->application, "offline-updates", notification, MINUTES_IN_A_DAY); + return; + } + + /* no results */ + if (gs_app_list_length (apps) == 0) { + g_debug ("no historical updates; withdrawing notification"); + gs_application_withdraw_notification (monitor->application, "updates-available"); + return; + } + + /* have we notified about this before */ + app = gs_app_list_index (apps, 0); + g_settings_get (monitor->settings, + "install-timestamp", "x", &time_last_notified); + if (time_last_notified >= gs_app_get_install_date (app)) + return; + + if (gs_app_get_kind (app) == AS_COMPONENT_KIND_OPERATING_SYSTEM) { + /* TRANSLATORS: Notification title when we've done a distro upgrade */ + notification = g_notification_new (_("System Upgrade Complete")); + + /* TRANSLATORS: This is the notification body when we've done a + * distro upgrade. First %s is the distro name and the 2nd %s + * is the version, e.g. "Welcome to Fedora 28!" */ + message = g_strdup_printf (_("Welcome to %s %s!"), + gs_app_get_name (app), + gs_app_get_version (app)); + g_notification_set_body (notification, message); + } else { + /* TRANSLATORS: title when we've done offline updates */ + title = ngettext ("Software Update Installed", + "Software Updates Installed", + gs_app_list_length (apps)); + /* TRANSLATORS: message when we've done offline updates */ + message = ngettext ("An important operating system update has been installed.", + "Important operating system updates have been installed.", + gs_app_list_length (apps)); + + notification = g_notification_new (title); + g_notification_set_body (notification, message); + /* TRANSLATORS: Button to look at the updates that were installed. + * Note that it has nothing to do with the application reviews, the + * users can't express their opinions here. In some languages + * "Review (evaluate) something" is a different translation than + * "Review (browse) something." */ + g_notification_add_button_with_target (notification, C_("updates", "Review"), "app.set-mode", "s", "updated"); + g_notification_set_default_action_and_target (notification, "app.set-mode", "s", "updated"); + } + gs_application_send_notification (monitor->application, "offline-updates", notification, MINUTES_IN_A_DAY); + + /* update the timestamp so we don't show again */ + g_settings_set (monitor->settings, + "install-timestamp", "x", gs_app_get_install_date (app)); + + reset_update_notification_timestamp (monitor); +} + +static gboolean +cleanup_notifications_cb (gpointer user_data) +{ + GsUpdateMonitor *monitor = user_data; + g_autoptr(GsPluginJob) plugin_job = NULL; + + /* this doesn't do any network access, and is only called once just + * after startup, so don’t cancel it with refreshes/updates */ + g_debug ("getting historical updates for fresh session"); + plugin_job = gs_plugin_job_newv (GS_PLUGIN_ACTION_GET_UPDATES_HISTORICAL, + "refine-flags", GS_PLUGIN_REFINE_FLAGS_REQUIRE_VERSION, + NULL); + gs_plugin_loader_job_process_async (monitor->plugin_loader, + plugin_job, + monitor->shutdown_cancellable, + get_updates_historical_cb, + monitor); + + /* wait until first check to show */ + gs_application_withdraw_notification (monitor->application, "updates-available"); + + monitor->cleanup_notifications_id = 0; + return G_SOURCE_REMOVE; +} + +void +gs_update_monitor_show_error (GsUpdateMonitor *monitor, GtkWindow *window) +{ + const gchar *title; + const gchar *msg; + gboolean show_detailed_error; + + /* can this happen in reality? */ + if (monitor->last_offline_error == NULL) + return; + + /* TRANSLATORS: this is when the offline update failed */ + title = _("Failed To Update"); + + if (g_error_matches (monitor->last_offline_error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_NOT_SUPPORTED)) { + /* TRANSLATORS: the user must have updated manually after + * the updates were prepared */ + msg = _("The system was already up to date."); + show_detailed_error = TRUE; + } else if (g_error_matches (monitor->last_offline_error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_CANCELLED) || + g_error_matches (monitor->last_offline_error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) { + /* TRANSLATORS: the user aborted the update manually */ + msg = _("The update was cancelled."); + show_detailed_error = FALSE; + } else if (g_error_matches (monitor->last_offline_error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_NO_NETWORK)) { + /* TRANSLATORS: the package manager needed to download + * something with no network available */ + msg = _("Internet access was required but wasn’t available. " + "Please make sure that you have internet access and try again."); + show_detailed_error = FALSE; + } else if (g_error_matches (monitor->last_offline_error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_NO_SECURITY)) { + /* TRANSLATORS: if the package is not signed correctly */ + msg = _("There were security issues with the update. " + "Please consult your software provider for more details."); + show_detailed_error = TRUE; + } else if (g_error_matches (monitor->last_offline_error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_NO_SPACE)) { + /* TRANSLATORS: we ran out of disk space */ + msg = _("There wasn’t enough disk space. Please free up some space and try again."); + show_detailed_error = FALSE; + } else { + /* TRANSLATORS: We didn't handle the error type */ + msg = _("We’re sorry: the update failed to install. " + "Please wait for another update and try again. " + "If the problem persists, contact your software provider."); + show_detailed_error = TRUE; + } + + gs_utils_show_error_dialog (window, + title, + msg, + show_detailed_error ? monitor->last_offline_error->message : NULL); +} + +static void +allow_updates_notify_cb (GsPluginLoader *plugin_loader, + GParamSpec *pspec, + GsUpdateMonitor *monitor) +{ + if (gs_plugin_loader_get_allow_updates (plugin_loader)) { + /* We restart the updates check here to avoid the user + * potentially waiting for the hourly check */ + restart_updates_check (monitor); + restart_upgrades_check (monitor); + } else { + stop_upgrades_check (monitor); + } +} + +static void +gs_update_monitor_network_changed_cb (GNetworkMonitor *network_monitor, + gboolean available, + GsUpdateMonitor *monitor) +{ + /* cancel an on-going refresh if we're now in a metered connection */ + if (!g_settings_get_boolean (monitor->settings, "refresh-when-metered") && + g_network_monitor_get_network_metered (network_monitor)) { + g_cancellable_cancel (monitor->refresh_cancellable); + g_object_unref (monitor->refresh_cancellable); + monitor->refresh_cancellable = g_cancellable_new (); + } else { + /* Else, it might be time to check for updates */ + check_updates (monitor); + } +} + +#if GLIB_CHECK_VERSION(2, 69, 1) +static void +gs_update_monitor_power_profile_changed_cb (GObject *object, + GParamSpec *pspec, + gpointer user_data) +{ + GsUpdateMonitor *self = GS_UPDATE_MONITOR (user_data); + + if (g_power_profile_monitor_get_power_saver_enabled (self->power_profile_monitor)) { + /* Cancel ongoing jobs, if we’re now in power saving mode. */ + g_cancellable_cancel (self->refresh_cancellable); + g_object_unref (self->refresh_cancellable); + self->refresh_cancellable = g_cancellable_new (); + + g_cancellable_cancel (self->update_cancellable); + g_object_unref (self->update_cancellable); + self->update_cancellable = g_cancellable_new (); + } else { + /* Else, it might be time to check for updates */ + check_updates (self); + } +} +#endif + +static void +gs_update_monitor_init (GsUpdateMonitor *monitor) +{ + GNetworkMonitor *network_monitor; + g_autoptr(GError) error = NULL; + monitor->settings = g_settings_new ("org.gnome.software"); + + /* cleanup at startup */ + monitor->cleanup_notifications_id = + g_idle_add (cleanup_notifications_cb, monitor); + + /* do a first check 60 seconds after login, and then every hour */ + monitor->check_startup_id = + g_timeout_add_seconds (60, check_updates_on_startup_cb, monitor); + + /* we use three cancellables because we want to be able to cancel refresh + * operations more opportunistically than other operations, since + * they’re less important and cancelling them doesn’t result in much + * wasted work, and we want to be able to cancel some operations only on + * shutdown. */ + monitor->shutdown_cancellable = g_cancellable_new (); + monitor->update_cancellable = g_cancellable_new (); + monitor->refresh_cancellable = g_cancellable_new (); + + /* connect to UPower to get the system power state */ + monitor->proxy_upower = g_dbus_proxy_new_for_bus_sync (G_BUS_TYPE_SYSTEM, + G_DBUS_PROXY_FLAGS_NONE, + NULL, + "org.freedesktop.UPower", + "/org/freedesktop/UPower/devices/DisplayDevice", + "org.freedesktop.UPower.Device", + NULL, + &error); + if (monitor->proxy_upower != NULL) { + g_signal_connect (monitor->proxy_upower, "notify", + G_CALLBACK (check_updates_upower_changed_cb), + monitor); + } else { + g_warning ("failed to connect to upower: %s", error->message); + } + + network_monitor = g_network_monitor_get_default (); + if (network_monitor != NULL) { + monitor->network_monitor = g_object_ref (network_monitor); + monitor->network_changed_handler = g_signal_connect (monitor->network_monitor, + "network-changed", + G_CALLBACK (gs_update_monitor_network_changed_cb), + monitor); + } + +#if GLIB_CHECK_VERSION(2, 69, 1) + monitor->power_profile_monitor = g_power_profile_monitor_dup_default (); + if (monitor->power_profile_monitor != NULL) + monitor->power_profile_changed_handler = g_signal_connect (monitor->power_profile_monitor, + "notify::power-saver-enabled", + G_CALLBACK (gs_update_monitor_power_profile_changed_cb), + monitor); +#endif +} + +static void +gs_update_monitor_dispose (GObject *object) +{ + GsUpdateMonitor *monitor = GS_UPDATE_MONITOR (object); + + if (monitor->network_changed_handler != 0) { + g_signal_handler_disconnect (monitor->network_monitor, + monitor->network_changed_handler); + monitor->network_changed_handler = 0; + } + +#if GLIB_CHECK_VERSION(2, 69, 1) + g_clear_signal_handler (&monitor->power_profile_changed_handler, monitor->power_profile_monitor); + g_clear_object (&monitor->power_profile_monitor); +#endif + + g_cancellable_cancel (monitor->update_cancellable); + g_clear_object (&monitor->update_cancellable); + g_cancellable_cancel (monitor->refresh_cancellable); + g_clear_object (&monitor->refresh_cancellable); + g_cancellable_cancel (monitor->shutdown_cancellable); + g_clear_object (&monitor->shutdown_cancellable); + + stop_updates_check (monitor); + stop_upgrades_check (monitor); + + if (monitor->check_startup_id != 0) { + g_source_remove (monitor->check_startup_id); + monitor->check_startup_id = 0; + } + if (monitor->cleanup_notifications_id != 0) { + g_source_remove (monitor->cleanup_notifications_id); + monitor->cleanup_notifications_id = 0; + } + if (monitor->plugin_loader != NULL) { + g_signal_handlers_disconnect_by_func (monitor->plugin_loader, + network_available_notify_cb, + monitor); + g_clear_object (&monitor->plugin_loader); + } + g_clear_object (&monitor->settings); + g_clear_object (&monitor->proxy_upower); + + G_OBJECT_CLASS (gs_update_monitor_parent_class)->dispose (object); +} + +static void +gs_update_monitor_finalize (GObject *object) +{ + GsUpdateMonitor *monitor = GS_UPDATE_MONITOR (object); + + g_application_release (G_APPLICATION (monitor->application)); + g_clear_error (&monitor->last_offline_error); + + G_OBJECT_CLASS (gs_update_monitor_parent_class)->finalize (object); +} + +static void +gs_update_monitor_class_init (GsUpdateMonitorClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + object_class->dispose = gs_update_monitor_dispose; + object_class->finalize = gs_update_monitor_finalize; +} + +GsUpdateMonitor * +gs_update_monitor_new (GsApplication *application, + GsPluginLoader *plugin_loader) +{ + GsUpdateMonitor *monitor; + + monitor = GS_UPDATE_MONITOR (g_object_new (GS_TYPE_UPDATE_MONITOR, NULL)); + monitor->application = application; + g_application_hold (G_APPLICATION (monitor->application)); + + monitor->plugin_loader = g_object_ref (plugin_loader); + g_signal_connect (monitor->plugin_loader, "notify::allow-updates", + G_CALLBACK (allow_updates_notify_cb), monitor); + g_signal_connect (monitor->plugin_loader, "notify::network-available", + G_CALLBACK (network_available_notify_cb), monitor); + + return monitor; +} diff --git a/src/gs-update-monitor.h b/src/gs-update-monitor.h new file mode 100644 index 0000000..0d39f52 --- /dev/null +++ b/src/gs-update-monitor.h @@ -0,0 +1,29 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013-2016 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2015 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <glib-object.h> + +#include "gs-application.h" +#include "gs-shell.h" + +G_BEGIN_DECLS + +#define GS_TYPE_UPDATE_MONITOR (gs_update_monitor_get_type ()) + +G_DECLARE_FINAL_TYPE (GsUpdateMonitor, gs_update_monitor, GS, UPDATE_MONITOR, GObject) + +GsUpdateMonitor *gs_update_monitor_new (GsApplication *app, + GsPluginLoader *plugin_loader); +void gs_update_monitor_autoupdate (GsUpdateMonitor *monitor); +void gs_update_monitor_show_error (GsUpdateMonitor *monitor, + GtkWindow *window); + +G_END_DECLS diff --git a/src/gs-updates-page.c b/src/gs-updates-page.c new file mode 100644 index 0000000..4456ddd --- /dev/null +++ b/src/gs-updates-page.c @@ -0,0 +1,1467 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013-2017 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2014-2018 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include <glib/gi18n.h> +#include <gio/gio.h> + +#include "gs-shell.h" +#include "gs-updates-page.h" +#include "gs-common.h" +#include "gs-app-row.h" +#include "gs-plugin-private.h" +#include "gs-removal-dialog.h" +#include "gs-update-monitor.h" +#include "gs-updates-section.h" +#include "gs-upgrade-banner.h" +#include "gs-application.h" + +typedef enum { + GS_UPDATES_PAGE_FLAG_NONE = 0, + GS_UPDATES_PAGE_FLAG_HAS_UPDATES = 1 << 0, + GS_UPDATES_PAGE_FLAG_HAS_UPGRADES = 1 << 1, + GS_UPDATES_PAGE_FLAG_LAST +} GsUpdatesPageFlags; + +typedef enum { + GS_UPDATES_PAGE_STATE_STARTUP, + GS_UPDATES_PAGE_STATE_ACTION_REFRESH, + GS_UPDATES_PAGE_STATE_ACTION_GET_UPDATES, + GS_UPDATES_PAGE_STATE_MANAGED, + GS_UPDATES_PAGE_STATE_IDLE, + GS_UPDATES_PAGE_STATE_FAILED, + GS_UPDATES_PAGE_STATE_LAST, +} GsUpdatesPageState; + +struct _GsUpdatesPage +{ + GsPage parent_instance; + + GsPluginLoader *plugin_loader; + GCancellable *cancellable; + GCancellable *cancellable_refresh; + GCancellable *cancellable_upgrade_download; + GSettings *settings; + GSettings *desktop_settings; + gboolean cache_valid; + guint action_cnt; + GsShell *shell; + GsUpdatesPageState state; + GsUpdatesPageFlags result_flags; + GtkWidget *button_refresh; + GtkWidget *header_spinner_start; + GtkWidget *header_start_box; + gboolean has_agreed_to_mobile_data; + gboolean ampm_available; + guint updates_counter; + gboolean is_narrow; + + GtkWidget *updates_box; + GtkWidget *button_updates_mobile; + GtkWidget *button_updates_offline; + GtkWidget *updates_failed_page; + GtkLabel *uptodate_description; + GtkWidget *scrolledwindow_updates; + GtkWidget *spinner_updates; + GtkWidget *stack_updates; + GtkWidget *upgrade_banner; + GtkWidget *infobar_end_of_life; + GtkWidget *label_end_of_life; + + GtkSizeGroup *sizegroup_name; + GtkSizeGroup *sizegroup_button_label; + GtkSizeGroup *sizegroup_button_image; + GtkSizeGroup *sizegroup_header; + GsUpdatesSection *sections[GS_UPDATES_SECTION_KIND_LAST]; + + guint refresh_last_checked_id; +}; + +enum { + COLUMN_UPDATE_APP, + COLUMN_UPDATE_NAME, + COLUMN_UPDATE_VERSION, + COLUMN_UPDATE_LAST +}; + +G_DEFINE_TYPE (GsUpdatesPage, gs_updates_page, GS_TYPE_PAGE) + +typedef enum { + PROP_IS_NARROW = 1, + /* Overrides: */ + PROP_VADJUSTMENT, + PROP_TITLE, + PROP_COUNTER, +} GsUpdatesPageProperty; + +static GParamSpec *obj_props[PROP_IS_NARROW + 1] = { NULL, }; + +static void +gs_updates_page_set_flag (GsUpdatesPage *self, GsUpdatesPageFlags flag) +{ + self->result_flags |= flag; +} + +static void +gs_updates_page_clear_flag (GsUpdatesPage *self, GsUpdatesPageFlags flag) +{ + self->result_flags &= ~flag; +} + +static const gchar * +gs_updates_page_state_to_string (GsUpdatesPageState state) +{ + if (state == GS_UPDATES_PAGE_STATE_STARTUP) + return "startup"; + if (state == GS_UPDATES_PAGE_STATE_ACTION_REFRESH) + return "action-refresh"; + if (state == GS_UPDATES_PAGE_STATE_ACTION_GET_UPDATES) + return "action-get-updates"; + if (state == GS_UPDATES_PAGE_STATE_MANAGED) + return "managed"; + if (state == GS_UPDATES_PAGE_STATE_IDLE) + return "idle"; + if (state == GS_UPDATES_PAGE_STATE_FAILED) + return "failed"; + return NULL; +} + +static void +gs_updates_page_invalidate (GsUpdatesPage *self) +{ + self->cache_valid = FALSE; +} + +static GsUpdatesSectionKind +_get_app_section (GsApp *app) +{ + if (gs_app_get_state (app) == GS_APP_STATE_UPDATABLE_LIVE || + gs_app_get_state (app) == GS_APP_STATE_INSTALLING) { + if (gs_app_get_kind (app) == AS_COMPONENT_KIND_FIRMWARE) + return GS_UPDATES_SECTION_KIND_ONLINE_FIRMWARE; + return GS_UPDATES_SECTION_KIND_ONLINE; + } + if (gs_app_get_kind (app) == AS_COMPONENT_KIND_FIRMWARE) + return GS_UPDATES_SECTION_KIND_OFFLINE_FIRMWARE; + return GS_UPDATES_SECTION_KIND_OFFLINE; +} + +static GsAppList * +_get_all_apps (GsUpdatesPage *self) +{ + GsAppList *apps = gs_app_list_new (); + for (guint i = 0; i < GS_UPDATES_SECTION_KIND_LAST; i++) { + GsAppList *list = gs_updates_section_get_list (self->sections[i]); + gs_app_list_add_list (apps, list); + } + return apps; +} + +static guint +_get_num_updates (GsUpdatesPage *self) +{ + guint count = 0; + g_autoptr(GsAppList) apps = _get_all_apps (self); + + for (guint i = 0; i < gs_app_list_length (apps); ++i) { + GsApp *app = gs_app_list_index (apps, i); + if (gs_app_is_updatable (app) || + gs_app_get_state (app) == GS_APP_STATE_INSTALLING) + ++count; + } + return count; +} + +static gchar * +gs_updates_page_last_checked_time_string (GsUpdatesPage *self, + gint *out_hours_ago, + gint *out_days_ago) +{ + gint64 last_checked; + gchar *res; + + g_settings_get (self->settings, "check-timestamp", "x", &last_checked); + res = gs_utils_time_to_string (last_checked); + if (res) { + g_assert (gs_utils_split_time_difference (last_checked, NULL, out_hours_ago, out_days_ago, NULL, NULL, NULL)); + } + + return res; +} + +static void +refresh_headerbar_updates_counter (GsUpdatesPage *self) +{ + guint new_updates_counter; + + new_updates_counter = _get_num_updates (self); + if (!gs_plugin_loader_get_allow_updates (self->plugin_loader) || + self->state == GS_UPDATES_PAGE_STATE_FAILED) + new_updates_counter = 0; + + if (new_updates_counter == self->updates_counter) + return; + + self->updates_counter = new_updates_counter; + g_object_notify (G_OBJECT (self), "counter"); +} + +static void +gs_updates_page_remove_last_checked_timeout (GsUpdatesPage *self) +{ + if (self->refresh_last_checked_id) { + g_source_remove (self->refresh_last_checked_id); + self->refresh_last_checked_id = 0; + } +} + +static void +gs_updates_page_refresh_last_checked (GsUpdatesPage *self); + +static gboolean +gs_updates_page_refresh_last_checked_cb (gpointer user_data) +{ + GsUpdatesPage *self = user_data; + gs_updates_page_refresh_last_checked (self); + return G_SOURCE_REMOVE; +} + +static void +gs_updates_page_refresh_last_checked (GsUpdatesPage *self) +{ + g_autofree gchar *checked_str = NULL; + gint hours_ago, days_ago; + checked_str = gs_updates_page_last_checked_time_string (self, &hours_ago, &days_ago); + if (checked_str != NULL) { + g_autofree gchar *last_checked = NULL; + guint interval; + + /* TRANSLATORS: This is the time when we last checked for updates */ + last_checked = g_strdup_printf (_("Last checked: %s"), checked_str); + gtk_label_set_label (self->uptodate_description, last_checked); + gtk_widget_set_visible (GTK_WIDGET (self->uptodate_description), TRUE); + + if (hours_ago < 1) + interval = 60; + else if (days_ago < 7) + interval = 60 * 60; + else + interval = 60 * 60 * 24; + + gs_updates_page_remove_last_checked_timeout (self); + + self->refresh_last_checked_id = g_timeout_add_seconds (interval, + gs_updates_page_refresh_last_checked_cb, self); + } else { + gtk_widget_set_visible (GTK_WIDGET (self->uptodate_description), FALSE); + } +} + +static void +gs_updates_page_update_ui_state (GsUpdatesPage *self) +{ + gboolean allow_mobile_refresh = TRUE; + + gs_updates_page_remove_last_checked_timeout (self); + + if (gs_shell_get_mode (self->shell) != GS_SHELL_MODE_UPDATES) + return; + + /* spinners */ + switch (self->state) { + case GS_UPDATES_PAGE_STATE_STARTUP: + case GS_UPDATES_PAGE_STATE_ACTION_GET_UPDATES: + case GS_UPDATES_PAGE_STATE_ACTION_REFRESH: + gtk_spinner_start (GTK_SPINNER (self->spinner_updates)); + break; + default: + gtk_spinner_stop (GTK_SPINNER (self->spinner_updates)); + gtk_spinner_stop (GTK_SPINNER (self->header_spinner_start)); + gtk_widget_hide (self->header_spinner_start); + break; + } + + /* headerbar refresh icon */ + switch (self->state) { + case GS_UPDATES_PAGE_STATE_ACTION_REFRESH: + case GS_UPDATES_PAGE_STATE_ACTION_GET_UPDATES: + gtk_button_set_icon_name (GTK_BUTTON (self->button_refresh), "media-playback-stop-symbolic"); + gtk_widget_show (self->button_refresh); + break; + case GS_UPDATES_PAGE_STATE_STARTUP: + case GS_UPDATES_PAGE_STATE_MANAGED: + gtk_widget_hide (self->button_refresh); + break; + case GS_UPDATES_PAGE_STATE_IDLE: + gtk_button_set_icon_name (GTK_BUTTON (self->button_refresh), "view-refresh-symbolic"); + if (self->result_flags != GS_UPDATES_PAGE_FLAG_NONE) { + gtk_widget_show (self->button_refresh); + } else { + if (gs_plugin_loader_get_network_metered (self->plugin_loader) && + !self->has_agreed_to_mobile_data) + allow_mobile_refresh = FALSE; + gtk_widget_set_visible (self->button_refresh, allow_mobile_refresh); + } + break; + case GS_UPDATES_PAGE_STATE_FAILED: + gtk_button_set_icon_name (GTK_BUTTON (self->button_refresh), "view-refresh-symbolic"); + gtk_widget_show (self->button_refresh); + break; + default: + g_assert_not_reached (); + break; + } + gtk_widget_set_sensitive (self->button_refresh, + gs_plugin_loader_get_network_available (self->plugin_loader)); + + /* stack */ + switch (self->state) { + case GS_UPDATES_PAGE_STATE_MANAGED: + gtk_stack_set_visible_child_name (GTK_STACK (self->stack_updates), "managed"); + break; + case GS_UPDATES_PAGE_STATE_FAILED: + gtk_stack_set_visible_child_name (GTK_STACK (self->stack_updates), "failed"); + break; + case GS_UPDATES_PAGE_STATE_ACTION_GET_UPDATES: + gtk_stack_set_visible_child_name (GTK_STACK (self->stack_updates), + "spinner"); + break; + case GS_UPDATES_PAGE_STATE_ACTION_REFRESH: + gtk_stack_set_visible_child_name (GTK_STACK (self->stack_updates), "spinner"); + break; + case GS_UPDATES_PAGE_STATE_STARTUP: + case GS_UPDATES_PAGE_STATE_IDLE: + + /* if have updates, just show the view, otherwise show network */ + if (self->result_flags != GS_UPDATES_PAGE_FLAG_NONE) { + gtk_stack_set_visible_child_name (GTK_STACK (self->stack_updates), "view"); + break; + } + + /* check we have a "free" network connection */ + if (gs_plugin_loader_get_network_available (self->plugin_loader) && + !gs_plugin_loader_get_network_metered (self->plugin_loader)) { + gtk_stack_set_visible_child_name (GTK_STACK (self->stack_updates), "uptodate"); + break; + } + + /* expensive network connection */ + if (gs_plugin_loader_get_network_metered (self->plugin_loader)) { + if (self->has_agreed_to_mobile_data) { + gtk_stack_set_visible_child_name (GTK_STACK (self->stack_updates), "uptodate"); + } else { + gtk_stack_set_visible_child_name (GTK_STACK (self->stack_updates), "mobile"); + } + break; + } + + /* no network connection */ + gtk_stack_set_visible_child_name (GTK_STACK (self->stack_updates), "offline"); + break; + default: + g_assert_not_reached (); + break; + } + + /* any updates? */ + gtk_widget_set_visible (self->updates_box, + self->result_flags & GS_UPDATES_PAGE_FLAG_HAS_UPDATES); + + /* last checked label */ + if (g_strcmp0 (gtk_stack_get_visible_child_name (GTK_STACK (self->stack_updates)), "uptodate") == 0) + gs_updates_page_refresh_last_checked (self); + + /* update the counter in headerbar */ + refresh_headerbar_updates_counter (self); +} + +static void +gs_updates_page_set_state (GsUpdatesPage *self, GsUpdatesPageState state) +{ + g_debug ("setting state from %s to %s (has-update:%i, has-upgrade:%i)", + gs_updates_page_state_to_string (self->state), + gs_updates_page_state_to_string (state), + (self->result_flags & GS_UPDATES_PAGE_FLAG_HAS_UPDATES) > 0, + (self->result_flags & GS_UPDATES_PAGE_FLAG_HAS_UPGRADES) > 0); + self->state = state; + gs_updates_page_update_ui_state (self); +} + +static void +gs_updates_page_decrement_refresh_count (GsUpdatesPage *self) +{ + /* every job increments this */ + if (self->action_cnt == 0) { + g_warning ("action_cnt already zero!"); + return; + } + if (--self->action_cnt > 0) + return; + + /* all done */ + gs_updates_page_set_state (self, GS_UPDATES_PAGE_STATE_IDLE); +} + +static void +gs_updates_page_network_available_notify_cb (GsPluginLoader *plugin_loader, + GParamSpec *pspec, + GsUpdatesPage *self) +{ + gs_updates_page_update_ui_state (self); +} + +static void +gs_updates_page_get_updates_cb (GsPluginLoader *plugin_loader, + GAsyncResult *res, + GsUpdatesPage *self) +{ + g_autoptr(GError) error = NULL; + g_autoptr(GsAppList) list = NULL; + + self->cache_valid = TRUE; + + /* get the results */ + list = gs_plugin_loader_job_process_finish (plugin_loader, res, &error); + if (list == NULL) { + gs_updates_page_clear_flag (self, GS_UPDATES_PAGE_FLAG_HAS_UPDATES); + 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 ("updates-shell: failed to get updates: %s", error->message); + adw_status_page_set_description (ADW_STATUS_PAGE (self->updates_failed_page), + error->message); + gs_updates_page_set_state (self, GS_UPDATES_PAGE_STATE_FAILED); + refresh_headerbar_updates_counter (self); + return; + } + + /* add the results */ + for (guint i = 0; i < gs_app_list_length (list); i++) { + GsApp *app = gs_app_list_index (list, i); + GsUpdatesSectionKind section = _get_app_section (app); + gs_updates_section_add_app (self->sections[section], app); + } + + /* update the counter in headerbar */ + refresh_headerbar_updates_counter (self); + + /* no results */ + if (gs_app_list_length (list) == 0) { + g_debug ("updates-shell: no updates to show"); + gs_updates_page_clear_flag (self, GS_UPDATES_PAGE_FLAG_HAS_UPDATES); + } else { + gs_updates_page_set_flag (self, GS_UPDATES_PAGE_FLAG_HAS_UPDATES); + } + + /* only when both set */ + gs_updates_page_decrement_refresh_count (self); +} + +static void +gs_updates_page_get_upgrades_cb (GObject *source_object, + GAsyncResult *res, + gpointer user_data) +{ + GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (source_object); + GsUpdatesPage *self = GS_UPDATES_PAGE (user_data); + g_autoptr(GError) error = NULL; + g_autoptr(GsAppList) list = NULL; + + /* get the results */ + list = gs_plugin_loader_job_process_finish (plugin_loader, res, &error); + if (list == NULL) { + gs_updates_page_clear_flag (self, GS_UPDATES_PAGE_FLAG_HAS_UPGRADES); + 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 ("updates-shell: failed to get upgrades: %s", + error->message); + } + } else if (gs_app_list_length (list) == 0) { + g_debug ("updates-shell: no upgrades to show"); + gs_updates_page_clear_flag (self, GS_UPDATES_PAGE_FLAG_HAS_UPGRADES); + gtk_widget_set_visible (self->upgrade_banner, FALSE); + } else { + /* rely on the app list already being sorted with the + * chronologically newest release last */ + GsApp *app = gs_app_list_index (list, gs_app_list_length (list) - 1); + g_debug ("got upgrade %s", gs_app_get_id (app)); + gs_upgrade_banner_set_app (GS_UPGRADE_BANNER (self->upgrade_banner), app); + gs_updates_page_set_flag (self, GS_UPDATES_PAGE_FLAG_HAS_UPGRADES); + gtk_widget_set_visible (self->upgrade_banner, TRUE); + } + + /* only when both set */ + gs_updates_page_decrement_refresh_count (self); +} + +typedef struct { + GsApp *app; /* (owned) */ + GsUpdatesPage *self; /* (owned) */ +} GsPageHelper; + +static GsPageHelper * +gs_page_helper_new (GsUpdatesPage *self, + GsApp *app) +{ + GsPageHelper *helper; + helper = g_slice_new0 (GsPageHelper); + helper->self = g_object_ref (self); + helper->app = g_object_ref (app); + return helper; +} + +static void +gs_page_helper_free (GsPageHelper *helper) +{ + g_clear_object (&helper->app); + g_clear_object (&helper->self); + g_slice_free (GsPageHelper, helper); +} + +G_DEFINE_AUTOPTR_CLEANUP_FUNC(GsPageHelper, gs_page_helper_free); + +static void +gs_updates_page_refine_system_finished_cb (GObject *source_object, + GAsyncResult *res, + gpointer user_data) +{ + GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (source_object); + g_autoptr(GsPageHelper) helper = user_data; + GsUpdatesPage *self = helper->self; + GsApp *app = helper->app; + g_autoptr(GError) error = NULL; + g_autoptr(GString) str = g_string_new (NULL); + + /* get result */ + if (!gs_plugin_loader_job_action_finish (plugin_loader, res, &error)) { + 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 refine system: %s", error->message); + return; + } + + /* show or hide the end of life notification */ + if (gs_app_get_state (app) != GS_APP_STATE_UNAVAILABLE) { + gtk_info_bar_set_revealed (GTK_INFO_BAR (self->infobar_end_of_life), FALSE); + return; + } + + /* construct a sufficiently scary message */ + if (gs_app_get_name (app) != NULL && gs_app_get_version (app) != NULL) { + /* TRANSLATORS: the first %s is the distro name, e.g. 'Fedora' + * and the second %s is the distro version, e.g. '25' */ + g_string_append_printf (str, _("%s %s is no longer supported."), + gs_app_get_name (app), + gs_app_get_version (app)); + } else { + g_string_append (str, _("Your operating system is no longer supported.")); + } + g_string_append (str, " "); + + /* TRANSLATORS: EOL distros do not get important updates */ + g_string_append (str, _("This means that it does not receive security updates.")); + g_string_append (str, " "); + + /* TRANSLATORS: upgrade refers to a major update, e.g. Fedora 25 to 26 */ + g_string_append (str, _("It is recommended that you upgrade to a more recent version.")); + + gtk_label_set_label (GTK_LABEL (self->label_end_of_life), str->str); + gtk_info_bar_set_revealed (GTK_INFO_BAR (self->infobar_end_of_life), TRUE); + +} + +static void +gs_updates_page_get_system_finished_cb (GObject *source_object, + GAsyncResult *res, + gpointer user_data) +{ + guint64 refine_flags; + GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (source_object); + GsUpdatesPage *self = user_data; + GsPageHelper *helper; + g_autoptr(GsApp) app = NULL; + g_autoptr(GsPluginJob) plugin_job = NULL; + g_autoptr(GError) error = NULL; + + app = gs_plugin_loader_get_system_app_finish (plugin_loader, res, &error); + if (app == 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 system: %s", error->message); + return; + } + + g_return_if_fail (GS_IS_UPDATES_PAGE (self)); + + refine_flags = GS_PLUGIN_REFINE_FLAGS_REQUIRE_ICON | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_SIZE | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_UPDATE_DETAILS | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_VERSION; + + helper = gs_page_helper_new (self, app); + plugin_job = gs_plugin_job_refine_new_for_app (app, refine_flags); + gs_plugin_job_set_interactive (plugin_job, TRUE); + gs_plugin_loader_job_process_async (self->plugin_loader, plugin_job, + self->cancellable, + gs_updates_page_refine_system_finished_cb, + helper); +} + +static void +gs_updates_page_load (GsUpdatesPage *self) +{ + guint64 refine_flags; + g_autoptr(GsApp) app = NULL; + g_autoptr(GsPluginJob) plugin_job = NULL; + + if (self->action_cnt > 0) + return; + + /* remove all existing apps */ + for (guint i = 0; i < GS_UPDATES_SECTION_KIND_LAST; i++) + gs_updates_section_remove_all (self->sections[i]); + + refine_flags = GS_PLUGIN_REFINE_FLAGS_REQUIRE_ICON | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_SIZE | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_UPDATE_DETAILS | + GS_PLUGIN_REFINE_FLAGS_REQUIRE_VERSION; + gs_updates_page_set_state (self, GS_UPDATES_PAGE_STATE_ACTION_GET_UPDATES); + self->action_cnt++; + plugin_job = gs_plugin_job_newv (GS_PLUGIN_ACTION_GET_UPDATES, + "interactive", TRUE, + "refine-flags", refine_flags, + "dedupe-flags", GS_APP_LIST_FILTER_FLAG_NONE, + NULL); + gs_plugin_loader_job_process_async (self->plugin_loader, plugin_job, + self->cancellable, + (GAsyncReadyCallback) gs_updates_page_get_updates_cb, + self); + + /* get the system state */ + gs_plugin_loader_get_system_app_async (self->plugin_loader, self->cancellable, + gs_updates_page_get_system_finished_cb, self); + + /* don't refresh every each time */ + if ((self->result_flags & GS_UPDATES_PAGE_FLAG_HAS_UPGRADES) == 0) { + refine_flags |= GS_PLUGIN_REFINE_FLAGS_REQUIRE_UPGRADE_REMOVED; + g_object_unref (plugin_job); + plugin_job = gs_plugin_job_list_distro_upgrades_new (GS_PLUGIN_LIST_DISTRO_UPGRADES_FLAGS_INTERACTIVE, + refine_flags); + gs_plugin_loader_job_process_async (self->plugin_loader, + plugin_job, + self->cancellable, + gs_updates_page_get_upgrades_cb, + self); + self->action_cnt++; + } +} + +static void +gs_updates_page_reload (GsPage *page) +{ + GsUpdatesPage *self = GS_UPDATES_PAGE (page); + + if (self->state == GS_UPDATES_PAGE_STATE_ACTION_REFRESH) { + g_debug ("ignoring reload as refresh is already in progress"); + return; + } + + gs_updates_page_invalidate (self); + gs_updates_page_load (self); +} + +static void +gs_updates_page_switch_to (GsPage *page) +{ + GsUpdatesPage *self = GS_UPDATES_PAGE (page); + + if (gs_shell_get_mode (self->shell) != GS_SHELL_MODE_UPDATES) { + g_warning ("Called switch_to(updates) when in mode %s", + gs_shell_get_mode_string (self->shell)); + return; + } + + gtk_widget_set_visible (self->button_refresh, TRUE); + + /* no need to refresh */ + if (self->cache_valid) { + gs_updates_page_update_ui_state (self); + return; + } + + if (self->state == GS_UPDATES_PAGE_STATE_ACTION_GET_UPDATES) { + gs_updates_page_update_ui_state (self); + return; + } + gs_updates_page_load (self); +} + +static void +gs_updates_page_switch_from (GsPage *page) +{ + GsUpdatesPage *self = GS_UPDATES_PAGE (page); + gs_updates_page_remove_last_checked_timeout (self); +} + +static void +gs_updates_page_refresh_cb (GsPluginLoader *plugin_loader, + GAsyncResult *res, + GsUpdatesPage *self) +{ + gboolean ret; + g_autoptr(GDateTime) now = NULL; + g_autoptr(GError) error = NULL; + + /* get the results */ + ret = gs_plugin_loader_job_action_finish (plugin_loader, res, &error); + if (!ret) { + /* user cancel */ + if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_CANCELLED) || + g_error_matches (error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) { + gs_updates_page_set_state (self, GS_UPDATES_PAGE_STATE_IDLE); + return; + } + g_warning ("failed to refresh: %s", error->message); + adw_status_page_set_description (ADW_STATUS_PAGE (self->updates_failed_page), + error->message); + gs_updates_page_set_state (self, GS_UPDATES_PAGE_STATE_FAILED); + return; + } + + /* update the last checked timestamp */ + now = g_date_time_new_now_local (); + g_settings_set (self->settings, "check-timestamp", "x", + g_date_time_to_unix (now)); + + /* get the new list */ + gs_updates_page_invalidate (self); + gs_page_switch_to (GS_PAGE (self)); + gs_page_scroll_up (GS_PAGE (self)); +} + +static void +gs_updates_page_get_new_updates (GsUpdatesPage *self) +{ + g_autoptr(GsPluginJob) plugin_job = NULL; + + /* force a check for updates and download */ + gs_updates_page_set_state (self, GS_UPDATES_PAGE_STATE_ACTION_REFRESH); + + g_cancellable_cancel (self->cancellable_refresh); + g_clear_object (&self->cancellable_refresh); + self->cancellable_refresh = g_cancellable_new (); + + plugin_job = gs_plugin_job_refresh_metadata_new (1, + GS_PLUGIN_REFRESH_METADATA_FLAGS_INTERACTIVE); + gs_plugin_loader_job_process_async (self->plugin_loader, plugin_job, + self->cancellable_refresh, + (GAsyncReadyCallback) gs_updates_page_refresh_cb, + self); +} + +static void +gs_updates_page_show_network_settings (GsUpdatesPage *self) +{ + g_autoptr(GError) error = NULL; + if (!g_spawn_command_line_async ("gnome-control-center wifi", &error)) + g_warning ("Failed to open the control center: %s", error->message); +} + +static void +gs_updates_page_refresh_confirm_cb (GtkDialog *dialog, + GtkResponseType response_type, + GsUpdatesPage *self) +{ + /* unmap the dialog */ + gtk_window_destroy (GTK_WINDOW (dialog)); + + switch (response_type) { + case GTK_RESPONSE_REJECT: + /* open the control center */ + gs_updates_page_show_network_settings (self); + break; + case GTK_RESPONSE_ACCEPT: + self->has_agreed_to_mobile_data = TRUE; + gs_updates_page_get_new_updates (self); + break; + case GTK_RESPONSE_CANCEL: + case GTK_RESPONSE_DELETE_EVENT: + break; + default: + g_assert_not_reached (); + } +} + +static void +gs_updates_page_button_network_settings_cb (GtkWidget *widget, + GsUpdatesPage *self) +{ + gs_updates_page_show_network_settings (self); +} + +static void +gs_updates_page_button_mobile_refresh_cb (GtkWidget *widget, + GsUpdatesPage *self) +{ + self->has_agreed_to_mobile_data = TRUE; + gs_updates_page_get_new_updates (self); +} + +static void +gs_updates_page_button_refresh_cb (GtkWidget *widget, + GsUpdatesPage *self) +{ + GtkWidget *dialog; + GtkWindow *parent_window = GTK_WINDOW (gtk_widget_get_ancestor (GTK_WIDGET (self), GTK_TYPE_WINDOW)); + + /* cancel existing action? */ + if (self->state == GS_UPDATES_PAGE_STATE_ACTION_REFRESH) { + g_cancellable_cancel (self->cancellable_refresh); + g_clear_object (&self->cancellable_refresh); + return; + } + + /* check we have a "free" network connection */ + if (gs_plugin_loader_get_network_available (self->plugin_loader) && + !gs_plugin_loader_get_network_metered (self->plugin_loader)) { + gs_updates_page_get_new_updates (self); + + /* expensive network connection */ + } else if (gs_plugin_loader_get_network_available (self->plugin_loader) && + gs_plugin_loader_get_network_metered (self->plugin_loader)) { + if (self->has_agreed_to_mobile_data) { + gs_updates_page_get_new_updates (self); + return; + } + dialog = gtk_message_dialog_new (parent_window, + GTK_DIALOG_MODAL | + GTK_DIALOG_USE_HEADER_BAR | + GTK_DIALOG_DESTROY_WITH_PARENT, + GTK_MESSAGE_ERROR, + GTK_BUTTONS_CANCEL, + /* TRANSLATORS: this is to explain that downloading updates may cost money */ + _("Charges May Apply")); + gtk_message_dialog_format_secondary_text (GTK_MESSAGE_DIALOG (dialog), + /* TRANSLATORS: we need network + * to do the updates check */ + _("Checking for updates while using mobile broadband could cause you to incur charges.")); + gtk_dialog_add_button (GTK_DIALOG (dialog), + /* TRANSLATORS: this is a link to the + * control-center network panel */ + _("Check _Anyway"), + GTK_RESPONSE_ACCEPT); + g_signal_connect (dialog, "response", + G_CALLBACK (gs_updates_page_refresh_confirm_cb), + self); + gs_shell_modal_dialog_present (self->shell, GTK_WINDOW (dialog)); + + /* no network connection */ + } else { + dialog = gtk_message_dialog_new (parent_window, + GTK_DIALOG_MODAL | + GTK_DIALOG_USE_HEADER_BAR | + GTK_DIALOG_DESTROY_WITH_PARENT, + GTK_MESSAGE_ERROR, + GTK_BUTTONS_CANCEL, + /* TRANSLATORS: can't do updates check */ + _("No Network")); + gtk_message_dialog_format_secondary_text (GTK_MESSAGE_DIALOG (dialog), + /* TRANSLATORS: we need network + * to do the updates check */ + _("Internet access is required to check for updates.")); + gtk_dialog_add_button (GTK_DIALOG (dialog), + /* TRANSLATORS: this is a link to the + * control-center network panel */ + _("Network Settings"), + GTK_RESPONSE_REJECT); + g_signal_connect (dialog, "response", + G_CALLBACK (gs_updates_page_refresh_confirm_cb), + self); + gs_shell_modal_dialog_present (self->shell, GTK_WINDOW (dialog)); + } +} + +static void +gs_updates_page_pending_apps_changed_cb (GsPluginLoader *plugin_loader, + GsUpdatesPage *self) +{ + gs_updates_page_invalidate (self); +} + +static void +upgrade_download_finished_cb (GObject *source, + GAsyncResult *res, + gpointer user_data) +{ + GsPluginLoader *plugin_loader = GS_PLUGIN_LOADER (source); + g_autoptr(GsPageHelper) helper = user_data; + g_autoptr(GError) error = NULL; + + if (!gs_plugin_loader_job_action_finish (plugin_loader, res, &error)) { + if (g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_CANCELLED) || + g_error_matches (error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) + return; + g_warning ("failed to upgrade-download: %s", error->message); + } +} + +static void +gs_updates_page_upgrade_download_cb (GsUpgradeBanner *upgrade_banner, + GsUpdatesPage *self) +{ + GsApp *app; + GsPageHelper *helper; + g_autoptr(GsPluginJob) plugin_job = NULL; + + app = gs_upgrade_banner_get_app (upgrade_banner); + if (app == NULL) { + g_warning ("no upgrade available to download"); + return; + } + + helper = gs_page_helper_new (self, app); + + if (self->cancellable_upgrade_download != NULL) + g_object_unref (self->cancellable_upgrade_download); + self->cancellable_upgrade_download = g_cancellable_new (); + g_debug ("Starting upgrade download with cancellable %p", self->cancellable_upgrade_download); + plugin_job = gs_plugin_job_newv (GS_PLUGIN_ACTION_UPGRADE_DOWNLOAD, + "interactive", TRUE, + "app", app, + NULL); + gs_plugin_loader_job_process_async (self->plugin_loader, plugin_job, + self->cancellable_upgrade_download, + upgrade_download_finished_cb, + helper); +} + +static void +_cancel_trigger_failed_cb (GObject *source, GAsyncResult *res, gpointer user_data) +{ + GsUpdatesPage *self = GS_UPDATES_PAGE (user_data); + g_autoptr(GError) error = NULL; + if (!gs_plugin_loader_job_action_finish (self->plugin_loader, res, &error)) { + g_warning ("failed to cancel trigger: %s", error->message); + return; + } +} + +static void +upgrade_reboot_failed_cb (GObject *source, + GAsyncResult *res, + gpointer user_data) +{ + GsUpdatesPage *self = (GsUpdatesPage *) user_data; + GsApp *app; + g_autoptr(GError) error = NULL; + g_autoptr(GsPluginJob) plugin_job = NULL; + + /* get result */ + if (gs_utils_invoke_reboot_finish (source, res, &error)) + return; + + if (g_error_matches (error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) + g_debug ("Calling reboot had been cancelled"); + else if (error != NULL) + g_warning ("Calling reboot failed: %s", error->message); + + app = gs_upgrade_banner_get_app (GS_UPGRADE_BANNER (self->upgrade_banner)); + if (app == NULL) { + g_warning ("no upgrade to cancel"); + return; + } + + /* cancel trigger */ + plugin_job = gs_plugin_job_newv (GS_PLUGIN_ACTION_UPDATE_CANCEL, + "app", app, + NULL); + gs_plugin_loader_job_process_async (self->plugin_loader, plugin_job, + self->cancellable, + _cancel_trigger_failed_cb, + self); +} + +static void +upgrade_trigger_finished_cb (GObject *source, + GAsyncResult *res, + gpointer user_data) +{ + GsUpdatesPage *self = (GsUpdatesPage *) user_data; + g_autoptr(GError) error = NULL; + + /* get the results */ + if (!gs_plugin_loader_job_action_finish (self->plugin_loader, res, &error)) { + g_warning ("Failed to trigger offline update: %s", error->message); + return; + } + + /* trigger reboot */ + gs_utils_invoke_reboot_async (NULL, upgrade_reboot_failed_cb, self); +} + +static void +trigger_upgrade (GsUpdatesPage *self) +{ + GsApp *upgrade; + g_autoptr(GsPluginJob) plugin_job = NULL; + + upgrade = gs_upgrade_banner_get_app (GS_UPGRADE_BANNER (self->upgrade_banner)); + if (upgrade == NULL) { + g_warning ("no upgrade available to install"); + return; + } + + plugin_job = gs_plugin_job_newv (GS_PLUGIN_ACTION_UPGRADE_TRIGGER, + "interactive", TRUE, + "app", upgrade, + NULL); + gs_plugin_loader_job_process_async (self->plugin_loader, plugin_job, + self->cancellable, + upgrade_trigger_finished_cb, + self); +} + +static void +gs_updates_page_upgrade_confirm_cb (GtkDialog *dialog, + GtkResponseType response_type, + GsUpdatesPage *self) +{ + /* unmap the dialog */ + gtk_window_destroy (GTK_WINDOW (dialog)); + + switch (response_type) { + case GTK_RESPONSE_ACCEPT: + g_debug ("agreed to upgrade removing apps"); + trigger_upgrade (self); + break; + case GTK_RESPONSE_CANCEL: + g_debug ("cancelled removal dialog"); + break; + case GTK_RESPONSE_DELETE_EVENT: + break; + default: + g_assert_not_reached (); + } +} + +static void +gs_updates_page_upgrade_install_cb (GsUpgradeBanner *upgrade_banner, + GsUpdatesPage *self) +{ + GsAppList *removals; + GsApp *upgrade; + GtkWidget *dialog; + guint cnt = 0; + guint i; + + upgrade = gs_upgrade_banner_get_app (GS_UPGRADE_BANNER (self->upgrade_banner)); + if (upgrade == NULL) { + g_warning ("no upgrade available to install"); + return; + } + + /* count the removals */ + removals = gs_app_get_related (upgrade); + for (i = 0; i < gs_app_list_length (removals); i++) { + GsApp *app = gs_app_list_index (removals, i); + if (gs_app_get_state (app) != GS_APP_STATE_UNAVAILABLE) + continue; + cnt++; + } + + if (cnt == 0) { + /* no need for a removal confirmation dialog */ + trigger_upgrade (self); + return; + } + + dialog = gs_removal_dialog_new (); + g_signal_connect (dialog, "response", + G_CALLBACK (gs_updates_page_upgrade_confirm_cb), + self); + gs_removal_dialog_show_upgrade_removals (GS_REMOVAL_DIALOG (dialog), + upgrade); + gs_shell_modal_dialog_present (self->shell, GTK_WINDOW (dialog)); +} + +static void +gs_updates_page_invalidate_downloaded_upgrade (GsUpdatesPage *self) +{ + GsApp *app; + app = gs_upgrade_banner_get_app (GS_UPGRADE_BANNER (self->upgrade_banner)); + if (app == NULL) + return; + if (gs_app_get_state (app) != GS_APP_STATE_UPDATABLE) + return; + gs_app_set_state (app, GS_APP_STATE_AVAILABLE); + g_debug ("resetting %s to AVAILABLE as the updates have changed", + gs_app_get_id (app)); +} + +static gboolean +gs_shell_update_are_updates_in_progress (GsUpdatesPage *self) +{ + g_autoptr(GsAppList) list = _get_all_apps (self); + for (guint i = 0; i < gs_app_list_length (list); i++) { + GsApp *app = gs_app_list_index (list, i); + switch (gs_app_get_state (app)) { + case GS_APP_STATE_INSTALLING: + case GS_APP_STATE_REMOVING: + return TRUE; + break; + default: + break; + } + } + return FALSE; +} + +static void +gs_updates_page_changed_cb (GsPluginLoader *plugin_loader, + GsUpdatesPage *self) +{ + /* if we do a live update and the upgrade is waiting to be deployed + * then make sure all new packages are downloaded */ + gs_updates_page_invalidate_downloaded_upgrade (self); + + /* check to see if any apps in the app list are in a processing state */ + if (gs_shell_update_are_updates_in_progress (self)) { + g_debug ("ignoring updates-changed as updates in progress"); + return; + } + + /* refresh updates list */ + gs_updates_page_reload (GS_PAGE (self)); +} + +static void +gs_updates_page_status_changed_cb (GsPluginLoader *plugin_loader, + GsApp *app, + GsPluginStatus status, + GsUpdatesPage *self) +{ + switch (status) { + case GS_PLUGIN_STATUS_INSTALLING: + case GS_PLUGIN_STATUS_REMOVING: + if (app == NULL || + (gs_app_get_kind (app) != AS_COMPONENT_KIND_OPERATING_SYSTEM && + gs_app_get_id (app) != NULL)) { + /* if we do a install or remove then make sure all new + * packages are downloaded */ + gs_updates_page_invalidate_downloaded_upgrade (self); + } + break; + default: + break; + } + + gs_updates_page_update_ui_state (self); +} + +static void +gs_updates_page_allow_updates_notify_cb (GsPluginLoader *plugin_loader, + GParamSpec *pspec, + GsUpdatesPage *self) +{ + if (!gs_plugin_loader_get_allow_updates (plugin_loader)) { + gs_updates_page_set_state (self, GS_UPDATES_PAGE_STATE_MANAGED); + return; + } + gs_updates_page_set_state (self, GS_UPDATES_PAGE_STATE_IDLE); +} + +static void +gs_updates_page_upgrade_cancel_cb (GsUpgradeBanner *upgrade_banner, + GsUpdatesPage *self) +{ + g_debug ("Cancelling upgrade download with %p", self->cancellable_upgrade_download); + g_cancellable_cancel (self->cancellable_upgrade_download); +} + +static gboolean +gs_updates_page_setup (GsPage *page, + GsShell *shell, + GsPluginLoader *plugin_loader, + GCancellable *cancellable, + GError **error) +{ + GsUpdatesPage *self = GS_UPDATES_PAGE (page); + + g_return_val_if_fail (GS_IS_UPDATES_PAGE (self), TRUE); + + for (guint i = 0; i < GS_UPDATES_SECTION_KIND_LAST; i++) { + self->sections[i] = gs_updates_section_new (i, plugin_loader, page); + gs_updates_section_set_size_groups (self->sections[i], + self->sizegroup_name, + self->sizegroup_button_label, + self->sizegroup_button_image, + self->sizegroup_header); + gtk_widget_set_vexpand (GTK_WIDGET (self->sections[i]), FALSE); + g_object_bind_property (G_OBJECT (self), "is-narrow", + self->sections[i], "is-narrow", + G_BINDING_SYNC_CREATE); + gtk_box_append (GTK_BOX (self->updates_box), GTK_WIDGET (self->sections[i])); + } + + self->shell = shell; + + self->plugin_loader = g_object_ref (plugin_loader); + g_signal_connect (self->plugin_loader, "pending-apps-changed", + G_CALLBACK (gs_updates_page_pending_apps_changed_cb), + self); + g_signal_connect (self->plugin_loader, "status-changed", + G_CALLBACK (gs_updates_page_status_changed_cb), + self); + g_signal_connect (self->plugin_loader, "updates-changed", + G_CALLBACK (gs_updates_page_changed_cb), + self); + g_signal_connect_object (self->plugin_loader, "notify::allow-updates", + G_CALLBACK (gs_updates_page_allow_updates_notify_cb), + self, 0); + g_signal_connect_object (self->plugin_loader, "notify::network-available", + G_CALLBACK (gs_updates_page_network_available_notify_cb), + self, 0); + self->cancellable = g_object_ref (cancellable); + + /* setup system upgrades */ + g_signal_connect (self->upgrade_banner, "download-clicked", + G_CALLBACK (gs_updates_page_upgrade_download_cb), self); + g_signal_connect (self->upgrade_banner, "install-clicked", + G_CALLBACK (gs_updates_page_upgrade_install_cb), self); + g_signal_connect (self->upgrade_banner, "cancel-clicked", + G_CALLBACK (gs_updates_page_upgrade_cancel_cb), self); + + self->header_start_box = gtk_box_new (GTK_ORIENTATION_HORIZONTAL, 6); + gtk_widget_set_visible (self->header_start_box, TRUE); + gs_page_set_header_start_widget (GS_PAGE (self), self->header_start_box); + + self->header_spinner_start = gtk_spinner_new (); + gtk_box_prepend (GTK_BOX (self->header_start_box), self->header_spinner_start); + + /* setup update details window */ + self->button_refresh = gtk_button_new_from_icon_name ("view-refresh-symbolic"); + gtk_accessible_update_property (GTK_ACCESSIBLE (self->button_refresh), + GTK_ACCESSIBLE_PROPERTY_LABEL, _("Check for updates"), + -1); + gtk_box_prepend (GTK_BOX (self->header_start_box), self->button_refresh); + g_signal_connect (self->button_refresh, "clicked", + G_CALLBACK (gs_updates_page_button_refresh_cb), + self); + + g_signal_connect (self->button_updates_mobile, "clicked", + G_CALLBACK (gs_updates_page_button_mobile_refresh_cb), + self); + g_signal_connect (self->button_updates_offline, "clicked", + G_CALLBACK (gs_updates_page_button_network_settings_cb), + self); + + /* set initial state */ + if (!gs_plugin_loader_get_allow_updates (self->plugin_loader)) + self->state = GS_UPDATES_PAGE_STATE_MANAGED; + return TRUE; +} + +static void +gs_updates_page_get_property (GObject *object, + guint prop_id, + GValue *value, + GParamSpec *pspec) +{ + GsUpdatesPage *self = GS_UPDATES_PAGE (object); + + switch ((GsUpdatesPageProperty) prop_id) { + case PROP_IS_NARROW: + g_value_set_boolean (value, gs_updates_page_get_is_narrow (self)); + break; + case PROP_VADJUSTMENT: + g_value_set_object (value, gtk_scrolled_window_get_vadjustment (GTK_SCROLLED_WINDOW (self->scrolledwindow_updates))); + break; + case PROP_TITLE: + g_value_set_string (value, C_("Apps to be updated", "Updates")); + break; + case PROP_COUNTER: + g_value_set_uint (value, self->updates_counter); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_updates_page_set_property (GObject *object, + guint prop_id, + const GValue *value, + GParamSpec *pspec) +{ + GsUpdatesPage *self = GS_UPDATES_PAGE (object); + + switch ((GsUpdatesPageProperty) prop_id) { + case PROP_IS_NARROW: + gs_updates_page_set_is_narrow (self, g_value_get_boolean (value)); + break; + case PROP_VADJUSTMENT: + case PROP_TITLE: + case PROP_COUNTER: + /* Read only */ + g_assert_not_reached (); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_updates_page_dispose (GObject *object) +{ + GsUpdatesPage *self = GS_UPDATES_PAGE (object); + + gs_updates_page_remove_last_checked_timeout (self); + + g_cancellable_cancel (self->cancellable_refresh); + g_clear_object (&self->cancellable_refresh); + g_cancellable_cancel (self->cancellable_upgrade_download); + g_clear_object (&self->cancellable_upgrade_download); + + for (guint i = 0; i < GS_UPDATES_SECTION_KIND_LAST; i++) { + if (self->sections[i] != NULL) { + gtk_widget_unparent (GTK_WIDGET (self->sections[i])); + self->sections[i] = NULL; + } + } + + g_clear_object (&self->plugin_loader); + g_clear_object (&self->cancellable); + g_clear_object (&self->settings); + g_clear_object (&self->desktop_settings); + + g_clear_object (&self->sizegroup_name); + g_clear_object (&self->sizegroup_button_label); + g_clear_object (&self->sizegroup_button_image); + g_clear_object (&self->sizegroup_header); + + G_OBJECT_CLASS (gs_updates_page_parent_class)->dispose (object); +} + +static void +gs_updates_page_class_init (GsUpdatesPageClass *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_updates_page_get_property; + object_class->set_property = gs_updates_page_set_property; + object_class->dispose = gs_updates_page_dispose; + + page_class->switch_to = gs_updates_page_switch_to; + page_class->switch_from = gs_updates_page_switch_from; + page_class->reload = gs_updates_page_reload; + page_class->setup = gs_updates_page_setup; + + /** + * GsUpdatesPage: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. using icons rather than labels in buttons. 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_VADJUSTMENT, "vadjustment"); + g_object_class_override_property (object_class, PROP_TITLE, "title"); + g_object_class_override_property (object_class, PROP_COUNTER, "counter"); + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-updates-page.ui"); + + gtk_widget_class_bind_template_child (widget_class, GsUpdatesPage, updates_box); + gtk_widget_class_bind_template_child (widget_class, GsUpdatesPage, button_updates_mobile); + gtk_widget_class_bind_template_child (widget_class, GsUpdatesPage, button_updates_offline); + gtk_widget_class_bind_template_child (widget_class, GsUpdatesPage, updates_failed_page); + gtk_widget_class_bind_template_child (widget_class, GsUpdatesPage, uptodate_description); + gtk_widget_class_bind_template_child (widget_class, GsUpdatesPage, scrolledwindow_updates); + gtk_widget_class_bind_template_child (widget_class, GsUpdatesPage, spinner_updates); + gtk_widget_class_bind_template_child (widget_class, GsUpdatesPage, stack_updates); + gtk_widget_class_bind_template_child (widget_class, GsUpdatesPage, upgrade_banner); + gtk_widget_class_bind_template_child (widget_class, GsUpdatesPage, infobar_end_of_life); + gtk_widget_class_bind_template_child (widget_class, GsUpdatesPage, label_end_of_life); +} + +static void +gs_updates_page_init (GsUpdatesPage *self) +{ + gtk_widget_init_template (GTK_WIDGET (self)); + + self->state = GS_UPDATES_PAGE_STATE_STARTUP; + self->settings = g_settings_new ("org.gnome.software"); + self->desktop_settings = g_settings_new ("org.gnome.desktop.interface"); + + self->sizegroup_name = gtk_size_group_new (GTK_SIZE_GROUP_HORIZONTAL); + self->sizegroup_button_label = gtk_size_group_new (GTK_SIZE_GROUP_HORIZONTAL); + self->sizegroup_button_image = gtk_size_group_new (GTK_SIZE_GROUP_HORIZONTAL); + self->sizegroup_header = gtk_size_group_new (GTK_SIZE_GROUP_VERTICAL); + +} + +/** + * gs_updates_page_get_is_narrow: + * @self: a #GsUpdatesPage + * + * Get the value of #GsUpdatesPage:is-narrow. + * + * Returns: %TRUE if the page is in narrow mode, %FALSE otherwise + * + * Since: 41 + */ +gboolean +gs_updates_page_get_is_narrow (GsUpdatesPage *self) +{ + g_return_val_if_fail (GS_IS_UPDATES_PAGE (self), FALSE); + + return self->is_narrow; +} + +/** + * gs_updates_page_set_is_narrow: + * @self: a #GsUpdatesPage + * @is_narrow: %TRUE to set the page in narrow mode, %FALSE otherwise + * + * Set the value of #GsUpdatesPage:is-narrow. + * + * Since: 41 + */ +void +gs_updates_page_set_is_narrow (GsUpdatesPage *self, gboolean is_narrow) +{ + g_return_if_fail (GS_IS_UPDATES_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]); +} + +GsUpdatesPage * +gs_updates_page_new (void) +{ + return GS_UPDATES_PAGE (g_object_new (GS_TYPE_UPDATES_PAGE, NULL)); +} diff --git a/src/gs-updates-page.h b/src/gs-updates-page.h new file mode 100644 index 0000000..05038eb --- /dev/null +++ b/src/gs-updates-page.h @@ -0,0 +1,26 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2015-2017 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include "gs-page.h" + +G_BEGIN_DECLS + +#define GS_TYPE_UPDATES_PAGE (gs_updates_page_get_type ()) + +G_DECLARE_FINAL_TYPE (GsUpdatesPage, gs_updates_page, GS, UPDATES_PAGE, GsPage) + +GsUpdatesPage *gs_updates_page_new (void); + +gboolean gs_updates_page_get_is_narrow (GsUpdatesPage *self); +void gs_updates_page_set_is_narrow (GsUpdatesPage *self, + gboolean is_narrow); + +G_END_DECLS diff --git a/src/gs-updates-page.ui b/src/gs-updates-page.ui new file mode 100644 index 0000000..654cbdb --- /dev/null +++ b/src/gs-updates-page.ui @@ -0,0 +1,307 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <requires lib="gtk+" version="3.10"/> + <template class="GsUpdatesPage" parent="GsPage"> + <accessibility> + <property name="label" translatable="yes">Updates page</property> + </accessibility> + <child> + <object class="GtkBox"> + <property name="orientation">vertical</property> + <child> + <object class="GtkInfoBar" id="infobar_end_of_life"> + <property name="revealed">False</property> + <child> + <object class="GtkBox"> + <property name="orientation">vertical</property> + <property name="spacing">6</property> + <child> + <object class="GtkLabel"> + <property name="label" translatable="yes">Operating System Updates Unavailable</property> + <property name="xalign">0</property> + <attributes> + <attribute name="weight" value="bold"/> + </attributes> + </object> + </child> + <child> + <object class="GtkLabel" id="label_end_of_life"> + <property name="label">Your operating system is no longer supported. This means that it does not receive security updates. It is recommended that you upgrade to a more recent version.</property> + <property name="wrap">True</property> + <property name="xalign">0</property> + </object> + </child> + </object> + </child> + </object> + </child> + <child> + <object class="GtkStack" id="stack_updates"> + + <child> + <object class="GtkStackPage"> + <property name="name">spinner</property> + <property name="child"> + <object class="AdwClamp"> + <property name="valign">center</property> + <!-- We explicitly want to use the default AdwClamp sizes here, + as does AdwStatusPage. --> + <style> + <class name="status-page"/> + </style> + <child> + <object class="GtkBox"> + <property name="orientation">vertical</property> + <property name="valign">center</property> + <child> + <object class="GtkBox"> + <property name="orientation">vertical</property> + <property name="valign">center</property> + <property name="halign">center</property> + <style> + <class name="iconbox"/> + </style> + <child> + <object class="GtkSpinner" id="spinner_updates"> + <property name="halign">center</property> + <property name="valign">center</property> + <property name="hexpand">True</property> + <property name="vexpand">True</property> + <style> + <class name="icon"/> + </style> + </object> + </child> + </object> + </child> + <child> + <object class="GtkLabel" id="label_updates_spinner"> + <property name="wrap">True</property> + <property name="wrap-mode">word-char</property> + <property name="justify">center</property> + <property name="label" translatable="yes" comments="TRANSLATORS: the updates panel is starting up.">Loading Updates…</property> + <style> + <class name="title"/> + <class name="title-1"/> + </style> + </object> + </child> + <child> + <object class="GtkLabel"> + <property name="wrap">True</property> + <property name="wrap-mode">word-char</property> + <property name="justify">center</property> + <property name="use-markup">True</property> + <property name="label" translatable="yes" comments="TRANSLATORS: the updates panel is starting up.">This could take a while.</property> + <style> + <class name="body"/> + <class name="description"/> + </style> + </object> + </child> + </object> + </child> + </object> + </property> + </object> + </child> + + <child> + <object class="GtkStackPage"> + <property name="name">view</property> + <property name="child"> + <object class="GtkScrolledWindow" id="scrolledwindow_updates"> + <property name="can_focus">True</property> + <property name="hscrollbar_policy">never</property> + <property name="vscrollbar_policy">automatic</property> + <style> + <class name="list-page"/> + </style> + <child> + <object class="AdwClamp"> + <property name="maximum-size">600</property> + <property name="tightening-threshold">400</property> + <child> + <object class="GtkBox" id="list_box_updates_box"> + <property name="orientation">vertical</property> + <child> + <object class="GsUpgradeBanner" id="upgrade_banner"> + <property name="visible">False</property> + <property name="hexpand">True</property> + <property name="vexpand">False</property> + <property name="margin-top">12</property> + </object> + </child> + <child> + <object class="GtkBox" id="updates_box"> + <property name="orientation">vertical</property> + <property name="spacing">24</property> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </property> + </object> + </child> + + <child> + <object class="GtkStackPage"> + <property name="name">uptodate</property> + <property name="child"> + <object class="GtkBox"> + <property name="orientation">vertical</property> + <property name="spacing">48</property> + <property name="hexpand">True</property> + <property name="vexpand">True</property> + <child> + <object class="GsUpgradeBanner" id="upgrade_banner_uptodate"> + <property name="visible">False</property> + <property name="hexpand">True</property> + <property name="vexpand">True</property> + </object> + </child> + <child> + + <!-- FIXME: This should be a AdwStatusPage but it doesn’t + currently support non-icon images + See https://gitlab.gnome.org/GNOME/libhandy/-/issues/448 --> + <object class="GtkScrolledWindow"> + <property name="hscrollbar-policy">never</property> + <property name="propagate-natural-height">True</property> + <property name="vexpand">True</property> + <property name="valign">center</property> + <style> + <class name="fake-adw-status-page"/> + </style> + <child> + <object class="GtkBox"> + <property name="orientation">vertical</property> + <property name="valign">center</property> + <child> + <object class="AdwClamp"> + <child> + <object class="GtkBox"> + <property name="orientation">vertical</property> + <property name="valign">center</property> + <child> + <object class="GtkImage"> + <property name="pixel-size">300</property> + <property name="resource">/org/gnome/Software/up-to-date.svg</property> + <style> + <class name="icon"/> + </style> + </object> + </child> + <child> + <object class="GtkLabel"> + <property name="wrap">True</property> + <property name="wrap-mode">word-char</property> + <property name="justify">center</property> + <property name="label" translatable="yes" comments="TRANSLATORS: This means all software (plural) installed on this system is up to date.">Up to Date</property> + <style> + <class name="title"/> + <class name="title-1"/> + </style> + </object> + </child> + <child> + <object class="GtkLabel" id="uptodate_description"> + <property name="wrap">True</property> + <property name="wrap-mode">word-char</property> + <property name="justify">center</property> + <property name="use-markup">True</property> + <property name="label">Last checked: HH:MM</property> + <style> + <class name="body"/> + <class name="description"/> + </style> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </property> + </object> + </child> + + <child> + <object class="GtkStackPage"> + <property name="name">mobile</property> + <property name="child"> + <object class="AdwStatusPage"> + <property name="icon_name">dialog-warning-symbolic</property> + <property name="title" translatable="yes">Use Mobile Data?</property> + <property name="description" translatable="yes">Checking for updates when using mobile broadband could cause you to incur charges.</property> + <child> + <object class="GtkButton" id="button_updates_mobile"> + <property name="label" translatable="yes">_Check Anyway</property> + <property name="use_underline">True</property> + <property name="halign">center</property> + </object> + </child> + </object> + </property> + </object> + </child> + + <child> + <object class="GtkStackPage"> + <property name="name">offline</property> + <property name="child"> + <object class="AdwStatusPage"> + <property name="icon_name">network-offline-symbolic</property> + <property name="title" translatable="yes">No Connection</property> + <property name="description" translatable="yes">Go online to check for updates.</property> + <child> + <object class="GtkButton" id="button_updates_offline"> + <property name="label" translatable="yes">_Network Settings</property> + <property name="use_underline">True</property> + <property name="halign">center</property> + </object> + </child> + </object> + </property> + </object> + </child> + + <child> + <object class="GtkStackPage"> + <property name="name">failed</property> + <property name="child"> + <object class="AdwStatusPage" id="updates_failed_page"> + <property name="icon_name">action-unavailable-symbolic</property> + <property name="title" translatable="No">Error</property> + <property name="description" translatable="No">Failed to get updates.</property> + </object> + </property> + </object> + </child> + + <child> + <object class="GtkStackPage"> + <property name="name">managed</property> + <property name="child"> + <object class="AdwStatusPage"> + <property name="icon_name">action-unavailable-symbolic</property> + <property name="title" translatable="yes">Error</property> + <property name="description" translatable="yes">Updates are automatically managed.</property> + </object> + </property> + </object> + </child> + + </object> + </child> + </object> + </child> + </template> +</interface> diff --git a/src/gs-updates-section.c b/src/gs-updates-section.c new file mode 100644 index 0000000..68d818a --- /dev/null +++ b/src/gs-updates-section.c @@ -0,0 +1,733 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013-2017 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2014-2018 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include <glib/gi18n.h> +#include <gio/gio.h> + +#include "gs-app-list-private.h" +#include "gs-app-row.h" +#include "gs-page.h" +#include "gs-common.h" +#include "gs-progress-button.h" +#include "gs-update-dialog.h" +#include "gs-updates-section.h" +#include "gs-utils.h" + +struct _GsUpdatesSection +{ + GtkBox parent_instance; + + GtkWidget *button_cancel; + GtkWidget *button_download; + GtkWidget *button_stack; + GtkWidget *button_update; + GtkWidget *description; + GtkWidget *listbox; + GtkWidget *listbox_box; + GtkWidget *section_header; + GtkWidget *title; + + GsAppList *list; + GsUpdatesSectionKind kind; + GCancellable *cancellable; + GsPage *page; /* (transfer none) */ + GsPluginLoader *plugin_loader; + GtkSizeGroup *sizegroup_name; + GtkSizeGroup *sizegroup_button_label; + GtkSizeGroup *sizegroup_button_image; + GtkSizeGroup *sizegroup_header; + gboolean is_narrow; +}; + +G_DEFINE_TYPE (GsUpdatesSection, gs_updates_section, GTK_TYPE_BOX) + +typedef enum { + PROP_IS_NARROW = 1, +} GsUpdatesSectionProperty; + +static GParamSpec *obj_props[PROP_IS_NARROW + 1] = { NULL, }; + +GsAppList * +gs_updates_section_get_list (GsUpdatesSection *self) +{ + return self->list; +} + +static gboolean +_listbox_keynav_failed_cb (GsUpdatesSection *self, GtkDirectionType direction, GtkListBox *listbox) +{ + GtkRoot *root = gtk_widget_get_root (GTK_WIDGET (listbox)); + + if (!root) + return FALSE; + + if (direction != GTK_DIR_UP && direction != GTK_DIR_DOWN) + return FALSE; + + return gtk_widget_child_focus (GTK_WIDGET (root), direction == GTK_DIR_UP ? GTK_DIR_TAB_BACKWARD : GTK_DIR_TAB_FORWARD); +} + +static void +_app_row_button_clicked_cb (GsAppRow *app_row, GsUpdatesSection *self) +{ + GsApp *app = gs_app_row_get_app (app_row); + if (gs_app_get_state (app) != GS_APP_STATE_UPDATABLE_LIVE) + return; + gs_page_update_app (GS_PAGE (self->page), app, gs_app_get_cancellable (app)); +} + +static void +_row_unrevealed_cb (GObject *row, GParamSpec *pspec, gpointer data) +{ + GtkWidget *widget; + GsUpdatesSection *self; + + widget = gtk_widget_get_parent (GTK_WIDGET (row)); + if (widget == NULL) + return; + + widget = gtk_widget_get_ancestor (GTK_WIDGET (row), GS_TYPE_UPDATES_SECTION); + g_return_if_fail (GS_IS_UPDATES_SECTION (widget)); + self = GS_UPDATES_SECTION (widget); + + gs_app_list_remove (self->list, gs_app_row_get_app (GS_APP_ROW (row))); + + gtk_list_box_remove (GTK_LIST_BOX (self->listbox), GTK_WIDGET (row)); + + if (!gs_app_list_length (self->list)) + gtk_widget_hide (widget); +} + +static void +_unreveal_row (GsAppRow *app_row) +{ + g_signal_connect (app_row, "unrevealed", + G_CALLBACK (_row_unrevealed_cb), NULL); + gs_app_row_unreveal (app_row); +} + +static void +_app_state_notify_cb (GsApp *app, GParamSpec *pspec, gpointer user_data) +{ + if (gs_app_get_state (app) == GS_APP_STATE_INSTALLED) { + GsAppRow *app_row = GS_APP_ROW (user_data); + _unreveal_row (app_row); + } +} + +void +gs_updates_section_add_app (GsUpdatesSection *self, GsApp *app) +{ + GtkWidget *app_row; + app_row = gs_app_row_new (app); + gs_app_row_set_show_description (GS_APP_ROW (app_row), FALSE); + gs_app_row_set_show_update (GS_APP_ROW (app_row), TRUE); + gs_app_row_set_show_buttons (GS_APP_ROW (app_row), TRUE); + g_signal_connect (app_row, "button-clicked", + G_CALLBACK (_app_row_button_clicked_cb), + self); + gtk_list_box_append (GTK_LIST_BOX (self->listbox), app_row); + gs_app_list_add (self->list, app); + + gs_app_row_set_size_groups (GS_APP_ROW (app_row), + self->sizegroup_name, + self->sizegroup_button_label, + self->sizegroup_button_image); + g_signal_connect_object (app, "notify::state", + G_CALLBACK (_app_state_notify_cb), + app_row, 0); + g_object_bind_property (G_OBJECT (self), "is-narrow", + app_row, "is-narrow", + G_BINDING_SYNC_CREATE); + gtk_widget_show (GTK_WIDGET (self)); +} + +void +gs_updates_section_remove_all (GsUpdatesSection *self) +{ + GtkWidget *child; + while ((child = gtk_widget_get_first_child (self->listbox)) != NULL) + gtk_list_box_remove (GTK_LIST_BOX (self->listbox), child); + gs_app_list_remove_all (self->list); + gtk_widget_hide (GTK_WIDGET (self)); +} + +typedef struct { + GsUpdatesSection *self; + gboolean do_reboot; + gboolean do_reboot_notification; +} GsUpdatesSectionUpdateHelper; + +static gchar * +_get_app_sort_key (GsApp *app) +{ + GString *key; + g_autofree gchar *sort_name = NULL; + + key = g_string_sized_new (64); + + /* sort apps by kind */ + switch (gs_app_get_kind (app)) { + case AS_COMPONENT_KIND_DESKTOP_APP: + g_string_append (key, "2:"); + break; + case AS_COMPONENT_KIND_WEB_APP: + g_string_append (key, "3:"); + break; + case AS_COMPONENT_KIND_RUNTIME: + g_string_append (key, "4:"); + break; + case AS_COMPONENT_KIND_ADDON: + g_string_append (key, "5:"); + break; + case AS_COMPONENT_KIND_CODEC: + g_string_append (key, "6:"); + break; + case AS_COMPONENT_KIND_FONT: + g_string_append (key, "6:"); + break; + case AS_COMPONENT_KIND_INPUT_METHOD: + g_string_append (key, "7:"); + break; + default: + if (gs_app_get_special_kind (app) == GS_APP_SPECIAL_KIND_OS_UPDATE) + g_string_append (key, "1:"); + else + g_string_append (key, "8:"); + break; + } + + /* finally, sort by short name */ + if (gs_app_get_name (app) != NULL) { + sort_name = gs_utils_sort_key (gs_app_get_name (app)); + g_string_append (key, sort_name); + } + + return g_string_free (key, FALSE); +} + +static gint +_list_sort_func (GtkListBoxRow *a, GtkListBoxRow *b, gpointer user_data) +{ + GsApp *a1 = gs_app_row_get_app (GS_APP_ROW (a)); + GsApp *a2 = gs_app_row_get_app (GS_APP_ROW (b)); + g_autofree gchar *key1 = _get_app_sort_key (a1); + g_autofree gchar *key2 = _get_app_sort_key (a2); + + /* compare the keys according to the algorithm above */ + return g_strcmp0 (key1, key2); +} + +static void +_update_helper_free (GsUpdatesSectionUpdateHelper *helper) +{ + g_object_unref (helper->self); + g_free (helper); +} + +G_DEFINE_AUTOPTR_CLEANUP_FUNC(GsUpdatesSectionUpdateHelper, _update_helper_free); + +static void +_cancel_trigger_failed_cb (GObject *source, GAsyncResult *res, gpointer user_data) +{ + GsUpdatesSection *self = GS_UPDATES_SECTION (user_data); + g_autoptr(GError) error = NULL; + if (!gs_plugin_loader_job_action_finish (self->plugin_loader, res, &error)) { + g_warning ("failed to cancel trigger: %s", error->message); + return; + } +} + +static void +_reboot_failed_cb (GObject *source, GAsyncResult *res, gpointer user_data) +{ + GsUpdatesSection *self = GS_UPDATES_SECTION (user_data); + g_autoptr(GError) error = NULL; + GsApp *app = NULL; + g_autoptr(GsPluginJob) plugin_job = NULL; + + /* get result */ + if (gs_utils_invoke_reboot_finish (source, res, &error)) + return; + + if (g_error_matches (error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) + g_debug ("Calling reboot had been cancelled"); + else if (error != NULL) + g_warning ("Calling reboot failed: %s", error->message); + + /* cancel trigger */ + app = gs_app_list_index (self->list, 0); + plugin_job = gs_plugin_job_newv (GS_PLUGIN_ACTION_UPDATE_CANCEL, + "app", app, + NULL); + gs_plugin_loader_job_process_async (self->plugin_loader, plugin_job, + gs_app_get_cancellable (app), + _cancel_trigger_failed_cb, + self); +} + +static gboolean +_all_offline_updates_downloaded (GsUpdatesSection *self) +{ + /* use the download size to figure out what is downloaded and what not */ + for (guint i = 0; i < gs_app_list_length (self->list); i++) { + GsApp *app = gs_app_list_index (self->list, i); + if (!gs_app_is_downloaded (app)) + return FALSE; + } + + return TRUE; +} + +static void +_update_buttons (GsUpdatesSection *self) +{ + /* operation in progress */ + if (self->cancellable != NULL) { + gtk_widget_set_sensitive (self->button_cancel, + !g_cancellable_is_cancelled (self->cancellable)); + gtk_stack_set_visible_child_name (GTK_STACK (self->button_stack), "cancel"); + gtk_widget_show (GTK_WIDGET (self->button_stack)); + return; + } + + if (self->kind == GS_UPDATES_SECTION_KIND_OFFLINE_FIRMWARE || + self->kind == GS_UPDATES_SECTION_KIND_OFFLINE) { + if (_all_offline_updates_downloaded (self)) + gtk_stack_set_visible_child_name (GTK_STACK (self->button_stack), "update"); + else + gtk_stack_set_visible_child_name (GTK_STACK (self->button_stack), "download"); + + gtk_widget_show (GTK_WIDGET (self->button_stack)); + /* TRANSLATORS: This is the button for installing all + * offline updates */ + gs_progress_button_set_label (GS_PROGRESS_BUTTON (self->button_update), _("Restart & Update")); + } else if (self->kind == GS_UPDATES_SECTION_KIND_ONLINE) { + gtk_stack_set_visible_child_name (GTK_STACK (self->button_stack), "update"); + gtk_widget_show (GTK_WIDGET (self->button_stack)); + /* TRANSLATORS: This is the button for upgrading all + * online-updatable applications */ + gs_progress_button_set_label (GS_PROGRESS_BUTTON (self->button_update), _("Update All")); + } else { + gtk_widget_hide (GTK_WIDGET (self->button_stack)); + } + +} + +static void +_perform_update_cb (GsPluginLoader *plugin_loader, GAsyncResult *res, gpointer user_data) +{ + g_autoptr(GError) error = NULL; + g_autoptr(GsUpdatesSectionUpdateHelper) helper = (GsUpdatesSectionUpdateHelper *) user_data; + GsUpdatesSection *self = helper->self; + + /* get the results */ + if (!gs_plugin_loader_job_action_finish (plugin_loader, res, &error)) { + if (!g_error_matches (error, GS_PLUGIN_ERROR, GS_PLUGIN_ERROR_CANCELLED) && + !g_error_matches (error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) { + GsApp *app = NULL; + + if (gs_app_list_length (self->list) == 1) + app = gs_app_list_index (self->list, 0); + + gs_plugin_loader_claim_error (plugin_loader, + NULL, + GS_PLUGIN_ACTION_UPDATE, + app, + TRUE, + error); + } + goto out; + } + + /* trigger reboot if any application was not updatable live */ + if (helper->do_reboot) { + gs_utils_invoke_reboot_async (NULL, _reboot_failed_cb, self); + + /* when we are not doing an offline update, show a notification + * if any application requires a reboot */ + } else if (helper->do_reboot_notification) { + gs_utils_reboot_notify (self->list, TRUE); + } + +out: + g_clear_object (&self->cancellable); + _update_buttons (self); +} + +static void +_button_cancel_clicked_cb (GsUpdatesSection *self) +{ + g_cancellable_cancel (self->cancellable); + _update_buttons (self); +} + +static void +_download_finished_cb (GObject *object, GAsyncResult *res, gpointer user_data) +{ + g_autoptr(GsUpdatesSection) self = (GsUpdatesSection *) user_data; + g_autoptr(GError) error = NULL; + g_autoptr(GsAppList) list = NULL; + + /* get result */ + list = gs_plugin_loader_job_process_finish (GS_PLUGIN_LOADER (object), 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 download updates: %s", error->message); + } + + g_clear_object (&self->cancellable); + _update_buttons (self); +} + +static void +_button_download_clicked_cb (GsUpdatesSection *self) +{ + g_autoptr(GCancellable) cancellable = g_cancellable_new (); + g_autoptr(GsPluginJob) plugin_job = NULL; + + g_set_object (&self->cancellable, cancellable); + plugin_job = gs_plugin_job_newv (GS_PLUGIN_ACTION_DOWNLOAD, + "list", self->list, + "refine-flags", GS_PLUGIN_REFINE_FLAGS_REQUIRE_SIZE, + "interactive", TRUE, + NULL); + gs_plugin_loader_job_process_async (self->plugin_loader, plugin_job, + self->cancellable, + (GAsyncReadyCallback) _download_finished_cb, + g_object_ref (self)); + _update_buttons (self); +} + +static void +_button_update_all_clicked_cb (GsUpdatesSection *self) +{ + g_autoptr(GCancellable) cancellable = g_cancellable_new (); + g_autoptr(GsPluginJob) plugin_job = NULL; + GsUpdatesSectionUpdateHelper *helper = g_new0 (GsUpdatesSectionUpdateHelper, 1); + + helper->self = g_object_ref (self); + + /* look at each app in turn */ + for (guint i = 0; i < gs_app_list_length (self->list); i++) { + GsApp *app = gs_app_list_index (self->list, i); + if (gs_app_get_state (app) == GS_APP_STATE_UPDATABLE) + helper->do_reboot = TRUE; + if (gs_app_has_quirk (app, GS_APP_QUIRK_NEEDS_REBOOT)) + helper->do_reboot_notification = TRUE; + } + + g_set_object (&self->cancellable, cancellable); + plugin_job = gs_plugin_job_newv (GS_PLUGIN_ACTION_UPDATE, + "list", self->list, + "interactive", TRUE, + "propagate-error", TRUE, + NULL); + gs_plugin_loader_job_process_async (self->plugin_loader, plugin_job, + self->cancellable, + (GAsyncReadyCallback) _perform_update_cb, + helper); + _update_buttons (self); +} + +static void +_setup_section_header (GsUpdatesSection *self) +{ + /* get labels and buttons for everything */ + switch (self->kind) { + case GS_UPDATES_SECTION_KIND_OFFLINE_FIRMWARE: + /* TRANSLATORS: This is the header for system firmware that + * requires a reboot to apply */ + gtk_label_set_label (GTK_LABEL (self->title), _("Integrated Firmware")); + break; + case GS_UPDATES_SECTION_KIND_OFFLINE: + /* TRANSLATORS: This is the header for offline OS and offline + * app updates that require a reboot to apply */ + gtk_label_set_label (GTK_LABEL (self->title), _("Requires Restart")); + break; + case GS_UPDATES_SECTION_KIND_ONLINE: + /* TRANSLATORS: This is the header for online runtime and + * app updates, typically flatpaks or snaps */ + gtk_label_set_label (GTK_LABEL (self->title), _("Application Updates")); + break; + case GS_UPDATES_SECTION_KIND_ONLINE_FIRMWARE: + /* TRANSLATORS: This is the header for device firmware that can + * be installed online */ + gtk_label_set_label (GTK_LABEL (self->title), _("Device Firmware")); + break; + default: + g_assert_not_reached (); + } +} + +static void +_app_row_activated_cb (GsUpdatesSection *self, GtkListBoxRow *row) +{ + GsApp *app = gs_app_row_get_app (GS_APP_ROW (row)); + GtkWidget *dialog; + g_autofree gchar *str = NULL; + + /* debug */ + str = gs_app_to_string (app); + g_debug ("%s", str); + + dialog = gs_update_dialog_new_for_app (self->plugin_loader, app); + gs_shell_modal_dialog_present (gs_page_get_shell (self->page), GTK_WINDOW (dialog)); +} + +static void +gs_updates_section_show (GtkWidget *widget) +{ + _update_buttons (GS_UPDATES_SECTION (widget)); + + GTK_WIDGET_CLASS (gs_updates_section_parent_class)->show (widget); +} + +static void +gs_updates_section_get_property (GObject *object, + guint prop_id, + GValue *value, + GParamSpec *pspec) +{ + GsUpdatesSection *self = GS_UPDATES_SECTION (object); + + switch ((GsUpdatesSectionProperty) prop_id) { + case PROP_IS_NARROW: + g_value_set_boolean (value, gs_updates_section_get_is_narrow (self)); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gs_updates_section_set_property (GObject *object, + guint prop_id, + const GValue *value, + GParamSpec *pspec) +{ + GsUpdatesSection *self = GS_UPDATES_SECTION (object); + + switch ((GsUpdatesSectionProperty) prop_id) { + case PROP_IS_NARROW: + gs_updates_section_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_updates_section_dispose (GObject *object) +{ + GsUpdatesSection *self = GS_UPDATES_SECTION (object); + + g_clear_object (&self->cancellable); + g_clear_object (&self->list); + g_clear_object (&self->plugin_loader); + g_clear_object (&self->sizegroup_name); + g_clear_object (&self->sizegroup_button_label); + g_clear_object (&self->sizegroup_button_image); + g_clear_object (&self->sizegroup_header); + self->button_download = NULL; + self->button_update = NULL; + self->button_cancel = NULL; + self->button_stack = NULL; + self->page = NULL; + + G_OBJECT_CLASS (gs_updates_section_parent_class)->dispose (object); +} + +static void +gs_updates_section_class_init (GsUpdatesSectionClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + + object_class->get_property = gs_updates_section_get_property; + object_class->set_property = gs_updates_section_set_property; + object_class->dispose = gs_updates_section_dispose; + widget_class->show = gs_updates_section_show; + + /** + * GsUpdatesSection:is-narrow: + * + * Whether the section is in narrow mode. + * + * In narrow mode, the section will take up less horizontal space, doing + * so by e.g. using icons rather than labels in buttons. 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); + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-updates-section.ui"); + + gtk_widget_class_bind_template_child (widget_class, GsUpdatesSection, button_cancel); + gtk_widget_class_bind_template_child (widget_class, GsUpdatesSection, button_download); + gtk_widget_class_bind_template_child (widget_class, GsUpdatesSection, button_stack); + gtk_widget_class_bind_template_child (widget_class, GsUpdatesSection, button_update); + gtk_widget_class_bind_template_child (widget_class, GsUpdatesSection, description); + gtk_widget_class_bind_template_child (widget_class, GsUpdatesSection, listbox); + gtk_widget_class_bind_template_child (widget_class, GsUpdatesSection, listbox_box); + gtk_widget_class_bind_template_child (widget_class, GsUpdatesSection, section_header); + gtk_widget_class_bind_template_child (widget_class, GsUpdatesSection, title); + gtk_widget_class_bind_template_callback (widget_class, _app_row_activated_cb); + gtk_widget_class_bind_template_callback (widget_class, _button_cancel_clicked_cb); + gtk_widget_class_bind_template_callback (widget_class, _button_download_clicked_cb); + gtk_widget_class_bind_template_callback (widget_class, _button_update_all_clicked_cb); + gtk_widget_class_bind_template_callback (widget_class, _listbox_keynav_failed_cb); +} + +void +gs_updates_section_set_size_groups (GsUpdatesSection *self, + GtkSizeGroup *name, + GtkSizeGroup *button_label, + GtkSizeGroup *button_image, + GtkSizeGroup *header) +{ + g_return_if_fail (GS_IS_UPDATES_SECTION (self)); + + g_set_object (&self->sizegroup_name, name); + g_set_object (&self->sizegroup_button_label, button_label); + g_set_object (&self->sizegroup_button_image, button_image); + g_set_object (&self->sizegroup_header, header); + + gs_progress_button_set_size_groups (GS_PROGRESS_BUTTON (self->button_cancel), button_label, button_image); + gs_progress_button_set_size_groups (GS_PROGRESS_BUTTON (self->button_download), button_label, button_image); + gs_progress_button_set_size_groups (GS_PROGRESS_BUTTON (self->button_update), button_label, button_image); + gtk_size_group_add_widget (self->sizegroup_header, self->section_header); +} + +static void +gs_updates_section_progress_notify_cb (GsAppList *list, + GParamSpec *pspec, + GsUpdatesSection *self) +{ + if (self->button_cancel == NULL) + return; + + gs_progress_button_set_progress (GS_PROGRESS_BUTTON (self->button_cancel), + gs_app_list_get_progress (list)); +} + +static void +gs_updates_section_app_state_changed_cb (GsAppList *list, + GsApp *in_app, + GsUpdatesSection *self) +{ + guint ii, len, busy = 0; + + len = gs_app_list_length (list); + + for (ii = 0; ii < len; ii++) { + GsApp *app = gs_app_list_index (list, ii); + GsAppState state = gs_app_get_state (app); + + if (state == GS_APP_STATE_INSTALLING || + state == GS_APP_STATE_REMOVING) { + busy++; + } + } + + gtk_widget_set_sensitive (self->button_update, busy < len); +} + +static void +gs_updates_section_init (GsUpdatesSection *self) +{ + gtk_widget_init_template (GTK_WIDGET (self)); + + self->list = gs_app_list_new (); + gs_app_list_add_flag (self->list, + GS_APP_LIST_FLAG_WATCH_APPS | + GS_APP_LIST_FLAG_WATCH_APPS_ADDONS | + GS_APP_LIST_FLAG_WATCH_APPS_RELATED); + g_signal_connect_object (self->list, "notify::progress", + G_CALLBACK (gs_updates_section_progress_notify_cb), + self, 0); + gtk_list_box_set_selection_mode (GTK_LIST_BOX (self->listbox), + GTK_SELECTION_NONE); + gtk_list_box_set_sort_func (GTK_LIST_BOX (self->listbox), + _list_sort_func, + self, NULL); +} + +/** + * gs_updates_section_get_is_narrow: + * @self: a #GsUpdatesSection + * + * Get the value of #GsUpdatesSection:is-narrow. + * + * Returns: %TRUE if the section is in narrow mode, %FALSE otherwise + * + * Since: 41 + */ +gboolean +gs_updates_section_get_is_narrow (GsUpdatesSection *self) +{ + g_return_val_if_fail (GS_IS_UPDATES_SECTION (self), FALSE); + + return self->is_narrow; +} + +/** + * gs_updates_section_set_is_narrow: + * @self: a #GsUpdatesSection + * @is_narrow: %TRUE to set the section in narrow mode, %FALSE otherwise + * + * Set the value of #GsUpdatesSection:is-narrow. + * + * Since: 41 + */ +void +gs_updates_section_set_is_narrow (GsUpdatesSection *self, gboolean is_narrow) +{ + g_return_if_fail (GS_IS_UPDATES_SECTION (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]); +} + +GsUpdatesSection * +gs_updates_section_new (GsUpdatesSectionKind kind, + GsPluginLoader *plugin_loader, + GsPage *page) +{ + GsUpdatesSection *self; + self = g_object_new (GS_TYPE_UPDATES_SECTION, NULL); + self->kind = kind; + self->plugin_loader = g_object_ref (plugin_loader); + self->page = page; + _setup_section_header (self); + + if (self->kind == GS_UPDATES_SECTION_KIND_ONLINE) { + g_signal_connect_object (self->list, "app-state-changed", + G_CALLBACK (gs_updates_section_app_state_changed_cb), + self, 0); + } + + return self; +} diff --git a/src/gs-updates-section.h b/src/gs-updates-section.h new file mode 100644 index 0000000..d558960 --- /dev/null +++ b/src/gs-updates-section.h @@ -0,0 +1,48 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2013 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2015-2017 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <gtk/gtk.h> + +#include "gs-app-list.h" +#include "gs-plugin-loader.h" +#include "gs-page.h" + +G_BEGIN_DECLS + +#define GS_TYPE_UPDATES_SECTION (gs_updates_section_get_type ()) + +G_DECLARE_FINAL_TYPE (GsUpdatesSection, gs_updates_section, GS, UPDATES_SECTION, GtkBox) + +typedef enum { + GS_UPDATES_SECTION_KIND_OFFLINE_FIRMWARE, + GS_UPDATES_SECTION_KIND_OFFLINE, + GS_UPDATES_SECTION_KIND_ONLINE, + GS_UPDATES_SECTION_KIND_ONLINE_FIRMWARE, + GS_UPDATES_SECTION_KIND_LAST +} GsUpdatesSectionKind; + +GsUpdatesSection *gs_updates_section_new (GsUpdatesSectionKind kind, + GsPluginLoader *plugin_loader, + GsPage *page); +GsAppList *gs_updates_section_get_list (GsUpdatesSection *self); +void gs_updates_section_add_app (GsUpdatesSection *self, + GsApp *app); +void gs_updates_section_remove_all (GsUpdatesSection *self); +void gs_updates_section_set_size_groups (GsUpdatesSection *self, + GtkSizeGroup *name, + GtkSizeGroup *button_label, + GtkSizeGroup *button_image, + GtkSizeGroup *header); +gboolean gs_updates_section_get_is_narrow (GsUpdatesSection *self); +void gs_updates_section_set_is_narrow (GsUpdatesSection *self, + gboolean is_narrow); + +G_END_DECLS diff --git a/src/gs-updates-section.ui b/src/gs-updates-section.ui new file mode 100644 index 0000000..3a1eac3 --- /dev/null +++ b/src/gs-updates-section.ui @@ -0,0 +1,109 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <requires lib="gtk+" version="3.0"/> + <template class="GsUpdatesSection" parent="GtkBox"> + <property name="orientation">vertical</property> + <style> + <class name="section"/> + </style> + <child> + <object class="GtkBox" id="section_header"> + <property name="spacing">12</property> + <child> + <object class="GtkLabel" id="title"> + <property name="can_focus">False</property> + <property name="ellipsize">end</property> + <property name="halign">start</property> + <property name="hexpand">True</property> + <property name="xalign">0</property> + <style> + <class name="heading"/> + </style> + </object> + </child> + <child> + <object class="GtkStack" id="button_stack"> + + <child> + <object class="GtkStackPage"> + <property name="name">download</property> + <property name="child"> + <object class="GsProgressButton" id="button_download"> + <property name="use_underline">True</property> + <property name="label" translatable="yes">_Download</property> + <signal name="clicked" handler="_button_download_clicked_cb" swapped="yes"/> + <style> + <class name="suggested-action"/> + </style> + </object> + </property> + </object> + </child> + + <child> + <object class="GtkStackPage"> + <property name="name">update</property> + <property name="child"> + <object class="GsProgressButton" id="button_update"> + <signal name="clicked" handler="_button_update_all_clicked_cb" swapped="yes"/> + <style> + <class name="suggested-action"/> + </style> + </object> + </property> + </object> + </child> + + <child> + <object class="GtkStackPage"> + <property name="name">cancel</property> + <property name="child"> + <object class="GsProgressButton" id="button_cancel"> + <property name="label" translatable="yes">Cancel</property> + <signal name="clicked" handler="_button_cancel_clicked_cb" swapped="yes"/> + <style> + <class name="install-progress"/> + </style> + </object> + </property> + </object> + </child> + + </object> + </child> + </object> + </child> + <child> + <object class="GtkLabel" id="description"> + <property name="visible">False</property> + <property name="can_focus">False</property> + <property name="halign">start</property> + <property name="wrap">True</property> + <property name="wrap-mode">word-char</property> + <property name="xalign">0</property> + <style> + <class name="dim-label"/> + </style> + </object> + </child> + <child> + <object class="GtkBox" id="listbox_box"> + <property name="orientation">vertical</property> + <accessibility> + <relation name="labelled-by">title</relation> + </accessibility> + <child> + <object class="GtkListBox" id="listbox"> + <property name="selection_mode">none</property> + <property name="valign">start</property> + <signal name="row-activated" handler="_app_row_activated_cb" swapped="yes"/> + <signal name="keynav-failed" handler="_listbox_keynav_failed_cb" swapped="yes"/> + <style> + <class name="boxed-list"/> + </style> + </object> + </child> + </object> + </child> + </template> +</interface> diff --git a/src/gs-upgrade-banner.c b/src/gs-upgrade-banner.c new file mode 100644 index 0000000..8a5e6a2 --- /dev/null +++ b/src/gs-upgrade-banner.c @@ -0,0 +1,374 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2016 Kalev Lember <klember@redhat.com> + * Copyright (C) 2016 Richard Hughes <richard@hughsie.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include <glib/gi18n.h> +#include <stdlib.h> + +#include "gs-upgrade-banner.h" +#include "gs-common.h" + +typedef struct +{ + GsApp *app; + + GtkWidget *box_upgrades_info; + GtkWidget *box_upgrades_download; + GtkWidget *box_upgrades_downloading; + GtkWidget *box_upgrades_install; + GtkWidget *button_upgrades_download; + GtkWidget *button_upgrades_install; + GtkWidget *button_upgrades_cancel; + GtkWidget *label_upgrades_summary; + GtkWidget *label_upgrades_title; + GtkWidget *label_download_info; + GtkWidget *label_upgrades_downloading; + GtkWidget *progressbar; + guint progress_pulse_id; + GtkCssProvider *banner_provider; /* (owned) (nullable) */ +} GsUpgradeBannerPrivate; + +G_DEFINE_TYPE_WITH_PRIVATE (GsUpgradeBanner, gs_upgrade_banner, ADW_TYPE_BIN) + +enum { + SIGNAL_DOWNLOAD_CLICKED, + SIGNAL_INSTALL_CLICKED, + SIGNAL_CANCEL_CLICKED, + SIGNAL_LAST +}; + +static guint signals [SIGNAL_LAST] = { 0 }; + +static gboolean +_pulse_cb (gpointer user_data) +{ + GsUpgradeBanner *self = GS_UPGRADE_BANNER (user_data); + GsUpgradeBannerPrivate *priv = gs_upgrade_banner_get_instance_private (self); + + gtk_progress_bar_pulse (GTK_PROGRESS_BAR (priv->progressbar)); + + return G_SOURCE_CONTINUE; +} + +static void +stop_progress_pulsing (GsUpgradeBanner *self) +{ + GsUpgradeBannerPrivate *priv = gs_upgrade_banner_get_instance_private (self); + + if (priv->progress_pulse_id != 0) { + g_source_remove (priv->progress_pulse_id); + priv->progress_pulse_id = 0; + } +} + +static void +gs_upgrade_banner_refresh (GsUpgradeBanner *self) +{ + GsUpgradeBannerPrivate *priv = gs_upgrade_banner_get_instance_private (self); + const gchar *uri, *summary, *version; + g_autofree gchar *str = NULL; + guint percentage; + GsSizeType size_download_type; + guint64 size_download_bytes; + + if (priv->app == NULL) + return; + + version = gs_app_get_version (priv->app); + + if (version != NULL && *version != '\0') { + /* TRANSLATORS: This is the text displayed when a distro + * upgrade is available. The first %s is the distro name + * and the 2nd %s is the version, e.g. "Fedora 35 Available" */ + str = g_strdup_printf (_("%s %s Available"), gs_app_get_name (priv->app), version); + } else { + /* TRANSLATORS: This is the text displayed when a distro + * upgrade is available. The %s is the distro name, + * e.g. "GNOME OS Available" */ + str = g_strdup_printf (_("%s Available"), gs_app_get_name (priv->app)); + } + gtk_label_set_text (GTK_LABEL (priv->label_upgrades_title), str); + + /* Normally a distro upgrade state goes from + * + * AVAILABLE (available to download) to + * INSTALLING (downloading packages for later installation) to + * UPDATABLE (packages are downloaded and upgrade is ready to go) + */ + switch (gs_app_get_state (priv->app)) { + case GS_APP_STATE_AVAILABLE: + case GS_APP_STATE_QUEUED_FOR_INSTALL: + gtk_widget_show (priv->box_upgrades_download); + gtk_widget_hide (priv->box_upgrades_downloading); + gtk_widget_hide (priv->box_upgrades_install); + break; + case GS_APP_STATE_INSTALLING: + gtk_widget_hide (priv->box_upgrades_download); + gtk_widget_show (priv->box_upgrades_downloading); + gtk_widget_hide (priv->box_upgrades_install); + break; + case GS_APP_STATE_UPDATABLE: + gtk_widget_hide (priv->box_upgrades_download); + gtk_widget_hide (priv->box_upgrades_downloading); + gtk_widget_show (priv->box_upgrades_install); + break; + default: + g_critical ("Unexpected app state ‘%s’ of app ‘%s’", + gs_app_state_to_string (gs_app_get_state (priv->app)), + gs_app_get_unique_id (priv->app)); + break; + } + + /* Hide the upgrade box until the app state is known. */ + gtk_widget_set_visible (GTK_WIDGET (self), + (gs_app_get_state (priv->app) != GS_APP_STATE_UNKNOWN)); + + /* Refresh the summary if we got anything better than the default blurb */ + summary = gs_app_get_summary (priv->app); + if (summary != NULL) + gtk_label_set_text (GTK_LABEL (priv->label_upgrades_summary), summary); + + uri = gs_app_get_url (priv->app, AS_URL_KIND_HOMEPAGE); + size_download_type = gs_app_get_size_download (priv->app, &size_download_bytes); + + if (uri != NULL) { + g_autofree gchar *link = NULL; + link = g_markup_printf_escaped ("<a href=\"%s\">%s</a>", uri, _("Learn about the new version")); + gtk_label_set_markup (GTK_LABEL (priv->label_download_info), link); + gtk_widget_show (priv->label_download_info); + } else if (size_download_type == GS_SIZE_TYPE_VALID && + size_download_bytes > 0) { + g_autofree gchar *tmp = NULL; + g_clear_pointer (&str, g_free); + tmp = g_format_size (size_download_bytes); + /* Translators: the '%s' is replaced with the download size, forming text like "2 GB download" */ + str = g_strdup_printf ("%s download", tmp); + gtk_label_set_text (GTK_LABEL (priv->label_download_info), str); + gtk_widget_show (priv->label_download_info); + } else { + gtk_widget_hide (priv->label_download_info); + } + + /* do a fill bar for the current progress */ + switch (gs_app_get_state (priv->app)) { + case GS_APP_STATE_INSTALLING: + percentage = gs_app_get_progress (priv->app); + if (percentage == GS_APP_PROGRESS_UNKNOWN) { + if (priv->progress_pulse_id == 0) + priv->progress_pulse_id = g_timeout_add (50, _pulse_cb, self); + + gtk_label_set_text (GTK_LABEL (priv->label_upgrades_downloading), _("Downloading…")); + break; + } else if (percentage <= 100) { + stop_progress_pulsing (self); + gtk_progress_bar_set_fraction (GTK_PROGRESS_BAR (priv->progressbar), + (gdouble) percentage / 100.f); + g_clear_pointer (&str, g_free); + + if (size_download_type == GS_SIZE_TYPE_VALID) { + g_autofree gchar *tmp = NULL; + g_autofree gchar *downloaded_tmp = NULL; + guint64 downloaded; + + downloaded = size_download_bytes * percentage / 100.0; + downloaded_tmp = g_format_size (downloaded); + tmp = g_format_size (size_download_bytes); + /* Translators: the first '%s' is replaced with the downloaded size, the second '%s' + with the total download size, forming text like "135 MB of 2 GB downloaded" */ + str = g_strdup_printf (_("%s of %s downloaded"), downloaded_tmp, tmp); + } else { + /* Translators: the '%u' is replaced with the actual percentage being already + downloaded, forming text like "13% downloaded" */ + str = g_strdup_printf (_("%u%% downloaded"), percentage); + } + gtk_label_set_text (GTK_LABEL (priv->label_upgrades_downloading), str); + break; + } + break; + default: + stop_progress_pulsing (self); + break; + } +} + +static gboolean +app_refresh_idle (gpointer user_data) +{ + GsUpgradeBanner *self = GS_UPGRADE_BANNER (user_data); + + gs_upgrade_banner_refresh (self); + + g_object_unref (self); + return G_SOURCE_REMOVE; +} + +static void +app_state_changed (GsApp *app, GParamSpec *pspec, GsUpgradeBanner *self) +{ + g_idle_add (app_refresh_idle, g_object_ref (self)); +} + +static void +app_progress_changed (GsApp *app, GParamSpec *pspec, GsUpgradeBanner *self) +{ + g_idle_add (app_refresh_idle, g_object_ref (self)); +} + +static void +download_button_cb (GtkWidget *widget, GsUpgradeBanner *self) +{ + g_signal_emit (self, signals[SIGNAL_DOWNLOAD_CLICKED], 0); +} + +static void +install_button_cb (GtkWidget *widget, GsUpgradeBanner *self) +{ + g_signal_emit (self, signals[SIGNAL_INSTALL_CLICKED], 0); +} + +static void +cancel_button_cb (GtkWidget *widget, GsUpgradeBanner *self) +{ + g_signal_emit (self, signals[SIGNAL_CANCEL_CLICKED], 0); +} + +void +gs_upgrade_banner_set_app (GsUpgradeBanner *self, GsApp *app) +{ + GsUpgradeBannerPrivate *priv = gs_upgrade_banner_get_instance_private (self); + const gchar *css; + g_autofree gchar *modified_css = NULL; + + g_return_if_fail (GS_IS_UPGRADE_BANNER (self)); + g_return_if_fail (GS_IS_APP (app) || app == NULL); + + if (priv->app) { + g_signal_handlers_disconnect_by_func (priv->app, app_state_changed, self); + g_signal_handlers_disconnect_by_func (priv->app, app_progress_changed, self); + } + + g_set_object (&priv->app, app); + if (!app) + return; + + g_signal_connect (priv->app, "notify::state", + G_CALLBACK (app_state_changed), self); + g_signal_connect (priv->app, "notify::progress", + G_CALLBACK (app_progress_changed), self); + + /* perhaps set custom css */ + css = gs_app_get_metadata_item (app, "GnomeSoftware::UpgradeBanner-css"); + modified_css = gs_utils_set_key_colors_in_css (css, app); + gs_utils_widget_set_css (priv->box_upgrades_info, &priv->banner_provider, "upgrade-banner-custom", modified_css); + + gs_upgrade_banner_refresh (self); +} + +GsApp * +gs_upgrade_banner_get_app (GsUpgradeBanner *self) +{ + GsUpgradeBannerPrivate *priv = gs_upgrade_banner_get_instance_private (self); + + g_return_val_if_fail (GS_IS_UPGRADE_BANNER (self), NULL); + + return priv->app; +} + +static void +gs_upgrade_banner_dispose (GObject *object) +{ + GsUpgradeBanner *self = GS_UPGRADE_BANNER (object); + GsUpgradeBannerPrivate *priv = gs_upgrade_banner_get_instance_private (self); + + stop_progress_pulsing (self); + + if (priv->app) { + g_signal_handlers_disconnect_by_func (priv->app, app_state_changed, self); + g_signal_handlers_disconnect_by_func (priv->app, app_progress_changed, self); + } + + g_clear_object (&priv->app); + g_clear_object (&priv->banner_provider); + + G_OBJECT_CLASS (gs_upgrade_banner_parent_class)->dispose (object); +} + +static void +gs_upgrade_banner_init (GsUpgradeBanner *self) +{ + GsUpgradeBannerPrivate *priv = gs_upgrade_banner_get_instance_private (self); + + gtk_widget_init_template (GTK_WIDGET (self)); + + g_signal_connect (priv->button_upgrades_download, "clicked", + G_CALLBACK (download_button_cb), + self); + g_signal_connect (priv->button_upgrades_install, "clicked", + G_CALLBACK (install_button_cb), + self); + g_signal_connect (priv->button_upgrades_cancel, "clicked", + G_CALLBACK (cancel_button_cb), + self); +} + +static void +gs_upgrade_banner_class_init (GsUpgradeBannerClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + GtkWidgetClass *widget_class = GTK_WIDGET_CLASS (klass); + + object_class->dispose = gs_upgrade_banner_dispose; + + signals [SIGNAL_DOWNLOAD_CLICKED] = + g_signal_new ("download-clicked", + G_TYPE_FROM_CLASS (object_class), G_SIGNAL_RUN_LAST, + G_STRUCT_OFFSET (GsUpgradeBannerClass, download_clicked), + NULL, NULL, g_cclosure_marshal_VOID__VOID, + G_TYPE_NONE, 0); + + signals [SIGNAL_INSTALL_CLICKED] = + g_signal_new ("install-clicked", + G_TYPE_FROM_CLASS (object_class), G_SIGNAL_RUN_LAST, + G_STRUCT_OFFSET (GsUpgradeBannerClass, install_clicked), + NULL, NULL, g_cclosure_marshal_VOID__VOID, + G_TYPE_NONE, 0); + + signals [SIGNAL_CANCEL_CLICKED] = + g_signal_new ("cancel-clicked", + G_TYPE_FROM_CLASS (object_class), G_SIGNAL_RUN_LAST, + G_STRUCT_OFFSET (GsUpgradeBannerClass, cancel_clicked), + NULL, NULL, g_cclosure_marshal_VOID__VOID, + G_TYPE_NONE, 0); + + gtk_widget_class_set_template_from_resource (widget_class, "/org/gnome/Software/gs-upgrade-banner.ui"); + + gtk_widget_class_bind_template_child_private (widget_class, GsUpgradeBanner, box_upgrades_info); + gtk_widget_class_bind_template_child_private (widget_class, GsUpgradeBanner, box_upgrades_download); + gtk_widget_class_bind_template_child_private (widget_class, GsUpgradeBanner, box_upgrades_downloading); + gtk_widget_class_bind_template_child_private (widget_class, GsUpgradeBanner, box_upgrades_install); + gtk_widget_class_bind_template_child_private (widget_class, GsUpgradeBanner, button_upgrades_download); + gtk_widget_class_bind_template_child_private (widget_class, GsUpgradeBanner, button_upgrades_install); + gtk_widget_class_bind_template_child_private (widget_class, GsUpgradeBanner, button_upgrades_cancel); + gtk_widget_class_bind_template_child_private (widget_class, GsUpgradeBanner, label_upgrades_summary); + gtk_widget_class_bind_template_child_private (widget_class, GsUpgradeBanner, label_upgrades_title); + gtk_widget_class_bind_template_child_private (widget_class, GsUpgradeBanner, label_download_info); + gtk_widget_class_bind_template_child_private (widget_class, GsUpgradeBanner, label_upgrades_downloading); + gtk_widget_class_bind_template_child_private (widget_class, GsUpgradeBanner, progressbar); +} + +GtkWidget * +gs_upgrade_banner_new (void) +{ + GsUpgradeBanner *self; + self = g_object_new (GS_TYPE_UPGRADE_BANNER, + "vexpand", FALSE, + NULL); + return GTK_WIDGET (self); +} diff --git a/src/gs-upgrade-banner.h b/src/gs-upgrade-banner.h new file mode 100644 index 0000000..7aff12b --- /dev/null +++ b/src/gs-upgrade-banner.h @@ -0,0 +1,35 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2016 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <adwaita.h> + +#include "gnome-software-private.h" + +G_BEGIN_DECLS + +#define GS_TYPE_UPGRADE_BANNER (gs_upgrade_banner_get_type ()) + +G_DECLARE_DERIVABLE_TYPE (GsUpgradeBanner, gs_upgrade_banner, GS, UPGRADE_BANNER, AdwBin) + +struct _GsUpgradeBannerClass +{ + AdwBinClass parent_class; + + void (*download_clicked) (GsUpgradeBanner *self); + void (*install_clicked) (GsUpgradeBanner *self); + void (*cancel_clicked) (GsUpgradeBanner *self); +}; + +GtkWidget *gs_upgrade_banner_new (void); +void gs_upgrade_banner_set_app (GsUpgradeBanner *self, + GsApp *app); +GsApp *gs_upgrade_banner_get_app (GsUpgradeBanner *self); + +G_END_DECLS diff --git a/src/gs-upgrade-banner.ui b/src/gs-upgrade-banner.ui new file mode 100644 index 0000000..3ea236b --- /dev/null +++ b/src/gs-upgrade-banner.ui @@ -0,0 +1,223 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <!-- interface-requires gtk+ 3.10 --> + <template class="GsUpgradeBanner" parent="AdwBin"> + <child> + <object class="GtkBox" id="vbox"> + <property name="orientation">vertical</property> + <property name="hexpand">True</property> + <property name="vexpand">True</property> + <property name="valign">center</property> + <property name="margin-top">12</property> + <property name="overflow">hidden</property> + <style> + <class name="card"/> + </style> + <child> + <object class="GtkBox" id="box_upgrades_info"> + <property name="orientation">vertical</property> + <property name="hexpand">True</property> + <property name="vexpand">True</property> + <property name="valign">center</property> + <style> + <class name="upgrade-banner-background"/> + </style> + <child> + <object class="GtkLabel" id="label_upgrades_title"> + <property name="margin-top">32</property> + <property name="margin_bottom">6</property> + <property name="wrap">True</property> + <property name="halign">center</property> + <property name="justify">center</property> + <!-- Just a placeholder; actual label text is set in code --> + <property name="label">GNOME 3.20 Now Available</property> + <attributes> + <attribute name="scale" value="2.0"/> + <attribute name="weight" value="ultrabold"/> + </attributes> + </object> + </child> + <child> + <object class="GtkLabel" id="label_upgrades_summary"> + <property name="label" translatable="yes">A major upgrade, with new features and added polish.</property> + <property name="wrap">True</property> + <property name="width-chars">40</property> + <property name="max-width-chars">48</property> + <property name="margin-bottom">32</property> + <property name="halign">center</property> + <property name="justify">center</property> + </object> + </child> + </object> + </child> + <child> + <object class="GtkBox"> + <property name="orientation">vertical</property> + <property name="hexpand">True</property> + <property name="vexpand">True</property> + + <child> + <object class="GtkBox" id="box_upgrades_download"> + <property name="orientation">vertical</property> + <property name="halign">center</property> + <property name="valign">end</property> + <property name="spacing">12</property> + <property name="margin-top">18</property> + <property name="margin-bottom">18</property> + <property name="margin-start">18</property> + <property name="margin-end">18</property> + + <child> + <object class="GtkButton" id="button_upgrades_download"> + <property name="label" translatable="yes">_Download</property> + <property name="width_request">150</property> + <property name="can_focus">True</property> + <property name="use_underline">True</property> + <property name="halign">center</property> + <property name="valign">center</property> + <property name="vexpand">True</property> + <style> + <class name="circular"/> + <class name="suggested-action"/> + </style> + </object> + </child> + <child> + <object class="GtkLabel" id="label_download_info"> + <property name="label"></property> <!-- set in the code --> + <property name="halign">center</property> + <property name="justify">center</property> + <attributes> + <attribute name="scale" value="0.8"/> + </attributes> + </object> + </child> + </object> + </child> + <child> + <object class="GtkBox" id="box_upgrades_downloading"> + <property name="orientation">horizontal</property> + <property name="halign">fill</property> + <property name="hexpand">True</property> + <property name="valign">end</property> + <property name="spacing">12</property> + <property name="margin-top">18</property> + <property name="margin-bottom">18</property> + <property name="margin-start">18</property> + <property name="margin-end">18</property> + <child> + <object class="GtkLabel" id="label_upgrades_downloading_spacer"> + <property name="label"></property> <!-- space-taker --> + <property name="halign">start</property> + <property name="margin-start">2</property> + <property name="margin-end">2</property> + </object> + </child> + <child> + <object class="GtkBox" id="hbox_upgrades_downloading"> + <property name="orientation">vertical</property> + <property name="halign">fill</property> + <property name="hexpand">True</property> + <property name="valign">center</property> + <property name="spacing">8</property> + <child> + <object class="GtkLabel" id="label_upgrades_downloading"> + <property name="label"></property> <!-- set in the code --> + <property name="halign">center</property> + <property name="valign">center</property> + <property name="vexpand">True</property> + <property name="justify">center</property> + <attributes> + <attribute name="scale" value="0.8"/> + <attribute name="font-features" value="tnum=1"/> + </attributes> + </object> + </child> + <child> + <object class="GtkProgressBar" id="progressbar"> + <property name="halign">center</property> + <property name="valign">center</property> + <property name="fraction">0.3</property> + <property name="width-request">340</property> + <style> + <class name="upgrade-progressbar"/> + </style> + </object> + </child> + </object> + </child> + + <child> + <object class="GtkButton" id="button_upgrades_cancel"> + <property name="can_focus">True</property> + <property name="use_underline">True</property> + <property name="halign">end</property> + <property name="hexpand">False</property> + <property name="valign">center</property> + <property name="margin-start">2</property> + <property name="margin-end">2</property> + <style> + <class name="circular"/> + </style> + <child> + <object class="GtkImage" id="button_upgrades_cancel_image"> + <property name="icon-name">window-close-symbolic</property> + <property name="icon-size">normal</property> + </object> + </child> + </object> + </child> + </object> + </child> + <child> + <object class="GtkBox" id="box_upgrades_install"> + <property name="orientation">vertical</property> + <property name="halign">center</property> + <property name="valign">end</property> + <property name="spacing">12</property> + <property name="margin-top">18</property> + <property name="margin-bottom">18</property> + <property name="margin-start">18</property> + <property name="margin-end">18</property> + <style> + <class name="upgrade-buttons"/> + </style> + <child> + <object class="GtkButton" id="button_upgrades_install"> + <property name="label" translatable="yes">_Restart & Upgrade</property> + <property name="name">button_upgrades_install</property> + <property name="can_focus">True</property> + <property name="use_underline">True</property> + <property name="halign">center</property> + <property name="valign">center</property> + <style> + <class name="circular"/> + <class name="suggested-action"/> + </style> + </object> + </child> + <child> + <object class="GtkLabel" id="label_upgrade_warning"> + <property name="label" translatable="yes">Remember to back up data and files before upgrading.</property> + <property name="halign">center</property> + <property name="justify">center</property> + <attributes> + <attribute name="scale" value="0.8"/> + </attributes> + </object> + </child> + </object> + </child> + </object> + </child> + </object> + </child> + </template> + <object class="GtkSizeGroup"> + <property name="mode">horizontal</property> + <widgets> + <widget name="label_upgrades_downloading_spacer"/> + <widget name="button_upgrades_cancel"/> + </widgets> + </object> +</interface> diff --git a/src/gs-vendor.c b/src/gs-vendor.c new file mode 100644 index 0000000..bc4bc27 --- /dev/null +++ b/src/gs-vendor.c @@ -0,0 +1,127 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2008 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2015 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#include "config.h" + +#include "gs-vendor.h" + +struct _GsVendor +{ + GObject parent_instance; + + GKeyFile *file; +}; + +G_DEFINE_TYPE (GsVendor, gs_vendor, G_TYPE_OBJECT) + +#ifdef HAVE_PACKAGEKIT +static const gchar * +gs_vendor_type_to_string (GsVendorUrlType type) +{ + if (type == GS_VENDOR_URL_TYPE_CODEC) + return "CodecUrl"; + if (type == GS_VENDOR_URL_TYPE_FONT) + return "FontUrl"; + if (type == GS_VENDOR_URL_TYPE_MIME) + return "MimeUrl"; + if (type == GS_VENDOR_URL_TYPE_HARDWARE) + return "HardwareUrl"; + return "DefaultUrl"; +} +#endif + +gchar * +gs_vendor_get_not_found_url (GsVendor *vendor, GsVendorUrlType type) +{ +#ifdef HAVE_PACKAGEKIT + const gchar *key; + gchar *url = NULL; + + /* get data */ + key = gs_vendor_type_to_string (type); + url = g_key_file_get_string (vendor->file, "PackagesNotFound", key, NULL); + + /* none is a special value */ + if (g_strcmp0 (url, "none") == 0) { + g_free (url); + url = NULL; + } + + /* got a valid URL */ + if (url != NULL) + goto out; + + /* default has no fallback */ + if (type == GS_VENDOR_URL_TYPE_DEFAULT) + goto out; + + /* get fallback data */ + g_debug ("using fallback"); + key = gs_vendor_type_to_string (GS_VENDOR_URL_TYPE_DEFAULT); + url = g_key_file_get_string (vendor->file, "PackagesNotFound", key, NULL); + + /* none is a special value */ + if (g_strcmp0 (url, "none") == 0) { + g_free (url); + url = NULL; + } +out: + g_debug ("url=%s", url); + return url; +#else + return NULL; +#endif +} + +static void +gs_vendor_init (GsVendor *vendor) +{ +#ifdef HAVE_PACKAGEKIT + g_autoptr(GError) local_error = NULL; + const gchar *fn = "/etc/PackageKit/Vendor.conf"; + gboolean ret; + + vendor->file = g_key_file_new (); + ret = g_key_file_load_from_file (vendor->file, fn, G_KEY_FILE_NONE, &local_error); + if (!ret && local_error && !g_error_matches (local_error, G_FILE_ERROR, G_FILE_ERROR_NOENT)) + g_warning ("Failed to read '%s': %s", fn, local_error->message); +#endif +} + +static void +gs_vendor_finalize (GObject *object) +{ + GsVendor *vendor = GS_VENDOR (object); + + if (vendor->file != NULL) + g_key_file_free (vendor->file); + + G_OBJECT_CLASS (gs_vendor_parent_class)->finalize (object); +} + +static void +gs_vendor_class_init (GsVendorClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + object_class->finalize = gs_vendor_finalize; +} + +/** + * gs_vendor_new: + * + * Return value: a new GsVendor object. + **/ +GsVendor * +gs_vendor_new (void) +{ + GsVendor *vendor; + vendor = g_object_new (GS_TYPE_VENDOR, NULL); + return GS_VENDOR (vendor); +} + diff --git a/src/gs-vendor.h b/src/gs-vendor.h new file mode 100644 index 0000000..68c2521 --- /dev/null +++ b/src/gs-vendor.h @@ -0,0 +1,33 @@ +/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*- + * vi:set noexpandtab tabstop=8 shiftwidth=8: + * + * Copyright (C) 2008 Richard Hughes <richard@hughsie.com> + * Copyright (C) 2015 Kalev Lember <klember@redhat.com> + * + * SPDX-License-Identifier: GPL-2.0+ + */ + +#pragma once + +#include <glib-object.h> + +G_BEGIN_DECLS + +#define GS_TYPE_VENDOR (gs_vendor_get_type ()) + +G_DECLARE_FINAL_TYPE (GsVendor, gs_vendor, GS, VENDOR, GObject) + +typedef enum +{ + GS_VENDOR_URL_TYPE_CODEC, + GS_VENDOR_URL_TYPE_FONT, + GS_VENDOR_URL_TYPE_MIME, + GS_VENDOR_URL_TYPE_HARDWARE, + GS_VENDOR_URL_TYPE_DEFAULT +} GsVendorUrlType; + +GsVendor *gs_vendor_new (void); +gchar *gs_vendor_get_not_found_url (GsVendor *vendor, + GsVendorUrlType type); + +G_END_DECLS diff --git a/src/meson.build b/src/meson.build new file mode 100644 index 0000000..e7f5c48 --- /dev/null +++ b/src/meson.build @@ -0,0 +1,312 @@ +cargs = ['-DG_LOG_DOMAIN="Gs"'] +cargs += ['-DLOCALPLUGINDIR=""'] + +resources_src = gnome.compile_resources( + 'gs-resources', + 'gnome-software.gresource.xml', + source_dir : '.', + c_name : 'gs' +) + +gdbus_src = gnome.gdbus_codegen( + 'gs-shell-search-provider-generated', + 'shell-search-provider-dbus-interfaces.xml', + interface_prefix : 'org.gnome.', + namespace : 'Gs' +) + +enums = gnome.mkenums_simple('gs-enums', + sources : [ + 'gs-context-dialog-row.h', + ], + install_header : false, +) + +gnome_software_sources = [ + 'gs-age-rating-context-dialog.c', + 'gs-app-addon-row.c', + 'gs-app-reviews-dialog.c', + 'gs-app-version-history-dialog.c', + 'gs-app-version-history-row.c', + 'gs-application.c', + 'gs-app-context-bar.c', + 'gs-app-details-page.c', + 'gs-app-row.c', + 'gs-app-tile.c', + 'gs-app-translation-dialog.c', + 'gs-basic-auth-dialog.c', + 'gs-category-page.c', + 'gs-category-tile.c', + 'gs-common.c', + 'gs-context-dialog-row.c', + 'gs-css.c', + 'gs-description-box.c', + 'gs-details-page.c', + 'gs-extras-page.c', + 'gs-feature-tile.c', + 'gs-featured-carousel.c', + 'gs-hardware-support-context-dialog.c', + 'gs-info-bar.c', + 'gs-info-window.c', + 'gs-installed-page.c', + 'gs-language.c', + 'gs-layout-manager.c', + 'gs-license-tile.c', + 'gs-loading-page.c', + 'gs-lozenge.c', + 'gs-main.c', + 'gs-metered-data-dialog.c', + 'gs-moderate-page.c', + 'gs-overview-page.c', + 'gs-origin-popover-row.c', + 'gs-os-update-page.c', + 'gs-page.c', + 'gs-prefs-dialog.c', + 'gs-progress-button.c', + 'gs-removal-dialog.c', + 'gs-repos-dialog.c', + 'gs-repos-section.c', + 'gs-repo-row.c', + 'gs-review-bar.c', + 'gs-review-dialog.c', + 'gs-review-histogram.c', + 'gs-review-row.c', + 'gs-safety-context-dialog.c', + 'gs-screenshot-carousel.c', + 'gs-screenshot-image.c', + 'gs-search-page.c', + 'gs-shell.c', + 'gs-shell-search-provider.c', + 'gs-star-image.c', + 'gs-star-widget.c', + 'gs-storage-context-dialog.c', + 'gs-summary-tile.c', + 'gs-update-dialog.c', + 'gs-update-list.c', + 'gs-update-monitor.c', + 'gs-updates-page.c', + 'gs-updates-section.c', + 'gs-upgrade-banner.c', + 'gs-vendor.c' +] + +gnome_software_dependencies = [ + appstream, + gio_unix, + glib, + gmodule, + gtk, + json_glib, + libgnomesoftware_dep, + libadwaita, + libm, + libsoup, + libxmlb, +] + +if get_option('packagekit') + gnome_software_sources += [ + 'gs-dbus-helper.c', + ] + gnome_software_sources += gnome.gdbus_codegen( + 'gs-packagekit-generated', + 'org.freedesktop.PackageKit.xml', + interface_prefix : 'org.freedesktop.', + namespace : 'Gs' + ) + gnome_software_sources += gnome.gdbus_codegen( + 'gs-packagekit-modify2-generated', + 'org.freedesktop.PackageKit.Modify2.xml', + interface_prefix : 'org.freedesktop.', + namespace : 'Gs' + ) + gnome_software_dependencies += [packagekit] +endif + +if gsettings_desktop_schemas.found() + gnome_software_dependencies += [gsettings_desktop_schemas] +endif + +if get_option('mogwai') + gnome_software_dependencies += [mogwai_schedule_client] +endif + +executable( + 'gnome-software', + resources_src, + gdbus_src, + sources : gnome_software_sources + enums, + include_directories : [ + include_directories('..'), + include_directories('../lib'), + ], + dependencies : gnome_software_dependencies, + c_args : cargs, + install : true, + install_dir : get_option('bindir'), + install_rpath : gs_private_libdir, +) + +executable( + 'gnome-software-restarter', + sources : 'gs-restarter.c', + include_directories : [ + include_directories('..'), + ], + dependencies : [ + gio_unix, + glib, + ], + c_args : cargs, + install : true, + install_dir : get_option('libexecdir') +) + +# no quoting +cdata = configuration_data() +cdata.set('bindir', join_paths(get_option('prefix'), + get_option('bindir'))) +if (get_option('apt')) + cdata.set('apthandler', 'x-scheme-handler/apt;') +else + cdata.set('apthandler', '') +endif +if (get_option('snap')) + cdata.set('snaphandler', 'x-scheme-handler/snap;') +else + cdata.set('snaphandler', '') +endif +cdata.set('application_id', application_id) + +# replace @bindir@ +configure_file( + input : 'org.gnome.Software.service.in', + output : application_id + '.service', + install_dir: join_paths(get_option('datadir'), 'dbus-1/services'), + configuration : cdata +) + +i18n.merge_file( + input: + # replace mime-type handlers + configure_file( + input : 'org.gnome.Software.desktop.in', + output : 'org.gnome.Software.desktop.tmp', + configuration : cdata + ), + output: application_id + '.desktop', + type: 'desktop', + po_dir: join_paths(meson.project_source_root(), 'po'), + install: true, + install_dir: join_paths(get_option('datadir'), 'applications') +) + +if get_option('flatpak') + i18n.merge_file( + input: 'gnome-software-local-file-flatpak.desktop.in', + output: 'gnome-software-local-file-flatpak.desktop', + type: 'desktop', + po_dir: join_paths(meson.project_source_root(), 'po'), + install: true, + install_dir: join_paths(get_option('datadir'), 'applications') + ) +endif + +if get_option('fwupd') + i18n.merge_file( + input: 'gnome-software-local-file-fwupd.desktop.in', + output: 'gnome-software-local-file-fwupd.desktop', + type: 'desktop', + po_dir: join_paths(meson.project_source_root(), 'po'), + install: true, + install_dir: join_paths(get_option('datadir'), 'applications') + ) +endif + +if get_option('packagekit') or get_option('rpm_ostree') + i18n.merge_file( + input: 'gnome-software-local-file-packagekit.desktop.in', + output: 'gnome-software-local-file-packagekit.desktop', + type: 'desktop', + po_dir: join_paths(meson.project_source_root(), 'po'), + install: true, + install_dir: join_paths(get_option('datadir'), 'applications') + ) +endif + +if get_option('snap') + i18n.merge_file( + input: 'gnome-software-local-file-snap.desktop.in', + output: 'gnome-software-local-file-snap.desktop', + type: 'desktop', + po_dir: join_paths(meson.project_source_root(), 'po'), + install: true, + install_dir: join_paths(get_option('datadir'), 'applications') + ) +endif + +install_data('org.gnome.Software-search-provider.ini', + install_dir : 'share/gnome-shell/search-providers') + +if get_option('man') + xsltproc = find_program('xsltproc') + custom_target('manfile-gnome-software', + input: 'gnome-software.xml', + output: 'gnome-software.1', + install: true, + install_dir: join_paths(get_option('mandir'), 'man1'), + command: [ + xsltproc, + '--nonet', + '--stringparam', 'man.output.quietly', '1', + '--stringparam', 'funcsynopsis.style', 'ansi', + '--stringparam', 'man.th.extra1.suppress', '1', + '--stringparam', 'man.authors.section.enabled', '0', + '--stringparam', 'man.copyright.section.enabled', '0', + '-o', '@OUTPUT@', + 'http://docbook.sourceforge.net/release/xsl/current/manpages/docbook.xsl', + '@INPUT@' + ] + ) +endif + +if get_option('packagekit') + # replace @bindir@ + configure_file( + input : 'org.freedesktop.PackageKit.service.in', + output : 'org.freedesktop.PackageKit.service', + install_dir: join_paths(get_option('datadir'), 'dbus-1', 'services'), + configuration : cdata + ) +endif + +if get_option('tests') + cargs += ['-DTESTDATADIR="' + join_paths(meson.current_source_dir(), '..', 'data') + '"'] + e = executable( + 'gs-self-test-src', + compiled_schemas, + sources : [ + 'gs-css.c', + 'gs-common.c', + 'gs-self-test.c', + ], + include_directories : [ + include_directories('..'), + include_directories('../lib'), + ], + dependencies : [ + appstream, + gio_unix, + glib, + gmodule, + gsettings_desktop_schemas, + gtk, + json_glib, + libgnomesoftware_dep, + libm, + libsoup, + ], + c_args : cargs + ) + test('gs-self-test-src', e, suite: ['plugins', 'src'], env: test_env) +endif diff --git a/src/org.freedesktop.PackageKit.Modify2.xml b/src/org.freedesktop.PackageKit.Modify2.xml new file mode 100644 index 0000000..1e8fb7d --- /dev/null +++ b/src/org.freedesktop.PackageKit.Modify2.xml @@ -0,0 +1,560 @@ +<!DOCTYPE node PUBLIC +"-//freedesktop//DTD D-BUS Object Introspection 1.0//EN" +"http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd" [ + <!ENTITY ERROR_GENERAL "org.freedesktop.PackageKit.Denied"> +]> +<node name="/" xmlns:doc="http://www.freedesktop.org/dbus/1.0/doc.dtd"> + + <interface name="org.freedesktop.PackageKit.Modify2"> + <doc:doc> + <doc:description> + <doc:para> + The interface used for modifying the package database (version 2). + </doc:para> + </doc:description> + </doc:doc> + + <!--*****************************************************************************************--> + <method name="InstallPackageFiles"> + <annotation name="org.freedesktop.DBus.GLib.Async" value=""/> + <doc:doc> + <doc:description> + <doc:para> + Installs local package files or service packs. + </doc:para> + </doc:description> + </doc:doc> + <arg type="as" name="files" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + An array of file names. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="s" name="interaction" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + An interaction mode that specifies which UI elements should be shown + or hidden different from the user default, e.g. + <doc:tt>hide-confirm-search,hide-confirm-deps,hide-confirm-install,show-progress</doc:tt>. + The show options are: + <doc:tt>show-confirm-search,show-confirm-deps,show-confirm-install,show-progress,show-finished,show-warning</doc:tt>. + The hide options are: + <doc:tt>hide-confirm-search,hide-confirm-deps,hide-confirm-install,hide-progress,hide-finished,hide-warning</doc:tt>. + Convenience options such as: + <doc:tt>never</doc:tt>, <doc:tt>defaults</doc:tt> or <doc:tt>always</doc:tt>. + are also available. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="s" name="desktop_id" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + The desktop file ID of the calling application. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="a{sv}" name="platform_data" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + Additional platform specific data, in the form of GVariant + dictionary mapping strings to variants. As the name indicates, + the platform data may vary depending on the operating system. + Currently recognized keys are: + <doc:tt>desktop-startup-id</doc:tt>: startup notification + identifier that is used to transfer focus to the software installer application; + if the startup notification id is not available, this can be just "_TIMEeventtime", + where eventtime is the time stamp from the event triggering the call. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + </method> + + <!--*****************************************************************************************--> + <method name="InstallProvideFiles"> + <annotation name="org.freedesktop.DBus.GLib.Async" value=""/> + <doc:doc> + <doc:description> + <doc:para> + Installs packages to provide files. + </doc:para> + </doc:description> + </doc:doc> + <arg type="as" name="files" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + An array of file names. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="s" name="interaction" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + An optional interaction mode, e.g. + <doc:tt>show-confirm-search,show-confirm-deps,show-confirm-install,show-progress,show-finished,show-warning</doc:tt> + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="s" name="desktop_id" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + The desktop file ID of the calling application. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="a{sv}" name="platform_data" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + Additional platform specific data, in the form of GVariant + dictionary mapping strings to variants. As the name indicates, + the platform data may vary depending on the operating system. + Currently recognized keys are: + <doc:tt>desktop-startup-id</doc:tt>: startup notification + identifier that is used to transfer focus to the software installer application; + if the startup notification id is not available, this can be just "_TIMEeventtime", + where eventtime is the time stamp from the event triggering the call. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + </method> + + <!--*****************************************************************************************--> + <method name="InstallPackageNames"> + <annotation name="org.freedesktop.DBus.GLib.Async" value=""/> + <doc:doc> + <doc:description> + <doc:para> + Installs packages from a configured software source. + </doc:para> + </doc:description> + </doc:doc> + <arg type="as" name="packages" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + An array of package names. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="s" name="interaction" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + An optional interaction mode, e.g. + <doc:tt>show-confirm-search,show-confirm-deps,show-confirm-install,show-progress,show-finished,show-warning</doc:tt> + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="s" name="desktop_id" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + The desktop file ID of the calling application. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="a{sv}" name="platform_data" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + Additional platform specific data, in the form of GVariant + dictionary mapping strings to variants. As the name indicates, + the platform data may vary depending on the operating system. + Currently recognized keys are: + <doc:tt>desktop-startup-id</doc:tt>: startup notification + identifier that is used to transfer focus to the software installer application; + if the startup notification id is not available, this can be just "_TIMEeventtime", + where eventtime is the time stamp from the event triggering the call. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + </method> + + <!--*****************************************************************************************--> + <method name="InstallMimeTypes"> + <annotation name="org.freedesktop.DBus.GLib.Async" value=""/> + <doc:doc> + <doc:description> + <doc:para> + Installs mimetype handlers from a configured software source. + </doc:para> + </doc:description> + </doc:doc> + <arg type="as" name="mime_types" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + An array of mime types, e.g. <doc:tt>text/plain</doc:tt> + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="s" name="interaction" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + An optional interaction mode, e.g. + <doc:tt>show-confirm-search,show-confirm-deps,show-confirm-install,show-progress,show-finished,show-warning</doc:tt> + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="s" name="desktop_id" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + The desktop file ID of the calling application. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="a{sv}" name="platform_data" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + Additional platform specific data, in the form of GVariant + dictionary mapping strings to variants. As the name indicates, + the platform data may vary depending on the operating system. + Currently recognized keys are: + <doc:tt>desktop-startup-id</doc:tt>: startup notification + identifier that is used to transfer focus to the software installer application; + if the startup notification id is not available, this can be just "_TIMEeventtime", + where eventtime is the time stamp from the event triggering the call. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + </method> + + <!--*****************************************************************************************--> + <method name="InstallFontconfigResources"> + <annotation name="org.freedesktop.DBus.GLib.Async" value=""/> + <doc:doc> + <doc:description> + <doc:para> + Installs fontconfig resources (usually fonts) from a configured software source. + </doc:para> + </doc:description> + </doc:doc> + <arg type="as" name="resources" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + An array of font descriptors from fontconfig, e.g. <doc:tt>:lang=mn</doc:tt> + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="s" name="interaction" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + An optional interaction mode, e.g. + <doc:tt>show-confirm-search,show-confirm-deps,show-confirm-install,show-progress,show-finished,show-warning</doc:tt> + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="s" name="desktop_id" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + The desktop file ID of the calling application. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="a{sv}" name="platform_data" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + Additional platform specific data, in the form of GVariant + dictionary mapping strings to variants. As the name indicates, + the platform data may vary depending on the operating system. + Currently recognized keys are: + <doc:tt>desktop-startup-id</doc:tt>: startup notification + identifier that is used to transfer focus to the software installer application; + if the startup notification id is not available, this can be just "_TIMEeventtime", + where eventtime is the time stamp from the event triggering the call. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + </method> + + <!--*****************************************************************************************--> + <method name="InstallGStreamerResources"> + <annotation name="org.freedesktop.DBus.GLib.Async" value=""/> + <doc:doc> + <doc:description> + <doc:para> + Installs GStreamer fontconfig resources (usually codecs) from a configured software source. + </doc:para> + </doc:description> + </doc:doc> + <arg type="as" name="resources" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + An array of codecs descriptors from <doc:tt>pk-gstreamer-install</doc:tt>, e.g. + <doc:tt>Advanced Streaming Format (ASF) demuxer|decoder-video/x-ms-asf</doc:tt> + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="s" name="interaction" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + An optional interaction mode, e.g. + <doc:tt>show-confirm-search,show-confirm-deps,show-confirm-install,show-progress,show-finished,show-warning</doc:tt> + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="s" name="desktop_id" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + The desktop file ID of the calling application. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="a{sv}" name="platform_data" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + Additional platform specific data, in the form of GVariant + dictionary mapping strings to variants. As the name indicates, + the platform data may vary depending on the operating system. + Currently recognized keys are: + <doc:tt>desktop-startup-id</doc:tt>: startup notification + identifier that is used to transfer focus to the software installer application; + if the startup notification id is not available, this can be just "_TIMEeventtime", + where eventtime is the time stamp from the event triggering the call. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + </method> + + <!--*****************************************************************************************--> + <method name="InstallResources"> + <annotation name="org.freedesktop.DBus.GLib.Async" value=""/> + <doc:doc> + <doc:description> + <doc:para> + Installs resources of a given type from a configured software source. + </doc:para> + </doc:description> + </doc:doc> + <arg type="s" name="type" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + The type of resource to request, e.g. <doc:tt>plasma-service</doc:tt> + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="as" name="resources" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + An array of resource descriptors + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="s" name="interaction" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + An optional interaction mode, e.g. + <doc:tt>show-confirm-search,show-confirm-deps,show-confirm-install,show-progress,show-finished,show-warning</doc:tt> + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="s" name="desktop_id" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + The desktop file ID of the calling application. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="a{sv}" name="platform_data" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + Additional platform specific data, in the form of GVariant + dictionary mapping strings to variants. As the name indicates, + the platform data may vary depending on the operating system. + Currently recognized keys are: + <doc:tt>desktop-startup-id</doc:tt>: startup notification + identifier that is used to transfer focus to the software installer application; + if the startup notification id is not available, this can be just "_TIMEeventtime", + where eventtime is the time stamp from the event triggering the call. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + </method> + + <!--*****************************************************************************************--> + <method name="RemovePackageByFiles"> + <annotation name="org.freedesktop.DBus.GLib.Async" value=""/> + <doc:doc> + <doc:description> + <doc:para> + Removes packages that provide the given local files. + </doc:para> + </doc:description> + </doc:doc> + <arg type="as" name="files" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + An array of file names. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="s" name="interaction" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + An interaction mode that specifies which UI elements should be shown + or hidden different from the user default, e.g. + <doc:tt>hide-confirm-search,hide-confirm-deps,hide-confirm-install,show-progress</doc:tt>. + The show options are: + <doc:tt>show-confirm-search,show-confirm-deps,show-confirm-install,show-progress,show-finished,show-warning</doc:tt>. + The hide options are: + <doc:tt>hide-confirm-search,hide-confirm-deps,hide-confirm-install,hide-progress,hide-finished,hide-warning</doc:tt>. + Convenience options such as: + <doc:tt>never</doc:tt>, <doc:tt>defaults</doc:tt> or <doc:tt>always</doc:tt>. + are also available. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="s" name="desktop_id" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + The desktop file ID of the calling application. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="a{sv}" name="platform_data" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + Additional platform specific data, in the form of GVariant + dictionary mapping strings to variants. As the name indicates, + the platform data may vary depending on the operating system. + Currently recognized keys are: + <doc:tt>desktop-startup-id</doc:tt>: startup notification + identifier that is used to transfer focus to the software installer application; + if the startup notification id is not available, this can be just "_TIMEeventtime", + where eventtime is the time stamp from the event triggering the call. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + </method> + + <method name="InstallPrinterDrivers"> + <annotation name="org.freedesktop.DBus.GLib.Async" value=""/> + <doc:doc> + <doc:description> + <doc:para> + Installs printer drivers from a configured software source. + </doc:para> + </doc:description> + </doc:doc> + <arg type="as" name="resources" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + An array of printer model descriptors in IEEE 1284 + Device ID format, + e.g. <doc:tt>MFG:Hewlett-Packard;MDL:HP LaserJet + 6MP;</doc:tt>. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="s" name="interaction" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + An optional interaction mode, e.g. + <doc:tt>show-confirm-search,show-confirm-deps,show-confirm-install,show-progress,show-finished,show-warning</doc:tt> + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="s" name="desktop_id" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + The desktop file ID of the calling application. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="a{sv}" name="platform_data" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + Additional platform specific data, in the form of GVariant + dictionary mapping strings to variants. As the name indicates, + the platform data may vary depending on the operating system. + Currently recognized keys are: + <doc:tt>desktop-startup-id</doc:tt>: startup notification + identifier that is used to transfer focus to the software installer application; + if the startup notification id is not available, this can be just "_TIMEeventtime", + where eventtime is the time stamp from the event triggering the call. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + </method> + + <property name="DisplayName" type="s" access="read"> + <doc:doc> + <doc:description> + <doc:para> + Translated, human readable name of the program implementing the interface, e.g. 'Software' for gnome-software. + </doc:para> + </doc:description> + </doc:doc> + </property> + + </interface> +</node> diff --git a/src/org.freedesktop.PackageKit.service.in b/src/org.freedesktop.PackageKit.service.in new file mode 100644 index 0000000..54c0466 --- /dev/null +++ b/src/org.freedesktop.PackageKit.service.in @@ -0,0 +1,3 @@ +[D-BUS Service] +Name=org.freedesktop.PackageKit +Exec=@bindir@/gnome-software --gapplication-service diff --git a/src/org.freedesktop.PackageKit.xml b/src/org.freedesktop.PackageKit.xml new file mode 100644 index 0000000..c70cac3 --- /dev/null +++ b/src/org.freedesktop.PackageKit.xml @@ -0,0 +1,506 @@ +<!DOCTYPE node PUBLIC +"-//freedesktop//DTD D-BUS Object Introspection 1.0//EN" +"http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd" [ + <!ENTITY ERROR_GENERAL "org.freedesktop.PackageKit.Denied"> +]> +<node name="/" xmlns:doc="http://www.freedesktop.org/dbus/1.0/doc.dtd"> + + <interface name="org.freedesktop.PackageKit.Query"> + <doc:doc> + <doc:description> + <doc:para> + The interface used for querying the package database. + </doc:para> + </doc:description> + </doc:doc> + + <!--*****************************************************************************************--> + <method name="IsInstalled"> + <annotation name="org.freedesktop.DBus.GLib.Async" value=""/> + <doc:doc> + <doc:description> + <doc:para> + Finds out if the package is installed. + </doc:para> + </doc:description> + </doc:doc> + <arg type="s" name="package_name" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + A package name, e.g. <doc:tt>hal-info</doc:tt> + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="s" name="interaction" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + An optional interaction mode, e.g. + <doc:tt>timeout=10</doc:tt> + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="b" name="installed" direction="out"> + <doc:doc> + <doc:summary> + <doc:para> + If the package is installed. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + </method> + + <!--*****************************************************************************************--> + <method name="SearchFile"> + <annotation name="org.freedesktop.DBus.GLib.Async" value=""/> + <doc:doc> + <doc:description> + <doc:para> + Finds the package name for an installed or available file + </doc:para> + </doc:description> + </doc:doc> + <arg type="s" name="file_name" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + A package name, e.g. <doc:tt>/usr/share/help/gimp/index.html</doc:tt> + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="s" name="interaction" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + An optional interaction mode, e.g. + <doc:tt>timeout=10</doc:tt> + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="b" name="installed" direction="out"> + <doc:doc> + <doc:summary> + <doc:para> + If the package is installed. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="s" name="package_name" direction="out"> + <doc:doc> + <doc:summary> + <doc:para> + The package name of the file, e.g. <doc:tt>hal-info</doc:tt> + </doc:para> + </doc:summary> + </doc:doc> + </arg> + </method> + </interface> + + <!-- ######################################################################################### --> + <interface name="org.freedesktop.PackageKit.Modify"> + <doc:doc> + <doc:description> + <doc:para> + The interface used for modifying the package database. + </doc:para> + </doc:description> + </doc:doc> + + <!--*****************************************************************************************--> + <method name="InstallPackageFiles"> + <annotation name="org.freedesktop.DBus.GLib.Async" value=""/> + <doc:doc> + <doc:description> + <doc:para> + Installs local package files or service packs. + </doc:para> + </doc:description> + </doc:doc> + <arg type="u" name="xid" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + The X window handle ID, used for focus stealing prevention and setting modality. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="as" name="files" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + An array of file names. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="s" name="interaction" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + An interaction mode that specifies which UI elements should be shown + or hidden different from the user default, e.g. + <doc:tt>hide-confirm-search,hide-confirm-deps,hide-confirm-install,show-progress</doc:tt>. + The show options are: + <doc:tt>show-confirm-search,show-confirm-deps,show-confirm-install,show-progress,show-finished,show-warning</doc:tt>. + The hide options are: + <doc:tt>hide-confirm-search,hide-confirm-deps,hide-confirm-install,hide-progress,hide-finished,hide-warning</doc:tt>. + Convenience options such as: + <doc:tt>never</doc:tt>, <doc:tt>defaults</doc:tt> or <doc:tt>always</doc:tt>. + are also available. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + </method> + + <!--*****************************************************************************************--> + <method name="InstallProvideFiles"> + <annotation name="org.freedesktop.DBus.GLib.Async" value=""/> + <doc:doc> + <doc:description> + <doc:para> + Installs packages to provide files. + </doc:para> + </doc:description> + </doc:doc> + <arg type="u" name="xid" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + The X window handle ID, used for focus stealing prevention and setting modality. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="as" name="files" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + An array of file names. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="s" name="interaction" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + An optional interaction mode, e.g. + <doc:tt>show-confirm-search,show-confirm-deps,show-confirm-install,show-progress,show-finished,show-warning</doc:tt> + </doc:para> + </doc:summary> + </doc:doc> + </arg> + </method> + + <!--*****************************************************************************************--> + <method name="InstallPackageNames"> + <annotation name="org.freedesktop.DBus.GLib.Async" value=""/> + <doc:doc> + <doc:description> + <doc:para> + Installs packages from a configured software source. + </doc:para> + </doc:description> + </doc:doc> + <arg type="u" name="xid" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + The X window handle ID, used for focus stealing prevention and setting modality. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="as" name="packages" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + An array of package names. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="s" name="interaction" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + An optional interaction mode, e.g. + <doc:tt>show-confirm-search,show-confirm-deps,show-confirm-install,show-progress,show-finished,show-warning</doc:tt> + </doc:para> + </doc:summary> + </doc:doc> + </arg> + </method> + + <!--*****************************************************************************************--> + <method name="InstallMimeTypes"> + <annotation name="org.freedesktop.DBus.GLib.Async" value=""/> + <doc:doc> + <doc:description> + <doc:para> + Installs mimetype handlers from a configured software source. + </doc:para> + </doc:description> + </doc:doc> + <arg type="u" name="xid" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + The X window handle ID, used for focus stealing prevention and setting modality. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="as" name="mime_types" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + An array of mime types, e.g. <doc:tt>text/plain</doc:tt> + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="s" name="interaction" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + An optional interaction mode, e.g. + <doc:tt>show-confirm-search,show-confirm-deps,show-confirm-install,show-progress,show-finished,show-warning</doc:tt> + </doc:para> + </doc:summary> + </doc:doc> + </arg> + </method> + + <!--*****************************************************************************************--> + <method name="InstallFontconfigResources"> + <annotation name="org.freedesktop.DBus.GLib.Async" value=""/> + <doc:doc> + <doc:description> + <doc:para> + Installs fontconfig resources (usually fonts) from a configured software source. + </doc:para> + </doc:description> + </doc:doc> + <arg type="u" name="xid" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + The X window handle ID, used for focus stealing prevention and setting modality. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="as" name="resources" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + An array of font descriptors from fontconfig, e.g. <doc:tt>:lang=mn</doc:tt> + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="s" name="interaction" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + An optional interaction mode, e.g. + <doc:tt>show-confirm-search,show-confirm-deps,show-confirm-install,show-progress,show-finished,show-warning</doc:tt> + </doc:para> + </doc:summary> + </doc:doc> + </arg> + </method> + + <!--*****************************************************************************************--> + <method name="InstallGStreamerResources"> + <annotation name="org.freedesktop.DBus.GLib.Async" value=""/> + <doc:doc> + <doc:description> + <doc:para> + Installs GStreamer fontconfig resources (usually codecs) from a configured software source. + </doc:para> + </doc:description> + </doc:doc> + <arg type="u" name="xid" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + The X window handle ID, used for focus stealing prevention and setting modality. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="as" name="resources" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + An array of codecs descriptors from <doc:tt>pk-gstreamer-install</doc:tt>, e.g. + <doc:tt>Advanced Streaming Format (ASF) demuxer|decoder-video/x-ms-asf</doc:tt> + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="s" name="interaction" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + An optional interaction mode, e.g. + <doc:tt>show-confirm-search,show-confirm-deps,show-confirm-install,show-progress,show-finished,show-warning</doc:tt> + </doc:para> + </doc:summary> + </doc:doc> + </arg> + </method> + + <!--*****************************************************************************************--> + <method name="InstallResources"> + <annotation name="org.freedesktop.DBus.GLib.Async" value=""/> + <doc:doc> + <doc:description> + <doc:para> + Installs resources of a given type from a configured software source. + </doc:para> + </doc:description> + </doc:doc> + <arg type="u" name="xid" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + The X window handle ID, used for focus stealing prevention and setting modality. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="s" name="type" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + The type of resource to request, e.g. <doc:tt>plasma-service</doc:tt> + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="as" name="resources" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + An array of resource descriptors + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="s" name="interaction" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + An optional interaction mode, e.g. + <doc:tt>show-confirm-search,show-confirm-deps,show-confirm-install,show-progress,show-finished,show-warning</doc:tt> + </doc:para> + </doc:summary> + </doc:doc> + </arg> + </method> + + <!--*****************************************************************************************--> + <method name="RemovePackageByFiles"> + <annotation name="org.freedesktop.DBus.GLib.Async" value=""/> + <doc:doc> + <doc:description> + <doc:para> + Removes packages that provide the given local files. + </doc:para> + </doc:description> + </doc:doc> + <arg type="u" name="xid" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + The X window handle ID, used for focus stealing prevention and setting modality. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="as" name="files" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + An array of file names. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="s" name="interaction" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + An interaction mode that specifies which UI elements should be shown + or hidden different from the user default, e.g. + <doc:tt>hide-confirm-search,hide-confirm-deps,hide-confirm-install,show-progress</doc:tt>. + The show options are: + <doc:tt>show-confirm-search,show-confirm-deps,show-confirm-install,show-progress,show-finished,show-warning</doc:tt>. + The hide options are: + <doc:tt>hide-confirm-search,hide-confirm-deps,hide-confirm-install,hide-progress,hide-finished,hide-warning</doc:tt>. + Convenience options such as: + <doc:tt>never</doc:tt>, <doc:tt>defaults</doc:tt> or <doc:tt>always</doc:tt>. + are also available. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + </method> + + <method name="InstallPrinterDrivers"> + <annotation name="org.freedesktop.DBus.GLib.Async" value=""/> + <doc:doc> + <doc:description> + <doc:para> + Installs printer drivers from a configured software source. + </doc:para> + </doc:description> + </doc:doc> + <arg type="u" name="xid" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + The X window handle ID, used for focus stealing prevention and setting modality. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="as" name="resources" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + An array of printer model descriptors in IEEE 1284 + Device ID format, + e.g. <doc:tt>MFG:Hewlett-Packard;MDL:HP LaserJet + 6MP;</doc:tt>. + </doc:para> + </doc:summary> + </doc:doc> + </arg> + <arg type="s" name="interaction" direction="in"> + <doc:doc> + <doc:summary> + <doc:para> + An optional interaction mode, e.g. + <doc:tt>show-confirm-search,show-confirm-deps,show-confirm-install,show-progress,show-finished,show-warning</doc:tt> + </doc:para> + </doc:summary> + </doc:doc> + </arg> + </method> + </interface> +</node> + diff --git a/src/org.gnome.Software-search-provider.ini b/src/org.gnome.Software-search-provider.ini new file mode 100644 index 0000000..7d8f809 --- /dev/null +++ b/src/org.gnome.Software-search-provider.ini @@ -0,0 +1,5 @@ +[Shell Search Provider] +DesktopId=org.gnome.Software.desktop +BusName=org.gnome.Software +ObjectPath=/org/gnome/Software/SearchProvider +Version=2 diff --git a/src/org.gnome.Software.desktop.in b/src/org.gnome.Software.desktop.in new file mode 100644 index 0000000..ff5802a --- /dev/null +++ b/src/org.gnome.Software.desktop.in @@ -0,0 +1,17 @@ +[Desktop Entry] +Name=Software +Comment=Add, remove or update software on this computer +# Translators: Do NOT translate or transliterate this text (this is an icon file name)! +Icon=@application_id@ +Exec=gnome-software %U +Terminal=false +Type=Application +Categories=GNOME;GTK;System;PackageManager; +# Translators: Search terms to find this application. Do NOT translate or localize the semicolons! The list MUST also end with a semicolon! +Keywords=Updates;Upgrade;Sources;Repositories;Preferences;Install;Uninstall;Program;Software;App;Store; +StartupNotify=true +MimeType=x-scheme-handler/appstream;@apthandler@@snaphandler@ +X-GNOME-UsesNotifications=true +DBusActivatable=true +# Translators: Do NOT translate or transliterate this text (these are enum types)! +X-Purism-FormFactor=Workstation;Mobile; diff --git a/src/org.gnome.Software.service.in b/src/org.gnome.Software.service.in new file mode 100644 index 0000000..5c39f0c --- /dev/null +++ b/src/org.gnome.Software.service.in @@ -0,0 +1,3 @@ +[D-BUS Service] +Name=@application_id@ +Exec=@bindir@/gnome-software --gapplication-service diff --git a/src/shell-search-provider-dbus-interfaces.xml b/src/shell-search-provider-dbus-interfaces.xml new file mode 100644 index 0000000..f6840e2 --- /dev/null +++ b/src/shell-search-provider-dbus-interfaces.xml @@ -0,0 +1,44 @@ +<!DOCTYPE node PUBLIC +"-//freedesktop//DTD D-BUS Object Introspection 1.0//EN" +"http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd"> + +<!-- + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General + Public License along with this library; if not, see <http://www.gnu.org/licenses/>. +--> +<node name="/" xmlns:doc="http://www.freedesktop.org/dbus/1.0/doc.dtd"> + <interface name='org.gnome.Shell.SearchProvider2'> + <method name='GetInitialResultSet'> + <arg type='as' name='Terms' direction='in' /> + <arg type='as' name='Results' direction='out' /> + </method> + <method name = 'GetSubsearchResultSet'> + <arg type='as' name='PreviousResults' direction='in' /> + <arg type='as' name='Terms' direction='in' /> + <arg type='as' name='Results' direction='out' /> + </method> + <method name = 'GetResultMetas'> + <arg type='as' name='Results' direction='in' /> + <arg type='aa{sv}' name='Metas' direction='out' /> + </method> + <method name = 'ActivateResult'> + <arg type='s' name='Result' direction='in' /> + <arg type='as' name='Terms' direction='in' /> + <arg type='u' name='Timestamp' direction='in' /> + </method> + <method name = 'LaunchSearch'> + <arg type='as' name='Terms' direction='in' /> + <arg type='u' name='Timestamp' direction='in' /> + </method> + </interface> +</node> diff --git a/src/style-dark.css b/src/style-dark.css new file mode 100644 index 0000000..4c9ba8c --- /dev/null +++ b/src/style-dark.css @@ -0,0 +1,114 @@ +.upgrade-banner-background { + background: linear-gradient(to bottom, @green_5, @blue_5); +} + +.context-tile-lozenge.green, +.context-tile-lozenge.details-rating-0 { + color: @green_1; + background-color: alpha(@green_4, .25); +} + +.context-tile-lozenge.blue, +.context-tile-lozenge.details-rating-5 { + color: @blue_1; +} + +.context-tile-lozenge.yellow, +.context-tile-lozenge.details-rating-12 { + color: @yellow_2; + background: alpha(#cd9309, .25); +} + +.context-tile-lozenge.details-rating-15 { + color: @orange_1; +} + +.context-tile-lozenge.red, +.context-tile-lozenge.details-rating-18 { + color: #ff7b63; +} + +/* Dark styling for specific category buttons. */ +.category-tile.category-create { + background: linear-gradient(180deg, #9141ac 0%, #2D5AA8 100%); + color: white; +} +.category-tile.category-create:hover { + background: linear-gradient(180deg, shade(#9141ac, 1.07) 0%, shade(#2D5AA8, 1.1) 100%); +} +.category-tile.category-create:active { + background: linear-gradient(180deg, shade(#9141ac, .95) 0%, shade(#2D5AA8, .95) 100%); +} + +.category-tile.category-develop { + background: #4E4C54; + color: white; +} +.category-tile.category-develop:hover { + background: shade(#4E4C54, 1.2); +} +.category-tile.category-develop:active { + background-color: shade(#4E4C54, .95); +} + +.category-tile.category-learn { + background: linear-gradient(180deg, #28AA6E 30%, #1E7E52 100%); + color: white; +} +.category-tile.category-learn:hover { + background: linear-gradient(180deg, shade(#28AA6E, 1.06) 30%, shade(#1E7E52, 1.06) 100%); +} +.category-tile.category-learn:active { + background: linear-gradient(180deg, shade(#28AA6E, .95) 30%, shade(#1E7E52, .95) 100%); +} + +.category-tile.category-play { + background: linear-gradient(75deg, #E9CF90 0%, #C8499C 50%, #4B35BA 100%); + color: #393484; +} +.category-tile.category-play:hover { + background: linear-gradient(75deg, shade(#E9CF90, 1.07) 0%, shade(#C8499C, 1.07) 50%, shade(#4B35BA, 1.07) 100%); +} +.category-tile.category-play:active { + background: linear-gradient(75deg, shade(#E9CF90, .97) 0%, shade(#C8499C, .95) 50%, shade(#4B35BA, 1.07) 100%); +} + +.category-tile.category-socialize { + background: linear-gradient(90deg, #CC307F 0%, #DD6C62 100%); + color: alpha(black, 0.75); +} +.category-tile.category-socialize:hover { + background: linear-gradient(90deg, shade(#CC307F, 1.08) 0%, shade(#DD6C62, 1.08) 100%); +} +.category-tile.category-socialize:active { + background: linear-gradient(90deg, shade(#CC307F, .95) 0%, shade(#DD6C62, .95) 100%); +} + +.category-tile.category-work { + padding: 1px; /* FIXME: work around https://gitlab.gnome.org/GNOME/gtk/-/issues/4324 */ + color: #1c71d8; + background-color:#ECDDA8; + background-image: linear-gradient(#C2C1BE 1px, transparent 1px), + linear-gradient(90deg, #C2C1BE 1px, transparent 1px); + background-size: 10px 10px, 10px 10px; + background-position: -1px -4px, center -1px; +} +.category-tile.category-work:hover { + background-color: shade(#ECDDA8, 1.03); + background-image: linear-gradient(shade(#C2C1BE, 1.04) 1px, transparent 1px), + linear-gradient(90deg, shade(#C2C1BE, 1.04) 1px, transparent 1px); +} +.category-tile.category-work:active { + background-color: shade(#ECDDA8, .93); + background-image: linear-gradient(shade(#C2C1BE, .97) 1px, transparent 1px), + linear-gradient(90deg, shade(#C2C1BE, .97) 1px, transparent 1px); +} + +star-image { + color: @yellow_1; +} + +.review-histogram star-image { + /* Specificity bump */ + color: alpha(@theme_fg_color, .4); +} diff --git a/src/style-hc.css b/src/style-hc.css new file mode 100644 index 0000000..767cbe8 --- /dev/null +++ b/src/style-hc.css @@ -0,0 +1,38 @@ +.installed-overlay-box { + text-shadow: none; +} + +.category-tile:not(.category-tile-iconless) { + box-shadow: inset 0 0 0 1px alpha(@card_fg_color, .5); +} + +.application-details-infobar.info { + background-color: #d3d7cf; + border-color: darker(#d3d7cf); +} + +.application-details-infobar.warning { + color: @theme_fg_color; +} + +review-bar, +.review-histogram star-image { + color: alpha(@theme_fg_color, .8); +} + +star-image { + color: inherit; +} + +.origin-rounded-box { + box-shadow: inset 0 0 0 1px currentColor; +} + +.context-tile-lozenge { + box-shadow: inset 0 0 0 2px currentColor; +} + +app-context-bar .context-tile { + border-color: @borders; + box-shadow: none; +} diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..b366208 --- /dev/null +++ b/src/style.css @@ -0,0 +1,580 @@ +.details-page { + margin: 24px 0px; +} + +.installed-overlay-box { + font-size: smaller; + background-color: @accent_bg_color; + border-radius: 0; + color: @accent_fg_color; + text-shadow: 0 1px 0 rgba(0,0,0,0.5); +} + +screenshot-carousel box.frame { + border-width: 1px 0; +} + +screenshot-carousel button, +.featured-carousel button { + margin: 12px; +} + +.screenshot-image-main .image1, .screenshot-image-main .image2 { + margin-top: 6px; + margin-bottom: 12px; + margin-left: 6px; + margin-right: 6px; +} + +.app-tile-label { + font-size: 105%; +} + +.review-textbox { + padding: 6px; +} + +.origin-rounded-box { + background-color: alpha(currentColor, .15); + border-radius: 999px; + padding: 4px; +} + +.origin-beta { + color: @warning_color; +} + +.origin-button > button { + padding: 2px 8px; +} + +/* This mimicks the style of list and row from Adwaita, and of list.content from + * Libhandy. */ + +.category-tile { + /* We have to remove the padding: 160px - 2*10px = 140px */ + min-width: 140px; + padding: 20px 10px; + font-weight: 900; + font-size: larger; +} + +clamp.medium .category-tile:not(.category-tile-iconless) { + font-size: large; +} + +clamp.large .category-tile:not(.category-tile-iconless) { + font-size: larger; +} + +.category-tile.category-tile-iconless { + /* We have to remove the padding: 160px - 2*15px = 130px */ + min-width: 130px; + padding: 10px 15px; + font-size: 105%; + font-weight: normal; +} + +/* Styling for specific category buttons. */ +.category-tile.category-create { + background: linear-gradient(180deg, #ce8cd7 0%, #2861c6 100%); + color: white; +} +.category-tile.category-create:hover { + background: linear-gradient(180deg, shade(#ce8cd7, 1.07) 0%, shade(#2861c6, 1.1) 100%); +} +.category-tile.category-create:active { + background: linear-gradient(180deg, shade(#ce8cd7, .95) 0%, shade(#2861c6, .95) 100%); +} + +.category-tile.category-develop { + background: #5e5c64; + color: white; +} +.category-tile.category-develop:hover { + background: shade(#5e5c64, 1.2); +} +.category-tile.category-develop:active { + background-color: shade(#5e5c64, .95); +} + +.category-tile.category-learn { + background: linear-gradient(180deg, #2ec27e 30%, #27a66c 100%); + color: white; +} +.category-tile.category-learn:hover { + background: linear-gradient(180deg, shade(#2ec27e, 1.06) 30%, shade(#27a66c, 1.06) 100%); +} +.category-tile.category-learn:active { + background: linear-gradient(180deg, shade(#2ec27e, .95) 30%, shade(#27a66c, .95) 100%); +} + +.category-tile.category-play { + background: linear-gradient(75deg, #f9e2a7 0%, #eb5ec3 50%, #6d53e0 100%); + color: #393484; +} +.category-tile.category-play:hover { + background: linear-gradient(75deg, shade(#f9e2a7, 1.07) 0%, shade(#eb5ec3, 1.07) 50%, shade(#6d53e0, 1.07) 100%); +} +.category-tile.category-play:active { + background: linear-gradient(75deg, shade(#f9e2a7, .97) 0%, shade(#eb5ec3, .95) 50%, shade(#6d53e0, 1.07) 100%); +} + +.category-tile.category-socialize { + background: linear-gradient(90deg, #ef4e9b 0%, #f77466 100%); + color: alpha(black, 0.7); +} +.category-tile.category-socialize:hover { + background: linear-gradient(90deg, shade(#ef4e9b, 1.08) 0%, shade(#f77466, 1.08) 100%); +} +.category-tile.category-socialize:active { + background: linear-gradient(90deg, shade(#ef4e9b, .95) 0%, shade(#f77466, .95) 100%); +} + +.category-tile.category-work { + padding: 1px; /* FIXME: work around https://gitlab.gnome.org/GNOME/gtk/-/issues/4324 */ + color: #1c71d8; + background-color:#fdf8d7; + background-image: linear-gradient(#deddda 1px, transparent 1px), + linear-gradient(90deg, #deddda 1px, transparent 1px); + background-size: 10px 10px, 10px 10px; + background-position: -1px -4px, center -1px; +} +.category-tile.category-work:hover { + background-color: shade(#fdf8d7, 1.03); + background-image: linear-gradient(shade(#deddda, 1.04) 1px, transparent 1px), + linear-gradient(90deg, shade(#deddda, 1.04) 1px, transparent 1px); +} +.category-tile.category-work:active { + background-color: shade(#fdf8d7, .93); + background-image: linear-gradient(shade(#deddda, .97) 1px, transparent 1px), + linear-gradient(90deg, shade(#deddda, .97) 1px, transparent 1px); +} + +/* The rest of the featured-tile CSS is loaded at runtime in gs-feature-tile.c */ +.featured-tile { + all: unset; + padding: 0; + box-shadow: none; + color: @theme_fg_color; +} + +.featured-tile label.title-1 { + margin-top: 6px; + margin-bottom: 6px; +} + +.featured-tile.narrow label.title-1 { + font-size: 16pt; /* 80% of .title-1 */ +} + +.application-details-infobar.info { + background-color: shade(@theme_bg_color, 0.9); + color: @theme_fg_color; + border-color: darker(shade(@theme_bg_color, 0.9)); + border-style: solid; + border-width: 1px; +} + +.application-details-infobar { + background-color: shade(@theme_bg_color, 0.9); + color: @theme_fg_color; + border-color: darker(shade(@theme_bg_color, 0.9)); + border-style: solid; + border-width: 1px; +} + +.application-details-infobar.warning { + background-color: #fcaf3e; + color: #2e3436; + border-color: darker(#fcaf3e); + border-style: solid; + border-width: 1px; +} + +@keyframes install-progress-unknown-move { + 0% { background-position: 0%; } + 50% { background-position: 100%; } + 100% { background-position: 0%; } +} + +.application-details-description .button { + padding-left:24px; + padding-right:24px; +} + +.install-progress { + background-image: linear-gradient(to top, @accent_bg_color 2px, alpha(@accent_bg_color, 0) 2px); + background-repeat: no-repeat; + background-position: 0 bottom; + background-size: 0; + transition: none; +} + +.install-progress:dir(rtl) { background-position: 100% bottom; } + +.review-row > * { + margin: 12px; +} + +.review-row button { font-size: smaller; } + +.review-row .vote-buttons button { + margin-right: -1px; +} + +/* this is the separator between yes and no vote buttons, gtk+ 3.20 only */ +.review-row .vote-buttons button:not(:first-child) { + border-image: linear-gradient(to top, @borders, @borders) 0 0 0 1 / 5px 0 5px 1px; +} + +.review-row .vote-buttons button:hover, +.review-row .vote-buttons button:active, +.review-row .vote-buttons button:hover + button, +.review-row .vote-buttons button:active + button { + border-image: none; +} + +review-bar { + color: alpha(@theme_fg_color, .4); + background-image: none; + background-color: alpha(currentColor, .5); +} + +.review-histogram star-image { + color: alpha(@theme_fg_color, .4); +} + +.version-arrow-label { + font-size: x-small; +} + +.overview-more-button { + font-size: smaller; + padding: 0px 15px; +} + +.app-row-origin-text { + font-size: smaller; +} + +.app-listbox-header { + padding: 6px; + border-bottom: 1px solid @borders; +} + +.image-list { + background-color: transparent; +} + +box.star { + background-color: transparent; + background-image: none; +} + +button.star { + outline-offset: 0; + background-color: transparent; + background-image: none; + border-image: none; + border-radius: 0; + border-width: 0px; + padding: 0; + box-shadow: none; + outline-offset: -1px; +} + +/* i have no idea why GTK adds padding here */ +flowboxchild { + padding: 0px; +} + +star-image { + color: @yellow_5; +} + +.dimmer-label { + opacity: 0.25; +} + +.update-failed-details { + font-family: Monospace; + font-size: smaller; + padding: 16px; +} + +.upgrade-banner { + padding: 0px; + border-radius: 8px; + border: none; +} + +.upgrade-banner-background { + background: linear-gradient(to bottom, @green_3, @blue_3); + color: white; +} + +.upgrade-buttons #button_upgrades_install { + padding-left: 16px; + padding-right: 16px; +} + +/* The following style are taken from libhandy's AdwPreferencesPage style, which + * implements the style for titled lists of lists. + * FIXME: Drop these styles if the pages using it are ported to + * AdwPreferencesPage or its successor in Libadwaita, if their clamp size can be + * set as a property. */ + +scrolledwindow.list-page > viewport > clamp > box { + margin: 24px 12px; + border-spacing: 24px; +} + +/* Increase the spacing in the Update Preferences window between the label and + * the listbox. */ + +.update-preferences preferencesgroup > box > box { + margin-top: 18px; +} + +/* The following style is taken from libhandy's AdwPreferencesGroup style, which + * implements the style for titled and described sections with a list box. + * FIXME: Drop this style if we use the successor of AdwPreferencesGroup in + * Libadwaita when porting to GTK 4. */ + +.section > label:not(:first-child) { margin-top: 6px; } + +.section > box:not(:first-child) { margin-top: 12px; } + +/* The following style is taken from libhandy's AdwStatusPage style. + * FIXME: Drop this style if AdwStatusPage or its GTK 4 successor allows setting + * a spinner and the updates spinner page can be ported to it. */ + +clamp.status-page { + margin: 36px 12px; +} + +clamp.status-page .iconbox { + min-height: 128px; + min-width: 128px; +} + +clamp.status-page .icon { + color: alpha(@theme_fg_color, 0.5); + min-height: 32px; + min-width: 32px; +} + +clamp.status-page .icon:not(:last-child) { + margin-bottom: 36px; +} + +clamp.status-page .title:not(:last-child) { + margin-bottom: 12px; +} + +app-context-bar .context-tile { + border: 1px solid @card_shade_color; + background-color: transparent; + border-radius: 0; + padding: 24px 12px 21px 12px; + outline-offset: 5px; + transition-property: outline, outline-offset, background-image; + border-bottom: none; + border-right: none; +} + +app-context-bar .context-tile:hover { + background-image: image(alpha(currentColor, .03)); +} + +app-context-bar .context-tile.keyboard-activating, +app-context-bar .context-tile:active { + background-image: image(alpha(currentColor, .08)); +} + +app-context-bar .context-tile:focus:focus-visible { + outline-offset: -1px; +} + +app-context-bar box:first-child .context-tile:first-child { + border-top-left-radius: 12px; +} + +app-context-bar.horizontal box:last-child .context-tile:last-child, +app-context-bar.vertical box:first-child .context-tile:last-child { + border-top-right-radius: 12px; +} + +app-context-bar.horizontal box:first-child .context-tile:first-child, +app-context-bar.vertical box:last-child .context-tile:first-child { + border-bottom-left-radius: 12px; +} + +app-context-bar box:last-child .context-tile:last-child { + border-bottom-right-radius: 12px; +} + +app-context-bar.horizontal box:first-child .context-tile:first-child, +app-context-bar.vertical .context-tile:first-child { + border-left: none; +} + +app-context-bar.horizontal .context-tile, +app-context-bar.vertical box:first-child .context-tile { + border-top: none; +} + +.context-tile-lozenge { + font-size: 18px; + font-weight: bold; + border-radius: 99999px; + padding: 9px 11px; + min-width: 18px; /* 40px minus the left and right padding */ + min-height: 22px; /* 40px minus the top and bottom padding */ +} + +.context-tile-lozenge.large { + font-size: 24px; + padding: 15px 18px; + min-width: 24px; /* 60px minus the left and right padding */ + min-height: 30px; /* 60px minus the top and bottom padding */ +} + +.context-tile-lozenge.wide-image image { + /* GtkImage always renders image square, so if we want an image which + * is wide, but still the same height as all the others, we have to + * use this hack to make it zero-height and vertically centred. The + * vertical size group ensures that it does still actually have a + * height. */ + margin-top: -28px; + margin-bottom: -28px; +} + +.context-tile-lozenge image { -gtk-icon-style: symbolic; } + +.context-tile-lozenge.grey { + color: alpha(@window_fg_color, .75); + background-color: alpha(@window_fg_color, .15); +} + +.context-tile-lozenge.green, +.context-tile-lozenge.details-rating-0 { + color: @green_5; + background-color: alpha(@green_3, .25); +} + +.context-tile-lozenge.blue, +.context-tile-lozenge.details-rating-5 { + color: @blue_4; + background-color: alpha(@blue_3, .25); +} + +.context-tile-lozenge.yellow, +.context-tile-lozenge.details-rating-12 { + color: #ae7b03; + background: alpha(@yellow_5, .25); +} + +.context-tile-lozenge.details-rating-15 { + color: @orange_5; + background-color: alpha(@orange_4, .25); +} + +.context-tile-lozenge.red, +.context-tile-lozenge.details-rating-18 { + color: @red_4; + background-color: alpha(@red_2, .25); +} + +.eol-red { + font-weight: bold; + color: #ab3342; +} + +window.narrow .app-title { + font-size: 16pt; +} + +window.narrow .app-developer { + font-size: small; +} + +.install-progress-label { + font-size: smaller; + font-feature-settings: "tnum"; +} + +/* FIXME: These are needed in the updates page until we can use AdwStatusPage + * again. See the note in gs-updates-page.ui. */ +scrolledwindow.fake-adw-status-page > viewport > box { margin: 36px 12px; } +scrolledwindow.fake-adw-status-page > viewport > box > clamp:not(:last-child) > box { margin-bottom: 36px; } +scrolledwindow.fake-adw-status-page > viewport > box > clamp > box > .icon:not(:last-child) { margin-bottom: 36px; } +scrolledwindow.fake-adw-status-page > viewport > box > clamp > box > .title:not(:last-child) { margin-bottom: 12px; } + +statuspage.icon-dropshadow image.icon { + /* This copies the style of .icon-dropshadow from Adwaita. */ + -gtk-icon-shadow: 0 1px 12px rgba(0,0,0,0.05), + 0 -1px rgba(0,0,0,0.05), + 1px 0 rgba(0,0,0,0.1), + 0 1px rgba(0,0,0,0.3), + -1px 0 rgba(0,0,0,0.1); +} + +window.info scrollbar.vertical { + /* The size a typical headerbar takes: 46px + 1px for the bottom border. */ + margin-top: 47px; + + /* Revelant for scrollbars without .overlay-indicator. */ + background: none; + box-shadow: none; +} + +window.info scrollbar.vertical trough { + /* The size a typical headerbar takes: 46px + 1px for the bottom border. */ + margin-top: 0; +} + +/************ + * GsAppRow * + ************/ + +row.app > box.header { + margin-left: 12px; + margin-right: 12px; +} + +row.app > box.header { + border-spacing: 12px; +} + +row.app > box.header > image { + margin-top: 12px; + margin-bottom: 12px; +} + +row.app label.warning { + color: @error_color; +} + +/************** + * GtkSpinner * + **************/ + +/* Ensure the spinner is hidden before the animation is triggered. */ +@keyframes pre-delay { + from { opacity: 0; } + to { opacity: 0; } +} + +/* We don't use the opacity CSS property because it's used by the spinner and we + * want to leave it untouched. */ +@keyframes fade-in { + from { filter: opacity(0%); } +} + +/* Give a fade-in animation to spinners. */ +spinner.fade-in:checked { + animation: pre-delay 0.5s linear 1, fade-in 1s linear 1, spin 1s linear infinite; + animation-delay: 0s, 0.5s, 0.5s; +} |