diff --git a/clients/apple/Config/Info.plist b/clients/apple/Config/Info.plist index 5c135bc5..93224825 100644 --- a/clients/apple/Config/Info.plist +++ b/clients/apple/Config/Info.plist @@ -19,6 +19,19 @@ _punktfunk._udp + + UIBackgroundModes + + audio + + + NSSupportsLiveActivities + diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index 0cd4a21a..7d3029fb 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -96,6 +96,14 @@ struct ContentView: View { /// fires Wake-on-LAN up front and falls into the "Waking…" wait if the dial fails. Off: connects /// go straight through with no wake. The explicit "Wake Host" action is unaffected either way. @AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true + /// Background keep-alive (Settings → General, iOS-only). Default OFF (today's freeze-on-background + /// is the default). When on, backgrounding a live session keeps audio + the connection alive and + /// drops video, auto-disconnecting after `backgroundTimeoutMinutes`. + @AppStorage(DefaultsKey.backgroundKeepAlive) private var backgroundKeepAlive = false + @AppStorage(DefaultsKey.backgroundTimeoutMinutes) private var backgroundTimeoutMinutes = 10 + /// scenePhase drives the keep-alive: use THIS, not the willResignActive observers — resign-active + /// also fires for Control Center / app-switcher peeks, where the disconnect timer must not start. + @Environment(\.scenePhase) private var scenePhase private var gamepadUIActive: Bool { GamepadUIEnvironment.isActive( gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled) @@ -122,6 +130,22 @@ struct ContentView: View { // tap uses, so trust policy / WoL / the approval sheet all come along. Never starts a // parallel session — this drives the one `model` ContentView owns. .onOpenURL { handleDeepLink($0) } + #if os(iOS) + // Background keep-alive driver (opt-in). Only .background/.active matter; .inactive (a + // transient peek) is ignored so the disconnect timer never starts for a Control-Center pull. + .onChange(of: scenePhase) { _, phase in + switch phase { + case .background: + if backgroundKeepAlive, model.phase == .streaming { + model.enterBackground(timeoutMinutes: backgroundTimeoutMinutes) + } + case .active: + model.exitBackground() + default: + break + } + } + #endif .onChange(of: model.phase) { _, phase in switch phase { case .streaming: diff --git a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift index 79c5d56d..66c103e0 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift @@ -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 diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift index 0277579a..c8c8f715 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift @@ -440,6 +440,34 @@ extension SettingsView { } } + /// iOS/iPadOS only: keep a backgrounded session alive (audio background mode). Empty elsewhere + /// (tvOS backgrounding semantics differ; macOS isn't gated by the mode) so the shared `.general` + /// detail can reference it unconditionally. + @ViewBuilder var keepAliveSection: some View { + #if os(iOS) + Section { + Toggle("Keep streaming in background", isOn: $backgroundKeepAlive) + if backgroundKeepAlive { + Picker("Disconnect after", selection: $backgroundTimeoutMinutes) { + Text("1 minute").tag(1) + Text("5 minutes").tag(5) + Text("10 minutes").tag(10) + Text("30 minutes").tag(30) + } + } + } header: { + Text("Background") + } footer: { + Text("Off by default: backgrounding the app freezes the session. When on, audio keeps " + + "playing and the connection stays live (video is dropped to save power) after you " + + "switch away — and the session auto-disconnects after the time above so it can't " + + "run down your battery. Returning to the app resumes video instantly.") + .font(.geist(12, relativeTo: .caption)) + .foregroundStyle(.secondary) + } + #endif + } + @ViewBuilder var experimentalSection: some View { Section { Toggle("Show game library", isOn: $libraryEnabled) diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift index 86fcce93..975512e4 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift @@ -49,6 +49,8 @@ struct SettingsView: View { @ObservedObject var gamepads = GamepadManager.shared @AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true @AppStorage(DefaultsKey.autoWake) var autoWakeEnabled = true + @AppStorage(DefaultsKey.backgroundKeepAlive) var backgroundKeepAlive = false + @AppStorage(DefaultsKey.backgroundTimeoutMinutes) var backgroundTimeoutMinutes = 10 #if DEBUG && !os(tvOS) @State var showControllerTest = false #endif @@ -242,6 +244,7 @@ struct SettingsView: View { pointerSection compositorSection wakeSection + keepAliveSection // iOS-only content; empty on tvOS } .formStyle(.grouped) .navigationTitle("General") diff --git a/clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift b/clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift index bbdae072..18260734 100644 --- a/clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift +++ b/clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift @@ -180,6 +180,23 @@ public final class SessionAudio { } } + /// Background keep-alive: silence the mic uplink while backgrounded (privacy — no room audio + /// leaves the device) and restore it on return. Pauses/resumes the capture engine; a no-op when + /// there's no uplink (playback-only / tvOS / mic disabled). The audio SESSION stays active for + /// background playback, so iOS may keep showing the recording indicator until a full reconfigure + /// — this stops the actual capture, which is the privacy-relevant part. Main thread. + public func setMicMuted(_ muted: Bool) { + stateLock.lock() + let capture = captureEngine + stateLock.unlock() + guard let capture else { return } + if muted { + capture.pause() + } else if !flag.isStopped { + try? capture.start() + } + } + // MARK: - Playback (host → speaker) private func startPlayback(speakerUID: String) { diff --git a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift index 807204a2..e3adc481 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift @@ -534,6 +534,23 @@ public final class PunktfunkConnection { _ = punktfunk_connection_request_keyframe(h) } + /// Background-keep-alive video drop (opt-in). While true, both video pumps keep DRAINING + /// `nextAU()` (so QUIC flow control and host pacing stay healthy) but DISCARD each AU before any + /// VideoToolbox/Metal decode or render — the crash/jetsam-safe way to hold a backgrounded + /// session (audio keeps rendering; no GPU work off-screen). Set on `SessionModel.enterBackground`, + /// cleared on `exitBackground` (which then requests a fresh IDR; the pump's re-anchor gate + /// auto-arms on the resumed frame-index gap). Its own tiny lock — read on the pump thread every + /// iteration, written on the main actor; never contends the ABI/plane locks. + private let videoDropLock = NSLock() + private var videoDropped = false + public var isVideoDropped: Bool { + videoDropLock.lock(); defer { videoDropLock.unlock() } + return videoDropped + } + public func setVideoDropped(_ dropped: Bool) { + videoDropLock.lock(); videoDropped = dropped; videoDropLock.unlock() + } + /// Feed each received AU's `frameIndex` (in receive order) so the client recovers from loss with a /// cheap reference-frame invalidation instead of always paying for a full IDR. On a forward gap — /// a `frameIndex` jump means the intervening frames were lost and the following AUs reference a diff --git a/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift b/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift index 674e1aac..58180106 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift @@ -432,6 +432,15 @@ public final class Stage2Pipeline { while alive, !token.isStopped { alive = autoreleasepool { () -> Bool in do { + // Background keep-alive: drain one AU (flow control + host pacing) and discard it + // BEFORE any VideoToolbox decode or Metal render — no GPU work off-screen. The + // decoder session is left intact; exitBackground requests a fresh IDR and the + // re-anchor gate arms on the resumed frame-index gap so concealed frames are + // withheld until it lands. + if connection.isVideoDropped { + _ = try connection.nextAU(timeoutMs: 100) + return true + } // Loss recovery (the primary path). The reassembler drops unrecoverable AUs and the // decoder conceals the reference-missing deltas — often WITHOUT an error callback — // so key off the drop count climbing, then keep asking (awaitingIDR) until a fresh @@ -687,6 +696,13 @@ public final class Stage2Pipeline { while alive, !token.isStopped { alive = autoreleasepool { () -> Bool in do { + // Background keep-alive: drain + discard before the Metal wavelet decode + // (PyroWave is all-intra, so the resumed frame heals on its own — no IDR + // request needed, just no GPU work off-screen). + if connection.isVideoDropped { + _ = try connection.nextAU(timeoutMs: 100) + return true + } guard let au = try connection.nextAU(timeoutMs: 100) else { return true } onFrame?(au) if let newest = newestIndex, diff --git a/clients/apple/Sources/PunktfunkKit/Video/StreamPump.swift b/clients/apple/Sources/PunktfunkKit/Video/StreamPump.swift index bb4df5aa..e647f44f 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/StreamPump.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/StreamPump.swift @@ -63,6 +63,14 @@ final class StreamPump { while alive, !token.isStopped { alive = autoreleasepool { () -> Bool in do { + // Background keep-alive: drain one AU to keep QUIC flow control + host pacing + // healthy, then discard it BEFORE any decode/enqueue — no VideoToolbox/Metal work + // off-screen. Skips all recovery/gate bookkeeping too; exitBackground requests a + // fresh IDR and the re-anchor gate re-arms on the resumed frame-index gap. + if connection.isVideoDropped { + _ = try connection.nextAU(timeoutMs: 100) + return true + } // Loss recovery (the primary path). Under the host's infinite GOP the only // recovery keyframe is one we request. The reassembler drops unrecoverable AUs // (framesDropped); the decoder then *conceals* the reference-missing deltas — a diff --git a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift index 6278bb8c..76de54cd 100644 --- a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift +++ b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift @@ -112,6 +112,16 @@ public enum DefaultsKey { /// routed/VPN host), so connects go straight through instead of waiting out the wake timeout. /// The explicit "Wake Host" action stays available regardless. Read by ContentView.startSession. public static let autoWake = "punktfunk.autoWake" + /// iOS/iPadOS: keep a streaming session ALIVE when the app is backgrounded (audio background + /// mode). Off by default (today's freeze-on-background is the default). When on, backgrounding a + /// live session keeps audio playing and the QUIC/pump live while DROPPING video decode, and a + /// bounded timer (`backgroundTimeoutMinutes`) auto-disconnects if the user doesn't return. Read + /// by ContentView's scenePhase driver. Hidden on tvOS/macOS. + public static let backgroundKeepAlive = "punktfunk.backgroundKeepAlive" + /// iOS/iPadOS: minutes a backgrounded keep-alive session runs before auto-disconnecting (a + /// battery/thermal/bandwidth backstop). Default 10; the UI offers 1/5/10/30. The auto-disconnect + /// is non-deliberate (host linger kept), so a late return reconnects fast. Read on enterBackground. + public static let backgroundTimeoutMinutes = "punktfunk.backgroundTimeoutMinutes" } extension Notification.Name {