From 1f1be64c8f943c4fe1788013b475286fb8fc69de Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Mon, 9 Feb 2026 14:01:15 +0100 Subject: [PATCH] Runner: add Runner1 daemon + client --- src/CMakeLists.txt | 3 + src/app.cpp | 6 + src/app.h | 4 + src/runner/CMakeLists.txt | 41 + .../dbus/org.kde.ALaKarte.Runner1.service.in | 4 + src/runner/main.cpp | 20 + src/runner/runnermanagerdaemon.cpp | 1091 +++++++++++++++++ src/runner/runnermanagerdaemon.h | 84 ++ .../org.kde.ALaKarte.Runner1.service.in | 8 + src/runnermanagerclient.cpp | 604 +++++++++ src/runnermanagerclient.h | 85 ++ 11 files changed, 1950 insertions(+) create mode 100644 src/runner/CMakeLists.txt create mode 100644 src/runner/dbus/org.kde.ALaKarte.Runner1.service.in create mode 100644 src/runner/main.cpp create mode 100644 src/runner/runnermanagerdaemon.cpp create mode 100644 src/runner/runnermanagerdaemon.h create mode 100644 src/runner/systemd/org.kde.ALaKarte.Runner1.service.in create mode 100644 src/runnermanagerclient.cpp create mode 100644 src/runnermanagerclient.h diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 6ece23c..886eb54 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -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) diff --git a/src/app.cpp b/src/app.cpp index 72bc0c3..50f99ee 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -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; diff --git a/src/app.h b/src/app.h index e48342c..a398976 100644 --- a/src/app.h +++ b/src/app.h @@ -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; diff --git a/src/runner/CMakeLists.txt b/src/runner/CMakeLists.txt new file mode 100644 index 0000000..62aee6e --- /dev/null +++ b/src/runner/CMakeLists.txt @@ -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} +) diff --git a/src/runner/dbus/org.kde.ALaKarte.Runner1.service.in b/src/runner/dbus/org.kde.ALaKarte.Runner1.service.in new file mode 100644 index 0000000..da378b2 --- /dev/null +++ b/src/runner/dbus/org.kde.ALaKarte.Runner1.service.in @@ -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 diff --git a/src/runner/main.cpp b/src/runner/main.cpp new file mode 100644 index 0000000..d059828 --- /dev/null +++ b/src/runner/main.cpp @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors + +#include +#include + +#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(); +} diff --git a/src/runner/runnermanagerdaemon.cpp b/src/runner/runnermanagerdaemon.cpp new file mode 100644 index 0000000..8582288 --- /dev/null +++ b/src/runner/runnermanagerdaemon.cpp @@ -0,0 +1,1091 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors + +#include "runnermanagerdaemon.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +static const QString kRunnerService = QStringLiteral("org.kde.ALaKarte.Runner1"); +static const QString kRunnerPath = QStringLiteral("/org/kde/ALaKarte/Runner1"); + +static QString stableIdForPath(const QString &prefix, const QString &path) +{ + const QByteArray digest = QCryptographicHash::hash(path.toUtf8(), QCryptographicHash::Sha1).toHex(); + return prefix + QLatin1Char(':') + QString::fromLatin1(digest); +} + +RunnerManagerDaemon::RunnerManagerDaemon(QObject *parent) + : QObject(parent) +{ +} + +bool RunnerManagerDaemon::init() +{ + loadRegistry(); + m_discovered = discoverRunners(); + + QDBusConnection bus = QDBusConnection::sessionBus(); + if (!bus.isConnected()) { + qWarning() << "RunnerManagerDaemon: session bus not connected"; + return false; + } + + if (!bus.registerService(kRunnerService)) { + qWarning() << "RunnerManagerDaemon: failed to register service" << kRunnerService << bus.lastError().message(); + return false; + } + + if (!bus.registerObject(kRunnerPath, this, QDBusConnection::ExportAllSlots | QDBusConnection::ExportAllSignals)) { + qWarning() << "RunnerManagerDaemon: failed to register object" << kRunnerPath << bus.lastError().message(); + return false; + } + + return true; +} + +QString RunnerManagerDaemon::Ping() const +{ + return QStringLiteral("ok"); +} + +void RunnerManagerDaemon::Version(uint &major, uint &minor) const +{ + major = 1; + minor = 0; +} + +QVariantMap RunnerManagerDaemon::RunnerInfo::toVariantMap() const +{ + return { + {QStringLiteral("id"), id}, + {QStringLiteral("type"), type}, + {QStringLiteral("name"), name}, + {QStringLiteral("path"), path}, + {QStringLiteral("source"), source}, + {QStringLiteral("installDir"), installDir}, + }; +} + +RunnerManagerDaemon::RunnerInfo RunnerManagerDaemon::RunnerInfo::fromVariantMap(const QVariantMap &map) +{ + RunnerInfo out; + out.id = map.value(QStringLiteral("id")).toString(); + out.type = map.value(QStringLiteral("type")).toString(); + out.name = map.value(QStringLiteral("name")).toString(); + out.path = map.value(QStringLiteral("path")).toString(); + out.source = map.value(QStringLiteral("source")).toString(); + out.installDir = map.value(QStringLiteral("installDir")).toString(); + return out; +} + +bool RunnerManagerDaemon::RunnerInfo::isValid() const +{ + return !id.isEmpty() && !type.isEmpty() && !path.isEmpty(); +} + +QString RunnerManagerDaemon::registryPath() const +{ + const QString base = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); + return base + QStringLiteral("/runner-registry.json"); +} + +void RunnerManagerDaemon::loadRegistry() +{ + m_registered.clear(); + + QFile f(registryPath()); + if (!f.open(QIODevice::ReadOnly)) { + return; + } + + const QJsonDocument doc = QJsonDocument::fromJson(f.readAll()); + if (!doc.isObject()) { + return; + } + + const QJsonArray arr = doc.object().value(QStringLiteral("runners")).toArray(); + for (const QJsonValue &v : arr) { + if (!v.isObject()) { + continue; + } + const QJsonObject o = v.toObject(); + RunnerInfo info; + info.id = o.value(QStringLiteral("id")).toString(); + info.type = o.value(QStringLiteral("type")).toString(); + info.name = o.value(QStringLiteral("name")).toString(); + info.path = o.value(QStringLiteral("path")).toString(); + info.source = o.value(QStringLiteral("source")).toString(); + info.installDir = o.value(QStringLiteral("installDir")).toString(); + if (info.source.isEmpty()) { + info.source = QStringLiteral("custom"); + } + if (info.isValid()) { + m_registered.push_back(info); + } + } +} + +bool RunnerManagerDaemon::saveRegistry() const +{ + const QString path = registryPath(); + QDir().mkpath(QFileInfo(path).absolutePath()); + + QJsonArray arr; + + for (const RunnerInfo &info : m_registered) { + QJsonObject o; + o.insert(QStringLiteral("id"), info.id); + o.insert(QStringLiteral("type"), info.type); + o.insert(QStringLiteral("name"), info.name); + o.insert(QStringLiteral("path"), info.path); + o.insert(QStringLiteral("source"), info.source); + if (!info.installDir.isEmpty()) { + o.insert(QStringLiteral("installDir"), info.installDir); + } + arr.push_back(o); + } + + QJsonObject root; + root.insert(QStringLiteral("runners"), arr); + + QFile f(path); + if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + return false; + } + + f.write(QJsonDocument(root).toJson(QJsonDocument::Indented)); + return true; +} + +QStringList RunnerManagerDaemon::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"), + }; +} + +QString RunnerManagerDaemon::findSteamClientInstallPathFromProton(const QString &protonExe) +{ + if (protonExe.isEmpty()) { + return {}; + } + + QDir dir(QFileInfo(protonExe).absoluteDir()); + for (int i = 0; i < 10; ++i) { + if (dir.exists(QStringLiteral("steamapps"))) { + return dir.absolutePath(); + } + if (!dir.cdUp()) { + break; + } + } + + for (const QString &root : steamCandidateRoots()) { + if (QDir(root).exists(QStringLiteral("steamapps"))) { + return root; + } + } + + return {}; +} + +QList RunnerManagerDaemon::discoverRunners() const +{ + QList out; + + { + const QString wineExe = QStandardPaths::findExecutable(QStringLiteral("wine")); + if (!wineExe.isEmpty()) { + RunnerInfo info; + info.type = QStringLiteral("wine"); + info.path = wineExe; + info.name = QStringLiteral("Wine"); + info.source = QStringLiteral("system"); + info.id = stableIdForPath(QStringLiteral("wine"), wineExe); + out.push_back(info); + } + } + + QStringList protonCandidates; + for (const QString &root : steamCandidateRoots()) { + const QString compatTools = root + QStringLiteral("/compatibilitytools.d"); + QDir compatDir(compatTools); + if (compatDir.exists()) { + const QStringList toolDirs = compatDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot); + for (const QString &tool : toolDirs) { + const QString protonPath = compatTools + QLatin1Char('/') + tool + QStringLiteral("/proton"); + if (QFileInfo::exists(protonPath)) { + protonCandidates.append(protonPath); + } + } + } + + const QString common = root + QStringLiteral("/steamapps/common"); + QDir commonDir(common); + if (commonDir.exists()) { + const QStringList dirs = commonDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot); + for (const QString &d : dirs) { + if (!d.contains(QStringLiteral("Proton"), Qt::CaseInsensitive)) { + continue; + } + const QString protonPath = common + QLatin1Char('/') + d + QStringLiteral("/proton"); + if (QFileInfo::exists(protonPath)) { + protonCandidates.append(protonPath); + } + } + } + } + + protonCandidates.removeDuplicates(); + protonCandidates.sort(); + + for (const QString &protonExe : protonCandidates) { + RunnerInfo info; + info.type = QStringLiteral("proton"); + info.path = protonExe; + info.source = QStringLiteral("steam"); + info.id = stableIdForPath(QStringLiteral("proton"), protonExe); + + const QString dirName = QFileInfo(protonExe).absoluteDir().dirName(); + info.name = dirName.isEmpty() ? QStringLiteral("Proton") : dirName; + + out.push_back(info); + } + + return out; +} + +QString RunnerManagerDaemon::defaultProtonExecutable() const +{ + QStringList candidates; + candidates.reserve(m_discovered.size()); + + for (const RunnerInfo &r : m_discovered) { + if (r.type == QLatin1String("proton")) { + candidates.push_back(r.path); + } + } + + candidates.removeDuplicates(); + candidates.sort(); + + for (const QString &c : candidates) { + if (c.contains(QStringLiteral("Proton - Experimental"))) { + return c; + } + } + + return candidates.isEmpty() ? QString() : candidates.constLast(); +} + +RunnerManagerDaemon::RunnerInfo RunnerManagerDaemon::runnerById(const QString &runnerId) const +{ + for (const RunnerInfo &r : m_registered) { + if (r.id == runnerId) { + return r; + } + } + for (const RunnerInfo &r : m_discovered) { + if (r.id == runnerId) { + return r; + } + } + return {}; +} + +QVariantList RunnerManagerDaemon::ListRunners() +{ + QVariantList out; + + m_discovered = discoverRunners(); + + QSet seen; + + for (const RunnerInfo &r : m_registered) { + if (!r.isValid() || seen.contains(r.id)) { + continue; + } + out.push_back(r.toVariantMap()); + seen.insert(r.id); + } + + for (const RunnerInfo &r : m_discovered) { + if (!r.isValid() || seen.contains(r.id)) { + continue; + } + out.push_back(r.toVariantMap()); + seen.insert(r.id); + } + + return out; +} + +bool RunnerManagerDaemon::Rescan() +{ + m_discovered = discoverRunners(); + return true; +} + +bool RunnerManagerDaemon::RegisterRunner(const QVariantMap &runner) +{ + RunnerInfo info = RunnerInfo::fromVariantMap(runner); + if (info.source.isEmpty()) { + info.source = QStringLiteral("custom"); + } + if (!info.isValid()) { + return false; + } + + for (RunnerInfo &existing : m_registered) { + if (existing.id == info.id) { + existing = info; + return saveRegistry(); + } + } + + m_registered.push_back(info); + return saveRegistry(); +} + +bool RunnerManagerDaemon::UnregisterRunner(const QString &runnerId) +{ + if (runnerId.isEmpty()) { + return false; + } + + const int before = m_registered.size(); + m_registered.erase(std::remove_if(m_registered.begin(), + m_registered.end(), + [&](const RunnerInfo &r) { + return r.id == runnerId; + }), + m_registered.end()); + + if (m_registered.size() == before) { + return false; + } + + return saveRegistry(); +} + +bool RunnerManagerDaemon::CancelInstall(const QString &installId) +{ + if (installId.isEmpty() || !m_activeInstalls.contains(installId)) { + return false; + } + + m_cancelRequested.insert(installId); + + if (m_installReplies.contains(installId)) { + const QPointer replyPtr = m_installReplies.value(installId); + if (QNetworkReply *reply = replyPtr.data()) { + reply->abort(); + } + } + + if (m_installProcesses.contains(installId)) { + const QPointer procPtr = m_installProcesses.value(installId); + if (QProcess *p = procPtr.data()) { + p->kill(); + } + } + + return true; +} + +static QString runnersBaseDir() +{ + const QString base = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); + return base + QStringLiteral("/runners"); +} + +static QString installDirNameFromId(QString id) +{ + id.replace(QLatin1Char(':'), QLatin1Char('_')); + id.replace(QLatin1Char('/'), QLatin1Char('_')); + id.replace(QLatin1Char('\\'), QLatin1Char('_')); + return id; +} + +static QString findExecutableNamed(const QString &rootDir, const QString &fileName) +{ + QDirIterator it(rootDir, {fileName}, QDir::Files, QDirIterator::Subdirectories); + while (it.hasNext()) { + const QString path = it.next(); + const QFileInfo fi(path); + if (fi.isFile() && fi.isExecutable()) { + return path; + } + } + return {}; +} + +QVariantMap RunnerManagerDaemon::InstallRunnerFromArchive(const QVariantMap &spec) +{ + QVariantMap out; + out.insert(QStringLiteral("ok"), false); + + const QString installId = spec.value(QStringLiteral("installId")).toString(); + + const QString archivePath = spec.value(QStringLiteral("archivePath")).toString(); + QString type = spec.value(QStringLiteral("type")).toString(); + const QString name = spec.value(QStringLiteral("name")).toString(); + QString id = spec.value(QStringLiteral("id")).toString(); + const bool overwrite = spec.contains(QStringLiteral("overwrite")) ? spec.value(QStringLiteral("overwrite")).toBool() : false; + + if (archivePath.isEmpty() || !QFileInfo::exists(archivePath)) { + out.insert(QStringLiteral("error"), QStringLiteral("archivePath not found")); + return out; + } + + if (type.isEmpty()) { + type = QStringLiteral("proton"); + } + + if (id.isEmpty()) { + id = stableIdForPath(type, name.isEmpty() ? archivePath : name); + } + + const QString baseDir = runnersBaseDir(); + if (!QDir().mkpath(baseDir)) { + out.insert(QStringLiteral("error"), QStringLiteral("failed to create runners base dir")); + return out; + } + + const QString installDir = baseDir + QLatin1Char('/') + installDirNameFromId(id); + if (QFileInfo::exists(installDir)) { + if (!overwrite) { + out.insert(QStringLiteral("error"), QStringLiteral("installDir already exists")); + out.insert(QStringLiteral("installDir"), installDir); + out.insert(QStringLiteral("id"), id); + return out; + } + QDir dir(installDir); + if (!dir.removeRecursively()) { + out.insert(QStringLiteral("error"), QStringLiteral("failed to remove existing installDir")); + out.insert(QStringLiteral("installDir"), installDir); + out.insert(QStringLiteral("id"), id); + return out; + } + } + + if (!QDir().mkpath(installDir)) { + out.insert(QStringLiteral("error"), QStringLiteral("failed to create installDir")); + out.insert(QStringLiteral("installDir"), installDir); + out.insert(QStringLiteral("id"), id); + return out; + } + + const QString archiveLower = archivePath.toLower(); + + QString extractorProgram = QStringLiteral("tar"); + QStringList extractorArgs = {QStringLiteral("-xf"), archivePath, QStringLiteral("-C"), installDir}; + if (archiveLower.endsWith(QLatin1String(".zip"))) { + extractorProgram = QStringLiteral("unzip"); + extractorArgs = {QStringLiteral("-q"), archivePath, QStringLiteral("-d"), installDir}; + } + + QProcess tar; + tar.setProgram(extractorProgram); + tar.setArguments(extractorArgs); + + if (!installId.isEmpty()) { + m_installProcesses.insert(installId, &tar); + } + + tar.start(); + + if (!tar.waitForStarted()) { + if (!installId.isEmpty()) { + m_installProcesses.remove(installId); + } + QDir dir(installDir); + dir.removeRecursively(); + out.insert(QStringLiteral("error"), QStringLiteral("failed to start extract")); + out.insert(QStringLiteral("extractProgram"), extractorProgram); + out.insert(QStringLiteral("tarError"), tar.errorString()); + out.insert(QStringLiteral("tarStdout"), QString::fromUtf8(tar.readAllStandardOutput())); + out.insert(QStringLiteral("tarStderr"), QString::fromUtf8(tar.readAllStandardError())); + return out; + } + + { + QEventLoop loop; + QObject::connect(&tar, &QProcess::finished, &loop, [&]() { + loop.quit(); + }); + QObject::connect(&tar, &QProcess::errorOccurred, &loop, [&]() { + loop.quit(); + }); + loop.exec(); + } + + if (!installId.isEmpty()) { + m_installProcesses.remove(installId); + if (m_cancelRequested.contains(installId)) { + QDir dir(installDir); + dir.removeRecursively(); + out.insert(QStringLiteral("error"), QStringLiteral("cancelled")); + return out; + } + } + + if (tar.exitStatus() != QProcess::NormalExit || tar.exitCode() != 0) { + QDir dir(installDir); + dir.removeRecursively(); + out.insert(QStringLiteral("error"), QStringLiteral("failed to extract archive")); + out.insert(QStringLiteral("extractProgram"), extractorProgram); + out.insert(QStringLiteral("tarError"), tar.errorString()); + out.insert(QStringLiteral("tarStdout"), QString::fromUtf8(tar.readAllStandardOutput())); + out.insert(QStringLiteral("tarStderr"), QString::fromUtf8(tar.readAllStandardError())); + return out; + } + + QString runnerExe; + if (type == QLatin1String("proton")) { + runnerExe = findExecutableNamed(installDir, QStringLiteral("proton")); + } else if (type == QLatin1String("wine")) { + runnerExe = findExecutableNamed(installDir, QStringLiteral("wine")); + } + + if (runnerExe.isEmpty()) { + QDir dir(installDir); + dir.removeRecursively(); + out.insert(QStringLiteral("error"), QStringLiteral("runner executable not found in archive (did you download a runner release archive?)")); + out.insert(QStringLiteral("installDir"), installDir); + out.insert(QStringLiteral("id"), id); + return out; + } + + RunnerInfo info; + info.id = id; + info.type = type; + info.path = runnerExe; + info.name = name.isEmpty() ? QFileInfo(runnerExe).absoluteDir().dirName() : name; + info.source = QStringLiteral("installed"); + info.installDir = installDir; + + bool updated = false; + for (RunnerInfo &existing : m_registered) { + if (existing.id == info.id) { + existing = info; + updated = true; + break; + } + } + if (!updated) { + m_registered.push_back(info); + } + + if (!saveRegistry()) { + out.insert(QStringLiteral("error"), QStringLiteral("failed to save registry")); + return out; + } + + out.insert(QStringLiteral("ok"), true); + out.insert(QStringLiteral("runner"), info.toVariantMap()); + return out; +} + +QVariantMap RunnerManagerDaemon::InstallRunnerFromUrl(const QVariantMap &spec) +{ + QVariantMap out; + out.insert(QStringLiteral("ok"), false); + + QString installId = spec.value(QStringLiteral("installId")).toString(); + if (installId.isEmpty()) { + installId = QUuid::createUuid().toString(QUuid::WithoutBraces); + } + out.insert(QStringLiteral("installId"), installId); + + QVariantMap startedSpec = spec; + startedSpec.insert(QStringLiteral("installId"), installId); + Q_EMIT InstallStarted(installId, startedSpec); + + m_activeInstalls.insert(installId); + m_cancelRequested.remove(installId); + + auto finish = [&](QVariantMap result) -> QVariantMap { + result.insert(QStringLiteral("installId"), installId); + Q_EMIT InstallFinished(installId, result); + + m_activeInstalls.remove(installId); + m_cancelRequested.remove(installId); + m_installReplies.remove(installId); + m_installProcesses.remove(installId); + return result; + }; + + QString urlString = spec.value(QStringLiteral("url")).toString(); + if (urlString.isEmpty()) { + urlString = spec.value(QStringLiteral("archiveUrl")).toString(); + } + if (urlString.isEmpty()) { + out.insert(QStringLiteral("error"), QStringLiteral("missing url")); + return finish(out); + } + + urlString = urlString.trimmed(); + if (urlString.startsWith(QLatin1String("~/"))) { + urlString = QDir::homePath() + QLatin1Char('/') + urlString.mid(2); + } + const QString urlLower = urlString.toLower(); + if (urlLower.endsWith(QLatin1String(".git"))) { + out.insert(QStringLiteral("error"), QStringLiteral("git repository URLs are not supported; use a direct download link to a release archive (.tar.gz)")); + return finish(out); + } + + if (urlLower.contains(QLatin1String("github.com")) && urlLower.contains(QLatin1String("/archive/refs/heads/"))) { + out.insert(QStringLiteral("error"), QStringLiteral("this looks like a GitHub source code archive; use a release asset archive (.tar.gz) instead")); + return finish(out); + } + + if (urlLower.endsWith(QLatin1String("-master.zip")) || urlLower.endsWith(QLatin1String("-main.zip"))) { + out.insert(QStringLiteral("error"), QStringLiteral("this looks like a source code zip; use a runner release archive (.tar.gz) instead")); + return finish(out); + } + + QUrl url(urlString); + if (url.scheme().isEmpty()) { + const QFileInfo fi(urlString); + if (fi.exists()) { + url = QUrl::fromLocalFile(fi.absoluteFilePath()); + } else if (urlString.startsWith(QLatin1Char('/')) || urlString.startsWith(QLatin1String("~/"))) { + out.insert(QStringLiteral("error"), QStringLiteral("file not found")); + out.insert(QStringLiteral("path"), urlString); + return finish(out); + } + } + + if (!url.isValid() || url.scheme().isEmpty()) { + out.insert(QStringLiteral("error"), QStringLiteral("invalid url")); + return finish(out); + } + + const QString scheme = url.scheme().toLower(); + if (scheme != QLatin1String("http") && scheme != QLatin1String("https") && scheme != QLatin1String("file")) { + out.insert(QStringLiteral("error"), QStringLiteral("unsupported url scheme")); + return finish(out); + } + + auto 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; + }; + + QString expectedHex; + QCryptographicHash::Algorithm algo = QCryptographicHash::Sha256; + + const QString expectedSha512Spec = normalizeHex(spec.value(QStringLiteral("sha512")).toString()); + QString expectedShaSpec = spec.value(QStringLiteral("sha256")).toString(); + expectedShaSpec = normalizeHex(expectedShaSpec); + + if (!expectedSha512Spec.isEmpty()) { + algo = QCryptographicHash::Sha512; + expectedHex = expectedSha512Spec; + } else if (!expectedShaSpec.isEmpty()) { + if (expectedShaSpec.size() == 128) { + algo = QCryptographicHash::Sha512; + expectedHex = expectedShaSpec; + } else { + algo = QCryptographicHash::Sha256; + expectedHex = expectedShaSpec; + } + } + + const bool checkHash = !expectedHex.isEmpty(); + + const int timeoutMs = spec.contains(QStringLiteral("timeoutMs")) ? spec.value(QStringLiteral("timeoutMs")).toInt() : 10 * 60 * 1000; + + QString tempDir = QStandardPaths::writableLocation(QStandardPaths::TempLocation); + if (tempDir.isEmpty()) { + tempDir = QDir::tempPath(); + } + + QString suffix; + const QString pathPart = url.path(); + const int lastDot = pathPart.lastIndexOf(QLatin1Char('.')); + if (lastDot >= 0 && (pathPart.size() - lastDot) <= 16) { + suffix = pathPart.mid(lastDot); + } + if (suffix.isEmpty()) { + suffix = QStringLiteral(".download"); + } + + const QString tempPath = QDir(tempDir).filePath(QStringLiteral("alakarte-runner-%1%2").arg(QUuid::createUuid().toString(QUuid::WithoutBraces), suffix)); + + QFile file(tempPath); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + out.insert(QStringLiteral("error"), QStringLiteral("failed to create temp file")); + return finish(out); + } + + QNetworkAccessManager nam; + QNetworkRequest req(url); + req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); + + QNetworkReply *reply = nam.get(req); + m_installReplies.insert(installId, reply); + + QObject::connect(reply, &QNetworkReply::downloadProgress, this, [this, installId](qint64 received, qint64 total) { + Q_EMIT InstallProgress(installId, received, total); + }); + + QEventLoop loop; + QTimer timer; + timer.setSingleShot(true); + + bool didTimeout = false; + QCryptographicHash hash(algo); + + QObject::connect(&timer, &QTimer::timeout, &loop, [&]() { + didTimeout = true; + reply->abort(); + loop.quit(); + }); + QObject::connect(reply, &QNetworkReply::finished, &loop, [&]() { + loop.quit(); + }); + QObject::connect(reply, &QIODevice::readyRead, &loop, [&]() { + const QByteArray chunk = reply->readAll(); + if (!chunk.isEmpty()) { + file.write(chunk); + if (checkHash) { + hash.addData(chunk); + } + } + }); + + timer.start(timeoutMs); + loop.exec(); + + if (file.isOpen()) { + file.flush(); + file.close(); + } + + m_installReplies.remove(installId); + + if (m_cancelRequested.contains(installId)) { + reply->deleteLater(); + QFile::remove(tempPath); + out.insert(QStringLiteral("error"), QStringLiteral("cancelled")); + return finish(out); + } + + const bool ok = !didTimeout && reply->error() == QNetworkReply::NoError; + const QString replyError = reply->errorString(); + reply->deleteLater(); + + if (!ok) { + QFile::remove(tempPath); + out.insert(QStringLiteral("error"), didTimeout ? QStringLiteral("download timeout") : QStringLiteral("download failed")); + out.insert(QStringLiteral("details"), replyError); + return finish(out); + } + + if (checkHash) { + const QString got = QString::fromLatin1(hash.result().toHex()); + if (got != expectedHex) { + QFile::remove(tempPath); + if (algo == QCryptographicHash::Sha512) { + out.insert(QStringLiteral("error"), QStringLiteral("sha512 mismatch")); + out.insert(QStringLiteral("sha512"), got); + } else { + out.insert(QStringLiteral("error"), QStringLiteral("sha256 mismatch")); + out.insert(QStringLiteral("sha256"), got); + } + return finish(out); + } + } + + QVariantMap archiveSpec; + archiveSpec.insert(QStringLiteral("archivePath"), tempPath); + archiveSpec.insert(QStringLiteral("type"), spec.value(QStringLiteral("type")).toString()); + archiveSpec.insert(QStringLiteral("name"), spec.value(QStringLiteral("name")).toString()); + archiveSpec.insert(QStringLiteral("id"), spec.value(QStringLiteral("id")).toString()); + archiveSpec.insert(QStringLiteral("installId"), installId); + if (spec.contains(QStringLiteral("overwrite"))) { + archiveSpec.insert(QStringLiteral("overwrite"), spec.value(QStringLiteral("overwrite")).toBool()); + } + + QVariantMap installed = InstallRunnerFromArchive(archiveSpec); + QFile::remove(tempPath); + + return finish(installed); +} + +QVariantMap RunnerManagerDaemon::UninstallRunner(const QString &runnerId) +{ + QVariantMap out; + out.insert(QStringLiteral("ok"), false); + + if (runnerId.isEmpty()) { + out.insert(QStringLiteral("error"), QStringLiteral("missing runnerId")); + return out; + } + + const QString baseDir = QDir(runnersBaseDir()).canonicalPath(); + if (baseDir.isEmpty()) { + out.insert(QStringLiteral("error"), QStringLiteral("runners base dir not available")); + return out; + } + + for (int i = 0; i < m_registered.size(); ++i) { + const RunnerInfo info = m_registered.at(i); + if (info.id != runnerId) { + continue; + } + + if (info.installDir.isEmpty()) { + out.insert(QStringLiteral("error"), QStringLiteral("runner is not uninstallable")); + return out; + } + + const QString canonicalInstallDir = QDir(info.installDir).canonicalPath(); + if (canonicalInstallDir.isEmpty()) { + out.insert(QStringLiteral("error"), QStringLiteral("installDir not found")); + return out; + } + + if (canonicalInstallDir == baseDir || !canonicalInstallDir.startsWith(baseDir + QLatin1Char('/'))) { + out.insert(QStringLiteral("error"), QStringLiteral("refusing to remove installDir outside runners base dir")); + out.insert(QStringLiteral("installDir"), canonicalInstallDir); + return out; + } + + QDir dir(canonicalInstallDir); + if (!dir.removeRecursively()) { + out.insert(QStringLiteral("error"), QStringLiteral("failed to remove installDir")); + out.insert(QStringLiteral("installDir"), canonicalInstallDir); + return out; + } + + m_registered.removeAt(i); + if (!saveRegistry()) { + out.insert(QStringLiteral("error"), QStringLiteral("failed to save registry")); + return out; + } + + out.insert(QStringLiteral("ok"), true); + out.insert(QStringLiteral("runnerId"), runnerId); + return out; + } + + out.insert(QStringLiteral("error"), QStringLiteral("unknown runnerId")); + return out; +} + +QVariantMap RunnerManagerDaemon::EnsurePrefix(const QVariantMap &spec) +{ + QVariantMap out; + out.insert(QStringLiteral("ok"), false); + + const QString runner = spec.value(QStringLiteral("runner")).toString(); + const QString gameId = spec.value(QStringLiteral("gameId")).toString(); + QString prefixPath = spec.value(QStringLiteral("prefixPath")).toString(); + + if (prefixPath.isEmpty()) { + if (gameId.isEmpty()) { + out.insert(QStringLiteral("error"), QStringLiteral("missing gameId")); + return out; + } + const QString base = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); + prefixPath = base + QStringLiteral("/prefixes/") + gameId; + } + + if ((runner == QLatin1String("wine")) || (runner == QLatin1String("proton"))) { + if (!QDir().mkpath(prefixPath)) { + out.insert(QStringLiteral("error"), QStringLiteral("failed to create prefix directory")); + return out; + } + } + + out.insert(QStringLiteral("ok"), true); + out.insert(QStringLiteral("prefixPath"), prefixPath); + return out; +} + +QVariantMap RunnerManagerDaemon::DeletePrefix(const QVariantMap &spec) +{ + QVariantMap out; + out.insert(QStringLiteral("ok"), false); + + const QString gameId = spec.value(QStringLiteral("gameId")).toString(); + QString prefixPath = spec.value(QStringLiteral("prefixPath")).toString(); + + if (prefixPath.isEmpty()) { + if (gameId.isEmpty()) { + out.insert(QStringLiteral("error"), QStringLiteral("missing gameId")); + return out; + } + const QString base = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); + prefixPath = base + QStringLiteral("/prefixes/") + gameId; + } + + const QString baseDir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + QStringLiteral("/prefixes"); + const QString canonicalBaseDir = QDir(baseDir).canonicalPath(); + const QString canonicalPrefixDir = QDir(prefixPath).canonicalPath(); + + if (canonicalPrefixDir.isEmpty()) { + out.insert(QStringLiteral("error"), QStringLiteral("prefixPath not found")); + return out; + } + if (canonicalBaseDir.isEmpty()) { + out.insert(QStringLiteral("error"), QStringLiteral("prefix base dir not found")); + return out; + } + + if (canonicalPrefixDir == canonicalBaseDir || !canonicalPrefixDir.startsWith(canonicalBaseDir + QLatin1Char('/'))) { + out.insert(QStringLiteral("error"), QStringLiteral("refusing to remove prefix outside managed prefixes dir")); + out.insert(QStringLiteral("prefixPath"), canonicalPrefixDir); + out.insert(QStringLiteral("prefixBase"), canonicalBaseDir); + return out; + } + + QDir dir(canonicalPrefixDir); + if (!dir.removeRecursively()) { + out.insert(QStringLiteral("error"), QStringLiteral("failed to remove prefix")); + out.insert(QStringLiteral("prefixPath"), canonicalPrefixDir); + return out; + } + + out.insert(QStringLiteral("ok"), true); + out.insert(QStringLiteral("prefixPath"), canonicalPrefixDir); + return out; +} + +QVariantMap RunnerManagerDaemon::ResolveLaunch(const QVariantMap &spec) const +{ + QVariantMap out; + out.insert(QStringLiteral("ok"), false); + + const QString runnerId = spec.value(QStringLiteral("runnerId")).toString(); + QString runner = spec.value(QStringLiteral("runner")).toString(); + QString runnerPath = spec.value(QStringLiteral("runnerPath")).toString(); + + if (!runnerId.isEmpty()) { + const RunnerInfo info = runnerById(runnerId); + if (!info.isValid()) { + out.insert(QStringLiteral("error"), QStringLiteral("unknown runnerId")); + return out; + } + runner = info.type; + runnerPath = info.path; + } + + const QString gameId = spec.value(QStringLiteral("gameId")).toString(); + const QString program = spec.value(QStringLiteral("program")).toString(); + const QStringList args = spec.value(QStringLiteral("args")).toStringList(); + QString prefixPath = spec.value(QStringLiteral("prefixPath")).toString(); + + if (program.isEmpty()) { + out.insert(QStringLiteral("error"), QStringLiteral("missing program")); + return out; + } + + QVariantMap effectiveEnv = spec.value(QStringLiteral("envOverrides")).toMap(); + + if ((runner == QLatin1String("wine")) || (runner == QLatin1String("proton"))) { + if (prefixPath.isEmpty()) { + if (gameId.isEmpty()) { + out.insert(QStringLiteral("error"), QStringLiteral("missing prefixPath or gameId")); + return out; + } + const QString base = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); + prefixPath = base + QStringLiteral("/prefixes/") + gameId; + } + out.insert(QStringLiteral("resolvedPrefixPath"), prefixPath); + } + + QString finalProgram = program; + QStringList finalArgs = args; + + if (runner == QLatin1String("wine")) { + QString wineExe = runnerPath; + if (wineExe.isEmpty()) { + wineExe = QStandardPaths::findExecutable(QStringLiteral("wine")); + } + if (wineExe.isEmpty()) { + out.insert(QStringLiteral("error"), QStringLiteral("wine not found")); + return out; + } + + effectiveEnv.insert(QStringLiteral("WINEPREFIX"), prefixPath); + + finalProgram = wineExe; + finalArgs = {program}; + finalArgs.append(args); + } else if (runner == QLatin1String("proton")) { + QString protonExe = runnerPath; + if (protonExe.isEmpty()) { + protonExe = defaultProtonExecutable(); + } + if (protonExe.isEmpty()) { + out.insert(QStringLiteral("error"), QStringLiteral("proton not found")); + return out; + } + + const QString steamInstallPath = findSteamClientInstallPathFromProton(protonExe); + out.insert(QStringLiteral("resolvedSteamInstallPath"), steamInstallPath); + + effectiveEnv.insert(QStringLiteral("STEAM_COMPAT_DATA_PATH"), prefixPath); + if (!steamInstallPath.isEmpty()) { + effectiveEnv.insert(QStringLiteral("STEAM_COMPAT_CLIENT_INSTALL_PATH"), steamInstallPath); + } + + finalProgram = protonExe; + finalArgs = {QStringLiteral("run"), program}; + finalArgs.append(args); + } + + out.insert(QStringLiteral("finalProgram"), finalProgram); + out.insert(QStringLiteral("finalArgs"), finalArgs); + out.insert(QStringLiteral("effectiveEnv"), effectiveEnv); + out.insert(QStringLiteral("ok"), true); + + return out; +} diff --git a/src/runner/runnermanagerdaemon.h b/src/runner/runnermanagerdaemon.h new file mode 100644 index 0000000..1012505 --- /dev/null +++ b/src/runner/runnermanagerdaemon.h @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors + +#pragma once + +#include +#include +#include +#include +#include + +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 discoverRunners() const; + QString defaultProtonExecutable() const; + + static QStringList steamCandidateRoots(); + static QString findSteamClientInstallPathFromProton(const QString &protonExe); + + RunnerInfo runnerById(const QString &runnerId) const; + + QList m_registered; + QList m_discovered; + + QSet m_activeInstalls; + QSet m_cancelRequested; + + QHash> m_installReplies; + QHash> m_installProcesses; +}; diff --git a/src/runner/systemd/org.kde.ALaKarte.Runner1.service.in b/src/runner/systemd/org.kde.ALaKarte.Runner1.service.in new file mode 100644 index 0000000..cf35cbe --- /dev/null +++ b/src/runner/systemd/org.kde.ALaKarte.Runner1.service.in @@ -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 diff --git a/src/runnermanagerclient.cpp b/src/runnermanagerclient.cpp new file mode 100644 index 0000000..03fa17b --- /dev/null +++ b/src/runnermanagerclient.cpp @@ -0,0 +1,604 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors + +#include "runnermanagerclient.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 names = bus.interface()->activatableServiceNames(); + if (names.isValid()) { + activatable = names.value().contains(kRunnerService); + } + } + + const QDBusReply 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::create(0); + auto callPtr = QSharedPointer>::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 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::create(0); + auto callPtr = QSharedPointer>::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 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(received), static_cast(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 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()) { + list.push_back(v.toMap()); + continue; + } + + if (v.metaType() == QMetaType::fromType()) { + const QDBusArgument arg = v.value(); + const QVariantMap map = qdbus_cast(arg); + list.push_back(map); + continue; + } + + if (v.canConvert()) { + 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 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::create(0); + auto callPtr = QSharedPointer>::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 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(); +} diff --git a/src/runnermanagerclient.h b/src/runnermanagerclient.h new file mode 100644 index 0000000..f78814b --- /dev/null +++ b/src/runnermanagerclient.h @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors + +#pragma once + +#include +#include +#include + +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; +};