diff --git a/crates/pf-vdisplay/protocols/dpms.xml b/crates/pf-vdisplay/protocols/dpms.xml
new file mode 100644
index 00000000..79b7bbe6
--- /dev/null
+++ b/crates/pf-vdisplay/protocols/dpms.xml
@@ -0,0 +1,87 @@
+
+
+
+
+
+ The Dpms manager allows to get a org_kde_kwin_dpms for a given wl_output.
+ The org_kde_kwin_dpms provides the currently used VESA Display Power Management
+ Signaling state (see https://en.wikipedia.org/wiki/VESA_Display_Power_Management_Signaling ).
+ In addition it allows to request a state change. A compositor is not obliged to honor it
+ and will normally automatically switch back to on state.
+
+ Warning! The protocol described in this file is a desktop environment
+ implementation detail. Regular clients must not use this protocol.
+ Backward incompatible changes may be added without bumping the major
+ version of the extension.
+
+
+
+ Factory request to get the org_kde_kwin_dpms for a given wl_output.
+
+
+
+
+
+
+
+ This interface provides information about the VESA DPMS state for a wl_output.
+ It gets created through the request get on the org_kde_kwin_dpms_manager interface.
+
+ On creating the resource the server will push whether DPSM is supported for the output,
+ the currently used DPMS state and notifies the client through the done event once all
+ states are pushed. Whenever a state changes the set of changes is committed with the
+ done event.
+
+
+
+ This event gets pushed on binding the resource and indicates whether the wl_output
+ supports DPMS. There are operation modes of a Wayland server where DPMS might not
+ make sense (e.g. nested compositors).
+
+
+
+
+
+
+
+
+
+
+
+ This mode gets pushed on binding the resource and provides the currently used
+ DPMS mode. It also gets pushed if DPMS is not supported for the wl_output, in that
+ case the value will be On.
+
+ The event is also pushed whenever the state changes.
+
+
+
+
+
+ This event gets pushed on binding the resource once all other states are pushed.
+
+ In addition it gets pushed whenever a state changes to tell the client that all
+ state changes have been pushed.
+
+
+
+
+ Requests that the compositor puts the wl_output into the passed mode. The compositor
+ is not obliged to change the state. In addition the compositor might leave the mode
+ whenever it seems suitable. E.g. the compositor might return to On state on user input.
+
+ The client should not assume that the mode changed after requesting a new mode.
+ Instead the client should listen for the mode event.
+
+
+
+
+
+
+
+
+
diff --git a/crates/pf-vdisplay/src/lib.rs b/crates/pf-vdisplay/src/lib.rs
index 1b39dc32..f51bc2d7 100644
--- a/crates/pf-vdisplay/src/lib.rs
+++ b/crates/pf-vdisplay/src/lib.rs
@@ -848,6 +848,15 @@ mod kwin;
#[path = "vdisplay/linux/kwin_output_mgmt.rs"]
mod kwin_output_mgmt;
+// DPMS control of the box's live KDE desktop (org_kde_kwin_dpms) — how a bare-spawn gamescope
+// session honors `Topology::Exclusive`: the spawn is its own headless compositor, so the desktop's
+// physical outputs can't be *disabled* (KWin refuses zero enabled outputs and no output there is
+// ours) — they are put to DPMS-off for the stream instead, refcounted across concurrent spawns.
+// Consumed by `gamescope` (best-effort, with kscreen fallback).
+#[cfg(target_os = "linux")]
+#[path = "vdisplay/linux/kwin_dpms.rs"]
+mod kwin_dpms;
+
#[cfg(target_os = "windows")]
#[path = "vdisplay/windows/manager.rs"]
pub mod manager;
diff --git a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs
index a36e25e1..51c83b39 100644
--- a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs
+++ b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs
@@ -69,6 +69,11 @@ pub struct GamescopeDisplay {
/// the decision and this session's `create`. `None` = nothing resolved it (a caller that never
/// ran `apply_input_env`); `create` then falls through to the bare spawn, the safe default.
route: Option,
+ /// The topology-restore action the bare-spawn `create` prepared under `Topology::Exclusive` —
+ /// the release of this display's [`crate::kwin_dpms`] darken hold — pending pickup by the
+ /// registry via [`VirtualDisplay::take_topology_restore`], so it runs at the display's
+ /// teardown (§6.1) and never before.
+ pending_restore: Option>,
}
/// A running host-managed session (its transient systemd --user unit) + the mode it was launched at.
@@ -441,6 +446,14 @@ impl VirtualDisplay for GamescopeDisplay {
self.route = route;
}
+ fn take_topology_restore(&mut self) -> Option> {
+ // The DPMS darken-hold release the bare-spawn `create` registered (Exclusive topology
+ // only). The registry stores it on this display's entry and runs it at teardown — which,
+ // for gamescope, is the display's OWN teardown: every spawn is its own group, and the
+ // cross-session ordering lives in `kwin_dpms`'s refcount, not in the group float.
+ self.pending_restore.take()
+ }
+
fn poolable_now(&self) -> bool {
// Only a bare SPAWN is registry-poolable (its `create` reports `Owned`); Managed and
// Attach report `SessionManaged`/`External`, so the registry must not reuse a kept spawn
@@ -576,6 +589,23 @@ impl VirtualDisplay for GamescopeDisplay {
hz = mode.refresh_hz,
"gamescope virtual output ready"
);
+ // `Topology::Exclusive`, bare-spawn edition: this spawn is its OWN headless compositor —
+ // nothing above touched the box's live desktop (KWin), which would otherwise keep driving
+ // the physical panel with the idle desktop for the whole stream. The KWin route disables
+ // the physicals outright, but that door is closed here (KWin refuses zero enabled outputs,
+ // and no output on that desktop is ours to leave enabled) — so the desktop's panels go to
+ // DPMS-off instead, best-effort and self-gating (a box with no KDE desktop declines
+ // quietly inside `kwin_dpms`). Placed AFTER the spawn succeeded, so a failed create never
+ // blanks the user's screen. The hold is refcounted in `kwin_dpms` rather than floated
+ // through the registry's group restore, because every gamescope spawn is its own group
+ // (`registry::group_key`) — the float alone would re-light the panel when the FIRST of two
+ // concurrent spawns ends, under the second's still-live stream. Skipped for Managed (its
+ // takeover already stopped the desktop) and Attach (it mirrors a gamescope that may itself
+ // be driving the physical panel) — both returned earlier in this function.
+ if crate::effective_topology() == crate::policy::Topology::Exclusive {
+ crate::kwin_dpms::acquire_stream_darken();
+ self.pending_restore = Some(Box::new(crate::kwin_dpms::release_stream_darken));
+ }
// Bare SPAWN: we own the nested gamescope process → registry-poolable (keep-alive-able).
Ok(VirtualOutput::owned(
node_id,
diff --git a/crates/pf-vdisplay/src/vdisplay/linux/kwin.rs b/crates/pf-vdisplay/src/vdisplay/linux/kwin.rs
index 817aa0c6..51f9aaa6 100644
--- a/crates/pf-vdisplay/src/vdisplay/linux/kwin.rs
+++ b/crates/pf-vdisplay/src/vdisplay/linux/kwin.rs
@@ -704,7 +704,7 @@ fn kscreen_ok(args: &[String]) -> bool {
/// before exiting, so a slow-but-working KWin gives us a kill on a request that already landed;
/// any caller that treats `None` as "it failed" is asserting something it does not know, and for
/// the restore path that assertion costs a monitor its refresh rate.
-fn kscreen_verdict(args: &[String]) -> Option {
+pub(crate) fn kscreen_verdict(args: &[String]) -> Option {
match crate::proc::status_within(
std::process::Command::new("kscreen-doctor").args(args),
KSCREEN_BUDGET,
diff --git a/crates/pf-vdisplay/src/vdisplay/linux/kwin_dpms.rs b/crates/pf-vdisplay/src/vdisplay/linux/kwin_dpms.rs
new file mode 100644
index 00000000..6738b0d9
--- /dev/null
+++ b/crates/pf-vdisplay/src/vdisplay/linux/kwin_dpms.rs
@@ -0,0 +1,675 @@
+//! DPMS control of the box's live KDE desktop (`org_kde_kwin_dpms`) — how a bare-spawn gamescope
+//! session honors [`Topology::Exclusive`](crate::policy::Topology::Exclusive).
+//!
+//! A bare spawn is its OWN headless compositor: nothing on that route touches the desktop the box
+//! is showing, so on a KDE machine the physical panel keeps displaying the (idle) desktop for the
+//! whole stream — while the same `exclusive` policy on the KWin route turns the physicals off
+//! outright. The KWin route's mechanism is closed to us here: KWin refuses an output configuration
+//! with ZERO enabled outputs, and a gamescope session has no KWin output of its own to leave
+//! enabled. DPMS is the honest translation of `exclusive` for this route — the desktop stays
+//! exactly where it is (no topology churn, no window re-homing), the panels go dark, and any
+//! LOCAL input wakes them, which is the right answer for a desktop someone can walk up to.
+//! Stream input never wakes them: it is injected into the nested gamescope's own EIS socket and
+//! does not pass through KWin.
+//!
+//! Driven in-process over the compositor's own Wayland (`Connection::connect_to_env`, the same
+//! stack as [`crate::kwin_output_mgmt`] and for the same reason: `kscreen-doctor` rides a separate
+//! libkscreen/KDED layer that can be wedged while KWin itself answers fine), with a
+//! `kscreen-doctor --dpms` shell-out fallback. Best-effort everywhere — a box with no Wayland
+//! session, or a non-KDE desktop, declines quietly and the stream proceeds with the panel lit,
+//! exactly as before this module existed.
+//!
+//! **The hold is refcounted here, NOT floated through the registry's per-group restore.** Every
+//! gamescope spawn is its own display group (`registry::group_key` — deliberately, they are
+//! independent nested sessions), so the §6.1 group machinery alone would run the FIRST session's
+//! restore at that session's teardown and re-light the panel under a second, still-streaming
+//! session. Instead each exclusive spawn takes one [`acquire_stream_darken`] hold (the 0→1 edge
+//! darkens) and registers [`release_stream_darken`] as its per-display topology restore (the 1→0
+//! edge re-lights) — the same shape as `sleep_inhibit`'s refcount, riding the registry only for
+//! the *timing* of each release.
+//!
+//! Crash safety comes free: DPMS is non-persistent, so a host that dies holding the panel dark
+//! leaves nothing to journal — the screen re-lights on the next local input or compositor
+//! restart. (Contrast the Windows `pnp_disable_monitors` path, which needs a recovery journal
+//! precisely because its disable survives everything.)
+
+use std::collections::HashMap;
+use std::os::fd::{AsFd, AsRawFd};
+use std::sync::Mutex;
+use std::time::{Duration, Instant};
+use wayland_client::protocol::wl_callback::{self, WlCallback};
+use wayland_client::protocol::wl_output::{self, WlOutput};
+use wayland_client::protocol::wl_registry::{self, WlRegistry};
+use wayland_client::{Connection, Dispatch, Proxy, QueueHandle};
+
+// Client bindings for the vendored KDE dpms protocol (`protocols/dpms.xml`), generated inline like
+// the two in `kwin_output_mgmt`. Self-contained: its only foreign object type is the core
+// `wl_output`, which `wayland_client::protocol` already provides.
+#[allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)]
+pub mod protocol {
+ use wayland_client;
+ use wayland_client::protocol::*;
+
+ pub mod __interfaces {
+ use wayland_client::protocol::__interfaces::*;
+ wayland_scanner::generate_interfaces!("protocols/dpms.xml");
+ }
+ use self::__interfaces::*;
+
+ wayland_scanner::generate_client_code!("protocols/dpms.xml");
+}
+
+use protocol::org_kde_kwin_dpms::{Event as DpmsEvent, OrgKdeKwinDpms as Dpms};
+use protocol::org_kde_kwin_dpms_manager::OrgKdeKwinDpmsManager as DpmsManager;
+
+// The wire enum `org_kde_kwin_dpms.mode`. The XML types the `mode` request/event args as plain
+// `uint` (no `enum=` attribute), so the generated signatures take/deliver `u32` — these constants
+// are the protocol's values, kept in sync with the vendored `dpms.xml`.
+const DPMS_MODE_ON: u32 = 0;
+const DPMS_MODE_OFF: u32 = 3;
+
+/// `org_kde_kwin_dpms_manager` is a frozen v1 protocol (its own header warns it may change
+/// without a version bump, but no v2 has appeared since 2015); bind `min(advertised, 1)`.
+const MANAGER_MAX: u32 = 1;
+/// `wl_output.name` — the connector name used for logging — arrived in v4. Everything else we do
+/// works at v1, so a lower advert just costs the log its names.
+const WL_OUTPUT_MAX: u32 = 4;
+
+/// Overall budget for one darken/re-light operation (mirrors `kwin_output_mgmt::OP_BUDGET`):
+/// generous next to a healthy roundtrip, and only there so a wedged compositor can't pin the
+/// session-create (or group-teardown) thread.
+const OP_BUDGET: Duration = Duration::from_secs(3);
+
+/// Poll slice while waiting on the Wayland fd (matches `kwin_output_mgmt`).
+const POLL_MS: i32 = 100;
+
+/// One output's accumulated state on this connection, keyed by its `wl_output` global name.
+#[derive(Default)]
+struct OutputState {
+ proxy: Option,
+ /// Connector name (`DP-1`) from `wl_output.name` (v4) — logging only; the global number is
+ /// the address everything operates on.
+ connector: Option,
+ dpms: Option,
+ /// `org_kde_kwin_dpms.supported` — `None` until the bind burst arrives.
+ supported: Option,
+ /// The last `org_kde_kwin_dpms.mode` seen — kept current, so the post-`set` wait can watch it
+ /// flip.
+ mode: Option,
+}
+
+/// Everything one connection's queue accumulates.
+#[derive(Default)]
+struct State {
+ manager: Option,
+ /// Keyed by the `wl_output` GLOBAL NAME — a stable address for the compositor's lifetime, and
+ /// the identity the darken records so the re-light (a separate, later connection) can find the
+ /// same outputs again.
+ outputs: HashMap,
+ /// Highest `wl_callback` serial whose `done` has arrived — the barrier the pump waits on.
+ sync_done: u32,
+}
+
+impl Dispatch for State {
+ fn event(
+ state: &mut Self,
+ registry: &WlRegistry,
+ event: wl_registry::Event,
+ _: &(),
+ _: &Connection,
+ qh: &QueueHandle,
+ ) {
+ match event {
+ wl_registry::Event::Global {
+ name,
+ interface,
+ version,
+ } => {
+ if interface == DpmsManager::interface().name {
+ let v = version.min(MANAGER_MAX);
+ state.manager = Some(registry.bind::(name, v, qh, ()));
+ } else if interface == WlOutput::interface().name {
+ let v = version.min(WL_OUTPUT_MAX);
+ // The global name rides in the UserData so the output's own events (and the
+ // dpms object's, which gets the same stamp) can find this entry.
+ let out = registry.bind::(name, v, qh, name);
+ state.outputs.entry(name).or_default().proxy = Some(out);
+ }
+ }
+ // An output unplugged mid-operation: drop the entry so we never `set` on its corpse.
+ wl_registry::Event::GlobalRemove { name } => {
+ state.outputs.remove(&name);
+ }
+ _ => {}
+ }
+ }
+}
+
+impl Dispatch for State {
+ fn event(
+ state: &mut Self,
+ _: &WlOutput,
+ event: wl_output::Event,
+ global: &u32,
+ _: &Connection,
+ _: &QueueHandle,
+ ) {
+ if let wl_output::Event::Name { name } = event {
+ if let Some(o) = state.outputs.get_mut(global) {
+ o.connector = Some(name);
+ }
+ }
+ }
+}
+
+impl Dispatch for State {
+ fn event(
+ state: &mut Self,
+ _: &Dpms,
+ event: DpmsEvent,
+ global: &u32,
+ _: &Connection,
+ _: &QueueHandle,
+ ) {
+ let Some(o) = state.outputs.get_mut(global) else {
+ return;
+ };
+ match event {
+ DpmsEvent::Supported { supported } => o.supported = Some(supported != 0),
+ DpmsEvent::Mode { mode } => o.mode = Some(mode),
+ DpmsEvent::Done => {}
+ }
+ }
+}
+
+// The manager has no events; the impl exists because `WlRegistry::bind` demands one.
+impl Dispatch for State {
+ fn event(
+ _: &mut Self,
+ _: &DpmsManager,
+ _: protocol::org_kde_kwin_dpms_manager::Event,
+ _: &(),
+ _: &Connection,
+ _: &QueueHandle,
+ ) {
+ }
+}
+
+impl Dispatch for State {
+ fn event(
+ state: &mut Self,
+ _: &WlCallback,
+ event: wl_callback::Event,
+ serial: &u32,
+ _: &Connection,
+ _: &QueueHandle,
+ ) {
+ if let wl_callback::Event::Done { .. } = event {
+ state.sync_done = state.sync_done.max(*serial);
+ }
+ }
+}
+
+/// Why [`Session::open`] declined — the same honest-decline discipline as
+/// `kwin_output_mgmt::OpenFailure`: which rung said no decides both the log level and whether the
+/// `kscreen-doctor` fallback is worth attempting.
+enum OpenFailure {
+ /// No Wayland connection at all (`WAYLAND_DISPLAY` unset/stale). The common case for the bare
+ /// spawn's natural habitat — a headless plain-distro box with no desktop to darken.
+ Connect(String),
+ /// The compositor accepted the connection but did not answer the registry barrier in budget:
+ /// a live but wedged session — the case the shell-out fallback exists for.
+ RegistryBarrier,
+ /// Connected and answering, but `org_kde_kwin_dpms_manager` is not advertised — not KWin. A
+ /// definitive answer: no fallback can succeed here either (`kscreen-doctor` drives the same
+ /// KDE-only machinery), so this rung declines without one.
+ NoDpmsGlobal,
+ /// The manager is there but the per-output DPMS state bursts never completed in budget.
+ StateBarrier,
+}
+
+impl std::fmt::Display for OpenFailure {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ OpenFailure::Connect(e) => write!(f, "no Wayland connection ({e})"),
+ OpenFailure::RegistryBarrier => {
+ write!(
+ f,
+ "the compositor did not answer the registry roundtrip in budget"
+ )
+ }
+ OpenFailure::NoDpmsGlobal => {
+ write!(f, "org_kde_kwin_dpms_manager is not advertised (not KWin)")
+ }
+ OpenFailure::StateBarrier => {
+ write!(
+ f,
+ "the outputs' DPMS state never finished announcing in budget"
+ )
+ }
+ }
+ }
+}
+
+/// A connected session with the manager bound and every output's DPMS state read.
+struct Session {
+ conn: Connection,
+ queue: wayland_client::EventQueue,
+ state: State,
+ next_sync: u32,
+}
+
+impl Session {
+ /// [`Session::connect`] for the operation named by `op`, logging the decline at a level that
+ /// matches what it means: `Connect`/`NoDpmsGlobal` are the everyday non-KDE answers (most
+ /// bare-spawn boxes have no desktop at all) and log at debug; the two barrier failures mean a
+ /// LIVE session stopped answering — on a KDE box that is a panel left lit, so they warn.
+ fn open(op: &'static str) -> Result {
+ let opened = Session::connect();
+ if let Err(reason) = &opened {
+ match reason {
+ OpenFailure::Connect(_) | OpenFailure::NoDpmsGlobal => {
+ tracing::debug!(op, %reason, "KWin DPMS unavailable");
+ }
+ OpenFailure::RegistryBarrier | OpenFailure::StateBarrier => {
+ tracing::warn!(
+ op,
+ %reason,
+ "KWin DPMS: in-process path unavailable — falling back to kscreen-doctor"
+ );
+ }
+ }
+ }
+ opened
+ }
+
+ /// Connect to the desktop's Wayland socket, bind the dpms manager + every `wl_output`, create
+ /// a dpms status object per output and drain their state bursts — all bounded by [`OP_BUDGET`].
+ fn connect() -> Result {
+ let conn = Connection::connect_to_env().map_err(|e| OpenFailure::Connect(e.to_string()))?;
+ let queue = conn.new_event_queue();
+ let qh = queue.handle();
+ let _registry = conn.display().get_registry(&qh, ());
+ let mut s = Session {
+ conn,
+ queue,
+ state: State::default(),
+ next_sync: 0,
+ };
+ let deadline = Instant::now() + OP_BUDGET;
+ // Phase 1: process the registry globals (binds the manager + every wl_output).
+ if !s.sync_barrier(deadline) {
+ return Err(OpenFailure::RegistryBarrier);
+ }
+ let Some(mgr) = s.state.manager.clone() else {
+ return Err(OpenFailure::NoDpmsGlobal);
+ };
+ // Phase 2: one dpms status object per output (stamped with the output's global name so its
+ // events land on the right entry), then a barrier that drains both the outputs' `name`
+ // events and the dpms objects' supported/mode/done bursts.
+ let qh = s.queue.handle();
+ let bound: Vec<(u32, WlOutput)> = s
+ .state
+ .outputs
+ .iter()
+ .filter_map(|(g, o)| o.proxy.clone().map(|p| (*g, p)))
+ .collect();
+ for (global, out) in bound {
+ let d = mgr.get(&out, &qh, global);
+ if let Some(o) = s.state.outputs.get_mut(&global) {
+ o.dpms = Some(d);
+ }
+ }
+ if !s.sync_barrier(deadline) {
+ return Err(OpenFailure::StateBarrier);
+ }
+ Ok(s)
+ }
+
+ /// Send a `wl_display.sync` and pump the queue until its `done` arrives or `deadline` passes.
+ fn sync_barrier(&mut self, deadline: Instant) -> bool {
+ self.next_sync += 1;
+ let serial = self.next_sync;
+ let qh = self.queue.handle();
+ let _cb = self.conn.display().sync(&qh, serial);
+ self.pump_until(deadline, |st| st.sync_done >= serial)
+ }
+
+ /// Bounded manual event loop — flush, dispatch, poll the fd. Mirrors
+ /// `kwin_output_mgmt::Session::pump_until` (same rationale: `blocking_dispatch` can't be
+ /// interrupted, so the fd is polled in [`POLL_MS`] slices against `deadline`).
+ fn pump_until(&mut self, deadline: Instant, done: impl Fn(&State) -> bool) -> bool {
+ loop {
+ if done(&self.state) {
+ return true;
+ }
+ if self.queue.dispatch_pending(&mut self.state).is_err() {
+ return false;
+ }
+ if done(&self.state) {
+ return true;
+ }
+ if Instant::now() >= deadline {
+ return false;
+ }
+ if self.conn.flush().is_err() {
+ return false;
+ }
+ let Some(guard) = self.conn.prepare_read() else {
+ continue; // events already queued — loop dispatches them
+ };
+ let mut pfd = libc::pollfd {
+ fd: self.conn.as_fd().as_raw_fd(),
+ events: libc::POLLIN,
+ revents: 0,
+ };
+ let remaining = deadline.saturating_duration_since(Instant::now());
+ let timeout = (remaining.as_millis() as i32).clamp(0, POLL_MS);
+ // SAFETY: `&mut pfd` points at one live, fully-initialized `libc::pollfd` on the stack
+ // and the count `1` matches that single element, so `poll` reads `fd`/`events` and
+ // writes `revents` strictly within `pfd`. `pfd.fd` is the Wayland connection's fd,
+ // valid because `self.conn` (and the `prepare_read` guard) outlive the call. `poll`
+ // blocks up to `timeout` ms and writes only `revents`; `pfd` is a fresh local that
+ // aliases nothing.
+ let r = unsafe { libc::poll(&mut pfd, 1, timeout) };
+ if r > 0 && (pfd.revents & libc::POLLIN) != 0 {
+ let _ = guard.read();
+ } // else: timeout/signal — drop the guard, re-check the deadline
+ }
+ }
+
+ /// Request `target` on every DPMS-supporting output not already there — restricted to the
+ /// globals in `only` when given (the re-light path, which must touch ONLY what the darken
+ /// touched: a panel the USER had put to sleep before the stream is theirs to keep dark).
+ /// Returns the outputs actually asked to change, `(global, connector)`, then waits (within
+ /// budget) for each one's `mode` event to confirm — the protocol is explicit that `set` is a
+ /// request the compositor may decline, so the confirmation is watched and its absence logged
+ /// rather than assumed.
+ fn set_mode(&mut self, target: u32, only: Option<&[u32]>) -> Vec<(u32, Option)> {
+ let deadline = Instant::now() + OP_BUDGET;
+ let mut touched: Vec<(u32, Option)> = Vec::new();
+ for (global, o) in &self.state.outputs {
+ if only.is_some_and(|list| !list.contains(global)) {
+ continue;
+ }
+ if o.supported != Some(true) || o.mode == Some(target) {
+ continue;
+ }
+ if let Some(dpms) = &o.dpms {
+ dpms.set(target);
+ touched.push((*global, o.connector.clone()));
+ }
+ }
+ if touched.is_empty() {
+ return touched;
+ }
+ let want: Vec = touched.iter().map(|(g, _)| *g).collect();
+ // An output that vanished mid-wait (GlobalRemove pruned it) counts as settled — there is
+ // nothing left to flip.
+ let confirmed = self.pump_until(deadline, |st| {
+ want.iter()
+ .all(|g| st.outputs.get(g).is_none_or(|o| o.mode == Some(target)))
+ });
+ if !confirmed {
+ tracing::warn!(
+ outputs = ?touched,
+ target,
+ "KWin DPMS: the compositor did not confirm the mode change in budget (the \
+ requests are flushed; it may still land, or KWin may have declined)"
+ );
+ }
+ touched
+ }
+}
+
+/// What the 0→1 darken actually achieved — the record the 1→0 re-light undoes. Which arm did the
+/// work matters: the two are undone through different doors.
+enum Darkened {
+ /// The in-process path turned these outputs off — `(wl_output global, connector)`. Global
+ /// names are stable for the compositor's lifetime, so a later connection re-lights exactly
+ /// these. If KWin restarted in between the names match nothing — and that is the CORRECT
+ /// no-op, because a fresh KWin brings its outputs up lit anyway.
+ Wayland(Vec<(u32, Option)>),
+ /// The `kscreen-doctor --dpms off` fallback ran (it takes no per-output address, so the
+ /// re-light is the symmetric `--dpms on`).
+ Kscreen,
+}
+
+/// The host-wide darken hold — refcounted like `sleep_inhibit`: the 0→1 edge darkens, the 1→0
+/// edge re-lights, and everything between is bookkeeping. See the module docs for why the
+/// registry's per-group restore float can't provide this (every gamescope spawn is its own group).
+struct Holds {
+ count: u32,
+ /// What the 0→1 darken achieved, held until the 1→0 release undoes it. `None` while count > 0
+ /// means the darken found nothing to do (no KDE, panels already dark) — the release then has
+ /// nothing to undo, which is exactly right.
+ darkened: Option,
+}
+
+impl Holds {
+ /// Take a hold; `true` on the 0→1 edge — the caller darkens and [`record`](Self::record)s.
+ fn acquire_edge(&mut self) -> bool {
+ self.count += 1;
+ self.count == 1
+ }
+
+ /// Store the 0→1 darken's outcome.
+ fn record(&mut self, d: Option) {
+ self.darkened = d;
+ }
+
+ /// Drop a hold; `Some` on the 1→0 edge hands the caller the record to undo. A release with no
+ /// hold outstanding is a caller bug (an unbalanced restore) — logged, never underflowed.
+ fn release_edge(&mut self) -> Option {
+ if self.count == 0 {
+ tracing::warn!("KWin DPMS: release without a matching acquire (unbalanced restore)");
+ return None;
+ }
+ self.count -= 1;
+ if self.count == 0 {
+ self.darkened.take()
+ } else {
+ None
+ }
+ }
+}
+
+static HOLDS: Mutex = Mutex::new(Holds {
+ count: 0,
+ darkened: None,
+});
+
+/// Take one darken hold for an exclusive-topology stream. The first hold turns the live KDE
+/// desktop's panels off (best-effort, bounded); later holds just count. Callers MUST balance each
+/// call with [`release_stream_darken`] — the gamescope backend does it by registering the release
+/// as the display's topology restore, so the registry runs it exactly once per display at
+/// teardown (§6.1).
+///
+/// The lock is deliberately held across the darken itself: a racing second acquire must queue
+/// behind it (and then see the recorded outcome), not observe a count of 2 with nothing darkened.
+/// Same discipline on the release side, which keeps a teardown-overlapping-connect sequence
+/// strictly ordered: re-light completes, then the new stream's darken runs.
+pub fn acquire_stream_darken() {
+ let mut h = HOLDS.lock().unwrap_or_else(|e| e.into_inner());
+ if h.acquire_edge() {
+ let d = darken();
+ h.record(d);
+ }
+}
+
+/// Drop one darken hold; the last one out re-lights whatever the first hold's darken achieved.
+pub fn release_stream_darken() {
+ let mut h = HOLDS.lock().unwrap_or_else(|e| e.into_inner());
+ if let Some(d) = h.release_edge() {
+ relight(d);
+ }
+}
+
+/// The 0→1 darken: in-process over `org_kde_kwin_dpms` first, `kscreen-doctor --dpms off` as the
+/// wedged-compositor fallback. `None` = nothing was darkened (no desktop, not KDE, panels already
+/// off, or every arm declined) — and therefore nothing to restore.
+fn darken() -> Option {
+ match Session::open("darken") {
+ Ok(mut s) => {
+ let touched = s.set_mode(DPMS_MODE_OFF, None);
+ if touched.is_empty() {
+ tracing::debug!(
+ "KWin DPMS: no output to darken (none supported, or all already off)"
+ );
+ None
+ } else {
+ tracing::info!(
+ outputs = ?touched,
+ "KWin DPMS: desktop outputs off for the exclusive gamescope stream"
+ );
+ Some(Darkened::Wayland(touched))
+ }
+ }
+ // Definitive "not KDE" / "no desktop": no fallback can do better (kscreen-doctor drives
+ // the same KDE-only machinery), so decline quietly — already logged by `open`.
+ Err(OpenFailure::NoDpmsGlobal) | Err(OpenFailure::Connect(_)) => None,
+ // A live session that stopped answering: the standalone tool rides a different stack
+ // (libkscreen/KDED) and may still get through — the same rationale as `kwin.rs`'s
+ // kscreen fallbacks, honest-verdict discipline included.
+ Err(_) => match kscreen_dpms("off") {
+ Some(true) => {
+ tracing::info!(
+ "KWin DPMS: desktop outputs off for the exclusive gamescope stream \
+ (kscreen-doctor fallback)"
+ );
+ Some(Darkened::Kscreen)
+ }
+ // Killed at its budget — NOT a refusal: kscreen-doctor applies first and then waits
+ // on the compositor, so a loaded KWin routinely lands the change and still gets
+ // killed. Record the darken so the teardown re-light runs either way; a `--dpms on`
+ // against a lit panel is a no-op.
+ None => Some(Darkened::Kscreen),
+ Some(false) => {
+ tracing::warn!(
+ "KWin DPMS: could not darken the desktop outputs for the exclusive topology \
+ (in-process path and kscreen-doctor both declined) — the panel stays lit"
+ );
+ None
+ }
+ },
+ }
+}
+
+/// The 1→0 re-light. **This is the last line of defence for a dark monitor**, so every arm that
+/// gives up says so loudly (the same discipline as `kwin.rs::reenable_outputs_kscreen`) — a dark
+/// panel with no line in the log is the failure mode this chain exists to prevent. The worst case
+/// stays self-healing regardless: DPMS is non-persistent, and any local input wakes the panel.
+fn relight(d: Darkened) {
+ match d {
+ Darkened::Wayland(outputs) => {
+ let globals: Vec = outputs.iter().map(|(g, _)| *g).collect();
+ match Session::open("re-light") {
+ Ok(mut s) => {
+ s.set_mode(DPMS_MODE_ON, Some(&globals));
+ tracing::info!(outputs = ?outputs, "KWin DPMS: desktop outputs back on");
+ }
+ Err(_) => match kscreen_dpms("on") {
+ Some(true) | None => {
+ tracing::info!(
+ "KWin DPMS: desktop outputs back on (kscreen-doctor fallback)"
+ );
+ }
+ Some(false) => {
+ tracing::error!(
+ outputs = ?outputs,
+ "KWin DPMS: could NOT re-light the desktop outputs (in-process \
+ restore and kscreen-doctor both declined) — the panel stays dark \
+ until local input wakes it"
+ );
+ }
+ },
+ }
+ }
+ Darkened::Kscreen => {
+ if kscreen_dpms("on") == Some(false) {
+ tracing::error!(
+ "KWin DPMS: could NOT re-light the desktop outputs (kscreen-doctor refused \
+ the --dpms on it earlier accepted the off for) — the panel stays dark until \
+ local input wakes it"
+ );
+ }
+ }
+ }
+}
+
+/// `kscreen-doctor --dpms ` for its verdict, on `kwin.rs`'s shared budget and three-state
+/// convention (`Some(true)` ran and succeeded, `Some(false)` refused or unrunnable, `None` killed
+/// at the budget — which, for a tool that applies first and waits after, usually means it landed).
+fn kscreen_dpms(mode: &'static str) -> Option {
+ crate::kwin::kscreen_verdict(&["--dpms".to_string(), mode.to_string()])
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{Darkened, Holds};
+
+ fn fresh() -> Holds {
+ Holds {
+ count: 0,
+ darkened: None,
+ }
+ }
+
+ #[test]
+ fn first_acquire_darkens_later_ones_count() {
+ let mut h = fresh();
+ assert!(h.acquire_edge(), "0→1 must darken");
+ h.record(Some(Darkened::Kscreen));
+ assert!(
+ !h.acquire_edge(),
+ "a second concurrent stream must not re-darken"
+ );
+ assert!(!h.acquire_edge());
+ }
+
+ #[test]
+ fn only_the_last_release_relights() {
+ let mut h = fresh();
+ assert!(h.acquire_edge());
+ h.record(Some(Darkened::Wayland(vec![(7, Some("DP-1".into()))])));
+ assert!(!h.acquire_edge());
+ // First release: a sibling still streams — the panel must stay dark.
+ assert!(h.release_edge().is_none());
+ // Last release hands back the record to undo.
+ let d = h.release_edge();
+ assert!(matches!(d, Some(Darkened::Wayland(v)) if v == vec![(7, Some("DP-1".into()))]));
+ }
+
+ #[test]
+ fn a_darken_that_did_nothing_restores_nothing() {
+ let mut h = fresh();
+ assert!(h.acquire_edge());
+ h.record(None); // no KDE / already dark: nothing was changed
+ assert!(h.release_edge().is_none(), "nothing to undo");
+ assert_eq!(h.count, 0);
+ }
+
+ #[test]
+ fn unbalanced_release_never_underflows() {
+ let mut h = fresh();
+ assert!(h.release_edge().is_none());
+ assert_eq!(h.count, 0, "count must not wrap");
+ // And the state machine still works afterwards.
+ assert!(h.acquire_edge());
+ h.record(Some(Darkened::Kscreen));
+ assert!(matches!(h.release_edge(), Some(Darkened::Kscreen)));
+ }
+
+ #[test]
+ fn a_full_cycle_rearms_the_darken() {
+ let mut h = fresh();
+ assert!(h.acquire_edge());
+ h.record(Some(Darkened::Kscreen));
+ assert!(h.release_edge().is_some());
+ // A later stream on the same host lifetime darkens again.
+ assert!(
+ h.acquire_edge(),
+ "the 0→1 edge must re-arm after a full cycle"
+ );
+ }
+}