mirror of
https://invent.kde.org/marcoa/shift-shell.git
synced 2026-04-28 22:53:09 +00:00
When a game appears as both a Steam manifest and an XDG desktop entry, drop the desktop duplicate. Steam entries are preferred because they carry cover artwork and route through the Steam launcher for Proton compatibility.
459 lines
14 KiB
C++
459 lines
14 KiB
C++
// SPDX-FileCopyrightText: 2026 Marco Allegretti
|
|
// SPDX-License-Identifier: EUPL-1.2
|
|
|
|
#include "gamelauncherprovider.h"
|
|
|
|
#include <KConfigGroup>
|
|
#include <KService>
|
|
#include <KSharedConfig>
|
|
#include <KShell>
|
|
#include <KSycoca>
|
|
|
|
#include <QDebug>
|
|
#include <QDir>
|
|
#include <QFile>
|
|
#include <QProcess>
|
|
#include <QRegularExpression>
|
|
#include <QStandardPaths>
|
|
#include <QTextStream>
|
|
|
|
static const QString s_recentGroup = QStringLiteral("GamingRecentlyPlayed");
|
|
|
|
GameLauncherProvider::GameLauncherProvider(QObject *parent)
|
|
: QAbstractListModel(parent)
|
|
, m_config(KSharedConfig::openConfig(QStringLiteral("plasmamobilerc")))
|
|
{
|
|
connect(KSycoca::self(), &KSycoca::databaseChanged, this, &GameLauncherProvider::refresh);
|
|
refresh();
|
|
}
|
|
|
|
int GameLauncherProvider::rowCount(const QModelIndex &parent) const
|
|
{
|
|
return parent.isValid() ? 0 : m_games.size();
|
|
}
|
|
|
|
QVariant GameLauncherProvider::data(const QModelIndex &index, int role) const
|
|
{
|
|
if (!index.isValid() || index.row() < 0 || index.row() >= m_games.size()) {
|
|
return {};
|
|
}
|
|
const auto &g = m_games.at(index.row());
|
|
switch (role) {
|
|
case NameRole:
|
|
return g.name;
|
|
case IconRole:
|
|
return g.icon;
|
|
case SourceRole:
|
|
return g.source;
|
|
case StorageIdRole:
|
|
return g.storageId;
|
|
case LaunchCommandRole:
|
|
return g.launchCommand;
|
|
case ArtworkRole:
|
|
return g.artwork;
|
|
case InstalledRole:
|
|
return g.installed;
|
|
}
|
|
return {};
|
|
}
|
|
|
|
QHash<int, QByteArray> GameLauncherProvider::roleNames() const
|
|
{
|
|
return {
|
|
{NameRole, "name"},
|
|
{IconRole, "icon"},
|
|
{SourceRole, "source"},
|
|
{StorageIdRole, "storageId"},
|
|
{LaunchCommandRole, "launchCommand"},
|
|
{ArtworkRole, "artwork"},
|
|
{InstalledRole, "installed"},
|
|
};
|
|
}
|
|
|
|
int GameLauncherProvider::count() const
|
|
{
|
|
return m_games.size();
|
|
}
|
|
|
|
bool GameLauncherProvider::loading() const
|
|
{
|
|
return m_loading;
|
|
}
|
|
|
|
void GameLauncherProvider::refresh()
|
|
{
|
|
m_loading = true;
|
|
Q_EMIT loadingChanged();
|
|
|
|
m_allGames.clear();
|
|
|
|
loadDesktopGames();
|
|
loadSteamGames();
|
|
loadFlatpakGames();
|
|
loadRecentTimestamps();
|
|
|
|
// Deduplicate: when the same game appears from multiple sources,
|
|
// prefer Steam (has artwork + Proton handling) over desktop.
|
|
deduplicateGames();
|
|
|
|
// Sort alphabetically, case-insensitive
|
|
std::sort(m_allGames.begin(), m_allGames.end(), [](const GameEntry &a, const GameEntry &b) {
|
|
return a.name.compare(b.name, Qt::CaseInsensitive) < 0;
|
|
});
|
|
|
|
applyFilter();
|
|
|
|
m_loading = false;
|
|
Q_EMIT loadingChanged();
|
|
}
|
|
|
|
void GameLauncherProvider::launch(int index)
|
|
{
|
|
if (index < 0 || index >= m_games.size()) {
|
|
return;
|
|
}
|
|
const auto &g = m_games.at(index);
|
|
|
|
if (g.source == QLatin1String("desktop")) {
|
|
// Launch via KService for proper activation tracking
|
|
auto service = KService::serviceByStorageId(g.storageId);
|
|
if (service) {
|
|
// Use QProcess to launch the exec line — KIO::ApplicationLauncherJob
|
|
// would be better but requires KIOWidgets which is heavy for a plugin.
|
|
QStringList args = KShell::splitArgs(service->exec());
|
|
if (!args.isEmpty()) {
|
|
QString program = args.takeFirst();
|
|
QProcess::startDetached(program, args);
|
|
}
|
|
}
|
|
} else {
|
|
// Steam, Flatpak, etc. — run the launch command directly
|
|
QStringList parts = g.launchCommand.split(QLatin1Char(' '));
|
|
if (!parts.isEmpty()) {
|
|
QString program = parts.takeFirst();
|
|
QProcess::startDetached(program, parts);
|
|
}
|
|
}
|
|
|
|
Q_EMIT gameLaunched(g.name);
|
|
|
|
// Record timestamp for "recently played"
|
|
saveRecentTimestamp(g.storageId, QDateTime::currentDateTime());
|
|
|
|
// Update the in-memory entry so recentGames() picks it up immediately
|
|
for (auto &entry : m_allGames) {
|
|
if (entry.storageId == g.storageId) {
|
|
entry.lastPlayed = QDateTime::currentDateTime();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
void GameLauncherProvider::launchByStorageId(const QString &storageId)
|
|
{
|
|
for (int i = 0; i < m_allGames.size(); ++i) {
|
|
if (m_allGames.at(i).storageId == storageId) {
|
|
// Find the index in the filtered model, or launch from allGames directly
|
|
for (int j = 0; j < m_games.size(); ++j) {
|
|
if (m_games.at(j).storageId == storageId) {
|
|
launch(j);
|
|
return;
|
|
}
|
|
}
|
|
// Not in filtered view — launch directly from allGames
|
|
const auto &g = m_allGames.at(i);
|
|
if (g.source == QLatin1String("desktop")) {
|
|
auto service = KService::serviceByStorageId(g.storageId);
|
|
if (service) {
|
|
QStringList args = KShell::splitArgs(service->exec());
|
|
if (!args.isEmpty()) {
|
|
QString program = args.takeFirst();
|
|
QProcess::startDetached(program, args);
|
|
}
|
|
}
|
|
} else {
|
|
QStringList parts = g.launchCommand.split(QLatin1Char(' '));
|
|
if (!parts.isEmpty()) {
|
|
QString program = parts.takeFirst();
|
|
QProcess::startDetached(program, parts);
|
|
}
|
|
}
|
|
Q_EMIT gameLaunched(g.name);
|
|
saveRecentTimestamp(g.storageId, QDateTime::currentDateTime());
|
|
m_allGames[i].lastPlayed = QDateTime::currentDateTime();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
void GameLauncherProvider::deduplicateGames()
|
|
{
|
|
// Build a set of names from Steam entries (case-insensitive).
|
|
QSet<QString> steamNames;
|
|
for (const auto &g : std::as_const(m_allGames)) {
|
|
if (g.source == QLatin1String("steam")) {
|
|
steamNames.insert(g.name.toLower());
|
|
}
|
|
}
|
|
|
|
// Remove desktop entries whose name matches a Steam entry.
|
|
m_allGames.erase(std::remove_if(m_allGames.begin(),
|
|
m_allGames.end(),
|
|
[&steamNames](const GameEntry &g) {
|
|
return g.source == QLatin1String("desktop") && steamNames.contains(g.name.toLower());
|
|
}),
|
|
m_allGames.end());
|
|
}
|
|
|
|
// --- XDG .desktop games ---
|
|
|
|
void GameLauncherProvider::loadDesktopGames()
|
|
{
|
|
const auto services = KService::allServices();
|
|
for (const auto &service : services) {
|
|
if (service->noDisplay() || service->exec().isEmpty()) {
|
|
continue;
|
|
}
|
|
const QStringList cats = service->categories();
|
|
bool isGame = false;
|
|
for (const auto &cat : cats) {
|
|
if (cat.compare(QLatin1String("Game"), Qt::CaseInsensitive) == 0) {
|
|
isGame = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!isGame) {
|
|
continue;
|
|
}
|
|
|
|
GameEntry entry;
|
|
entry.name = service->name();
|
|
entry.icon = service->icon();
|
|
entry.source = QStringLiteral("desktop");
|
|
entry.storageId = service->storageId();
|
|
entry.launchCommand = service->exec();
|
|
entry.installed = true;
|
|
m_allGames.append(entry);
|
|
}
|
|
}
|
|
|
|
// --- Steam library ---
|
|
|
|
void GameLauncherProvider::loadSteamGames()
|
|
{
|
|
// Look for Steam library folders
|
|
const QStringList steamRoots = {
|
|
QDir::homePath() + QStringLiteral("/.steam/steam"),
|
|
QDir::homePath() + QStringLiteral("/.local/share/Steam"),
|
|
// Flatpak Steam
|
|
QDir::homePath() + QStringLiteral("/.var/app/com.valvesoftware.Steam/.steam/steam"),
|
|
QDir::homePath() + QStringLiteral("/.var/app/com.valvesoftware.Steam/.local/share/Steam"),
|
|
};
|
|
|
|
QStringList libraryPaths;
|
|
for (const auto &root : steamRoots) {
|
|
const QString vdfPath = root + QStringLiteral("/steamapps/libraryfolders.vdf");
|
|
QFile vdf(vdfPath);
|
|
if (!vdf.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
|
continue;
|
|
}
|
|
// Simple parse: look for "path" lines
|
|
static const QRegularExpression pathRe(QStringLiteral("\"path\"\\s+\"([^\"]+)\""));
|
|
QTextStream stream(&vdf);
|
|
while (!stream.atEnd()) {
|
|
const QString line = stream.readLine();
|
|
auto match = pathRe.match(line);
|
|
if (match.hasMatch()) {
|
|
libraryPaths.append(match.captured(1));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Scan each library path for appmanifest_*.acf
|
|
static const QRegularExpression nameRe(QStringLiteral("\"name\"\\s+\"([^\"]+)\""));
|
|
static const QRegularExpression appidRe(QStringLiteral("\"appid\"\\s+\"(\\d+)\""));
|
|
|
|
for (const auto &libPath : std::as_const(libraryPaths)) {
|
|
QDir steamapps(libPath + QStringLiteral("/steamapps"));
|
|
if (!steamapps.exists()) {
|
|
continue;
|
|
}
|
|
const auto manifests = steamapps.entryList({QStringLiteral("appmanifest_*.acf")}, QDir::Files);
|
|
for (const auto &manifest : manifests) {
|
|
QFile f(steamapps.filePath(manifest));
|
|
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
|
continue;
|
|
}
|
|
QString appName;
|
|
QString appId;
|
|
QTextStream ts(&f);
|
|
while (!ts.atEnd()) {
|
|
const QString line = ts.readLine();
|
|
if (appName.isEmpty()) {
|
|
auto m = nameRe.match(line);
|
|
if (m.hasMatch()) {
|
|
appName = m.captured(1);
|
|
}
|
|
}
|
|
if (appId.isEmpty()) {
|
|
auto m = appidRe.match(line);
|
|
if (m.hasMatch()) {
|
|
appId = m.captured(1);
|
|
}
|
|
}
|
|
if (!appName.isEmpty() && !appId.isEmpty()) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (appName.isEmpty() || appId.isEmpty()) {
|
|
continue;
|
|
}
|
|
|
|
// Skip Steamworks Common Redistributables and Proton/tools
|
|
if (appId == QLatin1String("228980")) {
|
|
continue;
|
|
}
|
|
|
|
GameEntry entry;
|
|
entry.name = appName;
|
|
entry.icon = QStringLiteral("steam");
|
|
entry.source = QStringLiteral("steam");
|
|
entry.storageId = QStringLiteral("steam://rungameid/") + appId;
|
|
entry.launchCommand = QStringLiteral("steam steam://rungameid/") + appId;
|
|
entry.installed = true;
|
|
|
|
// Check for grid artwork
|
|
for (const auto &root : steamRoots) {
|
|
const QString gridDir = root + QStringLiteral("/appcache/librarycache/") + appId;
|
|
const QStringList artSuffixes = {
|
|
QStringLiteral("_library_600x900.jpg"),
|
|
QStringLiteral("_header.jpg"),
|
|
};
|
|
for (const auto &suffix : artSuffixes) {
|
|
const QString artPath = gridDir + suffix;
|
|
if (QFile::exists(artPath)) {
|
|
entry.artwork = artPath;
|
|
break;
|
|
}
|
|
}
|
|
if (!entry.artwork.isEmpty()) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
m_allGames.append(entry);
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Flatpak games (non-Steam) ---
|
|
|
|
void GameLauncherProvider::loadFlatpakGames()
|
|
{
|
|
// Flatpak games that export .desktop files with Game category
|
|
// are already picked up by loadDesktopGames() via KService.
|
|
// This method is a hook for future Flatpak-specific enrichment
|
|
// (e.g. querying flatpak metadata for games that don't set
|
|
// the Game category properly).
|
|
}
|
|
|
|
QString GameLauncherProvider::filterString() const
|
|
{
|
|
return m_filterString;
|
|
}
|
|
|
|
void GameLauncherProvider::setFilterString(const QString &filter)
|
|
{
|
|
if (m_filterString == filter) {
|
|
return;
|
|
}
|
|
m_filterString = filter;
|
|
Q_EMIT filterStringChanged();
|
|
applyFilter();
|
|
}
|
|
|
|
QString GameLauncherProvider::sourceFilter() const
|
|
{
|
|
return m_sourceFilter;
|
|
}
|
|
|
|
void GameLauncherProvider::setSourceFilter(const QString &source)
|
|
{
|
|
if (m_sourceFilter == source) {
|
|
return;
|
|
}
|
|
m_sourceFilter = source;
|
|
Q_EMIT sourceFilterChanged();
|
|
applyFilter();
|
|
}
|
|
|
|
void GameLauncherProvider::applyFilter()
|
|
{
|
|
beginResetModel();
|
|
m_games.clear();
|
|
|
|
for (const auto &g : std::as_const(m_allGames)) {
|
|
if (!m_sourceFilter.isEmpty() && g.source != m_sourceFilter) {
|
|
continue;
|
|
}
|
|
if (!m_filterString.isEmpty() && !g.name.contains(m_filterString, Qt::CaseInsensitive)) {
|
|
continue;
|
|
}
|
|
m_games.append(g);
|
|
}
|
|
|
|
endResetModel();
|
|
Q_EMIT countChanged();
|
|
}
|
|
|
|
void GameLauncherProvider::loadRecentTimestamps()
|
|
{
|
|
const KConfigGroup group(m_config, s_recentGroup);
|
|
for (auto &entry : m_allGames) {
|
|
const QString key = entry.storageId;
|
|
if (group.hasKey(key)) {
|
|
entry.lastPlayed = group.readEntry(key, QDateTime());
|
|
}
|
|
}
|
|
}
|
|
|
|
void GameLauncherProvider::saveRecentTimestamp(const QString &storageId, const QDateTime &when)
|
|
{
|
|
KConfigGroup group(m_config, s_recentGroup);
|
|
group.writeEntry(storageId, when);
|
|
group.sync();
|
|
}
|
|
|
|
QVariantList GameLauncherProvider::recentGames(int limit) const
|
|
{
|
|
// Gather entries that have been launched at least once
|
|
QList<const GameEntry *> recent;
|
|
for (const auto &g : m_allGames) {
|
|
if (g.lastPlayed.isValid()) {
|
|
recent.append(&g);
|
|
}
|
|
}
|
|
|
|
// Most recent first
|
|
std::sort(recent.begin(), recent.end(), [](const GameEntry *a, const GameEntry *b) {
|
|
return a->lastPlayed > b->lastPlayed;
|
|
});
|
|
|
|
if (recent.size() > limit) {
|
|
recent.resize(limit);
|
|
}
|
|
|
|
QVariantList result;
|
|
result.reserve(recent.size());
|
|
for (const auto *g : recent) {
|
|
QVariantMap map;
|
|
map[QStringLiteral("name")] = g->name;
|
|
map[QStringLiteral("icon")] = g->icon;
|
|
map[QStringLiteral("source")] = g->source;
|
|
map[QStringLiteral("storageId")] = g->storageId;
|
|
map[QStringLiteral("artwork")] = g->artwork;
|
|
result.append(map);
|
|
}
|
|
return result;
|
|
}
|