From 4dd0e17afb7909050bfbbe63d31a9bdf2373f724 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Wed, 22 Apr 2026 11:36:49 +0200 Subject: [PATCH] Track launch state in Game Center Record recent launches only after the launcher reports success instead of optimistically on every attempt. For desktop entries, wait for the ApplicationLauncherJob result; for detached commands, use startDetached's return value and show an inline error when startup fails. Parse Steam libraryfolders and app manifests as KeyValues instead of matching individual lines. This makes Steam discovery less brittle when the files contain comments, nested blocks, or different formatting. Keep Continue Playing visible alongside Running and show a temporary launching state so the overlay does not pretend a task already exists. Rename the GameMode status text to say what the shell actually knows. --- .../gamelauncherprovider.cpp | 381 ++++++++++++++++-- .../gamingshellplugin/gamelauncherprovider.h | 20 + .../folio/qml/gaming/GameCenterOverlay.qml | 62 ++- .../folio/qml/gaming/GamingQuickSettings.qml | 4 +- .../folio/qml/gaming/RunningGamesView.qml | 1 + 5 files changed, 419 insertions(+), 49 deletions(-) diff --git a/components/gamingshellplugin/gamelauncherprovider.cpp b/components/gamingshellplugin/gamelauncherprovider.cpp index 5aede406..4c465f23 100644 --- a/components/gamingshellplugin/gamelauncherprovider.cpp +++ b/components/gamingshellplugin/gamelauncherprovider.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -13,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -23,13 +25,203 @@ #include #include +#include + static const QString s_recentGroup = QStringLiteral("GamingRecentlyPlayed"); +namespace +{ +struct VdfNode { + QHash values; + QHash> children; +}; + +class VdfTokenizer +{ +public: + enum class TokenType { + End, + String, + OpenBrace, + CloseBrace, + Invalid, + }; + + struct Token { + TokenType type = TokenType::End; + QString text; + }; + + explicit VdfTokenizer(QStringView input) + : m_input(input) + { + } + + Token next() + { + skipWhitespaceAndComments(); + + if (m_pos >= m_input.size()) { + return {}; + } + + const QChar current = m_input.at(m_pos); + if (current == QLatin1Char('{')) { + ++m_pos; + return {TokenType::OpenBrace, {}}; + } + if (current == QLatin1Char('}')) { + ++m_pos; + return {TokenType::CloseBrace, {}}; + } + if (current == QLatin1Char('"')) { + return {TokenType::String, readQuotedString()}; + } + + return {TokenType::String, readBareString()}; + } + + int position() const + { + return m_pos; + } + +private: + void skipWhitespaceAndComments() + { + while (m_pos < m_input.size()) { + const QChar current = m_input.at(m_pos); + if (current.isSpace()) { + ++m_pos; + continue; + } + if (current == QLatin1Char('/') && m_pos + 1 < m_input.size() && m_input.at(m_pos + 1) == QLatin1Char('/')) { + m_pos += 2; + while (m_pos < m_input.size() && m_input.at(m_pos) != QLatin1Char('\n')) { + ++m_pos; + } + continue; + } + break; + } + } + + QString readQuotedString() + { + QString result; + ++m_pos; + + while (m_pos < m_input.size()) { + const QChar current = m_input.at(m_pos++); + if (current == QLatin1Char('"')) { + return result; + } + if (current == QLatin1Char('\\') && m_pos < m_input.size()) { + const QChar escaped = m_input.at(m_pos++); + switch (escaped.unicode()) { + case 'n': + result.append(QLatin1Char('\n')); + break; + case 't': + result.append(QLatin1Char('\t')); + break; + case 'r': + result.append(QLatin1Char('\r')); + break; + case '\\': + case '"': + result.append(escaped); + break; + default: + result.append(escaped); + break; + } + continue; + } + result.append(current); + } + + return result; + } + + QString readBareString() + { + const int start = m_pos; + while (m_pos < m_input.size()) { + const QChar current = m_input.at(m_pos); + if (current.isSpace() || current == QLatin1Char('{') || current == QLatin1Char('}') || current == QLatin1Char('"')) { + break; + } + if (current == QLatin1Char('/') && m_pos + 1 < m_input.size() && m_input.at(m_pos + 1) == QLatin1Char('/')) { + break; + } + ++m_pos; + } + return m_input.sliced(start, m_pos - start).toString(); + } + + QStringView m_input; + int m_pos = 0; +}; + +bool parseVdf(const QString &input, VdfNode &root, QString *error) +{ + VdfTokenizer tokenizer(input); + QList stack = {&root}; + + while (true) { + const auto key = tokenizer.next(); + if (key.type == VdfTokenizer::TokenType::End) { + if (stack.size() != 1 && error) { + *error = QStringLiteral("unexpected end of file"); + } + return stack.size() == 1; + } + if (key.type == VdfTokenizer::TokenType::CloseBrace) { + if (stack.size() == 1) { + if (error) { + *error = QStringLiteral("unexpected closing brace at position %1").arg(tokenizer.position()); + } + return false; + } + stack.removeLast(); + continue; + } + if (key.type != VdfTokenizer::TokenType::String || key.text.isEmpty()) { + if (error) { + *error = QStringLiteral("invalid key at position %1").arg(tokenizer.position()); + } + return false; + } + + const auto value = tokenizer.next(); + if (value.type == VdfTokenizer::TokenType::String) { + stack.last()->values.insert(key.text, value.text); + continue; + } + if (value.type == VdfTokenizer::TokenType::OpenBrace) { + auto child = std::make_shared(); + stack.last()->children.insert(key.text, child); + stack.append(child.get()); + continue; + } + + if (error) { + *error = QStringLiteral("expected value for key '%1'").arg(key.text); + } + return false; + } +} +} // namespace + GameLauncherProvider::GameLauncherProvider(QObject *parent) : QAbstractListModel(parent) , m_config(KSharedConfig::openConfig(QStringLiteral("plasmamobilerc"))) { connect(KSycoca::self(), &KSycoca::databaseChanged, this, &GameLauncherProvider::refresh); + m_pendingLaunchTimer.setInterval(15000); + m_pendingLaunchTimer.setSingleShot(true); + connect(&m_pendingLaunchTimer, &QTimer::timeout, this, &GameLauncherProvider::clearPendingLaunch); refresh(); } @@ -86,6 +278,21 @@ bool GameLauncherProvider::loading() const return m_loading; } +bool GameLauncherProvider::launchPending() const +{ + return m_launchPending; +} + +QString GameLauncherProvider::pendingLaunchName() const +{ + return m_pendingLaunchName; +} + +QString GameLauncherProvider::lastLaunchError() const +{ + return m_lastLaunchError; +} + void GameLauncherProvider::refresh() { m_loading = true; @@ -142,27 +349,45 @@ void GameLauncherProvider::launchByStorageId(const QString &storageId) void GameLauncherProvider::launchEntry(GameEntry &entry) { + clearLastLaunchError(); + if (entry.source == QLatin1String("desktop")) { auto service = KService::serviceByStorageId(entry.storageId); - if (service) { - auto *job = new KIO::ApplicationLauncherJob(service); - job->start(); + if (!service) { + markLaunchFailed(entry.name, QStringLiteral("Desktop entry is no longer available")); + return; } + + auto *job = new KIO::ApplicationLauncherJob(service); + connect(job, &KJob::result, this, [this, job, storageId = entry.storageId, name = entry.name]() { + if (job->error() != 0) { + markLaunchFailed(name, job->errorString()); + return; + } + markLaunchSucceeded(storageId, name); + }); + job->start(); } else if (entry.launchCommand.contains(QStringLiteral("://"))) { // Protocol handler (e.g. heroic://launch/...) — open via xdg-open - QProcess::startDetached(QStringLiteral("xdg-open"), {entry.launchCommand}); + if (!QProcess::startDetached(QStringLiteral("xdg-open"), {entry.launchCommand})) { + markLaunchFailed(entry.name, QStringLiteral("Unable to start xdg-open")); + return; + } + markLaunchSucceeded(entry.storageId, entry.name); } else { QStringList parts = KShell::splitArgs(entry.launchCommand); - if (!parts.isEmpty()) { - QString program = parts.takeFirst(); - QProcess::startDetached(program, parts); + if (parts.isEmpty()) { + markLaunchFailed(entry.name, QStringLiteral("Launch command is empty")); + return; } - } - Q_EMIT gameLaunched(entry.name); - const auto now = QDateTime::currentDateTime(); - saveRecentTimestamp(entry.storageId, now); - entry.lastPlayed = now; + QString program = parts.takeFirst(); + if (!QProcess::startDetached(program, parts)) { + markLaunchFailed(entry.name, QStringLiteral("Unable to start %1").arg(program)); + return; + } + markLaunchSucceeded(entry.storageId, entry.name); + } } void GameLauncherProvider::deduplicateGames() @@ -237,21 +462,30 @@ void GameLauncherProvider::loadSteamGames() if (!vdf.open(QIODevice::ReadOnly | QIODevice::Text)) { continue; } - // Simple parse: look for "path" lines - static const QRegularExpression pathRe(QStringLiteral("\"path\"\\s+\"([^\"]+)\"")); - QTextStream stream(&vdf); - while (!stream.atEnd()) { - const QString line = stream.readLine(); - auto match = pathRe.match(line); - if (match.hasMatch()) { - libraryPaths.append(match.captured(1)); + const QString content = QString::fromUtf8(vdf.readAll()); + VdfNode document; + QString error; + if (!parseVdf(content, document, &error)) { + qWarning() << "GameLauncherProvider: cannot parse Steam libraryfolders" << vdfPath << error; + continue; + } + + const VdfNode *libraries = nullptr; + if (document.children.contains(QStringLiteral("libraryfolders"))) { + libraries = document.children.value(QStringLiteral("libraryfolders")).get(); + } else { + libraries = &document; + } + + for (auto it = libraries->children.cbegin(); it != libraries->children.cend(); ++it) { + const QString path = it.value()->values.value(QStringLiteral("path")); + if (!path.isEmpty()) { + libraryPaths.append(path); } } } - // Scan each library path for appmanifest_*.acf - static const QRegularExpression nameRe(QStringLiteral("\"name\"\\s+\"([^\"]+)\"")); - static const QRegularExpression appidRe(QStringLiteral("\"appid\"\\s+\"(\\d+)\"")); + libraryPaths.removeDuplicates(); for (const auto &libPath : std::as_const(libraryPaths)) { QDir steamapps(libPath + QStringLiteral("/steamapps")); @@ -264,28 +498,19 @@ void GameLauncherProvider::loadSteamGames() if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { continue; } - QString appName; - QString appId; - QTextStream ts(&f); - while (!ts.atEnd()) { - const QString line = ts.readLine(); - if (appName.isEmpty()) { - auto m = nameRe.match(line); - if (m.hasMatch()) { - appName = m.captured(1); - } - } - if (appId.isEmpty()) { - auto m = appidRe.match(line); - if (m.hasMatch()) { - appId = m.captured(1); - } - } - if (!appName.isEmpty() && !appId.isEmpty()) { - break; - } + VdfNode manifestData; + QString error; + if (!parseVdf(QString::fromUtf8(f.readAll()), manifestData, &error)) { + qWarning() << "GameLauncherProvider: cannot parse Steam manifest" << manifest << error; + continue; } + const VdfNode *appState = + manifestData.children.contains(QStringLiteral("AppState")) ? manifestData.children.value(QStringLiteral("AppState")).get() : &manifestData; + + const QString appName = appState->values.value(QStringLiteral("name")); + const QString appId = appState->values.value(QStringLiteral("appid")); + if (appName.isEmpty() || appId.isEmpty()) { continue; } @@ -593,6 +818,76 @@ void GameLauncherProvider::saveRecentTimestamp(const QString &storageId, const Q group.sync(); } +void GameLauncherProvider::clearPendingLaunch() +{ + if (!m_launchPending && m_pendingLaunchName.isEmpty()) { + return; + } + + m_pendingLaunchTimer.stop(); + m_launchPending = false; + m_pendingLaunchName.clear(); + Q_EMIT launchPendingChanged(); +} + +void GameLauncherProvider::clearLastLaunchError() +{ + if (m_lastLaunchError.isEmpty()) { + return; + } + + m_lastLaunchError.clear(); + Q_EMIT lastLaunchErrorChanged(); +} + +GameLauncherProvider::GameEntry *GameLauncherProvider::findEntryByStorageId(const QString &storageId) +{ + for (auto &entry : m_allGames) { + if (entry.storageId == storageId) { + return &entry; + } + } + return nullptr; +} + +void GameLauncherProvider::markLaunchSucceeded(const QString &storageId, const QString &name) +{ + if (auto *entry = findEntryByStorageId(storageId)) { + const auto now = QDateTime::currentDateTime(); + saveRecentTimestamp(entry->storageId, now); + entry->lastPlayed = now; + } + + setPendingLaunch(name); + Q_EMIT gameLaunched(name); +} + +void GameLauncherProvider::markLaunchFailed(const QString &name, const QString &error) +{ + clearPendingLaunch(); + + const QString message = error.isEmpty() ? tr("Unable to launch %1").arg(name) : tr("Unable to launch %1: %2").arg(name, error); + + if (m_lastLaunchError != message) { + m_lastLaunchError = message; + Q_EMIT lastLaunchErrorChanged(); + } + + Q_EMIT gameLaunchFailed(name, message); +} + +void GameLauncherProvider::setPendingLaunch(const QString &name) +{ + const bool changed = !m_launchPending || m_pendingLaunchName != name; + m_launchPending = true; + m_pendingLaunchName = name; + m_pendingLaunchTimer.start(); + + if (changed) { + Q_EMIT launchPendingChanged(); + } +} + QVariantList GameLauncherProvider::recentGames(int limit) const { // Gather entries that have been launched at least once diff --git a/components/gamingshellplugin/gamelauncherprovider.h b/components/gamingshellplugin/gamelauncherprovider.h index 62a95463..c9fa156b 100644 --- a/components/gamingshellplugin/gamelauncherprovider.h +++ b/components/gamingshellplugin/gamelauncherprovider.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -22,6 +23,9 @@ class GameLauncherProvider : public QAbstractListModel Q_PROPERTY(QString filterString READ filterString WRITE setFilterString NOTIFY filterStringChanged) Q_PROPERTY(QString sourceFilter READ sourceFilter WRITE setSourceFilter NOTIFY sourceFilterChanged) Q_PROPERTY(bool overlayEnabled READ overlayEnabled WRITE setOverlayEnabled NOTIFY overlayEnabledChanged) + Q_PROPERTY(bool launchPending READ launchPending NOTIFY launchPendingChanged) + Q_PROPERTY(QString pendingLaunchName READ pendingLaunchName NOTIFY launchPendingChanged) + Q_PROPERTY(QString lastLaunchError READ lastLaunchError NOTIFY lastLaunchErrorChanged) public: explicit GameLauncherProvider(QObject *parent = nullptr); @@ -49,11 +53,16 @@ public: void setSourceFilter(const QString &source); bool overlayEnabled() const; void setOverlayEnabled(bool enabled); + bool launchPending() const; + QString pendingLaunchName() const; + QString lastLaunchError() const; Q_INVOKABLE void refresh(); Q_INVOKABLE void launch(int index); Q_INVOKABLE void launchByStorageId(const QString &storageId); Q_INVOKABLE QVariantList recentGames(int limit = 5) const; + Q_INVOKABLE void clearPendingLaunch(); + Q_INVOKABLE void clearLastLaunchError(); Q_SIGNALS: void countChanged(); @@ -61,7 +70,10 @@ Q_SIGNALS: void filterStringChanged(); void sourceFilterChanged(); void overlayEnabledChanged(); + void launchPendingChanged(); + void lastLaunchErrorChanged(); void gameLaunched(const QString &name); + void gameLaunchFailed(const QString &name, const QString &error); private: struct GameEntry { @@ -85,6 +97,10 @@ private: void saveRecentTimestamp(const QString &storageId, const QDateTime &when); void applyFilter(); void launchEntry(GameEntry &entry); + GameEntry *findEntryByStorageId(const QString &storageId); + void markLaunchSucceeded(const QString &storageId, const QString &name); + void markLaunchFailed(const QString &name, const QString &error); + void setPendingLaunch(const QString &name); QList m_allGames; QList m_games; // filtered view @@ -93,4 +109,8 @@ private: KSharedConfigPtr m_config; bool m_loading = false; bool m_overlayEnabled = false; + bool m_launchPending = false; + QString m_pendingLaunchName; + QString m_lastLaunchError; + QTimer m_pendingLaunchTimer; }; diff --git a/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml b/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml index 2aa43185..8af494af 100644 --- a/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml +++ b/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml @@ -22,6 +22,7 @@ Window { id: root required property var folio + property int launchTaskBaseline: 0 signal gameStarted() signal dismissRequested() @@ -51,14 +52,14 @@ Window { function launchGame(index) { pulsePrimaryGamepad(14000, 22000, 80) + launchTaskBaseline = runningGames.taskCount GamingShell.GameLauncherProvider.launch(index) - launchFade.restart() } function launchGameByStorageId(storageId) { pulsePrimaryGamepad(14000, 22000, 80) + launchTaskBaseline = runningGames.taskCount GamingShell.GameLauncherProvider.launchByStorageId(storageId) - launchFade.restart() } width: Screen.width @@ -79,6 +80,25 @@ Window { NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutQuad } } + Connections { + target: GamingShell.GameLauncherProvider + + function onGameLaunched(name) { + launchFade.restart() + } + + function onGameLaunchFailed(name, error) { + launchErrorTimer.restart() + } + } + + Timer { + id: launchErrorTimer + interval: 6000 + repeat: false + onTriggered: GamingShell.GameLauncherProvider.clearLastLaunchError() + } + onVisibleChanged: { if (visible) { GamingShell.GameLauncherProvider.filterString = "" @@ -376,15 +396,49 @@ Window { RunningGamesView { id: runningGames Layout.fillWidth: true - onTaskActivated: root.gameStarted() + onTaskActivated: { + GamingShell.GameLauncherProvider.clearPendingLaunch() + root.gameStarted() + } onMoveDownRequested: grid.forceActiveFocus() + onTaskCountChanged: { + if (GamingShell.GameLauncherProvider.launchPending + && taskCount > root.launchTaskBaseline) { + GamingShell.GameLauncherProvider.clearPendingLaunch() + } + } + } + + Kirigami.InlineMessage { + Layout.fillWidth: true + type: Kirigami.MessageType.Error + text: GamingShell.GameLauncherProvider.lastLaunchError + visible: text.length > 0 + } + + RowLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + visible: GamingShell.GameLauncherProvider.launchPending + + Kirigami.Icon { + implicitWidth: Kirigami.Units.iconSizes.small + implicitHeight: Kirigami.Units.iconSizes.small + source: "system-run" + } + + PC3.Label { + Layout.fillWidth: true + text: i18n("Launching %1…", GamingShell.GameLauncherProvider.pendingLaunchName) + opacity: 0.75 + } } // ---- continue playing ---- ColumnLayout { Layout.fillWidth: true spacing: Kirigami.Units.smallSpacing - visible: recentList.count > 0 && !runningGames.hasTasks + visible: recentList.count > 0 Kirigami.Heading { level: 2 diff --git a/containments/homescreens/folio/qml/gaming/GamingQuickSettings.qml b/containments/homescreens/folio/qml/gaming/GamingQuickSettings.qml index 8bac0afd..ea38664e 100644 --- a/containments/homescreens/folio/qml/gaming/GamingQuickSettings.qml +++ b/containments/homescreens/folio/qml/gaming/GamingQuickSettings.qml @@ -520,8 +520,8 @@ Item { } PC3.Label { text: GamingShell.GameModeControl.active - ? i18n("GameMode active") - : i18n("GameMode standby") + ? i18n("GameMode requested") + : i18n("GameMode not requested") opacity: 0.7 } } diff --git a/containments/homescreens/folio/qml/gaming/RunningGamesView.qml b/containments/homescreens/folio/qml/gaming/RunningGamesView.qml index 608d080d..4f6cae2a 100644 --- a/containments/homescreens/folio/qml/gaming/RunningGamesView.qml +++ b/containments/homescreens/folio/qml/gaming/RunningGamesView.qml @@ -16,6 +16,7 @@ Item { implicitHeight: taskList.count > 0 ? column.implicitHeight : 0 readonly property bool hasTasks: taskList.count > 0 + readonly property int taskCount: taskList.count signal taskActivated() signal moveDownRequested()