From 9c3c0e1dfd95971f8e427567d3cab72001855cf5 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Sat, 24 Jan 2026 13:51:53 +0100 Subject: [PATCH] Import: Improve Heroic cover handling Prefer cached cover images when available and import sideload apps. Also accept Legendary-only setups and normalize cover metadata from the Heroic configuration. --- src/heroicimporter.cpp | 442 ++++++++++++++++++++++++++++++++++++++--- src/heroicimporter.h | 5 + 2 files changed, 415 insertions(+), 32 deletions(-) diff --git a/src/heroicimporter.cpp b/src/heroicimporter.cpp index 23a6237..e9df6c7 100644 --- a/src/heroicimporter.cpp +++ b/src/heroicimporter.cpp @@ -3,6 +3,7 @@ #include "heroicimporter.h" +#include #include #include #include @@ -27,7 +28,7 @@ QString HeroicImporter::platformId() const bool HeroicImporter::isAvailable() const { - return directoryExists(findHeroicConfigPath()); + return directoryExists(findHeroicConfigPath()) || directoryExists(findLegendaryConfigPath()); } QString HeroicImporter::findHeroicConfigPath() const @@ -44,17 +45,158 @@ QString HeroicImporter::findHeroicConfigPath() const return {}; } +QString HeroicImporter::findLegendaryConfigPath() const +{ + QStringList possiblePaths = {expandPath(QStringLiteral("~/.config/legendary")), + expandPath(QStringLiteral("~/.var/app/com.heroicgameslauncher.hgl/config/legendary"))}; + + for (const QString &path : possiblePaths) { + if (directoryExists(path)) { + return path; + } + } + + return {}; +} + +QString HeroicImporter::launchCommandForApp(const QString &appName) const +{ + if (!findExecutable(QStringLiteral("heroic")).isEmpty()) { + return QStringLiteral("heroic --launch %1").arg(appName); + } + if (!findExecutable(QStringLiteral("flatpak")).isEmpty()) { + return QStringLiteral("flatpak run com.heroicgameslauncher.hgl --launch %1").arg(appName); + } + return QStringLiteral("heroic --launch %1").arg(appName); +} + +QUrl HeroicImporter::extractCoverUrl(const QJsonObject &gameObj) const +{ + static const QStringList stringKeys = { + QStringLiteral("coverUrl"), + QStringLiteral("cover_url"), + QStringLiteral("cover"), + QStringLiteral("image"), + QStringLiteral("imageUrl"), + QStringLiteral("image_url"), + QStringLiteral("art_cover"), + QStringLiteral("artCover"), + QStringLiteral("art_square"), + QStringLiteral("artSquare"), + QStringLiteral("thumbnail"), + }; + + for (const QString &key : stringKeys) { + const QString urlString = gameObj.value(key).toString(); + if (!urlString.isEmpty()) { + const QUrl url(urlString); + if (url.isValid()) { + return url; + } + } + } + + const QJsonValue keyImagesValue = gameObj.value(QStringLiteral("keyImages")); + if (keyImagesValue.isArray()) { + const QJsonArray keyImages = keyImagesValue.toArray(); + for (const QJsonValue &imgValue : keyImages) { + const QJsonObject imgObj = imgValue.toObject(); + const QString type = imgObj.value(QStringLiteral("type")).toString(); + if (type == QStringLiteral("DieselGameBoxTall") || type == QStringLiteral("Thumbnail") || type == QStringLiteral("Poster")) { + const QString urlString = imgObj.value(QStringLiteral("url")).toString(); + if (!urlString.isEmpty()) { + const QUrl url(urlString); + if (url.isValid()) { + return url; + } + } + } + } + } + + const QJsonValue imagesValue = gameObj.value(QStringLiteral("images")); + if (imagesValue.isArray()) { + const QJsonArray images = imagesValue.toArray(); + for (const QJsonValue &imgValue : images) { + const QJsonObject imgObj = imgValue.toObject(); + const QString urlString = imgObj.value(QStringLiteral("url")).toString(); + if (!urlString.isEmpty()) { + const QUrl url(urlString); + if (url.isValid()) { + return url; + } + } + } + } + + return {}; +} + +QUrl HeroicImporter::preferCachedCoverUrl(const QUrl &coverUrl) const +{ + if (!coverUrl.isValid()) { + return {}; + } + + if (coverUrl.isLocalFile() || coverUrl.scheme() == QLatin1String("file")) { + return coverUrl; + } + + QString urlString = coverUrl.toString(); + + // Heroic uses an imagecache:// scheme which percent-encodes the real image URL. + if (coverUrl.scheme() == QLatin1String("imagecache")) { + const QString prefix = QStringLiteral("imagecache://"); + if (urlString.startsWith(prefix)) { + urlString = QUrl::fromPercentEncoding(urlString.mid(prefix.size()).toUtf8()); + } + } + + const QUrl realUrl(urlString); + if (!realUrl.isValid()) { + return coverUrl; + } + + if (realUrl.scheme() != QLatin1String("http") && realUrl.scheme() != QLatin1String("https")) { + return realUrl; + } + + const QString configPath = findHeroicConfigPath(); + if (configPath.isEmpty()) { + return realUrl; + } + + const QString cacheDir = configPath + QStringLiteral("/images-cache"); + if (!QDir(cacheDir).exists()) { + return realUrl; + } + + // Upstream Heroic stores cached images as sha256(url) (no extension) inside images-cache. + const QByteArray digest = QCryptographicHash::hash(realUrl.toString().toUtf8(), QCryptographicHash::Sha256).toHex(); + const QString cachedPath = cacheDir + QStringLiteral("/") + QString::fromLatin1(digest); + if (QFile::exists(cachedPath)) { + return QUrl::fromLocalFile(cachedPath); + } + + return realUrl; +} + QUrl HeroicImporter::findCoverImage(const QString &appName, const QString &store) const { QString configPath = findHeroicConfigPath(); if (configPath.isEmpty()) { - return {}; + configPath = findLegendaryConfigPath(); } // Check images cache QStringList imageDirs = {configPath + QStringLiteral("/images-cache"), configPath + QStringLiteral("/images")}; + if (!store.isEmpty()) { + const QString storeLower = store.toLower(); + imageDirs.append(configPath + QStringLiteral("/images-cache/") + storeLower); + imageDirs.append(configPath + QStringLiteral("/images/") + storeLower); + } - QStringList extensions = {QStringLiteral(".jpg"), QStringLiteral(".png"), QStringLiteral(".webp")}; + QStringList extensions = {QStringLiteral(".jpg"), QStringLiteral(".jpeg"), QStringLiteral(".png"), QStringLiteral(".webp")}; QStringList prefixes = {appName, appName + QStringLiteral("_cover"), appName + QStringLiteral("_library")}; for (const QString &imageDir : imageDirs) { @@ -71,6 +213,16 @@ QUrl HeroicImporter::findCoverImage(const QString &appName, const QString &store } } + QStringList wildcardFilters; + wildcardFilters.reserve(extensions.count()); + for (const QString &ext : extensions) { + wildcardFilters.append(QStringLiteral("*") + appName + QStringLiteral("*") + ext); + } + const QStringList wildcardMatches = dir.entryList(wildcardFilters, QDir::Files); + if (!wildcardMatches.isEmpty()) { + return QUrl::fromLocalFile(imageDir + QStringLiteral("/") + wildcardMatches.first()); + } + // Also try looking in subdirectories QStringList subDirs = dir.entryList(QDir::Dirs | QDir::NoDotAndDotDot); for (const QString &subDir : subDirs) { @@ -82,6 +234,14 @@ QUrl HeroicImporter::findCoverImage(const QString &appName, const QString &store } } } + + QDir sub(imageDir + QStringLiteral("/") + subDir); + if (sub.exists()) { + const QStringList subMatches = sub.entryList(wildcardFilters, QDir::Files); + if (!subMatches.isEmpty()) { + return QUrl::fromLocalFile(sub.absoluteFilePath(subMatches.first())); + } + } } } @@ -95,6 +255,179 @@ QList HeroicImporter::importGames() games.append(importEpicGames()); games.append(importGOGGames()); games.append(importAmazonGames()); + games.append(importSideloadApps()); + + return games; +} + +QList HeroicImporter::importSideloadApps() +{ + QList games; + const QString configPath = findHeroicConfigPath(); + if (configPath.isEmpty()) { + return games; + } + + const QString libraryPath = configPath + QStringLiteral("/sideload_apps/library.json"); + QFile libraryFile(libraryPath); + if (!libraryFile.open(QIODevice::ReadOnly)) { + return games; + } + + const QJsonDocument doc = QJsonDocument::fromJson(libraryFile.readAll()); + + QList entries; + if (doc.isArray()) { + const QJsonArray arr = doc.array(); + entries.reserve(arr.size()); + for (const QJsonValue &value : arr) { + if (value.isObject()) { + entries.append(value.toObject()); + } + } + } else if (doc.isObject()) { + const QJsonObject root = doc.object(); + const QStringList arrayKeys = { + QStringLiteral("games"), + QStringLiteral("apps"), + QStringLiteral("library"), + QStringLiteral("items"), + }; + + bool foundArray = false; + for (const QString &key : arrayKeys) { + const QJsonValue v = root.value(key); + if (v.isArray()) { + const QJsonArray arr = v.toArray(); + entries.reserve(arr.size()); + for (const QJsonValue &item : arr) { + if (item.isObject()) { + entries.append(item.toObject()); + } + } + foundArray = true; + break; + } + } + + if (!foundArray) { + // Some formats may store entries keyed by appName. + for (auto it = root.constBegin(); it != root.constEnd(); ++it) { + if (it.value().isObject()) { + QJsonObject obj = it.value().toObject(); + if (!obj.contains(QStringLiteral("app_name")) && !obj.contains(QStringLiteral("appName"))) { + obj.insert(QStringLiteral("app_name"), it.key()); + } + entries.append(obj); + } + } + } + } else { + return games; + } + + for (const QJsonObject &obj : entries) { + QString appName = obj.value(QStringLiteral("app_name")).toString(); + if (appName.isEmpty()) { + appName = obj.value(QStringLiteral("appName")).toString(); + } + if (appName.isEmpty()) { + appName = obj.value(QStringLiteral("id")).toString(); + } + + QString title = obj.value(QStringLiteral("title")).toString(); + if (title.isEmpty()) { + title = obj.value(QStringLiteral("name")).toString(); + } + + if (appName.isEmpty() || title.isEmpty()) { + continue; + } + + QString launchCommand = obj.value(QStringLiteral("launchCommand")).toString(); + if (launchCommand.isEmpty()) { + launchCommand = obj.value(QStringLiteral("launch_command")).toString(); + } + if (launchCommand.isEmpty()) { + launchCommand = obj.value(QStringLiteral("command")).toString(); + } + if (launchCommand.isEmpty()) { + launchCommand = obj.value(QStringLiteral("exec")).toString(); + } + if (launchCommand.isEmpty()) { + launchCommand = obj.value(QStringLiteral("exe")).toString(); + } + if (launchCommand.isEmpty()) { + launchCommand = obj.value(QStringLiteral("executable")).toString(); + } + if (launchCommand.isEmpty()) { + launchCommand = obj.value(QStringLiteral("selectedExe")).toString(); + } + + QString workingDirectory = obj.value(QStringLiteral("workingDirectory")).toString(); + if (workingDirectory.isEmpty()) { + workingDirectory = obj.value(QStringLiteral("install_path")).toString(); + } + if (workingDirectory.isEmpty()) { + workingDirectory = obj.value(QStringLiteral("installPath")).toString(); + } + + bool isInstalled = obj.value(QStringLiteral("installed")).toBool(true); + if (obj.contains(QStringLiteral("is_installed"))) { + isInstalled = obj.value(QStringLiteral("is_installed")).toBool(); + } + if (obj.contains(QStringLiteral("isInstalled"))) { + isInstalled = obj.value(QStringLiteral("isInstalled")).toBool(); + } + + Game *game = new Game(QStringLiteral("heroic-sideload-%1").arg(appName), title); + game->setPlatform(QStringLiteral("Heroic (Sideload)")); + game->setPlatformId(appName); + if (!workingDirectory.isEmpty()) { + game->setWorkingDirectory(workingDirectory); + } + game->setInstalled(isInstalled); + + // We don't know if Heroic's CLI can always launch sideload apps consistently. + // Prefer an explicit command from the library file; fallback to Heroic's CLI if absent. + if (!launchCommand.isEmpty()) { + game->setLaunchCommand(launchCommand); + } else { + game->setLaunchCommand(launchCommandForApp(appName)); + } + + QString cover = obj.value(QStringLiteral("art_square")).toString(); + if (cover.isEmpty()) { + cover = obj.value(QStringLiteral("artSquare")).toString(); + } + if (cover.isEmpty()) { + cover = obj.value(QStringLiteral("art_cover")).toString(); + } + if (cover.isEmpty()) { + cover = obj.value(QStringLiteral("artCover")).toString(); + } + + QUrl coverUrl; + if (!cover.isEmpty()) { + if (cover.startsWith(QLatin1String("/"))) { + coverUrl = QUrl::fromLocalFile(cover); + } else { + coverUrl = QUrl(cover); + } + } else { + coverUrl = extractCoverUrl(obj); + } + + coverUrl = preferCachedCoverUrl(coverUrl); + if (!coverUrl.isValid()) { + coverUrl = findCoverImage(appName, QStringLiteral("sideload")); + } + if (coverUrl.isValid()) { + game->setCoverUrl(coverUrl); + } + + games.append(game); + } return games; } @@ -103,24 +436,37 @@ QList HeroicImporter::importEpicGames() { QList games; QString configPath = findHeroicConfigPath(); + QString legendaryConfigPath = findLegendaryConfigPath(); - if (configPath.isEmpty()) { + if (configPath.isEmpty() && legendaryConfigPath.isEmpty()) { return games; } // Check for installed.json - QString installedPath = configPath + QStringLiteral("/legendaryConfig/legendary/installed.json"); - QFile installedFile(installedPath); + QStringList installedPaths; + if (!legendaryConfigPath.isEmpty()) { + installedPaths.append(legendaryConfigPath + QStringLiteral("/installed.json")); + } + if (!configPath.isEmpty()) { + installedPaths.append(configPath + QStringLiteral("/legendaryConfig/legendary/installed.json")); + installedPaths.append(configPath + QStringLiteral("/store_cache/legendary_library")); + installedPaths.append(configPath + QStringLiteral("/store_cache/legendary_library.json")); + installedPaths.append(configPath + QStringLiteral("/store/legendary_library")); + installedPaths.append(configPath + QStringLiteral("/store/legendary_library.json")); + } - if (!installedFile.open(QIODevice::ReadOnly)) { - // Try alternative path - installedPath = configPath + QStringLiteral("/store_cache/legendary_library.json"); - installedFile.setFileName(installedPath); - if (!installedFile.open(QIODevice::ReadOnly)) { - return games; + QFile installedFile; + for (const QString &path : installedPaths) { + installedFile.setFileName(path); + if (installedFile.open(QIODevice::ReadOnly)) { + break; } } + if (!installedFile.isOpen()) { + return games; + } + QJsonDocument doc = QJsonDocument::fromJson(installedFile.readAll()); if (doc.isObject()) { @@ -142,7 +488,7 @@ QList HeroicImporter::importEpicGames() Game *game = new Game(QStringLiteral("heroic-epic-%1").arg(appName), title); game->setPlatform(QStringLiteral("Heroic (Epic)")); game->setPlatformId(appName); - game->setLaunchCommand(QStringLiteral("heroic --launch %1").arg(appName)); + game->setLaunchCommand(launchCommandForApp(appName)); game->setWorkingDirectory(installPath); game->setInstalled(isInstalled); @@ -152,7 +498,10 @@ QList HeroicImporter::importEpicGames() game->setDeveloper(developer); game->setPublisher(publisher); - QUrl coverUrl = findCoverImage(appName, QStringLiteral("epic")); + QUrl coverUrl = preferCachedCoverUrl(extractCoverUrl(gameObj)); + if (!coverUrl.isValid()) { + coverUrl = findCoverImage(appName, QStringLiteral("epic")); + } if (coverUrl.isValid()) { game->setCoverUrl(coverUrl); } @@ -180,10 +529,13 @@ QList HeroicImporter::importEpicGames() Game *game = new Game(QStringLiteral("heroic-epic-%1").arg(appName), title); game->setPlatform(QStringLiteral("Heroic (Epic)")); game->setPlatformId(appName); - game->setLaunchCommand(QStringLiteral("heroic --launch %1").arg(appName)); + game->setLaunchCommand(launchCommandForApp(appName)); game->setInstalled(isInstalled); - QUrl coverUrl = findCoverImage(appName, QStringLiteral("epic")); + QUrl coverUrl = preferCachedCoverUrl(extractCoverUrl(gameObj)); + if (!coverUrl.isValid()) { + coverUrl = findCoverImage(appName, QStringLiteral("epic")); + } if (coverUrl.isValid()) { game->setCoverUrl(coverUrl); } @@ -206,19 +558,27 @@ QList HeroicImporter::importGOGGames() return games; } - // Check for GOG installed games - QString installedPath = configPath + QStringLiteral("/gog_store/installed.json"); - QFile installedFile(installedPath); + // Check for GOG installed games / library cache + QStringList installedPaths = { + configPath + QStringLiteral("/gog_store/installed.json"), + configPath + QStringLiteral("/store_cache/gog_library"), + configPath + QStringLiteral("/store_cache/gog_library.json"), + configPath + QStringLiteral("/store/gog_library"), + configPath + QStringLiteral("/store/gog_library.json"), + }; - if (!installedFile.open(QIODevice::ReadOnly)) { - // Try library file - installedPath = configPath + QStringLiteral("/store_cache/gog_library.json"); - installedFile.setFileName(installedPath); - if (!installedFile.open(QIODevice::ReadOnly)) { - return games; + QFile installedFile; + for (const QString &path : installedPaths) { + installedFile.setFileName(path); + if (installedFile.open(QIODevice::ReadOnly)) { + break; } } + if (!installedFile.isOpen()) { + return games; + } + QJsonDocument doc = QJsonDocument::fromJson(installedFile.readAll()); auto parseGames = [this, &games](const QJsonArray &arr) { @@ -243,10 +603,13 @@ QList HeroicImporter::importGOGGames() Game *game = new Game(QStringLiteral("heroic-gog-%1").arg(appName), title); game->setPlatform(QStringLiteral("Heroic (GOG)")); game->setPlatformId(appName); - game->setLaunchCommand(QStringLiteral("heroic --launch %1").arg(appName)); + game->setLaunchCommand(launchCommandForApp(appName)); game->setInstalled(isInstalled); - QUrl coverUrl = findCoverImage(appName, QStringLiteral("gog")); + QUrl coverUrl = preferCachedCoverUrl(extractCoverUrl(gameObj)); + if (!coverUrl.isValid()) { + coverUrl = findCoverImage(appName, QStringLiteral("gog")); + } if (coverUrl.isValid()) { game->setCoverUrl(coverUrl); } @@ -276,10 +639,22 @@ QList HeroicImporter::importAmazonGames() return games; } - QString libraryPath = configPath + QStringLiteral("/store_cache/nile_library.json"); - QFile libraryFile(libraryPath); + QStringList libraryPaths = { + configPath + QStringLiteral("/store_cache/nile_library"), + configPath + QStringLiteral("/store_cache/nile_library.json"), + configPath + QStringLiteral("/store/nile_library"), + configPath + QStringLiteral("/store/nile_library.json"), + }; - if (!libraryFile.open(QIODevice::ReadOnly)) { + QFile libraryFile; + for (const QString &path : libraryPaths) { + libraryFile.setFileName(path); + if (libraryFile.open(QIODevice::ReadOnly)) { + break; + } + } + + if (!libraryFile.isOpen()) { return games; } @@ -304,10 +679,13 @@ QList HeroicImporter::importAmazonGames() Game *game = new Game(QStringLiteral("heroic-amazon-%1").arg(appName), title); game->setPlatform(QStringLiteral("Heroic (Amazon)")); game->setPlatformId(appName); - game->setLaunchCommand(QStringLiteral("heroic --launch %1").arg(appName)); + game->setLaunchCommand(launchCommandForApp(appName)); game->setInstalled(isInstalled); - QUrl coverUrl = findCoverImage(appName, QStringLiteral("amazon")); + QUrl coverUrl = preferCachedCoverUrl(extractCoverUrl(gameObj)); + if (!coverUrl.isValid()) { + coverUrl = findCoverImage(appName, QStringLiteral("amazon")); + } if (coverUrl.isValid()) { game->setCoverUrl(coverUrl); } diff --git a/src/heroicimporter.h b/src/heroicimporter.h index c4334f0..77cb2fd 100644 --- a/src/heroicimporter.h +++ b/src/heroicimporter.h @@ -19,8 +19,13 @@ public: private: QString findHeroicConfigPath() const; + QString findLegendaryConfigPath() const; QList importEpicGames(); QList importGOGGames(); QList importAmazonGames(); + QList importSideloadApps(); + QUrl extractCoverUrl(const QJsonObject &gameObj) const; + QUrl preferCachedCoverUrl(const QUrl &coverUrl) const; + QString launchCommandForApp(const QString &appName) const; QUrl findCoverImage(const QString &appName, const QString &store) const; };