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
@@ -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)