Files
wristcode/watch/WristCode/Notifications/NotificationHandler.swift
T
gib 7ca0362745 Watch: status, environment and notifications screens
A gear on the feed opens a status overview that leads with what is wrong:
offline hosts, hosts on a fallback route, tokens expiring within 7 days,
and push problems, with healthy hosts folded into one row. Each host opens
its route ladder with Reconnect. The notifications page shows registration,
the last push, Send test and Unpair. Demo mode gains a Laptop on its VPN
fallback, and snapshot tests render the three screens.
2026-11-01 15:16:18 -05:00

144 lines
6.0 KiB
Swift

// Notification categories and actions from docs/api.md, and what each action does. Allow, Deny
// and Reply run in the background against the gateway; tapping a notification opens its thread.
import Foundation
import UserNotifications
/// What a notification response asks for, parsed out of the payload.
enum NotificationCommand: Equatable, Sendable {
case respond(hostId: String, threadId: String, requestId: String, decision: ApprovalDecision)
case reply(hostId: String, threadId: String, text: String)
case open(Route?)
case none
enum Action {
static let allow = "allow"
static let deny = "deny"
static let reply = "reply"
static let open = "open"
}
/// `userInfo` holds the custom keys `hostId`, `threadId` and, for approvals, `requestId`.
init(actionIdentifier: String, userInfo: [AnyHashable: Any], text: String?) {
// The gateway sends `threadId: ""` on host notifications, so empty means absent.
func value(_ key: String) -> String? {
(userInfo[key] as? String).flatMap { $0.isEmpty ? nil : $0 }
}
let hostId = value("hostId")
let threadId = value("threadId")
let requestId = value("requestId")
let thread: Route? = if let hostId, let threadId { .thread(hostId: hostId, threadId: threadId) } else { nil }
switch actionIdentifier {
case Action.allow, Action.deny:
guard let hostId, let threadId, let requestId else { self = .open(thread); return }
self = .respond(
hostId: hostId, threadId: threadId, requestId: requestId,
decision: actionIdentifier == Action.allow ? .accept : .decline)
case Action.reply:
let text = text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard let hostId, let threadId, !text.isEmpty else { self = .open(thread); return }
self = .reply(hostId: hostId, threadId: threadId, text: text)
case UNNotificationDismissActionIdentifier:
self = .none
default:
// The Open action, or a tap on the notification itself.
self = .open(thread)
}
}
}
@MainActor
final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate {
static let shared = NotificationHandler()
func install() {
let center = UNUserNotificationCenter.current()
center.delegate = self
center.setNotificationCategories(Self.categories)
}
static var categories: Set<UNNotificationCategory> {
typealias Action = NotificationCommand.Action
let allow = UNNotificationAction(identifier: Action.allow, title: "Allow", options: [])
let deny = UNNotificationAction(identifier: Action.deny, title: "Deny", options: [.destructive])
let open = UNNotificationAction(identifier: Action.open, title: "Open", options: [.foreground])
let reply = UNTextInputNotificationAction(
identifier: Action.reply, title: "Reply", options: [],
textInputButtonTitle: "Send", textInputPlaceholder: "Reply")
func category(_ id: PushCategory, _ actions: [UNNotificationAction]) -> UNNotificationCategory {
UNNotificationCategory(identifier: id.rawValue, actions: actions, intentIdentifiers: [], options: [])
}
return [
category(.approval, [allow, deny]),
category(.input, [open]),
category(.finished, [reply, open]),
category(.failed, [open]),
category(.host, []),
category(.test, []),
]
}
nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter, willPresent notification: UNNotification
) async -> UNNotificationPresentationOptions {
await AppModel.shared.notificationArrived()
return [.banner, .list, .sound]
}
nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse
) async {
let command = NotificationCommand(
actionIdentifier: response.actionIdentifier,
userInfo: response.notification.request.content.userInfo,
text: (response as? UNTextInputNotificationResponse)?.userText)
await run(command)
}
private func run(_ command: NotificationCommand) async {
let model = AppModel.shared
switch command {
case .none:
return
case let .open(route):
if let route { model.open(route) } else { model.path = [] }
model.reloadComplication()
await model.refreshFeed()
return
case let .respond(hostId, threadId, requestId, decision):
guard model.mode != .unpaired else { return }
await attempt("Couldn't answer") {
try await model.api.respond(
hostId: hostId, threadId: threadId,
request: .decision(requestId: requestId, decision: decision))
}
case let .reply(hostId, threadId, text):
guard model.mode != .unpaired else { return }
await attempt("Couldn't send your reply") {
try await model.api.sendMessage(hostId: hostId, threadId: threadId, text: text)
}
}
model.reloadComplication()
await model.refreshFeed()
}
/// Runs a background action. If it fails, says so with a local notification, since nothing is
/// on screen.
private func attempt(_ title: String, _ action: () async throws -> Void) async {
do {
try await action()
} catch let error as GatewayError where error.isStale {
return
} catch {
guard let message = AppModel.shared.message(for: error) else { return }
let content = UNMutableNotificationContent()
content.title = title
content.body = message
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
try? await UNUserNotificationCenter.current().add(request)
}
}
}