From 017073c16204eb99b6c1c4ff5b67fe1716fe59d9 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Fri, 6 Feb 2026 14:01:35 +0100 Subject: [PATCH] Add Game Center daemon target Build and install the Game Center daemon as a DBus-activated\nuser service. This introduces the new src/gamecenter target and\nwires it into the main build. --- src/CMakeLists.txt | 2 + src/gamecenter/CMakeLists.txt | 44 + .../dbus/org.kde.GameCenter1.service.in | 4 + src/gamecenter/gamecenterdaemon.cpp | 1155 +++++++++++++++++ src/gamecenter/gamecenterdaemon.h | 96 ++ src/gamecenter/main.cpp | 18 + src/gamecenter/processscanner.cpp | 183 +++ src/gamecenter/processscanner.h | 48 + .../systemd/org.kde.GameCenter1.service.in | 9 + src/gamecenter/systemdusermanager.cpp | 183 +++ src/gamecenter/systemdusermanager.h | 69 + 11 files changed, 1811 insertions(+) create mode 100644 src/gamecenter/CMakeLists.txt create mode 100644 src/gamecenter/dbus/org.kde.GameCenter1.service.in create mode 100644 src/gamecenter/gamecenterdaemon.cpp create mode 100644 src/gamecenter/gamecenterdaemon.h create mode 100644 src/gamecenter/main.cpp create mode 100644 src/gamecenter/processscanner.cpp create mode 100644 src/gamecenter/processscanner.h create mode 100644 src/gamecenter/systemd/org.kde.GameCenter1.service.in create mode 100644 src/gamecenter/systemdusermanager.cpp create mode 100644 src/gamecenter/systemdusermanager.h diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 4b23883..6ece23c 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -137,3 +137,5 @@ ecm_add_qml_module(alakarte URI org.kde.alakarte ) install(TARGETS alakarte ${KDE_INSTALL_TARGETS_DEFAULT_ARGS}) + +add_subdirectory(gamecenter) diff --git a/src/gamecenter/CMakeLists.txt b/src/gamecenter/CMakeLists.txt new file mode 100644 index 0000000..b7af8e5 --- /dev/null +++ b/src/gamecenter/CMakeLists.txt @@ -0,0 +1,44 @@ +# SPDX-License-Identifier: BSD-3-Clause +# SPDX-FileCopyrightText: 2026 A-La-Karte Contributors + +add_executable(alakarte_gamecenter + main.cpp + gamecenterdaemon.cpp + gamecenterdaemon.h + processscanner.cpp + processscanner.h + systemdusermanager.cpp + systemdusermanager.h +) + +target_link_libraries(alakarte_gamecenter PRIVATE + Qt6::Core + Qt6::DBus +) + +set_target_properties(alakarte_gamecenter PROPERTIES + OUTPUT_NAME "alakarte-gamecenter" +) + +install(TARGETS alakarte_gamecenter ${KDE_INSTALL_TARGETS_DEFAULT_ARGS}) + +file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/dbus") +file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/systemd") + +configure_file(dbus/org.kde.GameCenter1.service.in + "${CMAKE_CURRENT_BINARY_DIR}/dbus/org.kde.GameCenter1.service" + @ONLY +) + +configure_file(systemd/org.kde.GameCenter1.service.in + "${CMAKE_CURRENT_BINARY_DIR}/systemd/org.kde.GameCenter1.service" + @ONLY +) + +install(FILES "${CMAKE_CURRENT_BINARY_DIR}/dbus/org.kde.GameCenter1.service" + DESTINATION ${KDE_INSTALL_DBUSSERVICEDIR} +) + +install(FILES "${CMAKE_CURRENT_BINARY_DIR}/systemd/org.kde.GameCenter1.service" + DESTINATION ${KDE_INSTALL_SYSTEMDUSERUNITDIR} +) diff --git a/src/gamecenter/dbus/org.kde.GameCenter1.service.in b/src/gamecenter/dbus/org.kde.GameCenter1.service.in new file mode 100644 index 0000000..7799e49 --- /dev/null +++ b/src/gamecenter/dbus/org.kde.GameCenter1.service.in @@ -0,0 +1,4 @@ +[D-BUS Service] +Name=org.kde.GameCenter1 +Exec=@CMAKE_INSTALL_PREFIX@/@KDE_INSTALL_BINDIR@/alakarte-gamecenter +SystemdService=org.kde.GameCenter1.service diff --git a/src/gamecenter/gamecenterdaemon.cpp b/src/gamecenter/gamecenterdaemon.cpp new file mode 100644 index 0000000..7cda242 --- /dev/null +++ b/src/gamecenter/gamecenterdaemon.cpp @@ -0,0 +1,1155 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors + +#include "gamecenterdaemon.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +static QString ensureScopeUnitName(const QString &unitName) +{ + if (unitName.endsWith(QLatin1String(".scope"))) { + return unitName; + } + + return unitName + QStringLiteral(".scope"); +} + +namespace +{ + +static QVariant unwrapDbusVariant(QVariant v) +{ + if (v.canConvert()) { + v = v.value().variant(); + } + return v; +} + +static QVariant normalizeForDbus(QVariant v) +{ + v = unwrapDbusVariant(v); + + if (v.userType() == QMetaType::QChar) { + return QString(v.toChar()); + } + + if (v.canConvert()) { + const QDBusArgument arg = v.value(); + const QMap asStringMap = qdbus_cast>(arg); + if (!asStringMap.isEmpty()) { + QVariantMap converted; + for (auto it = asStringMap.constBegin(); it != asStringMap.constEnd(); ++it) { + converted.insert(it.key(), it.value()); + } + return normalizeForDbus(converted); + } + + const QVariantMap asMap = qdbus_cast(arg); + if (!asMap.isEmpty()) { + return normalizeForDbus(asMap); + } + + const QStringList asStringList = qdbus_cast(arg); + if (!asStringList.isEmpty()) { + return asStringList; + } + } + if (v.canConvert()) { + QVariantMap m = v.toMap(); + for (auto it = m.begin(); it != m.end(); ++it) { + it.value() = normalizeForDbus(it.value()); + } + return m; + } + if (v.canConvert()) { + QVariantList list = v.toList(); + for (QVariant &item : list) { + item = normalizeForDbus(item); + } + return list; + } + return v; +} + +static QVariantMap normalizeVariantMapForDbus(const QVariantMap &map) +{ + QVariantMap out; + for (auto it = map.constBegin(); it != map.constEnd(); ++it) { + out.insert(it.key(), normalizeForDbus(it.value())); + } + return out; +} + +static QString mapStringValue(const QVariantMap &map, const QStringList &keys) +{ + for (const QString &k : keys) { + const auto it = map.constFind(k); + if (it == map.constEnd()) { + continue; + } + return unwrapDbusVariant(*it).toString(); + } + return {}; +} + +static QStringList variantToStringList(QVariant v) +{ + v = unwrapDbusVariant(v); + if (v.canConvert()) { + return v.toStringList(); + } + if (v.canConvert()) { + const QVariantList list = v.toList(); + QStringList out; + out.reserve(list.size()); + for (const QVariant &item : list) { + out.push_back(unwrapDbusVariant(item).toString()); + } + return out; + } + if (v.canConvert()) { + const QDBusArgument arg = v.value(); + const QStringList out = qdbus_cast(arg); + if (!out.isEmpty()) { + return out; + } + } + const QString s = v.toString(); + if (!s.trimmed().isEmpty()) { + return QProcess::splitCommand(s); + } + return {}; +} + +class UnitPropertiesWatcher : public QObject +{ + Q_OBJECT +public: + explicit UnitPropertiesWatcher(const QDBusObjectPath &path, QObject *parent = nullptr) + : QObject(parent) + , m_path(path) + { + } + + QDBusObjectPath path() const + { + return m_path; + } + +public Q_SLOTS: + void onPropertiesChanged(const QString &interfaceName, const QVariantMap &changedProperties, const QStringList &invalidated) + { + Q_UNUSED(invalidated) + if (interfaceName != QLatin1String("org.freedesktop.systemd1.Unit")) { + return; + } + Q_EMIT propertiesChanged(m_path, changedProperties); + } + +Q_SIGNALS: + void propertiesChanged(const QDBusObjectPath &path, const QVariantMap &changedProperties); + +private: + QDBusObjectPath m_path; +}; + +} // namespace + +void GameCenterDaemon::watchSystemdUnit(const QString &sessionId, const QString &unitName, const QDBusObjectPath &unitPath) +{ + if (unitName.isEmpty()) { + return; + } + + m_unitNameToSessionId.insert(unitName, sessionId); + + if (unitPath.path().isEmpty()) { + return; + } + + const QString unitPathKey = unitPath.path(); + m_unitPathToSessionId.insert(unitPathKey, sessionId); + + if (m_unitPathWatchers.contains(unitPathKey)) { + return; + } + + auto *watcher = new UnitPropertiesWatcher(unitPath, this); + m_unitPathWatchers.insert(unitPathKey, watcher); + + QDBusConnection bus = QDBusConnection::sessionBus(); + bus.connect(QStringLiteral("org.freedesktop.systemd1"), + unitPath.path(), + QStringLiteral("org.freedesktop.DBus.Properties"), + QStringLiteral("PropertiesChanged"), + watcher, + SLOT(onPropertiesChanged(QString, QVariantMap, QStringList))); + + connect(watcher, &UnitPropertiesWatcher::propertiesChanged, this, &GameCenterDaemon::handleSystemdUnitPropertiesChanged); +} + +void GameCenterDaemon::unwatchSystemdUnit(const QString &unitName, const QDBusObjectPath &unitPath) +{ + if (!unitName.isEmpty()) { + m_unitNameToSessionId.remove(unitName); + } + + if (!unitPath.path().isEmpty()) { + const QString unitPathKey = unitPath.path(); + m_unitPathToSessionId.remove(unitPathKey); + if (QObject *watcher = m_unitPathWatchers.take(unitPathKey)) { + watcher->deleteLater(); + } + } +} + +void GameCenterDaemon::handleSystemdUnitNew(const QString &unitName, const QDBusObjectPath &unitPath) +{ + const auto it = m_unitNameToSessionId.constFind(unitName); + if (it == m_unitNameToSessionId.constEnd()) { + return; + } + + const QString sessionId = it.value(); + auto sit = m_sessions.find(sessionId); + if (sit == m_sessions.end()) { + return; + } + + if (sit.value().unitPath.path().isEmpty() && !unitPath.path().isEmpty()) { + sit.value().unitPath = unitPath; + watchSystemdUnit(sessionId, unitName, unitPath); + } +} + +void GameCenterDaemon::handleSystemdUnitRemoved(const QString &unitName, const QDBusObjectPath &unitPath) +{ + QString sessionId; + + if (!unitPath.path().isEmpty()) { + const auto it = m_unitPathToSessionId.constFind(unitPath.path()); + if (it != m_unitPathToSessionId.constEnd()) { + sessionId = it.value(); + } + } + + if (sessionId.isEmpty() && !unitName.isEmpty()) { + const auto it = m_unitNameToSessionId.constFind(unitName); + if (it != m_unitNameToSessionId.constEnd()) { + sessionId = it.value(); + } + } + + if (sessionId.isEmpty()) { + return; + } + + auto sit = m_sessions.find(sessionId); + if (sit == m_sessions.end()) { + unwatchSystemdUnit(unitName, unitPath); + return; + } + + unwatchSystemdUnit(sit.value().unitName, sit.value().unitPath); + removeSessionInternal(sessionId, sit.value().stopping ? QStringLiteral("Stopped") : QStringLiteral("Exited")); +} + +void GameCenterDaemon::handleSystemdUnitPropertiesChanged(const QDBusObjectPath &unitPath, const QVariantMap &changedProperties) +{ + if (unitPath.path().isEmpty()) { + return; + } + + const auto sidIt = m_unitPathToSessionId.constFind(unitPath.path()); + if (sidIt == m_unitPathToSessionId.constEnd()) { + return; + } + const QString sessionId = sidIt.value(); + + auto sit = m_sessions.find(sessionId); + if (sit == m_sessions.end()) { + return; + } + + QVariant activeStateV = changedProperties.value(QStringLiteral("ActiveState")); + if (activeStateV.canConvert()) { + activeStateV = activeStateV.value().variant(); + } + if (!activeStateV.isValid()) { + return; + } + const QString activeState = activeStateV.toString(); + + if (activeState == QLatin1String("active") || activeState == QLatin1String("activating") || activeState == QLatin1String("deactivating")) { + const QList pids = m_systemd.scopePids(unitPath); + if (!pids.isEmpty()) { + sit.value().mainPid = pids.first(); + } + Q_EMIT SessionChanged(sessionToVariantMap(sit.value(), sessionState(sit.value()))); + return; + } + + const QString finalState = + sit.value().stopping ? QStringLiteral("Stopped") : (activeState == QLatin1String("failed") ? QStringLiteral("Failed") : QStringLiteral("Exited")); + unwatchSystemdUnit(sit.value().unitName, sit.value().unitPath); + removeSessionInternal(sessionId, finalState); +} + +void GameCenterDaemon::removeSessionInternal(const QString &sessionId, const QString &finalState) +{ + auto it = m_sessions.find(sessionId); + if (it == m_sessions.end()) { + return; + } + + QVariantMap finalMap = sessionToVariantMap(it.value(), finalState); + if (it.value().hasExitInfo) { + finalMap.insert(QStringLiteral("exitCode"), it.value().exitCode); + finalMap.insert(QStringLiteral("exitStatus"), it.value().exitStatus); + } + + if (it.value().process) { + it.value().process->deleteLater(); + it.value().process = nullptr; + } + if (it.value().scanner) { + it.value().scanner->cancel(); + it.value().scanner->deleteLater(); + it.value().scanner = nullptr; + } + + Q_EMIT SessionRemoved(sessionId, finalMap); + m_sessions.erase(it); +} +GameCenterDaemon::GameCenterDaemon(QObject *parent) + : QObject(parent) +{ +} + +bool GameCenterDaemon::init() +{ + QDBusConnection bus = QDBusConnection::sessionBus(); + if (!bus.isConnected()) { + return false; + } + + if (!bus.registerService(QStringLiteral("org.kde.GameCenter1"))) { + return false; + } + + if (!bus.registerObject(QStringLiteral("/org/kde/GameCenter1"), this, QDBusConnection::ExportAllSlots | QDBusConnection::ExportAllSignals)) { + return false; + } + + setupSystemdSubscriptions(); + + recoverExistingSessions(); + + return true; +} + +void GameCenterDaemon::setupSystemdSubscriptions() +{ + if (!m_systemd.isAvailable()) { + return; + } + + QDBusConnection bus = QDBusConnection::sessionBus(); + if (!bus.isConnected()) { + return; + } + + bus.connect(QStringLiteral("org.freedesktop.systemd1"), + QStringLiteral("/org/freedesktop/systemd1"), + QStringLiteral("org.freedesktop.systemd1.Manager"), + QStringLiteral("UnitNew"), + this, + SLOT(handleSystemdUnitNew(QString, QDBusObjectPath))); + + bus.connect(QStringLiteral("org.freedesktop.systemd1"), + QStringLiteral("/org/freedesktop/systemd1"), + QStringLiteral("org.freedesktop.systemd1.Manager"), + QStringLiteral("UnitRemoved"), + this, + SLOT(handleSystemdUnitRemoved(QString, QDBusObjectPath))); +} + +QString GameCenterDaemon::Ping() const +{ + return QStringLiteral("ok"); +} + +void GameCenterDaemon::Version(uint &major, uint &minor) const +{ + major = 1; + minor = 0; +} + +QVariantMap GameCenterDaemon::GetCapabilities() const +{ + QVariantMap caps; + caps.insert(QStringLiteral("supportsSystemd"), m_systemd.isAvailable()); + caps.insert(QStringLiteral("supportsSystemBus"), false); + const bool systemdAvailable = m_systemd.isAvailable(); + const bool steamAvailable = !QStandardPaths::findExecutable(QStringLiteral("steam")).isEmpty(); + const bool lutrisAvailable = !QStandardPaths::findExecutable(QStringLiteral("lutris")).isEmpty(); + caps.insert(QStringLiteral("supportsSteamAttach"), systemdAvailable && steamAvailable); + caps.insert(QStringLiteral("supportsLutrisAttach"), systemdAvailable && lutrisAvailable); + return caps; +} + +QVariantList GameCenterDaemon::ListSessions() const +{ + QVariantList sessions; + sessions.reserve(m_sessions.size()); + + for (const auto &session : m_sessions) { + sessions.push_back(sessionToVariantMap(session, sessionState(session))); + } + + return sessions; +} + +QVariantMap GameCenterDaemon::GetSession(const QString &sessionId) const +{ + const auto it = m_sessions.constFind(sessionId); + if (it == m_sessions.constEnd()) { + return {}; + } + return sessionToVariantMap(it.value(), sessionState(it.value())); +} + +void GameCenterDaemon::SetPolicy(const QVariantMap &policy) +{ + if (policy.contains(QStringLiteral("maxConcurrent"))) { + QVariant v = policy.value(QStringLiteral("maxConcurrent")); + if (v.canConvert()) { + v = v.value().variant(); + } + m_maxConcurrent = v.toInt(); + } +} + +QVariantMap GameCenterDaemon::GetPolicy() const +{ + return { + {QStringLiteral("maxConcurrent"), m_maxConcurrent}, + }; +} + +QString GameCenterDaemon::Launch(const QVariantMap &launchSpec) +{ + if (!m_systemd.isAvailable()) { + failLaunch(QStringLiteral("systemd user manager is not available"), launchSpec); + return {}; + } + + if (m_maxConcurrent > 0 && m_sessions.size() >= m_maxConcurrent) { + QVariantMap ctx = launchSpec; + ctx.insert(QStringLiteral("maxConcurrent"), m_maxConcurrent); + ctx.insert(QStringLiteral("activeSessions"), m_sessions.size()); + failLaunch(QStringLiteral("max concurrent session limit reached"), ctx); + return {}; + } + + const QString provider = detectProvider(launchSpec); + + if (provider == QLatin1String("steam") || provider == QLatin1String("lutris")) { + return launchMonitored(launchSpec, provider); + } + + return launchDirect(launchSpec); +} + +QString GameCenterDaemon::launchDirect(const QVariantMap &launchSpec) +{ + const QString gameId = mapStringValue(launchSpec, {QStringLiteral("gameId"), QStringLiteral("id")}); + const QString displayName = mapStringValue(launchSpec, {QStringLiteral("displayName"), QStringLiteral("name"), QStringLiteral("title")}); + const QString workingDirectory = mapStringValue(launchSpec, {QStringLiteral("workingDirectory"), QStringLiteral("cwd"), QStringLiteral("workingDir")}); + + const QString command = mapStringValue(launchSpec, {QStringLiteral("command"), QStringLiteral("launchCommand")}); + QString program = mapStringValue(launchSpec, {QStringLiteral("program"), QStringLiteral("executable"), QStringLiteral("exe")}); + QStringList args; + if (launchSpec.contains(QStringLiteral("args"))) { + args = variantToStringList(launchSpec.value(QStringLiteral("args"))); + } else if (launchSpec.contains(QStringLiteral("argv"))) { + args = variantToStringList(launchSpec.value(QStringLiteral("argv"))); + } else if (launchSpec.contains(QStringLiteral("arguments"))) { + args = variantToStringList(launchSpec.value(QStringLiteral("arguments"))); + } + + if (program.trimmed().isEmpty()) { + if (command.trimmed().isEmpty()) { + failLaunch(QStringLiteral("launchSpec.command or launchSpec.program is required"), launchSpec); + return {}; + } + + const QStringList argv = QProcess::splitCommand(command); + if (argv.isEmpty()) { + failLaunch(QStringLiteral("failed to parse command"), launchSpec); + return {}; + } + + program = argv.first(); + args = argv.mid(1); + } + + QVariantMap envOverrides; + auto extractVariantMap = [](QVariant v) { + if (v.canConvert()) { + v = v.value().variant(); + } + if (v.canConvert()) { + return v.toMap(); + } + if (v.canConvert()) { + const QDBusArgument arg = v.value(); + const QMap asStringMap = qdbus_cast>(arg); + if (!asStringMap.isEmpty()) { + QVariantMap converted; + for (auto it = asStringMap.constBegin(); it != asStringMap.constEnd(); ++it) { + converted.insert(it.key(), it.value()); + } + return converted; + } + + QVariantMap asMap = qdbus_cast(arg); + if (!asMap.isEmpty()) { + return asMap; + } + } + return QVariantMap{}; + }; + + if (launchSpec.contains(QStringLiteral("env"))) { + envOverrides = extractVariantMap(launchSpec.value(QStringLiteral("env"))); + } else if (launchSpec.contains(QStringLiteral("environment"))) { + envOverrides = extractVariantMap(launchSpec.value(QStringLiteral("environment"))); + } else if (launchSpec.contains(QStringLiteral("envOverrides"))) { + envOverrides = extractVariantMap(launchSpec.value(QStringLiteral("envOverrides"))); + } + + const QString sessionId = QUuid::createUuid().toString(QUuid::WithoutBraces); + const QString unitName = ensureScopeUnitName(QStringLiteral("alakarte-game-%1").arg(sessionId)); + + auto *process = new QProcess(this); + if (!workingDirectory.isEmpty()) { + process->setWorkingDirectory(workingDirectory); + } + + if (!envOverrides.isEmpty()) { + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + for (auto it = envOverrides.constBegin(); it != envOverrides.constEnd(); ++it) { + const QString key = it.key(); + if (key.isEmpty() || key.contains(QLatin1Char('='))) { + continue; + } + + QVariant value = it.value(); + if (value.canConvert()) { + value = value.value().variant(); + } + env.insert(key, value.toString()); + } + process->setProcessEnvironment(env); + } + + process->start(program, args); + if (!process->waitForStarted()) { + const QVariantMap ctx = { + {QStringLiteral("command"), command}, + {QStringLiteral("program"), program}, + {QStringLiteral("args"), args}, + {QStringLiteral("gameId"), gameId}, + {QStringLiteral("workingDirectory"), workingDirectory}, + {QStringLiteral("envOverrides"), envOverrides}, + {QStringLiteral("error"), process->errorString()}, + }; + process->deleteLater(); + failLaunch(QStringLiteral("failed to start process"), ctx); + return {}; + } + + const QList pids = {static_cast(process->processId())}; + const QString description = gameId.isEmpty() ? QStringLiteral("A-La-Karte game") : QStringLiteral("A-La-Karte game %1").arg(gameId); + + const QDBusReply startReply = m_systemd.startTransientScope(unitName, pids, description); + if (!startReply.isValid()) { + const QVariantMap ctx = { + {QStringLiteral("command"), command}, + {QStringLiteral("program"), program}, + {QStringLiteral("args"), args}, + {QStringLiteral("gameId"), gameId}, + {QStringLiteral("workingDirectory"), workingDirectory}, + {QStringLiteral("envOverrides"), envOverrides}, + {QStringLiteral("unit"), unitName}, + {QStringLiteral("error"), startReply.error().message()}, + }; + process->kill(); + process->deleteLater(); + failLaunch(QStringLiteral("failed to create transient scope"), ctx); + return {}; + } + + QDBusObjectPath unitPath; + const QDBusReply getUnitReply = m_systemd.getUnit(unitName); + if (getUnitReply.isValid()) { + unitPath = getUnitReply.value(); + } + + Session session; + session.sessionId = sessionId; + session.gameId = gameId; + session.displayName = displayName; + session.unitName = unitName; + session.unitPath = unitPath; + session.provider = QStringLiteral("manual"); + session.startTime = QDateTime::currentDateTimeUtc(); + session.process = process; + session.mainPid = static_cast(process->processId()); + m_sessions.insert(sessionId, session); + + watchSystemdUnit(sessionId, unitName, unitPath); + + Q_EMIT SessionAdded(sessionToVariantMap(session, QStringLiteral("Running"))); + + connect(process, qOverload(&QProcess::finished), this, [this, sessionId](int exitCode, QProcess::ExitStatus exitStatus) { + const auto it = m_sessions.find(sessionId); + if (it == m_sessions.end()) { + return; + } + it.value().hasExitInfo = true; + it.value().exitCode = exitCode; + it.value().exitStatus = static_cast(exitStatus); + if (it.value().process) { + it.value().process->deleteLater(); + it.value().process = nullptr; + } + }); + + return sessionId; +} + +QString GameCenterDaemon::launchMonitored(const QVariantMap &launchSpec, const QString &provider) +{ + const QString gameId = mapStringValue(launchSpec, {QStringLiteral("gameId"), QStringLiteral("id")}); + const QString displayName = mapStringValue(launchSpec, {QStringLiteral("displayName"), QStringLiteral("name"), QStringLiteral("title")}); + const QString command = mapStringValue(launchSpec, {QStringLiteral("command"), QStringLiteral("launchCommand")}); + + const QString sessionId = QUuid::createUuid().toString(QUuid::WithoutBraces); + + // Determine bootstrap command and scanner matcher + QString bootstrapProgram; + QStringList bootstrapArgs; + std::function()> matcher; + + if (provider == QLatin1String("steam")) { + const QString appId = extractSteamAppId(command); + if (appId.isEmpty()) { + failLaunch(QStringLiteral("could not extract Steam appId from command"), launchSpec); + return {}; + } + + bootstrapProgram = QStringLiteral("steam"); + bootstrapArgs = {QStringLiteral("-silent"), QStringLiteral("-applaunch"), appId}; + + matcher = [appId]() -> QList { + // Primary: look for SteamAppId= in environ + QList results = ProcessScanner::findByEnvironment(QStringLiteral("SteamAppId"), appId); + if (results.isEmpty()) { + results = ProcessScanner::findByEnvironment(QStringLiteral("SteamGameId"), appId); + } + // Filter out the steam client itself + QList filtered; + for (const auto &m : std::as_const(results)) { + if (!m.exe.contains(QLatin1String("steam")) || m.exe.contains(QLatin1String("proton"))) { + filtered.push_back(m); + } + } + return filtered.isEmpty() ? results : filtered; + }; + + } else if (provider == QLatin1String("lutris")) { + const QString lutrisId = extractLutrisId(command); + if (lutrisId.isEmpty()) { + failLaunch(QStringLiteral("could not extract Lutris ID from command"), launchSpec); + return {}; + } + + bootstrapProgram = QStringLiteral("lutris"); + bootstrapArgs = {QStringLiteral("lutris:rungameid/%1").arg(lutrisId)}; + + // For Lutris, look for child processes with the game slug or lutris game env + matcher = [lutrisId]() -> QList { + QList results = ProcessScanner::findByEnvironment(QStringLiteral("LUTRIS_GAME_SLUG"), lutrisId); + if (results.isEmpty()) { + results = ProcessScanner::findByCmdline(lutrisId); + // Filter out lutris itself + QList filtered; + for (const auto &m : std::as_const(results)) { + if (!m.exe.endsWith(QLatin1String("/lutris")) && !m.exe.endsWith(QLatin1String("/python3"))) { + filtered.push_back(m); + } + } + if (!filtered.isEmpty()) { + results = filtered; + } + } + return results; + }; + } + + if (bootstrapProgram.isEmpty() || !matcher) { + QVariantMap ctx = launchSpec; + ctx.insert(QStringLiteral("provider"), provider); + failLaunch(QStringLiteral("unsupported monitored provider"), ctx); + return {}; + } + + if (QStandardPaths::findExecutable(bootstrapProgram).isEmpty()) { + const QVariantMap ctx = { + {QStringLiteral("gameId"), gameId}, + {QStringLiteral("provider"), provider}, + {QStringLiteral("program"), bootstrapProgram}, + }; + failLaunch(QStringLiteral("bootstrap executable not found"), ctx); + return {}; + } + + // Create session in Launching state + Session session; + session.sessionId = sessionId; + session.gameId = gameId; + session.displayName = displayName; + session.provider = provider; + session.startTime = QDateTime::currentDateTimeUtc(); + m_sessions.insert(sessionId, session); + + Q_EMIT SessionAdded(sessionToVariantMap(session, QStringLiteral("Launching"))); + + // Start bootstrap process (e.g. steam -applaunch or lutris ...) + auto *bootstrap = new QProcess(this); + bootstrap->start(bootstrapProgram, bootstrapArgs); + + if (!bootstrap->waitForStarted(5000)) { + const QVariantMap ctx = { + {QStringLiteral("gameId"), gameId}, + {QStringLiteral("provider"), provider}, + {QStringLiteral("command"), command}, + {QStringLiteral("error"), bootstrap->errorString()}, + }; + bootstrap->deleteLater(); + QVariantMap finalState = sessionToVariantMap(session, QStringLiteral("Failed")); + Q_EMIT SessionRemoved(sessionId, finalState); + m_sessions.remove(sessionId); + failLaunch(QStringLiteral("failed to start bootstrap process"), ctx); + return {}; + } + + session.process = bootstrap; + m_sessions[sessionId] = session; + + connect(bootstrap, qOverload(&QProcess::finished), this, [this, sessionId](int, QProcess::ExitStatus) { + const auto it = m_sessions.find(sessionId); + if (it == m_sessions.end()) { + return; + } + if (it.value().scanner || it.value().stopping) { + return; + } + if (it.value().process) { + it.value().process->deleteLater(); + it.value().process = nullptr; + } + }); + + // Start polling for game PID + auto *scanner = new ProcessScanner(this); + m_sessions[sessionId].scanner = scanner; + + connect(scanner, &ProcessScanner::found, this, [this, sessionId](const QList &matches) { + QList pids; + pids.reserve(matches.size()); + for (const auto &m : matches) { + pids.push_back(m.pid); + } + attachPidsToSession(sessionId, pids); + }); + + connect(scanner, &ProcessScanner::timedOut, this, [this, sessionId, launchSpec]() { + auto it = m_sessions.find(sessionId); + if (it == m_sessions.end()) { + return; + } + + const bool stopping = it.value().stopping; + const QVariantMap ctx = { + {QStringLiteral("gameId"), it.value().gameId}, + {QStringLiteral("provider"), it.value().provider}, + }; + + QVariantMap finalState = sessionToVariantMap(it.value(), stopping ? QStringLiteral("Stopped") : QStringLiteral("Failed")); + + if (it.value().scanner) { + it.value().scanner->cancel(); + it.value().scanner->deleteLater(); + } + if (it.value().process) { + it.value().process->deleteLater(); + } + Q_EMIT SessionRemoved(sessionId, finalState); + m_sessions.erase(it); + + if (!stopping) { + failLaunch(QStringLiteral("timed out waiting for game process"), ctx); + } + }); + + scanner->pollUntilFound(std::move(matcher), 500, 20000); + + return sessionId; +} + +void GameCenterDaemon::attachPidsToSession(const QString &sessionId, const QList &pids) +{ + auto it = m_sessions.find(sessionId); + if (it == m_sessions.end() || pids.isEmpty()) { + return; + } + + if (it.value().scanner) { + it.value().scanner->cancel(); + it.value().scanner->deleteLater(); + it.value().scanner = nullptr; + } + + it.value().mainPid = pids.first(); + + const QString unitName = ensureScopeUnitName(QStringLiteral("alakarte-game-%1").arg(sessionId)); + const QString description = it.value().gameId.isEmpty() ? QStringLiteral("A-La-Karte game") : QStringLiteral("A-La-Karte game %1").arg(it.value().gameId); + + const QDBusReply reply = m_systemd.startTransientScope(unitName, pids, description); + if (!reply.isValid()) { + const QVariantMap ctx = { + {QStringLiteral("gameId"), it.value().gameId}, + {QStringLiteral("provider"), it.value().provider}, + {QStringLiteral("unit"), unitName}, + {QStringLiteral("error"), reply.error().message()}, + }; + + QVariantMap finalState = sessionToVariantMap(it.value(), QStringLiteral("Failed")); + if (it.value().process) { + it.value().process->deleteLater(); + } + Q_EMIT SessionRemoved(sessionId, finalState); + m_sessions.erase(it); + + failLaunch(QStringLiteral("failed to create transient scope for monitored game"), ctx); + return; + } + + it.value().unitName = unitName; + + const QDBusReply getUnitReply = m_systemd.getUnit(unitName); + if (getUnitReply.isValid()) { + it.value().unitPath = getUnitReply.value(); + } + + watchSystemdUnit(sessionId, unitName, it.value().unitPath); + + if (it.value().process) { + it.value().process->deleteLater(); + it.value().process = nullptr; + } + + if (it.value().stopping) { + m_systemd.stopUnit(it.value().unitName); + QVariantMap finalState = sessionToVariantMap(it.value(), QStringLiteral("Stopped")); + Q_EMIT SessionRemoved(sessionId, finalState); + unwatchSystemdUnit(unitName, it.value().unitPath); + m_sessions.erase(it); + return; + } + + Q_EMIT SessionChanged(sessionToVariantMap(it.value(), QStringLiteral("Running"))); +} + +QString GameCenterDaemon::detectProvider(const QVariantMap &launchSpec) +{ + // Explicit provider hint takes priority + QString explicit_provider; + { + QVariant v = launchSpec.value(QStringLiteral("provider")); + if (v.canConvert()) { + v = v.value().variant(); + } + explicit_provider = v.toString().toLower(); + } + if (!explicit_provider.isEmpty()) { + return explicit_provider; + } + + // Auto-detect from command + const QString command = mapStringValue(launchSpec, {QStringLiteral("command"), QStringLiteral("launchCommand")}); + if (command.contains(QLatin1String("steam://rungameid/")) || command.contains(QLatin1String("steam -applaunch"))) { + return QStringLiteral("steam"); + } + if (command.startsWith(QLatin1String("lutris ")) || command.contains(QLatin1String("lutris:rungame"))) { + return QStringLiteral("lutris"); + } + + return QStringLiteral("manual"); +} + +QString GameCenterDaemon::extractSteamAppId(const QString &command) +{ + // steam://rungameid/ + static const QRegularExpression steamUrlRe(QStringLiteral("steam://rungameid/(\\d+)")); + QRegularExpressionMatch match = steamUrlRe.match(command); + if (match.hasMatch()) { + return match.captured(1); + } + + // steam -applaunch + static const QRegularExpression applaunchRe(QStringLiteral("-applaunch\\s+(\\d+)")); + match = applaunchRe.match(command); + if (match.hasMatch()) { + return match.captured(1); + } + + return {}; +} + +QString GameCenterDaemon::extractLutrisId(const QString &command) +{ + // lutris:rungameid/ or lutris:rungame/ + static const QRegularExpression lutrisRe(QStringLiteral("lutris:rungame(?:id)?/([^\\s]+)")); + QRegularExpressionMatch match = lutrisRe.match(command); + if (match.hasMatch()) { + return match.captured(1); + } + + // "lutris lutris:rungameid/" form + static const QRegularExpression lutrisCmdRe(QStringLiteral("lutris\\s+lutris:rungame(?:id)?/([^\\s]+)")); + match = lutrisCmdRe.match(command); + if (match.hasMatch()) { + return match.captured(1); + } + + return {}; +} + +void GameCenterDaemon::Stop(const QString &sessionId) +{ + auto it = m_sessions.find(sessionId); + if (it == m_sessions.end()) { + return; + } + + it.value().stopping = true; + + if (it.value().scanner) { + Q_EMIT SessionChanged(sessionToVariantMap(it.value(), QStringLiteral("Stopping"))); + + if (it.value().scanner) { + it.value().scanner->cancel(); + it.value().scanner->deleteLater(); + it.value().scanner = nullptr; + } + + QPointer proc = it.value().process; + it.value().process = nullptr; + if (proc) { + connect(proc, qOverload(&QProcess::finished), proc, &QObject::deleteLater); + if (proc->state() != QProcess::NotRunning) { + proc->terminate(); + QTimer::singleShot(5000, proc, [proc]() { + if (proc && proc->state() != QProcess::NotRunning) { + proc->kill(); + } + }); + } else { + proc->deleteLater(); + } + } + + QVariantMap finalState = sessionToVariantMap(it.value(), QStringLiteral("Stopped")); + Q_EMIT SessionRemoved(sessionId, finalState); + m_sessions.erase(it); + return; + } + + if (it.value().unitName.isEmpty()) { + QVariantMap finalState = sessionToVariantMap(it.value(), QStringLiteral("Stopped")); + if (it.value().process) { + it.value().process->kill(); + it.value().process->deleteLater(); + } + Q_EMIT SessionRemoved(sessionId, finalState); + m_sessions.erase(it); + return; + } + + const QDBusReply reply = m_systemd.stopUnit(it.value().unitName); + if (!reply.isValid()) { + if (it.value().process && it.value().process->state() != QProcess::NotRunning) { + it.value().process->terminate(); + QTimer::singleShot(5000, it.value().process, [process = it.value().process]() { + if (process && process->state() != QProcess::NotRunning) { + process->kill(); + } + }); + Q_EMIT SessionChanged(sessionToVariantMap(it.value(), QStringLiteral("Stopping"))); + } else { + Q_EMIT SessionChanged(sessionToVariantMap(it.value(), QStringLiteral("Error"))); + } + return; + } + + Q_EMIT SessionChanged(sessionToVariantMap(it.value(), QStringLiteral("Stopping"))); +} + +void GameCenterDaemon::StopByGameId(const QString &gameId) +{ + if (gameId.isEmpty()) { + return; + } + + QStringList sessionsToStop; + sessionsToStop.reserve(m_sessions.size()); + + for (const auto &session : m_sessions) { + if (session.gameId == gameId) { + sessionsToStop.push_back(session.sessionId); + } + } + + for (const QString &sessionId : sessionsToStop) { + Stop(sessionId); + } +} + +QString GameCenterDaemon::sessionState(const Session &session) +{ + if (session.stopping) { + return QStringLiteral("Stopping"); + } + if (!session.unitName.isEmpty()) { + return QStringLiteral("Running"); + } + if (session.scanner) { + return QStringLiteral("Launching"); + } + if (session.process) { + return session.process->state() == QProcess::Running ? QStringLiteral("Running") : QStringLiteral("Exited"); + } + return QStringLiteral("Unknown"); +} + +QVariantMap GameCenterDaemon::sessionToVariantMap(const Session &session, const QString &state) const +{ + QVariantMap map; + map.insert(QStringLiteral("sessionId"), session.sessionId); + map.insert(QStringLiteral("gameId"), session.gameId); + if (!session.displayName.isEmpty()) { + map.insert(QStringLiteral("displayName"), session.displayName); + } + map.insert(QStringLiteral("unit"), session.unitName); + map.insert(QStringLiteral("unitName"), session.unitName); + map.insert(QStringLiteral("state"), state); + if (!session.provider.isEmpty()) { + map.insert(QStringLiteral("provider"), session.provider); + } + if (session.startTime.isValid()) { + map.insert(QStringLiteral("startTime"), session.startTime.toString(Qt::ISODate)); + } + if (session.process && session.process->processId() > 0) { + map.insert(QStringLiteral("pid"), static_cast(session.process->processId())); + } else if (session.mainPid > 0) { + map.insert(QStringLiteral("pid"), static_cast(session.mainPid)); + } + return map; +} + +void GameCenterDaemon::failLaunch(const QString &reason, const QVariantMap &context) +{ + QVariantMap error; + error.insert(QStringLiteral("error"), reason); + if (!context.isEmpty()) { + error.insert(QStringLiteral("context"), normalizeVariantMapForDbus(context)); + const QString gameId = mapStringValue(context, {QStringLiteral("gameId"), QStringLiteral("id")}); + if (!gameId.isEmpty()) { + error.insert(QStringLiteral("gameId"), gameId); + } + } + Q_EMIT LaunchFailed(error); +} + +void GameCenterDaemon::recoverExistingSessions() +{ + if (!m_systemd.isAvailable()) { + return; + } + + const QDBusReply reply = m_systemd.listUnits(); + if (!reply.isValid()) { + return; + } + + static const QString prefix = QStringLiteral("alakarte-game-"); + static const QString suffix = QStringLiteral(".scope"); + static const QString descPrefix = QStringLiteral("A-La-Karte game "); + + const SystemdUnitInfoList units = reply.value(); + for (const SystemdUnitInfo &unit : units) { + if (!unit.name.startsWith(prefix) || !unit.name.endsWith(suffix)) { + continue; + } + if (unit.activeState != QLatin1String("active")) { + continue; + } + + const QString sessionId = unit.name.mid(prefix.length(), unit.name.length() - prefix.length() - suffix.length()); + if (sessionId.isEmpty() || m_sessions.contains(sessionId)) { + continue; + } + + QString gameId; + if (unit.description.startsWith(descPrefix)) { + gameId = unit.description.mid(descPrefix.length()); + } + + Session session; + session.sessionId = sessionId; + session.gameId = gameId; + session.unitName = unit.name; + session.unitPath = unit.objectPath; + + const QList pids = m_systemd.scopePids(unit.objectPath); + if (!pids.isEmpty()) { + session.mainPid = pids.first(); + } + + m_sessions.insert(sessionId, session); + watchSystemdUnit(sessionId, session.unitName, session.unitPath); + Q_EMIT SessionAdded(sessionToVariantMap(session, QStringLiteral("Running"))); + } +} + +#include "gamecenterdaemon.moc" diff --git a/src/gamecenter/gamecenterdaemon.h b/src/gamecenter/gamecenterdaemon.h new file mode 100644 index 0000000..f6094b5 --- /dev/null +++ b/src/gamecenter/gamecenterdaemon.h @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "processscanner.h" +#include "systemdusermanager.h" + +class GameCenterDaemon : public QObject +{ + Q_OBJECT + Q_CLASSINFO("D-Bus Interface", "org.kde.GameCenter1") + +public: + explicit GameCenterDaemon(QObject *parent = nullptr); + + bool init(); + +public Q_SLOTS: + QString Ping() const; + void Version(uint &major, uint &minor) const; + QVariantMap GetCapabilities() const; + + QVariantList ListSessions() const; + QVariantMap GetSession(const QString &sessionId) const; + + QString Launch(const QVariantMap &launchSpec); + void Stop(const QString &sessionId); + void StopByGameId(const QString &gameId); + + void SetPolicy(const QVariantMap &policy); + QVariantMap GetPolicy() const; + +Q_SIGNALS: + void SessionAdded(const QVariantMap &session); + void SessionChanged(const QVariantMap &session); + void SessionRemoved(const QString &sessionId, const QVariantMap &finalState); + void LaunchFailed(const QVariantMap &error); + +private: + struct Session { + QString sessionId; + QString gameId; + QString displayName; + QString unitName; + QDBusObjectPath unitPath; + QString provider; + QDateTime startTime; + QPointer process; + QPointer scanner; + uint mainPid = 0; + bool stopping = false; + bool hasExitInfo = false; + int exitCode = 0; + int exitStatus = 0; + }; + + QVariantMap sessionToVariantMap(const Session &session, const QString &state) const; + static QString sessionState(const Session &session); + void failLaunch(const QString &reason, const QVariantMap &context = {}); + void recoverExistingSessions(); + + void setupSystemdSubscriptions(); + void watchSystemdUnit(const QString &sessionId, const QString &unitName, const QDBusObjectPath &unitPath); + void unwatchSystemdUnit(const QString &unitName, const QDBusObjectPath &unitPath); + void handleSystemdUnitPropertiesChanged(const QDBusObjectPath &unitPath, const QVariantMap &changedProperties); + void removeSessionInternal(const QString &sessionId, const QString &finalState); + +private Q_SLOTS: + void handleSystemdUnitNew(const QString &unitName, const QDBusObjectPath &unitPath); + void handleSystemdUnitRemoved(const QString &unitName, const QDBusObjectPath &unitPath); + +private: + QString launchDirect(const QVariantMap &launchSpec); + QString launchMonitored(const QVariantMap &launchSpec, const QString &provider); + void attachPidsToSession(const QString &sessionId, const QList &pids); + + static QString detectProvider(const QVariantMap &launchSpec); + static QString extractSteamAppId(const QString &command); + static QString extractLutrisId(const QString &command); + + SystemdUserManager m_systemd; + QHash m_sessions; + int m_maxConcurrent = 0; + QHash m_unitPathToSessionId; + QHash m_unitNameToSessionId; + QHash m_unitPathWatchers; +}; diff --git a/src/gamecenter/main.cpp b/src/gamecenter/main.cpp new file mode 100644 index 0000000..ccb3dbb --- /dev/null +++ b/src/gamecenter/main.cpp @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors + +#include + +#include "gamecenterdaemon.h" + +int main(int argc, char *argv[]) +{ + QCoreApplication app(argc, argv); + + GameCenterDaemon daemon; + if (!daemon.init()) { + return 1; + } + + return app.exec(); +} diff --git a/src/gamecenter/processscanner.cpp b/src/gamecenter/processscanner.cpp new file mode 100644 index 0000000..4f9d2d2 --- /dev/null +++ b/src/gamecenter/processscanner.cpp @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors + +#include "processscanner.h" + +#include +#include +#include + +ProcessScanner::ProcessScanner(QObject *parent) + : QObject(parent) +{ + m_timer.setSingleShot(false); + m_deadline.setSingleShot(true); + + connect(&m_deadline, &QTimer::timeout, this, [this]() { + m_timer.stop(); + Q_EMIT timedOut(); + }); + + connect(&m_timer, &QTimer::timeout, this, [this]() { + if (!m_matcher) { + m_timer.stop(); + return; + } + const QList results = m_matcher(); + if (!results.isEmpty()) { + m_timer.stop(); + m_deadline.stop(); + Q_EMIT found(results); + } + }); +} + +static QByteArray readProcFile(const QString &path, qint64 maxSize = 65536) +{ + QFile f(path); + if (!f.open(QIODevice::ReadOnly)) { + return {}; + } + return f.read(maxSize); +} + +static QString readExeLink(const QString &pidDir) +{ + return QFileInfo(pidDir + QStringLiteral("/exe")).symLinkTarget(); +} + +static QByteArray readCmdline(const QString &pidDir) +{ + return readProcFile(pidDir + QStringLiteral("/cmdline")); +} + +static QByteArray readEnviron(const QString &pidDir) +{ + return readProcFile(pidDir + QStringLiteral("/environ")); +} + +static QList listPids() +{ + QList pids; + const QDir proc(QStringLiteral("/proc")); + const QStringList entries = proc.entryList(QDir::Dirs | QDir::NoDotAndDotDot); + for (const QString &entry : entries) { + bool ok = false; + const uint pid = entry.toUInt(&ok); + if (ok && pid > 1) { + pids.push_back(pid); + } + } + return pids; +} + +QList ProcessScanner::findByEnvironment(const QString &key, const QString &value) +{ + QList matches; + const QByteArray needle = (key + QLatin1Char('=') + value).toUtf8(); + + const QList pids = listPids(); + for (uint pid : pids) { + const QString pidDir = QStringLiteral("/proc/%1").arg(pid); + const QByteArray env = readEnviron(pidDir); + if (env.isEmpty()) { + continue; + } + + // environ entries are separated by null bytes + bool found = false; + int start = 0; + while (start < env.size()) { + int end = env.indexOf('\0', start); + if (end < 0) { + end = env.size(); + } + if (env.mid(start, end - start) == needle) { + found = true; + break; + } + start = end + 1; + } + + if (found) { + Match m; + m.pid = pid; + m.exe = readExeLink(pidDir); + const QByteArray cmd = readCmdline(pidDir); + m.cmdline = QString::fromLocal8Bit(cmd).replace(QLatin1Char('\0'), QLatin1Char(' ')).trimmed(); + matches.push_back(m); + } + } + return matches; +} + +QList ProcessScanner::findByCmdline(const QString &substring) +{ + QList matches; + const QByteArray needle = substring.toUtf8(); + + const QList pids = listPids(); + for (uint pid : pids) { + const QString pidDir = QStringLiteral("/proc/%1").arg(pid); + const QByteArray cmd = readCmdline(pidDir); + if (cmd.isEmpty()) { + continue; + } + + // cmdline has null-separated args; search the whole blob + if (cmd.contains(needle)) { + Match m; + m.pid = pid; + m.exe = readExeLink(pidDir); + m.cmdline = QString::fromLocal8Bit(cmd).replace(QLatin1Char('\0'), QLatin1Char(' ')).trimmed(); + matches.push_back(m); + } + } + return matches; +} + +QList ProcessScanner::findByExePath(const QString &dirPrefix) +{ + QList matches; + + const QList pids = listPids(); + for (uint pid : pids) { + const QString pidDir = QStringLiteral("/proc/%1").arg(pid); + const QString exe = readExeLink(pidDir); + if (exe.isEmpty()) { + continue; + } + if (exe.startsWith(dirPrefix)) { + Match m; + m.pid = pid; + m.exe = exe; + const QByteArray cmd = readCmdline(pidDir); + m.cmdline = QString::fromLocal8Bit(cmd).replace(QLatin1Char('\0'), QLatin1Char(' ')).trimmed(); + matches.push_back(m); + } + } + return matches; +} + +void ProcessScanner::pollUntilFound(std::function()> matcher, int intervalMs, int timeoutMs) +{ + cancel(); + m_matcher = std::move(matcher); + + // Try once immediately + const QList immediate = m_matcher(); + if (!immediate.isEmpty()) { + Q_EMIT found(immediate); + return; + } + + m_deadline.start(timeoutMs); + m_timer.start(intervalMs); +} + +void ProcessScanner::cancel() +{ + m_timer.stop(); + m_deadline.stop(); + m_matcher = nullptr; +} diff --git a/src/gamecenter/processscanner.h b/src/gamecenter/processscanner.h new file mode 100644 index 0000000..e5b94d3 --- /dev/null +++ b/src/gamecenter/processscanner.h @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors + +#pragma once + +#include +#include +#include +#include +#include + +class ProcessScanner : public QObject +{ + Q_OBJECT + +public: + explicit ProcessScanner(QObject *parent = nullptr); + + struct Match { + uint pid = 0; + QString exe; + QString cmdline; + }; + + // Find PIDs whose /proc//environ contains key=value + static QList findByEnvironment(const QString &key, const QString &value); + + // Find PIDs whose /proc//cmdline contains the substring + static QList findByCmdline(const QString &substring); + + // Find PIDs whose /proc//exe resolves to a path under the given directory + static QList findByExePath(const QString &dirPrefix); + + // Async poll: calls matcher repeatedly until it returns non-empty or timeout. + // Emits found() with matching PIDs, or timedOut() on failure. + void pollUntilFound(std::function()> matcher, int intervalMs = 500, int timeoutMs = 15000); + + void cancel(); + +Q_SIGNALS: + void found(const QList &matches); + void timedOut(); + +private: + QTimer m_timer; + QTimer m_deadline; + std::function()> m_matcher; +}; diff --git a/src/gamecenter/systemd/org.kde.GameCenter1.service.in b/src/gamecenter/systemd/org.kde.GameCenter1.service.in new file mode 100644 index 0000000..a046f54 --- /dev/null +++ b/src/gamecenter/systemd/org.kde.GameCenter1.service.in @@ -0,0 +1,9 @@ +[Unit] +Description=A-La-Karte Game Center +PartOf=graphical-session.target + +[Service] +ExecStart=@CMAKE_INSTALL_PREFIX@/@KDE_INSTALL_BINDIR@/alakarte-gamecenter +Type=dbus +BusName=org.kde.GameCenter1 +Restart=no diff --git a/src/gamecenter/systemdusermanager.cpp b/src/gamecenter/systemdusermanager.cpp new file mode 100644 index 0000000..ce3846e --- /dev/null +++ b/src/gamecenter/systemdusermanager.cpp @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors + +#include "systemdusermanager.h" + +#include +#include +#include +#include +#include +#include + +static QDBusArgument &operator<<(QDBusArgument &argument, const SystemdProperty &prop) +{ + argument.beginStructure(); + argument << prop.name << QDBusVariant(prop.value); + argument.endStructure(); + return argument; +} + +static const QDBusArgument &operator>>(const QDBusArgument &argument, SystemdProperty &prop) +{ + QDBusVariant variant; + + argument.beginStructure(); + argument >> prop.name >> variant; + argument.endStructure(); + + prop.value = variant.variant(); + return argument; +} + +static QDBusArgument &operator<<(QDBusArgument &argument, const SystemdAuxUnit &unit) +{ + argument.beginStructure(); + argument << unit.name << unit.properties; + argument.endStructure(); + return argument; +} + +static const QDBusArgument &operator>>(const QDBusArgument &argument, SystemdAuxUnit &unit) +{ + argument.beginStructure(); + argument >> unit.name >> unit.properties; + argument.endStructure(); + return argument; +} + +static QDBusArgument &operator<<(QDBusArgument &argument, const SystemdUnitInfo &info) +{ + argument.beginStructure(); + argument << info.name << info.description << info.loadState << info.activeState << info.subState << info.following << info.objectPath << info.jobId + << info.jobType << info.jobPath; + argument.endStructure(); + return argument; +} + +static const QDBusArgument &operator>>(const QDBusArgument &argument, SystemdUnitInfo &info) +{ + argument.beginStructure(); + argument >> info.name >> info.description >> info.loadState >> info.activeState >> info.subState >> info.following >> info.objectPath >> info.jobId + >> info.jobType >> info.jobPath; + argument.endStructure(); + return argument; +} + +SystemdUserManager::SystemdUserManager(QObject *parent) + : QObject(parent) +{ + ensureTypesRegistered(); +} + +void SystemdUserManager::ensureTypesRegistered() +{ + static bool registered = false; + if (registered) { + return; + } + + qDBusRegisterMetaType(); + qDBusRegisterMetaType(); + qDBusRegisterMetaType(); + qDBusRegisterMetaType(); + qDBusRegisterMetaType(); + qDBusRegisterMetaType(); + + registered = true; +} + +bool SystemdUserManager::isAvailable() const +{ + QDBusInterface manager(QStringLiteral("org.freedesktop.systemd1"), + QStringLiteral("/org/freedesktop/systemd1"), + QStringLiteral("org.freedesktop.systemd1.Manager"), + QDBusConnection::sessionBus()); + + return manager.isValid(); +} + +QDBusReply SystemdUserManager::startTransientScope(const QString &unitName, const QList &pids, const QString &description) +{ + QDBusInterface manager(QStringLiteral("org.freedesktop.systemd1"), + QStringLiteral("/org/freedesktop/systemd1"), + QStringLiteral("org.freedesktop.systemd1.Manager"), + QDBusConnection::sessionBus()); + + SystemdProperties props; + props.push_back({QStringLiteral("Description"), description}); + props.push_back({QStringLiteral("PIDs"), QVariant::fromValue(pids)}); + props.push_back({QStringLiteral("CollectMode"), QStringLiteral("inactive-or-failed")}); + props.push_back({QStringLiteral("KillMode"), QStringLiteral("control-group")}); + props.push_back({QStringLiteral("SendSIGKILL"), true}); + props.push_back({QStringLiteral("TimeoutStopUSec"), static_cast(10) * 1000 * 1000}); + + const SystemdAuxUnits aux; + + const QDBusMessage reply = + manager.call(QStringLiteral("StartTransientUnit"), unitName, QStringLiteral("replace"), QVariant::fromValue(props), QVariant::fromValue(aux)); + return QDBusReply(reply); +} + +QDBusReply SystemdUserManager::stopUnit(const QString &unitName, const QString &mode) +{ + QDBusInterface manager(QStringLiteral("org.freedesktop.systemd1"), + QStringLiteral("/org/freedesktop/systemd1"), + QStringLiteral("org.freedesktop.systemd1.Manager"), + QDBusConnection::sessionBus()); + + const QDBusMessage reply = manager.call(QStringLiteral("StopUnit"), unitName, mode); + return QDBusReply(reply); +} + +QDBusReply SystemdUserManager::getUnit(const QString &unitName) +{ + QDBusInterface manager(QStringLiteral("org.freedesktop.systemd1"), + QStringLiteral("/org/freedesktop/systemd1"), + QStringLiteral("org.freedesktop.systemd1.Manager"), + QDBusConnection::sessionBus()); + + const QDBusMessage reply = manager.call(QStringLiteral("GetUnit"), unitName); + return QDBusReply(reply); +} + +QDBusReply SystemdUserManager::listUnits() +{ + QDBusInterface manager(QStringLiteral("org.freedesktop.systemd1"), + QStringLiteral("/org/freedesktop/systemd1"), + QStringLiteral("org.freedesktop.systemd1.Manager"), + QDBusConnection::sessionBus()); + + const QDBusMessage reply = manager.call(QStringLiteral("ListUnits")); + return QDBusReply(reply); +} + +QList SystemdUserManager::scopePids(const QDBusObjectPath &unitPath) +{ + QDBusInterface unit(QStringLiteral("org.freedesktop.systemd1"), + unitPath.path(), + QStringLiteral("org.freedesktop.systemd1.Unit"), + QDBusConnection::sessionBus()); + + const QDBusMessage reply = unit.call(QStringLiteral("GetProcesses")); + if (reply.type() != QDBusMessage::ReplyMessage || reply.arguments().isEmpty()) { + return {}; + } + + QList pids; + const QDBusArgument arg = reply.arguments().first().value(); + arg.beginArray(); + while (!arg.atEnd()) { + arg.beginStructure(); + QString cgroupPath; + uint pid = 0; + QString cmdline; + arg >> cgroupPath >> pid >> cmdline; + arg.endStructure(); + if (pid > 0) { + pids.push_back(pid); + } + } + arg.endArray(); + return pids; +} diff --git a/src/gamecenter/systemdusermanager.h b/src/gamecenter/systemdusermanager.h new file mode 100644 index 0000000..62318eb --- /dev/null +++ b/src/gamecenter/systemdusermanager.h @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +struct SystemdProperty { + QString name; + QVariant value; +}; +Q_DECLARE_METATYPE(SystemdProperty) + +using SystemdProperties = QList; +Q_DECLARE_METATYPE(SystemdProperties) + +struct SystemdAuxUnit { + QString name; + SystemdProperties properties; +}; +Q_DECLARE_METATYPE(SystemdAuxUnit) + +using SystemdAuxUnits = QList; +Q_DECLARE_METATYPE(SystemdAuxUnits) + +struct SystemdUnitInfo { + QString name; + QString description; + QString loadState; + QString activeState; + QString subState; + QString following; + QDBusObjectPath objectPath; + uint jobId = 0; + QString jobType; + QDBusObjectPath jobPath; +}; +Q_DECLARE_METATYPE(SystemdUnitInfo) + +using SystemdUnitInfoList = QList; +Q_DECLARE_METATYPE(SystemdUnitInfoList) + +class SystemdUserManager : public QObject +{ + Q_OBJECT + +public: + explicit SystemdUserManager(QObject *parent = nullptr); + + bool isAvailable() const; + + QDBusReply startTransientScope(const QString &unitName, const QList &pids, const QString &description); + + QDBusReply stopUnit(const QString &unitName, const QString &mode = QStringLiteral("replace")); + + QDBusReply getUnit(const QString &unitName); + + QDBusReply listUnits(); + QList scopePids(const QDBusObjectPath &unitPath); + +private: + void ensureTypesRegistered(); +};