From ec7d61e5b929bb33f5912af0181a7781419c85f4 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Fri, 30 Jan 2026 13:44:12 +0100 Subject: [PATCH 1/4] Add launch profiles MVP Store per-game launch profile overrides and active profile selection in library.json. Add a quick profile toggle in the details sheet and minimal editing in the edit dialog. Apply the effective profile config in both launching and diagnostics. --- src/game.cpp | 227 ++++++++++++++++++++++++++++++++++- src/game.h | 11 ++ src/gamelauncher.cpp | 18 +-- src/qml/GameDetailsSheet.qml | 11 ++ src/qml/GameEditDialog.qml | 117 +++++++++++++++--- 5 files changed, 356 insertions(+), 28 deletions(-) diff --git a/src/game.cpp b/src/game.cpp index 32993c3..22b7362 100644 --- a/src/game.cpp +++ b/src/game.cpp @@ -185,6 +185,129 @@ void Game::setLaunchPrefixPath(const QString &path) } } +QString Game::activeLaunchProfile() const +{ + return m_activeLaunchProfile; +} + +void Game::setActiveLaunchProfile(const QString &profileId) +{ + const QString normalized = profileId.trimmed().isEmpty() ? QStringLiteral("default") : profileId.trimmed(); + if (m_activeLaunchProfile != normalized) { + m_activeLaunchProfile = normalized; + Q_EMIT activeLaunchProfileChanged(); + } +} + +QVariantMap Game::launchProfileConfig(const QString &profileId) const +{ + const QString id = profileId.trimmed(); + if (id.isEmpty() || id == QLatin1String("default")) { + return {}; + } + + const QVariant value = m_launchProfiles.value(id); + if (!value.isValid()) { + return {}; + } + + return value.toMap(); +} + +void Game::setLaunchProfileConfig(const QString &profileId, const QVariantMap &config) +{ + const QString id = profileId.trimmed(); + if (id.isEmpty() || id == QLatin1String("default")) { + return; + } + + QVariantMap cleaned; + + const QString runner = config.value(QStringLiteral("runner")).toString().trimmed(); + if (!runner.isEmpty()) { + cleaned.insert(QStringLiteral("runner"), runner); + } + + const QString runnerPath = config.value(QStringLiteral("runnerPath")).toString().trimmed(); + if (!runnerPath.isEmpty()) { + cleaned.insert(QStringLiteral("runnerPath"), runnerPath); + } + + const QString prefixPath = config.value(QStringLiteral("prefixPath")).toString().trimmed(); + if (!prefixPath.isEmpty()) { + cleaned.insert(QStringLiteral("prefixPath"), prefixPath); + } + + const QVariantMap env = config.value(QStringLiteral("env")).toMap(); + if (!env.isEmpty()) { + cleaned.insert(QStringLiteral("env"), env); + } + + if (cleaned.isEmpty()) { + m_launchProfiles.remove(id); + } else { + m_launchProfiles.insert(id, cleaned); + } +} + +QVariantMap Game::effectiveLaunchConfig() const +{ + QVariantMap result; + + if (!m_launchRunner.isEmpty()) { + result.insert(QStringLiteral("runner"), m_launchRunner); + } + if (!m_launchRunnerPath.isEmpty()) { + result.insert(QStringLiteral("runnerPath"), m_launchRunnerPath); + } + if (!m_launchPrefixPath.isEmpty()) { + result.insert(QStringLiteral("prefixPath"), m_launchPrefixPath); + } + if (!m_launchEnv.isEmpty()) { + result.insert(QStringLiteral("env"), m_launchEnv); + } + + const QString profileId = m_activeLaunchProfile.trimmed(); + if (profileId.isEmpty() || profileId == QLatin1String("default")) { + return result; + } + + const QVariantMap profileConfig = launchProfileConfig(profileId); + if (profileConfig.isEmpty()) { + return result; + } + + const QString runner = profileConfig.value(QStringLiteral("runner")).toString().trimmed(); + if (!runner.isEmpty()) { + result.insert(QStringLiteral("runner"), runner); + } + + const QString runnerPath = profileConfig.value(QStringLiteral("runnerPath")).toString().trimmed(); + if (!runnerPath.isEmpty()) { + result.insert(QStringLiteral("runnerPath"), runnerPath); + } + + const QString prefixPath = profileConfig.value(QStringLiteral("prefixPath")).toString().trimmed(); + if (!prefixPath.isEmpty()) { + result.insert(QStringLiteral("prefixPath"), prefixPath); + } + + const QVariantMap profileEnv = profileConfig.value(QStringLiteral("env")).toMap(); + if (!profileEnv.isEmpty()) { + QVariantMap mergedEnv; + const QVariantMap baseEnv = result.value(QStringLiteral("env")).toMap(); + for (auto it = baseEnv.constBegin(); it != baseEnv.constEnd(); ++it) { + mergedEnv.insert(it.key(), it.value()); + } + for (auto it = profileEnv.constBegin(); it != profileEnv.constEnd(); ++it) { + mergedEnv.insert(it.key(), it.value()); + } + result.insert(QStringLiteral("env"), mergedEnv); + } + + return result; +} + QString Game::platform() const { return m_platform; @@ -323,7 +446,8 @@ QJsonObject Game::toJson() const 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(); + const bool hasLaunchConfig = !m_launchEnv.isEmpty() || !m_launchRunner.isEmpty() || !m_launchRunnerPath.isEmpty() || !m_launchPrefixPath.isEmpty() + || !m_launchProfiles.isEmpty() || (!m_activeLaunchProfile.isEmpty() && m_activeLaunchProfile != QLatin1String("default")); if (hasLaunchConfig) { QJsonObject launchObj; @@ -345,6 +469,57 @@ QJsonObject Game::toJson() const launchObj.insert(QStringLiteral("prefixPath"), m_launchPrefixPath); } + if (!m_activeLaunchProfile.isEmpty() && m_activeLaunchProfile != QLatin1String("default")) { + launchObj.insert(QStringLiteral("activeProfile"), m_activeLaunchProfile); + } + + if (!m_launchProfiles.isEmpty()) { + QJsonObject profilesObj; + for (auto it = m_launchProfiles.constBegin(); it != m_launchProfiles.constEnd(); ++it) { + const QString profileId = it.key(); + if (profileId.isEmpty() || profileId == QLatin1String("default")) { + continue; + } + + const QVariantMap profileConfig = it.value().toMap(); + if (profileConfig.isEmpty()) { + continue; + } + + QJsonObject profileObj; + + const QString runner = profileConfig.value(QStringLiteral("runner")).toString(); + if (!runner.isEmpty()) { + profileObj.insert(QStringLiteral("runner"), runner); + } + const QString runnerPath = profileConfig.value(QStringLiteral("runnerPath")).toString(); + if (!runnerPath.isEmpty()) { + profileObj.insert(QStringLiteral("runnerPath"), runnerPath); + } + const QString prefixPath = profileConfig.value(QStringLiteral("prefixPath")).toString(); + if (!prefixPath.isEmpty()) { + profileObj.insert(QStringLiteral("prefixPath"), prefixPath); + } + + const QVariantMap env = profileConfig.value(QStringLiteral("env")).toMap(); + if (!env.isEmpty()) { + QJsonObject envObj; + for (auto envIt = env.constBegin(); envIt != env.constEnd(); ++envIt) { + envObj.insert(envIt.key(), QJsonValue::fromVariant(envIt.value())); + } + profileObj.insert(QStringLiteral("env"), envObj); + } + + if (!profileObj.isEmpty()) { + profilesObj.insert(profileId, profileObj); + } + } + + if (!profilesObj.isEmpty()) { + launchObj.insert(QStringLiteral("profiles"), profilesObj); + } + } + obj.insert(QStringLiteral("launch"), launchObj); } @@ -393,6 +568,7 @@ Game *Game::fromJson(const QJsonObject &json, QObject *parent) QString runner; QString runnerPath; QString prefixPath; + QString activeProfile; const QJsonValue launchValue = json.value(QStringLiteral("launch")); if (launchValue.isObject()) { const QJsonObject launchObj = launchValue.toObject(); @@ -404,6 +580,51 @@ Game *Game::fromJson(const QJsonObject &json, QObject *parent) runner = launchObj.value(QStringLiteral("runner")).toString(); runnerPath = launchObj.value(QStringLiteral("runnerPath")).toString(); prefixPath = launchObj.value(QStringLiteral("prefixPath")).toString(); + + activeProfile = launchObj.value(QStringLiteral("activeProfile")).toString(); + + const QJsonValue profilesValue = launchObj.value(QStringLiteral("profiles")); + if (profilesValue.isObject()) { + const QJsonObject profilesObj = profilesValue.toObject(); + for (auto it = profilesObj.constBegin(); it != profilesObj.constEnd(); ++it) { + if (!it.value().isObject()) { + continue; + } + + const QString profileId = it.key(); + if (profileId.isEmpty() || profileId == QLatin1String("default")) { + continue; + } + + const QJsonObject profileObj = it.value().toObject(); + QVariantMap profileConfig; + + const QString pRunner = profileObj.value(QStringLiteral("runner")).toString(); + if (!pRunner.isEmpty()) { + profileConfig.insert(QStringLiteral("runner"), pRunner); + } + const QString pRunnerPath = profileObj.value(QStringLiteral("runnerPath")).toString(); + if (!pRunnerPath.isEmpty()) { + profileConfig.insert(QStringLiteral("runnerPath"), pRunnerPath); + } + const QString pPrefixPath = profileObj.value(QStringLiteral("prefixPath")).toString(); + if (!pPrefixPath.isEmpty()) { + profileConfig.insert(QStringLiteral("prefixPath"), pPrefixPath); + } + + const QJsonValue pEnvValue = profileObj.value(QStringLiteral("env")); + if (pEnvValue.isObject()) { + const QVariantMap pEnv = pEnvValue.toObject().toVariantMap(); + if (!pEnv.isEmpty()) { + profileConfig.insert(QStringLiteral("env"), pEnv); + } + } + + if (!profileConfig.isEmpty()) { + game->setLaunchProfileConfig(profileId, profileConfig); + } + } + } } const QJsonValue legacyEnvValue = json.value(QStringLiteral("launchEnv")); @@ -435,6 +656,10 @@ Game *Game::fromJson(const QJsonObject &json, QObject *parent) game->setLaunchPrefixPath(prefixPath); } + if (!activeProfile.isEmpty()) { + game->setActiveLaunchProfile(activeProfile); + } + return game; } diff --git a/src/game.h b/src/game.h index 05304b8..7d02266 100644 --- a/src/game.h +++ b/src/game.h @@ -30,6 +30,7 @@ class Game : public QObject 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 activeLaunchProfile READ activeLaunchProfile WRITE setActiveLaunchProfile NOTIFY activeLaunchProfileChanged) 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) @@ -82,6 +83,13 @@ public: QString launchPrefixPath() const; void setLaunchPrefixPath(const QString &path); + QString activeLaunchProfile() const; + void setActiveLaunchProfile(const QString &profileId); + + Q_INVOKABLE QVariantMap launchProfileConfig(const QString &profileId) const; + Q_INVOKABLE void setLaunchProfileConfig(const QString &profileId, const QVariantMap &config); + QVariantMap effectiveLaunchConfig() const; + QString platform() const; void setPlatform(const QString &platform); @@ -129,6 +137,7 @@ Q_SIGNALS: void launchRunnerChanged(); void launchRunnerPathChanged(); void launchPrefixPathChanged(); + void activeLaunchProfileChanged(); void platformChanged(); void platformIdChanged(); void dateAddedChanged(); @@ -153,6 +162,8 @@ private: QString m_launchRunner; QString m_launchRunnerPath; QString m_launchPrefixPath; + QString m_activeLaunchProfile = QStringLiteral("default"); + QVariantMap m_launchProfiles; QString m_platform; QString m_platformId; QDateTime m_dateAdded; diff --git a/src/gamelauncher.cpp b/src/gamelauncher.cpp index eeb9c5b..af90fe9 100644 --- a/src/gamelauncher.cpp +++ b/src/gamelauncher.cpp @@ -153,10 +153,11 @@ QVariantMap GameLauncher::resolveLaunchInfo(Game *game) const 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 QVariantMap effectiveLaunchConfig = game->effectiveLaunchConfig(); + const QString runner = effectiveLaunchConfig.value(QStringLiteral("runner")).toString().trimmed(); + const QString runnerPath = effectiveLaunchConfig.value(QStringLiteral("runnerPath")).toString().trimmed(); + const QString prefixPath = effectiveLaunchConfig.value(QStringLiteral("prefixPath")).toString().trimmed(); + const QVariantMap launchEnv = effectiveLaunchConfig.value(QStringLiteral("env")).toMap(); const bool hasLaunchOverrides = !runner.isEmpty() || !runnerPath.isEmpty() || !prefixPath.isEmpty() || !launchEnv.isEmpty(); info.insert(QStringLiteral("runner"), runner); @@ -297,10 +298,11 @@ 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 QVariantMap effectiveLaunchConfig = game->effectiveLaunchConfig(); + const QString runner = effectiveLaunchConfig.value(QStringLiteral("runner")).toString().trimmed(); + const QString runnerPath = effectiveLaunchConfig.value(QStringLiteral("runnerPath")).toString().trimmed(); + const QString prefixPath = effectiveLaunchConfig.value(QStringLiteral("prefixPath")).toString().trimmed(); + const QVariantMap launchEnv = effectiveLaunchConfig.value(QStringLiteral("env")).toMap(); const bool hasLaunchOverrides = !runner.isEmpty() || !runnerPath.isEmpty() || !prefixPath.isEmpty() || !launchEnv.isEmpty(); // Check if already running diff --git a/src/qml/GameDetailsSheet.qml b/src/qml/GameDetailsSheet.qml index 9f0c68d..4bb593f 100644 --- a/src/qml/GameDetailsSheet.qml +++ b/src/qml/GameDetailsSheet.qml @@ -418,6 +418,17 @@ Kirigami.OverlaySheet { onClicked: detailsSheet.editRequested() } + QQC2.Button { + icon.name: "preferences-other" + text: game && game.activeLaunchProfile === "couch" ? i18n("Profile: Couch") : i18n("Profile: Default") + display: detailsSheet.useCompactLayout ? QQC2.AbstractButton.TextUnderIcon : QQC2.AbstractButton.TextBesideIcon + enabled: !!game + onClicked: if (game) { + game.activeLaunchProfile = (game.activeLaunchProfile === "couch") ? "default" : "couch" + App.saveLibrary() + } + } + QQC2.Button { icon.name: "dialog-information" text: i18n("Diagnostics") diff --git a/src/qml/GameEditDialog.qml b/src/qml/GameEditDialog.qml index 4729aa4..65188a6 100644 --- a/src/qml/GameEditDialog.qml +++ b/src/qml/GameEditDialog.qml @@ -14,6 +14,7 @@ Kirigami.Dialog { property var game: null property bool isEditing: game !== null + property string editProfileId: "default" ListModel { id: envModel @@ -49,10 +50,19 @@ Kirigami.Dialog { 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 (dialog.editProfileId === "couch") { + game.setLaunchProfileConfig("couch", { + "runner": runnerValue, + "runnerPath": runnerPathValue, + "prefixPath": prefixPathValue, + "env": dialog.envModelToMap() + }) + } else { + game.launchEnv = dialog.envModelToMap() + game.launchRunner = runnerValue + game.launchRunnerPath = runnerPathValue + game.launchPrefixPath = prefixPathValue + } if (selectedCoverPath !== "") { App.setCoverFromFile(game, selectedCoverPath) } @@ -86,12 +96,15 @@ Kirigami.Dialog { property string selectedCoverPath: "" - readonly property bool anyMenuOpen: runnerCombo && runnerCombo.popup && runnerCombo.popup.visible + readonly property bool anyMenuOpen: (runnerCombo && runnerCombo.popup && runnerCombo.popup.visible) || (profileCombo && profileCombo.popup && profileCombo.popup.visible) function closeCurrentMenu() { if (runnerCombo && runnerCombo.popup && runnerCombo.popup.visible) { runnerCombo.popup.close() } + if (profileCombo && profileCombo.popup && profileCombo.popup.visible) { + profileCombo.popup.close() + } } function isDescendant(item, ancestor) { @@ -181,35 +194,72 @@ Kirigami.Dialog { return 0 } - function loadFields() { - selectedCoverPath = "" + function profileIdFromIndex(idx) { + if (idx === 1) return "couch" + return "default" + } + + function profileIndexFromId(profileId) { + if ((profileId || "").trim() === "couch") return 1 + return 0 + } + + function loadProfileFields() { 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 || "" + if (!isEditing || !game) { + runnerCombo.currentIndex = 0 + runnerPathField.text = "" + prefixPathField.text = "" + return + } - let env = game.launchEnv || ({}) + if (dialog.editProfileId === "couch") { + let cfg = game.launchProfileConfig("couch") || ({}) + runnerCombo.currentIndex = dialog.runnerToIndex(cfg.runner) + runnerPathField.text = cfg.runnerPath || "" + prefixPathField.text = cfg.prefixPath || "" + let env = cfg.env || ({}) 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 { + 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]) }) + } + } + } + + function loadFields() { + selectedCoverPath = "" + if (isEditing && game) { + nameField.text = game.name || "" + developerField.text = game.developer || "" + executableField.text = game.launchCommand || "" + workingDirField.text = game.workingDirectory || "" + + dialog.editProfileId = game.activeLaunchProfile === "couch" ? "couch" : "default" + profileCombo.currentIndex = dialog.profileIndexFromId(dialog.editProfileId) + dialog.loadProfileFields() } else { nameField.text = "" developerField.text = "" executableField.text = "" workingDirField.text = "" - runnerCombo.currentIndex = 0 - runnerPathField.text = "" - prefixPathField.text = "" + dialog.editProfileId = "default" + profileCombo.currentIndex = 0 + dialog.loadProfileFields() } } @@ -325,6 +375,35 @@ Kirigami.Dialog { title: i18n("Compatibility") } + Item { + Layout.fillWidth: true + Layout.margins: Kirigami.Units.largeSpacing + visible: isEditing + + RowLayout { + anchors.left: parent.left + anchors.right: parent.right + spacing: Kirigami.Units.largeSpacing + + QQC2.Label { + text: i18n("Profile") + Layout.alignment: Qt.AlignVCenter + } + + QQC2.ComboBox { + id: profileCombo + Layout.fillWidth: true + model: [i18n("Default"), i18n("Couch")] + onCurrentIndexChanged: if (dialog.isEditing) { + dialog.editProfileId = dialog.profileIdFromIndex(currentIndex) + dialog.loadProfileFields() + } + } + } + } + + FormCard.FormDelegateSeparator { visible: isEditing } + Item { Layout.fillWidth: true Layout.margins: Kirigami.Units.largeSpacing From 365c3b31cb2e5516151631844397a2b4c310eb5b Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Fri, 30 Jan 2026 14:07:58 +0100 Subject: [PATCH 2/4] Use global couch mode for profiles Select the couch launch profile based on effective UI mode so profile choice is consistent across games. Update details and edit UI to toggle and reflect couch mode globally, and fix duplicate QML property declarations. --- src/game.cpp | 11 ++++++++--- src/game.h | 1 + src/gamelauncher.cpp | 35 +++++++++++++++++++++++++++++++++-- src/qml/GameDetailsSheet.qml | 32 +++++++++++++++++++++++++------- src/qml/GameEditDialog.qml | 7 ++++++- 5 files changed, 73 insertions(+), 13 deletions(-) diff --git a/src/game.cpp b/src/game.cpp index 22b7362..964222a 100644 --- a/src/game.cpp +++ b/src/game.cpp @@ -251,6 +251,11 @@ void Game::setLaunchProfileConfig(const QString &profileId, const QVariantMap &c } QVariantMap Game::effectiveLaunchConfig() const +{ + return effectiveLaunchConfigForProfile(m_activeLaunchProfile); +} + +QVariantMap Game::effectiveLaunchConfigForProfile(const QString &profileId) const { QVariantMap result; @@ -267,12 +272,12 @@ QVariantMap Game::effectiveLaunchConfig() const result.insert(QStringLiteral("env"), m_launchEnv); } - const QString profileId = m_activeLaunchProfile.trimmed(); - if (profileId.isEmpty() || profileId == QLatin1String("default")) { + const QString id = profileId.trimmed(); + if (id.isEmpty() || id == QLatin1String("default")) { return result; } - const QVariantMap profileConfig = launchProfileConfig(profileId); + const QVariantMap profileConfig = launchProfileConfig(id); if (profileConfig.isEmpty()) { return result; } diff --git a/src/game.h b/src/game.h index 7d02266..00ce69f 100644 --- a/src/game.h +++ b/src/game.h @@ -88,6 +88,7 @@ public: Q_INVOKABLE QVariantMap launchProfileConfig(const QString &profileId) const; Q_INVOKABLE void setLaunchProfileConfig(const QString &profileId, const QVariantMap &config); + QVariantMap effectiveLaunchConfigForProfile(const QString &profileId) const; QVariantMap effectiveLaunchConfig() const; QString platform() const; diff --git a/src/gamelauncher.cpp b/src/gamelauncher.cpp index af90fe9..53cd5e4 100644 --- a/src/gamelauncher.cpp +++ b/src/gamelauncher.cpp @@ -3,6 +3,8 @@ #include "gamelauncher.h" #include "app.h" +#include "gamepadmanager.h" +#include "inputmanager.h" #include #include @@ -108,6 +110,33 @@ static QString discoverDefaultProtonExecutable() return cached; } +static QString profileIdForCurrentUiMode() +{ + const Config *config = App::instance() ? App::instance()->config() : nullptr; + if (!config) { + return QStringLiteral("default"); + } + + if (config->uiMode() == Config::Couch) { + return QStringLiteral("couch"); + } + + if (config->uiMode() == Config::Auto) { + GamepadManager *pad = GamepadManager::instance(); + InputManager *input = InputManager::instance(); + + if (pad && pad->connected()) { + const bool activeGamepad = input && input->activeInput() == InputManager::Gamepad; + const bool noKeyboardMouse = input && !input->hasSeenKeyboardMouse(); + if (activeGamepad || noKeyboardMouse) { + return QStringLiteral("couch"); + } + } + } + + return QStringLiteral("default"); +} + GameLauncher::GameLauncher(QObject *parent) : QObject(parent) { @@ -153,7 +182,8 @@ QVariantMap GameLauncher::resolveLaunchInfo(Game *game) const return info; } - const QVariantMap effectiveLaunchConfig = game->effectiveLaunchConfig(); + const QString profileId = profileIdForCurrentUiMode(); + const QVariantMap effectiveLaunchConfig = game->effectiveLaunchConfigForProfile(profileId); const QString runner = effectiveLaunchConfig.value(QStringLiteral("runner")).toString().trimmed(); const QString runnerPath = effectiveLaunchConfig.value(QStringLiteral("runnerPath")).toString().trimmed(); const QString prefixPath = effectiveLaunchConfig.value(QStringLiteral("prefixPath")).toString().trimmed(); @@ -298,7 +328,8 @@ void GameLauncher::launchGame(Game *game) return; } - const QVariantMap effectiveLaunchConfig = game->effectiveLaunchConfig(); + const QString profileId = profileIdForCurrentUiMode(); + const QVariantMap effectiveLaunchConfig = game->effectiveLaunchConfigForProfile(profileId); const QString runner = effectiveLaunchConfig.value(QStringLiteral("runner")).toString().trimmed(); const QString runnerPath = effectiveLaunchConfig.value(QStringLiteral("runnerPath")).toString().trimmed(); const QString prefixPath = effectiveLaunchConfig.value(QStringLiteral("prefixPath")).toString().trimmed(); diff --git a/src/qml/GameDetailsSheet.qml b/src/qml/GameDetailsSheet.qml index 4bb593f..f3a553d 100644 --- a/src/qml/GameDetailsSheet.qml +++ b/src/qml/GameDetailsSheet.qml @@ -12,7 +12,18 @@ import org.kde.alakarte Kirigami.OverlaySheet { id: detailsSheet - property var game + property var game: null + property int lastNonCouchUiMode: Config.Auto + + readonly property bool effectiveCouchMode: { + if (App.config.uiMode === Config.Couch) return true + if (App.config.uiMode !== Config.Auto) return false + if (GamepadManager.connected) { + if (InputManager.activeInput === InputManager.Gamepad) return true + if (!InputManager.hasSeenKeyboardMouse) return true + } + return false + } readonly property var screenshotsModel: game ? App.mediaManager.screenshotsModel(game) : null @@ -419,13 +430,20 @@ Kirigami.OverlaySheet { } QQC2.Button { - icon.name: "preferences-other" - text: game && game.activeLaunchProfile === "couch" ? i18n("Profile: Couch") : i18n("Profile: Default") + icon.name: "view-fullscreen" + text: detailsSheet.effectiveCouchMode ? i18n("Couch mode: On") : i18n("Couch mode: Off") display: detailsSheet.useCompactLayout ? QQC2.AbstractButton.TextUnderIcon : QQC2.AbstractButton.TextBesideIcon - enabled: !!game - onClicked: if (game) { - game.activeLaunchProfile = (game.activeLaunchProfile === "couch") ? "default" : "couch" - App.saveLibrary() + onClicked: { + if (detailsSheet.effectiveCouchMode) { + if (detailsSheet.lastNonCouchUiMode === Config.Auto) { + App.config.uiMode = Config.Desktop + } else { + App.config.uiMode = detailsSheet.lastNonCouchUiMode + } + } else { + detailsSheet.lastNonCouchUiMode = App.config.uiMode + App.config.uiMode = Config.Couch + } } } diff --git a/src/qml/GameEditDialog.qml b/src/qml/GameEditDialog.qml index 65188a6..8069b1c 100644 --- a/src/qml/GameEditDialog.qml +++ b/src/qml/GameEditDialog.qml @@ -248,7 +248,12 @@ Kirigami.Dialog { executableField.text = game.launchCommand || "" workingDirField.text = game.workingDirectory || "" - dialog.editProfileId = game.activeLaunchProfile === "couch" ? "couch" : "default" + dialog.editProfileId = (App.config.uiMode === Config.Couch + || (App.config.uiMode === Config.Auto + && GamepadManager.connected + && (InputManager.activeInput === InputManager.Gamepad || !InputManager.hasSeenKeyboardMouse))) + ? "couch" + : "default" profileCombo.currentIndex = dialog.profileIndexFromId(dialog.editProfileId) dialog.loadProfileFields() } else { From 245944fca9b02f696a5de3682db5200786e34804 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Tue, 3 Feb 2026 10:41:29 +0100 Subject: [PATCH 3/4] Make imports incremental and refreshable --- src/app.cpp | 450 ++++++++++++++++++++++++++++++++------- src/qml/Main.qml | 36 ++-- src/qml/SettingsPage.qml | 18 +- 3 files changed, 405 insertions(+), 99 deletions(-) diff --git a/src/app.cpp b/src/app.cpp index 72bc0c3..4016dc2 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -18,7 +18,9 @@ #include #include #include +#include #include +#include #include App *App::s_instance = nullptr; @@ -142,6 +144,12 @@ App::App(QObject *parent) } } }); + + if (m_config->autoImportOnStartup()) { + QTimer::singleShot(0, this, [this]() { + importAllGames(); + }); + } } App *App::instance() @@ -215,18 +223,17 @@ void App::importAllGames() if (m_importing) return; - const bool anyEnabled = m_config->importSteam() || m_config->importLutris() || m_config->importHeroic() || m_config->importDesktop() - || m_config->importBottles() || m_config->importFlatpak() || m_config->importItch() || m_config->importLegendary() || m_config->importRetroArch(); + const bool doSteam = m_config->importSteam(); + const bool doLutris = m_config->importLutris(); + const bool doHeroic = m_config->importHeroic(); + const bool doDesktop = m_config->importDesktop(); + const bool doBottles = m_config->importBottles(); + const bool doFlatpak = m_config->importFlatpak(); + const bool doItch = m_config->importItch(); + const bool doLegendary = m_config->importLegendary(); + const bool doRetroArch = m_config->importRetroArch(); - const bool doSteam = m_config->importSteam() && !m_gameModel->hasPlatformPrefix(QStringLiteral("Steam")); - const bool doLutris = m_config->importLutris() && !m_gameModel->hasPlatformPrefix(QStringLiteral("Lutris")); - const bool doHeroic = m_config->importHeroic() && !m_gameModel->hasPlatformPrefix(QStringLiteral("Heroic")); - const bool doDesktop = m_config->importDesktop() && !m_gameModel->hasPlatformPrefix(QStringLiteral("Desktop")); - const bool doBottles = m_config->importBottles() && !m_gameModel->hasPlatformPrefix(QStringLiteral("Bottles")); - const bool doFlatpak = m_config->importFlatpak() && !m_gameModel->hasPlatformPrefix(QStringLiteral("Flatpak")); - const bool doItch = m_config->importItch() && !m_gameModel->hasPlatformPrefix(QStringLiteral("itch.io")); - const bool doLegendary = m_config->importLegendary() && !m_gameModel->hasPlatformPrefix(QStringLiteral("Legendary")); - const bool doRetroArch = m_config->importRetroArch() && !m_gameModel->hasPlatformPrefix(QStringLiteral("RetroArch")); + const bool anyEnabled = doSteam || doLutris || doHeroic || doDesktop || doBottles || doFlatpak || doItch || doLegendary || doRetroArch; if (!anyEnabled) { setImportStatus(tr("No import sources enabled")); @@ -234,17 +241,55 @@ void App::importAllGames() return; } - if (!(doSteam || doLutris || doHeroic || doDesktop || doBottles || doFlatpak || doItch || doLegendary || doRetroArch)) { - setImportStatus(tr("All enabled sources already imported")); - Q_EMIT importCompleted(0); - return; - } + const QSet existingIds = [this]() { + QSet ids; + const QList games = m_gameModel->allGames(); + for (Game *game : games) { + if (game) { + ids.insert(game->id()); + } + } + return ids; + }(); setImporting(true); setImportStatus(tr("Importing games...")); - [[maybe_unused]] auto future = QtConcurrent::run([this, doSteam, doLutris, doHeroic, doDesktop, doBottles, doFlatpak, doItch, doLegendary, doRetroArch]() { + [[maybe_unused]] auto future = QtConcurrent::run([this, + doSteam, + doLutris, + doHeroic, + doDesktop, + doBottles, + doFlatpak, + doItch, + doLegendary, + doRetroArch, + existingIds]() { int totalCount = 0; + QSet seenIds = existingIds; + + const auto keepNewGames = [&seenIds](const QList &games) { + QList result; + result.reserve(games.size()); + + for (Game *game : games) { + if (!game) { + continue; + } + + const QString id = game->id(); + if (id.isEmpty() || seenIds.contains(id)) { + delete game; + continue; + } + + seenIds.insert(id); + result.append(game); + } + + return result; + }; // Import from Steam if (doSteam) { @@ -256,7 +301,7 @@ void App::importAllGames() Qt::QueuedConnection); SteamImporter steamImporter; - QList steamGames = steamImporter.importGames(); + QList steamGames = keepNewGames(steamImporter.importGames()); for (Game *game : steamGames) { game->moveToThread(this->thread()); game->setParent(nullptr); @@ -291,7 +336,7 @@ void App::importAllGames() Qt::QueuedConnection); LutrisImporter lutrisImporter; - QList lutrisGames = lutrisImporter.importGames(); + QList lutrisGames = keepNewGames(lutrisImporter.importGames()); for (Game *game : lutrisGames) { game->moveToThread(this->thread()); game->setParent(nullptr); @@ -326,7 +371,7 @@ void App::importAllGames() Qt::QueuedConnection); HeroicImporter heroicImporter; - QList heroicGames = heroicImporter.importGames(); + QList heroicGames = keepNewGames(heroicImporter.importGames()); for (Game *game : heroicGames) { game->moveToThread(this->thread()); game->setParent(nullptr); @@ -361,7 +406,7 @@ void App::importAllGames() Qt::QueuedConnection); DesktopImporter desktopImporter; - QList desktopGames = desktopImporter.importGames(); + QList desktopGames = keepNewGames(desktopImporter.importGames()); for (Game *game : desktopGames) { game->moveToThread(this->thread()); game->setParent(nullptr); @@ -396,7 +441,7 @@ void App::importAllGames() Qt::QueuedConnection); BottlesImporter bottlesImporter; - QList bottlesGames = bottlesImporter.importGames(); + QList bottlesGames = keepNewGames(bottlesImporter.importGames()); for (Game *game : bottlesGames) { game->moveToThread(this->thread()); game->setParent(nullptr); @@ -431,7 +476,7 @@ void App::importAllGames() Qt::QueuedConnection); FlatpakImporter flatpakImporter; - QList flatpakGames = flatpakImporter.importGames(); + QList flatpakGames = keepNewGames(flatpakImporter.importGames()); for (Game *game : flatpakGames) { game->moveToThread(this->thread()); game->setParent(nullptr); @@ -466,7 +511,7 @@ void App::importAllGames() Qt::QueuedConnection); ItchImporter itchImporter; - QList itchGames = itchImporter.importGames(); + QList itchGames = keepNewGames(itchImporter.importGames()); for (Game *game : itchGames) { game->moveToThread(this->thread()); game->setParent(nullptr); @@ -501,7 +546,7 @@ void App::importAllGames() Qt::QueuedConnection); LegendaryImporter legendaryImporter; - QList legendaryGames = legendaryImporter.importGames(); + QList legendaryGames = keepNewGames(legendaryImporter.importGames()); for (Game *game : legendaryGames) { game->moveToThread(this->thread()); game->setParent(nullptr); @@ -536,7 +581,7 @@ void App::importAllGames() Qt::QueuedConnection); RetroArchImporter retroArchImporter; - QList retroArchGames = retroArchImporter.importGames(); + QList retroArchGames = keepNewGames(retroArchImporter.importGames()); for (Game *game : retroArchGames) { game->moveToThread(this->thread()); game->setParent(nullptr); @@ -565,7 +610,7 @@ void App::importAllGames() QMetaObject::invokeMethod( this, [this, totalCount]() { - setImportStatus(tr("Import complete: %1 games found").arg(totalCount)); + setImportStatus(tr("Import complete: %1 new games found").arg(totalCount)); setImporting(false); saveLibrary(); Q_EMIT importCompleted(totalCount); @@ -579,28 +624,57 @@ void App::importFromSteam() if (m_importing) return; + const QSet existingIds = [this]() { + QSet ids; + const QList games = m_gameModel->allGames(); + for (Game *game : games) { + if (game) { + ids.insert(game->id()); + } + } + return ids; + }(); + setImporting(true); setImportStatus(tr("Scanning Steam library...")); - [[maybe_unused]] auto future = QtConcurrent::run([this]() { + [[maybe_unused]] auto future = QtConcurrent::run([this, existingIds]() { SteamImporter importer; QList games = importer.importGames(); + QSet seenIds = existingIds; + QList newGames; + newGames.reserve(games.size()); for (Game *game : games) { + if (!game) { + continue; + } + + const QString id = game->id(); + if (id.isEmpty() || seenIds.contains(id)) { + delete game; + continue; + } + + seenIds.insert(id); + newGames.append(game); + } + + for (Game *game : newGames) { game->moveToThread(this->thread()); game->setParent(nullptr); } QMetaObject::invokeMethod( this, - [this, games]() { - for (Game *game : games) { + [this, newGames]() { + for (Game *game : newGames) { m_gameModel->addGame(game); } - setImportStatus(tr("Steam import complete: %1 games found").arg(games.count())); + setImportStatus(tr("Steam import complete: %1 new games found").arg(newGames.count())); setImporting(false); saveLibrary(); - Q_EMIT importCompleted(games.count()); + Q_EMIT importCompleted(newGames.count()); }, Qt::QueuedConnection); }); @@ -611,28 +685,57 @@ void App::importFromLutris() if (m_importing) return; + const QSet existingIds = [this]() { + QSet ids; + const QList games = m_gameModel->allGames(); + for (Game *game : games) { + if (game) { + ids.insert(game->id()); + } + } + return ids; + }(); + setImporting(true); setImportStatus(tr("Scanning Lutris library...")); - [[maybe_unused]] auto future = QtConcurrent::run([this]() { + [[maybe_unused]] auto future = QtConcurrent::run([this, existingIds]() { LutrisImporter importer; QList games = importer.importGames(); + QSet seenIds = existingIds; + QList newGames; + newGames.reserve(games.size()); for (Game *game : games) { + if (!game) { + continue; + } + + const QString id = game->id(); + if (id.isEmpty() || seenIds.contains(id)) { + delete game; + continue; + } + + seenIds.insert(id); + newGames.append(game); + } + + for (Game *game : newGames) { game->moveToThread(this->thread()); game->setParent(nullptr); } QMetaObject::invokeMethod( this, - [this, games]() { - for (Game *game : games) { + [this, newGames]() { + for (Game *game : newGames) { m_gameModel->addGame(game); } - setImportStatus(tr("Lutris import complete: %1 games found").arg(games.count())); + setImportStatus(tr("Lutris import complete: %1 new games found").arg(newGames.count())); setImporting(false); saveLibrary(); - Q_EMIT importCompleted(games.count()); + Q_EMIT importCompleted(newGames.count()); }, Qt::QueuedConnection); }); @@ -643,28 +746,57 @@ void App::importFromHeroic() if (m_importing) return; + const QSet existingIds = [this]() { + QSet ids; + const QList games = m_gameModel->allGames(); + for (Game *game : games) { + if (game) { + ids.insert(game->id()); + } + } + return ids; + }(); + setImporting(true); setImportStatus(tr("Scanning Heroic library...")); - [[maybe_unused]] auto future = QtConcurrent::run([this]() { + [[maybe_unused]] auto future = QtConcurrent::run([this, existingIds]() { HeroicImporter importer; QList games = importer.importGames(); + QSet seenIds = existingIds; + QList newGames; + newGames.reserve(games.size()); for (Game *game : games) { + if (!game) { + continue; + } + + const QString id = game->id(); + if (id.isEmpty() || seenIds.contains(id)) { + delete game; + continue; + } + + seenIds.insert(id); + newGames.append(game); + } + + for (Game *game : newGames) { game->moveToThread(this->thread()); game->setParent(nullptr); } QMetaObject::invokeMethod( this, - [this, games]() { - for (Game *game : games) { + [this, newGames]() { + for (Game *game : newGames) { m_gameModel->addGame(game); } - setImportStatus(tr("Heroic import complete: %1 games found").arg(games.count())); + setImportStatus(tr("Heroic import complete: %1 new games found").arg(newGames.count())); setImporting(false); saveLibrary(); - Q_EMIT importCompleted(games.count()); + Q_EMIT importCompleted(newGames.count()); }, Qt::QueuedConnection); }); @@ -675,28 +807,57 @@ void App::importFromDesktop() if (m_importing) return; + const QSet existingIds = [this]() { + QSet ids; + const QList games = m_gameModel->allGames(); + for (Game *game : games) { + if (game) { + ids.insert(game->id()); + } + } + return ids; + }(); + setImporting(true); setImportStatus(tr("Scanning desktop entries...")); - [[maybe_unused]] auto future = QtConcurrent::run([this]() { + [[maybe_unused]] auto future = QtConcurrent::run([this, existingIds]() { DesktopImporter importer; QList games = importer.importGames(); + QSet seenIds = existingIds; + QList newGames; + newGames.reserve(games.size()); for (Game *game : games) { + if (!game) { + continue; + } + + const QString id = game->id(); + if (id.isEmpty() || seenIds.contains(id)) { + delete game; + continue; + } + + seenIds.insert(id); + newGames.append(game); + } + + for (Game *game : newGames) { game->moveToThread(this->thread()); game->setParent(nullptr); } QMetaObject::invokeMethod( this, - [this, games]() { - for (Game *game : games) { + [this, newGames]() { + for (Game *game : newGames) { m_gameModel->addGame(game); } - setImportStatus(tr("Desktop import complete: %1 games found").arg(games.count())); + setImportStatus(tr("Desktop import complete: %1 new games found").arg(newGames.count())); setImporting(false); saveLibrary(); - Q_EMIT importCompleted(games.count()); + Q_EMIT importCompleted(newGames.count()); }, Qt::QueuedConnection); }); @@ -707,28 +868,57 @@ void App::importFromBottles() if (m_importing) return; + const QSet existingIds = [this]() { + QSet ids; + const QList games = m_gameModel->allGames(); + for (Game *game : games) { + if (game) { + ids.insert(game->id()); + } + } + return ids; + }(); + setImporting(true); setImportStatus(tr("Scanning Bottles...")); - [[maybe_unused]] auto future = QtConcurrent::run([this]() { + [[maybe_unused]] auto future = QtConcurrent::run([this, existingIds]() { BottlesImporter importer; QList games = importer.importGames(); + QSet seenIds = existingIds; + QList newGames; + newGames.reserve(games.size()); for (Game *game : games) { + if (!game) { + continue; + } + + const QString id = game->id(); + if (id.isEmpty() || seenIds.contains(id)) { + delete game; + continue; + } + + seenIds.insert(id); + newGames.append(game); + } + + for (Game *game : newGames) { game->moveToThread(this->thread()); game->setParent(nullptr); } QMetaObject::invokeMethod( this, - [this, games]() { - for (Game *game : games) { + [this, newGames]() { + for (Game *game : newGames) { m_gameModel->addGame(game); } - setImportStatus(tr("Bottles import complete: %1 games found").arg(games.count())); + setImportStatus(tr("Bottles import complete: %1 new games found").arg(newGames.count())); setImporting(false); saveLibrary(); - Q_EMIT importCompleted(games.count()); + Q_EMIT importCompleted(newGames.count()); }, Qt::QueuedConnection); }); @@ -739,28 +929,57 @@ void App::importFromFlatpak() if (m_importing) return; + const QSet existingIds = [this]() { + QSet ids; + const QList games = m_gameModel->allGames(); + for (Game *game : games) { + if (game) { + ids.insert(game->id()); + } + } + return ids; + }(); + setImporting(true); setImportStatus(tr("Scanning Flatpak games...")); - [[maybe_unused]] auto future = QtConcurrent::run([this]() { + [[maybe_unused]] auto future = QtConcurrent::run([this, existingIds]() { FlatpakImporter importer; QList games = importer.importGames(); + QSet seenIds = existingIds; + QList newGames; + newGames.reserve(games.size()); for (Game *game : games) { + if (!game) { + continue; + } + + const QString id = game->id(); + if (id.isEmpty() || seenIds.contains(id)) { + delete game; + continue; + } + + seenIds.insert(id); + newGames.append(game); + } + + for (Game *game : newGames) { game->moveToThread(this->thread()); game->setParent(nullptr); } QMetaObject::invokeMethod( this, - [this, games]() { - for (Game *game : games) { + [this, newGames]() { + for (Game *game : newGames) { m_gameModel->addGame(game); } - setImportStatus(tr("Flatpak import complete: %1 games found").arg(games.count())); + setImportStatus(tr("Flatpak import complete: %1 new games found").arg(newGames.count())); setImporting(false); saveLibrary(); - Q_EMIT importCompleted(games.count()); + Q_EMIT importCompleted(newGames.count()); }, Qt::QueuedConnection); }); @@ -771,28 +990,57 @@ void App::importFromItch() if (m_importing) return; + const QSet existingIds = [this]() { + QSet ids; + const QList games = m_gameModel->allGames(); + for (Game *game : games) { + if (game) { + ids.insert(game->id()); + } + } + return ids; + }(); + setImporting(true); setImportStatus(tr("Scanning itch.io library...")); - [[maybe_unused]] auto future = QtConcurrent::run([this]() { + [[maybe_unused]] auto future = QtConcurrent::run([this, existingIds]() { ItchImporter importer; QList games = importer.importGames(); + QSet seenIds = existingIds; + QList newGames; + newGames.reserve(games.size()); for (Game *game : games) { + if (!game) { + continue; + } + + const QString id = game->id(); + if (id.isEmpty() || seenIds.contains(id)) { + delete game; + continue; + } + + seenIds.insert(id); + newGames.append(game); + } + + for (Game *game : newGames) { game->moveToThread(this->thread()); game->setParent(nullptr); } QMetaObject::invokeMethod( this, - [this, games]() { - for (Game *game : games) { + [this, newGames]() { + for (Game *game : newGames) { m_gameModel->addGame(game); } - setImportStatus(tr("itch.io import complete: %1 games found").arg(games.count())); + setImportStatus(tr("itch.io import complete: %1 new games found").arg(newGames.count())); setImporting(false); saveLibrary(); - Q_EMIT importCompleted(games.count()); + Q_EMIT importCompleted(newGames.count()); }, Qt::QueuedConnection); }); @@ -803,28 +1051,57 @@ void App::importFromLegendary() if (m_importing) return; + const QSet existingIds = [this]() { + QSet ids; + const QList games = m_gameModel->allGames(); + for (Game *game : games) { + if (game) { + ids.insert(game->id()); + } + } + return ids; + }(); + setImporting(true); setImportStatus(tr("Scanning Legendary library...")); - [[maybe_unused]] auto future = QtConcurrent::run([this]() { + [[maybe_unused]] auto future = QtConcurrent::run([this, existingIds]() { LegendaryImporter importer; QList games = importer.importGames(); + QSet seenIds = existingIds; + QList newGames; + newGames.reserve(games.size()); for (Game *game : games) { + if (!game) { + continue; + } + + const QString id = game->id(); + if (id.isEmpty() || seenIds.contains(id)) { + delete game; + continue; + } + + seenIds.insert(id); + newGames.append(game); + } + + for (Game *game : newGames) { game->moveToThread(this->thread()); game->setParent(nullptr); } QMetaObject::invokeMethod( this, - [this, games]() { - for (Game *game : games) { + [this, newGames]() { + for (Game *game : newGames) { m_gameModel->addGame(game); } - setImportStatus(tr("Legendary import complete: %1 games found").arg(games.count())); + setImportStatus(tr("Legendary import complete: %1 new games found").arg(newGames.count())); setImporting(false); saveLibrary(); - Q_EMIT importCompleted(games.count()); + Q_EMIT importCompleted(newGames.count()); }, Qt::QueuedConnection); }); @@ -835,28 +1112,57 @@ void App::importFromRetroArch() if (m_importing) return; + const QSet existingIds = [this]() { + QSet ids; + const QList games = m_gameModel->allGames(); + for (Game *game : games) { + if (game) { + ids.insert(game->id()); + } + } + return ids; + }(); + setImporting(true); setImportStatus(tr("Scanning RetroArch playlists...")); - [[maybe_unused]] auto future = QtConcurrent::run([this]() { + [[maybe_unused]] auto future = QtConcurrent::run([this, existingIds]() { RetroArchImporter importer; QList games = importer.importGames(); + QSet seenIds = existingIds; + QList newGames; + newGames.reserve(games.size()); for (Game *game : games) { + if (!game) { + continue; + } + + const QString id = game->id(); + if (id.isEmpty() || seenIds.contains(id)) { + delete game; + continue; + } + + seenIds.insert(id); + newGames.append(game); + } + + for (Game *game : newGames) { game->moveToThread(this->thread()); game->setParent(nullptr); } QMetaObject::invokeMethod( this, - [this, games]() { - for (Game *game : games) { + [this, newGames]() { + for (Game *game : newGames) { m_gameModel->addGame(game); } - setImportStatus(tr("RetroArch import complete: %1 games found").arg(games.count())); + setImportStatus(tr("RetroArch import complete: %1 new games found").arg(newGames.count())); setImporting(false); saveLibrary(); - Q_EMIT importCompleted(games.count()); + Q_EMIT importCompleted(newGames.count()); }, Qt::QueuedConnection); }); diff --git a/src/qml/Main.qml b/src/qml/Main.qml index a879bc4..9762727 100644 --- a/src/qml/Main.qml +++ b/src/qml/Main.qml @@ -1143,7 +1143,7 @@ Kirigami.ApplicationWindow { secondary: "steam" resourceFallback: Qt.resolvedUrl("icons/brand/steam-symbolic.svg") } - enabled: !App.importing && App.config.importSteam && !App.gameModel.hasPlatformPrefix("Steam") && App.gameModel.count >= 0 + enabled: !App.importing && App.config.importSteam && App.gameModel.count >= 0 onClicked: App.importFromSteam() } @@ -1157,7 +1157,7 @@ Kirigami.ApplicationWindow { primary: "lutris" secondary: "applications-games" } - enabled: !App.importing && App.config.importLutris && !App.gameModel.hasPlatformPrefix("Lutris") && App.gameModel.count >= 0 + enabled: !App.importing && App.config.importLutris && App.gameModel.count >= 0 onClicked: App.importFromLutris() } @@ -1171,7 +1171,7 @@ Kirigami.ApplicationWindow { primary: "com.heroicgameslauncher.hgl" secondary: "applications-games" } - enabled: !App.importing && App.config.importHeroic && !App.gameModel.hasPlatformPrefix("Heroic") && App.gameModel.count >= 0 + enabled: !App.importing && App.config.importHeroic && App.gameModel.count >= 0 onClicked: App.importFromHeroic() } @@ -1185,7 +1185,7 @@ Kirigami.ApplicationWindow { primary: "user-desktop" secondary: "computer" } - enabled: !App.importing && App.config.importDesktop && !App.gameModel.hasPlatformPrefix("Desktop") && App.gameModel.count >= 0 + enabled: !App.importing && App.config.importDesktop && App.gameModel.count >= 0 onClicked: App.importFromDesktop() } @@ -1199,7 +1199,7 @@ Kirigami.ApplicationWindow { primary: "com.usebottles.bottles" secondary: "application-x-executable" } - enabled: !App.importing && App.config.importBottles && !App.gameModel.hasPlatformPrefix("Bottles") && App.gameModel.count >= 0 + enabled: !App.importing && App.config.importBottles && App.gameModel.count >= 0 onClicked: App.importFromBottles() } @@ -1213,7 +1213,7 @@ Kirigami.ApplicationWindow { primary: "flatpak-discover" secondary: "applications-games" } - enabled: !App.importing && App.config.importFlatpak && !App.gameModel.hasPlatformPrefix("Flatpak") && App.gameModel.count >= 0 + enabled: !App.importing && App.config.importFlatpak && App.gameModel.count >= 0 onClicked: App.importFromFlatpak() } @@ -1228,7 +1228,7 @@ Kirigami.ApplicationWindow { secondary: "itch" resourceFallback: Qt.resolvedUrl("icons/brand/itchdotio-symbolic.svg") } - enabled: !App.importing && App.config.importItch && !App.gameModel.hasPlatformPrefix("itch.io") && App.gameModel.count >= 0 + enabled: !App.importing && App.config.importItch && App.gameModel.count >= 0 onClicked: App.importFromItch() } @@ -1242,7 +1242,7 @@ Kirigami.ApplicationWindow { primary: "legendary" secondary: "applications-games" } - enabled: !App.importing && App.config.importLegendary && !App.gameModel.hasPlatformPrefix("Legendary") && App.gameModel.count >= 0 + enabled: !App.importing && App.config.importLegendary && App.gameModel.count >= 0 onClicked: App.importFromLegendary() } @@ -1257,7 +1257,7 @@ Kirigami.ApplicationWindow { secondary: "retroarch" resourceFallback: Qt.resolvedUrl("icons/brand/retroarch-symbolic.svg") } - enabled: !App.importing && App.config.importRetroArch && !App.gameModel.hasPlatformPrefix("RetroArch") && App.gameModel.count >= 0 + enabled: !App.importing && App.config.importRetroArch && App.gameModel.count >= 0 onClicked: App.importFromRetroArch() } } @@ -1269,15 +1269,15 @@ Kirigami.ApplicationWindow { QQC2.Button { text: i18n("Import All") icon.name: "document-import" - enabled: !App.importing && App.gameModel.count >= 0 && ((App.config.importSteam && !App.gameModel.hasPlatformPrefix("Steam")) - || (App.config.importLutris && !App.gameModel.hasPlatformPrefix("Lutris")) - || (App.config.importHeroic && !App.gameModel.hasPlatformPrefix("Heroic")) - || (App.config.importDesktop && !App.gameModel.hasPlatformPrefix("Desktop")) - || (App.config.importBottles && !App.gameModel.hasPlatformPrefix("Bottles")) - || (App.config.importFlatpak && !App.gameModel.hasPlatformPrefix("Flatpak")) - || (App.config.importItch && !App.gameModel.hasPlatformPrefix("itch.io")) - || (App.config.importLegendary && !App.gameModel.hasPlatformPrefix("Legendary")) - || (App.config.importRetroArch && !App.gameModel.hasPlatformPrefix("RetroArch"))) + enabled: !App.importing && App.gameModel.count >= 0 && (App.config.importSteam + || App.config.importLutris + || App.config.importHeroic + || App.config.importDesktop + || App.config.importBottles + || App.config.importFlatpak + || App.config.importItch + || App.config.importLegendary + || App.config.importRetroArch) onClicked: App.importAllGames() } diff --git a/src/qml/SettingsPage.qml b/src/qml/SettingsPage.qml index 419747b..0c831a4 100644 --- a/src/qml/SettingsPage.qml +++ b/src/qml/SettingsPage.qml @@ -643,15 +643,15 @@ FormCard.FormHeader { text: i18n("Import All Games") description: i18n("Scan all enabled sources") icon.name: "document-import" - enabled: !App.importing && App.gameModel.count >= 0 && ((App.config.importSteam && !App.gameModel.hasPlatformPrefix("Steam")) - || (App.config.importLutris && !App.gameModel.hasPlatformPrefix("Lutris")) - || (App.config.importHeroic && !App.gameModel.hasPlatformPrefix("Heroic")) - || (App.config.importDesktop && !App.gameModel.hasPlatformPrefix("Desktop")) - || (App.config.importBottles && !App.gameModel.hasPlatformPrefix("Bottles")) - || (App.config.importFlatpak && !App.gameModel.hasPlatformPrefix("Flatpak")) - || (App.config.importItch && !App.gameModel.hasPlatformPrefix("itch.io")) - || (App.config.importLegendary && !App.gameModel.hasPlatformPrefix("Legendary")) - || (App.config.importRetroArch && !App.gameModel.hasPlatformPrefix("RetroArch"))) + enabled: !App.importing && App.gameModel.count >= 0 && (App.config.importSteam + || App.config.importLutris + || App.config.importHeroic + || App.config.importDesktop + || App.config.importBottles + || App.config.importFlatpak + || App.config.importItch + || App.config.importLegendary + || App.config.importRetroArch) onClicked: App.importAllGames() } From 5f0978dd0e00d9a3cf552a619ab3cfe4385b8384 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Tue, 3 Feb 2026 10:42:12 +0100 Subject: [PATCH 4/4] Fix desktop import paths and add CLI import mode --- src/desktopimporter.cpp | 8 ++++++- src/main.cpp | 46 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/desktopimporter.cpp b/src/desktopimporter.cpp index 30200c6..0e65f7d 100644 --- a/src/desktopimporter.cpp +++ b/src/desktopimporter.cpp @@ -40,7 +40,13 @@ QStringList DesktopImporter::getDesktopFilePaths() const // Add common system locations appDirs << QStringLiteral("/usr/share/applications"); appDirs << QStringLiteral("/usr/local/share/applications"); - appDirs << expandPath(QStringLiteral("~/.local/share/applications")); + + const QString xdgDataHome = qEnvironmentVariable("XDG_DATA_HOME"); + if (!xdgDataHome.isEmpty()) { + appDirs << QDir(xdgDataHome).absoluteFilePath(QStringLiteral("applications")); + } else { + appDirs << expandPath(QStringLiteral("~/.local/share/applications")); + } // Flatpak export directories appDirs << expandPath(QStringLiteral("~/.local/share/flatpak/exports/share/applications")); diff --git a/src/main.cpp b/src/main.cpp index 53e7e25..e88ad48 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -8,6 +8,8 @@ #include #include #include +#include +#include #include #include @@ -16,6 +18,7 @@ #include #include "alakarte-version.h" +#include "app.h" int main(int argc, char *argv[]) { @@ -47,15 +50,54 @@ int main(int argc, char *argv[]) QCommandLineParser parser; aboutData.setupCommandLine(&parser); + + QCommandLineOption importAllAndExitOption(QStringLiteral("import-all-and-exit"), i18n("Import all enabled sources and exit")); + QCommandLineOption importDesktopAndExitOption(QStringLiteral("import-desktop-and-exit"), i18n("Import desktop entries and exit")); + QCommandLineOption startupAndExitOption(QStringLiteral("startup-and-exit"), i18n("Start without UI and exit after startup auto-import (if enabled)")); + parser.addOption(importAllAndExitOption); + parser.addOption(importDesktopAndExitOption); + parser.addOption(startupAndExitOption); + parser.process(app); aboutData.processCommandLine(&parser); - KDBusService service(KDBusService::Unique); - if (qEnvironmentVariableIsEmpty("QT_QUICK_CONTROLS_STYLE")) { QQuickStyle::setStyle(QStringLiteral("org.kde.desktop")); } + if (parser.isSet(importAllAndExitOption) || parser.isSet(importDesktopAndExitOption) || parser.isSet(startupAndExitOption)) { + App *alakarteApp = App::instance(); + + QObject::connect(alakarteApp, &App::importCompleted, &app, [&app](int count) { + QTextStream(stdout) << count << Qt::endl; + app.exit(0); + }); + QObject::connect(alakarteApp, &App::importError, &app, [&app](const QString &error) { + QTextStream(stderr) << error << Qt::endl; + app.exit(1); + }); + + if (parser.isSet(importAllAndExitOption)) { + alakarteApp->importAllGames(); + } else if (parser.isSet(importDesktopAndExitOption)) { + alakarteApp->importFromDesktop(); + } else { + if (!alakarteApp->config() || !alakarteApp->config()->autoImportOnStartup()) { + QTextStream(stdout) << 0 << Qt::endl; + return 0; + } + + QTimer::singleShot(60000, &app, [&app]() { + QTextStream(stderr) << QStringLiteral("Timed out waiting for auto-import") << Qt::endl; + app.exit(2); + }); + } + + return app.exec(); + } + + KDBusService service(KDBusService::Unique); + QQmlApplicationEngine engine; engine.rootContext()->setContextObject(new KLocalizedContext(&engine));