Reimplement notifications for macOS and add support for actionable update notifications
authorClaudio Cambra <claudio.cambra@gmail.com>
Tue, 10 May 2022 14:12:15 +0000 (16:12 +0200)
committerMatthieu Gallien <matthieu_gallien@yahoo.fr>
Wed, 11 May 2022 15:33:33 +0000 (17:33 +0200)
Signed-off-by: Claudio Cambra <claudio.cambra@gmail.com>
src/gui/CMakeLists.txt
src/gui/application.cpp
src/gui/owncloudgui.cpp
src/gui/owncloudgui.h
src/gui/systray.cpp
src/gui/systray.h
src/gui/systray.mm
src/gui/updater/ocupdater.cpp
src/gui/updater/ocupdater.h

index 8b47d33ff5110e3aa52bf8f9dd20ab75a959f201..c7fb8bc597d4cd45d5baa26b57105cfc25d32da9 100644 (file)
@@ -665,7 +665,7 @@ endif()
 
 if (APPLE)
     find_package(Qt5 COMPONENTS MacExtras)
-    target_link_libraries(nextcloudCore PUBLIC Qt5::MacExtras)
+    target_link_libraries(nextcloudCore PUBLIC Qt5::MacExtras "-framework UserNotifications")
 endif()
 
 if(WITH_CRASHREPORTER)
index b1a3561eacda65e65d899cb39fb7ef2d09305242..323d87c915d502ac5e1e1b247413facd061602be 100644 (file)
@@ -390,7 +390,7 @@ Application::Application(int &argc, char **argv)
     // Update checks
     auto *updaterScheduler = new UpdaterScheduler(this);
     connect(updaterScheduler, &UpdaterScheduler::updaterAnnouncement,
-        _gui.data(), &ownCloudGui::slotShowTrayMessage);
+        _gui.data(), &ownCloudGui::slotShowTrayUpdateMessage);
     connect(updaterScheduler, &UpdaterScheduler::requestRestart,
         _folderManager.data(), &FolderMan::slotScheduleAppRestart);
 #endif
index d21a7446b079468051a30df586b8ee7cc3956b63..402e3801d35bf00cd4dc298219a0f20689589be3 100644 (file)
@@ -379,6 +379,15 @@ void ownCloudGui::slotShowTrayMessage(const QString &title, const QString &msg)
         qCWarning(lcApplication) << "Tray not ready: " << msg;
 }
 
+void ownCloudGui::slotShowTrayUpdateMessage(const QString &title, const QString &msg, const QUrl &webUrl)
+{
+    if(_tray) {
+        _tray->showUpdateMessage(title, msg, webUrl);
+    } else {
+        qCWarning(lcApplication) << "Tray not ready: " << msg;
+    }
+}
+
 void ownCloudGui::slotShowOptionalTrayMessage(const QString &title, const QString &msg)
 {
     slotShowTrayMessage(title, msg);
index 312c43748ead544589c2cbed25eccbf6e4d037b9..3ffa57cd1320f3fe35948d4e55826ada789700c0 100644 (file)
@@ -75,6 +75,7 @@ signals:
 public slots:
     void slotComputeOverallSyncStatus();
     void slotShowTrayMessage(const QString &title, const QString &msg);
+    void slotShowTrayUpdateMessage(const QString &title, const QString &msg, const QUrl &webUrl);
     void slotShowOptionalTrayMessage(const QString &title, const QString &msg);
     void slotFolderOpenAction(const QString &alias);
     void slotUpdateProgress(const QString &folder, const ProgressInfo &progress);
index 60d9a0e7542cc149fe78832d1983dab2dc8ffdd4..56ee9b9e793b25b82465025c874a2730c2aaec5d 100644 (file)
@@ -99,7 +99,11 @@ Systray::Systray()
 
     qmlRegisterType<WheelHandler>("com.nextcloud.desktopclient", 1, 0, "WheelHandler");
 
-#ifndef Q_OS_MAC
+#ifdef Q_OS_MACOS
+    setUserNotificationCenterDelegate();
+    checkNotificationAuth();
+    registerNotificationCategories(QString(tr("Download")));
+#else
     auto contextMenu = new QMenu();
     if (AccountManager::instance()->accounts().isEmpty()) {
         contextMenu->addAction(tr("Add account"), this, &Systray::openAccountWizard);
@@ -296,6 +300,16 @@ void Systray::showMessage(const QString &title, const QString &message, MessageI
     }
 }
 
+void Systray::showUpdateMessage(const QString &title, const QString &message, const QUrl &webUrl)
+{
+#ifdef Q_OS_MACOS
+    sendOsXUpdateNotification(title, message, webUrl);
+#else // TODO: Implement custom notifications (i.e. actionable) for other OSes
+    Q_UNUSED(webUrl);
+    showMessage(title, message);
+#endif
+}
+
 void Systray::setToolTip(const QString &tip)
 {
     QSystemTrayIcon::setToolTip(tr("%1: %2").arg(Theme::instance()->appNameGUI(), tip));
index 85978467a5061763b2f2bf0ab17e0341750dcb86..5bfbe50681cf95a5ba1b4befc9c9881dce818ae3 100644 (file)
@@ -39,9 +39,13 @@ public:
     QNetworkAccessManager* create(QObject *parent) override;
 };
 
-#ifdef Q_OS_OSX
+#ifdef Q_OS_MACOS
+void setUserNotificationCenterDelegate();
+void checkNotificationAuth();
+void registerNotificationCategories(const QString &localizedDownloadString);
 bool canOsXSendUserNotification();
 void sendOsXUserNotification(const QString &title, const QString &message);
+void sendOsXUpdateNotification(const QString &title, const QString &message, const QUrl &webUrl);
 void setTrayWindowLevelAndVisibleOnAllSpaces(QWindow *window);
 double statusBarThickness();
 #endif
@@ -71,6 +75,7 @@ public:
     void setTrayEngine(QQmlApplicationEngine *trayEngine);
     void create();
     void showMessage(const QString &title, const QString &message, MessageIcon icon = Information);
+    void showUpdateMessage(const QString &title, const QString &message, const QUrl &webUrl);
     void setToolTip(const QString &tip);
     bool isOpen();
     QString windowTitle() const;
index 7f564fcd2215e3b66da0300df82530004315c011..392f5fde6c13547db9bd69c1289f6b75abd19cfc 100644 (file)
@@ -1,17 +1,43 @@
+#include "QtCore/qurl.h"
+#include "config.h"
 #include <QString>
 #include <QWindow>
+#include <QLoggingCategory>
+
 #import <Cocoa/Cocoa.h>
+#import <UserNotifications/UserNotifications.h>
+
+Q_LOGGING_CATEGORY(lcMacSystray, "nextcloud.gui.macsystray")
 
 @interface NotificationCenterDelegate : NSObject
 @end
 @implementation NotificationCenterDelegate
+
 // Always show, even if app is active at the moment.
-- (BOOL)userNotificationCenter:(NSUserNotificationCenter *)center
-     shouldPresentNotification:(NSUserNotification *)notification
+- (void)userNotificationCenter:(UNUserNotificationCenter *)center
+    willPresentNotification:(UNNotification *)notification
+    withCompletionHandler:(void (^)(UNNotificationPresentationOptions options))completionHandler
 {
-    Q_UNUSED(center);
-    Q_UNUSED(notification);
-    return YES;
+    completionHandler(UNNotificationPresentationOptionSound + UNNotificationPresentationOptionBanner);
+}
+
+- (void)userNotificationCenter:(UNUserNotificationCenter *)center
+    didReceiveNotificationResponse:(UNNotificationResponse *)response
+    withCompletionHandler:(void (^)(void))completionHandler
+{
+    qCDebug(lcMacSystray()) << "Received notification with category identifier:" << response.notification.request.content.categoryIdentifier
+                            << "and action identifier" << response.actionIdentifier;
+    UNNotificationContent* content = response.notification.request.content;
+    if ([content.categoryIdentifier isEqualToString:@"UPDATE"]) {
+
+        if ([response.actionIdentifier isEqualToString:@"DOWNLOAD_ACTION"] || [response.actionIdentifier isEqualToString:UNNotificationDefaultActionIdentifier])
+        {
+            qCDebug(lcMacSystray()) << "Opening update download url in browser.";
+            [[NSWorkspace sharedWorkspace] openURL:[NSURL URLWithString:[content.userInfo objectForKey:@"webUrl"]]];
+        }
+    }
+
+    completionHandler();
 }
 @end
 
@@ -22,29 +48,102 @@ double statusBarThickness()
     return [NSStatusBar systemStatusBar].thickness;
 }
 
+// TODO: Get this to actually check for permissions
 bool canOsXSendUserNotification()
 {
-    return NSClassFromString(@"NSUserNotificationCenter") != nil;
+    UNUserNotificationCenter* center = [UNUserNotificationCenter currentNotificationCenter];
+    return center != nil;
 }
 
-void sendOsXUserNotification(const QString &title, const QString &message)
+void registerNotificationCategories(const QString &localisedDownloadString) {
+    UNNotificationCategory* generalCategory = [UNNotificationCategory
+          categoryWithIdentifier:@"GENERAL"
+          actions:@[]
+          intentIdentifiers:@[]
+          options:UNNotificationCategoryOptionCustomDismissAction];
+
+    // Create the custom actions for update notifications.
+    UNNotificationAction* downloadAction = [UNNotificationAction
+          actionWithIdentifier:@"DOWNLOAD_ACTION"
+          title:localisedDownloadString.toNSString()
+          options:UNNotificationActionOptionNone];
+
+    // Create the category with the custom actions.
+    UNNotificationCategory* updateCategory = [UNNotificationCategory
+          categoryWithIdentifier:@"UPDATE"
+          actions:@[downloadAction]
+          intentIdentifiers:@[]
+          options:UNNotificationCategoryOptionNone];
+
+    [[UNUserNotificationCenter currentNotificationCenter] setNotificationCategories:[NSSet setWithObjects:generalCategory, updateCategory, nil]];
+}
+
+void checkNotificationAuth()
+{
+    UNUserNotificationCenter* center = [UNUserNotificationCenter currentNotificationCenter];
+    [center requestAuthorizationWithOptions:(UNAuthorizationOptionAlert + UNAuthorizationOptionSound + UNAuthorizationOptionProvisional)
+        completionHandler:^(BOOL granted, NSError * _Nullable error) {
+            // Enable or disable features based on authorization.
+            if(granted) {
+                qCDebug(lcMacSystray) << "Authorization for notifications has been granted, can display notifications.";
+            } else {
+                qCDebug(lcMacSystray) << "Authorization for notifications not granted.";
+                if(error) {
+                    QString errorDescription([error.localizedDescription UTF8String]);
+                    qCDebug(lcMacSystray) << "Error from notification center: " << errorDescription;
+                }
+            }
+    }];
+}
+
+void setUserNotificationCenterDelegate()
 {
-    Class cuserNotificationCenter = NSClassFromString(@"NSUserNotificationCenter");
-    id userNotificationCenter = [cuserNotificationCenter defaultUserNotificationCenter];
+    UNUserNotificationCenter* center = [UNUserNotificationCenter currentNotificationCenter];
 
     static dispatch_once_t once;
     dispatch_once(&once, ^{
             id delegate = [[NotificationCenterDelegate alloc] init];
-            [userNotificationCenter setDelegate:delegate];
+            [center setDelegate:delegate];
     });
+}
+
+UNMutableNotificationContent* basicNotificationContent(const QString &title, const QString &message)
+{
+    UNMutableNotificationContent* content = [[UNMutableNotificationContent alloc] init];
+    content.title = title.toNSString();
+    content.body = message.toNSString();
+    content.sound = [UNNotificationSound defaultSound];
+
+    return content;
+}
+
+void sendOsXUserNotification(const QString &title, const QString &message)
+{
+    UNUserNotificationCenter* center = [UNUserNotificationCenter currentNotificationCenter];
+    checkNotificationAuth();
 
-    Class cuserNotification = NSClassFromString(@"NSUserNotification");
-    id notification = [[cuserNotification alloc] init];
-    [notification setTitle:[NSString stringWithUTF8String:title.toUtf8().data()]];
-    [notification setInformativeText:[NSString stringWithUTF8String:message.toUtf8().data()]];
+    UNMutableNotificationContent* content = basicNotificationContent(title, message);
+    content.categoryIdentifier = @"GENERAL";
 
-    [userNotificationCenter deliverNotification:notification];
-    [notification release];
+    UNTimeIntervalNotificationTrigger* trigger = [UNTimeIntervalNotificationTrigger triggerWithTimeInterval:1 repeats: NO];
+    UNNotificationRequest* request = [UNNotificationRequest requestWithIdentifier:@"NCUserNotification" content:content trigger:trigger];
+
+    [center addNotificationRequest:request withCompletionHandler:nil];
+}
+
+void sendOsXUpdateNotification(const QString &title, const QString &message, const QUrl &webUrl)
+{
+    UNUserNotificationCenter* center = [UNUserNotificationCenter currentNotificationCenter];
+    checkNotificationAuth();
+
+    UNMutableNotificationContent* content = basicNotificationContent(title, message);
+    content.categoryIdentifier = @"UPDATE";
+    content.userInfo = [NSDictionary dictionaryWithObject:[webUrl.toNSURL() absoluteString] forKey:@"webUrl"];
+
+    UNTimeIntervalNotificationTrigger* trigger = [UNTimeIntervalNotificationTrigger triggerWithTimeInterval:1 repeats: NO];
+    UNNotificationRequest* request = [UNNotificationRequest requestWithIdentifier:@"NCUpdateNotification" content:content trigger:trigger];
+
+    [center addNotificationRequest:request withCompletionHandler:nil];
 }
 
 void setTrayWindowLevelAndVisibleOnAllSpaces(QWindow *window)
@@ -63,3 +162,4 @@ bool osXInDarkMode()
 }
 
 }
+
index 6bcf726d05696383a0224578a1e9299d7c2a1b55..aecc66c9010b7443fed4e695f8d3d23da76c2f2c 100644 (file)
@@ -193,7 +193,7 @@ void OCUpdater::setDownloadState(DownloadState state)
     // or once for system based updates.
     if (_state == OCUpdater::DownloadComplete || (oldState != OCUpdater::UpdateOnlyAvailableThroughSystem
                                                      && _state == OCUpdater::UpdateOnlyAvailableThroughSystem)) {
-        emit newUpdateAvailable(tr("Update Check"), statusString());
+        emit newUpdateAvailable(tr("Update Check"), statusString(), _updateInfo.web());
     }
 }
 
index c6c1ad8dfa2271477024d283a710786975becf9f..15680f7988fc8967a6efc623f8c87d398324694f 100644 (file)
@@ -71,7 +71,7 @@ public:
     UpdaterScheduler(QObject *parent);
 
 signals:
-    void updaterAnnouncement(const QString &title, const QString &msg);
+    void updaterAnnouncement(const QString &title, const QString &msg, const QUrl &webUrl);
     void requestRestart();
 
 private slots:
@@ -116,7 +116,7 @@ public:
 
 signals:
     void downloadStateChanged();
-    void newUpdateAvailable(const QString &header, const QString &message);
+    void newUpdateAvailable(const QString &header, const QString &message, const QUrl &webUrl);
     void requestRestart();
 
 public slots: