import AppKit import UserNotifications @MainActor final class Notifier: NSObject, UNUserNotificationCenterDelegate { static let shared = Notifier() weak var connection: Connection? var showWindow: (() -> Void)? private var pending: String? func install() { UNUserNotificationCenter.current().delegate = self } private func open(_ taskId: String) { NSApp.activate() showWindow?() pending = taskId deliver() } func deliver() { guard let taskId = pending, let store = connection?.store else { return } pending = nil if !taskId.isEmpty { store.focusTask(taskId) } } nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { [.banner, .list, .sound] } nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { let taskId = response.notification.request.content.userInfo["task_id"] as? String ?? "" await MainActor.run { open(taskId) } } } private func wants(_ p: NotificationPrefs, _ item: InboxItem) -> Bool { switch item.kind { case .question, .clientRun: p.questions case .sessionError: p.errors case .rateLimited: p.rateLimits } } private func itemText(_ item: InboxItem) -> String { let p = item.payload.value switch item.kind { case .question: return str(p, "question") ?? "" case .sessionError: return "Error: \(str(p, "error") ?? "")" case .rateLimited: return "Rate limited" case .clientRun: return "Run on your Mac: \(str(p, "why") ?? "")" } } @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] 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, wants(me.notificationPrefs, item) else { return } show(store, item.taskId, item.id, itemText(item)) } @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)) 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)", str(ev.payload.value, "result") ?? "") }