diff --git a/src/game.cpp b/src/game.cpp index 3c85c3e..32993c3 100644 --- a/src/game.cpp +++ b/src/game.cpp @@ -4,6 +4,8 @@ #include "game.h" #include +#include +#include #include #include @@ -131,6 +133,58 @@ void Game::setWorkingDirectory(const QString &dir) } } +QVariantMap Game::launchEnv() const +{ + return m_launchEnv; +} + +void Game::setLaunchEnv(const QVariantMap &env) +{ + if (m_launchEnv != env) { + m_launchEnv = env; + Q_EMIT launchEnvChanged(); + } +} + +QString Game::launchRunner() const +{ + return m_launchRunner; +} + +void Game::setLaunchRunner(const QString &runner) +{ + if (m_launchRunner != runner) { + m_launchRunner = runner; + Q_EMIT launchRunnerChanged(); + } +} + +QString Game::launchRunnerPath() const +{ + return m_launchRunnerPath; +} + +void Game::setLaunchRunnerPath(const QString &path) +{ + if (m_launchRunnerPath != path) { + m_launchRunnerPath = path; + Q_EMIT launchRunnerPathChanged(); + } +} + +QString Game::launchPrefixPath() const +{ + return m_launchPrefixPath; +} + +void Game::setLaunchPrefixPath(const QString &path) +{ + if (m_launchPrefixPath != path) { + m_launchPrefixPath = path; + Q_EMIT launchPrefixPathChanged(); + } +} + QString Game::platform() const { return m_platform; @@ -268,6 +322,32 @@ QJsonObject Game::toJson() const obj[QStringLiteral("favorite")] = m_favorite; obj[QStringLiteral("hidden")] = m_hidden; obj[QStringLiteral("installed")] = m_installed; + + const bool hasLaunchConfig = !m_launchEnv.isEmpty() || !m_launchRunner.isEmpty() || !m_launchRunnerPath.isEmpty() || !m_launchPrefixPath.isEmpty(); + if (hasLaunchConfig) { + QJsonObject launchObj; + + if (!m_launchEnv.isEmpty()) { + QJsonObject envObj; + for (auto it = m_launchEnv.constBegin(); it != m_launchEnv.constEnd(); ++it) { + envObj.insert(it.key(), QJsonValue::fromVariant(it.value())); + } + launchObj.insert(QStringLiteral("env"), envObj); + } + + if (!m_launchRunner.isEmpty()) { + launchObj.insert(QStringLiteral("runner"), m_launchRunner); + } + if (!m_launchRunnerPath.isEmpty()) { + launchObj.insert(QStringLiteral("runnerPath"), m_launchRunnerPath); + } + if (!m_launchPrefixPath.isEmpty()) { + launchObj.insert(QStringLiteral("prefixPath"), m_launchPrefixPath); + } + + obj.insert(QStringLiteral("launch"), launchObj); + } + return obj; } @@ -276,26 +356,85 @@ Game *Game::fromJson(const QJsonObject &json, QObject *parent) QString id = json[QStringLiteral("id")].toString(); QString name = json[QStringLiteral("name")].toString(); - if (id.isEmpty() || name.isEmpty()) { + if (name.isEmpty()) { return nullptr; } + const QString platformId = json[QStringLiteral("platformId")].toString(); + const QString platform = json[QStringLiteral("platform")].toString(); + const QString launchCommand = json[QStringLiteral("launchCommand")].toString(); + const QString workingDirectory = json[QStringLiteral("workingDirectory")].toString(); + if (id.isEmpty()) { + const QByteArray fingerprint = + (platformId + QLatin1Char('|') + platform + QLatin1Char('|') + name + QLatin1Char('|') + launchCommand + QLatin1Char('|') + workingDirectory) + .toUtf8(); + const QByteArray hash = QCryptographicHash::hash(fingerprint, QCryptographicHash::Sha1).toHex(); + id = QStringLiteral("legacy-") + QString::fromLatin1(hash); + } + Game *game = new Game(id, name, parent); game->setDescription(json[QStringLiteral("description")].toString()); game->setDeveloper(json[QStringLiteral("developer")].toString()); game->setPublisher(json[QStringLiteral("publisher")].toString()); game->setCoverUrl(QUrl(json[QStringLiteral("coverUrl")].toString())); game->setIconUrl(QUrl(json[QStringLiteral("iconUrl")].toString())); - game->setLaunchCommand(json[QStringLiteral("launchCommand")].toString()); - game->setWorkingDirectory(json[QStringLiteral("workingDirectory")].toString()); - game->setPlatform(json[QStringLiteral("platform")].toString()); - game->setPlatformId(json[QStringLiteral("platformId")].toString()); + game->setLaunchCommand(launchCommand); + game->setWorkingDirectory(workingDirectory); + game->setPlatform(platform); + game->setPlatformId(platformId); game->setDateAdded(QDateTime::fromString(json[QStringLiteral("dateAdded")].toString(), Qt::ISODate)); game->setLastPlayed(QDateTime::fromString(json[QStringLiteral("lastPlayed")].toString(), Qt::ISODate)); game->setPlayTime(json[QStringLiteral("playTime")].toInteger()); game->setFavorite(json[QStringLiteral("favorite")].toBool()); game->setHidden(json[QStringLiteral("hidden")].toBool()); game->setInstalled(json[QStringLiteral("installed")].toBool(true)); + + QVariantMap env; + QString runner; + QString runnerPath; + QString prefixPath; + const QJsonValue launchValue = json.value(QStringLiteral("launch")); + if (launchValue.isObject()) { + const QJsonObject launchObj = launchValue.toObject(); + const QJsonValue envValue = launchObj.value(QStringLiteral("env")); + if (envValue.isObject()) { + env = envValue.toObject().toVariantMap(); + } + + runner = launchObj.value(QStringLiteral("runner")).toString(); + runnerPath = launchObj.value(QStringLiteral("runnerPath")).toString(); + prefixPath = launchObj.value(QStringLiteral("prefixPath")).toString(); + } + + const QJsonValue legacyEnvValue = json.value(QStringLiteral("launchEnv")); + if (env.isEmpty() && legacyEnvValue.isObject()) { + env = legacyEnvValue.toObject().toVariantMap(); + } + + if (runner.isEmpty()) { + runner = json.value(QStringLiteral("launchRunner")).toString(); + } + if (runnerPath.isEmpty()) { + runnerPath = json.value(QStringLiteral("launchRunnerPath")).toString(); + } + if (prefixPath.isEmpty()) { + prefixPath = json.value(QStringLiteral("launchPrefixPath")).toString(); + } + + if (!env.isEmpty()) { + game->setLaunchEnv(env); + } + + if (!runner.isEmpty()) { + game->setLaunchRunner(runner); + } + if (!runnerPath.isEmpty()) { + game->setLaunchRunnerPath(runnerPath); + } + if (!prefixPath.isEmpty()) { + game->setLaunchPrefixPath(prefixPath); + } + return game; } diff --git a/src/game.h b/src/game.h index eed97c5..05304b8 100644 --- a/src/game.h +++ b/src/game.h @@ -9,6 +9,7 @@ #include #include #include +#include class Game : public QObject { @@ -25,6 +26,10 @@ class Game : public QObject Q_PROPERTY(QUrl iconUrl READ iconUrl WRITE setIconUrl NOTIFY iconUrlChanged) Q_PROPERTY(QString launchCommand READ launchCommand WRITE setLaunchCommand NOTIFY launchCommandChanged) Q_PROPERTY(QString workingDirectory READ workingDirectory WRITE setWorkingDirectory NOTIFY workingDirectoryChanged) + Q_PROPERTY(QVariantMap launchEnv READ launchEnv WRITE setLaunchEnv NOTIFY launchEnvChanged) + Q_PROPERTY(QString launchRunner READ launchRunner WRITE setLaunchRunner NOTIFY launchRunnerChanged) + Q_PROPERTY(QString launchRunnerPath READ launchRunnerPath WRITE setLaunchRunnerPath NOTIFY launchRunnerPathChanged) + Q_PROPERTY(QString launchPrefixPath READ launchPrefixPath WRITE setLaunchPrefixPath NOTIFY launchPrefixPathChanged) Q_PROPERTY(QString platform READ platform WRITE setPlatform NOTIFY platformChanged) Q_PROPERTY(QString platformId READ platformId WRITE setPlatformId NOTIFY platformIdChanged) Q_PROPERTY(QDateTime dateAdded READ dateAdded WRITE setDateAdded NOTIFY dateAddedChanged) @@ -65,6 +70,18 @@ public: QString workingDirectory() const; void setWorkingDirectory(const QString &dir); + QVariantMap launchEnv() const; + void setLaunchEnv(const QVariantMap &env); + + QString launchRunner() const; + void setLaunchRunner(const QString &runner); + + QString launchRunnerPath() const; + void setLaunchRunnerPath(const QString &path); + + QString launchPrefixPath() const; + void setLaunchPrefixPath(const QString &path); + QString platform() const; void setPlatform(const QString &platform); @@ -108,6 +125,10 @@ Q_SIGNALS: void iconUrlChanged(); void launchCommandChanged(); void workingDirectoryChanged(); + void launchEnvChanged(); + void launchRunnerChanged(); + void launchRunnerPathChanged(); + void launchPrefixPathChanged(); void platformChanged(); void platformIdChanged(); void dateAddedChanged(); @@ -128,6 +149,10 @@ private: QUrl m_iconUrl; QString m_launchCommand; QString m_workingDirectory; + QVariantMap m_launchEnv; + QString m_launchRunner; + QString m_launchRunnerPath; + QString m_launchPrefixPath; QString m_platform; QString m_platformId; QDateTime m_dateAdded; diff --git a/src/gamelauncher.cpp b/src/gamelauncher.cpp index a653f81..8afe8e6 100644 --- a/src/gamelauncher.cpp +++ b/src/gamelauncher.cpp @@ -7,9 +7,107 @@ #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) { @@ -46,6 +144,12 @@ void GameLauncher::launchGame(Game *game) 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")); @@ -53,7 +157,7 @@ void GameLauncher::launchGame(Game *game) } // Handle Steam URLs - if (command.startsWith(QLatin1String("steam://"))) { + if (!hasLaunchOverrides && command.startsWith(QLatin1String("steam://"))) { QDesktopServices::openUrl(QUrl(command)); game->setLastPlayed(QDateTime::currentDateTime()); Q_EMIT gameStarted(game); @@ -61,7 +165,7 @@ void GameLauncher::launchGame(Game *game) } // Handle Lutris URLs - if (command.startsWith(QLatin1String("lutris "))) { + if (!hasLaunchOverrides && command.startsWith(QLatin1String("lutris "))) { QString lutrisCommand = command.mid(7); // Remove "lutris " QDesktopServices::openUrl(QUrl(lutrisCommand)); game->setLastPlayed(QDateTime::currentDateTime()); @@ -72,6 +176,21 @@ void GameLauncher::launchGame(Game *game) // 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()); @@ -84,7 +203,14 @@ void GameLauncher::launchGame(Game *game) m_processToGame.insert(process, game); // Parse command and arguments - QStringList parts = QProcess::splitCommand(command); + 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")); @@ -92,7 +218,89 @@ void GameLauncher::launchGame(Game *game) } QString program = parts.takeFirst(); - process->start(program, parts); + + 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(); diff --git a/src/qml/GameEditDialog.qml b/src/qml/GameEditDialog.qml index 19ee729..ebf6090 100644 --- a/src/qml/GameEditDialog.qml +++ b/src/qml/GameEditDialog.qml @@ -15,6 +15,10 @@ Kirigami.Dialog { property var game: null property bool isEditing: game !== null + ListModel { + id: envModel + } + signal gameCreated(var game) signal gameUpdated(var game) @@ -31,13 +35,24 @@ Kirigami.Dialog { Kirigami.Action { text: isEditing ? i18n("Apply") : i18n("Add") icon.name: isEditing ? "dialog-ok-apply" : "list-add" - enabled: nameField.text.trim() !== "" && executableField.text.trim() !== "" + enabled: nameField.text.trim() !== "" && executableField.text.trim() !== "" && (runnerCombo.currentIndex !== 3 || runnerPathField.text.trim() !== "") onTriggered: { + let runnerValue = dialog.runnerFromIndex(runnerCombo.currentIndex) + let runnerPathValue = runnerPathField.text.trim() + let prefixPathValue = prefixPathField.text.trim() + if (runnerValue === "") { + runnerPathValue = "" + prefixPathValue = "" + } if (isEditing) { game.name = nameField.text.trim() game.developer = developerField.text.trim() game.launchCommand = executableField.text.trim() game.workingDirectory = workingDirField.text.trim() + game.launchEnv = dialog.envModelToMap() + game.launchRunner = runnerValue + game.launchRunnerPath = runnerPathValue + game.launchPrefixPath = prefixPathValue if (selectedCoverPath !== "") { App.setCoverFromFile(game, selectedCoverPath) } @@ -48,6 +63,10 @@ Kirigami.Dialog { if (newGame) { newGame.developer = developerField.text.trim() newGame.workingDirectory = workingDirField.text.trim() + newGame.launchEnv = dialog.envModelToMap() + newGame.launchRunner = runnerValue + newGame.launchRunnerPath = runnerPathValue + newGame.launchPrefixPath = prefixPathValue if (selectedCoverPath !== "") { App.setCoverFromFile(newGame, selectedCoverPath) } @@ -109,18 +128,75 @@ Kirigami.Dialog { } } + function envModelToMap() { + let result = ({}) + for (let i = 0; i < envModel.count; i++) { + let row = envModel.get(i) + let k = (row.key || "").trim() + if (k === "") continue + if (k.indexOf("=") !== -1) continue + result[k] = String(row.value || "") + } + return result + } + + function setEnvVar(key, value) { + let k = (key || "").trim() + if (k === "") return + for (let i = 0; i < envModel.count; i++) { + let row = envModel.get(i) + if ((row.key || "").trim() === k) { + envModel.setProperty(i, "value", String(value || "")) + return + } + } + envModel.append({ key: k, value: String(value || "") }) + } + + function runnerFromIndex(idx) { + if (idx === 1) return "wine" + if (idx === 2) return "proton" + if (idx === 3) return "custom" + return "" + } + + function runnerToIndex(runner) { + let r = (runner || "").trim().toLowerCase() + if (r === "wine") return 1 + if (r === "proton") return 2 + if (r === "custom") return 3 + return 0 + } + function loadFields() { selectedCoverPath = "" + envModel.clear() if (isEditing && game) { nameField.text = game.name || "" developerField.text = game.developer || "" executableField.text = game.launchCommand || "" workingDirField.text = game.workingDirectory || "" + + runnerCombo.currentIndex = dialog.runnerToIndex(game.launchRunner) + runnerPathField.text = game.launchRunnerPath || "" + prefixPathField.text = game.launchPrefixPath || "" + + let env = game.launchEnv || ({}) + let keys = Object.keys(env) + keys.sort() + for (let i = 0; i < keys.length; i++) { + let k = keys[i] + envModel.append({ key: k, value: String(env[k]) }) + } } else { nameField.text = "" developerField.text = "" executableField.text = "" workingDirField.text = "" + + runnerCombo.currentIndex = 0 + runnerPathField.text = "" + prefixPathField.text = "" } } @@ -215,6 +291,168 @@ Kirigami.Dialog { text: "" placeholderText: i18n("Optional") } + + FormCard.FormDelegateSeparator {} + + FormCard.FormCard { + Layout.fillWidth: true + Layout.topMargin: Kirigami.Units.largeSpacing + + FormCard.FormHeader { + title: i18n("Compatibility") + } + + Item { + Layout.fillWidth: true + Layout.margins: Kirigami.Units.largeSpacing + + RowLayout { + anchors.left: parent.left + anchors.right: parent.right + spacing: Kirigami.Units.largeSpacing + + QQC2.Label { + text: i18n("Runner") + Layout.alignment: Qt.AlignVCenter + } + + QQC2.ComboBox { + id: runnerCombo + Layout.fillWidth: true + model: [i18n("Native"), i18n("Wine"), i18n("Proton"), i18n("Custom")] + } + } + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormTextFieldDelegate { + id: runnerPathField + label: i18n("Runner Path") + text: "" + placeholderText: runnerCombo.currentIndex === 2 ? i18n("Auto-detect Proton") : (runnerCombo.currentIndex === 1 ? i18n("Use system Wine") : i18n("Required for Custom")) + enabled: runnerCombo.currentIndex !== 0 + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormButtonDelegate { + text: i18n("Browse Runner...") + icon.name: "document-open" + enabled: runnerCombo.currentIndex !== 0 + onClicked: runnerFileDialog.open() + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormTextFieldDelegate { + id: prefixPathField + label: i18n("Prefix Path") + text: "" + placeholderText: i18n("Default (AppDataLocation/prefixes/)") + enabled: runnerCombo.currentIndex === 1 || runnerCombo.currentIndex === 2 + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormButtonDelegate { + text: i18n("Browse Prefix...") + icon.name: "document-open-folder" + enabled: runnerCombo.currentIndex === 1 || runnerCombo.currentIndex === 2 + onClicked: prefixFolderDialog.open() + } + } + + FormCard.FormCard { + Layout.fillWidth: true + Layout.topMargin: Kirigami.Units.largeSpacing + + FormCard.FormHeader { + title: i18n("Environment Variables") + } + + FormCard.FormTextDelegate { + text: i18n("These variables are applied only when launching this game from A-La-Karte.") + textItem.wrapMode: Text.WordWrap + textItem.font: Kirigami.Theme.smallFont + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormButtonDelegate { + text: i18n("Add Variable") + icon.name: "list-add" + onClicked: envModel.append({ key: "", value: "" }) + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormButtonDelegate { + text: i18n("Preset: MangoHud") + description: i18n("Sets MANGOHUD=1") + icon.name: "games-hint" + onClicked: dialog.setEnvVar("MANGOHUD", "1") + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormButtonDelegate { + text: i18n("Preset: GameMode") + description: i18n("Sets GAMEMODERUN=1") + icon.name: "speedometer" + onClicked: dialog.setEnvVar("GAMEMODERUN", "1") + } + + FormCard.FormDelegateSeparator {} + + FormCard.FormButtonDelegate { + text: i18n("Clear Variables") + icon.name: "edit-clear" + enabled: envModel.count > 0 + onClicked: envModel.clear() + } + + Item { + Layout.fillWidth: true + Layout.margins: Kirigami.Units.largeSpacing + + ColumnLayout { + anchors.left: parent.left + anchors.right: parent.right + spacing: Kirigami.Units.smallSpacing + + Repeater { + model: envModel + + RowLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + + QQC2.TextField { + Layout.fillWidth: true + Layout.preferredWidth: 1 + placeholderText: i18n("KEY") + text: key + onTextChanged: envModel.setProperty(index, "key", text) + } + + QQC2.TextField { + Layout.fillWidth: true + Layout.preferredWidth: 2 + placeholderText: i18n("VALUE") + text: value + onTextChanged: envModel.setProperty(index, "value", text) + } + + QQC2.ToolButton { + icon.name: "list-remove" + onClicked: envModel.remove(index) + } + } + } + } + } + } } FormCard.FormCard { @@ -339,6 +577,23 @@ Kirigami.Dialog { } } + FileDialog { + id: runnerFileDialog + title: i18n("Select Runner Executable") + fileMode: FileDialog.OpenFile + onAccepted: { + runnerPathField.text = selectedFile.toString().replace("file://", "") + } + } + + FolderDialog { + id: prefixFolderDialog + title: i18n("Select Prefix Folder") + onAccepted: { + prefixPathField.text = selectedFolder.toString().replace("file://", "") + } + } + function saveCoverImage(game) { if (selectedCoverPath === "") return