From bc5f6a388130df33f5b1660b11f01280ccd97cdd Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 24 Jul 2026 00:00:47 +0200 Subject: [PATCH] fix(apple/session): keep the display awake for the length of a session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stream is not user activity to the OS, and controller input does not feed the HID idle timer on any Apple platform — so a gamepad-only session reliably idles the panel out from under the user mid-play. A keyboard/mouse capture session masks the bug because those events are real local HID. The Android client has held FLAG_KEEP_SCREEN_ON while streaming all along; the Apple clients held nothing. DisplaySleepGuard is acquired in beginStreaming and released at the top of disconnect, so it is scoped to the session — every teardown path (user quit, sessionEnded, the backgrounded keep-alive timeout) funnels through disconnect. iOS/iPadOS/tvOS: UIApplication.isIdleTimerDisabled. App-wide and ignored while backgrounded, which is what the audio-only keep-alive wants. macOS: ProcessInfo.beginActivity(.userInitiated, .idleDisplaySleepDisabled), the Foundation wrapper over IOKit power assertions. That defers display sleep but NOT the screen saver, which runs off the HID idle timer independently — so a 30 s IOPMAssertionDeclareUserActivity heartbeat runs alongside it. Intended side effect: an idle-lock that follows the screen saver is deferred for the session too. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Session/DisplaySleepGuard.swift | 97 +++++++++++++++++++ .../Session/SessionModel.swift | 8 ++ 2 files changed, 105 insertions(+) create mode 100644 clients/apple/Sources/PunktfunkClient/Session/DisplaySleepGuard.swift diff --git a/clients/apple/Sources/PunktfunkClient/Session/DisplaySleepGuard.swift b/clients/apple/Sources/PunktfunkClient/Session/DisplaySleepGuard.swift new file mode 100644 index 00000000..a6c1b6fa --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Session/DisplaySleepGuard.swift @@ -0,0 +1,97 @@ +// Keeps the local display awake for the duration of a streaming session. +// +// A stream is not "user activity" to the OS: the pixels arrive over the network and the input that +// drives them is often a game controller, which does NOT feed the HID idle timer on any Apple +// platform. So a controller-only session reliably idles the panel out from under the user — the +// same reason the Android client holds FLAG_KEEP_SCREEN_ON while streaming (StreamScreen.kt). +// +// Held by SessionModel from `beginStreaming` to `disconnect`, so it is scoped to the session and +// never leaks past it (including a host-ended or timed-out background session, which both land in +// `disconnect`). + +import Foundation + +#if os(macOS) + import IOKit.pwr_mgt +#else + import UIKit +#endif + +@MainActor +final class DisplaySleepGuard { + #if os(macOS) + /// The `beginActivity` token; non-nil exactly while held. + private var activity: NSObjectProtocol? + /// Re-used across heartbeats so the whole session shares one assertion instead of + /// accumulating one per tick. + private var userActivityAssertion: IOPMAssertionID = IOPMAssertionID(0) + private var heartbeat: Timer? + + /// The power assertion defers DISPLAY SLEEP but not the screen saver — that runs off the + /// HID idle timer, which a controller-only session never touches. Declaring user activity + /// on an interval well under the shortest selectable screen-saver delay (1 minute) keeps + /// that timer from ever reaching it. Side effect, and the intended one: an idle-lock + /// configured to follow the screen saver is deferred too, for the session only. + private static let heartbeatInterval: TimeInterval = 30 + #endif + + private(set) var isHeld = false + + /// Idempotent — a second acquire while held is a no-op. + func acquire() { + guard !isHeld else { return } + isHeld = true + #if os(macOS) + // The high-level Foundation API over IOKit power assertions: `.idleDisplaySleepDisabled` + // is the panel, `.userInitiated` also holds off idle SYSTEM sleep and sudden termination + // for a session the user is watching in real time. + activity = ProcessInfo.processInfo.beginActivity( + options: [.userInitiated, .idleDisplaySleepDisabled], + reason: "Punktfunk streaming session") + declareUserActivity() + let timer = Timer.scheduledTimer(withTimeInterval: Self.heartbeatInterval, repeats: true) { + [weak self] _ in + MainActor.assumeIsolated { self?.declareUserActivity() } + } + // The stream runs under a tracking run-loop mode while a menu or a window resize is up; + // .common keeps the heartbeat ticking through those. + RunLoop.main.add(timer, forMode: .common) + heartbeat = timer + #else + // iOS/iPadOS/tvOS: app-wide, and ignored while backgrounded — the background keep-alive + // (audio-only, video dropped) correctly lets the device sleep without touching this. + UIApplication.shared.isIdleTimerDisabled = true + #endif + } + + /// Idempotent — safe to call when not held (`disconnect` runs on paths that never streamed). + func release() { + guard isHeld else { return } + isHeld = false + #if os(macOS) + heartbeat?.invalidate() + heartbeat = nil + if let activity { + ProcessInfo.processInfo.endActivity(activity) + self.activity = nil + } + if userActivityAssertion != IOPMAssertionID(0) { + IOPMAssertionRelease(userActivityAssertion) + userActivityAssertion = IOPMAssertionID(0) + } + #else + UIApplication.shared.isIdleTimerDisabled = false + #endif + } + + #if os(macOS) + /// Resets the HID idle timer (see `heartbeatInterval`). `kIOPMUserActiveLocal` = activity at + /// this Mac's own display, which is what a stream being watched here is. + private func declareUserActivity() { + IOPMAssertionDeclareUserActivity( + "Punktfunk streaming session" as CFString, + kIOPMUserActiveLocal, + &userActivityAssertion) + } + #endif +} diff --git a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift index 19a2ca7e..0f57fa0d 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift @@ -196,6 +196,11 @@ final class SessionModel: ObservableObject { /// Bounded auto-disconnect for a backgrounded keep-alive session. Fires on `.main`. private var backgroundTimer: DispatchSourceTimer? + /// Holds off display sleep (and, on macOS, the screen saver) for the life of a session — + /// nothing about watching a stream looks like user activity to the OS, least of all a + /// controller-only session. Acquired in `beginStreaming`, released in `disconnect`. + private let displaySleepGuard = DisplaySleepGuard() + /// `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` @@ -455,6 +460,8 @@ final class SessionModel: ObservableObject { func disconnect(deliberate: Bool = true) { statsTimer?.invalidate() statsTimer = nil + // No-op when this session never reached `.streaming` (a refused/aborted connect). + displaySleepGuard.release() // Drop any armed background keep-alive (incl. the timeout that just fired us). backgroundTimer?.cancel() backgroundTimer = nil @@ -550,6 +557,7 @@ final class SessionModel: ObservableObject { // Input capture itself is owned by StreamView (engaged by the captureEnabled // flip this phase change causes, released/re-engaged by the user from there). phase = .streaming + displaySleepGuard.acquire() // Audio starts with streaming, not during the trust prompt — no host sound (or // mic uplink!) before the user trusted the host. Devices come from Settings; // "" = system default.