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

345 lines
18 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
/// 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)
}
}
}