Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/LiveActivityManager.swift
T

426 lines
23 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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() {}
/// How many sessions the detail rows show. The glance stays a glance — the counts and churn
/// still summarize everything, this just bounds the per-session list.
private static let maxLines = 3
/// Whether the glance is currently holding the terminal "Done" summary. When work finishes while
/// the app is away, the Activity is kept on the lock screen showing the finished sessions and
/// *held there* — not dismissed on a timer — until the user opens the app and sees them, so a
/// completed run isn't dropped after a few seconds unseen (UX_IOS §5.3). Set when the Done glance
/// goes up; cleared when work resumes, or when the app foregrounds and the glance ends.
private var showingDoneGlance = false
private var activity: Activity<NucleicSessionAttributes>?
/// Whether an aggregate glance is currently on screen — `RemoteStore` checks this to decide
/// whether to fall back to a banner (no glance ⇒ the banner is the only surface).
var hasLiveActivity: Bool { activity != nil }
/// Whether the app is foreground, mirrored from `RemoteStore` (its `isForeground`). Drives where
/// a "needs you" arrival is announced: **foreground** the app UI / a banner does it, so the glance
/// updates silently; **backgrounded** there's no banner (we prefer the glance), so the update
/// itself carries an `AlertConfiguration` (sound/haptic). Starts `true` — `onAppear` runs
/// foreground; the background adopt path flips it. Mirrors the host's connected-vs-away gate for
/// the pushed glance, but for the still-connected phone whose own socket is alive.
var foreground = true {
didSet {
// The app just came forward and the user can now see the in-app session list — so dismiss
// the "Done" glance we were holding on the lock screen for exactly this moment. The
// counterpart to `finishWithDoneGlance` keeping it up while the app was away.
if foreground, !oldValue, showingDoneGlance { end() }
}
}
/// The last content we pushed. Updates that don't change it are skipped so we don't spend
/// ActivityKit's update budget on no-ops — `sync` fires on every host message (dashboard,
/// connectivity, pong, diff ticks…), most of which leave the aggregate identical. Burning the
/// budget on those is exactly what makes a *real* change land late and the glance read stale.
private var lastState: NucleicSessionAttributes.ContentState?
/// When the last update was applied — the clock for the mid-turn churn refresh. A status change
/// (attention-signature shift) updates immediately regardless; a churn-only change (diff totals
/// ticking as the agent edits) only re-applies once `churnRefreshInterval` has elapsed, so a busy
/// turn refreshes the numbers slowly instead of on every transcript delta. A passive timestamp
/// check on the normal path — no timer sits in front of a status update.
private var lastPushAt: Date?
/// How slowly the mid-turn diff totals refresh while nothing else about a session changes —
/// mirrors the host's `SyncHost.churnRefreshInterval` so the local and pushed glances agree.
private static let churnRefreshInterval: TimeInterval = 45
/// The newest state waiting to be applied, and the single task draining it. Coalescing to the
/// latest through one serial task means the newest data always wins — firing an unstructured
/// `Task` per `sync` let a later update lose a race to an earlier one and freeze the glance.
private var pendingState: NucleicSessionAttributes.ContentState?
/// The alert the next applied update should carry (sound/haptic when a backgrounded session newly
/// needs the user). Latched alongside `pendingState` so an update coalesced away by a fresher one
/// can't drop the alert; consumed (and cleared) when an update is applied.
private var pendingAlert: AlertConfiguration?
private var updateTask: Task<Void, Never>?
/// Set by `RemoteStore` to ship the activity's APNS push token to the paired Macs (and to tell
/// them it ended). The Macs use the token to keep this glance fresh over APNs while the phone
/// is backgrounded and its sync socket is suspended (UX_IOS §5.3).
var onPushToken: ((_ token: String, _ activityID: String) -> Void)?
var onActivityEnded: ((_ activityID: String) -> Void)?
/// Set by `RemoteStore` to ship this device's **push-to-start** token to the paired Macs. Unlike
/// `onPushToken` (a per-activity update token that exists only once an Activity does), this token
/// is device-scoped and exists before any Activity — it's what lets a Mac *create* the glance
/// over APNs when work starts while the app is closed, so it appears without the user opening the
/// app first (iOS 17.2+, UX_IOS §5.3).
var onPushToStartToken: ((_ token: String) -> Void)?
/// Streams the activity's per-activity APNS update token (it can rotate); cancelled on end.
private var tokenObservation: Task<Void, Never>?
/// Streams this device's push-to-start token (it can rotate). Device-scoped, so — unlike
/// `tokenObservation` — it lives for the whole process and is never cancelled on `end()`.
private var pushToStartObservation: Task<Void, Never>?
/// Observes Activities that appear without us creating them — i.e. ones a Mac push-started while
/// the app was closed — so we adopt them and forward their update token. Also process-lived.
private var activityAdoptionObservation: Task<Void, Never>?
/// 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 {
finishWithDoneGlance(hostName: hostName, live: live)
return
}
// Work is active again — drop any held "Done" glance so the fresh active state renders
// instead of the finished summary (a run just resumed, or a new one started).
showingDoneGlance = false
// Everything in flight or waiting on the user, attention-first (approvals, then waiting
// input, then running), freshest within a rank. This is both the detail-row source and
// the set the aggregate churn/approval totals sum over.
let active = live
.filter {
$0.status == .running || $0.status == .provisioning
|| $0.status.needsYou($0.disposition)
}
.sorted(by: StatusStyle.attentionThenRecency)
let approvals = active.reduce(0) { $0 + $1.pendingApprovalCount }
let files = active.reduce(0) { $0 + ($1.diffStat?.filesChanged ?? 0) }
let added = active.reduce(0) { $0 + ($1.diffStat?.added ?? 0) }
let removed = active.reduce(0) { $0 + ($1.diffStat?.removed ?? 0) }
let lines = active.prefix(Self.maxLines).map(Self.line(for:))
let state = NucleicSessionAttributes.ContentState(
runningCount: running.count,
needsYouCount: needsYou.count,
approvalCount: approvals,
filesChanged: files,
linesAdded: added,
linesRemoved: removed,
lines: Array(lines))
push(state, hostName: hostName)
}
/// Nothing is running or waiting. If the glance was showing active work, replace it with a "Done"
/// summary of the just-completed sessions and hold it on the lock screen until the user opens the
/// app and sees them (UX_IOS §5.3 — a finished run should read as *done* and stay put, not vanish
/// after a few seconds unseen). Foreground, the user is already on the in-app session list, so
/// there's nothing to hold — dismiss. If nothing was on screen, there's nothing to close.
private func finishWithDoneGlance(hostName: String, live: [WireSessionSummary]) {
// Already holding the "Done" summary — leave it up until the app foregrounds, don't rebuild it.
guard !showingDoneGlance else { return }
// Nothing tracked on screen: only reach into ActivityKit if an untracked orphan is lingering.
guard activity != nil else {
if !Activity<NucleicSessionAttributes>.activities.isEmpty { end() }
return
}
// Foreground: the user is already looking at the in-app session list (the glance isn't even
// visible over the app), so there's nothing to hold for later — just dismiss.
guard !foreground else { end(); return }
// The sessions that just finished — completed conversational turns and finished runs. These
// are exactly the ones the active-work filter above drops, surfaced now as `.done` rows.
let done = live
.filter {
$0.status == .finished
|| ($0.status == .awaitingInput && $0.disposition == .completed)
}
.sorted(by: StatusStyle.attentionThenRecency)
.prefix(Self.maxLines)
.map(Self.line(for:))
// Nothing to show (e.g. the work was discarded/deleted) → just dismiss.
guard !done.isEmpty else { end(); return }
let state = NucleicSessionAttributes.ContentState(
runningCount: 0, needsYouCount: 0, approvalCount: 0,
filesChanged: 0, linesAdded: 0, linesRemoved: 0, lines: Array(done))
push(state, hostName: hostName)
// Hold it: the Activity stays alive showing "Done". No scheduled dismissal — `foreground`
// flipping true (the app coming forward) is what ends it.
showingDoneGlance = true
}
/// Apply `state` to the Activity — coalesced through one serial task so the newest state always
/// wins, and gated on the attention signature so a mid-turn churn tick (which changes the diff
/// totals on every transcript edit but no status) doesn't spend ActivityKit's update budget. The
/// fresh churn still rides along on the next status-driven push.
private func push(_ state: NucleicSessionAttributes.ContentState, hostName: String) {
guard activity != nil else {
// No Activity yet — recover one that survived an app relaunch, or start a fresh one. The
// dedup gate below only guards *updates* to a live Activity; creation must never sit
// behind it. ActivityKit refuses a start unless the app has a foreground/background-
// assertion window, and committing the dedup state *before* the request meant a refused
// start left `lastState` matching the aggregate — so the next `sync` deduped the glance
// away and it never activated until the session's state changed. Record the pushed state
// only once an Activity actually exists; a refused start leaves `lastState` untouched so
// the next sync simply retries creation.
if let existing = Activity<NucleicSessionAttributes>.activities.first {
activity = existing
observePushToken(existing)
// Dismiss any duplicates so only the adopted Activity renders. iOS stacks multiple
// Activities of one type in the Dynamic Island — an orphan (from a prior launch that
// was killed before `end()`) then shows *its* stale state in the collapsed pill while
// expanding reveals the fresh one, and `end()` on completion would leave it lingering
// on the last "needs attention" glance. Keep exactly one.
endStrays(keeping: existing.id)
lastState = state
lastPushAt = Date()
enqueue(state)
} else if let started = try? Activity.request(
// `pushType: .token` opts the activity into APNs updates — the Macs push new
// content-state to the token so the glance stays fresh while the phone is locked.
attributes: NucleicSessionAttributes(hostName: hostName),
content: ActivityContent(state: state, staleDate: nil),
pushType: .token) {
activity = started
observePushToken(started)
lastState = state
lastPushAt = Date()
}
return
}
// A live Activity exists — spend ActivityKit's update budget only when the glance actually
// moved: immediately when the attention signature shifts (start/finish/approval/input — the
// alert-worthy changes), otherwise on a slow cadence for mid-turn churn (diff totals ticking
// on every transcript delta). Elapsed-time gate, not a sleeping timer — a status change is
// never held behind it.
let signatureChanged = state.attentionSignature != lastState?.attentionSignature
let churnRefreshDue = lastPushAt.map {
Date().timeIntervalSince($0) >= Self.churnRefreshInterval
} ?? true
guard signatureChanged || (state != lastState && churnRefreshDue) else { return }
// When a session newly needs the user while we're backgrounded, this update carries the alert
// (sound/haptic) — there's no banner then, we prefer the glance. Foreground, the app UI / a
// banner announces it, so the glance updates silently. Computed against the *previous* state
// before `lastState` is overwritten.
let alert = foreground ? nil : Self.alert(from: lastState, to: state)
lastState = state
lastPushAt = Date()
enqueue(state, alert: alert)
}
/// The alert an update should carry when backgrounded: a session newly needs the user. An approval
/// (its Allow/Deny live on the glance) outranks a needs-input for the wording; a rise in neither ⇒
/// nil (silent update). Reuses the same localized keys as the host's pushed alert so a local and a
/// pushed alert read identically. Approvals/inputs that merely persist (or resolve) don't re-alert.
private static func alert(
from previous: NucleicSessionAttributes.ContentState?,
to next: NucleicSessionAttributes.ContentState
) -> AlertConfiguration? {
if next.approvalCount > (previous?.approvalCount ?? 0) {
return AlertConfiguration(
title: LocalizedStringResource("approval.title"),
body: LocalizedStringResource("approval.pending"), sound: .default)
}
if next.needsYouCount > (previous?.needsYouCount ?? 0) {
return AlertConfiguration(
title: LocalizedStringResource("input.title"),
body: LocalizedStringResource("input.pending"), sound: .default)
}
return nil
}
/// Start the process-lived observers that make push-to-start work: the device's push-to-start
/// token (forwarded to the Macs so they can create the glance over APNs) and adoption of any
/// Activity a Mac push-started while the app was closed. Idempotent — safe to call on every
/// bridge setup; the guards keep a single observer each.
func beginPushToStartObservation() {
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
// Grab an activity that already exists when we start observing — e.g. one a Mac push-started
// while the app was closed, which won't re-emit through `activityUpdates` for a late observer.
// This is what lets a background "adopt" wake harvest and forward its update token.
if activity == nil, let existing = Activity<NucleicSessionAttributes>.activities.first {
adopt(existing)
}
if pushToStartObservation == nil {
pushToStartObservation = Task { [weak self] in
for await data in Activity<NucleicSessionAttributes>.pushToStartTokenUpdates {
let hex = data.map { String(format: "%02x", $0) }.joined()
self?.onPushToStartToken?(hex)
}
}
}
if activityAdoptionObservation == nil {
activityAdoptionObservation = Task { [weak self] in
for await activity in Activity<NucleicSessionAttributes>.activityUpdates {
self?.adopt(activity)
}
}
}
}
/// Adopt an Activity we didn't create locally — almost always one a Mac push-started while the
/// app was closed. Taking ownership wires up its update-token stream (`observePushToken`) so the
/// Macs can keep the glance fresh the normal way, and collapses any strays to one. If we already
/// track an Activity, leave it: the local `Activity.request` path and `endStrays` already keep a
/// single glance, and re-adopting would just churn the token observation.
private func adopt(_ activity: Activity<NucleicSessionAttributes>) {
guard self.activity == nil else { return }
self.activity = activity
observePushToken(activity)
endStrays(keeping: activity.id)
}
/// Forward the activity's APNS update token (and its rotations) to `RemoteStore`.
private func observePushToken(_ activity: Activity<NucleicSessionAttributes>) {
tokenObservation?.cancel()
tokenObservation = Task { [weak self] in
for await tokenData in activity.pushTokenUpdates {
let hex = tokenData.map { String(format: "%02x", $0) }.joined()
self?.onPushToken?(hex, activity.id)
}
}
}
/// Hand the newest state to the serial drainer (starting it if idle).
private func enqueue(
_ state: NucleicSessionAttributes.ContentState, alert: AlertConfiguration? = nil
) {
pendingState = state
if let alert { pendingAlert = alert } // latch — a coalesced-away update must not drop it
guard updateTask == nil else { return } // the running drainer will pick this up
updateTask = Task { @MainActor [weak self] in
guard let self else { return }
while let next = self.pendingState {
self.pendingState = nil
let alert = self.pendingAlert
self.pendingAlert = nil
await self.activity?.update(
ActivityContent(state: next, staleDate: nil), alertConfiguration: alert)
}
self.updateTask = nil
}
}
/// End the Activity (all idle, or unpaired).
func end() {
updateTask?.cancel()
updateTask = nil
tokenObservation?.cancel()
tokenObservation = nil
showingDoneGlance = false
pendingState = nil
pendingAlert = nil
lastState = nil
lastPushAt = nil
let tracked = activity
self.activity = nil
if let tracked { onActivityEnded?(tracked.id) } // let the Macs stop pushing to this token
// Dismiss the tracked Activity *and any strays*: ending only the one we track would leave an
// orphan (from a prior launch killed before `end()`) rendering a stale "needs attention"
// glance in the Dynamic Island after the work it described has finished. Clear them all so
// "everything idle" can't get stuck on screen.
Task {
for activity in Activity<NucleicSessionAttributes>.activities {
await activity.end(
ActivityContent(state: activity.content.state, staleDate: nil),
dismissalPolicy: .immediate)
}
}
}
/// Dismiss every live Activity except `keep` — collapses accidental duplicates down to one so
/// iOS can't render a stale orphan alongside the tracked glance.
private func endStrays(keeping keep: String) {
for stray in Activity<NucleicSessionAttributes>.activities where stray.id != keep {
Task {
await stray.end(
ActivityContent(state: stray.content.state, staleDate: nil),
dismissalPolicy: .immediate)
}
}
}
// MARK: - Projection
/// Project a wire summary into the widget's self-contained row model.
private static func line(for s: WireSessionSummary) -> NucleicSessionAttributes.SessionLine {
// Carry the approval id/risk only for a session awaiting a *tool approval* — the row the
// glance renders inline Allow/Deny on (`ApproveFromActivityIntent`). An `AskUserQuestion`
// block (pendingQuestionCount set) is excluded: it needs an answer selection the glance can't
// collect, so it deep-links to the app's picker card, like the notification path. Other states
// leave it nil so the row stays a plain deep-link tap.
let approvalID = (s.status == .awaitingApproval && s.pendingQuestionCount == nil)
? s.firstApprovalID?.rawValue : nil
return NucleicSessionAttributes.SessionLine(
id: s.sessionID.rawValue,
title: s.title.isEmpty ? s.projectName : s.title,
project: s.projectName,
backend: backend(s.backend),
kind: kind(for: s),
detail: detail(for: s),
approvalID: approvalID,
approvalIsHighRisk: approvalID == nil ? nil : s.firstApprovalIsHighRisk)
}
private static func kind(for s: WireSessionSummary) -> NucleicSessionAttributes.Kind {
switch s.status {
case .awaitingApproval: .approval
case .running: .running
case .provisioning: .provisioning
case .awaitingInput: s.disposition == .completed ? .done : .needsInput
case .idle: .idle
case .finished: .done
case .interrupted, .error: .error
}
}
private static func backend(_ id: BackendID) -> NucleicSessionAttributes.Backend {
switch id {
case .claudeCode: .claude
case .codex, .codexExec: .codex
case .grok: .grok
}
}
/// The compact right-aligned status for a row: the actionable ask wins (approvals, then
/// "waiting on you"), else the worktree churn, else what the agent is up to.
private static func detail(for s: WireSessionSummary) -> String {
if s.status == .awaitingApproval || s.pendingApprovalCount > 0 {
let n = max(s.pendingApprovalCount, 1)
return "\(n) to approve"
}
if s.status == .awaitingInput, s.disposition != .completed {
return "Waiting on you"
}
if let d = s.diffStat, d.filesChanged > 0 {
return "\(d.filesChanged) file\(d.filesChanged == 1 ? "" : "s") +\(d.added) −\(d.removed)"
}
switch s.status {
case .provisioning: return "Starting…"
case .running: return "Working…"
case .awaitingInput: return "Done"
default: return StatusStyle.label(s.status, disposition: s.disposition)
}
}
}