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(())
+}