/* bz-appstream-parser.c * * 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 . * * SPDX-License-Identifier: GPL-3.0-or-later */ #define G_LOG_DOMAIN "BAZAAR::APPSTREAM-PARSER" #define BAZAAR_MODULE "appstream-parser" #include "config.h" #include #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); } } // make it even so the link colums stay neat. 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); }