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
@@ -0,0 +1,126 @@
import ActivityKit
import SwiftUI
import WidgetKit
/// Lock-screen / Dynamic Island rendering of the aggregate session Activity (UX_IOS §5.3):
/// glance = running count + needs-you count + the most urgent session; tap opens the app
/// (straight to the approval, via the notification-style route). Self-contained colors — the
/// extension doesn't link the app's Theme, so the two palette constants it needs live here
/// (the same teal/amber/blue as `Palette`).
struct SessionLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: NucleicSessionAttributes.self) { context in
// Lock screen banner.
LockScreenView(state: context.state, hostName: context.attributes.hostName)
.activityBackgroundTint(Color.black.opacity(0.55))
.activitySystemActionForegroundColor(.white)
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Label("\(context.state.runningCount)", systemImage: "circle.lefthalf.filled")
.font(.headline)
.foregroundStyle(ActivityPalette.active)
}
DynamicIslandExpandedRegion(.trailing) {
if context.state.needsYouCount > 0 {
Label("\(context.state.needsYouCount)", systemImage: "exclamationmark.triangle.fill")
.font(.headline)
.foregroundStyle(ActivityPalette.attention)
} else {
Label("0", systemImage: "checkmark.circle")
.font(.headline)
.foregroundStyle(.secondary)
}
}
DynamicIslandExpandedRegion(.bottom) {
if let title = context.state.topTitle {
HStack(spacing: 6) {
Image(systemName: context.state.topNeedsYou
? "exclamationmark.triangle.fill" : "circle.lefthalf.filled")
.foregroundStyle(context.state.topNeedsYou
? ActivityPalette.attention : ActivityPalette.active)
Text(title).font(.subheadline.weight(.medium)).lineLimit(1)
Spacer()
Text(statusLine(context.state))
.font(.caption).foregroundStyle(.secondary)
}
} else {
Text(statusLine(context.state))
.font(.caption).foregroundStyle(.secondary)
}
}
} compactLeading: {
Image(systemName: "circle.lefthalf.filled")
.foregroundStyle(ActivityPalette.active)
} compactTrailing: {
if context.state.needsYouCount > 0 {
Text("\(context.state.needsYouCount)")
.font(.caption2.bold())
.foregroundStyle(ActivityPalette.attention)
} else {
Text("\(context.state.runningCount)")
.font(.caption2.bold())
.foregroundStyle(ActivityPalette.active)
}
} minimal: {
Image(systemName: context.state.needsYouCount > 0
? "exclamationmark.triangle.fill" : "circle.lefthalf.filled")
.foregroundStyle(context.state.needsYouCount > 0
? ActivityPalette.attention : ActivityPalette.active)
}
}
}
private func statusLine(_ state: NucleicSessionAttributes.ContentState) -> String {
var parts: [String] = []
if state.runningCount > 0 { parts.append("\(state.runningCount) running") }
if state.needsYouCount > 0 { parts.append("\(state.needsYouCount) need you") }
return parts.isEmpty ? "Idle" : parts.joined(separator: " · ")
}
}
private struct LockScreenView: View {
let state: NucleicSessionAttributes.ContentState
let hostName: String
var body: some View {
HStack(spacing: 12) {
Image(systemName: state.needsYouCount > 0
? "exclamationmark.triangle.fill" : "circle.lefthalf.filled")
.font(.title2)
.foregroundStyle(state.needsYouCount > 0
? ActivityPalette.attention : ActivityPalette.active)
VStack(alignment: .leading, spacing: 2) {
if let title = state.topTitle {
Text(title).font(.headline).lineLimit(1)
}
HStack(spacing: 4) {
if state.runningCount > 0 {
Text("\(state.runningCount) running").foregroundStyle(ActivityPalette.active)
}
if state.runningCount > 0 && state.needsYouCount > 0 { Text("·") }
if state.needsYouCount > 0 {
Text("\(state.needsYouCount) waiting for you")
.foregroundStyle(ActivityPalette.attention)
}
if state.runningCount == 0 && state.needsYouCount == 0 {
Text("Idle").foregroundStyle(.secondary)
}
}
.font(.subheadline)
}
Spacer()
Text(hostName)
.font(.caption2).foregroundStyle(.secondary)
.lineLimit(1)
}
.padding(14)
.foregroundStyle(.white)
}
}
/// The two categorical colors the Activity needs — same constants as the app's `Palette`.
private enum ActivityPalette {
static let active = Color(red: 0.20, green: 0.55, blue: 0.95) // running blue
static let attention = Color(red: 1.00, green: 0.65, blue: 0.15) // needs-you amber
}