2026-01-18 12:13:07 +00:00
|
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
|
// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors
|
|
|
|
|
|
|
|
|
|
#include "gamelauncher.h"
|
|
|
|
|
#include "app.h"
|
|
|
|
|
|
|
|
|
|
#include <QCoreApplication>
|
|
|
|
|
#include <QDateTime>
|
|
|
|
|
#include <QDesktopServices>
|
2026-01-25 13:19:57 +00:00
|
|
|
#include <QDir>
|
|
|
|
|
#include <QFileInfo>
|
|
|
|
|
#include <QProcessEnvironment>
|
|
|
|
|
#include <QStandardPaths>
|
2026-01-18 12:13:07 +00:00
|
|
|
#include <QTimer>
|
|
|
|
|
#include <QUrl>
|
|
|
|
|
|
2026-01-25 13:19:57 +00:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-18 12:13:07 +00:00
|
|
|
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);
|
2026-01-24 12:50:54 +00:00
|
|
|
process->deleteLater();
|
2026-01-18 12:13:07 +00:00
|
|
|
}
|
2026-01-24 12:50:54 +00:00
|
|
|
|
|
|
|
|
m_runningGames.clear();
|
|
|
|
|
m_processToGame.clear();
|
2026-01-18 12:13:07 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-25 13:19:57 +00:00
|
|
|
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();
|
|
|
|
|
|
2026-01-18 12:13:07 +00:00
|
|
|
// Check if already running
|
|
|
|
|
if (m_runningGames.contains(game->id())) {
|
|
|
|
|
Q_EMIT gameError(game, tr("Game is already running"));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Handle Steam URLs
|
2026-01-25 13:19:57 +00:00
|
|
|
if (!hasLaunchOverrides && command.startsWith(QLatin1String("steam://"))) {
|
2026-01-18 12:13:07 +00:00
|
|
|
QDesktopServices::openUrl(QUrl(command));
|
|
|
|
|
game->setLastPlayed(QDateTime::currentDateTime());
|
|
|
|
|
Q_EMIT gameStarted(game);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Handle Lutris URLs
|
2026-01-25 13:19:57 +00:00
|
|
|
if (!hasLaunchOverrides && command.startsWith(QLatin1String("lutris "))) {
|
2026-01-18 12:13:07 +00:00
|
|
|
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);
|
|
|
|
|
|
2026-01-25 13:19:57 +00:00
|
|
|
// 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());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-18 12:13:07 +00:00
|
|
|
// Set working directory if available
|
|
|
|
|
if (!game->workingDirectory().isEmpty()) {
|
|
|
|
|
process->setWorkingDirectory(game->workingDirectory());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
connect(process, QOverload<int, QProcess::ExitStatus>::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
|
2026-01-25 13:19:57 +00:00
|
|
|
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);
|
|
|
|
|
}
|
2026-01-18 12:13:07 +00:00
|
|
|
if (parts.isEmpty()) {
|
|
|
|
|
cleanupProcess(process);
|
|
|
|
|
Q_EMIT gameError(game, tr("Invalid launch command"));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
QString program = parts.takeFirst();
|
2026-01-25 13:19:57 +00:00
|
|
|
|
|
|
|
|
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);
|
2026-01-18 12:13:07 +00:00
|
|
|
|
|
|
|
|
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<QProcess *>(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<QProcess *>(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();
|
|
|
|
|
}
|