diff --git a/CMakeLists.txt b/CMakeLists.txt index 9ec58d3d..c3b37603 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -26,6 +26,7 @@ option(SERVICE_MPRIS "Mpris service" ON) option(SERVICE_PAM "Pam service" ON) option(SERVICE_GREETD "Greet service" ON) option(SERVICE_UPOWER "UPower service" ON) +option(SERVICE_NOTIFICATIONS "Notification server" ON) message(STATUS "Quickshell configuration") message(STATUS " Jemalloc: ${USE_JEMALLOC}") @@ -45,6 +46,7 @@ message(STATUS " Mpris: ${SERVICE_MPRIS}") message(STATUS " Pam: ${SERVICE_PAM}") message(STATUS " Greetd: ${SERVICE_GREETD}") message(STATUS " UPower: ${SERVICE_UPOWER}") +message(STATUS " Notifications: ${SERVICE_NOTIFICATIONS}") message(STATUS " Hyprland: ${HYPRLAND}") if (HYPRLAND) message(STATUS " IPC: ${HYPRLAND_IPC}") diff --git a/src/core/model.cpp b/src/core/model.cpp index 64f7d765..9aaa1472 100644 --- a/src/core/model.cpp +++ b/src/core/model.cpp @@ -72,3 +72,8 @@ bool UntypedObjectModel::removeObject(const QObject* object) { } qsizetype UntypedObjectModel::indexOf(QObject* object) { return this->valuesList.indexOf(object); } + +UntypedObjectModel* UntypedObjectModel::emptyInstance() { + static auto* instance = new UntypedObjectModel(nullptr); // NOLINT + return instance; +} diff --git a/src/core/model.hpp b/src/core/model.hpp index 10465bba..ab58f270 100644 --- a/src/core/model.hpp +++ b/src/core/model.hpp @@ -55,6 +55,8 @@ public: Q_INVOKABLE qsizetype indexOf(QObject* object); + static UntypedObjectModel* emptyInstance(); + signals: void valuesChanged(); /// Sent immediately before an object is inserted into the list. @@ -82,6 +84,10 @@ class ObjectModel: public UntypedObjectModel { public: explicit ObjectModel(QObject* parent): UntypedObjectModel(parent) {} + [[nodiscard]] QVector& valueList() { + return *reinterpret_cast*>(&this->valuesList); // NOLINT + } + [[nodiscard]] const QVector& valueList() const { return *reinterpret_cast*>(&this->valuesList); // NOLINT } @@ -91,4 +97,8 @@ public: } void removeObject(const T* object) { this->UntypedObjectModel::removeObject(object); } + + static ObjectModel* emptyInstance() { + return static_cast*>(UntypedObjectModel::emptyInstance()); + } }; diff --git a/src/services/CMakeLists.txt b/src/services/CMakeLists.txt index 089f5fd7..5ab5c550 100644 --- a/src/services/CMakeLists.txt +++ b/src/services/CMakeLists.txt @@ -21,3 +21,7 @@ endif() if (SERVICE_UPOWER) add_subdirectory(upower) endif() + +if (SERVICE_NOTIFICATIONS) + add_subdirectory(notifications) +endif() diff --git a/src/services/notifications/CMakeLists.txt b/src/services/notifications/CMakeLists.txt new file mode 100644 index 00000000..cc986a84 --- /dev/null +++ b/src/services/notifications/CMakeLists.txt @@ -0,0 +1,29 @@ +qt_add_dbus_adaptor(DBUS_INTERFACES + org.freedesktop.Notifications.xml + server.hpp + qs::service::notifications::NotificationServer + dbus_notifications + DBusNotificationServer +) + +qt_add_library(quickshell-service-notifications STATIC + server.cpp + notification.cpp + dbusimage.cpp + qml.cpp + ${DBUS_INTERFACES} +) + +# dbus headers +target_include_directories(quickshell-service-notifications PRIVATE ${CMAKE_CURRENT_BINARY_DIR}) + +qt_add_qml_module(quickshell-service-notifications + URI Quickshell.Services.Notifications + VERSION 0.1 +) + +target_link_libraries(quickshell-service-notifications PRIVATE ${QT_DEPS} quickshell-dbus) +target_link_libraries(quickshell PRIVATE quickshell-service-notificationsplugin) + +qs_pch(quickshell-service-notifications) +qs_pch(quickshell-service-notificationsplugin) diff --git a/src/services/notifications/dbusimage.cpp b/src/services/notifications/dbusimage.cpp new file mode 100644 index 00000000..b292f02f --- /dev/null +++ b/src/services/notifications/dbusimage.cpp @@ -0,0 +1,72 @@ +#include "dbusimage.hpp" + +#include +#include +#include +#include +#include +#include + +namespace qs::service::notifications { + +Q_DECLARE_LOGGING_CATEGORY(logNotifications); // server.cpp + +QImage DBusNotificationImage::createImage() const { + auto format = this->hasAlpha ? QImage::Format_RGBA8888 : QImage::Format_RGB888; + + return QImage( + reinterpret_cast(this->data.data()), // NOLINT + this->width, + this->height, + format + ); +} + +const QDBusArgument& operator>>(const QDBusArgument& argument, DBusNotificationImage& pixmap) { + argument.beginStructure(); + argument >> pixmap.width; + argument >> pixmap.height; + auto rowstride = qdbus_cast(argument); + argument >> pixmap.hasAlpha; + auto sampleBits = qdbus_cast(argument); + auto channels = qdbus_cast(argument); + argument >> pixmap.data; + argument.endStructure(); + + if (sampleBits != 8) { + qCWarning(logNotifications) << "Unable to parse pixmap as sample count is incorrect. Got" + << sampleBits << "expected" << 8; + } else if (channels != (pixmap.hasAlpha ? 4 : 3)) { + qCWarning(logNotifications) << "Unable to parse pixmap as channel count is incorrect." + << "Got " << channels << "expected" << (pixmap.hasAlpha ? 4 : 3); + } else if (rowstride != pixmap.width * sampleBits * channels) { + qCWarning(logNotifications) << "Unable to parse pixmap as rowstride is incorrect. Got" + << rowstride << "expected" + << (pixmap.width * sampleBits * channels); + } + + return argument; +} + +const QDBusArgument& operator<<(QDBusArgument& argument, const DBusNotificationImage& pixmap) { + argument.beginStructure(); + argument << pixmap.width; + argument << pixmap.height; + argument << pixmap.width * (pixmap.hasAlpha ? 4 : 3) * 8; + argument << pixmap.hasAlpha; + argument << 8; + argument << (pixmap.hasAlpha ? 4 : 3); + argument << pixmap.data; + argument.endStructure(); + return argument; +} + +QImage +NotificationImage::requestImage(const QString& /*unused*/, QSize* size, const QSize& /*unused*/) { + auto image = this->image.createImage(); + + if (size != nullptr) *size = image.size(); + return image; +} + +} // namespace qs::service::notifications diff --git a/src/services/notifications/dbusimage.hpp b/src/services/notifications/dbusimage.hpp new file mode 100644 index 00000000..d81d1e74 --- /dev/null +++ b/src/services/notifications/dbusimage.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include + +#include +#include +#include + +#include "../../core/imageprovider.hpp" + +namespace qs::service::notifications { + +struct DBusNotificationImage { + qint32 width = 0; + qint32 height = 0; + bool hasAlpha = false; + QByteArray data; + + // valid only for the lifetime of the pixmap + [[nodiscard]] QImage createImage() const; +}; + +const QDBusArgument& operator>>(const QDBusArgument& argument, DBusNotificationImage& pixmap); +const QDBusArgument& operator<<(QDBusArgument& argument, const DBusNotificationImage& pixmap); + +class NotificationImage: public QsImageHandle { +public: + explicit NotificationImage(DBusNotificationImage image, QObject* parent) + : QsImageHandle(QQuickAsyncImageProvider::Image, parent) + , image(std::move(image)) {} + + QImage requestImage(const QString& id, QSize* size, const QSize& requestedSize) override; + + DBusNotificationImage image; +}; +} // namespace qs::service::notifications diff --git a/src/services/notifications/module.md b/src/services/notifications/module.md new file mode 100644 index 00000000..8e9ed2c1 --- /dev/null +++ b/src/services/notifications/module.md @@ -0,0 +1,4 @@ +name = "Quickshell.Services.Notifications" +description = "Types for implementing a notification daemon" +headers = [ "qml.hpp", "notification.hpp" ] +----- diff --git a/src/services/notifications/notification.cpp b/src/services/notifications/notification.cpp new file mode 100644 index 00000000..e267699b --- /dev/null +++ b/src/services/notifications/notification.cpp @@ -0,0 +1,252 @@ +#include "notification.hpp" +#include + +#include +#include +#include +#include +#include +#include + +#include "../../core/desktopentry.hpp" +#include "../../core/iconimageprovider.hpp" +#include "dbusimage.hpp" +#include "server.hpp" + +namespace qs::service::notifications { + +Q_DECLARE_LOGGING_CATEGORY(logNotifications); // server.cpp + +QString NotificationUrgency::toString(NotificationUrgency::Enum value) { + switch (value) { + case NotificationUrgency::Low: return "Low"; + case NotificationUrgency::Normal: return "Normal"; + case NotificationUrgency::Critical: return "Critical"; + default: return "Invalid notification urgency"; + } +} + +QString NotificationCloseReason::toString(NotificationCloseReason::Enum value) { + switch (value) { + case NotificationCloseReason::Expired: return "Expired"; + case NotificationCloseReason::Dismissed: return "Dismissed"; + case NotificationCloseReason::CloseRequested: return "CloseRequested"; + default: return "Invalid notification close reason"; + } +} + +QString NotificationAction::identifier() const { return this->mIdentifier; } +QString NotificationAction::text() const { return this->mText; } + +void NotificationAction::invoke() { + NotificationServer::instance()->ActionInvoked(this->notification->id(), this->mIdentifier); + + if (!this->notification->isResident()) { + this->notification->close(NotificationCloseReason::Dismissed); + } +} + +void NotificationAction::setText(const QString& text) { + if (text != this->mText) return; + + this->mText = text; + emit this->textChanged(); +} + +void Notification::expire() { this->close(NotificationCloseReason::Expired); } +void Notification::dismiss() { this->close(NotificationCloseReason::Dismissed); } + +void Notification::close(NotificationCloseReason::Enum reason) { + this->mCloseReason = reason; + + if (reason != 0) { + NotificationServer::instance()->deleteNotification(this, reason); + } +} + +void Notification::updateProperties( + const QString& appName, + QString appIcon, + const QString& summary, + const QString& body, + const QStringList& actions, + QVariantMap hints, + qint32 expireTimeout +) { + auto urgency = hints.contains("urgency") ? hints.value("urgency").value() + : NotificationUrgency::Normal; + + auto hasActionIcons = hints.value("action-icons").value(); + auto isResident = hints.value("resident").value(); + auto isTransient = hints.value("transient").value(); + auto desktopEntry = hints.value("desktop-entry").value(); + + QString imageDataName; + if (hints.contains("image-data")) imageDataName = "image-data"; + else if (hints.contains("image_data")) imageDataName = "image_data"; + else if (hints.contains("icon_data")) imageDataName = "icon_data"; + + NotificationImage* imagePixmap = nullptr; + if (!imageDataName.isEmpty()) { + auto value = hints.value(imageDataName).value(); + DBusNotificationImage image; + value >> image; + imagePixmap = new NotificationImage(std::move(image), this); + } + + // don't store giant byte arrays more than necessary + hints.remove("image-data"); + hints.remove("image_data"); + hints.remove("icon_data"); + + QString imagePath; + if (!imagePixmap) { + QString imagePathName; + if (hints.contains("image-path")) imagePathName = "image-path"; + else if (hints.contains("image_path")) imagePathName = "image_path"; + + if (!imagePathName.isEmpty()) { + imagePath = hints.value(imagePathName).value(); + + if (!imagePath.startsWith("file:")) { + imagePath = IconImageProvider::requestString(imagePath, ""); + } + } + } + + if (appIcon.isEmpty() && !desktopEntry.isEmpty()) { + if (auto* entry = DesktopEntryManager::instance()->byId(desktopEntry)) { + appIcon = entry->mIcon; + } + } + + auto appNameChanged = appName != this->mAppName; + auto appIconChanged = appIcon != this->mAppIcon; + auto summaryChanged = summary != this->mSummary; + auto bodyChanged = body != this->mBody; + auto expireTimeoutChanged = expireTimeout != this->mExpireTimeout; + auto urgencyChanged = urgency != this->mUrgency; + auto hasActionIconsChanged = hasActionIcons != this->mHasActionIcons; + auto isResidentChanged = isResident != this->mIsResident; + auto isTransientChanged = isTransient != this->mIsTransient; + auto desktopEntryChanged = desktopEntry != this->mDesktopEntry; + auto imageChanged = imagePixmap || imagePath != this->mImagePath; + auto hintsChanged = hints != this->mHints; + + if (appNameChanged) this->mAppName = appName; + if (appIconChanged) this->mAppIcon = appIcon; + if (summaryChanged) this->mSummary = summary; + if (bodyChanged) this->mBody = body; + if (expireTimeoutChanged) this->mExpireTimeout = expireTimeout; + if (urgencyChanged) this->mUrgency = static_cast(urgency); + if (hasActionIcons) this->mHasActionIcons = hasActionIcons; + if (isResidentChanged) this->mIsResident = isResident; + if (isTransientChanged) this->mIsTransient = isTransient; + if (desktopEntryChanged) this->mDesktopEntry = desktopEntry; + + NotificationImage* oldImage = nullptr; + + if (imageChanged) { + oldImage = this->mImagePixmap; + this->mImagePixmap = imagePixmap; + this->mImagePath = imagePath; + } + + if (hintsChanged) this->mHints = hints; + + bool actionsChanged = false; + auto deletedActions = QVector(); + + if (actions.length() % 2 == 0) { + int ai = 0; + for (auto i = 0; i != actions.length(); i += 2) { + ai = i / 2; + const auto& identifier = actions.at(i); + const auto& text = actions.at(i + 1); + auto* action = ai < this->mActions.length() ? this->mActions.at(ai) : nullptr; + + if (action && identifier == action->identifier()) { + action->setText(text); + } else { + auto* newAction = new NotificationAction(identifier, text, this); + + if (action) { + deletedActions.push_back(action); + this->mActions.replace(ai, newAction); + } else { + this->mActions.push_back(newAction); + } + + actionsChanged = true; + } + + ai++; + } + + for (auto i = this->mActions.length(); i > ai; i--) { + deletedActions.push_back(this->mActions.at(i - 1)); + this->mActions.remove(i - 1); + actionsChanged = true; + } + } else { + qCWarning(logNotifications) << this << '(' << appName << ')' + << "sent an action set of an invalid length."; + } + + if (appNameChanged) emit this->appNameChanged(); + if (appIconChanged) emit this->appIconChanged(); + if (summaryChanged) emit this->summaryChanged(); + if (bodyChanged) emit this->bodyChanged(); + if (expireTimeoutChanged) emit this->expireTimeoutChanged(); + if (urgencyChanged) emit this->urgencyChanged(); + if (actionsChanged) emit this->actionsChanged(); + if (hasActionIconsChanged) emit this->hasActionIconsChanged(); + if (isResidentChanged) emit this->isResidentChanged(); + if (isTransientChanged) emit this->isTransientChanged(); + if (desktopEntryChanged) emit this->desktopEntryChanged(); + if (imageChanged) emit this->imageChanged(); + if (hintsChanged) emit this->hintsChanged(); + + for (auto* action: deletedActions) { + delete action; + } + + delete oldImage; +} + +quint32 Notification::id() const { return this->mId; } +bool Notification::isTracked() const { return this->mCloseReason == 0; } +NotificationCloseReason::Enum Notification::closeReason() const { return this->mCloseReason; } + +void Notification::setTracked(bool tracked) { + this->close( + tracked ? static_cast(0) : NotificationCloseReason::Dismissed + ); +} + +bool Notification::isLastGeneration() const { return this->mLastGeneration; } +void Notification::setLastGeneration() { this->mLastGeneration = true; } + +qreal Notification::expireTimeout() const { return this->mExpireTimeout; } +QString Notification::appName() const { return this->mAppName; } +QString Notification::appIcon() const { return this->mAppIcon; } +QString Notification::summary() const { return this->mSummary; } +QString Notification::body() const { return this->mBody; } +NotificationUrgency::Enum Notification::urgency() const { return this->mUrgency; } +QVector Notification::actions() const { return this->mActions; } +bool Notification::hasActionIcons() const { return this->mHasActionIcons; } +bool Notification::isResident() const { return this->mIsResident; } +bool Notification::isTransient() const { return this->mIsTransient; } +QString Notification::desktopEntry() const { return this->mDesktopEntry; } + +QString Notification::image() const { + if (this->mImagePixmap) { + return this->mImagePixmap->url(); + } else { + return this->mImagePath; + } +} + +QVariantMap Notification::hints() const { return this->mHints; } + +} // namespace qs::service::notifications diff --git a/src/services/notifications/notification.hpp b/src/services/notifications/notification.hpp new file mode 100644 index 00000000..5be56172 --- /dev/null +++ b/src/services/notifications/notification.hpp @@ -0,0 +1,225 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include + +namespace qs::service::notifications { + +class NotificationImage; + +class NotificationUrgency: public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_SINGLETON; + +public: + enum Enum { + Low = 0, + Normal = 1, + Critical = 2, + }; + Q_ENUM(Enum); + + Q_INVOKABLE static QString toString(NotificationUrgency::Enum value); +}; + +class NotificationCloseReason: public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_SINGLETON; + +public: + enum Enum { + /// The notification expired due to a timeout. + Expired = 1, + /// The notification was explicitly dismissed by the user. + Dismissed = 2, + /// The remote application requested the notification be removed. + CloseRequested = 3, + }; + Q_ENUM(Enum); + + Q_INVOKABLE static QString toString(NotificationCloseReason::Enum value); +}; + +class NotificationAction; + +///! A notification emitted by a NotificationServer. +class Notification: public QObject { + Q_OBJECT; + /// Id of the notification as given to the client. + Q_PROPERTY(quint32 id READ id CONSTANT); + /// If the notification is tracked by the notification server. + /// + /// Setting this property to false is equivalent to calling `dismiss()`. + Q_PROPERTY(bool tracked READ isTracked WRITE setTracked NOTIFY trackedChanged); + /// If this notification was carried over from the last generation + /// when quickshell reloaded. + /// + /// Notifications from the last generation will only be emitted if + /// [NotificationServer.keepOnReload](../notificationserver#prop.keepOnReload) is true. + Q_PROPERTY(bool lastGeneration READ isLastGeneration CONSTANT); + /// Time in seconds the notification should be valid for + Q_PROPERTY(qreal expireTimeout READ expireTimeout NOTIFY expireTimeoutChanged); + /// The sending application's name. + Q_PROPERTY(QString appName READ appName NOTIFY appNameChanged); + /// The sending application's icon. If none was provided, then the icon from an associated + /// desktop entry will be retrieved. If none was found then "". + Q_PROPERTY(QString appIcon READ appIcon NOTIFY appIconChanged); + /// The image associated with this notification, or "" if none. + Q_PROPERTY(QString summary READ summary NOTIFY summaryChanged); + Q_PROPERTY(QString body READ body NOTIFY bodyChanged); + Q_PROPERTY(NotificationUrgency::Enum urgency READ urgency NOTIFY urgencyChanged); + /// Actions that can be taken for this notification. + Q_PROPERTY(QVector actions READ actions NOTIFY actionsChanged); + /// If actions associated with this notification have icons available. + /// + /// See [NotificationAction.identifier](../notificationaction#prop.identifier) for details. + Q_PROPERTY(bool hasActionIcons READ hasActionIcons NOTIFY hasActionIconsChanged); + /// If true, the notification will not be destroyed after an action is invoked. + Q_PROPERTY(bool resident READ isResident NOTIFY isResidentChanged); + /// If true, the notification should skip any kind of persistence function like a notification area. + Q_PROPERTY(bool transient READ isTransient NOTIFY isTransientChanged); + /// The name of the sender's desktop entry or "" if none was supplied. + Q_PROPERTY(QString desktopEntry READ desktopEntry NOTIFY desktopEntryChanged); + /// An image associated with the notification. + /// + /// This image is often something like a profile picture in instant messaging applications. + Q_PROPERTY(QString image READ image NOTIFY imageChanged); + /// All hints sent by the client application as a javascript object. + /// Many common hints are exposed via other properties. + Q_PROPERTY(QVariantMap hints READ hints NOTIFY hintsChanged); + QML_ELEMENT; + QML_UNCREATABLE("Notifications must be acquired from a NotificationServer"); + +public: + explicit Notification(quint32 id, QObject* parent): QObject(parent), mId(id) {} + + /// Destroy the notification and hint to the remote application that it has + /// timed out an expired. + Q_INVOKABLE void expire(); + /// Destroy the notification and hint to the remote application that it was + /// explicitly closed by the user. + Q_INVOKABLE void dismiss(); + + void updateProperties( + const QString& appName, + QString appIcon, + const QString& summary, + const QString& body, + const QStringList& actions, + QVariantMap hints, + qint32 expireTimeout + ); + + void close(NotificationCloseReason::Enum reason); + + [[nodiscard]] quint32 id() const; + + [[nodiscard]] bool isTracked() const; + [[nodiscard]] NotificationCloseReason::Enum closeReason() const; + void setTracked(bool tracked); + + [[nodiscard]] bool isLastGeneration() const; + void setLastGeneration(); + + [[nodiscard]] qreal expireTimeout() const; + [[nodiscard]] QString appName() const; + [[nodiscard]] QString appIcon() const; + [[nodiscard]] QString summary() const; + [[nodiscard]] QString body() const; + [[nodiscard]] NotificationUrgency::Enum urgency() const; + [[nodiscard]] QVector actions() const; + [[nodiscard]] bool hasActionIcons() const; + [[nodiscard]] bool isResident() const; + [[nodiscard]] bool isTransient() const; + [[nodiscard]] QString desktopEntry() const; + [[nodiscard]] QString image() const; + [[nodiscard]] QVariantMap hints() const; + +signals: + /// Sent when a notification has been closed. + /// + /// The notification object will be destroyed as soon as all signal handlers exit. + void closed(NotificationCloseReason::Enum reason); + + void trackedChanged(); + void expireTimeoutChanged(); + void appNameChanged(); + void appIconChanged(); + void summaryChanged(); + void bodyChanged(); + void urgencyChanged(); + void actionsChanged(); + void hasActionIconsChanged(); + void isResidentChanged(); + void isTransientChanged(); + void desktopEntryChanged(); + void imageChanged(); + void hintsChanged(); + +private: + quint32 mId; + NotificationCloseReason::Enum mCloseReason = NotificationCloseReason::Dismissed; + bool mLastGeneration = false; + qreal mExpireTimeout = 0; + QString mAppName; + QString mAppIcon; + QString mSummary; + QString mBody; + NotificationUrgency::Enum mUrgency = NotificationUrgency::Normal; + QVector mActions; + bool mHasActionIcons = false; + bool mIsResident = false; + bool mIsTransient = false; + QString mImagePath; + NotificationImage* mImagePixmap = nullptr; + QString mDesktopEntry; + QVariantMap mHints; +}; + +class NotificationAction: public QObject { + Q_OBJECT; + /// The identifier of the action. + /// + /// When [Notification.hasActionIcons] is true, this property will be an icon name. + /// When it is false, this property is irrelevant. + /// + /// [Notification.hasActionIcons]: ../notification#prop.hasActionIcons + Q_PROPERTY(QString identifier READ identifier CONSTANT); + /// The localized text that should be displayed on a button. + Q_PROPERTY(QString text READ text NOTIFY textChanged); + QML_ELEMENT; + QML_UNCREATABLE("NotificationActions must be acquired from a Notification"); + +public: + explicit NotificationAction(QString identifier, QString text, Notification* notification) + : QObject(notification) + , notification(notification) + , mIdentifier(std::move(identifier)) + , mText(std::move(text)) {} + + /// Invoke the action. If [Notification.resident] is false it will be dismissed. + /// + /// [Notification.resident]: ../notification#prop.resident + Q_INVOKABLE void invoke(); + + [[nodiscard]] QString identifier() const; + [[nodiscard]] QString text() const; + void setText(const QString& text); + +signals: + void textChanged(); + +private: + Notification* notification; + QString mIdentifier; + QString mText; +}; + +} // namespace qs::service::notifications diff --git a/src/services/notifications/org.freedesktop.Notifications.xml b/src/services/notifications/org.freedesktop.Notifications.xml new file mode 100644 index 00000000..1a2001fe --- /dev/null +++ b/src/services/notifications/org.freedesktop.Notifications.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/services/notifications/qml.cpp b/src/services/notifications/qml.cpp new file mode 100644 index 00000000..99818214 --- /dev/null +++ b/src/services/notifications/qml.cpp @@ -0,0 +1,141 @@ +#include "qml.hpp" +#include + +#include +#include +#include +#include + +#include "../../core/model.hpp" +#include "notification.hpp" +#include "server.hpp" + +namespace qs::service::notifications { + +void NotificationServerQml::onPostReload() { + auto* instance = NotificationServer::instance(); + instance->support = this->support; + + QObject::connect( + instance, + &NotificationServer::notification, + this, + &NotificationServerQml::notification + ); + + instance->switchGeneration(this->mKeepOnReload, [this]() { + this->live = true; + emit this->trackedNotificationsChanged(); + }); +} + +bool NotificationServerQml::keepOnReload() const { return this->mKeepOnReload; } + +void NotificationServerQml::setKeepOnReload(bool keepOnReload) { + if (keepOnReload == this->mKeepOnReload) return; + + if (this->live) { + qCritical() << "Cannot set NotificationServer.keepOnReload after the server has been started."; + return; + } + + this->mKeepOnReload = keepOnReload; + emit this->keepOnReloadChanged(); +} + +bool NotificationServerQml::persistenceSupported() const { return this->support.persistence; } + +void NotificationServerQml::setPersistenceSupported(bool persistenceSupported) { + if (persistenceSupported == this->support.persistence) return; + this->support.persistence = persistenceSupported; + this->updateSupported(); + emit this->persistenceSupportedChanged(); +} + +bool NotificationServerQml::bodySupported() const { return this->support.body; } + +void NotificationServerQml::setBodySupported(bool bodySupported) { + if (bodySupported == this->support.body) return; + this->support.body = bodySupported; + this->updateSupported(); + emit this->bodySupportedChanged(); +} + +bool NotificationServerQml::bodyMarkupSupported() const { return this->support.bodyMarkup; } + +void NotificationServerQml::setBodyMarkupSupported(bool bodyMarkupSupported) { + if (bodyMarkupSupported == this->support.bodyMarkup) return; + this->support.bodyMarkup = bodyMarkupSupported; + this->updateSupported(); + emit this->bodyMarkupSupportedChanged(); +} + +bool NotificationServerQml::bodyHyperlinksSupported() const { return this->support.bodyHyperlinks; } + +void NotificationServerQml::setBodyHyperlinksSupported(bool bodyHyperlinksSupported) { + if (bodyHyperlinksSupported == this->support.bodyHyperlinks) return; + this->support.bodyHyperlinks = bodyHyperlinksSupported; + this->updateSupported(); + emit this->bodyHyperlinksSupportedChanged(); +} + +bool NotificationServerQml::bodyImagesSupported() const { return this->support.bodyImages; } + +void NotificationServerQml::setBodyImagesSupported(bool bodyImagesSupported) { + if (bodyImagesSupported == this->support.bodyImages) return; + this->support.bodyImages = bodyImagesSupported; + this->updateSupported(); + emit this->bodyImagesSupportedChanged(); +} + +bool NotificationServerQml::actionsSupported() const { return this->support.actions; } + +void NotificationServerQml::setActionsSupported(bool actionsSupported) { + if (actionsSupported == this->support.actions) return; + this->support.actions = actionsSupported; + this->updateSupported(); + emit this->actionsSupportedChanged(); +} + +bool NotificationServerQml::actionIconsSupported() const { return this->support.actionIcons; } + +void NotificationServerQml::setActionIconsSupported(bool actionIconsSupported) { + if (actionIconsSupported == this->support.actionIcons) return; + this->support.actionIcons = actionIconsSupported; + this->updateSupported(); + emit this->actionIconsSupportedChanged(); +} + +bool NotificationServerQml::imageSupported() const { return this->support.image; } + +void NotificationServerQml::setImageSupported(bool imageSupported) { + if (imageSupported == this->support.image) return; + this->support.image = imageSupported; + this->updateSupported(); + emit this->imageSupportedChanged(); +} + +QVector NotificationServerQml::extraHints() const { return this->support.extraHints; } + +void NotificationServerQml::setExtraHints(QVector extraHints) { + if (extraHints == this->support.extraHints) return; + this->support.extraHints = std::move(extraHints); + this->updateSupported(); + emit this->extraHintsChanged(); +} + +ObjectModel* NotificationServerQml::trackedNotifications() const { + if (this->live) { + return NotificationServer::instance()->trackedNotifications(); + } else { + return ObjectModel::emptyInstance(); + } +} + +void NotificationServerQml::updateSupported() { + if (this->live) { + NotificationServer::instance()->support = this->support; + } +} + +} // namespace qs::service::notifications diff --git a/src/services/notifications/qml.hpp b/src/services/notifications/qml.hpp new file mode 100644 index 00000000..341eaf2a --- /dev/null +++ b/src/services/notifications/qml.hpp @@ -0,0 +1,139 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "../../core/model.hpp" +#include "../../core/reload.hpp" +#include "notification.hpp" +#include "server.hpp" + +namespace qs::service::notifications { + +///! Desktop Notifications Server. +/// An implementation of the [Desktop Notifications Specification] for receiving notifications +/// from external applications. +/// +/// The server does not advertise most capabilities by default. See the individual properties for details. +/// +/// [Desktop Notifications Specification]: https://specifications.freedesktop.org/notification-spec/notification-spec-latest.html +class NotificationServerQml + : public QObject + , public PostReloadHook { + Q_OBJECT; + // clang-format off + /// If notifications should be re-emitted when quickshell reloads. Defaults to true. + /// + /// The [lastGeneration](../notification#prop.lastGeneration) flag will be + /// set on notifications from the prior generation for further filtering/handling. + Q_PROPERTY(bool keepOnReload READ keepOnReload WRITE setKeepOnReload NOTIFY keepOnReloadChanged); + /// If the notification server should advertise that it can persist notifications in the background + /// after going offscreen. Defaults to false. + Q_PROPERTY(bool persistenceSupported READ persistenceSupported WRITE setPersistenceSupported NOTIFY persistenceSupportedChanged); + /// If notification body text should be advertised as supported by the notification server. + /// Defaults to true. + /// + /// Note that returned notifications are likely to return body text even if this property is false, + /// as it is only a hint. + Q_PROPERTY(bool bodySupported READ bodySupported WRITE setBodySupported NOTIFY bodySupportedChanged); + /// If notification body text should be advertised as supporting markup as described in [the specification] + /// Defaults to false. + /// + /// Note that returned notifications may still contain markup if this property is false, as it is only a hint. + /// By default Text objects will try to render markup. To avoid this if any is sent, change [Text.textFormat] to `PlainText`. + /// + /// [the specification]: https://specifications.freedesktop.org/notification-spec/notification-spec-latest.html#markup + /// [Text.textFormat]: https://doc.qt.io/qt-6/qml-qtquick-text.html#textFormat-prop + Q_PROPERTY(bool bodyMarkupSupported READ bodyMarkupSupported WRITE setBodyMarkupSupported NOTIFY bodyMarkupSupportedChanged); + /// If notification body text should be advertised as supporting hyperlinks as described in [the specification] + /// Defaults to false. + /// + /// Note that returned notifications may still contain hyperlinks if this property is false, as it is only a hint. + /// + /// [the specification]: https://specifications.freedesktop.org/notification-spec/notification-spec-latest.html#hyperlinks + Q_PROPERTY(bool bodyHyperlinksSupported READ bodyHyperlinksSupported WRITE setBodyHyperlinksSupported NOTIFY bodyHyperlinksSupportedChanged); + /// If notification body text should be advertised as supporting images as described in [the specification] + /// Defaults to false. + /// + /// Note that returned notifications may still contain images if this property is false, as it is only a hint. + /// + /// [the specification]: https://specifications.freedesktop.org/notification-spec/notification-spec-latest.html#images + Q_PROPERTY(bool bodyImagesSupported READ bodyImagesSupported WRITE setBodyImagesSupported NOTIFY bodyImagesSupportedChanged); + /// If notification actions should be advertised as supported by the notification server. Defaults to false. + Q_PROPERTY(bool actionsSupported READ actionsSupported WRITE setActionsSupported NOTIFY actionsSupportedChanged); + /// If notification actions should be advertised as supporting the display of icons. Defaults to false. + Q_PROPERTY(bool actionIconsSupported READ actionIconsSupported WRITE setActionIconsSupported NOTIFY actionIconsSupportedChanged); + /// If the notification server should advertise that it supports images. Defaults to false. + Q_PROPERTY(bool imageSupported READ imageSupported WRITE setImageSupported NOTIFY imageSupportedChanged); + /// All notifications currently tracked by the server. + Q_PROPERTY(ObjectModel* trackedNotifications READ trackedNotifications NOTIFY trackedNotificationsChanged); + /// Extra hints to expose to notification clients. + Q_PROPERTY(QVector extraHints READ extraHints WRITE setExtraHints NOTIFY extraHintsChanged); + // clang-format on + QML_NAMED_ELEMENT(NotificationServer); + +public: + void onPostReload() override; + + [[nodiscard]] bool keepOnReload() const; + void setKeepOnReload(bool keepOnReload); + + [[nodiscard]] bool persistenceSupported() const; + void setPersistenceSupported(bool persistenceSupported); + + [[nodiscard]] bool bodySupported() const; + void setBodySupported(bool bodySupported); + + [[nodiscard]] bool bodyMarkupSupported() const; + void setBodyMarkupSupported(bool bodyMarkupSupported); + + [[nodiscard]] bool bodyHyperlinksSupported() const; + void setBodyHyperlinksSupported(bool bodyHyperlinksSupported); + + [[nodiscard]] bool bodyImagesSupported() const; + void setBodyImagesSupported(bool bodyImagesSupported); + + [[nodiscard]] bool actionsSupported() const; + void setActionsSupported(bool actionsSupported); + + [[nodiscard]] bool actionIconsSupported() const; + void setActionIconsSupported(bool actionIconsSupported); + + [[nodiscard]] bool imageSupported() const; + void setImageSupported(bool imageSupported); + + [[nodiscard]] QVector extraHints() const; + void setExtraHints(QVector extraHints); + + [[nodiscard]] ObjectModel* trackedNotifications() const; + +signals: + /// Sent when a notification is received by the server. + /// + /// If this notification should not be discarded, set its `tracked` property to true. + void notification(Notification* notification); + + void keepOnReloadChanged(); + void persistenceSupportedChanged(); + void bodySupportedChanged(); + void bodyMarkupSupportedChanged(); + void bodyHyperlinksSupportedChanged(); + void bodyImagesSupportedChanged(); + void actionsSupportedChanged(); + void actionIconsSupportedChanged(); + void imageSupportedChanged(); + void extraHintsChanged(); + void trackedNotificationsChanged(); + +private: + void updateSupported(); + + bool live = false; + bool mKeepOnReload = true; + NotificationServerSupport support; +}; + +} // namespace qs::service::notifications diff --git a/src/services/notifications/server.cpp b/src/services/notifications/server.cpp new file mode 100644 index 00000000..5b89a894 --- /dev/null +++ b/src/services/notifications/server.cpp @@ -0,0 +1,205 @@ +#include "server.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../core/model.hpp" +#include "dbus_notifications.h" +#include "dbusimage.hpp" +#include "notification.hpp" + +namespace qs::service::notifications { + +Q_LOGGING_CATEGORY(logNotifications, "quickshell.service.notifications"); + +NotificationServer::NotificationServer() { + qDBusRegisterMetaType(); + + new DBusNotificationServer(this); + + qCInfo(logNotifications) << "Starting notification server"; + + auto bus = QDBusConnection::sessionBus(); + + if (!bus.isConnected()) { + qCWarning(logNotifications) << "Could not connect to DBus. Notification service will not work."; + return; + } + + if (!bus.registerObject("/org/freedesktop/Notifications", this)) { + qCWarning(logNotifications) << "Could not register Notification server object with DBus. " + "Notification server will not work."; + return; + } + + QObject::connect( + &this->serviceWatcher, + &QDBusServiceWatcher::serviceUnregistered, + this, + &NotificationServer::onServiceUnregistered + ); + + this->serviceWatcher.setWatchMode(QDBusServiceWatcher::WatchForUnregistration); + this->serviceWatcher.addWatchedService("org.freedesktop.Notifications"); + this->serviceWatcher.setConnection(bus); + + NotificationServer::tryRegister(); +} + +NotificationServer* NotificationServer::instance() { + static auto* instance = new NotificationServer(); // NOLINT + return instance; +} + +void NotificationServer::switchGeneration(bool reEmit, const std::function& clearHook) { + auto notifications = this->mNotifications.valueList(); + this->mNotifications.valueList().clear(); + this->idMap.clear(); + + clearHook(); + + if (reEmit) { + for (auto* notification: notifications) { + notification->setLastGeneration(); + notification->setTracked(false); + emit this->notification(notification); + + if (!notification->isTracked()) { + emit this->NotificationClosed(notification->id(), notification->closeReason()); + delete notification; + } else { + this->idMap.insert(notification->id(), notification); + this->mNotifications.insertObject(notification); + } + } + } else { + for (auto* notification: notifications) { + emit this->NotificationClosed(notification->id(), NotificationCloseReason::Expired); + delete notification; + } + } +} + +ObjectModel* NotificationServer::trackedNotifications() { + return &this->mNotifications; +} + +void NotificationServer::deleteNotification( + Notification* notification, + NotificationCloseReason::Enum reason +) { + if (!this->idMap.contains(notification->id())) return; + + emit notification->closed(reason); + + this->mNotifications.removeObject(notification); + this->idMap.remove(notification->id()); + + emit this->NotificationClosed(notification->id(), reason); + delete notification; +} + +void NotificationServer::tryRegister() { + auto bus = QDBusConnection::sessionBus(); + auto success = bus.registerService("org.freedesktop.Notifications"); + + if (success) { + qCInfo(logNotifications) << "Registered notification server with dbus."; + } else { + qCWarning(logNotifications + ) << "Could not register notification server at org.freedesktop.Notifications, presumably " + "because one is already registered."; + qCWarning(logNotifications + ) << "Registration will be attempted again if the active service is unregistered."; + } +} +void NotificationServer::onServiceUnregistered(const QString& /*unused*/) { + qCDebug(logNotifications) << "Active notification server unregistered, attempting registration"; + this->tryRegister(); +} + +void NotificationServer::CloseNotification(uint id) { + auto* notification = this->idMap.value(id); + + if (notification) { + this->deleteNotification(notification, NotificationCloseReason::CloseRequested); + } +} + +QStringList NotificationServer::GetCapabilities() const { + auto capabilities = QStringList(); + + if (this->support.persistence) capabilities += "persistence"; + + if (this->support.body) { + capabilities += "body"; + if (this->support.bodyMarkup) capabilities += "body-markup"; + if (this->support.bodyHyperlinks) capabilities += "body-hyperlinks"; + if (this->support.bodyImages) capabilities += "body-images"; + } + + if (this->support.actions) { + capabilities += "actions"; + if (this->support.actionIcons) capabilities += "action-icons"; + } + + if (this->support.image) capabilities += "icon-static"; + + capabilities += this->support.extraHints; + + return capabilities; +} + +QString +NotificationServer::GetServerInformation(QString& vendor, QString& version, QString& specVersion) { + vendor = "quickshell"; + version = QCoreApplication::applicationVersion(); + specVersion = "1.2"; + return "quickshell"; +} + +uint NotificationServer::Notify( + const QString& appName, + uint replacesId, + const QString& appIcon, + const QString& summary, + const QString& body, + const QStringList& actions, + const QVariantMap& hints, + int expireTimeout +) { + auto* notification = replacesId == 0 ? nullptr : this->idMap.value(replacesId); + auto old = notification != nullptr; + + if (!notification) { + notification = new Notification(this->nextId++, this); + QQmlEngine::setObjectOwnership(notification, QQmlEngine::CppOwnership); + } + + notification->updateProperties(appName, appIcon, summary, body, actions, hints, expireTimeout); + + if (!old) { + emit this->notification(notification); + + if (!notification->isTracked()) { + emit this->NotificationClosed(notification->id(), notification->closeReason()); + delete notification; + } else { + this->idMap.insert(notification->id(), notification); + this->mNotifications.insertObject(notification); + } + } + + return notification->id(); +} + +} // namespace qs::service::notifications diff --git a/src/services/notifications/server.hpp b/src/services/notifications/server.hpp new file mode 100644 index 00000000..de140f8c --- /dev/null +++ b/src/services/notifications/server.hpp @@ -0,0 +1,79 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include + +#include "../../core/model.hpp" +#include "notification.hpp" + +namespace qs::service::notifications { + +struct NotificationServerSupport { + bool persistence = false; + bool body = true; + bool bodyMarkup = false; + bool bodyHyperlinks = false; + bool bodyImages = false; + bool actions = false; + bool actionIcons = false; + bool image = false; + QVector extraHints; +}; + +class NotificationServer: public QObject { + Q_OBJECT; + +public: + static NotificationServer* instance(); + + void switchGeneration(bool reEmit, const std::function& clearHook); + ObjectModel* trackedNotifications(); + void deleteNotification(Notification* notification, NotificationCloseReason::Enum reason); + + // NOLINTBEGIN + void CloseNotification(uint id); + QStringList GetCapabilities() const; + static QString GetServerInformation(QString& vendor, QString& version, QString& specVersion); + uint Notify( + const QString& appName, + uint replacesId, + const QString& appIcon, + const QString& summary, + const QString& body, + const QStringList& actions, + const QVariantMap& hints, + int expireTimeout + ); + // NOLINTEND + + NotificationServerSupport support; + +signals: + void notification(Notification* notification); + + // NOLINTBEGIN + void NotificationClosed(quint32 id, quint32 reason); + void ActionInvoked(quint32 id, QString action); + // NOLINTEND + +private slots: + void onServiceUnregistered(const QString& service); + +private: + explicit NotificationServer(); + + static void tryRegister(); + + QDBusServiceWatcher serviceWatcher; + quint32 nextId = 1; + QHash idMap; + ObjectModel mNotifications {this}; +}; + +} // namespace qs::service::notifications