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 <KIO/ApplicationLauncherJob>
#include <KJob>
#include <KService>
#include <KSharedConfig>
#include <KShell>
@ -13,6 +14,7 @@
#include <QDebug>
#include <QDir>
#include <QFile>
#include <QHash>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
@ -23,13 +25,203 @@
#include <QStandardPaths>
#include <QTextStream>
#include <memory>
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)
: 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

View file

@ -7,6 +7,7 @@
#include <QDateTime>
#include <QList>
#include <QString>
#include <QTimer>
#include <qqmlregistration.h>
#include <KSharedConfig>
@ -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<GameEntry> m_allGames;
QList<GameEntry> 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;
};

View file

@ -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

View file

@ -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
}
}

View file

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