Files
ux/macos/Classes/NotificationsPlugin.swift
agra 76621a4132 window: extract XWindow primitive; XNotifications stops carrying focus
The window-focus signal had no business living on the notifications
primitive — it was there because the same NotificationsPlugin happened
to observe NSApplication active/resign for its own reasons. Splitting
it into a sibling XWindow primitive (with its own WindowPlugin on
macOS, ux/window/events) lets future consumers — paused video,
deferred-work scheduling, dock badge counts — read focus state without
pulling in UNUserNotificationCenter.

XNotifications now only exposes notification I/O (show/cancel + tap +
authorization). The 'type:focus' event-channel branch is gone.
2026-05-27 14:42:39 +03:00

216 lines
7.6 KiB
Swift

import AppKit
import FlutterMacOS
import UserNotifications
/// `ux/notifications` + `ux/notifications/events`. Domain-agnostic native
/// OS-notification surface: show / cancel via the method channel, tap +
/// authorization changes via the event channel. Window-focus state lives
/// in `WindowPlugin` (see `XWindow` on the Dart side).
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
}
// MARK: - FlutterStreamHandler
public func onListen(
withArguments arguments: Any?,
eventSink events: @escaping FlutterEventSink
) -> FlutterError? {
eventSink = events
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
// .active is the implicit default but spelling it out clarifies that
// we want both a banner AND a Notification Center entry. .passive
// would suppress the banner; .timeSensitive would pierce focus.
if #available(macOS 12.0, *) {
content.interruptionLevel = .active
}
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: - UNUserNotificationCenterDelegate
public func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler:
@escaping (UNNotificationPresentationOptions) -> Void,
) {
// `.alert` is deprecated since macOS 11 and the system only honours
// it as a banner entries never reach Notification Center. The
// 11+ split is `.banner` + `.list`; we need both so the toast
// shows AND persists in the tray.
if #available(macOS 11.0, *) {
completionHandler([.banner, .list, .sound])
} else {
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)
}
}