2026-10-17 08:24:22 -07:00
|
|
|
import AppKit
|
|
|
|
|
import UserNotifications
|
|
|
|
|
|
|
|
|
|
@MainActor
|
|
|
|
|
final class Notifier: NSObject, UNUserNotificationCenterDelegate {
|
|
|
|
|
static let shared = Notifier()
|
2026-10-18 20:58:56 -07:00
|
|
|
private var pending: (origin: String, taskId: String)?
|
2026-10-17 08:24:22 -07:00
|
|
|
|
|
|
|
|
func install() {
|
|
|
|
|
UNUserNotificationCenter.current().delegate = self
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-18 20:58:56 -07:00
|
|
|
private func open(_ origin: String, _ taskId: String) {
|
2026-10-17 08:24:22 -07:00
|
|
|
NSApp.activate()
|
2026-10-18 20:58:56 -07:00
|
|
|
Servers.shared.openWindow?(origin)
|
|
|
|
|
pending = (origin, taskId)
|
2026-10-17 08:24:22 -07:00
|
|
|
deliver()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func deliver() {
|
2026-10-18 20:58:56 -07:00
|
|
|
guard let p = pending, let store = Servers.shared.connections[p.origin]?.store else {
|
2026-10-17 08:24:22 -07:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
pending = nil
|
2026-10-18 20:58:56 -07:00
|
|
|
if !p.taskId.isEmpty {
|
|
|
|
|
store.focusTask(p.taskId)
|
2026-10-17 08:24:22 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
|
|
|
|
|
[.banner, .list, .sound]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async {
|
2026-10-18 20:58:56 -07:00
|
|
|
let info = response.notification.request.content.userInfo
|
|
|
|
|
let origin = info["origin"] as? String ?? ""
|
|
|
|
|
let taskId = info["task_id"] as? String ?? ""
|
2026-10-17 08:24:22 -07:00
|
|
|
await MainActor.run {
|
2026-10-18 20:58:56 -07:00
|
|
|
open(origin, taskId)
|
2026-10-17 08:24:22 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-17 15:41:54 -07:00
|
|
|
@MainActor
|
|
|
|
|
private var queue: Task<Void, Never>?
|
|
|
|
|
|
|
|
|
|
@MainActor
|
|
|
|
|
private func enqueue(_ op: @escaping @MainActor () async -> Void) {
|
|
|
|
|
let prev = queue
|
|
|
|
|
queue = Task {
|
|
|
|
|
await prev?.value
|
|
|
|
|
await op()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-17 08:24:22 -07:00
|
|
|
@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
|
2026-10-18 20:58:56 -07:00
|
|
|
content.userInfo = ["task_id": taskId, "origin": store.api.origin.absoluteString]
|
2026-10-17 08:24:22 -07:00
|
|
|
let request = UNNotificationRequest(identifier: tag, content: content, trigger: nil)
|
2026-10-17 15:41:54 -07:00
|
|
|
enqueue {
|
2026-10-17 08:24:22 -07:00
|
|
|
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) {
|
2026-10-18 15:06:06 -07:00
|
|
|
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) {
|
2026-10-17 08:24:22 -07:00
|
|
|
return
|
|
|
|
|
}
|
2026-10-18 20:58:56 -07:00
|
|
|
show(store, taskId, "error-\(ev.seq)@\(store.api.origin.absoluteString)", limited ? "Rate limited" : "Error: \(str(ev.payload.value, "error") ?? "")")
|
2026-10-17 08:24:22 -07:00
|
|
|
}
|
|
|
|
|
|
2026-10-17 15:41:54 -07:00
|
|
|
@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
|
2026-10-18 20:58:56 -07:00
|
|
|
guard r.content.userInfo["task_id"] as? String == taskId, r.identifier.hasPrefix("turn-"), let s = Int64(r.identifier.dropFirst(5).prefix { $0 != "@" }) else {
|
2026-10-17 15:41:54 -07:00
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
return s <= seq
|
|
|
|
|
}.map(\.identifier)
|
|
|
|
|
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: ids)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-17 08:24:22 -07:00
|
|
|
@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
|
|
|
|
|
}
|
2026-10-18 20:58:56 -07:00
|
|
|
show(store, taskId, "turn-\(ev.seq)@\(store.api.origin.absoluteString)", str(ev.payload.value, "result") ?? "")
|
2026-10-17 08:24:22 -07:00
|
|
|
}
|