Nucleic-Session: 0F065CD6-20FE-4941-ACF5-C57A4572A5A3 Co-authored-by: Nucleic <[email protected]>
167 lines
8.3 KiB
Swift
167 lines
8.3 KiB
Swift
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?
|
|
|
|
/// The delivered identifier of the remote wake tickle ("A session is waiting for your
|
|
/// approval"). A remote notification's identifier equals its `apns-collapse-id`, so this must
|
|
/// match the relay's `APPROVAL_COLLAPSE_ID` (cloud/nucleic-edge/src/apns.ts) for the recall to
|
|
/// target the right notification.
|
|
static let approvalTickleIdentifier = "approval-pending"
|
|
|
|
// 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])
|
|
}
|
|
|
|
/// Recall the generic remote wake tickle ("A session is waiting for your approval") once no
|
|
/// approval is pending anywhere. This targets *only* the shared tickle (delivered under
|
|
/// `approvalTickleIdentifier`, its APNs collapse-id) — the content-free push a phone gets while
|
|
/// away. Per-approval notifications are each recalled by `withdrawApproval(_:)` as they resolve,
|
|
/// so a still-pending approval's own banner is never swept away by this.
|
|
func withdrawApprovalAttention() {
|
|
let center = UNUserNotificationCenter.current()
|
|
center.removeDeliveredNotifications(withIdentifiers: [Self.approvalTickleIdentifier])
|
|
center.removePendingNotificationRequests(withIdentifiers: [Self.approvalTickleIdentifier])
|
|
}
|
|
|
|
/// 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) }
|
|
}
|
|
}
|
|
}
|
|
}
|