feat(apple/M2): opt-in background keep-alive (audio + video-drop + timeout)

Backgrounding a live session no longer freezes it when the user opts in: audio
keeps playing (UIBackgroundModes audio), the QUIC connection + pump stay live,
video decode is DROPPED, and a bounded timer auto-disconnects. Off by default.

- PunktfunkConnection.setVideoDropped/isVideoDropped: a tiny lock-guarded flag
  both pumps read every iteration. StreamPump (stage-1), Stage2Pipeline (VT +
  PyroWave) drain nextAU() for flow control but DISCARD the AU before any
  VideoToolbox/Metal work — the crash/jetsam-safe seam (no GPU off-screen).
- SessionModel.enterBackground(timeoutMinutes:) / exitBackground(): set the drop
  flag, mute the mic (privacy — SessionAudio.setMicMuted pauses the capture
  engine), arm a DispatchSourceTimer that disconnect(deliberate:false)s on fire
  (keeps host linger → fast late reconnect). exitBackground clears the flag and
  requestKeyframe()s; the pump's freeze gate auto-arms on the resumed
  frame-index gap so concealed frames are withheld until the IDR re-anchors.
  disconnect() cancels the timer + clears isBackgrounded.
- ContentView scenePhase driver (iOS): .background+streaming+setting →
  enterBackground; .active → exitBackground. scenePhase (not willResignActive)
  so Control-Center/app-switcher peeks don't start the timer.
- Settings → General (iOS-only keepAliveSection): toggle + 1/5/10/30 timeout;
  new keys backgroundKeepAlive (def off) / backgroundTimeoutMinutes (def 10).
- Info.plist: UIBackgroundModes [audio] + NSSupportsLiveActivities (for M3).

macOS swift build + swift test green (142 tests). The iOS-gated scenePhase
handler + settings section are not exercised by the macOS CI target (known §9
gap) — need on-glass verification (audio never gaps, video re-anchors <1s LAN,
timeout ends the session, phone-call audio-steal degrades gracefully).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-16 18:41:46 +02:00
parent 4cae1b8bb8
commit 14c5e7c11c
10 changed files with 187 additions and 0 deletions
@@ -148,6 +148,13 @@ final class SessionModel: ObservableObject {
var isBusy: Bool { phase != .idle }
/// True while a streaming session is running in the background under the opt-in keep-alive
/// (audio plays, video dropped, timeout armed). Drives the Live Activity's stage/countdown (M3)
/// and is cleared on foreground or teardown. iOS/iPadOS only in practice.
@Published private(set) var isBackgrounded = false
/// Bounded auto-disconnect for a backgrounded keep-alive session. Fires on `.main`.
private var backgroundTimer: DispatchSourceTimer?
/// `allowTofu` gates the trust-on-first-use prompt for an unpinned host: it is only true
/// when the host EXPLICITLY advertised `pair=optional` (rule 3a). For any other unpinned host
/// `pair=required`, a manually-typed host, or a discovered host with no/unknown `pair`
@@ -332,6 +339,46 @@ final class SessionModel: ObservableObject {
}
}
// MARK: - Background keep-alive (opt-in, iOS)
/// Enter the backgrounded keep-alive state: keep audio playing, DROP video decode (no GPU work
/// off-screen), mute the mic (privacy), and arm a bounded auto-disconnect. The caller
/// (ContentView's scenePhase driver) gates this on the setting + `.streaming`; a no-op otherwise.
/// The video-drop seam is read by both pumps every iteration (`connection.isVideoDropped`).
func enterBackground(timeoutMinutes: Int) {
guard phase == .streaming, let conn = connection, !isBackgrounded else { return }
isBackgrounded = true
conn.setVideoDropped(true)
audio?.setMicMuted(true)
// Non-deliberate on fire (keep the host linger) so a user who returns late reconnects fast,
// exactly like today's network-drop path. min 1 minute guards a nonsense setting.
let minutes = max(1, timeoutMinutes)
let timer = DispatchSource.makeTimerSource(queue: .main)
timer.schedule(deadline: .now() + .seconds(minutes * 60))
timer.setEventHandler { [weak self] in
// The timer fires on `.main`, so the actor's executor is the main thread here.
MainActor.assumeIsolated { self?.disconnect(deliberate: false) }
}
backgroundTimer?.cancel()
backgroundTimer = timer
timer.resume()
}
/// Return to foreground: cancel the timeout, resume mic + video, and force a clean re-anchor
/// request a fresh IDR (infinite GOP: it won't come on its own) and let the pump's freeze gate
/// withhold the concealed frames until it lands (it auto-arms on the resumed frame-index gap).
func exitBackground() {
guard isBackgrounded else { return }
isBackgrounded = false
backgroundTimer?.cancel()
backgroundTimer = nil
audio?.setMicMuted(false)
if let conn = connection {
conn.setVideoDropped(false)
conn.requestKeyframe()
}
}
/// The user confirmed the fingerprint: returns it for pinning and enters streaming.
func confirmTrust() -> Data? {
guard case .awaitingTrust(let fingerprint) = phase else { return nil }
@@ -349,6 +396,10 @@ final class SessionModel: ObservableObject {
func disconnect(deliberate: Bool = true) {
statsTimer?.invalidate()
statsTimer = nil
// Drop any armed background keep-alive (incl. the timeout that just fired us).
backgroundTimer?.cancel()
backgroundTimer = nil
isBackgrounded = false
let audio = self.audio
self.audio = nil
// Gamepad capture is main-actor (releases held buttons on the wire while the