345 lines
18 KiB
Swift
345 lines
18 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
|
||
|
||
/// How long the terminal "all clear / Done" glance lingers before the Activity dismisses. Long
|
||
/// enough to read at a glance, short enough not to loiter on the lock screen.
|
||
private static let doneLinger: Duration = .seconds(4)
|
||
/// The delayed dismissal after the Done glance is shown, cancelled if work resumes first — so a
|
||
/// completed run flashes "Done" and then clears instead of vanishing the instant it finishes.
|
||
private var dismissTask: Task<Void, Never>?
|
||
|
||
private var activity: Activity<NucleicSessionAttributes>?
|
||
|
||
/// 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?
|
||
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 — abort any pending "Done" dismissal so the glance doesn't clear
|
||
// out from under a run that just resumed (or a new one that just started).
|
||
dismissTask?.cancel()
|
||
dismissTask = nil
|
||
|
||
// 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, flash a brief "Done"
|
||
/// summary of the just-completed sessions and then dismiss (UX_IOS §5.3 — a run should read as
|
||
/// *finished*, not just vanish). If nothing was on screen, there's nothing to close.
|
||
private func finishWithDoneGlance(hostName: String, live: [WireSessionSummary]) {
|
||
// Already flashing "Done" and scheduled to dismiss — let that run rather than restart it.
|
||
guard dismissTask == nil 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
|
||
}
|
||
// 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 celebrate (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)
|
||
dismissTask = Task { [weak self] in
|
||
try? await Task.sleep(for: Self.doneLinger)
|
||
guard !Task.isCancelled else { return }
|
||
self?.end()
|
||
}
|
||
}
|
||
|
||
/// 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) {
|
||
// Apply immediately when the attention signature shifts (start/finish/approval/input — the
|
||
// alert-worthy changes). Otherwise the only difference is mid-turn churn (diff totals); let
|
||
// that refresh on a slow cadence so it stays roughly current without spending ActivityKit's
|
||
// budget on every 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 }
|
||
lastState = state
|
||
lastPushAt = Date()
|
||
|
||
guard let activity else {
|
||
// Recover an Activity that survived an app relaunch before starting a new one; the
|
||
// recovered one still needs the fresh state, so fall through to the update pipeline.
|
||
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)
|
||
enqueue(state)
|
||
} else {
|
||
// `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.
|
||
let started = try? Activity.request(
|
||
attributes: NucleicSessionAttributes(hostName: hostName),
|
||
content: ActivityContent(state: state, staleDate: nil),
|
||
pushType: .token)
|
||
activity = started
|
||
if let started { observePushToken(started) }
|
||
}
|
||
return
|
||
}
|
||
enqueue(state)
|
||
}
|
||
|
||
/// 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 }
|
||
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) {
|
||
pendingState = state
|
||
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
|
||
await self.activity?.update(ActivityContent(state: next, staleDate: nil))
|
||
}
|
||
self.updateTask = nil
|
||
}
|
||
}
|
||
|
||
/// End the Activity (all idle, or unpaired).
|
||
func end() {
|
||
updateTask?.cancel()
|
||
updateTask = nil
|
||
tokenObservation?.cancel()
|
||
tokenObservation = nil
|
||
dismissTask?.cancel()
|
||
dismissTask = nil
|
||
pendingState = 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 {
|
||
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))
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
}
|