// 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 #include #include static const QString kRunnerService = QStringLiteral("org.kde.ALaKarte.Runner1"); static const QString kRunnerPath = QStringLiteral("/org/kde/ALaKarte/Runner1"); static QVariant unwrapDbusVariant(QVariant v) { if (v.metaType() == QMetaType::fromType()) { v = v.value().variant(); } return v; } static QVariantMap unwrapVariantMap(QVariant v) { v = unwrapDbusVariant(v); if (v.metaType() == QMetaType::fromType()) { const QDBusArgument arg = v.value(); return qdbus_cast(arg); } if (v.canConvert()) { return v.toMap(); } return {}; } static QStringList unwrapStringList(QVariant v) { v = unwrapDbusVariant(v); if (v.metaType() == QMetaType::fromType()) { const QDBusArgument arg = v.value(); return qdbus_cast(arg); } if (v.canConvert()) { return v.toStringList(); } if (v.canConvert()) { const QVariantList list = v.toList(); QStringList out; out.reserve(list.size()); for (const QVariant &item : list) { const QString s = item.toString(); if (!s.isEmpty()) { out.push_back(s); } } return out; } return {}; } 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(); loadGameProfiles(); 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; } QString RunnerManagerDaemon::gameProfilesPath() const { const QString base = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); return base + QStringLiteral("/runner-game-profiles.json"); } void RunnerManagerDaemon::loadGameProfiles() { m_gameProfiles.clear(); QFile f(gameProfilesPath()); if (!f.open(QIODevice::ReadOnly)) { return; } const QJsonDocument doc = QJsonDocument::fromJson(f.readAll()); if (!doc.isObject()) { return; } const QJsonArray arr = doc.object().value(QStringLiteral("profiles")).toArray(); for (const QJsonValue &v : arr) { if (!v.isObject()) { continue; } const QVariantMap map = v.toObject().toVariantMap(); const QString gameId = map.value(QStringLiteral("gameId")).toString(); if (gameId.isEmpty()) { continue; } m_gameProfiles.insert(gameId, map); } } bool RunnerManagerDaemon::saveGameProfiles() const { const QString path = gameProfilesPath(); QDir().mkpath(QFileInfo(path).absolutePath()); QJsonArray arr; for (auto it = m_gameProfiles.constBegin(); it != m_gameProfiles.constEnd(); ++it) { QVariantMap profile = it.value(); if (!profile.contains(QStringLiteral("gameId"))) { profile.insert(QStringLiteral("gameId"), it.key()); } arr.push_back(QJsonObject::fromVariantMap(profile)); } QJsonObject root; root.insert(QStringLiteral("profiles"), arr); QFile f(path); if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) { return false; } f.write(QJsonDocument(root).toJson(QJsonDocument::Indented)); return true; } QVariantMap RunnerManagerDaemon::gameProfileForGameId(const QString &gameId) const { const QString id = gameId.trimmed(); if (id.isEmpty()) { return {}; } const auto it = m_gameProfiles.constFind(id); return it == m_gameProfiles.constEnd() ? QVariantMap{} : it.value(); } 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::GetGameProfile(const QString &gameId) const { QVariantMap out; out.insert(QStringLiteral("ok"), false); const QString id = gameId.trimmed(); if (id.isEmpty()) { out.insert(QStringLiteral("error"), QStringLiteral("missing gameId")); return out; } const QVariantMap profile = gameProfileForGameId(id); out.insert(QStringLiteral("ok"), true); out.insert(QStringLiteral("profile"), profile); return out; } QVariantList RunnerManagerDaemon::ListGameProfiles() const { QVariantList out; out.reserve(m_gameProfiles.size()); for (auto it = m_gameProfiles.constBegin(); it != m_gameProfiles.constEnd(); ++it) { QVariantMap profile = it.value(); if (!profile.contains(QStringLiteral("gameId"))) { profile.insert(QStringLiteral("gameId"), it.key()); } out.push_back(profile); } return out; } QVariantMap RunnerManagerDaemon::SetGameProfile(const QVariantMap &spec) { QVariantMap out; out.insert(QStringLiteral("ok"), false); const QString gameId = spec.value(QStringLiteral("gameId")).toString().trimmed(); if (gameId.isEmpty()) { out.insert(QStringLiteral("error"), QStringLiteral("missing gameId")); return out; } QVariantMap profile; profile.insert(QStringLiteral("gameId"), gameId); auto takeStringIfPresent = [&](const QString &key) { if (!spec.contains(key)) { return; } const QString v = spec.value(key).toString().trimmed(); if (!v.isEmpty()) { profile.insert(key, v); } }; takeStringIfPresent(QStringLiteral("runnerId")); takeStringIfPresent(QStringLiteral("runner")); takeStringIfPresent(QStringLiteral("runnerPath")); takeStringIfPresent(QStringLiteral("prefixPath")); if (spec.contains(QStringLiteral("dllOverrides"))) { const QVariantMap raw = unwrapVariantMap(spec.value(QStringLiteral("dllOverrides"))); QVariantMap dllOverrides; for (auto it = raw.constBegin(); it != raw.constEnd(); ++it) { const QString key = it.key().trimmed(); if (key.isEmpty() || key.contains(QLatin1Char('=')) || key.contains(QLatin1Char(';'))) { continue; } const QString value = it.value().toString().trimmed(); if (value.isEmpty()) { continue; } dllOverrides.insert(key, value); } if (!dllOverrides.isEmpty()) { profile.insert(QStringLiteral("dllOverrides"), dllOverrides); } } if (spec.contains(QStringLiteral("envOverrides"))) { const QVariantMap rawEnv = unwrapVariantMap(spec.value(QStringLiteral("envOverrides"))); QVariantMap env; for (auto it = rawEnv.constBegin(); it != rawEnv.constEnd(); ++it) { const QString key = it.key(); if (key.isEmpty() || key.contains(QLatin1Char('='))) { continue; } env.insert(key, it.value().toString()); } if (!env.isEmpty()) { profile.insert(QStringLiteral("envOverrides"), env); } } if (spec.contains(QStringLiteral("extraArgs"))) { const QStringList raw = unwrapStringList(spec.value(QStringLiteral("extraArgs"))); QStringList extraArgs; extraArgs.reserve(raw.size()); for (const QString &a : raw) { const QString trimmed = a.trimmed(); if (!trimmed.isEmpty()) { extraArgs.push_back(trimmed); } } if (!extraArgs.isEmpty()) { profile.insert(QStringLiteral("extraArgs"), extraArgs); } } if (profile.size() <= 1) { out.insert(QStringLiteral("error"), QStringLiteral("empty profile")); return out; } m_gameProfiles.insert(gameId, profile); if (!saveGameProfiles()) { out.insert(QStringLiteral("error"), QStringLiteral("failed to save game profiles")); return out; } Q_EMIT GameProfilesChanged(); out.insert(QStringLiteral("ok"), true); out.insert(QStringLiteral("profile"), profile); return out; } QVariantMap RunnerManagerDaemon::ClearGameProfile(const QString &gameId) { QVariantMap out; out.insert(QStringLiteral("ok"), false); const QString id = gameId.trimmed(); if (id.isEmpty()) { out.insert(QStringLiteral("error"), QStringLiteral("missing gameId")); return out; } if (!m_gameProfiles.contains(id)) { out.insert(QStringLiteral("ok"), true); out.insert(QStringLiteral("gameId"), id); return out; } m_gameProfiles.remove(id); if (!saveGameProfiles()) { out.insert(QStringLiteral("error"), QStringLiteral("failed to save game profiles")); return out; } Q_EMIT GameProfilesChanged(); out.insert(QStringLiteral("ok"), true); out.insert(QStringLiteral("gameId"), id); return out; } QVariantMap RunnerManagerDaemon::ResolveLaunch(const QVariantMap &spec) const { QVariantMap out; out.insert(QStringLiteral("ok"), false); const QString gameId = spec.value(QStringLiteral("gameId")).toString(); const bool useGameProfile = spec.contains(QStringLiteral("useGameProfile")) ? spec.value(QStringLiteral("useGameProfile")).toBool() : false; QVariantMap profile; if (useGameProfile && !gameId.isEmpty()) { profile = gameProfileForGameId(gameId); } const QString specRunnerId = spec.value(QStringLiteral("runnerId")).toString(); const QString specRunner = spec.value(QStringLiteral("runner")).toString(); const QString specRunnerPath = spec.value(QStringLiteral("runnerPath")).toString(); QString runnerId = specRunnerId; QString runner = specRunner; QString runnerPath = specRunnerPath; if (runnerId.isEmpty() && profile.contains(QStringLiteral("runnerId"))) { runnerId = profile.value(QStringLiteral("runnerId")).toString(); } if (runner.isEmpty() && profile.contains(QStringLiteral("runner"))) { runner = profile.value(QStringLiteral("runner")).toString(); } if (runnerPath.isEmpty() && profile.contains(QStringLiteral("runnerPath"))) { runnerPath = profile.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 program = spec.value(QStringLiteral("program")).toString(); QStringList args = unwrapStringList(spec.value(QStringLiteral("args"))); QString prefixPath = spec.value(QStringLiteral("prefixPath")).toString(); if (prefixPath.isEmpty() && profile.contains(QStringLiteral("prefixPath"))) { prefixPath = profile.value(QStringLiteral("prefixPath")).toString(); } if (program.isEmpty()) { out.insert(QStringLiteral("error"), QStringLiteral("missing program")); return out; } { const QStringList rawProfileExtraArgs = unwrapStringList(profile.value(QStringLiteral("extraArgs"))); for (const QString &a : rawProfileExtraArgs) { const QString trimmed = a.trimmed(); if (!trimmed.isEmpty()) { args.append(trimmed); } } const QStringList rawSpecExtraArgs = unwrapStringList(spec.value(QStringLiteral("extraArgs"))); for (const QString &a : rawSpecExtraArgs) { const QString trimmed = a.trimmed(); if (!trimmed.isEmpty()) { args.append(trimmed); } } } QVariantMap effectiveEnv; if (profile.contains(QStringLiteral("envOverrides"))) { effectiveEnv = profile.value(QStringLiteral("envOverrides")).toMap(); } const QVariantMap specEnv = unwrapVariantMap(spec.value(QStringLiteral("envOverrides"))); for (auto it = specEnv.constBegin(); it != specEnv.constEnd(); ++it) { effectiveEnv.insert(it.key(), it.value()); } QVariantMap dllOverrides; if (profile.contains(QStringLiteral("dllOverrides"))) { dllOverrides = profile.value(QStringLiteral("dllOverrides")).toMap(); } const QVariantMap specDllOverrides = unwrapVariantMap(spec.value(QStringLiteral("dllOverrides"))); for (auto it = specDllOverrides.constBegin(); it != specDllOverrides.constEnd(); ++it) { const QString key = it.key().trimmed(); if (key.isEmpty() || key.contains(QLatin1Char('=')) || key.contains(QLatin1Char(';'))) { continue; } const QString value = it.value().toString().trimmed(); if (value.isEmpty()) { continue; } dllOverrides.insert(key, value); } if (!dllOverrides.isEmpty() && !effectiveEnv.contains(QStringLiteral("WINEDLLOVERRIDES")) && ((runner == QLatin1String("wine")) || (runner == QLatin1String("proton")))) { QStringList keys; keys.reserve(dllOverrides.size()); for (auto it = dllOverrides.constBegin(); it != dllOverrides.constEnd(); ++it) { keys.push_back(it.key()); } std::sort(keys.begin(), keys.end()); QStringList parts; parts.reserve(keys.size()); for (const QString &k : keys) { const QString v = dllOverrides.value(k).toString().trimmed(); if (v.isEmpty()) { continue; } parts.push_back(k + QLatin1Char('=') + v); } if (!parts.isEmpty()) { effectiveEnv.insert(QStringLiteral("WINEDLLOVERRIDES"), parts.join(QStringLiteral(";"))); } } 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; }