a-la-karte/src/gamelauncher.cpp

567 lines
18 KiB
C++
Raw Normal View History

// 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>
#include <QDir>
#include <QFileInfo>
#include <QProcessEnvironment>
#include <QStandardPaths>
#include <QTimer>
#include <QUrl>
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();
}
2026-01-30 10:02:28 +00:00
QVariantMap GameLauncher::resolveLaunchInfo(Game *game) const
{
QVariantMap info;
info.insert(QStringLiteral("ok"), false);
if (!game) {
info.insert(QStringLiteral("error"), tr("No game selected"));
return info;
}
info.insert(QStringLiteral("gameId"), game->id());
info.insert(QStringLiteral("gameName"), game->name());
info.insert(QStringLiteral("isRunning"), m_runningGames.contains(game->id()));
const QString command = game->launchCommand();
info.insert(QStringLiteral("launchCommand"), command);
if (command.isEmpty()) {
info.insert(QStringLiteral("error"), tr("No launch command configured"));
return info;
}
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();
info.insert(QStringLiteral("runner"), runner);
info.insert(QStringLiteral("runnerPath"), runnerPath);
info.insert(QStringLiteral("prefixPath"), prefixPath);
info.insert(QStringLiteral("workingDirectory"), game->workingDirectory());
info.insert(QStringLiteral("hasLaunchOverrides"), hasLaunchOverrides);
QVariantMap envOverrides;
for (auto it = launchEnv.constBegin(); it != launchEnv.constEnd(); ++it) {
const QString key = it.key();
if (key.isEmpty() || key.contains(QLatin1Char('='))) {
continue;
}
envOverrides.insert(key, it.value().toString());
}
info.insert(QStringLiteral("envOverrides"), envOverrides);
if (!hasLaunchOverrides && command.startsWith(QLatin1String("steam://"))) {
info.insert(QStringLiteral("ok"), true);
info.insert(QStringLiteral("launchType"), QStringLiteral("url"));
info.insert(QStringLiteral("url"), command);
info.insert(QStringLiteral("usesDesktopServices"), true);
return info;
}
if (!hasLaunchOverrides && command.startsWith(QLatin1String("lutris "))) {
info.insert(QStringLiteral("ok"), true);
info.insert(QStringLiteral("launchType"), QStringLiteral("url"));
info.insert(QStringLiteral("url"), command.mid(7));
info.insert(QStringLiteral("usesDesktopServices"), true);
return info;
}
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()) {
info.insert(QStringLiteral("error"), tr("Invalid launch command"));
return info;
}
const QString program = parts.takeFirst();
info.insert(QStringLiteral("program"), program);
info.insert(QStringLiteral("args"), parts);
QString finalProgram = program;
QStringList finalArgs = parts;
QVariantMap effectiveEnv = envOverrides;
if ((runner == QLatin1String("wine") || runner == QLatin1String("proton")) && (program == QLatin1String("xdg-open"))) {
info.insert(QStringLiteral("error"), tr("This runner cannot be used with URL-based launch commands"));
return info;
}
if (runner == QLatin1String("custom")) {
if (runnerPath.isEmpty()) {
info.insert(QStringLiteral("error"), tr("Custom runner is enabled but no runner path is configured"));
return info;
}
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()) {
info.insert(QStringLiteral("error"), tr("Wine runner is enabled but Wine was not found"));
return info;
}
QString winePrefix = prefixPath;
if (winePrefix.isEmpty()) {
winePrefix = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + QStringLiteral("/prefixes/") + game->id();
}
effectiveEnv.insert(QStringLiteral("WINEPREFIX"), winePrefix);
info.insert(QStringLiteral("resolvedPrefixPath"), winePrefix);
finalProgram = wineExe;
finalArgs = {program};
finalArgs.append(parts);
} else if (runner == QLatin1String("proton")) {
QString protonExe = runnerPath;
if (protonExe.isEmpty()) {
protonExe = discoverDefaultProtonExecutable();
}
if (protonExe.isEmpty()) {
info.insert(QStringLiteral("error"), tr("Proton runner is enabled but no Proton installation was found"));
return info;
}
const QString steamInstallPath = findSteamClientInstallPathFromProton(protonExe);
info.insert(QStringLiteral("resolvedSteamInstallPath"), steamInstallPath);
QString compatDataPath = prefixPath;
if (compatDataPath.isEmpty()) {
compatDataPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + QStringLiteral("/prefixes/") + game->id();
}
effectiveEnv.insert(QStringLiteral("STEAM_COMPAT_DATA_PATH"), compatDataPath);
info.insert(QStringLiteral("resolvedPrefixPath"), compatDataPath);
if (!steamInstallPath.isEmpty()) {
effectiveEnv.insert(QStringLiteral("STEAM_COMPAT_CLIENT_INSTALL_PATH"), steamInstallPath);
}
finalProgram = protonExe;
finalArgs = {QStringLiteral("run"), program};
finalArgs.append(parts);
}
info.insert(QStringLiteral("finalProgram"), finalProgram);
info.insert(QStringLiteral("finalArgs"), finalArgs);
info.insert(QStringLiteral("effectiveEnv"), effectiveEnv);
info.insert(QStringLiteral("ok"), true);
return info;
}
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<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
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<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();
}