Show Waydroid apps in Game Center

Read the Waydroid allowlist from plasmamobilerc and treat matching
Waydroid launchers as their own source in the gaming shell.

Surface that source in Game Center so Android titles get their own tab,
source chip, and empty-state guidance.
This commit is contained in:
Marco Allegretti 2026-04-24 09:40:53 +02:00
parent a8e76002f4
commit 26945e83fa
3 changed files with 127 additions and 18 deletions

View file

@ -28,6 +28,8 @@
#include <memory>
static const QString s_recentGroup = QStringLiteral("GamingRecentlyPlayed");
static const QString s_waydroidGamingGroup = QStringLiteral("WaydroidGaming");
static const QString s_gameShellPackagesKey = QStringLiteral("gameShellPackages");
namespace
{
@ -224,6 +226,29 @@ bool parseVdf(const QString &input, VdfNode &root, QString *error)
return false;
}
}
QString waydroidPackageFromService(const KService::Ptr &service)
{
static const QRegularExpression execPattern(QStringLiteral("^waydroid\\s+app\\s+launch\\s+([^\\s%]+)"));
const QRegularExpressionMatch execMatch = execPattern.match(service->exec());
if (execMatch.hasMatch()) {
return execMatch.captured(1);
}
static const QRegularExpression storageIdPattern(QStringLiteral("^waydroid\\.(.+)\\.desktop$"));
const QRegularExpressionMatch storageIdMatch = storageIdPattern.match(service->storageId());
if (!storageIdMatch.hasMatch()) {
return {};
}
return storageIdMatch.captured(1);
}
QStringList waydroidGameShellPackages(const KSharedConfigPtr &config)
{
const KConfigGroup group(config, s_waydroidGamingGroup);
return group.readEntry(s_gameShellPackagesKey, QStringList{});
}
} // namespace
GameLauncherProvider::GameLauncherProvider(QObject *parent)
@ -231,6 +256,13 @@ GameLauncherProvider::GameLauncherProvider(QObject *parent)
, m_config(KSharedConfig::openConfig(QStringLiteral("plasmamobilerc")))
{
connect(KSycoca::self(), &KSycoca::databaseChanged, this, &GameLauncherProvider::refresh);
m_configWatcher = KConfigWatcher::create(m_config);
connect(m_configWatcher.data(), &KConfigWatcher::configChanged, this, [this](const KConfigGroup &group) {
if (group.name() == s_waydroidGamingGroup) {
m_config->reparseConfiguration();
refresh();
}
});
m_pendingLaunchTimer.setInterval(15000);
m_pendingLaunchTimer.setSingleShot(true);
connect(&m_pendingLaunchTimer, &QTimer::timeout, this, &GameLauncherProvider::clearPendingLaunch);
@ -363,7 +395,7 @@ void GameLauncherProvider::launchEntry(GameEntry &entry)
{
clearLastLaunchError();
if (entry.source == QLatin1String("desktop")) {
if (entry.source == QLatin1String("desktop") || entry.source == QLatin1String("waydroid")) {
auto service = KService::serviceByStorageId(entry.storageId);
if (!service) {
markLaunchFailed(entry.name, QStringLiteral("Desktop entry is no longer available"));
@ -426,6 +458,8 @@ void GameLauncherProvider::deduplicateGames()
void GameLauncherProvider::loadDesktopGames()
{
const QStringList allowedWaydroidPackages = waydroidGameShellPackages(m_config);
const QSet<QString> enabledWaydroidPackages(allowedWaydroidPackages.cbegin(), allowedWaydroidPackages.cend());
const auto services = KService::allServices();
for (const auto &service : services) {
if (service->noDisplay() || service->exec().isEmpty()) {
@ -433,20 +467,29 @@ void GameLauncherProvider::loadDesktopGames()
}
const QStringList cats = service->categories();
bool isGame = false;
bool isWaydroidApp = false;
for (const auto &cat : cats) {
if (cat.compare(QLatin1String("Game"), Qt::CaseInsensitive) == 0) {
isGame = true;
break;
} else if (cat.compare(QLatin1String("X-WayDroid-App"), Qt::CaseInsensitive) == 0) {
isWaydroidApp = true;
}
}
if (!isGame) {
continue;
if (!isWaydroidApp) {
continue;
}
const QString packageName = waydroidPackageFromService(service);
if (packageName.isEmpty() || !enabledWaydroidPackages.contains(packageName)) {
continue;
}
}
GameEntry entry;
entry.name = service->name();
entry.icon = service->icon();
entry.source = QStringLiteral("desktop");
entry.source = isWaydroidApp ? QStringLiteral("waydroid") : QStringLiteral("desktop");
entry.storageId = service->storageId();
entry.launchCommand = service->exec();
entry.installed = true;

View file

@ -10,6 +10,7 @@
#include <QTimer>
#include <qqmlregistration.h>
#include <KConfigWatcher>
#include <KSharedConfig>
class GameLauncherProvider : public QAbstractListModel
@ -33,7 +34,7 @@ public:
enum Roles {
NameRole = Qt::UserRole + 1,
IconRole,
SourceRole, // "desktop", "steam", "flatpak"
SourceRole, // "desktop", "waydroid", "steam", "flatpak"
StorageIdRole, // .desktop file name or launch URI
LaunchCommandRole,
ArtworkRole, // path to banner/grid image (empty if none)
@ -107,7 +108,7 @@ private:
QList<GameEntry> m_allGames;
QList<GameEntry> m_games; // filtered view
QString m_filterString;
QString m_sourceFilter; // empty = all, or "desktop"/"steam"/"flatpak"
QString m_sourceFilter; // empty = all, or a specific source such as "desktop" or "waydroid"
KSharedConfigPtr m_config;
bool m_loading = false;
bool m_overlayEnabled = false;
@ -115,4 +116,5 @@ private:
QString m_pendingLaunchName;
QString m_lastLaunchError;
QTimer m_pendingLaunchTimer;
KConfigWatcher::Ptr m_configWatcher;
};

View file

@ -288,8 +288,8 @@ Window {
}
}
// Cycle through source filter tabs (All Steam Desktop All )
readonly property var _sourceFilters: ["", "steam", "desktop", "lutris", "heroic"]
// Cycle through source filter tabs.
readonly property var _sourceFilters: ["", "steam", "desktop", "waydroid", "lutris", "heroic"]
function cycleSourceFilter(direction) {
var current = _sourceFilters.indexOf(
GamingShell.GameLauncherProvider.sourceFilter)
@ -300,6 +300,40 @@ Window {
sourceFilterBar.currentIndex = next
}
function sourceLabel(source) {
switch (source) {
case "steam":
return i18n("Steam")
case "waydroid":
return i18n("Waydroid")
case "lutris":
return i18n("Lutris")
case "heroic":
return i18n("Heroic")
case "flatpak":
return i18n("Flatpak")
default:
return ""
}
}
function sourceChipColor(source) {
switch (source) {
case "steam":
return Qt.rgba(0.12, 0.23, 0.38, 0.9)
case "waydroid":
return Qt.rgba(0.13, 0.42, 0.36, 0.92)
case "lutris":
return Qt.rgba(0.42, 0.25, 0.11, 0.9)
case "heroic":
return Qt.rgba(0.37, 0.19, 0.16, 0.9)
case "flatpak":
return Qt.rgba(0.16, 0.26, 0.46, 0.9)
default:
return Qt.rgba(0.2, 0.2, 0.2, 0.72)
}
}
Rectangle {
anchors.fill: parent
Kirigami.Theme.inherit: false
@ -543,6 +577,11 @@ Window {
width: implicitWidth
onClicked: GamingShell.GameLauncherProvider.sourceFilter = "desktop"
}
QQC2.TabButton {
text: i18n("Waydroid")
width: implicitWidth
onClicked: GamingShell.GameLauncherProvider.sourceFilter = "waydroid"
}
QQC2.TabButton {
text: "Lutris"
width: implicitWidth
@ -586,7 +625,7 @@ Window {
: i18n("No games found")
explanation: searchField.text.length > 0
? ""
: i18n("Install games or check that they have the Game category in their .desktop file")
: i18n("Install games, or enable supported Waydroid apps from the Waydroid applications page")
}
onActiveFocusChanged: {
@ -657,6 +696,26 @@ Window {
smooth: true
asynchronous: true
}
Rectangle {
anchors.top: parent.top
anchors.left: parent.left
anchors.margins: Kirigami.Units.smallSpacing
visible: source !== "desktop"
radius: height / 2
color: root.sourceChipColor(source)
implicitHeight: chipLabel.implicitHeight + Kirigami.Units.smallSpacing
implicitWidth: chipLabel.implicitWidth + Kirigami.Units.largeSpacing
PC3.Label {
id: chipLabel
anchors.centerIn: parent
text: root.sourceLabel(source)
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.72
font.weight: Font.DemiBold
color: "white"
}
}
}
// Title beneath artwork
@ -710,17 +769,22 @@ Window {
: Kirigami.Theme.textColor
}
// Source badge
PC3.Label {
Rectangle {
Layout.alignment: Qt.AlignHCenter
text: source === "steam" ? "Steam"
: source === "flatpak" ? "Flatpak"
: source === "lutris" ? "Lutris"
: source === "heroic" ? "Heroic"
: ""
visible: source !== "desktop"
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.75
opacity: 0.6
radius: height / 2
color: root.sourceChipColor(source)
implicitHeight: sourceChipLabel.implicitHeight + Kirigami.Units.smallSpacing
implicitWidth: sourceChipLabel.implicitWidth + Kirigami.Units.largeSpacing
PC3.Label {
id: sourceChipLabel
anchors.centerIn: parent
text: root.sourceLabel(source)
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.72
font.weight: Font.DemiBold
color: "white"
}
}
Item { Layout.fillHeight: true }