*
* Copyright 2025 Adam Masciola, 2026 Alexander Vanhee
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program 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 General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
#define G_LOG_DOMAIN "BAZAAR::APPSTREAM-PARSER"
#define BAZAAR_MODULE "appstream-parser"
#include "config.h"
#include <glib/gi18n.h>
#include "bz-appstream-parser.h"
#include "bz-async-texture.h"
#include "bz-category-flags.h"
#include "bz-flatpak-entry.h"
#include "bz-release.h"
#include "bz-url.h"
#include "bz-verification-status.h"
#include "io.h"
static guint
parse_control_value (const char *value)
{
if (g_strcmp0 (value, "pointing") == 0)
return BZ_CONTROL_POINTING;
else if (g_strcmp0 (value, "keyboard") == 0)
return BZ_CONTROL_KEYBOARD;
else if (g_strcmp0 (value, "console") == 0)
return BZ_CONTROL_CONSOLE;
else if (g_strcmp0 (value, "tablet") == 0)
return BZ_CONTROL_TABLET;
else if (g_strcmp0 (value, "touch") == 0)
return BZ_CONTROL_TOUCH;
else if (g_strcmp0 (value, "gamepad") == 0)
return BZ_CONTROL_GAMEPAD;
else if (g_strcmp0 (value, "tv-remote") == 0)
return BZ_CONTROL_TV_REMOTE;
else if (g_strcmp0 (value, "voice") == 0)
return BZ_CONTROL_VOICE;
else if (g_strcmp0 (value, "vision") == 0)
return BZ_CONTROL_VISION;
else
return 0;
}
static gboolean
calculate_is_mobile_friendly (guint required_controls,
guint recommended_controls,
guint supported_controls)
{
return (required_controls & BZ_CONTROL_TOUCH) != 0 ||
(recommended_controls & BZ_CONTROL_TOUCH) != 0 ||
(supported_controls & BZ_CONTROL_TOUCH) != 0;
}
static char *
proxy_screenshot_url (const char *url, gboolean high_quality)
{
g_autofree char *src = NULL;
g_autofree char *encoded_url = NULL;
const char *suffix = NULL;
if (g_str_has_prefix (url, "https://dl.flathub.org/repo/screenshots/"))
{
suffix = url + strlen ("https://dl.flathub.org/repo/screenshots/");
src = g_strdup_printf ("https://dl.flathub.org/media/%s", suffix);
}
else if (g_str_has_prefix (url, "https://dl.flathub.org/"))
{
src = g_strdup (url);
}
else
{
return g_strdup (url);
}
encoded_url = g_base64_encode ((const guchar *) src, strlen (src));
g_strdelimit (encoded_url, "=", '\0');
for (char *p = encoded_url; *p; p++)
{
if (*p == '+')
*p = '-';
if (*p == '/')
*p = '_';
}
return g_strdup_printf (
"https://imgproxy.flathub.org/insecure/%s/%s",
high_quality ? "q:90/f:avif" : "dpr:1/f:avif/rs:fill-down",
encoded_url);
}
static GdkPaintable *
find_screenshot (GPtrArray *images,
const char *caption,
gboolean match_highest,
guint target_width,
guint target_height,
gboolean require_flathub,
const char *module_dir,
const char *unique_id_checksum,
const char *cache_filename,
char **out_caption)
{
const char *best_url = NULL;
gint best_diff = G_MAXINT;
guint best_res = 0;
guint best_width = 0;
guint best_height = 0;
guint target_pixels = target_width * target_height;
if (images == NULL)
return NULL;
for (guint j = 0; j < images->len; j++)
{
AsImage *image_obj = g_ptr_array_index (images, j);
const char *url = as_image_get_url (image_obj);
guint width = as_image_get_width (image_obj);
guint height = as_image_get_height (image_obj);
guint pixels = width * height;
if (url == NULL)
continue;
if (require_flathub && !g_str_has_prefix (url, "https://dl.flathub.org/"))
continue;
if (match_highest)
{
if (best_url == NULL || pixels > best_res)
{
best_url = url;
best_res = pixels;
best_width = width;
best_height = height;
}
}
else
{
gint diff = ABS ((gint) pixels - (gint) target_pixels);
if (best_url == NULL || diff < best_diff)
{
best_url = url;
best_diff = diff;
best_width = width;
best_height = height;
}
}
}
if (best_url != NULL)
{
g_autoptr (GFile) screenshot_file = NULL;
g_autoptr (GFile) cache_file = NULL;
g_autofree char *proxied_url = NULL;
BzAsyncTexture *texture = NULL;
proxied_url = proxy_screenshot_url (best_url, match_highest);
screenshot_file = g_file_new_for_uri (proxied_url);
cache_file = g_file_new_build_filename (
module_dir, unique_id_checksum, cache_filename, NULL);
if (best_width > 0 && best_height > 0)
texture = bz_async_texture_new_lazy_with_size (screenshot_file, cache_file, best_width, best_height);
else
texture = bz_async_texture_new_lazy (screenshot_file, cache_file);
if (out_caption != NULL)
*out_caption = g_strdup (caption ? caption : "");
return GDK_PAINTABLE (texture);
}
return NULL;
}
gboolean
bz_appstream_parser_populate_entry (BzEntry *entry,
AsComponent *component,
const char *appstream_dir,
const char *remote_name,
const char *module_dir,
const char *unique_id_checksum,
const char *id,
guint kinds,
GError **error)
{
AsDeveloper *developer_obj = NULL;
GPtrArray *screenshots = NULL;
AsReleaseList *releases = NULL;
GPtrArray *releases_arr = NULL;
GPtrArray *icons = NULL;
AsBranding *branding = NULL;
GPtrArray *requires_relations = NULL;
GPtrArray *recommends_relations = NULL;
GPtrArray *supports_relations = NULL;
const char *title = NULL;
const char *description = NULL;
const char *metadata_license = NULL;
const char *project_license = NULL;
gboolean is_floss = FALSE;
const char *project_group = NULL;
const char *developer = NULL;
const char *developer_id = NULL;
const char *long_description = NULL;
const char *project_url = NULL;
g_autoptr (GPtrArray) as_search_tokens = NULL;
g_autofree char *search_tokens = NULL;
g_autoptr (GdkPaintable) icon_paintable = NULL;
g_autoptr (GIcon) mini_icon = NULL;
g_autoptr (GListStore) screenshot_paintables = NULL;
g_autoptr (GListStore) screenshot_captions = NULL;
g_autoptr (GdkPaintable) thumbnail_paintable = NULL;
g_autoptr (GListStore) share_urls = NULL;
g_autofree char *donation_url = NULL;
g_autofree char *ratings_summary = NULL;
g_autoptr (GListStore) version_history = NULL;
const char *accent_color_light = NULL;
const char *accent_color_dark = NULL;
guint required_controls = 0;
guint recommended_controls = 0;
guint supported_controls = 0;
gint min_display_length = 0;
gint max_display_length = 0;
gboolean is_mobile_friendly = FALSE;
g_autoptr (AsContentRating) content_rating = NULL;
GPtrArray *as_keywords = NULL;
g_autoptr (GListStore) keywords = NULL;
GPtrArray *as_categories = NULL;
BzCategoryFlags categories = BZ_CATEGORY_FLAGS_NONE;
g_autoptr (BzVerificationStatus) verification_status = NULL;
g_return_val_if_fail (BZ_IS_ENTRY (entry), FALSE);
g_return_val_if_fail (AS_IS_COMPONENT (component), FALSE);
title = as_component_get_name (component);
if (title == NULL)
title = as_component_get_id (component);
description = as_component_get_summary (component);
metadata_license = as_component_get_metadata_license (component);
project_license = as_component_get_project_license (component);
is_floss = as_component_is_floss (component);
project_group = as_component_get_project_group (component);
project_url = as_component_get_url (component, AS_URL_KIND_HOMEPAGE);
as_search_tokens = as_component_get_search_tokens (component);
developer_obj = as_component_get_developer (component);
if (developer_obj != NULL)
{
developer = as_developer_get_name (developer_obj);
developer_id = as_developer_get_id (developer_obj);
}
long_description = as_component_get_description (component);
screenshots = as_component_get_screenshots_all (component);
if (screenshots != NULL)
{
screenshot_paintables = g_list_store_new (BZ_TYPE_ASYNC_TEXTURE);
screenshot_captions = g_list_store_new (GTK_TYPE_STRING_OBJECT);
for (guint i = 0; i < screenshots->len; i++)
{
AsScreenshot *screenshot = NULL;
GPtrArray *images = NULL;
const gchar *caption = NULL;
g_autofree char *caption_str = NULL;
g_autoptr (GdkPaintable) paintable = NULL;
g_autofree char *cache_name = NULL;
screenshot = g_ptr_array_index (screenshots, i);
images = as_screenshot_get_images_all (screenshot);
caption = as_screenshot_get_caption (screenshot);
if (i == 0 && thumbnail_paintable == NULL)
{
thumbnail_paintable = find_screenshot (images, caption, FALSE, 400, 300, TRUE,
module_dir, unique_id_checksum, "thumbnail", NULL);
if (thumbnail_paintable == NULL)
thumbnail_paintable = find_screenshot (images, caption, FALSE, 400, 300, FALSE,
module_dir, unique_id_checksum, "thumbnail", NULL);
}
cache_name = g_strdup_printf ("screenshot_%u", i);
paintable = find_screenshot (images, caption, TRUE, 0, 0, TRUE,
module_dir, unique_id_checksum, cache_name, &caption_str);
if (paintable == NULL)
paintable = find_screenshot (images, caption, TRUE, 0, 0, FALSE,
module_dir, unique_id_checksum, cache_name, &caption_str);
if (paintable != NULL)
{
g_autoptr (GtkStringObject) caption_obj = NULL;
g_list_store_append (screenshot_paintables, paintable);
caption_obj = gtk_string_object_new (caption_str);
g_list_store_append (screenshot_captions, caption_obj);
}
}
}
share_urls = g_list_store_new (BZ_TYPE_URL);
if (kinds & BZ_ENTRY_KIND_APPLICATION &&
g_strcmp0 (remote_name, "flathub") == 0)
{
g_autofree char *flathub_url = NULL;
g_autoptr (BzUrl) url = NULL;
flathub_url = g_strdup_printf ("https://flathub.org/apps/%s", id);
url = bz_url_new ();
bz_url_set_id (url, "flathub");
bz_url_set_url (url, flathub_url);
g_list_store_append (share_urls, url);
}
for (int e = AS_URL_KIND_UNKNOWN + 1; e < AS_URL_KIND_LAST; e++)
{
const char *url = NULL;
url = as_component_get_url (component, e);
if (url != NULL)
{
g_autoptr (BzUrl) share_url = NULL;
switch (e)
{
case AS_URL_KIND_DONATION:
g_clear_pointer (&donation_url, g_free);
donation_url = g_strdup (url);
break;
default:
break;
}
share_url = g_object_new (
BZ_TYPE_URL,
"id", as_url_kind_to_string (e),
"url", url,
NULL);
g_list_store_append (share_urls, share_url);
}
}
if (g_strcmp0 (remote_name, "flathub") == 0 &&
g_list_model_get_n_items (G_LIST_MODEL (share_urls)) % 2 != 0)
{
g_autofree char *manifest_url = NULL;
g_autoptr (BzUrl) url = NULL;
manifest_url = g_strdup_printf ("https://github.com/flathub/%s", id);
url = g_object_new (BZ_TYPE_URL, "id", "manifest", "url", manifest_url, NULL);
g_list_store_append (share_urls, url);
}
if (g_list_model_get_n_items (G_LIST_MODEL (share_urls)) == 0)
g_clear_object (&share_urls);
releases = as_component_load_releases (component, TRUE, error);
if (releases == NULL)
return FALSE;
releases_arr = as_release_list_get_entries (releases);
if (releases_arr != NULL)
{
version_history = g_list_store_new (BZ_TYPE_RELEASE);
for (guint i = 0; i < releases_arr->len; i++)
{
AsRelease *as_release = NULL;
const char *release_description = NULL;
g_autoptr (BzRelease) release = NULL;
as_release = g_ptr_array_index (releases_arr, i);
release_description = as_release_get_description (as_release);
release = g_object_new (
BZ_TYPE_RELEASE,
"description", release_description,
"timestamp", as_release_get_timestamp (as_release),
"url", as_release_get_url (as_release, AS_RELEASE_URL_KIND_DETAILS),
"version", as_release_get_version (as_release),
NULL);
g_list_store_append (version_history, release);
}
}
icons = as_component_get_icons (component);
if (icons != NULL)
{
g_autofree char *select = NULL;
gboolean select_is_local = FALSE;
int select_width = 0;
int select_height = 0;
for (guint i = 0; i < icons->len; i++)
{
AsIcon *icon = NULL;
int width = 0;
int height = 0;
gboolean is_local = FALSE;
icon = g_ptr_array_index (icons, i);
width = as_icon_get_width (icon);
height = as_icon_get_height (icon);
is_local = as_icon_get_kind (icon) != AS_ICON_KIND_REMOTE;
if (select == NULL ||
(is_local && !select_is_local) ||
(width > select_width && height > select_height))
{
if (is_local)
{
const char *filename = NULL;
g_autofree char *resolution = NULL;
g_autofree char *path = NULL;
filename = as_icon_get_filename (icon);
if (filename == NULL)
continue;
resolution = g_strdup_printf ("%dx%d", width, height);
path = g_build_filename (
appstream_dir,
"icons",
"flatpak",
resolution,
filename,
NULL);
if (!g_file_test (path, G_FILE_TEST_EXISTS))
continue;
g_clear_pointer (&select, g_free);
select = g_steal_pointer (&path);
select_is_local = TRUE;
select_width = width;
select_height = height;
}
else
{
const char *url = NULL;
url = as_icon_get_url (icon);
if (url == NULL)
continue;
g_clear_pointer (&select, g_free);
select = g_strdup (url);
select_is_local = FALSE;
select_width = width;
select_height = height;
}
}
}
if (select != NULL)
{
g_autofree char *select_uri = NULL;
g_autoptr (GFile) source = NULL;
g_autoptr (GFile) cache_into = NULL;
BzAsyncTexture *texture = NULL;
if (select_is_local)
select_uri = g_strdup_printf ("file://%s", select);
else
select_uri = g_steal_pointer (&select);
source = g_file_new_for_uri (select_uri);
cache_into = g_file_new_build_filename (
module_dir, unique_id_checksum, "icon-paintable.png", NULL);
texture = bz_async_texture_new_lazy (source, cache_into);
icon_paintable = GDK_PAINTABLE (texture);
if (select_is_local)
mini_icon = bz_load_mini_icon_sync (unique_id_checksum, select);
}
}
branding = as_component_get_branding (component);
if (branding != NULL)
{
accent_color_light = as_branding_get_color (
branding, AS_COLOR_KIND_PRIMARY, AS_COLOR_SCHEME_KIND_LIGHT);
accent_color_dark = as_branding_get_color (
branding, AS_COLOR_KIND_PRIMARY, AS_COLOR_SCHEME_KIND_DARK);
}
content_rating = as_component_get_content_rating (component, "oars-1.1");
if (content_rating != NULL)
{
g_object_ref (content_rating);
}
else
{
content_rating = as_component_get_content_rating (component, "oars-1.0");
if (content_rating != NULL)
g_object_ref (content_rating);
}
requires_relations = as_component_get_requires (component);
recommends_relations = as_component_get_recommends (component);
supports_relations = as_component_get_supports (component);
if (requires_relations != NULL)
{
for (guint i = 0; i < requires_relations->len; i++)
{
AsRelation *relation = g_ptr_array_index (requires_relations, i);
AsRelationItemKind item_kind = as_relation_get_item_kind (relation);
if (item_kind == AS_RELATION_ITEM_KIND_CONTROL)
{
AsControlKind control_kind = as_relation_get_value_control_kind (relation);
const char *control_str = as_control_kind_to_string (control_kind);
if (control_str != NULL)
required_controls |= parse_control_value (control_str);
}
else if (item_kind == AS_RELATION_ITEM_KIND_DISPLAY_LENGTH)
{
AsRelationCompare compare = as_relation_get_compare (relation);
gint value = as_relation_get_value_int (relation);
if (compare == AS_RELATION_COMPARE_GE)
min_display_length = value;
}
}
}
if (recommends_relations != NULL)
{
for (guint i = 0; i < recommends_relations->len; i++)
{
AsRelation *relation = g_ptr_array_index (recommends_relations, i);
AsRelationItemKind item_kind = as_relation_get_item_kind (relation);
if (item_kind == AS_RELATION_ITEM_KIND_CONTROL)
{
AsControlKind control_kind = as_relation_get_value_control_kind (relation);
const char *control_str = as_control_kind_to_string (control_kind);
if (control_str != NULL)
recommended_controls |= parse_control_value (control_str);
}
}
}
if (supports_relations != NULL)
{
for (guint i = 0; i < supports_relations->len; i++)
{
AsRelation *relation = g_ptr_array_index (supports_relations, i);
AsRelationItemKind item_kind = as_relation_get_item_kind (relation);
if (item_kind == AS_RELATION_ITEM_KIND_CONTROL)
{
AsControlKind control_kind = as_relation_get_value_control_kind (relation);
const char *control_str = as_control_kind_to_string (control_kind);
if (control_str != NULL)
supported_controls |= parse_control_value (control_str);
}
else if (item_kind == AS_RELATION_ITEM_KIND_DISPLAY_LENGTH)
{
AsRelationCompare compare = as_relation_get_compare (relation);
gint value = as_relation_get_value_int (relation);
if (compare == AS_RELATION_COMPARE_LE)
max_display_length = value;
}
}
}
is_mobile_friendly = calculate_is_mobile_friendly (required_controls,
recommended_controls,
supported_controls);
if (as_search_tokens != NULL)
{
g_autoptr (GStrvBuilder) builder = NULL;
g_auto (GStrv) strv = NULL;
builder = g_strv_builder_new ();
for (guint i = 0; i < as_search_tokens->len; i++)
{
const char *token = NULL;
token = g_ptr_array_index (as_search_tokens, i);
g_strv_builder_add (builder, token);
}
strv = g_strv_builder_end (builder);
search_tokens = g_strjoinv (" ", strv);
}
as_keywords = as_component_get_keywords (component);
if (as_keywords != NULL && as_keywords->len > 0)
{
keywords = g_list_store_new (GTK_TYPE_STRING_OBJECT);
for (guint i = 0; i < as_keywords->len; i++)
{
const char *keyword = NULL;
g_autoptr (GtkStringObject) keyword_obj = NULL;
keyword = g_ptr_array_index (as_keywords, i);
keyword_obj = gtk_string_object_new (keyword);
g_list_store_append (keywords, keyword_obj);
}
}
as_categories = as_component_get_categories (component);
if (as_categories != NULL)
{
for (guint i = 0; i < as_categories->len; i++)
{
const char *name = NULL;
name = g_ptr_array_index (as_categories, i);
categories = bz_category_flags_add (categories, name);
}
}
if (is_mobile_friendly)
categories = bz_category_flags_add (categories, "mobile");
if (g_strcmp0 (remote_name, "flathub") == 0)
{
const char *verified_str = NULL;
const char *method = NULL;
const char *website = NULL;
const char *login_name = NULL;
const char *login_provider = NULL;
const char *timestamp = NULL;
const char *login_is_org_str = NULL;
gboolean verified = FALSE;
gboolean login_is_org = FALSE;
GHashTable *custom_fields = NULL;
custom_fields = as_component_get_custom (component);
if (custom_fields != NULL)
{
verified_str = g_hash_table_lookup (custom_fields, "flathub::verification::verified");
method = g_hash_table_lookup (custom_fields, "flathub::verification::method");
website = g_hash_table_lookup (custom_fields, "flathub::verification::website");
login_name = g_hash_table_lookup (custom_fields, "flathub::verification::login_name");
login_provider = g_hash_table_lookup (custom_fields, "flathub::verification::login_provider");
timestamp = g_hash_table_lookup (custom_fields, "flathub::verification::timestamp");
login_is_org_str = g_hash_table_lookup (custom_fields, "flathub::verification::login_is_organization");
}
verified = (verified_str != NULL && g_strcmp0 (verified_str, "true") == 0);
login_is_org = (login_is_org_str != NULL && g_strcmp0 (login_is_org_str, "true") == 0);
verification_status = bz_verification_status_new ();
g_object_set (verification_status,
"verified", verified,
"method", method,
"website", website,
"login-name", login_name,
"login-provider", login_provider,
"timestamp", timestamp,
"login-is-organization", login_is_org,
NULL);
}
g_object_set (
entry,
"title", title,
"description", description,
"long-description", long_description,
"url", project_url,
"search-tokens", search_tokens,
"metadata-license", metadata_license,
"project-license", project_license,
"is-floss", is_floss,
"project-group", project_group,
"developer", developer,
"developer-id", developer_id,
"icon-paintable", icon_paintable,
"mini-icon", mini_icon,
"screenshot-paintables", screenshot_paintables,
"screenshot-captions", screenshot_captions,
"thumbnail-paintable", thumbnail_paintable,
"share-urls", share_urls,
"donation-url", donation_url,
"ratings-summary", ratings_summary,
"version-history", version_history,
"light-accent-color", accent_color_light,
"dark-accent-color", accent_color_dark,
"required-controls", required_controls,
"recommended-controls", recommended_controls,
"supported-controls", supported_controls,
"min-display-length", min_display_length,
"max-display-length", max_display_length,
"is-mobile-friendly", is_mobile_friendly,
"content-rating", content_rating,
"keywords", keywords,
"categories", categories,
"verification-status", verification_status,
NULL);
return TRUE;
}
BzEntry *
bz_appstream_parser_entry_from_metainfo (GFile *metainfo_file,
GFile *icon_file,
GError **error)
{
g_autoptr (AsMetadata) mdata = as_metadata_new ();
AsComponent *component = NULL;
g_autofree char *xml_contents = NULL;
g_autofree char *xml_path = NULL;
g_autofree char *xml_dir = NULL;
g_autofree char *module_dir = NULL;
g_autofree char *checksum = NULL;
gsize xml_length = 0;
g_autoptr (BzEntry) entry = NULL;
g_return_val_if_fail (G_IS_FILE (metainfo_file), NULL);
xml_path = g_file_get_path (metainfo_file);
if (!g_file_get_contents (xml_path, &xml_contents, &xml_length, error))
return NULL;
as_metadata_parse_data (mdata, xml_contents, xml_length, AS_FORMAT_KIND_XML, error);
if (*error != NULL)
return NULL;
component = as_metadata_get_component (mdata);
if (component == NULL)
{
g_set_error (error, G_IO_ERROR, G_IO_ERROR_FAILED,
"No component found in metainfo file");
return NULL;
}
xml_dir = g_path_get_dirname (xml_path);
module_dir = bz_dup_module_dir ();
checksum = g_compute_checksum_for_string (G_CHECKSUM_MD5, xml_path, -1);
entry = BZ_ENTRY (g_object_new (BZ_TYPE_FLATPAK_ENTRY, NULL));
bz_appstream_parser_populate_entry (
entry, component,
xml_dir,
"local-preview",
module_dir,
checksum,
as_component_get_id (component),
BZ_ENTRY_KIND_APPLICATION,
NULL);
g_object_set (entry, "remote-repo-name", "local-preview", NULL);
if (icon_file != NULL)
{
g_autoptr (GFile) cache_into = NULL;
cache_into = g_file_new_build_filename (
module_dir, checksum, "icon-paintable.png", NULL);
g_object_set (
entry,
"icon-paintable", GDK_PAINTABLE (bz_async_texture_new_lazy (icon_file, cache_into)),
NULL);
}
return g_steal_pointer (&entry);
}