feat(vdisplay): mirror a pinned physical monitor on KWin

P2 of design/per-monitor-portal-capture.md. zkde_screencast's
stream_output records an output KWin already has — the connector name IS
the selection, so there is no dialog, no portal and no chooser for a
background service to answer.

It arrives as a VirtualDisplay reporting DisplayOwnership::External, which
already means "someone else's display, merely mirrored: no keep-alive, no
topology, no reuse". So the rule that we must never disable, move or
"restore" a monitor the user is sitting in front of holds by construction
rather than by everyone remembering it — and create() ignores the client's
requested mode for the same reason: a panel runs at the mode its owner set.

Routing lives in vdisplay::open, the one place every session opens a
display, so the pin cannot be honored on one plane and ignored on another.
The host sets the libei anchor from the same pin (pf-vdisplay must not
depend on pf-inject), which is what makes absolute input land on the
mirrored head instead of a same-sized neighbour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-27 23:51:41 +02:00
co-authored by Claude Opus 5
parent 2eeee650b9
commit 93c2765db7
4 changed files with 449 additions and 24 deletions
+20
View File
@@ -289,7 +289,19 @@ pub fn rebuild_probe_active() -> bool {
}
/// Open the virtual-display driver for `compositor`.
///
/// A `PUNKTFUNK_CAPTURE_MONITOR` pin routes to the **mirror** backend instead: the host streams
/// that physical head and creates no virtual display at all. Deliberately resolved here, at the one
/// place every session opens a display, so the pin can't be honored on one plane and ignored on
/// another — it is a host-wide setting (`design/per-monitor-portal-capture.md` §5.3).
pub fn open(compositor: Compositor) -> Result<Box<dyn VirtualDisplay>> {
#[cfg(target_os = "linux")]
if let Some(connector) = pf_host_config::config().capture_monitor.as_deref() {
return Ok(Box::new(mirror::MirrorDisplay::new(
compositor,
connector.to_string(),
)?));
}
#[cfg(target_os = "linux")]
{
match compositor {
@@ -366,6 +378,14 @@ pub mod policy;
#[path = "vdisplay/monitors.rs"]
pub mod monitors;
// The monitor-mirror backend: stream a head the compositor ALREADY has (the
// `PUNKTFUNK_CAPTURE_MONITOR` pin) instead of creating one. Implements `VirtualDisplay` so the
// session machinery is unchanged, but reports `DisplayOwnership::External` so none of the
// virtual-display lifecycle policy is applied to someone else's monitor.
#[cfg(target_os = "linux")]
#[path = "vdisplay/mirror.rs"]
mod mirror;
// The pure per-display lifecycle state machine (refcount + linger + pin), platform-neutral and
// property-tested; the registry executes the side effects its transitions dictate.
#[path = "vdisplay/lifecycle.rs"]
+200 -11
View File
@@ -32,6 +32,7 @@ use std::sync::mpsc::Sender;
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use wayland_client::protocol::wl_output::{self, WlOutput};
use wayland_client::protocol::wl_registry::{self, WlRegistry};
use wayland_client::{Connection, Dispatch, Proxy, QueueHandle};
@@ -914,6 +915,10 @@ struct State {
node_id: Option<u32>,
failed: Option<String>,
closed: bool,
/// Every `wl_output` KWin advertises, keyed by the proxy, with its connector name once the
/// `name` event arrives. Only the monitor-mirror path ([`stream_existing_output`]) needs these
/// — `stream_output` takes a `wl_output` object, so the connector has to be resolved to one.
outputs: Vec<(WlOutput, Option<String>)>,
}
impl Dispatch<WlRegistry, ()> for State {
@@ -934,6 +939,34 @@ impl Dispatch<WlRegistry, ()> for State {
if interface == Screencast::interface().name {
let v = version.min(MAX_VERSION);
state.screencast = Some(registry.bind::<Screencast, _, _>(name, v, qh, ()));
} else if interface == WlOutput::interface().name {
// v4 is where `wl_output.name` (the connector) arrives; bind at least that when the
// compositor offers it, else bind what it has and let the resolve fail loudly
// rather than mirroring an unidentifiable head.
let v = version.min(WL_OUTPUT_MAX_VERSION);
let out = registry.bind::<WlOutput, _, _>(name, v, qh, ());
state.outputs.push((out, None));
}
}
}
}
/// `wl_output` version we bind at: 4 brings the `name` event carrying the connector
/// (`DP-1`, …) — the only way to tell KWin's outputs apart on this connection.
const WL_OUTPUT_MAX_VERSION: u32 = 4;
impl Dispatch<WlOutput, ()> for State {
fn event(
state: &mut Self,
output: &WlOutput,
event: wl_output::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
if let wl_output::Event::Name { name } = event {
if let Some(slot) = state.outputs.iter_mut().find(|(o, _)| o == output) {
slot.1 = Some(name);
}
}
}
@@ -988,6 +1021,50 @@ fn virtual_output_thread(
}
}
/// Start recording the existing KWin output named `connector` (the monitor-mirror path), returning
/// its PipeWire node id and the keepalive whose drop stops the recording.
///
/// `hw_cursor` selects the pointer mode exactly as the virtual-output path does: metadata for a
/// cursor-channel session, embedded otherwise, so the cursor behaves the same whichever source a
/// session is on.
pub(crate) fn stream_existing_output(
connector: &str,
hw_cursor: bool,
) -> Result<(u32, Box<dyn Send>)> {
let pointer_mode = if hw_cursor {
POINTER_METADATA
} else {
POINTER_EMBEDDED
};
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 connector_thread = connector.to_string();
thread::Builder::new()
.name("punktfunk-kwin-mirror".into())
.spawn(move || {
if let Err(e) = run_existing(&connector_thread, pointer_mode, &setup_tx, &stop_thread) {
let _ = setup_tx.send(Err(format!("{e:#}")));
}
})
.context("spawn KWin monitor-mirror thread")?;
let node_id = match setup_rx.recv_timeout(Duration::from_secs(20)) {
Ok(Ok(v)) => v,
Ok(Err(e)) => bail!("KWin monitor mirror failed: {e}"),
Err(_) => bail!("timed out recording the KWin output {connector:?}"),
};
Ok((node_id, Box::new(StopOnDrop(stop)) as Box<dyn Send>))
}
/// Stops the mirror thread (and thus the recording) when the capturer drops it.
struct StopOnDrop(Arc<AtomicBool>);
impl Drop for StopOnDrop {
fn drop(&mut self) {
self.0.store(true, Ordering::Relaxed);
}
}
/// Readiness probe: connect to the KWin Wayland socket, roundtrip the registry, and confirm
/// the privileged `zkde_screencast` global is actually advertised. This is exactly what
/// [`run`] needs before it can create a virtual output, so a session-bringup script can poll
@@ -1019,6 +1096,106 @@ pub fn is_available() -> bool {
probe().is_ok()
}
/// Stream an **existing** KWin output — the monitor-mirror path
/// (`design/per-monitor-portal-capture.md` L1). Same privileged global and the same thread/keepalive
/// shape as the virtual-output path; `stream_output` simply takes a `wl_output` instead of minting
/// one, so there is no dialog, no portal, and no chooser: the connector name IS the selection.
///
/// Returns the PipeWire node id. The thread parks until `stop`, holding the Wayland connection that
/// is the cast's lifetime — dropping it stops the recording and leaves the monitor untouched (we
/// never created it, so there is nothing to tear down; §7.1).
fn run_existing(
connector: &str,
pointer_mode: 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();
// Two roundtrips: the first processes the globals (binding screencast + every wl_output), the
// second drains each output's property burst — the `name` event we resolve the connector by.
queue.roundtrip(&mut state).context("registry roundtrip")?;
queue
.roundtrip(&mut state)
.context("wl_output property roundtrip")?;
let screencast = state.screencast.clone().ok_or_else(|| {
anyhow!(
"KWin does not expose zkde_screencast_unstable_v1 to this client — install the host's \
.desktop (io.unom.Punktfunk.Host.desktop, X-KDE-Wayland-Interfaces) and re-login so \
KWin authorizes it, or run KWin with KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 (headless test)"
)
})?;
// Resolve the connector to a bound wl_output. A miss is a hard error naming what IS there:
// mirroring some other monitor because the requested one is unplugged shows the operator a
// screen they did not ask for, which is worse than a session that refuses with a reason.
let named: Vec<&str> = state
.outputs
.iter()
.filter_map(|(_, n)| n.as_deref())
.collect();
let output = state
.outputs
.iter()
.find(|(_, n)| n.as_deref() == Some(connector))
.or_else(|| {
state.outputs.iter().find(|(_, n)| {
n.as_deref()
.is_some_and(|n| n.eq_ignore_ascii_case(connector))
})
})
.map(|(o, _)| o.clone())
.ok_or_else(|| {
if named.is_empty() {
anyhow!(
"KWin advertised no named wl_output (needs wl_output v4 for the connector \
name) — cannot mirror {connector:?}"
)
} else {
anyhow!(
"KWin has no output named {connector:?} — it has: {}",
named.join(", ")
)
}
})?;
let stream = screencast.stream_output(&output, pointer_mode, &qh, ());
tracing::info!(
connector,
embedded_pointer = pointer_mode != POINTER_METADATA,
"KWin: recording an existing output; awaiting PipeWire node"
);
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_output failed: {e}");
}
if state.closed {
bail!("KWin closed the stream before it was created");
}
};
setup_tx
.send(Ok(node_id))
.map_err(|_| anyhow!("monitor-mirror opener went away"))?;
park_until_stopped(&conn, &mut queue, &mut state, stop, connector, node_id)?;
stream.close();
let _ = conn.flush();
Ok(())
}
fn run(
width: u32,
height: u32,
@@ -1080,15 +1257,31 @@ fn run(
.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.
park_until_stopped(&conn, &mut queue, &mut state, stop, name, node_id)?;
// Best-effort clean teardown; dropping the connection also makes KWin reclaim the output.
stream.close();
let _ = conn.flush();
Ok(())
}
/// Keep the connection (and thus the stream) alive until told to stop, observing `closed`.
/// `blocking_dispatch` can't be interrupted, so poll the connection fd with a short timeout and
/// honor `stop` within ~200 ms. Shared by the virtual-output and monitor-mirror paths — for a
/// virtual output this connection IS the output's lifetime; for a mirror it is only the
/// recording's, and the monitor itself is untouched either way.
fn park_until_stopped(
conn: &Connection,
queue: &mut wayland_client::EventQueue<State>,
state: &mut State,
stop: &AtomicBool,
output: &str,
node_id: u32,
) -> Result<()> {
while !stop.load(Ordering::Relaxed) {
queue
.dispatch_pending(&mut state)
.context("dispatch_pending")?;
queue.dispatch_pending(state).context("dispatch_pending")?;
if state.closed {
tracing::warn!(output = %name, node_id, "KWin closed the virtual-output stream");
tracing::warn!(output = %output, node_id, "KWin closed the screencast stream");
break;
}
conn.flush().context("wayland flush")?;
@@ -1110,10 +1303,6 @@ fn run(
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(())
}
+200
View File
@@ -0,0 +1,200 @@
//! The monitor-mirror display backend: stream a **physical** head the compositor already has,
//! instead of creating a virtual one. See `design/per-monitor-portal-capture.md`.
//!
//! It implements [`VirtualDisplay`] so it drops into the session machinery unchanged — but it
//! creates nothing, and that difference is load-bearing:
//!
//! * [`DisplayOwnership::External`] ("someone else's display, merely mirrored: no keep-alive, no
//! topology, no reuse") is exactly the contract for a head we don't own, so the registry's
//! pooling/linger/exclusive machinery passes it through. §7.1 of the design doc — never disable,
//! re-position or "restore" a monitor the user is sitting in front of — holds *by construction*
//! here rather than by everyone remembering it.
//! * `create()` ignores the requested [`Mode`]. A panel runs at the mode the user set; the client
//! scales. Reconfiguring someone's physical display to match a phone would be the same class of
//! rudeness as blanking it (§7.3).
//!
//! Selection is a **host-level pin** (`PUNKTFUNK_CAPTURE_MONITOR`), not a per-session choice —
//! the host-pinned decision of record (§5.3), which is also what makes the input anchor below
//! sound: one pin for the whole host, so the shared injector can't be re-aimed per session.
use super::backend::{DisplayOwnership, VirtualDisplay, VirtualOutput};
use super::monitors;
use crate::{Compositor, Mode};
use anyhow::{bail, Context, Result};
/// Streams an existing monitor, named by connector.
pub struct MirrorDisplay {
compositor: Compositor,
connector: String,
hw_cursor: bool,
}
impl MirrorDisplay {
pub fn new(compositor: Compositor, connector: String) -> Result<Self> {
Ok(MirrorDisplay {
compositor,
connector,
hw_cursor: false,
})
}
}
impl VirtualDisplay for MirrorDisplay {
fn name(&self) -> &'static str {
"mirror"
}
fn set_hw_cursor(&mut self, on: bool) {
self.hw_cursor = on;
}
fn hw_cursor(&self) -> bool {
self.hw_cursor
}
fn create(&mut self, _mode: Mode) -> Result<VirtualOutput> {
// Resolve the pin against the live head list FIRST: it yields the geometry the input anchor
// needs, and it turns "that monitor is gone" into one clear error before any compositor
// call. `resolve` refuses to substitute a different head (see its doc).
let monitors = monitors::list(self.compositor)
.with_context(|| format!("enumerate monitors to mirror {:?}", self.connector))?;
let target = monitors::resolve(&monitors, &self.connector)?;
check_mirrorable(target)?;
let origin = (target.x, target.y);
let dims = (target.width, target.height, refresh_hz(target.refresh_mhz));
let (node_id, keepalive) = match self.compositor {
#[cfg(target_os = "linux")]
Compositor::Kwin => {
crate::kwin::stream_existing_output(&target.connector, self.hw_cursor)?
}
other => bail!(
"mirroring an existing monitor is not implemented for the {} backend yet — \
unset PUNKTFUNK_CAPTURE_MONITOR",
other.id()
),
};
// NOTE: aiming absolute input at this head is the HOST's job, not ours — this crate must
// not depend on pf-inject (see the crate doc: "never on capture/inject"). The host sets the
// anchor from the same pin at startup; §7.2 of the design doc explains why it is host-level
// rather than set here per session.
tracing::info!(
connector = %target.connector,
mode = %target.mode_label(),
at = %format!("+{}+{}", origin.0, origin.1),
node_id,
"mirroring an existing monitor (no virtual display created)"
);
// The keepalive IS the recording: dropping it stops the cast and leaves the monitor exactly
// as it was (we created nothing, so there is nothing to restore).
let mut out = VirtualOutput::owned(node_id, Some(dims), keepalive);
// Never pooled, never lingered, never made primary/exclusive: we don't own this head.
out.ownership = DisplayOwnership::External;
Ok(out)
}
}
/// Can this head be mirrored at all? Both rejections are things a compositor would answer with
/// silence or a black stream, so they are caught here where the reason can be stated.
fn check_mirrorable(target: &monitors::PhysicalMonitor) -> Result<()> {
if !target.enabled {
bail!(
"monitor {:?} is disabled — enable it before streaming it",
target.connector
);
}
if target.managed {
bail!(
"monitor {:?} is one of punktfunk's own virtual displays, not a physical head — unset \
PUNKTFUNK_CAPTURE_MONITOR to use the normal virtual-display path",
target.connector
);
}
if target.width == 0 || target.height == 0 {
bail!(
"monitor {:?} reports no current mode ({}x{}) — it is not driving a signal",
target.connector,
target.width,
target.height
);
}
Ok(())
}
/// mHz → whole Hz for [`VirtualOutput::preferred_mode`], never 0 (the negotiation treats 0 as
/// "unset", and a head that didn't report a refresh is still running at *some* rate — 60 is the
/// same assumption KWin's own virtual outputs are born with).
fn refresh_hz(mhz: u32) -> u32 {
let hz = (mhz as f64 / 1000.0).round() as u32;
if hz == 0 {
60
} else {
hz
}
}
#[cfg(test)]
mod tests {
use super::*;
fn head(connector: &str) -> monitors::PhysicalMonitor {
monitors::PhysicalMonitor {
connector: connector.into(),
description: "ACME 27".into(),
width: 2560,
height: 1440,
refresh_mhz: 144_000,
x: 1920,
y: 0,
scale: 1.0,
primary: false,
enabled: true,
managed: false,
}
}
#[test]
fn a_real_enabled_head_is_mirrorable() {
assert!(check_mirrorable(&head("DP-2")).is_ok());
}
/// Streaming a dark head would be a black rectangle with no explanation — say why instead.
#[test]
fn a_disabled_head_is_refused_with_the_reason() {
let mut m = head("DP-2");
m.enabled = false;
let err = check_mirrorable(&m).unwrap_err().to_string();
assert!(err.contains("disabled"), "{err}");
}
/// Mirroring our OWN virtual display is a loop with extra steps; catch the misconfiguration
/// rather than streaming something confusing.
#[test]
fn one_of_our_own_virtual_displays_is_refused() {
let mut m = head("Virtual-punktfunk-1");
m.managed = true;
let err = check_mirrorable(&m).unwrap_err().to_string();
assert!(err.contains("virtual displays"), "{err}");
}
/// A head listed but not driving a mode (enabled yet modeless) would negotiate a 0x0 stream.
#[test]
fn a_head_with_no_current_mode_is_refused() {
let mut m = head("DP-2");
m.width = 0;
m.height = 0;
let err = check_mirrorable(&m).unwrap_err().to_string();
assert!(err.contains("no current mode"), "{err}");
}
#[test]
fn refresh_rounds_and_never_reports_zero() {
assert_eq!(refresh_hz(60000), 60);
assert_eq!(refresh_hz(59940), 60);
assert_eq!(refresh_hz(119_920), 120);
// An unreported refresh must not become a 0 Hz preferred mode.
assert_eq!(refresh_hz(0), 60);
}
}
+29 -13
View File
@@ -231,26 +231,42 @@ fn real_main() -> Result<()> {
);
}
// Report a `PUNKTFUNK_CAPTURE_MONITOR` pin at startupthe operator sets it in a host.env and
// then has no session to watch, so this is the only place a typo can surface. It is deliberately
// loud about not being enforced yet: a knob that is parsed but inert must SAY so, or a silent
// "it didn't work" reads as a bug.
// Resolve a `PUNKTFUNK_CAPTURE_MONITOR` pin at startup: report it (the operator sets it in a
// host.env and then has no session to watch, so this is where a typo has to surface), and aim
// absolute input at that head.
//
// The anchor is set HERE rather than inside the mirror backend for two reasons: pf-vdisplay must
// not depend on pf-inject (its crate doc), and the anchor is a host-level pin anyway — the
// injector is host-lifetime and shared by every concurrent session, so there is nothing
// per-session to track (`design/per-monitor-portal-capture.md` §7.2).
#[cfg(target_os = "linux")]
if !management_cli {
if let Some(want) = pf_host_config::config().capture_monitor.as_deref() {
match pf_vdisplay::detect().and_then(pf_vdisplay::monitors::list) {
Ok(ms) => match pf_vdisplay::monitors::resolve(&ms, want) {
Ok(m) => tracing::info!(
connector = %m.connector,
description = %m.description,
mode = %m.mode_label(),
at = %format!("+{}+{}", m.x, m.y),
"PUNKTFUNK_CAPTURE_MONITOR names this monitor — NOTE: per-monitor capture \
is not implemented yet, so sessions still use the normal display path"
),
Ok(m) => {
// Match the libei region by the head's ORIGIN: two monitors can share a
// size — and a mirrored head's region is not the client's size at all — so
// size matching would put the pointer on the wrong screen.
pf_inject::set_absolute_anchor(Some(pf_inject::AbsoluteAnchor {
origin: Some((m.x, m.y)),
mapping_id: None,
}));
tracing::info!(
connector = %m.connector,
description = %m.description,
mode = %m.mode_label(),
at = %format!("+{}+{}", m.x, m.y),
"PUNKTFUNK_CAPTURE_MONITOR: sessions will mirror this monitor (no \
virtual display) and absolute input is anchored to it"
);
}
// Left unanchored on purpose: a pin that resolves to nothing must not aim input
// at a guess. The session's own `create` fails with the same reason.
Err(e) => tracing::warn!(
error = %e,
"PUNKTFUNK_CAPTURE_MONITOR names no monitor on this host"
"PUNKTFUNK_CAPTURE_MONITOR names no monitor on this host — sessions will \
fail to start until it is corrected or unset"
),
},
Err(e) => tracing::warn!(