1 /*
2     SPDX-FileCopyrightText: 2019 Piyush Aggarwal <piyushaggarwal002@gmail.com>
3 
4     SPDX-License-Identifier: LGPL-2.0-or-later
5 */
6 
7 #include "notifybysnore.h"
8 #include "debug_p.h"
9 #include "knotification.h"
10 #include "knotifyconfig.h"
11 #include "knotificationreplyaction.h"
12 
13 #include <QGuiApplication>
14 #include <QIcon>
15 #include <QLocalSocket>
16 
17 #include <snoretoastactions.h>
18 
19 /*
20  * On Windows a shortcut to your app is needed to be installed in the Start Menu
21  * (and subsequently, registered with the OS) in order to show notifications.
22  * Since KNotifications is a library, an app using it can't (feasibly) be properly
23  * registered with the OS. It is possible we could come up with some complicated solution
24  * which would require every KNotification-using app to do some special and probably
25  * difficult to understand change to support Windows. Or we can have SnoreToast.exe
26  * take care of all that nonsense for us.
27  * Note that, up to this point, there have been no special
28  * KNotifications changes to the generic application codebase to make this work,
29  * just some tweaks to the Craft blueprint and packaging script
30  * to pull in SnoreToast and trigger shortcut building respectively.
31  * Be sure to have a shortcut installed in Windows Start Menu by SnoreToast.
32  *
33  * So the location doesn't matter, but it's only possible to register the internal COM server in an executable.
34  * We could make it a static lib and link it in all KDE applications,
35  * but to make the action center integration work, we would need to also compile a class
36  * into the executable using a compile time uuid.
37  *
38  * The used header is meant to help with parsing the response.
39  * The cmake target for LibSnoreToast is a INTERFACE lib, it only provides the include path.
40  *
41  *
42  * Trigger the shortcut installation during the installation of your app; syntax for shortcut installation is -
43  * ./SnoreToast.exe -install <absolute\address\of\shortcut> <absolute\address\to\app.exe> <appID>
44  *
45  * appID: use as-is from your app's QCoreApplication::applicationName() when installing the shortcut.
46  * NOTE: Install the shortcut in Windows Start Menu folder.
47  * For example, check out Craft Blueprint for Quassel-IRC or KDE Connect.
48  */
49 
50 namespace
51 {
SnoreToastExecName()52 const QString SnoreToastExecName()
53 {
54     return QStringLiteral("SnoreToast.exe");
55 }
56 }
57 
NotifyBySnore(QObject * parent)58 NotifyBySnore::NotifyBySnore(QObject *parent)
59     : KNotificationPlugin(parent)
60 {
61     m_server.listen(QString::number(qHash(qApp->applicationDirPath())));
62     connect(&m_server, &QLocalServer::newConnection, this, [this]() {
63         QLocalSocket *responseSocket = m_server.nextPendingConnection();
64         connect(responseSocket, &QLocalSocket::readyRead, [this, responseSocket]() {
65             const QByteArray rawNotificationResponse = responseSocket->readAll();
66             responseSocket->deleteLater();
67 
68             const QString notificationResponse = QString::fromWCharArray(reinterpret_cast<const wchar_t *>(rawNotificationResponse.constData()),
69                                                                          rawNotificationResponse.size() / sizeof(wchar_t));
70             qCDebug(LOG_KNOTIFICATIONS) << notificationResponse;
71 
72             QMap<QString, QStringRef> notificationResponseMap;
73             for (auto &str : notificationResponse.splitRef(QLatin1Char(';'))) {
74                 const int equalIndex = str.indexOf(QLatin1Char('='));
75                 notificationResponseMap.insert(str.mid(0, equalIndex).toString(), str.mid(equalIndex + 1));
76             }
77 
78             const QString responseAction = notificationResponseMap[QStringLiteral("action")].toString();
79             const int responseNotificationId = notificationResponseMap[QStringLiteral("notificationId")].toInt();
80 
81             qCDebug(LOG_KNOTIFICATIONS) << "The notification ID is : " << responseNotificationId;
82 
83             KNotification *notification;
84             const auto iter = m_notifications.constFind(responseNotificationId);
85             if (iter != m_notifications.constEnd()) {
86                 notification = iter.value();
87             } else {
88                 qCWarning(LOG_KNOTIFICATIONS) << "Received a response for an unknown notification.";
89                 return;
90             }
91 
92             std::wstring w_action(responseAction.size(), 0);
93             responseAction.toWCharArray(const_cast<wchar_t *>(w_action.data()));
94 
95             switch (SnoreToastActions::getAction(w_action)) {
96             case SnoreToastActions::Actions::Clicked:
97                 qCDebug(LOG_KNOTIFICATIONS) << "User clicked on the toast.";
98                 break;
99 
100             case SnoreToastActions::Actions::Hidden:
101                 qCDebug(LOG_KNOTIFICATIONS) << "The toast got hidden.";
102                 break;
103 
104             case SnoreToastActions::Actions::Dismissed:
105                 qCDebug(LOG_KNOTIFICATIONS) << "User dismissed the toast.";
106                 break;
107 
108             case SnoreToastActions::Actions::Timedout:
109                 qCDebug(LOG_KNOTIFICATIONS) << "The toast timed out.";
110                 break;
111 
112             case SnoreToastActions::Actions::ButtonClicked: {
113                 qCDebug(LOG_KNOTIFICATIONS) << "User clicked an action button in the toast.";
114                 const QString responseButton = notificationResponseMap[QStringLiteral("button")].toString();
115                 QStringList s = m_notifications.value(responseNotificationId)->actions();
116                 int actionNum = s.indexOf(responseButton) + 1; // QStringList starts with index 0 but not actions
117                 Q_EMIT actionInvoked(responseNotificationId, actionNum);
118                 break;
119             }
120 
121             case SnoreToastActions::Actions::TextEntered: {
122                 qCWarning(LOG_KNOTIFICATIONS) << "User entered some text in the toast.";
123                 const QString replyText = notificationResponseMap[QStringLiteral("text")].toString();
124                 qCWarning(LOG_KNOTIFICATIONS) << "Text entered was :: " << replyText;
125                 Q_EMIT replied(responseNotificationId, replyText);
126                 break;
127             }
128 
129             default:
130                 qCWarning(LOG_KNOTIFICATIONS) << "Unexpected behaviour with the toast. Please file a bug report / feature request.";
131                 break;
132             }
133 
134             // Action Center callbacks are not yet supported so just close the notification once done
135             if (notification != nullptr) {
136                 NotifyBySnore::close(notification);
137             }
138         });
139     });
140 }
141 
~NotifyBySnore()142 NotifyBySnore::~NotifyBySnore()
143 {
144     m_server.close();
145 }
146 
notify(KNotification * notification,KNotifyConfig * config)147 void NotifyBySnore::notify(KNotification *notification, KNotifyConfig *config)
148 {
149     Q_UNUSED(config);
150     // HACK work around that notification->id() is only populated after returning from here
151     // note that config will be invalid at that point, so we can't pass that along
152     QMetaObject::invokeMethod(
153         this,
154         [this, notification]() {
155             NotifyBySnore::notifyDeferred(notification);
156         },
157         Qt::QueuedConnection);
158 }
159 
notifyDeferred(KNotification * notification)160 void NotifyBySnore::notifyDeferred(KNotification *notification)
161 {
162     m_notifications.insert(notification->id(), notification);
163 
164     const QString notificationTitle = ((!notification->title().isEmpty()) ? notification->title() : qApp->applicationDisplayName());
165     QStringList snoretoastArgsList{QStringLiteral("-id"),
166                                    QString::number(notification->id()),
167                                    QStringLiteral("-t"),
168                                    notificationTitle,
169                                    QStringLiteral("-m"),
170                                    stripRichText(notification->text()),
171                                    QStringLiteral("-appID"),
172                                    qApp->applicationName(),
173                                    QStringLiteral("-pid"),
174                                    QString::number(qApp->applicationPid()),
175                                    QStringLiteral("-pipename"),
176                                    m_server.fullServerName()};
177 
178     // handle the icon for toast notification
179     const QString iconPath = m_iconDir.path() + QLatin1Char('/') + QString::number(notification->id());
180     const bool hasIcon = (notification->pixmap().isNull()) ? qApp->windowIcon().pixmap(1024, 1024).save(iconPath, "PNG") //
181                                                            : notification->pixmap().save(iconPath, "PNG");
182     if (hasIcon) {
183         snoretoastArgsList << QStringLiteral("-p") << iconPath;
184     }
185 
186     // if'd below, because SnoreToast currently doesn't support both textbox and buttons in the same notification
187     if (notification->replyAction()) {
188         snoretoastArgsList << QStringLiteral("-tb");
189     } else if (!notification->actions().isEmpty()) {
190         // add actions if any
191         snoretoastArgsList << QStringLiteral("-b") << notification->actions().join(QLatin1Char(';'));
192     }
193 
194     QProcess *snoretoastProcess = new QProcess();
195     connect(snoretoastProcess, &QProcess::readyReadStandardError, [snoretoastProcess, snoretoastArgsList]() {
196         const auto data = snoretoastProcess->readAllStandardError();
197         qCDebug(LOG_KNOTIFICATIONS) << "SnoreToast process stderr:" << snoretoastArgsList << data;
198     });
199     connect(snoretoastProcess, &QProcess::readyReadStandardOutput, [snoretoastProcess, snoretoastArgsList]() {
200         const auto data = snoretoastProcess->readAllStandardOutput();
201         qCDebug(LOG_KNOTIFICATIONS) << "SnoreToast process stdout:" << snoretoastArgsList << data;
202     });
203     connect(snoretoastProcess, &QProcess::errorOccurred, this, [this, snoretoastProcess, snoretoastArgsList, iconPath](QProcess::ProcessError error) {
204         qCWarning(LOG_KNOTIFICATIONS) << "SnoreToast process errored:" << snoretoastArgsList << error;
205         snoretoastProcess->deleteLater();
206         QFile::remove(iconPath);
207     });
208     connect(snoretoastProcess,
209             qOverload<int, QProcess::ExitStatus>(&QProcess::finished),
210             this,
211             [this, snoretoastProcess, snoretoastArgsList, iconPath](int exitCode, QProcess::ExitStatus exitStatus) {
212                 qCDebug(LOG_KNOTIFICATIONS) << "SnoreToast process finished:" << snoretoastArgsList;
213                 qCDebug(LOG_KNOTIFICATIONS) << "code:" << exitCode << "status:" << exitStatus;
214                 snoretoastProcess->deleteLater();
215                 QFile::remove(iconPath);
216             });
217 
218     qCDebug(LOG_KNOTIFICATIONS) << "SnoreToast process starting:" << snoretoastArgsList;
219     snoretoastProcess->start(SnoreToastExecName(), snoretoastArgsList);
220 }
221 
close(KNotification * notification)222 void NotifyBySnore::close(KNotification *notification)
223 {
224     qCDebug(LOG_KNOTIFICATIONS) << "Requested to close notification with ID:" << notification->id();
225     if (m_notifications.constFind(notification->id()) == m_notifications.constEnd()) {
226         qCWarning(LOG_KNOTIFICATIONS) << "Couldn't find the notification in m_notifications. Nothing to close.";
227         return;
228     }
229 
230     m_notifications.remove(notification->id());
231 
232     const QStringList snoretoastArgsList{QStringLiteral("-close"), QString::number(notification->id()), QStringLiteral("-appID"), qApp->applicationName()};
233 
234     qCDebug(LOG_KNOTIFICATIONS) << "Closing notification; SnoreToast process arguments:" << snoretoastArgsList;
235     QProcess::startDetached(SnoreToastExecName(), snoretoastArgsList);
236 
237     finish(notification);
238 }
239 
update(KNotification * notification,KNotifyConfig * config)240 void NotifyBySnore::update(KNotification *notification, KNotifyConfig *config)
241 {
242     Q_UNUSED(notification);
243     Q_UNUSED(config);
244     qCWarning(LOG_KNOTIFICATIONS) << "updating a notification is not supported yet.";
245 }
246