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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user