From fa9c86415f894e9df84e2a80bfc8f891d694372e Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Fri, 6 Feb 2026 14:02:24 +0100 Subject: [PATCH] Launcher: use Game Center daemon Route game launching and running-state tracking through the\nGame Center DBus daemon. This removes the in-process QProcess-based\ntracking in favor of daemon sessions and adds availability checks. --- src/gamelauncher.cpp | 558 +++++++++++++++++++++++++------------------ src/gamelauncher.h | 22 +- 2 files changed, 336 insertions(+), 244 deletions(-) diff --git a/src/gamelauncher.cpp b/src/gamelauncher.cpp index eeb9c5b..36205e6 100644 --- a/src/gamelauncher.cpp +++ b/src/gamelauncher.cpp @@ -5,11 +5,16 @@ #include "app.h" #include +#include +#include +#include +#include +#include #include #include #include #include -#include +#include #include #include #include @@ -26,6 +31,23 @@ static QStringList steamCandidateRoots() }; } +static QVariant unwrapDbusVariant(QVariant v) +{ + if (v.canConvert()) { + v = v.value().variant(); + } + return v; +} + +static QVariantMap unwrapVariantMap(const QVariantMap &map) +{ + QVariantMap out; + for (auto it = map.constBegin(); it != map.constEnd(); ++it) { + out.insert(it.key(), unwrapDbusVariant(it.value())); + } + return out; +} + static QString findSteamClientInstallPathFromProton(const QString &protonExe) { if (protonExe.isEmpty()) { @@ -111,25 +133,60 @@ static QString discoverDefaultProtonExecutable() GameLauncher::GameLauncher(QObject *parent) : QObject(parent) { + QDBusConnection bus = QDBusConnection::sessionBus(); + if (bus.isConnected()) { + bus.connect(QStringLiteral("org.kde.GameCenter1"), + QStringLiteral("/org/kde/GameCenter1"), + QStringLiteral("org.kde.GameCenter1"), + QStringLiteral("SessionAdded"), + this, + SLOT(onDaemonSessionAdded(QVariantMap))); + + bus.connect(QStringLiteral("org.kde.GameCenter1"), + QStringLiteral("/org/kde/GameCenter1"), + QStringLiteral("org.kde.GameCenter1"), + QStringLiteral("SessionChanged"), + this, + SLOT(onDaemonSessionChanged(QVariantMap))); + + bus.connect(QStringLiteral("org.kde.GameCenter1"), + QStringLiteral("/org/kde/GameCenter1"), + QStringLiteral("org.kde.GameCenter1"), + QStringLiteral("SessionRemoved"), + this, + SLOT(onDaemonSessionRemoved(QString, QVariantMap))); + + bus.connect(QStringLiteral("org.kde.GameCenter1"), + QStringLiteral("/org/kde/GameCenter1"), + QStringLiteral("org.kde.GameCenter1"), + QStringLiteral("LaunchFailed"), + this, + SLOT(onDaemonLaunchFailed(QVariantMap))); + } + + if (auto *app = qobject_cast(parent)) { + connect(app->gameModel(), &GameModel::countChanged, this, [this]() { + QTimer::singleShot(0, this, [this]() { + applyRunningStateToLibrary(); + }); + }); + } + + QTimer::singleShot(0, this, [this]() { + checkDaemonAvailability(); + syncDaemonSessions(); + }); } GameLauncher::~GameLauncher() { - // Cleanup all running processes - for (QProcess *process : m_runningGames.values()) { - process->disconnect(); - process->terminate(); - process->waitForFinished(3000); - process->deleteLater(); - } - - m_runningGames.clear(); - m_processToGame.clear(); + m_daemonGameToSession.clear(); + m_daemonSessionToGame.clear(); } bool GameLauncher::hasRunningGames() const { - return !m_runningGames.isEmpty(); + return !m_daemonGameToSession.isEmpty(); } QVariantMap GameLauncher::resolveLaunchInfo(Game *game) const @@ -144,7 +201,18 @@ QVariantMap GameLauncher::resolveLaunchInfo(Game *game) const info.insert(QStringLiteral("gameId"), game->id()); info.insert(QStringLiteral("gameName"), game->name()); - info.insert(QStringLiteral("isRunning"), m_runningGames.contains(game->id())); + info.insert(QStringLiteral("displayName"), game->name()); + info.insert(QStringLiteral("isRunning"), m_daemonGameToSession.contains(game->id())); + + // Detect provider from platform + const QString platform = game->platform().toLower(); + if (platform == QLatin1String("steam")) { + info.insert(QStringLiteral("provider"), QStringLiteral("steam")); + } else if (platform == QLatin1String("lutris")) { + info.insert(QStringLiteral("provider"), QStringLiteral("lutris")); + } else { + info.insert(QStringLiteral("provider"), QStringLiteral("manual")); + } const QString command = game->launchCommand(); info.insert(QStringLiteral("launchCommand"), command); @@ -179,7 +247,7 @@ QVariantMap GameLauncher::resolveLaunchInfo(Game *game) const info.insert(QStringLiteral("ok"), true); info.insert(QStringLiteral("launchType"), QStringLiteral("url")); info.insert(QStringLiteral("url"), command); - info.insert(QStringLiteral("usesDesktopServices"), true); + info.insert(QStringLiteral("provider"), QStringLiteral("steam")); return info; } @@ -187,7 +255,7 @@ QVariantMap GameLauncher::resolveLaunchInfo(Game *game) const info.insert(QStringLiteral("ok"), true); info.insert(QStringLiteral("launchType"), QStringLiteral("url")); info.insert(QStringLiteral("url"), command.mid(7)); - info.insert(QStringLiteral("usesDesktopServices"), true); + info.insert(QStringLiteral("provider"), QStringLiteral("lutris")); return info; } @@ -286,281 +354,297 @@ QVariantMap GameLauncher::resolveLaunchInfo(Game *game) const } void GameLauncher::launchGame(Game *game) + { if (!game) { return; } - QString command = game->launchCommand(); - if (command.isEmpty()) { - Q_EMIT gameError(game, tr("No launch command configured")); - return; - } - - const QString runner = game->launchRunner().trimmed(); - const QString runnerPath = game->launchRunnerPath().trimmed(); - const QString prefixPath = game->launchPrefixPath().trimmed(); - const QVariantMap launchEnv = game->launchEnv(); - const bool hasLaunchOverrides = !runner.isEmpty() || !runnerPath.isEmpty() || !prefixPath.isEmpty() || !launchEnv.isEmpty(); - // Check if already running - if (m_runningGames.contains(game->id())) { + if (m_daemonGameToSession.contains(game->id())) { Q_EMIT gameError(game, tr("Game is already running")); return; } - // Handle Steam URLs - if (!hasLaunchOverrides && command.startsWith(QLatin1String("steam://"))) { - QDesktopServices::openUrl(QUrl(command)); - game->setLastPlayed(QDateTime::currentDateTime()); - Q_EMIT gameStarted(game); + const QVariantMap info = resolveLaunchInfo(game); + if (!info.value(QStringLiteral("ok")).toBool()) { + Q_EMIT gameError(game, info.value(QStringLiteral("error")).toString()); return; } - // Handle Lutris URLs - if (!hasLaunchOverrides && command.startsWith(QLatin1String("lutris "))) { - QString lutrisCommand = command.mid(7); // Remove "lutris " - QDesktopServices::openUrl(QUrl(lutrisCommand)); - game->setLastPlayed(QDateTime::currentDateTime()); - Q_EMIT gameStarted(game); - return; + const QString launchCommand = info.value(QStringLiteral("launchCommand")).toString(); + const QString launchType = info.value(QStringLiteral("launchType")).toString(); + const QString provider = info.value(QStringLiteral("provider")).toString(); + + if (info.value(QStringLiteral("runner")).toString() == QLatin1String("proton")) { + const QString resolvedPrefixPath = info.value(QStringLiteral("resolvedPrefixPath")).toString(); + if (!resolvedPrefixPath.isEmpty()) { + QDir().mkpath(resolvedPrefixPath); + } } - // Start process for other commands - QProcess *process = new QProcess(this); + const QString finalProgram = info.value(QStringLiteral("finalProgram")).toString(); + const QStringList finalArgs = info.value(QStringLiteral("finalArgs")).toStringList(); + const QString workingDirectory = info.value(QStringLiteral("workingDirectory")).toString(); + const QVariantMap effectiveEnv = info.value(QStringLiteral("effectiveEnv")).toMap(); - // Apply per-game environment variables - QProcessEnvironment env; - bool envChanged = false; - if (!launchEnv.isEmpty()) { - env = QProcessEnvironment::systemEnvironment(); - envChanged = true; - for (auto it = launchEnv.constBegin(); it != launchEnv.constEnd(); ++it) { - const QString key = it.key(); - if (key.isEmpty() || key.contains(QLatin1Char('='))) { - continue; + // Always try daemon first — for all launch types + { + QDBusInterface iface(QStringLiteral("org.kde.GameCenter1"), + QStringLiteral("/org/kde/GameCenter1"), + QStringLiteral("org.kde.GameCenter1"), + QDBusConnection::sessionBus()); + QVariantMap launchSpec = { + {QStringLiteral("command"), launchCommand}, + {QStringLiteral("gameId"), game->id()}, + {QStringLiteral("displayName"), game->name()}, + {QStringLiteral("provider"), provider}, + {QStringLiteral("origin"), QStringLiteral("ui")}, + }; + if (!finalProgram.isEmpty()) { + launchSpec.insert(QStringLiteral("program"), finalProgram); + launchSpec.insert(QStringLiteral("args"), finalArgs); + } + if (!effectiveEnv.isEmpty()) { + launchSpec.insert(QStringLiteral("envOverrides"), effectiveEnv); + } + if (!workingDirectory.isEmpty()) { + launchSpec.insert(QStringLiteral("workingDirectory"), workingDirectory); + } + + const QDBusReply reply = iface.call(QStringLiteral("Launch"), launchSpec); + if (reply.isValid() && !reply.value().isEmpty()) { + m_daemonGameToSession.insert(game->id(), reply.value()); + m_daemonSessionToGame.insert(reply.value(), game->id()); + game->setRunning(true); + game->setLastPlayed(QDateTime::currentDateTime()); + Q_EMIT gameStarted(game); + Q_EMIT runningGamesChanged(); + + if (App::instance()->config()->exitAfterLaunch()) { + QTimer::singleShot(500, qApp, &QCoreApplication::quit); } - env.insert(key, it.value().toString()); - } - } - - // Set working directory if available - if (!game->workingDirectory().isEmpty()) { - process->setWorkingDirectory(game->workingDirectory()); - } - - connect(process, QOverload::of(&QProcess::finished), this, &GameLauncher::onProcessFinished); - connect(process, &QProcess::errorOccurred, this, &GameLauncher::onProcessError); - - m_runningGames.insert(game->id(), process); - m_processToGame.insert(process, game); - - // Parse command and arguments - QStringList parts; - if (command.startsWith(QLatin1String("steam://"))) { - parts = {QStringLiteral("xdg-open"), command}; - } else if (command.startsWith(QLatin1String("lutris "))) { - parts = {QStringLiteral("xdg-open"), command.mid(7)}; - } else { - parts = QProcess::splitCommand(command); - } - if (parts.isEmpty()) { - cleanupProcess(process); - Q_EMIT gameError(game, tr("Invalid launch command")); - return; - } - - QString program = parts.takeFirst(); - - QString finalProgram = program; - QStringList finalArgs = parts; - - if ((runner == QLatin1String("wine") || runner == QLatin1String("proton")) && (program == QLatin1String("xdg-open"))) { - cleanupProcess(process); - Q_EMIT gameError(game, tr("This runner cannot be used with URL-based launch commands")); - return; - } - - if (runner == QLatin1String("custom")) { - if (runnerPath.isEmpty()) { - cleanupProcess(process); - Q_EMIT gameError(game, tr("Custom runner is enabled but no runner path is configured")); - return; - } - finalProgram = runnerPath; - finalArgs = {program}; - finalArgs.append(parts); - } else if (runner == QLatin1String("wine")) { - QString wineExe = runnerPath; - if (wineExe.isEmpty()) { - wineExe = QStandardPaths::findExecutable(QStringLiteral("wine")); - } - if (wineExe.isEmpty()) { - cleanupProcess(process); - Q_EMIT gameError(game, tr("Wine runner is enabled but Wine was not found")); return; } - QString winePrefix = prefixPath; - if (winePrefix.isEmpty()) { - winePrefix = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + QStringLiteral("/prefixes/") + game->id(); - } - QDir().mkpath(winePrefix); - - if (!envChanged) { - env = QProcessEnvironment::systemEnvironment(); - envChanged = true; - } - env.insert(QStringLiteral("WINEPREFIX"), winePrefix); - - finalProgram = wineExe; - finalArgs = {program}; - finalArgs.append(parts); - } else if (runner == QLatin1String("proton")) { - QString protonExe = runnerPath; - if (protonExe.isEmpty()) { - protonExe = discoverDefaultProtonExecutable(); - } - if (protonExe.isEmpty()) { - cleanupProcess(process); - Q_EMIT gameError(game, tr("Proton runner is enabled but no Proton installation was found")); - return; + // Daemon call failed — fallback for URL-type commands only + if (launchType == QLatin1String("url")) { + const QString url = info.value(QStringLiteral("url")).toString(); + if (!url.isEmpty()) { + QDesktopServices::openUrl(QUrl(url)); + game->setLastPlayed(QDateTime::currentDateTime()); + Q_EMIT gameStarted(game); + return; + } } - QString steamInstallPath = findSteamClientInstallPathFromProton(protonExe); - - QString compatDataPath = prefixPath; - if (compatDataPath.isEmpty()) { - compatDataPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + QStringLiteral("/prefixes/") + game->id(); - } - QDir().mkpath(compatDataPath); - - if (!envChanged) { - env = QProcessEnvironment::systemEnvironment(); - envChanged = true; - } - env.insert(QStringLiteral("STEAM_COMPAT_DATA_PATH"), compatDataPath); - if (!steamInstallPath.isEmpty()) { - env.insert(QStringLiteral("STEAM_COMPAT_CLIENT_INSTALL_PATH"), steamInstallPath); - } - - finalProgram = protonExe; - finalArgs = {QStringLiteral("run"), program}; - finalArgs.append(parts); - } - - if (envChanged) { - process->setProcessEnvironment(env); - } - - process->start(finalProgram, finalArgs); - - if (!process->waitForStarted(5000)) { - QString error = process->errorString(); - cleanupProcess(process); - Q_EMIT gameError(game, tr("Failed to start game: %1").arg(error)); - return; - } - - game->setRunning(true); - game->setLastPlayed(QDateTime::currentDateTime()); - Q_EMIT gameStarted(game); - Q_EMIT runningGamesChanged(); - - // Exit after launch if configured - if (App::instance()->config()->exitAfterLaunch()) { - QTimer::singleShot(500, qApp, &QCoreApplication::quit); + // No fallback for non-URL commands — emit error + Q_EMIT gameError(game, tr("Game Center daemon is not available")); } } void GameLauncher::stopGame(Game *game) { - if (!game || !m_runningGames.contains(game->id())) { + if (!game) { return; } - QProcess *process = m_runningGames.value(game->id()); - process->terminate(); - - if (!process->waitForFinished(5000)) { - process->kill(); - } + QDBusInterface iface(QStringLiteral("org.kde.GameCenter1"), + QStringLiteral("/org/kde/GameCenter1"), + QStringLiteral("org.kde.GameCenter1"), + QDBusConnection::sessionBus()); + iface.call(QStringLiteral("StopByGameId"), game->id()); } bool GameLauncher::isGameRunning(Game *game) const { - return game && m_runningGames.contains(game->id()); + return game && m_daemonGameToSession.contains(game->id()); } -void GameLauncher::onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus) +void GameLauncher::onDaemonSessionAdded(const QVariantMap &session) { - Q_UNUSED(exitStatus) - - QProcess *process = qobject_cast(sender()); - if (!process) { + const QVariantMap s = unwrapVariantMap(session); + const QString sessionId = s.value(QStringLiteral("sessionId")).toString(); + const QString gameId = s.value(QStringLiteral("gameId")).toString(); + if (sessionId.isEmpty() || gameId.isEmpty()) { return; } - Game *game = m_processToGame.value(process); + if (m_daemonSessionToGame.contains(sessionId)) { + return; + } + + m_daemonGameToSession.insert(gameId, sessionId); + m_daemonSessionToGame.insert(sessionId, gameId); + + Game *game = App::instance()->gameModel()->gameById(gameId); + if (game) { + game->setRunning(true); + } + Q_EMIT runningGamesChanged(); + + applyRunningStateToLibrary(); +} + +void GameLauncher::onDaemonSessionChanged(const QVariantMap &session) +{ + const QVariantMap s = unwrapVariantMap(session); + const QString sessionId = s.value(QStringLiteral("sessionId")).toString(); + const QString gameId = s.value(QStringLiteral("gameId")).toString(); + if (sessionId.isEmpty() || gameId.isEmpty()) { + return; + } + + m_daemonGameToSession.insert(gameId, sessionId); + m_daemonSessionToGame.insert(sessionId, gameId); + + const QString state = s.value(QStringLiteral("state")).toString(); + if (state == QLatin1String("Exited")) { + return; + } + + Game *game = App::instance()->gameModel()->gameById(gameId); + if (game) { + game->setRunning(true); + } + + Q_EMIT runningGamesChanged(); + + applyRunningStateToLibrary(); +} + +void GameLauncher::onDaemonSessionRemoved(const QString &sessionId, const QVariantMap &finalState) +{ + if (sessionId.isEmpty()) { + return; + } + + QString gameId = m_daemonSessionToGame.take(sessionId); + if (!gameId.isEmpty()) { + m_daemonGameToSession.remove(gameId); + } else { + const QVariantMap fs = unwrapVariantMap(finalState); + gameId = fs.value(QStringLiteral("gameId")).toString(); + if (!gameId.isEmpty()) { + m_daemonGameToSession.remove(gameId); + } + } + + Game *game = !gameId.isEmpty() ? App::instance()->gameModel()->gameById(gameId) : nullptr; if (game) { game->setRunning(false); - Q_EMIT gameStopped(game, exitCode); + const QVariantMap fs = unwrapVariantMap(finalState); + Q_EMIT gameStopped(game, fs.value(QStringLiteral("exitCode")).toInt()); } - - cleanupProcess(process); Q_EMIT runningGamesChanged(); + + applyRunningStateToLibrary(); } -void GameLauncher::onProcessError(QProcess::ProcessError error) +void GameLauncher::onDaemonLaunchFailed(const QVariantMap &error) { - QProcess *process = qobject_cast(sender()); - if (!process) { + const QVariantMap e = unwrapVariantMap(error); + QVariantMap context; + { + QVariant ctx = unwrapDbusVariant(e.value(QStringLiteral("context"))); + if (ctx.canConvert()) { + context = ctx.toMap(); + } else if (ctx.canConvert()) { + context = qdbus_cast(ctx.value()); + } + } + + QString gameId = unwrapDbusVariant(context.value(QStringLiteral("gameId"))).toString(); + if (gameId.isEmpty()) { + gameId = e.value(QStringLiteral("gameId")).toString(); + } + if (gameId.isEmpty()) { return; } - Game *game = m_processToGame.value(process); - - QString errorMessage; - switch (error) { - case QProcess::FailedToStart: - errorMessage = tr("Failed to start the game process"); - break; - case QProcess::Crashed: - errorMessage = tr("Game process crashed"); - break; - case QProcess::Timedout: - errorMessage = tr("Game process timed out"); - break; - case QProcess::WriteError: - errorMessage = tr("Failed to write to game process"); - break; - case QProcess::ReadError: - errorMessage = tr("Failed to read from game process"); - break; - default: - errorMessage = tr("Unknown error occurred"); - break; - } - - if (game) { - game->setRunning(false); - Q_EMIT gameError(game, errorMessage); - } - - cleanupProcess(process); - Q_EMIT runningGamesChanged(); -} - -void GameLauncher::cleanupProcess(QProcess *process) -{ - if (!process) { + const QString message = e.value(QStringLiteral("error")).toString(); + if (message.isEmpty()) { return; } - Game *game = m_processToGame.take(process); - if (game) { - m_runningGames.remove(game->id()); + QTimer::singleShot(100, this, [this, gameId, message]() { + if (m_daemonGameToSession.contains(gameId)) { + return; + } + + Game *game = App::instance()->gameModel()->gameById(gameId); + if (!game) { + return; + } + + Q_EMIT gameError(game, message); + }); +} + +void GameLauncher::syncDaemonSessions() +{ + QDBusInterface iface(QStringLiteral("org.kde.GameCenter1"), + QStringLiteral("/org/kde/GameCenter1"), + QStringLiteral("org.kde.GameCenter1"), + QDBusConnection::sessionBus()); + + const QDBusReply reply = iface.call(QStringLiteral("ListSessions")); + if (!reply.isValid()) { + return; } - process->deleteLater(); + const QVariantList list = reply.value(); + for (const QVariant &v : list) { + QVariantMap map; + if (v.canConvert()) { + map = v.toMap(); + } else if (v.canConvert()) { + map = qdbus_cast(v.value()); + } + if (!map.isEmpty()) { + onDaemonSessionAdded(map); + } + } + + applyRunningStateToLibrary(); +} + +void GameLauncher::applyRunningStateToLibrary() +{ + const QList games = App::instance()->gameModel()->allGames(); + for (Game *game : games) { + if (!game) { + continue; + } + const bool shouldBeRunning = m_daemonGameToSession.contains(game->id()); + game->setRunning(shouldBeRunning); + } +} + +void GameLauncher::checkDaemonAvailability() +{ + QDBusInterface iface(QStringLiteral("org.kde.GameCenter1"), + QStringLiteral("/org/kde/GameCenter1"), + QStringLiteral("org.kde.GameCenter1"), + QDBusConnection::sessionBus()); + + const QDBusReply reply = iface.call(QStringLiteral("Ping")); + const bool available = reply.isValid() && reply.value() == QLatin1String("ok"); + if (available != m_daemonAvailable) { + m_daemonAvailable = available; + Q_EMIT daemonAvailableChanged(); + } +} + +bool GameLauncher::daemonAvailable() const +{ + return m_daemonAvailable; +} + +void GameLauncher::retryDaemonConnection() +{ + checkDaemonAvailability(); + if (m_daemonAvailable) { + syncDaemonSessions(); + } } diff --git a/src/gamelauncher.h b/src/gamelauncher.h index 972ceae..a4eddee 100644 --- a/src/gamelauncher.h +++ b/src/gamelauncher.h @@ -3,9 +3,8 @@ #pragma once -#include +#include #include -#include #include #include @@ -17,31 +16,40 @@ class GameLauncher : public QObject QML_ELEMENT Q_PROPERTY(bool hasRunningGames READ hasRunningGames NOTIFY runningGamesChanged) + Q_PROPERTY(bool daemonAvailable READ daemonAvailable NOTIFY daemonAvailableChanged) public: explicit GameLauncher(QObject *parent = nullptr); ~GameLauncher(); bool hasRunningGames() const; + bool daemonAvailable() const; Q_INVOKABLE void launchGame(Game *game); Q_INVOKABLE QVariantMap resolveLaunchInfo(Game *game) const; Q_INVOKABLE void stopGame(Game *game); Q_INVOKABLE bool isGameRunning(Game *game) const; + Q_INVOKABLE void retryDaemonConnection(); Q_SIGNALS: void gameStarted(Game *game); void gameStopped(Game *game, int exitCode); void gameError(Game *game, const QString &error); void runningGamesChanged(); + void daemonAvailableChanged(); private Q_SLOTS: - void onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus); - void onProcessError(QProcess::ProcessError error); + void onDaemonSessionAdded(const QVariantMap &session); + void onDaemonSessionChanged(const QVariantMap &session); + void onDaemonSessionRemoved(const QString &sessionId, const QVariantMap &finalState); + void onDaemonLaunchFailed(const QVariantMap &error); private: - QMap m_runningGames; - QMap m_processToGame; + QHash m_daemonGameToSession; + QHash m_daemonSessionToGame; + bool m_daemonAvailable = false; - void cleanupProcess(QProcess *process); + void checkDaemonAvailability(); + void syncDaemonSessions(); + void applyRunningStateToLibrary(); };