diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c3445e..e891f5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: steps: - name: Install system packages run: | - pacman -Syu --noconfirm --needed base-devel clang pkgconf ffmpeg pipewire libva libusb git curl + pacman -Syu --noconfirm --needed base-devel clang pkgconf ffmpeg pipewire libva libusb git curl xorg-server xf86-video-dummy - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable with: @@ -27,6 +27,8 @@ jobs: - run: cargo fmt --all --check - run: cargo clippy --workspace --all-targets -- -D warnings - run: cargo test --workspace + # The X11 backend against Xorg with the dummy video driver (no desktop or Docker needed). + - run: tools/x11-dummy-test.sh windows: name: Windows (build, test, artifact) diff --git a/Cargo.lock b/Cargo.lock index f27d310..34087ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1695,6 +1695,10 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", + "x11rb", "zbus", ] diff --git a/README.md b/README.md index 54b695a..1c7d82f 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) · X11 · Android tablets · Rust + Kotlin [Download](https://github.com/LibreStatic/ezdisplay/releases) · [Install](#install) · @@ -55,19 +55,22 @@ 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 | X11 | +| --- | :---: | :---: | :---: | :---: | :---: | +| Extended display at native resolution and refresh rate | ✅ | ✅ | 🧪 | 🧪 | 🧪 | +| Locally drawn cursor | ✅ | ✅ | 🧪 | 🧪 | 🧪 | +| Zero-copy hardware encode | ✅ | ✅ | 🧪 | ❌ shared-memory capture | ❌ 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 | 🧪 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, +Hyprland only through unit tests, and X11 against Xorg with the dummy video driver (i3, xfwm4). +X11 works with any window manager or desktop that honours RandR 1.5 monitors; the proprietary +NVIDIA driver is untested. -**Not yet:** audio, wlroots backends, X11. +**Not yet:** audio. ## How it works @@ -132,11 +135,15 @@ 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 on Wayland; any desktop on X11) -Plasma 6 or a recent GNOME 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: +On Wayland, Plasma 6, a recent GNOME, Sway or Hyprland is required; other Wayland compositors are +not supported yet. On X11 the server needs RandR 1.5, MIT-SHM, XFixes, Damage and XInput 2 (any +Xorg of the last decade). A desktop daemon that rewrites the layout (kscreen, +gnome-settings-daemon, xfsettingsd) is answered by re-applying the virtual monitor, at most a few +times per ten seconds. The host picks the backend from an X11 session (`XDG_SESSION_TYPE=x11`, or +`DISPLAY` without `WAYLAND_DISPLAY`), then `XDG_CURRENT_DESKTOP` and the session bus; +`EZD_LINUX_BACKEND=kde|gnome|sway|hyprland|x11` 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 +236,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, Hyprland and X11 virtual output, PipeWire, Wayland and X11 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..877970f 100644 --- a/crates/ezd-host-linux/Cargo.toml +++ b/crates/ezd-host-linux/Cargo.toml @@ -25,6 +25,13 @@ 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"] } +# Pure-Rust X11 client for the X11 backend: RandR monitor, MIT-SHM + XDamage capture, XFixes cursor, +# XInput2 device transformation (XTest drives the integration test). +x11rb = { version = "0.13", features = ["randr", "shm", "xfixes", "damage", "xinput", "xtest"] } # 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..44c0703 100644 --- a/crates/ezd-host-linux/src/backend.rs +++ b/crates/ezd-host-linux/src/backend.rs @@ -1,23 +1,28 @@ //! Which Linux desktop the host runs on: picks the virtual output provider and the input backend. //! //! Detection is a pure function over the environment and two bus probes, so it can be tested -//! without a session: `EZD_LINUX_BACKEND`, then `XDG_CURRENT_DESKTOP`, then the compositors' bus -//! names (a host started over ssh or from a stripped environment has no desktop variable), then -//! the sway and Hyprland variables. +//! without a session: `EZD_LINUX_BACKEND`, then an X11 session (any desktop on X11 is driven +//! through RandR, whatever `XDG_CURRENT_DESKTOP` says), then `XDG_CURRENT_DESKTOP`, then the +//! compositors' bus names (a host started over ssh or from a stripped environment has no desktop +//! variable), then the sway and Hyprland variables. 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, + /// Any desktop on an X server: RandR 1.5 monitor, `XGetImage` capture, per-device input + /// transformation matrix. + X11, } impl LinuxBackend { @@ -28,6 +33,7 @@ impl LinuxBackend { LinuxBackend::Gnome => "gnome", LinuxBackend::Sway => "sway", LinuxBackend::Hyprland => "hyprland", + LinuxBackend::X11 => "x11", } } @@ -38,22 +44,10 @@ impl LinuxBackend { "gnome" => Some(LinuxBackend::Gnome), "sway" => Some(LinuxBackend::Sway), "hyprland" => Some(LinuxBackend::Hyprland), + "x11" => Some(LinuxBackend::X11), _ => 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 { @@ -69,6 +63,12 @@ pub struct DetectEnv { pub override_backend: Option, /// `XDG_CURRENT_DESKTOP`. pub desktop: Option, + /// `XDG_SESSION_TYPE`. + pub session_type: Option, + /// `DISPLAY` is set. + pub display: bool, + /// `WAYLAND_DISPLAY` is set. + pub wayland_display: bool, /// `SWAYSOCK` is set. pub sway_sock: bool, /// `HYPRLAND_INSTANCE_SIGNATURE` is set. @@ -82,6 +82,9 @@ impl DetectEnv { DetectEnv { override_backend: var("EZD_LINUX_BACKEND"), desktop: var("XDG_CURRENT_DESKTOP"), + session_type: var("XDG_SESSION_TYPE"), + display: var("DISPLAY").is_some(), + wayland_display: var("WAYLAND_DISPLAY").is_some(), sway_sock: var("SWAYSOCK").is_some(), hyprland_signature: var("HYPRLAND_INSTANCE_SIGNATURE").is_some(), } @@ -110,6 +113,20 @@ pub fn from_desktop(desktop: &str) -> Option { }) } +impl DetectEnv { + /// True for an X11 session: `XDG_SESSION_TYPE=x11`, or an X display without a Wayland one + /// (Xwayland sets `DISPLAY` on every Wayland session, so `WAYLAND_DISPLAY` wins). + /// A service started without `WAYLAND_DISPLAY` on a Wayland session still says `wayland` in + /// `XDG_SESSION_TYPE`, so that wins over a bare `DISPLAY`. + pub fn is_x11_session(&self) -> bool { + let session = self.session_type.as_deref().map(str::trim); + session.is_some_and(|t| t.eq_ignore_ascii_case("x11")) + || (self.display + && !self.wayland_display + && !session.is_some_and(|t| t.eq_ignore_ascii_case("wayland"))) + } +} + /// Detects the backend. `probe` is only called when the environment does not decide. `Err` is an /// invalid `EZD_LINUX_BACKEND`; `Ok(None)` means nothing was recognized. pub fn detect( @@ -118,9 +135,12 @@ pub fn detect( ) -> Result, String> { if let Some(v) = &env.override_backend { return LinuxBackend::parse(v).map(Some).ok_or_else(|| { - format!("EZD_LINUX_BACKEND={v:?} is not one of kde, gnome, sway, hyprland") + format!("EZD_LINUX_BACKEND={v:?} is not one of kde, gnome, sway, hyprland, x11") }); } + if env.is_x11_session() { + return Ok(Some(LinuxBackend::X11)); + } if let Some(b) = env.desktop.as_deref().and_then(from_desktop) { return Ok(Some(b)); } @@ -179,7 +199,7 @@ pub fn current() -> Result { Some(b) => Ok(b), None => { tracing::warn!( - "no supported desktop recognized; assuming KDE (set EZD_LINUX_BACKEND=kde|gnome to choose)" + "no supported desktop recognized; assuming KDE (set EZD_LINUX_BACKEND=kde|gnome|sway|hyprland|x11 to choose)" ); Ok(LinuxBackend::Kde) } @@ -282,17 +302,65 @@ 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, LinuxBackend::Sway, LinuxBackend::Hyprland, + LinuxBackend::X11, ] { assert_eq!(LinuxBackend::parse(b.name()), Some(b)); } } + + fn x11_env(session: Option<&str>, display: bool, wayland: bool, desktop: &str) -> DetectEnv { + DetectEnv { + session_type: session.map(str::to_string), + display, + wayland_display: wayland, + ..env(Some(desktop)) + } + } + + #[test] + fn x11_sessions_win_over_the_desktop_name() { + for desktop in ["KDE", "GNOME", "XFCE", "X-Cinnamon", "MATE", "i3"] { + let e = x11_env(Some("x11"), true, false, desktop); + assert_eq!( + detect(&e, || panic!("no probe")), + Ok(Some(LinuxBackend::X11)), + "{desktop}" + ); + } + // A session type alone is enough, and so is a bare DISPLAY. + let e = x11_env(Some("X11"), false, false, "KDE"); + assert_eq!(detect(&e, || NO_BUS), Ok(Some(LinuxBackend::X11))); + let e = x11_env(Some("tty"), true, false, ""); + assert_eq!(detect(&e, || NO_BUS), Ok(Some(LinuxBackend::X11))); + } + + #[test] + fn xwayland_does_not_make_a_wayland_session_x11() { + // Every Wayland session also has DISPLAY (Xwayland). + let e = x11_env(Some("wayland"), true, true, "GNOME"); + assert_eq!(detect(&e, || NO_BUS), Ok(Some(LinuxBackend::Gnome))); + let e = x11_env(None, true, true, "KDE"); + assert_eq!(detect(&e, || NO_BUS), Ok(Some(LinuxBackend::Kde))); + // A service that lost WAYLAND_DISPLAY but still has the session type. + let e = x11_env(Some("wayland"), true, false, "KDE"); + assert_eq!(detect(&e, || NO_BUS), Ok(Some(LinuxBackend::Kde))); + let mut e = x11_env(None, true, true, ""); + e.sway_sock = true; + assert_eq!(detect(&e, || NO_BUS), Ok(Some(LinuxBackend::Sway))); + } + + #[test] + fn the_override_beats_the_x11_session() { + let mut e = x11_env(Some("x11"), true, false, "KDE"); + e.override_backend = Some("kde".into()); + assert_eq!(detect(&e, || NO_BUS), Ok(Some(LinuxBackend::Kde))); + e.override_backend = Some("X11".into()); + assert_eq!(detect(&e, || NO_BUS), Ok(Some(LinuxBackend::X11))); + } } diff --git a/crates/ezd-host-linux/src/diag.rs b/crates/ezd-host-linux/src/diag.rs index 684b399..acbf974 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,19 @@ pub fn section() -> Value { }); let extra = match detected { Ok(LinuxBackend::Gnome) => json!({ "gnome": crate::gnome::diag() }), + Ok(LinuxBackend::X11) => json!({ "x11": crate::x11_output::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..95b06a5 100644 --- a/crates/ezd-host-linux/src/lib.rs +++ b/crates/ezd-host-linux/src/lib.rs @@ -1,6 +1,8 @@ -//! 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 on Wayland, any desktop on X11) 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, an X server as a RandR monitor), +//! PipeWire capture (DMA-BUF preferred), direct Wayland capture on the wlroots compositors or +//! MIT-SHM capture on X11, and VAAPI / NVENC / x264 encoding. //! //! Exposes the platform contract used by `ezd-host`: //! `create_virtual_source(&PlatformOptions)` and `diag_section()`. @@ -20,6 +22,7 @@ mod gbm_test; mod gnome; mod gnome_dc; mod gnome_input; +mod hyprland; mod input; mod kde; mod kscreen; @@ -29,8 +32,17 @@ mod portal; mod provider; mod pw; mod source; +mod sway; mod uinput_sink; mod vkdma; +mod wl_capture; +#[cfg(test)] +mod wlroots_test; +mod x11_capture; +mod x11_input; +mod x11_output; +#[cfg(test)] +mod x11_test; use backend::LinuxBackend; pub use ezd_host_core::PlatformOptions; @@ -45,9 +57,9 @@ 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), + LinuxBackend::X11 => Box::new(x11_output::X11Provider), }; tracing::info!(backend = backend.name(), "Linux backend"); let src = source::LinuxSource::new(opts, provider); @@ -60,8 +72,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, X11 through XInput2 transformation matrices. pub fn input_backend() -> Option> { let backend = match backend::current() { Ok(b) => b, @@ -78,16 +90,15 @@ 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, + )))), + LinuxBackend::X11 => Some(Arc::new(uinput_sink::UinputBackend::new(Arc::new( + x11_input::X11Desktop, + )))), } } diff --git a/crates/ezd-host-linux/src/provider.rs b/crates/ezd-host-linux/src/provider.rs index 7eb0975..9e1bdb4 100644 --- a/crates/ezd-host-linux/src/provider.rs +++ b/crates/ezd-host-linux/src/provider.rs @@ -2,10 +2,12 @@ //! 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`] or [`crate::x11_output::X11Provider`]. 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; the X11 provider returns a +//! rectangle of the root window that `x11_capture.rs` grabs. use crate::portal::SourceKind; use crate::pw::Target; @@ -29,6 +31,12 @@ 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 }, + /// A rectangle `(x, y, width, height)` of the X screen, grabbed from the root window + /// (`x11_capture.rs`). + X11 { rect: (i32, i32, u32, u32) }, } /// An opened output. @@ -58,3 +66,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..66f74a5 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,12 @@ 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 crate::x11_capture::X11Capture; use ezd_host_core::{ CursorSink, EncodedFrame, OutputInfo, PlatformOptions, SourceError, SourceHdr, VideoSource, }; @@ -101,10 +104,67 @@ enum Input { }, } +/// Where the frames come from: PipeWire (KDE, GNOME), the compositor's Wayland capture +/// protocols (sway, Hyprland) or `GetImage` on the root window (X11). +enum Capture { + Pw(PwCapture), + Wl(WlCapture), + X11(X11Capture), +} + +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), + Capture::X11(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), + Capture::X11(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), + Capture::X11(c) => c.next(timeout), + } + } + + fn frames(&self) -> Frames { + match self { + Capture::Pw(c) => c.frames(), + Capture::Wl(c) => c.frames(), + Capture::X11(c) => c.frames(), + } + } + + fn counters(&self) -> (u64, u64) { + match self { + Capture::Pw(c) => c.counters(), + Capture::Wl(c) => c.counters(), + Capture::X11(c) => c.counters(), + } + } + + /// Falls back to shared memory (PipeWire only: Wayland and X11 capture never use 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 +319,36 @@ 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) + } + FrameSource::X11 { rect } => { + let x = + X11Capture::start(rect, cfg.refresh_mhz, cursor).map_err(SourceError::msg)?; + tracing::info!(shm = x.uses_shm(), ?rect, "capturing the X screen"); + Capture::X11(x) + } }; - 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 +377,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 +388,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 +405,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 +469,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 +494,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..62cbc15 100644 --- a/crates/ezd-host-linux/src/uinput_sink.rs +++ b/crates/ezd-host-linux/src/uinput_sink.rs @@ -137,7 +137,7 @@ pub fn create_keyboard() -> io::Result { } /// `eventN` names of a freshly created device (the nodes can show up a moment later). -fn event_name(dev: &mut VirtualDevice) -> Option { +pub(crate) fn event_name(dev: &mut VirtualDevice) -> Option { for _ in 0..40 { if let Ok(nodes) = dev.enumerate_dev_nodes_blocking() { for p in nodes.flatten() { @@ -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/src/x11_capture.rs b/crates/ezd-host-linux/src/x11_capture.rs new file mode 100644 index 0000000..b63571c --- /dev/null +++ b/crates/ezd-host-linux/src/x11_capture.rs @@ -0,0 +1,712 @@ +//! X11 capture: a rectangle of the root window grabbed with MIT-SHM `GetImage`, paced by XDamage, +//! plus the cursor from XFixes. +//! +//! The capture thread owns its own connection. Frames are only grabbed when the rectangle was +//! damaged (the same idle behaviour as the Wayland capture, which gets no frames from a still +//! screen), at most once per refresh interval, and the first frame is grabbed at once. `GetImage` +//! does not contain the cursor: the pointer position (polled once per interval, X sends no event +//! for it) and the cursor image (XFixes `CursorNotify`) go to the [`CursorSink`], the tablet draws +//! its own local cursor. + +use crate::cursor::{PixelOrder, build_shape}; +use crate::frame::{Frame, NegotiatedFormat, Pixels, ShmBuf, SpaColor, spa_fmt}; +use crate::pw::{Frames, Shared}; +use crate::x11_output::{Rect, connect, xe}; +use ezd_host_core::{CursorSink, now_us}; +use ezd_proto::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 x11rb::connection::Connection; +use x11rb::protocol::Event; +use x11rb::protocol::damage::{self, ReportLevel}; +use x11rb::protocol::shm; +use x11rb::protocol::xfixes::{self, CursorNotifyMask}; +use x11rb::protocol::xproto::{self, ImageFormat, VisualClass}; +use x11rb::rust_connection::RustConnection; + +/// How often the loop looks at the stop flag while nothing happens. +const POLL_MS: i32 = 100; +/// Shortest interval between grabs (and pointer polls), whatever refresh rate was asked for. +const MIN_INTERVAL: Duration = Duration::from_millis(4); + +/// SPA format of the root window's pixels, `Err` for layouts the encoder cannot read. +pub fn spa_format_for( + depth: u8, + bits_per_pixel: u8, + masks: (u32, u32, u32), + msb_first: bool, +) -> Result { + if !matches!(depth, 24 | 32) || bits_per_pixel != 32 { + return Err(format!( + "the X screen has depth {depth} at {bits_per_pixel} bits per pixel; 24 or 32 deep \ + 32-bit pixels are required" + )); + } + match (masks, msb_first) { + ((0xff_0000, 0xff00, 0xff), false) => Ok(spa_fmt::BGRX), + ((0xff_0000, 0xff00, 0xff), true) => Ok(spa_fmt::XRGB), + ((0xff, 0xff00, 0xff_0000), false) => Ok(spa_fmt::RGBX), + ((0xff, 0xff00, 0xff_0000), true) => Ok(spa_fmt::XBGR), + (m, _) => Err(format!( + "the X screen's colour masks {:#x}/{:#x}/{:#x} are not 8-bit RGB", + m.0, m.1, m.2 + )), + } +} + +/// Bytes per row of an image of `width` pixels in a format with `scanline_pad` bits per row unit. +pub fn stride_for(width: u32, bits_per_pixel: u32, scanline_pad: u32) -> usize { + let pad = scanline_pad.max(8); + (((width * bits_per_pixel).div_ceil(pad) * pad) / 8) as usize +} + +/// The pixel layout of the root window. +#[derive(Debug, Clone, Copy)] +struct RootFormat { + spa: u32, + bits_per_pixel: u8, + scanline_pad: u8, +} + +fn root_format(conn: &RustConnection, screen_num: usize) -> Result { + let setup = conn.setup(); + let screen = &setup.roots[screen_num]; + let visual = screen + .allowed_depths + .iter() + .flat_map(|d| d.visuals.iter()) + .find(|v| v.visual_id == screen.root_visual) + .ok_or("the root visual is not listed")?; + if visual.class != VisualClass::TRUE_COLOR && visual.class != VisualClass::DIRECT_COLOR { + return Err(format!( + "the root visual is {:?}; a TrueColor screen is required", + visual.class + )); + } + let format = setup + .pixmap_formats + .iter() + .find(|f| f.depth == screen.root_depth) + .ok_or("no pixmap format for the root depth")?; + let spa = spa_format_for( + screen.root_depth, + format.bits_per_pixel, + (visual.red_mask, visual.green_mask, visual.blue_mask), + setup.image_byte_order == xproto::ImageOrder::MSB_FIRST, + )?; + Ok(RootFormat { + spa, + bits_per_pixel: format.bits_per_pixel, + scanline_pad: format.scanline_pad, + }) +} + +/// A shared memory segment the server writes images into. +struct ShmSegment { + seg: shm::Seg, + ptr: *mut u8, + len: usize, +} + +impl ShmSegment { + /// Creates a memfd of `len` bytes and attaches it to the server (MIT-SHM 1.2). + fn attach(conn: &RustConnection, len: usize) -> Result { + let version = shm::query_version(conn) + .map_err(xe("ShmQueryVersion"))? + .reply() + .map_err(xe("ShmQueryVersion"))?; + if (version.major_version, version.minor_version) < (1, 2) { + return Err(format!( + "MIT-SHM {}.{} cannot take file descriptors (1.2 is needed)", + version.major_version, version.minor_version + )); + } + // SAFETY: plain syscalls; the descriptor is owned right away and closed on every path. + let fd = unsafe { + let raw = libc::memfd_create(c"ezd-x11-capture".as_ptr(), libc::MFD_CLOEXEC); + if raw < 0 { + return Err(format!("memfd_create: {}", std::io::Error::last_os_error())); + } + OwnedFd::from_raw_fd(raw) + }; + // SAFETY: `fd` is a valid memfd. + if unsafe { libc::ftruncate(fd.as_raw_fd(), len as libc::off_t) } != 0 { + return Err(format!("ftruncate: {}", std::io::Error::last_os_error())); + } + // SAFETY: maps `len` bytes of the memfd that was just sized to `len`. + let ptr = unsafe { + 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())); + } + let seg = conn.generate_id().map_err(xe("generate_id"))?; + let attached = shm::attach_fd(conn, seg, fd, false) + .map_err(xe("ShmAttachFd")) + .and_then(|c| c.check().map_err(xe("ShmAttachFd"))); + if let Err(e) = attached { + // SAFETY: undoes the mapping above. + unsafe { libc::munmap(ptr, len) }; + return Err(e); + } + Ok(ShmSegment { + seg, + ptr: ptr.cast(), + len, + }) + } + + fn bytes(&self) -> &[u8] { + // SAFETY: the mapping lives as long as `self`; the server only writes during a GetImage + // whose reply has been received before this is read. + unsafe { std::slice::from_raw_parts(self.ptr, self.len) } + } + + fn detach(&self, conn: &RustConnection) { + let _ = shm::detach(conn, self.seg); + let _ = conn.flush(); + } +} + +impl Drop for ShmSegment { + fn drop(&mut self) { + // SAFETY: undoes the mapping made in `attach`. + unsafe { libc::munmap(self.ptr.cast(), self.len) }; + } +} + +/// Takes images of the rectangle. +struct Grabber { + root: xproto::Window, + rect: (i16, i16, u16, u16), + stride: usize, + shm: Option, +} + +impl Grabber { + fn grab(&self, conn: &RustConnection) -> Result, String> { + let (x, y, w, h) = self.rect; + match &self.shm { + Some(seg) => { + shm::get_image( + conn, + self.root, + x, + y, + w, + h, + !0, + ImageFormat::Z_PIXMAP.into(), + seg.seg, + 0, + ) + .map_err(xe("ShmGetImage"))? + .reply() + .map_err(xe("ShmGetImage"))?; + Ok(seg.bytes().to_vec()) + } + None => { + let r = xproto::get_image(conn, ImageFormat::Z_PIXMAP, self.root, x, y, w, h, !0) + .map_err(xe("GetImage"))? + .reply() + .map_err(xe("GetImage"))?; + Ok(r.data) + } + } + } +} + +/// The cursor as the capture reports it to the sink. +struct CursorTrack { + sink: CursorSink, + shape_id: u32, + last: Option<(i32, i32, bool, u32)>, + updates: Arc, +} + +impl CursorTrack { + /// Reads the current cursor image and sends it when the sink does not have it yet. + fn shape_changed(&mut self, conn: &RustConnection) { + let Some(image) = xfixes::get_cursor_image(conn) + .ok() + .and_then(|c| c.reply().ok()) + else { + return; + }; + // ARGB32 words, premultiplied; little-endian bytes are B G R A. + let bytes: Vec = image + .cursor_image + .iter() + .flat_map(|p| p.to_le_bytes()) + .collect(); + let (w, h) = (u32::from(image.width), u32::from(image.height)); + match build_shape( + &bytes, + w, + h, + w as usize * 4, + PixelOrder::Bgra, + i32::from(image.xhot), + i32::from(image.yhot), + ) { + Some(shape) => { + self.shape_id = shape.shape_id; + let _ = self.sink.set_shape(shape); + } + None => self.shape_id = 0, + } + } + + /// Publishes the pointer position relative to `rect` when something changed. + fn publish(&mut self, conn: &RustConnection, root: xproto::Window, rect: &Rect) { + let Some(p) = xproto::query_pointer(conn, root) + .ok() + .and_then(|c| c.reply().ok()) + else { + return; + }; + let (x, y) = (i32::from(p.root_x) - rect.x, i32::from(p.root_y) - rect.y); + let inside = x >= 0 && y >= 0 && (x as u32) < rect.w && (y as u32) < rect.h; + let cur = (x, y, inside && self.shape_id != 0, self.shape_id); + if self.last == Some(cur) { + return; + } + self.last = Some(cur); + self.updates.fetch_add(1, Ordering::Relaxed); + let _ = self.sink.update(CursorState { + x: cur.0, + y: cur.1, + visible: cur.2, + shape_id: cur.3, + ts_us: now_us(), + }); + } +} + +/// Handle to the capture thread. +pub struct X11Capture { + shared: Arc, + stop: Arc, + thread: Option>, + cursor_updates: Arc, + shm: bool, +} + +impl X11Capture { + /// Connects to the X server and starts grabbing `rect` (`x, y, width, height`) at up to + /// `refresh_mhz`. Returns once the capture is set up; the first frame follows. + pub fn start( + rect: (i32, i32, u32, u32), + refresh_mhz: u32, + cursor: CursorSink, + ) -> Result { + // `EZD_X11_NO_SHM=1` forces plain `GetImage` (remote displays, debugging). + let shm = std::env::var_os("EZD_X11_NO_SHM").is_none(); + Self::start_with(rect, refresh_mhz, cursor, shm) + } + + /// [`start`](Self::start) with the shared-memory path chosen by the caller. + pub fn start_with( + rect: (i32, i32, u32, u32), + refresh_mhz: u32, + cursor: CursorSink, + allow_shm: bool, + ) -> 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 rect = Rect { + x: rect.0, + y: rect.1, + w: rect.2, + h: rect.3, + }; + let thread = { + let (shared, stop, updates) = (shared.clone(), stop.clone(), cursor_updates.clone()); + std::thread::Builder::new() + .name("ezd-x11-capture".into()) + .spawn(move || { + let params = Params { + rect, + refresh_mhz, + allow_shm, + }; + let r = run(params, 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 X11 capture thread: {e}"))? + }; + match ready_rx.recv_timeout(Duration::from_secs(10)) { + Ok(Ok(shm)) => Ok(X11Capture { + shared, + stop, + thread: Some(thread), + cursor_updates, + shm, + }), + Ok(Err(e)) => { + let _ = thread.join(); + Err(e) + } + Err(_) => { + stop.store(true, Ordering::Relaxed); + Err("the X11 capture thread did not become ready".into()) + } + } + } + + /// Whether frames come through shared memory (else plain `GetImage`). + pub fn uses_shm(&self) -> bool { + self.shm + } + + /// 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 grabbed 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!( + cursor_updates = self.cursor_updates(), + "X11 capture stopped" + ); + } + } +} + +impl Drop for X11Capture { + fn drop(&mut self) { + self.stop(); + } +} + +/// Waits until the connection is readable or `timeout` passes. +fn wait_readable(conn: &RustConnection, timeout: Duration) { + let mut fds = [libc::pollfd { + fd: conn.stream().as_fd().as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }]; + let ms = timeout.as_millis().min(POLL_MS as u128) as i32; + // SAFETY: one valid pollfd. + unsafe { libc::poll(fds.as_mut_ptr(), 1, ms) }; +} + +/// Frame interval for `refresh_mhz`. +fn interval_for(refresh_mhz: u32) -> Duration { + let mhz = if refresh_mhz == 0 { + 60_000 + } else { + refresh_mhz + }; + Duration::from_micros(1_000_000_000 / u64::from(mhz)).max(MIN_INTERVAL) +} + +/// What the capture thread is asked to do. +struct Params { + rect: Rect, + refresh_mhz: u32, + allow_shm: bool, +} + +fn run( + params: Params, + cursor: CursorSink, + shared: &Arc, + stop: &AtomicBool, + cursor_updates: Arc, + ready_tx: &mpsc::Sender>, +) -> Result<(), String> { + let Params { + rect, + refresh_mhz, + allow_shm, + } = params; + let (conn, screen_num) = connect()?; + let root = conn.setup().roots[screen_num].root; + let geo = xproto::get_geometry(&conn, root) + .map_err(xe("GetGeometry"))? + .reply() + .map_err(xe("GetGeometry"))?; + if rect.x < 0 + || rect.y < 0 + || rect.right() > i32::from(geo.width) + || rect.bottom() > i32::from(geo.height) + || rect.w == 0 + || rect.h == 0 + || rect.w > u32::from(u16::MAX) + || rect.h > u32::from(u16::MAX) + { + return Err(format!( + "the capture area {}x{}+{}+{} is not inside the {}x{} X screen", + rect.w, rect.h, rect.x, rect.y, geo.width, geo.height + )); + } + let format = root_format(&conn, screen_num)?; + let stride = stride_for( + rect.w, + u32::from(format.bits_per_pixel), + u32::from(format.scanline_pad), + ); + + // Damage and the cursor need their versions announced first. + damage::query_version(&conn, 1, 1) + .map_err(|_| "the X server has no Damage extension".to_string())? + .reply() + .map_err(|_| "the X server has no Damage extension".to_string())?; + xfixes::query_version(&conn, 5, 0) + .map_err(|_| "the X server has no XFixes extension".to_string())? + .reply() + .map_err(|_| "the X server has no XFixes extension".to_string())?; + let damage_id = conn.generate_id().map_err(xe("generate_id"))?; + damage::create(&conn, damage_id, root, ReportLevel::NON_EMPTY) + .map_err(xe("DamageCreate"))? + .check() + .map_err(xe("DamageCreate"))?; + let region = conn.generate_id().map_err(xe("generate_id"))?; + xfixes::create_region(&conn, region, &[]) + .map_err(xe("XFixesCreateRegion"))? + .check() + .map_err(xe("XFixesCreateRegion"))?; + xfixes::select_cursor_input(&conn, root, CursorNotifyMask::DISPLAY_CURSOR) + .map_err(xe("XFixesSelectCursorInput"))? + .check() + .map_err(xe("XFixesSelectCursorInput"))?; + + let segment = if allow_shm { + ShmSegment::attach(&conn, stride * rect.h as usize) + .map_err(|e| { + tracing::warn!("X11: shared memory capture unavailable ({e}); using GetImage") + }) + .ok() + } else { + None + }; + let using_shm = segment.is_some(); + let grabber = Grabber { + root, + rect: (rect.x as i16, rect.y as i16, rect.w as u16, rect.h as u16), + stride, + shm: segment, + }; + tracing::info!( + width = rect.w, + height = rect.h, + x = rect.x, + y = rect.y, + shm = using_shm, + "X11 capture" + ); + + { + let fmt = NegotiatedFormat { + width: rect.w, + height: rect.h, + spa_format: format.spa, + modifier: None, + color: SpaColor::default(), + }; + let mut s = shared.lock(); + s.fmt = Some(fmt); + s.fmt_version += 1; + drop(s); + shared.notify(); + } + let _ = ready_tx.send(Ok(using_shm)); + + let interval = interval_for(refresh_mhz); + let mut track = CursorTrack { + sink: cursor, + shape_id: 0, + last: None, + updates: cursor_updates, + }; + track.shape_changed(&conn); + let mut seq = 0u64; + // The first frame is grabbed at once; later ones when the rectangle is damaged. + let mut dirty = true; + let mut damage_pending = false; + let mut cursor_shape_pending = false; + let mut next_grab = Instant::now(); + let mut next_pointer = Instant::now(); + + let result = loop { + if stop.load(Ordering::Relaxed) { + break Ok(()); + } + loop { + match conn.poll_for_event() { + Ok(Some(Event::DamageNotify(_))) => damage_pending = true, + Ok(Some(Event::XfixesCursorNotify(_))) => cursor_shape_pending = true, + Ok(Some(_)) => {} + Ok(None) => break, + Err(e) => return Err(format!("the X connection failed: {e}")), + } + } + if damage_pending { + damage_pending = false; + // Subtracting re-arms the damage and returns what was damaged since the last time. + damage::subtract(&conn, damage_id, x11rb::NONE, region) + .map_err(xe("DamageSubtract"))?; + let parts = xfixes::fetch_region(&conn, region) + .map_err(xe("XFixesFetchRegion"))? + .reply() + .map_err(xe("XFixesFetchRegion"))?; + dirty |= parts.rectangles.iter().any(|r| { + rect.intersects(&Rect { + x: i32::from(r.x), + y: i32::from(r.y), + w: u32::from(r.width), + h: u32::from(r.height), + }) + }); + } + if cursor_shape_pending { + cursor_shape_pending = false; + track.shape_changed(&conn); + track.publish(&conn, root, &rect); + } + let now = Instant::now(); + if now >= next_pointer { + track.publish(&conn, root, &rect); + next_pointer = now + interval; + } + if dirty && now >= next_grab { + dirty = false; + let data = grabber.grab(&conn)?; + next_grab = Instant::now() + interval; + seq += 1; + let frame = Frame { + width: rect.w, + height: rect.h, + capture_us: now_us(), + seq, + pixels: Pixels::Shm(ShmBuf { + spa_format: format.spa, + stride: grabber.stride, + data, + }), + }; + let mut s = shared.lock(); + s.produced += 1; + if s.latest.replace(frame).is_some() { + s.coalesced += 1; + } + drop(s); + shared.notify(); + continue; + } + let mut wake = next_pointer; + if dirty { + wake = wake.min(next_grab); + } + wait_readable(&conn, wake.saturating_duration_since(Instant::now())); + }; + if let Some(seg) = &grabber.shm { + seg.detach(&conn); + } + let _ = damage::destroy(&conn, damage_id); + let _ = conn.flush(); + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn little_endian_bgr_screens_are_bgrx() { + assert_eq!( + spa_format_for(24, 32, (0xff0000, 0xff00, 0xff), false), + Ok(spa_fmt::BGRX) + ); + assert_eq!( + spa_format_for(32, 32, (0xff0000, 0xff00, 0xff), false), + Ok(spa_fmt::BGRX) + ); + assert_eq!( + spa_format_for(24, 32, (0xff0000, 0xff00, 0xff), true), + Ok(spa_fmt::XRGB) + ); + assert_eq!( + spa_format_for(24, 32, (0xff, 0xff00, 0xff0000), false), + Ok(spa_fmt::RGBX) + ); + assert_eq!( + spa_format_for(24, 32, (0xff, 0xff00, 0xff0000), true), + Ok(spa_fmt::XBGR) + ); + } + + #[test] + fn other_layouts_are_refused() { + assert!(spa_format_for(16, 16, (0xf800, 0x7e0, 0x1f), false).is_err()); + assert!(spa_format_for(24, 24, (0xff0000, 0xff00, 0xff), false).is_err()); + assert!(spa_format_for(30, 32, (0x3ff00000, 0xffc00, 0x3ff), false).is_err()); + assert!(spa_format_for(24, 32, (0xf80000, 0xfc00, 0xf8), false).is_err()); + } + + #[test] + fn strides_round_up_to_the_scanline_pad() { + assert_eq!(stride_for(1920, 32, 32), 7680); + assert_eq!(stride_for(1, 32, 32), 4); + assert_eq!(stride_for(3, 32, 64), 16); + assert_eq!(stride_for(5, 8, 32), 8); + } + + #[test] + fn the_interval_follows_the_refresh_rate() { + assert_eq!(interval_for(60_000), Duration::from_micros(16_666)); + assert_eq!(interval_for(0), interval_for(60_000)); + assert_eq!(interval_for(1_000_000), MIN_INTERVAL); + } +} diff --git a/crates/ezd-host-linux/src/x11_input.rs b/crates/ezd-host-linux/src/x11_input.rs new file mode 100644 index 0000000..8fd9b21 --- /dev/null +++ b/crates/ezd-host-linux/src/x11_input.rs @@ -0,0 +1,528 @@ +//! X11 input: the touch and pen devices are tied to the virtual monitor with the XInput2 +//! `Coordinate Transformation Matrix` property, and the absolute pointer is placed over the whole +//! X screen by the sink's own mapper. +//! +//! The uinput devices show up as XInput2 slave devices a moment after they are created; their +//! `Device Node` property (set by the libinput and evdev drivers) names the `/dev/input/eventN` +//! node, which is how a device the sink created is found. The matrix maps the device's whole +//! range onto the monitor's rectangle inside the screen; the guard puts the identity back. + +use crate::kscreen::KOutput; +use crate::uinput_sink::{DesktopInput, MappedDevices}; +use crate::x11_output::{Rect, atom, connect, monitors, snapshot, xe}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; +use x11rb::connection::Connection; +use x11rb::protocol::Event; +use x11rb::protocol::randr::{self, NotifyMask}; +use x11rb::protocol::xinput::{self, XIChangePropertyAux, XIGetPropertyItems}; +use x11rb::protocol::xproto::{self, PropMode}; +use x11rb::rust_connection::RustConnection; + +/// How long a freshly created device may take to appear in the X server. +const DEVICE_WAIT: Duration = Duration::from_secs(3); +const IDENTITY: [f32; 9] = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]; +/// Name of the pseudo output that stands for the whole screen (see [`X11Desktop::outputs`]). +const SCREEN_OUTPUT: &str = "X11-screen"; + +/// The `Coordinate Transformation Matrix` (row-major 3x3) that maps a device's full range onto +/// `monitor` inside a `screen`-sized X screen. +pub fn transform_matrix(monitor: Rect, screen: (u32, u32)) -> [f32; 9] { + let (sw, sh) = (screen.0.max(1) as f32, screen.1.max(1) as f32); + [ + monitor.w as f32 / sw, + 0.0, + monitor.x as f32 / sw, + 0.0, + monitor.h as f32 / sh, + monitor.y as f32 / sh, + 0.0, + 0.0, + 1.0, + ] +} + +fn find_devices(conn: &RustConnection, node: &str, device_node: xproto::Atom) -> Vec { + let Some(all) = xinput::xi_query_device(conn, 0u16) + .ok() + .and_then(|c| c.reply().ok()) + else { + return Vec::new(); + }; + all.infos + .iter() + .filter(|d| { + xinput::xi_get_property(conn, d.deviceid, false, device_node, 0u32, 0, 64) + .ok() + .and_then(|c| c.reply().ok()) + .is_some_and(|r| match r.items { + XIGetPropertyItems::Data8(v) => { + String::from_utf8_lossy(&v).trim_end_matches('\0') == node + } + _ => false, + }) + }) + .map(|d| d.deviceid) + .collect() +} + +/// Sets the matrix property of one XInput2 device. +pub fn set_matrix( + conn: &RustConnection, + device: u16, + matrix: [f32; 9], + property: xproto::Atom, + float: xproto::Atom, +) -> Result<(), String> { + let items = XIChangePropertyAux::Data32(matrix.iter().map(|v| v.to_bits()).collect()); + xinput::xi_change_property(conn, device, PropMode::REPLACE, property, float, 9, &items) + .map_err(xe("XIChangeProperty"))? + .check() + .map_err(xe("XIChangeProperty")) +} + +/// Reads the matrix property of one XInput2 device. +#[cfg(test)] +pub fn get_matrix( + conn: &RustConnection, + device: u16, + property: xproto::Atom, +) -> Result, String> { + let r = xinput::xi_get_property(conn, device, false, property, 0u32, 0, 9) + .map_err(xe("XIGetProperty"))? + .reply() + .map_err(xe("XIGetProperty"))?; + Ok(match r.items { + XIGetPropertyItems::Data32(v) if v.len() == 9 => { + let mut m = [0f32; 9]; + for (d, s) in m.iter_mut().zip(v) { + *d = f32::from_bits(s); + } + Some(m) + } + _ => None, + }) +} + +/// The atoms the matrix needs. +pub fn matrix_atoms(conn: &RustConnection) -> Result<(xproto::Atom, xproto::Atom), String> { + Ok(( + atom(conn, "Coordinate Transformation Matrix")?, + atom(conn, "FLOAT")?, + )) +} + +/// Announces XInput 2.0 to the server (required before any XInput2 request). +pub fn init_xinput(conn: &RustConnection) -> Result<(), String> { + xinput::xi_query_version(conn, 2, 0) + .map_err(|_| "the X server has no XInput extension".to_string())? + .reply() + .map_err(|_| "the X server has no XInput 2 extension".to_string())?; + Ok(()) +} + +/// How long the watcher waits after an event before it looks at the devices and the layout. +const DEBOUNCE: Duration = Duration::from_millis(300); + +/// The matrix for the monitor called `output`, `None` when the monitor is not there. +pub fn matrix_for( + monitors: &[crate::x11_output::MonitorRect], + output: &str, + screen: (u32, u32), +) -> Option<[f32; 9]> { + monitors + .iter() + .find(|m| m.name == output) + .map(|m| transform_matrix(m.rect, screen)) +} + +/// The devices in `found` that have not been given the matrix yet. +pub fn unmapped(found: &[u16], touched: &[u16]) -> Vec { + found + .iter() + .copied() + .filter(|d| !touched.contains(d)) + .collect() +} + +/// What the mapping watcher keeps up to date. +struct MapState { + output: String, + /// `/dev/input/eventN` of the devices to map. + nodes: Vec, + property: xproto::Atom, + float: xproto::Atom, + device_node: xproto::Atom, + matrix: Mutex<[f32; 9]>, + /// Every X device that got the matrix (so it can be restored). + touched: Mutex>, + stop: AtomicBool, +} + +impl MapState { + fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { + m.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// Gives the matrix to devices with one of our nodes that do not have it yet (the stylus of + /// a tablet appears on the tool's first proximity, long after the parent device). + fn map_new_devices(&self, conn: &RustConnection) { + let matrix = *Self::lock(&self.matrix); + let mut touched = Self::lock(&self.touched); + for node in &self.nodes { + let found = find_devices(conn, node, self.device_node); + for id in unmapped(&found, &touched) { + match set_matrix(conn, id, matrix, self.property, self.float) { + Ok(()) => { + tracing::info!(device = id, %node, "X11 transformation matrix set on a new device"); + touched.push(id); + } + Err(e) => tracing::debug!("X11 matrix on device {id}: {e}"), + } + } + } + } + + /// Recomputes the matrix from the current layout and sets it again when it changed. + fn remap(&self, conn: &RustConnection, root: xproto::Window) { + let Ok(snap) = snapshot(conn, root) else { + return; + }; + let Some(matrix) = matrix_for(&snap.monitors, &self.output, snap.screen) else { + return; + }; + { + let mut current = Self::lock(&self.matrix); + if *current == matrix { + return; + } + *current = matrix; + } + let touched = Self::lock(&self.touched).clone(); + for id in &touched { + let _ = set_matrix(conn, *id, matrix, self.property, self.float); + } + tracing::info!(devices = ?touched, screen = ?snap.screen, "X11 transformation matrix re-applied"); + } +} + +/// Watches device hierarchy and screen changes on its own connection. +fn watch(state: &MapState) { + let started = connect().and_then(|(conn, n)| { + init_xinput(&conn)?; + let root = conn.setup().roots[n].root; + xinput::xi_select_events( + &conn, + root, + &[xinput::EventMask { + deviceid: 0, + mask: vec![xinput::XIEventMask::HIERARCHY], + }], + ) + .map_err(xe("XISelectEvents"))? + .check() + .map_err(xe("XISelectEvents"))?; + randr::select_input(&conn, root, NotifyMask::SCREEN_CHANGE) + .map_err(xe("RRSelectInput"))? + .check() + .map_err(xe("RRSelectInput"))?; + conn.flush().map_err(xe("flush"))?; + Ok((conn, root)) + }); + let (conn, root) = match started { + Ok(c) => c, + Err(e) => { + tracing::warn!("X11 input watcher cannot start: {e}"); + return; + } + }; + // Whatever changed between the mapping and the subscription. + state.remap(&conn, root); + state.map_new_devices(&conn); + let (mut devices_since, mut screen_since): (Option, Option) = (None, None); + while !state.stop.load(Ordering::Relaxed) { + loop { + match conn.poll_for_event() { + Ok(Some(Event::XinputHierarchy(_))) => devices_since = Some(Instant::now()), + Ok(Some(Event::RandrScreenChangeNotify(_))) => screen_since = Some(Instant::now()), + Ok(Some(_)) => {} + Ok(None) => break, + Err(e) => { + tracing::warn!("X11 input watcher stopped: {e}"); + return; + } + } + } + if screen_since.is_some_and(|t| t.elapsed() >= DEBOUNCE) { + screen_since = None; + state.remap(&conn, root); + } + if devices_since.is_some_and(|t| t.elapsed() >= DEBOUNCE) { + devices_since = None; + state.map_new_devices(&conn); + } + std::thread::sleep(Duration::from_millis(50)); + } +} + +/// Keeps the matrix current while the mapping lives, and puts the identity back when dropped. +pub struct MapGuard { + conn: RustConnection, + state: Arc, + watcher: Option>, +} + +impl MapGuard { + fn start(conn: RustConnection, state: MapState) -> MapGuard { + let state = Arc::new(state); + let s = state.clone(); + let watcher = std::thread::Builder::new() + .name("ezd-x11-input".into()) + .spawn(move || watch(&s)) + .map_err(|e| tracing::warn!("X11: cannot start the input watcher: {e}")) + .ok(); + MapGuard { + conn, + state, + watcher, + } + } + + /// A guard over devices that are already known by id (no `Device Node` lookup). + #[cfg(test)] + pub fn for_devices(output: &str, devices: Vec) -> Result { + let (conn, _) = connect()?; + init_xinput(&conn)?; + let (property, float) = matrix_atoms(&conn)?; + let device_node = atom(&conn, "Device Node")?; + let state = MapState { + output: output.into(), + nodes: Vec::new(), + property, + float, + device_node, + matrix: Mutex::new(IDENTITY), + touched: Mutex::new(devices), + stop: AtomicBool::new(false), + }; + Ok(MapGuard::start(conn, state)) + } +} + +impl Drop for MapGuard { + fn drop(&mut self) { + self.state.stop.store(true, Ordering::Relaxed); + if let Some(t) = self.watcher.take() { + let _ = t.join(); + } + for d in MapState::lock(&self.state.touched).iter() { + // The device is gone when the sink dropped it first; nothing to restore then. + let _ = set_matrix( + &self.conn, + *d, + IDENTITY, + self.state.property, + self.state.float, + ); + } + } +} + +/// Per-device results and the guard that keeps and undoes the mapping. +type Mapping = (Vec>, Option); + +/// X11: transformation matrices for the touch and pen devices. +#[derive(Default)] +pub struct X11Desktop; + +impl X11Desktop { + fn map(&self, devices: &[(&str, &str)], output: &str) -> Result { + let (conn, screen_num) = connect()?; + init_xinput(&conn)?; + let root = conn.setup().roots[screen_num].root; + let snap = snapshot(&conn, root)?; + let matrix = matrix_for(&snap.monitors, output, snap.screen) + .ok_or_else(|| format!("the X server has no monitor called {output}"))?; + let (property, float) = matrix_atoms(&conn)?; + let device_node = atom(&conn, "Device Node")?; + + let wanted: Vec = devices + .iter() + .map(|(_, event)| format!("/dev/input/{event}")) + .collect(); + let mut found: Vec> = vec![Vec::new(); devices.len()]; + let deadline = Instant::now() + DEVICE_WAIT; + loop { + for (ids, node) in found.iter_mut().zip(&wanted) { + if ids.is_empty() { + *ids = find_devices(&conn, node, device_node); + } + } + if found.iter().all(|ids| !ids.is_empty()) || Instant::now() >= deadline { + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + + let mut mapped = Vec::new(); + let results = devices + .iter() + .zip(&found) + .map(|((label, event), ids)| { + if ids.is_empty() { + return Err(format!( + "the X server did not add /dev/input/{event} as an input device (is it \ + ignored by the server's input configuration?)" + )); + } + // A pen can be several X devices (stylus, eraser, pad): all get the matrix. + for id in ids { + set_matrix(&conn, *id, matrix, property, float)?; + mapped.push(*id); + } + tracing::info!(device = *label, devices = ?ids, %output, "X11 transformation matrix set"); + Ok(()) + }) + .collect(); + // Devices the driver adds later (a tablet's stylus) are picked up by the watcher. + let guard = (!wanted.is_empty()).then(|| { + MapGuard::start( + conn, + MapState { + output: output.into(), + nodes: wanted, + property, + float, + device_node, + matrix: Mutex::new(matrix), + touched: Mutex::new(mapped), + stop: AtomicBool::new(false), + }, + ) + }); + Ok((results, guard)) + } +} + +impl DesktopInput for X11Desktop { + fn map_devices(&self, devices: &[(&str, &str)], output: &str) -> MappedDevices { + match self.map(devices, output) { + Ok((results, guard)) => MappedDevices { + results, + guard: guard.map(|g| Box::new(g) as Box), + }, + Err(e) => MappedDevices { + results: devices.iter().map(|_| Err(e.clone())).collect(), + guard: None, + }, + } + } + + /// The monitors, plus one output covering the whole screen: the absolute pointer device is + /// spread over the entire X screen, which can be larger than the union of the monitors. + fn outputs(&self) -> Option> { + let (conn, screen_num) = connect().ok()?; + let root = conn.setup().roots[screen_num].root; + let screen = snapshot(&conn, root).ok()?.screen; + let mons = monitors(&conn, root).ok()?; + let output = |name: String, id: i64, r: Rect| KOutput { + name, + id, + enabled: true, + connected: true, + size: (r.w, r.h), + pos: (r.x, r.y), + scale: 1.0, + current_mode_id: String::new(), + modes: Vec::new(), + }; + let mut out: Vec = mons + .into_iter() + .enumerate() + .map(|(i, m)| output(m.name, i as i64 + 1, m.rect)) + .collect(); + out.push(output( + SCREEN_OUTPUT.into(), + 0, + Rect { + x: 0, + y: 0, + w: screen.0, + h: screen.1, + }, + )); + Some(out) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn r(x: i32, y: i32, w: u32, h: u32) -> Rect { + Rect { x, y, w, h } + } + + #[test] + fn the_matrix_places_the_device_on_the_monitor() { + // 1920x1080 screen plus a 1920x1080 monitor to its right. + let m = transform_matrix(r(1920, 0, 1920, 1080), (3840, 1080)); + assert_eq!(m, [0.5, 0.0, 0.5, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]); + // A monitor lower in a taller screen. + let m = transform_matrix(r(0, 1080, 1920, 1080), (1920, 2160)); + assert_eq!(m, [1.0, 0.0, 0.0, 0.0, 0.5, 0.5, 0.0, 0.0, 1.0]); + // The monitor is the whole screen: identity. + assert_eq!(transform_matrix(r(0, 0, 800, 600), (800, 600)), IDENTITY); + } + + #[test] + fn the_corners_land_on_the_monitor_corners() { + let (mon, screen) = (r(1920, 0, 1280, 800), (3200u32, 1080u32)); + let m = transform_matrix(mon, screen); + let map = |u: f32, v: f32| (m[0] * u + m[1] * v + m[2], m[3] * u + m[4] * v + m[5]); + let (x0, y0) = map(0.0, 0.0); + let (x1, y1) = map(1.0, 1.0); + assert!((x0 * screen.0 as f32 - 1920.0).abs() < 1e-2); + assert!(y0.abs() < 1e-6); + assert!((x1 * screen.0 as f32 - 3200.0).abs() < 1e-2); + assert!((y1 * screen.1 as f32 - 800.0).abs() < 1e-2); + } + + #[test] + fn only_new_devices_need_the_matrix() { + assert_eq!(unmapped(&[4, 5, 6], &[4]), vec![5, 6]); + assert!(unmapped(&[4], &[4, 9]).is_empty()); + assert!(unmapped(&[], &[4]).is_empty()); + } + + #[test] + fn the_matrix_follows_the_screen_size() { + use crate::x11_output::MonitorRect; + let mon = |name: &str, rect| MonitorRect { + name: name.into(), + primary: false, + rect, + mm: (0, 0), + }; + let mons = [ + mon("A", r(0, 0, 1920, 1080)), + mon("EZDISPLAY", r(1920, 0, 1280, 720)), + ]; + let before = matrix_for(&mons, "EZDISPLAY", (3200, 1080)).unwrap(); + let after = matrix_for(&mons, "EZDISPLAY", (3400, 1080)).unwrap(); + assert_ne!(before, after); + assert_eq!( + before, + transform_matrix(r(1920, 0, 1280, 720), (3200, 1080)) + ); + assert_eq!(matrix_for(&mons, "missing", (3200, 1080)), None); + } + + #[test] + fn a_degenerate_screen_does_not_divide_by_zero() { + let m = transform_matrix(r(0, 0, 10, 10), (0, 0)); + assert!(m.iter().all(|v| v.is_finite())); + } +} diff --git a/crates/ezd-host-linux/src/x11_output.rs b/crates/ezd-host-linux/src/x11_output.rs new file mode 100644 index 0000000..abb854c --- /dev/null +++ b/crates/ezd-host-linux/src/x11_output.rs @@ -0,0 +1,1005 @@ +//! X11: a virtual monitor on an X server, and the helpers the capture and input halves share. +//! +//! An X server cannot create outputs, so the virtual monitor is a region of the screen that no +//! real output shows: +//! +//! 1. The screen (the root window) grows to the right by the tablet's width (`set_screen_size`). +//! 2. A RandR 1.5 monitor called `EZDISPLAY` covers the new area. Desktops that follow RandR +//! monitors (panels, maximizing, fullscreen) treat it as one more display. +//! 3. The server clamps the pointer to the CRTC areas (`RRConstrainCursorHarder`), so the new area +//! would be unreachable. The clamp is skipped while any CRTC has panning enabled, so one CRTC +//! gets panning over its own rectangle: the CRTC does not move, only the constraint goes away. +//! +//! `x11_capture.rs` grabs the rectangle from the root window and `x11_input.rs` maps the tablet's +//! devices onto it. Dropping the guard removes the monitor, restores the panning and shrinks the +//! screen. Desktop daemons (kscreen, gnome-settings-daemon, xfsettingsd) reset the layout when +//! they see it change, so a watcher thread applies it again, rate limited, while the guard lives. + +use crate::portal::SourceKind; +use crate::provider::{FrameSource, OpenedOutput, OutputRequest, VirtualOutputProvider}; +use ezd_host_core::{DisplayMode, OutputInfo, SourceError}; +use serde_json::{Value, json}; +use std::collections::VecDeque; +use std::fmt::Display; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; +use x11rb::connection::Connection; +use x11rb::protocol::Event; +use x11rb::protocol::randr::{self, NotifyMask}; +use x11rb::protocol::xproto::{self, AtomEnum}; +use x11rb::rust_connection::RustConnection; + +/// Name of the RandR monitor of the virtual output. +pub const MONITOR_NAME: &str = "EZDISPLAY"; + +/// How long the watcher waits after an event before it looks at the layout, so a daemon that +/// changes it in several steps is seen once. +const DEBOUNCE: Duration = Duration::from_millis(300); +/// Some changes (a deleted monitor) may send no RandR event: the layout is also checked this often. +const RECHECK: Duration = Duration::from_secs(2); +/// The watcher applies the layout at most this many times per [`REAPPLY_WINDOW`]. +const REAPPLY_LIMIT: usize = 3; +const REAPPLY_WINDOW: Duration = Duration::from_secs(10); +/// A screen whose pixel density is unknown is assumed to be this. +const FALLBACK_DPI: f64 = 96.0; + +/// Formats an X11 error with what was being asked. +pub fn xe(what: &'static str) -> impl Fn(E) -> String { + move |e| format!("X11 {what}: {e}") +} + +/// Connects to the X server of `$DISPLAY`; returns the connection and its screen number. +pub fn connect() -> Result<(RustConnection, usize), String> { + RustConnection::connect(None).map_err(|e| { + let display = std::env::var("DISPLAY").unwrap_or_default(); + format!("cannot connect to the X server (DISPLAY={display:?}): {e}") + }) +} + +/// Interns `name`. +pub fn atom(conn: &RustConnection, name: &str) -> Result { + Ok(xproto::intern_atom(conn, false, name.as_bytes()) + .map_err(xe("InternAtom"))? + .reply() + .map_err(xe("InternAtom"))? + .atom) +} + +/// A rectangle in screen coordinates. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Rect { + pub x: i32, + pub y: i32, + pub w: u32, + pub h: u32, +} + +impl Rect { + pub fn right(&self) -> i32 { + self.x.saturating_add(self.w as i32) + } + + pub fn bottom(&self) -> i32 { + self.y.saturating_add(self.h as i32) + } + + pub fn intersects(&self, o: &Rect) -> bool { + self.x < o.right() && o.x < self.right() && self.y < o.bottom() && o.y < self.bottom() + } +} + +/// Where the virtual monitor goes and how large the screen becomes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Layout { + /// The monitor's rectangle. + pub monitor: Rect, + /// The screen size that holds it and everything that was there. + pub screen: (u32, u32), +} + +/// Places a `size` monitor to the right of everything in `areas` (enabled CRTCs and monitors), +/// at the top. The screen grows to hold it and never shrinks below `current`. +pub fn placement(areas: &[Rect], current: (u32, u32), size: (u32, u32)) -> Layout { + let x = areas.iter().map(Rect::right).max().unwrap_or(0).max(0); + Layout { + monitor: Rect { + x, + y: 0, + w: size.0, + h: size.1, + }, + screen: (x as u32 + size.0, current.1.max(size.1)), + } +} + +/// Physical size for `px` pixels at the density of the current screen (`cur_px` pixels over +/// `cur_mm` millimetres), or at 96 DPI when the screen reports none. +pub fn scaled_mm(px: (u32, u32), cur_px: (u32, u32), cur_mm: (u32, u32)) -> (u32, u32) { + let axis = |px: u32, cur_px: u32, cur_mm: u32| -> u32 { + let mm = if cur_px > 0 && cur_mm > 0 { + f64::from(px) * f64::from(cur_mm) / f64::from(cur_px) + } else { + f64::from(px) * 25.4 / FALLBACK_DPI + }; + (mm.round() as u32).max(1) + }; + ( + axis(px.0, cur_px.0, cur_mm.0), + axis(px.1, cur_px.1, cur_mm.1), + ) +} + +/// The screen size to go back to: `original`, unless an enabled CRTC now reaches past it (the +/// user added a display meanwhile), then the smallest size that holds them all. +pub fn restore_size(original: (u32, u32), crtcs: &[Rect]) -> (u32, u32) { + let w = crtcs + .iter() + .map(|r| r.right().max(0) as u32) + .max() + .unwrap_or(0); + let h = crtcs + .iter() + .map(|r| r.bottom().max(0) as u32) + .max() + .unwrap_or(0); + if w > original.0 || h > original.1 { + (w, h) + } else { + original + } +} + +/// An enabled CRTC. +#[derive(Debug, Clone, Copy)] +pub struct Crtc { + pub id: randr::Crtc, + pub rect: Rect, +} + +/// What the server shows now. +pub struct Snapshot { + /// Screen size in pixels (`GetGeometry` of the root: the setup is stale after a change). + pub screen: (u32, u32), + /// Enabled CRTCs. + pub crtcs: Vec, + /// Active RandR monitors. + pub monitors: Vec, + /// CRTC of the primary output, if it has one. + pub primary_crtc: Option, +} + +/// An active RandR monitor. +#[derive(Debug, Clone)] +pub struct MonitorRect { + pub name: String, + pub primary: bool, + pub rect: Rect, + pub mm: (u32, u32), +} + +/// Reads the RandR state of `root`. +pub fn snapshot(conn: &RustConnection, root: xproto::Window) -> Result { + let geo = xproto::get_geometry(conn, root) + .map_err(xe("GetGeometry"))? + .reply() + .map_err(xe("GetGeometry"))?; + let res = randr::get_screen_resources_current(conn, root) + .map_err(xe("RRGetScreenResourcesCurrent"))? + .reply() + .map_err(xe("RRGetScreenResourcesCurrent"))?; + let cookies: Vec<_> = res + .crtcs + .iter() + .map(|c| randr::get_crtc_info(conn, *c, res.config_timestamp).map(|k| (*c, k))) + .collect::>() + .map_err(xe("RRGetCrtcInfo"))?; + let mut crtcs = Vec::new(); + for (id, cookie) in cookies { + let info = cookie.reply().map_err(xe("RRGetCrtcInfo"))?; + if info.mode != 0 && info.width > 0 && info.height > 0 { + crtcs.push(Crtc { + id, + rect: Rect { + x: i32::from(info.x), + y: i32::from(info.y), + w: u32::from(info.width), + h: u32::from(info.height), + }, + }); + } + } + let monitors = monitors(conn, root)?; + let primary = randr::get_output_primary(conn, root) + .map_err(xe("RRGetOutputPrimary"))? + .reply() + .map_err(xe("RRGetOutputPrimary"))? + .output; + let primary_crtc = if primary == 0 { + None + } else { + let crtc = randr::get_output_info(conn, primary, res.config_timestamp) + .map_err(xe("RRGetOutputInfo"))? + .reply() + .map_err(xe("RRGetOutputInfo"))? + .crtc; + (crtc != 0).then_some(crtc) + }; + Ok(Snapshot { + screen: (u32::from(geo.width), u32::from(geo.height)), + crtcs, + monitors, + primary_crtc, + }) +} + +/// The active RandR 1.5 monitors. +pub fn monitors(conn: &RustConnection, root: xproto::Window) -> Result, String> { + let reply = randr::get_monitors(conn, root, true) + .map_err(xe("RRGetMonitors"))? + .reply() + .map_err(xe("RRGetMonitors"))?; + let mut out = Vec::new(); + for m in reply.monitors { + let name = xproto::get_atom_name(conn, m.name) + .map_err(xe("GetAtomName"))? + .reply() + .map_err(xe("GetAtomName"))? + .name; + out.push(MonitorRect { + name: String::from_utf8_lossy(&name).into_owned(), + primary: m.primary, + rect: Rect { + x: i32::from(m.x), + y: i32::from(m.y), + w: u32::from(m.width), + h: u32::from(m.height), + }, + mm: (m.width_in_millimeters, m.height_in_millimeters), + }); + } + Ok(out) +} + +/// RandR version of the server, `Err` below 1.5 (monitors). +pub fn require_randr_15(conn: &RustConnection) -> Result<(u32, u32), String> { + let v = randr::query_version(conn, 1, 5) + .map_err(|_| "the X server has no RandR extension".to_string())? + .reply() + .map_err(|_| "the X server has no RandR extension".to_string())?; + let version = (v.major_version, v.minor_version); + if version < (1, 5) { + return Err(format!( + "the X server speaks RandR {}.{}; 1.5 (monitors) is required", + version.0, version.1 + )); + } + Ok(version) +} + +/// What the layout needs to be applied again. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Plan { + layout: Layout, + /// Screen size in millimetres. + screen_mm: (u32, u32), + /// Monitor size in millimetres. + monitor_mm: (u32, u32), +} + +/// The CRTC that carries the panning, and the panning it had before. +#[derive(Debug, Clone, Copy)] +struct SavedPanning { + crtc: randr::Crtc, + previous: randr::GetPanningReply, +} + +fn panning_enabled(conn: &RustConnection, crtc: randr::Crtc) -> Result { + Ok(randr::get_panning(conn, crtc) + .map_err(xe("RRGetPanning"))? + .reply() + .map_err(xe("RRGetPanning"))? + .width + > 0) +} + +/// The CRTC's panning area, `None` when panning is off. +fn panning_area(conn: &RustConnection, crtc: randr::Crtc) -> Result, String> { + let r = randr::get_panning(conn, crtc) + .map_err(xe("RRGetPanning"))? + .reply() + .map_err(xe("RRGetPanning"))?; + Ok((r.width > 0).then(|| Rect { + x: i32::from(r.left), + y: i32::from(r.top), + w: u32::from(r.width), + h: u32::from(r.height), + })) +} + +fn set_panning_to( + conn: &RustConnection, + crtc: randr::Crtc, + area: Rect, + track: (u16, u16, u16, u16), + border: (i16, i16, i16, i16), +) -> Result<(), String> { + let r = randr::set_panning( + conn, + crtc, + x11rb::CURRENT_TIME, + area.x.max(0) as u16, + area.y.max(0) as u16, + area.w as u16, + area.h as u16, + track.0, + track.1, + track.2, + track.3, + border.0, + border.1, + border.2, + border.3, + ) + .map_err(xe("RRSetPanning"))? + .reply() + .map_err(xe("RRSetPanning"))?; + if r.status != randr::SetConfig::SUCCESS { + return Err(format!("X11 RRSetPanning: status {:?}", r.status)); + } + Ok(()) +} + +/// Makes sure some CRTC has panning over its own rectangle, so the server does not clamp the +/// pointer to the CRTC areas. +fn ensure_panning( + conn: &RustConnection, + snap: &Snapshot, + saved: &mut Option, +) -> Result<(), String> { + if let Some(p) = *saved + && let Some(c) = snap.crtcs.iter().find(|c| c.id == p.crtc) + { + // Also when the CRTC moved: a stale panning area would pull it back to where it was. + if panning_area(conn, p.crtc)? != Some(c.rect) { + set_panning_to(conn, p.crtc, c.rect, (0, 0, 0, 0), (0, 0, 0, 0))?; + tracing::info!("X11 panning applied again"); + } + return Ok(()); + } + for c in &snap.crtcs { + if panning_enabled(conn, c.id)? { + // Someone else's panning already lifts the pointer constraint. + return Ok(()); + } + } + let Some(c) = snap + .primary_crtc + .and_then(|p| snap.crtcs.iter().find(|c| c.id == p)) + .or(snap.crtcs.first()) + else { + tracing::warn!( + "X11: no enabled CRTC to carry the panning; the pointer may not reach the monitor" + ); + return Ok(()); + }; + let previous = randr::get_panning(conn, c.id) + .map_err(xe("RRGetPanning"))? + .reply() + .map_err(xe("RRGetPanning"))?; + set_panning_to(conn, c.id, c.rect, (0, 0, 0, 0), (0, 0, 0, 0))?; + tracing::info!( + crtc = c.id, + "X11 panning enabled to lift the pointer constraint" + ); + *saved = Some(SavedPanning { + crtc: c.id, + previous, + }); + Ok(()) +} + +fn monitor_present( + conn: &RustConnection, + root: xproto::Window, + plan: &Plan, +) -> Result { + Ok(monitors(conn, root)? + .iter() + .any(|m| m.name == MONITOR_NAME && m.rect == plan.layout.monitor)) +} + +/// Brings the server to `plan`: screen size, panning, monitor. Idempotent. +fn apply( + conn: &RustConnection, + root: xproto::Window, + plan: &Plan, + saved: &mut Option, +) -> Result<(), String> { + xproto::grab_server(conn).map_err(xe("GrabServer"))?; + let r = apply_grabbed(conn, root, plan, saved); + let _ = xproto::ungrab_server(conn); + let _ = conn.flush(); + r +} + +fn apply_grabbed( + conn: &RustConnection, + root: xproto::Window, + plan: &Plan, + saved: &mut Option, +) -> Result<(), String> { + let snap = snapshot(conn, root)?; + if snap.screen.0 < plan.layout.screen.0 || snap.screen.1 < plan.layout.screen.1 { + let size = ( + snap.screen.0.max(plan.layout.screen.0), + snap.screen.1.max(plan.layout.screen.1), + ); + randr::set_screen_size( + conn, + root, + size.0 as u16, + size.1 as u16, + plan.screen_mm.0, + plan.screen_mm.1, + ) + .map_err(xe("RRSetScreenSize"))? + .check() + .map_err(xe("RRSetScreenSize"))?; + } + let snap = snapshot(conn, root)?; + ensure_panning(conn, &snap, saved)?; + if !monitor_present(conn, root, plan)? { + let name = atom(conn, MONITOR_NAME)?; + let m = plan.layout.monitor; + randr::set_monitor( + conn, + root, + randr::MonitorInfo { + name, + primary: false, + automatic: false, + x: m.x as i16, + y: m.y as i16, + width: m.w as u16, + height: m.h as u16, + width_in_millimeters: plan.monitor_mm.0, + height_in_millimeters: plan.monitor_mm.1, + outputs: Vec::new(), + }, + ) + .map_err(xe("RRSetMonitor"))? + .check() + .map_err(xe("RRSetMonitor"))?; + } + Ok(()) +} + +/// True if the server no longer matches `plan` (a desktop daemon reset the layout). +fn drifted( + conn: &RustConnection, + root: xproto::Window, + plan: &Plan, + saved: &Option, +) -> Result { + let snap = snapshot(conn, root)?; + if snap.screen.0 < plan.layout.screen.0 || snap.screen.1 < plan.layout.screen.1 { + return Ok(true); + } + if !snap + .monitors + .iter() + .any(|m| m.name == MONITOR_NAME && m.rect == plan.layout.monitor) + { + return Ok(true); + } + if let Some(p) = saved { + let Some(c) = snap.crtcs.iter().find(|c| c.id == p.crtc) else { + return Ok(true); + }; + return Ok(panning_area(conn, p.crtc)? != Some(c.rect)); + } + Ok(false) +} + +/// State shared by the guard and its watcher. +struct Shared { + plan: Plan, + saved: Mutex>, + stop: AtomicBool, +} + +/// Keeps the virtual monitor: applies the layout again when a desktop daemon resets it, and +/// undoes everything when dropped. +pub struct MonitorGuard { + conn: RustConnection, + root: xproto::Window, + original: (u32, u32), + original_mm: (u32, u32), + shared: Arc, + watcher: Option>, +} + +impl Drop for MonitorGuard { + fn drop(&mut self) { + self.shared.stop.store(true, Ordering::Relaxed); + if let Some(t) = self.watcher.take() { + let _ = t.join(); + } + if let Err(e) = self.restore() { + tracing::warn!("X11: restoring the screen failed: {e}"); + } + } +} + +impl MonitorGuard { + fn restore(&self) -> Result<(), String> { + xproto::grab_server(&self.conn).map_err(xe("GrabServer"))?; + let r = self.restore_grabbed(); + let _ = xproto::ungrab_server(&self.conn); + let _ = self.conn.flush(); + r + } + + fn restore_grabbed(&self) -> Result<(), String> { + let conn = &self.conn; + let root = self.root; + let mut result = Ok(()); + let mut keep = |r: Result<(), String>| { + if let Err(e) = r { + tracing::warn!("X11 restore: {e}"); + if result.is_ok() { + result = Err(e); + } + } + }; + keep(atom(conn, MONITOR_NAME).and_then(|a| { + randr::delete_monitor(conn, root, a) + .map_err(xe("RRDeleteMonitor"))? + .check() + .map_err(xe("RRDeleteMonitor")) + })); + let saved = self + .shared + .saved + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take(); + if let Some(p) = saved { + let q = p.previous; + let area = Rect { + x: i32::from(q.left), + y: i32::from(q.top), + w: u32::from(q.width), + h: u32::from(q.height), + }; + keep(set_panning_to( + conn, + p.crtc, + area, + (q.track_left, q.track_top, q.track_width, q.track_height), + (q.border_left, q.border_top, q.border_right, q.border_bottom), + )); + } + keep(snapshot(conn, root).and_then(|snap| { + let rects: Vec = snap.crtcs.iter().map(|c| c.rect).collect(); + let size = restore_size(self.original, &rects); + if size == snap.screen { + return Ok(()); + } + let mm = if size == self.original { + self.original_mm + } else { + scaled_mm(size, self.original, self.original_mm) + }; + randr::set_screen_size(conn, root, size.0 as u16, size.1 as u16, mm.0, mm.1) + .map_err(xe("RRSetScreenSize"))? + .check() + .map_err(xe("RRSetScreenSize"))?; + tracing::info!(width = size.0, height = size.1, "X11 screen size restored"); + Ok(()) + })); + result + } +} + +/// The watcher: applies the layout again after a desktop daemon changed it. +fn watch(shared: &Shared, root: xproto::Window) { + let (conn, _) = match connect() { + Ok(c) => c, + Err(e) => { + tracing::warn!("X11 layout watcher cannot start: {e}"); + return; + } + }; + let selected = randr::select_input( + &conn, + root, + NotifyMask::SCREEN_CHANGE | NotifyMask::CRTC_CHANGE, + ) + .map_err(xe("RRSelectInput")) + .and_then(|c| c.check().map_err(xe("RRSelectInput"))) + .and_then(|()| conn.flush().map_err(xe("flush"))); + if let Err(e) = selected { + tracing::warn!("X11 layout watcher cannot start: {e}"); + return; + } + let mut recent: VecDeque = VecDeque::new(); + let mut dirty_since: Option = None; + let mut warned_limit = false; + let mut last_check = Instant::now(); + while !shared.stop.load(Ordering::Relaxed) { + loop { + match conn.poll_for_event() { + Ok(Some(Event::RandrScreenChangeNotify(_) | Event::RandrNotify(_))) => { + dirty_since = Some(Instant::now()); + } + Ok(Some(_)) => {} + Ok(None) => break, + Err(e) => { + tracing::warn!("X11 layout watcher stopped: {e}"); + return; + } + } + } + if dirty_since.is_none() && last_check.elapsed() >= RECHECK { + dirty_since = Some(Instant::now() - DEBOUNCE); + } + if dirty_since.is_some_and(|t| t.elapsed() >= DEBOUNCE) { + last_check = Instant::now(); + let mut saved = shared.saved.lock().unwrap_or_else(|e| e.into_inner()); + match drifted(&conn, root, &shared.plan, &saved) { + Ok(false) => { + dirty_since = None; + warned_limit = false; + } + Ok(true) => { + let now = Instant::now(); + while recent.front().is_some_and(|t| now - *t > REAPPLY_WINDOW) { + recent.pop_front(); + } + if recent.len() >= REAPPLY_LIMIT { + if !std::mem::replace(&mut warned_limit, true) { + tracing::warn!( + "X11: a desktop daemon keeps resetting the virtual monitor; \ + applying it again later" + ); + } + // Look again after the window has moved on. + dirty_since = Some(now + REAPPLY_WINDOW / 2 - DEBOUNCE); + } else { + recent.push_back(now); + tracing::warn!("X11: the virtual monitor was reset; applying it again"); + match apply(&conn, root, &shared.plan, &mut saved) { + Ok(()) => dirty_since = Some(Instant::now()), + Err(e) => { + tracing::warn!( + "X11: applying the virtual monitor again failed: {e}" + ); + dirty_since = Some(Instant::now()); + } + } + } + } + Err(e) => { + tracing::debug!("X11 layout check failed: {e}"); + dirty_since = Some(Instant::now()); + } + } + } + std::thread::sleep(Duration::from_millis(50)); + } +} + +/// Creates the virtual monitor on the X server of `$DISPLAY`. +pub struct X11Provider; + +impl VirtualOutputProvider for X11Provider { + fn name(&self) -> &'static str { + "x11" + } + + fn open(&mut self, req: &OutputRequest) -> Result { + open(req).map_err(SourceError::msg) + } +} + +fn mode_of(req: &OutputRequest, rect: Rect) -> DisplayMode { + DisplayMode { + width: rect.w, + height: rect.h, + refresh_mhz: req.refresh_mhz, + } +} + +fn open(req: &OutputRequest) -> Result { + let (conn, screen_num) = connect()?; + let screen = &conn.setup().roots[screen_num]; + let root = screen.root; + let original = ( + u32::from(screen.width_in_pixels), + u32::from(screen.height_in_pixels), + ); + let original_mm = ( + u32::from(screen.width_in_millimeters), + u32::from(screen.height_in_millimeters), + ); + require_randr_15(&conn)?; + + if req.kind == SourceKind::Monitor { + let name = req.output_name.clone().ok_or("no output name to capture")?; + let mons = monitors(&conn, root)?; + let m = mons.iter().find(|m| m.name == name).ok_or_else(|| { + format!( + "the X server has no monitor called {name} (monitors: {})", + mons.iter() + .map(|m| m.name.as_str()) + .collect::>() + .join(", ") + ) + })?; + return Ok(OpenedOutput { + frames: FrameSource::X11 { + rect: (m.rect.x, m.rect.y, m.rect.w, m.rect.h), + }, + info: Some(OutputInfo { + name, + mode: mode_of(req, m.rect), + origin: (m.rect.x, m.rect.y), + adapter: None, + }), + guard: Box::new(()), + }); + } + + let snap = snapshot(&conn, root)?; + // A monitor left behind by an earlier run must not push the new one further right. + let areas: Vec = snap + .crtcs + .iter() + .map(|c| c.rect) + .chain( + snap.monitors + .iter() + .filter(|m| m.name != MONITOR_NAME) + .map(|m| m.rect), + ) + .collect(); + let layout = placement(&areas, snap.screen, (req.width, req.height)); + let range = randr::get_screen_size_range(&conn, root) + .map_err(xe("RRGetScreenSizeRange"))? + .reply() + .map_err(xe("RRGetScreenSizeRange"))?; + if layout.screen.0 > u32::from(range.max_width) || layout.screen.1 > u32::from(range.max_height) + { + return Err(format!( + "the X screen would have to be {}x{} to hold the {}x{} monitor, but the server allows \ + at most {}x{} (Xorg `Virtual` in the Screen section raises it)", + layout.screen.0, + layout.screen.1, + req.width, + req.height, + range.max_width, + range.max_height + )); + } + if layout.monitor.x > i32::from(i16::MAX) + || req.width > u32::from(u16::MAX) + || req.height > u32::from(u16::MAX) + { + return Err("the virtual monitor does not fit the X11 coordinate range".into()); + } + let plan = Plan { + layout, + screen_mm: scaled_mm(layout.screen, original, original_mm), + monitor_mm: scaled_mm((req.width, req.height), original, original_mm), + }; + let shared = Arc::new(Shared { + plan, + saved: Mutex::new(None), + stop: AtomicBool::new(false), + }); + // From here on the guard undoes whatever was applied, also on an early return. + let mut guard = MonitorGuard { + conn, + root, + original, + original_mm, + shared: shared.clone(), + watcher: None, + }; + { + let mut saved = shared.saved.lock().unwrap_or_else(|e| e.into_inner()); + apply(&guard.conn, root, &plan, &mut saved)?; + } + let m = layout.monitor; + tracing::info!( + x = m.x, + y = m.y, + width = m.w, + height = m.h, + screen = ?layout.screen, + "X11 virtual monitor {MONITOR_NAME} created" + ); + let watcher_shared = shared.clone(); + guard.watcher = std::thread::Builder::new() + .name("ezd-x11-layout".into()) + .spawn(move || watch(&watcher_shared, root)) + .map_err(|e| tracing::warn!("X11: cannot start the layout watcher: {e}")) + .ok(); + Ok(OpenedOutput { + frames: FrameSource::X11 { + rect: (m.x, m.y, m.w, m.h), + }, + info: Some(OutputInfo { + name: MONITOR_NAME.into(), + mode: mode_of(req, m), + origin: (m.x, m.y), + adapter: None, + }), + guard: Box::new(guard), + }) +} + +/// Value of a window property as text (first item). +fn text_property( + conn: &RustConnection, + window: xproto::Window, + property: xproto::Atom, +) -> Option { + let r = xproto::get_property(conn, false, window, property, AtomEnum::ANY, 0, 256) + .ok()? + .reply() + .ok()?; + (r.format == 8 && !r.value.is_empty()).then(|| { + String::from_utf8_lossy(&r.value) + .trim_end_matches('\0') + .to_string() + }) +} + +/// The window manager's name (`_NET_SUPPORTING_WM_CHECK` -> `_NET_WM_NAME`). +fn window_manager(conn: &RustConnection, root: xproto::Window) -> Option { + let check = atom(conn, "_NET_SUPPORTING_WM_CHECK").ok()?; + let r = xproto::get_property(conn, false, root, check, AtomEnum::WINDOW, 0, 1) + .ok()? + .reply() + .ok()?; + let win = r.value32()?.next()?; + text_property(conn, win, atom(conn, "_NET_WM_NAME").ok()?) +} + +/// The X11 part of `ezd-host diag`. +pub fn diag() -> Value { + let probe = || -> Result { + let (conn, screen_num) = connect()?; + let setup = conn.setup(); + let root = setup.roots[screen_num].root; + let randr = require_randr_15(&conn); + let versions = json!({ + "randr": randr.as_ref().ok().map(|v| format!("{}.{}", v.0, v.1)), + "randr_error": randr.as_ref().err(), + "shm": x11rb::protocol::shm::query_version(&conn) + .ok() + .and_then(|c| c.reply().ok()) + .map(|v| format!("{}.{}", v.major_version, v.minor_version)), + "xfixes": x11rb::protocol::xfixes::query_version(&conn, 5, 0) + .ok() + .and_then(|c| c.reply().ok()) + .map(|v| format!("{}.{}", v.major_version, v.minor_version)), + "damage": x11rb::protocol::damage::query_version(&conn, 1, 1) + .ok() + .and_then(|c| c.reply().ok()) + .map(|v| format!("{}.{}", v.major_version, v.minor_version)), + "xinput": x11rb::protocol::xinput::xi_query_version(&conn, 2, 2) + .ok() + .and_then(|c| c.reply().ok()) + .map(|v| format!("{}.{}", v.major_version, v.minor_version)), + }); + let mons = monitors(&conn, root).unwrap_or_default(); + let cm = atom(&conn, &format!("_NET_WM_CM_S{screen_num}")) + .ok() + .and_then(|a| xproto::get_selection_owner(&conn, a).ok()) + .and_then(|c| c.reply().ok()) + .map(|r| r.owner != 0); + Ok(json!({ + "display": std::env::var("DISPLAY").ok(), + "vendor": String::from_utf8_lossy(&setup.vendor), + "release": setup.release_number, + "extensions": versions, + "screen": [setup.roots[screen_num].width_in_pixels, setup.roots[screen_num].height_in_pixels], + "monitors": mons.iter().map(|m| json!({ + "name": m.name, + "primary": m.primary, + "size": [m.rect.w, m.rect.h], + "position": [m.rect.x, m.rect.y], + "mm": [m.mm.0, m.mm.1], + })).collect::>(), + "window_manager": window_manager(&conn, root), + "compositor_running": cm, + })) + }; + probe().unwrap_or_else(|e| json!({ "error": e })) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn r(x: i32, y: i32, w: u32, h: u32) -> Rect { + Rect { x, y, w, h } + } + + #[test] + fn the_monitor_goes_right_of_everything() { + let l = placement(&[r(0, 0, 1920, 1080)], (1920, 1080), (2560, 1600)); + assert_eq!(l.monitor, r(1920, 0, 2560, 1600)); + assert_eq!(l.screen, (4480, 1600)); + // The screen never shrinks below its current height. + let l = placement(&[r(0, 0, 1920, 1080)], (1920, 2000), (1280, 800)); + assert_eq!(l.screen, (3200, 2000)); + } + + #[test] + fn the_rightmost_area_decides() { + let areas = [ + r(0, 0, 1920, 1080), + r(1920, 200, 1280, 1024), + r(-1000, 0, 1000, 800), + ]; + let l = placement(&areas, (3200, 1224), (1000, 1000)); + assert_eq!(l.monitor.x, 3200); + assert_eq!(l.screen, (4200, 1224)); + assert_eq!(placement(&[], (0, 0), (800, 600)).monitor.x, 0); + // Only areas left of the origin: still starts at 0. + assert_eq!( + placement(&[r(-500, 0, 400, 300)], (100, 100), (10, 10)) + .monitor + .x, + 0 + ); + } + + #[test] + fn millimetres_keep_the_density() { + // 1920x1080 on 508x286 mm: a 3840-wide screen is twice as wide. + assert_eq!( + scaled_mm((3840, 1080), (1920, 1080), (508, 286)), + (1016, 286) + ); + // The monitor takes the same density. + assert_eq!( + scaled_mm((1920, 1080), (1920, 1080), (508, 286)), + (508, 286) + ); + // No physical size known: 96 DPI. + assert_eq!(scaled_mm((960, 96), (1920, 1080), (0, 0)), (254, 25)); + assert_eq!(scaled_mm((0, 0), (1920, 1080), (508, 286)), (1, 1)); + } + + #[test] + fn the_original_size_comes_back() { + let original = (1920, 1080); + assert_eq!(restore_size(original, &[r(0, 0, 1920, 1080)]), original); + assert_eq!(restore_size(original, &[]), original); + // A display added meanwhile keeps the screen large enough for it. + assert_eq!( + restore_size(original, &[r(0, 0, 1920, 1080), r(1920, 0, 1280, 1024)]), + (3200, 1080) + ); + assert_eq!( + restore_size(original, &[r(0, 0, 1920, 1080), r(0, 1080, 1280, 1024)]), + (1920, 2104) + ); + } + + #[test] + fn rects_intersect_when_they_overlap() { + let a = r(0, 0, 100, 100); + assert!(a.intersects(&r(99, 99, 10, 10))); + assert!(!a.intersects(&r(100, 0, 10, 10))); + assert!(!a.intersects(&r(0, 100, 10, 10))); + assert!(a.intersects(&r(-5, -5, 10, 10))); + } +} diff --git a/crates/ezd-host-linux/src/x11_test.rs b/crates/ezd-host-linux/src/x11_test.rs new file mode 100644 index 0000000..7cf4aed --- /dev/null +++ b/crates/ezd-host-linux/src/x11_test.rs @@ -0,0 +1,457 @@ +//! Env-gated tests against a real X server (`tools/x11-dummy-test.sh` starts one with the dummy +//! video driver, whose xf86 modes reproduce the pointer constraint that Xvfb lacks). + +use crate::frame::{Pixels, spa_fmt}; +use crate::portal::SourceKind; +use crate::provider::{FrameSource, OutputRequest, VirtualOutputProvider}; +use crate::uinput_sink::DesktopInput; +use crate::x11_capture::X11Capture; +use crate::x11_input::{ + X11Desktop, get_matrix, init_xinput, matrix_atoms, set_matrix, transform_matrix, +}; +use crate::x11_output::{MONITOR_NAME, Rect, X11Provider, atom, connect, monitors, snapshot}; +use ezd_host_core::CursorSink; +use std::time::{Duration, Instant}; +use x11rb::connection::Connection; +use x11rb::protocol::randr; +use x11rb::protocol::xinput; +use x11rb::protocol::xproto::{self, ConnectionExt as _, CreateWindowAux, WindowClass}; +use x11rb::protocol::xtest; +use x11rb::rust_connection::RustConnection; + +/// True when the test runs against the X server of the script. +fn enabled() -> bool { + if std::env::var_os("EZD_X11_TEST").is_none_or(|v| v != "1") { + eprintln!("skipped: set EZD_X11_TEST=1 and DISPLAY (tools/x11-dummy-test.sh does)"); + return false; + } + let _ = tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .with_test_writer() + .try_init(); + true +} + +fn sync(conn: &RustConnection) { + conn.get_input_focus().unwrap().reply().unwrap(); +} + +/// Maps an override-redirect window of `rect` filled with `pixel`. +fn paint(conn: &RustConnection, root: u32, rect: Rect, pixel: u32) -> u32 { + let win = conn.generate_id().unwrap(); + conn.create_window( + x11rb::COPY_DEPTH_FROM_PARENT, + win, + root, + rect.x as i16, + rect.y as i16, + rect.w as u16, + rect.h as u16, + 0, + WindowClass::INPUT_OUTPUT, + x11rb::COPY_FROM_PARENT, + &CreateWindowAux::new() + .background_pixel(pixel) + .override_redirect(1), + ) + .unwrap(); + conn.map_window(win).unwrap(); + sync(conn); + win +} + +fn repaint(conn: &RustConnection, win: u32, pixel: u32) { + conn.change_window_attributes( + win, + &xproto::ChangeWindowAttributesAux::new().background_pixel(pixel), + ) + .unwrap(); + conn.clear_area(false, win, 0, 0, 0, 0).unwrap(); + sync(conn); +} + +/// Waits for a frame whose pixel at (`x`, `y`) is `pixel` (`0xRRGGBB`); returns the frame. +fn frame_with_color(cap: &X11Capture, x: usize, y: usize, pixel: u32) -> crate::frame::Frame { + let want = [pixel as u8, (pixel >> 8) as u8, (pixel >> 16) as u8]; + let deadline = Instant::now() + Duration::from_secs(5); + let mut last = None; + while Instant::now() < deadline { + if let Some(f) = cap.next(Duration::from_millis(200)).unwrap() { + let Pixels::Shm(s) = &f.pixels else { + panic!("not a shared-memory frame") + }; + let at = y * s.stride + x * 4; + last = Some(s.data[at..at + 3].to_vec()); + if s.data[at..at + 3] == want { + return f; + } + } + } + panic!("no frame with colour {pixel:#08x} at ({x},{y}); last pixel {last:?}"); +} + +fn warp(conn: &RustConnection, root: u32, x: i16, y: i16) -> (i16, i16) { + xtest::fake_input(conn, xproto::MOTION_NOTIFY_EVENT, 0, 0, root, x, y, 0).unwrap(); + sync(conn); + let p = conn.query_pointer(root).unwrap().reply().unwrap(); + (p.root_x, p.root_y) +} + +fn panning_of(conn: &RustConnection, root: u32) -> Vec<(u32, u16)> { + let snap = snapshot(conn, root).unwrap(); + snap.crtcs + .iter() + .map(|c| { + let p = randr::get_panning(conn, c.id).unwrap().reply().unwrap(); + (c.id, p.width) + }) + .collect() +} + +fn request(width: u32, height: u32) -> OutputRequest { + OutputRequest { + kind: SourceKind::Virtual, + output_name: None, + width, + height, + refresh_mhz: 60_000, + } +} + +#[test] +fn x11_virtual_monitor_capture_input_and_restore() { + if !enabled() { + return; + } + let (conn, n) = connect().unwrap(); + let root = conn.setup().roots[n].root; + let before = snapshot(&conn, root).unwrap(); + let panning_before = panning_of(&conn, root); + let monitors_before = monitors(&conn, root).unwrap().len(); + + let opened = X11Provider.open(&request(1280, 720)).unwrap(); + let FrameSource::X11 { rect } = opened.frames else { + panic!("not an X11 frame source") + }; + let monitor = Rect { + x: rect.0, + y: rect.1, + w: rect.2, + h: rect.3, + }; + // To the right of everything that was there, same height as asked. + let right = before.crtcs.iter().map(|c| c.rect.right()).max().unwrap(); + assert_eq!( + monitor, + Rect { + x: right, + y: 0, + w: 1280, + h: 720 + } + ); + let info = opened.info.as_ref().unwrap(); + assert_eq!(info.name, MONITOR_NAME); + assert_eq!((info.mode.width, info.mode.height), (1280, 720)); + assert_eq!(info.origin, (monitor.x, 0)); + + let after = snapshot(&conn, root).unwrap(); + assert_eq!(after.screen.0, right as u32 + 1280); + assert!(after.screen.1 >= 720.max(before.screen.1)); + let listed = monitors(&conn, root).unwrap(); + assert_eq!(listed.len(), monitors_before + 1); + let ours = listed.iter().find(|m| m.name == MONITOR_NAME).unwrap(); + assert_eq!(ours.rect, monitor); + + // The pointer reaches the new area (the server would clamp it to the CRTCs otherwise). + let (tx, ty) = (monitor.x + 1000, 500); + assert_eq!( + warp(&conn, root, tx as i16, ty as i16), + (tx as i16, ty as i16) + ); + + // Paint the new area and capture it. + let win = paint(&conn, root, monitor, 0x00ff_8040); + let mut cap = X11Capture::start(rect, 60_000, CursorSink::null()).unwrap(); + assert!(cap.uses_shm(), "MIT-SHM capture should be available"); + let fmt = cap + .wait_format((1280, 720), Duration::from_secs(2)) + .expect("format"); + assert_eq!( + (fmt.width, fmt.height, fmt.spa_format), + (1280, 720, spa_fmt::BGRX) + ); + let frame = frame_with_color(&cap, 10, 10, 0x00ff_8040); + assert_eq!((frame.width, frame.height), (1280, 720)); + let Pixels::Shm(s) = &frame.pixels else { + unreachable!() + }; + assert_eq!(s.stride, 1280 * 4); + assert!(s.data.len() >= s.stride * 719 + 1280 * 4); + // Damage paces the capture: a change of colour produces a new frame. + repaint(&conn, win, 0x0010_a0e0); + frame_with_color(&cap, 1270, 710, 0x0010_a0e0); + // The pointer is inside the area: the cursor tracker saw it. + warp(&conn, root, (monitor.x + 100) as i16, 100); + let deadline = Instant::now() + Duration::from_secs(2); + while cap.cursor_updates() == 0 && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(20)); + } + assert!(cap.cursor_updates() > 0, "no cursor update"); + let (produced, _) = cap.counters(); + assert!(produced >= 2, "produced {produced}"); + cap.stop(); + + // A desktop daemon deleting the monitor is undone by the watcher. + let name = atom(&conn, MONITOR_NAME).unwrap(); + randr::delete_monitor(&conn, root, name) + .unwrap() + .check() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(6); + loop { + if monitors(&conn, root) + .unwrap() + .iter() + .any(|m| m.name == MONITOR_NAME) + { + break; + } + assert!( + Instant::now() < deadline, + "the monitor was not applied again" + ); + std::thread::sleep(Duration::from_millis(100)); + } + // The same for what a daemon that resets the layout does: no monitor, no panning, the + // original screen size. + randr::delete_monitor(&conn, root, name) + .unwrap() + .check() + .unwrap(); + for c in &before.crtcs { + randr::set_panning(&conn, c.id, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) + .unwrap() + .reply() + .unwrap(); + } + let (w, h) = before.screen; + randr::set_screen_size(&conn, root, w as u16, h as u16, 508, 286) + .unwrap() + .check() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(8); + loop { + let now = snapshot(&conn, root).unwrap(); + let present = monitors(&conn, root) + .unwrap() + .iter() + .any(|m| m.name == MONITOR_NAME); + let panning = panning_of(&conn, root).iter().any(|(_, w)| *w > 0); + if present && panning && now.screen.0 == right as u32 + 1280 { + break; + } + assert!( + Instant::now() < deadline, + "the layout was not applied again" + ); + std::thread::sleep(Duration::from_millis(100)); + } + conn.destroy_window(win).unwrap(); + sync(&conn); + + drop(opened.guard); + let end = snapshot(&conn, root).unwrap(); + assert_eq!(end.screen, before.screen, "screen size restored"); + let listed = monitors(&conn, root).unwrap(); + assert!( + listed.iter().all(|m| m.name != MONITOR_NAME), + "monitor removed" + ); + assert_eq!(listed.len(), monitors_before); + assert_eq!(panning_of(&conn, root), panning_before, "panning restored"); +} + +#[test] +fn x11_capture_without_shared_memory() { + if !enabled() { + return; + } + let (conn, n) = connect().unwrap(); + let root = conn.setup().roots[n].root; + let rect = Rect { + x: 0, + y: 0, + w: 321, + h: 201, + }; + let win = paint(&conn, root, rect, 0x0020_4060); + // An odd size, through plain GetImage. + let cap = X11Capture::start_with((0, 0, 321, 201), 30_000, CursorSink::null(), false).unwrap(); + assert!(!cap.uses_shm()); + let frame = frame_with_color(&cap, 5, 5, 0x0020_4060); + assert_eq!((frame.width, frame.height), (321, 201)); + conn.destroy_window(win).unwrap(); + sync(&conn); +} + +#[test] +fn x11_matrix_property_round_trip() { + if !enabled() { + return; + } + let (conn, _) = connect().unwrap(); + init_xinput(&conn).unwrap(); + let (property, float) = matrix_atoms(&conn).unwrap(); + // Any slave pointer that carries the property: the XTEST pointer does. + let all = xinput::xi_query_device(&conn, 0u16) + .unwrap() + .reply() + .unwrap(); + let dev = all + .infos + .iter() + .find(|d| String::from_utf8_lossy(&d.name).contains("XTEST pointer")) + .expect("XTEST pointer"); + let m = transform_matrix( + Rect { + x: 1920, + y: 0, + w: 1920, + h: 1080, + }, + (3840, 1080), + ); + set_matrix(&conn, dev.deviceid, m, property, float).unwrap(); + assert_eq!(get_matrix(&conn, dev.deviceid, property).unwrap(), Some(m)); + let identity = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]; + set_matrix(&conn, dev.deviceid, identity, property, float).unwrap(); + assert_eq!( + get_matrix(&conn, dev.deviceid, property).unwrap(), + Some(identity) + ); +} + +/// With `/dev/uinput` access and a server that hot-plugs input devices: a uinput touchscreen gets +/// the matrix of the monitor. Skips (with a message) when either is missing. +#[test] +fn x11_maps_a_uinput_touchscreen() { + if !enabled() { + return; + } + let mut touch = match crate::uinput_sink::create_touch((268.5, 167.6)) { + Ok(t) => t, + Err(e) => { + eprintln!("skipped: no /dev/uinput access ({e})"); + return; + } + }; + let event = crate::uinput_sink::event_name(&mut touch).expect("event node"); + let opened = X11Provider.open(&request(1280, 720)).unwrap(); + let desktop = X11Desktop; + let mapped = desktop.map_devices(&[("touch", event.as_str())], MONITOR_NAME); + match &mapped.results[0] { + Err(e) if e.contains("did not add") => { + eprintln!("skipped: the X server does not hot-plug input devices ({e})"); + return; + } + r => r.clone().unwrap(), + } + let (conn, n) = connect().unwrap(); + init_xinput(&conn).unwrap(); + let root = conn.setup().roots[n].root; + let snap = snapshot(&conn, root).unwrap(); + let mon = snap + .monitors + .iter() + .find(|m| m.name == MONITOR_NAME) + .unwrap(); + let (property, _) = matrix_atoms(&conn).unwrap(); + let node = atom(&conn, "Device Node").unwrap(); + let all = xinput::xi_query_device(&conn, 0u16) + .unwrap() + .reply() + .unwrap(); + let id = all + .infos + .iter() + .find(|d| { + matches!( + xinput::xi_get_property(&conn, d.deviceid, false, node, 0u32, 0, 64) + .unwrap() + .reply() + .unwrap() + .items, + xinput::XIGetPropertyItems::Data8(ref v) + if String::from_utf8_lossy(v).trim_end_matches('\0') == format!("/dev/input/{event}") + ) + }) + .map(|d| d.deviceid) + .expect("device"); + let want = transform_matrix(mon.rect, snap.screen); + assert_eq!(get_matrix(&conn, id, property).unwrap(), Some(want)); + drop(mapped.guard); + assert_eq!( + get_matrix(&conn, id, property).unwrap(), + Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]) + ); + // The outputs the pointer mapper sees: monitors plus the whole screen. + let outs = desktop.outputs().unwrap(); + assert!( + outs.iter() + .any(|o| o.name == MONITOR_NAME && o.size == (1280, 720)) + ); + drop(opened.guard); +} + +/// The matrix follows a change of the screen size (the output watcher re-applying, a monitor +/// plugged in). Uses the XTEST pointer, which every server has. +#[test] +fn x11_matrix_is_recomputed_when_the_screen_changes() { + if !enabled() { + return; + } + let opened = X11Provider.open(&request(1280, 720)).unwrap(); + let (conn, n) = connect().unwrap(); + init_xinput(&conn).unwrap(); + let root = conn.setup().roots[n].root; + let (property, _) = matrix_atoms(&conn).unwrap(); + let all = xinput::xi_query_device(&conn, 0u16) + .unwrap() + .reply() + .unwrap(); + let dev = all + .infos + .iter() + .find(|d| String::from_utf8_lossy(&d.name).contains("XTEST pointer")) + .expect("XTEST pointer") + .deviceid; + let guard = crate::x11_input::MapGuard::for_devices(MONITOR_NAME, vec![dev]).unwrap(); + // Let the watcher subscribe, so the change below arrives as an event. + std::thread::sleep(Duration::from_secs(1)); + + let snap = snapshot(&conn, root).unwrap(); + let mon = snap + .monitors + .iter() + .find(|m| m.name == MONITOR_NAME) + .unwrap() + .rect; + let (w, h) = snap.screen; + randr::set_screen_size(&conn, root, (w + 200) as u16, h as u16, 1000, 300) + .unwrap() + .check() + .unwrap(); + let want = transform_matrix(mon, (w + 200, h)); + let deadline = Instant::now() + Duration::from_secs(5); + while get_matrix(&conn, dev, property).unwrap() != Some(want) { + assert!(Instant::now() < deadline, "the matrix was not recomputed"); + std::thread::sleep(Duration::from_millis(100)); + } + drop(guard); + assert_eq!( + get_matrix(&conn, dev, property).unwrap(), + Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]) + ); + drop(opened.guard); +} 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..3d56724 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, Hyprland and X11 virtual outputs, PipeWire DMA-BUF, Wayland shm and X11 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,15 @@ 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. +- X11: `x11_output.rs` makes the virtual monitor out of a part of the screen no real output shows (an X server cannot create outputs). With the Xorg server grabbed it grows the screen to the right by the tablet's size (`RRSetScreenSize`, keeping the pixel density, 96 DPI when the screen reports none), puts a RandR 1.5 monitor called `EZDISPLAY` over the new area (`RRSetMonitor`, no outputs, not primary) and enables panning on one CRTC (the primary output's, else the first enabled) over its own rectangle. The panning moves nothing: the server clamps the pointer to the CRTC areas (`RRConstrainCursorHarder`) and skips that clamp while any CRTC has panning, so without it the new area would be unreachable. The guard deletes the monitor, restores the CRTC's previous panning and shrinks the screen back (to the union of the CRTCs when one reaches past the original size). A watcher thread on its own connection selects `RRScreenChangeNotify` and CRTC notifications and, after a 300 ms debounce, applies the layout again when a desktop daemon reset the screen size, dropped the panning or deleted the monitor, at most three times per ten seconds. `FrameSource::X11 { rect }` goes to `x11_capture.rs`: its own thread and connection, `ShmGetImage` of the root window over the rectangle into a memfd attached with `ShmAttachFd` (MIT-SHM 1.2; plain `GetImage` is the fallback, `EZD_X11_NO_SHM=1` forces it), BGRX frames through the shared-memory path. XDamage on the root (`NonEmpty`, subtracted into an XFixes region and intersected with the rectangle) decides when to grab, at most once per refresh interval; like the Wayland capture, a still screen produces no frames. The cursor is not in `GetImage`: XFixes `CursorNotify` gives the image (through `cursor.rs`) and the pointer position is polled once per interval, both published to the `CursorSink` relative to the rectangle. `XDG_SESSION_TYPE=x11` (or `DISPLAY` without `WAYLAND_DISPLAY`, since Xwayland sets `DISPLAY` on Wayland sessions) selects this backend before `XDG_CURRENT_DESKTOP` is read, so KDE, GNOME, Xfce, Cinnamon, MATE or i3 on X11 all use it. The proprietary NVIDIA X driver is untested. +- Backend selection (`backend.rs`): `EZD_LINUX_BACKEND=kde|gnome|sway|hyprland|x11`, then an X11 session, 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`, `hyprland.rs` and `x11_output.rs`); `source.rs` holds the shared capture and encode core. `FrameSource` has a PipeWire, a Wayland and an X11 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, X11 XInput2 transformation matrix). KDE, GNOME, Sway, Hyprland and X11 are implemented, see `docs/input.md`. ### Android @@ -126,5 +130,5 @@ flowchart TB 2. Input: touch, pen, S Pen button gestures. 3. Transports: Wi-Fi (QUIC with PIN or QR pairing, mDNS discovery) in progress; AOA (USB without developer mode, libusb) done. 4. Audio. -5. GNOME and wlroots backends. +5. GNOME, wlroots and X11 backends. 6. Packaging: Windows installer (done, with the tray app), Linux packages (Arch, .deb, AppImage; done) and udev rules, Android `full`/`play` builds. diff --git a/docs/input.md b/docs/input.md index d4603c2..255a42b 100644 --- a/docs/input.md +++ b/docs/input.md @@ -275,3 +275,64 @@ 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. + +## Linux (X11) + +The same four uinput devices are created. The X server (libinput or evdev driver) adds the touch and +pen devices as XInput2 slave devices a moment later; their `Device Node` property names the +`/dev/input/eventN` node, which is how the host finds the ones it created (it waits up to three +seconds). Each one gets the `Coordinate Transformation Matrix` property (`FLOAT`, nine values, +row-major) that maps the device's whole range onto the `EZDISPLAY` monitor inside the screen: + +``` +[ w/SW 0 x/SW ] +[ 0 h/SH y/SH ] +[ 0 0 1 ] +``` + +with `x, y, w, h` the monitor's rectangle and `SW, SH` the screen size. A pen that the driver exposes +as several devices (stylus, eraser, pad) gets the matrix on all of them. When the session ends the +identity matrix is set again. + +The mapping stays current while the session lives. A watcher thread (its own connection) listens for +XInput2 hierarchy changes and RandR screen changes. Drivers such as xf86-input-libinput create a +tablet's stylus and eraser devices only on the tool's first proximity, so a device that appears later +with one of the host's `Device Node` values gets the matrix when it is added. When the screen size +changes (the virtual monitor being applied again, a monitor plugged in) the matrix is recomputed from +the current layout and set again on every device it was set on. Both are debounced by 300 ms and +logged at info level. + +The absolute pointer is not mapped by the server per device: it is spread over the whole screen, so +the host computes the position itself, like on KDE, from the RandR monitors plus one entry for the +whole screen (the screen can be taller than every monitor). + +The pointer can only reach the virtual monitor because the backend enables panning on a CRTC (see +`docs/architecture.md`): Xorg otherwise clamps every pointer, including the absolute ones and XTest, to +the CRTC areas. `ezd-host diag` has an `x11` section with the server vendor and release, the versions +of RandR, MIT-SHM, XFixes, Damage and XInput, the monitors, the window manager and whether a compositor +is running. `tools/x11-dummy-test.sh` runs the tests against Xorg with the dummy driver; the transformation +of a real uinput device runs only where the server hot-plugs input devices and `/dev/uinput` is +accessible, and is skipped otherwise; the re-mapping on a screen change is tested on the XTEST pointer. diff --git a/packaging/linux/README.md b/packaging/linux/README.md index 5fc2b3c..aea2652 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, and any desktop on X11 (RandR 1.5), 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, Hyprland and X11 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 diff --git a/tools/x11-dummy-test.sh b/tools/x11-dummy-test.sh new file mode 100755 index 0000000..693b764 --- /dev/null +++ b/tools/x11-dummy-test.sh @@ -0,0 +1,86 @@ +#!/bin/sh +# Runs the X11 backend tests (`x11_test.rs`) against an Xorg with the dummy video driver, so no +# desktop session or Docker is needed. Unlike Xvfb, the dummy driver uses xf86 modes, which is what +# reproduces the server's pointer constraint to the CRTC areas that the backend has to lift. +# +# tools/x11-dummy-test.sh # all gated tests +# tools/x11-dummy-test.sh x11_capture # only tests whose name matches (default: x11) +# +# Needs `Xorg` and `xf86-video-dummy` (Arch: xorg-server xf86-video-dummy; Debian/Ubuntu: +# xserver-xorg-core xserver-xorg-video-dummy). Scratch files (config, server log) go to +# $EZD_X11_CACHE (default ~/.cache/ezdisplay/x11-test); the Rust build uses the normal target +# directory. The server runs on the first free display number from :90. +set -eu + +repo=$(cd "$(dirname "$0")/.." && pwd) +cache=${EZD_X11_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/ezdisplay/x11-test} +mkdir -p "$cache" + +xorg=${XORG:-} +if [ -z "$xorg" ]; then + for c in /usr/lib/Xorg /usr/lib/xorg/Xorg /usr/libexec/Xorg "$(command -v Xorg || true)"; do + if [ -n "$c" ] && [ -x "$c" ]; then + xorg=$c + break + fi + done +fi +[ -n "$xorg" ] || { echo "Xorg not found (set XORG=/path/to/Xorg)" >&2; exit 1; } + +cat >"$cache/dummy.conf" <<'CONF' +Section "Device" + Identifier "d" + Driver "dummy" + VideoRam 256000 +EndSection +Section "Monitor" + Identifier "m" + HorizSync 5.0-1000.0 + VertRefresh 5.0-200.0 + Modeline "1920x1080" 148.50 1920 2008 2052 2200 1080 1084 1089 1125 +EndSection +Section "Screen" + Identifier "s" + Device "d" + Monitor "m" + DefaultDepth 24 + SubSection "Display" + Depth 24 + Modes "1920x1080" + Virtual 8192 4096 + EndSubSection +EndSection +CONF + +# Build first, so a compile error does not leave a server running. +cd "$repo" +cargo test -p ezd-host-linux --lib --locked --no-run + +display= +n=90 +while [ "$n" -lt 190 ]; do + if [ ! -e "/tmp/.X11-unix/X$n" ] && [ ! -e "/tmp/.X$n-lock" ]; then + display=:$n + break + fi + n=$((n + 1)) +done +[ -n "$display" ] || { echo "no free X display number" >&2; exit 1; } + +"$xorg" "$display" -config "$cache/dummy.conf" -configdir "$cache" -logfile "$cache/Xorg.log" \ + -noreset -nolisten tcp -novtswitch -sharevts >"$cache/Xorg.out" 2>&1 & +xorg_pid=$! +trap 'kill $xorg_pid 2>/dev/null || true; wait $xorg_pid 2>/dev/null || true' EXIT + +# Wait for the socket, then for the server to answer. +for _ in $(seq 50); do + [ -e "/tmp/.X11-unix/X${display#:}" ] && break + kill -0 "$xorg_pid" 2>/dev/null || { echo "Xorg exited; see $cache/Xorg.log" >&2; cat "$cache/Xorg.out" >&2; exit 1; } + sleep 0.2 +done +export DISPLAY=$display +echo "Xorg on $DISPLAY (log: $cache/Xorg.log)" +if command -v xrandr >/dev/null 2>&1; then xrandr --listmonitors || true; fi + +[ "$#" -gt 0 ] || set -- x11 +EZD_X11_TEST=1 cargo test -p ezd-host-linux --lib --locked "$@" -- --nocapture --test-threads=1