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:
2026-07-02 16:06:33 -07:00
co-authored by Claude Fable 5
parent 480b29207f
commit 761dca5f1d
12 changed files with 643 additions and 3 deletions
+2
View File
@@ -8,5 +8,7 @@
</array>
<key>NucleicChannel</key>
<string>$(NUCLEIC_CHANNEL)</string>
<key>NSSupportsLiveActivities</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,62 @@
import ActivityKit
import Foundation
import NucleicProtocol
/// Owns the one aggregate session Live Activity (UX_IOS §5.3): started when work exists,
/// updated as sessions change, ended when everything is idle or the device unpairs. State
/// flows in from `RemoteStore` on every session-list change; the widget extension renders it
/// (`SessionLiveActivity`).
@MainActor
final class LiveActivityManager {
static let shared = LiveActivityManager()
private init() {}
private var activity: Activity<NucleicSessionAttributes>?
/// Reconcile the Activity with the current session set.
func sync(hostName: String, sessions: [WireSessionSummary]) {
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
let live = sessions.filter { !$0.archived }
let running = live.filter { $0.status == .running || $0.status == .provisioning }
let needsYou = live.filter { $0.status.needsYou($0.disposition) }
guard !running.isEmpty || !needsYou.isEmpty else {
end()
return
}
// The most urgent session headlines: the top waiter, else the freshest runner.
let top = needsYou.max { $0.updatedAt < $1.updatedAt }
?? running.max { $0.updatedAt < $1.updatedAt }
let state = NucleicSessionAttributes.ContentState(
runningCount: running.count,
needsYouCount: needsYou.count,
topTitle: top?.title,
topNeedsYou: top.map { $0.status.needsYou($0.disposition) } ?? false)
if let activity {
Task { await activity.update(ActivityContent(state: state, staleDate: nil)) }
} else {
// Recover an Activity that survived an app relaunch before starting a new one.
if let existing = Activity<NucleicSessionAttributes>.activities.first {
activity = existing
Task { await existing.update(ActivityContent(state: state, staleDate: nil)) }
return
}
activity = try? Activity.request(
attributes: NucleicSessionAttributes(hostName: hostName),
content: ActivityContent(state: state, staleDate: nil))
}
}
/// End the Activity (all idle, or unpaired).
func end() {
guard let activity else { return }
self.activity = nil
Task {
await activity.end(
ActivityContent(state: activity.content.state, staleDate: nil),
dismissalPolicy: .immediate)
}
}
}
@@ -0,0 +1,5 @@
/* The relay's content-free APNS wake (cloud/nucleic-edge apns.ts `approvalPayload`):
the push deliberately carries no approval detail (the relay can't read it — E2EE), so
this localized alert is all the lock screen shows. The app pulls the real approval
over the encrypted channel on open. */
"approval.pending" = "A session is waiting for your approval";
@@ -69,6 +69,43 @@ final class RemoteStore: ObservableObject {
/// doesn't carry the host's flag, so the phone tracks its own view locally).
@Published private(set) var lastOpenedAt: [SessionID: Date] = RemoteStore.loadLastOpened()
/// A navigation request from outside the view hierarchy (notification tap → this session).
/// `RootView` switches to the Sessions tab; `SessionsView` pushes it and clears.
@Published var pendingRoute: SessionID?
/// Whether the app is foreground-active (scene phase), mirrored here so the store can
/// decide which transitions deserve a notification.
private(set) var isActive = true
func setScenePhaseActive(_ active: Bool) { isActive = active }
/// Route to a session from a notification tap (deep link).
func route(to sessionID: SessionID) { pendingRoute = sessionID }
/// An Allow/Deny straight from a notification action (UX_IOS §5.1). Requires a live
/// channel; if the socket dropped while backgrounded, reconnect and send once ready —
/// but only briefly (a stale queued approval must never fire minutes later; see
/// UX_IOS §11.5, and the host dedupes/`alreadyResolved`s a lost race anyway).
func respondFromNotification(_ id: ApprovalID, allow: Bool) {
let decision: Decision = allow ? .allow(updatedInput: nil) : .deny(reason: nil)
if connectivity.isLive {
send(.approvalRespond(id, decision))
} else {
pendingNotificationDecision = (id, decision, Date())
reconnect()
}
}
/// At most one decision waits for reconnect, and it expires after 30s.
private var pendingNotificationDecision: (ApprovalID, Decision, Date)?
private func flushPendingNotificationDecision() {
guard let (id, decision, at) = pendingNotificationDecision else { return }
pendingNotificationDecision = nil
guard Date().timeIntervalSince(at) < 30 else { return } // stale — require the app
send(.approvalRespond(id, decision))
}
private static let lastOpenedKey = "nucleic.lastOpenedAt"
private static func loadLastOpened() -> [SessionID: Date] {
guard let raw = UserDefaults.standard.dictionary(forKey: lastOpenedKey) else { return [:] }
@@ -205,6 +242,7 @@ final class RemoteStore: ObservableObject {
]),
WireStatusFeed(provider: "xai", providerName: "xAI", incidents: []),
])
LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions)
}
/// Offline diff fixture (NUCLEIC_DEMO) so the full-patch Diff tab renders without a host.
@@ -275,6 +313,8 @@ final class RemoteStore: ObservableObject {
IdentityStore.clearPairedHost()
connectivity = .unpaired
sessions = []
LiveActivityManager.shared.end()
NotificationRouter.shared.updateBadge(0)
}
// MARK: - Intents (UX_IOS §9)
@@ -450,11 +490,29 @@ final class RemoteStore: ObservableObject {
send(.listSessions)
send(.listDashboard)
if let id = openSessionID { send(.subscribe(Subscribe(sessionID: id, sinceSeq: nil, verbosity: .full))) }
flushPendingNotificationDecision()
case .sessionList(let list):
sessions = list
NotificationRouter.shared.updateBadge(needsYouCount)
LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions)
case .sessionUpdated(let summary):
if let i = sessions.firstIndex(where: { $0.sessionID == summary.sessionID }) { sessions[i] = summary }
else { sessions.append(summary) }
let previous: WireSessionSummary?
if let i = sessions.firstIndex(where: { $0.sessionID == summary.sessionID }) {
previous = sessions[i]
sessions[i] = summary
} else {
previous = nil
sessions.append(summary)
}
// Notify on the transition into "waiting on you" / "finished" — only when the
// app isn't foreground-active (in-app, the list's washes and badges carry it).
let becameWaiting = summary.status == .awaitingInput
&& previous?.status != .awaitingInput
if becameWaiting, !isActive, !summary.archived {
NotificationRouter.shared.postSessionUpdate(summary)
}
NotificationRouter.shared.updateBadge(needsYouCount)
LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions)
case .dashboard(let snapshot):
dashboard = snapshot
case .snapshot(let snapshot):
@@ -472,8 +530,13 @@ final class RemoteStore: ObservableObject {
if req.sessionID == openSessionID, !openApprovals.contains(where: { $0.id == req.id }) {
openApprovals.append(req)
}
// Always post; the router suppresses the banner when the user is already
// looking at this session, and resolution (any device) withdraws it.
let title = sessions.first { $0.sessionID == req.sessionID }?.title ?? "Approval"
NotificationRouter.shared.postApproval(req, sessionTitle: title)
case .approvalResolved(let resolved):
openApprovals.removeAll { $0.id == resolved.id }
NotificationRouter.shared.withdrawApproval(resolved.id)
case .sessionDiff(let diff):
guard diff.sessionID == openSessionID else { break }
openDiff = diff
@@ -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) }
}
}
}
}
@@ -12,6 +12,9 @@ struct NucleicRemoteApp: App {
.environmentObject(store)
.onAppear {
store.onAppear()
// Notification categories + delegate must be in place before any
// notification can arrive, so actions/taps always route.
NotificationRouter.shared.install(store: store)
// Surface the notifications prompt + register for APNS. The token rides along
// in the sync Hello; the relay uses it to wake the phone for approvals (§3).
// Skipped in demo mode so offline UI previews aren't blocked by the system dialog.
@@ -21,8 +24,10 @@ struct NucleicRemoteApp: App {
// Anonymous, opt-out, once-a-day DAI heartbeat (docs/CLOUD_INFRA.md §4).
Task { await HeartbeatReporter.reportIfDue() }
}
// Re-check on every foreground so a new active day is counted.
// Re-check on every foreground so a new active day is counted; keep the store's
// activity flag current so it only notifies for background transitions.
.onChange(of: scenePhase) { _, phase in
store.setScenePhaseActive(phase == .active)
if phase == .active { Task { await HeartbeatReporter.reportIfDue() } }
}
}
@@ -64,6 +69,11 @@ struct RootView: View {
}
}
.overlay(alignment: .bottom) { ErrorBubble() }
// A notification tap routes to its session: jump to the Sessions tab, where
// `SessionsView` consumes `pendingRoute` and pushes the detail.
.onChange(of: store.pendingRoute) { _, route in
if route != nil { tab = 1 }
}
.tint(Palette.accent)
.preferredColorScheme((AppAppearance(rawValue: appearanceRaw) ?? .system).colorScheme)
.dynamicTypeSize((AppTextSize(rawValue: textSizeRaw) ?? .medium).dynamicTypeSize)
@@ -74,7 +74,10 @@ struct SessionsView: View {
!raw.isEmpty, path.isEmpty {
path.append(SessionID(rawValue: raw))
}
consumeRoute()
}
// A notification tap while this tab is already up.
.onChange(of: store.pendingRoute) { consumeRoute() }
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
@@ -87,6 +90,13 @@ struct SessionsView: View {
.safeAreaInset(edge: .bottom) { ConnectionChip().padding(.bottom, 8) }
}
}
/// Push the session a notification tap asked for, then clear the request.
private func consumeRoute() {
guard let route = store.pendingRoute else { return }
store.pendingRoute = nil
path.append(route)
}
}
struct SessionRow: View {