import AppKit import UserNotifications @MainActor final class Notifier: NSObject, UNUserNotificationCenterDelegate { static let shared = Notifier() private var pending: (origin: String, taskId: String)? func install() { UNUserNotificationCenter.current().delegate = self } private func open(_ origin: String, _ taskId: String) { NSApp.activate() Servers.shared.show(origin) pending = (origin, taskId) deliver() } func deliver() { guard let p = pending, let store = Servers.shared.connections[p.origin]?.store else { return } pending = nil if !p.taskId.isEmpty { store.focusTask(p.taskId) } } nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { [.banner, .list, .sound] } nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { let info = response.notification.request.content.userInfo let origin = info["origin"] as? String ?? "" let taskId = info["task_id"] as? String ?? "" await MainActor.run { open(origin, taskId) } } } @MainActor private var queue: Task? @MainActor private func enqueue(_ op: @escaping @MainActor () async -> Void) { let prev = queue queue = Task { await prev?.value await op() } } @MainActor private func show(_ store: Store, _ taskId: String, _ tag: String, _ text: String) { let line = trimmed(text).split(separator: "\n", omittingEmptySubsequences: false).first.map(String.init) ?? "" let content = UNMutableNotificationContent() content.title = store.findTask(taskId).map { "byre · \($0.title)" } ?? "byre" content.body = line.count > 200 ? "\(line.prefix(199))…" : line content.sound = .default content.userInfo = ["task_id": taskId, "origin": store.api.origin.absoluteString] let request = UNNotificationRequest(identifier: tag, content: content, trigger: nil) enqueue { let status = await UNUserNotificationCenter.current().notificationSettings().authorizationStatus if status != .authorized && status != .provisional { return } do { try await UNUserNotificationCenter.current().add(request) } catch { log.error("show notification: \(errorText(error), privacy: .public)") } } } @MainActor func notifyInbox(_ store: Store, _ item: InboxItem) { guard let me = store.me, me.notificationPrefs.questions else { return } show(store, item.taskId, item.id, str(item.payload.value, "question") ?? "") } @MainActor func notifyError(_ store: Store, _ ev: ByreEvent) { guard let me = store.me, let taskId = ev.taskId, !taskId.isEmpty else { return } let limited = ev.kind == "session.rate_limited" if !(limited ? me.notificationPrefs.rateLimits : me.notificationPrefs.errors) { return } show(store, taskId, "error-\(ev.seq)@\(store.api.origin.absoluteString)", limited ? "Rate limited" : "Error: \(str(ev.payload.value, "error") ?? "")") } @MainActor func clearInbox(_ item: InboxItem) { if item.state == .open { return } enqueue { UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [item.id]) } } @MainActor func clearTurns(_ taskId: String, through seq: Int64) { enqueue { let ids = await UNUserNotificationCenter.current().deliveredNotifications().map(\.request).filter { r in guard r.content.userInfo["task_id"] as? String == taskId, r.identifier.hasPrefix("turn-"), let s = Int64(r.identifier.dropFirst(5).prefix { $0 != "@" }) else { return false } return s <= seq }.map(\.identifier) UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: ids) } } @MainActor func notifyTurn(_ store: Store, _ ev: ByreEvent) { guard let me = store.me, me.notificationPrefs.reports, ev.payload.value["summary"] as? Bool == true, let taskId = ev.taskId, !taskId.isEmpty else { return } show(store, taskId, "turn-\(ev.seq)@\(store.api.origin.absoluteString)", str(ev.payload.value, "result") ?? "") }