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 Quick
QuickControls2 QuickControls2
Gui Gui
Widgets
Network Network
Concurrent Concurrent
DBus DBus
@ -108,6 +109,8 @@ find_package(KF6 ${KF_MIN_VERSION} REQUIRED COMPONENTS
find_package(KF6Auth ${KF_MIN_VERSION} QUIET) find_package(KF6Auth ${KF_MIN_VERSION} QUIET)
find_package(KF6StatusNotifierItem ${KF_MIN_VERSION} QUIET)
find_package(KF6KirigamiAddons 1.0.0 REQUIRED) find_package(KF6KirigamiAddons 1.0.0 REQUIRED)
qt_policy(SET QTP0001 NEW) qt_policy(SET QTP0001 NEW)

View file

@ -4,6 +4,7 @@
add_executable(alakarte add_executable(alakarte
main.cpp main.cpp
app.cpp app.cpp
notificationinhibitor.cpp
gamemodel.cpp gamemodel.cpp
gamesortfiltermodel.cpp gamesortfiltermodel.cpp
game.cpp game.cpp
@ -68,6 +69,7 @@ target_link_libraries(alakarte PRIVATE
Qt6::Quick Qt6::Quick
Qt6::QuickControls2 Qt6::QuickControls2
Qt6::Gui Qt6::Gui
Qt6::Widgets
Qt6::Network Qt6::Network
Qt6::Sql Qt6::Sql
Qt6::Concurrent Qt6::Concurrent
@ -84,12 +86,26 @@ target_link_libraries(alakarte PRIVATE
KF6::WindowSystem 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 ecm_add_qml_module(alakarte URI org.kde.alakarte
VERSION 1.0 VERSION 1.0
QML_FILES QML_FILES
qml/Main.qml qml/Main.qml
qml/LibraryView.qml qml/LibraryView.qml
qml/GameCard.qml qml/CouchSidebar.qml
qml/GameCard.qml
qml/GameDetailsSheet.qml qml/GameDetailsSheet.qml
qml/DiagnosticsSheet.qml qml/DiagnosticsSheet.qml
qml/SettingsPage.qml qml/SettingsPage.qml
@ -166,6 +182,10 @@ ecm_add_qml_module(alakarte URI org.kde.alakarte
install(TARGETS alakarte ${KDE_INSTALL_TARGETS_DEFAULT_ARGS}) install(TARGETS alakarte ${KDE_INSTALL_TARGETS_DEFAULT_ARGS})
install(FILES org.kde.alakarte.notifyrc
DESTINATION ${KDE_INSTALL_KNOTIFYRCDIR}
)
add_subdirectory(gamecenter) add_subdirectory(gamecenter)
add_subdirectory(runner) add_subdirectory(runner)

View file

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

View file

@ -18,6 +18,8 @@
#include "runnermanagerclient.h" #include "runnermanagerclient.h"
#include "steamgriddb.h" #include "steamgriddb.h"
class NotificationInhibitor;
class App : public QObject class App : public QObject
{ {
Q_OBJECT Q_OBJECT
@ -89,6 +91,7 @@ private:
SteamGridDB *m_steamGridDB; SteamGridDB *m_steamGridDB;
MediaManager *m_mediaManager; MediaManager *m_mediaManager;
Config *m_config; Config *m_config;
NotificationInhibitor *m_notificationInhibitor;
bool m_importing = false; bool m_importing = false;
QString m_importStatus; 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) void Config::setWindowSize(int width, int height)
{ {
width = qBound(0, width, 10000); width = qBound(0, width, 10000);
@ -381,6 +409,10 @@ void Config::save()
windowGroup.writeEntry(QStringLiteral("height"), m_windowHeight); windowGroup.writeEntry(QStringLiteral("height"), m_windowHeight);
windowGroup.writeEntry(QStringLiteral("maximized"), m_windowMaximized); 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(); config->sync();
} }
@ -417,6 +449,10 @@ void Config::load()
m_windowWidth = windowGroup.readEntry(QStringLiteral("width"), 0); m_windowWidth = windowGroup.readEntry(QStringLiteral("width"), 0);
m_windowHeight = windowGroup.readEntry(QStringLiteral("height"), 0); m_windowHeight = windowGroup.readEntry(QStringLiteral("height"), 0);
m_windowMaximized = windowGroup.readEntry(QStringLiteral("maximized"), false); 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() void Config::resetToDefaults()
@ -444,6 +480,8 @@ void Config::resetToDefaults()
m_windowWidth = 0; m_windowWidth = 0;
m_windowHeight = 0; m_windowHeight = 0;
m_windowMaximized = false; m_windowMaximized = false;
m_consoleBehaviors = false;
m_mirrorNotifications = true;
save(); save();
@ -469,4 +507,6 @@ void Config::resetToDefaults()
Q_EMIT windowWidthChanged(); Q_EMIT windowWidthChanged();
Q_EMIT windowHeightChanged(); Q_EMIT windowHeightChanged();
Q_EMIT windowMaximizedChanged(); 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 windowWidth READ windowWidth WRITE setWindowWidth NOTIFY windowWidthChanged)
Q_PROPERTY(int windowHeight READ windowHeight WRITE setWindowHeight NOTIFY windowHeightChanged) Q_PROPERTY(int windowHeight READ windowHeight WRITE setWindowHeight NOTIFY windowHeightChanged)
Q_PROPERTY(bool windowMaximized READ windowMaximized WRITE setWindowMaximized NOTIFY windowMaximizedChanged) 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: public:
enum ViewMode { enum ViewMode {
@ -117,6 +119,12 @@ public:
bool windowMaximized() const; bool windowMaximized() const;
void setWindowMaximized(bool maximized); 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 save();
Q_INVOKABLE void load(); Q_INVOKABLE void load();
Q_INVOKABLE void resetToDefaults(); Q_INVOKABLE void resetToDefaults();
@ -146,6 +154,8 @@ Q_SIGNALS:
void windowWidthChanged(); void windowWidthChanged();
void windowHeightChanged(); void windowHeightChanged();
void windowMaximizedChanged(); void windowMaximizedChanged();
void consoleBehaviorsChanged();
void mirrorNotificationsChanged();
private: private:
ViewMode m_viewMode = GridView; ViewMode m_viewMode = GridView;
@ -171,4 +181,6 @@ private:
int m_windowWidth = 0; int m_windowWidth = 0;
int m_windowHeight = 0; int m_windowHeight = 0;
bool m_windowMaximized = false; 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 kPowerProfilesPath = QStringLiteral("/net/hadess/PowerProfiles");
static const QString kPowerProfilesInterface = 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 class GameCenterHelper : public QObject
{ {
Q_OBJECT Q_OBJECT
public Q_SLOTS: public Q_SLOTS:
ActionReply setpowerprofile(const QVariantMap &args); 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) static QVariant unwrapDbusVariant(QVariant v)
@ -128,6 +137,110 @@ ActionReply GameCenterHelper::setpowerprofile(const QVariantMap &args)
return ActionReply::SuccessReply(); 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) KAUTH_HELPER_MAIN("org.kde.alakarte.gamecenter.helper", GameCenterHelper)
#include "gamecenterkauthhelper.moc" #include "gamecenterkauthhelper.moc"

View file

@ -14,4 +14,28 @@
<allow_active>auth_admin_keep</allow_active> <allow_active>auth_admin_keep</allow_active>
</defaults> </defaults>
</action> </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> </policyconfig>

View file

@ -12,6 +12,7 @@
#include <QDBusConnection> #include <QDBusConnection>
#include <QDBusConnectionInterface> #include <QDBusConnectionInterface>
#include <QDBusError> #include <QDBusError>
#include <QDBusReply>
#include <QDBusVariant> #include <QDBusVariant>
#include <QDateTime> #include <QDateTime>
#include <QDir> #include <QDir>
@ -32,7 +33,6 @@ static bool pingDaemon(QDBusConnection bus)
if (!bus.isConnected()) { if (!bus.isConnected()) {
return false; return false;
} }
org::kde::GameCenter1 iface(kGameCenterService, kGameCenterPath, bus); org::kde::GameCenter1 iface(kGameCenterService, kGameCenterPath, bus);
if (!iface.isValid()) { if (!iface.isValid()) {
return false; 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]() { QTimer::singleShot(0, this, [this]() {
ensureDaemon();
checkDaemonAvailability(); checkDaemonAvailability();
syncDaemonSessions(); syncDaemonSessions();
}); });
@ -291,10 +298,97 @@ GameLauncher::GameLauncher(QObject *parent)
GameLauncher::~GameLauncher() GameLauncher::~GameLauncher()
{ {
shutdownSpawnedDaemon();
m_daemonGameToSession.clear(); m_daemonGameToSession.clear();
m_daemonSessionToGame.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 bool GameLauncher::hasRunningGames() const
{ {
return !m_daemonGameToSession.isEmpty(); return !m_daemonGameToSession.isEmpty();
@ -803,6 +897,7 @@ void GameLauncher::applyRunningStateToLibrary()
void GameLauncher::checkDaemonAvailability() void GameLauncher::checkDaemonAvailability()
{ {
ensureDaemon();
const bool available = pingDaemon(QDBusConnection::sessionBus()); const bool available = pingDaemon(QDBusConnection::sessionBus());
disconnectDaemonSignals(QDBusConnection::sessionBus(), this); disconnectDaemonSignals(QDBusConnection::sessionBus(), this);

View file

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

View file

@ -1,6 +1,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors // SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
#include <QApplication>
#include <QByteArray> #include <QByteArray>
#include <QCommandLineParser> #include <QCommandLineParser>
#include <QGuiApplication> #include <QGuiApplication>
@ -8,6 +9,7 @@
#include <QQmlApplicationEngine> #include <QQmlApplicationEngine>
#include <QQmlContext> #include <QQmlContext>
#include <QQuickStyle> #include <QQuickStyle>
#include <QWindow>
#include <KAboutData> #include <KAboutData>
#include <KCrash> #include <KCrash>
@ -16,11 +18,22 @@
#include <KLocalizedString> #include <KLocalizedString>
#include "alakarte-version.h" #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[]) int main(int argc, char *argv[])
{ {
qputenv("QT_DESKTOP_FILE_NAME", QByteArray("org.kde.alakarte")); qputenv("QT_DESKTOP_FILE_NAME", QByteArray("org.kde.alakarte"));
QGuiApplication app(argc, argv); QApplication app(argc, argv);
QGuiApplication::setDesktopFileName(QStringLiteral("org.kde.alakarte")); QGuiApplication::setDesktopFileName(QStringLiteral("org.kde.alakarte"));
app.setOrganizationDomain(QStringLiteral("kde.org")); app.setOrganizationDomain(QStringLiteral("kde.org"));
@ -50,9 +63,42 @@ int main(int argc, char *argv[])
QCommandLineParser parser; QCommandLineParser parser;
aboutData.setupCommandLine(&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); parser.process(app);
aboutData.processCommandLine(&parser); 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); KDBusService service(KDBusService::Unique);
if (qEnvironmentVariableIsEmpty("QT_QUICK_CONTROLS_STYLE")) { if (qEnvironmentVariableIsEmpty("QT_QUICK_CONTROLS_STYLE")) {
@ -69,5 +115,15 @@ int main(int argc, char *argv[])
return -1; 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(); 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 if (w && w.isTouchDevice !== undefined) return w.isTouchDevice
return Kirigami.Settings.tabletMode || Kirigami.Settings.isMobile return Kirigami.Settings.tabletMode || Kirigami.Settings.isMobile
} }
readonly property int adaptiveFocusRingWidth: 1 readonly property real adaptiveHoverScale: root.isCouchMode ? 1.05 : 1.015
readonly property real adaptiveHoverScale: 1.015 readonly property real adaptiveFocusScale: root.isCouchMode ? 1.12 : 1.03
readonly property real adaptiveFocusScale: 1.03 readonly property int adaptiveFocusRingWidth: root.isCouchMode ? 3 : 1
readonly property bool useAnimatedCover: App.config.animatedCovers readonly property bool useAnimatedCover: App.config.animatedCovers
&& game && game
@ -43,8 +43,8 @@ FocusScope {
color: Kirigami.Theme.backgroundColor color: Kirigami.Theme.backgroundColor
shadow { shadow {
size: gameCard.focused ? Kirigami.Units.mediumSpacing : (hoverHandler.hovered ? Kirigami.Units.smallSpacing * 1.5 : Kirigami.Units.smallSpacing) 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 ? Qt.rgba(0, 0, 0, 0.34) : (hoverHandler.hovered ? Qt.rgba(0, 0, 0, 0.24) : Qt.rgba(0, 0, 0, 0.16)) 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 { Behavior on shadow.color {
@ -162,6 +162,8 @@ FocusScope {
// Gradient overlay for text // Gradient overlay for text
Rectangle { 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.left: parent.left
anchors.right: parent.right anchors.right: parent.right
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
@ -178,6 +180,8 @@ FocusScope {
// Game title // Game title
ColumnLayout { 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.left: parent.left
anchors.right: parent.right anchors.right: parent.right
anchors.bottom: parent.bottom anchors.bottom: parent.bottom

View file

@ -160,6 +160,11 @@ FocusScope {
id: searchHeader id: searchHeader
Layout.fillWidth: true Layout.fillWidth: true
visible: libraryRoot.searchActive 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 searchField: searchField

View file

@ -327,6 +327,7 @@ Kirigami.ApplicationWindow {
pageStack.columnView.columnResizeMode: Kirigami.ColumnView.SingleColumn pageStack.columnView.columnResizeMode: Kirigami.ColumnView.SingleColumn
footer: Item { footer: Item {
visible: !root.isCouchMode
implicitWidth: root.width implicitWidth: root.width
implicitHeight: footerBar.implicitHeight implicitHeight: footerBar.implicitHeight
@ -749,6 +750,7 @@ Kirigami.ApplicationWindow {
id: sidebar id: sidebar
edge: Qt.LeftEdge edge: Qt.LeftEdge
modal: root.isMobile || root.isNarrowScreen || root.isCouchMode modal: root.isMobile || root.isNarrowScreen || root.isCouchMode
enabled: !root.isCouchMode
handleClosedIcon.source: "application-menu" handleClosedIcon.source: "application-menu"
handleOpenIcon.source: "go-previous" handleOpenIcon.source: "go-previous"
handleVisible: root.isMobile || root.isNarrowScreen || root.isCouchMode handleVisible: root.isMobile || root.isNarrowScreen || root.isCouchMode
@ -856,7 +858,37 @@ Kirigami.ApplicationWindow {
id: mainPage id: mainPage
title: sidebarView.currentSourceName 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: [ actions: [
Kirigami.Action { Kirigami.Action {
@ -899,6 +931,29 @@ Kirigami.ApplicationWindow {
ColumnLayout { ColumnLayout {
anchors.fill: parent anchors.fill: parent
spacing: 0 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 { Kirigami.InlineMessage {
id: daemonBanner id: daemonBanner

View file

@ -1463,6 +1463,49 @@ QVariantMap RunnerManagerDaemon::ResolveLaunch(const QVariantMap &spec) const
finalProgram = protonExe; finalProgram = protonExe;
finalArgs = {QStringLiteral("run"), program}; finalArgs = {QStringLiteral("run"), program};
finalArgs.append(args); 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); 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;
};