Add Plasma tray, notification inhibition, session control, and flatpak runner

Expand TrayController with daemon health polling every 10 seconds via
D-Bus Ping calls to org.kde.GameCenter1, org.kde.ALaKarte.Runner1, and
org.kde.ALaKarte.Input1. Add menu actions to toggle console behaviors,
notification mirroring, and to restart each daemon via systemctl --user.

Add NotificationInhibitor, owned by App, which calls
org.freedesktop.Notifications Inhibit/UnInhibit on the session bus
whenever the consoleBehaviors config setting changes. The cookie is
released on application quit.

Add consoleBehaviors and mirrorNotifications properties to Config with
KConfig persistence under the [Console] group.

Ship org.kde.alakarte.notifyrc defining GameLaunched, GameExited, and
LaunchFailed notification events so Plasma attributes them correctly in
the notification history.

Extend RunnerManagerDaemon::ResolveLaunch with a flatpak runner branch
that constructs a flatpak run command, translates environment overrides
to --env= arguments, and respects flatpakAppId, flatpakBranch,
flatpakArch, and flatpakArgs from the launch spec.

Add activate_session, switch_to_vt, and terminate_session actions to
the gamecenter KAuth helper and its polkit policy. Each action calls
the corresponding method on org.freedesktop.login1 over the system bus.

Add CouchSidebar.qml, a horizontal source tab bar shown in couch mode
above the library view, exposing the same sourceSelected, settingsRequested,
importRequested, and aboutRequested signals as SidebarView.

Fix duplicate adaptiveFocusRingWidth property in GameCard.qml.
This commit is contained in:
Marco Allegretti 2026-03-22 16:53:09 +01:00
parent 0b10b01cc4
commit e592e7b093
21 changed files with 1123 additions and 9 deletions

View file

@ -44,6 +44,7 @@ find_package(Qt6 ${QT_MIN_VERSION} REQUIRED COMPONENTS
Quick
QuickControls2
Gui
Widgets
Network
Concurrent
DBus
@ -108,6 +109,8 @@ find_package(KF6 ${KF_MIN_VERSION} REQUIRED COMPONENTS
find_package(KF6Auth ${KF_MIN_VERSION} QUIET)
find_package(KF6StatusNotifierItem ${KF_MIN_VERSION} QUIET)
find_package(KF6KirigamiAddons 1.0.0 REQUIRED)
qt_policy(SET QTP0001 NEW)

View file

@ -4,6 +4,7 @@
add_executable(alakarte
main.cpp
app.cpp
notificationinhibitor.cpp
gamemodel.cpp
gamesortfiltermodel.cpp
game.cpp
@ -68,6 +69,7 @@ target_link_libraries(alakarte PRIVATE
Qt6::Quick
Qt6::QuickControls2
Qt6::Gui
Qt6::Widgets
Qt6::Network
Qt6::Sql
Qt6::Concurrent
@ -84,11 +86,25 @@ target_link_libraries(alakarte PRIVATE
KF6::WindowSystem
)
if (KF6StatusNotifierItem_FOUND)
target_sources(alakarte PRIVATE
traycontroller.cpp
traycontroller.h
)
target_compile_definitions(alakarte PRIVATE
ALAKARTE_HAVE_STATUSNOTIFIERITEM
)
target_link_libraries(alakarte PRIVATE
KF6::StatusNotifierItem
)
endif()
ecm_add_qml_module(alakarte URI org.kde.alakarte
VERSION 1.0
QML_FILES
qml/Main.qml
qml/LibraryView.qml
qml/CouchSidebar.qml
qml/GameCard.qml
qml/GameDetailsSheet.qml
qml/DiagnosticsSheet.qml
@ -166,6 +182,10 @@ ecm_add_qml_module(alakarte URI org.kde.alakarte
install(TARGETS alakarte ${KDE_INSTALL_TARGETS_DEFAULT_ARGS})
install(FILES org.kde.alakarte.notifyrc
DESTINATION ${KDE_INSTALL_KNOTIFYRCDIR}
)
add_subdirectory(gamecenter)
add_subdirectory(runner)

View file

@ -9,6 +9,7 @@
#include "itchimporter.h"
#include "legendaryimporter.h"
#include "lutrisimporter.h"
#include "notificationinhibitor.h"
#include "retroarchimporter.h"
#include "steamimporter.h"
@ -36,6 +37,7 @@ App::App(QObject *parent)
, m_steamGridDB(new SteamGridDB(this))
, m_mediaManager(new MediaManager(this))
, m_config(new Config(this))
, m_notificationInhibitor(new NotificationInhibitor(m_config, this))
{
if (QCoreApplication::instance()) {
connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit, this, [this]() {

View file

@ -18,6 +18,8 @@
#include "runnermanagerclient.h"
#include "steamgriddb.h"
class NotificationInhibitor;
class App : public QObject
{
Q_OBJECT
@ -89,6 +91,7 @@ private:
SteamGridDB *m_steamGridDB;
MediaManager *m_mediaManager;
Config *m_config;
NotificationInhibitor *m_notificationInhibitor;
bool m_importing = false;
QString m_importStatus;

View file

@ -323,6 +323,34 @@ void Config::setWindowMaximized(bool maximized)
}
}
bool Config::consoleBehaviors() const
{
return m_consoleBehaviors;
}
void Config::setConsoleBehaviors(bool enabled)
{
if (m_consoleBehaviors != enabled) {
m_consoleBehaviors = enabled;
save();
Q_EMIT consoleBehaviorsChanged();
}
}
bool Config::mirrorNotifications() const
{
return m_mirrorNotifications;
}
void Config::setMirrorNotifications(bool mirror)
{
if (m_mirrorNotifications != mirror) {
m_mirrorNotifications = mirror;
save();
Q_EMIT mirrorNotificationsChanged();
}
}
void Config::setWindowSize(int width, int height)
{
width = qBound(0, width, 10000);
@ -381,6 +409,10 @@ void Config::save()
windowGroup.writeEntry(QStringLiteral("height"), m_windowHeight);
windowGroup.writeEntry(QStringLiteral("maximized"), m_windowMaximized);
KConfigGroup consoleGroup(config, QStringLiteral("Console"));
consoleGroup.writeEntry(QStringLiteral("consoleBehaviors"), m_consoleBehaviors);
consoleGroup.writeEntry(QStringLiteral("mirrorNotifications"), m_mirrorNotifications);
config->sync();
}
@ -417,6 +449,10 @@ void Config::load()
m_windowWidth = windowGroup.readEntry(QStringLiteral("width"), 0);
m_windowHeight = windowGroup.readEntry(QStringLiteral("height"), 0);
m_windowMaximized = windowGroup.readEntry(QStringLiteral("maximized"), false);
const KConfigGroup consoleGroup(config, QStringLiteral("Console"));
m_consoleBehaviors = consoleGroup.readEntry(QStringLiteral("consoleBehaviors"), false);
m_mirrorNotifications = consoleGroup.readEntry(QStringLiteral("mirrorNotifications"), true);
}
void Config::resetToDefaults()
@ -444,6 +480,8 @@ void Config::resetToDefaults()
m_windowWidth = 0;
m_windowHeight = 0;
m_windowMaximized = false;
m_consoleBehaviors = false;
m_mirrorNotifications = true;
save();
@ -469,4 +507,6 @@ void Config::resetToDefaults()
Q_EMIT windowWidthChanged();
Q_EMIT windowHeightChanged();
Q_EMIT windowMaximizedChanged();
Q_EMIT consoleBehaviorsChanged();
Q_EMIT mirrorNotificationsChanged();
}

View file

@ -33,6 +33,8 @@ class Config : public QObject
Q_PROPERTY(int windowWidth READ windowWidth WRITE setWindowWidth NOTIFY windowWidthChanged)
Q_PROPERTY(int windowHeight READ windowHeight WRITE setWindowHeight NOTIFY windowHeightChanged)
Q_PROPERTY(bool windowMaximized READ windowMaximized WRITE setWindowMaximized NOTIFY windowMaximizedChanged)
Q_PROPERTY(bool consoleBehaviors READ consoleBehaviors WRITE setConsoleBehaviors NOTIFY consoleBehaviorsChanged)
Q_PROPERTY(bool mirrorNotifications READ mirrorNotifications WRITE setMirrorNotifications NOTIFY mirrorNotificationsChanged)
public:
enum ViewMode {
@ -117,6 +119,12 @@ public:
bool windowMaximized() const;
void setWindowMaximized(bool maximized);
bool consoleBehaviors() const;
void setConsoleBehaviors(bool enabled);
bool mirrorNotifications() const;
void setMirrorNotifications(bool mirror);
Q_INVOKABLE void save();
Q_INVOKABLE void load();
Q_INVOKABLE void resetToDefaults();
@ -146,6 +154,8 @@ Q_SIGNALS:
void windowWidthChanged();
void windowHeightChanged();
void windowMaximizedChanged();
void consoleBehaviorsChanged();
void mirrorNotificationsChanged();
private:
ViewMode m_viewMode = GridView;
@ -171,4 +181,6 @@ private:
int m_windowWidth = 0;
int m_windowHeight = 0;
bool m_windowMaximized = false;
bool m_consoleBehaviors = false;
bool m_mirrorNotifications = true;
};

View file

@ -22,12 +22,21 @@ static const QString kPowerProfilesService = QStringLiteral("net.hadess.PowerPro
static const QString kPowerProfilesPath = QStringLiteral("/net/hadess/PowerProfiles");
static const QString kPowerProfilesInterface = QStringLiteral("net.hadess.PowerProfiles");
static const QString kLogin1Service = QStringLiteral("org.freedesktop.login1");
static const QString kLogin1ManagerPath = QStringLiteral("/org/freedesktop/login1");
static const QString kLogin1ManagerIface = QStringLiteral("org.freedesktop.login1.Manager");
static const QString kLogin1SeatPath = QStringLiteral("/org/freedesktop/login1/seat/seat0");
static const QString kLogin1SeatIface = QStringLiteral("org.freedesktop.login1.Seat");
class GameCenterHelper : public QObject
{
Q_OBJECT
public Q_SLOTS:
ActionReply setpowerprofile(const QVariantMap &args);
ActionReply activate_session(const QVariantMap &args);
ActionReply switch_to_vt(const QVariantMap &args);
ActionReply terminate_session(const QVariantMap &args);
};
static QVariant unwrapDbusVariant(QVariant v)
@ -128,6 +137,110 @@ ActionReply GameCenterHelper::setpowerprofile(const QVariantMap &args)
return ActionReply::SuccessReply();
}
ActionReply GameCenterHelper::activate_session(const QVariantMap &args)
{
const QString sessionId = unwrapStringArg(args, QStringLiteral("sessionId"));
if (sessionId.isEmpty()) {
ActionReply reply = ActionReply::HelperErrorReply();
reply.setErrorDescription(QStringLiteral("missing sessionId"));
return reply;
}
QDBusConnection bus = QDBusConnection::systemBus();
if (!bus.isConnected()) {
ActionReply reply = ActionReply::HelperErrorReply();
reply.setErrorDescription(QStringLiteral("system bus not connected"));
return reply;
}
QDBusInterface mgr(kLogin1Service, kLogin1ManagerPath, kLogin1ManagerIface, bus);
if (!mgr.isValid()) {
ActionReply reply = ActionReply::HelperErrorReply();
reply.setErrorDescription(QStringLiteral("login1 manager interface not available"));
return reply;
}
const QDBusMessage replyMsg = mgr.call(QStringLiteral("ActivateSession"), sessionId);
if (replyMsg.type() == QDBusMessage::ErrorMessage) {
ActionReply reply = ActionReply::HelperErrorReply();
reply.setErrorDescription(replyMsg.errorName() + QStringLiteral(": ") + replyMsg.errorMessage());
return reply;
}
return ActionReply::SuccessReply();
}
ActionReply GameCenterHelper::switch_to_vt(const QVariantMap &args)
{
QVariant vtArg = args.value(QStringLiteral("vtnr"));
if (vtArg.canConvert<QDBusVariant>()) {
vtArg = vtArg.value<QDBusVariant>().variant();
}
bool ok = false;
const uint vtnr = vtArg.toUInt(&ok);
if (!ok || vtnr == 0) {
ActionReply reply = ActionReply::HelperErrorReply();
reply.setErrorDescription(QStringLiteral("missing or invalid vtnr"));
return reply;
}
QDBusConnection bus = QDBusConnection::systemBus();
if (!bus.isConnected()) {
ActionReply reply = ActionReply::HelperErrorReply();
reply.setErrorDescription(QStringLiteral("system bus not connected"));
return reply;
}
QDBusInterface seat(kLogin1Service, kLogin1SeatPath, kLogin1SeatIface, bus);
if (!seat.isValid()) {
ActionReply reply = ActionReply::HelperErrorReply();
reply.setErrorDescription(QStringLiteral("login1 seat interface not available"));
return reply;
}
const QDBusMessage replyMsg = seat.call(QStringLiteral("SwitchTo"), vtnr);
if (replyMsg.type() == QDBusMessage::ErrorMessage) {
ActionReply reply = ActionReply::HelperErrorReply();
reply.setErrorDescription(replyMsg.errorName() + QStringLiteral(": ") + replyMsg.errorMessage());
return reply;
}
return ActionReply::SuccessReply();
}
ActionReply GameCenterHelper::terminate_session(const QVariantMap &args)
{
const QString sessionId = unwrapStringArg(args, QStringLiteral("sessionId"));
if (sessionId.isEmpty()) {
ActionReply reply = ActionReply::HelperErrorReply();
reply.setErrorDescription(QStringLiteral("missing sessionId"));
return reply;
}
QDBusConnection bus = QDBusConnection::systemBus();
if (!bus.isConnected()) {
ActionReply reply = ActionReply::HelperErrorReply();
reply.setErrorDescription(QStringLiteral("system bus not connected"));
return reply;
}
QDBusInterface mgr(kLogin1Service, kLogin1ManagerPath, kLogin1ManagerIface, bus);
if (!mgr.isValid()) {
ActionReply reply = ActionReply::HelperErrorReply();
reply.setErrorDescription(QStringLiteral("login1 manager interface not available"));
return reply;
}
const QDBusMessage replyMsg = mgr.call(QStringLiteral("TerminateSession"), sessionId);
if (replyMsg.type() == QDBusMessage::ErrorMessage) {
ActionReply reply = ActionReply::HelperErrorReply();
reply.setErrorDescription(replyMsg.errorName() + QStringLiteral(": ") + replyMsg.errorMessage());
return reply;
}
return ActionReply::SuccessReply();
}
KAUTH_HELPER_MAIN("org.kde.alakarte.gamecenter.helper", GameCenterHelper)
#include "gamecenterkauthhelper.moc"

View file

@ -14,4 +14,28 @@
<allow_active>auth_admin_keep</allow_active>
</defaults>
</action>
<action id="org.kde.alakarte.gamecenter.helper.activate_session">
<description>Activate a login session</description>
<message>Authentication is required to activate a login session.</message>
<defaults>
<allow_inactive>no</allow_inactive>
<allow_active>auth_admin_keep</allow_active>
</defaults>
</action>
<action id="org.kde.alakarte.gamecenter.helper.switch_to_vt">
<description>Switch to a virtual terminal</description>
<message>Authentication is required to switch to a virtual terminal.</message>
<defaults>
<allow_inactive>no</allow_inactive>
<allow_active>auth_admin_keep</allow_active>
</defaults>
</action>
<action id="org.kde.alakarte.gamecenter.helper.terminate_session">
<description>Terminate a login session</description>
<message>Authentication is required to terminate a login session.</message>
<defaults>
<allow_inactive>no</allow_inactive>
<allow_active>auth_admin_keep</allow_active>
</defaults>
</action>
</policyconfig>

View file

@ -12,6 +12,7 @@
#include <QDBusConnection>
#include <QDBusConnectionInterface>
#include <QDBusError>
#include <QDBusReply>
#include <QDBusVariant>
#include <QDateTime>
#include <QDir>
@ -32,7 +33,6 @@ static bool pingDaemon(QDBusConnection bus)
if (!bus.isConnected()) {
return false;
}
org::kde::GameCenter1 iface(kGameCenterService, kGameCenterPath, bus);
if (!iface.isValid()) {
return false;
@ -283,7 +283,14 @@ GameLauncher::GameLauncher(QObject *parent)
});
}
if (QCoreApplication::instance()) {
connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit, this, [this]() {
shutdownSpawnedDaemon();
});
}
QTimer::singleShot(0, this, [this]() {
ensureDaemon();
checkDaemonAvailability();
syncDaemonSessions();
});
@ -291,10 +298,97 @@ GameLauncher::GameLauncher(QObject *parent)
GameLauncher::~GameLauncher()
{
shutdownSpawnedDaemon();
m_daemonGameToSession.clear();
m_daemonSessionToGame.clear();
}
void GameLauncher::ensureDaemon()
{
QDBusConnection bus = QDBusConnection::sessionBus();
if (!bus.isConnected() || !bus.interface()) {
return;
}
if (bus.interface()->isServiceRegistered(kGameCenterService)) {
return;
}
bool activatable = false;
{
const QDBusReply<QStringList> names = bus.interface()->activatableServiceNames();
if (names.isValid()) {
activatable = names.value().contains(kGameCenterService);
}
}
bus.interface()->startService(kGameCenterService);
if (bus.interface()->isServiceRegistered(kGameCenterService)) {
return;
}
if (activatable) {
return;
}
#if defined(QT_NO_DEBUG)
return;
#endif
if (m_daemonSpawnAttempted) {
return;
}
m_daemonSpawnAttempted = true;
QString program = QCoreApplication::applicationDirPath() + QLatin1String("/alakarte-gamecenter");
if (!QFileInfo::exists(program)) {
program = QStringLiteral("alakarte-gamecenter");
}
if (!m_daemonProcess) {
m_daemonProcess = new QProcess(this);
m_daemonProcess->setProcessChannelMode(QProcess::MergedChannels);
connect(m_daemonProcess, &QProcess::finished, this, [this](int, QProcess::ExitStatus) {
if (!m_daemonProcess) {
return;
}
m_daemonProcess->deleteLater();
m_daemonProcess = nullptr;
});
}
m_daemonProcess->setProgram(program);
m_daemonProcess->setArguments({});
m_daemonProcess->start();
QTimer::singleShot(500, this, [this]() {
checkDaemonAvailability();
if (m_daemonAvailable) {
syncDaemonSessions();
}
});
}
void GameLauncher::shutdownSpawnedDaemon()
{
if (!m_daemonProcess) {
return;
}
QProcess *p = m_daemonProcess;
if (p->state() != QProcess::NotRunning) {
p->disconnect(this);
p->terminate();
if (!p->waitForFinished(3000)) {
p->kill();
p->waitForFinished(3000);
}
}
p->deleteLater();
m_daemonProcess = nullptr;
}
bool GameLauncher::hasRunningGames() const
{
return !m_daemonGameToSession.isEmpty();
@ -803,6 +897,7 @@ void GameLauncher::applyRunningStateToLibrary()
void GameLauncher::checkDaemonAvailability()
{
ensureDaemon();
const bool available = pingDaemon(QDBusConnection::sessionBus());
disconnectDaemonSignals(QDBusConnection::sessionBus(), this);

View file

@ -49,7 +49,12 @@ private:
QHash<QString, QString> m_daemonSessionToGame;
bool m_daemonAvailable = false;
bool m_daemonSpawnAttempted = false;
class QProcess *m_daemonProcess = nullptr;
void checkDaemonAvailability();
void ensureDaemon();
void shutdownSpawnedDaemon();
void syncDaemonSessions();
void applyRunningStateToLibrary();
};

View file

@ -1,6 +1,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
#include <QApplication>
#include <QByteArray>
#include <QCommandLineParser>
#include <QGuiApplication>
@ -8,6 +9,7 @@
#include <QQmlApplicationEngine>
#include <QQmlContext>
#include <QQuickStyle>
#include <QWindow>
#include <KAboutData>
#include <KCrash>
@ -16,11 +18,22 @@
#include <KLocalizedString>
#include "alakarte-version.h"
#include "app.h"
#ifdef ALAKARTE_HAVE_STATUSNOTIFIERITEM
#include "traycontroller.h"
#endif
#if defined(Q_OS_UNIX)
#include <unistd.h>
#endif
#include <QDBusConnection>
#include <QDBusConnectionInterface>
int main(int argc, char *argv[])
{
qputenv("QT_DESKTOP_FILE_NAME", QByteArray("org.kde.alakarte"));
QGuiApplication app(argc, argv);
QApplication app(argc, argv);
QGuiApplication::setDesktopFileName(QStringLiteral("org.kde.alakarte"));
app.setOrganizationDomain(QStringLiteral("kde.org"));
@ -50,9 +63,42 @@ int main(int argc, char *argv[])
QCommandLineParser parser;
aboutData.setupCommandLine(&parser);
const QCommandLineOption quitOnCloseOption(QStringLiteral("quit-on-close"), i18n("Quit when the main window is closed"));
const QCommandLineOption closeToTrayOption(QStringLiteral("close-to-tray"), i18n("Hide the main window to the system tray when closed"));
parser.addOption(quitOnCloseOption);
parser.addOption(closeToTrayOption);
parser.process(app);
aboutData.processCommandLine(&parser);
bool terminalLaunched = false;
#if defined(Q_OS_UNIX)
terminalLaunched = (isatty(STDIN_FILENO) || isatty(STDOUT_FILENO) || isatty(STDERR_FILENO));
#endif
bool closeToTray = false;
#ifdef ALAKARTE_HAVE_STATUSNOTIFIERITEM
bool trayHostAvailable = false;
{
auto bus = QDBusConnection::sessionBus();
if (bus.isConnected() && bus.interface()) {
trayHostAvailable = bus.interface()->isServiceRegistered(QStringLiteral("org.kde.StatusNotifierWatcher"))
|| bus.interface()->isServiceRegistered(QStringLiteral("org.freedesktop.StatusNotifierWatcher"));
}
}
closeToTray = (!terminalLaunched) && trayHostAvailable;
if (parser.isSet(quitOnCloseOption)) {
closeToTray = false;
}
if (parser.isSet(closeToTrayOption)) {
closeToTray = trayHostAvailable;
}
#endif
QApplication::setQuitOnLastWindowClosed(!closeToTray);
KDBusService service(KDBusService::Unique);
if (qEnvironmentVariableIsEmpty("QT_QUICK_CONTROLS_STYLE")) {
@ -69,5 +115,15 @@ int main(int argc, char *argv[])
return -1;
}
auto *window = qobject_cast<QWindow *>(engine.rootObjects().constFirst());
if (window) {
#ifdef ALAKARTE_HAVE_STATUSNOTIFIERITEM
if (closeToTray) {
Config *cfg = App::instance() ? App::instance()->config() : nullptr;
new TrayController(window, true, cfg, &app);
}
#endif
}
return app.exec();
}

View file

@ -0,0 +1,88 @@
#include "notificationinhibitor.h"
#include "config.h"
#include <QCoreApplication>
#include <QDBusConnection>
#include <QDBusInterface>
#include <QDBusPendingCall>
#include <QDBusPendingCallWatcher>
#include <QDBusPendingReply>
static const QString kNotifService = QStringLiteral("org.freedesktop.Notifications");
static const QString kNotifPath = QStringLiteral("/org/freedesktop/Notifications");
static const QString kNotifIface = QStringLiteral("org.freedesktop.Notifications");
NotificationInhibitor::NotificationInhibitor(Config *config, QObject *parent)
: QObject(parent)
, m_config(config)
{
if (m_config) {
connect(m_config, &Config::consoleBehaviorsChanged, this, [this]() {
applyInhibition(m_config->consoleBehaviors());
});
applyInhibition(m_config->consoleBehaviors());
}
if (QCoreApplication::instance()) {
connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit, this, [this]() {
if (m_inhibited) {
uninhibit();
}
});
}
}
NotificationInhibitor::~NotificationInhibitor()
{
if (m_inhibited) {
uninhibit();
}
}
void NotificationInhibitor::applyInhibition(bool enabled)
{
if (enabled && !m_inhibited) {
inhibit();
} else if (!enabled && m_inhibited) {
uninhibit();
}
}
void NotificationInhibitor::inhibit()
{
QDBusInterface ifc(kNotifService, kNotifPath, kNotifIface, QDBusConnection::sessionBus());
if (!ifc.isValid()) {
return;
}
auto call = ifc.asyncCall(QStringLiteral("Inhibit"), QStringLiteral("org.kde.alakarte"), QStringLiteral("console behaviors enabled"), QVariantMap{});
auto *watcher = new QDBusPendingCallWatcher(call, this);
connect(watcher, &QDBusPendingCallWatcher::finished, this, [this, watcher](QDBusPendingCallWatcher *w) {
QDBusPendingReply<uint> reply = *w;
if (!reply.isError()) {
m_cookie = reply.value();
m_inhibited = true;
}
watcher->deleteLater();
});
}
void NotificationInhibitor::uninhibit()
{
if (!m_inhibited || m_cookie == 0) {
m_inhibited = false;
m_cookie = 0;
return;
}
QDBusInterface ifc(kNotifService, kNotifPath, kNotifIface, QDBusConnection::sessionBus());
if (ifc.isValid()) {
ifc.asyncCall(QStringLiteral("UnInhibit"), m_cookie);
}
m_inhibited = false;
m_cookie = 0;
}

View file

@ -0,0 +1,23 @@
#pragma once
#include <QObject>
class Config;
class NotificationInhibitor : public QObject
{
Q_OBJECT
public:
explicit NotificationInhibitor(Config *config, QObject *parent = nullptr);
~NotificationInhibitor() override;
private:
void applyInhibition(bool enabled);
void inhibit();
void uninhibit();
Config *m_config = nullptr;
uint m_cookie = 0;
bool m_inhibited = false;
};

View file

@ -0,0 +1,21 @@
[Global]
IconName=org.kde.alakarte
DesktopEntry=org.kde.alakarte
[Event/GameLaunched]
Name=Game Launched
Comment=A game session was started
Action=Popup
Urgency=Low
[Event/GameExited]
Name=Game Exited
Comment=A game session ended
Action=Popup
Urgency=Low
[Event/LaunchFailed]
Name=Launch Failed
Comment=A game failed to launch
Action=Popup
Urgency=Normal

179
src/qml/CouchSidebar.qml Normal file
View file

@ -0,0 +1,179 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.alakarte
import "components"
Item {
id: couchSidebarRoot
property string currentSource: "all"
signal sourceSelected(string source)
signal settingsRequested()
signal importRequested()
signal aboutRequested()
function iconInfoForPlatform(platformName) {
let p = (platformName || "").toLowerCase()
if (p.includes("steam")) return { source: "com.valvesoftware.Steam", fallback: "steam" }
if (p.includes("itch")) return { source: "io.itch.itch", fallback: "applications-games" }
if (p.includes("retroarch")) return { source: "org.libretro.RetroArch", fallback: "retroarch" }
if (p.includes("lutris")) return { source: "lutris", fallback: "applications-games" }
if (p.includes("heroic")) return { source: "com.heroicgameslauncher.hgl", fallback: "applications-games" }
if (p.includes("bottles")) return { source: "com.usebottles.bottles", fallback: "application-x-executable" }
if (p.includes("flatpak")) return { source: "flatpak-discover", fallback: "applications-games" }
if (p.includes("desktop")) return { source: "user-desktop", fallback: "computer" }
if (p.includes("legendary")) return { source: "legendary", fallback: "applications-games" }
return { source: "applications-games", fallback: "applications-games" }
}
ListModel {
id: sourceModel
Component.onCompleted: refresh()
function refresh() {
clear()
let allCount = 0
let favCount = 0
let sources = {}
for (let i = 0; i < App.gameModel.rowCount(); i++) {
let game = App.gameModel.gameAt(i)
if (!game || game.hidden) continue
allCount++
if (game.favorite) favCount++
let p = game.platform
sources[p] = (sources[p] || 0) + 1
}
append({ name: i18n("All"), sourceId: "all", icon: "view-list-icons", fallback: "applications-games" })
if (favCount > 0) {
append({ name: i18n("Favorites"), sourceId: "favorites", icon: "bookmark-new", fallback: "bookmark-new" })
}
for (let platform in sources) {
let info = couchSidebarRoot.iconInfoForPlatform(platform)
append({ name: platform, sourceId: platform, icon: info.source, fallback: info.fallback })
}
}
}
Connections {
target: App.gameModel
function onCountChanged() { sourceModel.refresh() }
}
Connections {
target: App
function onImportCompleted() { sourceModel.refresh() }
}
RowLayout {
anchors.fill: parent
spacing: 0
QQC2.ScrollView {
Layout.fillWidth: true
Layout.fillHeight: true
QQC2.ScrollBar.horizontal.policy: QQC2.ScrollBar.AlwaysOff
QQC2.ScrollBar.vertical.policy: QQC2.ScrollBar.AlwaysOff
ListView {
id: tabList
model: sourceModel
orientation: ListView.Horizontal
spacing: Kirigami.Units.smallSpacing
leftMargin: Kirigami.Units.largeSpacing
rightMargin: Kirigami.Units.largeSpacing
topMargin: Kirigami.Units.smallSpacing
bottomMargin: Kirigami.Units.smallSpacing
clip: true
delegate: QQC2.ItemDelegate {
id: tabDelegate
width: implicitWidth
height: tabList.height - tabList.topMargin - tabList.bottomMargin
readonly property bool isActive: model.sourceId === couchSidebarRoot.currentSource
background: Rectangle {
radius: Kirigami.Units.smallSpacing
color: tabDelegate.isActive ? Kirigami.Theme.highlightColor
: tabDelegate.hovered ? Kirigami.Theme.alternateBackgroundColor
: "transparent"
opacity: tabDelegate.isActive ? 0.20 : (tabDelegate.hovered ? 0.10 : 0.0)
Rectangle {
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
height: 2
radius: 1
color: Kirigami.Theme.highlightColor
visible: tabDelegate.isActive
}
Behavior on opacity {
NumberAnimation { duration: Kirigami.Units.shortDuration }
}
}
leftPadding: Kirigami.Units.mediumSpacing
rightPadding: Kirigami.Units.mediumSpacing
contentItem: RowLayout {
spacing: Kirigami.Units.smallSpacing
Kirigami.Icon {
source: model.icon
fallback: model.fallback
Layout.preferredWidth: Kirigami.Units.iconSizes.small
Layout.preferredHeight: Kirigami.Units.iconSizes.small
color: tabDelegate.isActive ? Kirigami.Theme.highlightColor : Kirigami.Theme.textColor
}
QQC2.Label {
text: model.name
color: tabDelegate.isActive ? Kirigami.Theme.highlightColor : Kirigami.Theme.textColor
font.bold: tabDelegate.isActive
elide: Text.ElideRight
maximumLineCount: 1
}
}
onClicked: couchSidebarRoot.sourceSelected(model.sourceId)
}
}
}
Kirigami.Separator {
Layout.fillHeight: true
}
QQC2.ToolButton {
icon.name: "document-import"
onClicked: couchSidebarRoot.importRequested()
Layout.fillHeight: true
flat: true
QQC2.ToolTip.text: i18n("Import Games")
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
QQC2.ToolButton {
icon.name: "configure"
onClicked: couchSidebarRoot.settingsRequested()
Layout.fillHeight: true
flat: true
QQC2.ToolTip.text: i18n("Settings")
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
}
}

View file

@ -20,9 +20,9 @@ FocusScope {
if (w && w.isTouchDevice !== undefined) return w.isTouchDevice
return Kirigami.Settings.tabletMode || Kirigami.Settings.isMobile
}
readonly property int adaptiveFocusRingWidth: 1
readonly property real adaptiveHoverScale: 1.015
readonly property real adaptiveFocusScale: 1.03
readonly property real adaptiveHoverScale: root.isCouchMode ? 1.05 : 1.015
readonly property real adaptiveFocusScale: root.isCouchMode ? 1.12 : 1.03
readonly property int adaptiveFocusRingWidth: root.isCouchMode ? 3 : 1
readonly property bool useAnimatedCover: App.config.animatedCovers
&& game
@ -43,8 +43,8 @@ FocusScope {
color: Kirigami.Theme.backgroundColor
shadow {
size: gameCard.focused ? Kirigami.Units.mediumSpacing : (hoverHandler.hovered ? Kirigami.Units.smallSpacing * 1.5 : Kirigami.Units.smallSpacing)
color: gameCard.focused ? Qt.rgba(0, 0, 0, 0.34) : (hoverHandler.hovered ? Qt.rgba(0, 0, 0, 0.24) : Qt.rgba(0, 0, 0, 0.16))
size: gameCard.focused ? (root.isCouchMode ? Kirigami.Units.gridUnit * 1.5 : Kirigami.Units.mediumSpacing) : (hoverHandler.hovered ? Kirigami.Units.smallSpacing * 1.5 : Kirigami.Units.smallSpacing)
color: gameCard.focused ? (root.isCouchMode ? Qt.rgba(Kirigami.Theme.highlightColor.r, Kirigami.Theme.highlightColor.g, Kirigami.Theme.highlightColor.b, 0.4) : Qt.rgba(0, 0, 0, 0.34)) : (hoverHandler.hovered ? Qt.rgba(0, 0, 0, 0.24) : Qt.rgba(0, 0, 0, 0.16))
}
Behavior on shadow.color {
@ -162,6 +162,8 @@ FocusScope {
// Gradient overlay for text
Rectangle {
opacity: (!root.isCouchMode || gameCard.focused || hoverHandler.hovered) ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: Kirigami.Units.shortDuration } }
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
@ -178,6 +180,8 @@ FocusScope {
// Game title
ColumnLayout {
opacity: (!root.isCouchMode || gameCard.focused || hoverHandler.hovered) ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: Kirigami.Units.shortDuration } }
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom

View file

@ -161,6 +161,11 @@ FocusScope {
Layout.fillWidth: true
visible: libraryRoot.searchActive
// In Couch Mode, position it absolute over the grid instead of shifting the grid
// to mimic a console drop-down search bar
y: root.isCouchMode ? Kirigami.Units.gridUnit * 2 : 0
z: 100
searchField: searchField
onSearchChanged: function(text) {

View file

@ -327,6 +327,7 @@ Kirigami.ApplicationWindow {
pageStack.columnView.columnResizeMode: Kirigami.ColumnView.SingleColumn
footer: Item {
visible: !root.isCouchMode
implicitWidth: root.width
implicitHeight: footerBar.implicitHeight
@ -749,6 +750,7 @@ Kirigami.ApplicationWindow {
id: sidebar
edge: Qt.LeftEdge
modal: root.isMobile || root.isNarrowScreen || root.isCouchMode
enabled: !root.isCouchMode
handleClosedIcon.source: "application-menu"
handleOpenIcon.source: "go-previous"
handleVisible: root.isMobile || root.isNarrowScreen || root.isCouchMode
@ -856,7 +858,37 @@ Kirigami.ApplicationWindow {
id: mainPage
title: sidebarView.currentSourceName
padding: Kirigami.Units.largeSpacing
padding: root.isCouchMode ? 0 : Kirigami.Units.largeSpacing
// Couch Mode Dynamic Background
Item {
anchors.fill: parent
visible: root.isCouchMode
z: -1
Image {
id: globalBackgroundCover
anchors.fill: parent
source: libraryView.focusedCoverUrl
fillMode: Image.PreserveAspectCrop
asynchronous: true
smooth: true
mipmap: App.config.highQualityImages
opacity: 0.15
layer.enabled: true
layer.effect: MultiEffect {
blurEnabled: true
blur: 0.95
blurMax: 80
}
}
Rectangle {
anchors.fill: parent
color: Qt.rgba(Kirigami.Theme.backgroundColor.r, Kirigami.Theme.backgroundColor.g, Kirigami.Theme.backgroundColor.b, 0.8)
}
}
actions: [
Kirigami.Action {
@ -900,6 +932,29 @@ Kirigami.ApplicationWindow {
anchors.fill: parent
spacing: 0
CouchSidebar {
id: couchSidebar
Layout.fillWidth: true
Layout.preferredHeight: Kirigami.Units.gridUnit * 4
visible: root.isCouchMode
currentSource: sidebarView.currentSource
onSourceSelected: function(source) {
sidebarView.applySourceById(source)
}
onSettingsRequested: {
root.openSettings()
}
onImportRequested: {
importSheet.open()
}
onAboutRequested: {
aboutSheet.open()
}
}
Kirigami.InlineMessage {
id: daemonBanner
Layout.fillWidth: true

View file

@ -1463,6 +1463,49 @@ QVariantMap RunnerManagerDaemon::ResolveLaunch(const QVariantMap &spec) const
finalProgram = protonExe;
finalArgs = {QStringLiteral("run"), program};
finalArgs.append(args);
} else if (runner == QLatin1String("flatpak")) {
const QString flatpakExe = QStandardPaths::findExecutable(QStringLiteral("flatpak"));
if (flatpakExe.isEmpty()) {
out.insert(QStringLiteral("error"), QStringLiteral("flatpak not found"));
return out;
}
const QString flatpakAppId = spec.value(QStringLiteral("flatpakAppId")).toString();
const QString appId = !flatpakAppId.isEmpty() ? flatpakAppId : program;
const QString flatpakBranch = spec.value(QStringLiteral("flatpakBranch")).toString();
const QString flatpakArch = spec.value(QStringLiteral("flatpakArch")).toString();
const QStringList flatpakExtraArgs = unwrapStringList(spec.value(QStringLiteral("flatpakArgs")));
QStringList runArgs;
runArgs.push_back(QStringLiteral("run"));
if (!flatpakBranch.isEmpty()) {
runArgs.push_back(QStringLiteral("--branch=") + flatpakBranch);
}
if (!flatpakArch.isEmpty()) {
runArgs.push_back(QStringLiteral("--arch=") + flatpakArch);
}
for (auto it = effectiveEnv.constBegin(); it != effectiveEnv.constEnd(); ++it) {
const QString val = it.value().toString();
if (!val.isEmpty()) {
runArgs.push_back(QStringLiteral("--env=") + it.key() + QLatin1Char('=') + val);
}
}
for (const QString &a : flatpakExtraArgs) {
if (!a.trimmed().isEmpty()) {
runArgs.push_back(a);
}
}
runArgs.push_back(appId);
runArgs.append(args);
finalProgram = flatpakExe;
finalArgs = runArgs;
effectiveEnv.clear();
}
out.insert(QStringLiteral("finalProgram"), finalProgram);

266
src/traycontroller.cpp Normal file
View file

@ -0,0 +1,266 @@
#include "traycontroller.h"
#include "config.h"
#include <QAction>
#include <QApplication>
#include <QDBusConnection>
#include <QDBusInterface>
#include <QDBusPendingCall>
#include <QDBusPendingCallWatcher>
#include <QDBusPendingReply>
#include <QEvent>
#include <QMenu>
#include <QProcess>
#include <QTimer>
#include <QWindow>
#include <KLocalizedString>
#include <KStatusNotifierItem>
static constexpr int kHealthPollIntervalMs = 10000;
TrayController::TrayController(QWindow *window, bool closeToTray, Config *config, QObject *parent)
: QObject(parent)
, m_window(window)
, m_config(config)
{
m_closeToTray = closeToTray;
if (m_window) {
m_window->installEventFilter(this);
}
m_sni = new KStatusNotifierItem(QStringLiteral("org.kde.alakarte"), this);
m_sni->setTitle(QStringLiteral("A-La-Karte"));
m_sni->setIconByName(QStringLiteral("org.kde.alakarte"));
m_sni->setStatus(KStatusNotifierItem::Active);
if (m_window) {
m_sni->setAssociatedWindow(m_window);
}
m_menu = new QMenu();
rebuildMenu();
m_sni->setContextMenu(m_menu);
connect(m_sni, &KStatusNotifierItem::activateRequested, this, [this](bool, const QPoint &) {
toggleWindow();
});
if (m_config) {
connect(m_config, &Config::consoleBehaviorsChanged, this, &TrayController::updateConsoleModeAction);
connect(m_config, &Config::mirrorNotificationsChanged, this, &TrayController::updateMirrorNotifAction);
}
m_healthTimer = new QTimer(this);
m_healthTimer->setInterval(kHealthPollIntervalMs);
connect(m_healthTimer, &QTimer::timeout, this, &TrayController::checkDaemonHealth);
m_healthTimer->start();
checkDaemonHealth();
}
TrayController::~TrayController()
{
if (m_menu) {
delete m_menu;
m_menu = nullptr;
}
}
bool TrayController::eventFilter(QObject *watched, QEvent *event)
{
if (watched == m_window && event && event->type() == QEvent::Close) {
if (m_closeToTray && !m_quitting) {
event->setAccepted(false);
hideWindow();
}
}
return QObject::eventFilter(watched, event);
}
void TrayController::rebuildMenu()
{
m_menu->clear();
m_consoleModeAction = m_menu->addAction(i18n("Console Behaviors"));
m_consoleModeAction->setCheckable(true);
if (m_config) {
m_consoleModeAction->setChecked(m_config->consoleBehaviors());
}
connect(m_consoleModeAction, &QAction::triggered, this, [this](bool checked) {
if (m_config) {
m_config->setConsoleBehaviors(checked);
}
});
m_mirrorNotifAction = m_menu->addAction(i18n("Mirror Notifications"));
m_mirrorNotifAction->setCheckable(true);
if (m_config) {
m_mirrorNotifAction->setChecked(m_config->mirrorNotifications());
}
connect(m_mirrorNotifAction, &QAction::triggered, this, [this](bool checked) {
if (m_config) {
m_config->setMirrorNotifications(checked);
}
});
m_menu->addSeparator();
m_gameCenterStatusAction = m_menu->addAction(i18n("GameCenter: checking…"));
m_gameCenterStatusAction->setEnabled(false);
m_runnerStatusAction = m_menu->addAction(i18n("Runner: checking…"));
m_runnerStatusAction->setEnabled(false);
m_inputStatusAction = m_menu->addAction(i18n("Input: checking…"));
m_inputStatusAction->setEnabled(false);
m_menu->addSeparator();
QMenu *restartMenu = m_menu->addMenu(i18n("Restart Daemons"));
QAction *restartGC = restartMenu->addAction(i18n("Restart GameCenter"));
connect(restartGC, &QAction::triggered, this, [this]() {
restartDaemon(QStringLiteral("org.kde.GameCenter1.service"));
});
QAction *restartRunner = restartMenu->addAction(i18n("Restart Runner"));
connect(restartRunner, &QAction::triggered, this, [this]() {
restartDaemon(QStringLiteral("org.kde.ALaKarte.Runner1.service"));
});
QAction *restartInput = restartMenu->addAction(i18n("Restart Input"));
connect(restartInput, &QAction::triggered, this, [this]() {
restartDaemon(QStringLiteral("org.kde.ALaKarte.Input1.service"));
});
m_menu->addSeparator();
m_showHideAction = m_menu->addAction(i18n("Hide"));
connect(m_showHideAction, &QAction::triggered, this, [this]() {
toggleWindow();
});
m_quitAction = m_menu->addAction(i18n("Quit"));
connect(m_quitAction, &QAction::triggered, this, [this]() {
m_quitting = true;
QApplication::quit();
});
updateShowHideAction();
}
void TrayController::updateShowHideAction()
{
if (!m_showHideAction || !m_window) {
return;
}
m_showHideAction->setText(m_window->isVisible() ? i18n("Hide") : i18n("Show"));
}
void TrayController::updateConsoleModeAction()
{
if (!m_consoleModeAction || !m_config) {
return;
}
m_consoleModeAction->setChecked(m_config->consoleBehaviors());
}
void TrayController::updateMirrorNotifAction()
{
if (!m_mirrorNotifAction || !m_config) {
return;
}
m_mirrorNotifAction->setChecked(m_config->mirrorNotifications());
}
void TrayController::checkDaemonHealth()
{
pingDaemon(QStringLiteral("org.kde.GameCenter1"),
QStringLiteral("/org/kde/GameCenter1"),
QStringLiteral("org.kde.GameCenter1"),
m_gameCenterStatusAction,
QStringLiteral("GameCenter"));
pingDaemon(QStringLiteral("org.kde.ALaKarte.Runner1"),
QStringLiteral("/org/kde/ALaKarte/Runner1"),
QStringLiteral("org.kde.ALaKarte.Runner1"),
m_runnerStatusAction,
QStringLiteral("Runner"));
pingDaemon(QStringLiteral("org.kde.ALaKarte.Input1"),
QStringLiteral("/org/kde/ALaKarte/Input1"),
QStringLiteral("org.kde.ALaKarte.Input1"),
m_inputStatusAction,
QStringLiteral("Input"));
}
void TrayController::pingDaemon(const QString &service, const QString &path, const QString &iface, QAction *statusAction, const QString &daemonName)
{
if (!statusAction) {
return;
}
auto *ifc = new QDBusInterface(service, path, iface, QDBusConnection::sessionBus(), this);
auto call = ifc->asyncCall(QStringLiteral("Ping"));
auto *watcher = new QDBusPendingCallWatcher(call, this);
connect(watcher, &QDBusPendingCallWatcher::finished, this, [this, watcher, statusAction, daemonName, ifc](QDBusPendingCallWatcher *w) {
QDBusPendingReply<QString> reply = *w;
const bool ok = !reply.isError();
updateDaemonStatusAction(statusAction, ok, daemonName);
watcher->deleteLater();
ifc->deleteLater();
});
}
void TrayController::updateDaemonStatusAction(QAction *action, bool ok, const QString &name)
{
if (!action) {
return;
}
if (ok) {
action->setText(i18nc("daemon name, status", "%1: Running", name));
} else {
action->setText(i18nc("daemon name, status", "%1: Offline", name));
}
}
void TrayController::restartDaemon(const QString &unitName)
{
QProcess::startDetached(QStringLiteral("systemctl"), {QStringLiteral("--user"), QStringLiteral("restart"), unitName});
}
void TrayController::toggleWindow()
{
if (!m_window) {
return;
}
if (m_window->isVisible()) {
hideWindow();
} else {
showWindow();
}
}
void TrayController::showWindow()
{
if (!m_window) {
return;
}
m_window->show();
m_window->requestActivate();
updateShowHideAction();
}
void TrayController::hideWindow()
{
if (!m_window) {
return;
}
m_window->hide();
updateShowHideAction();
}

57
src/traycontroller.h Normal file
View file

@ -0,0 +1,57 @@
#pragma once
#include <QObject>
#include <QPointer>
class QAction;
class QEvent;
class QMenu;
class QTimer;
class QWindow;
class Config;
class KStatusNotifierItem;
class TrayController : public QObject
{
Q_OBJECT
public:
explicit TrayController(QWindow *window, bool closeToTray, Config *config, QObject *parent = nullptr);
~TrayController() override;
private:
bool eventFilter(QObject *watched, QEvent *event) override;
void updateShowHideAction();
void toggleWindow();
void showWindow();
void hideWindow();
void rebuildMenu();
void updateConsoleModeAction();
void updateMirrorNotifAction();
void checkDaemonHealth();
void updateDaemonStatusAction(QAction *action, bool ok, const QString &name);
void restartDaemon(const QString &unitName);
void pingDaemon(const QString &service, const QString &path, const QString &iface, QAction *statusAction, const QString &daemonName);
QPointer<QWindow> m_window;
Config *m_config = nullptr;
KStatusNotifierItem *m_sni = nullptr;
QMenu *m_menu = nullptr;
QAction *m_showHideAction = nullptr;
QAction *m_consoleModeAction = nullptr;
QAction *m_mirrorNotifAction = nullptr;
QAction *m_gameCenterStatusAction = nullptr;
QAction *m_runnerStatusAction = nullptr;
QAction *m_inputStatusAction = nullptr;
QAction *m_quitAction = nullptr;
QTimer *m_healthTimer = nullptr;
bool m_quitting = false;
bool m_closeToTray = true;
};