From 86d5d6666011eff373f4c9d1c47b1d4519763c8a Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 24 Jul 2026 12:13:28 +0200 Subject: [PATCH] fix(kwin): drive display topology over kde_output_management_v2, not kscreen-doctor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A KDE host on Nobara stopped disabling its physical screens and creating the virtual output the moment it updated: streaming still came up, but bring-up took ~26 s and the streamed output never became the desktop (`also_disabled=[]`). Root cause is not our topology logic — it's that every `kscreen-doctor` call on the reporter's session wedges. kscreen-doctor drives libkscreen, which (per setup) waits on the kscreen KDED module over D-Bus; when that layer is stuck it blocks in its own connect and never returns, so all five topology queries hit their 5 s budget and got killed (host log). Reproduced live on a Nobara / KWin 6.6.4 box: `kscreen-doctor -j` there times out at 8 s, every time. But the compositor's OWN Wayland is fully responsive on that same session — the host just created a virtual output over it via zkde_screencast. So drive the topology (resolve our output, take primary, disable the physical/bootstrap outputs, capture their modes, re-enable on teardown, position) directly over `kde_output_management_v2` + `kde_output_device_v2` instead of shelling out. On that same Nobara box the in-process path binds management (v19) and enumerates the outputs in 2.4 ms — ~3400x faster than the 8 s hang, and immune to whatever wedges the standalone tool. - vendor kde-output-management-v2 / kde-output-device-v2 (KWin advertises mgmt v19, device v20); generate client bindings inline (the interdependent-protocol module split from the wayland-protocols crate; needs the `bitflags` dep for the device protocol's bitfield enums). - new `kwin_output_mgmt`: bounded enumerate-then-apply over one Wayland connection; every wait is time-bounded so a genuinely wedged compositor degrades to `handled = false` and the old kscreen-doctor path still runs. - `kwin::create` topology + `apply_position` prefer the in-process path (address our output by its stable device UUID, supersede-robust) with kscreen-doctor as the fallback. The 60 Hz path now makes ZERO kscreen-doctor calls; only the >60 Hz custom-mode install still shells out (its in-process port is a follow-up). Verified on Linux: cargo test -p pf-vdisplay (73 pass), clippy --all-targets -D warnings clean, fmt clean, punktfunk-host checks clean. In-process enumeration proven live against KWin 6.6.4 (the reporter's env); the disable/apply path is not yet on-glass. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 1 + crates/pf-vdisplay/Cargo.toml | 3 + .../protocols/kde-output-device-v2.xml | 653 +++++++++++++++++ .../protocols/kde-output-management-v2.xml | 539 ++++++++++++++ crates/pf-vdisplay/src/lib.rs | 7 + crates/pf-vdisplay/src/vdisplay/linux/kwin.rs | 126 +++- .../src/vdisplay/linux/kwin_output_mgmt.rs | 658 ++++++++++++++++++ 7 files changed, 1955 insertions(+), 32 deletions(-) create mode 100644 crates/pf-vdisplay/protocols/kde-output-device-v2.xml create mode 100644 crates/pf-vdisplay/protocols/kde-output-management-v2.xml create mode 100644 crates/pf-vdisplay/src/vdisplay/linux/kwin_output_mgmt.rs 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); + } +}