Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 24 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) ·
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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`. |
Expand Down
7 changes: 7 additions & 0 deletions crates/ezd-host-linux/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }

Expand Down
118 changes: 93 additions & 25 deletions crates/ezd-host-linux/src/backend.rs
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -28,6 +33,7 @@ impl LinuxBackend {
LinuxBackend::Gnome => "gnome",
LinuxBackend::Sway => "sway",
LinuxBackend::Hyprland => "hyprland",
LinuxBackend::X11 => "x11",
}
}

Expand All @@ -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 {
Expand All @@ -69,6 +63,12 @@ pub struct DetectEnv {
pub override_backend: Option<String>,
/// `XDG_CURRENT_DESKTOP`.
pub desktop: Option<String>,
/// `XDG_SESSION_TYPE`.
pub session_type: Option<String>,
/// `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.
Expand All @@ -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(),
}
Expand Down Expand Up @@ -110,6 +113,20 @@ pub fn from_desktop(desktop: &str) -> Option<LinuxBackend> {
})
}

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(
Expand All @@ -118,9 +135,12 @@ pub fn detect(
) -> Result<Option<LinuxBackend>, 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));
}
Expand Down Expand Up @@ -179,7 +199,7 @@ pub fn current() -> Result<LinuxBackend, String> {
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)
}
Expand Down Expand Up @@ -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)));
}
}
15 changes: 14 additions & 1 deletion crates/ezd-host-linux/src/diag.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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(),
Expand Down
Loading
Loading