Browse Source

feat: Enable APNS registration + notification delivery in macOS apps (#33574)

Joan Xie 2 years ago
parent
commit
afd08c9450

+ 48 - 0
docs/api/push-notifications.md

@@ -0,0 +1,48 @@
+# pushNotifications
+
+Process: [Main](../glossary.md#main-process)
+
+> Register for and receive notifications from remote push notification services
+
+For example, when registering for push notifications via Apple push notification services (APNS):
+
+```javascript
+const { pushNotifications, Notification } = require('electron')
+
+pushNotifications.registerForAPNSNotifications().then((token) => {
+  // forward token to your remote notification server
+})
+
+pushNotifications.on('received-apns-notification', (event, userInfo) => {
+  // generate a new Notification object with the relevant userInfo fields
+})
+```
+
+## Events
+
+The `pushNotification` module emits the following events:
+
+#### Event: 'received-apns-notification' _macOS_
+
+Returns:
+
+* `userInfo` Record<String, any>
+
+Emitted when the app receives a remote notification while running.
+See: https://developer.apple.com/documentation/appkit/nsapplicationdelegate/1428430-application?language=objc
+
+## Methods
+
+The `pushNotification` module has the following methods:
+
+### `pushNotifications.registerForAPNSNotifications()` _macOS_
+
+Returns `Promise<string>`
+
+Registers the app with Apple Push Notification service (APNS) to receive [Badge, Sound, and Alert](https://developer.apple.com/documentation/appkit/sremotenotificationtype?language=objc) notifications. If registration is successful, the promise will be resolved with the APNS device token. Otherwise, the promise will be rejected with an error message.
+See: https://developer.apple.com/documentation/appkit/nsapplication/1428476-registerforremotenotificationtyp?language=objc
+
+### `pushNotifications.unregisterForAPNSNotifications()` _macOS_
+
+Unregisters the app from notifications received from APNS.
+See: https://developer.apple.com/documentation/appkit/nsapplication/1428747-unregisterforremotenotifications?language=objc

+ 2 - 0
filenames.auto.gni

@@ -40,6 +40,7 @@ auto_filenames = {
     "docs/api/power-save-blocker.md",
     "docs/api/process.md",
     "docs/api/protocol.md",
+    "docs/api/push-notifications.md",
     "docs/api/safe-storage.md",
     "docs/api/screen.md",
     "docs/api/service-workers.md",
@@ -212,6 +213,7 @@ auto_filenames = {
     "lib/browser/api/power-monitor.ts",
     "lib/browser/api/power-save-blocker.ts",
     "lib/browser/api/protocol.ts",
+    "lib/browser/api/push-notifications.ts",
     "lib/browser/api/safe-storage.ts",
     "lib/browser/api/screen.ts",
     "lib/browser/api/session.ts",

+ 3 - 0
filenames.gni

@@ -128,6 +128,7 @@ filenames = {
     "shell/browser/api/electron_api_menu_mac.mm",
     "shell/browser/api/electron_api_native_theme_mac.mm",
     "shell/browser/api/electron_api_power_monitor_mac.mm",
+    "shell/browser/api/electron_api_push_notifications_mac.mm",
     "shell/browser/api/electron_api_system_preferences_mac.mm",
     "shell/browser/api/electron_api_web_contents_mac.mm",
     "shell/browser/auto_updater_mac.mm",
@@ -295,6 +296,8 @@ filenames = {
     "shell/browser/api/electron_api_printing.cc",
     "shell/browser/api/electron_api_protocol.cc",
     "shell/browser/api/electron_api_protocol.h",
+    "shell/browser/api/electron_api_push_notifications.cc",
+    "shell/browser/api/electron_api_push_notifications.h",
     "shell/browser/api/electron_api_safe_storage.cc",
     "shell/browser/api/electron_api_safe_storage.h",
     "shell/browser/api/electron_api_screen.cc",

+ 1 - 0
lib/browser/api/module-list.ts

@@ -22,6 +22,7 @@ export const browserModuleList: ElectronInternal.ModuleEntry[] = [
   { name: 'Notification', loader: () => require('./notification') },
   { name: 'powerMonitor', loader: () => require('./power-monitor') },
   { name: 'powerSaveBlocker', loader: () => require('./power-save-blocker') },
+  { name: 'pushNotifications', loader: () => require('./push-notifications') },
   { name: 'protocol', loader: () => require('./protocol') },
   { name: 'safeStorage', loader: () => require('./safe-storage') },
   { name: 'screen', loader: () => require('./screen') },

+ 3 - 0
lib/browser/api/push-notifications.ts

@@ -0,0 +1,3 @@
+const { pushNotifications } = process._linkedBinding('electron_browser_push_notifications');
+
+export default pushNotifications;

+ 77 - 0
shell/browser/api/electron_api_push_notifications.cc

@@ -0,0 +1,77 @@
+// Copyright (c) 2022 Asana, Inc.
+// Use of this source code is governed by the MIT license that can be
+// found in the LICENSE file.
+
+#include "shell/browser/api/electron_api_push_notifications.h"
+
+#include <string>
+
+#include "shell/common/gin_converters/value_converter.h"
+#include "shell/common/gin_helper/dictionary.h"
+#include "shell/common/node_includes.h"
+
+namespace electron {
+
+namespace api {
+
+PushNotifications* g_push_notifications = nullptr;
+
+gin::WrapperInfo PushNotifications::kWrapperInfo = {gin::kEmbedderNativeGin};
+
+PushNotifications::PushNotifications() = default;
+
+PushNotifications::~PushNotifications() {
+  g_push_notifications = nullptr;
+}
+
+// static
+PushNotifications* PushNotifications::Get() {
+  if (!g_push_notifications)
+    g_push_notifications = new PushNotifications();
+  return g_push_notifications;
+}
+
+// static
+gin::Handle<PushNotifications> PushNotifications::Create(v8::Isolate* isolate) {
+  return gin::CreateHandle(isolate, PushNotifications::Get());
+}
+
+// static
+gin::ObjectTemplateBuilder PushNotifications::GetObjectTemplateBuilder(
+    v8::Isolate* isolate) {
+  auto builder = gin_helper::EventEmitterMixin<
+      PushNotifications>::GetObjectTemplateBuilder(isolate);
+#if BUILDFLAG(IS_MAC)
+  builder
+      .SetMethod("registerForAPNSNotifications",
+                 &PushNotifications::RegisterForAPNSNotifications)
+      .SetMethod("unregisterForAPNSNotifications",
+                 &PushNotifications::UnregisterForAPNSNotifications);
+#endif
+  return builder;
+}
+
+const char* PushNotifications::GetTypeName() {
+  return "PushNotifications";
+}
+
+}  // namespace api
+
+}  // namespace electron
+
+namespace {
+
+void Initialize(v8::Local<v8::Object> exports,
+                v8::Local<v8::Value> unused,
+                v8::Local<v8::Context> context,
+                void* priv) {
+  v8::Isolate* isolate = context->GetIsolate();
+  gin::Dictionary dict(isolate, exports);
+  dict.Set("pushNotifications",
+           electron::api::PushNotifications::Create(isolate));
+}
+
+}  // namespace
+
+NODE_LINKED_MODULE_CONTEXT_AWARE(electron_browser_push_notifications,
+                                 Initialize)

+ 64 - 0
shell/browser/api/electron_api_push_notifications.h

@@ -0,0 +1,64 @@
+// Copyright (c) 2016 GitHub, Inc.
+// Use of this source code is governed by the MIT license that can be
+// found in the LICENSE file.
+
+#ifndef ELECTRON_SHELL_BROWSER_API_ELECTRON_API_PUSH_NOTIFICATIONS_H_
+#define ELECTRON_SHELL_BROWSER_API_ELECTRON_API_PUSH_NOTIFICATIONS_H_
+
+#include <string>
+
+#include <vector>
+#include "gin/handle.h"
+#include "gin/wrappable.h"
+#include "shell/browser/browser_observer.h"
+#include "shell/browser/electron_browser_client.h"
+#include "shell/browser/event_emitter_mixin.h"
+#include "shell/common/gin_helper/promise.h"
+
+namespace electron {
+
+namespace api {
+
+class PushNotifications
+    : public ElectronBrowserClient::Delegate,
+      public gin::Wrappable<PushNotifications>,
+      public gin_helper::EventEmitterMixin<PushNotifications>,
+      public BrowserObserver {
+ public:
+  static PushNotifications* Get();
+  static gin::Handle<PushNotifications> Create(v8::Isolate* isolate);
+
+  // gin::Wrappable
+  static gin::WrapperInfo kWrapperInfo;
+  gin::ObjectTemplateBuilder GetObjectTemplateBuilder(
+      v8::Isolate* isolate) override;
+  const char* GetTypeName() override;
+
+  // disable copy
+  PushNotifications(const PushNotifications&) = delete;
+  PushNotifications& operator=(const PushNotifications&) = delete;
+
+#if BUILDFLAG(IS_MAC)
+  void OnDidReceiveAPNSNotification(const base::DictionaryValue& user_info);
+  void ResolveAPNSPromiseSetWithToken(const std::string& token_string);
+  void RejectAPNSPromiseSetWithError(const std::string& error_message);
+#endif
+
+ private:
+  PushNotifications();
+  ~PushNotifications() override;
+  // This set maintains all the promises that should be fulfilled
+  // once macOS registers, or fails to register, for APNS
+  std::vector<gin_helper::Promise<std::string>> apns_promise_set_;
+
+#if BUILDFLAG(IS_MAC)
+  v8::Local<v8::Promise> RegisterForAPNSNotifications(v8::Isolate* isolate);
+  void UnregisterForAPNSNotifications();
+#endif
+};
+
+}  // namespace api
+
+}  // namespace electron
+
+#endif  // ELECTRON_SHELL_BROWSER_API_ELECTRON_API_PUSH_NOTIFICATIONS_H_

+ 62 - 0
shell/browser/api/electron_api_push_notifications_mac.mm

@@ -0,0 +1,62 @@
+// Copyright (c) 2022 Asana, Inc.
+// Use of this source code is governed by the MIT license that can be
+// found in the LICENSE file.
+
+#include "shell/browser/api/electron_api_push_notifications.h"
+
+#include <string>
+
+#include <utility>
+#include <vector>
+#import "shell/browser/mac/electron_application.h"
+#include "shell/common/gin_converters/value_converter.h"
+#include "shell/common/gin_helper/promise.h"
+
+namespace electron {
+
+namespace api {
+
+v8::Local<v8::Promise> PushNotifications::RegisterForAPNSNotifications(
+    v8::Isolate* isolate) {
+  gin_helper::Promise<std::string> promise(isolate);
+  v8::Local<v8::Promise> handle = promise.GetHandle();
+
+  [[AtomApplication sharedApplication]
+      registerForRemoteNotificationTypes:NSRemoteNotificationTypeBadge |
+                                         NSRemoteNotificationTypeAlert |
+                                         NSRemoteNotificationTypeSound];
+
+  PushNotifications::apns_promise_set_.emplace_back(std::move(promise));
+  return handle;
+}
+
+void PushNotifications::ResolveAPNSPromiseSetWithToken(
+    const std::string& token_string) {
+  std::vector<gin_helper::Promise<std::string>> promises =
+      std::move(PushNotifications::apns_promise_set_);
+  for (auto& promise : promises) {
+    promise.Resolve(token_string);
+  }
+}
+
+void PushNotifications::RejectAPNSPromiseSetWithError(
+    const std::string& error_message) {
+  std::vector<gin_helper::Promise<std::string>> promises =
+      std::move(PushNotifications::apns_promise_set_);
+  for (auto& promise : promises) {
+    promise.RejectWithErrorMessage(error_message);
+  }
+}
+
+void PushNotifications::UnregisterForAPNSNotifications() {
+  [[AtomApplication sharedApplication] unregisterForRemoteNotifications];
+}
+
+void PushNotifications::OnDidReceiveAPNSNotification(
+    const base::DictionaryValue& user_info) {
+  Emit("received-apns-notification", user_info);
+}
+
+}  // namespace api
+
+}  // namespace electron

+ 40 - 0
shell/browser/mac/electron_application_delegate.mm

@@ -13,6 +13,7 @@
 #include "base/mac/scoped_objc_class_swizzler.h"
 #include "base/strings/sys_string_conversions.h"
 #include "base/values.h"
+#include "shell/browser/api/electron_api_push_notifications.h"
 #include "shell/browser/browser.h"
 #include "shell/browser/mac/dict_util.h"
 #import "shell/browser/mac/electron_application.h"
@@ -157,4 +158,43 @@ static NSDictionary* UNNotificationResponseToNSDictionary(
   electron::Browser::Get()->NewWindowForTab();
 }
 
+- (void)application:(NSApplication*)application
+    didRegisterForRemoteNotificationsWithDeviceToken:(NSData*)deviceToken {
+  // https://stackoverflow.com/a/16411517
+  const char* token_data = static_cast<const char*>([deviceToken bytes]);
+  NSMutableString* token_string = [NSMutableString string];
+  for (NSUInteger i = 0; i < [deviceToken length]; i++) {
+    [token_string appendFormat:@"%02.2hhX", token_data[i]];
+  }
+  // Resolve outstanding APNS promises created during registration attempts
+  electron::api::PushNotifications* push_notifications =
+      electron::api::PushNotifications::Get();
+  if (push_notifications) {
+    push_notifications->ResolveAPNSPromiseSetWithToken(
+        base::SysNSStringToUTF8(token_string));
+  }
+}
+
+- (void)application:(NSApplication*)application
+    didFailToRegisterForRemoteNotificationsWithError:(NSError*)error {
+  std::string error_message(base::SysNSStringToUTF8(
+      [NSString stringWithFormat:@"%ld %@ %@", [error code], [error domain],
+                                 [error userInfo]]));
+  electron::api::PushNotifications* push_notifications =
+      electron::api::PushNotifications::Get();
+  if (push_notifications) {
+    push_notifications->RejectAPNSPromiseSetWithError(error_message);
+  }
+}
+
+- (void)application:(NSApplication*)application
+    didReceiveRemoteNotification:(NSDictionary*)userInfo {
+  electron::api::PushNotifications* push_notifications =
+      electron::api::PushNotifications::Get();
+  if (push_notifications) {
+    electron::api::PushNotifications::Get()->OnDidReceiveAPNSNotification(
+        electron::NSDictionaryToDictionaryValue(userInfo));
+  }
+}
+
 @end

+ 1 - 0
shell/common/node_bindings.cc

@@ -62,6 +62,7 @@
   V(electron_browser_power_save_blocker) \
   V(electron_browser_protocol)           \
   V(electron_browser_printing)           \
+  V(electron_browser_push_notifications) \
   V(electron_browser_safe_storage)       \
   V(electron_browser_session)            \
   V(electron_browser_screen)             \

+ 1 - 0
typings/internal-ambient.d.ts

@@ -225,6 +225,7 @@ declare namespace NodeJS {
     }
     _linkedBinding(name: 'electron_browser_power_monitor'): PowerMonitorBinding;
     _linkedBinding(name: 'electron_browser_power_save_blocker'): { powerSaveBlocker: Electron.PowerSaveBlocker };
+    _linkedBinding(name: 'electron_browser_push_notifications'): { pushNotifications: Electron.PushNotifications };
     _linkedBinding(name: 'electron_browser_safe_storage'): { safeStorage: Electron.SafeStorage };
     _linkedBinding(name: 'electron_browser_session'): typeof Electron.Session;
     _linkedBinding(name: 'electron_browser_screen'): { createScreen(): Electron.Screen };