mirror of
https://invent.kde.org/marcoa/a-la-karte.git
synced 2026-02-09 21:13:08 +00:00
Ignore Steam runtime/redistributable entries and other non-game content. Also skip manifests that are not fully installed and ensure the install folder exists before importing.
268 lines
7.9 KiB
C++
268 lines
7.9 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors
|
|
|
|
#include "steamimporter.h"
|
|
|
|
#include <QDir>
|
|
#include <QFile>
|
|
#include <QFileInfo>
|
|
#include <QRegularExpression>
|
|
#include <QStandardPaths>
|
|
#include <QStringList>
|
|
#include <QTextStream>
|
|
|
|
SteamImporter::SteamImporter(QObject *parent)
|
|
: PlatformImporter(parent)
|
|
{
|
|
}
|
|
|
|
QString SteamImporter::platformName() const
|
|
{
|
|
return QStringLiteral("Steam");
|
|
}
|
|
|
|
QString SteamImporter::platformId() const
|
|
{
|
|
return QStringLiteral("steam");
|
|
}
|
|
|
|
bool SteamImporter::isAvailable() const
|
|
{
|
|
return !findSteamPath().isEmpty();
|
|
}
|
|
|
|
QString SteamImporter::findSteamPath() const
|
|
{
|
|
// Common Steam installation paths on Linux
|
|
QStringList possiblePaths = {expandPath(QStringLiteral("~/.steam/steam")),
|
|
expandPath(QStringLiteral("~/.local/share/Steam")),
|
|
expandPath(QStringLiteral("~/.var/app/com.valvesoftware.Steam/.steam/steam")),
|
|
expandPath(QStringLiteral("~/.var/app/com.valvesoftware.Steam/.local/share/Steam")),
|
|
QStringLiteral("/usr/share/steam"),
|
|
QStringLiteral("/usr/local/share/steam")};
|
|
|
|
for (const QString &path : possiblePaths) {
|
|
if (directoryExists(path)) {
|
|
return path;
|
|
}
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
QString SteamImporter::steamPath() const
|
|
{
|
|
return findSteamPath();
|
|
}
|
|
|
|
QStringList SteamImporter::findLibraryFolders() const
|
|
{
|
|
QStringList folders;
|
|
QString steamDir = findSteamPath();
|
|
|
|
if (steamDir.isEmpty()) {
|
|
return folders;
|
|
}
|
|
|
|
// Add main Steam directory
|
|
QString steamApps = steamDir + QStringLiteral("/steamapps");
|
|
if (directoryExists(steamApps)) {
|
|
folders.append(steamApps);
|
|
}
|
|
|
|
// Parse libraryfolders.vdf
|
|
QString libraryFoldersPath = steamApps + QStringLiteral("/libraryfolders.vdf");
|
|
QFile file(libraryFoldersPath);
|
|
|
|
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
|
return folders;
|
|
}
|
|
|
|
QTextStream in(&file);
|
|
QString content = in.readAll();
|
|
|
|
// Simple VDF parser for library folders
|
|
static QRegularExpression pathRegex(QStringLiteral("\"path\"\\s+\"([^\"]+)\""));
|
|
QRegularExpressionMatchIterator it = pathRegex.globalMatch(content);
|
|
|
|
while (it.hasNext()) {
|
|
QRegularExpressionMatch match = it.next();
|
|
QString path = match.captured(1);
|
|
QString appsPath = path + QStringLiteral("/steamapps");
|
|
|
|
if (directoryExists(appsPath) && !folders.contains(appsPath)) {
|
|
folders.append(appsPath);
|
|
}
|
|
}
|
|
|
|
return folders;
|
|
}
|
|
|
|
Game *SteamImporter::parseAppManifest(const QString &path)
|
|
{
|
|
QFile file(path);
|
|
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
|
return nullptr;
|
|
}
|
|
|
|
QTextStream in(&file);
|
|
QString content = in.readAll();
|
|
|
|
// Simple VDF parser
|
|
auto getValue = [&content](const QString &key) -> QString {
|
|
QRegularExpression regex(QStringLiteral("\"%1\"\\s+\"([^\"]+)\"").arg(key));
|
|
QRegularExpressionMatch match = regex.match(content);
|
|
return match.hasMatch() ? match.captured(1) : QString();
|
|
};
|
|
|
|
QString appId = getValue(QStringLiteral("appid"));
|
|
QString name = getValue(QStringLiteral("name"));
|
|
QString installDir = getValue(QStringLiteral("installdir"));
|
|
|
|
if (appId.isEmpty() || name.isEmpty()) {
|
|
return nullptr;
|
|
}
|
|
|
|
auto isNonGameEntry = [](const QString &title, const QString &dir) {
|
|
auto normalize = [](QString s) {
|
|
s = s.toLower();
|
|
s.remove(QLatin1Char(' '));
|
|
s.remove(QLatin1Char('_'));
|
|
s.remove(QLatin1Char('-'));
|
|
return s;
|
|
};
|
|
|
|
const QString t = normalize(title);
|
|
const QString d = normalize(dir);
|
|
static const QStringList patterns = {
|
|
QStringLiteral("steamlinuxruntime"),
|
|
QStringLiteral("steamworkscommonredistributables"),
|
|
QStringLiteral("steamworkssdkredist"),
|
|
QStringLiteral("shaderprecaching"),
|
|
QStringLiteral("proton"),
|
|
QStringLiteral("steamvr"),
|
|
};
|
|
for (const QString &p : patterns) {
|
|
if (t.contains(p) || d.contains(p)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
if (isNonGameEntry(name, installDir)) {
|
|
return nullptr;
|
|
}
|
|
|
|
// Skip tools and other non-game content
|
|
QString stateFlags = getValue(QStringLiteral("StateFlags"));
|
|
if (!stateFlags.isEmpty()) {
|
|
bool ok = false;
|
|
const int flags = stateFlags.toInt(&ok);
|
|
if (ok) {
|
|
constexpr int InstalledFlag = 4;
|
|
if ((flags & InstalledFlag) == 0) {
|
|
return nullptr;
|
|
}
|
|
}
|
|
}
|
|
|
|
QFileInfo manifestInfo(path);
|
|
const QString gameDir = manifestInfo.absolutePath() + QStringLiteral("/common/") + installDir;
|
|
if (!QDir(gameDir).exists()) {
|
|
return nullptr;
|
|
}
|
|
|
|
Game *game = new Game(QStringLiteral("steam-%1").arg(appId), name);
|
|
game->setPlatform(platformName());
|
|
game->setPlatformId(appId);
|
|
game->setLaunchCommand(QStringLiteral("steam://rungameid/%1").arg(appId));
|
|
|
|
// Find cover image
|
|
QUrl coverUrl = findCoverImage(appId);
|
|
if (coverUrl.isValid()) {
|
|
game->setCoverUrl(coverUrl);
|
|
}
|
|
|
|
// Set installation directory
|
|
game->setWorkingDirectory(gameDir);
|
|
|
|
game->setInstalled(true);
|
|
|
|
return game;
|
|
}
|
|
|
|
QUrl SteamImporter::findCoverImage(const QString &appId) const
|
|
{
|
|
QString steamDir = findSteamPath();
|
|
if (steamDir.isEmpty()) {
|
|
return {};
|
|
}
|
|
|
|
// Check local cache first
|
|
QStringList coverNames = {QStringLiteral("library_600x900.jpg"),
|
|
QStringLiteral("library_600x900_2x.jpg"),
|
|
QStringLiteral("header.jpg"),
|
|
QStringLiteral("capsule_616x353.jpg")};
|
|
|
|
QString cacheDir = steamDir + QStringLiteral("/appcache/librarycache");
|
|
for (const QString &coverName : coverNames) {
|
|
QString coverPath = cacheDir + QStringLiteral("/%1_%2").arg(appId, coverName);
|
|
if (QFile::exists(coverPath)) {
|
|
return QUrl::fromLocalFile(coverPath);
|
|
}
|
|
}
|
|
|
|
// Check alternative grid path
|
|
QString gridDir = steamDir + QStringLiteral("/userdata");
|
|
QDir userDataDir(gridDir);
|
|
QStringList userDirs = userDataDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot);
|
|
|
|
for (const QString &userDir : userDirs) {
|
|
QString gridPath = gridDir + QStringLiteral("/%1/config/grid").arg(userDir);
|
|
QDir grid(gridPath);
|
|
|
|
if (grid.exists()) {
|
|
QStringList filters = {appId + QStringLiteral("p.*"), appId + QStringLiteral(".*")};
|
|
QStringList images = grid.entryList(filters, QDir::Files);
|
|
|
|
if (!images.isEmpty()) {
|
|
return QUrl::fromLocalFile(gridPath + QStringLiteral("/") + images.first());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Return Steam CDN URL as fallback
|
|
return QUrl(QStringLiteral("https://steamcdn-a.akamaihd.net/steam/apps/%1/library_600x900_2x.jpg").arg(appId));
|
|
}
|
|
|
|
QList<Game *> SteamImporter::importGames()
|
|
{
|
|
QList<Game *> games;
|
|
QStringList libraryFolders = findLibraryFolders();
|
|
|
|
int total = 0;
|
|
int current = 0;
|
|
|
|
// Count manifests first
|
|
for (const QString &folder : libraryFolders) {
|
|
QDir dir(folder);
|
|
total += dir.entryList({QStringLiteral("appmanifest_*.acf")}, QDir::Files).count();
|
|
}
|
|
|
|
for (const QString &folder : libraryFolders) {
|
|
QDir dir(folder);
|
|
QStringList manifests = dir.entryList({QStringLiteral("appmanifest_*.acf")}, QDir::Files);
|
|
|
|
for (const QString &manifest : manifests) {
|
|
Game *game = parseAppManifest(folder + QStringLiteral("/") + manifest);
|
|
if (game) {
|
|
games.append(game);
|
|
}
|
|
current++;
|
|
Q_EMIT importProgress(current, total);
|
|
}
|
|
}
|
|
|
|
return games;
|
|
}
|