ios: notifications, actionable approvals, Live Activity, and the relay push path
Brings the phone's ambient surfaces (UX_IOS §5/§8) to maturity: - Notification pipeline (NotificationRouter): local notifications for approvals and needs-input transitions while backgrounded; low-risk approvals are actionable from the banner (Allow requires device auth, high-risk must open the app's Face ID gate); taps deep-link to the session; app-icon badge = NEEDS YOU count; resolutions withdraw the notification (first-responder-wins). The relay's content-free approval.pending tickle localizes via Localizable.strings. - Live Activity: new NucleicRemoteWidgets extension target (lock screen + Dynamic Island) rendering one aggregate Activity — N running / M waiting + the most urgent session — started/updated/ ended by LiveActivityManager as session state changes. - Out-of-band push path: nucleic-edge gains POST /v1/push/register (admin) so the host can upload tokens for LAN-only pairings; the host's new PushRelayClient (config-gated on NUCLEIC_RELAY_URL + NUCLEIC_RELAY_ADMIN_SECRET) mirrors Hello.pushToken to the relay and wakes non-connected phones when an approval arrives, throttled per device. Everything stays off until the relay is provisioned. Worker tests (23) and Swift suites pass apart from the pre-existing fixture gaps and nvrsion flake. Simulated APNs delivery is blocked in this environment (notification auth can't be granted headlessly). Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
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 (Face ID gate 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
|
||||
content.categoryIdentifier = approval.risk.isHigh
|
||||
? 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user