a-la-karte/src/gamecenter/gamecenterdaemon.cpp

1156 lines
38 KiB
C++
Raw Normal View History

// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
#include "gamecenterdaemon.h"
#include <QDBusArgument>
#include <QDBusConnection>
#include <QDBusConnectionInterface>
#include <QDBusError>
#include <QDBusObjectPath>
#include <QDBusReply>
#include <QDBusVariant>
#include <QProcess>
#include <QProcessEnvironment>
#include <QStandardPaths>
#include <QTimer>
#include <QUuid>
#include <QVariantMap>
#include <QRegularExpression>
#include <utility>
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<QDBusVariant>()) {
v = v.value<QDBusVariant>().variant();
}
return v;
}
static QVariant normalizeForDbus(QVariant v)
{
v = unwrapDbusVariant(v);
if (v.userType() == QMetaType::QChar) {
return QString(v.toChar());
}
if (v.canConvert<QDBusArgument>()) {
const QDBusArgument arg = v.value<QDBusArgument>();
const QMap<QString, QString> asStringMap = qdbus_cast<QMap<QString, QString>>(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<QVariantMap>(arg);
if (!asMap.isEmpty()) {
return normalizeForDbus(asMap);
}
const QStringList asStringList = qdbus_cast<QStringList>(arg);
if (!asStringList.isEmpty()) {
return asStringList;
}
}
if (v.canConvert<QVariantMap>()) {
QVariantMap m = v.toMap();
for (auto it = m.begin(); it != m.end(); ++it) {
it.value() = normalizeForDbus(it.value());
}
return m;
}
if (v.canConvert<QVariantList>()) {
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<QStringList>()) {
return v.toStringList();
}
if (v.canConvert<QVariantList>()) {
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<QDBusArgument>()) {
const QDBusArgument arg = v.value<QDBusArgument>();
const QStringList out = qdbus_cast<QStringList>(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<QDBusVariant>()) {
activeStateV = activeStateV.value<QDBusVariant>().variant();
}
if (!activeStateV.isValid()) {
return;
}
const QString activeState = activeStateV.toString();
if (activeState == QLatin1String("active") || activeState == QLatin1String("activating") || activeState == QLatin1String("deactivating")) {
const QList<uint> 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<QDBusVariant>()) {
v = v.value<QDBusVariant>().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<QDBusVariant>()) {
v = v.value<QDBusVariant>().variant();
}
if (v.canConvert<QVariantMap>()) {
return v.toMap();
}
if (v.canConvert<QDBusArgument>()) {
const QDBusArgument arg = v.value<QDBusArgument>();
const QMap<QString, QString> asStringMap = qdbus_cast<QMap<QString, QString>>(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<QVariantMap>(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<QDBusVariant>()) {
value = value.value<QDBusVariant>().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<uint> pids = {static_cast<uint>(process->processId())};
const QString description = gameId.isEmpty() ? QStringLiteral("A-La-Karte game") : QStringLiteral("A-La-Karte game %1").arg(gameId);
const QDBusReply<QDBusObjectPath> 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<QDBusObjectPath> 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<uint>(process->processId());
m_sessions.insert(sessionId, session);
watchSystemdUnit(sessionId, unitName, unitPath);
Q_EMIT SessionAdded(sessionToVariantMap(session, QStringLiteral("Running")));
connect(process, qOverload<int, QProcess::ExitStatus>(&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<int>(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<QList<ProcessScanner::Match>()> 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<ProcessScanner::Match> {
// Primary: look for SteamAppId=<appId> in environ
QList<ProcessScanner::Match> results = ProcessScanner::findByEnvironment(QStringLiteral("SteamAppId"), appId);
if (results.isEmpty()) {
results = ProcessScanner::findByEnvironment(QStringLiteral("SteamGameId"), appId);
}
// Filter out the steam client itself
QList<ProcessScanner::Match> 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<ProcessScanner::Match> {
QList<ProcessScanner::Match> results = ProcessScanner::findByEnvironment(QStringLiteral("LUTRIS_GAME_SLUG"), lutrisId);
if (results.isEmpty()) {
results = ProcessScanner::findByCmdline(lutrisId);
// Filter out lutris itself
QList<ProcessScanner::Match> 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<int, QProcess::ExitStatus>(&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<ProcessScanner::Match> &matches) {
QList<uint> 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<uint> &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<QDBusObjectPath> 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<QDBusObjectPath> 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<QDBusVariant>()) {
v = v.value<QDBusVariant>().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/<appid>
static const QRegularExpression steamUrlRe(QStringLiteral("steam://rungameid/(\\d+)"));
QRegularExpressionMatch match = steamUrlRe.match(command);
if (match.hasMatch()) {
return match.captured(1);
}
// steam -applaunch <appid>
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/<id> or lutris:rungame/<slug>
static const QRegularExpression lutrisRe(QStringLiteral("lutris:rungame(?:id)?/([^\\s]+)"));
QRegularExpressionMatch match = lutrisRe.match(command);
if (match.hasMatch()) {
return match.captured(1);
}
// "lutris lutris:rungameid/<id>" 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<QProcess> proc = it.value().process;
it.value().process = nullptr;
if (proc) {
connect(proc, qOverload<int, QProcess::ExitStatus>(&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<QDBusObjectPath> 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<qulonglong>(session.process->processId()));
} else if (session.mainPid > 0) {
map.insert(QStringLiteral("pid"), static_cast<qulonglong>(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<SystemdUnitInfoList> 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<uint> 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"