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]>
63 lines
2.5 KiB
Swift
63 lines
2.5 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|