notifications: add XNotifications + macOS UNUserNotificationCenter plugin
Generic OS-notification + window-focus surface. Hand-rolled MethodChannel + EventChannel, registered through XPlugin alongside the existing camera / video / clipboard plugins. macOS native handler uses UNUserNotificationCenter with thread-grouping support, NSApp activation on tap, and NSApplicationDidBecomeActive/DidResignActive for the focus signal (more reliable than Flutter's AppLifecycleState on macOS).
This commit is contained in:
231
macos/Classes/NotificationsPlugin.swift
Normal file
231
macos/Classes/NotificationsPlugin.swift
Normal file
@@ -0,0 +1,231 @@
|
||||
import AppKit
|
||||
import FlutterMacOS
|
||||
import UserNotifications
|
||||
|
||||
/// `ux/notifications` + `ux/notifications/events`. Generic OS-notification
|
||||
/// + app-focus surface used by hosts that want native Notification Center
|
||||
/// entries on macOS. Domain-agnostic — the Dart caller decides when to
|
||||
/// emit and how to format the payload.
|
||||
public class NotificationsPlugin: NSObject, NativePlugin, FlutterStreamHandler,
|
||||
UNUserNotificationCenterDelegate
|
||||
{
|
||||
private var methodChannel: FlutterMethodChannel?
|
||||
private var eventChannel: FlutterEventChannel?
|
||||
private var eventSink: FlutterEventSink?
|
||||
|
||||
public func register(with registrar: FlutterPluginRegistrar) {
|
||||
let methods = FlutterMethodChannel(
|
||||
name: "ux/notifications",
|
||||
binaryMessenger: registrar.messenger,
|
||||
)
|
||||
methods.setMethodCallHandler { [weak self] call, result in
|
||||
self?.handle(call, result: result)
|
||||
}
|
||||
methodChannel = methods
|
||||
|
||||
let events = FlutterEventChannel(
|
||||
name: "ux/notifications/events",
|
||||
binaryMessenger: registrar.messenger,
|
||||
)
|
||||
events.setStreamHandler(self)
|
||||
eventChannel = events
|
||||
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
observeAppActivation()
|
||||
}
|
||||
|
||||
// MARK: - FlutterStreamHandler
|
||||
|
||||
public func onListen(
|
||||
withArguments arguments: Any?,
|
||||
eventSink events: @escaping FlutterEventSink
|
||||
) -> FlutterError? {
|
||||
eventSink = events
|
||||
// Seed the initial focus state so Dart-side starts in a deterministic
|
||||
// value rather than the constructor default.
|
||||
emit(["type": "focus", "focused": NSApp.isActive])
|
||||
return nil
|
||||
}
|
||||
|
||||
public func onCancel(withArguments arguments: Any?) -> FlutterError? {
|
||||
eventSink = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - MethodChannel dispatch
|
||||
|
||||
private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
switch call.method {
|
||||
case "requestPermission":
|
||||
requestPermission(result: result)
|
||||
case "show":
|
||||
show(args: call.arguments, result: result)
|
||||
case "cancelByThread":
|
||||
cancelByThread(args: call.arguments, result: result)
|
||||
case "cancelAll":
|
||||
UNUserNotificationCenter.current().removeAllDeliveredNotifications()
|
||||
result(nil)
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
|
||||
private func requestPermission(result: @escaping FlutterResult) {
|
||||
UNUserNotificationCenter.current().requestAuthorization(
|
||||
options: [.alert, .sound, .badge]
|
||||
) { [weak self] granted, error in
|
||||
if let error = error {
|
||||
NSLog("ux notifications: requestAuthorization error: \(error)")
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self?.emit(["type": "authorization", "granted": granted])
|
||||
result(granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func show(args: Any?, result: @escaping FlutterResult) {
|
||||
guard let m = args as? [String: Any],
|
||||
let id = m["id"] as? String,
|
||||
let title = m["title"] as? String,
|
||||
let body = m["body"] as? String
|
||||
else {
|
||||
result(FlutterError(code: "bad_args", message: "show expects id/title/body", details: nil))
|
||||
return
|
||||
}
|
||||
let threadId = m["threadId"] as? String
|
||||
let data = m["data"] as? [String: Any] ?? [:]
|
||||
|
||||
UNUserNotificationCenter.current().getNotificationSettings { [weak self] settings in
|
||||
switch settings.authorizationStatus {
|
||||
case .notDetermined:
|
||||
UNUserNotificationCenter.current().requestAuthorization(
|
||||
options: [.alert, .sound, .badge]
|
||||
) { [weak self] granted, _ in
|
||||
DispatchQueue.main.async {
|
||||
self?.emit(["type": "authorization", "granted": granted])
|
||||
if granted {
|
||||
self?.post(id: id, title: title, body: body, threadId: threadId, data: data)
|
||||
}
|
||||
result(nil)
|
||||
}
|
||||
}
|
||||
case .denied:
|
||||
DispatchQueue.main.async {
|
||||
self?.emit(["type": "authorization", "granted": false])
|
||||
result(nil)
|
||||
}
|
||||
default:
|
||||
DispatchQueue.main.async {
|
||||
self?.post(id: id, title: title, body: body, threadId: threadId, data: data)
|
||||
result(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func post(
|
||||
id: String,
|
||||
title: String,
|
||||
body: String,
|
||||
threadId: String?,
|
||||
data: [String: Any]
|
||||
) {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = body
|
||||
if let t = threadId { content.threadIdentifier = t }
|
||||
content.userInfo = data
|
||||
content.sound = .default
|
||||
|
||||
let request = UNNotificationRequest(identifier: id, content: content, trigger: nil)
|
||||
UNUserNotificationCenter.current().add(request) { error in
|
||||
if let error = error {
|
||||
NSLog("ux notifications: add failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelByThread(args: Any?, result: @escaping FlutterResult) {
|
||||
guard let m = args as? [String: Any],
|
||||
let threadId = m["threadId"] as? String
|
||||
else {
|
||||
result(FlutterError(code: "bad_args", message: "cancelByThread expects threadId", details: nil))
|
||||
return
|
||||
}
|
||||
let center = UNUserNotificationCenter.current()
|
||||
center.getDeliveredNotifications { delivered in
|
||||
let ids = delivered
|
||||
.filter { $0.request.content.threadIdentifier == threadId }
|
||||
.map { $0.request.identifier }
|
||||
if !ids.isEmpty {
|
||||
center.removeDeliveredNotifications(withIdentifiers: ids)
|
||||
}
|
||||
DispatchQueue.main.async { result(nil) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - App-activation observers
|
||||
|
||||
private func observeAppActivation() {
|
||||
let nc = NotificationCenter.default
|
||||
nc.addObserver(
|
||||
self,
|
||||
selector: #selector(onDidBecomeActive),
|
||||
name: NSApplication.didBecomeActiveNotification,
|
||||
object: nil,
|
||||
)
|
||||
nc.addObserver(
|
||||
self,
|
||||
selector: #selector(onDidResignActive),
|
||||
name: NSApplication.didResignActiveNotification,
|
||||
object: nil,
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func onDidBecomeActive() {
|
||||
emit(["type": "focus", "focused": true])
|
||||
}
|
||||
|
||||
@objc private func onDidResignActive() {
|
||||
emit(["type": "focus", "focused": false])
|
||||
}
|
||||
|
||||
// MARK: - UNUserNotificationCenterDelegate
|
||||
|
||||
public func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification,
|
||||
withCompletionHandler completionHandler:
|
||||
@escaping (UNNotificationPresentationOptions) -> Void,
|
||||
) {
|
||||
completionHandler([.alert, .sound])
|
||||
}
|
||||
|
||||
public func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
didReceive response: UNNotificationResponse,
|
||||
withCompletionHandler completionHandler: @escaping () -> Void,
|
||||
) {
|
||||
let data = response.notification.request.content.userInfo
|
||||
let payload = stringifiedData(data)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
NSApp.mainWindow?.makeKeyAndOrderFront(nil)
|
||||
emit(["type": "tap", "data": payload])
|
||||
completionHandler()
|
||||
}
|
||||
|
||||
private func stringifiedData(_ raw: [AnyHashable: Any]) -> [String: String] {
|
||||
var out: [String: String] = [:]
|
||||
for (k, v) in raw {
|
||||
out["\(k)"] = "\(v)"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func emit(_ event: [String: Any]) {
|
||||
eventSink?(event)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user