// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: 2024 A-La-Karte Contributors #include "heroicimporter.h" #include #include #include #include #include #include #include HeroicImporter::HeroicImporter(QObject *parent) : PlatformImporter(parent) { } QString HeroicImporter::platformName() const { return QStringLiteral("Heroic"); } QString HeroicImporter::platformId() const { return QStringLiteral("heroic"); } bool HeroicImporter::isAvailable() const { return directoryExists(findHeroicConfigPath()) || directoryExists(findLegendaryConfigPath()); } QString HeroicImporter::findHeroicConfigPath() const { QStringList possiblePaths = {expandPath(QStringLiteral("~/.config/heroic")), expandPath(QStringLiteral("~/.var/app/com.heroicgameslauncher.hgl/config/heroic"))}; for (const QString &path : possiblePaths) { if (directoryExists(path)) { return path; } } 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()) { 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(".jpeg"), QStringLiteral(".png"), QStringLiteral(".webp")}; QStringList prefixes = {appName, appName + QStringLiteral("_cover"), appName + QStringLiteral("_library")}; for (const QString &imageDir : imageDirs) { QDir dir(imageDir); if (!dir.exists()) continue; for (const QString &prefix : prefixes) { for (const QString &ext : extensions) { QString imagePath = imageDir + QStringLiteral("/") + prefix + ext; if (QFile::exists(imagePath)) { return QUrl::fromLocalFile(imagePath); } } } 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) { for (const QString &prefix : prefixes) { for (const QString &ext : extensions) { QString imagePath = imageDir + QStringLiteral("/") + subDir + QStringLiteral("/") + prefix + ext; if (QFile::exists(imagePath)) { return QUrl::fromLocalFile(imagePath); } } } 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())); } } } } return {}; } QList HeroicImporter::importGames() { QList games; 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; } QList HeroicImporter::importEpicGames() { QList games; QString configPath = findHeroicConfigPath(); QString legendaryConfigPath = findLegendaryConfigPath(); if (configPath.isEmpty() && legendaryConfigPath.isEmpty()) { return games; } // Check for installed.json 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")); } 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()) { QJsonObject obj = doc.object(); int current = 0; int total = obj.keys().count(); for (const QString &appName : obj.keys()) { QJsonObject gameObj = obj[appName].toObject(); QString title = gameObj[QStringLiteral("title")].toString(); if (title.isEmpty()) { title = appName; } QString installPath = gameObj[QStringLiteral("install_path")].toString(); bool isInstalled = !installPath.isEmpty(); Game *game = new Game(QStringLiteral("heroic-epic-%1").arg(appName), title); game->setPlatform(QStringLiteral("Heroic (Epic)")); game->setPlatformId(appName); game->setLaunchCommand(launchCommandForApp(appName)); game->setWorkingDirectory(installPath); game->setInstalled(isInstalled); // Developer/Publisher info QString developer = gameObj[QStringLiteral("developer")].toString(); QString publisher = gameObj[QStringLiteral("publisher")].toString(); game->setDeveloper(developer); game->setPublisher(publisher); QUrl coverUrl = preferCachedCoverUrl(extractCoverUrl(gameObj)); if (!coverUrl.isValid()) { coverUrl = findCoverImage(appName, QStringLiteral("epic")); } if (coverUrl.isValid()) { game->setCoverUrl(coverUrl); } games.append(game); current++; Q_EMIT importProgress(current, total); } } else if (doc.isArray()) { QJsonArray arr = doc.array(); int current = 0; int total = arr.count(); for (const QJsonValue &value : arr) { QJsonObject gameObj = value.toObject(); QString appName = gameObj[QStringLiteral("app_name")].toString(); QString title = gameObj[QStringLiteral("title")].toString(); if (appName.isEmpty() || title.isEmpty()) continue; bool isInstalled = gameObj[QStringLiteral("is_installed")].toBool(); Game *game = new Game(QStringLiteral("heroic-epic-%1").arg(appName), title); game->setPlatform(QStringLiteral("Heroic (Epic)")); game->setPlatformId(appName); game->setLaunchCommand(launchCommandForApp(appName)); game->setInstalled(isInstalled); QUrl coverUrl = preferCachedCoverUrl(extractCoverUrl(gameObj)); if (!coverUrl.isValid()) { coverUrl = findCoverImage(appName, QStringLiteral("epic")); } if (coverUrl.isValid()) { game->setCoverUrl(coverUrl); } games.append(game); current++; Q_EMIT importProgress(current, total); } } return games; } QList HeroicImporter::importGOGGames() { QList games; QString configPath = findHeroicConfigPath(); if (configPath.isEmpty()) { return games; } // 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"), }; 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) { for (const QJsonValue &value : arr) { QJsonObject gameObj = value.toObject(); QString appName = gameObj[QStringLiteral("app_name")].toString(); if (appName.isEmpty()) { appName = gameObj[QStringLiteral("appName")].toString(); } QString title = gameObj[QStringLiteral("title")].toString(); if (title.isEmpty()) { title = gameObj[QStringLiteral("name")].toString(); } if (appName.isEmpty() || title.isEmpty()) continue; bool isInstalled = gameObj[QStringLiteral("is_installed")].toBool() || gameObj[QStringLiteral("isInstalled")].toBool(); Game *game = new Game(QStringLiteral("heroic-gog-%1").arg(appName), title); game->setPlatform(QStringLiteral("Heroic (GOG)")); game->setPlatformId(appName); game->setLaunchCommand(launchCommandForApp(appName)); game->setInstalled(isInstalled); QUrl coverUrl = preferCachedCoverUrl(extractCoverUrl(gameObj)); if (!coverUrl.isValid()) { coverUrl = findCoverImage(appName, QStringLiteral("gog")); } if (coverUrl.isValid()) { game->setCoverUrl(coverUrl); } games.append(game); } }; if (doc.isArray()) { parseGames(doc.array()); } else if (doc.isObject()) { QJsonObject obj = doc.object(); if (obj.contains(QStringLiteral("games"))) { parseGames(obj[QStringLiteral("games")].toArray()); } } return games; } QList HeroicImporter::importAmazonGames() { QList games; QString configPath = findHeroicConfigPath(); if (configPath.isEmpty()) { return games; } 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"), }; QFile libraryFile; for (const QString &path : libraryPaths) { libraryFile.setFileName(path); if (libraryFile.open(QIODevice::ReadOnly)) { break; } } if (!libraryFile.isOpen()) { return games; } QJsonDocument doc = QJsonDocument::fromJson(libraryFile.readAll()); if (!doc.isArray()) { return games; } QJsonArray arr = doc.array(); for (const QJsonValue &value : arr) { QJsonObject gameObj = value.toObject(); QString appName = gameObj[QStringLiteral("app_name")].toString(); QString title = gameObj[QStringLiteral("title")].toString(); if (appName.isEmpty() || title.isEmpty()) continue; bool isInstalled = gameObj[QStringLiteral("is_installed")].toBool(); Game *game = new Game(QStringLiteral("heroic-amazon-%1").arg(appName), title); game->setPlatform(QStringLiteral("Heroic (Amazon)")); game->setPlatformId(appName); game->setLaunchCommand(launchCommandForApp(appName)); game->setInstalled(isInstalled); QUrl coverUrl = preferCachedCoverUrl(extractCoverUrl(gameObj)); if (!coverUrl.isValid()) { coverUrl = findCoverImage(appName, QStringLiteral("amazon")); } if (coverUrl.isValid()) { game->setCoverUrl(coverUrl); } games.append(game); } return games; }