Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3e21d9227 | ||
|
|
e35dad529b | ||
|
|
9b241d9d7b | ||
|
|
ebddffa4c8 | ||
|
|
cc8cccbee6 | ||
|
|
ac3dc4323f | ||
|
|
41fa25c440 | ||
|
|
3a33a69401 | ||
|
|
24a24734eb | ||
|
|
b5fa878bc6 | ||
|
|
4b2d2d1e14 | ||
|
|
bc5f6a3881 | ||
|
|
6617275387 | ||
|
|
bda015b101 |
Generated
+27
-27
@@ -2194,7 +2194,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "latency-probe"
|
name = "latency-probe"
|
||||||
version = "0.19.0"
|
version = "0.19.1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
@@ -2299,7 +2299,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libvpl-sys"
|
name = "libvpl-sys"
|
||||||
version = "0.19.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bindgen",
|
"bindgen",
|
||||||
"cmake",
|
"cmake",
|
||||||
@@ -2334,7 +2334,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "loss-harness"
|
name = "loss-harness"
|
||||||
version = "0.19.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
]
|
]
|
||||||
@@ -2823,7 +2823,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-capture"
|
name = "pf-capture"
|
||||||
version = "0.19.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ashpd",
|
"ashpd",
|
||||||
@@ -2844,7 +2844,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-client-core"
|
name = "pf-client-core"
|
||||||
version = "0.19.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -2868,7 +2868,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-clipboard"
|
name = "pf-clipboard"
|
||||||
version = "0.19.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ashpd",
|
"ashpd",
|
||||||
@@ -2886,7 +2886,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-console-ui"
|
name = "pf-console-ui"
|
||||||
version = "0.19.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -2907,7 +2907,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-encode"
|
name = "pf-encode"
|
||||||
version = "0.19.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -2931,7 +2931,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-ffvk"
|
name = "pf-ffvk"
|
||||||
version = "0.19.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ash",
|
"ash",
|
||||||
"bindgen",
|
"bindgen",
|
||||||
@@ -2940,7 +2940,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-frame"
|
name = "pf-frame"
|
||||||
version = "0.19.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -2952,7 +2952,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-gpu"
|
name = "pf-gpu"
|
||||||
version = "0.19.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"pf-host-config",
|
"pf-host-config",
|
||||||
@@ -2966,11 +2966,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-host-config"
|
name = "pf-host-config"
|
||||||
version = "0.19.0"
|
version = "0.19.1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-inject"
|
name = "pf-inject"
|
||||||
version = "0.19.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ashpd",
|
"ashpd",
|
||||||
@@ -2998,14 +2998,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-paths"
|
name = "pf-paths"
|
||||||
version = "0.19.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-presenter"
|
name = "pf-presenter"
|
||||||
version = "0.19.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -3020,7 +3020,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-vdisplay"
|
name = "pf-vdisplay"
|
||||||
version = "0.19.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ashpd",
|
"ashpd",
|
||||||
@@ -3050,7 +3050,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-win-display"
|
name = "pf-win-display"
|
||||||
version = "0.19.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"pf-paths",
|
"pf-paths",
|
||||||
@@ -3062,7 +3062,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pf-zerocopy"
|
name = "pf-zerocopy"
|
||||||
version = "0.19.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ash",
|
"ash",
|
||||||
@@ -3269,7 +3269,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-android"
|
name = "punktfunk-client-android"
|
||||||
version = "0.19.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"jni",
|
"jni",
|
||||||
@@ -3285,7 +3285,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-linux"
|
name = "punktfunk-client-linux"
|
||||||
version = "0.19.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -3301,7 +3301,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-session"
|
name = "punktfunk-client-session"
|
||||||
version = "0.19.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"pf-client-core",
|
"pf-client-core",
|
||||||
@@ -3316,7 +3316,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-client-windows"
|
name = "punktfunk-client-windows"
|
||||||
version = "0.19.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-channel",
|
"async-channel",
|
||||||
"ffmpeg-next",
|
"ffmpeg-next",
|
||||||
@@ -3335,7 +3335,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-core"
|
name = "punktfunk-core"
|
||||||
version = "0.19.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -3367,7 +3367,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-host"
|
name = "punktfunk-host"
|
||||||
version = "0.19.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
@@ -3451,7 +3451,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-probe"
|
name = "punktfunk-probe"
|
||||||
version = "0.19.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
@@ -3465,7 +3465,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "punktfunk-tray"
|
name = "punktfunk-tray"
|
||||||
version = "0.19.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"ksni",
|
"ksni",
|
||||||
@@ -3488,7 +3488,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyrowave-sys"
|
name = "pyrowave-sys"
|
||||||
version = "0.19.0"
|
version = "0.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bindgen",
|
"bindgen",
|
||||||
"cmake",
|
"cmake",
|
||||||
|
|||||||
+1
-1
@@ -48,7 +48,7 @@ exclude = [
|
|||||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.19.0"
|
version = "0.19.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.82"
|
rust-version = "1.82"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ import androidx.core.content.ContextCompat
|
|||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.core.view.WindowInsetsControllerCompat
|
import androidx.core.view.WindowInsetsControllerCompat
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import io.unom.punktfunk.kit.GamepadFeedback
|
import io.unom.punktfunk.kit.GamepadFeedback
|
||||||
import io.unom.punktfunk.kit.GamepadRouter
|
import io.unom.punktfunk.kit.GamepadRouter
|
||||||
import io.unom.punktfunk.kit.deviceBodyVibrator
|
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||||
@@ -384,6 +387,26 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
// Back gesture = a deliberate exit → signal the quit so the host tears down now (no linger).
|
// Back gesture = a deliberate exit → signal the quit so the host tears down now (no linger).
|
||||||
BackHandler { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
|
BackHandler { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
|
||||||
|
|
||||||
|
// Leaving the app (Home, task switch, screen off) MUST end the session. Android does not
|
||||||
|
// suspend a process for going to background, so without this the native worker kept running and
|
||||||
|
// its QUIC connection kept answering the host's keep-alives — the user was long gone but the
|
||||||
|
// host still saw a live client and held the session (and its display + encoder) open until the
|
||||||
|
// OS eventually reclaimed the process, which on a TV box is effectively never.
|
||||||
|
//
|
||||||
|
// Route it through `onDisconnect()` so the composable's `onDispose` above runs the one real
|
||||||
|
// teardown path. Deliberately NOT a `nativeDisconnectQuit`: backgrounding isn't a user "quit",
|
||||||
|
// so the host should linger the display and make coming straight back a fast reconnect.
|
||||||
|
DisposableEffect(handle) {
|
||||||
|
val lifecycle = (context as? LifecycleOwner)?.lifecycle
|
||||||
|
val obs = LifecycleEventObserver { _, event ->
|
||||||
|
if (event == Lifecycle.Event.ON_STOP) {
|
||||||
|
onDisconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lifecycle?.addObserver(obs)
|
||||||
|
onDispose { lifecycle?.removeObserver(obs) }
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-engage pointer capture at stream start (setting on + a mouse actually present).
|
// Auto-engage pointer capture at stream start (setting on + a mouse actually present).
|
||||||
// Delayed a beat: the grab needs window focus and the capture view attached.
|
// Delayed a beat: the grab needs window focus and the capture view attached.
|
||||||
LaunchedEffect(handle) {
|
LaunchedEffect(handle) {
|
||||||
|
|||||||
@@ -144,14 +144,26 @@ struct ContentView: View {
|
|||||||
// tap uses, so trust policy / WoL / the approval sheet all come along. Never starts a
|
// tap uses, so trust policy / WoL / the approval sheet all come along. Never starts a
|
||||||
// parallel session — this drives the one `model` ContentView owns.
|
// parallel session — this drives the one `model` ContentView owns.
|
||||||
.onOpenURL { handleDeepLink($0) }
|
.onOpenURL { handleDeepLink($0) }
|
||||||
#if os(iOS)
|
#if os(iOS) || os(tvOS)
|
||||||
// Background keep-alive driver (opt-in). Only .background/.active matter; .inactive (a
|
// Backgrounding driver. Only .background/.active matter; .inactive (a transient peek) is
|
||||||
// transient peek) is ignored so the disconnect timer never starts for a Control-Center pull.
|
// ignored so neither branch fires for a Control-Center pull.
|
||||||
|
//
|
||||||
|
// Backgrounding MUST end the session one way or the other: the app keeps running while
|
||||||
|
// streaming (the `audio` background mode plus a live audio session), so its QUIC connection
|
||||||
|
// keeps answering the host's keep-alives with the user long gone — the host has no way to
|
||||||
|
// tell that apart from someone watching, and the session survived indefinitely. Either hold
|
||||||
|
// it under the opt-in keep-alive (bounded by that path's own auto-disconnect timer) or end
|
||||||
|
// it here.
|
||||||
.onChange(of: scenePhase) { _, phase in
|
.onChange(of: scenePhase) { _, phase in
|
||||||
switch phase {
|
switch phase {
|
||||||
case .background:
|
case .background:
|
||||||
if backgroundKeepAlive, model.phase == .streaming {
|
guard model.phase == .streaming else { break }
|
||||||
|
if backgroundKeepAlive {
|
||||||
model.enterBackground(timeoutMinutes: backgroundTimeoutMinutes)
|
model.enterBackground(timeoutMinutes: backgroundTimeoutMinutes)
|
||||||
|
} else {
|
||||||
|
// Not deliberate: the user may come straight back, so let the host linger the
|
||||||
|
// display for a fast reconnect instead of tearing it down.
|
||||||
|
model.disconnect(deliberate: false)
|
||||||
}
|
}
|
||||||
case .active:
|
case .active:
|
||||||
model.exitBackground()
|
model.exitBackground()
|
||||||
@@ -159,7 +171,11 @@ struct ContentView: View {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Live Activity lifecycle, driven from the model's published state.
|
#endif
|
||||||
|
#if os(iOS)
|
||||||
|
// Live Activity lifecycle, driven from the model's published state. iPhone/iPad only —
|
||||||
|
// ActivityKit (and so `liveActivity`) does not exist on tvOS, which is why this stays in its
|
||||||
|
// own os(iOS) block rather than riding the backgrounding driver's.
|
||||||
.onChange(of: model.phase) { _, phase in
|
.onChange(of: model.phase) { _, phase in
|
||||||
switch phase {
|
switch phase {
|
||||||
case .streaming:
|
case .streaming:
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -196,6 +196,11 @@ final class SessionModel: ObservableObject {
|
|||||||
/// Bounded auto-disconnect for a backgrounded keep-alive session. Fires on `.main`.
|
/// Bounded auto-disconnect for a backgrounded keep-alive session. Fires on `.main`.
|
||||||
private var backgroundTimer: DispatchSourceTimer?
|
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
|
/// `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
|
/// 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`
|
/// — `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) {
|
func disconnect(deliberate: Bool = true) {
|
||||||
statsTimer?.invalidate()
|
statsTimer?.invalidate()
|
||||||
statsTimer = nil
|
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).
|
// Drop any armed background keep-alive (incl. the timeout that just fired us).
|
||||||
backgroundTimer?.cancel()
|
backgroundTimer?.cancel()
|
||||||
backgroundTimer = nil
|
backgroundTimer = nil
|
||||||
@@ -550,6 +557,7 @@ final class SessionModel: ObservableObject {
|
|||||||
// Input capture itself is owned by StreamView (engaged by the captureEnabled
|
// Input capture itself is owned by StreamView (engaged by the captureEnabled
|
||||||
// flip this phase change causes, released/re-engaged by the user from there).
|
// flip this phase change causes, released/re-engaged by the user from there).
|
||||||
phase = .streaming
|
phase = .streaming
|
||||||
|
displaySleepGuard.acquire()
|
||||||
// Audio starts with streaming, not during the trust prompt — no host sound (or
|
// 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;
|
// mic uplink!) before the user trusted the host. Devices come from Settings;
|
||||||
// "" = system default.
|
// "" = system default.
|
||||||
|
|||||||
@@ -10,20 +10,31 @@
|
|||||||
//!
|
//!
|
||||||
//! **Multiple Xwaylands.** gamescope runs one Xwayland per `--xwayland-count` (Steam Gaming Mode
|
//! **Multiple Xwaylands.** gamescope runs one Xwayland per `--xwayland-count` (Steam Gaming Mode
|
||||||
//! uses 2: one for Big Picture, one for the game). The pointer lives on whichever is FOCUSED — an
|
//! uses 2: one for Big Picture, one for the game). The pointer lives on whichever is FOCUSED — an
|
||||||
//! inactive display's pointer is frozen. So the source connects to ALL of them and each tick
|
//! inactive display's pointer is frozen. So the source connects to ALL of them and publishes from
|
||||||
//! follows the one whose pointer actually moved (gamescope routes input to the focused surface, so
|
//! the one gamescope is actually drawing the pointer on; it reads that display's shape too, since
|
||||||
//! exactly one moves at a time). It reads that display's shape too, since each Xwayland has its own
|
//! each Xwayland has its own current cursor. This is why a single-display read froze the pointer
|
||||||
//! current cursor. This is why a single-display read froze the pointer the moment a game on the
|
//! the moment a game on the OTHER Xwayland took focus.
|
||||||
//! OTHER Xwayland took focus.
|
|
||||||
//!
|
//!
|
||||||
//! Two X sources per display, split by cost (Sunshine's split):
|
//! Three X sources per display, split by cost (Sunshine's split, plus gamescope's own verdict):
|
||||||
//! * **Position** — core `QueryPointer` on the root, polled fast. Cheap (a few-byte reply, no
|
//! * **Position** — core `QueryPointer` on the root, polled fast. Cheap (a few-byte reply, no
|
||||||
//! bitmap), so it can out-pace the stream fps and keep the composited pointer smooth. It also
|
//! bitmap), so it can out-pace the stream fps and keep the composited pointer smooth.
|
||||||
//! doubles as the focus signal (the display whose pointer moves is the active one).
|
//! * **Shape / hotspot** — `XFixesGetCursorImage`, refreshed only after an XFixes `CursorNotify`
|
||||||
//! * **Shape / hotspot / visibility** — `XFixesGetCursorImage`, refreshed only after an XFixes
|
//! (a real cursor change). A fully-transparent image reads as hidden.
|
||||||
//! `CursorNotify` (a real cursor change). A game hiding the pointer IS a cursor change → the
|
//! * **Visibility + focus** — [`GAMESCOPE_CURSOR_VISIBLE_FEEDBACK`](GS_CURSOR_FEEDBACK) on the
|
||||||
//! image comes back fully transparent → `visible: false`, which the encode loop strips before
|
//! root, read at connect and re-read on its `PropertyNotify`.
|
||||||
//! any blend path draws it (so a grabbed pointer shows nothing, matching native gamescope).
|
//!
|
||||||
|
//! **Why the feedback atom and not pointer motion.** gamescope hides its pointer by WARPING the X
|
||||||
|
//! pointer to the root's bottom-right corner pixel — it does NOT swap in a transparent X cursor, so
|
||||||
|
//! `XFixesGetCursorImage` keeps handing back the last opaque arrow. The original "follow whichever
|
||||||
|
//! display's pointer moved" heuristic therefore stuck to the parked display (a parked pointer never
|
||||||
|
//! moves again) and composited that arrow at `(w-1, h-1)`: a sliver of cursor welded to the corner
|
||||||
|
//! of the stream for the rest of the session, while the real pointer went undrawn. Reported
|
||||||
|
//! on-glass as "part of cursor shows up on bottom right … isn't where it really is", in every game
|
||||||
|
//! (a game grabs the pointer, so the hide is permanent). Measured on a live 1920x1080 Gaming Mode
|
||||||
|
//! session: real motion ⇒ pointer live + feedback `1`; 3 s idle (`--hide-cursor-delay 3000`) ⇒
|
||||||
|
//! pointer `(1919, 1079)` + feedback `0`. The atom answers BOTH questions correctly for a static
|
||||||
|
//! pointer, which motion cannot: which Xwayland owns it, and whether to draw it at all — so
|
||||||
|
//! honouring it also gives the stream gamescope's own idle auto-hide, which this source never had.
|
||||||
|
|
||||||
use std::sync::{
|
use std::sync::{
|
||||||
atomic::{AtomicBool, Ordering},
|
atomic::{AtomicBool, Ordering},
|
||||||
@@ -35,7 +46,11 @@ use pf_frame::CursorOverlay;
|
|||||||
use x11rb::connection::Connection;
|
use x11rb::connection::Connection;
|
||||||
use x11rb::errors::ReplyError;
|
use x11rb::errors::ReplyError;
|
||||||
use x11rb::protocol::xfixes::{self, ConnectionExt as _, GetCursorImageReply};
|
use x11rb::protocol::xfixes::{self, ConnectionExt as _, GetCursorImageReply};
|
||||||
use x11rb::protocol::xproto::{ConnectionExt as _, QueryPointerReply, Window};
|
use x11rb::protocol::xproto::{
|
||||||
|
Atom, AtomEnum, ChangeWindowAttributesAux, ConnectionExt as _, EventMask, QueryPointerReply,
|
||||||
|
Window,
|
||||||
|
};
|
||||||
|
use x11rb::protocol::Event;
|
||||||
use x11rb::rust_connection::RustConnection;
|
use x11rb::rust_connection::RustConnection;
|
||||||
|
|
||||||
/// Serializes the brief `XAUTHORITY` env swap around a connect (the var is process-global). Only
|
/// Serializes the brief `XAUTHORITY` env swap around a connect (the var is process-global). Only
|
||||||
@@ -47,6 +62,17 @@ static XAUTH_LOCK: Mutex<()> = Mutex::new(());
|
|||||||
/// and must out-run a 240 fps session or the pointer stutters.
|
/// and must out-run a 240 fps session or the pointer stutters.
|
||||||
const POLL: Duration = Duration::from_millis(4);
|
const POLL: Duration = Duration::from_millis(4);
|
||||||
|
|
||||||
|
/// gamescope's own pointer verdict, published on EVERY nested Xwayland's root: `1` on the server
|
||||||
|
/// whose pointer gamescope is currently drawing, `0` on the others — and `0` on all of them once
|
||||||
|
/// the pointer is hidden (a game grabbed it, or `--hide-cursor-delay` fired). See the module docs
|
||||||
|
/// for why this, and not pointer motion, is the signal this source follows.
|
||||||
|
const GS_CURSOR_FEEDBACK: &str = "GAMESCOPE_CURSOR_VISIBLE_FEEDBACK";
|
||||||
|
|
||||||
|
/// Self-heal cadence for the feedback re-read: `PropertyNotify` drives it, this only covers a
|
||||||
|
/// missed/coalesced event (and a gamescope that publishes the atom after we connected). One
|
||||||
|
/// `GetProperty` per display per interval is nothing next to the 250 Hz pointer poll.
|
||||||
|
const FEEDBACK_RESYNC: Duration = Duration::from_millis(250);
|
||||||
|
|
||||||
/// A running XFixes cursor reader. Dropping it stops the worker thread and joins it, releasing the
|
/// A running XFixes cursor reader. Dropping it stops the worker thread and joins it, releasing the
|
||||||
/// X connections — so it lives exactly as long as the capturer that owns it.
|
/// X connections — so it lives exactly as long as the capturer that owns it.
|
||||||
pub(super) struct XFixesCursorSource {
|
pub(super) struct XFixesCursorSource {
|
||||||
@@ -68,7 +94,9 @@ impl XFixesCursorSource {
|
|||||||
let mut displays = Vec::new();
|
let mut displays = Vec::new();
|
||||||
for (dpy, xauth) in targets {
|
for (dpy, xauth) in targets {
|
||||||
match connect(&dpy, xauth.as_deref()) {
|
match connect(&dpy, xauth.as_deref()) {
|
||||||
Ok((conn, root)) => displays.push(XDisplay::new(dpy, conn, root)),
|
Ok((conn, root, feedback)) => {
|
||||||
|
displays.push(XDisplay::new(dpy, conn, root, feedback))
|
||||||
|
}
|
||||||
Err(e) => tracing::warn!(
|
Err(e) => tracing::warn!(
|
||||||
dpy = %dpy,
|
dpy = %dpy,
|
||||||
error = %e,
|
error = %e,
|
||||||
@@ -84,9 +112,13 @@ impl XFixesCursorSource {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let names: Vec<&str> = displays.iter().map(|d| d.name.as_str()).collect();
|
let names: Vec<&str> = displays.iter().map(|d| d.name.as_str()).collect();
|
||||||
|
let feedback = displays.iter().any(|d| d.gs_visible.is_some());
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
displays = ?names,
|
displays = ?names,
|
||||||
"gamescope cursor: XFixes source live — following the focused Xwayland's pointer"
|
cursor_feedback = feedback,
|
||||||
|
"gamescope cursor: XFixes source live — following the Xwayland gamescope draws the \
|
||||||
|
pointer on (cursor_feedback=false ⇒ this gamescope publishes no \
|
||||||
|
GAMESCOPE_CURSOR_VISIBLE_FEEDBACK, degrading to the pointer-motion heuristic)"
|
||||||
);
|
);
|
||||||
|
|
||||||
let stop = Arc::new(AtomicBool::new(false));
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
@@ -111,10 +143,15 @@ impl Drop for XFixesCursorSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open the X connection, negotiate XFixes, and select cursor-change events — returning the
|
/// Open the X connection, negotiate XFixes, and select cursor-change + root-property events —
|
||||||
/// connection + root window. `RustConnection` reads `XAUTHORITY` from the env at connect time only,
|
/// returning the connection, root window and this display's initial
|
||||||
/// so set it under the lock (the host isn't a gamescope child), connect, then restore.
|
/// [`GAMESCOPE_CURSOR_FEEDBACK`](GS_CURSOR_FEEDBACK) reading (`(atom, value)`; the value is `None`
|
||||||
fn connect(dpy: &str, xauthority: Option<&str>) -> Result<(RustConnection, Window), String> {
|
/// when gamescope publishes no such property here). `RustConnection` reads `XAUTHORITY` from the
|
||||||
|
/// env at connect time only, so set it under the lock (the host isn't a gamescope child), connect,
|
||||||
|
/// then restore.
|
||||||
|
type Connected = (RustConnection, Window, (Atom, Option<bool>));
|
||||||
|
|
||||||
|
fn connect(dpy: &str, xauthority: Option<&str>) -> Result<Connected, String> {
|
||||||
let (conn, screen_num) = {
|
let (conn, screen_num) = {
|
||||||
let _g = XAUTH_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
let _g = XAUTH_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
let prev = std::env::var_os("XAUTHORITY");
|
let prev = std::env::var_os("XAUTHORITY");
|
||||||
@@ -148,8 +185,41 @@ fn connect(dpy: &str, xauthority: Option<&str>) -> Result<(RustConnection, Windo
|
|||||||
.map_err(ReplyError::from)
|
.map_err(ReplyError::from)
|
||||||
.and_then(|c| c.check())
|
.and_then(|c| c.check())
|
||||||
.map_err(|e| format!("SelectCursorInput: {e}"))?;
|
.map_err(|e| format!("SelectCursorInput: {e}"))?;
|
||||||
|
|
||||||
|
// …and whenever gamescope republishes its cursor verdict. Interned with `only_if_exists=false`
|
||||||
|
// so we hold a matchable atom id even on a gamescope that sets the property later; a failure to
|
||||||
|
// select PROPERTY_CHANGE is NOT fatal — the resync re-read still tracks it, just at 250 ms.
|
||||||
|
let feedback_atom = conn
|
||||||
|
.intern_atom(false, GS_CURSOR_FEEDBACK.as_bytes())
|
||||||
|
.map_err(ReplyError::from)
|
||||||
|
.and_then(|c| c.reply())
|
||||||
|
.map(|r| r.atom)
|
||||||
|
.unwrap_or(0);
|
||||||
|
if let Ok(c) = conn.change_window_attributes(
|
||||||
|
root,
|
||||||
|
&ChangeWindowAttributesAux::new().event_mask(EventMask::PROPERTY_CHANGE),
|
||||||
|
) {
|
||||||
|
let _ = c.check();
|
||||||
|
}
|
||||||
let _ = conn.flush();
|
let _ = conn.flush();
|
||||||
Ok((conn, root))
|
let feedback = read_cursor_feedback(&conn, root, feedback_atom);
|
||||||
|
Ok((conn, root, (feedback_atom, feedback)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read `GAMESCOPE_CURSOR_VISIBLE_FEEDBACK` off `root`. `None` = the property is absent or
|
||||||
|
/// unreadable (not a gamescope that publishes it) — the caller then falls back to the pointer-motion
|
||||||
|
/// heuristic rather than blanking the cursor, so an older gamescope keeps today's behaviour.
|
||||||
|
fn read_cursor_feedback(conn: &RustConnection, root: Window, atom: Atom) -> Option<bool> {
|
||||||
|
if atom == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let reply = conn
|
||||||
|
.get_property(false, root, atom, AtomEnum::CARDINAL, 0, 1)
|
||||||
|
.ok()?
|
||||||
|
.reply()
|
||||||
|
.ok()?;
|
||||||
|
let value = reply.value32()?.next()?;
|
||||||
|
Some(value != 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One gamescope Xwayland the source tracks.
|
/// One gamescope Xwayland the source tracks.
|
||||||
@@ -163,12 +233,22 @@ struct XDisplay {
|
|||||||
shape: Shape,
|
shape: Shape,
|
||||||
/// A `CursorNotify` (or first read) is pending — fetch the shape when this display is active.
|
/// A `CursorNotify` (or first read) is pending — fetch the shape when this display is active.
|
||||||
need_shape: bool,
|
need_shape: bool,
|
||||||
|
/// Interned [`GS_CURSOR_FEEDBACK`] atom (`0` = intern failed — treated as absent).
|
||||||
|
feedback_atom: Atom,
|
||||||
|
/// gamescope's verdict for THIS display: `Some(true)` = it is drawing the pointer here,
|
||||||
|
/// `Some(false)` = it is not, `None` = this gamescope publishes no verdict at all.
|
||||||
|
gs_visible: Option<bool>,
|
||||||
/// The X connection died (game/Xwayland exited) — skip it.
|
/// The X connection died (game/Xwayland exited) — skip it.
|
||||||
dead: bool,
|
dead: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl XDisplay {
|
impl XDisplay {
|
||||||
fn new(name: String, conn: RustConnection, root: Window) -> Self {
|
fn new(
|
||||||
|
name: String,
|
||||||
|
conn: RustConnection,
|
||||||
|
root: Window,
|
||||||
|
(feedback_atom, gs_visible): (Atom, Option<bool>),
|
||||||
|
) -> Self {
|
||||||
XDisplay {
|
XDisplay {
|
||||||
name,
|
name,
|
||||||
conn,
|
conn,
|
||||||
@@ -176,9 +256,20 @@ impl XDisplay {
|
|||||||
last_pos: None,
|
last_pos: None,
|
||||||
shape: Shape::default(),
|
shape: Shape::default(),
|
||||||
need_shape: true,
|
need_shape: true,
|
||||||
|
feedback_atom,
|
||||||
|
gs_visible,
|
||||||
dead: false,
|
dead: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Re-read gamescope's verdict, keeping a previously-seen one if the read fails (a transient
|
||||||
|
/// failure must not look like "this gamescope has no feedback" and re-arm the motion heuristic).
|
||||||
|
fn resync_feedback(&mut self) {
|
||||||
|
let fresh = read_cursor_feedback(&self.conn, self.root, self.feedback_atom);
|
||||||
|
if fresh.is_some() || self.gs_visible.is_none() {
|
||||||
|
self.gs_visible = fresh;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cached cursor shape for one display.
|
/// Cached cursor shape for one display.
|
||||||
@@ -209,20 +300,35 @@ fn run(
|
|||||||
let mut out_serial = 0u64;
|
let mut out_serial = 0u64;
|
||||||
let mut last_key = (usize::MAX, u64::MAX);
|
let mut last_key = (usize::MAX, u64::MAX);
|
||||||
let mut warned_image = false;
|
let mut warned_image = false;
|
||||||
|
let mut last_resync = std::time::Instant::now();
|
||||||
|
|
||||||
while !stop.load(Ordering::Relaxed) {
|
while !stop.load(Ordering::Relaxed) {
|
||||||
// 1) Poll every display's pointer; note which moved since last tick (the focus signal).
|
// A missed/coalesced PropertyNotify would otherwise strand the verdict — re-read on a slow
|
||||||
|
// cadence so the source always converges (also picks the atom up if gamescope adds it late).
|
||||||
|
let resync = last_resync.elapsed() >= FEEDBACK_RESYNC;
|
||||||
|
if resync {
|
||||||
|
last_resync = std::time::Instant::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Poll every display's pointer; note which moved since last tick (the fallback focus
|
||||||
|
// signal, used only when this gamescope publishes no cursor verdict).
|
||||||
let mut active_moved = false;
|
let mut active_moved = false;
|
||||||
let mut other_moved: Option<usize> = None;
|
let mut other_moved: Option<usize> = None;
|
||||||
for (i, d) in displays.iter_mut().enumerate() {
|
for (i, d) in displays.iter_mut().enumerate() {
|
||||||
if d.dead {
|
if d.dead {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Drain pending events; the only ones selected are CursorNotify, so ANY event means
|
// Drain pending events. Two kinds are selected: XFixes CursorNotify (the shape
|
||||||
// "re-read this display's shape". poll_for_event never blocks.
|
// changed) and root PropertyNotify (gamescope republished its cursor verdict, among
|
||||||
|
// the many other properties it keeps on the root — hence the atom match).
|
||||||
|
let mut need_feedback = resync;
|
||||||
loop {
|
loop {
|
||||||
match d.conn.poll_for_event() {
|
match d.conn.poll_for_event() {
|
||||||
Ok(Some(_)) => d.need_shape = true,
|
Ok(Some(Event::XfixesCursorNotify(_))) => d.need_shape = true,
|
||||||
|
Ok(Some(Event::PropertyNotify(ev))) => {
|
||||||
|
need_feedback |= d.feedback_atom != 0 && ev.atom == d.feedback_atom;
|
||||||
|
}
|
||||||
|
Ok(Some(_)) => {}
|
||||||
Ok(None) => break,
|
Ok(None) => break,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
d.dead = true;
|
d.dead = true;
|
||||||
@@ -230,6 +336,9 @@ fn run(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if need_feedback && !d.dead {
|
||||||
|
d.resync_feedback();
|
||||||
|
}
|
||||||
match fetch_pointer(&d.conn, d.root) {
|
match fetch_pointer(&d.conn, d.root) {
|
||||||
Ok(p) if p.same_screen => {
|
Ok(p) if p.same_screen => {
|
||||||
let pos = (i32::from(p.root_x), i32::from(p.root_y));
|
let pos = (i32::from(p.root_x), i32::from(p.root_y));
|
||||||
@@ -248,13 +357,11 @@ fn run(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Switch focus: sticky to the active display while it moves (no flapping); otherwise
|
// 2) Pick the display to publish from, and decide whether a pointer should be drawn at all.
|
||||||
// follow another display that moved. If the active one died, fall to any live display.
|
let states: Vec<(bool, Option<bool>)> =
|
||||||
if !active_moved {
|
displays.iter().map(|d| (d.dead, d.gs_visible)).collect();
|
||||||
if let Some(j) = other_moved {
|
let hidden_by_gamescope;
|
||||||
active = j;
|
(active, hidden_by_gamescope) = pick_active(&states, active, active_moved, other_moved);
|
||||||
}
|
|
||||||
}
|
|
||||||
if displays.get(active).is_none_or(|d| d.dead) {
|
if displays.get(active).is_none_or(|d| d.dead) {
|
||||||
match displays.iter().position(|d| !d.dead) {
|
match displays.iter().position(|d| !d.dead) {
|
||||||
Some(k) => active = k,
|
Some(k) => active = k,
|
||||||
@@ -283,7 +390,11 @@ fn run(
|
|||||||
|
|
||||||
// 4) Publish the ACTIVE display's pointer + shape (or clear the slot when it has no cursor
|
// 4) Publish the ACTIVE display's pointer + shape (or clear the slot when it has no cursor
|
||||||
// of its own — so a focus switch never leaves the other display's stale pointer showing).
|
// of its own — so a focus switch never leaves the other display's stale pointer showing).
|
||||||
|
// A pointer gamescope is not drawing is published `visible: false`, NOT dropped: the
|
||||||
|
// encode loop overwrites the frame's overlay from this slot and strips invisible ones, so
|
||||||
|
// a `None` here would leave the last visible overlay standing on repeat frames.
|
||||||
let d = &displays[active];
|
let d = &displays[active];
|
||||||
|
let drawn = d.shape.visible && !hidden_by_gamescope;
|
||||||
let overlay = match (d.last_pos, d.shape.rgba.is_empty()) {
|
let overlay = match (d.last_pos, d.shape.rgba.is_empty()) {
|
||||||
(Some((px, py)), false) => {
|
(Some((px, py)), false) => {
|
||||||
let key = (active, d.shape.serial);
|
let key = (active, d.shape.serial);
|
||||||
@@ -301,7 +412,7 @@ fn run(
|
|||||||
serial: out_serial,
|
serial: out_serial,
|
||||||
hot_x: d.shape.hot_x,
|
hot_x: d.shape.hot_x,
|
||||||
hot_y: d.shape.hot_y,
|
hot_y: d.shape.hot_y,
|
||||||
visible: d.shape.visible,
|
visible: drawn,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
@@ -314,6 +425,39 @@ fn run(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which display to publish from, and whether gamescope is drawing NO pointer right now —
|
||||||
|
/// `(active, hidden)`. `states` is one `(dead, gs_visible)` per display, in `displays` order.
|
||||||
|
///
|
||||||
|
/// PREFERRED: gamescope's own verdict. It is authoritative for a STATIC pointer, which is exactly
|
||||||
|
/// the case motion cannot read — a game grabs the pointer, gamescope parks it in the bottom-right
|
||||||
|
/// corner, and "follow whichever moved" then never switches again (see the module docs).
|
||||||
|
///
|
||||||
|
/// FALLBACK, only when NO live display publishes the verdict: the original motion heuristic —
|
||||||
|
/// sticky to the active display while its pointer moves (no flapping), else follow another that
|
||||||
|
/// moved. `hidden` is never asserted on this path, so an older gamescope keeps today's behaviour.
|
||||||
|
fn pick_active(
|
||||||
|
states: &[(bool, Option<bool>)],
|
||||||
|
active: usize,
|
||||||
|
active_moved: bool,
|
||||||
|
other_moved: Option<usize>,
|
||||||
|
) -> (usize, bool) {
|
||||||
|
let live = |&(dead, _): &(bool, Option<bool>)| !dead;
|
||||||
|
if states.iter().filter(|s| live(s)).any(|(_, v)| v.is_some()) {
|
||||||
|
return match states.iter().position(|s| live(s) && s.1 == Some(true)) {
|
||||||
|
// gamescope is drawing the pointer here — publish from it.
|
||||||
|
Some(i) => (i, false),
|
||||||
|
// Drawing none of them (a game grabbed it, or the idle auto-hide fired). Keep `active`
|
||||||
|
// so the shape cache and last position stay warm for the re-show; the caller publishes
|
||||||
|
// the overlay `visible: false` instead of dropping it.
|
||||||
|
None => (active, true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
match other_moved {
|
||||||
|
Some(j) if !active_moved => (j, false),
|
||||||
|
_ => (active, false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Update `shape` from a fresh `GetCursorImage` reply. A hidden pointer (all-transparent) keeps the
|
/// Update `shape` from a fresh `GetCursorImage` reply. A hidden pointer (all-transparent) keeps the
|
||||||
/// last bitmap (instant re-show) but flips visibility; the serial still bumps so the change shows.
|
/// last bitmap (instant re-show) but flips visibility; the serial still bumps so the change shows.
|
||||||
fn update_shape(shape: &mut Shape, img: &GetCursorImageReply) {
|
fn update_shape(shape: &mut Shape, img: &GetCursorImageReply) {
|
||||||
@@ -364,3 +508,60 @@ fn argb_premul_to_straight_rgba(argb: &[u32]) -> Vec<u8> {
|
|||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::pick_active;
|
||||||
|
|
||||||
|
/// Steam Gaming Mode shape: display 0 = Big Picture's Xwayland, 1 = the game's.
|
||||||
|
const BPM: usize = 0;
|
||||||
|
const GAME: usize = 1;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn follows_the_display_gamescope_draws_on() {
|
||||||
|
// Verdict beats motion: gamescope says the game's Xwayland owns the pointer, so we publish
|
||||||
|
// from it even though only BPM's (parked) pointer looks like it moved.
|
||||||
|
let states = [(false, Some(false)), (false, Some(true))];
|
||||||
|
assert_eq!(pick_active(&states, BPM, false, Some(BPM)), (GAME, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// THE REGRESSION: gamescope hides the pointer by warping it to the root's bottom-right corner
|
||||||
|
/// and leaves the opaque arrow as the X cursor. Nothing moves ever again, so the motion
|
||||||
|
/// heuristic stayed on the parked display and composited that arrow at (w-1, h-1) — a sliver of
|
||||||
|
/// cursor welded to the corner of the stream for the whole session, in every game.
|
||||||
|
#[test]
|
||||||
|
fn a_pointer_gamescope_draws_nowhere_is_hidden_not_parked() {
|
||||||
|
let states = [(false, Some(false)), (false, Some(false))];
|
||||||
|
// `active` is kept (shape cache + last position stay warm for the re-show) but hidden.
|
||||||
|
assert_eq!(pick_active(&states, GAME, false, None), (GAME, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn re_show_returns_to_the_drawing_display() {
|
||||||
|
let states = [(false, Some(true)), (false, Some(false))];
|
||||||
|
assert_eq!(pick_active(&states, GAME, false, None), (BPM, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_dead_displays_verdict_is_ignored() {
|
||||||
|
// The game's Xwayland exited mid-session with a stale `Some(true)`; BPM is the live one.
|
||||||
|
let states = [(false, Some(true)), (true, Some(true))];
|
||||||
|
assert_eq!(pick_active(&states, GAME, false, None), (BPM, false));
|
||||||
|
// …and a dead display's `Some` must not count as "this gamescope publishes a verdict",
|
||||||
|
// which would blank the cursor forever on a gamescope that publishes none.
|
||||||
|
let states = [(false, None), (true, Some(false))];
|
||||||
|
assert_eq!(pick_active(&states, BPM, false, Some(GAME)), (GAME, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_verdict_falls_back_to_the_motion_heuristic() {
|
||||||
|
let none = [(false, None), (false, None)];
|
||||||
|
// Sticky while the active display's own pointer moves…
|
||||||
|
assert_eq!(pick_active(&none, BPM, true, Some(GAME)), (BPM, false));
|
||||||
|
// …otherwise follow the one that moved…
|
||||||
|
assert_eq!(pick_active(&none, BPM, false, Some(GAME)), (GAME, false));
|
||||||
|
// …and never assert `hidden` on this path (that would regress an older gamescope to a
|
||||||
|
// cursorless stream).
|
||||||
|
assert_eq!(pick_active(&none, BPM, false, None), (BPM, false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ use punktfunk_core::quic::{
|
|||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use windows::Win32::Foundation::POINT;
|
use windows::Win32::Foundation::POINT;
|
||||||
|
use windows::Win32::System::StationsAndDesktops::{
|
||||||
|
CloseDesktop, GetThreadDesktop, OpenInputDesktop, SetThreadDesktop, DESKTOP_ACCESS_FLAGS,
|
||||||
|
DESKTOP_CONTROL_FLAGS, HDESK,
|
||||||
|
};
|
||||||
|
use windows::Win32::System::Threading::GetCurrentThreadId;
|
||||||
use windows::Win32::UI::Controls::{
|
use windows::Win32::UI::Controls::{
|
||||||
CreateSyntheticPointerDevice, DestroySyntheticPointerDevice, HSYNTHETICPOINTERDEVICE,
|
CreateSyntheticPointerDevice, DestroySyntheticPointerDevice, HSYNTHETICPOINTERDEVICE,
|
||||||
POINTER_FEEDBACK_DEFAULT, POINTER_TYPE_INFO, POINTER_TYPE_INFO_0,
|
POINTER_FEEDBACK_DEFAULT, POINTER_TYPE_INFO, POINTER_TYPE_INFO_0,
|
||||||
@@ -46,6 +51,101 @@ use windows::Win32::UI::WindowsAndMessaging::{
|
|||||||
/// 50 ms against the ~100 ms staleness window; 40 ms keeps two refreshes inside it.
|
/// 50 ms against the ~100 ms staleness window; 40 ms keeps two refreshes inside it.
|
||||||
const REFRESH_MS: u64 = 40;
|
const REFRESH_MS: u64 = 40;
|
||||||
|
|
||||||
|
/// `GENERIC_ALL` for the desktop open — the `windows` crate models desktop rights as their own
|
||||||
|
/// flag type and doesn't re-export the generic ones (same constant `sendinput.rs` uses).
|
||||||
|
const DESKTOP_GENERIC_ALL: u32 = 0x1000_0000;
|
||||||
|
|
||||||
|
/// This thread's binding to the input desktop, restored on drop.
|
||||||
|
///
|
||||||
|
/// `SendInput` (`sendinput.rs`) solves the same problem by binding its DEDICATED injector thread
|
||||||
|
/// once and keeping it. That can't be borrowed here: `inject_pen`/`inject_touch_frame` are driven
|
||||||
|
/// from TWO threads — the caller's `apply_batch` and the `pf-pen-refresh`/`pf-touch-refresh`
|
||||||
|
/// staleness threads — and the batch caller is a shared task thread, which must not be left parked
|
||||||
|
/// on a `Winlogon` desktop that disappears when the prompt is dismissed. So the binding is scoped
|
||||||
|
/// to the retry: `GetThreadDesktop` hands back a BORROWED handle (never closed), and only the
|
||||||
|
/// `OpenInputDesktop` handle is closed, after the thread has moved back off it.
|
||||||
|
struct InputDesktopBinding {
|
||||||
|
previous: HDESK,
|
||||||
|
input: HDESK,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InputDesktopBinding {
|
||||||
|
fn bind() -> Option<Self> {
|
||||||
|
// SAFETY: FFI calls taking by-value args only. `OpenInputDesktop` yields an owned `HDESK`
|
||||||
|
// solely on `Ok`; it is either installed by `SetThreadDesktop` (then owned by this guard,
|
||||||
|
// closed exactly once in `Drop`) or closed here on failure — closed once on every path,
|
||||||
|
// never used after. `SetThreadDesktop` rebinds only the calling thread, which owns no
|
||||||
|
// windows or hooks, so it cannot fail on that account.
|
||||||
|
unsafe {
|
||||||
|
let previous = GetThreadDesktop(GetCurrentThreadId()).ok()?;
|
||||||
|
let input = OpenInputDesktop(
|
||||||
|
DESKTOP_CONTROL_FLAGS(0),
|
||||||
|
false,
|
||||||
|
DESKTOP_ACCESS_FLAGS(DESKTOP_GENERIC_ALL),
|
||||||
|
)
|
||||||
|
.ok()?;
|
||||||
|
if SetThreadDesktop(input).is_err() {
|
||||||
|
let _ = CloseDesktop(input);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Self { previous, input })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for InputDesktopBinding {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: `previous` is the borrowed desktop this thread started on, `input` the handle
|
||||||
|
// this guard uniquely owns. The thread moves back FIRST, so the handle is not the thread's
|
||||||
|
// desktop when closed — closed exactly once, never used after.
|
||||||
|
unsafe {
|
||||||
|
let _ = SetThreadDesktop(self.previous);
|
||||||
|
let _ = CloseDesktop(self.input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inject one synthetic-pointer frame, following the input desktop if Windows refuses it.
|
||||||
|
///
|
||||||
|
/// While a UAC consent prompt (or the lock / logon screen) owns input, the secure desktop does, and
|
||||||
|
/// injection from the host's own `WinSta0\Default` thread comes back `ERROR_ACCESS_DENIED` — which
|
||||||
|
/// is why pen and touch went dead on a prompt a user could SEE in the stream (capture already
|
||||||
|
/// renders it, and `SendInput` mouse/keyboard already followed the switch, so only these two were
|
||||||
|
/// left behind). Field-reported 2026-07-23; the exact rc reproduced in a probe the same night.
|
||||||
|
///
|
||||||
|
/// Measured then, with a real consent prompt up and a SYSTEM host in the console session:
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// device created on Default, thread on Default -> 0x80070005 (the field failure)
|
||||||
|
/// device created on Default, thread on INPUT desktop -> OK
|
||||||
|
/// device created on INPUT, thread on INPUT desktop -> OK
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// The middle row is the load-bearing one: the synthetic pointer device is NOT desktop-affine, so
|
||||||
|
/// rebinding the THREAD is sufficient and the device never has to be destroyed and recreated
|
||||||
|
/// across a desktop switch (which would drop in-flight contacts and the pen's in-range state).
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `dev` must be a live synthetic-pointer device and `frame` a live slice for the call.
|
||||||
|
unsafe fn inject_following_desktop(
|
||||||
|
dev: HSYNTHETICPOINTERDEVICE,
|
||||||
|
frame: &[POINTER_TYPE_INFO],
|
||||||
|
) -> windows::core::Result<()> {
|
||||||
|
// SAFETY: per this fn's contract — `dev` is live and `frame` outlives the call, which only
|
||||||
|
// reads it. Best-effort, exactly as the direct call was.
|
||||||
|
match InjectSyntheticPointerInput(dev, frame) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(first) => {
|
||||||
|
// Only a desktop switch is worth a rebind; anything else would just fail identically.
|
||||||
|
let Some(_binding) = InputDesktopBinding::bind() else {
|
||||||
|
return Err(first);
|
||||||
|
};
|
||||||
|
// SAFETY: same live `dev`/`frame`, re-issued with this thread on the input desktop.
|
||||||
|
InjectSyntheticPointerInput(dev, frame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Windows pen pressure is 0..1024 (vs the wire's full-scale u16).
|
/// Windows pen pressure is 0..1024 (vs the wire's full-scale u16).
|
||||||
const WIN_PEN_PRESSURE_MAX: u32 = 1024;
|
const WIN_PEN_PRESSURE_MAX: u32 = 1024;
|
||||||
|
|
||||||
@@ -340,10 +440,22 @@ fn inject_pen(sh: &mut PenShared, edge: POINTER_FLAGS, is_new: bool) {
|
|||||||
// SAFETY: `sh.dev.0` is the live device this wrapper owns; the one-element array is a live
|
// SAFETY: `sh.dev.0` is the live device this wrapper owns; the one-element array is a live
|
||||||
// stack value the call only reads. Best-effort like every injector write — a transient
|
// stack value the call only reads. Best-effort like every injector write — a transient
|
||||||
// failure (desktop switch) is healed by the next refresh tick re-asserting state.
|
// failure (desktop switch) is healed by the next refresh tick re-asserting state.
|
||||||
if let Err(e) = unsafe { InjectSyntheticPointerInput(sh.dev.0, &[info]) } {
|
if let Err(e) = unsafe { inject_following_desktop(sh.dev.0, &[info]) } {
|
||||||
if !sh.fail_warned {
|
if !sh.fail_warned {
|
||||||
sh.fail_warned = true;
|
sh.fail_warned = true;
|
||||||
tracing::warn!(error = %e, "pen inject failed");
|
tracing::warn!(
|
||||||
|
error = %e,
|
||||||
|
flags = format!("{:#x}", flags.0),
|
||||||
|
pen_flags = format!("{pen_flags:#x}"),
|
||||||
|
pen_mask = format!("{pen_mask:#x}"),
|
||||||
|
pressure,
|
||||||
|
rotation,
|
||||||
|
tilt_x,
|
||||||
|
tilt_y,
|
||||||
|
x = pt.x,
|
||||||
|
y = pt.y,
|
||||||
|
"pen inject failed"
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
tracing::trace!(error = %e, "pen inject failed (transient)");
|
tracing::trace!(error = %e, "pen inject failed (transient)");
|
||||||
}
|
}
|
||||||
@@ -543,7 +655,7 @@ fn inject_touch_frame(sh: &mut TouchShared, edge: Option<(u32, POINTER_FLAGS)>)
|
|||||||
}
|
}
|
||||||
// SAFETY: `sh.dev.0` is the live owned device; `frame` is a live Vec the call only reads.
|
// SAFETY: `sh.dev.0` is the live owned device; `frame` is a live Vec the call only reads.
|
||||||
// Best-effort — a transient failure heals on the next event/refresh re-assertion.
|
// Best-effort — a transient failure heals on the next event/refresh re-assertion.
|
||||||
if let Err(e) = unsafe { InjectSyntheticPointerInput(sh.dev.0, &frame) } {
|
if let Err(e) = unsafe { inject_following_desktop(sh.dev.0, &frame) } {
|
||||||
if !sh.fail_warned {
|
if !sh.fail_warned {
|
||||||
sh.fail_warned = true;
|
sh.fail_warned = true;
|
||||||
tracing::warn!(error = %e, contacts = frame.len(), "touch inject failed");
|
tracing::warn!(error = %e, contacts = frame.len(), "touch inject failed");
|
||||||
|
|||||||
@@ -58,6 +58,11 @@ pub(crate) fn emit_display_event(ev: DisplayEvent) {
|
|||||||
pub(crate) mod backend;
|
pub(crate) mod backend;
|
||||||
pub use backend::{DisplayOwnership, VirtualDisplay, VirtualOutput};
|
pub use backend::{DisplayOwnership, VirtualDisplay, VirtualOutput};
|
||||||
|
|
||||||
|
/// Time-bounded child-process helpers — every compositor query shells out, and an unbounded one
|
||||||
|
/// can wedge the calling (session) thread forever.
|
||||||
|
#[path = "vdisplay/proc.rs"]
|
||||||
|
pub(crate) mod proc;
|
||||||
|
|
||||||
/// Live-session detection + session-epoch + env retargeting (plan §W3).
|
/// Live-session detection + session-epoch + env retargeting (plan §W3).
|
||||||
#[path = "vdisplay/session.rs"]
|
#[path = "vdisplay/session.rs"]
|
||||||
pub(crate) mod session;
|
pub(crate) mod session;
|
||||||
|
|||||||
@@ -61,6 +61,32 @@ static MANAGED_SESSION: std::sync::Mutex<Option<SessionState>> = std::sync::Mute
|
|||||||
/// (single-instance), so [`schedule_restore_tv_session`] can restart them when the client disconnects.
|
/// (single-instance), so [`schedule_restore_tv_session`] can restart them when the client disconnects.
|
||||||
static STOPPED_AUTOLOGIN: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
|
static STOPPED_AUTOLOGIN: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
|
||||||
|
|
||||||
|
/// The display-manager unit we stopped for the takeover on a mask-fragile DM flavor (Nobara's
|
||||||
|
/// `plasmalogin` — see [`dm_survives_masked_unit`]), so the restore brings the box back via
|
||||||
|
/// `reset-failed` + `restart` of the DM instead of a `--user start` of the gamescope unit (which
|
||||||
|
/// cannot work there: without a DM login session there is no seat, so gamescope never gets DRM
|
||||||
|
/// master — live-proven on the Nobara repro VM 2026-07-24).
|
||||||
|
static STOPPED_DM: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
|
||||||
|
|
||||||
|
/// mtime of the `steamos-session-select` sentinel at managed-session launch — the baseline the
|
||||||
|
/// in-stream "Switch to Desktop" detector compares against. Steam's session-select script writes
|
||||||
|
/// `~/.config/steamos-session-select` unconditionally in its USER pass, before any of its
|
||||||
|
/// display-manager checks — so it advances even under a DM-stop takeover, where the script's
|
||||||
|
/// config-rewrite tail is a silent no-op (every write branch is gated on the DM *running*;
|
||||||
|
/// diagnosed live on the Nobara repro VM 2026-07-24). An advanced mtime after a capture loss is
|
||||||
|
/// therefore the one durable trace of the user's switch request.
|
||||||
|
static SESSION_SELECT_BASELINE: std::sync::Mutex<Option<std::time::SystemTime>> =
|
||||||
|
std::sync::Mutex::new(None);
|
||||||
|
|
||||||
|
/// When [`honor_session_select_switch`] last ran. While recent, a managed (re)launch is refused —
|
||||||
|
/// the rebuild loop would otherwise race the booting desktop back into game mode (gamescope+Steam
|
||||||
|
/// come up faster than KWin, and a delivering managed pipeline ends the rebuild's re-detection).
|
||||||
|
static SWITCH_HONORED_AT: std::sync::Mutex<Option<Instant>> = std::sync::Mutex::new(None);
|
||||||
|
|
||||||
|
/// How long after honoring an in-stream desktop switch the managed path refuses to relaunch,
|
||||||
|
/// giving the DM's desktop session time to come up so re-detection follows it instead.
|
||||||
|
const SWITCH_HONOR_GRACE: Duration = Duration::from_secs(120);
|
||||||
|
|
||||||
/// A pending debounced TV-session restore: the instant [`do_restore_tv_session`] should fire after
|
/// A pending debounced TV-session restore: the instant [`do_restore_tv_session`] should fire after
|
||||||
/// the last client disconnect. A reconnect inside the window clears it (and reuses the still-warm
|
/// the last client disconnect. A reconnect inside the window clears it (and reuses the still-warm
|
||||||
/// managed session), so we never stop+relaunch gamescope per connect — that per-connect teardown is
|
/// managed session), so we never stop+relaunch gamescope per connect — that per-connect teardown is
|
||||||
@@ -115,6 +141,10 @@ struct TakeoverState {
|
|||||||
stopped_autologin: Vec<String>,
|
stopped_autologin: Vec<String>,
|
||||||
/// Whether we took over SteamOS's `gamescope-session.target` (restore = remove drop-in + restart).
|
/// Whether we took over SteamOS's `gamescope-session.target` (restore = remove drop-in + restart).
|
||||||
steamos: bool,
|
steamos: bool,
|
||||||
|
/// The display-manager unit we stopped on a mask-fragile DM flavor (restore = `reset-failed` +
|
||||||
|
/// `restart` of the DM). `default` so takeover files from older hosts still parse.
|
||||||
|
#[serde(default)]
|
||||||
|
stopped_dm: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Path of the persisted [`TakeoverState`], under `$XDG_RUNTIME_DIR` (per-user, 0700, tmpfs — cleared
|
/// Path of the persisted [`TakeoverState`], under `$XDG_RUNTIME_DIR` (per-user, 0700, tmpfs — cleared
|
||||||
@@ -133,8 +163,9 @@ fn persist_takeover() {
|
|||||||
.unwrap_or_else(|e| e.into_inner())
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
.clone(),
|
.clone(),
|
||||||
steamos: *STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner()),
|
steamos: *STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner()),
|
||||||
|
stopped_dm: STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()).clone(),
|
||||||
};
|
};
|
||||||
if state.stopped_autologin.is_empty() && !state.steamos {
|
if state.stopped_autologin.is_empty() && !state.steamos && state.stopped_dm.is_none() {
|
||||||
clear_takeover();
|
clear_takeover();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -162,17 +193,22 @@ pub fn restore_takeover_on_startup() {
|
|||||||
clear_takeover();
|
clear_takeover();
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if state.stopped_autologin.is_empty() && !state.steamos {
|
if state.stopped_autologin.is_empty() && !state.steamos && state.stopped_dm.is_none() {
|
||||||
clear_takeover();
|
clear_takeover();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
units = ?state.stopped_autologin,
|
units = ?state.stopped_autologin,
|
||||||
steamos = state.steamos,
|
steamos = state.steamos,
|
||||||
|
stopped_dm = ?state.stopped_dm,
|
||||||
"gamescope: found a stranded takeover from a previous host instance — scheduling TV restore"
|
"gamescope: found a stranded takeover from a previous host instance — scheduling TV restore"
|
||||||
);
|
);
|
||||||
*STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()) = state.stopped_autologin;
|
*STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()) = state.stopped_autologin;
|
||||||
*STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner()) = state.steamos;
|
*STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner()) = state.steamos;
|
||||||
|
*STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()) = state.stopped_dm;
|
||||||
|
// Re-baseline the session-select sentinel: after a crash-restore the launch-time baseline is
|
||||||
|
// gone, and a long-existing sentinel file must not read as a fresh in-stream switch request.
|
||||||
|
record_session_select_baseline();
|
||||||
// A generous grace so a client reconnecting right after the restart cancels it (create_managed_session
|
// A generous grace so a client reconnecting right after the restart cancels it (create_managed_session
|
||||||
// clears PENDING_RESTORE) and keeps the streamed session rather than bouncing to gaming mode.
|
// clears PENDING_RESTORE) and keeps the streamed session rather than bouncing to gaming mode.
|
||||||
*PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner()) =
|
*PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner()) =
|
||||||
@@ -263,7 +299,10 @@ impl VirtualDisplay for GamescopeDisplay {
|
|||||||
// A3 takeover machinery (recorded in STOPPED_AUTOLOGIN + persisted; restarted on session end via
|
// A3 takeover machinery (recorded in STOPPED_AUTOLOGIN + persisted; restarted on session end via
|
||||||
// schedule_restore_tv_session). Non-Steam launches don't conflict, so they skip this.
|
// schedule_restore_tv_session). Non-Steam launches don't conflict, so they skip this.
|
||||||
if self.cmd.as_deref().is_some_and(is_steam_launch) {
|
if self.cmd.as_deref().is_some_and(is_steam_launch) {
|
||||||
stop_autologin_sessions();
|
// A dedicated launch NEEDS Steam's single instance — no attach degrade exists here, so
|
||||||
|
// a mask-fragile-DM box without takeover privilege fails with the actionable error.
|
||||||
|
stop_autologin_sessions()
|
||||||
|
.context("dedicated Steam launch needs the box's gaming session freed")?;
|
||||||
// B1b: a Steam running in a plain DESKTOP session (GNOME/KDE) holds the instance just
|
// B1b: a Steam running in a plain DESKTOP session (GNOME/KDE) holds the instance just
|
||||||
// the same, and the autologin stop above can't see it — free it too, or fail loudly.
|
// the same, and the autologin stop above can't see it — free it too, or fail loudly.
|
||||||
free_desktop_steam()?;
|
free_desktop_steam()?;
|
||||||
@@ -326,6 +365,40 @@ fn create_managed_session(client: &str, mode: Mode) -> Result<VirtualOutput> {
|
|||||||
if steamos_session_present() {
|
if steamos_session_present() {
|
||||||
return create_managed_session_steamos(mode);
|
return create_managed_session_steamos(mode);
|
||||||
}
|
}
|
||||||
|
// In-stream "Switch to Desktop" under a DM-stop takeover: the user's session-select inside
|
||||||
|
// the streamed game mode advanced the sentinel, but its config rewrite was a silent no-op
|
||||||
|
// (every write branch needs the DM running, and the takeover stopped it) — so without this,
|
||||||
|
// the capture loss it caused would just relaunch game mode ("thrown back in", field-tested
|
||||||
|
// 2026-07-24). Honor the request instead: restore the DM and replay the switch.
|
||||||
|
let dm_takeover = STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||||
|
if let Some(dm) = dm_takeover {
|
||||||
|
if session_select_requested() {
|
||||||
|
*STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||||
|
honor_session_select_switch(dm);
|
||||||
|
return Err(anyhow!(
|
||||||
|
"the user switched the box to the desktop session — display manager restored; \
|
||||||
|
re-detection follows the desktop compositor as it comes up"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Post-honor grace: while the selected desktop boots, a managed relaunch would win the race
|
||||||
|
// (gamescope+Steam start faster than KWin) and a delivering pipeline ends the rebuild's
|
||||||
|
// re-detection — right back in game mode. A live box-owned game-mode unit supersedes the
|
||||||
|
// grace: the user already switched back, so managed may proceed.
|
||||||
|
let honor_pending = SWITCH_HONORED_AT
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.is_some_and(|t| t.elapsed() < SWITCH_HONOR_GRACE);
|
||||||
|
if honor_pending {
|
||||||
|
if running_autologin_gamescope_unit().is_some() {
|
||||||
|
*SWITCH_HONORED_AT.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||||
|
} else {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"waiting for the desktop session the user selected — refusing to relaunch game \
|
||||||
|
mode (re-detection follows the desktop once it's up)"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
// Attach-only rebuild probe: reuse a live same-mode session, but NEVER stop/relaunch box
|
// Attach-only rebuild probe: reuse a live same-mode session, but NEVER stop/relaunch box
|
||||||
// sessions — right after a capture loss the caller's session detection can be stale, and a
|
// sessions — right after a capture loss the caller's session detection can be stale, and a
|
||||||
// destructive rebuild here would fight the session the user just switched to.
|
// destructive rebuild here would fight the session the user just switched to.
|
||||||
@@ -353,7 +426,28 @@ fn create_managed_session(client: &str, mode: Mode) -> Result<VirtualOutput> {
|
|||||||
// Bazzite default — `gamescope-session-plus@ogui-steam` on the TV), that session holds Steam and
|
// Bazzite default — `gamescope-session-plus@ogui-steam` on the TV), that session holds Steam and
|
||||||
// renders to the TV's native mode, which we'd capture instead of the client's. Free Steam by
|
// renders to the TV's native mode, which we'd capture instead of the client's. Free Steam by
|
||||||
// stopping it; [`schedule_restore_tv_session`] (on disconnect) brings it back after a debounce.
|
// stopping it; [`schedule_restore_tv_session`] (on disconnect) brings it back after a debounce.
|
||||||
stop_autologin_sessions();
|
// On a mask-fragile-DM box without the privilege to stop the DM, the takeover would destabilize
|
||||||
|
// the seat — degrade to ATTACH instead: mirror the box's own live game-mode session (capture +
|
||||||
|
// inject, no lifecycle ownership), which needs no takeover at all.
|
||||||
|
if let Err(e) = stop_autologin_sessions() {
|
||||||
|
tracing::warn!(
|
||||||
|
error = %format!("{e:#}"),
|
||||||
|
"gamescope: managed takeover unavailable — degrading to ATTACH (mirroring the box's \
|
||||||
|
own game-mode session)"
|
||||||
|
);
|
||||||
|
let node_id = ensure_box_gamescope_mode(mode)?;
|
||||||
|
point_injector_at_eis();
|
||||||
|
return Ok(VirtualOutput {
|
||||||
|
node_id,
|
||||||
|
remote_fd: None,
|
||||||
|
preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)),
|
||||||
|
keepalive: Box::new(()),
|
||||||
|
ownership: DisplayOwnership::External,
|
||||||
|
reused_gen: None,
|
||||||
|
pool_gen: None,
|
||||||
|
expect_exact_dims: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
// B1b: a desktop-session Steam (outside any gamescope unit) also holds the single instance and
|
// B1b: a desktop-session Steam (outside any gamescope unit) also holds the single instance and
|
||||||
// would make the managed session's own Steam exit at birth. The managed session's Steam itself
|
// would make the managed session's own Steam exit at birth. The managed session's Steam itself
|
||||||
// is exempt (it lives in the SESSION_UNIT cgroup), so the same-mode reuse below is unaffected.
|
// is exempt (it lives in the SESSION_UNIT cgroup), so the same-mode reuse below is unaffected.
|
||||||
@@ -379,7 +473,21 @@ fn create_managed_session(client: &str, mode: Mode) -> Result<VirtualOutput> {
|
|||||||
}
|
}
|
||||||
// (Re)launch at the new mode. `launch_session` stops the old unit by name first, so there is
|
// (Re)launch at the new mode. `launch_session` stops the old unit by name first, so there is
|
||||||
// exactly one gamescope `Video/Source` node for discovery.
|
// exactly one gamescope `Video/Source` node for discovery.
|
||||||
let node_id = launch_session(client, SESSION_UNIT, mode)?;
|
let node_id = match launch_session(client, SESSION_UNIT, mode) {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(e) => {
|
||||||
|
// The takeover already happened (autologin units stopped, possibly the DM down) — arm
|
||||||
|
// the restore now, or a failed launch strands the box sessionless until a host
|
||||||
|
// restart. Policy-timed; a quick client retry cancels it and relaunches warm.
|
||||||
|
// MANAGED_SESSION must be released first: the scheduler reads it (orphan detection).
|
||||||
|
drop(guard);
|
||||||
|
schedule_restore_tv_session();
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Baseline the session-select sentinel NOW: only a write from INSIDE this session (the user's
|
||||||
|
// "Switch to Desktop") should read as a switch request, not the one that led here.
|
||||||
|
record_session_select_baseline();
|
||||||
point_injector_at_eis();
|
point_injector_at_eis();
|
||||||
*guard = Some(SessionState {
|
*guard = Some(SessionState {
|
||||||
width: mode.width,
|
width: mode.width,
|
||||||
@@ -807,6 +915,38 @@ fn ensure_box_gamescope_mode(mode: Mode) -> Result<u32> {
|
|||||||
return Ok(node);
|
return Ok(node);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Attach-only rebuild probe (parity with both managed paths — this gap was the attach-path
|
||||||
|
// stale-detection hazard): right after a capture loss the caller's session detection can be
|
||||||
|
// stale, and a set-environment + unit restart here would fight the session the user just
|
||||||
|
// switched to. Mirror whatever live node exists at its own mode; refuse otherwise.
|
||||||
|
if crate::rebuild_probe_active() {
|
||||||
|
if let Some(node) = find_gamescope_node() {
|
||||||
|
tracing::info!(
|
||||||
|
node,
|
||||||
|
"gamescope: attach-only rebuild probe — mirroring the live node at its own mode"
|
||||||
|
);
|
||||||
|
return Ok(node);
|
||||||
|
}
|
||||||
|
return Err(anyhow!(
|
||||||
|
"no live gamescope node — attach-only rebuild probe refuses to restart the box's \
|
||||||
|
session (re-detection follows the live session)"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// A box driving a PHYSICAL display is mirrored at its own mode, never re-moded: the re-mode
|
||||||
|
// restart is the headless-box model (no panel ⇒ the game-mode resolution is ours to set);
|
||||||
|
// on-glass it would flip the user's own screen to the client's resolution — and on a
|
||||||
|
// DM-session-driven box (Nobara) the unit restart bounces the login session with it.
|
||||||
|
if physical_display_connected() {
|
||||||
|
if let Some(node) = find_gamescope_node() {
|
||||||
|
tracing::info!(
|
||||||
|
node,
|
||||||
|
client_w = mode.width,
|
||||||
|
client_h = mode.height,
|
||||||
|
"gamescope: box drives a physical display — attaching at its own mode (no re-mode)"
|
||||||
|
);
|
||||||
|
return Ok(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
let Some(unit) = running_autologin_gamescope_unit() else {
|
let Some(unit) = running_autologin_gamescope_unit() else {
|
||||||
// No box-owned autologin session to reconfigure (a bare/foreign gamescope): attach to
|
// No box-owned autologin session to reconfigure (a bare/foreign gamescope): attach to
|
||||||
// whatever node exists, accepting its resolution.
|
// whatever node exists, accepting its resolution.
|
||||||
@@ -972,17 +1112,193 @@ fn unmask_unit(unit: &str) {
|
|||||||
.status();
|
.status();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The unit name of the display manager driving this box's graphical logins, from the
|
||||||
|
/// `display-manager.service` alias symlink (the Fedora/Arch/openSUSE convention every
|
||||||
|
/// gamescope-session distro follows). `None` when no DM is installed (a box that boots straight
|
||||||
|
/// into a user session — getty autologin / an enabled user unit).
|
||||||
|
fn display_manager_unit() -> Option<String> {
|
||||||
|
display_manager_unit_under(std::path::Path::new("/etc/systemd/system"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`display_manager_unit`] against an arbitrary root (the unit-testable core).
|
||||||
|
fn display_manager_unit_under(base: &std::path::Path) -> Option<String> {
|
||||||
|
let target = std::fs::read_link(base.join("display-manager.service")).ok()?;
|
||||||
|
target.file_name().map(|n| n.to_string_lossy().into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does this display manager's autologin loop SURVIVE the gamescope unit being masked? Only SDDM is
|
||||||
|
/// proven to (Bazzite/SteamOS `Relogin=true` — the mask is what stops its relogin from restarting
|
||||||
|
/// the unit mid-stream, diagnosed live on .181 2026-07-07; a failing autologin leaves sddm itself
|
||||||
|
/// running). Nobara's `plasmalogin` (KDE's SDDM successor) is proven FATAL: against a masked unit
|
||||||
|
/// its session Exec fails instantly, `Relogin=true` retries, and `plasmalogin.service` trips
|
||||||
|
/// systemd's start limit within ~1 s — the DM dies and the box is a permanent black screen that
|
||||||
|
/// only a root `reset-failed` + `restart` recovers (live-proven on the Nobara repro VM
|
||||||
|
/// 2026-07-24). Unknown DMs are treated as fragile: the fragile path degrades gracefully, a wrong
|
||||||
|
/// "safe" kills the seat.
|
||||||
|
fn dm_survives_masked_unit(dm: &str) -> bool {
|
||||||
|
dm == "sddm.service"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the display manager for a takeover on a mask-fragile DM flavor. Plain `systemctl stop` on
|
||||||
|
/// the SYSTEM bus — succeeds as root or under an operator polkit rule scoped to the DM unit (see
|
||||||
|
/// docs); fails cleanly otherwise ("interactive authentication required"), in which case the
|
||||||
|
/// caller degrades to attach.
|
||||||
|
fn try_stop_display_manager(dm: &str) -> bool {
|
||||||
|
Command::new("systemctl")
|
||||||
|
.args(["stop", dm])
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The distro's session-switch helper (ChimeraOS/Nobara layout). Its USER pass records the
|
||||||
|
/// sentinel + self-pkexecs (authorized `allow_any` by the distro's own polkit action policy, so
|
||||||
|
/// it works from our sessionless context); its ROOT pass rewrites the DM autologin config — but
|
||||||
|
/// only while the DM is RUNNING, which is why the takeover must restart the DM before calling it.
|
||||||
|
const OS_SESSION_SELECT: &str = "/usr/libexec/os-session-select";
|
||||||
|
|
||||||
|
/// The sentinel Steam's `steamos-session-select` writes in its user pass
|
||||||
|
/// (`~/.config/steamos-session-select`) — see [`SESSION_SELECT_BASELINE`].
|
||||||
|
fn session_select_sentinel() -> Option<std::path::PathBuf> {
|
||||||
|
let home = std::env::var("HOME").ok()?;
|
||||||
|
Some(
|
||||||
|
std::path::Path::new(&home)
|
||||||
|
.join(".config")
|
||||||
|
.join("steamos-session-select"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current mtime of the session-select sentinel (`None` when it doesn't exist yet).
|
||||||
|
fn session_select_mtime() -> Option<std::time::SystemTime> {
|
||||||
|
let path = session_select_sentinel()?;
|
||||||
|
std::fs::metadata(path).ok()?.modified().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record the sentinel baseline at managed-session launch, so a LATER write (the user's in-stream
|
||||||
|
/// "Switch to Desktop") is distinguishable from the switch that led into this session.
|
||||||
|
fn record_session_select_baseline() {
|
||||||
|
*SESSION_SELECT_BASELINE
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner()) = session_select_mtime();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Did a session-select run inside the managed session since its launch (sentinel newer than the
|
||||||
|
/// recorded baseline, or newly created)? Inside a managed game session the only switch Steam
|
||||||
|
/// offers is TO the desktop, so an advanced sentinel reads as that request.
|
||||||
|
fn session_select_requested() -> bool {
|
||||||
|
let baseline = *SESSION_SELECT_BASELINE
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner());
|
||||||
|
match (baseline, session_select_mtime()) {
|
||||||
|
(Some(base), Some(now)) => now > base,
|
||||||
|
(None, Some(_)) => true, // created during the session
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Honor the user's in-stream "Switch to Desktop" under a DM-stop takeover. The OS flow was a
|
||||||
|
/// silent no-op (the switch script's config rewrite requires a running DM, which the takeover
|
||||||
|
/// stopped), so replay it with the DM up — every verb live-validated on the Nobara repro VM:
|
||||||
|
/// 1. consume the takeover and start the DM (its autologin heads back into game mode briefly —
|
||||||
|
/// the config still names it);
|
||||||
|
/// 2. run the distro's own `os-session-select desktop` as the user (its internal pkexec is
|
||||||
|
/// `allow_any`-authorized), which rewrites the DM autologin config to the desktop session;
|
||||||
|
/// 3. stop the autologin gamescope unit — the login session exits, and `Relogin=true` relogs
|
||||||
|
/// into the now-selected desktop.
|
||||||
|
///
|
||||||
|
/// The caller then refuses managed relaunches for [`SWITCH_HONOR_GRACE`] so the capture-loss
|
||||||
|
/// re-detection follows the desktop compositor once it's up instead of racing it.
|
||||||
|
fn honor_session_select_switch(dm: String) {
|
||||||
|
tracing::info!(
|
||||||
|
%dm,
|
||||||
|
"gamescope: in-stream session-select detected — restoring the display manager and \
|
||||||
|
switching the box to the desktop session"
|
||||||
|
);
|
||||||
|
// Consume the takeover state up front: from here on the box is the DM's again.
|
||||||
|
std::mem::take(&mut *STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()));
|
||||||
|
clear_takeover();
|
||||||
|
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||||
|
stop_session(SESSION_UNIT); // dead already (the switch shut its Steam down) — clear the unit
|
||||||
|
let _ = Command::new("systemctl")
|
||||||
|
.args(["reset-failed", &dm])
|
||||||
|
.status();
|
||||||
|
let _ = Command::new("systemctl").args(["start", &dm]).status();
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(10);
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
let active = Command::new("systemctl")
|
||||||
|
.args(["is-active", &dm])
|
||||||
|
.output()
|
||||||
|
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "active")
|
||||||
|
.unwrap_or(false);
|
||||||
|
if active {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(500));
|
||||||
|
}
|
||||||
|
// Rewrite the autologin session via the distro's own switch helper (needs the DM running).
|
||||||
|
// Absent/failing helper degrades to a plain DM restore — the box lands back in game mode on
|
||||||
|
// glass and the stream follows that instead (no black screen either way).
|
||||||
|
if std::path::Path::new(OS_SESSION_SELECT).exists() {
|
||||||
|
match Command::new(OS_SESSION_SELECT).arg("desktop").status() {
|
||||||
|
Ok(s) if s.success() => {
|
||||||
|
// The relogin only fires when the CURRENT (game-mode) login session exits: wait
|
||||||
|
// for its autologin unit to come up, then stop it. Never mask here — the mask is
|
||||||
|
// what start-limit-kills this DM flavor.
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(15);
|
||||||
|
loop {
|
||||||
|
if let Some(unit) = running_autologin_gamescope_unit() {
|
||||||
|
systemctl_user(&["stop", &unit]);
|
||||||
|
tracing::info!(
|
||||||
|
%unit,
|
||||||
|
"gamescope: desktop selected — stopped the game-mode session so the \
|
||||||
|
DM relogs into the desktop"
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
tracing::warn!(
|
||||||
|
"gamescope: game-mode session never appeared after the DM restart — \
|
||||||
|
the desktop switch may need a manual session exit"
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(500));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => tracing::warn!(
|
||||||
|
status = ?other,
|
||||||
|
"gamescope: os-session-select failed — leaving the box in its configured session"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::warn!(
|
||||||
|
"gamescope: no {OS_SESSION_SELECT} on this box — restored the DM into its configured \
|
||||||
|
session instead of switching to the desktop"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
record_session_select_baseline();
|
||||||
|
*SWITCH_HONORED_AT.lock().unwrap_or_else(|e| e.into_inner()) = Some(Instant::now());
|
||||||
|
}
|
||||||
|
|
||||||
/// Stop every autologin gaming-mode session (`gamescope-session-plus@*.service`) so its
|
/// Stop every autologin gaming-mode session (`gamescope-session-plus@*.service`) so its
|
||||||
/// single-instance Steam is free for our own host-managed session. Records the units so
|
/// single-instance Steam is free for our own host-managed session. Records the units so
|
||||||
/// [`schedule_restore_tv_session`] can restart them on disconnect. Our own session is the transient
|
/// [`schedule_restore_tv_session`] can restart them on disconnect. Our own session is the transient
|
||||||
/// `punktfunk-gamescope` unit (not a `@`-instance), so it's never matched here. No-op when nothing
|
/// `punktfunk-gamescope` unit (not a `@`-instance), so it's never matched here. No-op when nothing
|
||||||
/// is autologged in (e.g. a box that boots headless). Each unit is **masked first** ([`mask_unit`] —
|
/// is autologged in (e.g. a box that boots headless).
|
||||||
/// SDDM's `Relogin=true` would otherwise restart it instantly), then torn down with **SIGKILL**
|
///
|
||||||
/// ([`kill_unit`]) to avoid the F44 GPU-context leak that the autologin's SIGTERM stop triggers.
|
/// The teardown is DM-flavor-aware ([`dm_survives_masked_unit`]):
|
||||||
/// Matches every loaded instance, not just `running` ones — under the SDDM relogin churn the unit
|
/// * **SDDM / no DM**: each unit is **masked first** ([`mask_unit`] — SDDM's `Relogin=true` would
|
||||||
/// flaps through `activating`/`failed` between cycles, and an unmasked flapping unit re-enters the
|
/// otherwise restart it instantly), then torn down with **SIGKILL** ([`kill_unit`]) to avoid the
|
||||||
/// fight the moment the supervisor restarts it.
|
/// F44 GPU-context leak that the autologin's SIGTERM stop triggers. Matches every loaded
|
||||||
fn stop_autologin_sessions() {
|
/// instance, not just `running` ones — under the SDDM relogin churn the unit flaps through
|
||||||
|
/// `activating`/`failed` between cycles, and an unmasked flapping unit re-enters the fight the
|
||||||
|
/// moment the supervisor restarts it.
|
||||||
|
/// * **Mask-fragile DM** (Nobara's `plasmalogin`, unknown DMs): masking start-limit-kills the DM
|
||||||
|
/// itself (permanent black screen), so instead **stop the DM** — no supervisor left to relogin —
|
||||||
|
/// then SIGKILL the units unmasked. Needs privilege (root / an operator polkit rule); without it
|
||||||
|
/// nothing is touched and the error tells the caller to degrade to ATTACH (mirror the box's own
|
||||||
|
/// session) rather than destabilize the seat.
|
||||||
|
fn stop_autologin_sessions() -> Result<()> {
|
||||||
let Ok(out) = Command::new("systemctl")
|
let Ok(out) = Command::new("systemctl")
|
||||||
.args([
|
.args([
|
||||||
"--user",
|
"--user",
|
||||||
@@ -995,26 +1311,65 @@ fn stop_autologin_sessions() {
|
|||||||
])
|
])
|
||||||
.output()
|
.output()
|
||||||
else {
|
else {
|
||||||
return;
|
return Ok(());
|
||||||
};
|
};
|
||||||
let mut stopped = Vec::new();
|
// `(unit, ACTIVE state)` — the `--plain` columns are UNIT LOAD ACTIVE SUB DESCRIPTION.
|
||||||
for line in String::from_utf8_lossy(&out.stdout).lines() {
|
let listed: Vec<(String, String)> = String::from_utf8_lossy(&out.stdout)
|
||||||
if let Some(unit) = line.split_whitespace().next() {
|
.lines()
|
||||||
if unit.starts_with("gamescope-session-plus@") && unit.ends_with(".service") {
|
.filter_map(|l| {
|
||||||
mask_unit(unit); // block the SDDM relogin loop from restarting it mid-stream
|
let mut cols = l.split_whitespace();
|
||||||
kill_unit(unit); // SIGKILL teardown — avoid the F44 GPU-context leak
|
let unit = cols.next()?;
|
||||||
tracing::info!(
|
let active = cols.nth(1).unwrap_or("");
|
||||||
unit,
|
(unit.starts_with("gamescope-session-plus@") && unit.ends_with(".service"))
|
||||||
"freed Steam: masked + SIGKILL-stopped the autologin gaming session for this stream"
|
.then(|| (unit.to_string(), active.to_string()))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if listed.is_empty() {
|
||||||
|
return Ok(()); // nothing autologged in — Steam is already free
|
||||||
|
}
|
||||||
|
let dm = display_manager_unit();
|
||||||
|
let mask_safe = dm.as_deref().is_none_or(dm_survives_masked_unit);
|
||||||
|
if !mask_safe {
|
||||||
|
// Only a LIVE instance holds Steam / justifies touching the DM. A loaded-but-inactive
|
||||||
|
// leftover (the box switched back to the desktop earlier) must not stop the DM — that
|
||||||
|
// would kill the user's live desktop to free nothing.
|
||||||
|
if !listed
|
||||||
|
.iter()
|
||||||
|
.any(|(_, active)| matches!(active.as_str(), "active" | "activating"))
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let dm = dm.expect("!is_none_or ⇒ Some");
|
||||||
|
if !try_stop_display_manager(&dm) {
|
||||||
|
bail!(
|
||||||
|
"the box's gaming session is driven by {dm}, which does not survive a masked \
|
||||||
|
session unit, and stopping it needs privilege — install the punktfunk \
|
||||||
|
display-manager polkit rule (see docs) to enable the managed takeover"
|
||||||
);
|
);
|
||||||
stopped.push(unit.to_string());
|
|
||||||
}
|
}
|
||||||
|
tracing::info!(
|
||||||
|
%dm,
|
||||||
|
"freed Steam: stopped the display manager for this stream (mask-fragile DM flavor)"
|
||||||
|
);
|
||||||
|
*STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()) = Some(dm);
|
||||||
}
|
}
|
||||||
|
let units: Vec<String> = listed.into_iter().map(|(u, _)| u).collect();
|
||||||
|
let mut stopped = Vec::new();
|
||||||
|
for unit in units {
|
||||||
|
if mask_safe {
|
||||||
|
mask_unit(&unit); // block the SDDM relogin loop from restarting it mid-stream
|
||||||
|
}
|
||||||
|
kill_unit(&unit); // SIGKILL teardown — avoid the F44 GPU-context leak
|
||||||
|
tracing::info!(
|
||||||
|
%unit,
|
||||||
|
masked = mask_safe,
|
||||||
|
"freed Steam: stopped the autologin gaming session for this stream"
|
||||||
|
);
|
||||||
|
stopped.push(unit);
|
||||||
}
|
}
|
||||||
if !stopped.is_empty() {
|
|
||||||
*STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()) = stopped;
|
*STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()) = stopped;
|
||||||
persist_takeover(); // A3: survive a host crash mid-stream
|
persist_takeover(); // A3: survive a host crash mid-stream
|
||||||
}
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How long a desktop Steam gets to honor `steam -shutdown` before the spawn fails. Steam tears
|
/// How long a desktop Steam gets to honor `steam -shutdown` before the spawn fails. Steam tears
|
||||||
@@ -1159,7 +1514,16 @@ pub fn schedule_restore_tv_session() {
|
|||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(|e| e.into_inner())
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
.is_empty()
|
.is_empty()
|
||||||
&& !*STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner());
|
&& !*STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner())
|
||||||
|
&& STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()).is_none()
|
||||||
|
// A managed session that took nothing over (started beside a live desktop — e.g. a client
|
||||||
|
// gamescope pin on a KDE box) still owns the transient SESSION_UNIT: without this arm it
|
||||||
|
// was ORPHANED forever after disconnect ("closing the app does not end the session",
|
||||||
|
// field report 2026-07-24) — the restore stops it even with no autologin to bring back.
|
||||||
|
&& MANAGED_SESSION
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.is_none();
|
||||||
if nothing_to_restore {
|
if nothing_to_restore {
|
||||||
return; // nothing was taken over → nothing to restore (also the non-managed path)
|
return; // nothing was taken over → nothing to restore (also the non-managed path)
|
||||||
}
|
}
|
||||||
@@ -1254,8 +1618,24 @@ fn do_restore_tv_session() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let units = std::mem::take(&mut *STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()));
|
let units = std::mem::take(&mut *STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()));
|
||||||
if units.is_empty() {
|
let dm = std::mem::take(&mut *STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()));
|
||||||
return; // nothing was stolen → nothing to restore (also the non-Bazzite path)
|
if units.is_empty() && dm.is_none() {
|
||||||
|
// Nothing was stolen — but a managed session that started BESIDE a live desktop (client
|
||||||
|
// gamescope pin on a KDE box) still owns the transient unit; stop it so it doesn't run
|
||||||
|
// orphaned forever after the disconnect. No-op when the unit isn't running.
|
||||||
|
if MANAGED_SESSION
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.take()
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
stop_session(SESSION_UNIT);
|
||||||
|
tracing::info!(
|
||||||
|
"gamescope: stopped the idle managed session (nothing was taken over — no box \
|
||||||
|
session to restore)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
clear_takeover(); // A3: takeover consumed — drop the persisted crash-restore marker
|
clear_takeover(); // A3: takeover consumed — drop the persisted crash-restore marker
|
||||||
stop_session(SESSION_UNIT); // our gamescope/Steam session, so Steam is free for the autologin
|
stop_session(SESSION_UNIT); // our gamescope/Steam session, so Steam is free for the autologin
|
||||||
@@ -1266,18 +1646,54 @@ fn do_restore_tv_session() {
|
|||||||
}
|
}
|
||||||
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||||
// Only bring the gaming autologin BACK if the box is still meant to be in gaming mode. If the
|
// Only bring the gaming autologin BACK if the box is still meant to be in gaming mode. If the
|
||||||
// user switched to a desktop session (KDE/GNOME/wlroots) in the meantime, don't yank them back
|
// user switched to a desktop session (KDE/GNOME/wlroots/Hyprland) in the meantime, don't yank
|
||||||
// to gaming — leave the desktop alone. (We still stopped our idle managed session above.)
|
// them back to gaming — leave the desktop alone. (We still stopped our idle managed session
|
||||||
|
// above.)
|
||||||
use super::ActiveKind;
|
use super::ActiveKind;
|
||||||
if matches!(
|
if matches!(
|
||||||
super::detect_active_session().kind,
|
super::detect_active_session().kind,
|
||||||
ActiveKind::DesktopKde | ActiveKind::DesktopGnome | ActiveKind::DesktopWlroots
|
ActiveKind::DesktopKde
|
||||||
|
| ActiveKind::DesktopGnome
|
||||||
|
| ActiveKind::DesktopWlroots
|
||||||
|
| ActiveKind::DesktopHyprland
|
||||||
) {
|
) {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"gamescope: a desktop session is active — not restoring the TV gaming session"
|
"gamescope: a desktop session is active — not restoring the TV gaming session"
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Mask-fragile-DM takeover: the gamescope unit CANNOT be `--user start`ed back — without a DM
|
||||||
|
// login session there is no seat, so gamescope never gets DRM master (unit goes `failed`,
|
||||||
|
// screen stays black — live-proven on the Nobara repro VM). Restore the DM instead
|
||||||
|
// (`reset-failed` clears the relogin start-limit accounting, then `restart`); its autologin
|
||||||
|
// session Exec starts the gamescope unit itself.
|
||||||
|
if let Some(dm) = dm {
|
||||||
|
let _ = Command::new("systemctl")
|
||||||
|
.args(["reset-failed", &dm])
|
||||||
|
.status();
|
||||||
|
let restart = Command::new("systemctl")
|
||||||
|
.args(["restart", &dm])
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if restart {
|
||||||
|
tracing::info!(%dm, "restored the display manager (its autologin brings gaming mode back)");
|
||||||
|
} else if crate::try_recover_session() {
|
||||||
|
tracing::warn!(
|
||||||
|
%dm,
|
||||||
|
"display-manager restart lost its privilege — fired PUNKTFUNK_RECOVER_SESSION_CMD \
|
||||||
|
to bring the session back"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tracing::error!(
|
||||||
|
%dm,
|
||||||
|
"could not restart the display manager and no PUNKTFUNK_RECOVER_SESSION_CMD is \
|
||||||
|
configured — the box has no graphical session until someone runs \
|
||||||
|
`systemctl reset-failed {dm} && systemctl restart {dm}` as root"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
for unit in units {
|
for unit in units {
|
||||||
let _ = Command::new("systemctl")
|
let _ = Command::new("systemctl")
|
||||||
.args(["--user", "start", &unit])
|
.args(["--user", "start", &unit])
|
||||||
@@ -1641,10 +2057,35 @@ impl Drop for GamescopeProc {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
cgroup_is_punktfunk_owned, connected_connector_under, is_steam_launch,
|
cgroup_is_punktfunk_owned, connected_connector_under, display_manager_unit_under,
|
||||||
shape_dedicated_command,
|
dm_survives_masked_unit, is_steam_launch, shape_dedicated_command,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn display_manager_flavor_detection() {
|
||||||
|
let base = std::env::temp_dir().join(format!("pf-dm-scan-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&base).unwrap();
|
||||||
|
// No alias symlink (no DM installed — getty autologin boxes) → None.
|
||||||
|
assert_eq!(display_manager_unit_under(&base), None);
|
||||||
|
// The Fedora-style alias symlink resolves to its target's basename (read_link, not
|
||||||
|
// canonicalize — the target needn't exist on the build box).
|
||||||
|
std::os::unix::fs::symlink(
|
||||||
|
"/usr/lib/systemd/system/plasmalogin.service",
|
||||||
|
base.join("display-manager.service"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
display_manager_unit_under(&base).as_deref(),
|
||||||
|
Some("plasmalogin.service")
|
||||||
|
);
|
||||||
|
// Only SDDM is proven to survive a masked session unit; plasmalogin start-limit-kills
|
||||||
|
// itself (live-proven), and unknown DMs default to fragile.
|
||||||
|
assert!(dm_survives_masked_unit("sddm.service"));
|
||||||
|
assert!(!dm_survives_masked_unit("plasmalogin.service"));
|
||||||
|
assert!(!dm_survives_masked_unit("gdm.service"));
|
||||||
|
std::fs::remove_dir_all(&base).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn connector_status_scan() {
|
fn connector_status_scan() {
|
||||||
let base = std::env::temp_dir().join(format!("pf-drm-scan-{}", std::process::id()));
|
let base = std::env::temp_dir().join(format!("pf-drm-scan-{}", std::process::id()));
|
||||||
|
|||||||
@@ -149,11 +149,7 @@ impl VirtualDisplay for KwinDisplay {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
// kscreen-doctor position syntax: `output.<name-or-id>.position.<x>,<y>`.
|
// kscreen-doctor position syntax: `output.<name-or-id>.position.<x>,<y>`.
|
||||||
let ok = std::process::Command::new("kscreen-doctor")
|
let ok = kscreen_ok(&[format!("output.{output}.position.{x},{y}")]);
|
||||||
.arg(format!("output.{output}.position.{x},{y}"))
|
|
||||||
.status()
|
|
||||||
.map(|s| s.success())
|
|
||||||
.unwrap_or(false);
|
|
||||||
if ok {
|
if ok {
|
||||||
tracing::info!(output, x, y, "KWin: placed output in the desktop layout");
|
tracing::info!(output, x, y, "KWin: placed output in the desktop layout");
|
||||||
} else {
|
} else {
|
||||||
@@ -210,14 +206,16 @@ impl VirtualDisplay for KwinDisplay {
|
|||||||
// resize handling: when the source's texture size changes while recording, KWin re-runs
|
// resize handling: when the source's texture size changes while recording, KWin re-runs
|
||||||
// `buildFormats` — picking up the output's CURRENT refresh — and renegotiates the live
|
// `buildFormats` — picking up the output's CURRENT refresh — and renegotiates the live
|
||||||
// stream via `pw_stream_update_params`. So above 60 Hz the output is born at a
|
// stream via `pw_stream_update_params`. So above 60 Hz the output is born at a
|
||||||
// SACRIFICIAL height: installing + selecting the real `WxH@hz` custom mode (supported on
|
// SACRIFICIAL height: installing + selecting the real high-refresh custom mode (supported
|
||||||
// virtual outputs since KWin 6.6) then changes the SIZE, and the first buffers recorded
|
// on virtual outputs since KWin 6.6) then changes the SIZE, and the first buffers recorded
|
||||||
// after the consumer connects trigger KWin's resize → a renegotiation to `WxH@hz`. The
|
// after the consumer connects trigger KWin's resize → a renegotiation to that mode. The
|
||||||
// capturer holds frames until that lands (`expect_exact_dims`), so the pipeline never
|
// capturer holds frames until that lands (`expect_exact_dims`), so the pipeline never
|
||||||
// builds against the birth mode. First cut shells out to kscreen-doctor; the in-process
|
// builds against the birth mode. First cut shells out to kscreen-doctor; the in-process
|
||||||
// kde_output_management_v2 client is a follow-up. `set_custom_refresh` reads back what
|
// kde_output_management_v2 client is a follow-up. `set_custom_refresh` reads back what
|
||||||
// KWin *actually* gave so the encoder paces to the real source rate. At ≤60 Hz there's
|
// KWin *actually* gave — both the rate (so the encoder paces to the real source) and the
|
||||||
// nothing to install — the output is born at the real size and 60 Hz is the offer anyway.
|
// size, which KWin's CVT generator may have aligned down (see `CVT_H_GRANULARITY`). At
|
||||||
|
// ≤60 Hz there's nothing to install — the output is born at the real size and 60 Hz is the
|
||||||
|
// offer anyway.
|
||||||
let want_high = mode.refresh_hz > 60;
|
let want_high = mode.refresh_hz > 60;
|
||||||
let birth_h = if want_high { height + 16 } else { height };
|
let birth_h = if want_high { height + 16 } else { height };
|
||||||
let (mut node_id, mut stop) = spawn_vout(width, birth_h)?;
|
let (mut node_id, mut stop) = spawn_vout(width, birth_h)?;
|
||||||
@@ -241,18 +239,33 @@ impl VirtualDisplay for KwinDisplay {
|
|||||||
let mut addr = resolve_kscreen_addr(&name, width, birth_h);
|
let mut addr = resolve_kscreen_addr(&name, width, birth_h);
|
||||||
self.last_name = Some(addr.clone()); // for apply_position (registry-driven §6.2 layout)
|
self.last_name = Some(addr.clone()); // for apply_position (registry-driven §6.2 layout)
|
||||||
let mut expect_exact_dims = false;
|
let mut expect_exact_dims = false;
|
||||||
|
// The size the output actually ENDS UP at — the request, unless KWin's CVT generator had to
|
||||||
|
// shrink the width to the cell grain (see `CVT_H_GRANULARITY`). Reported as the output's
|
||||||
|
// `preferred_mode`, which is what the capturer's renegotiation gate waits for and what the
|
||||||
|
// encoder opens against, so a CVT-aligned mode flows end-to-end instead of starving.
|
||||||
|
let mut final_dims = (width, height);
|
||||||
let achieved_hz = if want_high {
|
let achieved_hz = if want_high {
|
||||||
let (achieved, size_applied) =
|
let active = set_custom_refresh(width, height, mode.refresh_hz, &addr);
|
||||||
set_custom_refresh(width, height, mode.refresh_hz, &addr);
|
// Accept only an active mode that IS our custom one: the exact requested height, and a
|
||||||
if size_applied {
|
// width at or just below the request (a CVT alignment). That also proves the output
|
||||||
// Real mode selected: the recording stream will renegotiate to it (see above).
|
// left the sacrificial birth size, so the recording stream will renegotiate to it.
|
||||||
|
match active {
|
||||||
|
Some((aw, ah, ahz))
|
||||||
|
if ah == height && aw <= width && width - aw < CVT_H_GRANULARITY =>
|
||||||
|
{
|
||||||
expect_exact_dims = true;
|
expect_exact_dims = true;
|
||||||
achieved
|
final_dims = (aw, ah);
|
||||||
} else {
|
ahz
|
||||||
|
}
|
||||||
|
other => {
|
||||||
// Custom-mode install/select rejected (pre-6.6 KWin / stale kscreen-doctor): the
|
// Custom-mode install/select rejected (pre-6.6 KWin / stale kscreen-doctor): the
|
||||||
// output is STUCK at the sacrificial birth size — unusable. Recreate plain at the
|
// output is STUCK at the sacrificial birth size — unusable. Recreate plain at the
|
||||||
// real size (the pre-sacrifice behavior: correct size, KWin's native 60 Hz).
|
// real size (the pre-sacrifice behavior: correct size, KWin's native 60 Hz).
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
|
active = ?other,
|
||||||
|
requested_w = width,
|
||||||
|
requested_h = height,
|
||||||
|
requested_hz = mode.refresh_hz,
|
||||||
"KWin rejected the custom mode — recreating the virtual output at the real \
|
"KWin rejected the custom mode — recreating the virtual output at the real \
|
||||||
size (60 Hz ceiling on this KWin)"
|
size (60 Hz ceiling on this KWin)"
|
||||||
);
|
);
|
||||||
@@ -272,6 +285,7 @@ impl VirtualDisplay for KwinDisplay {
|
|||||||
);
|
);
|
||||||
60
|
60
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
mode.refresh_hz
|
mode.refresh_hz
|
||||||
};
|
};
|
||||||
@@ -302,7 +316,7 @@ impl VirtualDisplay for KwinDisplay {
|
|||||||
// (it owns the display group, so it computes auto-row / manual placement over the whole group).
|
// (it owns the display group, so it computes auto-row / manual placement over the whole group).
|
||||||
let mut out = VirtualOutput::owned(
|
let mut out = VirtualOutput::owned(
|
||||||
node_id,
|
node_id,
|
||||||
Some((mode.width, mode.height, achieved_hz)),
|
Some((final_dims.0, final_dims.1, achieved_hz)),
|
||||||
Box::new(StopGuard { stop }),
|
Box::new(StopGuard { stop }),
|
||||||
);
|
);
|
||||||
out.expect_exact_dims = expect_exact_dims;
|
out.expect_exact_dims = expect_exact_dims;
|
||||||
@@ -324,9 +338,7 @@ fn reenable_outputs(outputs: &[(String, String)]) {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|(name, _)| format!("output.{name}.enable"))
|
.map(|(name, _)| format!("output.{name}.enable"))
|
||||||
.collect();
|
.collect();
|
||||||
let _ = std::process::Command::new("kscreen-doctor")
|
let _ = kscreen_ok(&enable_args);
|
||||||
.args(&enable_args)
|
|
||||||
.status();
|
|
||||||
// THEN re-assert each captured mode, best-effort — a bare re-enable lets KWin fall back to the
|
// THEN re-assert each captured mode, best-effort — a bare re-enable lets KWin fall back to the
|
||||||
// EDID-preferred mode (a 120 Hz panel returns at ~60 Hz); this restores the exact refresh. The
|
// EDID-preferred mode (a 120 Hz panel returns at ~60 Hz); this restores the exact refresh. The
|
||||||
// output is enabled now, so the mode set is valid; a rejected mode just leaves KWin's default.
|
// output is enabled now, so the mode set is valid; a rejected mode just leaves KWin's default.
|
||||||
@@ -336,9 +348,7 @@ fn reenable_outputs(outputs: &[(String, String)]) {
|
|||||||
.map(|(name, mode)| format!("output.{name}.mode.{mode}"))
|
.map(|(name, mode)| format!("output.{name}.mode.{mode}"))
|
||||||
.collect();
|
.collect();
|
||||||
if !mode_args.is_empty() {
|
if !mode_args.is_empty() {
|
||||||
let _ = std::process::Command::new("kscreen-doctor")
|
let _ = kscreen_ok(&mode_args);
|
||||||
.args(&mode_args)
|
|
||||||
.status();
|
|
||||||
}
|
}
|
||||||
std::thread::sleep(Duration::from_millis(200));
|
std::thread::sleep(Duration::from_millis(200));
|
||||||
tracing::info!(reenabled = ?outputs, "KWin: restored the physical/bootstrap outputs at their captured modes (group empty)");
|
tracing::info!(reenabled = ?outputs, "KWin: restored the physical/bootstrap outputs at their captured modes (group empty)");
|
||||||
@@ -386,13 +396,37 @@ fn resolve_kscreen_addr(name: &str, w: u32, h: u32) -> String {
|
|||||||
fallback
|
fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Budget for one `kscreen-doctor` call.
|
||||||
|
///
|
||||||
|
/// It is a Wayland client of the very compositor it configures, so against a wedged KWin it blocks
|
||||||
|
/// in its own connect and never returns — and these calls run on the session's stream thread, whose
|
||||||
|
/// only way to end a session is to return. Generous next to a healthy call (tens of ms).
|
||||||
|
const KSCREEN_BUDGET: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
/// `kscreen-doctor <args>` run for its exit status, bounded by [`KSCREEN_BUDGET`]. A timeout reads
|
||||||
|
/// as a failed apply — the same best-effort path a rejected argument already takes.
|
||||||
|
fn kscreen_ok(args: &[String]) -> bool {
|
||||||
|
crate::proc::status_within(
|
||||||
|
std::process::Command::new("kscreen-doctor").args(args),
|
||||||
|
KSCREEN_BUDGET,
|
||||||
|
)
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `kscreen-doctor -j` stdout, bounded by [`KSCREEN_BUDGET`]; `None` on any failure.
|
||||||
|
fn kscreen_json_bytes() -> Option<Vec<u8>> {
|
||||||
|
crate::proc::output_within(
|
||||||
|
std::process::Command::new("kscreen-doctor").arg("-j"),
|
||||||
|
KSCREEN_BUDGET,
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
.map(|o| o.stdout)
|
||||||
|
}
|
||||||
|
|
||||||
/// `kscreen-doctor -j` parsed, `None` on any failure.
|
/// `kscreen-doctor -j` parsed, `None` on any failure.
|
||||||
fn kscreen_json() -> Option<serde_json::Value> {
|
fn kscreen_json() -> Option<serde_json::Value> {
|
||||||
let out = std::process::Command::new("kscreen-doctor")
|
serde_json::from_slice(&kscreen_json_bytes()?).ok()
|
||||||
.arg("-j")
|
|
||||||
.output()
|
|
||||||
.ok()?;
|
|
||||||
serde_json::from_slice(&out.stdout).ok()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `(width, height)` of an output's CURRENT mode from its `kscreen-doctor -j` entry.
|
/// The `(width, height)` of an output's CURRENT mode from its `kscreen-doctor -j` entry.
|
||||||
@@ -415,40 +449,184 @@ fn output_active_size(o: &serde_json::Value) -> Option<(u32, u32)> {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Best-effort: install + select the `width`x`height`@`hz` custom mode on the just-created
|
/// CVT's horizontal cell granularity. KWin generates every custom mode's timing with **libxcvt**,
|
||||||
/// virtual output via `kscreen-doctor` (`output` is the RESOLVED kscreen address — numeric id or
|
/// whose first step is `hdisplay_rnd = hdisplay - (hdisplay % 8)` — so a width that isn't a multiple
|
||||||
/// name, see [`resolve_kscreen_addr`] — refresh given in mHz), then **read back the active mode**
|
/// of 8 comes back NARROWER than asked, and the clock-step rounding that follows lands a fractional
|
||||||
/// and return `(achieved_hz, size_applied)`. The apply command can report success yet leave the
|
/// refresh. A 2868x1320@120 request (an iPhone 16 Pro Max panel) becomes **2864x1320@119.92**.
|
||||||
/// output on its old mode (rejected), and a silent rate mismatch surfaces downstream as judder /
|
///
|
||||||
/// duplicated frames — so the caller paces the encoder to the *achieved* rate, not the requested
|
/// That is why a custom mode must never be selected by the `WxH@Hz` string we *requested*:
|
||||||
/// one. `size_applied` tells the sacrificial-birth caller (see `create`) whether the SIZE half of
|
/// kscreen-doctor's `findMode` matches a mode's id or its own `WxH@qRound(Hz)` name, so
|
||||||
/// the mode actually landed — that, not the refresh, is what triggers KWin's stream
|
/// `2868x1320@120` matches nothing, the select silently no-ops, the output stays on its sacrificial
|
||||||
/// renegotiation.
|
/// birth mode, and the caller falls back to 60 Hz — while KDE's display list shows the perfectly
|
||||||
fn set_custom_refresh(width: u32, height: u32, hz: u32, output: &str) -> (u32, bool) {
|
/// good 2864x1320@119.92 mode sitting there unselected. Widths like 1920/2560/3840 are all
|
||||||
|
/// multiples of 8, which is why only phone-shaped clients ever hit it.
|
||||||
|
const CVT_H_GRANULARITY: u32 = 8;
|
||||||
|
|
||||||
|
/// One row of an output's mode list, as parsed from `kscreen-doctor -j`.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
struct KModeRow {
|
||||||
|
/// kscreen's mode id — what we address the mode by (never the requested `WxH@Hz` string).
|
||||||
|
id: String,
|
||||||
|
w: u32,
|
||||||
|
h: u32,
|
||||||
|
hz: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A kscreen JSON id, which is a string on some KWin versions and a number on others.
|
||||||
|
fn json_id(v: &serde_json::Value) -> Option<String> {
|
||||||
|
v.as_str()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.or_else(|| v.as_u64().map(|n| n.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The full mode list of `output` (a RESOLVED kscreen address — numeric id or name) from a parsed
|
||||||
|
/// `kscreen-doctor -j` document. Split from the process call so the picker can be tested on
|
||||||
|
/// captured JSON.
|
||||||
|
fn modes_from_json(doc: &serde_json::Value, output: &str) -> Vec<KModeRow> {
|
||||||
|
let Some(o) = doc
|
||||||
|
.get("outputs")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.and_then(|outs| {
|
||||||
|
outs.iter().find(|o| {
|
||||||
|
o.get("name").and_then(|n| n.as_str()) == Some(output)
|
||||||
|
|| o.get("id").and_then(json_id).as_deref() == Some(output)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
o.get("modes")
|
||||||
|
.and_then(|m| m.as_array())
|
||||||
|
.map(|ms| {
|
||||||
|
ms.iter()
|
||||||
|
.filter_map(|m| {
|
||||||
|
let size = m.get("size")?;
|
||||||
|
Some(KModeRow {
|
||||||
|
id: m.get("id").and_then(json_id)?,
|
||||||
|
w: size.get("width").and_then(|v| v.as_u64())? as u32,
|
||||||
|
h: size.get("height").and_then(|v| v.as_u64())? as u32,
|
||||||
|
hz: m.get("refreshRate").and_then(|r| r.as_f64())?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`modes_from_json`] against a live `kscreen-doctor -j`.
|
||||||
|
fn output_modes(output: &str) -> Vec<KModeRow> {
|
||||||
|
kscreen_json()
|
||||||
|
.map(|doc| modes_from_json(&doc, output))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mode in `modes` that actually fulfils a `width`x`height`@`hz` request, tolerating the CVT
|
||||||
|
/// alignment KWin applies when it generates the timing (see [`CVT_H_GRANULARITY`]): the height must
|
||||||
|
/// match exactly (CVT never touches the vertical active), the width may be up to one cell narrower
|
||||||
|
/// than asked (never wider — that would be a different mode), and the refresh must land within 1 Hz
|
||||||
|
/// of the request (which excludes the output's native 60 Hz entry for every rate we install a custom
|
||||||
|
/// mode for). Widest wins, then fastest — so an exact-width mode always beats an aligned one, and a
|
||||||
|
/// list carrying duplicate custom modes from earlier sessions still resolves.
|
||||||
|
fn pick_custom_mode<'a>(
|
||||||
|
modes: &'a [KModeRow],
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
hz: u32,
|
||||||
|
) -> Option<&'a KModeRow> {
|
||||||
|
modes
|
||||||
|
.iter()
|
||||||
|
.filter(|m| {
|
||||||
|
m.h == height
|
||||||
|
&& m.w <= width
|
||||||
|
&& width - m.w < CVT_H_GRANULARITY
|
||||||
|
&& (m.hz - f64::from(hz)).abs() < 1.0
|
||||||
|
})
|
||||||
|
.max_by(|a, b| {
|
||||||
|
a.w.cmp(&b.w)
|
||||||
|
.then(a.hz.partial_cmp(&b.hz).unwrap_or(std::cmp::Ordering::Equal))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort: install + select the `width`x`height`@`hz` custom mode on the just-created virtual
|
||||||
|
/// output via `kscreen-doctor` (`output` is the RESOLVED kscreen address — numeric id or name, see
|
||||||
|
/// [`resolve_kscreen_addr`] — refresh given in mHz), then **read back the active mode** and return
|
||||||
|
/// it as `(width, height, refresh_hz)`. `None` if the read-back failed entirely.
|
||||||
|
///
|
||||||
|
/// The apply command can report success yet leave the output on its old mode (rejected), and a
|
||||||
|
/// silent size/rate mismatch surfaces downstream as a starved capture gate or judder — so the
|
||||||
|
/// caller drives the pipeline off the *achieved* mode, not the requested one. The mode is selected
|
||||||
|
/// by kscreen **mode id** resolved from the output's own list, never by the requested `WxH@Hz`
|
||||||
|
/// string, because KWin's CVT generator may hand back a slightly different one
|
||||||
|
/// ([`CVT_H_GRANULARITY`]).
|
||||||
|
fn set_custom_refresh(width: u32, height: u32, hz: u32, output: &str) -> Option<(u32, u32, u32)> {
|
||||||
let output = output.to_string();
|
let output = output.to_string();
|
||||||
let mhz = hz.saturating_mul(1000);
|
let mhz = hz.saturating_mul(1000);
|
||||||
let run = |arg: String| {
|
let run = |arg: String| kscreen_ok(&[arg]);
|
||||||
std::process::Command::new("kscreen-doctor")
|
// Install the mode only if the output doesn't already carry a usable one: kscreen-doctor
|
||||||
.arg(arg)
|
// APPENDS to the output's custom-mode list and KWin PERSISTS that list per output name
|
||||||
.status()
|
// (`kwinoutputconfig.json`, which is why the same per-slot name is reused across sessions) — so
|
||||||
.map(|s| s.success())
|
// re-adding on every connect would grow the user's display list without bound.
|
||||||
.unwrap_or(false)
|
let mut modes = output_modes(&output);
|
||||||
};
|
if pick_custom_mode(&modes, width, height, hz).is_none() {
|
||||||
// Add the custom mode (a fresh output has none), then select it.
|
|
||||||
let _ = run(format!(
|
let _ = run(format!(
|
||||||
"output.{output}.addCustomMode.{width}.{height}.{mhz}.full"
|
"output.{output}.addCustomMode.{width}.{height}.{mhz}.full"
|
||||||
));
|
));
|
||||||
let applied = run(format!("output.{output}.mode.{width}x{height}@{hz}"));
|
modes = output_modes(&output);
|
||||||
|
}
|
||||||
|
let applied = match pick_custom_mode(&modes, width, height, hz) {
|
||||||
|
Some(target) => {
|
||||||
|
if (target.w, target.h) != (width, height) {
|
||||||
|
tracing::info!(
|
||||||
|
output,
|
||||||
|
requested_w = width,
|
||||||
|
requested_h = height,
|
||||||
|
mode_w = target.w,
|
||||||
|
mode_h = target.h,
|
||||||
|
mode_hz = target.hz,
|
||||||
|
"KWin aligned the custom mode to the CVT cell grain — streaming at its size"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// By id first; the human `WxH@Hz` form (built from the mode's OWN size/refresh, not the
|
||||||
|
// request) is the fallback for builds whose ids don't round-trip through the CLI.
|
||||||
|
run(format!("output.{output}.mode.{}", target.id))
|
||||||
|
|| run(format!(
|
||||||
|
"output.{output}.mode.{}x{}@{}",
|
||||||
|
target.w,
|
||||||
|
target.h,
|
||||||
|
target.hz.round() as u32
|
||||||
|
))
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
tracing::warn!(
|
||||||
|
output,
|
||||||
|
requested_w = width,
|
||||||
|
requested_h = height,
|
||||||
|
requested_hz = hz,
|
||||||
|
offered = ?modes,
|
||||||
|
"KWin offers no mode matching the request after addCustomMode — is kscreen-doctor \
|
||||||
|
up to date, and KWin ≥ 6.6 (custom modes on virtual outputs)?"
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
match read_active_mode(&output) {
|
match read_active_mode(&output) {
|
||||||
Some((w, h, achieved)) => {
|
Some((w, h, achieved)) => {
|
||||||
let size_applied = (w, h) == (width, height);
|
if achieved >= hz && (w, h) == (width, height) {
|
||||||
if achieved >= hz && size_applied {
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
output,
|
output,
|
||||||
requested = hz,
|
requested = hz,
|
||||||
achieved,
|
achieved,
|
||||||
"KWin virtual output: custom refresh applied"
|
"KWin virtual output: custom refresh applied"
|
||||||
);
|
);
|
||||||
|
} else if achieved >= hz {
|
||||||
|
tracing::info!(
|
||||||
|
output,
|
||||||
|
requested = hz,
|
||||||
|
achieved,
|
||||||
|
active_w = w,
|
||||||
|
active_h = h,
|
||||||
|
"KWin virtual output: custom refresh applied at a CVT-aligned size"
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
output,
|
output,
|
||||||
@@ -461,7 +639,7 @@ fn set_custom_refresh(width: u32, height: u32, hz: u32, output: &str) -> (u32, b
|
|||||||
achieved rate (custom-mode install rejected? is kscreen-doctor up to date?)"
|
achieved rate (custom-mode install rejected? is kscreen-doctor up to date?)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
(achieved.max(1), size_applied)
|
Some((w, h, achieved.max(1)))
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@@ -471,7 +649,7 @@ fn set_custom_refresh(width: u32, height: u32, hz: u32, output: &str) -> (u32, b
|
|||||||
"could not read back KWin virtual output refresh — assuming 60 Hz (is \
|
"could not read back KWin virtual output refresh — assuming 60 Hz (is \
|
||||||
kscreen-doctor installed?)"
|
kscreen-doctor installed?)"
|
||||||
);
|
);
|
||||||
(60, false)
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -542,14 +720,11 @@ fn output_current_mode_spec(o: &serde_json::Value) -> Option<String> {
|
|||||||
/// session's own output — so a 2nd `exclusive` session (with a distinct per-slot name) never disables
|
/// session's own output — so a 2nd `exclusive` session (with a distinct per-slot name) never disables
|
||||||
/// the 1st session's live output. Parsed from `kscreen-doctor -j` (same source as [`read_active_mode`]).
|
/// the 1st session's live output. Parsed from `kscreen-doctor -j` (same source as [`read_active_mode`]).
|
||||||
fn other_enabled_outputs() -> Vec<(String, String)> {
|
fn other_enabled_outputs() -> Vec<(String, String)> {
|
||||||
let out = match std::process::Command::new("kscreen-doctor")
|
let out = match kscreen_json_bytes() {
|
||||||
.arg("-j")
|
Some(o) => o,
|
||||||
.output()
|
None => return Vec::new(),
|
||||||
{
|
|
||||||
Ok(o) => o,
|
|
||||||
Err(_) => return Vec::new(),
|
|
||||||
};
|
};
|
||||||
let doc: serde_json::Value = match serde_json::from_slice(&out.stdout) {
|
let doc: serde_json::Value = match serde_json::from_slice(&out) {
|
||||||
Ok(d) => d,
|
Ok(d) => d,
|
||||||
Err(_) => return Vec::new(),
|
Err(_) => return Vec::new(),
|
||||||
};
|
};
|
||||||
@@ -578,13 +753,10 @@ fn other_enabled_outputs() -> Vec<(String, String)> {
|
|||||||
/// then sets itself primary — the pre-group behavior). Recent kscreen marks the primary with
|
/// then sets itself primary — the pre-group behavior). Recent kscreen marks the primary with
|
||||||
/// `"priority": 1`; older builds used a `"primary": true` bool — accept either.
|
/// `"priority": 1`; older builds used a `"primary": true` bool — accept either.
|
||||||
fn a_managed_output_is_primary() -> bool {
|
fn a_managed_output_is_primary() -> bool {
|
||||||
let Ok(out) = std::process::Command::new("kscreen-doctor")
|
let Some(out) = kscreen_json_bytes() else {
|
||||||
.arg("-j")
|
|
||||||
.output()
|
|
||||||
else {
|
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
let Ok(doc) = serde_json::from_slice::<serde_json::Value>(&out.stdout) else {
|
let Ok(doc) = serde_json::from_slice::<serde_json::Value>(&out) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
doc.get("outputs")
|
doc.get("outputs")
|
||||||
@@ -609,13 +781,7 @@ fn a_managed_output_is_primary() -> bool {
|
|||||||
/// the keepalive to re-enable on teardown. Best-effort: on failure, streaming continues (just possibly
|
/// the keepalive to re-enable on teardown. Best-effort: on failure, streaming continues (just possibly
|
||||||
/// showing only the wallpaper) rather than failing the session.
|
/// showing only the wallpaper) rather than failing the session.
|
||||||
fn apply_virtual_primary(ours: &str) -> Vec<(String, String)> {
|
fn apply_virtual_primary(ours: &str) -> Vec<(String, String)> {
|
||||||
let kscreen = |args: &[String]| {
|
let kscreen = |args: &[String]| kscreen_ok(args);
|
||||||
std::process::Command::new("kscreen-doctor")
|
|
||||||
.args(args)
|
|
||||||
.status()
|
|
||||||
.map(|s| s.success())
|
|
||||||
.unwrap_or(false)
|
|
||||||
};
|
|
||||||
// First-slot-wins (§6.1): only grab primary if no managed group member is primary yet — so a 2nd
|
// First-slot-wins (§6.1): only grab primary if no managed group member is primary yet — so a 2nd
|
||||||
// exclusive session joins as a secondary monitor of the shared desktop instead of stealing the
|
// exclusive session joins as a secondary monitor of the shared desktop instead of stealing the
|
||||||
// shell off the 1st session's output. KWin usually then re-homes the desktop + disables the
|
// shell off the 1st session's output. KWin usually then re-homes the desktop + disables the
|
||||||
@@ -647,11 +813,7 @@ fn apply_virtual_primary(ours: &str) -> Vec<(String, String)> {
|
|||||||
/// (don't disable the bootstrap/physical) — so the shell re-homes onto the streamed surface while a
|
/// (don't disable the bootstrap/physical) — so the shell re-homes onto the streamed surface while a
|
||||||
/// physical screen stays usable. Nothing to restore on teardown (we disabled nothing).
|
/// physical screen stays usable. Nothing to restore on teardown (we disabled nothing).
|
||||||
fn apply_virtual_primary_only(ours: &str) {
|
fn apply_virtual_primary_only(ours: &str) {
|
||||||
let ok = std::process::Command::new("kscreen-doctor")
|
let ok = kscreen_ok(&[format!("output.{ours}.primary")]);
|
||||||
.arg(format!("output.{ours}.primary"))
|
|
||||||
.status()
|
|
||||||
.map(|s| s.success())
|
|
||||||
.unwrap_or(false);
|
|
||||||
if ok {
|
if ok {
|
||||||
tracing::info!("KWin: streamed output set primary (physical outputs kept)");
|
tracing::info!("KWin: streamed output set primary (physical outputs kept)");
|
||||||
} else {
|
} else {
|
||||||
@@ -884,7 +1046,88 @@ fn run(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::MANAGED_PREFIX;
|
use super::{modes_from_json, pick_custom_mode, KModeRow, MANAGED_PREFIX};
|
||||||
|
|
||||||
|
fn row(id: &str, w: u32, h: u32, hz: f64) -> KModeRow {
|
||||||
|
KModeRow {
|
||||||
|
id: id.to_string(),
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
hz,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The reported regression: an iPhone 16 Pro Max asks for 2868x1320@120; libxcvt rounds the
|
||||||
|
/// width down to the 8-pixel cell grain and the clock step lands 119.92, so KWin's list holds
|
||||||
|
/// 2864x1320@119.92. Selecting by the REQUESTED `2868x1320@120` string matched nothing — the
|
||||||
|
/// output stayed on its birth mode and the session fell back to 60 Hz. The picker must find it.
|
||||||
|
#[test]
|
||||||
|
fn picks_the_cvt_aligned_mode() {
|
||||||
|
let modes = [
|
||||||
|
row("1", 2868, 1320, 60.0), // the virtual output's native/birth mode
|
||||||
|
row("2", 2864, 1320, 119.92), // the custom mode KWin actually generated
|
||||||
|
];
|
||||||
|
let got = pick_custom_mode(&modes, 2868, 1320, 120).expect("CVT-aligned mode");
|
||||||
|
assert_eq!((got.id.as_str(), got.w, got.h), ("2", 2864, 1320));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A width already on the cell grain (every PC resolution: 1920/2560/3840) round-trips exactly,
|
||||||
|
/// and an exact-width mode outranks an aligned one when both are offered.
|
||||||
|
#[test]
|
||||||
|
fn exact_width_outranks_an_aligned_one() {
|
||||||
|
let modes = [
|
||||||
|
row("1", 2560, 1440, 60.0),
|
||||||
|
row("2", 2552, 1440, 119.93), // a stale narrower custom mode from an earlier session
|
||||||
|
row("3", 2560, 1440, 119.98),
|
||||||
|
];
|
||||||
|
let got = pick_custom_mode(&modes, 2560, 1440, 120).expect("exact mode");
|
||||||
|
assert_eq!(got.id, "3");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The picker must never wander onto an unrelated mode: not the 60 Hz native entry (the old
|
||||||
|
/// fallback the reporter got stuck on), not a different height, not a wider width, and not a
|
||||||
|
/// mode more than one cell narrower than asked.
|
||||||
|
#[test]
|
||||||
|
fn rejects_modes_that_are_not_the_request() {
|
||||||
|
let modes = [
|
||||||
|
row("1", 2868, 1320, 60.0), // native — refresh too far off
|
||||||
|
row("2", 2868, 1080, 119.92), // wrong height
|
||||||
|
row("3", 2880, 1320, 119.92), // wider than requested
|
||||||
|
row("4", 2856, 1320, 119.92), // two cells narrower — not a CVT alignment of 2868
|
||||||
|
row("5", 1920, 1080, 120.0), // unrelated
|
||||||
|
];
|
||||||
|
assert!(pick_custom_mode(&modes, 2868, 1320, 120).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mode + output ids come through as JSON strings on some KWin versions and numbers on others;
|
||||||
|
/// both must parse, and a mode row missing its size/refresh is skipped rather than poisoning
|
||||||
|
/// the list.
|
||||||
|
#[test]
|
||||||
|
fn parses_both_id_encodings() {
|
||||||
|
let doc: serde_json::Value = serde_json::from_str(
|
||||||
|
r#"{"outputs":[
|
||||||
|
{"id":7,"name":"Virtual-punktfunk","modes":[
|
||||||
|
{"id":"m1","size":{"width":2868,"height":1320},"refreshRate":60.0},
|
||||||
|
{"id":42,"size":{"width":2864,"height":1320},"refreshRate":119.92},
|
||||||
|
{"id":"broken","size":{"width":800}}
|
||||||
|
]},
|
||||||
|
{"id":1,"name":"eDP-1","modes":[
|
||||||
|
{"id":"x","size":{"width":2864,"height":1320},"refreshRate":119.92}
|
||||||
|
]}
|
||||||
|
]}"#,
|
||||||
|
)
|
||||||
|
.expect("fixture parses");
|
||||||
|
// Addressable by numeric id (how `resolve_kscreen_addr` returns it) and by name.
|
||||||
|
for addr in ["7", "Virtual-punktfunk"] {
|
||||||
|
let modes = modes_from_json(&doc, addr);
|
||||||
|
assert_eq!(modes.len(), 2, "the malformed row is dropped ({addr})");
|
||||||
|
assert_eq!(modes[1].id, "42", "numeric mode ids stringify ({addr})");
|
||||||
|
let got = pick_custom_mode(&modes, 2868, 1320, 120).expect("aligned mode");
|
||||||
|
assert_eq!(got.id, "42");
|
||||||
|
}
|
||||||
|
// Never reads another output's list (the eDP-1 entry carries a matching mode).
|
||||||
|
assert!(modes_from_json(&doc, "Virtual-nope").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
/// Group-aware exclusive (§6.1): with two managed group members + a physical panel enabled,
|
/// Group-aware exclusive (§6.1): with two managed group members + a physical panel enabled,
|
||||||
/// exclusive disables ONLY the non-managed panel — never a sibling session's per-slot output
|
/// exclusive disables ONLY the non-managed panel — never a sibling session's per-slot output
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
//! Time-bounded child-process helpers.
|
||||||
|
//!
|
||||||
|
//! Every compositor query this crate makes shells out to a helper (`kscreen-doctor`, `systemctl`,
|
||||||
|
//! `pw-dump`, …), and most of them are *clients of the very thing being diagnosed*: `kscreen-doctor`
|
||||||
|
//! is a Wayland client, so against a wedged KWin it blocks in its own connect and **never returns**.
|
||||||
|
//! `Command::status()` / `Command::output()` have no timeout, so one hung helper pinned the calling
|
||||||
|
//! thread forever — and on the host that thread is the session's stream thread, whose only way to
|
||||||
|
//! end a session is to return. A stuck query therefore became a permanently stuck session.
|
||||||
|
//!
|
||||||
|
//! These wrappers bound the wait: poll for exit until the budget runs out, then kill the child and
|
||||||
|
//! report [`std::io::ErrorKind::TimedOut`], so callers see a plain "the helper failed" error and
|
||||||
|
//! take their existing failure path instead of hanging.
|
||||||
|
|
||||||
|
use std::io::{Error, ErrorKind, Result};
|
||||||
|
use std::process::{Command, ExitStatus, Output};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// Poll interval while waiting for a child to exit. Short enough that a fast helper (the normal
|
||||||
|
/// case — `kscreen-doctor` answers in tens of ms) isn't measurably delayed.
|
||||||
|
const POLL: Duration = Duration::from_millis(20);
|
||||||
|
|
||||||
|
/// Run `cmd` to completion, killing it if it outlives `budget`.
|
||||||
|
///
|
||||||
|
/// Stdout/stderr are left as the caller configured them (inherited by default), so this is for
|
||||||
|
/// commands run for their exit status alone — see [`output_within`] when the output is read.
|
||||||
|
pub(crate) fn status_within(cmd: &mut Command, budget: Duration) -> Result<ExitStatus> {
|
||||||
|
let mut child = cmd.spawn()?;
|
||||||
|
let deadline = Instant::now() + budget;
|
||||||
|
loop {
|
||||||
|
match child.try_wait()? {
|
||||||
|
Some(status) => return Ok(status),
|
||||||
|
None if Instant::now() >= deadline => {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait(); // reap it — never leave a zombie behind
|
||||||
|
return Err(timed_out(cmd, budget));
|
||||||
|
}
|
||||||
|
None => std::thread::sleep(POLL),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `cmd` to completion and capture its stdout/stderr, killing it if it outlives `budget`.
|
||||||
|
///
|
||||||
|
/// The output is read only after the child has exited, so a helper that fills the pipe buffer and
|
||||||
|
/// stalls is caught by the budget rather than deadlocking the reader (these helpers emit at most a
|
||||||
|
/// few hundred KiB, well under any real pipe pressure).
|
||||||
|
pub(crate) fn output_within(cmd: &mut Command, budget: Duration) -> Result<Output> {
|
||||||
|
let mut child = cmd
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped())
|
||||||
|
.spawn()?;
|
||||||
|
let deadline = Instant::now() + budget;
|
||||||
|
loop {
|
||||||
|
match child.try_wait()? {
|
||||||
|
// Exited: `wait_with_output` now only drains already-buffered pipes.
|
||||||
|
Some(_) => return child.wait_with_output(),
|
||||||
|
None if Instant::now() >= deadline => {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
return Err(timed_out(cmd, budget));
|
||||||
|
}
|
||||||
|
None => std::thread::sleep(POLL),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn timed_out(cmd: &Command, budget: Duration) -> Error {
|
||||||
|
let program = cmd.get_program().to_string_lossy().to_string();
|
||||||
|
tracing::warn!(
|
||||||
|
program,
|
||||||
|
budget_ms = budget.as_millis() as u64,
|
||||||
|
"helper did not exit within its budget — killed it (a wedged compositor/session bus is the \
|
||||||
|
usual cause); treating it as a failed query"
|
||||||
|
);
|
||||||
|
Error::new(
|
||||||
|
ErrorKind::TimedOut,
|
||||||
|
format!("`{program}` did not exit within {budget:?}"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A helper that never exits must be killed at the budget and reported as `TimedOut` — the
|
||||||
|
/// whole point of the module (an unbounded `status()` here is what wedged a whole session).
|
||||||
|
#[test]
|
||||||
|
fn a_hung_child_is_killed_at_the_budget() {
|
||||||
|
let started = Instant::now();
|
||||||
|
let err = status_within(Command::new("sleep").arg("30"), Duration::from_millis(150))
|
||||||
|
.expect_err("must time out");
|
||||||
|
assert_eq!(err.kind(), ErrorKind::TimedOut);
|
||||||
|
assert!(
|
||||||
|
started.elapsed() < Duration::from_secs(5),
|
||||||
|
"must return at its budget, not the child's lifetime (took {:?})",
|
||||||
|
started.elapsed()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The normal path is unaffected: a quick command still yields its status and its output.
|
||||||
|
#[test]
|
||||||
|
fn a_quick_child_returns_normally() {
|
||||||
|
let st = status_within(&mut Command::new("true"), Duration::from_secs(5)).expect("ran");
|
||||||
|
assert!(st.success());
|
||||||
|
|
||||||
|
let out = output_within(
|
||||||
|
Command::new("echo").arg("punktfunk"),
|
||||||
|
Duration::from_secs(5),
|
||||||
|
)
|
||||||
|
.expect("ran");
|
||||||
|
assert!(out.status.success());
|
||||||
|
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "punktfunk");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,15 @@
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// Budget for one `systemctl --user` / `dbus-update-activation-environment` call.
|
||||||
|
///
|
||||||
|
/// These talk to the session bus, and a bus that is itself restarting or wedged answers nothing —
|
||||||
|
/// unbounded, that pinned the caller (on the host, the session's stream thread) forever. A restart
|
||||||
|
/// of the portal units is the slowest legitimate case, hence the generous window; missing it just
|
||||||
|
/// means the portal env settles late, which the callers already treat as best-effort.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
const SYSTEMD_BUDGET: std::time::Duration = std::time::Duration::from_secs(10);
|
||||||
|
|
||||||
/// The **session epoch** — bumped whenever session detection observes a different compositor
|
/// The **session epoch** — bumped whenever session detection observes a different compositor
|
||||||
/// *instance*: an [`ActiveKind`] change, **or** a new compositor PID for the same kind (the
|
/// *instance*: an [`ActiveKind`] change, **or** a new compositor PID for the same kind (the
|
||||||
/// Desktop→Game→Desktop bounce that brings up a fresh KWin/gamescope with an unrelated node-id space).
|
/// Desktop→Game→Desktop bounce that brings up a fresh KWin/gamescope with an unrelated node-id space).
|
||||||
@@ -86,9 +95,15 @@ pub fn observe_session_instance(active: &ActiveSession) {
|
|||||||
/// via the next [`settle_desktop_portal`], so scrubbing on a bounce is harmless.)
|
/// via the next [`settle_desktop_portal`], so scrubbing on a bounce is harmless.)
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn scrub_desktop_manager_env() {
|
fn scrub_desktop_manager_env() {
|
||||||
let _ = std::process::Command::new("systemctl")
|
let _ = crate::proc::status_within(
|
||||||
.args(["--user", "unset-environment", "WAYLAND_DISPLAY", "DISPLAY"])
|
std::process::Command::new("systemctl").args([
|
||||||
.status();
|
"--user",
|
||||||
|
"unset-environment",
|
||||||
|
"WAYLAND_DISPLAY",
|
||||||
|
"DISPLAY",
|
||||||
|
]),
|
||||||
|
SYSTEMD_BUDGET,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
@@ -499,40 +514,46 @@ pub fn settle_desktop_portal(chosen: Compositor) {
|
|||||||
];
|
];
|
||||||
// Push our (correct) env into the systemd --user manager + the D-Bus activation environment so a
|
// Push our (correct) env into the systemd --user manager + the D-Bus activation environment so a
|
||||||
// re-activated portal/backend inherits the live session.
|
// re-activated portal/backend inherits the live session.
|
||||||
let _ = std::process::Command::new("systemctl")
|
let _ = crate::proc::status_within(
|
||||||
|
std::process::Command::new("systemctl")
|
||||||
.args(["--user", "import-environment"])
|
.args(["--user", "import-environment"])
|
||||||
.args(VARS)
|
.args(VARS),
|
||||||
.status();
|
SYSTEMD_BUDGET,
|
||||||
let _ = std::process::Command::new("dbus-update-activation-environment")
|
);
|
||||||
|
let _ = crate::proc::status_within(
|
||||||
|
std::process::Command::new("dbus-update-activation-environment")
|
||||||
.arg("--systemd")
|
.arg("--systemd")
|
||||||
.args(VARS)
|
.args(VARS),
|
||||||
.status();
|
SYSTEMD_BUDGET,
|
||||||
|
);
|
||||||
// KWin input goes through the xdg RemoteDesktop portal; the frontend routes RemoteDesktop to a
|
// KWin input goes through the xdg RemoteDesktop portal; the frontend routes RemoteDesktop to a
|
||||||
// backend by its OWN startup XDG_CURRENT_DESKTOP, so restart it (+ the KDE backend) to re-read
|
// backend by its OWN startup XDG_CURRENT_DESKTOP, so restart it (+ the KDE backend) to re-read
|
||||||
// the now-live session, then let it settle before the injector reopens against it.
|
// the now-live session, then let it settle before the injector reopens against it.
|
||||||
if chosen == Compositor::Kwin {
|
if chosen == Compositor::Kwin {
|
||||||
let _ = std::process::Command::new("systemctl")
|
let _ = crate::proc::status_within(
|
||||||
.args([
|
std::process::Command::new("systemctl").args([
|
||||||
"--user",
|
"--user",
|
||||||
"try-restart",
|
"try-restart",
|
||||||
"xdg-desktop-portal-kde.service",
|
"xdg-desktop-portal-kde.service",
|
||||||
"xdg-desktop-portal.service",
|
"xdg-desktop-portal.service",
|
||||||
])
|
]),
|
||||||
.status();
|
SYSTEMD_BUDGET,
|
||||||
|
);
|
||||||
std::thread::sleep(std::time::Duration::from_millis(600));
|
std::thread::sleep(std::time::Duration::from_millis(600));
|
||||||
}
|
}
|
||||||
// Hyprland capture rides the xdg ScreenCast portal serviced by xdph (G5): on a mid-stream switch
|
// Hyprland capture rides the xdg ScreenCast portal serviced by xdph (G5): on a mid-stream switch
|
||||||
// xdph may still hold the old session's Wayland/instance env, so restart it (+ the frontend) to
|
// xdph may still hold the old session's Wayland/instance env, so restart it (+ the frontend) to
|
||||||
// re-read the now-live session, mirroring the KWin settling above.
|
// re-read the now-live session, mirroring the KWin settling above.
|
||||||
if chosen == Compositor::Hyprland {
|
if chosen == Compositor::Hyprland {
|
||||||
let _ = std::process::Command::new("systemctl")
|
let _ = crate::proc::status_within(
|
||||||
.args([
|
std::process::Command::new("systemctl").args([
|
||||||
"--user",
|
"--user",
|
||||||
"try-restart",
|
"try-restart",
|
||||||
"xdg-desktop-portal-hyprland.service",
|
"xdg-desktop-portal-hyprland.service",
|
||||||
"xdg-desktop-portal.service",
|
"xdg-desktop-portal.service",
|
||||||
])
|
]),
|
||||||
.status();
|
SYSTEMD_BUDGET,
|
||||||
|
);
|
||||||
std::thread::sleep(std::time::Duration::from_millis(600));
|
std::thread::sleep(std::time::Duration::from_millis(600));
|
||||||
}
|
}
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
|
|||||||
@@ -31,5 +31,8 @@ windows = { version = "0.62", features = [
|
|||||||
"Win32_System_LibraryLoader",
|
"Win32_System_LibraryLoader",
|
||||||
# console_session_mismatch: WTSGetActiveConsoleSessionId + ProcessIdToSessionId + GetCurrentProcessId.
|
# console_session_mismatch: WTSGetActiveConsoleSessionId + ProcessIdToSessionId + GetCurrentProcessId.
|
||||||
"Win32_System_RemoteDesktop",
|
"Win32_System_RemoteDesktop",
|
||||||
|
# input_desktop: OpenInputDesktop/SetThreadDesktop/GetUserObjectInformationW — display writes
|
||||||
|
# follow the input desktop so a UAC/lock screen can't refuse them.
|
||||||
|
"Win32_System_StationsAndDesktops",
|
||||||
"Win32_System_Threading",
|
"Win32_System_Threading",
|
||||||
] }
|
] }
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
//! Make display-config writes follow the INPUT desktop.
|
||||||
|
//!
|
||||||
|
//! Windows refuses `ChangeDisplaySettingsEx` / `SetDisplayConfig` issued from a thread that is not
|
||||||
|
//! on the desktop currently receiving input. While a UAC consent prompt (or the lock / logon
|
||||||
|
//! screen) is up, the input desktop is `Winlogon`; a host thread sitting on `WinSta0\Default` — the
|
||||||
|
//! desktop the service explicitly launches it onto — then gets `DISP_CHANGE_FAILED` /
|
||||||
|
//! `ERROR_ACCESS_DENIED` for EVERY write. A session starting in that window can never set its
|
||||||
|
//! virtual display's mode, so the capturer sizes its ring to a stale mode, every composed frame is
|
||||||
|
//! dropped for a size mismatch, and the client sits on a black screen until bring-up runs out of
|
||||||
|
//! retries. Field-reported 2026-07-23: a consent prompt left up on an unattended host made the box
|
||||||
|
//! unreachable until someone walked over to it.
|
||||||
|
//!
|
||||||
|
//! Measured on-glass that same day (RTX 4090 box, SYSTEM host in console session 2, a real
|
||||||
|
//! interactive consent prompt up, virtual display `\\.\DISPLAY42` active):
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! INPUT desktop = Winlogon
|
||||||
|
//! UNBOUND CDS_TEST -> -1 (DISP_CHANGE_FAILED) <- the field failure, reproduced
|
||||||
|
//! UNBOUND SDC_VALIDATE -> 0x5 (ERROR_ACCESS_DENIED) <- the field failure, reproduced
|
||||||
|
//! bound thread desktop -> Winlogon
|
||||||
|
//! BOUND CDS_TEST -> 0 (DISP_CHANGE_SUCCESSFUL)
|
||||||
|
//! BOUND SDC_VALIDATE -> 0x0 (ERROR_SUCCESS)
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! The retry model mirrors `pf-inject`'s `sendinput.rs`: do NOT pay
|
||||||
|
//! `OpenInputDesktop`/`SetThreadDesktop` on every write — issue the write, and only when it fails
|
||||||
|
//! the way a wrong-desktop write fails do we rebind and retry once. That keeps the normal path
|
||||||
|
//! byte-for-byte as it was (a working write is never touched) and makes this strictly additive.
|
||||||
|
//!
|
||||||
|
//! Unlike sendinput's injector — a dedicated thread that KEEPS its binding — these helpers run on
|
||||||
|
//! shared/task threads, so the binding here is SCOPED: [`InputDesktopBinding`] restores the
|
||||||
|
//! thread's original desktop on drop. A thread left bound to a `Winlogon` desktop that is later
|
||||||
|
//! destroyed (prompt dismissed) would fail every subsequent display write for the life of the
|
||||||
|
//! process — the exact wedge this module exists to remove, so it must not be introduced here.
|
||||||
|
|
||||||
|
use windows::Win32::Foundation::HANDLE;
|
||||||
|
use windows::Win32::System::StationsAndDesktops::{
|
||||||
|
CloseDesktop, GetThreadDesktop, GetUserObjectInformationW, OpenInputDesktop, SetThreadDesktop,
|
||||||
|
DESKTOP_ACCESS_FLAGS, DESKTOP_CONTROL_FLAGS, HDESK, UOI_NAME,
|
||||||
|
};
|
||||||
|
use windows::Win32::System::Threading::GetCurrentThreadId;
|
||||||
|
|
||||||
|
/// `GENERIC_ALL` for the desktop open. The `windows` crate models desktop access as its own flag
|
||||||
|
/// type and doesn't export the generic rights, so spell it out (same constant `sendinput.rs` and
|
||||||
|
/// the cursor poller use).
|
||||||
|
const GENERIC_ALL: u32 = 0x1000_0000;
|
||||||
|
|
||||||
|
/// This thread's binding to the input desktop, restored on drop.
|
||||||
|
///
|
||||||
|
/// `GetThreadDesktop` returns a BORROWED handle (documented: it creates no new handle and must not
|
||||||
|
/// be closed), so only the handle `OpenInputDesktop` produced is closed here — and only after the
|
||||||
|
/// thread has been moved back off it.
|
||||||
|
pub(crate) struct InputDesktopBinding {
|
||||||
|
previous: HDESK,
|
||||||
|
input: HDESK,
|
||||||
|
/// `UOI_NAME` of the desktop bound to, for the "this write only landed because we rebound"
|
||||||
|
/// log. Read once at bind time (the failure path only), never in steady state.
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InputDesktopBinding {
|
||||||
|
/// Bind the calling thread to the desktop currently receiving input. `None` when there is no
|
||||||
|
/// reachable input desktop (not privileged for `Winlogon` — a host that is not SYSTEM) or the
|
||||||
|
/// rebind is refused, in which case the caller keeps whatever result it already had rather than
|
||||||
|
/// changing behaviour.
|
||||||
|
pub(crate) fn bind() -> Option<Self> {
|
||||||
|
// SAFETY: all four are FFI calls taking by-value args only. `GetThreadDesktop` yields a
|
||||||
|
// borrowed handle for THIS thread (never closed here). `OpenInputDesktop` yields an owned
|
||||||
|
// `HDESK` only on `Ok`; it is either installed by `SetThreadDesktop` (and then owned by the
|
||||||
|
// returned guard, which closes it exactly once in `Drop`) or closed right here on failure —
|
||||||
|
// so it is closed exactly once on every path and never used after close. `SetThreadDesktop`
|
||||||
|
// rebinds only the calling thread, which owns no windows or hooks (these are display-config
|
||||||
|
// worker threads), so it cannot fail on that account.
|
||||||
|
unsafe {
|
||||||
|
let previous = GetThreadDesktop(GetCurrentThreadId()).ok()?;
|
||||||
|
let input = OpenInputDesktop(
|
||||||
|
DESKTOP_CONTROL_FLAGS(0),
|
||||||
|
false,
|
||||||
|
DESKTOP_ACCESS_FLAGS(GENERIC_ALL),
|
||||||
|
)
|
||||||
|
.ok()?;
|
||||||
|
let name = desktop_name(input).unwrap_or_else(|| "<unnamed>".into());
|
||||||
|
if SetThreadDesktop(input).is_err() {
|
||||||
|
let _ = CloseDesktop(input);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Self {
|
||||||
|
previous,
|
||||||
|
input,
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for InputDesktopBinding {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: `self.previous` is the borrowed desktop this thread started on and `self.input`
|
||||||
|
// is the handle this guard uniquely owns. The thread is moved back FIRST, so the handle is
|
||||||
|
// no longer the thread's desktop when it is closed — closed exactly once, never used after.
|
||||||
|
unsafe {
|
||||||
|
let _ = SetThreadDesktop(self.previous);
|
||||||
|
let _ = CloseDesktop(self.input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `UOI_NAME` of the current input desktop — `Some("Winlogon")` while a UAC consent prompt, the
|
||||||
|
/// lock screen or the logon screen owns input, `Some("Default")` in normal operation, `None` when
|
||||||
|
/// it cannot be opened at all.
|
||||||
|
pub(crate) fn input_desktop_name() -> Option<String> {
|
||||||
|
// SAFETY: `OpenInputDesktop` yields an owned handle only on `Ok`, which is closed exactly once
|
||||||
|
// below and not used after.
|
||||||
|
unsafe {
|
||||||
|
let h = OpenInputDesktop(
|
||||||
|
DESKTOP_CONTROL_FLAGS(0),
|
||||||
|
false,
|
||||||
|
DESKTOP_ACCESS_FLAGS(GENERIC_ALL),
|
||||||
|
)
|
||||||
|
.ok()?;
|
||||||
|
let name = desktop_name(h);
|
||||||
|
let _ = CloseDesktop(h);
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `UOI_NAME` of an already-open desktop handle. Borrows `h` — closing it stays the caller's job.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `h` must be a live desktop handle for the duration of the call.
|
||||||
|
unsafe fn desktop_name(h: HDESK) -> Option<String> {
|
||||||
|
let mut name = [0u16; 64]; // "Default"/"Winlogon"/"Screen-saver" all fit with room to spare
|
||||||
|
let mut needed = 0u32;
|
||||||
|
// SAFETY: `h` is live per this fn's contract; `name`/`needed` are live out-params and the call
|
||||||
|
// writes at most `nlength` bytes, exactly the size passed.
|
||||||
|
GetUserObjectInformationW(
|
||||||
|
HANDLE(h.0),
|
||||||
|
UOI_NAME,
|
||||||
|
Some(name.as_mut_ptr().cast()),
|
||||||
|
(name.len() * 2) as u32,
|
||||||
|
Some(&mut needed),
|
||||||
|
)
|
||||||
|
.ok()?;
|
||||||
|
let len = name.iter().position(|&c| c == 0).unwrap_or(name.len());
|
||||||
|
Some(String::from_utf16_lossy(&name[..len]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `true` when the input desktop is a SECURE one (`UOI_NAME` != `Default`: `Winlogon` for UAC
|
||||||
|
/// consent / lock / logon, or a screen-saver desktop) — i.e. when display writes from the host's
|
||||||
|
/// own desktop are being refused for that reason. `false` when it is normal OR unreadable: this
|
||||||
|
/// only ever phrases a diagnostic, so an unknown desktop must not claim the secure one is up.
|
||||||
|
pub(crate) fn input_desktop_is_secure() -> bool {
|
||||||
|
input_desktop_name().is_some_and(|n| !n.eq_ignore_ascii_case("Default"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `ERROR_ACCESS_DENIED` — exactly how Windows refuses a `SetDisplayConfig` issued from a thread
|
||||||
|
/// that is not on the input desktop (measured, see the module header). Narrower than "any error" on
|
||||||
|
/// purpose: the CCD path also returns `ERROR_INVALID_PARAMETER` (0x57) for the unrelated
|
||||||
|
/// exclusive-mode topology bug, and re-issuing THAT on another desktop would only confuse its
|
||||||
|
/// diagnosis.
|
||||||
|
const SDC_ACCESS_DENIED: i32 = 5;
|
||||||
|
|
||||||
|
/// [`retry_on_input_desktop`] specialised for the CCD writes, which all return a Win32 error code.
|
||||||
|
pub(crate) fn retry_set_display_config(write: impl FnMut() -> i32) -> i32 {
|
||||||
|
retry_on_input_desktop(|rc| *rc == SDC_ACCESS_DENIED, write)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a display-config write; if it comes back the way a write from the wrong desktop comes back,
|
||||||
|
/// bind this thread to the CURRENT input desktop and run it exactly once more.
|
||||||
|
///
|
||||||
|
/// `denied` decides which results are worth a retry — keep it NARROW (the specific
|
||||||
|
/// wrong-desktop verdict), so a genuinely bad mode or a driver-level refusal is not re-issued and
|
||||||
|
/// mis-attributed. The binding is dropped before returning, so the calling thread always leaves on
|
||||||
|
/// the desktop it arrived on.
|
||||||
|
pub(crate) fn retry_on_input_desktop<T>(
|
||||||
|
denied: impl Fn(&T) -> bool,
|
||||||
|
mut write: impl FnMut() -> T,
|
||||||
|
) -> T {
|
||||||
|
let first = write();
|
||||||
|
if !denied(&first) {
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
// Only worth the rebind when the input desktop is genuinely elsewhere; on a normal desktop the
|
||||||
|
// refusal means something else entirely and a retry would just repeat it.
|
||||||
|
let Some(binding) = InputDesktopBinding::bind() else {
|
||||||
|
return first;
|
||||||
|
};
|
||||||
|
let second = write();
|
||||||
|
if !denied(&second) {
|
||||||
|
// Say so. A silent save is indistinguishable in a log from a write that never needed
|
||||||
|
// saving, which makes "did the fix fire?" unanswerable in the field — and made the first
|
||||||
|
// on-glass verification of this very change inconclusive.
|
||||||
|
tracing::info!(
|
||||||
|
desktop = %binding.name,
|
||||||
|
"display write was refused off the input desktop — retried bound to it and it applied \
|
||||||
|
(a UAC prompt / lock screen owns input; the session continues normally)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
second
|
||||||
|
}
|
||||||
@@ -11,6 +11,9 @@
|
|||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
pub mod display_events;
|
pub mod display_events;
|
||||||
|
/// Bind display-config writes to the input desktop so a UAC / lock screen can't refuse them.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
mod input_desktop;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
pub mod monitor_devnode;
|
pub mod monitor_devnode;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ use windows::Win32::Devices::Display::{
|
|||||||
use windows::Win32::Foundation::POINTL;
|
use windows::Win32::Foundation::POINTL;
|
||||||
use windows::Win32::Graphics::Gdi::{
|
use windows::Win32::Graphics::Gdi::{
|
||||||
ChangeDisplaySettingsExW, EnumDisplaySettingsW, CDS_TEST, CDS_UPDATEREGISTRY, DEVMODEW,
|
ChangeDisplaySettingsExW, EnumDisplaySettingsW, CDS_TEST, CDS_UPDATEREGISTRY, DEVMODEW,
|
||||||
DISP_CHANGE_SUCCESSFUL, DM_BITSPERPEL, DM_DISPLAYFREQUENCY, DM_PELSHEIGHT, DM_PELSWIDTH,
|
DISP_CHANGE_FAILED, DISP_CHANGE_SUCCESSFUL, DM_BITSPERPEL, DM_DISPLAYFREQUENCY, DM_PELSHEIGHT,
|
||||||
ENUM_CURRENT_SETTINGS, ENUM_DISPLAY_SETTINGS_MODE,
|
DM_PELSWIDTH, ENUM_CURRENT_SETTINGS, ENUM_DISPLAY_SETTINGS_MODE,
|
||||||
};
|
};
|
||||||
|
|
||||||
use punktfunk_core::Mode;
|
use punktfunk_core::Mode;
|
||||||
@@ -62,7 +62,9 @@ use punktfunk_core::Mode;
|
|||||||
pub unsafe fn force_extend_topology() {
|
pub unsafe fn force_extend_topology() {
|
||||||
// A topology flag with no supplied path/mode arrays tells the OS to recompute + apply that preset
|
// A topology flag with no supplied path/mode arrays tells the OS to recompute + apply that preset
|
||||||
// for the currently-connected displays (the same code path DisplaySwitch.exe drives).
|
// for the currently-connected displays (the same code path DisplaySwitch.exe drives).
|
||||||
let rc = SetDisplayConfig(None, None, SDC_APPLY | SDC_TOPOLOGY_EXTEND);
|
let rc = crate::input_desktop::retry_set_display_config(|| {
|
||||||
|
SetDisplayConfig(None, None, SDC_APPLY | SDC_TOPOLOGY_EXTEND)
|
||||||
|
});
|
||||||
if rc == 0 {
|
if rc == 0 {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"display topology forced to EXTEND (a new IddCx monitor would otherwise be CLONED onto the \
|
"display topology forced to EXTEND (a new IddCx monitor would otherwise be CLONED onto the \
|
||||||
@@ -152,11 +154,13 @@ pub unsafe fn activate_target_path(target_id: u32) -> bool {
|
|||||||
// SAVE_TO_DATABASE so Windows remembers the arrangement — the next same-identity ADD (the driver
|
// SAVE_TO_DATABASE so Windows remembers the arrangement — the next same-identity ADD (the driver
|
||||||
// reuses the slot's EDID serial/ConnectorIndex) then auto-activates from the persistence DB and
|
// reuses the slot's EDID serial/ConnectorIndex) then auto-activates from the persistence DB and
|
||||||
// skips this whole fallback ladder.
|
// skips this whole fallback ladder.
|
||||||
let rc = SetDisplayConfig(
|
let rc = crate::input_desktop::retry_set_display_config(|| {
|
||||||
|
SetDisplayConfig(
|
||||||
Some(supplied.as_slice()),
|
Some(supplied.as_slice()),
|
||||||
Some(modes.as_slice()),
|
Some(modes.as_slice()),
|
||||||
SDC_APPLY | SDC_USE_SUPPLIED_DISPLAY_CONFIG | SDC_ALLOW_CHANGES | SDC_SAVE_TO_DATABASE,
|
SDC_APPLY | SDC_USE_SUPPLIED_DISPLAY_CONFIG | SDC_ALLOW_CHANGES | SDC_SAVE_TO_DATABASE,
|
||||||
);
|
)
|
||||||
|
});
|
||||||
if rc == 0 {
|
if rc == 0 {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
target_id,
|
target_id,
|
||||||
@@ -273,14 +277,16 @@ pub unsafe fn force_mode_reenumeration() -> bool {
|
|||||||
let Some((paths, modes)) = query_active_config() else {
|
let Some((paths, modes)) = query_active_config() else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
let rc = SetDisplayConfig(
|
let rc = crate::input_desktop::retry_set_display_config(|| {
|
||||||
|
SetDisplayConfig(
|
||||||
Some(paths.as_slice()),
|
Some(paths.as_slice()),
|
||||||
Some(modes.as_slice()),
|
Some(modes.as_slice()),
|
||||||
SDC_APPLY
|
SDC_APPLY
|
||||||
| SDC_USE_SUPPLIED_DISPLAY_CONFIG
|
| SDC_USE_SUPPLIED_DISPLAY_CONFIG
|
||||||
| SDC_ALLOW_CHANGES
|
| SDC_ALLOW_CHANGES
|
||||||
| SDC_FORCE_MODE_ENUMERATION,
|
| SDC_FORCE_MODE_ENUMERATION,
|
||||||
);
|
)
|
||||||
|
});
|
||||||
if rc != 0 {
|
if rc != 0 {
|
||||||
tracing::debug!("force mode re-enumeration: SetDisplayConfig rc={rc:#x}");
|
tracing::debug!("force mode re-enumeration: SetDisplayConfig rc={rc:#x}");
|
||||||
}
|
}
|
||||||
@@ -625,9 +631,15 @@ pub fn set_active_mode(gdi_name: &str, mode: Mode) {
|
|||||||
// SAFETY: `wname` is a live NUL-terminated UTF-16 device name and `&dm` is a live DEVMODEW describing
|
// SAFETY: `wname` is a live NUL-terminated UTF-16 device name and `&dm` is a live DEVMODEW describing
|
||||||
// the requested mode; both outlive the call. CDS_TEST only validates the mode (no apply), the two
|
// the requested mode; both outlive the call. CDS_TEST only validates the mode (no apply), the two
|
||||||
// trailing args are null, and the API only reads its inputs.
|
// trailing args are null, and the API only reads its inputs.
|
||||||
let test = unsafe {
|
// A CDS write from a thread that is not on the input desktop is refused with DISP_CHANGE_FAILED
|
||||||
|
// (UAC consent / lock screen up) — retry it bound to that desktop rather than declaring the mode
|
||||||
|
// unsupported, which is what stranded sessions on a black screen for a whole bring-up.
|
||||||
|
let test = crate::input_desktop::retry_on_input_desktop(
|
||||||
|
|rc| *rc == DISP_CHANGE_FAILED,
|
||||||
|
|| unsafe {
|
||||||
ChangeDisplaySettingsExW(PCWSTR(wname.as_ptr()), Some(&dm), None, CDS_TEST, None)
|
ChangeDisplaySettingsExW(PCWSTR(wname.as_ptr()), Some(&dm), None, CDS_TEST, None)
|
||||||
};
|
},
|
||||||
|
);
|
||||||
if test != DISP_CHANGE_SUCCESSFUL {
|
if test != DISP_CHANGE_SUCCESSFUL {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
result = test.0,
|
result = test.0,
|
||||||
@@ -642,7 +654,11 @@ pub fn set_active_mode(gdi_name: &str, mode: Mode) {
|
|||||||
// SAFETY: same inputs as the CDS_TEST call above — `wname` (live NUL-terminated device name) and
|
// SAFETY: same inputs as the CDS_TEST call above — `wname` (live NUL-terminated device name) and
|
||||||
// `&dm` (live DEVMODEW) both outlive the call; CDS_UPDATEREGISTRY applies the already-validated mode,
|
// `&dm` (live DEVMODEW) both outlive the call; CDS_UPDATEREGISTRY applies the already-validated mode,
|
||||||
// and the API only reads its inputs.
|
// and the API only reads its inputs.
|
||||||
let apply = unsafe {
|
// Same wrong-desktop retry as the validate above: the two calls bind independently, so an apply
|
||||||
|
// still lands even when the secure desktop came up between them.
|
||||||
|
let apply = crate::input_desktop::retry_on_input_desktop(
|
||||||
|
|rc| *rc == DISP_CHANGE_FAILED,
|
||||||
|
|| unsafe {
|
||||||
ChangeDisplaySettingsExW(
|
ChangeDisplaySettingsExW(
|
||||||
PCWSTR(wname.as_ptr()),
|
PCWSTR(wname.as_ptr()),
|
||||||
Some(&dm),
|
Some(&dm),
|
||||||
@@ -650,7 +666,8 @@ pub fn set_active_mode(gdi_name: &str, mode: Mode) {
|
|||||||
CDS_UPDATEREGISTRY,
|
CDS_UPDATEREGISTRY,
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
};
|
},
|
||||||
|
);
|
||||||
if apply == DISP_CHANGE_SUCCESSFUL {
|
if apply == DISP_CHANGE_SUCCESSFUL {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"{gdi_name}: active mode set to {}x{}@{}",
|
"{gdi_name}: active mode set to {}x{}@{}",
|
||||||
@@ -672,12 +689,20 @@ pub fn set_active_mode(gdi_name: &str, mode: Mode) {
|
|||||||
|
|
||||||
/// Human decode for a failed `ChangeDisplaySettingsExW` result. The two codes worth telling apart
|
/// Human decode for a failed `ChangeDisplaySettingsExW` result. The two codes worth telling apart
|
||||||
/// in a field log: `BADMODE` (the display's mode list genuinely lacks the mode) vs `FAILED` (the
|
/// in a field log: `BADMODE` (the display's mode list genuinely lacks the mode) vs `FAILED` (the
|
||||||
/// write itself was rejected — on a healthy driver that is the signature of a host process without
|
/// write itself was rejected). An earlier revision printed "mode not advertised?" for BOTH, which
|
||||||
/// console-session access, e.g. one trapped in a disconnected RDP session). An earlier revision
|
/// sent a lid-closed field report chasing the wrong cause.
|
||||||
/// printed "mode not advertised?" for BOTH, which sent a lid-closed field report chasing the wrong
|
///
|
||||||
/// cause.
|
/// `FAILED` itself has two causes needing opposite fixes — no console-session access (disconnected
|
||||||
|
/// RDP) versus the right session but the wrong desktop (UAC / lock / logon screen owns input) — so
|
||||||
|
/// it asks which before naming one. See [`sdc_access_denied_hint`] for the same split on the CCD
|
||||||
|
/// side.
|
||||||
fn disp_change_reason(rc: i32) -> &'static str {
|
fn disp_change_reason(rc: i32) -> &'static str {
|
||||||
match rc {
|
match rc {
|
||||||
|
-1 if crate::input_desktop::input_desktop_is_secure() => {
|
||||||
|
"DISP_CHANGE_FAILED: the SECURE desktop owns input — a UAC consent prompt, the lock \
|
||||||
|
screen or the logon screen is up, and display writes are refused off it. Dismiss the \
|
||||||
|
prompt on the host"
|
||||||
|
}
|
||||||
-1 => {
|
-1 => {
|
||||||
"DISP_CHANGE_FAILED: the display write was rejected — a host without console-session \
|
"DISP_CHANGE_FAILED: the display write was rejected — a host without console-session \
|
||||||
access (disconnected RDP session / non-console session) fails ALL display writes \
|
access (disconnected RDP session / non-console session) fails ALL display writes \
|
||||||
@@ -691,15 +716,28 @@ fn disp_change_reason(rc: i32) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Appended to `SetDisplayConfig` failure logs when rc is `ERROR_ACCESS_DENIED` (0x5) — per MS
|
/// Appended to `SetDisplayConfig` failure logs when rc is `ERROR_ACCESS_DENIED` (0x5). Every other
|
||||||
/// docs "the caller does not have access to the console session", the field signature of a host
|
/// rc gets no hint.
|
||||||
/// running in a disconnected RDP / non-console session. Every other rc gets no hint.
|
///
|
||||||
|
/// `ERROR_ACCESS_DENIED` has TWO field causes and they need opposite fixes, so ask which one before
|
||||||
|
/// naming it. MS docs say only "the caller does not have access to the console session", which is
|
||||||
|
/// the disconnected-RDP / non-console case — but the SAME rc comes back when the host IS in the
|
||||||
|
/// console session and merely off the input desktop (UAC consent, lock or logon screen up). Naming
|
||||||
|
/// only the first sent a 2026-07-23 investigation chasing a phantom RDP session while a consent
|
||||||
|
/// prompt was the actual cause, on a host the message told to "run via the installed service" —
|
||||||
|
/// which it already was. [`crate::input_desktop`] now retries these bound to the input desktop, so
|
||||||
|
/// seeing this at all means even that did not help.
|
||||||
fn sdc_access_denied_hint(rc: i32) -> &'static str {
|
fn sdc_access_denied_hint(rc: i32) -> &'static str {
|
||||||
if rc == 5 {
|
if rc != 5 {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if crate::input_desktop::input_desktop_is_secure() {
|
||||||
|
" (ERROR_ACCESS_DENIED: the SECURE desktop owns input — a UAC consent prompt, the lock \
|
||||||
|
screen or the logon screen is up, and display writes are refused off it. Dismiss the \
|
||||||
|
prompt on the host)"
|
||||||
|
} else {
|
||||||
" (ERROR_ACCESS_DENIED: the host has no console-session access — disconnected RDP \
|
" (ERROR_ACCESS_DENIED: the host has no console-session access — disconnected RDP \
|
||||||
session? run via the installed service so it tracks the console session)"
|
session? run via the installed service so it tracks the console session)"
|
||||||
} else {
|
|
||||||
""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -901,7 +939,7 @@ pub unsafe fn isolate_displays_ccd(keep_target_ids: &[u32]) -> Option<SavedConfi
|
|||||||
// live topology each attempt and re-apply until ONLY the keep set is active. Secure-desktop
|
// live topology each attempt and re-apply until ONLY the keep set is active. Secure-desktop
|
||||||
// correctness depends on this — the lock screen must not land on a stray panel while we stream.
|
// correctness depends on this — the lock screen must not land on a stray panel while we stream.
|
||||||
for attempt in 1..=4u32 {
|
for attempt in 1..=4u32 {
|
||||||
let (mut paths, modes) = query_active_config()?;
|
let (mut paths, mut modes) = query_active_config()?;
|
||||||
let mut others = 0u32;
|
let mut others = 0u32;
|
||||||
for p in paths.iter_mut() {
|
for p in paths.iter_mut() {
|
||||||
if keep_target_ids.contains(&p.targetInfo.id) {
|
if keep_target_ids.contains(&p.targetInfo.id) {
|
||||||
@@ -923,17 +961,26 @@ pub unsafe fn isolate_displays_ccd(keep_target_ids: &[u32]) -> Option<SavedConfi
|
|||||||
others += 1;
|
others += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The doomed display may have HELD the desktop origin (the physical is primary in the
|
||||||
|
// single-slot exclusive case): re-anchor the kept sources so the supplied config still
|
||||||
|
// contains a primary — an origin-less desktop is rejected 0x57 no matter which array
|
||||||
|
// shape carries it (see `anchor_kept_sources_at_origin`).
|
||||||
|
if others > 0 {
|
||||||
|
anchor_kept_sources_at_origin(&paths, &mut modes);
|
||||||
|
}
|
||||||
// Commit the config. Even when nothing needed deactivating we re-commit: a legacy mode-set does
|
// Commit the config. Even when nothing needed deactivating we re-commit: a legacy mode-set does
|
||||||
// NOT drive the IddCx adapter's EVT_IDD_CX_ADAPTER_COMMIT_MODES, and without COMMIT_MODES the OS
|
// NOT drive the IddCx adapter's EVT_IDD_CX_ADAPTER_COMMIT_MODES, and without COMMIT_MODES the OS
|
||||||
// never calls ASSIGN_SWAPCHAIN, so the driver receives no frames. SDC_FORCE_MODE_ENUMERATION
|
// never calls ASSIGN_SWAPCHAIN, so the driver receives no frames. SDC_FORCE_MODE_ENUMERATION
|
||||||
// forces the re-commit; SAVE_TO_DATABASE only in the sole-path case (matches prior behavior —
|
// forces the re-commit; SAVE_TO_DATABASE only in the sole-path case (matches prior behavior —
|
||||||
// don't permanently rewrite the user's multi-display layout; the teardown restore handles it).
|
// don't permanently rewrite the user's multi-display layout; the teardown restore handles it).
|
||||||
let rc = if others > 0 && attempt >= 2 {
|
// The supplied shape is decided (and its escalation announced) ONCE, outside the write, so a
|
||||||
// ESCALATION (attempt 2+): supply ONLY the keep paths. Field-reported (AMD +
|
// wrong-desktop retry re-issues the identical config instead of logging the escalation twice.
|
||||||
// pf-vdisplay): carrying the doomed path in the array — inactive, modes unpinned —
|
let keep_only = (others > 0 && attempt >= 2).then(|| {
|
||||||
// gets the whole config rejected 0x57 on EVERY retry, so the loop alone never
|
// ESCALATION (attempt 2+): supply ONLY the keep paths. Kept as belt-and-braces —
|
||||||
// converged; the same host applies the keep-only shape rc=0 whenever the topology
|
// the field 0x57 this was built for turned out to be the missing desktop origin
|
||||||
// database has already made the virtual display sole. The final attempt also drops
|
// (see `anchor_kept_sources_at_origin`), which rejected BOTH shapes identically;
|
||||||
|
// but should some other validator still choke on the full array, the minimal
|
||||||
|
// shape is the best last word. The final attempt also drops
|
||||||
// SDC_FORCE_MODE_ENUMERATION in case the driver rejects it combined with a real
|
// SDC_FORCE_MODE_ENUMERATION in case the driver rejects it combined with a real
|
||||||
// topology change — an actual path removal drives COMMIT_MODES on its own, so the
|
// topology change — an actual path removal drives COMMIT_MODES on its own, so the
|
||||||
// re-commit rationale doesn't need the flag here.
|
// re-commit rationale doesn't need the flag here.
|
||||||
@@ -946,8 +993,11 @@ pub unsafe fn isolate_displays_ccd(keep_target_ids: &[u32]) -> Option<SavedConfi
|
|||||||
"display isolate (CCD): escalating to a keep-only supplied config (attempt {attempt}/4, paths {}→{}, modes {}→{})",
|
"display isolate (CCD): escalating to a keep-only supplied config (attempt {attempt}/4, paths {}→{}, modes {}→{})",
|
||||||
paths.len(), kp.len(), modes.len(), km.len()
|
paths.len(), kp.len(), modes.len(), km.len()
|
||||||
);
|
);
|
||||||
SetDisplayConfig(Some(kp.as_slice()), Some(km.as_slice()), esc)
|
(kp, km, esc)
|
||||||
} else {
|
});
|
||||||
|
let rc = crate::input_desktop::retry_set_display_config(|| match &keep_only {
|
||||||
|
Some((kp, km, esc)) => SetDisplayConfig(Some(kp.as_slice()), Some(km.as_slice()), *esc),
|
||||||
|
None => {
|
||||||
let mut flags = SDC_APPLY
|
let mut flags = SDC_APPLY
|
||||||
| SDC_USE_SUPPLIED_DISPLAY_CONFIG
|
| SDC_USE_SUPPLIED_DISPLAY_CONFIG
|
||||||
| SDC_ALLOW_CHANGES
|
| SDC_ALLOW_CHANGES
|
||||||
@@ -956,7 +1006,8 @@ pub unsafe fn isolate_displays_ccd(keep_target_ids: &[u32]) -> Option<SavedConfi
|
|||||||
flags |= SDC_SAVE_TO_DATABASE;
|
flags |= SDC_SAVE_TO_DATABASE;
|
||||||
}
|
}
|
||||||
SetDisplayConfig(Some(paths.as_slice()), Some(modes.as_slice()), flags)
|
SetDisplayConfig(Some(paths.as_slice()), Some(modes.as_slice()), flags)
|
||||||
};
|
}
|
||||||
|
});
|
||||||
// A failed apply must be VISIBLE even when the verification below passes vacuously (nothing
|
// A failed apply must be VISIBLE even when the verification below passes vacuously (nothing
|
||||||
// else was active to deactivate — the lid-closed laptop case, where the success INFO used to
|
// else was active to deactivate — the lid-closed laptop case, where the success INFO used to
|
||||||
// swallow rc=0x5): the re-commit above is load-bearing (COMMIT_MODES → ASSIGN_SWAPCHAIN),
|
// swallow rc=0x5): the re-commit above is load-bearing (COMMIT_MODES → ASSIGN_SWAPCHAIN),
|
||||||
@@ -979,7 +1030,18 @@ pub unsafe fn isolate_displays_ccd(keep_target_ids: &[u32]) -> Option<SavedConfi
|
|||||||
tracing::warn!("display isolate (CCD): {survivors} display(s) STILL active after attempt {attempt}/4 (deactivated {others}, rc={rc:#x}) — re-querying + retrying");
|
tracing::warn!("display isolate (CCD): {survivors} display(s) STILL active after attempt {attempt}/4 (deactivated {others}, rc={rc:#x}) — re-querying + retrying");
|
||||||
std::thread::sleep(std::time::Duration::from_millis(250));
|
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||||
}
|
}
|
||||||
tracing::error!("display isolate (CCD): failed to isolate target set {keep_target_ids:?} after 4 attempts — a non-virtual display stayed active (field-reported exclusive-mode bug)");
|
// Name the survivors instead of assuming their kind — the field logs showed this path fire
|
||||||
|
// with a sibling VIRTUAL display as the survivor (linger-expiry shrink), where the old
|
||||||
|
// "a non-virtual display stayed active" wording sent the triage the wrong way.
|
||||||
|
let survivors: Vec<String> = target_inventory()
|
||||||
|
.iter()
|
||||||
|
.filter(|t| t.active && !keep_target_ids.contains(&t.target_id))
|
||||||
|
.map(|t| format!("{} {} \"{}\"", t.target_id, t.tech, t.friendly))
|
||||||
|
.collect();
|
||||||
|
tracing::error!(
|
||||||
|
"display isolate (CCD): failed to isolate target set {keep_target_ids:?} after 4 attempts — still active: [{}] (field-reported exclusive-mode bug)",
|
||||||
|
survivors.join(", ")
|
||||||
|
);
|
||||||
Some(saved)
|
Some(saved)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1040,6 +1102,60 @@ fn remap_mode_idx(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A committable desktop must still contain a PRIMARY — a source pinned exactly at the origin
|
||||||
|
/// `(0,0)`. Deactivating the display that held the origin (the physical, in the exclusive
|
||||||
|
/// topology) while the kept virtual stays pinned at its EXTEND offset supplies an origin-less
|
||||||
|
/// desktop, and Windows rejects that wholesale with 0x57 ERROR_INVALID_PARAMETER no matter the
|
||||||
|
/// array shape — the field box failed identically with the doomed path carried inactive AND with
|
||||||
|
/// the keep-only escalation, yet the very same call converged rc=0 whenever a kept member already
|
||||||
|
/// sat at `(0,0)`; the origin was the real variable all along. Translate the kept sources RIGIDLY
|
||||||
|
/// (relative arrangement preserved) so the top-left-most lands exactly on the origin. A set that
|
||||||
|
/// already covers `(0,0)` is left untouched, so a plain re-commit stays byte-identical and a
|
||||||
|
/// user's negative-coordinate multi-monitor layout is never rearranged.
|
||||||
|
unsafe fn anchor_kept_sources_at_origin(
|
||||||
|
paths: &[DISPLAYCONFIG_PATH_INFO],
|
||||||
|
modes: &mut [DISPLAYCONFIG_MODE_INFO],
|
||||||
|
) {
|
||||||
|
// Unique source-mode entries of the still-ACTIVE (kept) paths — clone-style pairs share one,
|
||||||
|
// and a shared entry must be translated once.
|
||||||
|
let mut idxs: Vec<usize> = Vec::new();
|
||||||
|
for p in paths {
|
||||||
|
if p.flags & DISPLAYCONFIG_PATH_ACTIVE == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let idx = p.sourceInfo.Anonymous.modeInfoIdx as usize;
|
||||||
|
let Some(m) = modes.get(idx) else { continue };
|
||||||
|
if m.infoType == DISPLAYCONFIG_MODE_INFO_TYPE_SOURCE && !idxs.contains(&idx) {
|
||||||
|
idxs.push(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let positions: Vec<(i32, i32)> = idxs
|
||||||
|
.iter()
|
||||||
|
.map(|&i| {
|
||||||
|
let pos = modes[i].Anonymous.sourceMode.position;
|
||||||
|
(pos.x, pos.y)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if positions.contains(&(0, 0)) {
|
||||||
|
return; // the kept set already holds the primary — don't touch a valid layout
|
||||||
|
}
|
||||||
|
// Lexicographic min over the actual positions — the anchor IS one of the kept sources, so
|
||||||
|
// after translation one source sits exactly at (0,0), which is what the validator wants.
|
||||||
|
let Some((ax, ay)) = positions.iter().copied().min() else {
|
||||||
|
return; // no pinned kept sources — placement is the OS's (SDC_ALLOW_CHANGES), nothing to anchor
|
||||||
|
};
|
||||||
|
for &i in &idxs {
|
||||||
|
let sm = &mut modes[i].Anonymous.sourceMode;
|
||||||
|
sm.position.x -= ax;
|
||||||
|
sm.position.y -= ay;
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
"display isolate (CCD): kept source(s) re-anchored onto the desktop origin (primary) — the doomed display held (0,0) delta=({},{})",
|
||||||
|
-ax,
|
||||||
|
-ay
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The desktop-space rectangle `(x, y, w, h)` of `target_id`'s SOURCE — where this display's
|
/// The desktop-space rectangle `(x, y, w, h)` of `target_id`'s SOURCE — where this display's
|
||||||
/// region lives in the desktop coordinate space. `None` while the target isn't an active path.
|
/// region lives in the desktop coordinate space. `None` while the target isn't an active path.
|
||||||
/// Used by the IDD-push compose kick to dirty THE TARGET display: with parallel displays the
|
/// Used by the IDD-push compose kick to dirty THE TARGET display: with parallel displays the
|
||||||
@@ -1134,14 +1250,16 @@ pub unsafe fn apply_source_positions(positions: &[(u32, i32, i32)]) {
|
|||||||
if moved == 0 {
|
if moved == 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let rc = SetDisplayConfig(
|
let rc = crate::input_desktop::retry_set_display_config(|| {
|
||||||
|
SetDisplayConfig(
|
||||||
Some(paths.as_slice()),
|
Some(paths.as_slice()),
|
||||||
Some(modes.as_slice()),
|
Some(modes.as_slice()),
|
||||||
SDC_APPLY
|
SDC_APPLY
|
||||||
| SDC_USE_SUPPLIED_DISPLAY_CONFIG
|
| SDC_USE_SUPPLIED_DISPLAY_CONFIG
|
||||||
| SDC_ALLOW_CHANGES
|
| SDC_ALLOW_CHANGES
|
||||||
| SDC_FORCE_MODE_ENUMERATION,
|
| SDC_FORCE_MODE_ENUMERATION,
|
||||||
);
|
)
|
||||||
|
});
|
||||||
if rc == 0 {
|
if rc == 0 {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
?positions,
|
?positions,
|
||||||
@@ -1226,14 +1344,16 @@ pub unsafe fn set_virtual_primary_ccd(keep_target_id: u32) -> Option<SavedConfig
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let rc = SetDisplayConfig(
|
let rc = crate::input_desktop::retry_set_display_config(|| {
|
||||||
|
SetDisplayConfig(
|
||||||
Some(paths.as_slice()),
|
Some(paths.as_slice()),
|
||||||
Some(modes.as_slice()),
|
Some(modes.as_slice()),
|
||||||
SDC_APPLY
|
SDC_APPLY
|
||||||
| SDC_USE_SUPPLIED_DISPLAY_CONFIG
|
| SDC_USE_SUPPLIED_DISPLAY_CONFIG
|
||||||
| SDC_ALLOW_CHANGES
|
| SDC_ALLOW_CHANGES
|
||||||
| SDC_FORCE_MODE_ENUMERATION,
|
| SDC_FORCE_MODE_ENUMERATION,
|
||||||
);
|
)
|
||||||
|
});
|
||||||
if rc == 0 {
|
if rc == 0 {
|
||||||
tracing::info!("display primary (CCD): virtual target {keep_target_id} set PRIMARY at (0,0); {others} other display(s) kept ACTIVE + packed to its right");
|
tracing::info!("display primary (CCD): virtual target {keep_target_id} set PRIMARY at (0,0); {others} other display(s) kept ACTIVE + packed to its right");
|
||||||
} else {
|
} else {
|
||||||
@@ -1253,11 +1373,13 @@ pub unsafe fn restore_displays_ccd(saved: &SavedConfig) {
|
|||||||
if paths.is_empty() {
|
if paths.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let rc = SetDisplayConfig(
|
let rc = crate::input_desktop::retry_set_display_config(|| {
|
||||||
|
SetDisplayConfig(
|
||||||
Some(paths.as_slice()),
|
Some(paths.as_slice()),
|
||||||
Some(modes.as_slice()),
|
Some(modes.as_slice()),
|
||||||
SDC_APPLY | SDC_USE_SUPPLIED_DISPLAY_CONFIG | SDC_ALLOW_CHANGES,
|
SDC_APPLY | SDC_USE_SUPPLIED_DISPLAY_CONFIG | SDC_ALLOW_CHANGES,
|
||||||
);
|
)
|
||||||
|
});
|
||||||
if rc == 0 {
|
if rc == 0 {
|
||||||
tracing::info!("display isolate (CCD): restored original topology");
|
tracing::info!("display isolate (CCD): restored original topology");
|
||||||
} else {
|
} else {
|
||||||
@@ -1266,4 +1388,22 @@ pub unsafe fn restore_displays_ccd(saved: &SavedConfig) {
|
|||||||
sdc_access_denied_hint(rc)
|
sdc_access_denied_hint(rc)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// GUARANTEE the desk is never left all-dark. The saved config can be unappliable (field
|
||||||
|
// rc=0x64a ERROR_BAD_CONFIGURATION: it pinned a virtual target incarnation that was since
|
||||||
|
// removed) or even apply rc=0 yet re-light nothing (snapshotted while an earlier failed
|
||||||
|
// teardown had the physicals off — the poisoned-snapshot chain from the field logs). Either
|
||||||
|
// way, if no external physical panel is active after the apply while at least one is
|
||||||
|
// connected, fall back to the OS database EXTEND preset, which re-activates every connected
|
||||||
|
// display. Internal panels deliberately don't count as lit-able here — a closed clamshell
|
||||||
|
// lid must not be forced back on.
|
||||||
|
let (connected, lit) = target_inventory()
|
||||||
|
.iter()
|
||||||
|
.filter(|t| t.external_physical)
|
||||||
|
.fold((0u32, 0u32), |(c, a), t| (c + 1, a + u32::from(t.active)));
|
||||||
|
if connected > 0 && lit == 0 {
|
||||||
|
tracing::warn!(
|
||||||
|
"display isolate (CCD): no external physical display active after the restore (rc={rc:#x}, connected={connected}) — forcing the EXTEND preset so the desk is not left dark"
|
||||||
|
);
|
||||||
|
force_extend_topology();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
|||||||
};
|
};
|
||||||
let handshake::HandshakeOut {
|
let handshake::HandshakeOut {
|
||||||
conn,
|
conn,
|
||||||
|
ep,
|
||||||
session,
|
session,
|
||||||
ctrl_send,
|
ctrl_send,
|
||||||
ctrl_recv,
|
ctrl_recv,
|
||||||
@@ -192,4 +193,11 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
|||||||
0
|
0
|
||||||
};
|
};
|
||||||
conn.close(close_code.into(), b"client closed");
|
conn.close(close_code.into(), b"client closed");
|
||||||
|
// Flush the CONNECTION_CLOSE before the runtime is dropped (the same discipline as the pairing
|
||||||
|
// + probe paths). `close` only queues the frame — the endpoint driver puts it on the wire, and
|
||||||
|
// this fn is the body of a `block_on` whose runtime is dropped the instant it returns, so
|
||||||
|
// without this the driver could simply never be polled again. The host then saw a deliberate
|
||||||
|
// quit as silence: no `QUIT_CLOSE_CODE`, an 8 s idle timeout, and the keep-alive linger meant
|
||||||
|
// for an UNWANTED disconnect. Bounded — a host already gone must not delay the client's exit.
|
||||||
|
let _ = tokio::time::timeout(std::time::Duration::from_millis(300), ep.wait_idle()).await;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,17 @@
|
|||||||
//! up the data-plane [`Session`]. A typed application close from the host surfaces as
|
//! up the data-plane [`Session`]. A typed application close from the host surfaces as
|
||||||
//! [`PunktfunkError::Rejected`] instead of the generic transport error.
|
//! [`PunktfunkError::Rejected`] instead of the generic transport error.
|
||||||
|
|
||||||
use super::super::*;
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// Everything [`run_pump`](super::run_pump) needs from a successful connect + handshake.
|
/// Everything [`run_pump`](super::run_pump) needs from a successful connect + handshake.
|
||||||
pub(super) struct HandshakeOut {
|
pub(super) struct HandshakeOut {
|
||||||
pub(super) conn: quinn::Connection,
|
pub(super) conn: quinn::Connection,
|
||||||
|
/// The dialing endpoint, kept alive for the session so the pump can FLUSH its
|
||||||
|
/// `CONNECTION_CLOSE` before the runtime is dropped ([`super::run_pump`]). Dropping it here
|
||||||
|
/// left nothing to drive the close onto the wire, so a deliberate quit reached the host as
|
||||||
|
/// silence — an 8 s idle timeout with no quit code, which the host reads as an unwanted
|
||||||
|
/// disconnect and lingers the display for.
|
||||||
|
pub(super) ep: quinn::Endpoint,
|
||||||
pub(super) session: Session,
|
pub(super) session: Session,
|
||||||
pub(super) ctrl_send: quinn::SendStream,
|
pub(super) ctrl_send: quinn::SendStream,
|
||||||
pub(super) ctrl_recv: io::MsgReader,
|
pub(super) ctrl_recv: io::MsgReader,
|
||||||
@@ -238,6 +243,7 @@ pub(super) async fn connect_and_handshake(args: &WorkerArgs) -> Result<Handshake
|
|||||||
match handshake.await {
|
match handshake.await {
|
||||||
Ok((session, send, recv, negotiated, host_caps)) => Ok(HandshakeOut {
|
Ok((session, send, recv, negotiated, host_caps)) => Ok(HandshakeOut {
|
||||||
conn,
|
conn,
|
||||||
|
ep,
|
||||||
session,
|
session,
|
||||||
ctrl_send: send,
|
ctrl_send: send,
|
||||||
ctrl_recv: recv,
|
ctrl_recv: recv,
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
//! Keyboard/mouse/touch events pass through unchanged; an older host (no caps bit) keeps
|
//! Keyboard/mouse/touch events pass through unchanged; an older host (no caps bit) keeps
|
||||||
//! getting the legacy per-transition gamepad events.
|
//! getting the legacy per-transition gamepad events.
|
||||||
|
|
||||||
use super::super::*;
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
pub(super) async fn run(
|
pub(super) async fn run(
|
||||||
|
|||||||
@@ -220,12 +220,13 @@ pub fn start(
|
|||||||
rikeyid: i32,
|
rikeyid: i32,
|
||||||
params: AudioParams,
|
params: AudioParams,
|
||||||
audio_cap: AudioCapSlot,
|
audio_cap: AudioCapSlot,
|
||||||
|
on_lost: super::OnSessionLost,
|
||||||
) {
|
) {
|
||||||
let _ = std::thread::Builder::new()
|
let _ = std::thread::Builder::new()
|
||||||
.name("punktfunk-audio".into())
|
.name("punktfunk-audio".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
tracing::info!(?params, "audio stream starting");
|
tracing::info!(?params, "audio stream starting");
|
||||||
if let Err(e) = run(&running, &gcm_key, rikeyid, params, &audio_cap) {
|
if let Err(e) = run(&running, &gcm_key, rikeyid, params, &audio_cap, &on_lost) {
|
||||||
tracing::error!(error = %format!("{e:#}"), "audio stream failed");
|
tracing::error!(error = %format!("{e:#}"), "audio stream failed");
|
||||||
}
|
}
|
||||||
running.store(false, Ordering::SeqCst);
|
running.store(false, Ordering::SeqCst);
|
||||||
@@ -243,6 +244,7 @@ pub fn start(
|
|||||||
_rikeyid: i32,
|
_rikeyid: i32,
|
||||||
_params: AudioParams,
|
_params: AudioParams,
|
||||||
_audio_cap: AudioCapSlot,
|
_audio_cap: AudioCapSlot,
|
||||||
|
_on_lost: super::OnSessionLost,
|
||||||
) {
|
) {
|
||||||
tracing::error!("GameStream audio requires Linux (PipeWire) or Windows (WASAPI) + libopus");
|
tracing::error!("GameStream audio requires Linux (PipeWire) or Windows (WASAPI) + libopus");
|
||||||
running.store(false, std::sync::atomic::Ordering::SeqCst);
|
running.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||||
@@ -255,6 +257,7 @@ fn run(
|
|||||||
rikeyid: i32,
|
rikeyid: i32,
|
||||||
params: AudioParams,
|
params: AudioParams,
|
||||||
audio_cap: &std::sync::Mutex<Option<Box<dyn AudioCapturer>>>,
|
audio_cap: &std::sync::Mutex<Option<Box<dyn AudioCapturer>>>,
|
||||||
|
on_lost: &super::OnSessionLost,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let sock = UdpSocket::bind(("0.0.0.0", AUDIO_PORT)).context("bind audio UDP")?;
|
let sock = UdpSocket::bind(("0.0.0.0", AUDIO_PORT)).context("bind audio UDP")?;
|
||||||
// Grow SO_SNDBUF/RCVBUF; the opt-in DSCP/QoS tag happens after connect below (Windows
|
// Grow SO_SNDBUF/RCVBUF; the opt-in DSCP/QoS tag happens after connect below (Windows
|
||||||
@@ -296,7 +299,7 @@ fn run(
|
|||||||
}
|
}
|
||||||
None => audio::open_audio_capture(want).context("open audio capture")?,
|
None => audio::open_audio_capture(want).context("open audio capture")?,
|
||||||
};
|
};
|
||||||
let result = audio_body(&mut *cap, &sock, gcm_key, rikeyid, params, running);
|
let result = audio_body(&mut *cap, &sock, gcm_key, rikeyid, params, running, on_lost);
|
||||||
cap.idle(); // parked between sessions — release the routing claim (Linux stream sink)
|
cap.idle(); // parked between sessions — release the routing claim (Linux stream sink)
|
||||||
audio::park_audio_capture(audio_cap, cap); // drop on Windows (restores the default), keep on Linux
|
audio::park_audio_capture(audio_cap, cap); // drop on Windows (restores the default), keep on Linux
|
||||||
result
|
result
|
||||||
@@ -355,6 +358,7 @@ impl SessionEncoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn audio_body(
|
fn audio_body(
|
||||||
cap: &mut dyn AudioCapturer,
|
cap: &mut dyn AudioCapturer,
|
||||||
sock: &UdpSocket,
|
sock: &UdpSocket,
|
||||||
@@ -362,6 +366,9 @@ fn audio_body(
|
|||||||
rikeyid: i32,
|
rikeyid: i32,
|
||||||
params: AudioParams,
|
params: AudioParams,
|
||||||
running: &AtomicBool,
|
running: &AtomicBool,
|
||||||
|
// Whole-session teardown for the client-unreachable send errors below — video would
|
||||||
|
// otherwise keep streaming at the dead endpoint (see `AppState::end_session`).
|
||||||
|
on_lost: &super::OnSessionLost,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let layout = layout_for(¶ms);
|
let layout = layout_for(¶ms);
|
||||||
let mut enc = SessionEncoder::new(layout)?;
|
let mut enc = SessionEncoder::new(layout)?;
|
||||||
@@ -427,7 +434,8 @@ fn audio_body(
|
|||||||
.encrypt_padded_vec_mut::<Pkcs7>(&out[..n]);
|
.encrypt_padded_vec_mut::<Pkcs7>(&out[..n]);
|
||||||
let pkt = build_rtp(seq, timestamp, &ct);
|
let pkt = build_rtp(seq, timestamp, &ct);
|
||||||
if sock.send(&pkt).is_err() {
|
if sock.send(&pkt).is_err() {
|
||||||
tracing::info!(sent, "audio: client unreachable — stopping");
|
tracing::info!(sent, "audio: client unreachable — ending session");
|
||||||
|
on_lost();
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
// Surround FEC: accumulate the encrypted payloads of the aligned 4-packet block;
|
// Surround FEC: accumulate the encrypted payloads of the aligned 4-packet block;
|
||||||
@@ -449,7 +457,11 @@ fn audio_body(
|
|||||||
let fp =
|
let fp =
|
||||||
build_fec_rtp(rtp_seq, x as u8, fec_base_seq, fec_base_ts, par);
|
build_fec_rtp(rtp_seq, x as u8, fec_base_seq, fec_base_ts, par);
|
||||||
if sock.send(&fp).is_err() {
|
if sock.send(&fp).is_err() {
|
||||||
tracing::info!(sent, "audio: client unreachable — stopping");
|
tracing::info!(
|
||||||
|
sent,
|
||||||
|
"audio: client unreachable — ending session"
|
||||||
|
);
|
||||||
|
on_lost();
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,10 +83,34 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
|
|||||||
match host.service() {
|
match host.service() {
|
||||||
Ok(Some(event)) => match event {
|
Ok(Some(event)) => match event {
|
||||||
Event::Connect { peer: p, .. } => {
|
Event::Connect { peer: p, .. } => {
|
||||||
|
// Track this peer as THE session peer only if it comes from the
|
||||||
|
// `/launch` owner's IP (when captured — `None` falls back to
|
||||||
|
// trusting the connect, the pre-teardown behavior). The tracked
|
||||||
|
// peer's disconnect now ENDS the session, so an unauthenticated
|
||||||
|
// LAN peer that connects+disconnects on 47999 must not be able to
|
||||||
|
// steal the slot and tear a live session down. Same source-IP
|
||||||
|
// bind the RTSP/media plane uses (security-review #4).
|
||||||
|
let owner_ip = state.launch.lock().unwrap().and_then(|s| s.peer_ip);
|
||||||
|
let from = p.address().map(|a| a.ip());
|
||||||
|
if owner_ip.is_some() && from.is_some() && owner_ip != from {
|
||||||
|
tracing::warn!(
|
||||||
|
?from,
|
||||||
|
"control: peer connected from a non-owner IP — ignoring"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
tracing::info!("control: client connected");
|
tracing::info!("control: client connected");
|
||||||
peer = Some(p.id());
|
peer = Some(p.id());
|
||||||
}
|
}
|
||||||
Event::Disconnect { .. } => {
|
}
|
||||||
|
Event::Disconnect { peer: p, .. } => {
|
||||||
|
// Gate on the TRACKED session peer: a stray probe peer (or the
|
||||||
|
// OLD peer's late timeout after a fast reconnect replaced it in
|
||||||
|
// the Connect arm) must neither clobber the live session's input
|
||||||
|
// state nor end its session.
|
||||||
|
if peer != Some(p.id()) {
|
||||||
|
tracing::debug!("control: non-session peer disconnected");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
tracing::info!("control: client disconnected");
|
tracing::info!("control: client disconnected");
|
||||||
detected = None;
|
detected = None;
|
||||||
decrypt_fails = 0;
|
decrypt_fails = 0;
|
||||||
@@ -97,6 +121,16 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
|
|||||||
// uinput pen releases any held tool/tip kernel-side).
|
// uinput pen releases any held tool/tip kernel-side).
|
||||||
pads = GamepadManager::new();
|
pads = GamepadManager::new();
|
||||||
pointer = super::pen::GsPointer::new();
|
pointer = super::pen::GsPointer::new();
|
||||||
|
// The control stream is the session's liveness anchor — Moonlight
|
||||||
|
// holds it for the whole stream, and ENet detects a vanished peer
|
||||||
|
// via its reliable-ping timeout (~5–30 s), which ALSO lands here.
|
||||||
|
// End the session: without this, a client that disconnects without
|
||||||
|
// an explicit RTSP TEARDOWN / nvhttp `/cancel` (a network drop,
|
||||||
|
// sleep, crash — or just a plain Moonlight quit, which sends
|
||||||
|
// neither) left the media threads streaming at the dead endpoint
|
||||||
|
// forever (a UDP send only errors on an ICMP port-unreachable) and
|
||||||
|
// the stale launch/streaming state wedged every reconnect.
|
||||||
|
state.end_session("control stream disconnected");
|
||||||
}
|
}
|
||||||
Event::Receive {
|
Event::Receive {
|
||||||
channel_id, packet, ..
|
channel_id, packet, ..
|
||||||
|
|||||||
@@ -182,7 +182,46 @@ pub struct AppState {
|
|||||||
pub stats: Arc<crate::stats_recorder::StatsRecorder>,
|
pub stats: Arc<crate::stats_recorder::StatsRecorder>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Session-lost callback the media threads invoke when they detect the client is unreachable
|
||||||
|
/// (a UDP send error): ends the WHOLE GameStream session via [`AppState::end_session`], not just
|
||||||
|
/// the thread that noticed — video and audio otherwise stop independently and leave the launch
|
||||||
|
/// state behind. Built by the RTSP PLAY handler (the one place with the `Arc<AppState>`).
|
||||||
|
pub(crate) type OnSessionLost = Arc<dyn Fn() + Send + Sync>;
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
|
/// End the GameStream session as one unit: signal BOTH media threads to stop (they observe
|
||||||
|
/// their `streaming`/`audio_streaming` flags) and clear the launch + negotiated stream
|
||||||
|
/// config. Idempotent — safe to call from every "the client is gone" site.
|
||||||
|
///
|
||||||
|
/// This is THE teardown for the compat plane. Anything less leaves a stale session behind:
|
||||||
|
/// a lingering `launch` 503-blocks a different client's `/launch` under
|
||||||
|
/// `mode_conflict = reject`, and a stale `streaming = true` makes a reconnect's RTSP PLAY
|
||||||
|
/// take its "stream already running" branch while the old threads still stream at the
|
||||||
|
/// vanished client's endpoint (no new threads are started — the reconnect gets no media).
|
||||||
|
/// Returns whether the video stream was live (for the caller's log line).
|
||||||
|
pub(crate) fn end_session(&self, reason: &str) -> bool {
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
let was_streaming = self.streaming.swap(false, Ordering::SeqCst);
|
||||||
|
let was_audio = self.audio_streaming.swap(false, Ordering::SeqCst);
|
||||||
|
let had_launch = self
|
||||||
|
.launch
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.take()
|
||||||
|
.is_some();
|
||||||
|
self.stream.lock().unwrap_or_else(|e| e.into_inner()).take();
|
||||||
|
if was_streaming || was_audio || had_launch {
|
||||||
|
tracing::info!(
|
||||||
|
reason,
|
||||||
|
was_streaming,
|
||||||
|
was_audio,
|
||||||
|
had_launch,
|
||||||
|
"gamestream: session ended"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
was_streaming
|
||||||
|
}
|
||||||
|
|
||||||
/// Fresh control-plane state: no active session; the pairing allow-list is loaded from
|
/// Fresh control-plane state: no active session; the pairing allow-list is loaded from
|
||||||
/// disk (pairings persist across restarts). `stats` is the shared recorder handed to both the
|
/// disk (pairings persist across restarts). `stats` is the shared recorder handed to both the
|
||||||
/// mgmt API and the streaming loops.
|
/// mgmt API and the streaming loops.
|
||||||
@@ -429,6 +468,69 @@ pub(crate) fn save_paired(paired: &[Vec<u8>]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod session_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn test_state() -> AppState {
|
||||||
|
let host = Host {
|
||||||
|
hostname: "test-host".into(),
|
||||||
|
uniqueid: "deadbeef".into(),
|
||||||
|
local_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||||
|
http_port: HTTP_PORT,
|
||||||
|
https_port: HTTPS_PORT,
|
||||||
|
};
|
||||||
|
let identity = cert::ServerIdentity::ephemeral().expect("ephemeral identity");
|
||||||
|
let stats = crate::stats_recorder::StatsRecorder::new(std::env::temp_dir().join(format!(
|
||||||
|
"pf-gs-endsession-{}-{:p}",
|
||||||
|
std::process::id(),
|
||||||
|
&0u8 as *const u8
|
||||||
|
)));
|
||||||
|
AppState::new(host, identity, stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `end_session` is THE compat-plane teardown: one call must clear the whole session — both
|
||||||
|
/// media-thread flags, the launch, and the negotiated stream config — and be idempotent.
|
||||||
|
/// Guards the ENet-Disconnect / client-unreachable paths that previously stopped nothing
|
||||||
|
/// (the "session stays alive after the client disconnects" bug).
|
||||||
|
#[test]
|
||||||
|
fn end_session_clears_the_whole_session() {
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
let state = test_state();
|
||||||
|
state.streaming.store(true, Ordering::SeqCst);
|
||||||
|
state.audio_streaming.store(true, Ordering::SeqCst);
|
||||||
|
*state.launch.lock().unwrap() = Some(LaunchSession {
|
||||||
|
gcm_key: [0; 16],
|
||||||
|
rikeyid: 0,
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
fps: 60,
|
||||||
|
appid: 1,
|
||||||
|
peer_ip: None,
|
||||||
|
owner_fp: None,
|
||||||
|
});
|
||||||
|
*state.stream.lock().unwrap() = Some(stream::StreamConfig {
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
fps: 60,
|
||||||
|
packet_size: 1024,
|
||||||
|
bitrate_kbps: 20_000,
|
||||||
|
codec: crate::encode::Codec::H265,
|
||||||
|
min_fec: 0,
|
||||||
|
hdr: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(state.end_session("test"), "video was live");
|
||||||
|
assert!(!state.streaming.load(Ordering::SeqCst));
|
||||||
|
assert!(!state.audio_streaming.load(Ordering::SeqCst));
|
||||||
|
assert!(state.launch.lock().unwrap().is_none());
|
||||||
|
assert!(state.stream.lock().unwrap().is_none());
|
||||||
|
|
||||||
|
// Idempotent: a second end (e.g. `/cancel` racing the ENet Disconnect) is a no-op.
|
||||||
|
assert!(!state.end_session("test again"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(all(test, unix))]
|
#[cfg(all(test, unix))]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|||||||
@@ -250,14 +250,9 @@ async fn h_cancel(
|
|||||||
tracing::warn!("cancel rejected — caller does not own the session");
|
tracing::warn!("cancel rejected — caller does not own the session");
|
||||||
return xml(error_xml());
|
return xml(error_xml());
|
||||||
}
|
}
|
||||||
*st.launch.lock().unwrap() = None;
|
// Quit semantics: the shared full teardown (launch cleared + both media threads stop on
|
||||||
// Quit semantics: stop the running media threads (they observe these flags) so the session
|
// their flags) — the virtual output/gamescope teardown follows via the capturer's RAII.
|
||||||
// actually ends — the virtual output/gamescope teardown follows via the capturer's RAII.
|
st.end_session("client /cancel");
|
||||||
st.streaming
|
|
||||||
.store(false, std::sync::atomic::Ordering::SeqCst);
|
|
||||||
st.audio_streaming
|
|
||||||
.store(false, std::sync::atomic::Ordering::SeqCst);
|
|
||||||
tracing::info!("cancel — launch session cleared, streams stopping");
|
|
||||||
xml("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\"><cancel>1</cancel></root>\n".to_string())
|
xml("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\"><cancel>1</cancel></root>\n".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -190,7 +190,9 @@ fn authorized_launch(state: &AppState, peer: Option<SocketAddr>) -> Option<Launc
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_request(req: &Request, state: &AppState, peer: Option<SocketAddr>) -> String {
|
// `&Arc<AppState>` (not `&AppState`): PLAY hands the media threads a `'static` session-lost
|
||||||
|
// callback, which needs an owned clone of the state.
|
||||||
|
fn handle_request(req: &Request, state: &Arc<AppState>, peer: Option<SocketAddr>) -> String {
|
||||||
match req.method.as_str() {
|
match req.method.as_str() {
|
||||||
"OPTIONS" => response(
|
"OPTIONS" => response(
|
||||||
&req.cseq,
|
&req.cseq,
|
||||||
@@ -254,6 +256,15 @@ fn handle_request(req: &Request, state: &AppState, peer: Option<SocketAddr>) ->
|
|||||||
return response_status("401 Unauthorized", &req.cseq, &[], None);
|
return response_status("401 Unauthorized", &req.cseq, &[], None);
|
||||||
};
|
};
|
||||||
let cfg = *state.stream.lock().unwrap();
|
let cfg = *state.stream.lock().unwrap();
|
||||||
|
// Client-unreachable teardown for the media threads: ends the WHOLE session (both
|
||||||
|
// planes + launch state), so one plane detecting the dead client can't leave the
|
||||||
|
// other streaming at it — or leave a stale launch to wedge the next connect.
|
||||||
|
let on_lost: super::OnSessionLost = {
|
||||||
|
let st = state.clone();
|
||||||
|
Arc::new(move || {
|
||||||
|
st.end_session("client unreachable");
|
||||||
|
})
|
||||||
|
};
|
||||||
match cfg {
|
match cfg {
|
||||||
Some(cfg) if !state.streaming.swap(true, Ordering::SeqCst) => {
|
Some(cfg) if !state.streaming.swap(true, Ordering::SeqCst) => {
|
||||||
// Resolve the launched catalog entry (session recipe) for the stream.
|
// Resolve the launched catalog entry (session recipe) for the stream.
|
||||||
@@ -267,6 +278,7 @@ fn handle_request(req: &Request, state: &AppState, peer: Option<SocketAddr>) ->
|
|||||||
state.rfi_range.clone(),
|
state.rfi_range.clone(),
|
||||||
state.video_cap.clone(),
|
state.video_cap.clone(),
|
||||||
state.stats.clone(),
|
state.stats.clone(),
|
||||||
|
on_lost.clone(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Some(_) => tracing::info!("RTSP PLAY — stream already running"),
|
Some(_) => tracing::info!("RTSP PLAY — stream already running"),
|
||||||
@@ -283,6 +295,7 @@ fn handle_request(req: &Request, state: &AppState, peer: Option<SocketAddr>) ->
|
|||||||
ls.rikeyid,
|
ls.rikeyid,
|
||||||
*state.audio_params.lock().unwrap(),
|
*state.audio_params.lock().unwrap(),
|
||||||
state.audio_cap.clone(),
|
state.audio_cap.clone(),
|
||||||
|
on_lost,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
response(&req.cseq, &[("Session", "DEADBEEFCAFE;timeout = 90")], None)
|
response(&req.cseq, &[("Session", "DEADBEEFCAFE;timeout = 90")], None)
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ pub type RfiSlot = Arc<std::sync::Mutex<Option<(i64, i64)>>>;
|
|||||||
/// Spawn the video stream thread (idempotent via `running`). Stops when `running` clears.
|
/// Spawn the video stream thread (idempotent via `running`). Stops when `running` clears.
|
||||||
/// `force_idr` is set by the control stream on a client recovery request; `video_cap` holds
|
/// `force_idr` is set by the control stream on a client recovery request; `video_cap` holds
|
||||||
/// the persistent capturer the thread borrows for the stream's duration.
|
/// the persistent capturer the thread borrows for the stream's duration.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn start(
|
pub fn start(
|
||||||
cfg: StreamConfig,
|
cfg: StreamConfig,
|
||||||
app: Option<super::apps::AppEntry>,
|
app: Option<super::apps::AppEntry>,
|
||||||
@@ -53,6 +54,7 @@ pub fn start(
|
|||||||
rfi_range: RfiSlot,
|
rfi_range: RfiSlot,
|
||||||
video_cap: CapturerSlot,
|
video_cap: CapturerSlot,
|
||||||
stats: Arc<crate::stats_recorder::StatsRecorder>,
|
stats: Arc<crate::stats_recorder::StatsRecorder>,
|
||||||
|
on_lost: super::OnSessionLost,
|
||||||
) {
|
) {
|
||||||
let _ = std::thread::Builder::new()
|
let _ = std::thread::Builder::new()
|
||||||
.name("punktfunk-video".into())
|
.name("punktfunk-video".into())
|
||||||
@@ -103,6 +105,7 @@ pub fn start(
|
|||||||
&rfi_range,
|
&rfi_range,
|
||||||
&video_cap,
|
&video_cap,
|
||||||
&stats,
|
&stats,
|
||||||
|
&on_lost,
|
||||||
);
|
);
|
||||||
// A clean return is a stop (RTSP teardown / cancel / client unreachable) → `quit`;
|
// A clean return is a stop (RTSP teardown / cancel / client unreachable) → `quit`;
|
||||||
// an error return is `error`. The compat plane can't tell a user stop from an idle
|
// an error return is `error`. The compat plane can't tell a user stop from an idle
|
||||||
@@ -137,6 +140,8 @@ fn run(
|
|||||||
// Shared stats recorder for the web-console capture/graph. Threaded into `stream_body` (the
|
// Shared stats recorder for the web-console capture/graph. Threaded into `stream_body` (the
|
||||||
// encode loop); per-frame sample emission is wired by a later pass.
|
// encode loop); per-frame sample emission is wired by a later pass.
|
||||||
stats: &Arc<crate::stats_recorder::StatsRecorder>,
|
stats: &Arc<crate::stats_recorder::StatsRecorder>,
|
||||||
|
// Whole-session teardown for the send thread's client-unreachable detection.
|
||||||
|
on_lost: &super::OnSessionLost,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
// GameStream capture/encode thread: apply Windows session tuning (no-op off Windows).
|
// GameStream capture/encode thread: apply Windows session tuning (no-op off Windows).
|
||||||
pf_frame::session_tuning::on_hot_thread();
|
pf_frame::session_tuning::on_hot_thread();
|
||||||
@@ -252,6 +257,7 @@ fn run(
|
|||||||
rfi_range,
|
rfi_range,
|
||||||
stats,
|
stats,
|
||||||
&client_label,
|
&client_label,
|
||||||
|
on_lost,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,6 +305,7 @@ fn run(
|
|||||||
rfi_range,
|
rfi_range,
|
||||||
stats,
|
stats,
|
||||||
&client_label,
|
&client_label,
|
||||||
|
on_lost,
|
||||||
);
|
);
|
||||||
capturer.set_active(false);
|
capturer.set_active(false);
|
||||||
*video_cap.lock().unwrap() = Some((capturer, cfg.hdr));
|
*video_cap.lock().unwrap() = Some((capturer, cfg.hdr));
|
||||||
@@ -572,12 +579,15 @@ fn spawn_packetizer(
|
|||||||
/// shared [`send_pacing`](crate::send_pacing) policy at the GameStream parameterization: no
|
/// shared [`send_pacing`](crate::send_pacing) policy at the GameStream parameterization: no
|
||||||
/// microburst stage, a BOUNDED step count (≤ 12, chunk ≥ 16, see the policy's docs for the
|
/// microburst stage, a BOUNDED step count (≤ 12, chunk ≥ 16, see the policy's docs for the
|
||||||
/// "send queue full" history that bound guards), each step ending in a sleep toward its slice
|
/// "send queue full" history that bound guards), each step ending in a sleep toward its slice
|
||||||
/// of the fixed budget. On send failure (client gone) it clears `running`.
|
/// of the fixed budget. On send failure (client gone) it ends the whole session via `on_lost` —
|
||||||
|
/// not just this thread: audio would otherwise keep streaming at the dead endpoint and the stale
|
||||||
|
/// launch state would wedge the next connect (see `AppState::end_session`).
|
||||||
fn spawn_sender(
|
fn spawn_sender(
|
||||||
sock: UdpSocket,
|
sock: UdpSocket,
|
||||||
rx: std::sync::mpsc::Receiver<PacketBatch>,
|
rx: std::sync::mpsc::Receiver<PacketBatch>,
|
||||||
frame_interval: Duration,
|
frame_interval: Duration,
|
||||||
running: Arc<AtomicBool>,
|
running: Arc<AtomicBool>,
|
||||||
|
on_lost: super::OnSessionLost,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("punktfunk-send".into())
|
.name("punktfunk-send".into())
|
||||||
@@ -613,8 +623,9 @@ fn spawn_sender(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
if let Err(e) = r {
|
if let Err(e) = r {
|
||||||
tracing::info!(error = %e, sent, "video: client unreachable — stopping stream");
|
tracing::info!(error = %e, sent, "video: client unreachable — ending session");
|
||||||
running.store(false, Ordering::SeqCst);
|
running.store(false, Ordering::SeqCst);
|
||||||
|
on_lost();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -645,6 +656,8 @@ fn stream_body(
|
|||||||
stats: &Arc<crate::stats_recorder::StatsRecorder>,
|
stats: &Arc<crate::stats_recorder::StatsRecorder>,
|
||||||
// Short client label (peer IP) seeded into the capture meta on the first armed registration.
|
// Short client label (peer IP) seeded into the capture meta on the first armed registration.
|
||||||
client_label: &str,
|
client_label: &str,
|
||||||
|
// Whole-session teardown, handed to the send thread's client-unreachable detection.
|
||||||
|
on_lost: &super::OnSessionLost,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
// The first frame establishes the authoritative size/format for the encoder.
|
// The first frame establishes the authoritative size/format for the encoder.
|
||||||
let mut frame = capturer.next_frame().context("capture first frame")?;
|
let mut frame = capturer.next_frame().context("capture first frame")?;
|
||||||
@@ -708,6 +721,7 @@ fn stream_body(
|
|||||||
batch_rx,
|
batch_rx,
|
||||||
Duration::from_secs_f64(1.0 / target_fps as f64),
|
Duration::from_secs_f64(1.0 / target_fps as f64),
|
||||||
running.clone(),
|
running.clone(),
|
||||||
|
on_lost.clone(),
|
||||||
)?;
|
)?;
|
||||||
let (raw_tx, raw_rx) = std::sync::mpsc::sync_channel::<RawFrame>(2);
|
let (raw_tx, raw_rx) = std::sync::mpsc::sync_channel::<RawFrame>(2);
|
||||||
spawn_packetizer(raw_rx, batch_tx, pk, goodput.clone())?;
|
spawn_packetizer(raw_rx, batch_tx, pk, goodput.clone())?;
|
||||||
@@ -1075,6 +1089,7 @@ mod tests {
|
|||||||
rx,
|
rx,
|
||||||
Duration::from_millis(8), // ~120fps frame interval
|
Duration::from_millis(8), // ~120fps frame interval
|
||||||
running.clone(),
|
running.clone(),
|
||||||
|
Arc::new(|| {}),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -19,11 +19,8 @@ use std::sync::atomic::Ordering;
|
|||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
pub(crate) async fn stop_session(State(st): State<Arc<MgmtState>>) -> StatusCode {
|
pub(crate) async fn stop_session(State(st): State<Arc<MgmtState>>) -> StatusCode {
|
||||||
let was_streaming = st.app.streaming.swap(false, Ordering::SeqCst);
|
let was_streaming = st.app.end_session("management API stop");
|
||||||
st.app.audio_streaming.store(false, Ordering::SeqCst);
|
// Native plane: the GameStream teardown above doesn't reach it (it runs its own loops off the shared
|
||||||
*st.app.launch.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
|
||||||
*st.app.stream.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
|
||||||
// Native plane: the GameStream flags above don't reach it (it runs its own loops off the shared
|
|
||||||
// session registry), so signal every live native session to tear down too.
|
// session registry), so signal every live native session to tear down too.
|
||||||
let native = crate::session_status::count();
|
let native = crate::session_status::count();
|
||||||
crate::session_status::stop_all();
|
crate::session_status::stop_all();
|
||||||
|
|||||||
@@ -392,11 +392,6 @@ pub(crate) async fn serve(
|
|||||||
);
|
);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let permit = sem
|
|
||||||
.clone()
|
|
||||||
.acquire_owned()
|
|
||||||
.await
|
|
||||||
.expect("session semaphore is never closed");
|
|
||||||
let incoming = match ep.accept().await {
|
let incoming = match ep.accept().await {
|
||||||
Some(i) => i,
|
Some(i) => i,
|
||||||
None => break, // endpoint closed
|
None => break, // endpoint closed
|
||||||
@@ -409,9 +404,18 @@ pub(crate) async fn serve(
|
|||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(error = %e, "QUIC accept failed");
|
tracing::warn!(error = %e, "QUIC accept failed");
|
||||||
continue; // `permit` drops here → slot freed; not counted toward max_sessions
|
continue; // not counted toward max_sessions
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// Take the session slot only AFTER the handshake, so a full host still ACCEPTS the
|
||||||
|
// connection and the waiting client sees a live path (quinn's keep-alive holds it) instead
|
||||||
|
// of a silent dial timeout — previously the loop parked on this await before `accept()`, so
|
||||||
|
// a host at its concurrency cap looked simply unreachable.
|
||||||
|
let permit = sem
|
||||||
|
.clone()
|
||||||
|
.acquire_owned()
|
||||||
|
.await
|
||||||
|
.expect("session semaphore is never closed");
|
||||||
let peer = conn.remote_address();
|
let peer = conn.remote_address();
|
||||||
tracing::info!(%peer, "punktfunk/1 client connected");
|
tracing::info!(%peer, "punktfunk/1 client connected");
|
||||||
let opts = opts.clone();
|
let opts = opts.clone();
|
||||||
@@ -486,6 +490,31 @@ pub(crate) async fn serve(
|
|||||||
/// connects and never finishes the handshake would otherwise wedge the host for everyone.
|
/// connects and never finishes the handshake would otherwise wedge the host for everyone.
|
||||||
const HANDSHAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
const HANDSHAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||||
|
|
||||||
|
/// How long the stream thread may still run AFTER its session was told to stop before
|
||||||
|
/// [`serve_session`] gives up waiting for it.
|
||||||
|
///
|
||||||
|
/// Must exceed every legitimate post-stop path so a slow-but-healthy teardown is never abandoned:
|
||||||
|
/// the capture-loss rebuild budget is 40 s and one pipeline-build attempt can take ~10 s on a cold
|
||||||
|
/// compositor, so 90 s leaves generous headroom.
|
||||||
|
const STREAM_STOP_GRACE: std::time::Duration = std::time::Duration::from_secs(90);
|
||||||
|
|
||||||
|
/// How long teardown waits for the audio + input threads once the connection is closed. They exit
|
||||||
|
/// promptly by construction (the audio loop checks `stop` every ≤5 s; the input thread's channel
|
||||||
|
/// drops with the connection), so this only catches a genuine wedge.
|
||||||
|
const SIDE_THREAD_JOIN_GRACE: std::time::Duration = std::time::Duration::from_secs(10);
|
||||||
|
|
||||||
|
/// Resolves once `stop` has been set for [`STREAM_STOP_GRACE`] — i.e. the session was told to end
|
||||||
|
/// and its stream thread *still* hasn't returned.
|
||||||
|
///
|
||||||
|
/// Polled rather than notified: `stop` is a plain flag shared with blocking threads, and the poll
|
||||||
|
/// only runs while a session is live (every 500 ms, one relaxed atomic load).
|
||||||
|
async fn stop_overdue(stop: &AtomicBool) {
|
||||||
|
while !stop.load(Ordering::SeqCst) {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(STREAM_STOP_GRACE).await;
|
||||||
|
}
|
||||||
|
|
||||||
/// QUIC application error code the host closes with on a `mode_conflict = reject` admission refusal,
|
/// QUIC application error code the host closes with on a `mode_conflict = reject` admission refusal,
|
||||||
/// carrying the human-readable busy reason (live mode + client label) the client surfaces. A distinct
|
/// carrying the human-readable busy reason (live mode + client label) the client surfaces. A distinct
|
||||||
/// code lets a client tell "host busy" apart from a transport failure. Shared with the clients via
|
/// code lets a client tell "host busy" apart from a transport failure. Shared with the clients via
|
||||||
@@ -1330,7 +1359,7 @@ async fn serve_session(
|
|||||||
let bringup_dp = bringup.clone();
|
let bringup_dp = bringup.clone();
|
||||||
let resize_ms_dp = resize_ms.clone();
|
let resize_ms_dp = resize_ms.clone();
|
||||||
let result: Result<()> = async {
|
let result: Result<()> = async {
|
||||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
let stream_thread = tokio::task::spawn_blocking(move || -> Result<()> {
|
||||||
// Bring up the (already-bound) data-plane socket. Default: hole-punch — wait briefly
|
// Bring up the (already-bound) data-plane socket. Default: hole-punch — wait briefly
|
||||||
// for the client's punch, then stream to its OBSERVED source, so video traverses a
|
// for the client's punch, then stream to its OBSERVED source, so video traverses a
|
||||||
// NAT / stateful inter-VLAN firewall (control + side planes ride the client-initiated
|
// NAT / stateful inter-VLAN firewall (control + side planes ride the client-initiated
|
||||||
@@ -1444,9 +1473,34 @@ async fn serve_session(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
.await
|
// `stop` is only ADVISORY: the stream thread observes it between iterations, so a call that
|
||||||
.context("stream thread")??;
|
// blocks without a bound INSIDE one (a compositor CLI that never returns, a D-Bus round-trip
|
||||||
|
// on a stuck bus, a driver wait on a hung GPU) never reaches the check — and nothing else
|
||||||
|
// can end the session, because every teardown below runs only once this await resolves. That
|
||||||
|
// made one stuck syscall a permanent zombie: it kept its semaphore slot (four of them and the
|
||||||
|
// host stops accepting entirely), its admission entry (a later client gets "host busy"
|
||||||
|
// forever) and its stream marker, and even the console's Stop button — which just sets this
|
||||||
|
// same flag — could not clear it.
|
||||||
|
//
|
||||||
|
// So bound the wait: once the session HAS been told to stop, give the thread
|
||||||
|
// `STREAM_STOP_GRACE` to return, then stop waiting for it and let teardown run. The thread is
|
||||||
|
// detached, not killed (a blocking thread can't be cancelled in Rust) — it keeps its capturer
|
||||||
|
// and encoder until the stuck call returns, and its own guards unwind if it ever does. That
|
||||||
|
// is a leak, but a bounded one: the session's slot and admission entry come back, so the rest
|
||||||
|
// of the host keeps serving.
|
||||||
|
tokio::select! {
|
||||||
|
joined = stream_thread => joined.context("stream thread")??,
|
||||||
|
() = stop_overdue(&stop) => {
|
||||||
|
tracing::error!(
|
||||||
|
grace_s = STREAM_STOP_GRACE.as_secs(),
|
||||||
|
"stream thread has not returned since the session was stopped — abandoning it so \
|
||||||
|
the session slot is freed. Its capture/encoder stay held until the stuck call \
|
||||||
|
returns; this is a HOST WEDGE — please report it with the log above"
|
||||||
|
);
|
||||||
|
anyhow::bail!("stream thread wedged after stop");
|
||||||
|
}
|
||||||
|
}
|
||||||
// Give the client a moment to drain before the close.
|
// Give the client a moment to drain before the close.
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1462,13 +1516,25 @@ async fn serve_session(
|
|||||||
if result.is_ok() { 0u32 } else { 1u32 }.into(),
|
if result.is_ok() { 0u32 } else { 1u32 }.into(),
|
||||||
if result.is_ok() { b"done" } else { b"error" },
|
if result.is_ok() { b"done" } else { b"error" },
|
||||||
);
|
);
|
||||||
let _ = tokio::task::spawn_blocking(move || {
|
// Bounded, for the same reason the stream-thread wait is: the input thread exits only when the
|
||||||
|
// datagram task drops its channel, which the `conn.close()` above forces — but a join is the
|
||||||
|
// last unbounded await in teardown, and one stuck side thread must not hold the session's
|
||||||
|
// permit/admission entry (released when this fn returns) hostage.
|
||||||
|
let side_threads = tokio::task::spawn_blocking(move || {
|
||||||
if let Some(h) = audio_handle {
|
if let Some(h) = audio_handle {
|
||||||
let _ = h.join();
|
let _ = h.join();
|
||||||
}
|
}
|
||||||
let _ = input_handle.join();
|
let _ = input_handle.join();
|
||||||
})
|
});
|
||||||
.await;
|
if tokio::time::timeout(SIDE_THREAD_JOIN_GRACE, side_threads)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
tracing::warn!(
|
||||||
|
grace_s = SIDE_THREAD_JOIN_GRACE.as_secs(),
|
||||||
|
"audio/input threads did not exit after the connection closed — detaching them"
|
||||||
|
);
|
||||||
|
}
|
||||||
// The capture (and our gamescope session's VirtualOutput) are gone by here. If this was the
|
// The capture (and our gamescope session's VirtualOutput) are gone by here. If this was the
|
||||||
// host-managed gamescope path on a box that autologs into gaming mode (Bazzite default), put the
|
// host-managed gamescope path on a box that autologs into gaming mode (Bazzite default), put the
|
||||||
// TV's gaming session back so it's the default when no one is streaming.
|
// TV's gaming session back so it's the default when no one is streaming.
|
||||||
|
|||||||
@@ -1940,6 +1940,15 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
// connected, frozen on the last frame, and the stream resumes when the new output
|
// connected, frozen on the last frame, and the stream resumes when the new output
|
||||||
// appears — no reconnect.
|
// appears — no reconnect.
|
||||||
const REBUILD_BUDGET: std::time::Duration = std::time::Duration::from_secs(40);
|
const REBUILD_BUDGET: std::time::Duration = std::time::Duration::from_secs(40);
|
||||||
|
// A managed/attach gamescope (re)launch legitimately takes up to 45 s — the Steam
|
||||||
|
// Big Picture cold start that `launch_session`/`ensure_box_gamescope_mode` poll
|
||||||
|
// for — so the 40 s budget used to expire INSIDE the first attempt (a single-shot
|
||||||
|
// failure ending the session even when a second, warm attempt would have
|
||||||
|
// succeeded). Give gamescope-targeted rebuilds room for two full launch attempts;
|
||||||
|
// desktop compositors keep the tighter budget. Checked per iteration because the
|
||||||
|
// loop retargets `compositor` as re-detection follows the box.
|
||||||
|
const GAMESCOPE_REBUILD_BUDGET: std::time::Duration =
|
||||||
|
std::time::Duration::from_secs(100);
|
||||||
// Attach-only holdoff: for the first seconds after a capture loss the session
|
// Attach-only holdoff: for the first seconds after a capture loss the session
|
||||||
// detection can be STALE (the new session isn't up yet), and a rebuild acting on
|
// detection can be STALE (the new session isn't up yet), and a rebuild acting on
|
||||||
// a stale "Gaming" answer restarts gamescope-session.target — which on SteamOS
|
// a stale "Gaming" answer restarts gamescope-session.target — which on SteamOS
|
||||||
@@ -1948,7 +1957,24 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
// scope: attach to live outputs only, never stop/relaunch/take over sessions.
|
// scope: attach to live outputs only, never stop/relaunch/take over sessions.
|
||||||
const PROBE_HOLDOFF: std::time::Duration = std::time::Duration::from_secs(4);
|
const PROBE_HOLDOFF: std::time::Duration = std::time::Duration::from_secs(4);
|
||||||
let loss_at = std::time::Instant::now();
|
let loss_at = std::time::Instant::now();
|
||||||
let rebuild_deadline = loss_at + REBUILD_BUDGET;
|
// An explicit PUNKTFUNK_COMPOSITOR pin disables the re-detection below — the
|
||||||
|
// stream cannot follow a session switch. When the live session no longer matches
|
||||||
|
// the pin, say so loudly ONCE per loss: this rebuild can only retry the pinned
|
||||||
|
// backend and will die at the budget (the "mid-stream switch to game mode kills
|
||||||
|
// the stream" field reports all traced back to a stale pin).
|
||||||
|
if pf_host_config::config().compositor.is_some() {
|
||||||
|
let active = crate::vdisplay::detect_active_session();
|
||||||
|
if crate::vdisplay::compositor_for_kind(active.kind) != Some(compositor) {
|
||||||
|
tracing::warn!(
|
||||||
|
pinned = compositor.id(),
|
||||||
|
live = ?active.kind,
|
||||||
|
"capture lost while PUNKTFUNK_COMPOSITOR pins the backend and the \
|
||||||
|
live session no longer matches it — the pin disables \
|
||||||
|
session-following, so this rebuild can only retry the pinned \
|
||||||
|
backend; remove the pin to let the stream follow session switches"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
let (new_cap, new_enc, new_frame, new_interval, new_node_id, new_display_gen) = loop {
|
let (new_cap, new_enc, new_frame, new_interval, new_node_id, new_display_gen) = loop {
|
||||||
// Follow the active session unless an explicit PUNKTFUNK_COMPOSITOR pin forbids
|
// Follow the active session unless an explicit PUNKTFUNK_COMPOSITOR pin forbids
|
||||||
// retargeting (then we stick to the pinned backend and just rebuild it).
|
// retargeting (then we stick to the pinned backend and just rebuild it).
|
||||||
@@ -2021,8 +2047,13 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
) {
|
) {
|
||||||
Ok(p) => break p,
|
Ok(p) => break p,
|
||||||
Err(e2) => {
|
Err(e2) => {
|
||||||
|
let budget = if compositor == crate::vdisplay::Compositor::Gamescope {
|
||||||
|
GAMESCOPE_REBUILD_BUDGET
|
||||||
|
} else {
|
||||||
|
REBUILD_BUDGET
|
||||||
|
};
|
||||||
if stop.load(Ordering::SeqCst)
|
if stop.load(Ordering::SeqCst)
|
||||||
|| std::time::Instant::now() >= rebuild_deadline
|
|| std::time::Instant::now() >= loss_at + budget
|
||||||
{
|
{
|
||||||
return Err(e2)
|
return Err(e2)
|
||||||
.context("capture lost — no compositor came up within the rebuild budget");
|
.context("capture lost — no compositor came up within the rebuild budget");
|
||||||
|
|||||||
@@ -85,11 +85,10 @@ cp /usr/share/punktfunk/host.env.bazzite ~/.config/punktfunk/host.env
|
|||||||
|
|
||||||
The template is deliberately minimal — it does **not** force a compositor, because the host
|
The template is deliberately minimal — it does **not** force a compositor, because the host
|
||||||
auto-detects Gaming Mode (gamescope) vs Desktop (KWin) on every connect and follows the switch
|
auto-detects Gaming Mode (gamescope) vs Desktop (KWin) on every connect and follows the switch
|
||||||
mid-stream. The only settings that matter are the session anchors (GPU zero-copy is on by default):
|
mid-stream. No session anchors are needed either (a user service inherits the right runtime dir).
|
||||||
|
The only settings that matter (GPU zero-copy is on by default):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
XDG_RUNTIME_DIR=/run/user/1000
|
|
||||||
DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus
|
|
||||||
PUNKTFUNK_VIDEO_SOURCE=virtual
|
PUNKTFUNK_VIDEO_SOURCE=virtual
|
||||||
# GPU zero-copy (dmabuf → CUDA → NVENC) is ON by default; auto-falls back to CPU. Set =0 to force CPU.
|
# GPU zero-copy (dmabuf → CUDA → NVENC) is ON by default; auto-falls back to CPU. Set =0 to force CPU.
|
||||||
PUNKTFUNK_GAMESCOPE_ATTACH=1 # Gaming Mode = attach to the box's own session (see below)
|
PUNKTFUNK_GAMESCOPE_ATTACH=1 # Gaming Mode = attach to the box's own session (see below)
|
||||||
@@ -99,12 +98,14 @@ PUNKTFUNK_GAMESCOPE_ATTACH=1 # Gaming Mode = attach to the box's own session
|
|||||||
|
|
||||||
For Gaming Mode there are two models (pick one; the shipped default is **attach**):
|
For Gaming Mode there are two models (pick one; the shipped default is **attach**):
|
||||||
|
|
||||||
- **Attach** (`PUNKTFUNK_GAMESCOPE_ATTACH=1`, the default) — the **box** owns its gamescope session,
|
- **Attach** (`PUNKTFUNK_GAMESCOPE_ATTACH=1`, the template's default) — the **box** owns its
|
||||||
the host attaches to whatever's live and never tears it down, and the streamed game-mode resolution
|
gamescope session on its own display, and the host attaches to whatever's live without ever
|
||||||
is the box's own gamescope mode. Switching Desktop ↔ Game is rock-solid.
|
tearing it down (on a headless box, a box-owned autologin session is restarted at the client's
|
||||||
- **Managed** (`PUNKTFUNK_GAMESCOPE_MANAGED=1`, and remove the attach line) — the host launches its
|
resolution on a mismatch; with a display connected it streams at the box's own mode). Switching
|
||||||
**own** gamescope at the *client's* exact resolution and refresh. Client-mode-following, but there
|
Desktop ↔ Game is rock-solid.
|
||||||
must be no physical gaming session already running.
|
- **Managed** (`PUNKTFUNK_GAMESCOPE_MANAGED=1`, and remove the attach line) — the host takes the
|
||||||
|
box's gamescope over and relaunches it **headless** at the *client's* exact resolution and
|
||||||
|
refresh — Game Mode on the virtual screen — restoring the box on idle.
|
||||||
|
|
||||||
Full treatment: [Steam / gamescope → Attach vs managed](/docs/gamescope#attach-vs-managed).
|
Full treatment: [Steam / gamescope → Attach vs managed](/docs/gamescope#attach-vs-managed).
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ description: Every host.env setting and PUNKTFUNK_* environment variable — com
|
|||||||
---
|
---
|
||||||
|
|
||||||
The host reads its settings from **`~/.config/punktfunk/host.env`** (a simple `KEY=value` file, `#`
|
The host reads its settings from **`~/.config/punktfunk/host.env`** (a simple `KEY=value` file, `#`
|
||||||
starts a comment). On Windows the service reads **`%ProgramData%\punktfunk\host.env`** instead. Your
|
starts a comment; keys are **case-sensitive** — `punktfunk_compositor` sets nothing, use the exact
|
||||||
|
uppercase names). On Windows the service reads **`%ProgramData%\punktfunk\host.env`** instead. Your
|
||||||
[setup guide](/docs/requirements) gives you a starting `host.env` for your desktop; this page is the
|
[setup guide](/docs/requirements) gives you a starting `host.env` for your desktop; this page is the
|
||||||
full reference for every setting.
|
full reference for every setting.
|
||||||
|
|
||||||
@@ -16,25 +17,24 @@ full reference for every setting.
|
|||||||
|
|
||||||
## Session anchors
|
## Session anchors
|
||||||
|
|
||||||
These tell the host which desktop session to attach to. Your setup guide sets them for you; they're
|
**Leave these unset on a normal setup.** Running as a `systemctl --user` service the host inherits
|
||||||
required when the host runs outside your interactive session (e.g. as a service).
|
the correct `XDG_RUNTIME_DIR` from systemd, derives the session bus from it, and **rewrites
|
||||||
|
`WAYLAND_DISPLAY` / `XDG_CURRENT_DESKTOP` / `XDG_RUNTIME_DIR` / `DBUS_SESSION_BUS_ADDRESS` on every
|
||||||
|
connect** to follow the active session (Gaming ↔ Desktop) — a value written here can only be
|
||||||
|
redundant or stale.
|
||||||
|
|
||||||
| Setting | What it does |
|
| Setting | When to set it |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `XDG_RUNTIME_DIR` | Your session's runtime dir (e.g. `/run/user/1000`). Always needed for a service. |
|
| `XDG_RUNTIME_DIR` | Only when the host runs **outside** a user service (ssh, cron): `/run/user/<your uid>` — check `id -u`. A copy-pasted `1000` on a box where that isn't your uid points the host at another user's (nonexistent) PipeWire/D-Bus, and **everything** fails (audio `Creation failed`, no capture, clients report the host unreachable). |
|
||||||
| `DBUS_SESSION_BUS_ADDRESS` | Your session bus (e.g. `unix:path=/run/user/1000/bus`). Always needed for a service. |
|
| `DBUS_SESSION_BUS_ADDRESS` | Same cases only: `unix:path=/run/user/<your uid>/bus`. Otherwise derived automatically. |
|
||||||
| `WAYLAND_DISPLAY` | The Wayland socket of your session (`wayland-0` for a normal desktop, `wayland-kde` for the headless-KDE unit). |
|
| `WAYLAND_DISPLAY` | Only the dedicated [headless-KDE appliance](/docs/kde#headless-session) (`wayland-kde`, set by its shipped `host.env.kde`). |
|
||||||
| `XDG_CURRENT_DESKTOP` | Your desktop (`GNOME`, `KDE`). |
|
| `XDG_CURRENT_DESKTOP` | Same — appliance-only. |
|
||||||
|
|
||||||
On Linux the host **rewrites `WAYLAND_DISPLAY` / `XDG_CURRENT_DESKTOP` / `XDG_RUNTIME_DIR` /
|
|
||||||
`DBUS_SESSION_BUS_ADDRESS` on every connect** to follow the active session (Gaming ↔ Desktop). Only
|
|
||||||
`XDG_RUNTIME_DIR` and `DBUS_SESSION_BUS_ADDRESS` need to be pinned as trustworthy anchors.
|
|
||||||
|
|
||||||
## Core
|
## Core
|
||||||
|
|
||||||
| Setting | Values | Meaning |
|
| Setting | Values | Meaning |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `PUNKTFUNK_COMPOSITOR` | `kwin` · `mutter` · `gamescope` · `wlroots` · `hyprland` (aliases: `kde`/`plasma`, `gnome`, `sway`/`wlr`) | Which backend creates the virtual display. `wlroots` is sway/River; `hyprland` is its own backend. **Leave unset to auto-detect;** set only to force one. |
|
| `PUNKTFUNK_COMPOSITOR` | `kwin` · `mutter` · `gamescope` · `wlroots` · `hyprland` (aliases: `kde`/`plasma`, `gnome`, `sway`/`wlr`) | Which backend creates the virtual display. `wlroots` is sway/River; `hyprland` is its own backend. **Leave unset.** Setting it **pins** the backend and turns session-following **off** — per connect *and* mid-stream, so a Desktop ↔ Gaming switch kills the stream instead of being followed. For CI/tests and dedicated single-session appliances only. |
|
||||||
| `PUNKTFUNK_VIDEO_SOURCE` | `virtual` · `portal` | `virtual` creates a per-client display at the client's exact mode (the normal choice). `portal` captures an existing monitor instead. |
|
| `PUNKTFUNK_VIDEO_SOURCE` | `virtual` · `portal` | `virtual` creates a per-client display at the client's exact mode (the normal choice). `portal` captures an existing monitor instead. |
|
||||||
| `PUNKTFUNK_ZEROCOPY` | `1` · `0` *(default on)* | GPU zero-copy capture→encode (dmabuf → CUDA → NVENC, or D3D11 on Windows). **On by default** — no need to set it; it falls back to a CPU path automatically. Set `0` to force the CPU path. One exception: Windows **Intel/QSV** keeps the CPU path by default until zero-copy is validated on Intel hardware — set `1` to try it there. |
|
| `PUNKTFUNK_ZEROCOPY` | `1` · `0` *(default on)* | GPU zero-copy capture→encode (dmabuf → CUDA → NVENC, or D3D11 on Windows). **On by default** — no need to set it; it falls back to a CPU path automatically. Set `0` to force the CPU path. One exception: Windows **Intel/QSV** keeps the CPU path by default until zero-copy is validated on Intel hardware — set `1` to try it there. |
|
||||||
| `PUNKTFUNK_INPUT_BACKEND` | `libei` · `gamescope` · `wlr` · `uinput` | How input is injected. `libei` for GNOME/KDE, `gamescope` for Bazzite/gamescope, `wlr` for Sway/wlroots **and Hyprland**. Auto-detected with the compositor. |
|
| `PUNKTFUNK_INPUT_BACKEND` | `libei` · `gamescope` · `wlr` · `uinput` | How input is injected. `libei` for GNOME/KDE, `gamescope` for Bazzite/gamescope, `wlr` for Sway/wlroots **and Hyprland**. Auto-detected with the compositor. |
|
||||||
@@ -53,8 +53,8 @@ the full picture (and [Bazzite](/docs/bazzite) for that distro's specifics).
|
|||||||
|
|
||||||
| Setting | Values | Meaning |
|
| Setting | Values | Meaning |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `PUNKTFUNK_GAMESCOPE_ATTACH` | `1` | **Attach** model: the box owns its gamescope session (you switch Gaming ↔ Desktop with the Steam UI); the host just captures whatever's live and never tears it down. Rock-solid; streamed resolution is the box's gamescope mode. |
|
| `PUNKTFUNK_GAMESCOPE_ATTACH` | `1` | **Attach** model: the box owns its gamescope session on its own display (you switch Gaming ↔ Desktop with the Steam UI); the host just captures whatever's live and never tears it down. On a **headless** box the box-owned autologin session is restarted at the client's resolution on a mismatch; a box driving a physical display, and any foreign/bare gamescope, streams at its own mode. |
|
||||||
| `PUNKTFUNK_GAMESCOPE_MANAGED` | `1` | **Managed** model: the host tears the box's gamescope down on connect and launches its **own** at the *client's* exact resolution, restoring on idle. Client-mode-following, but doesn't coexist with a box-owned game-mode session. |
|
| `PUNKTFUNK_GAMESCOPE_MANAGED` | `1` | **Managed** model (the default where session infra is detected): the host takes the box's gamescope over and relaunches it **headless** at the *client's* exact resolution — Game Mode on the virtual screen — restoring the box on idle. |
|
||||||
| `PUNKTFUNK_GAMESCOPE_SESSION` | `steam` | The host owns a `gamescope-session-plus` (Steam) session at the client's mode (headless appliance; no physical session running). |
|
| `PUNKTFUNK_GAMESCOPE_SESSION` | `steam` | The host owns a `gamescope-session-plus` (Steam) session at the client's mode (headless appliance; no physical session running). |
|
||||||
| `PUNKTFUNK_GAMESCOPE_NODE` | `auto` · node id | Discover + capture a **running** gamescope's PipeWire node at a fixed mode. Do **not** combine with `SESSION`. |
|
| `PUNKTFUNK_GAMESCOPE_NODE` | `auto` · node id | Discover + capture a **running** gamescope's PipeWire node at a fixed mode. Do **not** combine with `SESSION`. |
|
||||||
| `PUNKTFUNK_GAMESCOPE_APP` | command | For an ad-hoc bare-gamescope session, the nested command to run (e.g. `vkcube`). |
|
| `PUNKTFUNK_GAMESCOPE_APP` | command | For an ad-hoc bare-gamescope session, the nested command to run (e.g. `vkcube`). |
|
||||||
|
|||||||
@@ -17,18 +17,52 @@ from the install guide for your OS: [Bazzite](/docs/bazzite) or [SteamOS (Host)]
|
|||||||
|
|
||||||
## Attach vs managed
|
## Attach vs managed
|
||||||
|
|
||||||
There are two mutually-exclusive models for a gamescope box; pick one. The shipped default is
|
There are two mutually-exclusive models for a gamescope box; pick one. With **nothing set**, a box
|
||||||
**attach**.
|
that has gamescope session infrastructure (Bazzite, SteamOS, Nobara) gets **managed**; the
|
||||||
|
[Bazzite template](/docs/bazzite) ships with **attach** chosen instead.
|
||||||
|
|
||||||
- **Attach** (`PUNKTFUNK_GAMESCOPE_ATTACH=1`, the default) — the **box** owns its gamescope session
|
- **Attach** (`PUNKTFUNK_GAMESCOPE_ATTACH=1`) — the **box** owns its gamescope session and decides
|
||||||
and decides Gaming vs Desktop via the normal Steam UI. The host just attaches to whatever's live
|
Gaming vs Desktop via the normal Steam UI. Game Mode stays on the box's own (physical) display;
|
||||||
and never tears it down, so switching Desktop ↔ Game is rock-solid and disconnecting leaves the box
|
the host attaches to whatever's live and never tears it down, so switching Desktop ↔ Game is
|
||||||
where it was. The streamed game-mode resolution is the box's gamescope mode
|
rock-solid and disconnecting leaves the box where it was. When the box is **headless** (no
|
||||||
(`SCREEN_WIDTH/HEIGHT` in `/etc/gamescope-session-plus/sessions.d/steam`), not the client's.
|
display connected) and the session is its own autologin unit, the host restarts it at the
|
||||||
- **Managed** (`PUNKTFUNK_GAMESCOPE_MANAGED=1`, and remove the attach line) — the host tears the
|
**client's** resolution on a mismatch; a box driving a physical display — and any foreign or
|
||||||
box's gamescope down on connect and launches its **own** at the *client's* exact resolution and
|
bare gamescope — is streamed at its own mode.
|
||||||
refresh, restoring on idle. Client-mode-following, but it can't coexist with a box-owned game-mode
|
- **Managed** (the infra-detected default; force with `PUNKTFUNK_GAMESCOPE_MANAGED=1`) — the host
|
||||||
session, and there must be **no physical gaming session already running**.
|
takes the box's gamescope session over and relaunches it **headless** at the *client's* exact
|
||||||
|
resolution and refresh — Game Mode runs on the virtual screen, physical displays drop out of it —
|
||||||
|
restoring the box on idle after disconnect.
|
||||||
|
|
||||||
|
### Nobara and other autologin display managers
|
||||||
|
|
||||||
|
The managed takeover has to stop the box's Gaming Mode session to free Steam. How it does that
|
||||||
|
depends on the display manager driving the autologin:
|
||||||
|
|
||||||
|
- **SDDM** (Bazzite, SteamOS): handled automatically — no setup.
|
||||||
|
- **plasmalogin** (Nobara) and other display managers: the host must stop the display manager
|
||||||
|
itself for the length of the stream and restart it afterwards, which needs privilege. Allow it
|
||||||
|
with a polkit rule (adjust the unit and user names to your box):
|
||||||
|
|
||||||
|
```js
|
||||||
|
// /etc/polkit-1/rules.d/49-punktfunk-dm.rules
|
||||||
|
polkit.addRule(function(action, subject) {
|
||||||
|
if (action.id == "org.freedesktop.systemd1.manage-units" &&
|
||||||
|
action.lookup("unit") == "plasmalogin.service" &&
|
||||||
|
subject.user == "YOUR_USER") {
|
||||||
|
return polkit.Result.YES;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Without the rule the host degrades safely: it **attaches** to the live Gaming Mode session
|
||||||
|
instead (Game Mode stays on the box's display, mirrored to the client) rather than risk the
|
||||||
|
display manager. If the display-manager restart ever loses its privilege mid-restore,
|
||||||
|
`PUNKTFUNK_RECOVER_SESSION_CMD` (see [Configuration](/docs/configuration)) is fired as the
|
||||||
|
fallback.
|
||||||
|
|
||||||
|
With the rule in place the **in-stream session switch round-trips** in managed mode: Steam's
|
||||||
|
"Switch to Desktop" inside the streamed Game Mode returns the box to its desktop session and the
|
||||||
|
stream follows it there; the desktop's "Return to Gaming Mode" switches it forward again.
|
||||||
|
|
||||||
## Session following
|
## Session following
|
||||||
|
|
||||||
@@ -40,8 +74,8 @@ over its own compositor, and re-targets whichever is live on each switch.
|
|||||||
## Start the host
|
## Start the host
|
||||||
|
|
||||||
On an appliance box (Bazzite, SteamOS) the install guide already enables the host service for you. On
|
On an appliance box (Bazzite, SteamOS) the install guide already enables the host service for you. On
|
||||||
any other distro running a gamescope session, start it from your session — the default attach model
|
any other distro running a gamescope session, just start it — the host auto-detects the live
|
||||||
just latches onto whatever gamescope session is live:
|
gamescope session and picks the model for it:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
systemctl --user enable --now punktfunk-host
|
systemctl --user enable --now punktfunk-host
|
||||||
@@ -56,8 +90,8 @@ a model. See the full [Configuration reference](/docs/configuration) for every o
|
|||||||
|
|
||||||
| Setting | Values | Meaning |
|
| Setting | Values | Meaning |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `PUNKTFUNK_GAMESCOPE_ATTACH` | `1` | **Attach** model: the box owns its gamescope session; the host captures whatever's live and never tears it down. Streamed resolution is the box's gamescope mode. The default. |
|
| `PUNKTFUNK_GAMESCOPE_ATTACH` | `1` | **Attach** model: the box owns its gamescope session (on its own display); the host captures whatever's live and never tears it down. On a **headless** box the box-owned autologin session is restarted at the client's resolution on a mismatch; a box driving a physical display, and any foreign/bare gamescope, streams at its own mode. |
|
||||||
| `PUNKTFUNK_GAMESCOPE_MANAGED` | `1` | **Managed** model: the host tears the box's gamescope down on connect and launches its own at the client's exact mode, restoring on idle. Doesn't coexist with a box-owned game-mode session. |
|
| `PUNKTFUNK_GAMESCOPE_MANAGED` | `1` | **Managed** model (the default where session infra is detected): the host takes the box's gamescope over and relaunches it headless at the client's exact mode, restoring on idle. |
|
||||||
| `PUNKTFUNK_GAMESCOPE_SESSION` | `steam` | The host owns a `gamescope-session-plus` (Steam) session at the client's mode — a headless appliance with no physical session running. |
|
| `PUNKTFUNK_GAMESCOPE_SESSION` | `steam` | The host owns a `gamescope-session-plus` (Steam) session at the client's mode — a headless appliance with no physical session running. |
|
||||||
| `PUNKTFUNK_GAMESCOPE_NODE` | `auto` · node id | Discover and capture a **running** gamescope's PipeWire node at a fixed mode. Do **not** combine with `SESSION`. |
|
| `PUNKTFUNK_GAMESCOPE_NODE` | `auto` · node id | Discover and capture a **running** gamescope's PipeWire node at a fixed mode. Do **not** combine with `SESSION`. |
|
||||||
| `PUNKTFUNK_GAMESCOPE_APP` | command | For an ad-hoc bare-gamescope session, the nested command to run (e.g. `vkcube`). |
|
| `PUNKTFUNK_GAMESCOPE_APP` | command | For an ad-hoc bare-gamescope session, the nested command to run (e.g. `vkcube`). |
|
||||||
|
|||||||
@@ -12,19 +12,20 @@ installed — see [Ubuntu](/docs/ubuntu), [Fedora](/docs/fedora), or [Arch](/doc
|
|||||||
|
|
||||||
## host.env
|
## host.env
|
||||||
|
|
||||||
Write `~/.config/punktfunk/host.env` with the GNOME settings. The host auto-detects the compositor
|
The host auto-detects the compositor from your live session on every connect, so the starter
|
||||||
from your session, so the explicit `PUNKTFUNK_COMPOSITOR` is belt-and-braces:
|
`~/.config/punktfunk/host.env` is one line:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
# ~/.config/punktfunk/host.env
|
# ~/.config/punktfunk/host.env (keys are case-sensitive)
|
||||||
WAYLAND_DISPLAY=wayland-0
|
|
||||||
XDG_CURRENT_DESKTOP=GNOME
|
|
||||||
PUNKTFUNK_COMPOSITOR=mutter
|
|
||||||
PUNKTFUNK_VIDEO_SOURCE=virtual
|
PUNKTFUNK_VIDEO_SOURCE=virtual
|
||||||
# GPU zero-copy (dmabuf → CUDA → NVENC) is ON by default; auto-falls back to CPU. Set =0 to force CPU.
|
# GPU zero-copy (dmabuf → CUDA → NVENC) is ON by default; auto-falls back to CPU. Set =0 to force CPU.
|
||||||
PUNKTFUNK_INPUT_BACKEND=libei
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Don't set `PUNKTFUNK_COMPOSITOR`, `WAYLAND_DISPLAY`, or `XDG_CURRENT_DESKTOP` here.** Pinning
|
||||||
|
> the compositor turns auto-detection **off** — per connect *and* mid-stream — so the host stops
|
||||||
|
> following session switches, and stale session values point it at dead sockets. Forcing a backend
|
||||||
|
> is a CI / dedicated-appliance posture, not desktop configuration.
|
||||||
|
|
||||||
You must be on a **Wayland** session (not X11), and Mutter must be **≥ 48**. See the
|
You must be on a **Wayland** session (not X11), and Mutter must be **≥ 48**. See the
|
||||||
[Configuration reference](/docs/configuration) for every option.
|
[Configuration reference](/docs/configuration) for every option.
|
||||||
|
|
||||||
|
|||||||
@@ -19,14 +19,19 @@ or [Fedora](/docs/fedora).
|
|||||||
|
|
||||||
## host.env
|
## host.env
|
||||||
|
|
||||||
The host auto-detects a Hyprland session, so you usually need nothing here. To force the backend, set
|
The host auto-detects a Hyprland session, so the starter `~/.config/punktfunk/host.env` is one line:
|
||||||
these in `~/.config/punktfunk/host.env`:
|
|
||||||
|
```ini
|
||||||
|
PUNKTFUNK_VIDEO_SOURCE=virtual
|
||||||
|
# GPU zero-copy capture→encode is ON by default; auto-falls back to CPU. Set PUNKTFUNK_ZEROCOPY=0 to force CPU.
|
||||||
|
```
|
||||||
|
|
||||||
|
To force the backend (CI/testing — note that pinning turns live-session auto-detection **off**, so
|
||||||
|
the host stops following session switches):
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
PUNKTFUNK_COMPOSITOR=hyprland
|
PUNKTFUNK_COMPOSITOR=hyprland
|
||||||
PUNKTFUNK_INPUT_BACKEND=wlr
|
PUNKTFUNK_INPUT_BACKEND=wlr
|
||||||
PUNKTFUNK_VIDEO_SOURCE=virtual
|
|
||||||
# GPU zero-copy capture→encode is ON by default; auto-falls back to CPU. Set PUNKTFUNK_ZEROCOPY=0 to force CPU.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
See [Configuration](/docs/configuration) for the full reference.
|
See [Configuration](/docs/configuration) for the full reference.
|
||||||
|
|||||||
@@ -13,20 +13,30 @@ installed — see [Ubuntu](/docs/ubuntu), [Fedora](/docs/fedora), [Arch](/docs/a
|
|||||||
|
|
||||||
## host.env
|
## host.env
|
||||||
|
|
||||||
A KDE starter `~/.config/punktfunk/host.env`:
|
The host auto-detects your KWin session on every connect — including a box that switches between
|
||||||
|
the Plasma desktop and Steam Game Mode — so the starter `~/.config/punktfunk/host.env` is one line:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
WAYLAND_DISPLAY=wayland-0
|
# ~/.config/punktfunk/host.env (keys are case-sensitive)
|
||||||
XDG_CURRENT_DESKTOP=KDE
|
|
||||||
PUNKTFUNK_COMPOSITOR=kwin
|
|
||||||
PUNKTFUNK_VIDEO_SOURCE=virtual
|
PUNKTFUNK_VIDEO_SOURCE=virtual
|
||||||
# GPU zero-copy (dmabuf → CUDA → NVENC) is ON by default; auto-falls back to CPU. Set =0 to force CPU.
|
# GPU zero-copy (dmabuf → CUDA → NVENC) is ON by default; auto-falls back to CPU. Set =0 to force CPU.
|
||||||
PUNKTFUNK_INPUT_BACKEND=libei
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The host auto-detects the running compositor on every connect, so most of this is optional — the
|
> **Don't set `PUNKTFUNK_COMPOSITOR`, `WAYLAND_DISPLAY`, or `XDG_CURRENT_DESKTOP` here.** Pinning
|
||||||
values above are just what it resolves to on a KWin session. See the
|
> the compositor turns auto-detection **off** — per connect *and* mid-stream — so a switch to Game
|
||||||
[Configuration reference](/docs/configuration) for every option.
|
> Mode then kills the stream instead of being followed, and stale session values point the host at
|
||||||
|
> dead sockets. Forcing a backend is for CI and dedicated appliances (the
|
||||||
|
> [headless session](#headless-session) below ships a `host.env.kde` that pins on purpose).
|
||||||
|
|
||||||
|
If the box switches between the desktop and Game Mode, also enable lingering — the host is a user
|
||||||
|
service, and without linger the logout moment of a session switch tears it (and PipeWire) down
|
||||||
|
mid-stream:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo loginctl enable-linger "$USER"
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [Configuration reference](/docs/configuration) for every option.
|
||||||
|
|
||||||
## Use a Wayland session
|
## Use a Wayland session
|
||||||
|
|
||||||
|
|||||||
@@ -23,16 +23,21 @@ or [Fedora](/docs/fedora).
|
|||||||
|
|
||||||
## host.env
|
## host.env
|
||||||
|
|
||||||
The host auto-detects a wlroots session, so you usually need nothing here. To force the backend, set
|
The host auto-detects a wlroots session, so the starter `~/.config/punktfunk/host.env` is one line:
|
||||||
these in `~/.config/punktfunk/host.env`:
|
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
PUNKTFUNK_COMPOSITOR=wlroots # aliases: sway, wlr, hyprland (all the wlroots family; the exact backend is auto-detected)
|
|
||||||
PUNKTFUNK_INPUT_BACKEND=wlr
|
|
||||||
PUNKTFUNK_VIDEO_SOURCE=virtual
|
PUNKTFUNK_VIDEO_SOURCE=virtual
|
||||||
# GPU zero-copy capture→encode is ON by default; auto-falls back to CPU. Set PUNKTFUNK_ZEROCOPY=0 to force CPU.
|
# GPU zero-copy capture→encode is ON by default; auto-falls back to CPU. Set PUNKTFUNK_ZEROCOPY=0 to force CPU.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
To force the backend (CI/testing — note that pinning turns live-session auto-detection **off**, so
|
||||||
|
the host stops following session switches):
|
||||||
|
|
||||||
|
```ini
|
||||||
|
PUNKTFUNK_COMPOSITOR=wlroots # aliases: sway, wlr (the wlroots-proper family)
|
||||||
|
PUNKTFUNK_INPUT_BACKEND=wlr
|
||||||
|
```
|
||||||
|
|
||||||
See [Configuration](/docs/configuration) for the full reference.
|
See [Configuration](/docs/configuration) for the full reference.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|||||||
@@ -103,7 +103,37 @@ See [GNOME](/docs/gnome) for the GL/EGL userspace details.
|
|||||||
- KWin must be **≥ 6.5.6** (`kwin_wayland --version`); GNOME **≥ 48**; gamescope **≥ 3.16.22**. See
|
- KWin must be **≥ 6.5.6** (`kwin_wayland --version`); GNOME **≥ 48**; gamescope **≥ 3.16.22**. See
|
||||||
[KDE](/docs/kde) for the KWin/Wayland requirement and [gamescope](/docs/gamescope) for the
|
[KDE](/docs/kde) for the KWin/Wayland requirement and [gamescope](/docs/gamescope) for the
|
||||||
gamescope one.
|
gamescope one.
|
||||||
- Confirm `PUNKTFUNK_COMPOSITOR` in [`host.env`](/docs/configuration) matches your desktop.
|
- If [`host.env`](/docs/configuration) sets `PUNKTFUNK_COMPOSITOR`, **remove it** — the host
|
||||||
|
auto-detects the live compositor, and the pin points it at one backend even when a different
|
||||||
|
session is live (it also disables Gaming ↔ Desktop following).
|
||||||
|
|
||||||
|
## The screen stays black after switching to Game Mode (Nobara)
|
||||||
|
|
||||||
|
On distros whose Game Mode is display-manager autologin under **plasmalogin** (Nobara), a managed
|
||||||
|
takeover from a host **0.19.1 or older** could kill the display manager: it trips systemd's start
|
||||||
|
limit and the box stays black until someone restarts it. Recover from a VT (Ctrl+Alt+F3) or SSH:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
systemctl --user unmask --runtime 'gamescope-session-plus@*.service'
|
||||||
|
sudo systemctl reset-failed plasmalogin && sudo systemctl restart plasmalogin
|
||||||
|
```
|
||||||
|
|
||||||
|
Current hosts detect the display-manager flavor and never mask the session unit there — see
|
||||||
|
[gamescope → autologin display managers](/docs/gamescope) for the polkit rule that enables the full
|
||||||
|
managed takeover on these boxes (without it the host mirrors Game Mode instead).
|
||||||
|
|
||||||
|
## Session fails right after editing host.env
|
||||||
|
|
||||||
|
- Keys are **case-sensitive**: `punktfunk_gamescope_attach=1` sets nothing — use the exact
|
||||||
|
uppercase names.
|
||||||
|
- Hardcoded session anchors with the wrong uid (`XDG_RUNTIME_DIR=/run/user/1000` when `id -u`
|
||||||
|
isn't 1000) point the host at another user's PipeWire/D-Bus: audio errors like
|
||||||
|
`pw audio connect … Creation failed`, no capture, and clients reporting the host as
|
||||||
|
unreachable or asleep. **Delete both anchor lines** — a `systemctl --user` service doesn't need
|
||||||
|
them — or fix the uid.
|
||||||
|
- `PUNKTFUNK_COMPOSITOR` pins the backend and disables Gaming ↔ Desktop following — remove it on
|
||||||
|
any box that switches sessions.
|
||||||
|
- The env file is read at service start: `systemctl --user restart punktfunk-host` after edits.
|
||||||
|
|
||||||
## Capture fails: "Session creation inhibited" (GNOME)
|
## Capture fails: "Session creation inhibited" (GNOME)
|
||||||
|
|
||||||
|
|||||||
+20
-7
@@ -31,15 +31,28 @@ even across a tag re-point.
|
|||||||
Canary / `-rc` builds have **no** file here on purpose: they get no curated body and are not
|
Canary / `-rc` builds have **no** file here on purpose: they get no curated body and are not
|
||||||
announced.
|
announced.
|
||||||
|
|
||||||
## Format
|
## Voice & format
|
||||||
|
|
||||||
Match the house style (see any recent `vX.Y.Z.md`):
|
**Write for the people who USE Punktfunk to stream their games and desktops — not for the people who
|
||||||
|
build it.** A non-engineer should finish knowing what's new and whether it affects them; an engineer
|
||||||
|
should never be confused or forced to decode internals. (See any recent `vX.Y.Z.md` for the target.)
|
||||||
|
|
||||||
- Open with a **wire-compatibility** line — *"Wire-compatible with X.Y.x — existing pairings and
|
1. **Lead with the benefit.** Each entry = what the user can now *do*, what now *works*, or what
|
||||||
clients keep working."* — plus a one-sentence fallback/negotiation note. This lead-in (all text
|
stopped *going wrong* — in their words. Implementation is not the story.
|
||||||
before the first `##` header) is what the Discord embed shows, so make it a real summary.
|
2. **No internal vocabulary in the body.** No protocol/message names, code type names, hex codes or
|
||||||
- Then `## Section` headers grouping the changes, with **bold lead-in** bullets.
|
hardware IDs, crate/component names, or API symbols. Translate any essential detail to plain
|
||||||
- Be concrete: env vars, ABI/protocol versions, on-glass-verified hardware, platform scope.
|
language. Name things users recognize (iPad, Apple Pencil, Steam Deck, Android TV, the Windows
|
||||||
|
sign-in screen) — not subsystems.
|
||||||
|
3. **Group as New / Improved / Fixed**, each a bold one-line lead-in + a tight plain explanation.
|
||||||
|
Skimmable. The lead-in text before the first `##` is what the Discord announcement shows, so make
|
||||||
|
it a real, plain-language summary.
|
||||||
|
4. **Be specific and honest** — no vague "various improvements"; a reader should know exactly what
|
||||||
|
changed.
|
||||||
|
5. **Compatibility line up top, in plain terms:** can they update one side at a time? does their
|
||||||
|
existing setup keep working? No version numbers in the lead.
|
||||||
|
6. **All protocol / ABI / driver / embedder detail goes in ONE `## Under the hood (for developers)`
|
||||||
|
section at the very bottom** — the only place internal names and version numbers belong, clearly
|
||||||
|
optional. The old dense engineering style survives only there.
|
||||||
|
|
||||||
The short annotated-**tag** message stays separate and short (a headline + a paragraph); it is the
|
The short annotated-**tag** message stays separate and short (a headline + a paragraph); it is the
|
||||||
tag object's message, not this file.
|
tag object's message, not this file.
|
||||||
|
|||||||
+20
-34
@@ -1,47 +1,33 @@
|
|||||||
Wire-compatible with 0.18.x — existing pairings and clients keep working. The new stylus plane is capability-negotiated (`HOST_CAP_PEN`) and rides an additive datagram that older peers never send and never have to parse, so a 0.18 host and a 0.19 client (or the reverse) pair exactly as before and simply fall back to pen-as-touch. WIRE_VERSION stays **2**; the embeddable C ABI moves to **13** for one new entry point (`punktfunk_connection_send_pen`); the Windows display-driver protocol is unchanged at **6** (compat floor **3**).
|
Update whenever it suits you — you can update your app and the machine you stream from one at a time, the new and old versions work together, and everything you've already paired stays paired. The new pen features simply switch on once both ends are updated. Nothing you already use changes.
|
||||||
|
|
||||||
## Pen, stylus & tablet — a whole new input plane
|
## New: draw and write with a pen or stylus
|
||||||
|
|
||||||
Punktfunk streams now carry a first-class, pressure-sensitive stylus, end to end and on every platform. It is its own wire plane — not mouse events in disguise — so pressure, tilt, hover and barrel buttons survive the trip from glass to host.
|
You can now use a real stylus while streaming, and it behaves like a real pen on the machine you're controlling — **pressure, tilt, and the pen's side buttons all come through**, not just a plain tap. Use your **iPad's Apple Pencil** (including Pencil Pro), an **Android phone or tablet's S Pen** or other active stylus, or a pen from a Moonlight app.
|
||||||
|
|
||||||
- **The wire (P0).** A state-full `RICH_PEN` datagram carries batches of up to 8 samples — sub-pixel `f32` position, `u16` pressure, hover distance, tilt + azimuth, an eraser tool, and two barrel buttons — ordered by a wrapping sequence number and dropped whole when stale, so a late batch never rewrites the present. A `PenTracker` coalesces the stream and `HOST_CAP_PEN` advertises the plane; toward a host without the bit the client keeps its pen-as-touch fallback.
|
It's great for drawing apps, handwriting and note-taking, signing documents, and photo editing over the stream. Windows and Linux hosts receive it as genuine pen input. If the machine you're streaming from is still on an older version, your pen keeps working as an ordinary touch.
|
||||||
- **Linux host (P1).** A per-session uinput virtual graphics tablet injects the full stylus — pressure and tilt included — and tears down with the session. `HOST_CAP_PEN` goes live wherever uinput is available.
|
|
||||||
- **GameStream / Moonlight (P2).** The host ingests Moonlight's `SS_PEN`/`SS_TOUCH` pointer packets and advertises the matching `featureFlags`, so a capable Moonlight client sends real stylus onto the same plane.
|
|
||||||
- **Windows host (P3).** `PT_PEN` + `PT_TOUCH` synthetic pointer injection drives the Windows pen stack directly — pressure and barrel buttons reach applications as genuine pen input.
|
|
||||||
- **iPad (P4).** Apple Pencil (including Pencil Pro) capture maps onto the stylus plane — pressure and tilt from the panel, with the cursor-capability fix so the pencil and the pointer coexist.
|
|
||||||
- **Android (P5).** Active-stylus capture (S Pen and friends) forwards onto the pen plane with pressure and tilt.
|
|
||||||
|
|
||||||
## Touch injection, fixed
|
## Fixed: your Steam Deck controller shows up as the right controller
|
||||||
|
|
||||||
- **Windows native touch actually injects now.** `PT_TOUCH` pointer ids must be contiguous injector slots, but the host was passing the raw wire touch ids straight through — Windows rejected them **silently** and Moonlight-native multitouch landed nothing. Wire ids are now compacted into slots, and the first rejection is surfaced with a warning instead of vanishing.
|
If a Steam controller was plugged into the computer you stream from, a streamed **Steam Deck** controller could be misread by games as a **PlayStation (DualSense)** controller — so button prompts and layouts came out wrong. It now stays a Steam Deck. Punktfunk only steps in to avoid a clash when two genuinely identical controllers are present.
|
||||||
- **NaN pressure no longer drops a finger.** VoidLink's finger touches arrive with a NaN `pressureOrDistance`; the GameStream path discarded the whole packet. It now tolerates NaN and injects the touch.
|
|
||||||
|
|
||||||
## Cursor & pointer polish
|
## Improved: a sharper, correctly-sized mouse pointer
|
||||||
|
|
||||||
- **The forwarded cursor is scaled to the video fit** on the SDL/Linux and Apple/macOS clients. A high-DPI host pointer was drawn against the client's own backing scale and came out roughly 2× too large; it now renders true-size against the streamed video.
|
- **Right-sized pointer on high-resolution screens.** On Linux and Mac, a pointer coming from a high-resolution host could show up about twice as big as it should. It now matches the video exactly.
|
||||||
|
- **Cleaner pointer on Windows security screens.** The mouse pointer no longer duplicates or gets stuck on Windows sign-in and User Account Control (admin permission) prompts.
|
||||||
|
- **More reliable multi-monitor streaming on Windows.** Switching between monitor layouts on the host could occasionally drop the stream — that's fixed.
|
||||||
|
|
||||||
## Windows host fixes
|
## Fixed: touch input
|
||||||
|
|
||||||
- **No more `0x57` on display-config isolate.** When a supplied CCD path is doomed (a Steam Deck's live-deactivate always is), the isolate escalates to a keep-only supplied config instead of failing the whole apply with `0x57`.
|
- **Multi-touch from Moonlight apps now works on Windows hosts.** It previously registered nothing; pinch, zoom, and multi-finger gestures now come through.
|
||||||
- **Cursor exclusion is reported adapter-wide.** The IddCx declare's cursor exclusion is not a per-target property; the virtual-display driver now reports `cursor_excluded` across the adapter.
|
- **Touch is no longer silently dropped** from some devices that report finger pressure in an unusual way.
|
||||||
- **Secure-desktop cursor stood down.** The IddCx hardware cursor is held down while the Windows secure desktop (UAC / Winlogon) is up, ending the duplicated/stuck pointer there.
|
|
||||||
|
|
||||||
## Gamepad
|
## Fixed: Android (and Android TV)
|
||||||
|
|
||||||
- **A virtual Steam Deck pad no longer degrades to a DualSense because of an unrelated Steam controller.** To stop Steam Input from double-driving two *identical* controllers, the host downgrades a requested virtual Steam pad to a DualSense when a matching physical Valve controller is present — but the gate matched *any* `28DE` (Valve) device. So plugging a physical Steam Controller 2 (`28DE:1302`) into the host dropped a client's virtual Steam Deck (`28DE:1205`) to the wrong pad — the passthrough came up as a DualSense. The gate now keys on the exact VID+PID: distinct Steam controllers coexist (Steam Input drives them side by side fine), and only a genuine same-identity duplicate — a physical Deck alongside a virtual Deck — still degrades.
|
- **Your mouse's back/forward buttons stay in the stream** instead of bouncing you out to the Android home screen — a relief on Android TV in particular.
|
||||||
|
- **The on-screen keyboard stays out of the way** when you're typing on a physical keyboard.
|
||||||
|
|
||||||
## Android input — mouse & keyboard regressions
|
## Under the hood (for developers)
|
||||||
|
|
||||||
Two regressions from 0.18.0's mouse-&-keyboard overhaul, felt most on Android TV boxes:
|
- The streaming protocol is unchanged, so 0.18 and 0.19 hosts and apps mix freely; the pen is negotiated and older peers simply fall back to touch.
|
||||||
|
- The embeddable core library adds one new call for sending pen input (the C ABI moves to **13**) — rebuild any embedders against 0.19. The Windows virtual-display driver is unchanged: pen goes through Windows' normal pen system, not the display driver.
|
||||||
- **Mouse back/forward stays in the stream.** A mouse's back/forward buttons were synthesized by the reader as `SOURCE_MOUSE` key events and leaked into Android navigation (yanking you out of the stream on a TV); they're now swallowed while streaming.
|
- iOS builds are now attached to each release as a downloadable file (for TestFlight/archival).
|
||||||
- **Hardware typing no longer pops the IME.** The soft keyboard is gated on `imeShown`, so typing on a physical keyboard against a text-input host doesn't summon the on-screen IME.
|
|
||||||
|
|
||||||
## Release & CI
|
|
||||||
|
|
||||||
- **Release notes now ship with the release.** Notes are authored in-repo at `docs/releases/vX.Y.Z.md` and seeded into the release body at creation, and stable releases announce to the Discord `#releases` channel. This file is the first of them.
|
|
||||||
- **iOS `.ipa` attached.** The iOS build exports an App Store-signed `.ipa` to the unified release and to the run artifacts (archival/TestFlight, not a direct sideload).
|
|
||||||
|
|
||||||
## Versions
|
|
||||||
|
|
||||||
WIRE_VERSION **2** (unchanged — 0.18.x hosts and clients interoperate) · C ABI **13** (adds `punktfunk_connection_send_pen`; embedders recompile) · Windows display driver protocol **6**, compat floor **3** (unchanged — pen injects through the Windows pen stack, not the display driver). Windows drivers ship separately, as always.
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
A maintenance update — nothing changes in how Punktfunk works, and you can update your app and the machine you stream from one at a time. Everything you've already paired stays paired.
|
||||||
|
|
||||||
|
This one is entirely about things that used to go wrong, and the big one is stuck sessions: a machine you streamed from could go on believing you were still connected long after you'd closed the app, walked out of Wi-Fi range or put the device to sleep — leaving it busy, refusing the next connection, and in the worst case unable to accept anyone at all until it was restarted. That's fixed on both sides. Also fixed: an admin prompt no longer locks you out of a Windows machine, your Apple device's screen no longer sleeps while you're playing with a controller, the mouse pointer behaves in Steam's Gaming Mode, and phone-shaped screens finally get the high refresh rate they asked for from a KDE Plasma machine.
|
||||||
|
|
||||||
|
## Fixed: sessions no longer outlive the device that started them
|
||||||
|
|
||||||
|
If a session ended in any way other than pressing Disconnect, the machine you were streaming from often never found out. Quitting the app, closing your laptop, losing Wi-Fi, a device running out of battery, or simply switching to another app on a phone or TV box — all of these could leave a session running forever, encoding video and audio to nobody.
|
||||||
|
|
||||||
|
That had real consequences, and all of them are fixed:
|
||||||
|
|
||||||
|
- **You can reconnect and actually get a picture.** A machine that still thought you were connected would accept your next connection and then show you nothing, because it believed it was already streaming to you.
|
||||||
|
- **Someone else can use the machine.** A stuck session made it look busy, so the next person to connect was refused — indefinitely.
|
||||||
|
- **A machine no longer stops accepting connections entirely.** A session that got wedged used to hold onto its slot permanently, and a handful of those would make the machine unreachable until it was restarted. Even the Stop button in the web console couldn't clear one. Stuck sessions are now cleaned up regardless, and the Stop button works.
|
||||||
|
- **Closing the app on your phone or TV box actually ends the session.** On Android and on iPhone, iPad and Apple TV, switching away from Punktfunk now ends the stream instead of quietly keeping it alive in the background. Coming straight back is still quick.
|
||||||
|
- **Quitting deliberately is heard.** Pressing Disconnect now reliably reaches the other machine, rather than looking like a device that vanished.
|
||||||
|
- **Streaming from Moonlight ends properly too.** Moonlight sessions had the same problem — quitting Moonlight left the machine streaming into the void. It now notices a client that has gone away, typically within half a minute.
|
||||||
|
- **A busy machine tells you it's busy.** If it's already at its limit, you now get a clear answer instead of a connection that hangs and eventually times out.
|
||||||
|
|
||||||
|
## Fixed: an admin prompt on Windows no longer locks you out
|
||||||
|
|
||||||
|
If a Windows machine was left showing a **User Account Control prompt** (the "do you want to allow this app to make changes?" dialog), or was sitting on the lock or sign-in screen, connecting to it broke in two ways. Both are fixed.
|
||||||
|
|
||||||
|
- **Connecting works again.** Every attempt used to show a black screen and then give up after about a minute, which meant an unattended machine had to be reached some other way just to click one button. A connection now comes up in about **three seconds** with the prompt still on screen.
|
||||||
|
- **Pen and touch reach the prompt.** Mouse and keyboard already worked, but pen and touch input did nothing — so you could see the prompt but not dismiss it from a tablet. You can now dismiss a Windows admin prompt with an **Apple Pencil from an iPad**, or with a finger.
|
||||||
|
|
||||||
|
## Fixed: your screen stays awake on iPhone, iPad, Apple TV and Mac
|
||||||
|
|
||||||
|
Playing with a controller, the screen would dim and sleep out from under you mid-session — because a game controller isn't something the operating system counts as "using the device". The display now stays awake for as long as the session lasts, and goes back to normal the moment you disconnect. On a Mac, the screen saver and the idle lock that follows it are held off for the session too. (The Android app has always done this; the Apple apps now match.)
|
||||||
|
|
||||||
|
## Fixed: the stuck pointer in Steam's Gaming Mode
|
||||||
|
|
||||||
|
On a machine streaming from Steam's Gaming Mode — a Steam Deck, or a desktop in the same session — a **fragment of the mouse pointer got welded to the bottom-right corner** of the stream and stayed there for the rest of the session, while the real pointer went undrawn. It showed up in every game, because a game takes over the pointer.
|
||||||
|
|
||||||
|
The stream now follows what the machine itself decides to draw: the pointer appears where it really is, disappears when the machine hides it, and picks up Gaming Mode's own "hide the pointer after a few seconds of stillness" behaviour, which the stream never had before.
|
||||||
|
|
||||||
|
## Fixed: high refresh rate on phone-shaped screens from KDE Plasma
|
||||||
|
|
||||||
|
Streaming from a **KDE Plasma** desktop to a screen with an unusual width — an **iPhone 16 Pro Max**, for instance — dropped to **60 Hz**, even though the 120 Hz option was sitting right there in the machine's own display settings. Punktfunk was asking for the resolution it wanted, while the desktop had quietly built a mode a few pixels narrower, so the two never matched.
|
||||||
|
|
||||||
|
Punktfunk now picks whichever mode the desktop actually created and streams at its real refresh rate. Screens with common widths (1080p, 1440p, 4K) were never affected.
|
||||||
|
|
||||||
|
Also fixed: connecting no longer leaves a **new leftover entry in your display settings every time**. Previously each connection appended one more custom resolution to the list.
|
||||||
|
|
||||||
|
## Improved: setup guides for Linux desktops
|
||||||
|
|
||||||
|
Following a field report, the Linux setup guides no longer tell you to lock Punktfunk to a specific desktop environment. That setting **stops the stream from following you** when you switch to Steam's Gaming Mode — the stream would end instead of coming with you. The example configuration also no longer hard-codes a user account ID, which broke audio for anyone who wasn't the first user created on the machine.
|
||||||
|
|
||||||
|
**If you copied a Punktfunk configuration from the docs before this release, it's worth re-checking it** — the current guides for KDE, GNOME, Hyprland and Sway are down to a single setting, and the troubleshooting page now has a section for sessions that break right after a config edit.
|
||||||
|
|
||||||
|
## Under the hood (for developers)
|
||||||
|
|
||||||
|
- **Nothing changed on the wire or at the API boundary.** The streaming protocol stays at version 2 and the embeddable core library stays at C ABI **13** — no embedder rebuild is required, and 0.18/0.19 hosts and clients still mix freely. The Windows virtual-display driver protocol is unchanged.
|
||||||
|
- **Native session teardown is now bounded and enforceable.** `stop` was advisory: every teardown (`conn.close`, the joins, and the RAII drops of the session permit, admission entry and stream marker) sat after the stream thread's await, and the encode loop only checks the flag *between* iterations — so one unbounded syscall inside an iteration became a permanent zombie holding its semaphore slot and admission entry. The thread now gets `STREAM_STOP_GRACE` (90 s, past the 40 s capture-rebuild budget) and teardown runs regardless; the thread is detached rather than killed, since Rust cannot cancel a blocking thread. Audio/input joins are bounded too. The session permit is taken *after* the QUIC handshake rather than before `accept()`, so a host at its cap still accepts and the client sees a live path instead of a silent dial timeout. New `pf-vdisplay` `proc::{status_within, output_within}` kill a child that outruns its budget — `kscreen-doctor` is a Wayland client of the very compositor it configures and never returns against a wedged KWin.
|
||||||
|
- **Client-side close actually reaches the wire.** `conn.close()` only queues the frame, and `run_pump` is the body of a `block_on` whose runtime is dropped the moment it returns, so a deliberate quit arrived as silence (8 s idle timeout, no quit code). The endpoint is now carried out of the handshake and flushed with `wait_idle()`. Android tears down on `ON_STOP` via the existing `onDispose` path; the Apple `.background` arm was iOS-only *and* gated on an opt-in defaulting off while the `audio` background mode kept the connection alive indefinitely — it now acts unconditionally and covers tvOS.
|
||||||
|
- **GameStream sessions end on ENet disconnect.** The only automatic detector was a media-UDP send error, which needs an ICMP port-unreachable — so a true vanish left both planes encoding forever, and a plain Moonlight quit (no RTSP `TEARDOWN`, no nvhttp `/cancel`) leaked the session. The control peer's reliable-ping timeout fires `Event::Disconnect` within ~5–30 s and now runs the shared, idempotent `AppState::end_session`. Peer tracking is gated on the `/launch` owner's source IP so an unauthenticated LAN peer cannot end a live session and a stale peer cannot kill its successor. Unreachable-client UDP errors end the whole session via an `OnSessionLost` callback built at PLAY, instead of stopping only the plane that noticed.
|
||||||
|
- **Windows display and pointer-injection calls now follow the input desktop.** `SetDisplayConfig`/`ChangeDisplaySettingsEx` and `InjectSyntheticPointerInput` are rejected with `ERROR_ACCESS_DENIED` when issued from a thread that isn't on the desktop currently receiving input — Winlogon, whenever UAC or the lock screen is up. Both paths retry once bound to the input desktop, with the binding scoped rather than persistent so a shared thread is never left parked on a desktop that disappears. The retry predicate stays narrow (`ERROR_ACCESS_DENIED` only) so the unrelated exclusive-mode `0x57` topology failure is not re-issued under a different name.
|
||||||
|
- **The X11 cursor source honours `GAMESCOPE_CURSOR_VISIBLE_FEEDBACK`.** gamescope hides the pointer by warping it to the root window's bottom-right pixel rather than swapping in a transparent cursor, so `XFixesGetCursorImage` kept returning the last opaque arrow and the "whichever pointer moved last" heuristic froze on the parked Xwayland. The atom is read at connect and re-read on its root `PropertyNotify`, with a 250 ms resync; the motion heuristic remains the fallback for a gamescope that publishes no verdict (logged as `cursor_feedback=false`).
|
||||||
|
- **KWin custom modes are resolved from the output's own mode list and selected by mode id.** libxcvt rounds the requested width down to a multiple of 8 (2868×1320@120 → 2864×1320@119.92), so the `WxH@Hz` name passed to kscreen-doctor's `findMode` matched nothing and the select silently no-op'd. `set_custom_refresh` now returns the achieved mode and it is reported as the output's preferred mode, so the renegotiation gate waits for the geometry KWin will really deliver. `addCustomMode` is skipped when a usable mode already exists, since kscreen-doctor appends and KWin persists per output name.
|
||||||
|
- **Apple display-sleep guard.** `UIApplication.isIdleTimerDisabled` on iOS/iPadOS/tvOS; on macOS `ProcessInfo.beginActivity(.idleDisplaySleepDisabled)` plus a 30 s `IOPMAssertionDeclareUserActivity` heartbeat, because the screen saver runs off the HID idle timer independently of display sleep. Acquired in `beginStreaming`, released at the top of `disconnect`, so every teardown path releases it.
|
||||||
|
- **The xcframework builds warning-free again.** Two redundant `super::super::*` globs in the client pump warned only on tvOS targets, which CI never builds (that needs `BUILD_TVOS=1` and the Tier-3 build-std nightly path).
|
||||||
+17
-24
@@ -234,33 +234,25 @@ cp /usr/share/punktfunk/host.env.bazzite ~/.config/punktfunk/host.env
|
|||||||
The Bazzite template (`packaging/bazzite/host.env`) contains:
|
The Bazzite template (`packaging/bazzite/host.env`) contains:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
XDG_RUNTIME_DIR=/run/user/1000
|
|
||||||
DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus
|
|
||||||
|
|
||||||
# gamescope backend: spawned per session, no compositor login required.
|
|
||||||
PUNKTFUNK_COMPOSITOR=gamescope
|
|
||||||
PUNKTFUNK_VIDEO_SOURCE=virtual
|
PUNKTFUNK_VIDEO_SOURCE=virtual
|
||||||
PUNKTFUNK_GAMESCOPE_APP=steam -gamepadui
|
|
||||||
|
|
||||||
# gamescope hosts its own EIS input socket — input lands in the nested session.
|
|
||||||
PUNKTFUNK_INPUT_BACKEND=gamescope
|
|
||||||
|
|
||||||
# GPU zero-copy capture (dmabuf -> CUDA -> NVENC) is ON by default and auto-falls back to CPU if
|
# GPU zero-copy capture (dmabuf -> CUDA -> NVENC) is ON by default and auto-falls back to CPU if
|
||||||
# unavailable. No need to set it. Set to 0 only to force the CPU path.
|
# unavailable. No need to set it. Set to 0 only to force the CPU path.
|
||||||
# PUNKTFUNK_ZEROCOPY=0
|
# PUNKTFUNK_ZEROCOPY=0
|
||||||
|
|
||||||
#RUST_LOG=info
|
#RUST_LOG=info
|
||||||
|
|
||||||
|
# Gaming Mode = ATTACH: the box owns its gamescope session; the host captures + follows it.
|
||||||
|
PUNKTFUNK_GAMESCOPE_ATTACH=1
|
||||||
```
|
```
|
||||||
|
|
||||||
**What each knob means and why these are the Bazzite defaults:**
|
**What each knob means and why these are the Bazzite defaults:**
|
||||||
|
|
||||||
| Knob | Value | Meaning |
|
| Knob | Value | Meaning |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `XDG_RUNTIME_DIR` / `DBUS_SESSION_BUS_ADDRESS` | `…/user/1000` | Session bus / runtime dir. **`1000` assumes your user is UID 1000** — change both if `id -u` says otherwise. |
|
| *(no compositor / no anchors)* | — | The host **auto-detects** the live session per connect (Gaming Mode gamescope vs the KDE desktop) and follows switches mid-stream; a `systemctl --user` service inherits the right `XDG_RUNTIME_DIR` and the host derives the bus itself. Pinning `PUNKTFUNK_COMPOSITOR` or hardcoding uid-1000 anchors only breaks this — leave them out. |
|
||||||
| `PUNKTFUNK_COMPOSITOR` | `gamescope` | **The Bazzite default.** The host spawns a **headless gamescope per session** at the client's exact resolution/refresh and captures its PipeWire node — so you need **no graphical desktop login** to stream. Bazzite ships gamescope, so this "just works." |
|
|
||||||
| `PUNKTFUNK_VIDEO_SOURCE` | `virtual` | Create a per-client virtual output at the client's exact WxH@Hz (the flagship "native resolution, no scaling" mode), vs. `portal` which captures an existing monitor. |
|
| `PUNKTFUNK_VIDEO_SOURCE` | `virtual` | Create a per-client virtual output at the client's exact WxH@Hz (the flagship "native resolution, no scaling" mode), vs. `portal` which captures an existing monitor. |
|
||||||
| `PUNKTFUNK_GAMESCOPE_APP` | `steam -gamepadui` | The command launched **inside** the nested gamescope — here, a SteamOS-style couch UI. Set it to whatever you want the session to run. |
|
| `PUNKTFUNK_GAMESCOPE_ATTACH` | `1` | Gaming Mode model: the **box** owns its gamescope session; the host attaches to whatever's live and never tears it down. Swap for `PUNKTFUNK_GAMESCOPE_MANAGED=1` to have the host relaunch the gaming session headless at the **client's** exact mode instead (see the template's comments). |
|
||||||
| `PUNKTFUNK_INPUT_BACKEND` | `gamescope` | Inject mouse/keyboard/gamepad into the nested gamescope via its own EIS socket. |
|
|
||||||
| `PUNKTFUNK_ZEROCOPY` | `on` *(default)* | GPU zero-copy capture (dmabuf → CUDA → NVENC), on by default. Falls back to CPU automatically if unavailable; set `0` to force the CPU path. |
|
| `PUNKTFUNK_ZEROCOPY` | `on` *(default)* | GPU zero-copy capture (dmabuf → CUDA → NVENC), on by default. Falls back to CPU automatically if unavailable; set `0` to force the CPU path. |
|
||||||
| `RUST_LOG` | (commented) | Uncomment `RUST_LOG=info` for verbose logs while debugging. |
|
| `RUST_LOG` | (commented) | Uncomment `RUST_LOG=info` for verbose logs while debugging. |
|
||||||
|
|
||||||
@@ -268,16 +260,14 @@ PUNKTFUNK_INPUT_BACKEND=gamescope
|
|||||||
games a virtual Sony DualSense (lightbar, adaptive triggers, touchpad, motion) instead of the
|
games a virtual Sony DualSense (lightbar, adaptive triggers, touchpad, motion) instead of the
|
||||||
default X-Box-360 pad. The feedback flows back to a real DualSense on the client.
|
default X-Box-360 pad. The feedback flows back to a real DualSense on the client.
|
||||||
|
|
||||||
**Alternative — drive the full Plasma/GNOME desktop** instead of a nested gamescope (per the
|
**The Plasma desktop needs no extra config:** the same auto-detection streams the KDE Desktop
|
||||||
template's footer comment): switch to `PUNKTFUNK_COMPOSITOR=kwin` and
|
session whenever that's what's live — no compositor pin, no `WAYLAND_DISPLAY` /
|
||||||
`PUNKTFUNK_INPUT_BACKEND=libei`, and run the host **inside** a KDE session with `WAYLAND_DISPLAY` /
|
`XDG_CURRENT_DESKTOP` (the host retargets those per connect). The full knob list (FEC %, per-stage
|
||||||
`XDG_CURRENT_DESKTOP` set. The full knob list (FEC %, per-stage timing, etc.) is in
|
timing, etc.) is in `scripts/host.env.example` / `/usr/share/punktfunk/host.env.example`.
|
||||||
`scripts/host.env.example` / `/usr/share/punktfunk/host.env.example`.
|
|
||||||
|
|
||||||
> The gamescope default is what makes Bazzite the easy path: it's a **headless, per-session**
|
> Auto-detection is what makes Bazzite the easy path: the host follows the box between Gaming Mode
|
||||||
> compositor — no desktop login, no display manager, no `--drm` scanout. You don't need any of the
|
> and the Desktop — even mid-stream — with a one-line config. You don't need any of the
|
||||||
> headless-KDE bring-up scripts (`scripts/headless/run-headless-kde.sh`) on Bazzite unless you
|
> headless-KDE bring-up scripts (`scripts/headless/run-headless-kde.sh`) on Bazzite.
|
||||||
> deliberately switch to the KWin backend.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -474,8 +464,11 @@ desktop viewer.
|
|||||||
NVIDIA driver. The code falls back to CPU automatically; check the log for the fallback line and
|
NVIDIA driver. The code falls back to CPU automatically; check the log for the fallback line and
|
||||||
verify the `-nvidia` image / driver is healthy.
|
verify the `-nvidia` image / driver is healthy.
|
||||||
|
|
||||||
- **Wrong UID in `host.env`.** `XDG_RUNTIME_DIR=/run/user/1000` and the bus path assume UID 1000. Run
|
- **Session anchors in `host.env`.** The template no longer sets `XDG_RUNTIME_DIR` /
|
||||||
`id -u`; if it's different, fix both lines or the host can't reach your session's PipeWire/D-Bus.
|
`DBUS_SESSION_BUS_ADDRESS` — a `systemctl --user` service inherits the right values. If an older
|
||||||
|
config hardcodes them with the wrong uid (`/run/user/1000` when `id -u` isn't 1000), the host
|
||||||
|
points at another user's PipeWire/D-Bus and everything fails (`pw audio connect … Creation
|
||||||
|
failed`, no capture). Delete both lines, or fix the uid.
|
||||||
|
|
||||||
- **Service `ExecStart` points at a missing path in `$HOME`.** The dev unit references
|
- **Service `ExecStart` points at a missing path in `$HOME`.** The dev unit references
|
||||||
`%h/punktfunk/target/release/...`. The RPM binary is `/usr/bin/punktfunk-host`. Override
|
`%h/punktfunk/target/release/...`. The RPM binary is `/usr/bin/punktfunk-host`. Override
|
||||||
|
|||||||
@@ -3,10 +3,9 @@
|
|||||||
# The compositor + input backend are AUTO-DETECTED per connect from the ACTIVE session: the host
|
# The compositor + input backend are AUTO-DETECTED per connect from the ACTIVE session: the host
|
||||||
# follows the box as you flip between Steam Gaming Mode (gamescope — a managed session at the
|
# follows the box as you flip between Steam Gaming Mode (gamescope — a managed session at the
|
||||||
# CLIENT's resolution) and a KDE/GNOME Desktop (KWin/Mutter virtual output at the client's mode).
|
# CLIENT's resolution) and a KDE/GNOME Desktop (KWin/Mutter virtual output at the client's mode).
|
||||||
# So nothing here forces a backend — only the trustworthy anchors stay.
|
# So nothing here forces a backend, and no session anchors are needed: a `systemctl --user`
|
||||||
|
# service inherits the correct XDG_RUNTIME_DIR and the host derives the bus from it. (Keys are
|
||||||
XDG_RUNTIME_DIR=/run/user/1000
|
# CASE-SENSITIVE — use the exact uppercase names.)
|
||||||
DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus
|
|
||||||
|
|
||||||
PUNKTFUNK_VIDEO_SOURCE=virtual
|
PUNKTFUNK_VIDEO_SOURCE=virtual
|
||||||
|
|
||||||
@@ -23,10 +22,11 @@ PUNKTFUNK_VIDEO_SOURCE=virtual
|
|||||||
#
|
#
|
||||||
# GAME MODE = ATTACH (the box owns its session; the host follows). The box decides whether it's in
|
# GAME MODE = ATTACH (the box owns its session; the host follows). The box decides whether it's in
|
||||||
# Steam Gaming Mode or a Desktop — you switch with the normal Steam UI / "Switch to Desktop". The
|
# Steam Gaming Mode or a Desktop — you switch with the normal Steam UI / "Switch to Desktop". The
|
||||||
# host just ATTACHES to whatever's live and captures it; it never tears the session down or relaunches
|
# host just ATTACHES to whatever's live and captures it; it never tears the session down. So
|
||||||
# it. So switching Desktop<->Game is rock-solid, and when you disconnect the box STAYS in its current
|
# switching Desktop<->Game is rock-solid, and when you disconnect the box STAYS in its current
|
||||||
# mode — reconnecting drops you right back where you were. The streamed resolution in game mode is the
|
# mode — reconnecting drops you right back where you were. On a resolution mismatch the host
|
||||||
# box's gamescope mode (see SCREEN_WIDTH/HEIGHT in /etc/gamescope-session-plus/sessions.d/steam).
|
# restarts the box's own game-mode session at the CLIENT's resolution (a foreign/bare gamescope
|
||||||
|
# instead streams at its own mode).
|
||||||
PUNKTFUNK_GAMESCOPE_ATTACH=1
|
PUNKTFUNK_GAMESCOPE_ATTACH=1
|
||||||
#
|
#
|
||||||
# Opt OUT to the MANAGED model instead (host tears the box's gamescope down on connect and launches
|
# Opt OUT to the MANAGED model instead (host tears the box's gamescope down on connect and launches
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
# punktfunk host config for a Fedora/Ubuntu KDE Plasma appliance (kwin backend).
|
# punktfunk host config for a Fedora/Ubuntu KDE Plasma appliance (kwin backend).
|
||||||
|
#
|
||||||
|
# APPLIANCE-ONLY: this file deliberately PINS the backend (PUNKTFUNK_COMPOSITOR) and the session
|
||||||
|
# env (WAYLAND_DISPLAY/XDG_CURRENT_DESKTOP) at the dedicated headless KWin session — which also
|
||||||
|
# turns OFF the host's live-session auto-detection and Desktop<->Game following. On a normal
|
||||||
|
# desktop (or any box that switches to Steam Game Mode) do NOT use this file; start from
|
||||||
|
# host.env.example instead, whose defaults auto-detect and follow the live session.
|
||||||
|
#
|
||||||
# Copy to ~/.config/punktfunk/host.env. Pairs with punktfunk-kde-session.service, which brings
|
# Copy to ~/.config/punktfunk/host.env. Pairs with punktfunk-kde-session.service, which brings
|
||||||
# up a headless `kwin --virtual` on wayland-kde (with KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 so the
|
# up a headless `kwin --virtual` on wayland-kde (with KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 so the
|
||||||
# host can bind KWin's privileged zkde_screencast protocol — an interactive Plasma session will
|
# host can bind KWin's privileged zkde_screencast protocol — an interactive Plasma session will
|
||||||
|
|||||||
@@ -168,14 +168,16 @@ Everything the RPM's `%install` + `%post` do, declaratively:
|
|||||||
|
|
||||||
### Headless / appliance
|
### Headless / appliance
|
||||||
|
|
||||||
Set `autoStart = true`, enable lingering, and pick a backend in `settings`:
|
Set `autoStart = true`, enable lingering, and — for a **dedicated single-session appliance** —
|
||||||
|
pin a backend in `settings` (pinning `PUNKTFUNK_COMPOSITOR` disables live-session auto-detection,
|
||||||
|
so leave it out on any box that switches between a desktop and Game Mode):
|
||||||
|
|
||||||
```nix
|
```nix
|
||||||
services.punktfunk.host = {
|
services.punktfunk.host = {
|
||||||
enable = true;
|
enable = true;
|
||||||
autoStart = true;
|
autoStart = true;
|
||||||
users = [ "streamer" ];
|
users = [ "streamer" ];
|
||||||
settings = { PUNKTFUNK_COMPOSITOR = "gamescope"; }; # or kwin/mutter/wlroots
|
settings = { PUNKTFUNK_COMPOSITOR = "gamescope"; }; # appliance-only; omit to auto-detect
|
||||||
};
|
};
|
||||||
users.users.streamer.linger = true;
|
users.users.streamer.linger = true;
|
||||||
# For the gamescope/KWin backends extend the service PATH, e.g.:
|
# For the gamescope/KWin backends extend the service PATH, e.g.:
|
||||||
|
|||||||
+44
-35
@@ -1,16 +1,17 @@
|
|||||||
# punktfunk host configuration (~/.config/punktfunk/host.env) — consumed by punktfunk-host.service.
|
# punktfunk host configuration (~/.config/punktfunk/host.env) — consumed by punktfunk-host.service.
|
||||||
#
|
#
|
||||||
# The compositor + input backend are AUTO-DETECTED per connect from the live session (the host
|
# YOU BARELY NEED THIS FILE. The host AUTO-DETECTS the live session per connect — which compositor
|
||||||
# probes which compositor is actually running and retargets WAYLAND_DISPLAY/XDG_CURRENT_DESKTOP/
|
# is running (KWin / Mutter / sway / Hyprland / gamescope), its Wayland socket, session bus, and the
|
||||||
# DBUS at it), so a box that flips between Steam Gaming Mode and a KDE/GNOME desktop is followed
|
# matching input backend — and FOLLOWS the box when it switches between a desktop and Steam Gaming
|
||||||
# automatically. The blocks below are OPTIONAL OVERRIDES — uncomment one only to force a backend
|
# Mode, even mid-stream. Everything below except PUNKTFUNK_VIDEO_SOURCE is an optional override.
|
||||||
# (this also skips the per-connect env retargeting). The anchors XDG_RUNTIME_DIR + DBUS stay.
|
#
|
||||||
|
# Two rules that save debugging sessions:
|
||||||
# Session / compositor environment (headless KWin example).
|
# * Keys are CASE-SENSITIVE. `punktfunk_gamescope_attach=1` sets nothing — use the exact
|
||||||
XDG_RUNTIME_DIR=/run/user/1000
|
# uppercase names.
|
||||||
DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus
|
# * On a desktop you actually use, do NOT set PUNKTFUNK_COMPOSITOR / WAYLAND_DISPLAY /
|
||||||
WAYLAND_DISPLAY=wayland-kde
|
# XDG_CURRENT_DESKTOP. Pinning the compositor DISABLES session-following (a switch to Game
|
||||||
XDG_CURRENT_DESKTOP=KDE
|
# Mode mid-stream then kills the stream instead of being followed), and stale session vars
|
||||||
|
# point detection at dead sockets. Those knobs are for CI and dedicated appliances (below).
|
||||||
|
|
||||||
# Video source: `virtual` creates a per-client virtual output at the client's exact
|
# Video source: `virtual` creates a per-client virtual output at the client's exact
|
||||||
# resolution+refresh (the flagship mode); `portal` captures an existing monitor.
|
# resolution+refresh (the flagship mode); `portal` captures an existing monitor.
|
||||||
@@ -20,33 +21,41 @@ PUNKTFUNK_VIDEO_SOURCE=virtual
|
|||||||
# CPU automatically. No need to set it. Set to 0 only to force the CPU path.
|
# CPU automatically. No need to set it. Set to 0 only to force the CPU path.
|
||||||
# PUNKTFUNK_ZEROCOPY=0
|
# PUNKTFUNK_ZEROCOPY=0
|
||||||
|
|
||||||
# --- Bazzite / SteamOS-like host: host-managed Steam-Deck-UI session -----------------------
|
# --- Session anchors (rarely needed) -------------------------------------------------------
|
||||||
# The host LAUNCHES gamescope-session-plus headless AT THE CLIENT'S mode (so games see the
|
# As a `systemctl --user` service the host inherits the correct XDG_RUNTIME_DIR from systemd and
|
||||||
# client's exact resolution + refresh, not the box's TV), and relaunches it when the mode
|
# derives the bus (`unix:path=$XDG_RUNTIME_DIR/bus`) itself. Set these ONLY when running the host
|
||||||
# changes. Requires the headless-appliance prereqs (linger + multi-user.target — see
|
# outside a user service (ssh, cron) — and with YOUR uid (`id -u`), never a copy-pasted 1000: a
|
||||||
# punktfunk-steam-session.service header) and NO physical gaming session running.
|
# wrong uid points the host at another user's (nonexistent) PipeWire/D-Bus, and every session
|
||||||
#PUNKTFUNK_COMPOSITOR=gamescope
|
# fails with errors like "pw audio connect … Creation failed".
|
||||||
#PUNKTFUNK_GAMESCOPE_SESSION=steam # host owns a gamescope-session-plus session at the client mode
|
#XDG_RUNTIME_DIR=/run/user/<uid>
|
||||||
#PUNKTFUNK_INPUT_BACKEND=gamescope
|
#DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/<uid>/bus
|
||||||
# Mutually exclusive with the above: ATTACH to a gamescope session something ELSE owns (fixed mode):
|
|
||||||
#PUNKTFUNK_GAMESCOPE_NODE=auto # discover + capture a running gamescope (do NOT combine with SESSION)
|
|
||||||
|
|
||||||
# --- GNOME / Mutter host (e.g. an Ubuntu desktop) -----------------------------------------
|
# --- Steam Gaming Mode (Linux boxes with gamescope session infra: Bazzite/SteamOS/Nobara) ---
|
||||||
# Attach to a running GNOME (Wayland) session — its default socket is wayland-0, not wayland-kde.
|
# Game Mode is auto-handled; two models decide WHERE it runs when a client streams:
|
||||||
# Mutter creates the per-client virtual output via its `RecordVirtual` D-Bus API (a virtual
|
# * MANAGED (the default where session infra is detected) — the host relaunches the gaming
|
||||||
# monitor alongside any real one), and input goes through the RemoteDesktop portal (libei). On a
|
# session HEADLESS at the CLIENT's exact mode ("game mode on the virtual screen"); physical
|
||||||
# real desktop the host runs as the logged-in user; headless GNOME also works (gnome-shell
|
# displays drop out of it, and the box is restored on a debounced idle after disconnect.
|
||||||
# --headless). Needs GNOME ≥ 48 for the zero-copy RecordVirtual path.
|
# * ATTACH — the BOX owns its session: Game Mode stays on the physical screen and the host
|
||||||
#WAYLAND_DISPLAY=wayland-0
|
# captures/follows it, never tearing it down. Reconnects land wherever the box is.
|
||||||
#XDG_CURRENT_DESKTOP=GNOME
|
#PUNKTFUNK_GAMESCOPE_ATTACH=1 # pick the ATTACH model
|
||||||
#PUNKTFUNK_COMPOSITOR=mutter
|
#PUNKTFUNK_GAMESCOPE_MANAGED=1 # force MANAGED even where infra detection wouldn't pick it
|
||||||
#PUNKTFUNK_VIDEO_SOURCE=virtual
|
#PUNKTFUNK_GAMESCOPE_SESSION=steam # host owns a gamescope-session-plus session at the client mode
|
||||||
#PUNKTFUNK_INPUT_BACKEND=libei
|
#PUNKTFUNK_GAMESCOPE_NODE=auto # raw attach: discover + capture a running gamescope's node
|
||||||
|
# # (do NOT combine with SESSION)
|
||||||
|
#PUNKTFUNK_GAMESCOPE_APP=vkcube # nested command for ad-hoc bare-gamescope sessions
|
||||||
|
#PUNKTFUNK_SESSION_WATCH=0 # disable mid-stream Desktop<->Game following (on by default
|
||||||
|
# # on gamescope-infra boxes)
|
||||||
|
|
||||||
|
# --- Force a backend (CI / tests / dedicated single-session appliances ONLY) ---------------
|
||||||
|
# PINS the backend: the host stops following the live session entirely — per connect AND
|
||||||
|
# mid-stream. Fine for a dedicated headless appliance (punktfunk-kde-session.service, a pure
|
||||||
|
# gamescope box) or a CI run; wrong for any box that switches sessions.
|
||||||
|
#PUNKTFUNK_COMPOSITOR=kwin # kwin | mutter | gamescope | wlroots | hyprland
|
||||||
|
#PUNKTFUNK_INPUT_BACKEND=libei # wlr | libei | gamescope | uinput (auto-routed per connect)
|
||||||
|
#WAYLAND_DISPLAY=wayland-kde # headless-KDE appliance socket; retargeted per connect otherwise
|
||||||
|
#XDG_CURRENT_DESKTOP=KDE
|
||||||
|
|
||||||
# Optional overrides (apps.json is the primary mechanism for per-app settings):
|
# Optional overrides (apps.json is the primary mechanism for per-app settings):
|
||||||
#PUNKTFUNK_COMPOSITOR=kwin # kwin | mutter | gamescope | wlroots
|
|
||||||
#PUNKTFUNK_GAMESCOPE_APP=vkcube # nested command for ad-hoc bare-gamescope sessions
|
|
||||||
#PUNKTFUNK_INPUT_BACKEND=libei # wlr | libei | gamescope | uinput
|
|
||||||
#PUNKTFUNK_FEC_PCT=20 # video FEC overhead percent
|
#PUNKTFUNK_FEC_PCT=20 # video FEC overhead percent
|
||||||
#PUNKTFUNK_PERF=1 # per-stage timing logs
|
#PUNKTFUNK_PERF=1 # per-stage timing logs
|
||||||
#PUNKTFUNK_MDNS=0 # disable the mDNS adverts (native + GameStream) — for multicast-
|
#PUNKTFUNK_MDNS=0 # disable the mDNS adverts (native + GameStream) — for multicast-
|
||||||
|
|||||||
@@ -2,15 +2,18 @@
|
|||||||
# GameStream/Moonlight-compat planes). For a SECURE native-only host (no plain-HTTP pairing / legacy
|
# GameStream/Moonlight-compat planes). For a SECURE native-only host (no plain-HTTP pairing / legacy
|
||||||
# GCM nonce reuse — security-review #5/#9; native clients only), drop `--gamestream` from ExecStart.
|
# GCM nonce reuse — security-review #5/#9; native clients only), drop `--gamestream` from ExecStart.
|
||||||
#
|
#
|
||||||
# Install (against an already-running compositor session):
|
# Install (against an already-running compositor session — the host auto-detects and follows it,
|
||||||
|
# so host.env needs no backend config):
|
||||||
# mkdir -p ~/.config/systemd/user && cp scripts/punktfunk-host.service ~/.config/systemd/user/
|
# mkdir -p ~/.config/systemd/user && cp scripts/punktfunk-host.service ~/.config/systemd/user/
|
||||||
# cp scripts/host.env.example ~/.config/punktfunk/host.env # then edit for your backend
|
# cp scripts/host.env.example ~/.config/punktfunk/host.env # defaults are right for a desktop
|
||||||
# systemctl --user daemon-reload && systemctl --user enable --now punktfunk-host
|
# systemctl --user daemon-reload && systemctl --user enable --now punktfunk-host
|
||||||
#
|
#
|
||||||
# Self-contained boot appliance (no login, no manual steps after boot):
|
# Self-contained boot appliance (no login, no manual steps after boot). These routes PIN the
|
||||||
|
# backend via PUNKTFUNK_COMPOSITOR — correct for a dedicated single-session box, but it turns off
|
||||||
|
# live-session auto-detection, so never do it on a desktop that switches sessions (Game Mode etc.):
|
||||||
# - kwin backend (stream the Plasma desktop): also install + enable
|
# - kwin backend (stream the Plasma desktop): also install + enable
|
||||||
# punktfunk-kde-session.service (it brings up the headless KWin session this After=s), and set
|
# punktfunk-kde-session.service (it brings up the headless KWin session this After=s), and use
|
||||||
# PUNKTFUNK_COMPOSITOR=kwin + WAYLAND_DISPLAY=wayland-kde in host.env.
|
# the shipped packaging/kde/host.env (pins kwin + WAYLAND_DISPLAY=wayland-kde on purpose).
|
||||||
# - gamescope backend (stream a nested app, no desktop): set PUNKTFUNK_COMPOSITOR=gamescope in
|
# - gamescope backend (stream a nested app, no desktop): set PUNKTFUNK_COMPOSITOR=gamescope in
|
||||||
# host.env — the host spawns gamescope per session, so no kde-session unit is needed.
|
# host.env — the host spawns gamescope per session, so no kde-session unit is needed.
|
||||||
# Then `sudo loginctl enable-linger "$USER"` so user units start at boot, and reboot.
|
# Then `sudo loginctl enable-linger "$USER"` so user units start at boot, and reboot.
|
||||||
|
|||||||
Reference in New Issue
Block a user