From 7d08e43c168a1e837f35343256250f5db8957351 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 9 Jun 2026 17:30:02 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20M2=20=E2=80=94=20KWin=20virtual-output?= =?UTF-8?q?=20backend=20behind=20a=20VirtualDisplay=20trait=20(native=20cl?= =?UTF-8?q?ient=20resolution)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Honor the client's requested resolution by rendering a compositor virtual output at exactly that size — native, headless, no scaling. There is no cross-compositor Wayland protocol for this, so it's a per-compositor backend behind the (previously stubbed) VirtualDisplay trait. - vdisplay.rs: VirtualDisplay::create(mode) now returns a live VirtualOutput { node_id, remote_fd: Option, keepalive } with RAII teardown (drop releases the output) instead of an inert OutputHandle + explicit destroy. Add compositor detect() (LUMEN_COMPOSITOR / XDG_CURRENT_DESKTOP). - vdisplay/kwin.rs: the KWin backend — the zkde_screencast_unstable_v1 stream_virtual_output client (vendored protocol XML + wayland-scanner codegen). Creates a WxH output, returns its PipeWire node (default daemon, remote_fd=None); a keepalive thread holds the Wayland connection until dropped. (Moved here from capture/kwin.rs — it's a vdisplay backend, not capture.) - capture: generalize the PipeWire consumer to Option (portal remote vs. default daemon) and add capture_virtual_output(vout), compositor-agnostic, owning the keepalive. - gamestream/stream.rs: LUMEN_VIDEO_SOURCE=virtual creates a virtual display sized to the client's cfg and captures it (self-contained, not pooled — a reconnect at a new resolution gets a fresh output). - m0: --source kwin-virtual goes through the trait. Verified end-to-end against the running headless KWin: the request reaches the compositor and is handled cleanly. Native creation needs a backend implementing createVirtualOutput — the DRM backend, or the VirtualBackend since KWin 6.5.6; on this box's --virtual 6.4.5 it returns "Could not find output" (expected; validates after the KWin upgrade). wlroots/Mutter backends are the next ones to land on the same seam. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 2 + crates/lumen-host/Cargo.toml | 5 + .../protocols/zkde-screencast-unstable-v1.xml | 105 +++++++ crates/lumen-host/src/capture.rs | 15 + crates/lumen-host/src/capture/linux.rs | 79 +++-- crates/lumen-host/src/gamestream/stream.rs | 26 ++ crates/lumen-host/src/m0.rs | 20 ++ crates/lumen-host/src/main.rs | 9 +- crates/lumen-host/src/vdisplay.rs | 131 +++++---- crates/lumen-host/src/vdisplay/kwin.rs | 273 ++++++++++++++++++ 10 files changed, 581 insertions(+), 84 deletions(-) create mode 100644 crates/lumen-host/protocols/zkde-screencast-unstable-v1.xml create mode 100644 crates/lumen-host/src/vdisplay/kwin.rs diff --git a/Cargo.lock b/Cargo.lock index 8df5a8a..6ab133e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1528,9 +1528,11 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "wayland-backend", "wayland-client", "wayland-protocols-misc", "wayland-protocols-wlr", + "wayland-scanner", "x509-parser", "xkbcommon", ] diff --git a/crates/lumen-host/Cargo.toml b/crates/lumen-host/Cargo.toml index 8615ce1..f5b06f6 100644 --- a/crates/lumen-host/Cargo.toml +++ b/crates/lumen-host/Cargo.toml @@ -49,6 +49,11 @@ tokio = { version = "1", features = ["rt", "rt-multi-thread", "net", "time"] } wayland-client = "0.31" wayland-protocols-wlr = { version = "0.3", features = ["client"] } wayland-protocols-misc = { version = "0.3", features = ["client"] } +# Codegen for KDE's `zkde_screencast_unstable_v1` (vendored in `protocols/`): create a KWin +# virtual output sized to the client's resolution and get its PipeWire node (KRdp's path). +# `wayland-backend` is referenced by the generated interface tables. +wayland-scanner = "0.31" +wayland-backend = "0.3" # Builds/validates the xkb keymap uploaded to the virtual keyboard + tracks modifier state. xkbcommon = "0.8" # Opus encode for the GameStream audio stream (links system libopus). diff --git a/crates/lumen-host/protocols/zkde-screencast-unstable-v1.xml b/crates/lumen-host/protocols/zkde-screencast-unstable-v1.xml new file mode 100644 index 0000000..aa32a91 --- /dev/null +++ b/crates/lumen-host/protocols/zkde-screencast-unstable-v1.xml @@ -0,0 +1,105 @@ + + + + + SPDX-License-Identifier: LGPL-2.1-or-later + ]]> + + + 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. + + + + + + + + + + + + + + + + + + + + + + + + + Destroy the zkde_screencast_unstable_v1 object. + + + + + + + + + + + + + + + + Since version 5, the compositor will choose the highest scale + factor for the region if the given scale is 0.0. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Deprecated since version 6, use the object serial from the serial event instead + + + + + + + + + + The pipewire object serial of the stream. Should be preferred over the node id which is prone to id reuse. + Will be sent before the created event. + + + + + + diff --git a/crates/lumen-host/src/capture.rs b/crates/lumen-host/src/capture.rs index de282bb..185321d 100644 --- a/crates/lumen-host/src/capture.rs +++ b/crates/lumen-host/src/capture.rs @@ -217,5 +217,20 @@ pub fn open_portal_monitor() -> Result> { anyhow::bail!("portal capture requires Linux (xdg-desktop-portal + PipeWire)") } +/// Build a capturer from an already-created virtual output (see [`crate::vdisplay`]). Consumes +/// the output's PipeWire node + optional remote fd + keepalive — the capturer owns the keepalive, +/// so dropping the capturer releases the virtual output. Compositor-agnostic: works for any +/// [`crate::vdisplay::VirtualDisplay`] backend. The captured size is the size the output was +/// created at — native, no scaling. +#[cfg(target_os = "linux")] +pub fn capture_virtual_output(vout: crate::vdisplay::VirtualOutput) -> Result> { + linux::PortalCapturer::from_virtual_output(vout).map(|c| Box::new(c) as Box) +} + +#[cfg(not(target_os = "linux"))] +pub fn capture_virtual_output(_vout: crate::vdisplay::VirtualOutput) -> Result> { + anyhow::bail!("virtual-output capture requires Linux") +} + #[cfg(target_os = "linux")] mod linux; diff --git a/crates/lumen-host/src/capture/linux.rs b/crates/lumen-host/src/capture/linux.rs index dd40af3..3c042e5 100644 --- a/crates/lumen-host/src/capture/linux.rs +++ b/crates/lumen-host/src/capture/linux.rs @@ -31,6 +31,10 @@ use std::time::Duration; pub struct PortalCapturer { frames: Receiver, active: Arc, + /// Owns the virtual output (if this capturer was built from one) — dropped when the capturer + /// is, releasing the compositor-side output via the keepalive's own `Drop`. `None` for the + /// portal source (its session ends with the portal thread's zbus connection). + _keepalive: Option>, } impl PortalCapturer { @@ -60,28 +64,52 @@ impl PortalCapturer { node_id, "ScreenCast portal session started; connecting PipeWire" ); - - // Frames flow from the pipewire thread over a small bounded channel. - let (frame_tx, frame_rx) = sync_channel::(8); - let active = Arc::new(AtomicBool::new(false)); - let active_cb = active.clone(); - let zerocopy = crate::zerocopy::enabled(); - thread::Builder::new() - .name("lumen-pipewire".into()) - .spawn(move || { - if let Err(e) = - pipewire::pipewire_thread(fd, node_id, frame_tx, active_cb, zerocopy) - { - tracing::error!(error = %format!("{e:#}"), "pipewire capture thread failed"); - } - }) - .context("spawn pipewire thread")?; - + let (frames, active) = spawn_pipewire(Some(fd), node_id)?; Ok(PortalCapturer { - frames: frame_rx, + frames, active, + _keepalive: None, }) } + + /// Build a capturer from an already-created virtual output ([`crate::vdisplay::VirtualOutput`]): + /// connect PipeWire to its node (`remote_fd` selects portal-remote vs. default-daemon) and + /// take ownership of its keepalive so the output lives exactly as long as this capturer. This + /// is how the client's requested resolution becomes the captured resolution without scaling. + pub fn from_virtual_output(vout: crate::vdisplay::VirtualOutput) -> Result { + tracing::info!( + node_id = vout.node_id, + "connecting PipeWire to virtual output" + ); + let (frames, active) = spawn_pipewire(vout.remote_fd, vout.node_id)?; + Ok(PortalCapturer { + frames, + active, + _keepalive: Some(vout.keepalive), + }) + } +} + +/// Spawn the PipeWire consumer thread for `node_id` (fd `Some` = portal remote, `None` = +/// default daemon) and return the frame channel + the activation flag it gates on. +fn spawn_pipewire( + fd: Option, + node_id: u32, +) -> Result<(Receiver, Arc)> { + // Frames flow from the pipewire thread over a small bounded channel. + let (frame_tx, frame_rx) = sync_channel::(8); + let active = Arc::new(AtomicBool::new(false)); + let active_cb = active.clone(); + let zerocopy = crate::zerocopy::enabled(); + thread::Builder::new() + .name("lumen-pipewire".into()) + .spawn(move || { + if let Err(e) = pipewire::pipewire_thread(fd, node_id, frame_tx, active_cb, zerocopy) { + tracing::error!(error = %format!("{e:#}"), "pipewire capture thread failed"); + } + }) + .context("spawn pipewire thread")?; + Ok((frame_rx, active)) } impl Capturer for PortalCapturer { @@ -419,7 +447,7 @@ mod pipewire { } pub fn pipewire_thread( - fd: OwnedFd, + fd: Option, node_id: u32, tx: SyncSender, active: Arc, @@ -429,9 +457,16 @@ mod pipewire { let mainloop = pw::main_loop::MainLoopRc::new(None).context("pw MainLoop")?; let context = pw::context::ContextRc::new(&mainloop, None).context("pw Context")?; - let core = context - .connect_fd_rc(fd, None) - .context("pw connect_fd (portal remote)")?; + // A portal source hands us an fd to a (sandboxed) PipeWire remote; the KWin + // virtual-output source has no fd — its node lives on the user's default daemon. + let core = match fd { + Some(fd) => context + .connect_fd_rc(fd, None) + .context("pw connect_fd (portal remote)")?, + None => context + .connect_rc(None) + .context("pw connect (default daemon)")?, + }; // Build the EGL→CUDA importer up front; if it fails, log and fall back to the CPU path // (we simply won't request dmabuf below). diff --git a/crates/lumen-host/src/gamestream/stream.rs b/crates/lumen-host/src/gamestream/stream.rs index d7a640a..c654700 100644 --- a/crates/lumen-host/src/gamestream/stream.rs +++ b/crates/lumen-host/src/gamestream/stream.rs @@ -77,6 +77,32 @@ fn run( .context("connect client video endpoint")?; tracing::info!(%client, "video: client endpoint learned"); + // Native client-resolution source: create a compositor virtual output sized to the client's + // request and capture it (no scaling). Self-contained — deliberately NOT pooled in + // `video_cap`, since a reconnect at a different resolution needs a freshly-sized output; the + // output is released when this capturer drops at stream end (RAII via its keepalive). + if std::env::var("LUMEN_VIDEO_SOURCE").as_deref() == Ok("virtual") { + let compositor = crate::vdisplay::detect().context("detect compositor")?; + tracing::info!( + ?compositor, + w = cfg.width, + h = cfg.height, + "video source: virtual display (native client resolution)" + ); + let mut vd = crate::vdisplay::open(compositor).context("open virtual display")?; + let vout = vd + .create(lumen_core::Mode { + width: cfg.width, + height: cfg.height, + refresh_hz: cfg.fps, + }) + .context("create virtual output at client resolution")?; + let mut capturer = + capture::capture_virtual_output(vout).context("capture virtual output")?; + capturer.set_active(true); + return stream_body(&mut *capturer, &sock, cfg, running, force_idr); + } + // Reuse the persistent capturer (one screencast session → clean reconnect); create it on // the first stream. Borrow it for this stream and return it on exit. let mut capturer: Box = match video_cap.lock().unwrap().take() { diff --git a/crates/lumen-host/src/m0.rs b/crates/lumen-host/src/m0.rs index 4c18645..fe40a97 100644 --- a/crates/lumen-host/src/m0.rs +++ b/crates/lumen-host/src/m0.rs @@ -24,6 +24,9 @@ pub enum Source { Synthetic, /// Live monitor via the xdg ScreenCast portal + PipeWire. Portal, + /// KWin virtual output created at `width`x`height` (zkde_screencast). Lets us validate + /// capture (and zero-copy) at an arbitrary client resolution against a headless KWin. + KwinVirtual, } #[derive(Clone, Debug)] @@ -57,6 +60,23 @@ pub fn run(opts: Options) -> Result<()> { tracing::info!("M0 source: xdg ScreenCast portal (live monitor)"); capture::open_portal_monitor().context("open portal capturer")? } + Source::KwinVirtual => { + tracing::info!( + width = opts.width, + height = opts.height, + "M0 source: KWin virtual output (zkde_screencast)" + ); + let mut vd = crate::vdisplay::open(crate::vdisplay::Compositor::Kwin) + .context("open KWin virtual display")?; + let vout = vd + .create(lumen_core::Mode { + width: opts.width, + height: opts.height, + refresh_hz: opts.fps, + }) + .context("create KWin virtual output")?; + capture::capture_virtual_output(vout).context("capture virtual output")? + } }; // Activate the capturer so the portal/PipeWire process callback actually delivers frames diff --git a/crates/lumen-host/src/main.rs b/crates/lumen-host/src/main.rs index ae67dd0..c13b874 100644 --- a/crates/lumen-host/src/main.rs +++ b/crates/lumen-host/src/main.rs @@ -145,7 +145,10 @@ fn parse_m0(args: &[String]) -> Result { source = match next()?.as_str() { "synthetic" => Source::Synthetic, "portal" => Source::Portal, - other => bail!("unknown --source '{other}' (synthetic|portal)"), + "kwin-virtual" => Source::KwinVirtual, + other => { + bail!("unknown --source '{other}' (synthetic|portal|kwin-virtual)") + } } } "--width" => { @@ -223,7 +226,9 @@ USAGE: lumen-host m0 [OPTIONS] M0 capture→encode→file pipeline spike OPTIONS: - --source frame source (default: portal) + --source + frame source (default: portal). 'kwin-virtual' creates a + KWin virtual output at --width x --height and captures it --seconds capture duration in seconds (default: 5) --fps target frame rate (default: 60) --codec NVENC codec (default: h265) diff --git a/crates/lumen-host/src/vdisplay.rs b/crates/lumen-host/src/vdisplay.rs index 76ecf98..bca44ac 100644 --- a/crates/lumen-host/src/vdisplay.rs +++ b/crates/lumen-host/src/vdisplay.rs @@ -1,49 +1,99 @@ //! Virtual display orchestration (plan §6) — the project's differentiator. //! -//! A [`VirtualDisplay`] creates a client-sized output on demand, to be captured and -//! streamed, then torn down on disconnect. Two deployment models exist (Model A: attach -//! to the running session; Model B: dedicated headless session); both sit behind this -//! trait so compositors are pluggable and a stuck one never blocks the project. +//! A [`VirtualDisplay`] creates a *client-sized* output on demand, rendered natively and +//! headless (no scaling), to be captured and streamed, then torn down on disconnect. There is +//! no cross-compositor Wayland protocol for this, so each compositor has its own backend behind +//! this trait: //! -//! Backends are `#[cfg(target_os = "linux")]` and currently stubs (see the per-backend -//! modules). The MVP target is KWin; a wlroots spike validates the pipeline first. +//! * **KWin** — privileged `zkde_screencast_unstable_v1::stream_virtual_output` ([`kwin`]). +//! * **wlroots/Sway** — `swaymsg create_output` + `output mode --custom` (TODO). +//! * **Mutter/GNOME** — D-Bus `RemoteDesktop` + `ScreenCast.RecordVirtual` (TODO). +//! +//! [`VirtualDisplay::create`] returns a [`VirtualOutput`]: the PipeWire node to capture plus an +//! owned keepalive whose `Drop` releases the output (RAII — no explicit `destroy`). Capture +//! consumes the node via [`crate::capture::capture_virtual_output`]. use anyhow::Result; pub use lumen_core::Mode; +use std::os::fd::OwnedFd; -/// Opaque handle to a created virtual output, returned by [`VirtualDisplay::create`]. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct OutputHandle(pub u64); +/// A created virtual output: a PipeWire source to capture, plus an owned keepalive whose drop +/// tears the output down (releases the compositor-side resource). +/// +/// Allowed dead on non-Linux: the backends that construct it are all `cfg(target_os = "linux")`. +#[allow(dead_code)] +pub struct VirtualOutput { + /// PipeWire node id of the output's screencast stream. + pub node_id: u32, + /// Portal/remote PipeWire fd when the node lives on a sandboxed remote (e.g. Mutter's + /// RemoteDesktop+ScreenCast). `None` means the node is on the user's default PipeWire daemon + /// (KWin `zkde_screencast`), captured by connecting to that daemon directly. + pub remote_fd: Option, + /// Keeps the output — and whatever connection/thread backs it — alive; dropped on teardown. + pub keepalive: Box, +} /// Pluggable virtual-output creation, per compositor. pub trait VirtualDisplay: Send { /// Human-readable backend name (e.g. `"kwin"`, `"wlroots"`, `"mutter"`). fn name(&self) -> &'static str; - /// Create a virtual output of the given mode. - fn create(&mut self, mode: Mode) -> Result; - /// Destroy a previously created output. - fn destroy(&mut self, handle: OutputHandle) -> Result<()>; + /// Create a virtual output of the given mode. Teardown is RAII: drop the returned + /// [`VirtualOutput`]'s `keepalive`. + fn create(&mut self, mode: Mode) -> Result; } /// Compositors lumen knows how to drive (plan §6). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Compositor { - /// KWin / Plasma 6 — MVP target (matches the CachyOS/KDE daily driver). + /// KWin / Plasma 6 — `zkde_screencast` virtual output. Kwin, - /// wlroots (Sway/Hyprland) — fastest to prototype the pipeline. + /// wlroots (Sway/Hyprland) — headless `create_output`. Wlroots, - /// Mutter / GNOME — headless backend + Mutter DBus. + /// Mutter / GNOME — headless backend + Mutter DBus `RecordVirtual`. Mutter, } -/// Detect or select a backend and return its driver. +/// Detect the compositor to drive: `LUMEN_COMPOSITOR` override, else `XDG_CURRENT_DESKTOP`. +pub fn detect() -> Result { + if let Ok(v) = std::env::var("LUMEN_COMPOSITOR") { + return match v.trim().to_ascii_lowercase().as_str() { + "kwin" | "kde" | "plasma" => Ok(Compositor::Kwin), + "wlroots" | "sway" | "hyprland" | "wlr" => Ok(Compositor::Wlroots), + "mutter" | "gnome" => Ok(Compositor::Mutter), + other => anyhow::bail!("unknown LUMEN_COMPOSITOR '{other}' (kwin|wlroots|mutter)"), + }; + } + let desktop = std::env::var("XDG_CURRENT_DESKTOP") + .unwrap_or_default() + .to_ascii_uppercase(); + if desktop.contains("KDE") { + Ok(Compositor::Kwin) + } else if desktop.contains("GNOME") { + Ok(Compositor::Mutter) + } else if desktop.contains("SWAY") + || desktop.contains("WLROOTS") + || desktop.contains("HYPRLAND") + { + Ok(Compositor::Wlroots) + } else { + anyhow::bail!( + "could not detect compositor from XDG_CURRENT_DESKTOP='{desktop}'; set LUMEN_COMPOSITOR" + ) + } +} + +/// Open the virtual-display driver for `compositor`. pub fn open(compositor: Compositor) -> Result> { #[cfg(target_os = "linux")] { match compositor { - Compositor::Kwin => Ok(Box::new(linux::kwin::KwinDisplay::new()?)), - Compositor::Wlroots => Ok(Box::new(linux::wlroots::WlrootsDisplay::new()?)), - Compositor::Mutter => Ok(Box::new(linux::mutter::MutterDisplay::new()?)), + Compositor::Kwin => Ok(Box::new(kwin::KwinDisplay::new()?)), + Compositor::Wlroots => { + anyhow::bail!("wlroots virtual-output backend not yet implemented") + } + Compositor::Mutter => { + anyhow::bail!("mutter virtual-output backend not yet implemented") + } } } #[cfg(not(target_os = "linux"))] @@ -54,43 +104,4 @@ pub fn open(compositor: Compositor) -> Result> { } #[cfg(target_os = "linux")] -mod linux { - //! Linux backends. TODO(M2): drive KWin via DBus (study KRdp's source for the - //! virtual-output path); wlroots via `create_output` on the headless backend; - //! Mutter via `org.gnome.Mutter.*`. - macro_rules! stub_backend { - ($modname:ident, $ty:ident, $name:literal) => { - pub mod $modname { - use super::super::{Mode, OutputHandle, VirtualDisplay}; - use anyhow::Result; - - pub struct $ty; - impl $ty { - pub fn new() -> Result { - Ok($ty) - } - } - impl VirtualDisplay for $ty { - fn name(&self) -> &'static str { - $name - } - fn create(&mut self, _mode: Mode) -> Result { - anyhow::bail!(concat!( - $name, - " virtual-output creation not yet implemented" - )) - } - fn destroy(&mut self, _handle: OutputHandle) -> Result<()> { - anyhow::bail!(concat!( - $name, - " virtual-output destroy not yet implemented" - )) - } - } - } - }; - } - stub_backend!(kwin, KwinDisplay, "kwin"); - stub_backend!(wlroots, WlrootsDisplay, "wlroots"); - stub_backend!(mutter, MutterDisplay, "mutter"); -} +mod kwin; diff --git a/crates/lumen-host/src/vdisplay/kwin.rs b/crates/lumen-host/src/vdisplay/kwin.rs new file mode 100644 index 0000000..1249ba9 --- /dev/null +++ b/crates/lumen-host/src/vdisplay/kwin.rs @@ -0,0 +1,273 @@ +//! KWin virtual-output backend via the privileged `zkde_screencast_unstable_v1` Wayland +//! protocol (the mechanism KRdp / krfb-virtualmonitor use). +//! +//! `stream_virtual_output(name, width, height, scale, pointer)` asks KWin to create a new output +//! sized to exactly `width`x`height`, rendered natively (no scaling), and hands back a PipeWire +//! node for it. The node lives on the user's default PipeWire daemon, so [`VirtualOutput::remote_fd`] +//! is `None` and capture connects to that daemon directly. +//! +//! Requirements: KWin must expose the privileged `zkde_screencast` global — a real Plasma session +//! authorizes it for its own clients; the headless test exposes it to bare clients via +//! `KWIN_WAYLAND_NO_PERMISSION_CHECKS=1`. The compositor backend must implement +//! `createVirtualOutput`: the **DRM backend** (any version) or the **VirtualBackend since KWin +//! 6.5.6** (`kwin_wayland --virtual`); on `--virtual` < 6.5.6 the request fails with +//! "Could not find output". We talk raw Wayland on `$WAYLAND_DISPLAY`, so the host must run inside +//! the KWin session's environment. + +#![allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)] + +use super::{Mode, VirtualDisplay, VirtualOutput}; +use anyhow::{anyhow, bail, Context, Result}; +use std::os::fd::{AsFd, AsRawFd}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::Sender; +use std::sync::Arc; +use std::thread; +use std::time::Duration; +use wayland_client::protocol::wl_registry::{self, WlRegistry}; +use wayland_client::{Connection, Dispatch, Proxy, QueueHandle}; + +// Generate the client bindings for the vendored protocol XML inline (no build.rs). Path is +// relative to CARGO_MANIFEST_DIR. See wayland-rs' "implementing a custom protocol" docs. +#[allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)] +pub mod zkde { + use wayland_client; + use wayland_client::protocol::*; + + pub mod __interfaces { + use wayland_client::protocol::__interfaces::*; + wayland_scanner::generate_interfaces!("protocols/zkde-screencast-unstable-v1.xml"); + } + use self::__interfaces::*; + + wayland_scanner::generate_client_code!("protocols/zkde-screencast-unstable-v1.xml"); +} + +use zkde::zkde_screencast_stream_unstable_v1::{ + Event as StreamEvent, ZkdeScreencastStreamUnstableV1 as ScreencastStream, +}; +use zkde::zkde_screencast_unstable_v1::ZkdeScreencastUnstableV1 as Screencast; + +/// `pointer` attachment mode (the protocol enum): render the cursor into the stream so the +/// remote sees it move with injected input. +const POINTER_EMBEDDED: u32 = 2; + +/// Highest interface version we drive. KWin currently advertises 5; we rely on the `created` +/// event (deprecated only since v6) for the node id, so cap the bind at 5. +const MAX_VERSION: u32 = 5; + +/// The KWin virtual-display driver. Stateless — each [`create`](VirtualDisplay::create) spins up +/// its own Wayland connection/thread that owns the resulting output. +pub struct KwinDisplay; + +impl KwinDisplay { + pub fn new() -> Result { + Ok(KwinDisplay) + } +} + +impl VirtualDisplay for KwinDisplay { + fn name(&self) -> &'static str { + "kwin" + } + + fn create(&mut self, mode: Mode) -> Result { + let (setup_tx, setup_rx) = std::sync::mpsc::channel::>(); + let stop = Arc::new(AtomicBool::new(false)); + let stop_thread = stop.clone(); + let (width, height) = (mode.width, mode.height); + thread::Builder::new() + .name("lumen-kwin-vout".into()) + .spawn(move || virtual_output_thread(width, height, setup_tx, stop_thread)) + .context("spawn KWin virtual-output thread")?; + + let node_id = match setup_rx.recv_timeout(Duration::from_secs(20)) { + Ok(Ok(v)) => v, + Ok(Err(e)) => bail!("KWin virtual output failed: {e}"), + Err(_) => bail!("timed out creating the KWin virtual output"), + }; + tracing::info!(node_id, width, height, "KWin virtual output ready"); + Ok(VirtualOutput { + node_id, + remote_fd: None, + keepalive: Box::new(StopGuard(stop)), + }) + } +} + +/// 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. +struct StopGuard(Arc); + +impl Drop for StopGuard { + fn drop(&mut self) { + self.0.store(true, Ordering::Relaxed); + } +} + +#[derive(Default)] +struct State { + screencast: Option, + node_id: Option, + failed: Option, + closed: bool, +} + +impl Dispatch for State { + fn event( + state: &mut Self, + registry: &WlRegistry, + event: wl_registry::Event, + _: &(), + _: &Connection, + qh: &QueueHandle, + ) { + if let wl_registry::Event::Global { + name, + interface, + version, + } = event + { + if interface == Screencast::interface().name { + let v = version.min(MAX_VERSION); + state.screencast = Some(registry.bind::(name, v, qh, ())); + } + } + } +} + +// The manager has no events. +impl Dispatch for State { + fn event( + _: &mut Self, + _: &Screencast, + _: zkde::zkde_screencast_unstable_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + } +} + +impl Dispatch for State { + fn event( + state: &mut Self, + _: &ScreencastStream, + event: StreamEvent, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + match event { + StreamEvent::Created { node } => state.node_id = Some(node), + StreamEvent::Failed { error } => state.failed = Some(error), + StreamEvent::Closed => state.closed = true, + // `serial` (v6) — we use the node id from `created`, so ignore. + _ => {} + } + } +} + +/// Worker thread: create a `width`x`height` virtual output on KWin, send its PipeWire node id +/// back over `setup_tx`, then keep the Wayland connection alive (so the output isn't destroyed) +/// until `stop` is set. Mirrors the portal thread's "park to keep the session alive". +fn virtual_output_thread( + width: u32, + height: u32, + setup_tx: Sender>, + stop: Arc, +) { + if let Err(e) = run(width, height, &setup_tx, &stop) { + // If we never delivered a node id, report the failure to the waiting opener. + let _ = setup_tx.send(Err(format!("{e:#}"))); + } +} + +fn run( + width: u32, + height: u32, + setup_tx: &Sender>, + stop: &AtomicBool, +) -> Result<()> { + let conn = Connection::connect_to_env() + .context("connect to KWin Wayland (is WAYLAND_DISPLAY set to the KWin socket?)")?; + let mut queue = conn.new_event_queue(); + let qh = queue.handle(); + let _registry = conn.display().get_registry(&qh, ()); + + let mut state = State::default(); + queue.roundtrip(&mut state).context("registry roundtrip")?; + + let screencast = state.screencast.clone().ok_or_else(|| { + anyhow!( + "KWin does not expose zkde_screencast_unstable_v1 (need a real KDE session, or run \ + KWin with KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 for the headless test)" + ) + })?; + + // Create the virtual output sized to the client, cursor composited into the stream. + let stream = screencast.stream_virtual_output( + "lumen".to_string(), + width as i32, + height as i32, + 1.0, // scale (logical == physical) + POINTER_EMBEDDED, + &qh, + (), + ); + tracing::info!( + width, + height, + "KWin: requested virtual output; awaiting PipeWire node" + ); + + // Pump events until KWin reports the node id (or an error). + let node_id = loop { + queue + .blocking_dispatch(&mut state) + .context("wayland dispatch (awaiting created)")?; + if let Some(node) = state.node_id { + break node; + } + if let Some(e) = state.failed.take() { + bail!("stream_virtual_output failed: {e}"); + } + if state.closed { + bail!("KWin closed the stream before it was created"); + } + }; + setup_tx + .send(Ok(node_id)) + .map_err(|_| anyhow!("virtual-output opener went away"))?; + + // Keep the connection (and thus the virtual output) alive until told to stop, observing + // `closed`. blocking_dispatch can't be interrupted, so poll the connection fd with a short + // timeout so `stop` is honored within ~200 ms. + while !stop.load(Ordering::Relaxed) { + queue + .dispatch_pending(&mut state) + .context("dispatch_pending")?; + if state.closed { + tracing::warn!("KWin closed the virtual-output stream"); + break; + } + conn.flush().context("wayland flush")?; + let Some(guard) = conn.prepare_read() else { + continue; // events already queued — loop dispatches them + }; + let mut pfd = libc::pollfd { + fd: conn.as_fd().as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }; + let r = unsafe { libc::poll(&mut pfd, 1, 200) }; + if r > 0 && (pfd.revents & libc::POLLIN) != 0 { + let _ = guard.read(); + } // else: timeout or signal — drop the guard, re-check `stop` + } + + // Best-effort clean teardown; dropping the connection also makes KWin reclaim the output. + stream.close(); + let _ = conn.flush(); + Ok(()) +}