// 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(); }