feat(vdisplay): mirror a pinned monitor on sway and Hyprland

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) <noreply@anthropic.com>
This commit is contained in:
2026-07-27 23:51:41 +02:00
co-authored by Claude Opus 5
parent d461d889c3
commit b08226dbf1
5 changed files with 200 additions and 59 deletions
@@ -390,7 +390,7 @@ fn session_thread(
pub(crate) fn stream_existing_output(
connector: &str,
hw_cursor: bool,
) -> Result<(u32, Box<dyn Send>)> {
) -> Result<crate::mirror::MirrorStream> {
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<u32, String>>();
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<dyn Send>))
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.