// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: 2024 A-La-Karte Contributors #include "gamelauncher.h" #include "app.h" #include #include #include #include #include #include #include #include #include static QStringList steamCandidateRoots() { const QString home = QDir::homePath(); return { home + QStringLiteral("/.steam/root"), home + QStringLiteral("/.steam/steam"), home + QStringLiteral("/.local/share/Steam"), home + QStringLiteral("/.var/app/com.valvesoftware.Steam/data/Steam"), home + QStringLiteral("/.var/app/com.valvesoftware.Steam/.local/share/Steam"), }; } static QString 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 {}; } static QString discoverDefaultProtonExecutable() { static bool done = false; static QString cached; if (done) { return cached; } done = true; QStringList candidates; 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)) { candidates.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)) { candidates.append(protonPath); } } } } candidates.removeDuplicates(); candidates.sort(); for (const QString &c : candidates) { if (c.contains(QStringLiteral("Proton - Experimental"))) { cached = c; return cached; } } if (!candidates.isEmpty()) { cached = candidates.constLast(); } return cached; } GameLauncher::GameLauncher(QObject *parent) : QObject(parent) { } GameLauncher::~GameLauncher() { // Cleanup all running processes for (QProcess *process : m_runningGames.values()) { process->disconnect(); process->terminate(); process->waitForFinished(3000); process->deleteLater(); } m_runningGames.clear(); m_processToGame.clear(); } bool GameLauncher::hasRunningGames() const { return !m_runningGames.isEmpty(); } void GameLauncher::launchGame(Game *game) { if (!game) { return; } QString command = game->launchCommand(); if (command.isEmpty()) { Q_EMIT gameError(game, tr("No launch command configured")); return; } const QString runner = game->launchRunner().trimmed(); const QString runnerPath = game->launchRunnerPath().trimmed(); const QString prefixPath = game->launchPrefixPath().trimmed(); const QVariantMap launchEnv = game->launchEnv(); const bool hasLaunchOverrides = !runner.isEmpty() || !runnerPath.isEmpty() || !prefixPath.isEmpty() || !launchEnv.isEmpty(); // Check if already running if (m_runningGames.contains(game->id())) { Q_EMIT gameError(game, tr("Game is already running")); return; } // Handle Steam URLs if (!hasLaunchOverrides && command.startsWith(QLatin1String("steam://"))) { QDesktopServices::openUrl(QUrl(command)); game->setLastPlayed(QDateTime::currentDateTime()); Q_EMIT gameStarted(game); return; } // Handle Lutris URLs if (!hasLaunchOverrides && command.startsWith(QLatin1String("lutris "))) { QString lutrisCommand = command.mid(7); // Remove "lutris " QDesktopServices::openUrl(QUrl(lutrisCommand)); game->setLastPlayed(QDateTime::currentDateTime()); Q_EMIT gameStarted(game); return; } // Start process for other commands QProcess *process = new QProcess(this); // Apply per-game environment variables QProcessEnvironment env; bool envChanged = false; if (!launchEnv.isEmpty()) { env = QProcessEnvironment::systemEnvironment(); envChanged = true; for (auto it = launchEnv.constBegin(); it != launchEnv.constEnd(); ++it) { const QString key = it.key(); if (key.isEmpty() || key.contains(QLatin1Char('='))) { continue; } env.insert(key, it.value().toString()); } } // Set working directory if available if (!game->workingDirectory().isEmpty()) { process->setWorkingDirectory(game->workingDirectory()); } connect(process, QOverload::of(&QProcess::finished), this, &GameLauncher::onProcessFinished); connect(process, &QProcess::errorOccurred, this, &GameLauncher::onProcessError); m_runningGames.insert(game->id(), process); m_processToGame.insert(process, game); // Parse command and arguments QStringList parts; if (command.startsWith(QLatin1String("steam://"))) { parts = {QStringLiteral("xdg-open"), command}; } else if (command.startsWith(QLatin1String("lutris "))) { parts = {QStringLiteral("xdg-open"), command.mid(7)}; } else { parts = QProcess::splitCommand(command); } if (parts.isEmpty()) { cleanupProcess(process); Q_EMIT gameError(game, tr("Invalid launch command")); return; } QString program = parts.takeFirst(); QString finalProgram = program; QStringList finalArgs = parts; if ((runner == QLatin1String("wine") || runner == QLatin1String("proton")) && (program == QLatin1String("xdg-open"))) { cleanupProcess(process); Q_EMIT gameError(game, tr("This runner cannot be used with URL-based launch commands")); return; } if (runner == QLatin1String("custom")) { if (runnerPath.isEmpty()) { cleanupProcess(process); Q_EMIT gameError(game, tr("Custom runner is enabled but no runner path is configured")); return; } finalProgram = runnerPath; finalArgs = {program}; finalArgs.append(parts); } else if (runner == QLatin1String("wine")) { QString wineExe = runnerPath; if (wineExe.isEmpty()) { wineExe = QStandardPaths::findExecutable(QStringLiteral("wine")); } if (wineExe.isEmpty()) { cleanupProcess(process); Q_EMIT gameError(game, tr("Wine runner is enabled but Wine was not found")); return; } QString winePrefix = prefixPath; if (winePrefix.isEmpty()) { winePrefix = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + QStringLiteral("/prefixes/") + game->id(); } QDir().mkpath(winePrefix); if (!envChanged) { env = QProcessEnvironment::systemEnvironment(); envChanged = true; } env.insert(QStringLiteral("WINEPREFIX"), winePrefix); finalProgram = wineExe; finalArgs = {program}; finalArgs.append(parts); } else if (runner == QLatin1String("proton")) { QString protonExe = runnerPath; if (protonExe.isEmpty()) { protonExe = discoverDefaultProtonExecutable(); } if (protonExe.isEmpty()) { cleanupProcess(process); Q_EMIT gameError(game, tr("Proton runner is enabled but no Proton installation was found")); return; } QString steamInstallPath = findSteamClientInstallPathFromProton(protonExe); QString compatDataPath = prefixPath; if (compatDataPath.isEmpty()) { compatDataPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + QStringLiteral("/prefixes/") + game->id(); } QDir().mkpath(compatDataPath); if (!envChanged) { env = QProcessEnvironment::systemEnvironment(); envChanged = true; } env.insert(QStringLiteral("STEAM_COMPAT_DATA_PATH"), compatDataPath); if (!steamInstallPath.isEmpty()) { env.insert(QStringLiteral("STEAM_COMPAT_CLIENT_INSTALL_PATH"), steamInstallPath); } finalProgram = protonExe; finalArgs = {QStringLiteral("run"), program}; finalArgs.append(parts); } if (envChanged) { process->setProcessEnvironment(env); } process->start(finalProgram, finalArgs); if (!process->waitForStarted(5000)) { QString error = process->errorString(); cleanupProcess(process); Q_EMIT gameError(game, tr("Failed to start game: %1").arg(error)); return; } game->setRunning(true); game->setLastPlayed(QDateTime::currentDateTime()); Q_EMIT gameStarted(game); Q_EMIT runningGamesChanged(); // Exit after launch if configured if (App::instance()->config()->exitAfterLaunch()) { QTimer::singleShot(500, qApp, &QCoreApplication::quit); } } void GameLauncher::stopGame(Game *game) { if (!game || !m_runningGames.contains(game->id())) { return; } QProcess *process = m_runningGames.value(game->id()); process->terminate(); if (!process->waitForFinished(5000)) { process->kill(); } } bool GameLauncher::isGameRunning(Game *game) const { return game && m_runningGames.contains(game->id()); } void GameLauncher::onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus) { Q_UNUSED(exitStatus) QProcess *process = qobject_cast(sender()); if (!process) { return; } Game *game = m_processToGame.value(process); if (game) { game->setRunning(false); Q_EMIT gameStopped(game, exitCode); } cleanupProcess(process); Q_EMIT runningGamesChanged(); } void GameLauncher::onProcessError(QProcess::ProcessError error) { QProcess *process = qobject_cast(sender()); if (!process) { return; } Game *game = m_processToGame.value(process); QString errorMessage; switch (error) { case QProcess::FailedToStart: errorMessage = tr("Failed to start the game process"); break; case QProcess::Crashed: errorMessage = tr("Game process crashed"); break; case QProcess::Timedout: errorMessage = tr("Game process timed out"); break; case QProcess::WriteError: errorMessage = tr("Failed to write to game process"); break; case QProcess::ReadError: errorMessage = tr("Failed to read from game process"); break; default: errorMessage = tr("Unknown error occurred"); break; } if (game) { game->setRunning(false); Q_EMIT gameError(game, errorMessage); } cleanupProcess(process); Q_EMIT runningGamesChanged(); } void GameLauncher::cleanupProcess(QProcess *process) { if (!process) { return; } Game *game = m_processToGame.take(process); if (game) { m_runningGames.remove(game->id()); } process->deleteLater(); }