401 lines
12 KiB
C
401 lines
12 KiB
C
/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
|
||
* vi:set noexpandtab tabstop=8 shiftwidth=8:
|
||
*
|
||
* Copyright (C) 2021 Endless OS Foundation, Inc
|
||
*
|
||
* Author: Philip Withnall <pwithnall@endlessos.org>
|
||
*
|
||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||
*/
|
||
|
||
/**
|
||
* SECTION:gs-remote-icon
|
||
* @short_description: A #GIcon implementation for remote icons
|
||
*
|
||
* #GsRemoteIcon is a #GIcon implementation which represents remote icons —
|
||
* icons which have an HTTP or HTTPS URI. It provides a well-known local filename
|
||
* for a cached copy of the icon, accessible as #GFileIcon:file, and a method
|
||
* to download the icon to the cache, gs_remote_icon_ensure_cached().
|
||
*
|
||
* Constructing a #GsRemoteIcon does not guarantee that the icon is cached. Call
|
||
* gs_remote_icon_ensure_cached() for that.
|
||
*
|
||
* #GsRemoteIcon is immutable after construction and hence is entirely thread
|
||
* safe.
|
||
*
|
||
* FIXME: Currently does no cache invalidation.
|
||
*
|
||
* Since: 40
|
||
*/
|
||
|
||
#include "config.h"
|
||
|
||
#include <gio/gio.h>
|
||
#include <glib.h>
|
||
#include <glib-object.h>
|
||
#include <glib/gstdio.h>
|
||
#include <fcntl.h>
|
||
#include <sys/types.h>
|
||
#include <sys/stat.h>
|
||
#include <libsoup/soup.h>
|
||
|
||
#include "gs-icon.h"
|
||
#include "gs-remote-icon.h"
|
||
#include "gs-utils.h"
|
||
|
||
/* FIXME: Work around the fact that GFileIcon is not derivable, by deriving from
|
||
* it anyway by copying its `struct GFileIcon` definition inline here. This will
|
||
* work as long as the size of `struct GFileIcon` doesn’t change within GIO.
|
||
* There’s no way of knowing if that’s the case.
|
||
*
|
||
* See https://gitlab.gnome.org/GNOME/glib/-/issues/2345 for why this is
|
||
* necessary. */
|
||
struct _GsRemoteIcon
|
||
{
|
||
/* struct GFileIcon { */
|
||
GObject grandparent;
|
||
GFile *file;
|
||
/* } */
|
||
|
||
gchar *uri; /* (owned), immutable after construction */
|
||
};
|
||
|
||
G_DEFINE_TYPE (GsRemoteIcon, gs_remote_icon, G_TYPE_FILE_ICON)
|
||
|
||
typedef enum {
|
||
PROP_URI = 1,
|
||
} GsRemoteIconProperty;
|
||
|
||
static GParamSpec *obj_props[PROP_URI + 1] = { NULL, };
|
||
|
||
static void
|
||
gs_remote_icon_get_property (GObject *object,
|
||
guint prop_id,
|
||
GValue *value,
|
||
GParamSpec *pspec)
|
||
{
|
||
GsRemoteIcon *self = GS_REMOTE_ICON (object);
|
||
|
||
switch ((GsRemoteIconProperty) prop_id) {
|
||
case PROP_URI:
|
||
g_value_set_string (value, gs_remote_icon_get_uri (self));
|
||
break;
|
||
default:
|
||
G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
|
||
break;
|
||
}
|
||
}
|
||
|
||
static void
|
||
gs_remote_icon_set_property (GObject *object,
|
||
guint prop_id,
|
||
const GValue *value,
|
||
GParamSpec *pspec)
|
||
{
|
||
GsRemoteIcon *self = GS_REMOTE_ICON (object);
|
||
|
||
switch ((GsRemoteIconProperty) prop_id) {
|
||
case PROP_URI:
|
||
/* Construct only */
|
||
g_assert (self->uri == NULL);
|
||
self->uri = g_value_dup_string (value);
|
||
g_assert (g_str_has_prefix (self->uri, "http:") ||
|
||
g_str_has_prefix (self->uri, "https:"));
|
||
break;
|
||
default:
|
||
G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
|
||
break;
|
||
}
|
||
}
|
||
|
||
static void
|
||
gs_remote_icon_finalize (GObject *object)
|
||
{
|
||
GsRemoteIcon *self = GS_REMOTE_ICON (object);
|
||
|
||
g_free (self->uri);
|
||
|
||
G_OBJECT_CLASS (gs_remote_icon_parent_class)->finalize (object);
|
||
}
|
||
|
||
static void
|
||
gs_remote_icon_class_init (GsRemoteIconClass *klass)
|
||
{
|
||
GObjectClass *object_class = G_OBJECT_CLASS (klass);
|
||
|
||
object_class->get_property = gs_remote_icon_get_property;
|
||
object_class->set_property = gs_remote_icon_set_property;
|
||
object_class->finalize = gs_remote_icon_finalize;
|
||
|
||
/**
|
||
* GsRemoteIcon:uri: (not nullable)
|
||
*
|
||
* Remote URI of the icon. This must be an HTTP or HTTPS URI; it is a
|
||
* programmer error to provide other URI schemes.
|
||
*
|
||
* Since: 40
|
||
*/
|
||
obj_props[PROP_URI] =
|
||
g_param_spec_string ("uri", NULL, NULL,
|
||
NULL,
|
||
G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS);
|
||
|
||
g_object_class_install_properties (object_class, G_N_ELEMENTS (obj_props), obj_props);
|
||
}
|
||
|
||
static void
|
||
gs_remote_icon_init (GsRemoteIcon *self)
|
||
{
|
||
}
|
||
|
||
/* Use a hash-prefixed filename to avoid cache clashes.
|
||
* This can only fail if @create_directory is %TRUE. */
|
||
static gchar *
|
||
gs_remote_icon_get_cache_filename (const gchar *uri,
|
||
gboolean create_directory,
|
||
GError **error)
|
||
{
|
||
g_autofree gchar *uri_checksum = NULL;
|
||
g_autofree gchar *uri_basename = NULL;
|
||
g_autofree gchar *cache_basename = NULL;
|
||
GsUtilsCacheFlags flags;
|
||
|
||
uri_checksum = g_compute_checksum_for_string (G_CHECKSUM_SHA1,
|
||
uri,
|
||
-1);
|
||
uri_basename = g_path_get_basename (uri);
|
||
|
||
/* convert filename from jpg to png, as we always convert to PNG on
|
||
* download */
|
||
if (g_str_has_suffix (uri_basename, ".jpg"))
|
||
memcpy (uri_basename + strlen (uri_basename) - 4, ".png", 4);
|
||
|
||
cache_basename = g_strdup_printf ("%s-%s", uri_checksum, uri_basename);
|
||
|
||
flags = GS_UTILS_CACHE_FLAG_WRITEABLE;
|
||
if (create_directory)
|
||
flags |= GS_UTILS_CACHE_FLAG_CREATE_DIRECTORY;
|
||
|
||
return gs_utils_get_cache_filename ("icons",
|
||
cache_basename,
|
||
flags,
|
||
error);
|
||
}
|
||
|
||
/**
|
||
* gs_remote_icon_new:
|
||
* @uri: remote URI of the icon
|
||
*
|
||
* Create a new #GsRemoteIcon representing @uri. The #GFileIcon:file of the
|
||
* resulting icon will represent the local cache location for the icon.
|
||
*
|
||
* Returns: (transfer full): a new remote icon
|
||
* Since: 40
|
||
*/
|
||
GIcon *
|
||
gs_remote_icon_new (const gchar *uri)
|
||
{
|
||
g_autofree gchar *cache_filename = NULL;
|
||
g_autoptr(GFile) file = NULL;
|
||
|
||
g_return_val_if_fail (uri != NULL, NULL);
|
||
|
||
/* The file is the expected cached location of the icon, once it’s
|
||
* downloaded. By setting it as the #GFileIcon:file property, existing
|
||
* code (particularly in GTK) which operates on #GFileIcons will work
|
||
* transparently with this.
|
||
*
|
||
* Ideally, #GFileIcon would be an interface rather than a class, which
|
||
* would make this implementation cleaner, but this is what we’re stuck
|
||
* with.
|
||
*
|
||
* See https://gitlab.gnome.org/GNOME/glib/-/issues/2345 */
|
||
cache_filename = gs_remote_icon_get_cache_filename (uri, FALSE, NULL);
|
||
g_assert (cache_filename != NULL);
|
||
file = g_file_new_for_path (cache_filename);
|
||
|
||
return g_object_new (GS_TYPE_REMOTE_ICON,
|
||
"file", file,
|
||
"uri", uri,
|
||
NULL);
|
||
}
|
||
|
||
/**
|
||
* gs_remote_icon_get_uri:
|
||
* @self: a #GsRemoteIcon
|
||
*
|
||
* Gets the value of #GsRemoteIcon:uri.
|
||
*
|
||
* Returns: (not nullable): remote URI of the icon
|
||
* Since: 40
|
||
*/
|
||
const gchar *
|
||
gs_remote_icon_get_uri (GsRemoteIcon *self)
|
||
{
|
||
g_return_val_if_fail (GS_IS_REMOTE_ICON (self), NULL);
|
||
|
||
return self->uri;
|
||
}
|
||
|
||
static GdkPixbuf *
|
||
gs_icon_download (SoupSession *session,
|
||
const gchar *uri,
|
||
const gchar *destination_path,
|
||
guint max_size,
|
||
GCancellable *cancellable,
|
||
GError **error)
|
||
{
|
||
guint status_code;
|
||
g_autoptr(SoupMessage) msg = NULL;
|
||
g_autoptr(GInputStream) stream = NULL;
|
||
g_autoptr(GdkPixbuf) pixbuf = NULL;
|
||
g_autoptr(GdkPixbuf) scaled_pixbuf = NULL;
|
||
|
||
/* Create the request */
|
||
msg = soup_message_new (SOUP_METHOD_GET, uri);
|
||
if (msg == NULL) {
|
||
g_set_error_literal (error,
|
||
G_IO_ERROR,
|
||
G_IO_ERROR_INVALID_DATA,
|
||
"Icon has an invalid URL");
|
||
return NULL;
|
||
}
|
||
|
||
/* Send request synchronously and start reading the response. */
|
||
stream = soup_session_send (session, msg, cancellable, error);
|
||
|
||
status_code = soup_message_get_status (msg);
|
||
if (stream == NULL) {
|
||
return NULL;
|
||
} else if (status_code != SOUP_STATUS_OK) {
|
||
g_set_error (error,
|
||
G_IO_ERROR,
|
||
G_IO_ERROR_FAILED,
|
||
"Failed to download icon %s: %s",
|
||
uri, soup_status_get_phrase (status_code));
|
||
return NULL;
|
||
}
|
||
|
||
/* Typically these icons are 64x64px PNG files. If not, resize down
|
||
* so it’s at most @max_size square, to minimise the size of the on-disk
|
||
* cache.*/
|
||
pixbuf = gdk_pixbuf_new_from_stream (stream, cancellable, error);
|
||
if (pixbuf == NULL)
|
||
return NULL;
|
||
|
||
if ((guint) gdk_pixbuf_get_height (pixbuf) <= max_size &&
|
||
(guint) gdk_pixbuf_get_width (pixbuf) <= max_size) {
|
||
scaled_pixbuf = g_object_ref (pixbuf);
|
||
} else {
|
||
scaled_pixbuf = gdk_pixbuf_scale_simple (pixbuf, max_size, max_size,
|
||
GDK_INTERP_BILINEAR);
|
||
}
|
||
|
||
/* write file */
|
||
if (!gdk_pixbuf_save (scaled_pixbuf, destination_path, "png", error, NULL))
|
||
return NULL;
|
||
|
||
return g_steal_pointer (&scaled_pixbuf);
|
||
}
|
||
|
||
/**
|
||
* gs_remote_icon_ensure_cached:
|
||
* @self: a #GsRemoteIcon
|
||
* @soup_session: a #SoupSession to use to download the icon
|
||
* @maximum_icon_size: maximum size (in logical pixels) of the icon to save
|
||
* @scale: scale the icon will be used at
|
||
* @cancellable: (nullable): a #GCancellable, or %NULL
|
||
* @error: return location for a #GError, or %NULL
|
||
*
|
||
* Ensure the given icon is present in the local cache, potentially downloading
|
||
* it from its remote server if needed. This will do network and disk I/O.
|
||
*
|
||
* @maximum_icon_size specifies the maximum size (in logical pixels) of the icon
|
||
* which should be saved to the cache. This is the maximum size that the icon
|
||
* can ever be used at, as icons can be downscaled but never upscaled. Typically
|
||
* this will be 160px. The device scale factor (`gtk_widget_get_scale_factor()`)
|
||
* is provided separately as @scale.
|
||
*
|
||
* This can be called from any thread, as #GsRemoteIcon is immutable and hence
|
||
* thread-safe.
|
||
*
|
||
* Returns: %TRUE on success, %FALSE otherwise
|
||
* Since: 48
|
||
*/
|
||
gboolean
|
||
gs_remote_icon_ensure_cached (GsRemoteIcon *self,
|
||
SoupSession *soup_session,
|
||
guint maximum_icon_size,
|
||
guint scale,
|
||
GCancellable *cancellable,
|
||
GError **error)
|
||
{
|
||
const gchar *uri;
|
||
g_autofree gchar *cache_filename = NULL;
|
||
GIcon *icon = NULL;
|
||
GStatBuf stat_buf;
|
||
int pixbuf_width = 0, pixbuf_height = 0;
|
||
unsigned int icon_device_width, icon_device_height;
|
||
|
||
g_return_val_if_fail (GS_IS_REMOTE_ICON (self), FALSE);
|
||
g_return_val_if_fail (SOUP_IS_SESSION (soup_session), FALSE);
|
||
g_return_val_if_fail (maximum_icon_size > 0, FALSE);
|
||
g_return_val_if_fail (scale > 0, FALSE);
|
||
g_return_val_if_fail (cancellable == NULL || G_IS_CANCELLABLE (cancellable), FALSE);
|
||
g_return_val_if_fail (error == NULL || *error == NULL, FALSE);
|
||
|
||
uri = gs_remote_icon_get_uri (self);
|
||
|
||
/* Work out cache filename. */
|
||
cache_filename = gs_remote_icon_get_cache_filename (uri, TRUE, error);
|
||
if (cache_filename == NULL)
|
||
return FALSE;
|
||
|
||
icon = G_ICON (self);
|
||
|
||
/* Already in cache and not older than 30 days */
|
||
if (g_stat (cache_filename, &stat_buf) != -1 &&
|
||
S_ISREG (stat_buf.st_mode) &&
|
||
(g_get_real_time () / G_USEC_PER_SEC) - stat_buf.st_mtim.tv_sec < (60 * 60 * 24 * 30)) {
|
||
/* Fallthrough and ensure the downloaded image dimensions are stored on the icon */
|
||
gdk_pixbuf_get_file_info (cache_filename, &pixbuf_width, &pixbuf_height);
|
||
} else {
|
||
g_autoptr(GdkPixbuf) cached_pixbuf = NULL;
|
||
|
||
cached_pixbuf = gs_icon_download (soup_session, uri, cache_filename, maximum_icon_size * gs_icon_get_scale (icon), cancellable, error);
|
||
if (cached_pixbuf == NULL)
|
||
return FALSE;
|
||
|
||
pixbuf_width = gdk_pixbuf_get_width (cached_pixbuf);
|
||
pixbuf_height = gdk_pixbuf_get_height (cached_pixbuf);
|
||
}
|
||
|
||
/* Ensure the dimensions are set correctly on the icon. We know the
|
||
* pixbuf’s (device) dimensions, so need to convert those to logical
|
||
* dimensions using the icon’s scale. The caller will have set the scale
|
||
* on the #GsIcon already, or it will default to 1. */
|
||
scale = gs_icon_get_scale (icon);
|
||
g_assert (scale > 0);
|
||
|
||
icon_device_width = pixbuf_width / scale;
|
||
icon_device_height = pixbuf_height / scale;
|
||
|
||
if (gs_icon_get_width (icon) != 0 && gs_icon_get_height (icon) != 0 &&
|
||
(gs_icon_get_width (icon) != icon_device_width ||
|
||
gs_icon_get_height (icon) != icon_device_height)) {
|
||
g_debug ("Icon downloaded from ‘%s’ has dimensions %ux%u@%u, "
|
||
"but was expected to have dimensions %ux%u@%u "
|
||
"according to metadata. Overriding with downloaded "
|
||
"dimensions.",
|
||
uri, icon_device_width, icon_device_height, scale,
|
||
gs_icon_get_width (icon), gs_icon_get_height (icon),
|
||
gs_icon_get_scale (icon));
|
||
gs_icon_set_width (icon, icon_device_width);
|
||
gs_icon_set_height (icon, icon_device_height);
|
||
} else if (gs_icon_get_width (icon) == 0 || gs_icon_get_height (icon) == 0) {
|
||
gs_icon_set_width (icon, icon_device_width);
|
||
gs_icon_set_height (icon, icon_device_height);
|
||
}
|
||
|
||
return TRUE;
|
||
}
|