tauri-plugin-notifications 0.4.6

A Tauri v2 plugin for sending notifications on desktop and mobile platforms with support for system notifications and push delivery via FCM and APNs.
Documentation
import SwiftRs
import Tauri
import UIKit
import UserNotifications
import WebKit

enum ShowNotificationError: LocalizedError {
  case make(Error)
  case create(Error)

  var errorDescription: String? {
    switch self {
    case .make(let error):
      return "Unable to make notification: \(error)"
    case .create(let error):
      return "Unable to create notification: \(error)"
    }
  }
}

enum ScheduleEveryKind: String, Codable {
  case year
  case month
  case twoWeeks
  case week
  case day
  case hour
  case minute
  case second
}

struct ScheduleInterval: Codable {
  var year: Int?
  var month: Int?
  var day: Int?
  var weekday: Int?
  var hour: Int?
  var minute: Int?
  var second: Int?
}

enum NotificationSchedule: Codable {
  case at(date: String, repeating: Bool)
  case interval(interval: ScheduleInterval)
  case every(interval: ScheduleEveryKind, count: Int)
}

struct NotificationAttachmentOptions: Codable {
  let iosUNNotificationAttachmentOptionsTypeHintKey: String?
  let iosUNNotificationAttachmentOptionsThumbnailHiddenKey: String?
  let iosUNNotificationAttachmentOptionsThumbnailClippingRectKey: String?
  let iosUNNotificationAttachmentOptionsThumbnailTimeKey: String?
}

struct NotificationAttachment: Codable {
  let id: String
  let url: String
  let options: NotificationAttachmentOptions?
}

struct Notification: Decodable {
  let id: Int
  var title: String
  var body: String?
  var extra: [String: String]?
  var schedule: NotificationSchedule?
  var attachments: [NotificationAttachment]?
  var sound: String?
  var group: String?
  var actionTypeId: String?
  var summary: String?
  var silent: Bool?
}

struct RemoveActiveNotification: Decodable {
  let id: Int
}

struct RemoveActiveArgs: Decodable {
  let notifications: [RemoveActiveNotification]
}

func showNotification(invoke: Invoke, notification: Notification)
  throws -> UNNotificationRequest
{
  var content: UNNotificationContent
  do {
    content = try makeNotificationContent(notification)
  } catch {
    throw ShowNotificationError.make(error)
  }

  var trigger: UNNotificationTrigger?

  do {
    if let schedule = notification.schedule {
      try trigger = handleScheduledNotification(schedule)
    }
  } catch {
    throw ShowNotificationError.create(error)
  }

  // Schedule the request.
  let request = UNNotificationRequest(
    identifier: "\(notification.id)", content: content, trigger: trigger
  )

  let center = UNUserNotificationCenter.current()
  center.add(request) { (error: Error?) in
    if let theError = error {
      invoke.reject(theError.localizedDescription)
    }
  }

  return request
}

struct CancelArgs: Decodable {
  let notifications: [Int]
}

struct Action: Decodable {
  let id: String
  let title: String
  var requiresAuthentication: Bool?
  var foreground: Bool?
  var destructive: Bool?
  var input: Bool?
  var inputButtonTitle: String?
  var inputPlaceholder: String?
}

struct ActionType: Decodable {
  let id: String
  let actions: [Action]
  var hiddenPreviewsBodyPlaceholder: String?
  var customDismissAction: Bool?
  var allowInCarPlay: Bool?
  var hiddenPreviewsShowTitle: Bool?
  var hiddenPreviewsShowSubtitle: Bool?
  var hiddenBodyPlaceholder: String?
}

struct RegisterActionTypesArgs: Decodable {
  let types: [ActionType]
}

struct BatchArgs: Decodable {
  let notifications: [Notification]
}

struct SetClickListenerActiveArgs: Decodable {
  let active: Bool
}

class NotificationPlugin: Plugin {
  let notificationHandler = NotificationHandler()
  let notificationManager = NotificationManager()

  #if ENABLE_PUSH_NOTIFICATIONS
    // Completion handler for push token registration
    private var pushTokenCompletion: ((Result<String, Error>) -> Void)?
    private let pushTokenTimeout: TimeInterval = 10.0
    private var pushTokenTimer: Timer?
  #endif

  override init() {
    super.init()
    notificationManager.notificationHandler = notificationHandler
    notificationHandler.plugin = self
  }

  public override func load(webview: WKWebView) {
    super.load(webview: webview)

    #if ENABLE_PUSH_NOTIFICATIONS
      // Store reference to this plugin for event triggering
      AppDelegateSwizzler.plugin = self

      // swizzle UIApplicationDelegate push methods
      AppDelegateSwizzler.swizzlePushCallbacks()
    #endif
  }

  @objc public func show(_ invoke: Invoke) throws {
    let notification = try invoke.parseArgs(Notification.self)

    let request = try showNotification(invoke: invoke, notification: notification)
    notificationHandler.saveNotification(request.identifier, notification)
    invoke.resolve(Int(request.identifier) ?? -1)
  }

  @objc public func batch(_ invoke: Invoke) throws {
    let args = try invoke.parseArgs(BatchArgs.self)
    var ids = [Int]()

    for notification in args.notifications {
      let request = try showNotification(invoke: invoke, notification: notification)
      notificationHandler.saveNotification(request.identifier, notification)
      ids.append(Int(request.identifier) ?? -1)
    }

    invoke.resolve(ids)
  }

  @objc public override func requestPermissions(_ invoke: Invoke) {
    notificationHandler.requestPermissions { granted, error in
      guard error == nil else {
        invoke.reject(error!.localizedDescription)
        return
      }

      let permissionState = granted ? "granted" : "denied"
      invoke.resolve(["permissionState": permissionState])
    }
  }

  @objc public func registerForPushNotifications(_ invoke: Invoke) {
    #if ENABLE_PUSH_NOTIFICATIONS
      // First request notification permissions
      notificationHandler.requestPermissions { [weak self] granted, error in
        guard error == nil else {
          invoke.reject(error!.localizedDescription)
          return
        }

        self?.registerForPushNotifications { result in
          switch result {
          case .success(let token):
            invoke.resolve(["deviceToken": token])
          case .failure(let error):
            invoke.reject(error.localizedDescription)
          }
        }
      }
    #else
      invoke.reject("Push notifications are disabled in this build")
    #endif
  }

  @objc public func unregisterForPushNotifications(_ invoke: Invoke) {
    #if ENABLE_PUSH_NOTIFICATIONS
      DispatchQueue.main.async {
        UIApplication.shared.unregisterForRemoteNotifications()
        invoke.resolve()
      }
    #else
      invoke.reject("Push notifications are disabled in this build")
    #endif
  }

  #if ENABLE_PUSH_NOTIFICATIONS
    private func registerForPushNotifications(completion: @escaping (Result<String, Error>) -> Void)
    {
      // Store completion for later
      self.pushTokenCompletion = completion

      // Set up timeout
      self.pushTokenTimer?.invalidate()
      self.pushTokenTimer = Timer.scheduledTimer(withTimeInterval: pushTokenTimeout, repeats: false)
      { [weak self] _ in
        self?.handlePushTokenTimeout()
      }

      // Register for remote notifications
      DispatchQueue.main.async {
        UIApplication.shared.registerForRemoteNotifications()
      }
    }

    private func handlePushTokenTimeout() {
      pushTokenTimer?.invalidate()
      pushTokenTimer = nil

      if let completion = pushTokenCompletion {
        pushTokenCompletion = nil
        let error = NSError(
          domain: "NotificationPlugin",
          code: -1,
          userInfo: [NSLocalizedDescriptionKey: "Timeout waiting for device token"]
        )
        completion(.failure(error))
      }
    }

    // Called by AppDelegateSwizzler when token is received
    func handlePushTokenReceived(_ token: String) {
      pushTokenTimer?.invalidate()
      pushTokenTimer = nil

      if let completion = pushTokenCompletion {
        pushTokenCompletion = nil
        completion(.success(token))
      }
    }

    // Called by AppDelegateSwizzler when registration fails
    func handlePushTokenError(_ error: Error) {
      pushTokenTimer?.invalidate()
      pushTokenTimer = nil

      if let completion = pushTokenCompletion {
        pushTokenCompletion = nil
        completion(.failure(error))
      }
    }
  #endif

  @objc public override func checkPermissions(_ invoke: Invoke) {
    notificationHandler.checkPermissions { status in
      let permission: String

      switch status {
      case .authorized, .ephemeral, .provisional:
        permission = "granted"
      case .denied:
        permission = "denied"
      case .notDetermined:
        permission = "prompt"
      @unknown default:
        permission = "prompt"
      }

      invoke.resolve(["permissionState": permission])
    }
  }

  @objc func cancel(_ invoke: Invoke) throws {
    let args = try invoke.parseArgs(CancelArgs.self)

    UNUserNotificationCenter.current().removePendingNotificationRequests(
      withIdentifiers: args.notifications.map { String($0) }
    )
    invoke.resolve()
  }

  @objc func cancelAll(_ invoke: Invoke) {
    UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
    invoke.resolve()
  }

  @objc func getPending(_ invoke: Invoke) {
    UNUserNotificationCenter.current().getPendingNotificationRequests(completionHandler: {
      (notifications) in
      let ret = notifications.compactMap({ [weak self] (notification) -> PendingNotification? in
        return self?.notificationHandler.toPendingNotification(notification)
      })

      invoke.resolve(ret)
    })
  }

  @objc func registerActionTypes(_ invoke: Invoke) throws {
    let args = try invoke.parseArgs(RegisterActionTypesArgs.self)
    makeCategories(args.types)
    invoke.resolve()
  }

  @objc func removeActive(_ invoke: Invoke) {
    let args = try? invoke.parseArgs(RemoveActiveArgs.self)
    if let args, !args.notifications.isEmpty {
      UNUserNotificationCenter.current().removeDeliveredNotifications(
        withIdentifiers: args.notifications.map { String($0.id) })
    } else {
      UNUserNotificationCenter.current().removeAllDeliveredNotifications()
      DispatchQueue.main.async(execute: {
        UIApplication.shared.applicationIconBadgeNumber = 0
      })
    }
    invoke.resolve()
  }

  @objc func getActive(_ invoke: Invoke) {
    UNUserNotificationCenter.current().getDeliveredNotifications(completionHandler: {
      (notifications) in
      let ret = notifications.compactMap({ (notification) -> ActiveNotification? in
        return self.notificationHandler.toActiveNotification(
          notification.request)
      })
      invoke.resolve(ret)
    })
  }

  @objc func setClickListenerActive(_ invoke: Invoke) {
    do {
      let args = try invoke.parseArgs(SetClickListenerActiveArgs.self)
      notificationHandler.setClickListenerActive(args.active)
      invoke.resolve()
    } catch {
      invoke.reject(error.localizedDescription)
    }
  }
}

@_cdecl("init_plugin_notification")
func initPlugin() -> Plugin {
  return NotificationPlugin()
}