Skip to main content

pixelflux/wayland/
kdeclient.rs

1/*
2 * This Source Code Form is subject to the terms of the Mozilla Public
3 * License, v. 2.0. If a copy of the MPL was not distributed with this
4 * file, You can obtain one at https://mozilla.org/MPL/2.0/.
5 */
6
7//! KDE session-screen client: grows, removes, and arranges the screens of a
8//! nested KWin session, in-process.
9//!
10//! KWin serves no control socket; its interface for a screen that exists on
11//! demand is `zkde_screencast_unstable_v1`. A `stream_virtual_output` request
12//! creates a real output carrying the requested name, and that output lives
13//! exactly as long as the stream object: KWin removes it when the stream is
14//! closed or its client disconnects, and only then. A `failed` event is the
15//! PipeWire stream's failure, not the screen's — the output is created before
16//! the stream starts — so no PipeWire consumer is required and frames keep
17//! arriving through the host window the nested output opens, which also
18//! drives the screen's size ([`add_screen`] only seeds it). Each grown screen
19//! therefore parks one open connection in a process-wide registry; nothing
20//! dispatches it afterwards, because holding the socket open is the entire
21//! contract, and [`remove_screen`] closes the stream to give the screen back.
22//! A session is offered a second display on this rung when the compositor
23//! serves the request ([`screen_control_offered`], a registry read that
24//! touches no screen); whether it registers the screen it grows is proven
25//! when a display asks for one, since a KWin whose nested backend registers
26//! no virtual output serves the request all the same: [`grow`] confirms the
27//! screen against the output devices and refuses the display with that
28//! reason.
29//!
30//! Arrangement rides `kde_output_management_v2` over the per-output
31//! `kde_output_device_v2` globals. Unlike `zwlr_output_management_v1` there
32//! is no state serial and a configuration is a delta — a device left out
33//! keeps its state — and modes come from the advertised list only, never
34//! custom, so [`set_screen_layout`] positions screens and leaves their size
35//! to the capture side; their scale is not set here either, because a nested
36//! KWin accepts one and ignores it, following the host window's preferred
37//! fractional scale instead. Screens are addressed by position in name order,
38//! matching `outclient`. Blocking, off the compositor thread, with every
39//! round-trip deadline-bounded.
40
41use std::collections::HashMap;
42use std::os::unix::net::UnixStream;
43use std::sync::{Mutex, OnceLock};
44use std::time::Instant;
45
46use crate::wayland::kdeproto::output_device::{
47    kde_output_device_mode_v2::{self, KdeOutputDeviceModeV2},
48    kde_output_device_v2::{self, KdeOutputDeviceV2},
49};
50use crate::wayland::kdeproto::output_management::{
51    kde_output_configuration_v2::{self, KdeOutputConfigurationV2},
52    kde_output_management_v2::KdeOutputManagementV2,
53};
54use wayland_client::protocol::wl_registry;
55use wayland_client::{Connection, Dispatch, EventQueue, QueueHandle, delegate_noop};
56use wayland_protocols_plasma::screencast::v1::client::{
57    zkde_screencast_stream_unstable_v1::{self, ZkdeScreencastStreamUnstableV1},
58    zkde_screencast_unstable_v1::ZkdeScreencastUnstableV1,
59};
60
61use crate::wayland::outclient::trailing_number;
62use crate::wayland::wlclient::{IO_TIMEOUT, SyncState, bounded_roundtrip, impl_sync_callback};
63
64/// `zkde_screencast_unstable_v1.pointer` value asking for no cursor in the
65/// stream nobody consumes.
66const POINTER_HIDDEN: u32 = 1;
67
68/// A grown screen's keep-alive: the connection whose lifetime is the screen's.
69struct HeldScreen {
70    conn: Connection,
71    _queue: EventQueue<CastState>,
72    _state: CastState,
73    stream: ZkdeScreencastStreamUnstableV1,
74}
75
76/// Held screens by the name they were grown under, process-wide: the FFI layer
77/// is stateless per call, and a screen must outlive the call that grew it.
78fn held_screens() -> &'static Mutex<HashMap<String, HeldScreen>> {
79    static HELD: OnceLock<Mutex<HashMap<String, HeldScreen>>> = OnceLock::new();
80    HELD.get_or_init(Default::default)
81}
82
83#[derive(Default)]
84struct CastState {
85    manager: Option<ZkdeScreencastUnstableV1>,
86    /// The stream's verdict: created, or the error `failed`/`closed` carried.
87    outcome: Option<Result<(), String>>,
88    sync_done: bool,
89}
90
91impl SyncState for CastState {
92    fn sync_done_mut(&mut self) -> &mut bool {
93        &mut self.sync_done
94    }
95}
96impl_sync_callback!(CastState);
97
98impl Dispatch<wl_registry::WlRegistry, ()> for CastState {
99    fn event(
100        state: &mut Self,
101        registry: &wl_registry::WlRegistry,
102        event: wl_registry::Event,
103        _: &(),
104        _: &Connection,
105        qh: &QueueHandle<Self>,
106    ) {
107        if let wl_registry::Event::Global {
108            name,
109            interface,
110            version,
111        } = event
112        {
113            // stream_virtual_output is a version-2 request.
114            if interface == "zkde_screencast_unstable_v1" && state.manager.is_none() && version >= 2
115            {
116                state.manager = Some(registry.bind(name, version.min(2), qh, ()));
117            }
118        }
119    }
120}
121delegate_noop!(CastState: ZkdeScreencastUnstableV1);
122
123impl Dispatch<ZkdeScreencastStreamUnstableV1, ()> for CastState {
124    fn event(
125        state: &mut Self,
126        _: &ZkdeScreencastStreamUnstableV1,
127        event: zkde_screencast_stream_unstable_v1::Event,
128        _: &(),
129        _: &Connection,
130        _: &QueueHandle<Self>,
131    ) {
132        match event {
133            zkde_screencast_stream_unstable_v1::Event::Created { .. } => {
134                state.outcome = Some(Ok(()))
135            }
136            zkde_screencast_stream_unstable_v1::Event::Failed { error } => {
137                state.outcome = Some(Err(error))
138            }
139            zkde_screencast_stream_unstable_v1::Event::Closed if state.outcome.is_none() => {
140                state.outcome = Some(Err("the compositor closed the stream".to_string()));
141            }
142            _ => {}
143        }
144    }
145}
146
147/// Why a screen could not be grown: the compositor was unreachable, or it
148/// answered and refused. A probe reports the first and answers "no" to the
149/// second.
150enum GrowError {
151    Unreachable(String),
152    Refused(String),
153}
154
155/// Connect to the compositor at `socket_path` and read its registry, which binds the
156/// screencast manager when one is served at the version carrying `stream_virtual_output`.
157fn connect(socket_path: &str) -> Result<(Connection, EventQueue<CastState>, CastState), String> {
158    let stream =
159        UnixStream::connect(socket_path).map_err(|e| format!("connect {socket_path}: {e}"))?;
160    let conn = Connection::from_socket(stream).map_err(|e| format!("wayland setup: {e}"))?;
161    let mut queue: EventQueue<CastState> = conn.new_event_queue();
162    let _registry = conn.display().get_registry(&queue.handle(), ());
163    let mut state = CastState::default();
164    bounded_roundtrip(&conn, &mut queue, &mut state)?;
165    Ok((conn, queue, state))
166}
167
168/// Grow a screen named `name` on the compositor at `socket_path` and return
169/// its keep-alive; the screen lives until that is dropped or its stream closed.
170fn grow(
171    socket_path: &str,
172    name: &str,
173    size: (i32, i32),
174    scale: f64,
175) -> Result<HeldScreen, GrowError> {
176    let (conn, mut queue, mut state) = connect(socket_path).map_err(GrowError::Unreachable)?;
177    let qh = queue.handle();
178    let Some(manager) = state.manager.clone() else {
179        return Err(GrowError::Refused(
180            "the compositor offers no zkde_screencast_unstable_v1".to_string(),
181        ));
182    };
183    let stream = manager.stream_virtual_output(
184        name.to_string(),
185        size.0,
186        size.1,
187        scale,
188        POINTER_HIDDEN,
189        &qh,
190        (),
191    );
192    queue
193        .flush()
194        .map_err(|e| GrowError::Unreachable(format!("flush: {e}")))?;
195    let deadline = Instant::now() + IO_TIMEOUT;
196    while state.outcome.is_none() && Instant::now() < deadline {
197        bounded_roundtrip(&conn, &mut queue, &mut state).map_err(GrowError::Unreachable)?;
198    }
199    // The stream's verdict is the PipeWire stream's; the screen is confirmed
200    // against the output-device globals whatever it said.
201    if !screen_present(socket_path, name).map_err(GrowError::Unreachable)? {
202        return Err(GrowError::Refused(match state.outcome.take() {
203            Some(Err(e)) => format!("virtual screen '{name}' refused: {e}"),
204            _ => format!("virtual screen '{name}' produced no output device"),
205        }));
206    }
207    Ok(HeldScreen {
208        conn,
209        _queue: queue,
210        _state: state,
211        stream,
212    })
213}
214
215/// Whether an enabled screen named `name` is among the compositor's output devices.
216fn screen_present(socket_path: &str, name: &str) -> Result<bool, String> {
217    Ok(list_screens(socket_path)?.iter().any(|(n, ..)| n == name))
218}
219
220/// Whether the compositor on `socket_path` serves `zkde_screencast_unstable_v1` at the
221/// version carrying `stream_virtual_output`: the rung a session with no control socket is
222/// offered a second display on. A registry read alone, so a session is asked on every
223/// landing without a screen coming or going on it; whether a grown screen registers is
224/// [`grow`]'s to prove. Err only when the compositor is unreachable.
225pub fn screen_control_offered(socket_path: &str) -> Result<bool, String> {
226    let (_conn, _queue, state) = connect(socket_path)?;
227    Ok(state.manager.is_some())
228}
229
230/// Grow a screen named `name` on the compositor at `socket_path`, holding it
231/// open until [`remove_screen`]. Idempotent for a name already held.
232///
233/// `size` and `scale` only seed the screen: a nested KWin output follows the
234/// host window a capture output adopts. The stream's verdict is not the
235/// screen's (see the module docstring), so a refusal is confirmed against the
236/// output-device globals before it is reported.
237pub fn add_screen(
238    socket_path: &str,
239    name: &str,
240    size: (i32, i32),
241    scale: f64,
242) -> Result<(), String> {
243    if name.is_empty() {
244        return Err("a screen needs a name".to_string());
245    }
246    if size.0 <= 0 || size.1 <= 0 {
247        return Err(format!("size {}x{} out of range", size.0, size.1));
248    }
249    if !(0.1..=16.0).contains(&scale) {
250        return Err(format!("scale {scale} out of range"));
251    }
252    if held_screens().lock().unwrap().contains_key(name) {
253        return Ok(());
254    }
255    let held = grow(socket_path, name, size, scale).map_err(|e| match e {
256        GrowError::Unreachable(e) | GrowError::Refused(e) => e,
257    })?;
258    held_screens()
259        .lock()
260        .unwrap()
261        .insert(name.to_string(), held);
262    Ok(())
263}
264
265/// Close the held stream behind the screen `name`, removing it — the
266/// compositor's own output-destroy path evacuates its windows. False when no
267/// such screen is held (one the session booted with, or another process's).
268pub fn remove_screen(name: &str) -> bool {
269    let Some(held) = held_screens().lock().unwrap().remove(name) else {
270        return false;
271    };
272    held.stream.close();
273    let _ = held.conn.flush();
274    true
275}
276
277/// One `kde_output_device_v2` global with the state its events carried.
278struct Device {
279    dev: KdeOutputDeviceV2,
280    name: Option<String>,
281    enabled: bool,
282    pos: (i32, i32),
283    scale: f64,
284    current: Option<KdeOutputDeviceModeV2>,
285}
286
287#[derive(Default)]
288struct KdeOutState {
289    manager: Option<KdeOutputManagementV2>,
290    devices: Vec<Device>,
291    /// Size per announced mode object; a device carries no size of its own,
292    /// so its current mode's is reported.
293    modes: Vec<(KdeOutputDeviceModeV2, (i32, i32))>,
294    applied: Option<bool>,
295    sync_done: bool,
296}
297
298impl SyncState for KdeOutState {
299    fn sync_done_mut(&mut self) -> &mut bool {
300        &mut self.sync_done
301    }
302}
303impl_sync_callback!(KdeOutState);
304
305impl Dispatch<wl_registry::WlRegistry, ()> for KdeOutState {
306    fn event(
307        state: &mut Self,
308        registry: &wl_registry::WlRegistry,
309        event: wl_registry::Event,
310        _: &(),
311        _: &Connection,
312        qh: &QueueHandle<Self>,
313    ) {
314        if let wl_registry::Event::Global {
315            name,
316            interface,
317            version,
318        } = event
319        {
320            if interface == "kde_output_management_v2" && state.manager.is_none() {
321                state.manager = Some(registry.bind(name, version.min(1), qh, ()));
322            } else if interface == "kde_output_device_v2" {
323                // Version 2 carries the name event screens are addressed by.
324                let dev: KdeOutputDeviceV2 = registry.bind(name, version.min(2), qh, ());
325                state.devices.push(Device {
326                    dev,
327                    name: None,
328                    enabled: false,
329                    pos: (0, 0),
330                    scale: 1.0,
331                    current: None,
332                });
333            }
334        }
335    }
336}
337delegate_noop!(KdeOutState: KdeOutputManagementV2);
338
339impl Dispatch<KdeOutputDeviceV2, ()> for KdeOutState {
340    fn event(
341        state: &mut Self,
342        dev: &KdeOutputDeviceV2,
343        event: kde_output_device_v2::Event,
344        _: &(),
345        _: &Connection,
346        _: &QueueHandle<Self>,
347    ) {
348        let Some(entry) = state.devices.iter_mut().find(|d| &d.dev == dev) else {
349            return;
350        };
351        match event {
352            kde_output_device_v2::Event::Name { name } => entry.name = Some(name),
353            kde_output_device_v2::Event::Enabled { enabled } => entry.enabled = enabled != 0,
354            kde_output_device_v2::Event::Geometry { x, y, .. } => entry.pos = (x, y),
355            kde_output_device_v2::Event::Scale { factor } => entry.scale = factor,
356            kde_output_device_v2::Event::CurrentMode { mode } => entry.current = Some(mode),
357            _ => {}
358        }
359    }
360
361    wayland_client::event_created_child!(KdeOutState, KdeOutputDeviceV2, [
362        kde_output_device_v2::EVT_MODE_OPCODE => (KdeOutputDeviceModeV2, ()),
363    ]);
364}
365
366impl Dispatch<KdeOutputDeviceModeV2, ()> for KdeOutState {
367    fn event(
368        state: &mut Self,
369        mode: &KdeOutputDeviceModeV2,
370        event: kde_output_device_mode_v2::Event,
371        _: &(),
372        _: &Connection,
373        _: &QueueHandle<Self>,
374    ) {
375        if let kde_output_device_mode_v2::Event::Size { width, height } = event {
376            let mode = mode.clone();
377            state.modes.retain(|(m, _)| *m != mode);
378            state.modes.push((mode, (width, height)));
379        }
380    }
381}
382
383impl Dispatch<KdeOutputConfigurationV2, ()> for KdeOutState {
384    fn event(
385        state: &mut Self,
386        _: &KdeOutputConfigurationV2,
387        event: kde_output_configuration_v2::Event,
388        _: &(),
389        _: &Connection,
390        _: &QueueHandle<Self>,
391    ) {
392        match event {
393            kde_output_configuration_v2::Event::Applied => state.applied = Some(true),
394            kde_output_configuration_v2::Event::Failed => state.applied = Some(false),
395            _ => {}
396        }
397    }
398}
399
400/// Connect to `socket_path` and read the output-device globals to a settled
401/// state: one round-trip announces them, a second delivers their events.
402fn read_devices(
403    socket_path: &str,
404) -> Result<(Connection, EventQueue<KdeOutState>, KdeOutState), String> {
405    let stream =
406        UnixStream::connect(socket_path).map_err(|e| format!("connect {socket_path}: {e}"))?;
407    let conn = Connection::from_socket(stream).map_err(|e| format!("wayland setup: {e}"))?;
408    let mut queue: EventQueue<KdeOutState> = conn.new_event_queue();
409    let qh = queue.handle();
410    let _registry = conn.display().get_registry(&qh, ());
411    let mut state = KdeOutState::default();
412    bounded_roundtrip(&conn, &mut queue, &mut state)?;
413    bounded_roundtrip(&conn, &mut queue, &mut state)?;
414    Ok((conn, queue, state))
415}
416
417/// Indices of the enabled devices in name order — the order screens map to
418/// displays, matching `outclient`'s addressing.
419fn enabled_sorted(state: &KdeOutState) -> Vec<usize> {
420    let mut order: Vec<(usize, String)> = state
421        .devices
422        .iter()
423        .enumerate()
424        .filter(|(_, d)| d.enabled)
425        .map(|(i, d)| (i, d.name.clone().unwrap_or_default()))
426        .collect();
427    order.sort_by_key(|(_, name)| (trailing_number(name), name.clone()));
428    order.into_iter().map(|(i, _)| i).collect()
429}
430
431/// The session's enabled screens as `(name, x, y, width, height)` in screen
432/// order; the size is the current mode's, `(0, 0)` where none was announced.
433/// Empty when the compositor serves no `kde_output_device_v2` globals.
434pub fn list_screens(socket_path: &str) -> Result<Vec<super::AppScreen>, String> {
435    let (_conn, _queue, state) = read_devices(socket_path)?;
436    let screens = enabled_sorted(&state)
437        .into_iter()
438        .map(|i| {
439            let d = &state.devices[i];
440            let (w, h) = d
441                .current
442                .as_ref()
443                .and_then(|mode| state.modes.iter().find(|(m, _)| m == mode))
444                .map(|(_, size)| *size)
445                .unwrap_or((0, 0));
446            (d.name.clone().unwrap_or_default(), d.pos.0, d.pos.1, w, h)
447        })
448        .collect();
449    Ok(screens)
450}
451
452/// Position the session's screens at `rects`, one `(x, y, width, height)` per
453/// screen in screen order; sizes are validated but not applied, because a
454/// nested KWin screen's size follows its host window. The rectangles are
455/// capture pixels and the positions land in the session's logical space, closed
456/// up around each screen's scale (`outclient::close_gaps`). Returns how many
457/// were positioned; 0 when the compositor serves no `kde_output_management_v2`.
458pub fn set_screen_layout(
459    socket_path: &str,
460    rects: Vec<(i32, i32, i32, i32)>,
461) -> Result<usize, String> {
462    if rects.iter().any(|(_, _, w, h)| *w <= 0 || *h <= 0) {
463        return Err("a screen rectangle has a non-positive size".to_string());
464    }
465    let (conn, mut queue, mut state) = read_devices(socket_path)?;
466    let Some(manager) = state.manager.clone() else {
467        return Ok(0);
468    };
469    let qh = queue.handle();
470    let order = enabled_sorted(&state);
471    let n = order.len().min(rects.len());
472    let sizes: Vec<(i32, i32)> = order
473        .iter()
474        .zip(&rects)
475        .map(|(&i, r)| {
476            let scale = state.devices[i].scale;
477            (
478                (r.2 as f64 / scale).round() as i32,
479                (r.3 as f64 / scale).round() as i32,
480            )
481        })
482        .collect();
483    let placed: Vec<(KdeOutputDeviceV2, (i32, i32))> = order
484        .iter()
485        .zip(crate::wayland::outclient::close_gaps(&rects[..n], &sizes))
486        .map(|(&i, p)| (state.devices[i].dev.clone(), (p.0, p.1)))
487        .collect();
488    if placed.is_empty() {
489        return Ok(0);
490    }
491    let config = manager.create_configuration(&qh, ());
492    for (dev, (x, y)) in &placed {
493        config.position(dev, *x, *y);
494    }
495    apply_configuration(&conn, &mut queue, &mut state, config)?;
496    Ok(placed.len())
497}
498
499/// Apply a configuration and wait, deadline-bounded, for the compositor's
500/// verdict on it; the configuration object is released either way.
501fn apply_configuration(
502    conn: &Connection,
503    queue: &mut EventQueue<KdeOutState>,
504    state: &mut KdeOutState,
505    config: KdeOutputConfigurationV2,
506) -> Result<(), String> {
507    state.applied = None;
508    config.apply();
509    queue
510        .flush()
511        .map_err(|e| format!("flush configuration: {e}"))?;
512    let deadline = Instant::now() + IO_TIMEOUT;
513    while state.applied.is_none() && Instant::now() < deadline {
514        bounded_roundtrip(conn, queue, state)?;
515    }
516    config.destroy();
517    let _ = queue.flush();
518    match state.applied {
519        Some(true) => Ok(()),
520        Some(false) => Err("the compositor refused the configuration".to_string()),
521        None => Err("timed out waiting for the configuration".to_string()),
522    }
523}