// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: 2024 A-La-Karte Contributors #include "gamelauncher.h" #include "app.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include static QStringList steamCandidateRoots() { const QString home = QDir::homePath(); return { home + QStringLiteral("/.steam/root"), home + QStringLiteral("/.steam/steam"), home + QStringLiteral("/.local/share/Steam"), home + QStringLiteral("/.var/app/com.valvesoftware.Steam/data/Steam"), home + QStringLiteral("/.var/app/com.valvesoftware.Steam/.local/share/Steam"), }; } 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()) { return {}; } QDir dir(QFileInfo(protonExe).absoluteDir()); for (int i = 0; i < 10; ++i) { if (dir.exists(QStringLiteral("steamapps"))) { return dir.absolutePath(); } if (!dir.cdUp()) { break; } } for (const QString &root : steamCandidateRoots()) { if (QDir(root).exists(QStringLiteral("steamapps"))) { return root; } } return {}; } static QString discoverDefaultProtonExecutable() { static bool done = false; static QString cached; if (done) { return cached; } done = true; QStringList candidates; for (const QString &root : steamCandidateRoots()) { const QString compatTools = root + QStringLiteral("/compatibilitytools.d"); QDir compatDir(compatTools); if (compatDir.exists()) { const QStringList toolDirs = compatDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot); for (const QString &tool : toolDirs) { const QString protonPath = compatTools + QLatin1Char('/') + tool + QStringLiteral("/proton"); if (QFileInfo::exists(protonPath)) { candidates.append(protonPath); } } } const QString common = root + QStringLiteral("/steamapps/common"); QDir commonDir(common); if (commonDir.exists()) { const QStringList dirs = commonDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot); for (const QString &d : dirs) { if (!d.contains(QStringLiteral("Proton"), Qt::CaseInsensitive)) { continue; } const QString protonPath = common + QLatin1Char('/') + d + QStringLiteral("/proton"); if (QFileInfo::exists(protonPath)) { candidates.append(protonPath); } } } } candidates.removeDuplicates(); candidates.sort(); for (const QString &c : candidates) { if (c.contains(QStringLiteral("Proton - Experimental"))) { cached = c; return cached; } } if (!candidates.isEmpty()) { cached = candidates.constLast(); } return cached; } 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() { m_daemonGameToSession.clear(); m_daemonSessionToGame.clear(); } bool GameLauncher::hasRunningGames() const { return !m_daemonGameToSession.isEmpty(); } QVariantMap GameLauncher::resolveLaunchInfo(Game *game) const { QVariantMap info; info.insert(QStringLiteral("ok"), false); if (!game) { info.insert(QStringLiteral("error"), tr("No game selected")); return info; } info.insert(QStringLiteral("gameId"), game->id()); info.insert(QStringLiteral("gameName"), game->name()); 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); if (command.isEmpty()) { info.insert(QStringLiteral("error"), tr("No launch command configured")); return info; } 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(); info.insert(QStringLiteral("runner"), runner); info.insert(QStringLiteral("runnerPath"), runnerPath); info.insert(QStringLiteral("prefixPath"), prefixPath); info.insert(QStringLiteral("workingDirectory"), game->workingDirectory()); info.insert(QStringLiteral("hasLaunchOverrides"), hasLaunchOverrides); QVariantMap envOverrides; for (auto it = launchEnv.constBegin(); it != launchEnv.constEnd(); ++it) { const QString key = it.key(); if (key.isEmpty() || key.contains(QLatin1Char('='))) { continue; } envOverrides.insert(key, it.value().toString()); } info.insert(QStringLiteral("envOverrides"), envOverrides); if (!hasLaunchOverrides && command.startsWith(QLatin1String("steam://"))) { info.insert(QStringLiteral("ok"), true); info.insert(QStringLiteral("launchType"), QStringLiteral("url")); info.insert(QStringLiteral("url"), command); info.insert(QStringLiteral("provider"), QStringLiteral("steam")); return info; } if (!hasLaunchOverrides && command.startsWith(QLatin1String("lutris "))) { info.insert(QStringLiteral("ok"), true); info.insert(QStringLiteral("launchType"), QStringLiteral("url")); info.insert(QStringLiteral("url"), command.mid(7)); info.insert(QStringLiteral("provider"), QStringLiteral("lutris")); return info; } 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()) { info.insert(QStringLiteral("error"), tr("Invalid launch command")); return info; } const QString program = parts.takeFirst(); info.insert(QStringLiteral("program"), program); info.insert(QStringLiteral("args"), parts); QString finalProgram = program; QStringList finalArgs = parts; QVariantMap effectiveEnv = envOverrides; if ((runner == QLatin1String("wine") || runner == QLatin1String("proton")) && (program == QLatin1String("xdg-open"))) { info.insert(QStringLiteral("error"), tr("This runner cannot be used with URL-based launch commands")); return info; } if (runner == QLatin1String("custom")) { if (runnerPath.isEmpty()) { info.insert(QStringLiteral("error"), tr("Custom runner is enabled but no runner path is configured")); return info; } 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()) { info.insert(QStringLiteral("error"), tr("Wine runner is enabled but Wine was not found")); return info; } QString winePrefix = prefixPath; if (winePrefix.isEmpty()) { winePrefix = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + QStringLiteral("/prefixes/") + game->id(); } effectiveEnv.insert(QStringLiteral("WINEPREFIX"), winePrefix); info.insert(QStringLiteral("resolvedPrefixPath"), winePrefix); finalProgram = wineExe; finalArgs = {program}; finalArgs.append(parts); } else if (runner == QLatin1String("proton")) { QString protonExe = runnerPath; if (protonExe.isEmpty()) { protonExe = discoverDefaultProtonExecutable(); } if (protonExe.isEmpty()) { info.insert(QStringLiteral("error"), tr("Proton runner is enabled but no Proton installation was found")); return info; } const QString steamInstallPath = findSteamClientInstallPathFromProton(protonExe); info.insert(QStringLiteral("resolvedSteamInstallPath"), steamInstallPath); QString compatDataPath = prefixPath; if (compatDataPath.isEmpty()) { compatDataPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + QStringLiteral("/prefixes/") + game->id(); } effectiveEnv.insert(QStringLiteral("STEAM_COMPAT_DATA_PATH"), compatDataPath); info.insert(QStringLiteral("resolvedPrefixPath"), compatDataPath); if (!steamInstallPath.isEmpty()) { effectiveEnv.insert(QStringLiteral("STEAM_COMPAT_CLIENT_INSTALL_PATH"), steamInstallPath); } finalProgram = protonExe; finalArgs = {QStringLiteral("run"), program}; finalArgs.append(parts); } info.insert(QStringLiteral("finalProgram"), finalProgram); info.insert(QStringLiteral("finalArgs"), finalArgs); info.insert(QStringLiteral("effectiveEnv"), effectiveEnv); info.insert(QStringLiteral("ok"), true); return info; } void GameLauncher::launchGame(Game *game) { if (!game) { return; } // Check if already running if (m_daemonGameToSession.contains(game->id())) { Q_EMIT gameError(game, tr("Game is already running")); return; } const QVariantMap info = resolveLaunchInfo(game); if (!info.value(QStringLiteral("ok")).toBool()) { Q_EMIT gameError(game, info.value(QStringLiteral("error")).toString()); 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); } } 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(); // 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); } 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; } } // 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) { return; } 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_daemonGameToSession.contains(game->id()); } void GameLauncher::onDaemonSessionAdded(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; } 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); const QVariantMap fs = unwrapVariantMap(finalState); Q_EMIT gameStopped(game, fs.value(QStringLiteral("exitCode")).toInt()); } Q_EMIT runningGamesChanged(); applyRunningStateToLibrary(); } void GameLauncher::onDaemonLaunchFailed(const QVariantMap &error) { 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; } const QString message = e.value(QStringLiteral("error")).toString(); if (message.isEmpty()) { return; } 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; } 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(); } }