Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/Notifications.swift
T

150 lines
7.2 KiB
Swift
Raw Normal View History

import Foundation
import UserNotifications
import UIKit
import NucleicProtocol
/// The phone's notification pipeline (UX_IOS §5.1): local notifications for approvals and
/// needs-input transitions while the app is backgrounded, actionable Allow/Deny for low-risk
/// approvals, deep-link routing on tap, and the app-icon badge (= NEEDS YOU count).
///
/// Two arrival paths converge here:
/// - **Local** (LAN): the app is backgrounded but its socket is briefly alive; `RemoteStore`
/// posts a rich local notification (content composed on-device — nothing rides APNs).
/// - **Remote** (relay, M5): a content-free `approval.pending` tickle wakes the phone; the
/// alert text comes from Localizable.strings and the app pulls the real approval over the
/// encrypted channel on open.
@MainActor
final class NotificationRouter: NSObject {
static let shared = NotificationRouter()
/// The store notifications act on; set once at app start.
weak var store: RemoteStore?
// Category / action identifiers (also referenced from the notification service side).
static let approvalCategory = "NUCLEIC_APPROVAL"
static let approvalActionableCategory = "NUCLEIC_APPROVAL_ACTIONABLE"
static let inputCategory = "NUCLEIC_INPUT"
static let allowAction = "NUCLEIC_ALLOW"
static let denyAction = "NUCLEIC_DENY"
/// Install the delegate + categories. Call once at launch (before any notification can
/// arrive, so actions are always registered).
func install(store: RemoteStore) {
self.store = store
let center = UNUserNotificationCenter.current()
center.delegate = self
// Low/medium-risk approvals resolve straight from the banner (UX_IOS §5.1);
// Allow requires device auth so a pocket-tap can't grant. High-risk approvals use
// the action-less category — they must open the app and be answered on the card.
let allow = UNNotificationAction(
identifier: Self.allowAction, title: "Allow",
options: [.authenticationRequired])
let deny = UNNotificationAction(
identifier: Self.denyAction, title: "Deny",
options: [.destructive])
let actionable = UNNotificationCategory(
identifier: Self.approvalActionableCategory,
actions: [deny, allow], intentIdentifiers: [])
let plain = UNNotificationCategory(
identifier: Self.approvalCategory, actions: [], intentIdentifiers: [])
let input = UNNotificationCategory(
identifier: Self.inputCategory, actions: [], intentIdentifiers: [])
center.setNotificationCategories([actionable, plain, input])
}
// MARK: - Posting (local path, composed on-device)
/// Post a local notification for a pending approval. Rich because it never leaves the
/// device; the remote tickle stays content-free.
func postApproval(_ approval: ApprovalRequest, sessionTitle: String) {
let content = UNMutableNotificationContent()
content.title = sessionTitle
content.body = "\(approval.toolName) wants: \(approval.title)"
content.sound = .default
// AskUserQuestion collects answers, it doesn't gate — a banner Allow would reply with no
// selection (updatedInput: nil) and the CLI reports the question went unanswered. So, like
// a high-risk approval, it uses the action-less category: it must open the app and be
// answered on the picker card.
content.categoryIdentifier = approval.risk.isHigh || approval.toolName == AskUserQuestion.toolName
? Self.approvalCategory : Self.approvalActionableCategory
content.userInfo = [
"sessionID": approval.sessionID.rawValue,
"approvalID": approval.id.rawValue,
]
// One notification per approval; a re-post for the same id replaces, and resolution
// (any device) withdraws it.
let request = UNNotificationRequest(
identifier: "approval-\(approval.id.rawValue)", content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
}
/// Post a "session needs you / finished" transition notification.
func postSessionUpdate(_ summary: WireSessionSummary) {
let content = UNMutableNotificationContent()
content.title = summary.title
content.body = summary.status == .awaitingInput && summary.disposition == .completed
? "Finished its work." : "Waiting for your next prompt."
content.sound = .default
content.categoryIdentifier = Self.inputCategory
content.userInfo = ["sessionID": summary.sessionID.rawValue]
let request = UNNotificationRequest(
identifier: "input-\(summary.sessionID.rawValue)", content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
}
/// Withdraw an approval's notification once it's resolved (first-responder-wins — the
/// Mac may have answered).
func withdrawApproval(_ id: ApprovalID) {
let identifier = "approval-\(id.rawValue)"
let center = UNUserNotificationCenter.current()
center.removeDeliveredNotifications(withIdentifiers: [identifier])
center.removePendingNotificationRequests(withIdentifiers: [identifier])
}
/// Keep the app-icon badge equal to the NEEDS YOU count (UX_IOS §8).
func updateBadge(_ count: Int) {
UNUserNotificationCenter.current().setBadgeCount(count)
}
}
extension NotificationRouter: UNUserNotificationCenterDelegate {
/// Foreground arrivals: show the banner unless the user is already looking at that
/// session (the approval card is louder than a banner).
nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification
) async -> UNNotificationPresentationOptions {
let sessionID = notification.request.content.userInfo["sessionID"] as? String
let suppress = await MainActor.run {
sessionID != nil && store?.openSessionID?.rawValue == sessionID
}
return suppress ? [] : [.banner, .sound, .badge]
}
/// Taps and actions. A tap routes to the session; Allow/Deny resolve the approval over
/// a live channel (reconnecting first if the socket dropped).
nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse
) async {
let info = response.notification.request.content.userInfo
let sessionID = (info["sessionID"] as? String).map(SessionID.init(rawValue:))
let approvalID = (info["approvalID"] as? String).map(ApprovalID.init(rawValue:))
let action = response.actionIdentifier
await MainActor.run { [weak self] in
guard let store = self?.store else { return }
switch action {
case Self.allowAction:
if let approvalID { store.respondFromNotification(approvalID, allow: true) }
case Self.denyAction:
if let approvalID { store.respondFromNotification(approvalID, allow: false) }
default:
// Plain tap (or a long-press open): route to the session.
if let sessionID { store.route(to: sessionID) }
}
}
}
}