diff --git a/Cargo.lock b/Cargo.lock index f27d310..d7a8662 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1695,6 +1695,9 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", "zbus", ] diff --git a/README.md b/README.md index 54b695a..e8a3189 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ [![Release](https://img.shields.io/github/v/release/LibreStatic/ezdisplay?include_prereleases&label=release)](https://github.com/LibreStatic/ezdisplay/releases) [![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue)](#license) -Windows · KDE Plasma 6 and GNOME (Wayland) · Android tablets · Rust + Kotlin +Windows · KDE Plasma 6, GNOME, Sway and Hyprland (Wayland) · Android tablets · Rust + Kotlin [Download](https://github.com/LibreStatic/ezdisplay/releases) · [Install](#install) · @@ -55,19 +55,20 @@ and no telemetry, and everything travels through the cable. ezdisplay is in **alpha** (see the [releases](https://github.com/LibreStatic/ezdisplay/releases); every change merged to `main` is published as a pre-release). Working today, over USB with ADB: -| | Windows | KDE Plasma 6 (Wayland) | GNOME (Wayland) | -| --- | :---: | :---: | :---: | -| Extended display at native resolution and refresh rate | ✅ | ✅ | 🧪 | -| Locally drawn cursor | ✅ | ✅ | 🧪 | -| Zero-copy hardware encode | ✅ | ✅ | 🧪 | -| Touch, pen pressure, tilt and hover | ✅ | ✅ | 🧪 | -| Tray app with settings and live statistics | ✅ | ✅ | 🧪 | -| Installer / packages | ✅ `.exe` | ✅ AppImage, `.deb`, Arch | 🧪 AppImage, `.deb`, Arch | +| | Windows | KDE Plasma 6 (Wayland) | GNOME (Wayland) | Sway, Hyprland | +| --- | :---: | :---: | :---: | :---: | +| Extended display at native resolution and refresh rate | ✅ | ✅ | 🧪 | 🧪 | +| Locally drawn cursor | ✅ | ✅ | 🧪 | 🧪 | +| Zero-copy hardware encode | ✅ | ✅ | 🧪 | ❌ shared-memory capture | +| Touch, pen pressure, tilt and hover | ✅ | ✅ | 🧪 | 🧪 | +| Tray app with settings and live statistics | ✅ | ✅ | 🧪 | 🧪 | +| Installer / packages | ✅ `.exe` | ✅ AppImage, `.deb`, Arch | 🧪 AppImage, `.deb`, Arch | 🧪 AppImage, `.deb`, Arch | -🧪 GNOME support is new: it has been tested against a headless Mutter only, not yet on a real GNOME -session with a tablet. The tray needs the AppIndicator extension there. +🧪 New and not yet tested on a real session with a tablet: GNOME has been tested against a +headless Mutter (the tray needs the AppIndicator extension there), Sway against a headless sway, +and Hyprland only through unit tests. -**Not yet:** audio, wlroots backends, X11. +**Not yet:** audio, X11. ## How it works @@ -132,11 +133,11 @@ notification area. Connect the tablet over USB: the app opens on the tablet and > and Smart App Control blocks it. See [docs/windows-testing.md](docs/windows-testing.md) and > [packaging/windows/README.md](packaging/windows/README.md). -### 2. PC: Linux (KDE Plasma 6 or GNOME, Wayland) +### 2. PC: Linux (KDE Plasma 6, GNOME, Sway or Hyprland, Wayland) -Plasma 6 or a recent GNOME on Wayland is required; X11 and other desktops are not supported yet. +Plasma 6, a recent GNOME, Sway or Hyprland on Wayland is required; X11 and other desktops are not supported yet. The host picks the desktop from `XDG_CURRENT_DESKTOP` and the session bus; -`EZD_LINUX_BACKEND=kde|gnome` overrides it. Pick one: +`EZD_LINUX_BACKEND=kde|gnome|sway|hyprland` overrides it. Pick one: - **Arch Linux**: `sudo pacman -U ezdisplay-*.pkg.tar.zst`. - **Debian and Ubuntu**: `sudo apt install ./ezdisplay_*.deb`, using the file for your release: @@ -229,7 +230,7 @@ cargo check --target aarch64-linux-android -p ezd-proto -p ezd-link | `ezd-link` | `Link` byte streams (TCP, in-memory), ADB helper, prioritized mux. | | `ezd-host-core` | `VideoSource` / `VirtualDisplay` traits, host session, synthetic test frames. | | `ezd-host-service` | The host loop as a library, with live status for the CLI and the tray app. | -| `ezd-host-linux` | KDE and GNOME virtual output, PipeWire capture, VAAPI/NVENC encode, uinput input. | +| `ezd-host-linux` | KDE, GNOME, Sway and Hyprland virtual output, PipeWire and Wayland capture, VAAPI/NVENC encode, uinput input. | | `ezd-host-windows` | GPU policy, Virtual Display Driver, Desktop Duplication, D3D11 encode, input injection. | | `ezd-ffmpeg` | Safe FFmpeg wrapper: low-latency `Encoder`, `Decoder`, `SwVideoSource`, encoder probing. | | `ezd-host` | The host CLI: `run`, `diag` and `vdd`. | diff --git a/crates/ezd-host-linux/Cargo.toml b/crates/ezd-host-linux/Cargo.toml index 17c8d7f..7067a2c 100644 --- a/crates/ezd-host-linux/Cargo.toml +++ b/crates/ezd-host-linux/Cargo.toml @@ -25,6 +25,10 @@ zbus = { version = "5", default-features = false, features = ["async-io", "block tokio = { workspace = true } serde_json.workspace = true tracing.workspace = true +# Direct Wayland capture for the wlroots compositors (ext-image-copy-capture, wlr-screencopy). +wayland-client = "0.31" +wayland-protocols = { version = "0.32", features = ["client", "staging"] } +wayland-protocols-wlr = { version = "0.3", features = ["client"] } # Only the bindings: functions are loaded through FFmpeg's Vulkan device. ash = { version = "0.38", default-features = false, features = ["std"] } diff --git a/crates/ezd-host-linux/src/backend.rs b/crates/ezd-host-linux/src/backend.rs index c46aa05..75dd60b 100644 --- a/crates/ezd-host-linux/src/backend.rs +++ b/crates/ezd-host-linux/src/backend.rs @@ -7,16 +7,17 @@ use std::fmt; -/// A desktop the Linux host can (or, later, will) drive. +/// A desktop the Linux host can drive. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LinuxBackend { /// KDE Plasma 6 (KWin): portal VIRTUAL source, `kscreen-doctor`, KWin input mapping. Kde, /// GNOME (Mutter): `org.gnome.Mutter.ScreenCast` virtual monitor, `DisplayConfig`, gsettings. Gnome, - /// Sway. Detected, but not implemented yet. + /// Sway: i3 IPC headless output, direct Wayland capture, per-device input mapping. Sway, - /// Hyprland. Detected, but not implemented yet. + /// Hyprland: headless output over its command socket, direct Wayland capture, per-device + /// input mapping. Hyprland, } @@ -41,19 +42,6 @@ impl LinuxBackend { _ => None, } } - - /// Whether a virtual output provider exists for this backend. - pub fn supported(self) -> bool { - matches!(self, LinuxBackend::Kde | LinuxBackend::Gnome) - } - - /// Error text for backends that are detected but not written yet. - pub fn unsupported_error(self) -> String { - format!( - "the {} backend is not implemented yet; supported desktops are KDE Plasma and GNOME", - self.name() - ) - } } impl fmt::Display for LinuxBackend { @@ -282,10 +270,7 @@ mod tests { } #[test] - fn only_kde_and_gnome_are_supported() { - assert!(LinuxBackend::Kde.supported() && LinuxBackend::Gnome.supported()); - assert!(!LinuxBackend::Sway.supported() && !LinuxBackend::Hyprland.supported()); - assert!(LinuxBackend::Sway.unsupported_error().contains("sway")); + fn names_round_trip() { for b in [ LinuxBackend::Kde, LinuxBackend::Gnome, diff --git a/crates/ezd-host-linux/src/diag.rs b/crates/ezd-host-linux/src/diag.rs index 684b399..dee5313 100644 --- a/crates/ezd-host-linux/src/diag.rs +++ b/crates/ezd-host-linux/src/diag.rs @@ -63,7 +63,7 @@ pub fn section() -> Value { let name = detected.as_ref().map_or("unknown", |b| b.name()); let mut v = json!({ "backend": format!("linux-{name}"), - "backend_supported": detected.as_ref().is_ok_and(|b| b.supported()), + "backend_supported": detected.is_ok(), "backend_error": detected.as_ref().err(), "xdg_current_desktop": std::env::var("XDG_CURRENT_DESKTOP").ok(), "wayland_display": std::env::var("WAYLAND_DISPLAY").ok(), @@ -79,6 +79,18 @@ pub fn section() -> Value { }); let extra = match detected { Ok(LinuxBackend::Gnome) => json!({ "gnome": crate::gnome::diag() }), + Ok(b @ (LinuxBackend::Sway | LinuxBackend::Hyprland)) => { + let compositor = if b == LinuxBackend::Sway { + crate::sway::diag() + } else { + crate::hyprland::diag() + }; + json!({ + "compositor": b.name(), + "wlroots": compositor, + "wayland_capture": crate::wl_capture::diag(), + }) + } _ => json!({ "kwin_version": kwin_version(), "kwin_drm_devices": crate::adapter::kwin_drm_devices(), diff --git a/crates/ezd-host-linux/src/hyprland.rs b/crates/ezd-host-linux/src/hyprland.rs new file mode 100644 index 0000000..887428e --- /dev/null +++ b/crates/ezd-host-linux/src/hyprland.rs @@ -0,0 +1,489 @@ +//! Hyprland: a headless virtual output through the command socket +//! (`$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket.sock`), captured directly over +//! Wayland (`wl_capture.rs`), and the input mapping of the touch, pen and pointer devices. +//! +//! Every request is one connection: the command is written, the reply read until the compositor +//! closes it. `output create headless NAME` makes the output, a `keyword monitor` rule gives it +//! its mode, and `output remove NAME` destroys it when the guard drops. + +use crate::kscreen::KOutput; +use crate::portal::SourceKind; +use crate::provider::{FrameSource, OpenedOutput, OutputRequest, VirtualOutputProvider, rate_text}; +use crate::uinput_sink::{DesktopInput, MappedDevices, PEN_NAME, POINTER_NAME, TOUCH_NAME}; +use ezd_host_core::{DisplayMode, OutputInfo, SourceError}; +use serde_json::Value; +use std::io::{Read, Write}; +use std::os::unix::net::UnixStream; +use std::path::PathBuf; +use std::time::{Duration, Instant}; + +const IO_TIMEOUT: Duration = Duration::from_secs(5); +const OUTPUT_TIMEOUT: Duration = Duration::from_secs(3); +/// Names of the virtual outputs: `ezdisplay-1`, `ezdisplay-2`, ... +const OUTPUT_PREFIX: &str = "ezdisplay-"; + +/// The command socket for `signature` (`$HYPRLAND_INSTANCE_SIGNATURE`). Hyprland moved it from +/// `/tmp/hypr` to the runtime directory; the old place is the fallback. +pub fn socket_path_for(runtime_dir: Option<&str>, signature: &str) -> PathBuf { + match runtime_dir.filter(|d| !d.is_empty()) { + Some(d) => PathBuf::from(d) + .join("hypr") + .join(signature) + .join(".socket.sock"), + None => PathBuf::from("/tmp/hypr") + .join(signature) + .join(".socket.sock"), + } +} + +fn socket_path() -> Result { + let sig = std::env::var("HYPRLAND_INSTANCE_SIGNATURE") + .ok() + .filter(|s| !s.is_empty()) + .ok_or("HYPRLAND_INSTANCE_SIGNATURE is not set (is Hyprland running in this session?)")?; + let runtime = std::env::var("XDG_RUNTIME_DIR").ok(); + let primary = socket_path_for(runtime.as_deref(), &sig); + if primary.exists() { + return Ok(primary); + } + let legacy = socket_path_for(None, &sig); + Ok(if legacy.exists() { legacy } else { primary }) +} + +/// Sends one command and returns the reply text. +pub fn request(command: &str) -> Result { + let path = socket_path()?; + let mut s = UnixStream::connect(&path) + .map_err(|e| format!("cannot connect to {}: {e}", path.display()))?; + let _ = s.set_read_timeout(Some(IO_TIMEOUT)); + let _ = s.set_write_timeout(Some(IO_TIMEOUT)); + s.write_all(command.as_bytes()) + .map_err(|e| format!("Hyprland IPC write: {e}"))?; + let mut reply = String::new(); + s.read_to_string(&mut reply) + .map_err(|e| format!("Hyprland IPC read: {e}"))?; + Ok(reply) +} + +/// Runs a command that answers `ok` on success. +pub fn run(command: &str) -> Result<(), String> { + expect_ok(command, &request(command)?) +} + +/// Checks the reply of a command: `ok`, or an error text. +pub fn expect_ok(command: &str, reply: &str) -> Result<(), String> { + match reply.trim() { + "ok" => Ok(()), + "" => Err(format!("Hyprland `{command}`: empty reply")), + other => Err(format!("Hyprland `{command}`: {other}")), + } +} + +/// A monitor as `j/monitors` reports it. +#[derive(Debug, Clone, PartialEq)] +pub struct HyprMonitor { + pub name: String, + pub width: u32, + pub height: u32, + pub refresh_hz: f64, + pub x: i32, + pub y: i32, + pub scale: f64, + pub disabled: bool, +} + +/// Parses the reply of `j/monitors`. +pub fn parse_monitors(json: &str) -> Result, String> { + let v: Value = serde_json::from_str(json).map_err(|e| format!("bad monitors reply: {e}"))?; + let items = v.as_array().ok_or("bad monitors reply: not an array")?; + Ok(items + .iter() + .filter_map(|m| { + let int = |k: &str| m.get(k).and_then(Value::as_i64).unwrap_or(0); + Some(HyprMonitor { + name: m.get("name")?.as_str()?.to_string(), + width: int("width") as u32, + height: int("height") as u32, + refresh_hz: m.get("refreshRate").and_then(Value::as_f64).unwrap_or(0.0), + x: int("x") as i32, + y: int("y") as i32, + scale: m.get("scale").and_then(Value::as_f64).unwrap_or(1.0), + disabled: m.get("disabled").and_then(Value::as_bool).unwrap_or(false), + }) + }) + .collect()) +} + +/// The first `ezdisplay-N` (N from 1) that no monitor uses. +pub fn free_output_name(monitors: &[HyprMonitor]) -> String { + (1..) + .map(|n| format!("{OUTPUT_PREFIX}{n}")) + .find(|n| !monitors.iter().any(|m| &m.name == n)) + .unwrap_or_else(|| format!("{OUTPUT_PREFIX}1")) +} + +/// `keyword monitor NAME,WxH@HZ,auto,1`: the mode, automatic placement, scale 1. +pub fn monitor_rule(name: &str, width: u32, height: u32, refresh_mhz: u32) -> String { + format!( + "keyword monitor {name},{width}x{height}@{},auto,1", + rate_text(refresh_mhz.max(1000)) + ) +} + +/// The `version` of a `j/version` reply. +pub fn parse_version(json: &str) -> Option { + let v: Value = serde_json::from_str(json).ok()?; + let version = v + .get("version") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()); + let tag = v + .get("tag") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()); + version.or(tag).map(str::to_string) +} + +fn monitors() -> Result, String> { + parse_monitors(&request("j/monitors")?) +} + +/// The output description of `name`. +pub fn output_info(monitors: &[HyprMonitor], name: &str) -> Option { + let m = monitors.iter().find(|m| m.name == name)?; + Some(OutputInfo { + name: m.name.clone(), + mode: DisplayMode { + width: m.width, + height: m.height, + refresh_mhz: (m.refresh_hz * 1000.0).round() as u32, + }, + origin: (m.x, m.y), + adapter: None, + }) +} + +/// Removes a virtual output when dropped. +struct OutputGuard { + name: String, +} + +impl Drop for OutputGuard { + fn drop(&mut self) { + match run(&format!("output remove {}", self.name)) { + Ok(()) => tracing::info!(output = %self.name, "virtual output removed"), + Err(e) => tracing::warn!("cannot remove {}: {e}", self.name), + } + } +} + +/// Provider for Hyprland. +#[derive(Default)] +pub struct HyprlandProvider; + +impl VirtualOutputProvider for HyprlandProvider { + fn name(&self) -> &'static str { + "hyprland" + } + + fn open(&mut self, req: &OutputRequest) -> Result { + let existing = monitors().map_err(SourceError::msg)?; + if req.kind == SourceKind::Monitor { + let name = req + .output_name + .clone() + .ok_or_else(|| SourceError::msg("no output name to capture"))?; + let info = output_info(&existing, &name).ok_or_else(|| { + SourceError::msg(format!( + "Hyprland has no monitor called {name} (monitors: {})", + existing + .iter() + .map(|m| m.name.as_str()) + .collect::>() + .join(", ") + )) + })?; + return Ok(OpenedOutput { + frames: FrameSource::Wayland { output: name }, + info: Some(info), + guard: Box::new(()), + }); + } + + let name = free_output_name(&existing); + run(&format!("output create headless {name}")).map_err(SourceError::msg)?; + tracing::info!(output = %name, "Hyprland virtual output created"); + // From here on the guard removes the output on every early return. + let guard = OutputGuard { name: name.clone() }; + run(&monitor_rule(&name, req.width, req.height, req.refresh_mhz)) + .map_err(SourceError::msg)?; + let mut info = None; + let deadline = Instant::now() + OUTPUT_TIMEOUT; + loop { + let all = monitors().map_err(SourceError::msg)?; + info = output_info(&all, &name).or(info); + if info + .as_ref() + .is_some_and(|i| (i.mode.width, i.mode.height) == (req.width, req.height)) + { + break; + } + if Instant::now() >= deadline { + tracing::warn!(output = %name, ?info, "the mode was not applied in time"); + break; + } + std::thread::sleep(Duration::from_millis(50)); + } + Ok(OpenedOutput { + frames: FrameSource::Wayland { output: name }, + info, + guard: Box::new(guard), + }) + } +} + +/// Hyprland's name of an input device: the kernel name in lowercase, spaces as dashes. +pub fn device_name(kernel_name: &str) -> String { + kernel_name.trim().to_lowercase().replace(' ', "-") +} + +/// The Hyprland device behind a label (`touch`, `pen`, `pointer`). +pub fn device_for(label: &str) -> Option { + let name = match label { + "touch" => TOUCH_NAME, + "pen" => PEN_NAME, + "pointer" => POINTER_NAME, + _ => return None, + }; + Some(device_name(name)) +} + +/// `keyword device[NAME]:output OUTPUT`. +pub fn map_command(device: &str, output: &str) -> String { + format!("keyword device[{device}]:output {output}") +} + +/// The `output` option's default: no mapping. +const AUTO_OUTPUT: &str = "[[Auto]]"; + +/// Every device name in a `j/devices` reply (mice, keyboards, tablets, touch devices). +pub fn parse_device_names(json: &str) -> Vec { + let Ok(v) = serde_json::from_str::(json) else { + return Vec::new(); + }; + let mut names = Vec::new(); + if let Some(groups) = v.as_object() { + for list in groups.values().filter_map(Value::as_array) { + names.extend( + list.iter() + .filter_map(|d| d.get("name").and_then(Value::as_str)) + .map(str::to_string), + ); + } + } + names +} + +/// Puts the `output` option of the devices back when dropped. +struct Restore { + devices: Vec, +} + +impl Drop for Restore { + fn drop(&mut self) { + for d in &self.devices { + if let Err(e) = run(&map_command(d, AUTO_OUTPUT)) { + tracing::debug!("cannot clear the output mapping of {d}: {e}"); + } + } + } +} + +/// Input mapping for Hyprland: touch, tablet and absolute pointer devices each get an `output`. +#[derive(Default)] +pub struct HyprlandDesktop; + +impl DesktopInput for HyprlandDesktop { + fn map_devices(&self, devices: &[(&str, &str)], output: &str) -> MappedDevices { + let known = request("j/devices") + .map(|r| parse_device_names(&r)) + .unwrap_or_default(); + let mut restore = Restore { + devices: Vec::new(), + }; + let results = devices + .iter() + .map(|(label, _)| { + let dev = device_for(label).ok_or_else(|| format!("unknown device {label}"))?; + if !known.is_empty() && !known.contains(&dev) { + tracing::debug!(device = %dev, "Hyprland does not list the device (yet)"); + } + run(&map_command(&dev, output))?; + tracing::info!(device = %dev, %output, "Hyprland output mapping set"); + restore.devices.push(dev); + Ok(()) + }) + .collect(); + MappedDevices { + results, + guard: (!restore.devices.is_empty()).then(|| Box::new(restore) as Box), + } + } + + fn outputs(&self) -> Option> { + None + } + + fn maps_pointer(&self) -> bool { + true + } +} + +/// Hyprland's part of `ezd-host diag`. +pub fn diag() -> Value { + let version = request("j/version").ok().and_then(|r| parse_version(&r)); + match monitors() { + Ok(ms) => serde_json::json!({ + "hyprland_version": version, + "monitors": ms.iter().map(|m| serde_json::json!({ + "name": m.name, + "size": [m.width, m.height], + "refresh_hz": m.refresh_hz, + "position": [m.x, m.y], + "scale": m.scale, + "disabled": m.disabled, + })).collect::>(), + }), + Err(e) => serde_json::json!({ "hyprland_version": version, "error": e }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Shape of `hyprctl monitors -j` (Hyprland 0.4x), trimmed to the fields that matter. + const MONITORS: &str = r#"[ + {"id": 0, "name": "eDP-1", "description": "Panel", "make": "BOE", "model": "0x0a1c", + "width": 2560, "height": 1600, "physicalWidth": 290, "physicalHeight": 180, + "refreshRate": 165.00400, "x": 0, "y": 0, + "activeWorkspace": {"id": 1, "name": "1"}, "scale": 1.60, "transform": 0, + "focused": true, "dpmsStatus": true, "vrr": false, "disabled": false}, + {"id": 1, "name": "ezdisplay-1", "description": "", "make": "", "model": "", + "width": 1280, "height": 720, "refreshRate": 60.00000, "x": 1600, "y": 0, + "scale": 1.00, "transform": 0, "focused": false, "disabled": false} + ]"#; + + #[test] + fn socket_locations() { + assert_eq!( + socket_path_for(Some("/run/user/1000"), "abc_123_9"), + PathBuf::from("/run/user/1000/hypr/abc_123_9/.socket.sock") + ); + assert_eq!( + socket_path_for(None, "abc"), + PathBuf::from("/tmp/hypr/abc/.socket.sock") + ); + assert_eq!( + socket_path_for(Some(""), "abc"), + socket_path_for(None, "abc") + ); + } + + #[test] + fn replies() { + assert_eq!(expect_ok("c", "ok"), Ok(())); + assert_eq!(expect_ok("c", "ok\n"), Ok(())); + assert!( + expect_ok("c", "Invalid monitor") + .unwrap_err() + .contains("Invalid monitor") + ); + assert!(expect_ok("c", "").is_err()); + } + + #[test] + fn parses_monitors() { + let ms = parse_monitors(MONITORS).unwrap(); + assert_eq!(ms.len(), 2); + assert_eq!(ms[0].name, "eDP-1"); + assert_eq!((ms[0].width, ms[0].height, ms[0].scale), (2560, 1600, 1.6)); + let i = output_info(&ms, "ezdisplay-1").unwrap(); + assert_eq!(i.origin, (1600, 0)); + assert_eq!( + (i.mode.width, i.mode.height, i.mode.refresh_mhz), + (1280, 720, 60_000) + ); + assert_eq!(output_info(&ms, "eDP-1").unwrap().mode.refresh_mhz, 165_004); + assert!(output_info(&ms, "nope").is_none()); + assert!(parse_monitors("{}").is_err()); + assert!(parse_monitors("").is_err()); + } + + #[test] + fn picks_a_free_output_name() { + let ms = parse_monitors(MONITORS).unwrap(); + assert_eq!(free_output_name(&ms), "ezdisplay-2"); + assert_eq!(free_output_name(&ms[..1]), "ezdisplay-1"); + assert_eq!(free_output_name(&[]), "ezdisplay-1"); + } + + #[test] + fn monitor_rules() { + assert_eq!( + monitor_rule("ezdisplay-1", 2560, 1600, 60_000), + "keyword monitor ezdisplay-1,2560x1600@60,auto,1" + ); + assert_eq!( + monitor_rule("ezdisplay-2", 1920, 1080, 59_940), + "keyword monitor ezdisplay-2,1920x1080@59.94,auto,1" + ); + } + + #[test] + fn version_text() { + assert_eq!( + parse_version(r#"{"branch":"main","commit":"abc","version":"0.45.2","tag":"v0.45.2"}"#) + .as_deref(), + Some("0.45.2") + ); + assert_eq!( + parse_version(r#"{"tag":"v0.50.0"}"#).as_deref(), + Some("v0.50.0") + ); + assert_eq!(parse_version("nope"), None); + } + + #[test] + fn device_names_are_lowercase_with_dashes() { + assert_eq!(device_for("touch").unwrap(), "ezdisplay-touch"); + assert_eq!(device_for("pen").unwrap(), "ezdisplay-pen"); + assert_eq!(device_for("pointer").unwrap(), "ezdisplay-pointer"); + assert_eq!(device_for("keyboard"), None); + assert_eq!(device_name("My Fancy Pen"), "my-fancy--pen"); + assert_eq!( + map_command("ezdisplay-touch", "ezdisplay-1"), + "keyword device[ezdisplay-touch]:output ezdisplay-1" + ); + } + + #[test] + fn lists_device_names() { + let json = r#"{"mice":[{"address":"0x1","name":"ezdisplay-pointer"}], + "keyboards":[{"address":"0x2","name":"ezdisplay-keyboard","main":true}], + "tablets":[{"address":"0x3","name":"ezdisplay-pen"}], + "touch":[{"address":"0x4","name":"ezdisplay-touch"}],"switches":[]}"#; + let mut names = parse_device_names(json); + names.sort(); + assert_eq!( + names, + [ + "ezdisplay-keyboard", + "ezdisplay-pen", + "ezdisplay-pointer", + "ezdisplay-touch" + ] + ); + assert!(parse_device_names("nope").is_empty()); + } +} diff --git a/crates/ezd-host-linux/src/lib.rs b/crates/ezd-host-linux/src/lib.rs index 2adc2e6..e7539bd 100644 --- a/crates/ezd-host-linux/src/lib.rs +++ b/crates/ezd-host-linux/src/lib.rs @@ -1,6 +1,7 @@ -//! Linux (KDE Plasma and GNOME, Wayland) host backend: a virtual output from the compositor (KWin -//! through the ScreenCast portal, Mutter through its own ScreenCast service), PipeWire capture -//! (DMA-BUF preferred) and VAAPI / NVENC / x264 encoding. +//! Linux (KDE Plasma, GNOME, sway and Hyprland, Wayland) host backend: a virtual output from the +//! compositor (KWin through the ScreenCast portal, Mutter through its own ScreenCast service, sway +//! and Hyprland through their IPC), PipeWire capture (DMA-BUF preferred) or, on the wlroots +//! compositors, direct Wayland capture, and VAAPI / NVENC / x264 encoding. //! //! Exposes the platform contract used by `ezd-host`: //! `create_virtual_source(&PlatformOptions)` and `diag_section()`. @@ -20,6 +21,7 @@ mod gbm_test; mod gnome; mod gnome_dc; mod gnome_input; +mod hyprland; mod input; mod kde; mod kscreen; @@ -29,8 +31,12 @@ mod portal; mod provider; mod pw; mod source; +mod sway; mod uinput_sink; mod vkdma; +mod wl_capture; +#[cfg(test)] +mod wlroots_test; use backend::LinuxBackend; pub use ezd_host_core::PlatformOptions; @@ -45,9 +51,8 @@ pub fn create_virtual_source( let provider: Box = match backend { LinuxBackend::Kde => Box::new(kde::KdeProvider), LinuxBackend::Gnome => Box::new(gnome::GnomeProvider::new()), - LinuxBackend::Sway | LinuxBackend::Hyprland => { - return Err(PlatformError::Other(backend.unsupported_error())); - } + LinuxBackend::Sway => Box::new(sway::SwayProvider), + LinuxBackend::Hyprland => Box::new(hyprland::HyprlandProvider), }; tracing::info!(backend = backend.name(), "Linux backend"); let src = source::LinuxSource::new(opts, provider); @@ -60,8 +65,8 @@ pub fn create_virtual_source( } /// Input injection backend for the detected desktop (see [`backend`]): KDE through KWin's input -/// device interface, GNOME through gsettings. Elsewhere the backend is unavailable with a note -/// for the tablet. +/// device interface, GNOME through gsettings, sway and Hyprland through their per-device output +/// mapping. pub fn input_backend() -> Option> { let backend = match backend::current() { Ok(b) => b, @@ -78,16 +83,12 @@ pub fn input_backend() -> Option> { LinuxBackend::Gnome => Some(Arc::new(uinput_sink::UinputBackend::new(Arc::new( gnome_input::GnomeDesktop::new(), )))), - LinuxBackend::Sway | LinuxBackend::Hyprland => { - tracing::info!( - backend = backend.name(), - "input injection is not implemented for this desktop" - ); - Some(Arc::new(UnavailableBackend(format!( - "input injection: {}", - backend.unsupported_error() - )))) - } + LinuxBackend::Sway => Some(Arc::new(uinput_sink::UinputBackend::new(Arc::new( + sway::SwayDesktop, + )))), + LinuxBackend::Hyprland => Some(Arc::new(uinput_sink::UinputBackend::new(Arc::new( + hyprland::HyprlandDesktop, + )))), } } diff --git a/crates/ezd-host-linux/src/provider.rs b/crates/ezd-host-linux/src/provider.rs index 7eb0975..f3fa1ea 100644 --- a/crates/ezd-host-linux/src/provider.rs +++ b/crates/ezd-host-linux/src/provider.rs @@ -2,10 +2,11 @@ //! virtual output, and saying where its frames come from. //! //! [`crate::source::LinuxSource`] owns the shared half (encoder selection, capture negotiation, -//! encode pipeline) and talks to one [`VirtualOutputProvider`]: [`crate::kde::KdeProvider`] or -//! [`crate::gnome::GnomeProvider`]. A provider returns a [`FrameSource`]; PipeWire is the only -//! one today, and a producer that does not use PipeWire (the wlroots backends capture through -//! Wayland protocols) becomes another variant there. +//! encode pipeline) and talks to one [`VirtualOutputProvider`]: [`crate::kde::KdeProvider`], +//! [`crate::gnome::GnomeProvider`], [`crate::sway::SwayProvider`] or +//! [`crate::hyprland::HyprlandProvider`]. A provider returns a [`FrameSource`]: PipeWire for KDE +//! and GNOME; the wlroots providers return a Wayland output that `wl_capture.rs` captures with +//! the compositor's own protocols. use crate::portal::SourceKind; use crate::pw::Target; @@ -29,6 +30,9 @@ pub struct OutputRequest { pub enum FrameSource { /// A PipeWire node, on the remote behind `fd` or, without one, on the user's own daemon. PipeWire { fd: Option, target: Target }, + /// A Wayland output captured directly over the compositor's capture protocols + /// (`wl_capture.rs`); `output` is its `wl_output` name (`HEADLESS-2`, `ezdisplay-1`). + Wayland { output: String }, } /// An opened output. @@ -58,3 +62,27 @@ pub trait VirtualOutputProvider: Send { None } } + +/// Refresh rate as compositor mode strings take it (`60`, `59.94`): whole hertz without a +/// fraction, else three decimals with trailing zeros trimmed. +pub fn rate_text(refresh_mhz: u32) -> String { + if refresh_mhz % 1000 == 0 { + (refresh_mhz / 1000).to_string() + } else { + let s = format!("{:.3}", f64::from(refresh_mhz) / 1000.0); + s.trim_end_matches('0').to_string() + } +} + +#[cfg(test)] +mod tests { + use super::rate_text; + + #[test] + fn rates() { + assert_eq!(rate_text(60_000), "60"); + assert_eq!(rate_text(59_940), "59.94"); + assert_eq!(rate_text(143_999), "143.999"); + assert_eq!(rate_text(29_970), "29.97"); + } +} diff --git a/crates/ezd-host-linux/src/pw.rs b/crates/ezd-host-linux/src/pw.rs index ce8a861..b52232e 100644 --- a/crates/ezd-host-linux/src/pw.rs +++ b/crates/ezd-host-linux/src/pw.rs @@ -57,29 +57,94 @@ enum Cmd { Quit, } +/// Mailbox state shared between a capture thread and its consumers (also used by `wl_capture`). #[derive(Default)] -struct State { - latest: Option, - fmt: Option, - fmt_version: u64, - error: Option, - closed: bool, - produced: u64, - coalesced: u64, +pub(crate) struct State { + pub(crate) latest: Option, + pub(crate) fmt: Option, + pub(crate) fmt_version: u64, + pub(crate) error: Option, + pub(crate) closed: bool, + pub(crate) produced: u64, + pub(crate) coalesced: u64, } -struct Shared { +/// One-slot frame mailbox plus the negotiated format. +pub(crate) struct Shared { m: Mutex, cv: Condvar, } impl Shared { - fn lock(&self) -> std::sync::MutexGuard<'_, State> { + /// An empty mailbox. + pub(crate) fn new() -> Shared { + Shared { + m: Mutex::new(State::default()), + cv: Condvar::new(), + } + } + + /// Wakes the threads waiting in `next` and the `wait_*` calls. + pub(crate) fn notify(&self) { + self.cv.notify_all(); + } + + /// Waits until a format with exactly `want` size is negotiated (or `timeout`); returns the + /// last known format either way. + pub(crate) fn wait_format( + &self, + want: (u32, u32), + timeout: Duration, + ) -> Option { + let deadline = Instant::now() + timeout; + let mut s = self.lock(); + loop { + if let Some(f) = s.fmt + && (f.width, f.height) == want + { + return Some(f); + } + if s.closed || s.error.is_some() { + return s.fmt; + } + let now = Instant::now(); + if now >= deadline { + return s.fmt; + } + s = self + .cv + .wait_timeout(s, deadline - now) + .unwrap_or_else(|e| e.into_inner()) + .0; + } + } + + /// Waits for any format to be negotiated. + pub(crate) fn wait_any_format(&self, timeout: Duration) -> Option { + let deadline = Instant::now() + timeout; + let mut s = self.lock(); + loop { + if s.fmt.is_some() || s.closed || s.error.is_some() { + return s.fmt; + } + let now = Instant::now(); + if now >= deadline { + return None; + } + s = self + .cv + .wait_timeout(s, deadline - now) + .unwrap_or_else(|e| e.into_inner()) + .0; + } + } + + pub(crate) fn lock(&self) -> std::sync::MutexGuard<'_, State> { self.m.lock().unwrap_or_else(|e| e.into_inner()) } /// Takes the newest frame, waiting up to `timeout`. `Err` when the stream died. - fn next(&self, timeout: Duration) -> Result, String> { + pub(crate) fn next(&self, timeout: Duration) -> Result, String> { let deadline = Instant::now() + timeout; let mut s = self.lock(); loop { @@ -106,7 +171,7 @@ impl Shared { } /// Takes frames from the capture thread; usable from any thread while the capture runs. -pub struct Frames(Arc); +pub struct Frames(pub(crate) Arc); impl crate::ahead::FrameSource for Frames { fn next(&self, timeout: Duration) -> Result, String> { @@ -129,10 +194,7 @@ impl PwCapture { params: PwParams, cursor: CursorSink, ) -> Result { - let shared = Arc::new(Shared { - m: Mutex::new(State::default()), - cv: Condvar::new(), - }); + let shared = Arc::new(Shared::new()); let (tx, rx) = pw::channel::channel::(); let (ready_tx, ready_rx) = std::sync::mpsc::channel::>(); let thread = { @@ -188,49 +250,12 @@ impl PwCapture { /// Waits until a format with exactly `want` size is negotiated (or `timeout`); returns the /// last known format either way. pub fn wait_format(&self, want: (u32, u32), timeout: Duration) -> Option { - let deadline = Instant::now() + timeout; - let mut s = self.shared.lock(); - loop { - if let Some(f) = s.fmt - && (f.width, f.height) == want - { - return Some(f); - } - if s.closed || s.error.is_some() { - return s.fmt; - } - let now = Instant::now(); - if now >= deadline { - return s.fmt; - } - s = self - .shared - .cv - .wait_timeout(s, deadline - now) - .unwrap_or_else(|e| e.into_inner()) - .0; - } + self.shared.wait_format(want, timeout) } /// Waits for any format to be negotiated. pub fn wait_any_format(&self, timeout: Duration) -> Option { - let deadline = Instant::now() + timeout; - let mut s = self.shared.lock(); - loop { - if s.fmt.is_some() || s.closed || s.error.is_some() { - return s.fmt; - } - let now = Instant::now(); - if now >= deadline { - return None; - } - s = self - .shared - .cv - .wait_timeout(s, deadline - now) - .unwrap_or_else(|e| e.into_inner()) - .0; - } + self.shared.wait_any_format(timeout) } /// Takes the newest frame, waiting up to `timeout`. `Err` when the stream died. diff --git a/crates/ezd-host-linux/src/source.rs b/crates/ezd-host-linux/src/source.rs index 3a0418b..a98a81f 100644 --- a/crates/ezd-host-linux/src/source.rs +++ b/crates/ezd-host-linux/src/source.rs @@ -1,5 +1,5 @@ //! [`VideoSource`] for Linux: a compositor-specific virtual output (see `provider.rs`) -> PipeWire -//! -> hardware encode. +//! or direct Wayland capture (`wl_capture.rs`) -> hardware encode. use crate::adapter::{self, RenderNode}; use crate::ahead::{ConvertAhead, Item, Start}; @@ -7,9 +7,11 @@ use crate::encode::{ Converter, Encoder, Kind, Pipeline, Plan, StreamTags, plan_encoders, plan_hdr_encoders, probe_plan, }; +use crate::frame::{Frame, NegotiatedFormat}; use crate::portal::SourceKind; use crate::provider::{FrameSource, OutputRequest, VirtualOutputProvider}; -use crate::pw::{PwCapture, PwParams, default_intel_modifiers, default_nvidia_modifiers}; +use crate::pw::{Frames, PwCapture, PwParams, default_intel_modifiers, default_nvidia_modifiers}; +use crate::wl_capture::WlCapture; use ezd_host_core::{ CursorSink, EncodedFrame, OutputInfo, PlatformOptions, SourceError, SourceHdr, VideoSource, }; @@ -101,10 +103,61 @@ enum Input { }, } +/// Where the frames come from: PipeWire (KDE, GNOME) or the compositor's Wayland capture +/// protocols (sway, Hyprland). +enum Capture { + Pw(PwCapture), + Wl(WlCapture), +} + +impl Capture { + fn wait_format(&self, want: (u32, u32), timeout: Duration) -> Option { + match self { + Capture::Pw(c) => c.wait_format(want, timeout), + Capture::Wl(c) => c.wait_format(want, timeout), + } + } + + fn wait_any_format(&self, timeout: Duration) -> Option { + match self { + Capture::Pw(c) => c.wait_any_format(timeout), + Capture::Wl(c) => c.wait_any_format(timeout), + } + } + + fn next(&self, timeout: Duration) -> Result, String> { + match self { + Capture::Pw(c) => c.next(timeout), + Capture::Wl(c) => c.next(timeout), + } + } + + fn frames(&self) -> Frames { + match self { + Capture::Pw(c) => c.frames(), + Capture::Wl(c) => c.frames(), + } + } + + fn counters(&self) -> (u64, u64) { + match self { + Capture::Pw(c) => c.counters(), + Capture::Wl(c) => c.counters(), + } + } + + /// Falls back to shared memory (PipeWire only: Wayland capture never uses DMA-BUF). + fn disable_dmabuf(&self) { + if let Capture::Pw(c) = self { + c.disable_dmabuf(); + } + } +} + struct Running { // Field order matters for drop: stop converting, then capture, then destroy the output. input: Input, - pw: PwCapture, + capture: Capture, /// The provider's hold on the virtual output. _output: Box, dmabuf_ok: bool, @@ -259,18 +312,30 @@ impl VideoSource for LinuxSource { })?; self.output = opened.info; - let params = PwParams { - width: cfg.width, - height: cfg.height, - fps: cfg.refresh_mhz.div_ceil(1000), - dmabuf_modifiers: self.dmabuf_modifiers(&plan), + let capture = match opened.frames { + FrameSource::PipeWire { fd, target } => { + let params = PwParams { + width: cfg.width, + height: cfg.height, + fps: cfg.refresh_mhz.div_ceil(1000), + dmabuf_modifiers: self.dmabuf_modifiers(&plan), + }; + Capture::Pw(PwCapture::start(fd, target, params, cursor).map_err(SourceError::msg)?) + } + FrameSource::Wayland { output } => { + let wl = WlCapture::start(&output, cursor).map_err(SourceError::msg)?; + tracing::info!( + protocol = wl.protocol().name(), + separate_cursor = wl.protocol().separate_cursor(), + "capturing {output} over Wayland" + ); + Capture::Wl(wl) + } }; - let FrameSource::PipeWire { fd, target } = opened.frames; - let pw = PwCapture::start(fd, target, params, cursor).map_err(SourceError::msg)?; - let fmt = pw + let fmt = capture .wait_format((cfg.width, cfg.height), Duration::from_secs(5)) - .or_else(|| pw.wait_any_format(Duration::from_secs(5))) - .ok_or_else(|| SourceError::msg("PipeWire stream did not negotiate a format"))?; + .or_else(|| capture.wait_any_format(Duration::from_secs(5))) + .ok_or_else(|| SourceError::msg("the capture did not negotiate a format"))?; if (fmt.width, fmt.height) != (cfg.width, cfg.height) { tracing::warn!( "stream is {}x{} but {}x{} was negotiated with the client; encoding at the stream size", @@ -299,7 +364,7 @@ impl VideoSource for LinuxSource { enc: Encoder::new(plan.name, cfg.refresh_mhz, cfg.bitrate_kbps).with_tags(tags), conv: ConvertAhead::start( Converter::new(plan, cfg.refresh_mhz).with_tags(tags), - pw.frames(), + capture.frames(), start, ) .map_err(SourceError::msg)?, @@ -310,7 +375,7 @@ impl VideoSource for LinuxSource { }; self.running = Some(Running { input, - pw, + capture, _output: opened.guard, dmabuf_ok: fmt.modifier.is_some(), dmabuf_off_at: None, @@ -327,7 +392,7 @@ impl VideoSource for LinuxSource { // `start_us`: when this frame's conversion (serial) or encoding (ahead) began. let (result, start_us, capture_us, seq, was_dma) = match &mut r.input { Input::Serial(pipeline) => { - let frame = match r.pw.next(timeout) { + let frame = match r.capture.next(timeout) { Ok(Some(f)) => f, Ok(None) => { self.force_key |= force; @@ -391,7 +456,7 @@ impl VideoSource for LinuxSource { tracing::warn!("DMA-BUF encode failed ({e}); falling back to shared memory"); r.dmabuf_ok = false; r.dmabuf_off_at = Some(Instant::now()); - r.pw.disable_dmabuf(); + r.capture.disable_dmabuf(); self.force_key = true; Ok(None) } @@ -416,7 +481,7 @@ impl VideoSource for LinuxSource { // Drop order: converter and encoder, PipeWire, then the output guard (removes the // virtual output). if let Some(r) = &self.running { - let (produced, coalesced) = r.pw.counters(); + let (produced, coalesced) = r.capture.counters(); tracing::info!(produced, coalesced, "capture stopped"); } self.running = None; diff --git a/crates/ezd-host-linux/src/sway.rs b/crates/ezd-host-linux/src/sway.rs new file mode 100644 index 0000000..68ee370 --- /dev/null +++ b/crates/ezd-host-linux/src/sway.rs @@ -0,0 +1,663 @@ +//! Sway: a headless virtual output through the i3 IPC socket (`$SWAYSOCK`), captured directly over +//! Wayland (`wl_capture.rs`), and the input mapping of the touch, pen and pointer devices. +//! +//! The IPC framing is `"i3-ipc"`, payload length (u32, native endian), message type (u32, native +//! endian), payload. A virtual output is `create_output` (sway adds a `HEADLESS-n` output, also on +//! a DRM session), then `output HEADLESS-n mode --custom WxH@RHz`. Sway places the new output to +//! the right of the existing ones by itself: an explicit `position` would mark the output as +//! configured, and sway then moves the user's own unconfigured outputs behind it. Dropping the +//! guard unplugs the output. + +use crate::kscreen::KOutput; +use crate::portal::SourceKind; +use crate::provider::{FrameSource, OpenedOutput, OutputRequest, VirtualOutputProvider}; +use crate::uinput_sink::{ + DesktopInput, MappedDevices, PEN_NAME, PEN_PRODUCT, POINTER_NAME, POINTER_PRODUCT, TOUCH_NAME, + TOUCH_PRODUCT, VENDOR, +}; +use ezd_host_core::{DisplayMode, OutputInfo, SourceError}; +use serde_json::Value; +use std::io::{self, Read, Write}; +use std::os::unix::net::UnixStream; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +const MAGIC: &[u8; 6] = b"i3-ipc"; +/// `RUN_COMMAND`. +pub const RUN_COMMAND: u32 = 0; +/// `GET_OUTPUTS`. +pub const GET_OUTPUTS: u32 = 3; +/// `GET_VERSION`. +pub const GET_VERSION: u32 = 7; +/// Replies larger than this are a protocol error. +const MAX_PAYLOAD: usize = 16 << 20; +const IO_TIMEOUT: Duration = Duration::from_secs(5); +const OUTPUT_TIMEOUT: Duration = Duration::from_secs(3); + +/// One IPC message: magic, length, type, payload. +pub fn encode_message(ty: u32, payload: &[u8]) -> Vec { + let mut out = Vec::with_capacity(14 + payload.len()); + out.extend_from_slice(MAGIC); + out.extend_from_slice(&(payload.len() as u32).to_ne_bytes()); + out.extend_from_slice(&ty.to_ne_bytes()); + out.extend_from_slice(payload); + out +} + +/// Reads one message: `(type, payload)`. +pub fn read_message(r: &mut impl Read) -> io::Result<(u32, Vec)> { + let mut head = [0u8; 14]; + r.read_exact(&mut head)?; + if &head[..6] != MAGIC { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "not an i3-ipc message", + )); + } + let len = u32::from_ne_bytes([head[6], head[7], head[8], head[9]]) as usize; + let ty = u32::from_ne_bytes([head[10], head[11], head[12], head[13]]); + if len > MAX_PAYLOAD { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("i3-ipc payload of {len} bytes"), + )); + } + let mut payload = vec![0u8; len]; + r.read_exact(&mut payload)?; + Ok((ty, payload)) +} + +/// Result of a `RUN_COMMAND`: the first failure's message, if any. +pub fn parse_command_reply(payload: &[u8]) -> Result<(), String> { + let v: Value = serde_json::from_slice(payload).map_err(|e| format!("bad reply: {e}"))?; + let items = v.as_array().ok_or("bad reply: not an array")?; + for item in items { + if item.get("success").and_then(Value::as_bool) != Some(true) { + let msg = item + .get("error") + .and_then(Value::as_str) + .unwrap_or("the command failed"); + return Err(msg.to_string()); + } + } + Ok(()) +} + +/// An output as `GET_OUTPUTS` reports it. +#[derive(Debug, Clone, PartialEq)] +pub struct SwayOutput { + pub name: String, + pub active: bool, + pub scale: f64, + /// Position and size in the layout (logical pixels). + pub rect: (i32, i32, u32, u32), + /// Current mode: width, height (pixels), refresh in millihertz (0 when the output has none). + pub mode: Option<(u32, u32, u32)>, +} + +/// Parses a `GET_OUTPUTS` reply. +pub fn parse_outputs(payload: &[u8]) -> Result, String> { + let v: Value = serde_json::from_slice(payload).map_err(|e| format!("bad reply: {e}"))?; + let items = v.as_array().ok_or("bad reply: not an array")?; + let int = |v: &Value, k: &str| v.get(k).and_then(Value::as_i64).unwrap_or(0); + Ok(items + .iter() + .filter_map(|o| { + let name = o.get("name")?.as_str()?.to_string(); + let rect = o.get("rect"); + let r = |k: &str| rect.map_or(0, |r| int(r, k)); + let mode = o.get("current_mode").filter(|m| m.is_object()).map(|m| { + ( + int(m, "width") as u32, + int(m, "height") as u32, + int(m, "refresh") as u32, + ) + }); + Some(SwayOutput { + name, + active: o.get("active").and_then(Value::as_bool).unwrap_or(false), + scale: o.get("scale").and_then(Value::as_f64).unwrap_or(1.0), + rect: ( + r("x") as i32, + r("y") as i32, + r("width") as u32, + r("height") as u32, + ), + mode, + }) + }) + .collect()) +} + +/// The `human_readable` version of a `GET_VERSION` reply. +pub fn parse_version(payload: &[u8]) -> Option { + let v: Value = serde_json::from_slice(payload).ok()?; + v.get("human_readable")?.as_str().map(str::to_string) +} + +/// The output that is in `after` but not in `before` (the one `create_output` added). +pub fn new_output(before: &[SwayOutput], after: &[SwayOutput]) -> Option { + after + .iter() + .find(|a| !before.iter().any(|b| b.name == a.name)) + .map(|a| a.name.clone()) +} + +/// Output names with spaces need quotes in sway commands. +fn quote_name(name: &str) -> String { + if name + .chars() + .all(|c| c.is_ascii_alphanumeric() || "-_.:*".contains(c)) + { + name.to_string() + } else { + format!("\"{}\"", name.replace('\\', "\\\\").replace('"', "\\\"")) + } +} + +/// The command that sets a custom mode. +pub fn mode_command(name: &str, width: u32, height: u32, refresh_mhz: u32) -> String { + format!( + "output {} mode --custom {width}x{height}@{}Hz", + quote_name(name), + crate::provider::rate_text(refresh_mhz.max(1000)) + ) +} + +/// A connection to sway's IPC socket. +pub struct SwayIpc { + stream: UnixStream, +} + +/// The socket path from `$SWAYSOCK`. +pub fn socket_path() -> Result { + std::env::var_os("SWAYSOCK") + .filter(|v| !v.is_empty()) + .map(PathBuf::from) + .ok_or_else(|| "SWAYSOCK is not set (is sway running in this session?)".into()) +} + +impl SwayIpc { + /// Connects to `$SWAYSOCK`. + pub fn connect() -> Result { + SwayIpc::connect_to(&socket_path()?) + } + + /// Connects to the socket at `path`. + pub fn connect_to(path: &Path) -> Result { + let stream = UnixStream::connect(path) + .map_err(|e| format!("cannot connect to {}: {e}", path.display()))?; + let _ = stream.set_read_timeout(Some(IO_TIMEOUT)); + let _ = stream.set_write_timeout(Some(IO_TIMEOUT)); + Ok(SwayIpc { stream }) + } + + /// Sends one request and returns the payload of its reply. + pub fn request(&mut self, ty: u32, payload: &[u8]) -> Result, String> { + self.stream + .write_all(&encode_message(ty, payload)) + .map_err(|e| format!("sway IPC write: {e}"))?; + loop { + let (t, p) = + read_message(&mut self.stream).map_err(|e| format!("sway IPC read: {e}"))?; + // Events (high bit set) are only sent to subscribers; skip any that show up. + if t == ty { + return Ok(p); + } + if t & 0x8000_0000 == 0 { + return Err(format!( + "sway IPC: reply type {t} to a request of type {ty}" + )); + } + } + } + + /// Runs a sway command. + pub fn command(&mut self, cmd: &str) -> Result<(), String> { + let reply = self.request(RUN_COMMAND, cmd.as_bytes())?; + parse_command_reply(&reply).map_err(|e| format!("sway `{cmd}`: {e}")) + } + + /// The outputs sway knows. + pub fn outputs(&mut self) -> Result, String> { + parse_outputs(&self.request(GET_OUTPUTS, b"")?) + } + + /// Sway's version string. + pub fn version(&mut self) -> Result { + parse_version(&self.request(GET_VERSION, b"")?).ok_or_else(|| "no version in reply".into()) + } +} + +/// The output description of `name`. +pub fn output_info(outputs: &[SwayOutput], name: &str, fallback_mhz: u32) -> Option { + let o = outputs.iter().find(|o| o.name == name)?; + let (w, h, hz) = o.mode.unwrap_or((o.rect.2, o.rect.3, 0)); + Some(OutputInfo { + name: o.name.clone(), + mode: DisplayMode { + width: w, + height: h, + refresh_mhz: if hz == 0 { fallback_mhz } else { hz }, + }, + origin: (o.rect.0, o.rect.1), + adapter: None, + }) +} + +/// Unplugs a virtual output when dropped. +struct OutputGuard { + socket: PathBuf, + name: String, +} + +impl Drop for OutputGuard { + fn drop(&mut self) { + let r = SwayIpc::connect_to(&self.socket) + .and_then(|mut ipc| ipc.command(&format!("output {} unplug", quote_name(&self.name)))); + match r { + Ok(()) => tracing::info!(output = %self.name, "virtual output unplugged"), + Err(e) => tracing::warn!("cannot unplug {}: {e}", self.name), + } + } +} + +/// Provider for sway. +#[derive(Default)] +pub struct SwayProvider; + +impl VirtualOutputProvider for SwayProvider { + fn name(&self) -> &'static str { + "sway" + } + + fn open(&mut self, req: &OutputRequest) -> Result { + let socket = socket_path().map_err(SourceError::msg)?; + let mut ipc = SwayIpc::connect_to(&socket).map_err(SourceError::msg)?; + if req.kind == SourceKind::Monitor { + let name = req + .output_name + .clone() + .ok_or_else(|| SourceError::msg("no output name to capture"))?; + let outputs = ipc.outputs().map_err(SourceError::msg)?; + let info = output_info(&outputs, &name, req.refresh_mhz).ok_or_else(|| { + SourceError::msg(format!( + "sway has no output called {name} (outputs: {})", + outputs + .iter() + .map(|o| o.name.as_str()) + .collect::>() + .join(", ") + )) + })?; + return Ok(OpenedOutput { + frames: FrameSource::Wayland { output: name }, + info: Some(info), + guard: Box::new(()), + }); + } + + let before = ipc.outputs().map_err(SourceError::msg)?; + ipc.command("create_output").map_err(SourceError::msg)?; + let deadline = Instant::now() + OUTPUT_TIMEOUT; + let name = loop { + let after = ipc.outputs().map_err(SourceError::msg)?; + if let Some(n) = new_output(&before, &after) { + break n; + } + if Instant::now() >= deadline { + return Err(SourceError::msg("sway did not add a virtual output")); + } + std::thread::sleep(Duration::from_millis(50)); + }; + tracing::info!(output = %name, "sway virtual output created"); + // From here on the guard removes the output on every early return. + let guard = OutputGuard { + socket, + name: name.clone(), + }; + ipc.command(&mode_command(&name, req.width, req.height, req.refresh_mhz)) + .map_err(SourceError::msg)?; + let mut info = None; + let deadline = Instant::now() + OUTPUT_TIMEOUT; + loop { + let outputs = ipc.outputs().map_err(SourceError::msg)?; + info = output_info(&outputs, &name, req.refresh_mhz).or(info); + if info + .as_ref() + .is_some_and(|i| (i.mode.width, i.mode.height) == (req.width, req.height)) + { + break; + } + if Instant::now() >= deadline { + tracing::warn!(output = %name, ?info, "the mode was not applied in time"); + break; + } + std::thread::sleep(Duration::from_millis(50)); + } + Ok(OpenedOutput { + frames: FrameSource::Wayland { output: name }, + info, + guard: Box::new(guard), + }) + } +} + +/// Sway's identifier of an input device: `::` in decimal, with spaces (and +/// unprintable characters) in the name replaced by underscores. +pub fn input_identifier(vendor: u16, product: u16, name: &str) -> String { + let name: String = name + .trim() + .chars() + .map(|c| if c == ' ' || c.is_control() { '_' } else { c }) + .collect(); + format!("{vendor}:{product}:{name}") +} + +/// The identifier of the device behind a label (`touch`, `pen`, `pointer`). +pub fn device_identifier(label: &str) -> Option { + let (product, name) = match label { + "touch" => (TOUCH_PRODUCT, TOUCH_NAME), + "pen" => (PEN_PRODUCT, PEN_NAME), + "pointer" => (POINTER_PRODUCT, POINTER_NAME), + _ => return None, + }; + Some(input_identifier(VENDOR, product, name)) +} + +/// `input map_to_output `. +pub fn map_command(identifier: &str, output: &str) -> String { + format!( + "input {} map_to_output {}", + quote_name(identifier), + quote_name(output) + ) +} + +/// Undoes the mappings when dropped: `map_to_output *` names no output, which sway treats as no +/// mapping. The devices are gone by then, but their configuration stays in sway. +struct Restore { + identifiers: Vec, +} + +impl Drop for Restore { + fn drop(&mut self) { + let Ok(mut ipc) = SwayIpc::connect() else { + return; + }; + for id in &self.identifiers { + if let Err(e) = ipc.command(&map_command(id, "*")) { + tracing::debug!("cannot clear the output mapping of {id}: {e}"); + } + } + } +} + +/// Input mapping for sway: every device is tied to the virtual output, so the absolute pointer +/// needs no workspace mapping. +#[derive(Default)] +pub struct SwayDesktop; + +impl DesktopInput for SwayDesktop { + fn map_devices(&self, devices: &[(&str, &str)], output: &str) -> MappedDevices { + let mut ipc = match SwayIpc::connect() { + Ok(i) => i, + Err(e) => { + return MappedDevices { + results: devices.iter().map(|_| Err(e.clone())).collect(), + guard: None, + }; + } + }; + let mut restore = Restore { + identifiers: Vec::new(), + }; + let results = devices + .iter() + .map(|(label, _)| { + let id = + device_identifier(label).ok_or_else(|| format!("unknown device {label}"))?; + ipc.command(&map_command(&id, output))?; + tracing::info!(device = *label, %id, %output, "sway output mapping set"); + restore.identifiers.push(id); + Ok(()) + }) + .collect(); + MappedDevices { + results, + guard: (!restore.identifiers.is_empty()).then(|| Box::new(restore) as Box), + } + } + + fn outputs(&self) -> Option> { + None + } + + fn maps_pointer(&self) -> bool { + true + } +} + +/// Sway's part of `ezd-host diag`. +pub fn diag() -> Value { + let probe = || -> Result<(String, Vec), String> { + let mut ipc = SwayIpc::connect()?; + Ok((ipc.version()?, ipc.outputs()?)) + }; + match probe() { + Ok((version, outputs)) => serde_json::json!({ + "sway_version": version, + "outputs": outputs.iter().map(|o| serde_json::json!({ + "name": o.name, + "active": o.active, + "size": o.mode.map(|m| [m.0, m.1]), + "position": [o.rect.0, o.rect.1], + "scale": o.scale, + })).collect::>(), + }), + Err(e) => serde_json::json!({ "error": e }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{Duration, Instant}; + + const OUTPUTS: &str = include_str!("../testdata/sway-get-outputs.json"); + const VERSION: &str = include_str!("../testdata/sway-get-version.json"); + + #[test] + fn framing_is_magic_length_type_payload() { + // Captured from a real sway: a GET_VERSION reply header (136 bytes, type 7). + let real = [ + 0x69, 0x33, 0x2d, 0x69, 0x70, 0x63, 0x88, 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, + ]; + let mut msg = real.to_vec(); + msg.extend(std::iter::repeat_n(b' ', 0x88)); + let (ty, payload) = read_message(&mut msg.as_slice()).unwrap(); + assert_eq!((ty, payload.len()), (GET_VERSION, 0x88)); + let enc = encode_message(RUN_COMMAND, b"create_output"); + assert_eq!(&enc[..6], b"i3-ipc"); + assert_eq!(u32::from_ne_bytes(enc[6..10].try_into().unwrap()), 13); + assert_eq!(u32::from_ne_bytes(enc[10..14].try_into().unwrap()), 0); + assert_eq!(&enc[14..], b"create_output"); + let (ty, payload) = read_message(&mut enc.as_slice()).unwrap(); + assert_eq!((ty, payload), (RUN_COMMAND, b"create_output".to_vec())); + } + + #[test] + fn rejects_bad_magic_truncation_and_huge_payloads() { + let mut bad = encode_message(0, b"x"); + bad[0] = b'j'; + assert!(read_message(&mut bad.as_slice()).is_err()); + let mut short = encode_message(3, b"hello"); + short.truncate(short.len() - 1); + assert!(read_message(&mut short.as_slice()).is_err()); + let mut huge = encode_message(3, b""); + huge[6..10].copy_from_slice(&u32::MAX.to_ne_bytes()); + assert!(read_message(&mut huge.as_slice()).is_err()); + } + + #[test] + fn command_replies() { + assert_eq!(parse_command_reply(br#"[{"success":true}]"#), Ok(())); + // Real error reply of `output NOPE-9 unplug`. + let err = parse_command_reply( + br#"[{"success":false,"parse_error":true,"error":"Cannot unplug unknown output NOPE-9"}]"#, + ) + .unwrap_err(); + assert!(err.contains("unknown output NOPE-9")); + assert!(parse_command_reply(b"{}").is_err()); + assert!(parse_command_reply(b"nope").is_err()); + } + + #[test] + fn parses_real_get_outputs() { + let outs = parse_outputs(OUTPUTS.as_bytes()).unwrap(); + assert_eq!(outs.len(), 2); + assert_eq!(outs[0].name, "HEADLESS-1"); + assert_eq!(outs[0].mode, Some((1280, 720, 0))); + let o = &outs[1]; + assert_eq!(o.name, "HEADLESS-2"); + assert!(o.active); + assert_eq!(o.rect, (1280, 0, 1280, 720)); + assert_eq!(o.mode, Some((1280, 720, 60000))); + assert_eq!(o.scale, 1.0); + } + + #[test] + fn diffing_finds_the_created_output() { + let outs = parse_outputs(OUTPUTS.as_bytes()).unwrap(); + assert_eq!(new_output(&outs[..1], &outs), Some("HEADLESS-2".into())); + assert_eq!(new_output(&outs, &outs), None); + assert_eq!(new_output(&[], &outs[..1]), Some("HEADLESS-1".into())); + } + + #[test] + fn output_info_reads_geometry_and_falls_back_for_a_missing_refresh() { + let outs = parse_outputs(OUTPUTS.as_bytes()).unwrap(); + let i = output_info(&outs, "HEADLESS-2", 90_000).unwrap(); + assert_eq!(i.origin, (1280, 0)); + assert_eq!( + (i.mode.width, i.mode.height, i.mode.refresh_mhz), + (1280, 720, 60_000) + ); + // HEADLESS-1 reports refresh 0. + assert_eq!( + output_info(&outs, "HEADLESS-1", 90_000) + .unwrap() + .mode + .refresh_mhz, + 90_000 + ); + assert!(output_info(&outs, "DP-9", 1).is_none()); + } + + #[test] + fn version_text() { + assert_eq!(parse_version(VERSION.as_bytes()).as_deref(), Some("1.12")); + assert_eq!(parse_version(b"[]"), None); + } + + #[test] + fn mode_commands() { + assert_eq!( + mode_command("HEADLESS-2", 2560, 1600, 60_000), + "output HEADLESS-2 mode --custom 2560x1600@60Hz" + ); + assert_eq!( + mode_command("HEADLESS-2", 1920, 1080, 59_940), + "output HEADLESS-2 mode --custom 1920x1080@59.94Hz" + ); + assert_eq!( + mode_command("My Monitor", 800, 600, 120_000), + "output \"My Monitor\" mode --custom 800x600@120Hz" + ); + } + + #[test] + fn input_identifiers_are_decimal_with_underscores() { + assert_eq!( + device_identifier("touch").unwrap(), + "4617:60881:ezdisplay_touch" + ); + assert_eq!( + device_identifier("pen").unwrap(), + "4617:60882:ezdisplay_pen" + ); + assert_eq!( + device_identifier("pointer").unwrap(), + "4617:60883:ezdisplay_pointer" + ); + assert_eq!(device_identifier("keyboard"), None); + assert_eq!(input_identifier(1, 2, " a b\tc "), "1:2:a_b_c"); + } + + #[test] + fn mapping_commands() { + assert_eq!( + map_command("4617:60881:ezdisplay_touch", "HEADLESS-2"), + "input 4617:60881:ezdisplay_touch map_to_output HEADLESS-2" + ); + assert_eq!( + map_command("4617:60881:ezdisplay_touch", "*"), + "input 4617:60881:ezdisplay_touch map_to_output *" + ); + } + + /// The whole pipeline in a headless sway: output, direct capture, libx264. Run it with + /// `tools/wlroots-headless-test.sh`. + #[test] + fn headless_sway_encoded_frames() { + use crate::source::LinuxSource; + use ezd_host_core::{CursorSink, PlatformOptions, VideoSource}; + use ezd_proto::{Codec, StreamConfig}; + if !crate::wlroots_test::enabled() { + return; + } + let mut src = LinuxSource::new(&PlatformOptions::default(), Box::new(SwayProvider)); + let cfg = StreamConfig { + width: 1280, + height: 720, + refresh_mhz: 60_000, + codec: Codec::H264, + bitrate_kbps: 8000, + encoder_name: "libx264".into(), + }; + src.start(&cfg, CursorSink::null()).expect("start"); + let out = src.output().expect("output info"); + eprintln!("output: {out:?}"); + assert_eq!((out.mode.width, out.mode.height), (1280, 720)); + let _driver = crate::wlroots_test::Driver::start(&out.name); + // Input mapping: sway accepts the commands for the three devices. + { + let mapped = SwayDesktop.map_devices( + &[ + ("touch", "event0"), + ("pen", "event1"), + ("pointer", "event2"), + ], + &out.name, + ); + assert!( + mapped.results.iter().all(Result::is_ok), + "{:?}", + mapped.results + ); + } + let deadline = Instant::now() + Duration::from_secs(20); + let mut frames = Vec::new(); + while frames.len() < 5 && Instant::now() < deadline { + if let Some(f) = src.next_frame(Duration::from_millis(500)).expect("frame") { + frames.push(f); + } + } + src.stop(); + eprintln!("frames: {}", frames.len()); + assert!(frames.len() >= 3, "got {} frames", frames.len()); + assert!(frames[0].keyframe && frames[0].codec_config.is_some()); + let outputs = SwayIpc::connect().unwrap().outputs().unwrap(); + assert!(!outputs.iter().any(|o| o.name == out.name)); + } +} diff --git a/crates/ezd-host-linux/src/uinput_sink.rs b/crates/ezd-host-linux/src/uinput_sink.rs index f083863..eea0596 100644 --- a/crates/ezd-host-linux/src/uinput_sink.rs +++ b/crates/ezd-host-linux/src/uinput_sink.rs @@ -339,6 +339,13 @@ pub trait DesktopInput: Send + Sync { /// The current output layout, `None` when it cannot be read. fn outputs(&self) -> Option>; + + /// True when the compositor maps the absolute pointer to the output itself: `map_devices` + /// then also gets a `pointer` device, and the sink sends coordinates over the output + /// instead of over the whole workspace. + fn maps_pointer(&self) -> bool { + false + } } /// Result of [`DesktopInput::map_devices`]. @@ -397,7 +404,7 @@ impl InputBackend for UinputBackend { let mouse = target.pen_mode == PenMode::Mouse; let mut notes: Vec = Vec::new(); - let pointer = create_pointer().map_err(|e| open_error(&e))?; + let mut pointer = create_pointer().map_err(|e| open_error(&e))?; let mut touch_dev = create_touch(mm).map_err(|e| open_error(&e))?; let mut pen_dev = if mouse { None @@ -406,7 +413,8 @@ impl InputBackend for UinputBackend { }; // The compositor drops touch and tablet events of devices without an output. - let (mut touch_ok, mut pen_ok) = (false, false); + let (mut touch_ok, mut pen_ok, mut pointer_ok) = (false, false, false); + let map_pointer = self.desktop.maps_pointer(); let mut mapping_guard = None; match target.output.as_ref().map(|o| o.name.as_str()) { None | Some("") => notes.push( @@ -425,6 +433,12 @@ impl InputBackend for UinputBackend { None => notes.push("the pen device has no /dev/input node".into()), } } + if map_pointer { + match event_name(&mut pointer) { + Some(n) => devices.push(("pointer", n)), + None => notes.push("the pointer device has no /dev/input node".into()), + } + } let refs: Vec<(&str, &str)> = devices.iter().map(|(l, n)| (*l, n.as_str())).collect(); let mapped = self.desktop.map_devices(&refs, output); @@ -433,6 +447,7 @@ impl InputBackend for UinputBackend { match r { Ok(()) => match *label { "touch" => touch_ok = true, + "pointer" => pointer_ok = true, _ => pen_ok = true, }, Err(e) => notes.push(format!("{label}: {e}")), @@ -459,7 +474,12 @@ impl InputBackend for UinputBackend { // Output geometry for the pointer (one blocking call here, then a helper thread). let mut mapper = Mapper::identity(); let mut refresh = None; - if let Some(o) = target.output.as_ref().filter(|o| !o.name.is_empty()) { + // A pointer the compositor maps to the output needs no workspace geometry. + let geometry_output = target + .output + .as_ref() + .filter(|o| !o.name.is_empty() && !pointer_ok); + if let Some(o) = geometry_output { if let Some(outs) = self.desktop.outputs() { mapper = Mapper::from_outputs(&outs, &o.name); } diff --git a/crates/ezd-host-linux/src/wl_capture.rs b/crates/ezd-host-linux/src/wl_capture.rs new file mode 100644 index 0000000..fa32d63 --- /dev/null +++ b/crates/ezd-host-linux/src/wl_capture.rs @@ -0,0 +1,1523 @@ +//! Direct Wayland capture of one output, for the wlroots compositors (sway, Hyprland): no portal and +//! no PipeWire, the frames come from the compositor's own capture protocols over `$WAYLAND_DISPLAY`. +//! +//! Two protocols are supported, the first one advertised wins ([`choose_protocol`]): +//! +//! * `ext-image-copy-capture-v1` with `ext-output-image-capture-source-v1`: frames are captured +//! without the cursor, and a pointer cursor session reports the cursor's position, hotspot and +//! image, which go to the [`CursorSink`] (the tablet draws its own local cursor). +//! * `wlr-screencopy-unstable-v1` as the fallback: the cursor is painted into the frames +//! (`overlay_cursor`), so no cursor data is sent and the tablet shows none of its own. +//! +//! Buffers are `wl_shm` (a memfd) only; DMA-BUF capture is not implemented. The capture thread +//! asks for the next frame as soon as one is delivered: both protocols answer when the output +//! changed, so the rate follows the compositor's damage up to its refresh rate. Frames are copied +//! out of the shared buffer into the one-slot mailbox of `pw.rs` (the newest frame wins), so the +//! encoder sees the same frame type as on the PipeWire path. + +use crate::cursor::{PixelOrder, build_shape}; +use crate::frame::{ + Frame, NegotiatedFormat, Pixels, ShmBuf, SpaColor, capture_us_from_pts, pixel_order, spa_fmt, +}; +use crate::pw::{Frames, Shared}; +use ezd_host_core::{CursorSink, now_us}; +use ezd_proto::{CursorShape, CursorState}; +use std::os::fd::{AsFd, AsRawFd, FromRawFd, OwnedFd}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, mpsc}; +use std::time::{Duration, Instant}; +use wayland_client::globals::{GlobalListContents, registry_queue_init}; +use wayland_client::protocol::{ + wl_buffer, wl_output, wl_pointer, wl_registry, wl_seat, wl_shm, wl_shm_pool, +}; +use wayland_client::{Connection, Dispatch, Proxy, QueueHandle, WEnum, delegate_noop}; +use wayland_protocols::ext::image_capture_source::v1::client::{ + ext_image_capture_source_v1::ExtImageCaptureSourceV1, + ext_output_image_capture_source_manager_v1::ExtOutputImageCaptureSourceManagerV1, +}; +use wayland_protocols::ext::image_copy_capture::v1::client::{ + ext_image_copy_capture_cursor_session_v1::{self, ExtImageCopyCaptureCursorSessionV1}, + ext_image_copy_capture_frame_v1::{self, ExtImageCopyCaptureFrameV1, FailureReason}, + ext_image_copy_capture_manager_v1::{ExtImageCopyCaptureManagerV1, Options}, + ext_image_copy_capture_session_v1::{self, ExtImageCopyCaptureSessionV1}, +}; +use wayland_protocols_wlr::screencopy::v1::client::{ + zwlr_screencopy_frame_v1::{self, ZwlrScreencopyFrameV1}, + zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1, +}; + +const EXT_COPY: &str = "ext_image_copy_capture_manager_v1"; +const EXT_SOURCE: &str = "ext_output_image_capture_source_manager_v1"; +const WLR_COPY: &str = "zwlr_screencopy_manager_v1"; +/// Consecutive failed captures before the stream is given up. +const MAX_FAILURES: u32 = 20; +/// How long the output may take to show up in the registry after the compositor created it. +const OUTPUT_WAIT: Duration = Duration::from_secs(5); +/// How often the loop looks at the stop flag while the connection is idle. +const POLL_MS: i32 = 50; + +/// The capture protocol in use. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Protocol { + /// `ext-image-copy-capture-v1`: no cursor in the frames, cursor session. + ExtImageCopy, + /// `wlr-screencopy-unstable-v1`: the cursor is painted into the frames. + WlrScreencopy, +} + +impl Protocol { + /// Short name for logs. + pub fn name(self) -> &'static str { + match self { + Protocol::ExtImageCopy => "ext-image-copy-capture-v1", + Protocol::WlrScreencopy => "wlr-screencopy-unstable-v1", + } + } + + /// True if the compositor sends the cursor separately (the tablet can draw it locally). + pub fn separate_cursor(self) -> bool { + self == Protocol::ExtImageCopy + } +} + +/// Which capture globals the compositor advertises. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Advertised { + pub ext_copy: bool, + pub ext_output_source: bool, + pub wlr_screencopy: bool, +} + +impl Advertised { + /// Reads the interface names of a registry. + pub fn from_interfaces<'a>(names: impl IntoIterator) -> Advertised { + let mut a = Advertised::default(); + for n in names { + match n { + EXT_COPY => a.ext_copy = true, + EXT_SOURCE => a.ext_output_source = true, + WLR_COPY => a.wlr_screencopy = true, + _ => {} + } + } + a + } +} + +/// Picks the capture protocol: `forced` (`EZD_WL_CAPTURE`: `ext` or `wlr`) when given, else the +/// ext protocol (it needs both of its globals), else wlr-screencopy. +pub fn choose_protocol(adv: &Advertised, forced: Option<&str>) -> Result { + let ext = adv.ext_copy && adv.ext_output_source; + match forced.map(|f| f.trim().to_ascii_lowercase()).as_deref() { + None | Some("") | Some("auto") => {} + Some("ext") => { + return if ext { + Ok(Protocol::ExtImageCopy) + } else { + Err("EZD_WL_CAPTURE=ext, but the compositor does not advertise \ + ext-image-copy-capture-v1 with ext-output-image-capture-source-v1" + .into()) + }; + } + Some("wlr") => { + return if adv.wlr_screencopy { + Ok(Protocol::WlrScreencopy) + } else { + Err("EZD_WL_CAPTURE=wlr, but the compositor does not advertise \ + wlr-screencopy-unstable-v1" + .into()) + }; + } + Some(other) => { + return Err(format!( + "EZD_WL_CAPTURE={other} is not valid (use ext, wlr or auto)" + )); + } + } + if ext { + Ok(Protocol::ExtImageCopy) + } else if adv.wlr_screencopy { + Ok(Protocol::WlrScreencopy) + } else { + Err("the compositor offers no screen capture protocol \ + (ext-image-copy-capture-v1 or wlr-screencopy-unstable-v1)" + .into()) + } +} + +/// `wl_shm` formats the encoder takes: `(wl_shm code, SPA format)`. The codes are the DRM fourccs +/// except for the two legacy ones, and describe a little-endian word, so XRGB8888 is `B G R x` in +/// memory. +const SHM_FORMATS: [(u32, u32); 4] = [ + (1, spa_fmt::BGRX), // XRGB8888 + (0, spa_fmt::BGRA), // ARGB8888 + (0x3432_4258, spa_fmt::RGBX), // XBGR8888 + (0x3432_4241, spa_fmt::RGBA), // ABGR8888 +]; + +/// The `wl_shm` format to use out of the ones the compositor offers, with its SPA id. The +/// capture prefers formats without alpha; the cursor image needs one with alpha. +pub fn choose_shm_format(offered: &[u32], want_alpha: bool) -> Option<(u32, u32)> { + let order: [usize; 4] = if want_alpha { + [1, 3, 0, 2] + } else { + [0, 2, 1, 3] + }; + order + .iter() + .map(|&i| SHM_FORMATS[i]) + .find(|(code, _)| offered.contains(code)) +} + +fn wl_format(code: u32) -> Option { + wl_shm::Format::try_from(code).ok() +} + +/// Copies `rows` rows of `stride` bytes in reverse order (a `y_invert` screencopy frame). +pub fn flip_rows(data: &[u8], stride: usize, rows: usize) -> Vec { + let mut out = Vec::with_capacity(data.len()); + for y in (0..rows).rev() { + out.extend_from_slice(&data[y * stride..(y + 1) * stride]); + } + out +} + +/// A `presentation_time` / `ready` timestamp as CLOCK_MONOTONIC nanoseconds. +pub fn timestamp_ns(hi: u32, lo: u32, nsec: u32) -> i64 { + let secs = (u64::from(hi) << 32) | u64::from(lo); + (secs as i64) + .saturating_mul(1_000_000_000) + .saturating_add(i64::from(nsec)) +} + +fn mono_now_ns() -> i64 { + let mut ts = libc::timespec { + tv_sec: 0, + tv_nsec: 0, + }; + // SAFETY: `ts` is a valid out pointer. + unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut ts) }; + // `time_t` and `c_long` are `i64` on 64-bit Linux but narrower elsewhere. + #[allow(clippy::useless_conversion)] + { + i64::from(ts.tv_sec) * 1_000_000_000 + i64::from(ts.tv_nsec) + } +} + +fn capture_time(pts: Option<(u32, u32, u32)>) -> u64 { + let now = now_us(); + match pts { + Some((hi, lo, ns)) => capture_us_from_pts(timestamp_ns(hi, lo, ns), mono_now_ns(), now), + None => now, + } +} + +/// A `wl_shm` buffer over a memfd mapping. +struct ShmBuffer { + pool: wl_shm_pool::WlShmPool, + buffer: wl_buffer::WlBuffer, + ptr: *mut u8, + len: usize, + width: u32, + height: u32, + stride: u32, + wl_code: u32, + spa: u32, +} + +impl ShmBuffer { + fn new( + shm: &wl_shm::WlShm, + qh: &QueueHandle, + (width, height): (u32, u32), + stride: u32, + (wl_code, spa): (u32, u32), + ) -> Result { + let format = wl_format(wl_code).ok_or("unknown wl_shm format")?; + let len = stride as usize * height as usize; + if len == 0 || len > i32::MAX as usize { + return Err(format!("bad buffer size {width}x{height}")); + } + // SAFETY: plain libc calls; the fd is owned right after creation and the mapping is + // unmapped in `drop`. + let (fd, ptr) = unsafe { + let raw = libc::memfd_create(c"ezd-wl-capture".as_ptr(), libc::MFD_CLOEXEC); + if raw < 0 { + return Err(format!("memfd_create: {}", std::io::Error::last_os_error())); + } + let fd = OwnedFd::from_raw_fd(raw); + if libc::ftruncate(fd.as_raw_fd(), len as libc::off_t) != 0 { + return Err(format!("ftruncate: {}", std::io::Error::last_os_error())); + } + let ptr = libc::mmap( + std::ptr::null_mut(), + len, + libc::PROT_READ | libc::PROT_WRITE, + libc::MAP_SHARED, + fd.as_raw_fd(), + 0, + ); + if ptr == libc::MAP_FAILED { + return Err(format!("mmap: {}", std::io::Error::last_os_error())); + } + (fd, ptr as *mut u8) + }; + let pool = shm.create_pool(fd.as_fd(), len as i32, qh, ()); + let buffer = pool.create_buffer( + 0, + width as i32, + height as i32, + stride as i32, + format, + qh, + (), + ); + Ok(ShmBuffer { + pool, + buffer, + ptr, + len, + width, + height, + stride, + wl_code, + spa, + }) + } + + fn bytes(&self) -> &[u8] { + // SAFETY: the mapping lives as long as `self`; the compositor only writes between the + // capture request and `ready`/`failed`, and callers read after `ready`. + unsafe { std::slice::from_raw_parts(self.ptr, self.len) } + } + + fn matches(&self, size: (u32, u32), stride: u32, code: u32) -> bool { + (self.width, self.height) == size && self.stride == stride && self.wl_code == code + } +} + +impl Drop for ShmBuffer { + fn drop(&mut self) { + self.buffer.destroy(); + self.pool.destroy(); + // SAFETY: `ptr` / `len` come from the successful mmap in `new`. + unsafe { libc::munmap(self.ptr as *mut libc::c_void, self.len) }; + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Role { + Main, + Cursor, +} + +/// One ext-image-copy-capture session (the output's, or the cursor's). +struct ExtSession { + proxy: ExtImageCopyCaptureSessionV1, + pending_size: Option<(u32, u32)>, + pending_formats: Vec, + size: Option<(u32, u32)>, + formats: Vec, + /// Counts `done` events (constraint batches). + constraints_seq: u64, + buffer: Option, + frame: Option, + /// `constraints_seq` when the frame was requested. + frame_seq: u64, + pts: Option<(u32, u32, u32)>, + /// A frame failed on stale constraints: wait for the next batch before asking again. + waiting: bool, + failures: u32, +} + +impl ExtSession { + fn new(proxy: ExtImageCopyCaptureSessionV1) -> ExtSession { + ExtSession { + proxy, + pending_size: None, + pending_formats: Vec::new(), + size: None, + formats: Vec::new(), + constraints_seq: 0, + buffer: None, + frame: None, + frame_seq: 0, + pts: None, + waiting: false, + failures: 0, + } + } + + /// Asks for the next frame when none is in flight and there is a buffer. + fn capture(&mut self, qh: &QueueHandle, role: Role) { + if self.frame.is_some() || self.waiting { + return; + } + let Some(buf) = &self.buffer else { return }; + let frame = self.proxy.create_frame(qh, role); + frame.attach_buffer(&buf.buffer); + frame.damage_buffer(0, 0, buf.width as i32, buf.height as i32); + frame.capture(); + self.frame = Some(frame); + self.frame_seq = self.constraints_seq; + self.pts = None; + } +} + +/// The cursor as the pointer cursor session reports it. +#[derive(Default)] +struct CursorTrack { + entered: bool, + pos: (i32, i32), + hotspot: (i32, i32), + /// Last image: pixels (tightly as captured), size, stride, byte order. + image: Option<(Vec, u32, u32, usize, PixelOrder)>, + shape_id: u32, + last: Option<(i32, i32, bool, u32)>, +} + +struct OutputEntry { + global: u32, + output: wl_output::WlOutput, + name: Option, +} + +struct WlrState { + manager: ZwlrScreencopyManagerV1, + output: wl_output::WlOutput, + frame: Option, + /// `buffer` event of the frame in flight: format code, size, stride. + info: Option<(u32, u32, u32, u32)>, + y_invert: bool, + buffer: Option, + failures: u32, +} + +/// Everything the dispatch callbacks need; lives on the capture thread. +struct State { + shared: Arc, + cursor: CursorSink, + target: String, + protocol: Protocol, + outputs: Vec, + target_removed: bool, + shm: Option, + seat: Option, + pointer: Option, + ext_manager: Option, + ext_source: Option, + main: Option, + cursor_session: Option, + cursor_capture: Option, + track: CursorTrack, + wlr: Option, + cursor_updates: Arc, + seq: u64, + error: Option, +} + +impl State { + fn fail(&mut self, msg: impl Into) { + self.error.get_or_insert(msg.into()); + } + + fn session(&mut self, role: Role) -> Option<&mut ExtSession> { + match role { + Role::Main => self.main.as_mut(), + Role::Cursor => self.cursor_capture.as_mut(), + } + } + + fn bind_output( + &mut self, + registry: &wl_registry::WlRegistry, + global: u32, + version: u32, + qh: &QueueHandle, + ) { + if self.outputs.iter().any(|o| o.global == global) { + return; + } + // Version 4 adds the output's name, which is how the provider's output is found. + let output = registry.bind::(global, version.min(4), qh, global); + self.outputs.push(OutputEntry { + global, + output, + name: None, + }); + } + + fn target_output(&self) -> Option<&wl_output::WlOutput> { + self.outputs + .iter() + .find(|o| o.name.as_deref() == Some(self.target.as_str())) + .map(|o| &o.output) + } + + fn publish(&mut self, mut frame: Frame) { + self.seq += 1; + frame.seq = self.seq; + let mut s = self.shared.lock(); + s.produced += 1; + if s.latest.replace(frame).is_some() { + s.coalesced += 1; + } + drop(s); + self.shared.notify(); + } + + fn set_format(&self, w: u32, h: u32, spa: u32) { + let fmt = NegotiatedFormat { + width: w, + height: h, + spa_format: spa, + modifier: None, + color: SpaColor::default(), + }; + let mut s = self.shared.lock(); + if s.fmt != Some(fmt) { + s.fmt = Some(fmt); + s.fmt_version += 1; + } + drop(s); + self.shared.notify(); + } + + // ----- ext-image-copy-capture ----- + + /// A batch of constraints arrived: (re)allocate the buffer and ask for a frame. + fn constraints_done(&mut self, role: Role, qh: &QueueHandle) { + let Some(shm) = self.shm.clone() else { return }; + let Some(s) = self.session(role) else { return }; + s.constraints_seq += 1; + s.waiting = false; + s.size = s.pending_size.or(s.size); + s.formats = std::mem::take(&mut s.pending_formats); + let size = s.size.filter(|&(w, h)| w > 0 && h > 0); + let (Some(size), Some(fmt)) = (size, choose_shm_format(&s.formats, role == Role::Cursor)) + else { + if role == Role::Cursor { + // The cursor has no image right now (an empty size, no formats): nothing to + // capture until the next batch. + s.buffer = None; + return; + } + let msg = format!( + "no usable buffer constraints: {:?}, {:?}", + s.size, s.formats + ); + self.fail(msg); + return; + }; + let reuse = s + .buffer + .as_ref() + .is_some_and(|b| b.matches(size, size.0 * 4, fmt.0)); + if !reuse { + match ShmBuffer::new(&shm, qh, size, size.0 * 4, fmt) { + Ok(b) => s.buffer = Some(b), + Err(e) => { + self.fail(e); + return; + } + } + } + s.capture(qh, role); + if role == Role::Main { + self.set_format(size.0, size.1, fmt.1); + } + } + + fn frame_ready(&mut self, role: Role, qh: &QueueHandle) { + let Some(s) = self.session(role) else { return }; + if let Some(f) = s.frame.take() { + f.destroy(); + } + s.failures = 0; + let pts = s.pts.take(); + let Some(buf) = &s.buffer else { return }; + match role { + Role::Main => { + let frame = Frame { + width: buf.width, + height: buf.height, + capture_us: capture_time(pts), + seq: 0, + pixels: Pixels::Shm(ShmBuf { + spa_format: buf.spa, + stride: buf.stride as usize, + data: buf.bytes().to_vec(), + }), + }; + s.capture(qh, role); + self.publish(frame); + } + Role::Cursor => { + let image = pixel_order(buf.spa).map(|order| { + ( + buf.bytes().to_vec(), + buf.width, + buf.height, + buf.stride as usize, + order, + ) + }); + s.capture(qh, role); + if image.is_some() { + self.track.image = image; + } + self.cursor_image_changed(); + } + } + } + + fn frame_failed(&mut self, role: Role, reason: WEnum, qh: &QueueHandle) { + let Some(s) = self.session(role) else { return }; + let frame_seq = s.frame_seq; + if let Some(f) = s.frame.take() { + f.destroy(); + } + match reason { + WEnum::Value(FailureReason::Stopped) => { + if role == Role::Main { + self.fail("the compositor stopped the capture session (output removed?)"); + } + } + WEnum::Value(FailureReason::BufferConstraints) => { + if s.constraints_seq > frame_seq { + s.capture(qh, role); + } else { + s.waiting = true; + } + } + other => { + s.failures += 1; + if s.failures > MAX_FAILURES { + let msg = format!("capture failed {} times in a row ({other:?})", s.failures); + if role == Role::Main { + self.fail(msg); + } + } else { + s.capture(qh, role); + } + } + } + } + + /// Builds the wire shape from the last cursor image and the current hotspot. + fn cursor_shape(&self) -> Option { + let (data, w, h, stride, order) = self.track.image.as_ref()?; + build_shape( + data, + *w, + *h, + *stride, + *order, + self.track.hotspot.0, + self.track.hotspot.1, + ) + } + + fn cursor_image_changed(&mut self) { + match self.cursor_shape() { + Some(shape) => { + self.track.shape_id = shape.shape_id; + let _ = self.cursor.set_shape(shape); + } + None => self.track.shape_id = 0, + } + self.publish_cursor(); + } + + fn publish_cursor(&mut self) { + let t = &self.track; + let cur = (t.pos.0, t.pos.1, t.entered && t.shape_id != 0, t.shape_id); + if t.last == Some(cur) { + return; + } + self.track.last = Some(cur); + self.cursor_updates.fetch_add(1, Ordering::Relaxed); + let _ = self.cursor.update(CursorState { + x: cur.0, + y: cur.1, + visible: cur.2, + shape_id: cur.3, + ts_us: now_us(), + }); + } + + /// Creates the cursor session once the seat has a pointer (and the output source exists). + fn try_cursor_session(&mut self, qh: &QueueHandle) { + if self.protocol != Protocol::ExtImageCopy || self.cursor_session.is_some() { + return; + } + let (Some(seat), Some(manager), Some(source)) = + (&self.seat, &self.ext_manager, &self.ext_source) + else { + return; + }; + let Some(pointer) = self.pointer.clone().or_else(|| { + let p = seat.get_pointer(qh, ()); + Some(p) + }) else { + return; + }; + let session = manager.create_pointer_cursor_session(source, &pointer, qh, ()); + let capture = session.get_capture_session(qh, Role::Cursor); + tracing::info!("cursor session created"); + self.pointer = Some(pointer); + self.cursor_session = Some(session); + self.cursor_capture = Some(ExtSession::new(capture)); + } + + // ----- wlr-screencopy ----- + + fn wlr_capture(&mut self, qh: &QueueHandle) { + if let Some(w) = self.wlr.as_mut() { + // overlay_cursor = 1: the cursor is part of the frame. + w.frame = Some(w.manager.capture_output(1, &w.output, qh, ())); + w.info = None; + w.y_invert = false; + } + } + + fn wlr_buffer_ready(&mut self, qh: &QueueHandle) { + let Some(shm) = self.shm.clone() else { return }; + let Some(w) = self.wlr.as_mut() else { return }; + let (Some((code, width, height, stride)), Some(frame)) = (w.info, w.frame.as_ref()) else { + return; + }; + let Some(fmt) = SHM_FORMATS.iter().find(|(c, _)| *c == code).copied() else { + let msg = format!("screencopy offers only unsupported buffer format {code:#x}"); + self.fail(msg); + return; + }; + if !w + .buffer + .as_ref() + .is_some_and(|b| b.matches((width, height), stride, code)) + { + match ShmBuffer::new(&shm, qh, (width, height), stride, fmt) { + Ok(b) => w.buffer = Some(b), + Err(e) => { + self.fail(e); + return; + } + } + } + if let Some(b) = &w.buffer { + if frame.version() >= 2 { + frame.copy_with_damage(&b.buffer); + } else { + frame.copy(&b.buffer); + } + let (bw, bh, spa) = (b.width, b.height, b.spa); + self.set_format(bw, bh, spa); + } + } + + fn wlr_ready(&mut self, ts: (u32, u32, u32), qh: &QueueHandle) { + let Some(w) = self.wlr.as_mut() else { return }; + if let Some(f) = w.frame.take() { + f.destroy(); + } + w.failures = 0; + let (y_invert, Some(buf)) = (w.y_invert, w.buffer.as_ref()) else { + return; + }; + let raw = buf.bytes(); + let data = if y_invert { + flip_rows(raw, buf.stride as usize, buf.height as usize) + } else { + raw.to_vec() + }; + let frame = Frame { + width: buf.width, + height: buf.height, + capture_us: capture_time(Some(ts)), + seq: 0, + pixels: Pixels::Shm(ShmBuf { + spa_format: buf.spa, + stride: buf.stride as usize, + data, + }), + }; + self.wlr_capture(qh); + self.publish(frame); + } + + fn wlr_failed(&mut self, qh: &QueueHandle) { + let Some(w) = self.wlr.as_mut() else { return }; + if let Some(f) = w.frame.take() { + f.destroy(); + } + w.failures += 1; + if w.failures > MAX_FAILURES { + self.fail("screencopy failed repeatedly (output removed?)"); + } else { + self.wlr_capture(qh); + } + } +} + +// ----- dispatch ----- + +impl Dispatch for State { + fn event( + state: &mut Self, + registry: &wl_registry::WlRegistry, + event: wl_registry::Event, + _: &GlobalListContents, + _: &Connection, + qh: &QueueHandle, + ) { + match event { + wl_registry::Event::Global { + name, + interface, + version, + } if interface == "wl_output" => state.bind_output(registry, name, version, qh), + wl_registry::Event::GlobalRemove { name } => { + if let Some(i) = state.outputs.iter().position(|o| o.global == name) { + let gone = state.outputs.remove(i); + if gone.name.as_deref() == Some(state.target.as_str()) { + state.target_removed = true; + } + } + } + _ => {} + } + } +} + +impl Dispatch for State { + fn event( + state: &mut Self, + _: &wl_output::WlOutput, + event: wl_output::Event, + global: &u32, + _: &Connection, + _: &QueueHandle, + ) { + if let wl_output::Event::Name { name } = event + && let Some(o) = state.outputs.iter_mut().find(|o| o.global == *global) + { + o.name = Some(name); + } + } +} + +impl Dispatch for State { + fn event( + _: &mut Self, + _: &wl_shm::WlShm, + _: wl_shm::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + } +} + +impl Dispatch for State { + fn event( + state: &mut Self, + _: &wl_seat::WlSeat, + event: wl_seat::Event, + _: &(), + _: &Connection, + qh: &QueueHandle, + ) { + if let wl_seat::Event::Capabilities { + capabilities: WEnum::Value(caps), + } = event + && caps.contains(wl_seat::Capability::Pointer) + { + state.try_cursor_session(qh); + } + } +} + +delegate_noop!(State: ignore wl_pointer::WlPointer); +delegate_noop!(State: ignore wl_shm_pool::WlShmPool); +delegate_noop!(State: ignore wl_buffer::WlBuffer); +delegate_noop!(State: ExtImageCopyCaptureManagerV1); +delegate_noop!(State: ExtOutputImageCaptureSourceManagerV1); +delegate_noop!(State: ExtImageCaptureSourceV1); +delegate_noop!(State: ZwlrScreencopyManagerV1); + +impl Dispatch for State { + fn event( + state: &mut Self, + _: &ExtImageCopyCaptureSessionV1, + event: ext_image_copy_capture_session_v1::Event, + role: &Role, + _: &Connection, + qh: &QueueHandle, + ) { + use ext_image_copy_capture_session_v1::Event; + match event { + Event::BufferSize { width, height } => { + if let Some(s) = state.session(*role) { + s.pending_size = Some((width, height)); + } + } + Event::ShmFormat { + format: WEnum::Value(f), + } => { + if let Some(s) = state.session(*role) { + s.pending_formats.push(u32::from(f)); + } + } + Event::ShmFormat { + format: WEnum::Unknown(f), + } => { + if let Some(s) = state.session(*role) { + s.pending_formats.push(f); + } + } + Event::Done => state.constraints_done(*role, qh), + Event::Stopped => match role { + Role::Main => state.fail("the compositor stopped the capture session"), + Role::Cursor => tracing::debug!("the cursor capture session stopped"), + }, + _ => {} + } + } +} + +impl Dispatch for State { + fn event( + state: &mut Self, + _: &ExtImageCopyCaptureFrameV1, + event: ext_image_copy_capture_frame_v1::Event, + role: &Role, + _: &Connection, + qh: &QueueHandle, + ) { + use ext_image_copy_capture_frame_v1::Event; + match event { + Event::PresentationTime { + tv_sec_hi, + tv_sec_lo, + tv_nsec, + } => { + if let Some(s) = state.session(*role) { + s.pts = Some((tv_sec_hi, tv_sec_lo, tv_nsec)); + } + } + Event::Ready => state.frame_ready(*role, qh), + Event::Failed { reason } => state.frame_failed(*role, reason, qh), + _ => {} + } + } +} + +impl Dispatch for State { + fn event( + state: &mut Self, + _: &ExtImageCopyCaptureCursorSessionV1, + event: ext_image_copy_capture_cursor_session_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + use ext_image_copy_capture_cursor_session_v1::Event; + match event { + Event::Enter => { + state.track.entered = true; + state.publish_cursor(); + } + Event::Leave => { + state.track.entered = false; + state.publish_cursor(); + } + Event::Position { x, y } => { + state.track.pos = (x, y); + state.publish_cursor(); + } + Event::Hotspot { x, y } if state.track.hotspot != (x, y) => { + state.track.hotspot = (x, y); + state.cursor_image_changed(); + } + _ => {} + } + } +} + +impl Dispatch for State { + fn event( + state: &mut Self, + _: &ZwlrScreencopyFrameV1, + event: zwlr_screencopy_frame_v1::Event, + _: &(), + _: &Connection, + qh: &QueueHandle, + ) { + use zwlr_screencopy_frame_v1::Event; + match event { + Event::Buffer { + format, + width, + height, + stride, + } => { + let code = match format { + WEnum::Value(f) => u32::from(f), + WEnum::Unknown(f) => f, + }; + if let Some(w) = state.wlr.as_mut() { + // Several `buffer` events can come; keep the first one we can use. + let usable = SHM_FORMATS.iter().any(|(c, _)| *c == code); + if usable && w.info.is_none() { + w.info = Some((code, width, height, stride)); + } + // Before version 3 there is no `buffer_done`: answer right away. + if usable && w.frame.as_ref().is_some_and(|f| f.version() < 3) { + state.wlr_buffer_ready(qh); + } + } + } + Event::BufferDone => state.wlr_buffer_ready(qh), + Event::Flags { flags } => { + if let Some(w) = state.wlr.as_mut() { + w.y_invert = matches!(flags, WEnum::Value(f) if f.contains(zwlr_screencopy_frame_v1::Flags::YInvert)); + } + } + Event::Ready { + tv_sec_hi, + tv_sec_lo, + tv_nsec, + } => state.wlr_ready((tv_sec_hi, tv_sec_lo, tv_nsec), qh), + Event::Failed => state.wlr_failed(qh), + _ => {} + } + } +} + +/// Lists the globals of the compositor behind `$WAYLAND_DISPLAY` as `(interface, version)`. +pub fn advertised_globals() -> Result, String> { + struct Probe; + impl Dispatch for Probe { + fn event( + _: &mut Self, + _: &wl_registry::WlRegistry, + _: wl_registry::Event, + _: &GlobalListContents, + _: &Connection, + _: &QueueHandle, + ) { + } + } + let conn = + Connection::connect_to_env().map_err(|e| format!("cannot connect to Wayland: {e}"))?; + let (globals, _queue) = + registry_queue_init::(&conn).map_err(|e| format!("Wayland registry: {e}"))?; + Ok(globals + .contents() + .clone_list() + .into_iter() + .map(|g| (g.interface, g.version)) + .collect()) +} + +/// Capture-related part of `ezd-host diag`: the protocols the compositor offers and the choice. +pub fn diag() -> serde_json::Value { + match advertised_globals() { + Ok(globals) => { + let adv = Advertised::from_interfaces(globals.iter().map(|(n, _)| n.as_str())); + let forced = std::env::var("EZD_WL_CAPTURE").ok(); + let chosen = choose_protocol(&adv, forced.as_deref()); + let version = |name: &str| globals.iter().find(|(n, _)| n == name).map(|g| g.1); + serde_json::json!({ + "ext_image_copy_capture": version(EXT_COPY), + "ext_output_image_capture_source": version(EXT_SOURCE), + "wlr_screencopy": version(WLR_COPY), + "wl_shm": version("wl_shm"), + "zwp_linux_dmabuf": version("zwp_linux_dmabuf_v1"), + "chosen": chosen.as_ref().ok().map(|p| p.name()), + "separate_cursor": chosen.as_ref().is_ok_and(|p| p.separate_cursor()), + "error": chosen.err(), + }) + } + Err(e) => serde_json::json!({ "error": e }), + } +} + +/// Handle to the capture thread. +pub struct WlCapture { + shared: Arc, + stop: Arc, + thread: Option>, + protocol: Protocol, + cursor_updates: Arc, +} + +impl WlCapture { + /// Connects to the compositor and starts capturing the output called `output`. Returns once + /// the capture objects exist; the first frame follows. + pub fn start(output: &str, cursor: CursorSink) -> Result { + let shared = Arc::new(Shared::new()); + let stop = Arc::new(AtomicBool::new(false)); + let cursor_updates = Arc::new(AtomicU64::new(0)); + let (ready_tx, ready_rx) = mpsc::channel::>(); + let thread = { + let (shared, stop, updates) = (shared.clone(), stop.clone(), cursor_updates.clone()); + let target = output.to_string(); + std::thread::Builder::new() + .name("ezd-wl-capture".into()) + .spawn(move || { + let r = run(&target, cursor, &shared, &stop, updates, &ready_tx); + if let Err(e) = r { + let _ = ready_tx.send(Err(e.clone())); + shared.lock().error.get_or_insert(e); + } + let mut s = shared.lock(); + s.closed = true; + s.latest = None; + drop(s); + shared.notify(); + }) + .map_err(|e| format!("cannot spawn the Wayland capture thread: {e}"))? + }; + match ready_rx.recv_timeout(OUTPUT_WAIT + Duration::from_secs(5)) { + Ok(Ok(protocol)) => Ok(WlCapture { + shared, + stop, + thread: Some(thread), + protocol, + cursor_updates, + }), + Ok(Err(e)) => { + let _ = thread.join(); + Err(e) + } + Err(_) => { + stop.store(true, Ordering::Relaxed); + Err("the Wayland capture thread did not become ready".into()) + } + } + } + + /// The protocol in use. + pub fn protocol(&self) -> Protocol { + self.protocol + } + + /// Waits until a frame size of exactly `want` is known (or `timeout`); returns the last + /// known format either way. + pub fn wait_format(&self, want: (u32, u32), timeout: Duration) -> Option { + self.shared.wait_format(want, timeout) + } + + /// Waits for any format. + pub fn wait_any_format(&self, timeout: Duration) -> Option { + self.shared.wait_any_format(timeout) + } + + /// Takes the newest frame, waiting up to `timeout`. `Err` when the capture died. + pub fn next(&self, timeout: Duration) -> Result, String> { + self.shared.next(timeout) + } + + /// Handle that takes frames from another thread. + pub fn frames(&self) -> Frames { + Frames(self.shared.clone()) + } + + /// Frames delivered by the compositor and frames replaced before the encoder took them. + pub fn counters(&self) -> (u64, u64) { + let s = self.shared.lock(); + (s.produced, s.coalesced) + } + + /// How many cursor state changes were published to the sink. + pub fn cursor_updates(&self) -> u64 { + self.cursor_updates.load(Ordering::Relaxed) + } + + /// Stops the thread. + pub fn stop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(t) = self.thread.take() { + let _ = t.join(); + tracing::info!( + protocol = self.protocol.name(), + cursor_updates = self.cursor_updates(), + "Wayland capture stopped" + ); + } + } +} + +impl Drop for WlCapture { + fn drop(&mut self) { + self.stop(); + } +} + +/// Waits for the connection to become readable (up to `POLL_MS`). +fn wait_readable(fd: std::os::fd::BorrowedFd<'_>) -> bool { + let mut p = libc::pollfd { + fd: fd.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }; + // SAFETY: one valid pollfd. + let n = unsafe { libc::poll(&mut p, 1, POLL_MS) }; + n > 0 && p.revents & (libc::POLLIN | libc::POLLERR | libc::POLLHUP) != 0 +} + +/// One pass of the event loop: dispatch, flush, wait for the socket, read. +fn pump(queue: &mut wayland_client::EventQueue, state: &mut State) -> Result<(), String> { + queue + .dispatch_pending(state) + .map_err(|e| format!("Wayland dispatch: {e}"))?; + queue.flush().map_err(|e| format!("Wayland flush: {e}"))?; + if let Some(guard) = queue.prepare_read() { + if wait_readable(guard.connection_fd()) { + match guard.read() { + Ok(_) => {} + Err(wayland_client::backend::WaylandError::Io(e)) + if e.kind() == std::io::ErrorKind::WouldBlock => {} + Err(e) => return Err(format!("Wayland read: {e}")), + } + } + queue + .dispatch_pending(state) + .map_err(|e| format!("Wayland dispatch: {e}"))?; + } + Ok(()) +} + +fn run( + target: &str, + cursor: CursorSink, + shared: &Arc, + stop: &AtomicBool, + cursor_updates: Arc, + ready_tx: &mpsc::Sender>, +) -> Result<(), String> { + let conn = Connection::connect_to_env() + .map_err(|e| format!("cannot connect to the Wayland compositor: {e}"))?; + let (globals, mut queue) = + registry_queue_init::(&conn).map_err(|e| format!("Wayland registry: {e}"))?; + let qh = queue.handle(); + let list = globals.contents().clone_list(); + let adv = Advertised::from_interfaces(list.iter().map(|g| g.interface.as_str())); + let forced = std::env::var("EZD_WL_CAPTURE").ok(); + let protocol = choose_protocol(&adv, forced.as_deref())?; + tracing::info!( + protocol = protocol.name(), + output = target, + "Wayland capture" + ); + + let mut state = State { + shared: shared.clone(), + cursor, + target: target.to_string(), + protocol, + outputs: Vec::new(), + target_removed: false, + shm: None, + seat: None, + pointer: None, + ext_manager: None, + ext_source: None, + main: None, + cursor_session: None, + cursor_capture: None, + track: CursorTrack::default(), + wlr: None, + cursor_updates, + seq: 0, + error: None, + }; + let registry = globals.registry(); + for g in list.iter().filter(|g| g.interface == "wl_output") { + state.bind_output(registry, g.name, g.version, &qh); + } + state.shm = Some( + globals + .bind::(&qh, 1..=1, ()) + .map_err(|e| format!("wl_shm: {e}"))?, + ); + + // The compositor created the output a moment ago: wait for its registry entry and name. + let deadline = Instant::now() + OUTPUT_WAIT; + while state.target_output().is_none() { + if stop.load(Ordering::Relaxed) { + return Ok(()); + } + if Instant::now() >= deadline { + let names: Vec<_> = state + .outputs + .iter() + .filter_map(|o| o.name.clone()) + .collect(); + let hint = if state.outputs.iter().any(|o| o.name.is_none()) { + " (the compositor's wl_output is older than version 4 and has no names)" + } else { + "" + }; + return Err(format!( + "the compositor has no output called {target} (outputs: {}){hint}", + names.join(", ") + )); + } + pump(&mut queue, &mut state)?; + } + let output = state.target_output().cloned().ok_or("output vanished")?; + + match protocol { + Protocol::ExtImageCopy => { + let manager = globals + .bind::(&qh, 1..=1, ()) + .map_err(|e| format!("{EXT_COPY}: {e}"))?; + let sources = globals + .bind::(&qh, 1..=1, ()) + .map_err(|e| format!("{EXT_SOURCE}: {e}"))?; + let source = sources.create_source(&output, &qh, ()); + // Options 0: no cursor in the frames; the cursor session reports it. + let session = manager.create_session(&source, Options::empty(), &qh, Role::Main); + state.main = Some(ExtSession::new(session)); + state.ext_manager = Some(manager); + state.ext_source = Some(source); + // The seat may already offer a pointer; later capability changes are handled in + // `Dispatch`. + state.seat = globals.bind::(&qh, 1..=5, ()).ok(); + } + Protocol::WlrScreencopy => { + let manager = globals + .bind::(&qh, 1..=3, ()) + .map_err(|e| format!("{WLR_COPY}: {e}"))?; + state.wlr = Some(WlrState { + manager, + output, + frame: None, + info: None, + y_invert: false, + buffer: None, + failures: 0, + }); + state.wlr_capture(&qh); + tracing::info!("screencopy paints the cursor into the frames; no cursor data is sent"); + } + } + let _ = ready_tx.send(Ok(protocol)); + + while !stop.load(Ordering::Relaxed) { + pump(&mut queue, &mut state)?; + if let Some(e) = state.error.take() { + return Err(e); + } + if state.target_removed { + return Err(format!("the output {target} was removed")); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn adv(ext_copy: bool, ext_output_source: bool, wlr_screencopy: bool) -> Advertised { + Advertised { + ext_copy, + ext_output_source, + wlr_screencopy, + } + } + + #[test] + fn advertised_from_registry_names() { + let a = Advertised::from_interfaces([ + "wl_shm", + "zwlr_screencopy_manager_v1", + "ext_image_copy_capture_manager_v1", + "ext_output_image_capture_source_manager_v1", + ]); + assert_eq!(a, adv(true, true, true)); + assert_eq!( + Advertised::from_interfaces(["wl_shm"]), + adv(false, false, false) + ); + } + + #[test] + fn ext_is_preferred_when_it_is_complete() { + assert_eq!( + choose_protocol(&adv(true, true, true), None), + Ok(Protocol::ExtImageCopy) + ); + // The copy protocol alone cannot name an output. + assert_eq!( + choose_protocol(&adv(true, false, true), None), + Ok(Protocol::WlrScreencopy) + ); + assert_eq!( + choose_protocol(&adv(false, false, true), Some("auto")), + Ok(Protocol::WlrScreencopy) + ); + assert!(choose_protocol(&adv(false, false, false), None).is_err()); + } + + #[test] + fn the_override_can_pick_either_protocol() { + let both = adv(true, true, true); + assert_eq!( + choose_protocol(&both, Some("wlr")), + Ok(Protocol::WlrScreencopy) + ); + assert_eq!( + choose_protocol(&both, Some(" EXT ")), + Ok(Protocol::ExtImageCopy) + ); + assert!(choose_protocol(&adv(false, false, true), Some("ext")).is_err()); + assert!(choose_protocol(&adv(true, true, false), Some("wlr")).is_err()); + assert!( + choose_protocol(&both, Some("pipewire")) + .unwrap_err() + .contains("pipewire") + ); + } + + #[test] + fn only_the_ext_protocol_has_a_separate_cursor() { + assert!(Protocol::ExtImageCopy.separate_cursor()); + assert!(!Protocol::WlrScreencopy.separate_cursor()); + } + + #[test] + fn shm_format_preference() { + // Capture: opaque first. + assert_eq!(choose_shm_format(&[0, 1], false), Some((1, spa_fmt::BGRX))); + assert_eq!( + choose_shm_format(&[0, 0x3432_4241], false), + Some((0, spa_fmt::BGRA)) + ); + assert_eq!( + choose_shm_format(&[0x3432_4258], false), + Some((0x3432_4258, spa_fmt::RGBX)) + ); + // Cursor: alpha first. + assert_eq!(choose_shm_format(&[0, 1], true), Some((0, spa_fmt::BGRA))); + assert_eq!(choose_shm_format(&[1], true), Some((1, spa_fmt::BGRX))); + // 10-bit only: nothing the encoder path takes. + assert_eq!(choose_shm_format(&[0x3033_5258], false), None); + assert_eq!(choose_shm_format(&[], false), None); + } + + #[test] + fn shm_codes_match_the_binding_and_the_spa_table() { + for (code, spa) in SHM_FORMATS { + assert!(wl_format(code).is_some(), "{code:#x}"); + assert!(crate::frame::drm_fourcc(spa).is_some()); + assert!(pixel_order(spa).is_some()); + } + assert_eq!(wl_format(1), Some(wl_shm::Format::Xrgb8888)); + assert_eq!(wl_format(0x3432_4258), Some(wl_shm::Format::Xbgr8888)); + // DRM fourcc of the SPA format equals the wl_shm code (except the two legacy codes). + assert_eq!(crate::frame::drm_fourcc(spa_fmt::RGBX), Some(0x3432_4258)); + } + + #[test] + fn rows_flip() { + let data = [1u8, 1, 2, 2, 3, 3]; + assert_eq!(flip_rows(&data, 2, 3), [3, 3, 2, 2, 1, 1]); + assert_eq!(flip_rows(&[], 4, 0), Vec::::new()); + } + + #[test] + fn timestamps() { + assert_eq!(timestamp_ns(0, 5, 7), 5_000_000_007); + assert_eq!(timestamp_ns(1, 0, 0), 4_294_967_296_000_000_000); + // A missing or implausible stamp falls back to now. + let before = now_us(); + assert!(capture_time(None) >= before); + assert!(capture_time(Some((0, 0, 0))) >= before); + } + + /// Creates an output in a headless sway, captures it and checks size, content and (with the + /// ext protocol) the cursor session. Run it with `tools/wlroots-headless-test.sh`; set + /// `EZD_WL_CAPTURE=wlr` to take the screencopy path. + #[test] + fn headless_sway_frames() { + use crate::portal::SourceKind; + use crate::provider::{FrameSource, OutputRequest, VirtualOutputProvider}; + use crate::sway::{SwayIpc, SwayProvider}; + if !crate::wlroots_test::enabled() { + return; + } + let req = OutputRequest { + kind: SourceKind::Virtual, + output_name: None, + width: 1280, + height: 720, + refresh_mhz: 60_000, + }; + let opened = SwayProvider.open(&req).expect("open"); + let info = opened.info.clone().expect("output info"); + eprintln!("output: {info:?}"); + assert_eq!((info.mode.width, info.mode.height), (1280, 720)); + let FrameSource::Wayland { output } = &opened.frames else { + panic!("not a Wayland frame source"); + }; + let _driver = crate::wlroots_test::Driver::start(output); + let mut cap = WlCapture::start(output, CursorSink::null()).expect("capture"); + eprintln!("protocol: {}", cap.protocol().name()); + let fmt = cap + .wait_format((1280, 720), Duration::from_secs(5)) + .expect("format"); + assert_eq!((fmt.width, fmt.height), (1280, 720)); + let deadline = Instant::now() + Duration::from_secs(20); + let (mut frames, mut colours) = (0, std::collections::HashSet::new()); + while frames < 10 && Instant::now() < deadline { + let Some(f) = cap.next(Duration::from_millis(500)).expect("frame") else { + continue; + }; + assert_eq!((f.width, f.height), (1280, 720)); + let Pixels::Shm(shm) = &f.pixels else { + panic!("expected a shared-memory frame"); + }; + assert!(shm.data.len() >= shm.stride * 719 + 1280 * 4); + let at = 360 * shm.stride + 640 * 4; + let order = pixel_order(shm.spa_format).expect("byte order"); + let px = &shm.data[at..at + 4]; + // Red or blue, whatever the byte order. + let (r, b) = match order { + PixelOrder::Bgra | PixelOrder::Bgrx => (px[2], px[0]), + _ => (px[0], px[2]), + }; + colours.insert((r, b)); + frames += 1; + } + eprintln!("frames: {frames}, centre colours (r, b): {colours:?}"); + assert!(frames >= 3, "got {frames} frames"); + assert!( + colours.iter().any(|&(r, b)| r > 200 && b < 50) + || colours.iter().any(|&(r, b)| b > 200 && r < 50), + "frames do not show the output background: {colours:?}" + ); + if cap.protocol() == Protocol::ExtImageCopy { + let deadline = Instant::now() + Duration::from_secs(10); + while cap.cursor_updates() == 0 && Instant::now() < deadline { + let _ = cap.next(Duration::from_millis(100)); + } + eprintln!("cursor updates: {}", cap.cursor_updates()); + assert!(cap.cursor_updates() > 0, "no cursor session data arrived"); + } else { + assert_eq!(cap.cursor_updates(), 0); + } + cap.stop(); + let name = info.name.clone(); + drop(opened); + let after = SwayIpc::connect().unwrap().outputs().unwrap(); + assert!( + !after.iter().any(|o| o.name == name), + "{name} is still there: {after:?}" + ); + } +} diff --git a/crates/ezd-host-linux/src/wlroots_test.rs b/crates/ezd-host-linux/src/wlroots_test.rs new file mode 100644 index 0000000..7d07c83 --- /dev/null +++ b/crates/ezd-host-linux/src/wlroots_test.rs @@ -0,0 +1,302 @@ +//! Helpers for the env-gated headless sway tests (`tools/wlroots-headless-test.sh`): a layer surface +//! that keeps the new output changing (the capture protocols only deliver frames on damage) and a +//! virtual pointer that moves across it (so the cursor session has something to report). + +use std::os::fd::{AsFd, AsRawFd, FromRawFd}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread::JoinHandle; +use std::time::Duration; +use wayland_client::globals::{GlobalListContents, registry_queue_init}; +use wayland_client::protocol::{ + wl_buffer, wl_compositor, wl_output, wl_registry, wl_seat, wl_shm, wl_shm_pool, wl_surface, +}; +use wayland_client::{Connection, Dispatch, QueueHandle, delegate_noop}; +use wayland_protocols_wlr::layer_shell::v1::client::{ + zwlr_layer_shell_v1::{self, ZwlrLayerShellV1}, + zwlr_layer_surface_v1::{self, ZwlrLayerSurfaceV1}, +}; +use wayland_protocols_wlr::virtual_pointer::v1::client::{ + zwlr_virtual_pointer_manager_v1::ZwlrVirtualPointerManagerV1, + zwlr_virtual_pointer_v1::ZwlrVirtualPointerV1, +}; + +/// True when the test runs inside the headless compositor of the script. +pub fn enabled() -> bool { + if std::env::var_os("EZD_WLROOTS_TEST").is_none_or(|v| v != "1") { + eprintln!("skipped: set EZD_WLROOTS_TEST=1 inside a headless sway session"); + return false; + } + let _ = tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .with_test_writer() + .try_init(); + true +} + +struct Pointer; + +impl Dispatch for Pointer { + fn event( + _: &mut Self, + _: &wl_registry::WlRegistry, + _: wl_registry::Event, + _: &GlobalListContents, + _: &Connection, + _: &QueueHandle, + ) { + } +} +delegate_noop!(Pointer: ignore wl_seat::WlSeat); +delegate_noop!(Pointer: ZwlrVirtualPointerManagerV1); +delegate_noop!(Pointer: ZwlrVirtualPointerV1); + +struct Painter { + outputs: Vec<(wl_output::WlOutput, Option)>, + size: Option<(u32, u32)>, + closed: bool, +} + +impl Dispatch for Painter { + fn event( + _: &mut Self, + _: &wl_registry::WlRegistry, + _: wl_registry::Event, + _: &GlobalListContents, + _: &Connection, + _: &QueueHandle, + ) { + } +} + +impl Dispatch for Painter { + fn event( + state: &mut Self, + _: &wl_output::WlOutput, + event: wl_output::Event, + index: &usize, + _: &Connection, + _: &QueueHandle, + ) { + if let wl_output::Event::Name { name } = event { + state.outputs[*index].1 = Some(name); + } + } +} + +impl Dispatch for Painter { + fn event( + state: &mut Self, + surface: &ZwlrLayerSurfaceV1, + event: zwlr_layer_surface_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + match event { + zwlr_layer_surface_v1::Event::Configure { + serial, + width, + height, + } => { + surface.ack_configure(serial); + state.size = Some((width, height)); + } + zwlr_layer_surface_v1::Event::Closed => state.closed = true, + _ => {} + } + } +} + +delegate_noop!(Painter: ignore wl_surface::WlSurface); +delegate_noop!(Painter: wl_compositor::WlCompositor); +delegate_noop!(Painter: ignore wl_shm::WlShm); +delegate_noop!(Painter: ignore wl_shm_pool::WlShmPool); +delegate_noop!(Painter: ignore wl_buffer::WlBuffer); +delegate_noop!(Painter: ZwlrLayerShellV1); + +/// Covers `output` with a layer surface that alternates between red and blue, until `stop`. +fn paint(output: &str, stop: &AtomicBool) -> Result<(), String> { + let conn = Connection::connect_to_env().map_err(|e| e.to_string())?; + let (globals, mut queue) = registry_queue_init::(&conn).map_err(|e| e.to_string())?; + let qh = queue.handle(); + let mut state = Painter { + outputs: Vec::new(), + size: None, + closed: false, + }; + for (i, g) in globals + .contents() + .clone_list() + .iter() + .filter(|g| g.interface == "wl_output") + .enumerate() + { + let out = + globals + .registry() + .bind::(g.name, g.version.min(4), &qh, i); + state.outputs.push((out, None)); + } + let compositor = globals + .bind::(&qh, 1..=4, ()) + .map_err(|e| e.to_string())?; + let shm = globals + .bind::(&qh, 1..=1, ()) + .map_err(|e| e.to_string())?; + let shell = globals + .bind::(&qh, 1..=1, ()) + .map_err(|e| e.to_string())?; + queue.roundtrip(&mut state).map_err(|e| e.to_string())?; + let target = state + .outputs + .iter() + .find(|(_, n)| n.as_deref() == Some(output)) + .map(|(o, _)| o.clone()) + .ok_or_else(|| format!("no output {output}"))?; + let surface = compositor.create_surface(&qh, ()); + let layer = shell.get_layer_surface( + &surface, + Some(&target), + zwlr_layer_shell_v1::Layer::Top, + "ezd-test".into(), + &qh, + (), + ); + layer.set_anchor( + zwlr_layer_surface_v1::Anchor::Top + | zwlr_layer_surface_v1::Anchor::Bottom + | zwlr_layer_surface_v1::Anchor::Left + | zwlr_layer_surface_v1::Anchor::Right, + ); + layer.set_exclusive_zone(-1); + surface.commit(); + while state.size.is_none() && !state.closed { + queue.roundtrip(&mut state).map_err(|e| e.to_string())?; + } + let (w, h) = state + .size + .filter(|&(w, h)| w > 0 && h > 0) + .ok_or("no size")?; + let len = (w * h * 4) as usize; + // SAFETY: a private memfd, mapped for the lifetime of the loop below. + let (fd, ptr) = unsafe { + let raw = libc::memfd_create(c"ezd-test-paint".as_ptr(), libc::MFD_CLOEXEC); + if raw < 0 { + return Err("memfd_create failed".into()); + } + let fd = std::os::fd::OwnedFd::from_raw_fd(raw); + if libc::ftruncate(fd.as_raw_fd(), len as libc::off_t) != 0 { + return Err("ftruncate failed".into()); + } + let p = libc::mmap( + std::ptr::null_mut(), + len, + libc::PROT_READ | libc::PROT_WRITE, + libc::MAP_SHARED, + fd.as_raw_fd(), + 0, + ); + if p == libc::MAP_FAILED { + return Err("mmap failed".into()); + } + (fd, p as *mut u32) + }; + let pool = shm.create_pool(fd.as_fd(), len as i32, &qh, ()); + let buffer = pool.create_buffer( + 0, + w as i32, + h as i32, + (w * 4) as i32, + wl_shm::Format::Xrgb8888, + &qh, + (), + ); + let mut n = 0u32; + while !stop.load(Ordering::Relaxed) && !state.closed { + let colour: u32 = if n % 2 == 0 { 0x00ff_0000 } else { 0x0000_00ff }; + // SAFETY: `ptr` maps `len` bytes, `w * h` pixels. + unsafe { std::slice::from_raw_parts_mut(ptr, (w * h) as usize).fill(colour) }; + surface.attach(Some(&buffer), 0, 0); + surface.damage_buffer(0, 0, w as i32, h as i32); + surface.commit(); + conn.flush().map_err(|e| e.to_string())?; + queue + .dispatch_pending(&mut state) + .map_err(|e| e.to_string())?; + n += 1; + std::thread::sleep(Duration::from_millis(40)); + } + // SAFETY: unmapping the mapping created above. + unsafe { libc::munmap(ptr as *mut libc::c_void, len) }; + layer.destroy(); + surface.destroy(); + let _ = conn.flush(); + Ok(()) +} + +/// Covers an output with alternating red and blue and wiggles a virtual pointer over its right +/// half, until dropped. +pub struct Driver { + stop: Arc, + threads: Vec>, +} + +impl Driver { + /// Starts both helpers for the sway output `output`. + pub fn start(output: &str) -> Driver { + let stop = Arc::new(AtomicBool::new(false)); + let mut threads = Vec::new(); + { + let (stop, output) = (stop.clone(), output.to_string()); + threads.push(std::thread::spawn(move || { + if let Err(e) = paint(&output, &stop) { + eprintln!("painter: {e}"); + } + })); + } + { + let stop = stop.clone(); + threads.push(std::thread::spawn(move || { + if let Err(e) = wiggle(&stop) { + eprintln!("virtual pointer: {e}"); + } + })); + } + Driver { stop, threads } + } +} +fn wiggle(stop: &AtomicBool) -> Result<(), String> { + let conn = Connection::connect_to_env().map_err(|e| e.to_string())?; + let (globals, mut queue) = registry_queue_init::(&conn).map_err(|e| e.to_string())?; + let qh = queue.handle(); + let manager = globals + .bind::(&qh, 1..=2, ()) + .map_err(|e| e.to_string())?; + let seat = globals.bind::(&qh, 1..=5, ()).ok(); + let pointer = manager.create_virtual_pointer(seat.as_ref(), &qh, ()); + queue.roundtrip(&mut Pointer).map_err(|e| e.to_string())?; + let mut t = 0u32; + while !stop.load(Ordering::Relaxed) { + // The extent covers the whole layout of two equal outputs: 600..900 of 1000 lies inside + // the second one. + let x = 600 + (t % 300); + pointer.motion_absolute(t * 20, x, 500, 1000, 1000); + pointer.frame(); + conn.flush().map_err(|e| e.to_string())?; + t += 7; + std::thread::sleep(Duration::from_millis(30)); + } + pointer.destroy(); + let _ = conn.flush(); + Ok(()) +} + +impl Drop for Driver { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + for t in self.threads.drain(..) { + let _ = t.join(); + } + } +} diff --git a/crates/ezd-host-linux/testdata/sway-get-outputs.json b/crates/ezd-host-linux/testdata/sway-get-outputs.json new file mode 100644 index 0000000..5a59156 --- /dev/null +++ b/crates/ezd-host-linux/testdata/sway-get-outputs.json @@ -0,0 +1,150 @@ +[ + { + "id": 3, + "type": "output", + "orientation": "none", + "percent": 0.5, + "urgent": false, + "marks": [], + "layout": "output", + "border": "none", + "current_border_width": 0, + "rect": { + "x": 0, + "y": 0, + "width": 1280, + "height": 720 + }, + "deco_rect": { + "x": 0, + "y": 0, + "width": 0, + "height": 0 + }, + "window_rect": { + "x": 0, + "y": 0, + "width": 0, + "height": 0 + }, + "geometry": { + "x": 0, + "y": 0, + "width": 0, + "height": 0 + }, + "name": "HEADLESS-1", + "window": null, + "nodes": [], + "floating_nodes": [], + "focus": [ + 4 + ], + "fullscreen_mode": 0, + "sticky": false, + "floating": null, + "scratchpad_state": null, + "primary": false, + "make": "Unknown", + "model": "Unknown", + "serial": "Unknown", + "modes": [], + "features": { + "adaptive_sync": false, + "hdr": false + }, + "non_desktop": false, + "active": true, + "dpms": true, + "power": true, + "scale": 1.0, + "scale_filter": "nearest", + "transform": "normal", + "adaptive_sync_status": "disabled", + "current_workspace": "1", + "current_mode": { + "width": 1280, + "height": 720, + "refresh": 0 + }, + "max_render_time": 0, + "allow_tearing": false, + "hdr": false, + "focused": true, + "subpixel_hinting": "unknown" + }, + { + "id": 5, + "type": "output", + "orientation": "none", + "percent": 0.5, + "urgent": false, + "marks": [], + "layout": "output", + "border": "none", + "current_border_width": 0, + "rect": { + "x": 1280, + "y": 0, + "width": 1280, + "height": 720 + }, + "deco_rect": { + "x": 0, + "y": 0, + "width": 0, + "height": 0 + }, + "window_rect": { + "x": 0, + "y": 0, + "width": 0, + "height": 0 + }, + "geometry": { + "x": 0, + "y": 0, + "width": 0, + "height": 0 + }, + "name": "HEADLESS-2", + "window": null, + "nodes": [], + "floating_nodes": [], + "focus": [ + 6 + ], + "fullscreen_mode": 0, + "sticky": false, + "floating": null, + "scratchpad_state": null, + "primary": false, + "make": "Unknown", + "model": "Unknown", + "serial": "Unknown", + "modes": [], + "features": { + "adaptive_sync": false, + "hdr": false + }, + "non_desktop": false, + "active": true, + "dpms": true, + "power": true, + "scale": 1.0, + "scale_filter": "nearest", + "transform": "normal", + "adaptive_sync_status": "disabled", + "current_workspace": "2", + "current_mode": { + "width": 1280, + "height": 720, + "refresh": 60000 + }, + "max_render_time": 0, + "allow_tearing": false, + "hdr": false, + "focused": false, + "subpixel_hinting": "unknown" + } +] diff --git a/crates/ezd-host-linux/testdata/sway-get-version.json b/crates/ezd-host-linux/testdata/sway-get-version.json new file mode 100644 index 0000000..39499d2 --- /dev/null +++ b/crates/ezd-host-linux/testdata/sway-get-version.json @@ -0,0 +1,8 @@ +{ + "human_readable": "1.12", + "variant": "sway", + "major": 1, + "minor": 12, + "patch": 0, + "loaded_config_file_name": "//etc/sway/config" +} diff --git a/docs/architecture.md b/docs/architecture.md index 9d9168c..7741e45 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -59,7 +59,7 @@ left out. The platform backends are only built on their own OS. | `crates/ezd-host` | Host CLI: `run`, `diag`, `vdd` (driver status, setup, install, uninstall). | | `crates/ezd-tray` | The PC app (`ezdisplay`): tray icon and menu plus a settings and statistics window (egui), autostart, single instance. | | `crates/ezd-fake-client` | Desktop stand-in for the Android client (decode, latency stats, frame dumps). | -| `crates/ezd-host-linux` | Linux backends (Phase 1c): KDE and GNOME virtual outputs, PipeWire DMA-BUF, VAAPI/NVENC. | +| `crates/ezd-host-linux` | Linux backends (Phase 1c): KDE, GNOME, Sway and Hyprland virtual outputs, PipeWire DMA-BUF and Wayland shm capture, VAAPI/NVENC. | | `crates/ezd-host-windows` | Windows backends (Phase 1d): Virtual Display Driver, DXGI adapter policy, Desktop Duplication, D3D11 encoders. | | `crates/ezd-client-core` | Client session shared by the Android library and the fake client: handshake, receive loop, frame acks, clock sync, cursor cache. | | `crates/ezd-input` | Client input engine: motion events to protocol input messages, palm rejection, S Pen button gestures, local zoom. | @@ -108,11 +108,14 @@ flowchart TB ### Linux - KDE Plasma: the ScreenCast portal source type `VIRTUAL` creates a KWin virtual output. The user picks its size in the portal dialog, default 1920×1080, and `restore_token` skips the dialog afterwards. KWin virtual outputs start at 60 Hz but support custom modes, so the host adds the stream-size mode (the tablet's native size, or the `--scale`d one) with `kscreen-doctor output..addCustomMode....reduced` and switches to it. -- GNOME: Mutter's own `org.gnome.Mutter.ScreenCast` service. The host calls `CreateSession`, `RecordVirtual` (`cursor-mode` 2 = metadata, `is-platform`) and `Start`, waits for the `PipeWireStreamAdded` signal and captures that node from the user's PipeWire daemon (no portal, no dialog). Mutter sizes the virtual monitor from the PipeWire format negotiation, so the capture offers a size range anchored at the wanted size (`pw.rs`), and the host reads the result back from `org.gnome.Mutter.DisplayConfig.GetCurrentState` once the format is known. The monitor appears in `DisplayConfig` only after the stream connects, and Mutter places it to the right of the existing ones. Without the ScreenCast service on the bus (or to capture an existing monitor) the host falls back to the portal. wlroots (later): a headless output created over IPC, captured through Wayland protocols. -- Backend selection (`backend.rs`): `EZD_LINUX_BACKEND=kde|gnome|sway|hyprland`, then `XDG_CURRENT_DESKTOP`, then the bus names `org.kde.KWin` and `org.gnome.Mutter.ScreenCast`, then `SWAYSOCK` and `HYPRLAND_INSTANCE_SIGNATURE`. Sway and Hyprland are detected but report a clear error. The compositor-specific half is a `VirtualOutputProvider` (`provider.rs`: `open` creates the output and returns a `FrameSource`, `settle` reports the final output once the stream format is known; implemented by `kde.rs` and `gnome.rs`); `source.rs` holds the shared capture and encode core. `FrameSource` has only a PipeWire variant today; a producer that does not use PipeWire becomes another variant. +- GNOME: Mutter's own `org.gnome.Mutter.ScreenCast` service. The host calls `CreateSession`, `RecordVirtual` (`cursor-mode` 2 = metadata, `is-platform`) and `Start`, waits for the `PipeWireStreamAdded` signal and captures that node from the user's PipeWire daemon (no portal, no dialog). Mutter sizes the virtual monitor from the PipeWire format negotiation, so the capture offers a size range anchored at the wanted size (`pw.rs`), and the host reads the result back from `org.gnome.Mutter.DisplayConfig.GetCurrentState` once the format is known. The monitor appears in `DisplayConfig` only after the stream connects, and Mutter places it to the right of the existing ones. Without the ScreenCast service on the bus (or to capture an existing monitor) the host falls back to the portal. +- Sway: `sway.rs` speaks i3-ipc over `$SWAYSOCK` (a 6-byte magic, the payload length, the message type, both native-endian `u32`, then the payload). It runs `create_output`, finds the new output by diffing `GET_OUTPUTS`, sets `mode --custom WxH@HzHz` and reads the geometry back; dropping the guard runs `output NAME unplug`. The host sets no `position`: an explicit position marks the output as configured and sway moves the user's unconfigured outputs behind it, whereas the new output is placed to the right on its own. The monitor kind captures an existing output. +- Hyprland: `hyprland.rs` sends one command per connection to `$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket.sock` and reads the reply to EOF. It picks a free name, runs `output create headless ezdisplay-N`, applies `keyword monitor NAME,WxH@HZ,auto,1`, reads `j/monitors` and removes the output with `output remove` on drop. Only the JSON parsing and command building are tested; Hyprland could not be run headless. +- wlroots capture (`wl_capture.rs`): direct Wayland capture instead of PipeWire, as `FrameSource::Wayland { output }`. It prefers `ext-image-copy-capture-v1` with `ext-output-image-capture-source-v1` and a second session on the pointer (`create_pointer_cursor_session`) that feeds the `CursorSink`, so the cursor is drawn locally like on KDE and GNOME. It falls back to `zwlr_screencopy_manager_v1` with `overlay_cursor=1`, which paints the cursor into the frames (no cursor data is sent). `EZD_WL_CAPTURE=ext|wlr|auto` forces a choice. Buffers are `wl_shm` only; DMA-BUF import is not implemented, so frames are copied once and encoded through the shared-memory path. The thread publishes into the same one-slot mailbox as `pw.rs` (`pw::Shared`), so `LinuxSource` treats both through one `Capture` enum. +- Backend selection (`backend.rs`): `EZD_LINUX_BACKEND=kde|gnome|sway|hyprland`, then `XDG_CURRENT_DESKTOP`, then the bus names `org.kde.KWin` and `org.gnome.Mutter.ScreenCast`, then `SWAYSOCK` and `HYPRLAND_INSTANCE_SIGNATURE`. The compositor-specific half is a `VirtualOutputProvider` (`provider.rs`: `open` creates the output and returns a `FrameSource`, `settle` reports the final output once the stream format is known; implemented by `kde.rs`, `gnome.rs`, `sway.rs` and `hyprland.rs`); `source.rs` holds the shared capture and encode core. `FrameSource` has a PipeWire variant and a Wayland variant. - Capture: PipeWire with DMA-BUF. Encoding uses VAAPI on Intel/AMD. On NVIDIA, the DMA-BUF is imported into Vulkan with a dedicated allocation (FFmpeg's own import makes some buffers sample as black there), converted to NV12 with `scale_vulkan`, copied into CUDA memory on the same GPU and encoded with NVENC. Shared memory is the fallback. - The zero-copy paths need the encoder GPU to be the one KWin renders on. The host takes KWin's primary GPU from `KWIN_DRM_DEVICES` (its first entry) when it is set, and otherwise the GPU driving the internal panel. -- Input (Phase 2): uinput devices mapped to the virtual output (KWin `org.kde.KWin.InputDevice.outputName`, GNOME gsettings, Sway/Hyprland config). KDE and GNOME are implemented, see `docs/input.md`. +- Input (Phase 2): uinput devices mapped to the virtual output (KWin `org.kde.KWin.InputDevice.outputName`, GNOME gsettings, Sway/Hyprland config). KDE, GNOME, Sway and Hyprland are implemented, see `docs/input.md`. ### Android diff --git a/docs/input.md b/docs/input.md index d4603c2..946c848 100644 --- a/docs/input.md +++ b/docs/input.md @@ -275,3 +275,26 @@ The Windows host injects the pen and touch through synthetic pointer devices (`C - **Mouse pen mode** (`--pen-mode mouse`). No pen device is created. The pen moves the mouse, contact is the left button, the barrel button is the right button, and leaving range releases them. - **Limits.** Windows blocks injected input to windows of an elevated (administrator) process (UIPI) unless the host also runs elevated. The tray app therefore runs elevated by default on Windows (through its `ezdisplay` scheduled task, so there is no UAC prompt at launch); the *Control administrator windows (run elevated)* setting turns that off. `ezd-host run` from a normal terminal is not elevated. The host logs a rate-limited warning when `SendInput` injects fewer events than requested. - **Status.** `InputStatus` always carries ACTIONS and KEYS, and TOUCH and PEN only when their device was created. If a device cannot be created (Windows older than 1809), the note says so. Without a known virtual display, input is unavailable. `ezd-host diag` has an `input` section that reports whether both devices can be created. + +## Linux (Sway and Hyprland) + +The same uinput devices are created, plus a fourth absolute pointer, and each one is mapped to the +virtual output by the compositor, per device. Because the mapping is per device, the pointer needs no +workspace layout and the host skips the position conversion used on KDE and GNOME. + +| Device | Sway identifier | Hyprland name | +| --- | --- | --- | +| touch | `4617:60881:ezdisplay_touch` | `ezdisplay-touch` | +| pen | `4617:60882:ezdisplay_pen` | `ezdisplay-pen` | +| pointer | `4617:60883:ezdisplay_pointer` | `ezdisplay-pointer` | + +- Sway: `swaymsg input :: map_to_output `. The identifier is built the + way sway does it: vendor and product in decimal, and spaces and unprintable characters in the name + replaced by `_`. When the session ends the host runs `map_to_output *` to release the device. +- Hyprland: `hyprctl keyword device[]:output `, with lowercase, dash-separated names. + When the session ends the host sets the value back to `[[Auto]]`. + +Neither restore command has been checked on a live compositor (both are harmless for a device that is +gone). Sway accepts the `map_to_output` commands for devices that do not exist yet, which is how the +headless test exercises the path; real device mapping needs a session with udev. `ezd-host diag` +reports the compositor, its version and the capture protocols it advertises. diff --git a/packaging/linux/README.md b/packaging/linux/README.md index 5fc2b3c..11a684b 100644 --- a/packaging/linux/README.md +++ b/packaging/linux/README.md @@ -1,6 +1,6 @@ # Linux packaging -Only KDE Plasma 6 and GNOME on Wayland are supported today. CI builds all of this in +KDE Plasma 6, GNOME, Sway and Hyprland on Wayland are supported. CI builds all of this in `.github/workflows/linux-packages.yml`; the commands below reproduce it locally. Replace `~/.cache/ezd-pkg` with any scratch directory on a disk (not a RAM-backed `/tmp`). @@ -56,7 +56,7 @@ docker run --rm -v "$PWD:/src:ro" -v ~/.cache/ezd-pkg/deb:/work -e CARGO_HOME=/w leave in the binaries. Use `1~debian13` on `debian:trixie` and `1~ubuntu26.04` on `ubuntu:26.04`. The revision suffix keeps the three files distinct and makes each sort below a later plain revision. `adb` is a hard dependency; `xdg-desktop-portal-kde` and the package that ships `kscreen-doctor` are recommended -(KDE only; GNOME needs nothing beyond its own session). On Arch they are optional dependencies. +(KDE only; GNOME, Sway and Hyprland need nothing beyond their own session). On Arch they are optional dependencies. ## AppImage diff --git a/packaging/linux/arch/PKGBUILD b/packaging/linux/arch/PKGBUILD index 19f04ff..45b47b9 100644 --- a/packaging/linux/arch/PKGBUILD +++ b/packaging/linux/arch/PKGBUILD @@ -7,7 +7,7 @@ pkgname=ezdisplay pkgver=0.1.0 # replaced by pkgver() with the workspace version pkgrel=1 -pkgdesc='Use an Android tablet as an extended monitor over USB (KDE Plasma and GNOME host, and tray app)' +pkgdesc='Use an Android tablet as an extended monitor over USB (KDE Plasma, GNOME, Sway and Hyprland host, and tray app)' arch=(x86_64) url='https://github.com/LibreStatic/ezdisplay' license=('MIT OR Apache-2.0') diff --git a/tools/wlroots-headless-test.sh b/tools/wlroots-headless-test.sh new file mode 100755 index 0000000..b6c56b9 --- /dev/null +++ b/tools/wlroots-headless-test.sh @@ -0,0 +1,58 @@ +#!/bin/sh +# Runs the wlroots backend tests (`sway::tests::headless_sway_encoded_frames` and +# `wl_capture::tests::headless_sway_frames`) against a headless sway inside Docker, so sway does not +# have to be installed on the host. Hyprland is not covered: it needs a GPU or a render node to start. +# +# tools/wlroots-headless-test.sh # ext-image-copy-capture (plus cursor session) +# EZD_WL_CAPTURE=wlr tools/wlroots-headless-test.sh # wlr-screencopy only +# +# The first run builds the image `ezd-wlroots-test` (sway, rust, ffmpeg and the build dependencies). +# Build output goes to $EZD_WLROOTS_CACHE (default ~/.cache/ezdisplay/wlroots-target): keep it off +# tmpfs. Remove the image with `docker rmi ezd-wlroots-test` when done. +set -eu + +if [ -z "${EZD_WLROOTS_INNER:-}" ]; then + repo=$(cd "$(dirname "$0")/.." && pwd) + image=${EZD_WLROOTS_IMAGE:-ezd-wlroots-test} + cache=${EZD_WLROOTS_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/ezdisplay/wlroots-target} + mkdir -p "$cache" + if ! docker image inspect "$image" >/dev/null 2>&1; then + docker build -t "$image" - <<'DOCKERFILE' +FROM archlinux:latest +RUN pacman -Syu --noconfirm --needed sway wayland-utils rust clang pkgconf base-devel ffmpeg pipewire \ + libva mesa vulkan-swrast libdrm wayland wayland-protocols libxkbcommon \ + && pacman -Scc --noconfirm +DOCKERFILE + fi + exec docker run --rm \ + --user "$(id -u):$(id -g)" \ + -v "$repo":/src:ro \ + -v "$cache":/target \ + -v "$HOME/.cargo/registry":/cargo/registry \ + -e CARGO_HOME=/cargo -e CARGO_TARGET_DIR=/target -e HOME=/tmp/home \ + -e EZD_WLROOTS_INNER=1 -e EZD_WL_CAPTURE="${EZD_WL_CAPTURE:-}" -e RUST_LOG="${RUST_LOG:-info}" \ + -w /src "$image" sh /src/tools/wlroots-headless-test.sh "$@" +fi + +# Inside the container. +mkdir -p "$HOME/bin" "$HOME/run" +chmod 700 "$HOME/run" +export XDG_RUNTIME_DIR="$HOME/run" +# sway ships with file capabilities that a container without CAP_SETFCAP cannot grant: use a copy. +cp /usr/bin/sway "$HOME/bin/sway" +cargo test -p ezd-host-linux --lib --offline --locked --no-run +WLR_BACKENDS=headless WLR_RENDERER=pixman WLR_LIBINPUT_NO_DEVICES=1 "$HOME/bin/sway" >"$HOME/sway.log" 2>&1 & +sway_pid=$! +trap 'kill $sway_pid 2>/dev/null || true' EXIT +for _ in $(seq 50); do + SWAYSOCK=$(ls "$XDG_RUNTIME_DIR"/sway-ipc.*.sock 2>/dev/null | head -n 1 || true) + [ -n "$SWAYSOCK" ] && break + sleep 0.2 +done +export SWAYSOCK +WAYLAND_DISPLAY=$(cd "$XDG_RUNTIME_DIR" && ls | grep '^wayland-[0-9]*$' | head -n 1) +export WAYLAND_DISPLAY +echo "$("$HOME/bin/sway" --version) on $WAYLAND_DISPLAY; capture protocols advertised:" +wayland-info | grep -E "interface: '(ext_image_copy|ext_output_image|zwlr_screencopy|zwp_linux_dmabuf|wl_shm|zwlr_virtual_pointer|wl_seat)" || true +EZD_WLROOTS_TEST=1 cargo test -p ezd-host-linux --lib --offline --locked headless_sway \ + "$@" -- --nocapture --test-threads=1