Compare commits

...

5 commits

27 changed files with 3398 additions and 128 deletions

View file

@ -10,6 +10,7 @@ add_executable(alakarte
gamepadmanager.cpp
inputmanager.cpp
mediamanager.cpp
runnermanagerclient.cpp
screenshotmodel.cpp
platformimporter.cpp
steamimporter.cpp
@ -139,3 +140,5 @@ ecm_add_qml_module(alakarte URI org.kde.alakarte
install(TARGETS alakarte ${KDE_INSTALL_TARGETS_DEFAULT_ARGS})
add_subdirectory(gamecenter)
add_subdirectory(runner)

View file

@ -27,6 +27,7 @@ App::App(QObject *parent)
: QObject(parent)
, m_gameModel(new GameModel(this))
, m_launcher(new GameLauncher(this))
, m_runnerManager(new RunnerManagerClient(this))
, m_steamGridDB(new SteamGridDB(this))
, m_mediaManager(new MediaManager(this))
, m_config(new Config(this))
@ -169,6 +170,11 @@ GameLauncher *App::launcher() const
return m_launcher;
}
RunnerManagerClient *App::runnerManager() const
{
return m_runnerManager;
}
SteamGridDB *App::steamGridDB() const
{
return m_steamGridDB;

View file

@ -12,6 +12,7 @@
#include "gamelauncher.h"
#include "gamemodel.h"
#include "mediamanager.h"
#include "runnermanagerclient.h"
#include "steamgriddb.h"
class App : public QObject
@ -22,6 +23,7 @@ class App : public QObject
Q_PROPERTY(GameModel *gameModel READ gameModel CONSTANT)
Q_PROPERTY(GameLauncher *launcher READ launcher CONSTANT)
Q_PROPERTY(RunnerManagerClient *runnerManager READ runnerManager CONSTANT)
Q_PROPERTY(SteamGridDB *steamGridDB READ steamGridDB CONSTANT)
Q_PROPERTY(MediaManager *mediaManager READ mediaManager CONSTANT)
Q_PROPERTY(Config *config READ config CONSTANT)
@ -34,6 +36,7 @@ public:
GameModel *gameModel() const;
GameLauncher *launcher() const;
RunnerManagerClient *runnerManager() const;
SteamGridDB *steamGridDB() const;
MediaManager *mediaManager() const;
Config *config() const;
@ -74,6 +77,7 @@ private:
GameModel *m_gameModel;
GameLauncher *m_launcher;
RunnerManagerClient *m_runnerManager;
SteamGridDB *m_steamGridDB;
MediaManager *m_mediaManager;
Config *m_config;

View file

@ -159,6 +159,19 @@ void Game::setLaunchRunner(const QString &runner)
}
}
QString Game::launchRunnerId() const
{
return m_launchRunnerId;
}
void Game::setLaunchRunnerId(const QString &runnerId)
{
if (m_launchRunnerId != runnerId) {
m_launchRunnerId = runnerId;
Q_EMIT launchRunnerIdChanged();
}
}
QString Game::launchRunnerPath() const
{
return m_launchRunnerPath;
@ -323,7 +336,8 @@ QJsonObject Game::toJson() const
obj[QStringLiteral("hidden")] = m_hidden;
obj[QStringLiteral("installed")] = m_installed;
const bool hasLaunchConfig = !m_launchEnv.isEmpty() || !m_launchRunner.isEmpty() || !m_launchRunnerPath.isEmpty() || !m_launchPrefixPath.isEmpty();
const bool hasLaunchConfig =
!m_launchEnv.isEmpty() || !m_launchRunner.isEmpty() || !m_launchRunnerId.isEmpty() || !m_launchRunnerPath.isEmpty() || !m_launchPrefixPath.isEmpty();
if (hasLaunchConfig) {
QJsonObject launchObj;
@ -338,6 +352,9 @@ QJsonObject Game::toJson() const
if (!m_launchRunner.isEmpty()) {
launchObj.insert(QStringLiteral("runner"), m_launchRunner);
}
if (!m_launchRunnerId.isEmpty()) {
launchObj.insert(QStringLiteral("runnerId"), m_launchRunnerId);
}
if (!m_launchRunnerPath.isEmpty()) {
launchObj.insert(QStringLiteral("runnerPath"), m_launchRunnerPath);
}
@ -391,6 +408,7 @@ Game *Game::fromJson(const QJsonObject &json, QObject *parent)
QVariantMap env;
QString runner;
QString runnerId;
QString runnerPath;
QString prefixPath;
const QJsonValue launchValue = json.value(QStringLiteral("launch"));
@ -402,6 +420,7 @@ Game *Game::fromJson(const QJsonObject &json, QObject *parent)
}
runner = launchObj.value(QStringLiteral("runner")).toString();
runnerId = launchObj.value(QStringLiteral("runnerId")).toString();
runnerPath = launchObj.value(QStringLiteral("runnerPath")).toString();
prefixPath = launchObj.value(QStringLiteral("prefixPath")).toString();
}
@ -414,6 +433,9 @@ Game *Game::fromJson(const QJsonObject &json, QObject *parent)
if (runner.isEmpty()) {
runner = json.value(QStringLiteral("launchRunner")).toString();
}
if (runnerId.isEmpty()) {
runnerId = json.value(QStringLiteral("launchRunnerId")).toString();
}
if (runnerPath.isEmpty()) {
runnerPath = json.value(QStringLiteral("launchRunnerPath")).toString();
}
@ -428,6 +450,9 @@ Game *Game::fromJson(const QJsonObject &json, QObject *parent)
if (!runner.isEmpty()) {
game->setLaunchRunner(runner);
}
if (!runnerId.isEmpty()) {
game->setLaunchRunnerId(runnerId);
}
if (!runnerPath.isEmpty()) {
game->setLaunchRunnerPath(runnerPath);
}

View file

@ -28,6 +28,7 @@ class Game : public QObject
Q_PROPERTY(QString workingDirectory READ workingDirectory WRITE setWorkingDirectory NOTIFY workingDirectoryChanged)
Q_PROPERTY(QVariantMap launchEnv READ launchEnv WRITE setLaunchEnv NOTIFY launchEnvChanged)
Q_PROPERTY(QString launchRunner READ launchRunner WRITE setLaunchRunner NOTIFY launchRunnerChanged)
Q_PROPERTY(QString launchRunnerId READ launchRunnerId WRITE setLaunchRunnerId NOTIFY launchRunnerIdChanged)
Q_PROPERTY(QString launchRunnerPath READ launchRunnerPath WRITE setLaunchRunnerPath NOTIFY launchRunnerPathChanged)
Q_PROPERTY(QString launchPrefixPath READ launchPrefixPath WRITE setLaunchPrefixPath NOTIFY launchPrefixPathChanged)
Q_PROPERTY(QString platform READ platform WRITE setPlatform NOTIFY platformChanged)
@ -76,6 +77,9 @@ public:
QString launchRunner() const;
void setLaunchRunner(const QString &runner);
QString launchRunnerId() const;
void setLaunchRunnerId(const QString &runnerId);
QString launchRunnerPath() const;
void setLaunchRunnerPath(const QString &path);
@ -127,6 +131,7 @@ Q_SIGNALS:
void workingDirectoryChanged();
void launchEnvChanged();
void launchRunnerChanged();
void launchRunnerIdChanged();
void launchRunnerPathChanged();
void launchPrefixPathChanged();
void platformChanged();
@ -151,6 +156,7 @@ private:
QString m_workingDirectory;
QVariantMap m_launchEnv;
QString m_launchRunner;
QString m_launchRunnerId;
QString m_launchRunnerPath;
QString m_launchPrefixPath;
QString m_platform;

View file

@ -24,6 +24,9 @@ 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")
file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/dbus-system")
file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/systemd-system")
file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/dbus-policy")
configure_file(dbus/org.kde.GameCenter1.service.in
"${CMAKE_CURRENT_BINARY_DIR}/dbus/org.kde.GameCenter1.service"
@ -35,6 +38,21 @@ configure_file(systemd/org.kde.GameCenter1.service.in
@ONLY
)
configure_file(dbus/org.kde.GameCenter1.system.service.in
"${CMAKE_CURRENT_BINARY_DIR}/dbus-system/org.kde.GameCenter1.service"
@ONLY
)
configure_file(systemd/org.kde.GameCenter1.system.service.in
"${CMAKE_CURRENT_BINARY_DIR}/systemd-system/org.kde.GameCenter1.service"
@ONLY
)
configure_file(dbus/org.kde.GameCenter1.conf.in
"${CMAKE_CURRENT_BINARY_DIR}/dbus-policy/org.kde.GameCenter1.conf"
@ONLY
)
install(FILES "${CMAKE_CURRENT_BINARY_DIR}/dbus/org.kde.GameCenter1.service"
DESTINATION ${KDE_INSTALL_DBUSSERVICEDIR}
)
@ -42,3 +60,15 @@ install(FILES "${CMAKE_CURRENT_BINARY_DIR}/dbus/org.kde.GameCenter1.service"
install(FILES "${CMAKE_CURRENT_BINARY_DIR}/systemd/org.kde.GameCenter1.service"
DESTINATION ${KDE_INSTALL_SYSTEMDUSERUNITDIR}
)
install(FILES "${CMAKE_CURRENT_BINARY_DIR}/dbus-system/org.kde.GameCenter1.service"
DESTINATION ${KDE_INSTALL_DBUSSYSTEMSERVICEDIR}
)
install(FILES "${CMAKE_CURRENT_BINARY_DIR}/systemd-system/org.kde.GameCenter1.service"
DESTINATION ${KDE_INSTALL_SYSTEMDUNITDIR}
)
install(FILES "${CMAKE_CURRENT_BINARY_DIR}/dbus-policy/org.kde.GameCenter1.conf"
DESTINATION ${KDE_INSTALL_SYSCONFDIR}/dbus-1/system.d
)

View file

@ -0,0 +1,13 @@
<!DOCTYPE busconfig PUBLIC
"-//freedesktop//DTD D-Bus Bus Configuration 1.0//EN"
"http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
<busconfig>
<policy user="root">
<allow own="org.kde.GameCenter1"/>
</policy>
<policy context="default">
<allow send_destination="org.kde.GameCenter1"/>
<allow receive_sender="org.kde.GameCenter1"/>
</policy>
</busconfig>

View file

@ -0,0 +1,4 @@
[D-BUS Service]
Name=org.kde.GameCenter1
Exec=@CMAKE_INSTALL_PREFIX@/@KDE_INSTALL_BINDIR@/alakarte-gamecenter --system
SystemdService=org.kde.GameCenter1.service

View file

@ -7,9 +7,13 @@
#include <QDBusConnection>
#include <QDBusConnectionInterface>
#include <QDBusError>
#include <QDBusInterface>
#include <QDBusMessage>
#include <QDBusObjectPath>
#include <QDBusReply>
#include <QDBusVariant>
#include <QDBusVirtualObject>
#include <QDir>
#include <QProcess>
#include <QProcessEnvironment>
#include <QStandardPaths>
@ -19,8 +23,14 @@
#include <QRegularExpression>
#include <QSet>
#include <utility>
static const QString kRunnerService = QStringLiteral("org.kde.ALaKarte.Runner1");
static const QString kRunnerPath = QStringLiteral("/org/kde/ALaKarte/Runner1");
static const QString kRunnerInterface = QStringLiteral("org.kde.ALaKarte.Runner1");
static QString ensureScopeUnitName(const QString &unitName)
{
if (unitName.endsWith(QLatin1String(".scope"))) {
@ -49,6 +59,10 @@ static QVariant normalizeForDbus(QVariant v)
return QString(v.toChar());
}
if (v.userType() == QMetaType::QString || v.userType() == QMetaType::QStringList) {
return v;
}
if (v.canConvert<QDBusArgument>()) {
const QDBusArgument arg = v.value<QDBusArgument>();
const QMap<QString, QString> asStringMap = qdbus_cast<QMap<QString, QString>>(arg);
@ -77,7 +91,7 @@ static QVariant normalizeForDbus(QVariant v)
}
return m;
}
if (v.canConvert<QVariantList>()) {
if (v.userType() == QMetaType::QVariantList) {
QVariantList list = v.toList();
for (QVariant &item : list) {
item = normalizeForDbus(item);
@ -96,6 +110,57 @@ static QVariantMap normalizeVariantMapForDbus(const QVariantMap &map)
return out;
}
static bool tryResolveWithRunnerManager(const QVariantMap &spec, QVariantMap &out)
{
QDBusConnection bus = QDBusConnection::sessionBus();
if (!bus.isConnected() || !bus.interface()) {
return false;
}
if (!bus.interface()->isServiceRegistered(kRunnerService)) {
bus.interface()->startService(kRunnerService);
}
QDBusInterface iface(kRunnerService, kRunnerPath, kRunnerInterface, bus);
if (!iface.isValid()) {
if (bus.interface()->startService(kRunnerService).isValid()) {
QDBusInterface retryIface(kRunnerService, kRunnerPath, kRunnerInterface, bus);
if (retryIface.isValid()) {
retryIface.setTimeout(2000);
const QDBusReply<QVariantMap> retryReply = retryIface.call(QStringLiteral("ResolveLaunch"), spec);
if (retryReply.isValid()) {
out = normalizeVariantMapForDbus(retryReply.value());
return true;
}
}
}
return false;
}
iface.setTimeout(2000);
const QDBusReply<QVariantMap> reply = iface.call(QStringLiteral("ResolveLaunch"), spec);
if (!reply.isValid()) {
if (reply.error().type() == QDBusError::ServiceUnknown) {
bus.interface()->startService(kRunnerService);
QDBusInterface retryIface(kRunnerService, kRunnerPath, kRunnerInterface, bus);
if (!retryIface.isValid()) {
return false;
}
retryIface.setTimeout(2000);
const QDBusReply<QVariantMap> retryReply = retryIface.call(QStringLiteral("ResolveLaunch"), spec);
if (!retryReply.isValid()) {
return false;
}
out = normalizeVariantMapForDbus(retryReply.value());
return true;
}
return false;
}
out = normalizeVariantMapForDbus(reply.value());
return true;
}
static QString mapStringValue(const QVariantMap &map, const QStringList &keys)
{
for (const QString &k : keys) {
@ -171,6 +236,445 @@ private:
} // namespace
static const QString kGameCenterService = QStringLiteral("org.kde.GameCenter1");
static const QString kGameCenterPath = QStringLiteral("/org/kde/GameCenter1");
static const QString kGameCenterInterface = QStringLiteral("org.kde.GameCenter1");
namespace
{
static QString gameCenterIntrospectionXml()
{
return QStringLiteral(
"<!DOCTYPE node PUBLIC \"-//freedesktop//DTD D-Bus Object Introspection 1.0//EN\" "
"\"http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd\">"
"<node>"
" <interface name=\"org.freedesktop.DBus.Introspectable\">"
" <method name=\"Introspect\">"
" <arg name=\"xml\" type=\"s\" direction=\"out\"/>"
" </method>"
" </interface>"
" <interface name=\"org.kde.GameCenter1\">"
" <method name=\"Ping\">"
" <arg name=\"out\" type=\"s\" direction=\"out\"/>"
" </method>"
" <method name=\"Version\">"
" <arg name=\"major\" type=\"u\" direction=\"out\"/>"
" <arg name=\"minor\" type=\"u\" direction=\"out\"/>"
" </method>"
" <method name=\"GetCapabilities\">"
" <arg name=\"caps\" type=\"a{sv}\" direction=\"out\"/>"
" </method>"
" <method name=\"ListSessions\">"
" <arg name=\"sessions\" type=\"av\" direction=\"out\"/>"
" </method>"
" <method name=\"GetSession\">"
" <arg name=\"sessionId\" type=\"s\" direction=\"in\"/>"
" <arg name=\"session\" type=\"a{sv}\" direction=\"out\"/>"
" </method>"
" <method name=\"Launch\">"
" <arg name=\"launchSpec\" type=\"a{sv}\" direction=\"in\"/>"
" <arg name=\"sessionId\" type=\"s\" direction=\"out\"/>"
" </method>"
" <method name=\"Stop\">"
" <arg name=\"sessionId\" type=\"s\" direction=\"in\"/>"
" </method>"
" <method name=\"StopByGameId\">"
" <arg name=\"gameId\" type=\"s\" direction=\"in\"/>"
" </method>"
" <method name=\"SetPolicy\">"
" <arg name=\"policy\" type=\"a{sv}\" direction=\"in\"/>"
" </method>"
" <method name=\"GetPolicy\">"
" <arg name=\"policy\" type=\"a{sv}\" direction=\"out\"/>"
" </method>"
" <signal name=\"SessionAdded\">"
" <arg name=\"session\" type=\"a{sv}\"/>"
" </signal>"
" <signal name=\"SessionChanged\">"
" <arg name=\"session\" type=\"a{sv}\"/>"
" </signal>"
" <signal name=\"SessionRemoved\">"
" <arg name=\"sessionId\" type=\"s\"/>"
" <arg name=\"finalState\" type=\"a{sv}\"/>"
" </signal>"
" <signal name=\"LaunchFailed\">"
" <arg name=\"error\" type=\"a{sv}\"/>"
" </signal>"
" </interface>"
"</node>");
}
} // namespace
class GameCenterSystemProxyPrivate;
class GameCenterProxyVirtualObject : public QDBusVirtualObject
{
Q_OBJECT
public:
explicit GameCenterProxyVirtualObject(GameCenterSystemProxyPrivate *proxy, QObject *parent = nullptr)
: QDBusVirtualObject(parent)
, m_proxy(proxy)
{
}
QString introspect(const QString &path) const override;
bool handleMessage(const QDBusMessage &message, const QDBusConnection &connection) override;
private:
GameCenterSystemProxyPrivate *m_proxy = nullptr;
};
class BackendSignalForwarder : public QObject
{
Q_OBJECT
public:
explicit BackendSignalForwarder(uint uid, GameCenterSystemProxyPrivate *proxy, QObject *parent = nullptr)
: QObject(parent)
, m_uid(uid)
, m_proxy(proxy)
{
}
public Q_SLOTS:
void onSessionAdded(const QVariantMap &session);
void onSessionChanged(const QVariantMap &session);
void onSessionRemoved(const QString &sessionId, const QVariantMap &finalState);
void onLaunchFailed(const QVariantMap &error);
private:
uint m_uid = 0;
GameCenterSystemProxyPrivate *m_proxy = nullptr;
};
struct BackendBusEntry {
BackendBusEntry(const QDBusConnection &bus, BackendSignalForwarder *forwarder)
: bus(bus)
, forwarder(forwarder)
{
}
QDBusConnection bus;
BackendSignalForwarder *forwarder = nullptr;
};
class GameCenterSystemProxyPrivate : public QObject
{
Q_OBJECT
public:
explicit GameCenterSystemProxyPrivate(QObject *parent = nullptr)
: QObject(parent)
{
}
bool init();
bool handleSystemMessage(const QDBusMessage &message, const QDBusConnection &connection);
void forwardSignalToUid(uint uid, const QString &member, const QList<QVariant> &args);
private Q_SLOTS:
void handleServiceOwnerChanged(const QString &name, const QString &oldOwner, const QString &newOwner);
private:
bool uidForSystemSender(const QString &senderUniqueName, uint &uid) const;
void rememberClient(const QString &senderUniqueName, uint uid);
void forgetClient(const QString &senderUniqueName);
BackendBusEntry *ensureBackend(uint uid);
QDBusConnection m_systemBus = QDBusConnection::systemBus();
std::unique_ptr<GameCenterProxyVirtualObject> m_virtualObject;
QHash<uint, BackendBusEntry> m_backends;
QHash<QString, uint> m_clientUid;
QHash<uint, QSet<QString>> m_uidClients;
};
QString GameCenterProxyVirtualObject::introspect(const QString &path) const
{
if (path == kGameCenterPath) {
return gameCenterIntrospectionXml();
}
return {};
}
bool GameCenterProxyVirtualObject::handleMessage(const QDBusMessage &message, const QDBusConnection &connection)
{
if (!m_proxy) {
return false;
}
return m_proxy->handleSystemMessage(message, connection);
}
void BackendSignalForwarder::onSessionAdded(const QVariantMap &session)
{
if (m_proxy) {
m_proxy->forwardSignalToUid(m_uid, QStringLiteral("SessionAdded"), {session});
}
}
void BackendSignalForwarder::onSessionChanged(const QVariantMap &session)
{
if (m_proxy) {
m_proxy->forwardSignalToUid(m_uid, QStringLiteral("SessionChanged"), {session});
}
}
void BackendSignalForwarder::onSessionRemoved(const QString &sessionId, const QVariantMap &finalState)
{
if (m_proxy) {
m_proxy->forwardSignalToUid(m_uid, QStringLiteral("SessionRemoved"), {sessionId, finalState});
}
}
void BackendSignalForwarder::onLaunchFailed(const QVariantMap &error)
{
if (m_proxy) {
m_proxy->forwardSignalToUid(m_uid, QStringLiteral("LaunchFailed"), {error});
}
}
bool GameCenterSystemProxyPrivate::init()
{
if (!m_systemBus.isConnected()) {
return false;
}
if (!m_systemBus.registerService(kGameCenterService)) {
return false;
}
m_virtualObject = std::make_unique<GameCenterProxyVirtualObject>(this);
if (!m_systemBus.registerVirtualObject(kGameCenterPath, m_virtualObject.get(), QDBusConnection::SingleNode)) {
return false;
}
if (m_systemBus.interface()) {
connect(m_systemBus.interface(), &QDBusConnectionInterface::serviceOwnerChanged, this, &GameCenterSystemProxyPrivate::handleServiceOwnerChanged);
}
return true;
}
bool GameCenterSystemProxyPrivate::uidForSystemSender(const QString &senderUniqueName, uint &uid) const
{
if (!m_systemBus.interface()) {
return false;
}
const QDBusReply<uint> uidReply = m_systemBus.interface()->serviceUid(senderUniqueName);
if (!uidReply.isValid()) {
return false;
}
uid = uidReply.value();
return true;
}
void GameCenterSystemProxyPrivate::rememberClient(const QString &senderUniqueName, uint uid)
{
const auto it = m_clientUid.constFind(senderUniqueName);
if (it != m_clientUid.constEnd()) {
if (it.value() == uid) {
return;
}
forgetClient(senderUniqueName);
}
m_clientUid.insert(senderUniqueName, uid);
m_uidClients[uid].insert(senderUniqueName);
}
void GameCenterSystemProxyPrivate::forgetClient(const QString &senderUniqueName)
{
const auto it = m_clientUid.find(senderUniqueName);
if (it == m_clientUid.end()) {
return;
}
const uint uid = it.value();
m_clientUid.erase(it);
auto clientsIt = m_uidClients.find(uid);
if (clientsIt != m_uidClients.end()) {
clientsIt.value().remove(senderUniqueName);
if (clientsIt.value().isEmpty()) {
m_uidClients.erase(clientsIt);
}
}
}
void GameCenterSystemProxyPrivate::handleServiceOwnerChanged(const QString &name, const QString &oldOwner, const QString &newOwner)
{
Q_UNUSED(oldOwner)
if (!newOwner.isEmpty()) {
return;
}
if (!name.startsWith(QLatin1Char(':'))) {
return;
}
forgetClient(name);
}
BackendBusEntry *GameCenterSystemProxyPrivate::ensureBackend(uint uid)
{
const auto it = m_backends.find(uid);
if (it != m_backends.end()) {
return &it.value();
}
const QString address = QStringLiteral("unix:path=/run/user/%1/bus").arg(uid);
const QString connectionName = QStringLiteral("gamecenter-user-%1").arg(uid);
QDBusConnection backend = QDBusConnection::connectToBus(address, connectionName);
if (!backend.isConnected()) {
return nullptr;
}
auto *forwarder = new BackendSignalForwarder(uid, this, this);
BackendBusEntry entry(backend, forwarder);
backend.connect(kGameCenterService, kGameCenterPath, kGameCenterInterface, QStringLiteral("SessionAdded"), forwarder, SLOT(onSessionAdded(QVariantMap)));
backend
.connect(kGameCenterService, kGameCenterPath, kGameCenterInterface, QStringLiteral("SessionChanged"), forwarder, SLOT(onSessionChanged(QVariantMap)));
backend.connect(kGameCenterService,
kGameCenterPath,
kGameCenterInterface,
QStringLiteral("SessionRemoved"),
forwarder,
SLOT(onSessionRemoved(QString, QVariantMap)));
backend.connect(kGameCenterService, kGameCenterPath, kGameCenterInterface, QStringLiteral("LaunchFailed"), forwarder, SLOT(onLaunchFailed(QVariantMap)));
auto inserted = m_backends.insert(uid, entry);
return &inserted.value();
}
void GameCenterSystemProxyPrivate::forwardSignalToUid(uint uid, const QString &member, const QList<QVariant> &args)
{
if (!m_systemBus.isConnected()) {
return;
}
const auto clientsIt = m_uidClients.constFind(uid);
if (clientsIt == m_uidClients.constEnd()) {
return;
}
const QSet<QString> clients = clientsIt.value();
for (const QString &client : clients) {
if (client.isEmpty()) {
continue;
}
QDBusMessage signal = QDBusMessage::createTargetedSignal(client, kGameCenterPath, kGameCenterInterface, member);
signal.setArguments(args);
m_systemBus.send(signal);
}
}
bool GameCenterSystemProxyPrivate::handleSystemMessage(const QDBusMessage &message, const QDBusConnection &connection)
{
if (message.type() != QDBusMessage::MethodCallMessage) {
return false;
}
if (message.interface() == QLatin1String("org.freedesktop.DBus.Introspectable") && message.member() == QLatin1String("Introspect")) {
if (!message.isReplyRequired()) {
return true;
}
connection.send(message.createReply(gameCenterIntrospectionXml()));
return true;
}
const QString sender = message.service();
if (sender.isEmpty()) {
if (message.isReplyRequired()) {
connection.send(message.createErrorReply(QDBusError::AccessDenied, QStringLiteral("missing sender")));
}
return true;
}
uint uid = 0;
if (!uidForSystemSender(sender, uid)) {
if (message.isReplyRequired()) {
connection.send(message.createErrorReply(QDBusError::AccessDenied, QStringLiteral("failed to determine caller uid")));
}
return true;
}
rememberClient(sender, uid);
BackendBusEntry *backend = ensureBackend(uid);
if (!backend) {
if (message.isReplyRequired()) {
connection.send(message.createErrorReply(QDBusError::ServiceUnknown, QStringLiteral("user bus backend unavailable")));
}
return true;
}
QDBusMessage forward = QDBusMessage::createMethodCall(kGameCenterService, message.path(), message.interface(), message.member());
forward.setArguments(message.arguments());
forward.setAutoStartService(true);
const QDBusMessage backendReply = backend->bus.call(forward, QDBus::Block, -1);
if (!message.isReplyRequired()) {
return true;
}
if (backendReply.type() == QDBusMessage::ErrorMessage) {
connection.send(message.createErrorReply(backendReply.errorName(), backendReply.errorMessage()));
return true;
}
if (backendReply.type() != QDBusMessage::ReplyMessage) {
connection.send(message.createErrorReply(QDBusError::Failed, QStringLiteral("backend did not reply")));
return true;
}
QDBusMessage out = message.createReply(backendReply.arguments());
if (message.interface() == kGameCenterInterface && message.member() == QLatin1String("GetCapabilities")) {
if (!out.arguments().isEmpty()) {
QVariant capsV = unwrapDbusVariant(out.arguments().first());
QVariantMap caps;
if (capsV.canConvert<QDBusArgument>()) {
const QDBusArgument arg = capsV.value<QDBusArgument>();
caps = qdbus_cast<QVariantMap>(arg);
} else if (capsV.canConvert<QVariantMap>()) {
caps = capsV.toMap();
}
if (!caps.isEmpty()) {
caps.insert(QStringLiteral("supportsSystemBus"), true);
out.setArguments({caps});
}
}
}
connection.send(out);
return true;
}
GameCenterSystemProxy::GameCenterSystemProxy(QObject *parent)
: QObject(parent)
, d(std::make_unique<GameCenterSystemProxyPrivate>())
{
}
GameCenterSystemProxy::~GameCenterSystemProxy() = default;
bool GameCenterSystemProxy::init()
{
return d->init();
}
void GameCenterDaemon::watchSystemdUnit(const QString &sessionId, const QString &unitName, const QDBusObjectPath &unitPath)
{
if (unitName.isEmpty()) {
@ -545,6 +1049,69 @@ QString GameCenterDaemon::launchDirect(const QVariantMap &launchSpec)
envOverrides = extractVariantMap(launchSpec.value(QStringLiteral("envOverrides")));
}
const QString runnerId = mapStringValue(launchSpec, {QStringLiteral("runnerId")});
const QString runner = mapStringValue(launchSpec, {QStringLiteral("runner")});
const QString runnerPath = mapStringValue(launchSpec, {QStringLiteral("runnerPath")});
const QString prefixPath = mapStringValue(launchSpec, {QStringLiteral("prefixPath")});
const QString requestedProgram = mapStringValue(launchSpec, {QStringLiteral("requestedProgram")});
QStringList requestedArgs;
if (launchSpec.contains(QStringLiteral("requestedArgs"))) {
requestedArgs = variantToStringList(launchSpec.value(QStringLiteral("requestedArgs")));
}
QVariantMap requestedEnvOverrides;
if (launchSpec.contains(QStringLiteral("requestedEnvOverrides"))) {
requestedEnvOverrides = extractVariantMap(launchSpec.value(QStringLiteral("requestedEnvOverrides")));
}
const bool wantsRunnerResolution = !runnerId.isEmpty() || runner == QLatin1String("wine") || runner == QLatin1String("proton");
if (wantsRunnerResolution) {
const QString baseProgram = requestedProgram.isEmpty() ? program : requestedProgram;
const QStringList baseArgs = requestedArgs.isEmpty() ? args : requestedArgs;
const QVariantMap baseEnv = requestedEnvOverrides.isEmpty() ? envOverrides : requestedEnvOverrides;
const QVariantMap resolveSpec = {
{QStringLiteral("runnerId"), runnerId},
{QStringLiteral("runner"), runner},
{QStringLiteral("runnerPath"), runnerPath},
{QStringLiteral("gameId"), gameId},
{QStringLiteral("prefixPath"), prefixPath},
{QStringLiteral("program"), baseProgram},
{QStringLiteral("args"), baseArgs},
{QStringLiteral("envOverrides"), baseEnv},
};
QVariantMap resolved;
if (tryResolveWithRunnerManager(resolveSpec, resolved)) {
if (!resolved.value(QStringLiteral("ok")).toBool()) {
QVariantMap ctx = launchSpec;
ctx.insert(QStringLiteral("runnerResolution"), resolved);
failLaunch(resolved.value(QStringLiteral("error")).toString(), ctx);
return {};
}
program = mapStringValue(resolved, {QStringLiteral("finalProgram")});
if (resolved.contains(QStringLiteral("finalArgs"))) {
args = variantToStringList(resolved.value(QStringLiteral("finalArgs")));
}
if (resolved.contains(QStringLiteral("effectiveEnv"))) {
envOverrides = extractVariantMap(resolved.value(QStringLiteral("effectiveEnv")));
}
const QString resolvedPrefixPath = mapStringValue(resolved, {QStringLiteral("resolvedPrefixPath")});
if (!resolvedPrefixPath.isEmpty()) {
QDir().mkpath(resolvedPrefixPath);
}
} else if (!runnerId.isEmpty()) {
QVariantMap ctx = launchSpec;
ctx.insert(QStringLiteral("runnerResolution"),
QVariantMap{{QStringLiteral("ok"), false}, {QStringLiteral("error"), QStringLiteral("runner service unavailable")}});
failLaunch(QStringLiteral("runner service is not available"), ctx);
return {};
}
}
const QString sessionId = QUuid::createUuid().toString(QUuid::WithoutBraces);
const QString unitName = ensureScopeUnitName(QStringLiteral("alakarte-game-%1").arg(sessionId));

View file

@ -3,6 +3,8 @@
#pragma once
#include <memory>
#include <QDBusObjectPath>
#include <QDateTime>
#include <QHash>
@ -94,3 +96,19 @@ private:
QHash<QString, QString> m_unitNameToSessionId;
QHash<QString, QObject *> m_unitPathWatchers;
};
class GameCenterSystemProxyPrivate;
class GameCenterSystemProxy : public QObject
{
Q_OBJECT
public:
explicit GameCenterSystemProxy(QObject *parent = nullptr);
~GameCenterSystemProxy() override;
bool init();
private:
std::unique_ptr<GameCenterSystemProxyPrivate> d;
};

View file

@ -1,6 +1,8 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
#include <QCommandLineOption>
#include <QCommandLineParser>
#include <QCoreApplication>
#include "gamecenterdaemon.h"
@ -9,6 +11,21 @@ int main(int argc, char *argv[])
{
QCoreApplication app(argc, argv);
QCommandLineParser parser;
parser.setApplicationDescription(QStringLiteral("A-La-Karte Game Center"));
parser.addHelpOption();
parser.addOption(QCommandLineOption(QStringLiteral("system"), QStringLiteral("Run as system bus proxy")));
parser.process(app);
if (parser.isSet(QStringLiteral("system"))) {
GameCenterSystemProxy proxy;
if (!proxy.init()) {
return 1;
}
return app.exec();
}
GameCenterDaemon daemon;
if (!daemon.init()) {
return 1;

View file

@ -0,0 +1,8 @@
[Unit]
Description=A-La-Karte Game Center
[Service]
ExecStart=@CMAKE_INSTALL_PREFIX@/@KDE_INSTALL_BINDIR@/alakarte-gamecenter --system
Type=dbus
BusName=org.kde.GameCenter1
Restart=no

View file

@ -7,6 +7,8 @@
#include <QCoreApplication>
#include <QDBusArgument>
#include <QDBusConnection>
#include <QDBusConnectionInterface>
#include <QDBusError>
#include <QDBusInterface>
#include <QDBusReply>
#include <QDBusVariant>
@ -19,16 +21,88 @@
#include <QTimer>
#include <QUrl>
static QStringList steamCandidateRoots()
static const QString kGameCenterService = QStringLiteral("org.kde.GameCenter1");
static const QString kGameCenterPath = QStringLiteral("/org/kde/GameCenter1");
static const QString kGameCenterInterface = QStringLiteral("org.kde.GameCenter1");
static const QString kRunnerService = QStringLiteral("org.kde.ALaKarte.Runner1");
static const QString kRunnerPath = QStringLiteral("/org/kde/ALaKarte/Runner1");
static const QString kRunnerInterface = QStringLiteral("org.kde.ALaKarte.Runner1");
static bool pingDaemon(QDBusConnection bus)
{
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"),
};
if (!bus.isConnected()) {
return false;
}
QDBusInterface iface(kGameCenterService, kGameCenterPath, kGameCenterInterface, bus);
if (!iface.isValid()) {
return false;
}
iface.setTimeout(2000);
const QDBusReply<QString> reply = iface.call(QStringLiteral("Ping"));
return reply.isValid() && reply.value() == QLatin1String("ok");
}
static void disconnectDaemonSignals(QDBusConnection bus, GameLauncher *launcher)
{
if (!bus.isConnected()) {
return;
}
bus.disconnect(kGameCenterService,
kGameCenterPath,
kGameCenterInterface,
QStringLiteral("SessionAdded"),
launcher,
SLOT(onDaemonSessionAdded(QVariantMap)));
bus.disconnect(kGameCenterService,
kGameCenterPath,
kGameCenterInterface,
QStringLiteral("SessionChanged"),
launcher,
SLOT(onDaemonSessionChanged(QVariantMap)));
bus.disconnect(kGameCenterService,
kGameCenterPath,
kGameCenterInterface,
QStringLiteral("SessionRemoved"),
launcher,
SLOT(onDaemonSessionRemoved(QString, QVariantMap)));
bus.disconnect(kGameCenterService,
kGameCenterPath,
kGameCenterInterface,
QStringLiteral("LaunchFailed"),
launcher,
SLOT(onDaemonLaunchFailed(QVariantMap)));
}
static void connectDaemonSignals(QDBusConnection bus, GameLauncher *launcher)
{
if (!bus.isConnected()) {
return;
}
bus.connect(kGameCenterService, kGameCenterPath, kGameCenterInterface, QStringLiteral("SessionAdded"), launcher, SLOT(onDaemonSessionAdded(QVariantMap)));
bus.connect(kGameCenterService,
kGameCenterPath,
kGameCenterInterface,
QStringLiteral("SessionChanged"),
launcher,
SLOT(onDaemonSessionChanged(QVariantMap)));
bus.connect(kGameCenterService,
kGameCenterPath,
kGameCenterInterface,
QStringLiteral("SessionRemoved"),
launcher,
SLOT(onDaemonSessionRemoved(QString, QVariantMap)));
bus.connect(kGameCenterService, kGameCenterPath, kGameCenterInterface, QStringLiteral("LaunchFailed"), launcher, SLOT(onDaemonLaunchFailed(QVariantMap)));
}
static QVariant unwrapDbusVariant(QVariant v)
@ -48,6 +122,59 @@ static QVariantMap unwrapVariantMap(const QVariantMap &map)
return out;
}
static bool tryResolveWithRunnerManager(const QVariantMap &spec, QVariantMap &out)
{
QDBusConnection bus = QDBusConnection::sessionBus();
if (!bus.isConnected() || !bus.interface()) {
return false;
}
if (!bus.interface()->isServiceRegistered(kRunnerService)) {
bus.interface()->startService(kRunnerService);
}
QDBusInterface iface(kRunnerService, kRunnerPath, kRunnerInterface, bus);
if (!iface.isValid()) {
if (bus.interface()->startService(kRunnerService).isValid()) {
QDBusInterface retryIface(kRunnerService, kRunnerPath, kRunnerInterface, bus);
if (retryIface.isValid()) {
retryIface.setTimeout(2000);
const QDBusReply<QVariantMap> retryReply = retryIface.call(QStringLiteral("ResolveLaunch"), spec);
if (retryReply.isValid()) {
out = unwrapVariantMap(retryReply.value());
return true;
}
}
}
return false;
}
iface.setTimeout(2000);
const QDBusReply<QVariantMap> reply = iface.call(QStringLiteral("ResolveLaunch"), spec);
if (!reply.isValid()) {
if (reply.error().type() == QDBusError::ServiceUnknown) {
bus.interface()->startService(kRunnerService);
QDBusInterface retryIface(kRunnerService, kRunnerPath, kRunnerInterface, bus);
if (!retryIface.isValid()) {
return false;
}
retryIface.setTimeout(2000);
const QDBusReply<QVariantMap> retryReply = retryIface.call(QStringLiteral("ResolveLaunch"), spec);
if (!retryReply.isValid()) {
return false;
}
out = unwrapVariantMap(retryReply.value());
return true;
}
return false;
}
out = unwrapVariantMap(reply.value());
return true;
}
static QStringList steamCandidateRoots();
static QString findSteamClientInstallPathFromProton(const QString &protonExe)
{
if (protonExe.isEmpty()) {
@ -130,40 +257,23 @@ static QString discoverDefaultProtonExecutable()
return cached;
}
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"),
};
}
GameLauncher::GameLauncher(QObject *parent)
: QObject(parent)
, m_daemonAvailable(false)
, m_usingSystemBus(false)
{
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<App *>(parent)) {
connect(app->gameModel(), &GameModel::countChanged, this, [this]() {
QTimer::singleShot(0, this, [this]() {
@ -222,12 +332,14 @@ QVariantMap GameLauncher::resolveLaunchInfo(Game *game) const
}
const QString runner = game->launchRunner().trimmed();
const QString runnerId = game->launchRunnerId().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();
const bool hasLaunchOverrides = !runner.isEmpty() || !runnerId.isEmpty() || !runnerPath.isEmpty() || !prefixPath.isEmpty() || !launchEnv.isEmpty();
info.insert(QStringLiteral("runner"), runner);
info.insert(QStringLiteral("runnerId"), runnerId);
info.insert(QStringLiteral("runnerPath"), runnerPath);
info.insert(QStringLiteral("prefixPath"), prefixPath);
info.insert(QStringLiteral("workingDirectory"), game->workingDirectory());
@ -287,6 +399,47 @@ QVariantMap GameLauncher::resolveLaunchInfo(Game *game) const
return info;
}
if (!runnerId.isEmpty() || runner == QLatin1String("wine") || runner == QLatin1String("proton")) {
QVariantMap runnerSpec = {
{QStringLiteral("runnerId"), runnerId},
{QStringLiteral("runner"), runner},
{QStringLiteral("runnerPath"), runnerPath},
{QStringLiteral("gameId"), game->id()},
{QStringLiteral("prefixPath"), prefixPath},
{QStringLiteral("program"), program},
{QStringLiteral("args"), parts},
{QStringLiteral("envOverrides"), envOverrides},
};
QVariantMap resolved;
if (tryResolveWithRunnerManager(runnerSpec, resolved)) {
if (!resolved.value(QStringLiteral("ok")).toBool()) {
info.insert(QStringLiteral("error"), resolved.value(QStringLiteral("error")).toString());
return info;
}
info.insert(QStringLiteral("finalProgram"), resolved.value(QStringLiteral("finalProgram")).toString());
info.insert(QStringLiteral("finalArgs"), resolved.value(QStringLiteral("finalArgs")).toStringList());
info.insert(QStringLiteral("effectiveEnv"), resolved.value(QStringLiteral("effectiveEnv")).toMap());
const QString resolvedPrefixPath = resolved.value(QStringLiteral("resolvedPrefixPath")).toString();
if (!resolvedPrefixPath.isEmpty()) {
info.insert(QStringLiteral("resolvedPrefixPath"), resolvedPrefixPath);
}
const QString resolvedSteamInstallPath = resolved.value(QStringLiteral("resolvedSteamInstallPath")).toString();
if (!resolvedSteamInstallPath.isEmpty()) {
info.insert(QStringLiteral("resolvedSteamInstallPath"), resolvedSteamInstallPath);
}
info.insert(QStringLiteral("ok"), true);
return info;
} else if (!runnerId.isEmpty()) {
info.insert(QStringLiteral("error"), tr("Runner service is not available"));
return info;
}
}
if (runner == QLatin1String("custom")) {
if (runnerPath.isEmpty()) {
info.insert(QStringLiteral("error"), tr("Custom runner is enabled but no runner path is configured"));
@ -354,7 +507,6 @@ QVariantMap GameLauncher::resolveLaunchInfo(Game *game) const
}
void GameLauncher::launchGame(Game *game)
{
if (!game) {
return;
@ -376,7 +528,12 @@ void GameLauncher::launchGame(Game *game)
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 runner = info.value(QStringLiteral("runner")).toString();
const QString runnerId = info.value(QStringLiteral("runnerId")).toString();
const QString runnerPath = info.value(QStringLiteral("runnerPath")).toString();
const QString prefixPath = info.value(QStringLiteral("prefixPath")).toString();
const QVariantMap envOverrides = info.value(QStringLiteral("envOverrides")).toMap();
if (runner == QLatin1String("proton") || runner == QLatin1String("wine")) {
const QString resolvedPrefixPath = info.value(QStringLiteral("resolvedPrefixPath")).toString();
if (!resolvedPrefixPath.isEmpty()) {
QDir().mkpath(resolvedPrefixPath);
@ -390,10 +547,10 @@ void GameLauncher::launchGame(Game *game)
// Always try daemon first — for all launch types
{
QDBusInterface iface(QStringLiteral("org.kde.GameCenter1"),
QStringLiteral("/org/kde/GameCenter1"),
QStringLiteral("org.kde.GameCenter1"),
QDBusConnection::sessionBus());
QDBusInterface iface(kGameCenterService,
kGameCenterPath,
kGameCenterInterface,
m_usingSystemBus ? QDBusConnection::systemBus() : QDBusConnection::sessionBus());
QVariantMap launchSpec = {
{QStringLiteral("command"), launchCommand},
{QStringLiteral("gameId"), game->id()},
@ -401,6 +558,26 @@ void GameLauncher::launchGame(Game *game)
{QStringLiteral("provider"), provider},
{QStringLiteral("origin"), QStringLiteral("ui")},
};
if (!runnerId.isEmpty()) {
launchSpec.insert(QStringLiteral("runnerId"), runnerId);
}
if (!runner.isEmpty()) {
launchSpec.insert(QStringLiteral("runner"), runner);
}
if (!runnerPath.isEmpty()) {
launchSpec.insert(QStringLiteral("runnerPath"), runnerPath);
}
if (!prefixPath.isEmpty()) {
launchSpec.insert(QStringLiteral("prefixPath"), prefixPath);
}
launchSpec.insert(QStringLiteral("requestedProgram"), info.value(QStringLiteral("program")).toString());
launchSpec.insert(QStringLiteral("requestedArgs"), info.value(QStringLiteral("args")).toStringList());
if (!envOverrides.isEmpty()) {
launchSpec.insert(QStringLiteral("requestedEnvOverrides"), envOverrides);
}
if (!finalProgram.isEmpty()) {
launchSpec.insert(QStringLiteral("program"), finalProgram);
launchSpec.insert(QStringLiteral("args"), finalArgs);
@ -427,6 +604,8 @@ void GameLauncher::launchGame(Game *game)
return;
}
const QString launchError = reply.isValid() ? QString() : reply.error().message();
// Daemon call failed — fallback for URL-type commands only
if (launchType == QLatin1String("url")) {
const QString url = info.value(QStringLiteral("url")).toString();
@ -439,7 +618,11 @@ void GameLauncher::launchGame(Game *game)
}
// No fallback for non-URL commands — emit error
Q_EMIT gameError(game, tr("Game Center daemon is not available"));
if (!launchError.isEmpty()) {
Q_EMIT gameError(game, tr("Game Center launch failed: %1").arg(launchError));
} else {
Q_EMIT gameError(game, tr("Game Center daemon is not available"));
}
}
}
@ -449,10 +632,10 @@ void GameLauncher::stopGame(Game *game)
return;
}
QDBusInterface iface(QStringLiteral("org.kde.GameCenter1"),
QStringLiteral("/org/kde/GameCenter1"),
QStringLiteral("org.kde.GameCenter1"),
QDBusConnection::sessionBus());
QDBusInterface iface(kGameCenterService,
kGameCenterPath,
kGameCenterInterface,
m_usingSystemBus ? QDBusConnection::systemBus() : QDBusConnection::sessionBus());
iface.call(QStringLiteral("StopByGameId"), game->id());
}
@ -583,10 +766,10 @@ void GameLauncher::onDaemonLaunchFailed(const QVariantMap &error)
void GameLauncher::syncDaemonSessions()
{
QDBusInterface iface(QStringLiteral("org.kde.GameCenter1"),
QStringLiteral("/org/kde/GameCenter1"),
QStringLiteral("org.kde.GameCenter1"),
QDBusConnection::sessionBus());
QDBusInterface iface(kGameCenterService,
kGameCenterPath,
kGameCenterInterface,
m_usingSystemBus ? QDBusConnection::systemBus() : QDBusConnection::sessionBus());
const QDBusReply<QVariantList> reply = iface.call(QStringLiteral("ListSessions"));
if (!reply.isValid()) {
@ -623,13 +806,28 @@ void GameLauncher::applyRunningStateToLibrary()
void GameLauncher::checkDaemonAvailability()
{
QDBusInterface iface(QStringLiteral("org.kde.GameCenter1"),
QStringLiteral("/org/kde/GameCenter1"),
QStringLiteral("org.kde.GameCenter1"),
QDBusConnection::sessionBus());
const bool systemAvailable = pingDaemon(QDBusConnection::systemBus());
const bool sessionAvailable = systemAvailable ? false : pingDaemon(QDBusConnection::sessionBus());
const bool available = systemAvailable || sessionAvailable;
const bool useSystemBus = systemAvailable;
disconnectDaemonSignals(QDBusConnection::systemBus(), this);
disconnectDaemonSignals(QDBusConnection::sessionBus(), this);
if (available) {
connectDaemonSignals(useSystemBus ? QDBusConnection::systemBus() : QDBusConnection::sessionBus(), this);
}
const bool busChanged = m_usingSystemBus != useSystemBus;
if (busChanged) {
m_daemonGameToSession.clear();
m_daemonSessionToGame.clear();
Q_EMIT runningGamesChanged();
applyRunningStateToLibrary();
}
m_usingSystemBus = useSystemBus;
const QDBusReply<QString> reply = iface.call(QStringLiteral("Ping"));
const bool available = reply.isValid() && reply.value() == QLatin1String("ok");
if (available != m_daemonAvailable) {
m_daemonAvailable = available;
Q_EMIT daemonAvailableChanged();

View file

@ -48,6 +48,7 @@ private:
QHash<QString, QString> m_daemonGameToSession;
QHash<QString, QString> m_daemonSessionToGame;
bool m_daemonAvailable = false;
bool m_usingSystemBus = false;
void checkDaemonAvailability();
void syncDaemonSessions();

View file

@ -17,6 +17,26 @@
K_PLUGIN_CLASS_WITH_JSON(AlakarteRunner, "plasma-runner-alakarte.json")
static const QString kGameCenterService = QStringLiteral("org.kde.GameCenter1");
static const QString kGameCenterPath = QStringLiteral("/org/kde/GameCenter1");
static const QString kGameCenterInterface = QStringLiteral("org.kde.GameCenter1");
static bool launchViaDaemon(QDBusConnection bus, const QVariantMap &launchSpec)
{
if (!bus.isConnected()) {
return false;
}
QDBusInterface iface(kGameCenterService, kGameCenterPath, kGameCenterInterface, bus);
if (!iface.isValid()) {
return false;
}
iface.setTimeout(2000);
const QDBusReply<QString> reply = iface.call(QStringLiteral("Launch"), launchSpec);
return reply.isValid() && !reply.value().isEmpty();
}
AlakarteRunner::AlakarteRunner(QObject *parent, const KPluginMetaData &metaData)
: KRunner::AbstractRunner(parent, metaData)
{
@ -154,10 +174,6 @@ void AlakarteRunner::run(const KRunner::RunnerContext &context, const KRunner::Q
// Always try daemon first for all commands (including Steam/Lutris)
if (!match.id().isEmpty()) {
QDBusInterface iface(QStringLiteral("org.kde.GameCenter1"),
QStringLiteral("/org/kde/GameCenter1"),
QStringLiteral("org.kde.GameCenter1"),
QDBusConnection::sessionBus());
QVariantMap launchSpec = {
{QStringLiteral("command"), command},
{QStringLiteral("gameId"), match.id()},
@ -165,8 +181,7 @@ void AlakarteRunner::run(const KRunner::RunnerContext &context, const KRunner::Q
{QStringLiteral("origin"), QStringLiteral("krunner")},
};
const QDBusReply<QString> reply = iface.call(QStringLiteral("Launch"), launchSpec);
if (reply.isValid() && !reply.value().isEmpty()) {
if (launchViaDaemon(QDBusConnection::systemBus(), launchSpec) || launchViaDaemon(QDBusConnection::sessionBus(), launchSpec)) {
return;
}
}

View file

@ -97,6 +97,18 @@ Kirigami.OverlaySheet {
wrapMode: Text.WrapAnywhere
}
QQC2.Label {
Kirigami.FormData.label: i18n("Runner ID:")
text: launchInfo && launchInfo.runnerId ? launchInfo.runnerId : i18n("None")
wrapMode: Text.WrapAnywhere
}
QQC2.Label {
Kirigami.FormData.label: i18n("Runner path:")
text: launchInfo && launchInfo.runnerPath ? launchInfo.runnerPath : i18n("Not specified")
wrapMode: Text.WrapAnywhere
}
QQC2.Label {
Kirigami.FormData.label: i18n("Program:")
text: launchInfo && launchInfo.finalProgram ? launchInfo.finalProgram : ""
@ -120,6 +132,12 @@ Kirigami.OverlaySheet {
text: launchInfo && launchInfo.resolvedPrefixPath ? launchInfo.resolvedPrefixPath : (launchInfo && launchInfo.prefixPath ? launchInfo.prefixPath : "")
wrapMode: Text.WrapAnywhere
}
QQC2.Label {
Kirigami.FormData.label: i18n("Steam install path:")
text: launchInfo && launchInfo.resolvedSteamInstallPath ? launchInfo.resolvedSteamInstallPath : i18n("Not applicable")
wrapMode: Text.WrapAnywhere
}
}
}
}

View file

@ -19,6 +19,10 @@ Kirigami.Dialog {
id: envModel
}
ListModel {
id: runnerChoicesModel
}
signal gameCreated(var game)
signal gameUpdated(var game)
@ -35,14 +39,35 @@ Kirigami.Dialog {
Kirigami.Action {
text: isEditing ? i18n("Apply") : i18n("Add")
icon.name: isEditing ? "dialog-ok-apply" : "list-add"
enabled: nameField.text.trim() !== "" && executableField.text.trim() !== "" && (runnerCombo.currentIndex !== 3 || runnerPathField.text.trim() !== "")
enabled: nameField.text.trim() !== "" && executableField.text.trim() !== "" && (dialog.currentRunnerChoiceKind() !== "custom" || runnerPathField.text.trim() !== "")
onTriggered: {
let runnerValue = dialog.runnerFromIndex(runnerCombo.currentIndex)
let choice = dialog.currentRunnerChoice()
let runnerKind = choice && choice.kind ? String(choice.kind) : "native"
let runnerType = choice && choice.runnerType ? String(choice.runnerType) : (choice && choice.runner ? String(choice.runner) : "")
let runnerValue = choice && choice.runner ? String(choice.runner) : ""
let runnerIdValue = choice && choice.runnerId ? String(choice.runnerId) : ""
let runnerPathValue = runnerPathField.text.trim()
let prefixPathValue = prefixPathField.text.trim()
if (runnerValue === "") {
if (runnerKind === "native") {
runnerValue = ""
runnerIdValue = ""
runnerPathValue = ""
prefixPathValue = ""
} else if (runnerKind === "inventory") {
runnerValue = ""
runnerPathValue = ""
} else if (runnerKind === "wine" || runnerKind === "proton") {
runnerIdValue = ""
} else if (runnerKind === "custom") {
runnerIdValue = ""
prefixPathValue = ""
}
if (runnerType !== "wine" && runnerType !== "proton") {
prefixPathValue = ""
}
if (isEditing) {
game.name = nameField.text.trim()
@ -51,6 +76,7 @@ Kirigami.Dialog {
game.workingDirectory = workingDirField.text.trim()
game.launchEnv = dialog.envModelToMap()
game.launchRunner = runnerValue
game.launchRunnerId = runnerIdValue
game.launchRunnerPath = runnerPathValue
game.launchPrefixPath = prefixPathValue
if (selectedCoverPath !== "") {
@ -65,6 +91,7 @@ Kirigami.Dialog {
newGame.workingDirectory = workingDirField.text.trim()
newGame.launchEnv = dialog.envModelToMap()
newGame.launchRunner = runnerValue
newGame.launchRunnerId = runnerIdValue
newGame.launchRunnerPath = runnerPathValue
newGame.launchPrefixPath = prefixPathValue
if (selectedCoverPath !== "") {
@ -86,7 +113,24 @@ Kirigami.Dialog {
property string selectedCoverPath: ""
readonly property bool anyMenuOpen: runnerCombo && runnerCombo.popup && runnerCombo.popup.visible
readonly property bool anyConfirmOpen: !!(deletePrefixConfirmDialog && deletePrefixConfirmDialog.opened)
readonly property bool anyMenuOpen: !!(runnerCombo && runnerCombo.popup && runnerCombo.popup.visible)
function currentConfirmDialog() {
if (deletePrefixConfirmDialog && deletePrefixConfirmDialog.opened) return deletePrefixConfirmDialog
return null
}
function closeCurrentConfirmDialog() {
let d = currentConfirmDialog()
if (!d) return
if (typeof d.reject === "function") {
d.reject()
} else {
d.close()
}
}
function closeCurrentMenu() {
if (runnerCombo && runnerCombo.popup && runnerCombo.popup.visible) {
@ -181,16 +225,84 @@ Kirigami.Dialog {
return 0
}
function currentRunnerChoice() {
if (!runnerChoicesModel || runnerChoicesModel.count <= 0) return null
let idx = runnerCombo ? runnerCombo.currentIndex : 0
if (idx < 0 || idx >= runnerChoicesModel.count) return runnerChoicesModel.get(0)
return runnerChoicesModel.get(idx)
}
function currentRunnerChoiceKind() {
let c = dialog.currentRunnerChoice()
if (!c || !c.kind) return "native"
return String(c.kind)
}
function currentRunnerChoiceRunnerType() {
let c = dialog.currentRunnerChoice()
if (!c) return ""
if (c.runnerType) return String(c.runnerType)
if (c.runner) return String(c.runner)
return ""
}
function rebuildRunnerChoices() {
runnerChoicesModel.clear()
runnerChoicesModel.append({ kind: "native", label: i18n("Native"), runner: "", runnerId: "", runnerType: "" })
runnerChoicesModel.append({ kind: "wine", label: i18n("Wine (system)"), runner: "wine", runnerId: "", runnerType: "wine" })
runnerChoicesModel.append({ kind: "proton", label: i18n("Proton (auto)"), runner: "proton", runnerId: "", runnerType: "proton" })
let runners = (App.runnerManager && App.runnerManager.runners) ? App.runnerManager.runners : []
for (let i = 0; i < runners.length; i++) {
let r = runners[i]
if (!r || !r.id) continue
let name = r.name ? String(r.name) : String(r.id)
let source = r.source ? String(r.source) : ""
let type = r.type ? String(r.type) : ""
let label = name
if (type !== "" || source !== "") {
label = name + " (" + type + (source !== "" ? (" · " + source) : "") + ")"
}
runnerChoicesModel.append({ kind: "inventory", label: label, runner: "", runnerId: String(r.id), runnerType: type })
}
runnerChoicesModel.append({ kind: "custom", label: i18n("Custom runner path"), runner: "custom", runnerId: "", runnerType: "custom" })
}
function indexForRunnerId(runnerId) {
let id = (runnerId || "").trim()
if (id === "") return 0
for (let i = 0; i < runnerChoicesModel.count; i++) {
let row = runnerChoicesModel.get(i)
if (row.kind === "inventory" && String(row.runnerId || "") === id) return i
}
return 0
}
function indexForRunner(runner) {
let r = (runner || "").trim().toLowerCase()
if (r === "wine") return 1
if (r === "proton") return 2
if (r === "custom") return Math.max(0, runnerChoicesModel.count - 1)
return 0
}
function loadFields() {
selectedCoverPath = ""
envModel.clear()
dialog.rebuildRunnerChoices()
if (isEditing && game) {
nameField.text = game.name || ""
developerField.text = game.developer || ""
executableField.text = game.launchCommand || ""
workingDirField.text = game.workingDirectory || ""
runnerCombo.currentIndex = dialog.runnerToIndex(game.launchRunner)
let idx = dialog.indexForRunnerId(game.launchRunnerId)
if (idx === 0) {
idx = dialog.indexForRunner(game.launchRunner)
}
runnerCombo.currentIndex = idx
runnerPathField.text = game.launchRunnerPath || ""
prefixPathField.text = game.launchPrefixPath || ""
@ -213,6 +325,66 @@ Kirigami.Dialog {
}
}
Connections {
target: App.runnerManager
function onRunnersChanged() {
if (!dialog.opened) return
let desiredRunnerId = ""
if (isEditing && game) {
desiredRunnerId = String(game.launchRunnerId || "").trim()
}
let current = dialog.currentRunnerChoice()
let currentRunnerId = current && current.runnerId ? String(current.runnerId) : ""
let currentRunner = current && current.runner ? String(current.runner) : ""
dialog.rebuildRunnerChoices()
if (desiredRunnerId !== "") {
runnerCombo.currentIndex = dialog.indexForRunnerId(desiredRunnerId)
} else if (currentRunnerId !== "") {
runnerCombo.currentIndex = dialog.indexForRunnerId(currentRunnerId)
} else {
runnerCombo.currentIndex = dialog.indexForRunner(currentRunner)
}
}
function onPrefixEnsured(gameId, prefixPath) {
if (!dialog.opened) return
if (!isEditing || !game) return
if (String(gameId) !== String(game.id)) return
prefixPathField.text = String(prefixPath || "")
}
function onPrefixDeleted(gameId, prefixPath) {
if (!dialog.opened) return
if (!isEditing || !game) return
if (String(gameId) !== String(game.id)) return
prefixPathField.text = ""
}
}
function effectivePrefixPath() {
let p = prefixPathField.text.trim()
if (p !== "") return p
if (!isEditing || !game) return ""
return StandardPaths.writableLocation(StandardPaths.AppDataLocation) + "/prefixes/" + game.id
}
function toFileUrl(path) {
let p = String(path || "").trim()
if (p === "") return ""
if (p.startsWith("~/")) {
p = StandardPaths.writableLocation(StandardPaths.HomeLocation) + "/" + p.substring(2)
}
if (p.startsWith("file:")) return p
if (p.startsWith("/")) {
return "file:///" + encodeURI(p.substring(1))
}
return "file:///" + encodeURI(p)
}
Connections {
target: GamepadManager
function onNavigateUp() {
@ -325,26 +497,11 @@ Kirigami.Dialog {
title: i18n("Compatibility")
}
Item {
Layout.fillWidth: true
Layout.margins: Kirigami.Units.largeSpacing
RowLayout {
anchors.left: parent.left
anchors.right: parent.right
spacing: Kirigami.Units.largeSpacing
QQC2.Label {
text: i18n("Runner")
Layout.alignment: Qt.AlignVCenter
}
QQC2.ComboBox {
id: runnerCombo
Layout.fillWidth: true
model: [i18n("Native"), i18n("Wine"), i18n("Proton"), i18n("Custom")]
}
}
FormCard.FormComboBoxDelegate {
id: runnerCombo
text: i18n("Runner")
model: runnerChoicesModel
textRole: "label"
}
FormCard.FormDelegateSeparator {}
@ -353,8 +510,8 @@ Kirigami.Dialog {
id: runnerPathField
label: i18n("Runner Path")
text: ""
placeholderText: runnerCombo.currentIndex === 2 ? i18n("Auto-detect Proton") : (runnerCombo.currentIndex === 1 ? i18n("Use system Wine") : i18n("Required for Custom"))
enabled: runnerCombo.currentIndex !== 0
placeholderText: dialog.currentRunnerChoiceKind() === "proton" ? i18n("Auto-detect Proton") : (dialog.currentRunnerChoiceKind() === "wine" ? i18n("Use system Wine") : i18n("Required for Custom"))
enabled: dialog.currentRunnerChoiceKind() === "custom" || dialog.currentRunnerChoiceKind() === "wine" || dialog.currentRunnerChoiceKind() === "proton"
}
FormCard.FormDelegateSeparator {}
@ -362,7 +519,7 @@ Kirigami.Dialog {
FormCard.FormButtonDelegate {
text: i18n("Browse Runner...")
icon.name: "document-open"
enabled: runnerCombo.currentIndex !== 0
enabled: dialog.currentRunnerChoiceKind() === "custom" || dialog.currentRunnerChoiceKind() === "wine" || dialog.currentRunnerChoiceKind() === "proton"
onClicked: runnerFileDialog.open()
}
@ -373,7 +530,7 @@ Kirigami.Dialog {
label: i18n("Prefix Path")
text: ""
placeholderText: i18n("Default (AppDataLocation/prefixes/<gameId>)")
enabled: runnerCombo.currentIndex === 1 || runnerCombo.currentIndex === 2
enabled: dialog.currentRunnerChoiceRunnerType() === "wine" || dialog.currentRunnerChoiceRunnerType() === "proton"
}
FormCard.FormDelegateSeparator {}
@ -381,9 +538,52 @@ Kirigami.Dialog {
FormCard.FormButtonDelegate {
text: i18n("Browse Prefix...")
icon.name: "document-open-folder"
enabled: runnerCombo.currentIndex === 1 || runnerCombo.currentIndex === 2
enabled: dialog.currentRunnerChoiceRunnerType() === "wine" || dialog.currentRunnerChoiceRunnerType() === "proton"
onClicked: prefixFolderDialog.open()
}
FormCard.FormDelegateSeparator {}
FormCard.FormButtonDelegate {
text: i18n("Open Prefix Folder")
icon.name: "folder-open"
enabled: isEditing && (dialog.currentRunnerChoiceRunnerType() === "wine" || dialog.currentRunnerChoiceRunnerType() === "proton")
onClicked: {
let p = dialog.effectivePrefixPath()
if (p === "") return
Qt.openUrlExternally(dialog.toFileUrl(p))
}
}
FormCard.FormDelegateSeparator {}
FormCard.FormButtonDelegate {
text: i18n("Delete Prefix")
icon.name: "edit-delete"
enabled: isEditing
&& (dialog.currentRunnerChoiceRunnerType() === "wine" || dialog.currentRunnerChoiceRunnerType() === "proton")
&& (prefixPathField.text.trim() === "")
onClicked: deletePrefixConfirmDialog.open()
}
FormCard.FormDelegateSeparator {}
FormCard.FormButtonDelegate {
text: i18n("Create Prefix")
icon.name: "folder-new"
enabled: isEditing
&& (dialog.currentRunnerChoiceRunnerType() === "wine" || dialog.currentRunnerChoiceRunnerType() === "proton")
onClicked: App.runnerManager.ensurePrefix(game.id, dialog.currentRunnerChoiceRunnerType(), prefixPathField.text.trim())
}
FormCard.FormDelegateSeparator {}
FormCard.FormButtonDelegate {
text: i18n("Reset Prefix to Default")
icon.name: "edit-clear"
enabled: dialog.currentRunnerChoiceRunnerType() === "wine" || dialog.currentRunnerChoiceRunnerType() === "proton"
onClicked: prefixPathField.text = ""
}
}
FormCard.FormCard {
@ -435,42 +635,37 @@ Kirigami.Dialog {
onClicked: envModel.clear()
}
Item {
ColumnLayout {
Layout.fillWidth: true
Layout.margins: Kirigami.Units.largeSpacing
spacing: Kirigami.Units.smallSpacing
ColumnLayout {
anchors.left: parent.left
anchors.right: parent.right
spacing: Kirigami.Units.smallSpacing
Repeater {
model: envModel
Repeater {
model: envModel
RowLayout {
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
RowLayout {
QQC2.TextField {
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
Layout.preferredWidth: 1
placeholderText: i18n("KEY")
text: key
onTextChanged: envModel.setProperty(index, "key", text)
}
QQC2.TextField {
Layout.fillWidth: true
Layout.preferredWidth: 1
placeholderText: i18n("KEY")
text: key
onTextChanged: envModel.setProperty(index, "key", text)
}
QQC2.TextField {
Layout.fillWidth: true
Layout.preferredWidth: 2
placeholderText: i18n("VALUE")
text: value
onTextChanged: envModel.setProperty(index, "value", text)
}
QQC2.TextField {
Layout.fillWidth: true
Layout.preferredWidth: 2
placeholderText: i18n("VALUE")
text: value
onTextChanged: envModel.setProperty(index, "value", text)
}
QQC2.ToolButton {
icon.name: "list-remove"
onClicked: envModel.remove(index)
}
QQC2.ToolButton {
icon.name: "list-remove"
onClicked: envModel.remove(index)
}
}
}
@ -630,9 +825,35 @@ Kirigami.Dialog {
}
onOpened: {
App.runnerManager.refreshRunners()
loadFields()
nameField.forceActiveFocus()
}
onGameChanged: loadFields()
Kirigami.PromptDialog {
id: deletePrefixConfirmDialog
title: i18n("Delete Prefix")
subtitle: (isEditing && game)
? i18n("Delete the prefix for '%1'? This will remove the entire prefix directory.", game.name)
: i18n("Delete this prefix? This will remove the entire prefix directory.")
standardButtons: Kirigami.Dialog.Yes | Kirigami.Dialog.No
onAccepted: {
if (!isEditing || !game) return
App.runnerManager.deletePrefix(game.id, prefixPathField.text.trim())
}
onOpened: {
Qt.callLater(function() {
if (typeof deletePrefixConfirmDialog.standardButton === "function") {
let noButton = deletePrefixConfirmDialog.standardButton(Kirigami.Dialog.No)
if (noButton) {
noButton.forceActiveFocus()
return
}
}
deletePrefixConfirmDialog.forceActiveFocus()
})
}
}
}

View file

@ -102,6 +102,7 @@ Kirigami.ApplicationWindow {
function currentConfirmDialog() {
if (removeGameConfirmDialog.opened) return removeGameConfirmDialog
if (gameEditDialog.visible && gameEditDialog.anyConfirmOpen) return gameEditDialog.currentConfirmDialog()
let layerContent = root.settingsLayerContentItem()
if (settingsLayerOpen && layerContent && layerContent.anyConfirmOpen) return layerContent.currentConfirmDialog()
if (settingsSheet.opened && settingsContent.anyConfirmOpen) return settingsContent.currentConfirmDialog()
@ -177,6 +178,10 @@ Kirigami.ApplicationWindow {
return true
}
if (gameEditDialog.visible) {
if (gameEditDialog.anyConfirmOpen) {
gameEditDialog.closeCurrentConfirmDialog()
return true
}
if (gameEditDialog.anyMenuOpen) {
gameEditDialog.closeCurrentMenu()
return true
@ -2041,6 +2046,17 @@ Kirigami.ApplicationWindow {
}
}
Connections {
target: App.runnerManager
function onLastErrorChanged() {
if (!App.runnerManager) return
let msg = String(App.runnerManager.lastError || "")
if (msg.trim() === "") return
showPassiveNotification(i18n("Runner error: %1", msg), "long")
}
}
Component.onCompleted: {
if (!root.isMobile) {
const savedW = App.config.windowWidth

View file

@ -13,6 +13,10 @@ ColumnLayout {
id: settingsPage
spacing: Kirigami.Units.mediumSpacing
property string runnerInstallUrl: ""
property string runnerInstallSha256: ""
property string runnerInstallName: ""
function focusFirstControl() {
showPlatformBadgesDelegate.forceActiveFocus()
}
@ -546,6 +550,165 @@ FormCard.FormHeader {
}
}
FormCard.FormHeader {
Layout.topMargin: Kirigami.Units.mediumSpacing
Layout.fillWidth: true
title: i18n("Runners")
}
FormCard.FormCard {
Layout.fillWidth: true
FormCard.FormTextFieldDelegate {
label: i18n("Archive URL or local path")
text: settingsPage.runnerInstallUrl
placeholderText: i18n("https://... or /home/.../file.tar.gz")
enabled: !App.runnerManager.busy
onTextChanged: settingsPage.runnerInstallUrl = text
}
FormCard.FormDelegateSeparator {}
FormCard.FormTextFieldDelegate {
label: i18n("SHA256/SHA512 (optional)")
text: settingsPage.runnerInstallSha256
placeholderText: i18n("Leave empty to skip verification")
enabled: !App.runnerManager.busy
onTextChanged: settingsPage.runnerInstallSha256 = text
}
FormCard.FormDelegateSeparator {}
FormCard.FormTextFieldDelegate {
label: i18n("Name (optional)")
text: settingsPage.runnerInstallName
placeholderText: i18n("Proton-GE")
enabled: !App.runnerManager.busy
onTextChanged: settingsPage.runnerInstallName = text
}
FormCard.FormDelegateSeparator {}
FormCard.FormButtonDelegate {
text: i18n("Install from URL")
description: App.runnerManager.busy ? App.runnerManager.status : i18n("Download and install runner")
icon.name: "download"
enabled: settingsPage.runnerInstallUrl.trim() !== "" && !App.runnerManager.busy
onClicked: App.runnerManager.installRunnerFromUrl(settingsPage.runnerInstallUrl, settingsPage.runnerInstallSha256, settingsPage.runnerInstallName)
}
FormCard.FormDelegateSeparator {}
FormCard.FormButtonDelegate {
text: i18n("Cancel install")
icon.name: "dialog-cancel"
enabled: App.runnerManager.busy
onClicked: App.runnerManager.cancelCurrentInstall()
}
FormCard.FormDelegateSeparator {}
Item {
Layout.fillWidth: true
Layout.margins: Kirigami.Units.largeSpacing
ColumnLayout {
anchors.left: parent.left
anchors.right: parent.right
spacing: Kirigami.Units.smallSpacing
QQC2.Label {
Layout.fillWidth: true
text: App.runnerManager.status
wrapMode: Text.WordWrap
visible: App.runnerManager.status !== ""
}
QQC2.Label {
Layout.fillWidth: true
text: App.runnerManager.lastError
wrapMode: Text.WordWrap
visible: App.runnerManager.lastError !== ""
color: Kirigami.Theme.negativeTextColor
}
QQC2.ProgressBar {
Layout.fillWidth: true
from: 0
to: App.runnerManager.totalBytes > 0 ? App.runnerManager.totalBytes : 1
value: App.runnerManager.receivedBytes
indeterminate: App.runnerManager.busy && App.runnerManager.totalBytes <= 0
visible: App.runnerManager.busy
}
}
}
}
FormCard.FormHeader {
Layout.topMargin: Kirigami.Units.mediumSpacing
Layout.fillWidth: true
title: i18n("Installed Runners")
}
FormCard.FormCard {
Layout.fillWidth: true
FormCard.FormButtonDelegate {
text: i18n("Refresh")
icon.name: "view-refresh"
enabled: !App.runnerManager.busy
onClicked: App.runnerManager.refreshRunners()
}
FormCard.FormDelegateSeparator {}
Repeater {
model: App.runnerManager.runners
ColumnLayout {
Layout.fillWidth: true
readonly property var runner: modelData
readonly property string runnerId: (runner && runner.id) ? runner.id : ""
readonly property string runnerName: (runner && runner.name) ? runner.name : runnerId
readonly property string runnerType: (runner && runner.type) ? runner.type : ""
readonly property string runnerSource: (runner && runner.source) ? runner.source : ""
readonly property string runnerPath: (runner && runner.path) ? runner.path : ""
FormCard.FormTextDelegate {
text: runnerName
description: (runnerType !== "" ? (runnerType + " · ") : "") + (runnerSource !== "" ? runnerSource : "")
textItem.wrapMode: Text.WordWrap
}
FormCard.FormDelegateSeparator {}
FormCard.FormTextDelegate {
text: runnerPath
textItem.wrapMode: Text.WrapAnywhere
textItem.font: Kirigami.Theme.smallFont
}
FormCard.FormDelegateSeparator {}
FormCard.FormButtonDelegate {
text: i18n("Uninstall")
icon.name: "edit-delete"
enabled: runnerSource === "installed" && runnerId !== "" && !App.runnerManager.busy
onClicked: App.runnerManager.uninstallRunner(runnerId)
}
FormCard.FormDelegateSeparator {}
}
}
FormCard.FormTextDelegate {
visible: App.runnerManager.runners.length === 0
text: i18n("No runners found")
textItem.font: Kirigami.Theme.smallFont
}
}
FormCard.FormHeader {
Layout.topMargin: Kirigami.Units.mediumSpacing
Layout.fillWidth: true

41
src/runner/CMakeLists.txt Normal file
View file

@ -0,0 +1,41 @@
# SPDX-License-Identifier: BSD-3-Clause
# SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
add_executable(alakarte_runnerd
main.cpp
runnermanagerdaemon.cpp
runnermanagerdaemon.h
)
target_link_libraries(alakarte_runnerd PRIVATE
Qt6::Core
Qt6::DBus
Qt6::Network
)
set_target_properties(alakarte_runnerd PROPERTIES
OUTPUT_NAME "alakarte-runnerd"
)
install(TARGETS alakarte_runnerd ${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.ALaKarte.Runner1.service.in
"${CMAKE_CURRENT_BINARY_DIR}/dbus/org.kde.ALaKarte.Runner1.service"
@ONLY
)
configure_file(systemd/org.kde.ALaKarte.Runner1.service.in
"${CMAKE_CURRENT_BINARY_DIR}/systemd/org.kde.ALaKarte.Runner1.service"
@ONLY
)
install(FILES "${CMAKE_CURRENT_BINARY_DIR}/dbus/org.kde.ALaKarte.Runner1.service"
DESTINATION ${KDE_INSTALL_DBUSSERVICEDIR}
)
install(FILES "${CMAKE_CURRENT_BINARY_DIR}/systemd/org.kde.ALaKarte.Runner1.service"
DESTINATION ${KDE_INSTALL_SYSTEMDUSERUNITDIR}
)

View file

@ -0,0 +1,4 @@
[D-BUS Service]
Name=org.kde.ALaKarte.Runner1
Exec=@CMAKE_INSTALL_PREFIX@/@KDE_INSTALL_BINDIR@/alakarte-runnerd
SystemdService=org.kde.ALaKarte.Runner1.service

20
src/runner/main.cpp Normal file
View file

@ -0,0 +1,20 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
#include <QCoreApplication>
#include <QDBusConnection>
#include "runnermanagerdaemon.h"
int main(int argc, char *argv[])
{
QCoreApplication app(argc, argv);
QCoreApplication::setApplicationName(QStringLiteral("alakarte"));
RunnerManagerDaemon daemon;
if (!daemon.init()) {
return 1;
}
return app.exec();
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,84 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
#pragma once
#include <QHash>
#include <QObject>
#include <QPointer>
#include <QSet>
#include <QVariantMap>
class QNetworkReply;
class QProcess;
class RunnerManagerDaemon : public QObject
{
Q_OBJECT
Q_CLASSINFO("D-Bus Interface", "org.kde.ALaKarte.Runner1")
public:
explicit RunnerManagerDaemon(QObject *parent = nullptr);
bool init();
public Q_SLOTS:
QString Ping() const;
void Version(uint &major, uint &minor) const;
QVariantList ListRunners();
bool Rescan();
bool RegisterRunner(const QVariantMap &runner);
bool UnregisterRunner(const QString &runnerId);
QVariantMap InstallRunnerFromArchive(const QVariantMap &spec);
QVariantMap InstallRunnerFromUrl(const QVariantMap &spec);
QVariantMap UninstallRunner(const QString &runnerId);
bool CancelInstall(const QString &installId);
QVariantMap EnsurePrefix(const QVariantMap &spec);
QVariantMap DeletePrefix(const QVariantMap &spec);
QVariantMap ResolveLaunch(const QVariantMap &spec) const;
Q_SIGNALS:
void InstallStarted(const QString &installId, const QVariantMap &spec);
void InstallProgress(const QString &installId, qint64 receivedBytes, qint64 totalBytes);
void InstallFinished(const QString &installId, const QVariantMap &result);
private:
struct RunnerInfo {
QString id;
QString type;
QString name;
QString path;
QString source;
QString installDir;
QVariantMap toVariantMap() const;
static RunnerInfo fromVariantMap(const QVariantMap &map);
bool isValid() const;
};
QString registryPath() const;
void loadRegistry();
bool saveRegistry() const;
QList<RunnerInfo> discoverRunners() const;
QString defaultProtonExecutable() const;
static QStringList steamCandidateRoots();
static QString findSteamClientInstallPathFromProton(const QString &protonExe);
RunnerInfo runnerById(const QString &runnerId) const;
QList<RunnerInfo> m_registered;
QList<RunnerInfo> m_discovered;
QSet<QString> m_activeInstalls;
QSet<QString> m_cancelRequested;
QHash<QString, QPointer<QNetworkReply>> m_installReplies;
QHash<QString, QPointer<QProcess>> m_installProcesses;
};

View file

@ -0,0 +1,8 @@
[Unit]
Description=A-La-Karte Runner Manager
[Service]
ExecStart=@CMAKE_INSTALL_PREFIX@/@KDE_INSTALL_BINDIR@/alakarte-runnerd
Type=dbus
BusName=org.kde.ALaKarte.Runner1
Restart=no

604
src/runnermanagerclient.cpp Normal file
View file

@ -0,0 +1,604 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
#include "runnermanagerclient.h"
#include <QCoreApplication>
#include <QCryptographicHash>
#include <QDBusArgument>
#include <QDBusConnection>
#include <QDBusConnectionInterface>
#include <QDBusInterface>
#include <QDBusMetaType>
#include <QDBusPendingCall>
#include <QDBusPendingCallWatcher>
#include <QDBusReply>
#include <QDir>
#include <QFileInfo>
#include <QProcess>
#include <QSharedPointer>
#include <QTimer>
#include <QUrl>
#include <QUuid>
#include <functional>
static const QString kRunnerService = QStringLiteral("org.kde.ALaKarte.Runner1");
static const QString kRunnerPath = QStringLiteral("/org/kde/ALaKarte/Runner1");
static const QString kRunnerInterface = QStringLiteral("org.kde.ALaKarte.Runner1");
static QString stableIdForUrl(const QString &type, const QString &url)
{
const QByteArray digest = QCryptographicHash::hash(url.toUtf8(), QCryptographicHash::Sha1).toHex();
return type + QStringLiteral(":url:") + QString::fromLatin1(digest);
}
static QString stableIdKeyForInputUrl(QString urlString)
{
urlString = urlString.trimmed();
if (urlString.startsWith(QLatin1String("~/"))) {
urlString = QDir::homePath() + QLatin1Char('/') + urlString.mid(2);
}
QUrl url(urlString);
if (url.isValid() && url.scheme().toLower() == QLatin1String("file")) {
QFileInfo fi(url.toLocalFile());
const QString canonical = fi.canonicalFilePath();
const QString abs = fi.absoluteFilePath();
const QString path = canonical.isEmpty() ? abs : canonical;
return QUrl::fromLocalFile(path).toString(QUrl::FullyEncoded);
}
if (url.scheme().isEmpty()) {
QFileInfo fi(urlString);
if (fi.isAbsolute() || urlString.startsWith(QLatin1String("./")) || urlString.startsWith(QLatin1String("../"))) {
const QString canonical = fi.exists() ? fi.canonicalFilePath() : QString();
const QString abs = fi.absoluteFilePath();
const QString path = canonical.isEmpty() ? abs : canonical;
return QUrl::fromLocalFile(path).toString(QUrl::FullyEncoded);
}
}
if (url.isValid() && !url.scheme().isEmpty()) {
return url.toString(QUrl::FullyEncoded);
}
return urlString;
}
RunnerManagerClient::RunnerManagerClient(QObject *parent)
: QObject(parent)
{
QDBusConnection bus = QDBusConnection::sessionBus();
if (bus.isConnected()) {
bus.connect(kRunnerService, kRunnerPath, kRunnerInterface, QStringLiteral("InstallStarted"), this, SLOT(onInstallStarted(QString, QVariantMap)));
bus.connect(kRunnerService,
kRunnerPath,
kRunnerInterface,
QStringLiteral("InstallProgress"),
this,
SLOT(onInstallProgress(QString, qlonglong, qlonglong)));
bus.connect(kRunnerService, kRunnerPath, kRunnerInterface, QStringLiteral("InstallFinished"), this, SLOT(onInstallFinished(QString, QVariantMap)));
}
if (QCoreApplication::instance()) {
connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit, this, [this]() {
shutdownSpawnedRunnerDaemon();
});
}
refreshRunners();
}
RunnerManagerClient::~RunnerManagerClient()
{
shutdownSpawnedRunnerDaemon();
}
void RunnerManagerClient::shutdownSpawnedRunnerDaemon()
{
if (!m_runnerdProcess) {
return;
}
if (m_runnerdProcess->state() == QProcess::NotRunning) {
return;
}
m_runnerdProcess->terminate();
if (!m_runnerdProcess->waitForFinished(1000)) {
m_runnerdProcess->kill();
m_runnerdProcess->waitForFinished(1000);
}
}
void RunnerManagerClient::ensureRunnerDaemon()
{
QDBusConnection bus = QDBusConnection::sessionBus();
if (!bus.isConnected() || !bus.interface()) {
return;
}
if (bus.interface()->isServiceRegistered(kRunnerService)) {
return;
}
bool activatable = false;
{
const QDBusReply<QStringList> names = bus.interface()->activatableServiceNames();
if (names.isValid()) {
activatable = names.value().contains(kRunnerService);
}
}
const QDBusReply<void> reply = bus.interface()->startService(kRunnerService);
if (reply.isValid()) {
return;
}
if (bus.interface()->isServiceRegistered(kRunnerService)) {
return;
}
if (activatable) {
setLastError(reply.error().message());
return;
}
#if defined(QT_NO_DEBUG)
setLastError(QStringLiteral("Runner service is not available (missing DBus activation for org.kde.ALaKarte.Runner1)"));
return;
#endif
if (m_runnerdSpawnAttempted) {
return;
}
m_runnerdSpawnAttempted = true;
QString program = QCoreApplication::applicationDirPath() + QLatin1String("/alakarte-runnerd");
if (!QFileInfo::exists(program)) {
program = QStringLiteral("alakarte-runnerd");
}
if (!m_runnerdProcess) {
m_runnerdProcess = new QProcess(this);
m_runnerdProcess->setProcessChannelMode(QProcess::MergedChannels);
connect(m_runnerdProcess, &QProcess::errorOccurred, this, [this](QProcess::ProcessError) {
setLastError(QStringLiteral("Failed to start runner service"));
});
connect(m_runnerdProcess, &QProcess::finished, this, [this](int, QProcess::ExitStatus) {
m_runnerdProcess->deleteLater();
m_runnerdProcess = nullptr;
});
}
m_runnerdProcess->setProgram(program);
m_runnerdProcess->setArguments({});
m_runnerdProcess->start();
}
bool RunnerManagerClient::busy() const
{
return m_busy;
}
QString RunnerManagerClient::installId() const
{
return m_installId;
}
qint64 RunnerManagerClient::receivedBytes() const
{
return m_receivedBytes;
}
qint64 RunnerManagerClient::totalBytes() const
{
return m_totalBytes;
}
QString RunnerManagerClient::status() const
{
return m_status;
}
QString RunnerManagerClient::lastError() const
{
return m_lastError;
}
QVariantList RunnerManagerClient::runners() const
{
return m_runners;
}
static QString normalizeHex(QString s)
{
s = s.trimmed().toLower();
const int colon = s.indexOf(QLatin1Char(':'));
if (colon >= 0) {
const QString prefix = s.left(colon);
if (prefix == QLatin1String("sha256") || prefix == QLatin1String("sha512")) {
s = s.mid(colon + 1);
}
}
QString out;
out.reserve(s.size());
for (const QChar &c : s) {
if ((c >= QLatin1Char('0') && c <= QLatin1Char('9')) || (c >= QLatin1Char('a') && c <= QLatin1Char('f'))) {
out.push_back(c);
}
}
return out;
}
void RunnerManagerClient::installRunnerFromUrl(const QString &url, const QString &sha256, const QString &name, const QString &type, bool overwrite)
{
const QString trimmedUrl = url.trimmed();
if (trimmedUrl.isEmpty() || m_busy) {
return;
}
const QString effectiveType = type.trimmed().isEmpty() ? QStringLiteral("proton") : type.trimmed();
setLastError(QString());
setProgress(0, 0);
setStatus(QStringLiteral("Starting..."));
const QString newInstallId = QUuid::createUuid().toString(QUuid::WithoutBraces);
setInstallId(newInstallId);
setBusy(true);
QVariantMap spec;
spec.insert(QStringLiteral("installId"), newInstallId);
spec.insert(QStringLiteral("url"), trimmedUrl);
spec.insert(QStringLiteral("type"), effectiveType);
spec.insert(QStringLiteral("name"), name.trimmed());
spec.insert(QStringLiteral("overwrite"), overwrite);
const QString expected = normalizeHex(sha256);
if (!expected.isEmpty()) {
if (expected.size() == 128) {
spec.insert(QStringLiteral("sha512"), expected);
} else {
spec.insert(QStringLiteral("sha256"), expected);
}
}
const QString stableId = stableIdForUrl(effectiveType, stableIdKeyForInputUrl(trimmedUrl));
spec.insert(QStringLiteral("id"), stableId);
ensureRunnerDaemon();
auto attempts = QSharedPointer<int>::create(0);
auto callPtr = QSharedPointer<std::function<void()>>::create();
*callPtr = [this, spec, attempts, callPtr]() {
QDBusInterface iface(kRunnerService, kRunnerPath, kRunnerInterface, QDBusConnection::sessionBus());
QDBusPendingCall call = iface.asyncCall(QStringLiteral("InstallRunnerFromUrl"), spec);
auto *watcher = new QDBusPendingCallWatcher(call, this);
connect(watcher, &QDBusPendingCallWatcher::finished, this, [this, spec, attempts, callPtr](QDBusPendingCallWatcher *w) {
QDBusPendingReply<QVariantMap> reply = *w;
w->deleteLater();
if (reply.isError()) {
const QDBusError::ErrorType t = reply.error().type();
if ((*attempts) < 1 && (t == QDBusError::ServiceUnknown || t == QDBusError::NoReply)) {
(*attempts)++;
ensureRunnerDaemon();
QTimer::singleShot(500, this, [callPtr]() {
(*callPtr)();
});
return;
}
setStatus(QStringLiteral("Failed to call InstallRunnerFromUrl"));
setLastError(reply.error().message());
setBusy(false);
return;
}
QVariantMap result = reply.value();
const bool ok = result.value(QStringLiteral("ok")).toBool();
if (!ok) {
const QString err = result.value(QStringLiteral("error")).toString();
setLastError(err);
setStatus(err.isEmpty() ? QStringLiteral("Failed") : err);
setBusy(false);
return;
}
setStatus(QStringLiteral("Started"));
});
};
(*callPtr)();
}
void RunnerManagerClient::deletePrefix(const QString &gameId, const QString &prefixPath)
{
const QString id = gameId.trimmed();
const QString p = prefixPath.trimmed();
if (id.isEmpty()) {
return;
}
ensureRunnerDaemon();
QVariantMap spec;
spec.insert(QStringLiteral("gameId"), id);
if (!p.isEmpty()) {
spec.insert(QStringLiteral("prefixPath"), p);
}
auto attempts = QSharedPointer<int>::create(0);
auto callPtr = QSharedPointer<std::function<void()>>::create();
*callPtr = [this, spec, id, attempts, callPtr]() {
QDBusInterface iface(kRunnerService, kRunnerPath, kRunnerInterface, QDBusConnection::sessionBus());
QDBusPendingCall call = iface.asyncCall(QStringLiteral("DeletePrefix"), spec);
auto *watcher = new QDBusPendingCallWatcher(call, this);
connect(watcher, &QDBusPendingCallWatcher::finished, this, [this, id, attempts, callPtr](QDBusPendingCallWatcher *w) {
QDBusPendingReply<QVariantMap> reply = *w;
w->deleteLater();
if (reply.isError()) {
const QDBusError::ErrorType t = reply.error().type();
if ((*attempts) < 1 && (t == QDBusError::ServiceUnknown || t == QDBusError::NoReply)) {
(*attempts)++;
ensureRunnerDaemon();
QTimer::singleShot(500, this, [callPtr]() {
(*callPtr)();
});
return;
}
setLastError(reply.error().message());
return;
}
const QVariantMap result = reply.value();
if (!result.value(QStringLiteral("ok")).toBool()) {
setLastError(result.value(QStringLiteral("error")).toString());
return;
}
const QString removed = result.value(QStringLiteral("prefixPath")).toString();
Q_EMIT prefixDeleted(id, removed);
});
};
(*callPtr)();
}
void RunnerManagerClient::cancelCurrentInstall()
{
if (!m_busy || m_installId.isEmpty()) {
return;
}
setStatus(QStringLiteral("Cancelling..."));
QDBusInterface iface(kRunnerService, kRunnerPath, kRunnerInterface, QDBusConnection::sessionBus());
iface.asyncCall(QStringLiteral("CancelInstall"), m_installId);
}
void RunnerManagerClient::onInstallStarted(const QString &installId, const QVariantMap &)
{
if (installId.isEmpty() || installId != m_installId) {
return;
}
setStatus(QStringLiteral("Downloading..."));
}
void RunnerManagerClient::onInstallProgress(const QString &installId, qlonglong received, qlonglong total)
{
if (installId.isEmpty() || installId != m_installId) {
return;
}
setProgress(static_cast<qint64>(received), static_cast<qint64>(total));
}
void RunnerManagerClient::onInstallFinished(const QString &installId, const QVariantMap &result)
{
if (installId.isEmpty() || installId != m_installId) {
return;
}
const bool ok = result.value(QStringLiteral("ok")).toBool();
if (!ok) {
const QString err = result.value(QStringLiteral("error")).toString();
setLastError(err);
setStatus(err.isEmpty() ? QStringLiteral("Failed") : err);
} else {
setStatus(QStringLiteral("Installed"));
}
setBusy(false);
refreshRunners();
}
void RunnerManagerClient::refreshRunners()
{
ensureRunnerDaemon();
QDBusInterface iface(kRunnerService, kRunnerPath, kRunnerInterface, QDBusConnection::sessionBus());
QDBusPendingCall call = iface.asyncCall(QStringLiteral("ListRunners"));
auto *watcher = new QDBusPendingCallWatcher(call, this);
connect(watcher, &QDBusPendingCallWatcher::finished, this, [this](QDBusPendingCallWatcher *w) {
QDBusPendingReply<QVariantList> reply = *w;
w->deleteLater();
if (reply.isError()) {
if (!m_refreshRetryPending && (reply.error().type() == QDBusError::ServiceUnknown || reply.error().type() == QDBusError::NoReply)) {
m_refreshRetryPending = true;
ensureRunnerDaemon();
QTimer::singleShot(500, this, [this]() {
m_refreshRetryPending = false;
refreshRunners();
});
}
return;
}
const QVariantList raw = reply.value();
QVariantList list;
list.reserve(raw.size());
for (const QVariant &v : raw) {
if (v.metaType() == QMetaType::fromType<QVariantMap>()) {
list.push_back(v.toMap());
continue;
}
if (v.metaType() == QMetaType::fromType<QDBusArgument>()) {
const QDBusArgument arg = v.value<QDBusArgument>();
const QVariantMap map = qdbus_cast<QVariantMap>(arg);
list.push_back(map);
continue;
}
if (v.canConvert<QVariantMap>()) {
list.push_back(v.toMap());
continue;
}
list.push_back(QVariantMap{});
}
if (m_runners == list) {
return;
}
m_runners = list;
Q_EMIT runnersChanged();
});
}
void RunnerManagerClient::uninstallRunner(const QString &runnerId)
{
const QString id = runnerId.trimmed();
if (id.isEmpty()) {
return;
}
QDBusInterface iface(kRunnerService, kRunnerPath, kRunnerInterface, QDBusConnection::sessionBus());
QDBusPendingCall call = iface.asyncCall(QStringLiteral("UninstallRunner"), id);
auto *watcher = new QDBusPendingCallWatcher(call, this);
connect(watcher, &QDBusPendingCallWatcher::finished, this, [this](QDBusPendingCallWatcher *w) {
QDBusPendingReply<QVariantMap> reply = *w;
w->deleteLater();
if (reply.isError()) {
setLastError(reply.error().message());
return;
}
const QVariantMap result = reply.value();
if (!result.value(QStringLiteral("ok")).toBool()) {
setLastError(result.value(QStringLiteral("error")).toString());
}
refreshRunners();
});
}
void RunnerManagerClient::ensurePrefix(const QString &gameId, const QString &runner, const QString &prefixPath)
{
const QString id = gameId.trimmed();
const QString r = runner.trimmed();
const QString p = prefixPath.trimmed();
if (id.isEmpty()) {
return;
}
ensureRunnerDaemon();
QVariantMap spec;
spec.insert(QStringLiteral("gameId"), id);
if (!r.isEmpty()) {
spec.insert(QStringLiteral("runner"), r);
}
if (!p.isEmpty()) {
spec.insert(QStringLiteral("prefixPath"), p);
}
auto attempts = QSharedPointer<int>::create(0);
auto callPtr = QSharedPointer<std::function<void()>>::create();
*callPtr = [this, spec, id, attempts, callPtr]() {
QDBusInterface iface(kRunnerService, kRunnerPath, kRunnerInterface, QDBusConnection::sessionBus());
QDBusPendingCall call = iface.asyncCall(QStringLiteral("EnsurePrefix"), spec);
auto *watcher = new QDBusPendingCallWatcher(call, this);
connect(watcher, &QDBusPendingCallWatcher::finished, this, [this, id, attempts, callPtr](QDBusPendingCallWatcher *w) {
QDBusPendingReply<QVariantMap> reply = *w;
w->deleteLater();
if (reply.isError()) {
const QDBusError::ErrorType t = reply.error().type();
if ((*attempts) < 1 && (t == QDBusError::ServiceUnknown || t == QDBusError::NoReply)) {
(*attempts)++;
ensureRunnerDaemon();
QTimer::singleShot(500, this, [callPtr]() {
(*callPtr)();
});
return;
}
setLastError(reply.error().message());
return;
}
const QVariantMap result = reply.value();
if (!result.value(QStringLiteral("ok")).toBool()) {
setLastError(result.value(QStringLiteral("error")).toString());
return;
}
const QString ensured = result.value(QStringLiteral("prefixPath")).toString();
if (!ensured.isEmpty()) {
Q_EMIT prefixEnsured(id, ensured);
}
});
};
(*callPtr)();
}
void RunnerManagerClient::setBusy(bool busy)
{
if (m_busy == busy) {
return;
}
m_busy = busy;
Q_EMIT busyChanged();
}
void RunnerManagerClient::setInstallId(const QString &installId)
{
if (m_installId == installId) {
return;
}
m_installId = installId;
Q_EMIT installIdChanged();
}
void RunnerManagerClient::setProgress(qint64 received, qint64 total)
{
if (m_receivedBytes == received && m_totalBytes == total) {
return;
}
m_receivedBytes = received;
m_totalBytes = total;
Q_EMIT progressChanged();
}
void RunnerManagerClient::setStatus(const QString &status)
{
if (m_status == status) {
return;
}
m_status = status;
Q_EMIT statusChanged();
}
void RunnerManagerClient::setLastError(const QString &error)
{
if (m_lastError == error) {
return;
}
m_lastError = error;
Q_EMIT lastErrorChanged();
}

85
src/runnermanagerclient.h Normal file
View file

@ -0,0 +1,85 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
#pragma once
#include <QObject>
#include <QVariantList>
#include <QVariantMap>
class RunnerManagerClient : public QObject
{
Q_OBJECT
Q_PROPERTY(bool busy READ busy NOTIFY busyChanged)
Q_PROPERTY(QString installId READ installId NOTIFY installIdChanged)
Q_PROPERTY(qint64 receivedBytes READ receivedBytes NOTIFY progressChanged)
Q_PROPERTY(qint64 totalBytes READ totalBytes NOTIFY progressChanged)
Q_PROPERTY(QString status READ status NOTIFY statusChanged)
Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)
Q_PROPERTY(QVariantList runners READ runners NOTIFY runnersChanged)
public:
explicit RunnerManagerClient(QObject *parent = nullptr);
~RunnerManagerClient() override;
bool busy() const;
QString installId() const;
qint64 receivedBytes() const;
qint64 totalBytes() const;
QString status() const;
QString lastError() const;
QVariantList runners() const;
Q_INVOKABLE void installRunnerFromUrl(const QString &url,
const QString &sha256 = QString(),
const QString &name = QString(),
const QString &type = QStringLiteral("proton"),
bool overwrite = true);
Q_INVOKABLE void cancelCurrentInstall();
Q_INVOKABLE void refreshRunners();
Q_INVOKABLE void uninstallRunner(const QString &runnerId);
Q_INVOKABLE void ensurePrefix(const QString &gameId, const QString &runner, const QString &prefixPath = QString());
Q_INVOKABLE void deletePrefix(const QString &gameId, const QString &prefixPath = QString());
Q_SIGNALS:
void busyChanged();
void installIdChanged();
void progressChanged();
void statusChanged();
void lastErrorChanged();
void runnersChanged();
void prefixEnsured(const QString &gameId, const QString &prefixPath);
void prefixDeleted(const QString &gameId, const QString &prefixPath);
private Q_SLOTS:
void onInstallStarted(const QString &installId, const QVariantMap &spec);
void onInstallProgress(const QString &installId, qlonglong received, qlonglong total);
void onInstallFinished(const QString &installId, const QVariantMap &result);
private:
void ensureRunnerDaemon();
void shutdownSpawnedRunnerDaemon();
void setBusy(bool busy);
void setInstallId(const QString &installId);
void setProgress(qint64 received, qint64 total);
void setStatus(const QString &status);
void setLastError(const QString &error);
bool m_busy = false;
QString m_installId;
qint64 m_receivedBytes = 0;
qint64 m_totalBytes = 0;
QString m_status;
QString m_lastError;
QVariantList m_runners;
bool m_refreshRetryPending = false;
class QProcess *m_runnerdProcess = nullptr;
bool m_runnerdSpawnAttempted = false;
};