diff --git a/components/gamingshellplugin/gamelauncherprovider.cpp b/components/gamingshellplugin/gamelauncherprovider.cpp index d37e66cf..5e343249 100644 --- a/components/gamingshellplugin/gamelauncherprovider.cpp +++ b/components/gamingshellplugin/gamelauncherprovider.cpp @@ -18,7 +18,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -28,6 +30,8 @@ #include static const QString s_recentGroup = QStringLiteral("GamingRecentlyPlayed"); +static const QString s_pinnedGroup = QStringLiteral("GamingPinned"); +static const QString s_perGameGroup = QStringLiteral("GamingPerGame"); static const QString s_waydroidGamingGroup = QStringLiteral("WaydroidGaming"); static const QString s_gameShellPackagesKey = QStringLiteral("gameShellPackages"); @@ -266,6 +270,9 @@ GameLauncherProvider::GameLauncherProvider(QObject *parent) m_pendingLaunchTimer.setInterval(15000); m_pendingLaunchTimer.setSingleShot(true); connect(&m_pendingLaunchTimer, &QTimer::timeout, this, &GameLauncherProvider::clearPendingLaunch); + m_mangohudPath = QStandardPaths::findExecutable(QStringLiteral("mangohud")); + m_mangohudAvailable = !m_mangohudPath.isEmpty(); + loadPinnedGames(); refresh(); } @@ -291,10 +298,16 @@ QVariant GameLauncherProvider::data(const QModelIndex &index, int role) const return g.storageId; case LaunchCommandRole: return g.launchCommand; + case LaunchMethodRole: + return launchMethodForEntry(g); case ArtworkRole: return g.artwork; + case LastPlayedTextRole: + return formatLastPlayed(g.lastPlayed); case InstalledRole: return g.installed; + case PinnedRole: + return m_pinnedGames.contains(g.storageId); } return {}; } @@ -307,8 +320,11 @@ QHash GameLauncherProvider::roleNames() const {SourceRole, "source"}, {StorageIdRole, "storageId"}, {LaunchCommandRole, "launchCommand"}, + {LaunchMethodRole, "launchMethod"}, {ArtworkRole, "artwork"}, + {LastPlayedTextRole, "lastPlayedText"}, {InstalledRole, "installed"}, + {PinnedRole, "pinned"}, }; } @@ -391,10 +407,68 @@ void GameLauncherProvider::launchByStorageId(const QString &storageId) } } +QVariantMap GameLauncherProvider::gameDetails(const QString &storageId) const +{ + const int entryIndex = findEntryIndexByStorageId(storageId); + if (entryIndex < 0) { + return {}; + } + + const auto &entry = m_allGames.at(entryIndex); + return { + {QStringLiteral("name"), entry.name}, + {QStringLiteral("icon"), entry.icon}, + {QStringLiteral("source"), entry.source}, + {QStringLiteral("storageId"), entry.storageId}, + {QStringLiteral("launchMethod"), launchMethodForEntry(entry)}, + {QStringLiteral("artwork"), entry.artwork}, + {QStringLiteral("lastPlayedText"), formatLastPlayed(entry.lastPlayed)}, + {QStringLiteral("installed"), entry.installed}, + {QStringLiteral("pinned"), m_pinnedGames.contains(entry.storageId)}, + {QStringLiteral("perGameFpsLimit"), perGameFpsLimit(entry.storageId)}, + {QStringLiteral("perGameOverlayState"), perGameOverlayState(entry.storageId)}, + }; +} + +bool GameLauncherProvider::openSourceApp(const QString &source) +{ + QString program; + QString displayName; + + if (source == QLatin1String("steam")) { + program = QStringLiteral("steam"); + displayName = QStringLiteral("Steam"); + } else if (source == QLatin1String("lutris")) { + program = QStringLiteral("lutris"); + displayName = QStringLiteral("Lutris"); + } else if (source == QLatin1String("heroic")) { + program = QStringLiteral("heroic"); + displayName = QStringLiteral("Heroic"); + } else { + return false; + } + + clearLastLaunchError(); + + if (!QProcess::startDetached(program, {})) { + markLaunchFailed(displayName, QStringLiteral("Unable to start %1").arg(program)); + return false; + } + + return true; +} + void GameLauncherProvider::launchEntry(GameEntry &entry) { clearLastLaunchError(); + const KConfigGroup pgParent(m_config.data(), s_perGameGroup); + const KConfigGroup pgGroup(&pgParent, entry.storageId); + const int pgFpsLimit = pgGroup.readEntry("fpsLimit", -1); + const int pgOverlayState = pgGroup.readEntry("overlayState", -1); + const int effectiveFps = (pgFpsLimit >= 0) ? pgFpsLimit : m_fpsLimit; + const bool effectiveOverlay = (pgOverlayState >= 0) ? (pgOverlayState == 1) : m_overlayEnabled; + if (entry.source == QLatin1String("desktop") || entry.source == QLatin1String("waydroid")) { auto service = KService::serviceByStorageId(entry.storageId); if (!service) { @@ -402,6 +476,27 @@ void GameLauncherProvider::launchEntry(GameEntry &entry) return; } + // Wrap native desktop entries with mangohud when overlay or FPS cap is active. + // Waydroid (Android) games don't benefit from it, so skip them. + if (entry.source == QLatin1String("desktop") && m_mangohudAvailable && (effectiveOverlay || effectiveFps > 0)) { + QString exec = service->exec(); + // Strip KDE .desktop field-code placeholders (%u, %U, %f, %F, %i, %c, %k …) + exec.remove(QRegularExpression(QStringLiteral("%[a-zA-Z]"))); + KShell::Errors parseError = KShell::NoError; + QStringList parts = KShell::splitArgs(exec.simplified(), KShell::NoOptions, &parseError); + if (parseError == KShell::NoError && !parts.isEmpty()) { + const QString program = parts.takeFirst(); + qint64 pid = 0; + if (launchWithMangohud(program, parts, effectiveOverlay, effectiveFps, &pid)) { + markLaunchSucceeded(entry.storageId, entry.name); + } else { + markLaunchFailed(entry.name, QStringLiteral("Failed to launch %1 with mangohud").arg(program)); + } + return; + } + // Exec parsing failed — fall through to the standard KIO job without overlay. + } + auto *job = new KIO::ApplicationLauncherJob(service); connect(job, &KJob::result, this, [this, job, storageId = entry.storageId, name = entry.name]() { if (job->error() != 0) { @@ -426,7 +521,13 @@ void GameLauncherProvider::launchEntry(GameEntry &entry) } QString program = parts.takeFirst(); - if (!QProcess::startDetached(program, parts)) { + if (m_mangohudAvailable && (effectiveOverlay || effectiveFps > 0)) { + qint64 pid = 0; + if (!launchWithMangohud(program, parts, effectiveOverlay, effectiveFps, &pid)) { + markLaunchFailed(entry.name, QStringLiteral("Unable to start %1 with mangohud").arg(program)); + return; + } + } else if (!QProcess::startDetached(program, parts)) { markLaunchFailed(entry.name, QStringLiteral("Unable to start %1").arg(program)); return; } @@ -818,6 +919,46 @@ bool GameLauncherProvider::overlayEnabled() const return m_overlayEnabled; } +bool GameLauncherProvider::mangohudAvailable() const +{ + return m_mangohudAvailable; +} + +int GameLauncherProvider::fpsLimit() const +{ + return m_fpsLimit; +} + +void GameLauncherProvider::setFpsLimit(int limit) +{ + if (m_fpsLimit == limit) { + return; + } + m_fpsLimit = limit; + Q_EMIT fpsLimitChanged(); +} + +bool GameLauncherProvider::launchWithMangohud(const QString &program, const QStringList &args, bool overlayEnabled, int fpsLimit, qint64 *pid) +{ + QStringList config; + if (!overlayEnabled) { + config << QStringLiteral("no_display"); + } + if (fpsLimit > 0) { + config << QStringLiteral("fps_limit=%1").arg(fpsLimit); + } + + QProcess proc; + if (!config.isEmpty()) { + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert(QStringLiteral("MANGOHUD_CONFIG"), config.join(QLatin1Char(','))); + proc.setProcessEnvironment(env); + } + proc.setProgram(m_mangohudPath); + proc.setArguments(QStringList{program} + args); + return proc.startDetached(pid); +} + void GameLauncherProvider::setOverlayEnabled(bool enabled) { if (m_overlayEnabled == enabled) { @@ -825,15 +966,6 @@ void GameLauncherProvider::setOverlayEnabled(bool enabled) } m_overlayEnabled = enabled; Q_EMIT overlayEnabledChanged(); - - // Set/unset MangoHud environment variables for child processes - if (enabled) { - qputenv("MANGOHUD", "1"); - qputenv("MANGOHUD_DLSYM", "1"); - } else { - qunsetenv("MANGOHUD"); - qunsetenv("MANGOHUD_DLSYM"); - } } void GameLauncherProvider::applyFilter() @@ -851,6 +983,10 @@ void GameLauncherProvider::applyFilter() m_games.append(g); } + std::stable_sort(m_games.begin(), m_games.end(), [this](const GameEntry &a, const GameEntry &b) { + return m_pinnedGames.contains(a.storageId) > m_pinnedGames.contains(b.storageId); + }); + endResetModel(); Q_EMIT countChanged(); } @@ -873,6 +1009,129 @@ void GameLauncherProvider::saveRecentTimestamp(const QString &storageId, const Q group.sync(); } +void GameLauncherProvider::loadPinnedGames() +{ + const KConfigGroup group(m_config, s_pinnedGroup); + const QStringList list = group.readEntry(QStringLiteral("pinned"), QStringList{}); + m_pinnedGames = QSet(list.begin(), list.end()); +} + +void GameLauncherProvider::togglePin(const QString &storageId) +{ + if (storageId.isEmpty()) { + return; + } + if (m_pinnedGames.contains(storageId)) { + m_pinnedGames.remove(storageId); + } else { + m_pinnedGames.insert(storageId); + } + KConfigGroup group(m_config, s_pinnedGroup); + group.writeEntry(QStringLiteral("pinned"), QStringList(m_pinnedGames.begin(), m_pinnedGames.end())); + group.sync(); + applyFilter(); +} + +int GameLauncherProvider::perGameFpsLimit(const QString &storageId) const +{ + const KConfigGroup parent(m_config.data(), s_perGameGroup); + const KConfigGroup group(&parent, storageId); + return group.readEntry("fpsLimit", -1); +} + +void GameLauncherProvider::setPerGameFpsLimit(const QString &storageId, int limit) +{ + if (storageId.isEmpty()) { + return; + } + KConfigGroup parent(m_config.data(), s_perGameGroup); + KConfigGroup group(&parent, storageId); + if (limit < 0) { + group.deleteEntry("fpsLimit"); + } else { + group.writeEntry("fpsLimit", limit); + } + group.sync(); +} + +int GameLauncherProvider::perGameOverlayState(const QString &storageId) const +{ + const KConfigGroup parent(m_config.data(), s_perGameGroup); + const KConfigGroup group(&parent, storageId); + return group.readEntry("overlayState", -1); +} + +void GameLauncherProvider::setPerGameOverlayState(const QString &storageId, int state) +{ + if (storageId.isEmpty()) { + return; + } + KConfigGroup parent(m_config.data(), s_perGameGroup); + KConfigGroup group(&parent, storageId); + if (state < 0) { + group.deleteEntry("overlayState"); + } else { + group.writeEntry("overlayState", state); + } + group.sync(); +} + +void GameLauncherProvider::clearLastPlayed(const QString &storageId) +{ + if (storageId.isEmpty()) { + return; + } + + KConfigGroup group(m_config, s_recentGroup); + if (!group.hasKey(storageId)) { + return; + } + group.deleteEntry(storageId); + group.sync(); + + const int entryIndex = findEntryIndexByStorageId(storageId); + if (entryIndex >= 0) { + m_allGames[entryIndex].lastPlayed = QDateTime(); + const int filteredIndex = [&] { + for (int i = 0; i < m_games.size(); ++i) { + if (m_games.at(i).storageId == storageId) { + return i; + } + } + return -1; + }(); + if (filteredIndex >= 0) { + m_games[filteredIndex].lastPlayed = QDateTime(); + const QModelIndex idx = index(filteredIndex); + Q_EMIT dataChanged(idx, idx, {LastPlayedTextRole}); + } + } + + Q_EMIT recentGamesChanged(); +} + +QString GameLauncherProvider::launchMethodForEntry(const GameEntry &entry) const +{ + if (entry.source == QLatin1String("desktop") || entry.source == QLatin1String("waydroid")) { + return QStringLiteral("desktop-entry"); + } + + if (entry.launchCommand.contains(QStringLiteral("://"))) { + return QStringLiteral("protocol"); + } + + return QStringLiteral("command"); +} + +QString GameLauncherProvider::formatLastPlayed(const QDateTime &when) const +{ + if (!when.isValid()) { + return {}; + } + + return QLocale().toString(when, QLocale::ShortFormat); +} + void GameLauncherProvider::clearPendingLaunch() { if (!m_launchPending && m_pendingLaunchName.isEmpty()) { @@ -917,6 +1176,7 @@ void GameLauncherProvider::markLaunchSucceeded(const QString &storageId, const Q setPendingLaunch(name); Q_EMIT gameLaunched(name); + Q_EMIT recentGamesChanged(); } void GameLauncherProvider::markLaunchFailed(const QString &name, const QString &error) diff --git a/components/gamingshellplugin/gamelauncherprovider.h b/components/gamingshellplugin/gamelauncherprovider.h index 675e00f2..2a6b0550 100644 --- a/components/gamingshellplugin/gamelauncherprovider.h +++ b/components/gamingshellplugin/gamelauncherprovider.h @@ -6,8 +6,10 @@ #include #include #include +#include #include #include +#include #include #include @@ -24,6 +26,8 @@ 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 mangohudAvailable READ mangohudAvailable NOTIFY mangohudAvailableChanged) + Q_PROPERTY(int fpsLimit READ fpsLimit WRITE setFpsLimit NOTIFY fpsLimitChanged) Q_PROPERTY(bool launchPending READ launchPending NOTIFY launchPendingChanged) Q_PROPERTY(QString pendingLaunchName READ pendingLaunchName NOTIFY launchPendingChanged) Q_PROPERTY(QString lastLaunchError READ lastLaunchError NOTIFY lastLaunchErrorChanged) @@ -37,8 +41,11 @@ public: SourceRole, // "desktop", "waydroid", "steam", "flatpak" StorageIdRole, // .desktop file name or launch URI LaunchCommandRole, + LaunchMethodRole, ArtworkRole, // path to banner/grid image (empty if none) + LastPlayedTextRole, InstalledRole, + PinnedRole, }; Q_ENUM(Roles) @@ -54,6 +61,9 @@ public: void setSourceFilter(const QString &source); bool overlayEnabled() const; void setOverlayEnabled(bool enabled); + bool mangohudAvailable() const; + int fpsLimit() const; + void setFpsLimit(int limit); bool launchPending() const; QString pendingLaunchName() const; QString lastLaunchError() const; @@ -61,9 +71,17 @@ public: Q_INVOKABLE void refresh(); Q_INVOKABLE void launch(int index); Q_INVOKABLE void launchByStorageId(const QString &storageId); + Q_INVOKABLE QVariantMap gameDetails(const QString &storageId) const; + Q_INVOKABLE bool openSourceApp(const QString &source); + Q_INVOKABLE void clearLastPlayed(const QString &storageId); Q_INVOKABLE QVariantList recentGames(int limit = 5) const; Q_INVOKABLE void clearPendingLaunch(); Q_INVOKABLE void clearLastLaunchError(); + Q_INVOKABLE void togglePin(const QString &storageId); + Q_INVOKABLE int perGameFpsLimit(const QString &storageId) const; + Q_INVOKABLE void setPerGameFpsLimit(const QString &storageId, int limit); + Q_INVOKABLE int perGameOverlayState(const QString &storageId) const; + Q_INVOKABLE void setPerGameOverlayState(const QString &storageId, int state); Q_SIGNALS: void countChanged(); @@ -71,10 +89,13 @@ Q_SIGNALS: void filterStringChanged(); void sourceFilterChanged(); void overlayEnabledChanged(); + void mangohudAvailableChanged(); + void fpsLimitChanged(); void launchPendingChanged(); void lastLaunchErrorChanged(); void gameLaunched(const QString &name); void gameLaunchFailed(const QString &name, const QString &error); + void recentGamesChanged(); private: struct GameEntry { @@ -98,12 +119,16 @@ private: void saveRecentTimestamp(const QString &storageId, const QDateTime &when); void applyFilter(); void launchEntry(GameEntry &entry); + bool launchWithMangohud(const QString &program, const QStringList &args, bool overlayEnabled, int fpsLimit, qint64 *pid = nullptr); + QString launchMethodForEntry(const GameEntry &entry) const; + QString formatLastPlayed(const QDateTime &when) const; // Returns the current m_allGames index for the storage id. // Callers must re-lookup after any mutation that can rebuild or reorder the list. int findEntryIndexByStorageId(const QString &storageId) const; void markLaunchSucceeded(const QString &storageId, const QString &name); void markLaunchFailed(const QString &name, const QString &error); void setPendingLaunch(const QString &name); + void loadPinnedGames(); QList m_allGames; QList m_games; // filtered view @@ -112,6 +137,10 @@ private: KSharedConfigPtr m_config; bool m_loading = false; bool m_overlayEnabled = false; + int m_fpsLimit = 0; + bool m_mangohudAvailable = false; + QString m_mangohudPath; + QSet m_pinnedGames; bool m_launchPending = false; QString m_pendingLaunchName; QString m_lastLaunchError; diff --git a/containments/homescreens/folio/qml/gaming/GamingQuickSettings.qml b/containments/homescreens/folio/qml/gaming/GamingQuickSettings.qml index ea38664e..a623c310 100644 --- a/containments/homescreens/folio/qml/gaming/GamingQuickSettings.qml +++ b/containments/homescreens/folio/qml/gaming/GamingQuickSettings.qml @@ -505,7 +505,45 @@ Item { id: overlaySwitch text: i18n("Perf Overlay") checked: GamingShell.GameLauncherProvider.overlayEnabled + enabled: GamingShell.GameLauncherProvider.mangohudAvailable + opacity: enabled ? 1.0 : 0.5 onToggled: GamingShell.GameLauncherProvider.overlayEnabled = checked + + QQC2.ToolTip.visible: !GamingShell.GameLauncherProvider.mangohudAvailable && hovered + QQC2.ToolTip.text: i18n("MangoHud is not installed") + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + } + + // FPS cap — spans both columns, driven by MangoHud fps_limit + QQC2.ButtonGroup { id: fpsCap; exclusive: true } + + RowLayout { + Layout.columnSpan: 2 + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + enabled: GamingShell.GameLauncherProvider.mangohudAvailable + opacity: enabled ? 1.0 : 0.5 + + PC3.Label { text: i18n("FPS Cap") } + Item { Layout.fillWidth: true } + + Repeater { + model: [ + { label: i18nc("@action:button FPS cap off", "Off"), fps: 0 }, + { label: "30", fps: 30 }, + { label: "40", fps: 40 }, + { label: "60", fps: 60 } + ] + delegate: QQC2.Button { + required property var modelData + text: modelData.label + flat: true + checkable: true + checked: GamingShell.GameLauncherProvider.fpsLimit === modelData.fps + QQC2.ButtonGroup.group: fpsCap + onClicked: GamingShell.GameLauncherProvider.fpsLimit = modelData.fps + } + } } // GameMode status (auto-managed, read-only indicator)