Files
punktfunk/clients/apple/Sources/PunktfunkClient/Session/DisplaySleepGuard.swift
T
enricobuehlerandClaude Opus 4.8 bc5f6a3881
ci / web (push) Successful in 50s
apple / swift (push) Successful in 1m19s
ci / docs-site (push) Successful in 1m58s
decky / build-publish (push) Successful in 33s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 54s
deb / build-publish (push) Failing after 4m47s
ci / bench (push) Successful in 6m1s
docker / deploy-docs (push) Successful in 33s
release / apple (push) Successful in 10m4s
android / android (push) Successful in 13m5s
deb / build-publish-host (push) Successful in 12m52s
arch / build-publish (push) Successful in 16m56s
apple / screenshots (push) Successful in 6m33s
ci / rust (push) Successful in 19m36s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m27s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m15s
fix(apple/session): keep the display awake for the length of a session
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) <noreply@anthropic.com>
2026-07-24 00:00:55 +02:00

98 lines
4.3 KiB
Swift

// 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
}