diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -39,6 +39,17 @@ list(APPEND knotifications_SRCS notifybyandroid.cpp knotifications.qrc) endif() +if (WIN32) + find_package(LibSnoreToast REQUIRED) + set_package_properties(LibSnoreToast PROPERTIES TYPE REQUIRED + PURPOSE "Enable support for the Windows Toast Notifications with SnoreToast back-end for KNotifications" + DESCRIPTION "A command line application which is capable of creating Windows Toast notifications on Windows 8 or later." + ) + find_package(Qt5Core REQUIRED) + find_package(Qt5Network REQUIRED) + list(APPEND knotifications_SRCS notifybysnore.cpp) + endif () + ecm_qt_declare_logging_category(knotifications_SRCS HEADER debug_p.h IDENTIFIER LOG_KNOTIFICATIONS CATEGORY_NAME org.kde.knotifications) if (Canberra_FOUND) @@ -97,6 +108,9 @@ KF5::WindowSystem KF5::Codecs ) +if (TARGET SnoreToast::SnoreToastActions) + target_link_libraries(KF5Notifications PRIVATE Qt5::Core Qt5::Network SnoreToast::SnoreToastActions) +endif () if (Phonon4Qt5_FOUND) target_link_libraries(KF5Notifications PRIVATE diff --git a/src/knotification.cpp b/src/knotification.cpp --- a/src/knotification.cpp +++ b/src/knotification.cpp @@ -376,10 +376,12 @@ static QString defaultComponentName() { -#ifndef Q_OS_ANDROID - return QStringLiteral("plasma_workspace"); -#else +#if defined(Q_OS_ANDROID) return QStringLiteral("android_defaults"); +#elif defined(Q_OS_WIN) + return QStringLiteral("win32_defaults"); +#else + return QStringLiteral("plasma_workspace"); #endif } diff --git a/src/knotificationmanager.cpp b/src/knotificationmanager.cpp --- a/src/knotificationmanager.cpp +++ b/src/knotificationmanager.cpp @@ -40,12 +40,16 @@ #include "notifybylogfile.h" #include "notifybytaskbar.h" #include "notifybyexecute.h" -#ifndef Q_OS_ANDROID + +#if defined(Q_OS_ANDROID) +#include "notifybyandroid.h" +#elif defined(Q_OS_WIN) +#include "notifybysnore.h" +#else #include "notifybypopup.h" #include "notifybyportal.h" -#else -#include "notifybyandroid.h" #endif + #include "debug_p.h" #if defined(HAVE_CANBERRA) @@ -91,10 +95,10 @@ d->notifyPlugins.clear(); #ifdef QT_DBUS_LIB - const bool inSandbox = QFileInfo::exists(QLatin1String("/.flatpak-info")) || qEnvironmentVariableIsSet("SNAP"); + const bool inSandbox = QFileInfo::exists(QLatin1String("/.flatpak-info")) || qEnvironmentVariableIsSet("SNAP"); - if (inSandbox) { - QDBusConnectionInterface *interface = QDBusConnection::sessionBus().interface(); + if (inSandbox) { + QDBusConnectionInterface *interface = QDBusConnection::sessionBus().interface(); d->portalDBusServiceExists = interface->isServiceRegistered(QStringLiteral("org.freedesktop.portal.Desktop")); } @@ -133,24 +137,25 @@ // We have a series of built-ins up first, and fall back to trying // to instantiate an externally supplied plugin. if (action == QLatin1String("Popup")) { -#ifndef Q_OS_ANDROID +#if defined(Q_OS_ANDROID) + plugin = new NotifyByAndroid(this); +#elif defined(Q_OS_WIN) + plugin = new NotifyBySnore(this); +#else if (d->portalDBusServiceExists) { plugin = new NotifyByPortal(this); } else { plugin = new NotifyByPopup(this); - } -#else - plugin = new NotifyByAndroid(this); + } #endif - addPlugin(plugin); } else if (action == QLatin1String("Taskbar")) { plugin = new NotifyByTaskbar(this); addPlugin(plugin); } else if (action == QLatin1String("Sound")) { #if defined(HAVE_PHONON4QT5) || defined(HAVE_CANBERRA) - plugin = new NotifyByAudio(this); - addPlugin(plugin); + plugin = new NotifyByAudio(this); + addPlugin(plugin); #endif } else if (action == QLatin1String("Execute")) { plugin = new NotifyByExecute(this); diff --git a/src/notifybyportal.h b/src/notifybyportal.h --- a/src/notifybyportal.h +++ b/src/notifybyportal.h @@ -21,44 +21,348 @@ License along with this library. If not, see . */ -#ifndef NOTIFYBYPORTAL_H -#define NOTIFYBYPORTAL_H +#include "notifybyportal.h" -#include "knotificationplugin.h" +#include "knotifyconfig.h" +#include "knotification.h" +#include "debug_p.h" -#include -#include -#include +#include +#include +#include +#include +#include +#include +#include +#include -class KNotification; -class NotifyByPortalPrivate; +#include +static const char portalDbusServiceName[] = "org.freedesktop.portal.Desktop"; +static const char portalDbusInterfaceName[] = "org.freedesktop.portal.Notification"; +static const char portalDbusPath[] = "/org/freedesktop/portal/desktop"; -class NotifyByPortal : public KNotificationPlugin -{ - Q_OBJECT +class NotifyByPortalPrivate { public: - explicit NotifyByPortal(QObject *parent = nullptr); - ~NotifyByPortal() override; + struct PortalIcon { + QString str; + QDBusVariant data; + }; + + NotifyByPortalPrivate(NotifyByPortal *parent) : dbusServiceExists(false), q(parent) {} + + /** + * Sends notification to DBus "org.freedesktop.notifications" interface. + * @param id knotify-sid identifier of notification + * @param config notification data + * @param update If true, will request the DBus service to update + the notification with new data from \c notification + * Otherwise will put new notification on screen + * @return true for success or false if there was an error. + */ + bool sendNotificationToPortal(KNotification *notification, const KNotifyConfig &config); + + /** + * Sends request to close Notification with id to DBus "org.freedesktop.notifications" interface + * @param id knotify-side notification ID to close + */ - QString optionName() override { return QStringLiteral("Popup"); } - void notify(KNotification *notification, KNotifyConfig *notifyConfig) override; - void close(KNotification *notification) override; - void update(KNotification *notification, KNotifyConfig *config) override; + void closePortalNotification(KNotification *notification); + /** + * Find the caption and the icon name of the application + */ -private Q_SLOTS: + void getAppCaptionAndIconName(const KNotifyConfig &config, QString *appCaption, QString *iconName); - // slot to catch appearance or disappearance of org.freedesktop.Desktop DBus service - void onServiceOwnerChanged(const QString &, const QString &, const QString &); + /** + * Specifies if DBus Notifications interface exists on session bus + */ + bool dbusServiceExists; - void onPortalNotificationActionInvoked(const QString &, const QString &, const QVariantList &); + /* + * As we communicate with the notification server over dbus + * we use only ids, this is for fast KNotifications lookup + */ + QHash> portalNotifications; -private: - // TODO KF6, replace current public notify/update - void notify(KNotification *notification, const KNotifyConfig ¬ifyConfig); - void update(KNotification *notification, const KNotifyConfig ¬ifyConfig); + /* + * Holds the id that will be assigned to the next notification source + * that will be created + */ + uint nextId; - NotifyByPortalPrivate * const d; + NotifyByPortal * const q; }; -#endif +QDBusArgument &operator<<(QDBusArgument &argument, const NotifyByPortalPrivate::PortalIcon &icon) +{ + argument.beginStructure(); + argument << icon.str << icon.data; + argument.endStructure(); + return argument; +} + +const QDBusArgument &operator>>(const QDBusArgument &argument, NotifyByPortalPrivate::PortalIcon &icon) +{ + argument.beginStructure(); + argument >> icon.str >> icon.data; + argument.endStructure(); + return argument; +} + +Q_DECLARE_METATYPE(NotifyByPortalPrivate::PortalIcon) + +//--------------------------------------------------------------------------------------- + +NotifyByPortal::NotifyByPortal(QObject *parent) + : KNotificationPlugin(parent), + d(new NotifyByPortalPrivate(this)) +{ + // check if service already exists on plugin instantiation + QDBusConnectionInterface *interface = QDBusConnection::sessionBus().interface(); + d->dbusServiceExists = interface && interface->isServiceRegistered(QString::fromLatin1(portalDbusServiceName)); + + if (d->dbusServiceExists) { + onServiceOwnerChanged(QString::fromLatin1(portalDbusServiceName), QString(), QStringLiteral("_")); //connect signals + } + + // to catch register/unregister events from service in runtime + QDBusServiceWatcher *watcher = new QDBusServiceWatcher(this); + watcher->setConnection(QDBusConnection::sessionBus()); + watcher->setWatchMode(QDBusServiceWatcher::WatchForOwnerChange); + watcher->addWatchedService(QString::fromLatin1(portalDbusServiceName)); + connect(watcher,&QDBusServiceWatcher::serviceOwnerChanged, this, &NotifyByPortal::onServiceOwnerChanged); +} + + +NotifyByPortal::~NotifyByPortal() +{ + delete d; +} + +void NotifyByPortal::notify(KNotification *notification, KNotifyConfig *notifyConfig) +{ + notify(notification, *notifyConfig); +} + +void NotifyByPortal::notify(KNotification *notification, const KNotifyConfig ¬ifyConfig) +{ + if (d->portalNotifications.contains(notification->id())) { + // notification is already on the screen, do nothing + finish(notification); + return; + } + + // check if Notifications DBus service exists on bus, use it if it does + if (d->dbusServiceExists) { + if (!d->sendNotificationToPortal(notification, notifyConfig)) { + finish(notification); //an error occurred. + } + } +} + +void NotifyByPortal::close(KNotification *notification) +{ + if (d->dbusServiceExists) { + d->closePortalNotification(notification); + } +} + +void NotifyByPortal::update(KNotification *notification, KNotifyConfig *notifyConfig) +{ + // TODO not supported by portals + Q_UNUSED(notification); + Q_UNUSED(notifyConfig); +} + +void NotifyByPortal::onServiceOwnerChanged(const QString &serviceName, const QString &oldOwner, const QString &newOwner) +{ + Q_UNUSED(serviceName); + // close all notifications we currently hold reference to + for (KNotification *n : qAsConst(d->portalNotifications)) { + if (n) { + emit finished(n); + } + } + + d->portalNotifications.clear(); + + if (newOwner.isEmpty()) { + d->dbusServiceExists = false; + } else if (oldOwner.isEmpty()) { + d->dbusServiceExists = true; + d->nextId = 1; + + // connect to action invocation signals + bool connected = QDBusConnection::sessionBus().connect(QString(), // from any service + QString::fromLatin1(portalDbusPath), + QString::fromLatin1(portalDbusInterfaceName), + QStringLiteral("ActionInvoked"), + this, + SLOT(onPortalNotificationActionInvoked(QString,QString,QVariantList))); + if (!connected) { + qCWarning(LOG_KNOTIFICATIONS) << "warning: failed to connect to ActionInvoked dbus signal"; + } + } +} + +void NotifyByPortal::onPortalNotificationActionInvoked(const QString &id, const QString &action, const QVariantList ¶meter) +{ + Q_UNUSED(parameter); + + auto iter = d->portalNotifications.find(id.toUInt()); + if (iter == d->portalNotifications.end()) { + return; + } + + KNotification *n = *iter; + if (n) { + emit actionInvoked(n->id(), action.toUInt()); + } else { + d->portalNotifications.erase(iter); + } +} + +void NotifyByPortalPrivate::getAppCaptionAndIconName(const KNotifyConfig ¬ifyConfig, QString *appCaption, QString *iconName) +{ + KConfigGroup globalgroup(&(*notifyConfig.eventsfile), QStringLiteral("Global")); + *appCaption = globalgroup.readEntry("Name", globalgroup.readEntry("Comment", notifyConfig.appname)); + + KConfigGroup eventGroup(&(*notifyConfig.eventsfile), QStringLiteral("Event/%1").arg(notifyConfig.eventid)); + if (eventGroup.hasKey("IconName")) { + *iconName = eventGroup.readEntry("IconName", notifyConfig.appname); + } else { + *iconName = globalgroup.readEntry("IconName", notifyConfig.appname); + } +} + +bool NotifyByPortalPrivate::sendNotificationToPortal(KNotification *notification, const KNotifyConfig ¬ifyConfig_nocheck) +{ + QDBusMessage dbusNotificationMessage; + dbusNotificationMessage = QDBusMessage::createMethodCall(QString::fromLatin1(portalDbusServiceName), + QString::fromLatin1(portalDbusPath), + QString::fromLatin1(portalDbusInterfaceName), + QStringLiteral("AddNotification")); + + QVariantList args; + // Will be used only with xdg-desktop-portal + QVariantMap portalArgs; + + QString appCaption; + QString iconName; + getAppCaptionAndIconName(notifyConfig_nocheck, &appCaption, &iconName); + + //did the user override the icon name? + if (!notification->iconName().isEmpty()) { + iconName = notification->iconName(); + } + + QString title = notification->title().isEmpty() ? appCaption : notification->title(); + QString text = notification->text(); + + if (!notification->defaultAction().isEmpty()) { + portalArgs.insert(QStringLiteral("default-action"), notification->defaultAction()); + portalArgs.insert(QStringLiteral("default-action-target"), QStringLiteral("0")); + } + + QString priority; + switch (notification->urgency()) { + case KNotification::DefaultUrgency: + break; + case KNotification::LowUrgency: + priority = QStringLiteral("low"); + break; + case KNotification::NormalUrgency: + priority = QStringLiteral("normal"); + break; + case KNotification::HighUrgency: + priority = QStringLiteral("high"); + break; + case KNotification::CriticalUrgency: + priority = QStringLiteral("urgent"); + break; + } + + if (!priority.isEmpty()) { + portalArgs.insert(QStringLiteral("priority"), priority); + } + + // galago spec defines action list to be list like + // (act_id1, action1, act_id2, action2, ...) + // + // assign id's to actions like it's done in fillPopup() method + // (i.e. starting from 1) + QList buttons; + buttons.reserve(notification->actions().count()); + + int actId = 0; + const auto listActions = notification->actions(); + for (const QString &actionName : listActions) { + actId++; + QVariantMap button = { + {QStringLiteral("action"), QString::number(actId)}, + {QStringLiteral("label"), actionName} + }; + buttons << button; + } + + qDBusRegisterMetaType >(); + qDBusRegisterMetaType(); + + if (!notification->pixmap().isNull()) { + QByteArray pixmapData; + QBuffer buffer(&pixmapData); + buffer.open(QIODevice::WriteOnly); + notification->pixmap().save(&buffer, "PNG"); + buffer.close(); + + PortalIcon icon; + icon.str = QStringLiteral("bytes"); + icon.data.setVariant(pixmapData); + portalArgs.insert(QStringLiteral("icon"), QVariant::fromValue(icon)); + } else { + // Use this for now for backwards compatibility, we can as well set the variant to be (sv) where the + // string is keyword "themed" and the variant is an array of strings with icon names + portalArgs.insert(QStringLiteral("icon"), iconName); + } + + portalArgs.insert(QStringLiteral("title"), title); + portalArgs.insert(QStringLiteral("body"), text); + portalArgs.insert(QStringLiteral("buttons"), QVariant::fromValue >(buttons)); + + args.append(QString::number(nextId)); + args.append(portalArgs); + + dbusNotificationMessage.setArguments(args); + + QDBusPendingCall notificationCall = QDBusConnection::sessionBus().asyncCall(dbusNotificationMessage, -1); + + // If we are in sandbox we don't need to wait for returned notification id + portalNotifications.insert(nextId++, notification); + + return true; +} + +void NotifyByPortalPrivate::closePortalNotification(KNotification *notification) +{ + uint id = portalNotifications.key(notification, 0); + + qCDebug(LOG_KNOTIFICATIONS) << "ID: " << id; + + if (id == 0) { + qCDebug(LOG_KNOTIFICATIONS) << "not found dbus id to close" << notification->id(); + return; + } + + QDBusMessage m = QDBusMessage::createMethodCall(QString::fromLatin1(portalDbusServiceName), + QString::fromLatin1(portalDbusPath), + QString::fromLatin1(portalDbusInterfaceName), + QStringLiteral("RemoveNotification")); + m.setArguments({QString::number(id)}); + + // send(..) does not block + bool queued = QDBusConnection::sessionBus().send(m); + if (!queued) { + qCWarning(LOG_KNOTIFICATIONS) << "Failed to queue dbus message for closing a notification"; + } +} diff --git a/src/notifybysnore.h b/src/notifybysnore.h new file mode 100644 --- /dev/null +++ b/src/notifybysnore.h @@ -0,0 +1,50 @@ +/* + Copyright (C) 2019 Piyush Aggarwal + + This program is free software; you can redistribute it and/or modify it + under the terms of the GNU Library General Public License as published by + the Free Software Foundation; either version 2 of the License, or (at your + option) any later version. + + This program is distributed in the hope that it will be useful, but WITHOUT + ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + FITNESS FOR A PARTICULAR PURPOSE. See the GNU Library General Public + License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +#ifndef NOTIFYBYSNORE_H +#define NOTIFYBYSNORE_H + +#include "knotificationplugin.h" + +#include +#include +#include +#include +#include + + +/** Windows notification backend - inspired by Android notification backend. */ +class NotifyBySnore : public KNotificationPlugin +{ + Q_OBJECT + +public: + explicit NotifyBySnore(QObject *parent = nullptr); + ~NotifyBySnore() override; + + QString optionName() override { return QStringLiteral("Popup"); } + void notify(KNotification *notification, KNotifyConfig *config) override; + void close(KNotification * notification) override; + void update(KNotification *notification, KNotifyConfig *config) override; +private: + QMap> m_notifications; + QString program = QStringLiteral("SnoreToast.exe"); + QLocalServer *server; + QTemporaryDir *iconDir; +}; + +#endif // NOTIFYBYSNORE_H diff --git a/src/notifybysnore.cpp b/src/notifybysnore.cpp new file mode 100644 --- /dev/null +++ b/src/notifybysnore.cpp @@ -0,0 +1,189 @@ +/* + Copyright (C) 2019 Piyush Aggarwal + + This program is free software; you can redistribute it and/or modify it + under the terms of the GNU Library General Public License as published by + the Free Software Foundation; either version 2 of the License, or (at your + option) any later version. + + This program is distributed in the hope that it will be useful, but WITHOUT + ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + FITNESS FOR A PARTICULAR PURPOSE. See the GNU Library General Public + License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +#include "notifybysnore.h" +#include "knotification.h" +#include "knotifyconfig.h" +#include "debug_p.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +/** + * Be sure to have a shortcut installed in Windows Start Menu by SnoreToast + * The syntax is - + * ./SnoreToast.exe -install + * + * appID : use as-is from your app's QCoreApplication::applicationName() when installing the shortcut. + * NOTE: Install the shortcut in Windows Start Menu. + */ + +NotifyBySnore::NotifyBySnore(QObject* parent) : + KNotificationPlugin(parent) +{ + server.listen(QString::fromStdString(QCryptographicHash::hash(qApp->applicationDirPath().toUtf8(), \ + QCryptographicHash::Md5 ).toHex().toStdString()).left(5)); + // ^ can be increased from 5 to N for lesser collisions + + QObject::connect(&server, &QLocalServer::newConnection, &server, [this]() { + auto sock = server.nextPendingConnection(); + sock->waitForReadyRead(); + const QByteArray rawData = sock->readAll(); + sock->close(); + const QString data = + QString::fromWCharArray(reinterpret_cast(rawData.constData()), + rawData.size() / sizeof(wchar_t)); + QMap map; + for (const auto &str : data.split(QStringLiteral(";"))) { + const auto index = str.indexOf(QStringLiteral("=")); + map.insert(str.mid(0, index), str.mid(index + 1)); + } + const auto action = map[QStringLiteral("action")]; + const auto id = map[QStringLiteral("notificationId")].toInt(); + KNotification *notification = nullptr; + const auto it = m_notifications.find(id); + if (it != m_notifications.end()) { + notification = it.value(); + } + const auto snoreAction = SnoreToastActions::getAction(action.toStdWString()); + qCDebug(LOG_KNOTIFICATIONS) << "The notification ID is : " << id; + switch (snoreAction) { + case SnoreToastActions::Actions::Clicked : + qCDebug(LOG_KNOTIFICATIONS) << " User clicked on the toast."; + if (notification) { + close(notification); + } + break; + case SnoreToastActions::Actions::Hidden : + qCDebug(LOG_KNOTIFICATIONS) << "The toast got hidden."; + break; + case SnoreToastActions::Actions::Dismissed : + qCDebug(LOG_KNOTIFICATIONS) << "User dismissed the toast."; + break; + case SnoreToastActions::Actions::Timedout : + qCDebug(LOG_KNOTIFICATIONS) << "The toast timed out."; + break; + case SnoreToastActions::Actions::ButtonClicked :{ + qCDebug(LOG_KNOTIFICATIONS) << " User clicked a button on the toast."; + const auto button = map[QStringLiteral("button")]; + QStringList s = m_notifications.value(id)->actions(); + int actionNum = s.indexOf(button) + 1; // QStringList starts with index 0 but not actions + emit actionInvoked(id, actionNum); + break;} + case SnoreToastActions::Actions::TextEntered : + qCDebug(LOG_KNOTIFICATIONS) << " User entered some text in the toast."; + break; + default: + qCDebug(LOG_KNOTIFICATIONS) << "Unexpected behaviour with the toast."; + if (notification) { + close(notification); + } + break; + } + }); +} + +NotifyBySnore::~NotifyBySnore() +{ + server.close(); + iconDir.remove(); +} + +void NotifyBySnore::notify(KNotification *notification, KNotifyConfig *config) +{ + if (m_notifications.constFind(notification->id()) != m_notifications.constEnd()) { + qCDebug(LOG_KNOTIFICATIONS) << "Duplicate notification with ID: " << notification->id() << " ignored."; + return; + } + QProcess *proc = new QProcess(); + QStringList arguments; + QString iconPath; + + arguments << QStringLiteral("-t"); + if (!notification->title().isEmpty()) { + arguments << notification->title(); + } + else { + arguments << qApp->applicationDisplayName(); + } + arguments << QStringLiteral("-m") << notification->text(); + if (!notification->pixmap().isNull()) { + iconPath = iconDir.path() + QStringLiteral("/") + + QString::number(notification->id()) + QStringLiteral(".png"); + notification->pixmap().save(iconPath, "PNG"); + arguments << QStringLiteral("-p") << iconPath; + } + arguments << QStringLiteral("-appID") << qApp->applicationName() + << QStringLiteral("-id") << QString::number(notification->id()) + << QStringLiteral("-pipename") << server.fullServerName(); + + if (!notification->actions().isEmpty()) { + arguments << QStringLiteral("-b") << notification->actions().join(QStringLiteral(";")); + } + qCDebug(LOG_KNOTIFICATIONS) << arguments; + + m_notifications.insert(notification->id(), notification); + proc->start(program, arguments); + + connect(proc, QOverload::of(&QProcess::finished), + [=](int exitCode, QProcess::ExitStatus exitStatus){ + proc->deleteLater(); + }); +} + +void NotifyBySnore::close(KNotification *notification) +{ + const auto it = m_notifications.find(notification->id()); + if (it == m_notifications.end()) { + return; + } + + qCDebug(LOG_KNOTIFICATIONS) << "SnoreToast closing notification with ID: " << notification->id(); + + QProcess *proc = new QProcess(); + QStringList arguments; + arguments << QStringLiteral("-close") << QString::number(notification->id()) + << QStringLiteral("-appID") << qApp->applicationName(); + proc->start(program, arguments); + if (it.value()) { + finish(it.value()); + } + m_notifications.erase(it); + connect(proc, QOverload::of(&QProcess::finished), + [=](int exitCode, QProcess::ExitStatus exitStatus){ + proc->deleteLater(); + delete proc; + }); +} + +void NotifyBySnore::update(KNotification *notification, KNotifyConfig *config) +{ + close(notification); + notify(notification, config); +}