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()