428 lines
23 KiB
Swift
428 lines
23 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() {}
|
||
|
||
/// 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
|
||
// ACP wrapper agents share the generic "Agent" tag/tint in the Live Activity for now.
|
||
case .opencode, .openclaw, .hermes, .cursorAgent, .acp: .other
|
||
}
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
}
|
||
}
|