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.
This commit is contained in:
Marco Allegretti 2026-04-22 11:36:49 +02:00
parent d913c2ab0a
commit 4dd0e17afb
5 changed files with 419 additions and 49 deletions

View file

@ -5,6 +5,7 @@
#include <KConfigGroup> #include <KConfigGroup>
#include <KIO/ApplicationLauncherJob> #include <KIO/ApplicationLauncherJob>
#include <KJob>
#include <KService> #include <KService>
#include <KSharedConfig> #include <KSharedConfig>
#include <KShell> #include <KShell>
@ -13,6 +14,7 @@
#include <QDebug> #include <QDebug>
#include <QDir> #include <QDir>
#include <QFile> #include <QFile>
#include <QHash>
#include <QJsonArray> #include <QJsonArray>
#include <QJsonDocument> #include <QJsonDocument>
#include <QJsonObject> #include <QJsonObject>
@ -23,13 +25,203 @@
#include <QStandardPaths> #include <QStandardPaths>
#include <QTextStream> #include <QTextStream>
#include <memory>
static const QString s_recentGroup = QStringLiteral("GamingRecentlyPlayed"); static const QString s_recentGroup = QStringLiteral("GamingRecentlyPlayed");
namespace
{
struct VdfNode {
QHash<QString, QString> values;
QHash<QString, std::shared_ptr<VdfNode>> 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<VdfNode *> 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<VdfNode>();
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) GameLauncherProvider::GameLauncherProvider(QObject *parent)
: QAbstractListModel(parent) : QAbstractListModel(parent)
, m_config(KSharedConfig::openConfig(QStringLiteral("plasmamobilerc"))) , m_config(KSharedConfig::openConfig(QStringLiteral("plasmamobilerc")))
{ {
connect(KSycoca::self(), &KSycoca::databaseChanged, this, &GameLauncherProvider::refresh); 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(); refresh();
} }
@ -86,6 +278,21 @@ bool GameLauncherProvider::loading() const
return m_loading; 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() void GameLauncherProvider::refresh()
{ {
m_loading = true; m_loading = true;
@ -142,27 +349,45 @@ void GameLauncherProvider::launchByStorageId(const QString &storageId)
void GameLauncherProvider::launchEntry(GameEntry &entry) void GameLauncherProvider::launchEntry(GameEntry &entry)
{ {
clearLastLaunchError();
if (entry.source == QLatin1String("desktop")) { if (entry.source == QLatin1String("desktop")) {
auto service = KService::serviceByStorageId(entry.storageId); auto service = KService::serviceByStorageId(entry.storageId);
if (service) { if (!service) {
auto *job = new KIO::ApplicationLauncherJob(service); markLaunchFailed(entry.name, QStringLiteral("Desktop entry is no longer available"));
job->start(); 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("://"))) { } else if (entry.launchCommand.contains(QStringLiteral("://"))) {
// Protocol handler (e.g. heroic://launch/...) — open via xdg-open // 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 { } else {
QStringList parts = KShell::splitArgs(entry.launchCommand); QStringList parts = KShell::splitArgs(entry.launchCommand);
if (!parts.isEmpty()) { if (parts.isEmpty()) {
QString program = parts.takeFirst(); markLaunchFailed(entry.name, QStringLiteral("Launch command is empty"));
QProcess::startDetached(program, parts); return;
} }
}
Q_EMIT gameLaunched(entry.name); QString program = parts.takeFirst();
const auto now = QDateTime::currentDateTime(); if (!QProcess::startDetached(program, parts)) {
saveRecentTimestamp(entry.storageId, now); markLaunchFailed(entry.name, QStringLiteral("Unable to start %1").arg(program));
entry.lastPlayed = now; return;
}
markLaunchSucceeded(entry.storageId, entry.name);
}
} }
void GameLauncherProvider::deduplicateGames() void GameLauncherProvider::deduplicateGames()
@ -237,21 +462,30 @@ void GameLauncherProvider::loadSteamGames()
if (!vdf.open(QIODevice::ReadOnly | QIODevice::Text)) { if (!vdf.open(QIODevice::ReadOnly | QIODevice::Text)) {
continue; continue;
} }
// Simple parse: look for "path" lines const QString content = QString::fromUtf8(vdf.readAll());
static const QRegularExpression pathRe(QStringLiteral("\"path\"\\s+\"([^\"]+)\"")); VdfNode document;
QTextStream stream(&vdf); QString error;
while (!stream.atEnd()) { if (!parseVdf(content, document, &error)) {
const QString line = stream.readLine(); qWarning() << "GameLauncherProvider: cannot parse Steam libraryfolders" << vdfPath << error;
auto match = pathRe.match(line); continue;
if (match.hasMatch()) { }
libraryPaths.append(match.captured(1));
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 libraryPaths.removeDuplicates();
static const QRegularExpression nameRe(QStringLiteral("\"name\"\\s+\"([^\"]+)\""));
static const QRegularExpression appidRe(QStringLiteral("\"appid\"\\s+\"(\\d+)\""));
for (const auto &libPath : std::as_const(libraryPaths)) { for (const auto &libPath : std::as_const(libraryPaths)) {
QDir steamapps(libPath + QStringLiteral("/steamapps")); QDir steamapps(libPath + QStringLiteral("/steamapps"));
@ -264,28 +498,19 @@ void GameLauncherProvider::loadSteamGames()
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
continue; continue;
} }
QString appName; VdfNode manifestData;
QString appId; QString error;
QTextStream ts(&f); if (!parseVdf(QString::fromUtf8(f.readAll()), manifestData, &error)) {
while (!ts.atEnd()) { qWarning() << "GameLauncherProvider: cannot parse Steam manifest" << manifest << error;
const QString line = ts.readLine(); continue;
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;
}
} }
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()) { if (appName.isEmpty() || appId.isEmpty()) {
continue; continue;
} }
@ -593,6 +818,76 @@ void GameLauncherProvider::saveRecentTimestamp(const QString &storageId, const Q
group.sync(); 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 QVariantList GameLauncherProvider::recentGames(int limit) const
{ {
// Gather entries that have been launched at least once // Gather entries that have been launched at least once

View file

@ -7,6 +7,7 @@
#include <QDateTime> #include <QDateTime>
#include <QList> #include <QList>
#include <QString> #include <QString>
#include <QTimer>
#include <qqmlregistration.h> #include <qqmlregistration.h>
#include <KSharedConfig> #include <KSharedConfig>
@ -22,6 +23,9 @@ class GameLauncherProvider : public QAbstractListModel
Q_PROPERTY(QString filterString READ filterString WRITE setFilterString NOTIFY filterStringChanged) Q_PROPERTY(QString filterString READ filterString WRITE setFilterString NOTIFY filterStringChanged)
Q_PROPERTY(QString sourceFilter READ sourceFilter WRITE setSourceFilter NOTIFY sourceFilterChanged) Q_PROPERTY(QString sourceFilter READ sourceFilter WRITE setSourceFilter NOTIFY sourceFilterChanged)
Q_PROPERTY(bool overlayEnabled READ overlayEnabled WRITE setOverlayEnabled NOTIFY overlayEnabledChanged) 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: public:
explicit GameLauncherProvider(QObject *parent = nullptr); explicit GameLauncherProvider(QObject *parent = nullptr);
@ -49,11 +53,16 @@ public:
void setSourceFilter(const QString &source); void setSourceFilter(const QString &source);
bool overlayEnabled() const; bool overlayEnabled() const;
void setOverlayEnabled(bool enabled); void setOverlayEnabled(bool enabled);
bool launchPending() const;
QString pendingLaunchName() const;
QString lastLaunchError() const;
Q_INVOKABLE void refresh(); Q_INVOKABLE void refresh();
Q_INVOKABLE void launch(int index); Q_INVOKABLE void launch(int index);
Q_INVOKABLE void launchByStorageId(const QString &storageId); Q_INVOKABLE void launchByStorageId(const QString &storageId);
Q_INVOKABLE QVariantList recentGames(int limit = 5) const; Q_INVOKABLE QVariantList recentGames(int limit = 5) const;
Q_INVOKABLE void clearPendingLaunch();
Q_INVOKABLE void clearLastLaunchError();
Q_SIGNALS: Q_SIGNALS:
void countChanged(); void countChanged();
@ -61,7 +70,10 @@ Q_SIGNALS:
void filterStringChanged(); void filterStringChanged();
void sourceFilterChanged(); void sourceFilterChanged();
void overlayEnabledChanged(); void overlayEnabledChanged();
void launchPendingChanged();
void lastLaunchErrorChanged();
void gameLaunched(const QString &name); void gameLaunched(const QString &name);
void gameLaunchFailed(const QString &name, const QString &error);
private: private:
struct GameEntry { struct GameEntry {
@ -85,6 +97,10 @@ private:
void saveRecentTimestamp(const QString &storageId, const QDateTime &when); void saveRecentTimestamp(const QString &storageId, const QDateTime &when);
void applyFilter(); void applyFilter();
void launchEntry(GameEntry &entry); 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<GameEntry> m_allGames; QList<GameEntry> m_allGames;
QList<GameEntry> m_games; // filtered view QList<GameEntry> m_games; // filtered view
@ -93,4 +109,8 @@ private:
KSharedConfigPtr m_config; KSharedConfigPtr m_config;
bool m_loading = false; bool m_loading = false;
bool m_overlayEnabled = false; bool m_overlayEnabled = false;
bool m_launchPending = false;
QString m_pendingLaunchName;
QString m_lastLaunchError;
QTimer m_pendingLaunchTimer;
}; };

View file

@ -22,6 +22,7 @@ Window {
id: root id: root
required property var folio required property var folio
property int launchTaskBaseline: 0
signal gameStarted() signal gameStarted()
signal dismissRequested() signal dismissRequested()
@ -51,14 +52,14 @@ Window {
function launchGame(index) { function launchGame(index) {
pulsePrimaryGamepad(14000, 22000, 80) pulsePrimaryGamepad(14000, 22000, 80)
launchTaskBaseline = runningGames.taskCount
GamingShell.GameLauncherProvider.launch(index) GamingShell.GameLauncherProvider.launch(index)
launchFade.restart()
} }
function launchGameByStorageId(storageId) { function launchGameByStorageId(storageId) {
pulsePrimaryGamepad(14000, 22000, 80) pulsePrimaryGamepad(14000, 22000, 80)
launchTaskBaseline = runningGames.taskCount
GamingShell.GameLauncherProvider.launchByStorageId(storageId) GamingShell.GameLauncherProvider.launchByStorageId(storageId)
launchFade.restart()
} }
width: Screen.width width: Screen.width
@ -79,6 +80,25 @@ Window {
NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutQuad } 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: { onVisibleChanged: {
if (visible) { if (visible) {
GamingShell.GameLauncherProvider.filterString = "" GamingShell.GameLauncherProvider.filterString = ""
@ -376,15 +396,49 @@ Window {
RunningGamesView { RunningGamesView {
id: runningGames id: runningGames
Layout.fillWidth: true Layout.fillWidth: true
onTaskActivated: root.gameStarted() onTaskActivated: {
GamingShell.GameLauncherProvider.clearPendingLaunch()
root.gameStarted()
}
onMoveDownRequested: grid.forceActiveFocus() 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 ---- // ---- continue playing ----
ColumnLayout { ColumnLayout {
Layout.fillWidth: true Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing spacing: Kirigami.Units.smallSpacing
visible: recentList.count > 0 && !runningGames.hasTasks visible: recentList.count > 0
Kirigami.Heading { Kirigami.Heading {
level: 2 level: 2

View file

@ -520,8 +520,8 @@ Item {
} }
PC3.Label { PC3.Label {
text: GamingShell.GameModeControl.active text: GamingShell.GameModeControl.active
? i18n("GameMode active") ? i18n("GameMode requested")
: i18n("GameMode standby") : i18n("GameMode not requested")
opacity: 0.7 opacity: 0.7
} }
} }

View file

@ -16,6 +16,7 @@ Item {
implicitHeight: taskList.count > 0 ? column.implicitHeight : 0 implicitHeight: taskList.count > 0 ? column.implicitHeight : 0
readonly property bool hasTasks: taskList.count > 0 readonly property bool hasTasks: taskList.count > 0
readonly property int taskCount: taskList.count
signal taskActivated() signal taskActivated()
signal moveDownRequested() signal moveDownRequested()