From 0a236f447b3e13750c64d730027e5418a56552cb Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 19:44:00 -0700 Subject: [PATCH] Live Activity Launch Consistency Nucleic-Session: 9D2EE9AB-2E44-485B-A196-54D250F9D89B Co-authored-by: Nucleic --- .../NucleicRemote/LiveActivityManager.swift | 51 +++++++++++-------- 1 file changed, 30 insertions(+), 21 deletions(-) diff --git a/NucleicRemote/NucleicRemote/LiveActivityManager.swift b/NucleicRemote/NucleicRemote/LiveActivityManager.swift index 73dddd9..61486bb 100644 --- a/NucleicRemote/NucleicRemote/LiveActivityManager.swift +++ b/NucleicRemote/NucleicRemote/LiveActivityManager.swift @@ -148,22 +148,15 @@ final class LiveActivityManager { /// 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. + // 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.activities.first { activity = existing observePushToken(existing) @@ -173,19 +166,35 @@ final class LiveActivityManager { // 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 { + } 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. - let started = try? Activity.request( - attributes: NucleicSessionAttributes(hostName: hostName), - content: ActivityContent(state: state, staleDate: nil), - pushType: .token) + attributes: NucleicSessionAttributes(hostName: hostName), + content: ActivityContent(state: state, staleDate: nil), + pushType: .token) { activity = started - if let started { observePushToken(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 } + lastState = state + lastPushAt = Date() enqueue(state) }