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:
Generated
+2
@@ -1528,9 +1528,11 @@ dependencies = [
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols-misc",
|
||||
"wayland-protocols-wlr",
|
||||
"wayland-scanner",
|
||||
"x509-parser",
|
||||
"xkbcommon",
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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::<CapturedFrame>(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<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));
|
||||
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<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
|
||||
.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).
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user