diff --git a/CMakeLists.txt b/CMakeLists.txt index f7da0e7..fb5b58d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 0fbbcc5..1bd5df2 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -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,12 +86,26 @@ 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/GameCard.qml + qml/CouchSidebar.qml + qml/GameCard.qml qml/GameDetailsSheet.qml qml/DiagnosticsSheet.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(FILES org.kde.alakarte.notifyrc + DESTINATION ${KDE_INSTALL_KNOTIFYRCDIR} +) + add_subdirectory(gamecenter) add_subdirectory(runner) diff --git a/src/app.cpp b/src/app.cpp index 349bd68..8a65f11 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -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]() { diff --git a/src/app.h b/src/app.h index 0c32f1e..2614de9 100644 --- a/src/app.h +++ b/src/app.h @@ -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; diff --git a/src/config.cpp b/src/config.cpp index 8333ce4..735ff35 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -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(); } diff --git a/src/config.h b/src/config.h index 4722065..1911b1d 100644 --- a/src/config.h +++ b/src/config.h @@ -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; }; diff --git a/src/gamecenter/gamecenterkauthhelper.cpp b/src/gamecenter/gamecenterkauthhelper.cpp index c73c675..fbf67e6 100644 --- a/src/gamecenter/gamecenterkauthhelper.cpp +++ b/src/gamecenter/gamecenterkauthhelper.cpp @@ -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()) { + vtArg = vtArg.value().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" diff --git a/src/gamecenter/polkit/org.kde.alakarte.gamecenter.helper.policy b/src/gamecenter/polkit/org.kde.alakarte.gamecenter.helper.policy index b1102a9..cd5b1a1 100644 --- a/src/gamecenter/polkit/org.kde.alakarte.gamecenter.helper.policy +++ b/src/gamecenter/polkit/org.kde.alakarte.gamecenter.helper.policy @@ -14,4 +14,28 @@ auth_admin_keep + + Activate a login session + Authentication is required to activate a login session. + + no + auth_admin_keep + + + + Switch to a virtual terminal + Authentication is required to switch to a virtual terminal. + + no + auth_admin_keep + + + + Terminate a login session + Authentication is required to terminate a login session. + + no + auth_admin_keep + + diff --git a/src/gamelauncher.cpp b/src/gamelauncher.cpp index ed110c7..5f0655f 100644 --- a/src/gamelauncher.cpp +++ b/src/gamelauncher.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -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 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); diff --git a/src/gamelauncher.h b/src/gamelauncher.h index a4eddee..91621fc 100644 --- a/src/gamelauncher.h +++ b/src/gamelauncher.h @@ -49,7 +49,12 @@ private: QHash 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(); }; diff --git a/src/main.cpp b/src/main.cpp index 5eb72f3..2512db5 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: 2026 A-La-Karte Contributors +#include #include #include #include @@ -8,6 +9,7 @@ #include #include #include +#include #include #include @@ -16,11 +18,22 @@ #include #include "alakarte-version.h" +#include "app.h" +#ifdef ALAKARTE_HAVE_STATUSNOTIFIERITEM +#include "traycontroller.h" +#endif + +#if defined(Q_OS_UNIX) +#include +#endif + +#include +#include 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(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(); } diff --git a/src/notificationinhibitor.cpp b/src/notificationinhibitor.cpp new file mode 100644 index 0000000..6c7db3a --- /dev/null +++ b/src/notificationinhibitor.cpp @@ -0,0 +1,88 @@ +#include "notificationinhibitor.h" + +#include "config.h" + +#include +#include +#include +#include +#include +#include + +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 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; +} diff --git a/src/notificationinhibitor.h b/src/notificationinhibitor.h new file mode 100644 index 0000000..d732dc4 --- /dev/null +++ b/src/notificationinhibitor.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +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; +}; diff --git a/src/org.kde.alakarte.notifyrc b/src/org.kde.alakarte.notifyrc new file mode 100644 index 0000000..67c615b --- /dev/null +++ b/src/org.kde.alakarte.notifyrc @@ -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 diff --git a/src/qml/CouchSidebar.qml b/src/qml/CouchSidebar.qml new file mode 100644 index 0000000..52cfe66 --- /dev/null +++ b/src/qml/CouchSidebar.qml @@ -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 + } + } +} diff --git a/src/qml/GameCard.qml b/src/qml/GameCard.qml index a7280a3..a139cc6 100644 --- a/src/qml/GameCard.qml +++ b/src/qml/GameCard.qml @@ -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 diff --git a/src/qml/LibraryView.qml b/src/qml/LibraryView.qml index 78cdff8..1473da7 100644 --- a/src/qml/LibraryView.qml +++ b/src/qml/LibraryView.qml @@ -160,6 +160,11 @@ FocusScope { id: searchHeader 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 diff --git a/src/qml/Main.qml b/src/qml/Main.qml index d7c8c3c..8274964 100644 --- a/src/qml/Main.qml +++ b/src/qml/Main.qml @@ -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 { @@ -899,6 +931,29 @@ Kirigami.ApplicationWindow { ColumnLayout { 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 diff --git a/src/runner/runnermanagerdaemon.cpp b/src/runner/runnermanagerdaemon.cpp index 37b5a09..55fdcb1 100644 --- a/src/runner/runnermanagerdaemon.cpp +++ b/src/runner/runnermanagerdaemon.cpp @@ -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); diff --git a/src/traycontroller.cpp b/src/traycontroller.cpp new file mode 100644 index 0000000..0c9c6bd --- /dev/null +++ b/src/traycontroller.cpp @@ -0,0 +1,266 @@ +#include "traycontroller.h" + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +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 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(); +} diff --git a/src/traycontroller.h b/src/traycontroller.h new file mode 100644 index 0000000..2a2dede --- /dev/null +++ b/src/traycontroller.h @@ -0,0 +1,57 @@ +#pragma once + +#include +#include + +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 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; +};