From a169f73749072dd9ee878cf2466be45c4d853306 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Mon, 18 May 2026 20:41:07 +0200 Subject: [PATCH] Auto-restart Live Activity when iOS sends .ended When iOS reaches the Live Activity lifetime cap before renewal fires it delivers .ended, not .dismissed. The state observer only ran restart logic on .dismissed, so handleForeground saw renewalFailed=false and renewBy still in the future and returned "no action needed", leaving the LA dark until manual force-restart. Mark laRenewalFailed=true on the .ended path (gated on wasCurrent and !endingForRestart) so the next foreground entry triggers performForegroundRestart, which sweeps the corpse activity and pushes a fresh one. --- .../LiveActivity/LiveActivityManager.swift | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 3a58d12e1..f1d275b73 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -1318,7 +1318,8 @@ final class LiveActivityManager { for await state in activity.activityStateUpdates { LogManager.shared.log(category: .general, message: "Live Activity state id=\(activity.id) -> \(state)", isDebug: true) if state == .ended || state == .dismissed { - if current?.id == activity.id { + let wasCurrentActivity = current?.id == activity.id + if wasCurrentActivity { current = nil // Do NOT clear laRenewBy here. Preserving it means handleForeground() // can detect the renewal window on the next foreground event and restart @@ -1330,6 +1331,20 @@ final class LiveActivityManager { // • the user disables LA or calls forceRestart LogManager.shared.log(category: .general, message: "[LA] activity cleared id=\(activity.id) state=\(state)", isDebug: true) } + if state == .ended, wasCurrentActivity, !endingForRestart { + // iOS terminated the activity itself — typically the ~8h lifetime + // cap reached before renewal fired. The .dismissed path below + // already handles iOS-initiated dismissals via renewalFailed / + // pastDeadline, but .ended bypasses that branch entirely. Without + // a signal here, handleForeground() sees `renewalFailed=false` and + // `renewBy` still in the future, returns "no action needed", and + // startIfNeeded keeps re-binding the corpse — the LA stays dark + // until the user manually force-restarts. Mark renewalFailed so + // the next foreground entry runs performForegroundRestart, which + // sweeps any leftover ended activity and pushes a fresh one. + Storage.shared.laRenewalFailed.value = true + LogManager.shared.log(category: .general, message: "[LA] ended by iOS (not our restart) — marked renewalFailed=true, auto-restart on next foreground") + } if state == .dismissed { // Three possible sources of .dismissed — only the third blocks restart: //