From b08226dbf1da8237535488ba4eed9eed1b7fa4ee Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 27 Jul 2026 22:27:48 +0200 Subject: [PATCH] feat(vdisplay): mirror a pinned monitor on sway and Hyprland MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P5 of design/per-monitor-portal-capture.md, and the end of a hole worth naming: the pin was accepted, persisted and offered in the console while `create` bailed on these two backends, so a sway or Hyprland host with a monitor selected failed EVERY session. Both already ship the mechanism — a managed portal-chooser config plus a per-session selection file (xdpw's `Monitor: NAME`, xdph's `[SELECTION]screen:NAME`) — so mirroring is that same chooser pointed at a physical connector instead of a headless output we created. The keepalive stops the cast and nothing else; the monitor is the compositor's. The selection write is now serialized against the handshake that reads it (SELECTION_LOCK, applied to the virtual-output paths too). That file is one per-user path: whoever writes last before the portal reads wins, and the loser does not fail — it silently captures the other session's output. `managed` is no longer grounds for refusal everywhere. It is conclusive only where the name is ours by construction (KWin's `Virtual-punktfunk`, Hyprland's `PF-N`); sway names EVERY headless output `HEADLESS-N`, its own included, so the old rule would have refused to mirror any output on a headless sway box — exactly the remote setup this feature serves. Found on-glass, and now a test. Verified on a two-output headless sway: asking for HEADLESS-1 yields 1280x720 frames and HEADLESS-2 yields 1920x1080, so the chooser really does steer per request. Hyprland is compile-only — no Hyprland box exists. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/vdisplay/linux/hyprland.rs | 68 +++++++++---- crates/pf-vdisplay/src/vdisplay/linux/kwin.rs | 9 +- .../pf-vdisplay/src/vdisplay/linux/mutter.rs | 9 +- .../pf-vdisplay/src/vdisplay/linux/wlroots.rs | 74 ++++++++++---- crates/pf-vdisplay/src/vdisplay/mirror.rs | 99 +++++++++++++++---- 5 files changed, 200 insertions(+), 59 deletions(-) diff --git a/crates/pf-vdisplay/src/vdisplay/linux/hyprland.rs b/crates/pf-vdisplay/src/vdisplay/linux/hyprland.rs index 656e6381..c0daf943 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/hyprland.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/hyprland.rs @@ -174,25 +174,12 @@ impl VirtualDisplay for HyprlandDisplay { set_monitor_rule(&name, mode).with_context(|| format!("set monitor rule for {name}"))?; // Steer xdph's custom picker at our new output, then run the portal handshake on its own - // thread (it parks to keep the cast alive, like the other backends). - ensure_xdph_config()?; - let sel = selection_file(); - std::fs::write(&sel, picker_selection_line(&name)) - .with_context(|| format!("write {sel}"))?; - - let (setup_tx, setup_rx) = std::sync::mpsc::channel::>(); - let stop = Arc::new(AtomicBool::new(false)); - let stop_thread = stop.clone(); - let hw_cursor = self.hw_cursor; - thread::Builder::new() - .name("punktfunk-hypr-vout".into()) - .spawn(move || portal_thread(setup_tx, stop_thread, hw_cursor)) - .context("spawn hyprland portal thread")?; - - let (fd, node_id) = match setup_rx.recv_timeout(Duration::from_secs(20)) { - Ok(Ok(v)) => v, - Ok(Err(e)) => bail!("ScreenCast portal on {name} failed: {e}"), - Err(_) => bail!("timed out waiting for the ScreenCast portal on {name}"), + // thread (it parks to keep the cast alive, like the other backends). Serialized: the + // selection is one per-user file, so a concurrent session's write between ours and xdph's + // read would silently capture the wrong output (see `SELECTION_LOCK`). + let (fd, node_id, stop) = { + let _sel = SELECTION_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + select_and_cast(&name, self.hw_cursor)? }; tracing::info!( node_id, @@ -272,6 +259,49 @@ fn hyprctl(args: &[&str]) -> Result { Ok(String::from_utf8_lossy(&out.stdout).into_owned()) } +/// Serializes **write-the-selection → complete-the-handshake**, process-wide — see the wlroots +/// backend's `SELECTION_LOCK`. The xdph selection is likewise one per-user file, so a concurrent +/// write between ours and xdph's read would silently steer capture at the other session's output. +static SELECTION_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/// Point xdph's custom picker at `output` and run the ScreenCast handshake, returning the portal fd +/// + node id and the guard that stops the cast. The caller must hold [`SELECTION_LOCK`]. +fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, Arc)> { + ensure_xdph_config()?; + let sel = selection_file(); + std::fs::write(&sel, picker_selection_line(output)).with_context(|| format!("write {sel}"))?; + let (setup_tx, setup_rx) = std::sync::mpsc::channel::>(); + let stop = Arc::new(AtomicBool::new(false)); + let stop_thread = stop.clone(); + thread::Builder::new() + .name("punktfunk-hypr-cast".into()) + .spawn(move || portal_thread(setup_tx, stop_thread, hw_cursor)) + .context("spawn hyprland portal thread")?; + match setup_rx.recv_timeout(Duration::from_secs(20)) { + Ok(Ok((fd, node_id))) => Ok((fd, node_id, stop)), + Ok(Err(e)) => bail!("ScreenCast portal on {output} failed: {e}"), + Err(_) => bail!("timed out waiting for the ScreenCast portal on {output}"), + } +} + +/// Record an **existing** Hyprland monitor — the monitor-mirror path +/// (`design/per-monitor-portal-capture.md` L3): the same custom-picker mechanism the virtual-output +/// path uses, pointed at a physical connector, so no GUI picker is involved. +/// +/// The keepalive stops the cast only — the monitor is Hyprland's, not ours. +pub(crate) fn stream_existing_output( + connector: &str, + hw_cursor: bool, +) -> Result { + let _sel = SELECTION_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let (fd, node_id, stop) = select_and_cast(connector, hw_cursor)?; + Ok(crate::mirror::MirrorStream { + node_id, + remote_fd: Some(fd), + keepalive: Box::new(StopGuard(stop)), + }) +} + /// Every head Hyprland reports, for [`crate::monitors::list`]. /// /// `hyprctl -j monitors all` (rather than plain `monitors`) so **disabled** heads are listed too — diff --git a/crates/pf-vdisplay/src/vdisplay/linux/kwin.rs b/crates/pf-vdisplay/src/vdisplay/linux/kwin.rs index 037e2998..f71e4df5 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/kwin.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/kwin.rs @@ -1030,7 +1030,7 @@ fn virtual_output_thread( pub(crate) fn stream_existing_output( connector: &str, hw_cursor: bool, -) -> Result<(u32, Box)> { +) -> Result { let pointer_mode = if hw_cursor { POINTER_METADATA } else { @@ -1053,7 +1053,12 @@ pub(crate) fn stream_existing_output( 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)) + Ok(crate::mirror::MirrorStream { + node_id, + // KWin publishes on the user's own PipeWire daemon — no portal remote to carry. + remote_fd: None, + keepalive: Box::new(StopOnDrop(stop)), + }) } /// Stops the mirror thread (and thus the recording) when the capturer drops it. diff --git a/crates/pf-vdisplay/src/vdisplay/linux/mutter.rs b/crates/pf-vdisplay/src/vdisplay/linux/mutter.rs index 57f3aa84..3cb6866e 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/mutter.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/mutter.rs @@ -390,7 +390,7 @@ fn session_thread( pub(crate) fn stream_existing_output( connector: &str, hw_cursor: bool, -) -> Result<(u32, Box)> { +) -> Result { let (setup_tx, setup_rx) = std::sync::mpsc::channel::>(); let stop = Arc::new(AtomicBool::new(false)); let stop_thread = stop.clone(); @@ -404,7 +404,12 @@ pub(crate) fn stream_existing_output( Ok(Err(e)) => bail!("Mutter monitor mirror failed: {e}"), Err(_) => bail!("timed out recording the Mutter output {connector:?}"), }; - Ok((node_id, Box::new(MirrorStop(stop)) as Box)) + Ok(crate::mirror::MirrorStream { + node_id, + // Mutter's RecordMonitor node lives on the user's PipeWire daemon (like RecordVirtual). + remote_fd: None, + keepalive: Box::new(MirrorStop(stop)), + }) } /// Stops the mirror thread — and with it the recording — when the capturer drops it. diff --git a/crates/pf-vdisplay/src/vdisplay/linux/wlroots.rs b/crates/pf-vdisplay/src/vdisplay/linux/wlroots.rs index dde6482b..74aad2a7 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/wlroots.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/wlroots.rs @@ -111,25 +111,13 @@ impl VirtualDisplay for WlrootsDisplay { swaymsg(&["output", &name, "enable"]) .with_context(|| format!("swaymsg output {name} enable"))?; - // Steer xdpw's headless output chooser at our new output, then run the portal - // handshake on its own thread (it parks to keep the cast alive, like the other backends). - ensure_xdpw_config()?; - let chooser = chooser_file(); - std::fs::write(&chooser, format!("Monitor: {name}\n")) - .with_context(|| format!("write {chooser}"))?; - let (setup_tx, setup_rx) = std::sync::mpsc::channel::>(); - let stop = Arc::new(AtomicBool::new(false)); - let stop_thread = stop.clone(); - let hw_cursor = self.hw_cursor; - thread::Builder::new() - .name("punktfunk-wlr-vout".into()) - .spawn(move || portal_thread(setup_tx, stop_thread, hw_cursor)) - .context("spawn wlroots portal thread")?; - - let (fd, node_id) = match setup_rx.recv_timeout(Duration::from_secs(20)) { - Ok(Ok(v)) => v, - Ok(Err(e)) => bail!("ScreenCast portal on {name} failed: {e}"), - Err(_) => bail!("timed out waiting for the ScreenCast portal on {name}"), + // Steer xdpw's headless output chooser at our new output, then run the portal handshake on + // its own thread (it parks to keep the cast alive, like the other backends). Serialized: + // the chooser is one per-user file, so a concurrent session's write between ours and xdpw's + // read would silently capture the wrong output (see `SELECTION_LOCK`). + let (fd, node_id, stop) = { + let _sel = SELECTION_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + select_and_cast(&name, self.hw_cursor)? }; tracing::info!( node_id, @@ -238,6 +226,54 @@ fn output_names() -> Result> { .collect()) } +/// Serializes **write-the-chooser → complete-the-handshake**, process-wide. +/// +/// The chooser is a single per-user file: whoever writes last before xdpw reads wins. Two sessions +/// starting at once (or a mirror starting beside a virtual output) would otherwise race, and the +/// loser doesn't fail — it silently captures the *other* session's output. Held across the portal +/// handshake, not just the write, because the read happens inside it. +static SELECTION_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/// Point xdpw's chooser at `output` and run the ScreenCast handshake, returning the portal fd + +/// node id and the guard that stops the cast. The caller must hold [`SELECTION_LOCK`]. +fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, Arc)> { + ensure_xdpw_config()?; + let chooser = chooser_file(); + std::fs::write(&chooser, format!("Monitor: {output}\n")) + .with_context(|| format!("write {chooser}"))?; + let (setup_tx, setup_rx) = std::sync::mpsc::channel::>(); + let stop = Arc::new(AtomicBool::new(false)); + let stop_thread = stop.clone(); + thread::Builder::new() + .name("punktfunk-wlr-cast".into()) + .spawn(move || portal_thread(setup_tx, stop_thread, hw_cursor)) + .context("spawn wlroots portal thread")?; + match setup_rx.recv_timeout(Duration::from_secs(20)) { + Ok(Ok((fd, node_id))) => Ok((fd, node_id, stop)), + Ok(Err(e)) => bail!("ScreenCast portal on {output} failed: {e}"), + Err(_) => bail!("timed out waiting for the ScreenCast portal on {output}"), + } +} + +/// Record an **existing** sway output — the monitor-mirror path +/// (`design/per-monitor-portal-capture.md` L3). Same chooser mechanism the virtual-output path +/// uses, pointed at a physical connector instead of a headless one we created, so it inherits the +/// "no GUI picker" property a background service needs. +/// +/// The keepalive stops the cast and nothing else: sway keeps the monitor, because we never made it. +pub(crate) fn stream_existing_output( + connector: &str, + hw_cursor: bool, +) -> Result { + let _sel = SELECTION_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let (fd, node_id, stop) = select_and_cast(connector, hw_cursor)?; + Ok(crate::mirror::MirrorStream { + node_id, + remote_fd: Some(fd), + keepalive: Box::new(StopGuard(stop)), + }) +} + /// Every head sway reports, for [`crate::monitors::list`]. /// /// `swaymsg -t get_outputs` reports `rect` in the logical coordinate space (post-scale, diff --git a/crates/pf-vdisplay/src/vdisplay/mirror.rs b/crates/pf-vdisplay/src/vdisplay/mirror.rs index 0bb84e1a..78c1043b 100644 --- a/crates/pf-vdisplay/src/vdisplay/mirror.rs +++ b/crates/pf-vdisplay/src/vdisplay/mirror.rs @@ -22,6 +22,19 @@ use super::monitors; use crate::{Compositor, Mode}; use anyhow::{bail, Context, Result}; +/// What a backend hands back when it starts recording an existing head. +/// +/// `remote_fd` is the split: KWin and Mutter publish the node on the user's own PipeWire daemon +/// (nothing to carry), while the portal-based backends (sway/xdpw, Hyprland/xdph) hand back a +/// sandboxed remote fd that the capturer must connect through — the same distinction their +/// *virtual*-output paths already make. +pub(crate) struct MirrorStream { + pub node_id: u32, + pub remote_fd: Option, + /// Dropping this ends the recording. It never owns the monitor — we did not create it. + pub keepalive: Box, +} + /// Streams an existing monitor, named by connector. pub struct MirrorDisplay { compositor: Compositor, @@ -59,11 +72,11 @@ impl VirtualDisplay for MirrorDisplay { 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)?; + check_mirrorable(target, self.compositor)?; let origin = (target.x, target.y); let dims = (target.width, target.height, refresh_hz(target.refresh_mhz)); - let (node_id, keepalive) = match self.compositor { + let stream = match self.compositor { #[cfg(target_os = "linux")] Compositor::Kwin => { crate::kwin::stream_existing_output(&target.connector, self.hw_cursor)? @@ -72,9 +85,19 @@ impl VirtualDisplay for MirrorDisplay { Compositor::Mutter => { crate::mutter::stream_existing_output(&target.connector, self.hw_cursor)? } + #[cfg(target_os = "linux")] + Compositor::Wlroots => { + crate::wlroots::stream_existing_output(&target.connector, self.hw_cursor)? + } + #[cfg(target_os = "linux")] + Compositor::Hyprland => { + crate::hyprland::stream_existing_output(&target.connector, self.hw_cursor)? + } + // gamescope is nested — it has no physical heads of its own, and `monitors::list` + // already returned an empty set, so `resolve` failed before we got here. This arm + // exists for exhaustiveness, not as a reachable path. other => bail!( - "mirroring an existing monitor is not implemented for the {} backend yet — \ - unset PUNKTFUNK_CAPTURE_MONITOR", + "mirroring an existing monitor is not supported on the {} backend", other.id() ), }; @@ -87,22 +110,25 @@ impl VirtualDisplay for MirrorDisplay { connector = %target.connector, mode = %target.mode_label(), at = %format!("+{}+{}", origin.0, origin.1), - node_id, + node_id = stream.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); + let mut out = VirtualOutput::owned(stream.node_id, Some(dims), stream.keepalive); + // Portal-based backends (sway/xdpw, Hyprland/xdph) publish on a sandboxed remote the + // capturer must connect through; KWin/Mutter use the user's own daemon. + out.remote_fd = stream.remote_fd; // 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<()> { +/// Can this head be mirrored at all? Each rejection is something a compositor would otherwise +/// answer with silence or a black stream, so it is caught here where the reason can be stated. +fn check_mirrorable(target: &monitors::PhysicalMonitor, compositor: Compositor) -> Result<()> { if !target.enabled { bail!( "monitor {:?} is disabled — enable it before streaming it", @@ -110,10 +136,23 @@ fn check_mirrorable(target: &monitors::PhysicalMonitor) -> Result<()> { ); } 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 + // `managed` is only *conclusive* where the name is ours by construction: KWin's + // `Virtual-punktfunk` prefix, Hyprland's `PF-N`. Sway names EVERY headless output + // `HEADLESS-N` — its own included — so refusing there would block a legitimate setup + // (a headless sway box whose outputs are all HEADLESS-N is exactly the remote case this + // feature serves). Warn and continue; a user who really did pin our own virtual display + // sees a mirror of it and the log says why. + if names_ours_conclusively(compositor) { + bail!( + "monitor {:?} is one of punktfunk's own virtual displays, not a physical head — \ + clear the streamed-screen setting to use the normal virtual-display path", + target.connector + ); + } + tracing::warn!( + connector = %target.connector, + "the pinned monitor looks like a headless output — on this compositor that name is \ + ambiguous (it may be one of punktfunk's own virtual displays), mirroring it anyway" ); } if target.width == 0 || target.height == 0 { @@ -127,6 +166,13 @@ fn check_mirrorable(target: &monitors::PhysicalMonitor) -> Result<()> { Ok(()) } +/// Does this compositor's `managed` flag mean "ours, for certain"? KWin outputs carry the +/// `Virtual-punktfunk` prefix we chose, and Hyprland's are `PF-N` — both ours by construction. +/// Sway's `HEADLESS-N` is sway's own generic naming, so it is a hint, not proof. +fn names_ours_conclusively(compositor: Compositor) -> bool { + matches!(compositor, Compositor::Kwin | Compositor::Hyprland) +} + /// 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). @@ -161,7 +207,7 @@ mod tests { #[test] fn a_real_enabled_head_is_mirrorable() { - assert!(check_mirrorable(&head("DP-2")).is_ok()); + assert!(check_mirrorable(&head("DP-2"), Compositor::Kwin).is_ok()); } /// Streaming a dark head would be a black rectangle with no explanation — say why instead. @@ -169,7 +215,9 @@ mod tests { 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(); + let err = check_mirrorable(&m, Compositor::Kwin) + .unwrap_err() + .to_string(); assert!(err.contains("disabled"), "{err}"); } @@ -179,17 +227,34 @@ mod tests { 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(); + let err = check_mirrorable(&m, Compositor::Kwin) + .unwrap_err() + .to_string(); assert!(err.contains("virtual displays"), "{err}"); } + /// Sway names every headless output `HEADLESS-N`, its own included, so the `managed` hint is + /// NOT proof there — refusing would block a headless sway box, which is exactly the remote + /// setup this feature serves. (Found on-glass: a two-output headless sway had both heads + /// flagged, and the KWin-strength rule would have refused to mirror either.) + #[test] + fn a_headless_sway_output_is_mirrored_despite_the_ambiguous_name() { + let mut m = head("HEADLESS-2"); + m.managed = true; + assert!(check_mirrorable(&m, Compositor::Wlroots).is_ok()); + // Hyprland's `PF-N` IS our naming, so it stays conclusive. + assert!(check_mirrorable(&m, Compositor::Hyprland).is_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(); + let err = check_mirrorable(&m, Compositor::Kwin) + .unwrap_err() + .to_string(); assert!(err.contains("no current mode"), "{err}"); }