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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user