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

428 lines
23 KiB
Swift
Raw Normal View History

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
2026-07-06 12:45:08 -07:00
private var activity: Activity<NucleicSessionAttributes>?
2026-07-06 21:32:28 -07:00
/// 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() }
}
}
2026-07-06 21:32:28 -07:00
/// 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?
2026-07-06 13:03:11 -07:00
/// 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?
2026-07-06 21:33:11 -07:00
/// 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)?
2026-07-06 13:43:48 -07:00
/// 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>?
2026-07-06 13:43:48 -07:00
/// 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 {
2026-07-06 12:45:18 -07:00
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.
2026-07-06 12:45:31 -07:00
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 }
2026-07-06 12:45:31 -07:00
// 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 }
2026-07-06 12:45:31 -07:00
// 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.
2026-07-06 12:45:31 -07:00
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
2026-07-06 12:45:31 -07:00
}
2026-07-06 12:55:21 -07:00
/// 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) {
2026-07-06 19:45:36 -07:00
guard activity != nil else {
2026-07-06 19:44:00 -07:00
// 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)
2026-07-06 12:40:19 -07:00
// 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)
2026-07-06 19:44:00 -07:00
lastState = state
lastPushAt = Date()
enqueue(state)
2026-07-06 19:44:00 -07:00
} 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.
2026-07-06 19:44:00 -07:00
attributes: NucleicSessionAttributes(hostName: hostName),
content: ActivityContent(state: state, staleDate: nil),
pushType: .token) {
activity = started
2026-07-06 19:44:00 -07:00
observePushToken(started)
lastState = state
lastPushAt = Date()
}
return
}
2026-07-06 19:44:00 -07:00
// 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 }
2026-07-06 21:33:26 -07:00
// 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)
2026-07-06 19:44:00 -07:00
lastState = state
lastPushAt = Date()
2026-07-06 21:33:26 -07:00
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
}
2026-07-06 13:44:01 -07:00
/// 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 }
2026-07-06 14:04:38 -07:00
// 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)
}
2026-07-06 13:44:01 -07:00
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).
2026-07-06 21:33:38 -07:00
private func enqueue(
_ state: NucleicSessionAttributes.ContentState, alert: AlertConfiguration? = nil
) {
pendingState = state
2026-07-06 21:33:38 -07:00
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
2026-07-06 21:33:38 -07:00
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
2026-07-06 21:33:51 -07:00
pendingAlert = nil
lastState = nil
2026-07-06 13:03:23 -07:00
lastPushAt = nil
2026-07-06 12:40:34 -07:00
let tracked = activity
self.activity = nil
2026-07-06 12:40:34 -07:00
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 {
2026-07-06 12:40:34 -07:00
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 {
2026-07-06 22:08:41 -07:00
// 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
2026-07-06 21:32:13 -07:00
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),
2026-07-06 21:32:13 -07:00
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
2026-07-09 21:51:06 -07:00
// ACP wrapper agents share the generic "Agent" tag/tint in the Live Activity for now.
2026-07-10 03:46:31 -07:00
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)
}
}
}