feat: M2 — KWin virtual-output backend behind a VirtualDisplay trait (native client resolution)

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<OwnedFd>, 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<OwnedFd> (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) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 17:30:02 +00:00
parent 6508980564
commit 7d08e43c16
10 changed files with 581 additions and 84 deletions
Generated
+2
View File
@@ -1528,9 +1528,11 @@ dependencies = [
"tokio",
"tracing",
"tracing-subscriber",
"wayland-backend",
"wayland-client",
"wayland-protocols-misc",
"wayland-protocols-wlr",
"wayland-scanner",
"x509-parser",
"xkbcommon",
]
+5
View File
@@ -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).
@@ -0,0 +1,105 @@
<?xml version="1.0" encoding="UTF-8"?>
<protocol name="zkde_screencast_unstable_v1">
<copyright><![CDATA[
SPDX-FileCopyrightText: 2020-2021 Aleix Pol Gonzalez <aleixpol@kde.org>
SPDX-License-Identifier: LGPL-2.1-or-later
]]></copyright>
<interface name="zkde_screencast_unstable_v1" version="6">
<description summary="Protocol for managing PipeWire feeds of the different displays and windows">
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.
</description>
<enum name="pointer">
<description summary="Stream consumer attachment attributes" />
<entry name="hidden" value="1" summary="No cursor"/>
<entry name="embedded" value="2" summary="Render the cursor on the stream"/>
<entry name="metadata" value="4" summary="Send metadata about where the cursor is through PipeWire"/>
</enum>
<request name="stream_output">
<description summary="requests a feed from a given source"/>
<arg name="stream" type="new_id" interface="zkde_screencast_stream_unstable_v1"/>
<arg name="output" type="object" interface="wl_output"/>
<arg name="pointer" type="uint" summary="Requested pointer mode"/>
</request>
<request name="stream_window">
<description summary="requests a feed from a given source"/>
<arg name="stream" type="new_id" interface="zkde_screencast_stream_unstable_v1"/>
<arg name="window_uuid" type="string" summary="window Identifier"/>
<arg name="pointer" type="uint" summary="Requested pointer mode"/>
</request>
<request name="destroy" type="destructor">
<description summary="Destroy the zkde_screencast_unstable_v1">
Destroy the zkde_screencast_unstable_v1 object.
</description>
</request>
<request name="stream_virtual_output" since="2">
<description summary="requests a feed from a new virtual output"/>
<arg name="stream" type="new_id" interface="zkde_screencast_stream_unstable_v1"/>
<arg name="name" type="string" summary="name of the created output"/>
<arg name="width" type="int" summary="Logical width resolution"/>
<arg name="height" type="int" summary="Logical height resolution"/>
<arg name="scale" type="fixed" summary="Scaling factor of the display where it's to be displayed"/>
<arg name="pointer" type="uint" summary="Requested pointer mode"/>
</request>
<request name="stream_region" since="3">
<description summary="requests a feed from region in the workspace">
Since version 5, the compositor will choose the highest scale
factor for the region if the given scale is 0.0.
</description>
<arg name="stream" type="new_id" interface="zkde_screencast_stream_unstable_v1"/>
<arg name="x" type="int" summary="Logical left position"/>
<arg name="y" type="int" summary="Logical top position"/>
<arg name="width" type="uint" summary="Logical width resolution"/>
<arg name="height" type="uint" summary="Logical height resolution"/>
<arg name="scale" type="fixed" summary="Scaling factor of the output recording"/>
<arg name="pointer" type="uint" summary="Requested pointer mode"/>
</request>
<request name="stream_virtual_output_with_description" since="4">
<description summary="requests a feed from a new virtual output"/>
<arg name="stream" type="new_id" interface="zkde_screencast_stream_unstable_v1"/>
<arg name="name" type="string" summary="name of the created output"/>
<arg name="description" type="string" summary="user visible description of the created output"/>
<arg name="width" type="int" summary="Logical width resolution"/>
<arg name="height" type="int" summary="Logical height resolution"/>
<arg name="scale" type="fixed" summary="Scaling factor of the display where it's to be displayed"/>
<arg name="pointer" type="uint" summary="Requested pointer mode"/>
</request>
</interface>
<interface name="zkde_screencast_stream_unstable_v1" version="6">
<request name="close" type="destructor">
<description summary="Indicates we are done with the stream and the communication is over."/>
</request>
<event name="closed">
<description summary="Notifies that the server has stopped the stream. Clients should now call close."/>
</event>
<event name="created" deprecated-since="6">
<description summary="Notifies about a pipewire feed being created">
Deprecated since version 6, use the object serial from the serial event instead
</description>
<arg name="node" type="uint" summary="node of the pipewire buffer"/>
</event>
<event name="failed">
<description summary="Offers an error message so the client knows the created event will not arrive, and the client should close the resource."/>
<arg name="error" type="string" summary="A human readable translated error message."/>
</event>
<event name="serial" since="6">
<description summary="the pipewire object serial">
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.
</description>
<arg name="object_serial_hi" type="uint" summary="high bits of the pipewire object serial"/>
<arg name="object_serial_low" type="uint" summary="low bits of the pipewire object serial"/>
</event>
</interface>
</protocol>
+15
View File
@@ -217,5 +217,20 @@ pub fn open_portal_monitor() -> Result<Box<dyn Capturer>> {
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<Box<dyn Capturer>> {
linux::PortalCapturer::from_virtual_output(vout).map(|c| Box::new(c) as Box<dyn Capturer>)
}
#[cfg(not(target_os = "linux"))]
pub fn capture_virtual_output(_vout: crate::vdisplay::VirtualOutput) -> Result<Box<dyn Capturer>> {
anyhow::bail!("virtual-output capture requires Linux")
}
#[cfg(target_os = "linux")]
mod linux;
+47 -12
View File
@@ -31,6 +31,10 @@ use std::time::Duration;
pub struct PortalCapturer {
frames: Receiver<CapturedFrame>,
active: Arc<AtomicBool>,
/// 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<Box<dyn Send>>,
}
impl PortalCapturer {
@@ -60,7 +64,38 @@ impl PortalCapturer {
node_id,
"ScreenCast portal session started; connecting PipeWire"
);
let (frames, active) = spawn_pipewire(Some(fd), node_id)?;
Ok(PortalCapturer {
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<PortalCapturer> {
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<OwnedFd>,
node_id: u32,
) -> Result<(Receiver<CapturedFrame>, Arc<AtomicBool>)> {
// Frames flow from the pipewire thread over a small bounded channel.
let (frame_tx, frame_rx) = sync_channel::<CapturedFrame>(8);
let active = Arc::new(AtomicBool::new(false));
@@ -69,19 +104,12 @@ impl PortalCapturer {
thread::Builder::new()
.name("lumen-pipewire".into())
.spawn(move || {
if let Err(e) =
pipewire::pipewire_thread(fd, node_id, frame_tx, active_cb, zerocopy)
{
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(PortalCapturer {
frames: frame_rx,
active,
})
}
Ok((frame_rx, active))
}
impl Capturer for PortalCapturer {
@@ -419,7 +447,7 @@ mod pipewire {
}
pub fn pipewire_thread(
fd: OwnedFd,
fd: Option<OwnedFd>,
node_id: u32,
tx: SyncSender<CapturedFrame>,
active: Arc<AtomicBool>,
@@ -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
// 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)")?;
.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).
@@ -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<dyn Capturer> = match video_cap.lock().unwrap().take() {
+20
View File
@@ -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
+7 -2
View File
@@ -145,7 +145,10 @@ fn parse_m0(args: &[String]) -> Result<Options> {
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 <synthetic|portal> frame source (default: portal)
--source <synthetic|portal|kwin-virtual>
frame source (default: portal). 'kwin-virtual' creates a
KWin virtual output at --width x --height and captures it
--seconds <N> capture duration in seconds (default: 5)
--fps <N> target frame rate (default: 60)
--codec <h264|h265|av1> NVENC codec (default: h265)
+71 -60
View File
@@ -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<OwnedFd>,
/// Keeps the output — and whatever connection/thread backs it — alive; dropped on teardown.
pub keepalive: Box<dyn Send>,
}
/// 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<OutputHandle>;
/// 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<VirtualOutput>;
}
/// 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<Compositor> {
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<Box<dyn VirtualDisplay>> {
#[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<Box<dyn VirtualDisplay>> {
}
#[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<Self> {
Ok($ty)
}
}
impl VirtualDisplay for $ty {
fn name(&self) -> &'static str {
$name
}
fn create(&mut self, _mode: Mode) -> Result<OutputHandle> {
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;
+273
View File
@@ -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<Self> {
Ok(KwinDisplay)
}
}
impl VirtualDisplay for KwinDisplay {
fn name(&self) -> &'static str {
"kwin"
}
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<u32, String>>();
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<AtomicBool>);
impl Drop for StopGuard {
fn drop(&mut self) {
self.0.store(true, Ordering::Relaxed);
}
}
#[derive(Default)]
struct State {
screencast: Option<Screencast>,
node_id: Option<u32>,
failed: Option<String>,
closed: bool,
}
impl Dispatch<WlRegistry, ()> for State {
fn event(
state: &mut Self,
registry: &WlRegistry,
event: wl_registry::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
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::<Screencast, _, _>(name, v, qh, ()));
}
}
}
}
// The manager has no events.
impl Dispatch<Screencast, ()> for State {
fn event(
_: &mut Self,
_: &Screencast,
_: zkde::zkde_screencast_unstable_v1::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
impl Dispatch<ScreencastStream, ()> for State {
fn event(
state: &mut Self,
_: &ScreencastStream,
event: StreamEvent,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
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<Result<u32, String>>,
stop: Arc<AtomicBool>,
) {
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<Result<u32, String>>,
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(())
}