diff --git a/crates/punktfunk-host/src/vdisplay.rs b/crates/punktfunk-host/src/vdisplay.rs index d74fb62..c771b02 100644 --- a/crates/punktfunk-host/src/vdisplay.rs +++ b/crates/punktfunk-host/src/vdisplay.rs @@ -6,8 +6,8 @@ //! this trait: //! //! * **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). +//! * **wlroots/Sway** — `swaymsg create_output` + `output mode --custom` ([`wlroots`]). +//! * **Mutter/GNOME** — D-Bus `RemoteDesktop` + `ScreenCast.RecordVirtual` ([`mutter`]). //! //! [`VirtualDisplay::create`] returns a [`VirtualOutput`]: the PipeWire node to capture plus an //! owned keepalive whose `Drop` releases the output (RAII — no explicit `destroy`). Capture @@ -101,9 +101,7 @@ pub fn open(compositor: Compositor) -> Result> { Compositor::Kwin => Ok(Box::new(kwin::KwinDisplay::new()?)), Compositor::Gamescope => Ok(Box::new(gamescope::GamescopeDisplay::new()?)), Compositor::Mutter => Ok(Box::new(mutter::MutterDisplay::new()?)), - Compositor::Wlroots => { - anyhow::bail!("wlroots virtual-output backend not yet implemented") - } + Compositor::Wlroots => Ok(Box::new(wlroots::WlrootsDisplay::new()?)), } } #[cfg(not(target_os = "linux"))] @@ -126,3 +124,5 @@ mod gamescope; mod kwin; #[cfg(target_os = "linux")] mod mutter; +#[cfg(target_os = "linux")] +mod wlroots; diff --git a/crates/punktfunk-host/src/vdisplay/wlroots.rs b/crates/punktfunk-host/src/vdisplay/wlroots.rs new file mode 100644 index 0000000..48e078f --- /dev/null +++ b/crates/punktfunk-host/src/vdisplay/wlroots.rs @@ -0,0 +1,311 @@ +//! wlroots/Sway virtual-output backend via sway IPC + the xdg ScreenCast portal +//! (xdg-desktop-portal-wlr): +//! +//! 1. `swaymsg create_output` adds a headless output (`HEADLESS-N` — sway must run the +//! headless backend, or have it co-loaded; the name is found by diffing +//! `swaymsg -t get_outputs` before/after). +//! 2. `swaymsg output mode --custom WxH@HzHz` sets the client's exact mode — a fresh +//! headless output also *needs* a real mode for a refresh clock, or it produces no frames. +//! 3. The ScreenCast portal yields the output's PipeWire node. There is no GUI to pick an +//! output headlessly, so xdpw is steered through its chooser hook: a managed config +//! (`~/.config/xdg-desktop-portal-wlr/config`, written once + portal restarted on change) +//! sets `chooser_type=simple` with a `chooser_cmd` that cats [`CHOOSER_FILE`], which we +//! write per session (`Monitor: ` — xdpw 0.8 parses that prefix strictly). +//! 4. Teardown is RAII: drop stops the portal thread (its zbus connection ends the cast) and +//! runs `swaymsg output unplug` (headless outputs support unplug since sway 1.8). +//! +//! Requirements: the host runs inside the sway session's environment (`SWAYSOCK` for swaymsg, +//! and the portal activation env — `WAYLAND_DISPLAY`/`XDG_CURRENT_DESKTOP=sway` imported into +//! `systemctl --user`, see `scripts/headless/prepare-session.sh`), with the ScreenCast +//! interface routed to xdpw (`scripts/headless/portals.conf`). + +use super::{Mode, VirtualDisplay, VirtualOutput}; +use anyhow::{anyhow, bail, Context, Result}; +use std::os::fd::OwnedFd; +use std::process::Command; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::Sender; +use std::sync::Arc; +use std::thread; +use std::time::{Duration, Instant}; + +/// File the xdpw output chooser reads the selected output from (see [`XDPW_CONFIG`]); we write +/// `Monitor: \n` here right before the portal handshake selects sources. +const CHOOSER_FILE: &str = "/tmp/punktfunk-xdpw-output"; + +/// The managed xdpw config: per-session output selection with no GUI. The `|| echo` fallback +/// keeps plain portal capture (`--source portal`, M0 flow) working when no session has written +/// the chooser file. xdpw runs `chooser_cmd` via `/bin/sh -c`, reads stdout. +const XDPW_CONFIG: &str = + "# managed by punktfunk (vdisplay/wlroots.rs) — per-session output selection.\n\ +[screencast]\n\ +chooser_type=simple\n\ +chooser_cmd=cat /tmp/punktfunk-xdpw-output 2>/dev/null || echo 'Monitor: HEADLESS-1'\n"; + +/// The wlroots/Sway virtual-display driver. Stateless — each [`create`](VirtualDisplay::create) +/// adds one headless output and spins up a portal thread owning the cast on it. +pub struct WlrootsDisplay; + +impl WlrootsDisplay { + pub fn new() -> Result { + Ok(WlrootsDisplay) + } +} + +impl VirtualDisplay for WlrootsDisplay { + fn name(&self) -> &'static str { + "wlroots" + } + + fn create(&mut self, mode: Mode) -> Result { + let before = output_names() + .context("swaymsg get_outputs (is the host inside the sway session env — SWAYSOCK?)")?; + swaymsg(&["create_output"]) + .context("swaymsg create_output (sway needs the headless backend loaded)")?; + // The output appears synchronously in practice; poll briefly to be safe, and own it + // from here on so error unwinding unplugs it. + let output = OutputGuard(wait_new_output(&before, Duration::from_secs(5))?); + let name = output.0.clone(); + + // The client's exact mode (also the refresh clock that makes the output produce frames). + let m = format!( + "{}x{}@{}Hz", + mode.width, + mode.height, + mode.refresh_hz.max(1) + ); + swaymsg(&["output", &name, "mode", "--custom", &m]) + .with_context(|| format!("swaymsg output {name} mode --custom {m}"))?; + 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()?; + std::fs::write(CHOOSER_FILE, format!("Monitor: {name}\n")) + .with_context(|| format!("write {CHOOSER_FILE}"))?; + 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-vout".into()) + .spawn(move || portal_thread(setup_tx, stop_thread)) + .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}"), + }; + tracing::info!( + node_id, + output = %name, + w = mode.width, + h = mode.height, + hz = mode.refresh_hz, + "sway headless output ready" + ); + Ok(VirtualOutput { + node_id, + remote_fd: Some(fd), + preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)), + keepalive: Box::new(Keepalive { + _stop: StopGuard(stop), + _output: output, + }), + }) + } +} + +/// Drop order matters: stop the portal thread first (zbus connection drop ends the cast), +/// then unplug the output (fields drop in declaration order). +struct Keepalive { + _stop: StopGuard, + _output: OutputGuard, +} + +/// Dropping this ends the portal keepalive thread, closing its zbus connection — the portal +/// then tears the screencast session down. +struct StopGuard(Arc); + +impl Drop for StopGuard { + fn drop(&mut self) { + self.0.store(true, Ordering::Relaxed); + } +} + +/// Owns the created headless output; dropping it unplugs it from sway. +struct OutputGuard(String); + +impl Drop for OutputGuard { + fn drop(&mut self) { + match swaymsg(&["output", &self.0, "unplug"]) { + Ok(_) => tracing::info!(output = %self.0, "sway headless output unplugged"), + Err(e) => tracing::warn!(output = %self.0, error = %format!("{e:#}"), "unplug failed"), + } + } +} + +/// Run `swaymsg -- `, returning stdout (`--` so command tokens like `--custom` reach +/// sway instead of swaymsg's own getopt). swaymsg exits non-zero (with the error on stderr/ +/// stdout) when the command fails, so checking the status covers `{"success": false}` too. +fn swaymsg(args: &[&str]) -> Result { + let out = Command::new("swaymsg") + .arg("--") + .args(args) + .output() + .context("run swaymsg (is sway installed?)")?; + if !out.status.success() { + bail!( + "swaymsg {:?} failed: {}{}", + args, + String::from_utf8_lossy(&out.stdout).trim(), + String::from_utf8_lossy(&out.stderr).trim() + ); + } + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) +} + +/// Current output names from `swaymsg -t get_outputs` (JSON). +fn output_names() -> Result> { + let out = Command::new("swaymsg") + .args(["-t", "get_outputs", "--raw"]) + .output() + .context("run swaymsg (is sway installed?)")?; + if !out.status.success() { + bail!( + "swaymsg -t get_outputs failed: {}", + String::from_utf8_lossy(&out.stderr).trim() + ); + } + let raw = String::from_utf8_lossy(&out.stdout).into_owned(); + let outputs: serde_json::Value = serde_json::from_str(&raw).context("parse get_outputs")?; + Ok(outputs + .as_array() + .context("get_outputs: not an array")? + .iter() + .filter_map(|o| o.get("name").and_then(|n| n.as_str()).map(str::to_owned)) + .collect()) +} + +/// Wait for the output `create_output` added (the name not in `before` — HEADLESS-N). +fn wait_new_output(before: &[String], timeout: Duration) -> Result { + let deadline = Instant::now() + timeout; + loop { + if let Some(name) = output_names()? + .into_iter() + .find(|n| !before.iter().any(|b| b == n)) + { + return Ok(name); + } + if Instant::now() >= deadline { + bail!("create_output succeeded but no new output appeared"); + } + thread::sleep(Duration::from_millis(50)); + } +} + +/// Make sure xdpw uses our output chooser. xdpw reads its config only at startup, so on a +/// change restart it if running (`try-restart`; if it isn't, D-Bus activation will start it +/// with the new config). The config itself is static — the *selection* is [`CHOOSER_FILE`]. +fn ensure_xdpw_config() -> Result<()> { + let base = std::env::var_os("XDG_CONFIG_HOME") + .map(std::path::PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))) + .ok_or_else(|| anyhow!("neither XDG_CONFIG_HOME nor HOME set"))?; + let dir = base.join("xdg-desktop-portal-wlr"); + let path = dir.join("config"); + if std::fs::read_to_string(&path).is_ok_and(|c| c == XDPW_CONFIG) { + return Ok(()); + } + std::fs::create_dir_all(&dir).with_context(|| format!("mkdir {}", dir.display()))?; + std::fs::write(&path, XDPW_CONFIG).with_context(|| format!("write {}", path.display()))?; + tracing::info!(path = %path.display(), "wrote managed xdg-desktop-portal-wlr config"); + let _ = Command::new("systemctl") + .args(["--user", "try-restart", "xdg-desktop-portal-wlr.service"]) + .status(); + Ok(()) +} + +/// The ScreenCast portal handshake (same shape as the capture module's portal thread, but it +/// reports the fd + node id and parks until stopped — the zbus connection is the cast's +/// lifetime). xdpw answers the source selection via the chooser, no dialog. +fn portal_thread(setup_tx: Sender>, stop: Arc) { + use ashpd::desktop::screencast::{CursorMode, Screencast, SelectSourcesOptions, SourceType}; + use ashpd::desktop::PersistMode; + use ashpd::enumflags2::BitFlags; + + // Multi-thread runtime: the zbus background reader must be pumped across the + // create_session → select_sources → start handshake (see capture/linux.rs). + let rt = match tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + let _ = setup_tx.send(Err(format!("build tokio runtime: {e}"))); + return; + } + }; + let err_tx = setup_tx.clone(); + + rt.block_on(async move { + let result: Result<()> = async { + let proxy = Screencast::new().await.context( + "connect ScreenCast portal (is xdg-desktop-portal running with the wlr backend?)", + )?; + let session = proxy + .create_session(Default::default()) + .await + .context("create_session")?; + proxy + .select_sources( + &session, + SelectSourcesOptions::default() + .set_cursor_mode(CursorMode::Embedded) + // xdpw offers MONITOR only; the chooser picks our output. + .set_sources(BitFlags::from_flag(SourceType::Monitor)) + .set_multiple(false) + .set_persist_mode(PersistMode::DoNot), + ) + .await + .context("select_sources")? + .response() + .context("select_sources rejected")?; + let streams = proxy + .start(&session, None, Default::default()) + .await + .context("start cast")? + .response() + .context("start response (chooser declined? check the xdpw config/chooser file)")?; + let stream = streams + .streams() + .first() + .context("portal returned no streams")? + .clone(); + let node_id = stream.pipe_wire_node_id(); + let fd = proxy + .open_pipe_wire_remote(&session, Default::default()) + .await + .context("open_pipe_wire_remote")?; + + setup_tx + .send(Ok((fd, node_id))) + .map_err(|_| anyhow!("virtual-output opener went away"))?; + + // Park, keeping `proxy` + `session` (the zbus connection) alive until stopped — + // the cast is torn down when the connection drops. + let _keep_alive = (&proxy, &session); + while !stop.load(Ordering::Relaxed) { + tokio::time::sleep(Duration::from_millis(200)).await; + } + Ok(()) + } + .await; + + if let Err(e) = result { + let _ = err_tx.send(Err(format!("{e:#}"))); + } + }); +} diff --git a/scripts/headless/xdpw.config b/scripts/headless/xdpw.config index 6810f7d..2ee95a9 100644 --- a/scripts/headless/xdpw.config +++ b/scripts/headless/xdpw.config @@ -1,6 +1,4 @@ -# ~/.config/xdg-desktop-portal-wlr/config -# Headless ScreenCast: there is no GUI output chooser, so select the output by name. +# managed by punktfunk (vdisplay/wlroots.rs) — per-session output selection. [screencast] -chooser_type=none -output_name=HEADLESS-1 -max_fps=60 +chooser_type=simple +chooser_cmd=cat /tmp/punktfunk-xdpw-output 2>/dev/null || echo 'Monitor: HEADLESS-1'