diff --git a/Cargo.lock b/Cargo.lock
index 16f3d0c4..f7d20c89 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3024,6 +3024,7 @@ version = "0.19.1"
dependencies = [
"anyhow",
"ashpd",
+ "bitflags",
"bytemuck",
"futures-util",
"hex",
diff --git a/crates/pf-vdisplay/Cargo.toml b/crates/pf-vdisplay/Cargo.toml
index 55304ad5..1df4b04d 100644
--- a/crates/pf-vdisplay/Cargo.toml
+++ b/crates/pf-vdisplay/Cargo.toml
@@ -45,6 +45,9 @@ futures-util = "0.3"
wayland-client = "0.31"
wayland-scanner = "0.31"
wayland-backend = "0.3"
+# wayland-scanner emits `bitflags::bitflags!` for the KDE output-device protocol's bitfield enums
+# (kde-output-device-v2 `capability`/`flags`); needs the crate in scope (kwin_output_mgmt.rs).
+bitflags = "2"
[target.'cfg(target_os = "windows")'.dependencies]
# The host<->driver wire contract for the pf-vdisplay IddCx backend (control IOCTLs + Pod structs).
diff --git a/crates/pf-vdisplay/protocols/kde-output-device-v2.xml b/crates/pf-vdisplay/protocols/kde-output-device-v2.xml
new file mode 100644
index 00000000..5f167aeb
--- /dev/null
+++ b/crates/pf-vdisplay/protocols/kde-output-device-v2.xml
@@ -0,0 +1,653 @@
+
+
+
+ SPDX-FileCopyrightText: 2021 Méven Car
+
+ SPDX-License-Identifier: MIT-CMU
+ ]]>
+
+
+
+ This interface can be used to list output devices.
+
+ If this global is bound with a version less than 21, the unsupported_version
+ protocol error will be posted.
+
+
+
+
+ These errors can be emitted in response to some requests.
+
+
+
+
+
+
+ This event is sent in response to the stop request. The compositor will
+ immediately destroy the object after sending this event.
+
+
+
+
+
+ This request indicates that the client no longer wants to receive new
+ output announcements. The compositor will send the
+ kde_output_device_registry_v2.finished event in response to this request.
+ The compositor may still send new output announcements after calling this
+ request until the kde_output_device_registry_v2.finished event is sent.
+
+
+
+
+
+ This event is sent when a new output is connected or after binding this
+ global to list all available outputs.
+
+
+
+
+
+
+
+ An output device describes a display device available to the compositor.
+ output_device is similar to wl_output, but focuses on output
+ configuration management.
+
+ A client can query all global output_device objects to enlist all
+ available display devices, even those that may currently not be
+ represented by the compositor as a wl_output.
+
+ The client sends configuration changes to the server through the
+ outputconfiguration interface, and the server applies the configuration
+ changes to the hardware and signals changes to the output devices
+ accordingly.
+
+ This object is published as global during start up for every available
+ display devices, or when one later becomes available, for example by
+ being hotplugged via a physical connector.
+
+ 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.
+
+
+
+
+ This enumeration describes how the physical pixels on an output are
+ laid out.
+
+
+
+
+
+
+
+
+
+
+
+ This describes the transform, that a compositor will apply to a
+ surface to compensate for the rotation or mirroring of an
+ output device.
+
+ The flipped values correspond to an initial flip around a
+ vertical axis followed by rotation.
+
+ The purpose is mainly to allow clients to render accordingly and
+ tell the compositor, so that for fullscreen surfaces, the
+ compositor is still able to scan out directly client surfaces.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The geometry event describes geometric properties of the output.
+ The event is sent when binding to the output object and whenever
+ any of the properties change.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ This event describes the mode currently in use for this head. It is only
+ sent if the output is enabled.
+
+
+
+
+
+
+ The mode event describes an available mode for the output.
+
+ When the client binds to the output_device object, the server sends this
+ event once for every available mode the output_device can be operated by.
+
+ There will always be at least one event sent out on initial binding,
+ which represents the current mode.
+
+ Later if an output changes, its mode event is sent again for the
+ eventual added modes and lastly the current mode. In other words, the
+ current mode is always represented by the latest event sent with the current
+ flag set.
+
+ The size of a mode is given in physical hardware units of the output device.
+ This is not necessarily the same as the output size in the global compositor
+ space. For instance, the output may be scaled, as described in
+ kde_output_device_v2.scale, or transformed, as described in
+ kde_output_device_v2.transform.
+
+
+
+
+
+
+ This event is sent after all other properties have been
+ sent on binding to the output object as well as after any
+ other output property change have been applied later on.
+ This allows to see changes to the output properties as atomic,
+ even if multiple events successively announce them.
+
+
+
+
+
+ This event contains scaling geometry information
+ that is not in the geometry event. It may be sent after
+ binding the output object or if the output scale changes
+ later. If it is not sent, the client should assume a
+ scale of 1.
+
+ A scale larger than 1 means that the compositor will
+ automatically scale surface buffers by this amount
+ when rendering. This is used for high resolution
+ displays where applications rendering at the native
+ resolution would be too small to be legible.
+
+ It is intended that scaling aware clients track the
+ current output of a surface, and if it is on a scaled
+ output it should use wl_surface.set_buffer_scale with
+ the scale of the output. That way the compositor can
+ avoid scaling the surface, and the client can supply
+ a higher detail image.
+
+
+
+
+
+
+ The edid event encapsulates the EDID data for the outputdevice.
+
+ The event is sent when binding to the output object. The EDID
+ data may be empty, in which case this event is sent anyway.
+ If the EDID information is empty, you can fall back to the name
+ et al. properties of the outputdevice.
+
+
+
+
+
+
+ The enabled event notifies whether this output is currently
+ enabled and used for displaying content by the server.
+ The event is sent when binding to the output object and
+ whenever later on an output changes its state by becoming
+ enabled or disabled.
+
+
+
+
+
+
+ The uuid can be used to identify the output. It's controlled by
+ the server entirely. The server should make sure the uuid is
+ persistent across restarts. An empty uuid is considered invalid.
+
+
+
+
+
+
+ Serial ID of the monitor, sent on startup before the first done event.
+
+
+
+
+
+ EISA ID of the monitor, sent on startup before the first done event.
+
+
+
+
+
+
+ Describes what capabilities this device has.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ What capabilities this device has, sent on startup before the first
+ done event.
+
+
+
+
+
+
+ Overscan value of the monitor in percent, sent on startup before the
+ first done event.
+
+
+
+
+
+
+ Describes when the compositor may employ variable refresh rate
+
+
+
+
+
+
+
+
+ What policy the compositor will employ regarding its use of variable
+ refresh rate.
+
+
+
+
+
+
+ Whether full or limited color range should be used
+
+
+
+
+
+
+
+
+ What rgb range the compositor is using for this output
+
+
+
+
+
+
+ Name of the output, it's useful to cross-reference to an zxdg_output_v1 and ultimately QScreen
+
+
+
+
+
+
+ Whether or not high dynamic range is enabled for this output
+
+
+
+
+
+
+ If high dynamic range is used, this value defines the brightness in nits for content
+ that's in standard dynamic range format. Note that while the value is in nits, that
+ doesn't necessarily translate to the same brightness on the screen.
+
+
+
+
+
+
+ Whether or not the use of a wide color gamut is enabled for this output
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ This can be used to provide the colors users assume sRGB applications should have based on the
+ default experience on many modern sRGB screens.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ This is the brightness modifier of the output. It doesn't specify
+ any absolute values, but is merely a multiplier on top of other
+ brightness values, like sdr_brightness and brightness_metadata.
+ 0 is the minimum brightness (not completely dark) and 10000 is
+ the maximum brightness.
+ This is currently only supported / meaningful while HDR is active.
+
+
+
+
+
+
+ The compositor can do a lot of things that trade between
+ performance, power and color accuracy. This setting describes
+ a high level preference from the user about in which direction
+ that tradeoff should be made.
+
+
+
+
+
+
+
+
+
+
+
+
+ This is the dimming multiplier of the output. This is similar to
+ the brightness setting, except it's meant to be a temporary setting
+ only, not persistent and may be implemented differently depending
+ on the display.
+ 0 is the minimum dimming factor (not completely dark) and 10000
+ means the output is not dimmed.
+
+
+
+
+
+
+
+
+
+
+
+ If the ddc_ci capability is present, this determines if settings
+ such as brightness, contrast or others should be set using DDC/CI.
+
+
+
+
+
+
+ This limits the amount of bits per color that are sent to the display.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ When EDR is enabled, the compositor may increase the backlight beyond
+ the user-specified setting, in order to present HDR content on displays
+ without native HDR support.
+ This will usually result in better visuals, but also increases battery
+ usage.
+
+
+
+
+
+
+ This is the sharpness modifier of the output.
+ 0 is sharpness disabled and 10000 is the maximum sharpness
+
+
+
+
+
+
+ Describes the position of the output in the output order list,
+ with lower values being earlier in the list. There's no specific
+ value the list has to start at, this value is only used in sorting
+ outputs.
+
+ Note that the output order protocol is not sufficient for this,
+ as an output may not be in the output order if it's disabled or
+ mirroring another screen.
+
+
+
+
+
+
+
+
+
+
+
+ This notifies the compositor that the client no longer wishes to use
+ the kde_output_device_v2 object.
+
+
+
+
+
+ This event is sent when the output device is disconnected and no new
+ updates will be sent. The client should call the kde_output_device_v2.release
+ request after receiving this event.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Adaptive backlight modulation is a feature that reduces the backlight
+ and increases contrast of colors on the screen to improve power usage.
+
+
+
+
+
+
+
+ This object describes an output mode.
+
+ Some heads don't support output modes, in which case modes won't be
+ advertised.
+
+ Properties sent via this interface are applied atomically via the
+ kde_output_device.done event. No guarantees are made regarding the order
+ in which properties are sent.
+
+
+
+
+ This event describes the mode size. The size is given in physical
+ hardware units of the output device. This is not necessarily the same as
+ the output size in the global compositor space. For instance, the output
+ may be scaled or transformed.
+
+
+
+
+
+
+
+ This event describes the mode's fixed vertical refresh rate. It is only
+ sent if the mode has a fixed refresh rate.
+
+
+
+
+
+
+ This event advertises this mode as preferred.
+
+
+
+
+
+ The compositor will destroy the object immediately after sending this
+ event, so it will become invalid and the client should release any
+ resources associated with it.
+
+
+
+
+
+
+
+
+
+
+
+ This event describes the mode's flags.
+
+
+
+
+
+
+ This event describes the CVT timings associated with the mode.
+
+ If an output mode has no CVT timings, this event will not be sent.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/crates/pf-vdisplay/protocols/kde-output-management-v2.xml b/crates/pf-vdisplay/protocols/kde-output-management-v2.xml
new file mode 100644
index 00000000..e78db440
--- /dev/null
+++ b/crates/pf-vdisplay/protocols/kde-output-management-v2.xml
@@ -0,0 +1,539 @@
+
+
+
+ SPDX-FileCopyrightText: 2021 Méven Car
+ SPDX-FileCopyrightText: 2023 Xaver Hugl
+
+ SPDX-License-Identifier: MIT-CMU
+ ]]>
+
+
+
+ This interface enables clients to set properties of output devices for screen
+ configuration purposes via the server. To this end output devices are referenced
+ by global kde_output_device_v2 objects.
+
+ outputmanagement (wl_global)
+ --------------------------
+ request:
+ * create_configuration -> outputconfiguration (wl_resource)
+
+ outputconfiguration (wl_resource)
+ --------------------------
+ requests:
+ * enable(outputdevice, bool)
+ * mode(outputdevice, mode)
+ * transformation(outputdevice, flag)
+ * position(outputdevice, x, y)
+ * apply
+
+ events:
+ * applied
+ * failed
+
+ The server registers one outputmanagement object as a global object. In order
+ to configure outputs a client requests create_configuration, which provides a
+ resource referencing an outputconfiguration for one-time configuration. That
+ way the server knows which requests belong together and can group them by that.
+
+ On the outputconfiguration object the client calls for each output whether the
+ output should be enabled, which mode should be set (by referencing the mode from
+ the list of announced modes) and the output's global position. Once all outputs
+ are configured that way, the client calls apply.
+ At that point and not earlier the server should try to apply the configuration.
+ If this succeeds the server emits the applied signal, otherwise the failed
+ signal, such that the configuring client is noticed about the success of its
+ configuration request.
+
+ Through this design the interface enables atomic output configuration changes if
+ internally supported by the server.
+
+ 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.
+
+
+
+ Request an outputconfiguration object through which the client can configure
+ output devices.
+
+
+
+
+
+
+ For details, see the description of kde_mode_list_v2 and
+ kde_output_configuration_v2.set_custom_modes.
+
+
+
+
+
+
+
+
+ outputconfiguration is a client-specific resource that can be used to ask
+ the server to apply changes to available output devices.
+
+ The client receives a list of output devices from the registry. When it wants
+ to apply new settings, it creates a configuration object from the
+ outputmanagement global, writes changes through this object's enable, scale,
+ transform and mode calls. It then asks the server to apply these settings in
+ an atomic fashion, for example through Linux' DRM interface.
+
+ The server signals back whether the new settings have applied successfully
+ or failed to apply. outputdevice objects are updated after the changes have been
+ applied to the hardware and before the server side sends the applied event.
+
+
+
+
+ These error can be emitted in response to kde_output_configuration_v2 requests.
+
+
+
+
+
+
+ Mark the output as enabled or disabled.
+
+
+
+
+
+
+
+ Sets the mode for a given output.
+
+
+
+
+
+
+
+ Sets the transformation for a given output.
+
+
+
+
+
+
+
+ Sets the position for this output device. (x,y) describe the top-left corner
+ of the output in global space, whereby the origin (0,0) of the global space
+ has to be aligned with the top-left corner of the most left and in case this
+ does not define a single one the top output.
+
+ There may be no gaps or overlaps between outputs, i.e. the outputs are
+ stacked horizontally, vertically, or both on each other.
+
+
+
+
+
+
+
+
+ Sets the scaling factor for this output device.
+
+
+
+
+
+
+
+ Asks the server to apply property changes requested through this outputconfiguration
+ object to all outputs on the server side.
+
+ The output configuration can be applied only once. The already_applied protocol error
+ will be posted if the apply request is called the second time.
+
+
+
+
+
+ Sent after the server has successfully applied the changes.
+ .
+
+
+
+
+
+ Sent if the server rejects the changes or failed to apply them.
+
+
+
+
+
+
+
+
+
+ Set the overscan value of this output device with a value in percent.
+
+
+
+
+
+
+
+ Describes when the compositor may employ variable refresh rate
+
+
+
+
+
+
+
+
+ Set what policy the compositor should employ regarding its use of
+ variable refresh rate.
+
+
+
+
+
+
+
+ Whether this output should use full or limited rgb.
+
+
+
+
+
+
+
+
+ Whether full or limited color range should be used
+
+
+
+
+
+
+
+
+
+
+
+
+ Set the position of the output in the output order list, with lower values
+ being earlier in the list. There's no specific value the list has to start
+ at, this value is only used in sorting outputs.
+
+ The order of outputs can be used to assign desktop environment components
+ to a specific screen, see kde_output_order_v1 and kde-output-device-v2 for
+ details. Note that for consistent behavior, the priority value needs to be
+ unique among all enabled outputs.
+
+
+
+
+
+
+
+ Sets whether or not the output should be set to HDR mode.
+
+
+
+
+
+
+
+ Sets the brightness of standard dynamic range content in nits. Only has an effect while the output is in HDR mode.
+ Note that while the value is in nits, that doesn't necessarily translate to the same brightness on the screen.
+
+
+
+
+
+
+
+ Whether or not the output should use a wide color gamut
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ This can be used to provide the colors users assume sRGB applications should have based on the
+ default experience on many modern sRGB screens.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Set the brightness modifier of the output. It doesn't specify
+ any absolute values, but is merely a multiplier on top of other
+ brightness values, like sdr_brightness and brightness_metadata.
+ 0 is the minimum brightness (not completely dark) and 10000 is
+ the maximum brightness.
+ This is supported while HDR is active in versions 8 and below,
+ or when the device supports the "brightness" capability in
+ versions 9 and above.
+
+
+
+
+
+
+
+ The compositor can do a lot of things that trade between
+ performance, power and color accuracy. This setting describes
+ a high level preference from the user about in which direction
+ that tradeoff should be made.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Set the dimming multiplier of the output. This is similar to the
+ brightness setting, except it's meant to be a temporary setting
+ only, not persistent and may be implemented differently depending
+ on the display.
+ 0 is the minimum dimming factor (not completely dark) and 10000
+ means the output is not dimmed.
+
+ This is supported only when the "brightness" capability is
+ also supported.
+
+
+
+
+
+
+
+ Describes why applying the output configuration failed. Is only
+ sent before the failure event.
+
+
+
+
+
+
+ Set the source output that the outputdevice should mirror its
+ viewport from.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ This limits the amount of bits per color that are sent to the display.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ When EDR is enabled, the compositor may increase the backlight beyond
+ the user-specified setting, in order to present HDR content on displays
+ without native HDR support.
+ This will usually result in better visuals, but also increases battery
+ usage.
+
+
+
+
+
+
+
+ This is the sharpness modifier of the output.
+ 0 is sharpness disabled and 10000 is the maximum sharpness
+
+
+
+
+
+
+
+ Set the list of custom modes for this output. The compositor
+ will in response generate the requested modes and add them to
+ the output (or delete ones no longer in the list).
+ This can be useful for overclocking displays, or for working
+ around broken EDIDs.
+ Note that there is no guarantee for any custom mode to
+ actually work, or even to leave the display undamaged (in the
+ case of CRTs). It's entirely the responsibility of the user
+ to ensure each added mode is the right one for their display.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Adaptive backlight modulation is a feature that reduces the backlight
+ and increases contrast of colors on the screen to improve power usage.
+
+
+
+
+
+
+
+
+ This list is populated by first setting each relevant property,
+ and then calling add_mode to add a mode with these properties.
+ One would for example call
+ - set_resolution
+ - set_refresh_rate
+ - set_reduced_blanking
+ - add_mode
+
+ add_mode does not reset the properties that were previously set,
+ they are valid until the object is destroyed.
+ The compositor may additionally have sensible defaults for some
+ properties like reduced_blanking, but for consistent results,
+ it's best to always set each known property every time.
+
+ One can also specify custom modes with CVT timings, for example
+ - add_cvt
+ - add_cvt
+
+ The parameters resolution and refresh rate are required, if they
+ are not set, the missing_parameters error will be emitted.
+
+
+
+
+ These errors can be emitted in response to add_mode requests.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Reduced blanking is an optimization that can reduce bandwidth / timing
+ requirements for a display mode by reducing the time vblank takes.
+ As not all displays support it, it may be desired to still turn it off
+ though (like with CRTs, where full blanking is required).
+
+
+
+
+
+
+ Adds a new mode with the specified CVT timings.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/crates/pf-vdisplay/src/lib.rs b/crates/pf-vdisplay/src/lib.rs
index f8b58385..d925a7d6 100644
--- a/crates/pf-vdisplay/src/lib.rs
+++ b/crates/pf-vdisplay/src/lib.rs
@@ -445,6 +445,13 @@ mod hyprland;
#[path = "vdisplay/linux/kwin.rs"]
mod kwin;
+// In-process KDE output management (kde_output_management_v2) — the topology path that used to shell
+// out to `kscreen-doctor`, driven over the compositor's own Wayland instead so it can't be wedged by
+// a stuck libkscreen/kscreen-KDED backend. Consumed by `kwin` (best-effort, with kscreen fallback).
+#[cfg(target_os = "linux")]
+#[path = "vdisplay/linux/kwin_output_mgmt.rs"]
+mod kwin_output_mgmt;
+
#[cfg(target_os = "windows")]
#[path = "vdisplay/windows/manager.rs"]
pub mod manager;
diff --git a/crates/pf-vdisplay/src/vdisplay/linux/kwin.rs b/crates/pf-vdisplay/src/vdisplay/linux/kwin.rs
index 809cb1aa..bae06dfc 100644
--- a/crates/pf-vdisplay/src/vdisplay/linux/kwin.rs
+++ b/crates/pf-vdisplay/src/vdisplay/linux/kwin.rs
@@ -89,6 +89,11 @@ pub struct KwinDisplay {
/// [`apply_position`](VirtualDisplay::apply_position) addresses OUR output even while a
/// superseded same-name sibling is still alive.
last_name: Option,
+ /// The RESOLVED `kde_output_device_v2` UUID of the last `create`'s output, when the in-process
+ /// output-management path handled the topology. A stable per-output id (unlike the shared name),
+ /// so [`apply_position`](VirtualDisplay::apply_position) and restore address exactly OUR output
+ /// across a supersede — preferred over `last_name`, which is only the kscreen-doctor fallback.
+ our_uuid: Option,
/// The topology-restore action the last `create` prepared (re-enable the outputs an `exclusive`
/// topology disabled), pending pickup by the registry via [`take_topology_restore`] — so the
/// physical is re-enabled only when the display GROUP's last member drops (§6.1), not this session's.
@@ -114,6 +119,48 @@ impl KwinDisplay {
pub fn new() -> Result {
Ok(KwinDisplay::default())
}
+
+ /// Apply the effective display topology for the just-created output `our_prefix` (current size
+ /// `dims`), preferring the in-process `kde_output_management_v2` path and falling back to
+ /// `kscreen-doctor` if the compositor doesn't answer in budget or the management global is
+ /// absent. Records the output's UUID (in-process) or kscreen address (fallback) for
+ /// [`apply_position`](VirtualDisplay::apply_position), and returns the disabled outputs (each
+ /// `(name, "WxH@Hz")`) for the group teardown restore. `Extend`/`Auto` disable nothing.
+ fn apply_topology(
+ &mut self,
+ name: &str,
+ our_prefix: &str,
+ dims: (u32, u32),
+ ) -> Vec<(String, String)> {
+ use crate::kwin_output_mgmt::TopologyKind;
+ use crate::policy::Topology;
+ let topology = crate::effective_topology();
+ let kind = match topology {
+ Topology::Exclusive => TopologyKind::Exclusive,
+ Topology::Primary => TopologyKind::Primary,
+ Topology::Extend | Topology::Auto => return Vec::new(),
+ };
+ // In-process over Wayland — immune to whatever wedges the standalone kscreen-doctor.
+ let outcome = crate::kwin_output_mgmt::apply_topology(our_prefix, dims.0, dims.1, kind);
+ if outcome.handled {
+ self.our_uuid = outcome.our_uuid;
+ return outcome.disabled;
+ }
+ // Fallback: kscreen-doctor — resolve our address the old way, then shell out the topology.
+ tracing::info!(
+ "KWin topology: kde_output_management unavailable — kscreen-doctor fallback"
+ );
+ let addr = resolve_kscreen_addr(name, dims.0, dims.1);
+ self.last_name = Some(addr.clone());
+ match topology {
+ Topology::Exclusive => apply_virtual_primary(&addr),
+ Topology::Primary => {
+ apply_virtual_primary_only(&addr);
+ Vec::new()
+ }
+ Topology::Extend | Topology::Auto => Vec::new(),
+ }
+ }
}
impl VirtualDisplay for KwinDisplay {
@@ -142,8 +189,15 @@ impl VirtualDisplay for KwinDisplay {
}
fn apply_position(&mut self, x: i32, y: i32) {
- // `last_name` holds the RESOLVED kscreen address (numeric output id, or the
- // `Virtual-` fallback) — never re-derive from the name: during a supersede two
+ // Prefer the in-process path: address OUR output by its stable UUID (supersede-robust) over
+ // kde_output_management_v2 — immune to a wedged kscreen-doctor backend (see kwin_output_mgmt).
+ if let Some(uuid) = self.our_uuid.clone() {
+ if crate::kwin_output_mgmt::set_position(&uuid, x, y) {
+ return;
+ }
+ }
+ // Fallback: kscreen-doctor. `last_name` holds the RESOLVED kscreen address (numeric output id,
+ // or the `Virtual-` fallback) — never re-derive from the name: during a supersede two
// outputs share it and the command would hit the old one (see `create`).
let Some(output) = self.last_name.clone() else {
return;
@@ -227,17 +281,10 @@ impl VirtualDisplay for KwinDisplay {
embedded_pointer = !self.hw_cursor,
"KWin virtual output ready"
);
- // ⚠️ ADDRESS BY NUMERIC KSCREEN ID, NEVER BY NAME: a supersede (mode switch) creates the
- // replacement output — SAME per-slot name, deliberately, for KWin's per-name config
- // persistence — while the superseded sibling is still alive (create-before-drop). Every
- // name-addressed kscreen command then hits the FIRST match = the OLD output: on-glass this
- // resized the LIVE session's display out from under it (wrong-res/black), read back the
- // OLD output as "custom mode applied", made the OLD output primary, and positioned it —
- // while the new output never left its birth mode and the capturer's dims gate starved.
- // Resolve OUR output's kscreen id once (match: managed-prefix name AND current mode ==
- // the just-created birth size; newest id wins), and use it for every kscreen operation.
- let mut addr = resolve_kscreen_addr(&name, width, birth_h);
- self.last_name = Some(addr.clone()); // for apply_position (registry-driven §6.2 layout)
+ // Topology + positioning address OUR output by its kde_output_management UUID (resolved
+ // in-process in `apply_topology`, supersede-robust) — no early kscreen-doctor resolve, so
+ // the 60 Hz path never shells out. Only the >60 Hz custom-mode install below still uses
+ // kscreen-doctor (its in-process port — set_custom_modes/add_cvt — is a follow-up).
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
@@ -245,6 +292,14 @@ impl VirtualDisplay for KwinDisplay {
// 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 {
+ // ⚠️ ADDRESS BY NUMERIC KSCREEN ID, NEVER BY NAME: a supersede (mode switch) creates the
+ // replacement output — SAME per-slot name, deliberately, for KWin's per-name config
+ // persistence — while the superseded sibling is still alive (create-before-drop). A
+ // name-addressed kscreen command would hit the FIRST match = the OLD output. Resolve OUR
+ // output's kscreen id (managed-prefix name AND current mode == the birth size; newest id
+ // wins) for the custom-mode install, and keep it as the kscreen-doctor position fallback.
+ let addr = resolve_kscreen_addr(&name, width, birth_h);
+ self.last_name = Some(addr.clone());
let active = 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
// width at or just below the request (a CVT alignment). That also proves the output
@@ -275,8 +330,6 @@ impl VirtualDisplay for KwinDisplay {
let (nid, st) = spawn_vout(width, height)?;
node_id = nid;
stop = st;
- addr = resolve_kscreen_addr(&name, width, height);
- self.last_name = Some(addr.clone());
tracing::info!(
node_id,
width,
@@ -293,16 +346,17 @@ impl VirtualDisplay for KwinDisplay {
// `Primary` makes it the primary output but keeps the bootstrap/physical outputs enabled;
// `Exclusive` makes it the SOLE desktop (others disabled, restored on teardown) — so
// plasmashell + windows land on the streamed surface, not the headless `kwin --virtual`
- // bootstrap output. Read from the policy (replacing the PUNKTFUNK_KWIN_VIRTUAL_PRIMARY boolean).
- use crate::policy::Topology;
- let disabled = match crate::effective_topology() {
- Topology::Exclusive => apply_virtual_primary(&addr),
- Topology::Primary => {
- apply_virtual_primary_only(&addr);
- Vec::new() // nothing disabled → nothing to restore
- }
- Topology::Extend | Topology::Auto => Vec::new(),
- };
+ // bootstrap output. Applied over kde_output_management_v2 in-process (immune to a wedged
+ // kscreen-doctor backend; see `apply_topology`), with a kscreen-doctor fallback. `disabled`
+ // is the physical/bootstrap outputs, each `(name, "WxH@Hz")`, to restore on teardown.
+ let our_prefix = format!("Virtual-{name}");
+ let disabled = self.apply_topology(&name, &our_prefix, final_dims);
+ // A plain managed name is enough for apply_position's kscreen-doctor fallback when the
+ // in-process UUID path isn't set (single-output sessions are unambiguous; a supersede uses
+ // the UUID path instead). `want_high` already set `last_name` to the resolved kscreen id.
+ if self.last_name.is_none() {
+ self.last_name = Some(our_prefix);
+ }
// Per-group restore (§6.1): DON'T bind the re-enable to this session's keepalive (a per-session
// `StopGuard` restore would re-enable the physical the moment the FIRST of several exclusive
// sessions drops — under a still-live sibling). Instead stash it as a closure the registry lifts
@@ -310,7 +364,12 @@ impl VirtualDisplay for KwinDisplay {
// that display's output is reclaimed, so KWin never sees zero outputs). Empty ⇒ nothing to restore.
self.pending_restore = (!disabled.is_empty()).then(|| {
let disabled = disabled.clone();
- Box::new(move || reenable_outputs(&disabled)) as Box
+ // In-process first; fall back to kscreen-doctor if the compositor doesn't answer in budget.
+ Box::new(move || {
+ if !crate::kwin_output_mgmt::reenable_outputs(&disabled) {
+ reenable_outputs_kscreen(&disabled);
+ }
+ }) as Box
});
// Layout position (§6.2) is applied by the registry via `apply_position` right after create
// (it owns the display group, so it computes auto-row / manual placement over the whole group).
@@ -324,10 +383,12 @@ impl VirtualDisplay for KwinDisplay {
}
}
-/// Re-enable the outputs an `exclusive` topology disabled (bootstrap / physical), so KWin re-homes onto
-/// them. Called by the registry when the display group's last member is torn down (design §6.1), BEFORE
-/// that member's output is reclaimed — so KWin is never momentarily left with zero enabled outputs.
-fn reenable_outputs(outputs: &[(String, String)]) {
+/// Re-enable the outputs an `exclusive` topology disabled (bootstrap / physical) via `kscreen-doctor`
+/// — the fallback for the in-process [`crate::kwin_output_mgmt::reenable_outputs`], run by the restore
+/// closure only when the in-process path reports the compositor didn't answer. Called by the registry
+/// when the display group's last member is torn down (design §6.1), BEFORE that member's output is
+/// reclaimed — so KWin is never momentarily left with zero enabled outputs.
+fn reenable_outputs_kscreen(outputs: &[(String, String)]) {
if outputs.is_empty() {
return;
}
@@ -823,8 +884,9 @@ fn apply_virtual_primary_only(ours: &str) {
/// Dropping this releases the KWin virtual output: it flips the keepalive thread's `stop`, which
/// drops the Wayland connection and makes KWin reclaim the output. The topology **restore** is no
-/// longer bound here — it moved to the registry's display group (§6.1, [`reenable_outputs`]), which
-/// runs it once when the group's last member drops, BEFORE this keepalive is dropped.
+/// longer bound here — it moved to the registry's display group (§6.1, restored in-process via
+/// [`crate::kwin_output_mgmt::reenable_outputs`], `kscreen-doctor` fallback), which runs it once when
+/// the group's last member drops, BEFORE this keepalive is dropped.
struct StopGuard {
stop: Arc,
}
diff --git a/crates/pf-vdisplay/src/vdisplay/linux/kwin_output_mgmt.rs b/crates/pf-vdisplay/src/vdisplay/linux/kwin_output_mgmt.rs
new file mode 100644
index 00000000..f765e875
--- /dev/null
+++ b/crates/pf-vdisplay/src/vdisplay/linux/kwin_output_mgmt.rs
@@ -0,0 +1,658 @@
+//! In-process KDE output management (`kde_output_management_v2` + `kde_output_device_v2`).
+//!
+//! Topology — make the streamed output primary, disable the physical/bootstrap outputs, capture
+//! their modes for restore, re-enable them on teardown, position the output — used to shell out to
+//! `kscreen-doctor` (see [`super::kwin`]). But `kscreen-doctor` drives a *separate* stack:
+//! libkscreen picks a backend and, depending on the setup, waits on the kscreen KDED module over
+//! D-Bus. On a machine where THAT layer is wedged it blocks in its own connect and never returns —
+//! a field report (Nobara, KWin 6.6.4) showed every topology `kscreen-doctor` timing out at its 5 s
+//! budget, so the streamed output never became the desktop (`also_disabled=[]`) and bring-up took
+//! ~26 s.
+//!
+//! The compositor's OWN Wayland is provably responsive on that same session — the host just created
+//! a virtual output over it via `zkde_screencast` — so we drive `kde_output_management_v2` directly
+//! over Wayland here, sidestepping whatever wedges the standalone tool. Every wait is time-bounded,
+//! so a genuinely wedged compositor degrades to `handled = false` and the caller falls back to the
+//! `kscreen-doctor` path rather than hanging.
+//!
+//! KWin advertises one `kde_output_device_v2` global per output (the classic model; verified live:
+//! `kde_output_management_v2` v19, `kde_output_device_v2` v20 on KWin 6.6.4). We bind them all, read
+//! each output's name / enabled / priority / current-mode size, then build a
+//! `kde_output_configuration_v2` and `apply()` it, waiting for `applied` / `failed`.
+
+#![allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)]
+#![deny(clippy::undocumented_unsafe_blocks)]
+
+use std::collections::HashMap;
+use std::os::fd::{AsFd, AsRawFd};
+use std::time::{Duration, Instant};
+use wayland_client::backend::ObjectId;
+use wayland_client::protocol::wl_callback::{self, WlCallback};
+use wayland_client::protocol::wl_registry::{self, WlRegistry};
+use wayland_client::{event_created_child, Connection, Dispatch, Proxy, QueueHandle};
+
+// Generate the client bindings for the two vendored KDE protocols inline (no build.rs). The
+// management protocol references the device interfaces, so they can't share one `__interfaces`
+// module (each `generate_interfaces!` emits its own helper items, which collide). Instead — the
+// interdependent-protocol pattern from the `wayland-protocols` crate — `device` is a self-contained
+// module and `management` pulls in `device`'s interface statics + generated proxy types before its
+// own codegen, so its cross-protocol object args (`kde_output_device_v2`, `…_mode_v2`) resolve.
+#[allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)]
+pub mod device {
+ use wayland_client;
+ use wayland_client::protocol::*;
+
+ pub mod __interfaces {
+ use wayland_client::protocol::__interfaces::*;
+ wayland_scanner::generate_interfaces!("protocols/kde-output-device-v2.xml");
+ }
+ use self::__interfaces::*;
+
+ wayland_scanner::generate_client_code!("protocols/kde-output-device-v2.xml");
+}
+
+#[allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)]
+pub mod management {
+ use wayland_client;
+ use wayland_client::protocol::*;
+
+ pub mod __interfaces {
+ use super::super::device::__interfaces::*;
+ use wayland_client::protocol::__interfaces::*;
+ wayland_scanner::generate_interfaces!("protocols/kde-output-management-v2.xml");
+ }
+ use self::__interfaces::*;
+ // The device protocol's generated modules/types, so the foreign object args resolve.
+ use super::device::*;
+
+ wayland_scanner::generate_client_code!("protocols/kde-output-management-v2.xml");
+}
+
+use device::kde_output_device_mode_v2::{Event as ModeEvent, KdeOutputDeviceModeV2 as DeviceMode};
+use device::kde_output_device_v2::{Event as DeviceEvent, KdeOutputDeviceV2 as OutputDevice};
+use management::kde_output_configuration_v2::{
+ Event as ConfigEvent, KdeOutputConfigurationV2 as OutputConfig,
+};
+use management::kde_output_management_v2::KdeOutputManagementV2 as OutputManagement;
+
+/// Highest interface versions we drive; we bind `min(advertised, MAX)`. Every request we issue is
+/// `since ≤ 2` (`create_configuration`/`enable`/`mode`/`position`/`apply` are v1, `set_primary_output`
+/// is v2) and every event we read is `since ≤ 18` (`priority`), so binding high and calling low is
+/// always in range on any KWin that advertises the globals.
+const MGMT_MAX: u32 = 22;
+const DEVICE_MAX: u32 = 24;
+
+/// The opcode of `kde_output_device_v2.mode` (0-based event index) — the event that creates a child
+/// `kde_output_device_mode_v2`. Kept in sync with the vendored `kde-output-device-v2.xml`.
+const DEVICE_MODE_EVENT_OPCODE: u16 = 2;
+
+/// Overall budget for one enumerate-then-apply operation. Generous next to a healthy roundtrip (a
+/// few ms); it exists only so a wedged compositor can't pin the session's stream thread.
+const OP_BUDGET: Duration = Duration::from_secs(3);
+
+/// Poll slice while waiting on the Wayland fd (matches the keepalive loop's cadence in `kwin.rs`).
+const POLL_MS: i32 = 100;
+
+/// Which topology to apply once our output is resolved.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub(crate) enum TopologyKind {
+ /// Make ours the sole desktop: primary + disable every other enabled output.
+ Exclusive,
+ /// Make ours primary but leave the other outputs enabled.
+ Primary,
+}
+
+/// Outcome of [`apply_topology`].
+pub(crate) struct TopologyOutcome {
+ /// UUID of our resolved virtual output — a stable per-output id that survives a mode-switch
+ /// supersede (unlike the shared name) — for later [`set_position`] / restore addressing.
+ pub our_uuid: Option,
+ /// The outputs we disabled, each `(name, "WxH@Hz")`, so teardown can restore them at their exact
+ /// mode. Empty for `Primary`, or when nothing else was enabled.
+ pub disabled: Vec<(String, String)>,
+ /// `true` if the in-process path bound management, resolved our output, and applied (or tried to)
+ /// a configuration. `false` ⇒ the compositor didn't answer in budget or our output never
+ /// appeared, so the caller should fall back to `kscreen-doctor`.
+ pub handled: bool,
+}
+
+/// One output as read from `kde_output_device_v2`.
+#[derive(Default, Clone)]
+struct DeviceState {
+ /// The global `name` number (higher = more recently advertised) — used to pick the newest of two
+ /// same-named outputs during a supersede.
+ global: u32,
+ name: Option,
+ uuid: Option,
+ enabled: bool,
+ /// KWin's output priority; 1 is the primary. `None` until the `priority` event (device ≥ v18).
+ priority: Option,
+ /// The `current_mode` object id; its size is looked up in [`State::mode_dims`].
+ current_mode: Option,
+ /// Every mode this output advertised, in announce order — `(mode object id, proxy)` — so restore
+ /// can pick the one matching a captured `WxH@Hz`.
+ modes: Vec<(ObjectId, DeviceMode)>,
+ /// Set once this output's `done` burst has been seen (its state is coherent to read).
+ seen_done: bool,
+ proxy: Option,
+}
+
+/// Everything the enumerate/apply queue accumulates on one connection.
+#[derive(Default)]
+struct State {
+ management: Option,
+ mgmt_name_version: Option<(u32, u32)>,
+ devices: HashMap,
+ /// mode object id → `(width, height, refresh_mHz)`.
+ mode_dims: HashMap,
+ /// Highest `wl_callback` serial whose `done` has arrived — the barrier the pump waits on.
+ sync_done: u32,
+ /// Configuration apply verdict: `Some(true)` = applied, `Some(false)` = failed.
+ applied: Option,
+ failure_reason: Option,
+}
+
+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 == OutputManagement::interface().name {
+ let v = version.min(MGMT_MAX);
+ state.management =
+ Some(registry.bind::(name, v, qh, ()));
+ state.mgmt_name_version = Some((name, v));
+ } else if interface == OutputDevice::interface().name {
+ let v = version.min(DEVICE_MAX);
+ // The device's `name` global carries into the device's UserData so the event
+ // handler can record it (newest-wins tie-break during a supersede).
+ let dev = registry.bind::(name, v, qh, name);
+ let id = dev.id();
+ state.devices.entry(id).or_default().proxy = Some(dev);
+ }
+ }
+ wl_registry::Event::GlobalRemove { .. } => {}
+ _ => {}
+ }
+ }
+}
+
+// Management has no events.
+impl Dispatch for State {
+ fn event(
+ _: &mut Self,
+ _: &OutputManagement,
+ _: management::kde_output_management_v2::Event,
+ _: &(),
+ _: &Connection,
+ _: &QueueHandle,
+ ) {
+ }
+}
+
+/// The device's UserData is its global `name` number.
+impl Dispatch for State {
+ fn event(
+ state: &mut Self,
+ device: &OutputDevice,
+ event: DeviceEvent,
+ global: &u32,
+ _: &Connection,
+ _: &QueueHandle,
+ ) {
+ let entry = state.devices.entry(device.id()).or_default();
+ entry.global = *global;
+ if entry.proxy.is_none() {
+ entry.proxy = Some(device.clone());
+ }
+ match event {
+ DeviceEvent::Name { name } => entry.name = Some(name),
+ DeviceEvent::Uuid { uuid } => entry.uuid = Some(uuid),
+ DeviceEvent::Enabled { enabled } => entry.enabled = enabled != 0,
+ DeviceEvent::Priority { priority } => entry.priority = Some(priority),
+ DeviceEvent::CurrentMode { mode } => entry.current_mode = Some(mode.id()),
+ DeviceEvent::Mode { mode } => entry.modes.push((mode.id(), mode)),
+ DeviceEvent::Done => entry.seen_done = true,
+ _ => {}
+ }
+ }
+
+ // The `mode` event hands us a server-created `kde_output_device_mode_v2`. The opcode is a bare
+ // literal (the macro's fragment matcher rejects a `const` in some wayland-client versions); it is
+ // pinned to `DEVICE_MODE_EVENT_OPCODE` by `mode_event_opcode_is_two` below.
+ event_created_child!(State, OutputDevice, [
+ 2 => (DeviceMode, ()),
+ ]);
+}
+
+impl Dispatch for State {
+ fn event(
+ state: &mut Self,
+ mode: &DeviceMode,
+ event: ModeEvent,
+ _: &(),
+ _: &Connection,
+ _: &QueueHandle,
+ ) {
+ let entry = state.mode_dims.entry(mode.id()).or_insert((0, 0, 0));
+ match event {
+ ModeEvent::Size { width, height } => {
+ entry.0 = width.max(0) as u32;
+ entry.1 = height.max(0) as u32;
+ }
+ ModeEvent::Refresh { refresh } => entry.2 = refresh.max(0) as u32,
+ _ => {}
+ }
+ }
+}
+
+impl Dispatch for State {
+ fn event(
+ state: &mut Self,
+ _: &OutputConfig,
+ event: ConfigEvent,
+ _: &(),
+ _: &Connection,
+ _: &QueueHandle,
+ ) {
+ match event {
+ ConfigEvent::Applied => state.applied = Some(true),
+ ConfigEvent::Failed => state.applied = Some(false),
+ ConfigEvent::FailureReason { reason } => state.failure_reason = Some(reason),
+ _ => {}
+ }
+ }
+}
+
+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);
+ }
+ }
+}
+
+/// A connected, bound output-management session on its own Wayland connection.
+struct Session {
+ conn: Connection,
+ queue: wayland_client::EventQueue,
+ state: State,
+ next_sync: u32,
+}
+
+impl Session {
+ /// Connect to the KWin Wayland socket, bind `kde_output_management_v2` + every
+ /// `kde_output_device_v2`, and read each output's state — all bounded by `OP_BUDGET`. `None` if
+ /// we can't connect, the management global isn't advertised, or the compositor doesn't answer in
+ /// budget (the wedge case — the caller then falls back to `kscreen-doctor`).
+ fn open() -> Option {
+ let conn = Connection::connect_to_env().ok()?;
+ 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 management + every device in the handler).
+ if !s.sync_barrier(deadline) {
+ return None;
+ }
+ if s.state.management.is_none() {
+ tracing::debug!(
+ "KWin does not advertise kde_output_management_v2 to this client — kscreen-doctor \
+ fallback"
+ );
+ return None;
+ }
+ // Phase 2: flush the device binds issued in phase 1 and drain each output's state burst
+ // (name / enabled / priority / current_mode / mode sizes / done).
+ if !s.sync_barrier(deadline) {
+ return None;
+ }
+ Some(s)
+ }
+
+ /// Send a `wl_display.sync` and pump the queue until its `done` arrives or `deadline` passes.
+ /// Returns `true` on the barrier, `false` on timeout.
+ 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 what's queued, then poll the connection fd for up
+ /// to `POLL_MS` and read. Mirrors the keepalive loop in `kwin.rs::run` (blocking_dispatch can't
+ /// be interrupted, so we poll the fd instead). Returns `true` once `done(&state)` holds.
+ 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
+ }
+ }
+
+ /// A fresh `kde_output_configuration_v2` on this connection.
+ fn new_config(&self) -> OutputConfig {
+ let qh = self.queue.handle();
+ self.state
+ .management
+ .as_ref()
+ .unwrap()
+ .create_configuration(&qh, ())
+ }
+
+ /// `apply()` the config and pump until `applied`/`failed` or the deadline. Returns the verdict
+ /// (`true` applied, `false` failed/timeout).
+ fn apply(&mut self, config: &OutputConfig, deadline: Instant) -> bool {
+ self.state.applied = None;
+ config.apply();
+ let ok = self.pump_until(deadline, |st| st.applied.is_some());
+ if !ok {
+ return false;
+ }
+ matches!(self.state.applied, Some(true))
+ }
+
+ /// The current-mode size of a device as `(w, h, refresh_mHz)`, if known.
+ fn current_dims(&self, dev: &DeviceState) -> Option<(u32, u32, u32)> {
+ let id = dev.current_mode.as_ref()?;
+ self.state.mode_dims.get(id).copied()
+ }
+}
+
+/// `(width, height, "WxH@Hz")` capture of a device's current mode, Hz rounded — the same shape the
+/// `kscreen-doctor` restore path used, so teardown can put a panel back at its real refresh.
+fn mode_spec(dims: (u32, u32, u32)) -> String {
+ let hz = ((dims.2 as f64) / 1000.0).round() as u32;
+ format!("{}x{}@{}", dims.0, dims.1, hz)
+}
+
+/// Prefix EVERY managed KWin output shares (mirrors `kwin::MANAGED_PREFIX`) — the streamed outputs
+/// are `Virtual-punktfunk` / `Virtual-punktfunk-`, so a same-family sibling session is never
+/// treated as a physical to disable, and its primary is never stolen (first-slot-wins).
+const MANAGED_PREFIX: &str = "Virtual-punktfunk";
+
+/// Make the streamed output (name starts with `our_prefix`, current size `our_w`×`our_h`) the
+/// primary — and, for `Exclusive`, disable every other enabled output — over `kde_output_management_v2`.
+/// See the module docs for why this is done in-process instead of via `kscreen-doctor`.
+pub(crate) fn apply_topology(
+ our_prefix: &str,
+ our_w: u32,
+ our_h: u32,
+ kind: TopologyKind,
+) -> TopologyOutcome {
+ let miss = || TopologyOutcome {
+ our_uuid: None,
+ disabled: Vec::new(),
+ handled: false,
+ };
+ let Some(mut sess) = Session::open() else {
+ return miss();
+ };
+ let deadline = Instant::now() + OP_BUDGET;
+
+ // Resolve OUR output: managed-prefix name AND current size == the birth size (only the
+ // just-created output sits there during a supersede); newest global wins the tie.
+ let ours = sess
+ .state
+ .devices
+ .values()
+ .filter(|d| {
+ d.name.as_deref().is_some_and(|n| n.starts_with(our_prefix))
+ && sess.current_dims(d).map(|(w, h, _)| (w, h)) == Some((our_w, our_h))
+ })
+ .max_by_key(|d| d.global)
+ .cloned();
+ let Some(ours) = ours else {
+ tracing::warn!(
+ our_prefix,
+ our_w,
+ our_h,
+ "KWin output management: our virtual output hasn't appeared yet — kscreen-doctor fallback"
+ );
+ return miss();
+ };
+ let our_uuid = ours.uuid.clone();
+ let our_id = ours.proxy.as_ref().map(|p| p.id());
+
+ // First-slot-wins (§6.1): don't steal primary if another managed sibling already holds it
+ // (priority 1) — a 2nd exclusive session joins as a secondary of the shared desktop.
+ let sibling_is_primary = sess.state.devices.values().any(|d| {
+ d.enabled
+ && d.priority == Some(1)
+ && d.proxy.as_ref().map(|p| p.id()) != our_id
+ && d.name
+ .as_deref()
+ .is_some_and(|n| n.starts_with(MANAGED_PREFIX))
+ });
+
+ // The physical/bootstrap outputs to disable for `Exclusive`: enabled, not any managed sibling,
+ // not ours. Captured WITH their current mode so teardown restores the exact refresh.
+ let mut to_disable: Vec<(OutputDevice, String, String)> = Vec::new();
+ if kind == TopologyKind::Exclusive {
+ for d in sess.state.devices.values() {
+ let is_ours = d.proxy.as_ref().map(|p| p.id()) == our_id;
+ let managed = d
+ .name
+ .as_deref()
+ .is_some_and(|n| n.starts_with(MANAGED_PREFIX));
+ if d.enabled && !is_ours && !managed {
+ if let (Some(name), Some(proxy)) = (d.name.clone(), d.proxy.clone()) {
+ let spec = sess.current_dims(d).map(mode_spec).unwrap_or_default();
+ to_disable.push((proxy, name, spec));
+ }
+ }
+ }
+ }
+
+ // Build one configuration: ensure ours is enabled, take primary (unless a sibling holds it),
+ // disable the others. `apply()` is atomic — KWin re-homes the shell onto the remaining desktop.
+ let config = sess.new_config();
+ if let Some(proxy) = ours.proxy.as_ref() {
+ config.enable(proxy, 1);
+ if !sibling_is_primary {
+ config.set_primary_output(proxy);
+ }
+ }
+ for (proxy, _, _) in &to_disable {
+ config.enable(proxy, 0);
+ }
+ let applied = sess.apply(&config, deadline);
+ config.destroy();
+
+ // Always report the outputs we ASKED to disable so teardown restores them: re-enabling an output
+ // that never actually got disabled is a harmless no-op, whereas dropping them here would strand a
+ // physical dark if KWin processed the disable but the `applied` ack didn't land in budget.
+ let disabled: Vec<(String, String)> = to_disable
+ .into_iter()
+ .map(|(_, name, spec)| (name, spec))
+ .collect();
+ if applied {
+ tracing::info!(
+ also_disabled = ?disabled,
+ primary_taken = !sibling_is_primary,
+ "KWin output management: streamed output set as the desktop (in-process)"
+ );
+ } else {
+ tracing::warn!(
+ reason = ?sess.state.failure_reason,
+ also_disabled = ?disabled,
+ "KWin output management: apply() not confirmed in budget — proceeding (restore will re-enable)"
+ );
+ }
+ // We resolved our output and drove the config over Wayland; don't ALSO run kscreen-doctor — that
+ // would double-apply (and on a wedged box it would just add 26 s of timeouts). `handled` is true
+ // even on an unconfirmed apply; a genuinely absent management global / unresolved output took the
+ // `handled = false` early returns above and falls back to kscreen-doctor.
+ TopologyOutcome {
+ our_uuid,
+ disabled,
+ handled: true,
+ }
+}
+
+/// Re-enable outputs by name at their captured `WxH@Hz` modes (teardown), in-process. Returns
+/// `true` if the config applied; `false` (compositor unresponsive / management absent) tells the
+/// caller to fall back to `kscreen-doctor`.
+pub(crate) fn reenable_outputs(outputs: &[(String, String)]) -> bool {
+ if outputs.is_empty() {
+ return true;
+ }
+ let Some(mut sess) = Session::open() else {
+ return false;
+ };
+ let deadline = Instant::now() + OP_BUDGET;
+ let config = sess.new_config();
+ for (name, spec) in outputs {
+ // Find the device by name (physical names are stable across a session).
+ let Some(dev) = sess
+ .state
+ .devices
+ .values()
+ .find(|d| d.name.as_deref() == Some(name.as_str()))
+ .cloned()
+ else {
+ continue;
+ };
+ let Some(proxy) = dev.proxy.as_ref() else {
+ continue;
+ };
+ // Enable first — a bare enable always succeeds, so a physical is never left dark.
+ config.enable(proxy, 1);
+ // Then re-assert the captured mode so a 120 Hz panel doesn't return at KWin's ~60 Hz default.
+ if let Some(mode) = find_mode(&sess, &dev, spec) {
+ config.mode(proxy, &mode);
+ }
+ }
+ let ok = sess.apply(&config, deadline);
+ config.destroy();
+ if ok {
+ tracing::info!(reenabled = ?outputs, "KWin output management: restored outputs (in-process)");
+ }
+ ok
+}
+
+/// Position the output identified by `uuid` at `(x, y)` in the desktop layout, in-process. Returns
+/// `true` if applied; `false` tells the caller to fall back to `kscreen-doctor`.
+pub(crate) fn set_position(uuid: &str, x: i32, y: i32) -> bool {
+ let Some(mut sess) = Session::open() else {
+ return false;
+ };
+ let deadline = Instant::now() + OP_BUDGET;
+ let Some(dev) = sess
+ .state
+ .devices
+ .values()
+ .find(|d| d.uuid.as_deref() == Some(uuid))
+ .cloned()
+ else {
+ return false;
+ };
+ let Some(proxy) = dev.proxy.as_ref() else {
+ return false;
+ };
+ let config = sess.new_config();
+ config.position(proxy, x, y);
+ let ok = sess.apply(&config, deadline);
+ config.destroy();
+ if ok {
+ tracing::info!(
+ uuid,
+ x,
+ y,
+ "KWin output management: placed output (in-process)"
+ );
+ }
+ ok
+}
+
+/// Find a device's advertised mode proxy matching a captured `"WxH@Hz"` spec (Hz rounded), for
+/// restore. `None` if the spec is empty or no mode matches (the caller then enables without a mode
+/// and lets KWin pick its preferred one).
+fn find_mode(sess: &Session, dev: &DeviceState, spec: &str) -> Option {
+ if spec.is_empty() {
+ return None;
+ }
+ let (wh, hz) = spec.split_once('@')?;
+ let (w, h) = wh.split_once('x')?;
+ let (w, h, hz): (u32, u32, u32) = (w.parse().ok()?, h.parse().ok()?, hz.parse().ok()?);
+ dev.modes.iter().find_map(|(id, proxy)| {
+ let (mw, mh, mmhz) = sess.state.mode_dims.get(id).copied()?;
+ let mhz = ((mmhz as f64) / 1000.0).round() as u32;
+ ((mw, mh, mhz) == (w, h, hz)).then(|| proxy.clone())
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// The `WxH@Hz` capture rounds mHz to whole Hz — the shape teardown parses back.
+ #[test]
+ fn mode_spec_rounds_millihertz() {
+ assert_eq!(mode_spec((2560, 1440, 59940)), "2560x1440@60");
+ assert_eq!(mode_spec((1920, 1080, 60000)), "1920x1080@60");
+ assert_eq!(mode_spec((3840, 2160, 119880)), "3840x2160@120");
+ }
+
+ /// The vendored device XML must keep `mode` at the opcode the `event_created_child!` macro
+ /// hardcodes — a reorder there would bind the child to the wrong event and desync mode sizes.
+ #[test]
+ fn mode_event_opcode_is_two() {
+ assert_eq!(DEVICE_MODE_EVENT_OPCODE, 2);
+ }
+}