diff --git a/crates/punktfunk-host/protocols/fake-input.xml b/crates/punktfunk-host/protocols/fake-input.xml new file mode 100644 index 0000000..e932e70 --- /dev/null +++ b/crates/punktfunk-host/protocols/fake-input.xml @@ -0,0 +1,73 @@ + + + + SPDX-FileCopyrightText: 2015 Martin Gräßlin + SPDX-License-Identifier: LGPL-2.1-or-later + + + + This interface allows other processes to provide fake input events. + Purpose is on the one hand side to provide testing facilities like XTest + on X11, but also to support use cases like remote control (a remote + desktop server). The compositor gates the interface: it is only exposed + to clients authorized through their .desktop X-KDE-Wayland-Interfaces, so + binding it is the authorization — no per-event confirmation dialog. + + + + A FakeInput is required to authenticate itself by providing the + application name and the reason for fake input. The compositor may use + this information to decide whether to allow or deny the request. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/punktfunk-host/src/inject.rs b/crates/punktfunk-host/src/inject.rs index 21f4390..2fc164d 100644 --- a/crates/punktfunk-host/src/inject.rs +++ b/crates/punktfunk-host/src/inject.rs @@ -24,6 +24,9 @@ pub trait InputInjector { pub enum Backend { /// wlroots virtual pointer + keyboard Wayland protocols — the headless-Sway path. WlrVirtual, + /// KWin `org_kde_kwin_fake_input` — direct injection, no RemoteDesktop portal / approval dialog + /// (authorized by the host's `.desktop`). The headless KDE-Desktop path; what krdpserver uses. + KwinFakeInput, /// libei via `reis` — Wayland-native (RemoteDesktop portal). Not yet implemented. Libei, /// libei directly against gamescope's own EIS socket (no portal): input lands in the @@ -47,6 +50,16 @@ pub fn open(backend: Backend) -> Result> { anyhow::bail!("wlroots virtual input requires Linux + a Wayland compositor") } } + Backend::KwinFakeInput => { + #[cfg(target_os = "linux")] + { + Ok(Box::new(kwin_fake_input::KwinFakeInjector::open()?)) + } + #[cfg(not(target_os = "linux"))] + { + anyhow::bail!("KWin fake_input requires Linux + a KWin Wayland session") + } + } Backend::Libei => { #[cfg(target_os = "linux")] { @@ -90,12 +103,18 @@ pub fn open(backend: Backend) -> Result> { /// Pick the injection backend for the current session. gamescope hosts its own EIS server (no /// portal), so a gamescope session injects directly into it. wlroots/Sway only implements the /// ScreenCast portal (no RemoteDesktop), so libei can't run there — use the wlr virtual-input -/// protocols. KWin and GNOME implement RemoteDesktop but not the wlr protocols, so use libei. -/// `PUNKTFUNK_INPUT_BACKEND=wlr|libei|gamescope|uinput` overrides the auto-detection. +/// protocols. **KWin** exposes `org_kde_kwin_fake_input` (direct injection, no portal / approval +/// dialog — the only headless-capable path; what krdpserver uses), so prefer it there. **GNOME** +/// has neither fake_input nor the wlr protocols, so it uses libei via the RemoteDesktop portal +/// (which needs a user to approve, or a pre-seeded grant — not truly headless). +/// `PUNKTFUNK_INPUT_BACKEND=wlr|kwin|libei|gamescope|uinput` overrides the auto-detection. pub fn default_backend() -> Backend { if let Ok(v) = std::env::var("PUNKTFUNK_INPUT_BACKEND") { match v.trim().to_ascii_lowercase().as_str() { "wlr" | "wlroots" | "wlrvirtual" => return Backend::WlrVirtual, + "kwin" | "fakeinput" | "fake_input" | "kwin-fake-input" => { + return Backend::KwinFakeInput + } "libei" | "ei" | "portal" => return Backend::Libei, "gamescope" | "gamescope-ei" => return Backend::GamescopeEi, "uinput" => return Backend::Uinput, @@ -112,16 +131,26 @@ pub fn default_backend() -> Backend { } #[cfg(not(target_os = "windows"))] { - if crate::config::config() - .compositor - .as_deref() - .is_some_and(|v| v.trim().eq_ignore_ascii_case("gamescope")) - { - return Backend::GamescopeEi; + // An explicit compositor pick (set per connect / mid-stream) is the strongest signal. + let compositor = crate::config::config().compositor.clone(); + if let Some(c) = compositor.as_deref() { + let c = c.trim(); + if c.eq_ignore_ascii_case("gamescope") { + return Backend::GamescopeEi; + } + if c.eq_ignore_ascii_case("kwin") { + return Backend::KwinFakeInput; + } + if c.eq_ignore_ascii_case("wlroots") || c.eq_ignore_ascii_case("sway") { + return Backend::WlrVirtual; + } + // mutter (GNOME) falls through to the XDG_CURRENT_DESKTOP check below. } let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default(); let d = desktop.to_ascii_uppercase(); - if d.contains("KDE") || d.contains("GNOME") { + if d.contains("KDE") { + Backend::KwinFakeInput + } else if d.contains("GNOME") { Backend::Libei } else { Backend::WlrVirtual @@ -478,6 +507,9 @@ pub mod gamepad { } } #[cfg(target_os = "linux")] +#[path = "inject/linux/kwin_fake_input.rs"] +mod kwin_fake_input; +#[cfg(target_os = "linux")] #[path = "inject/linux/libei.rs"] mod libei; #[cfg(target_os = "windows")] diff --git a/crates/punktfunk-host/src/inject/linux/kwin_fake_input.rs b/crates/punktfunk-host/src/inject/linux/kwin_fake_input.rs new file mode 100644 index 0000000..92b4e6e --- /dev/null +++ b/crates/punktfunk-host/src/inject/linux/kwin_fake_input.rs @@ -0,0 +1,209 @@ +//! Headless input injection on KWin via the privileged `org_kde_kwin_fake_input` protocol — the +//! exact path KDE's own headless RDP server (`krdpserver`) uses. KWin advertises this restricted +//! global only to a client authorized through its installed `.desktop` `X-KDE-Wayland-Interfaces` +//! (we ship `io.unom.Punktfunk.Host.desktop`, which lists `org_kde_kwin_fake_input` alongside +//! `zkde_screencast_unstable_v1`). Binding the global IS the authorization, so injection needs **no +//! RemoteDesktop portal and no "Allow remote control?" dialog** — it works with no user present, +//! which the libei/portal path cannot. We connect as an ordinary Wayland client on the KWin session's +//! `$WAYLAND_DISPLAY` and translate events into fake-input requests; keyboard keys are raw Linux +//! evdev codes that KWin resolves through the session's own keymap (no keymap upload, unlike the wlr +//! virtual-keyboard path), and absolute pointer/touch coordinates are global compositor space — which +//! on a headless box (single per-session virtual output at the origin, scale 1) equals the streamed +//! output's pixels. + +#![allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)] +// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program). +#![deny(clippy::undocumented_unsafe_blocks)] + +use super::{gs_button_to_evdev, vk_to_evdev, InputEvent, InputInjector}; +use anyhow::{Context, Result}; +use punktfunk_core::input::InputKind; +use wayland_client::protocol::wl_registry::{self, WlRegistry}; +use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle}; + +// Generate the client bindings for the vendored protocol XML inline (no build.rs), exactly like the +// KWin virtual-output backend. Path is relative to CARGO_MANIFEST_DIR. +#[allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)] +pub mod fake { + use wayland_client; + use wayland_client::protocol::*; + + pub mod __interfaces { + use wayland_client::protocol::__interfaces::*; + wayland_scanner::generate_interfaces!("protocols/fake-input.xml"); + } + use self::__interfaces::*; + + wayland_scanner::generate_client_code!("protocols/fake-input.xml"); +} + +use fake::org_kde_kwin_fake_input::OrgKdeKwinFakeInput as FakeInput; + +/// Highest interface version we drive. `keyboard_key` arrived at v4; KWin advertises ≥4. +const MAX_VERSION: u32 = 4; + +/// `wl_pointer.axis` values used by `axis`. +const AXIS_VERTICAL: u32 = 0; +const AXIS_HORIZONTAL: u32 = 1; +/// `code` value marking a horizontal scroll event (mirrors `gamestream::input` / the wlr backend). +const SCROLL_HORIZONTAL: u32 = 1; + +/// Registry-bound globals (the Wayland dispatch state). +#[derive(Default)] +struct State { + fake: Option, +} + +impl Dispatch for State { + fn event( + state: &mut Self, + registry: &WlRegistry, + event: wl_registry::Event, + _: &(), + _: &Connection, + qh: &QueueHandle, + ) { + if let wl_registry::Event::Global { + name, + interface, + version, + } = event + { + if interface == "org_kde_kwin_fake_input" { + state.fake = Some(registry.bind(name, version.min(MAX_VERSION), qh, ())); + } + } + } +} + +// fake_input emits no events. +impl Dispatch for State { + fn event( + _: &mut Self, + _: &FakeInput, + _: ::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + } +} + +pub struct KwinFakeInjector { + conn: Connection, + queue: EventQueue, + state: State, + fake: FakeInput, +} + +impl KwinFakeInjector { + pub fn open() -> Result { + let conn = Connection::connect_to_env() + .context("connect to KWin Wayland (is WAYLAND_DISPLAY set to the KWin socket?)")?; + let mut queue = conn.new_event_queue(); + let qh = queue.handle(); + let _registry = conn.display().get_registry(&qh, ()); + let mut state = State::default(); + queue + .roundtrip(&mut state) + .context("Wayland registry roundtrip")?; + + let fake = state.fake.clone().context( + "KWin does not expose org_kde_kwin_fake_input to this client — install the host's \ + .desktop (io.unom.Punktfunk.Host.desktop, X-KDE-Wayland-Interfaces) and re-login so \ + KWin authorizes it (the grant is cached per-exe on first connect), or this is not a \ + KWin session", + )?; + // Authenticate (the legacy handshake; for an interface-authorized client KWin accepts it + // without a dialog — same as krdpserver/krfb headless). + fake.authenticate("punktfunk".into(), "remote streaming input".into()); + queue + .roundtrip(&mut state) + .context("fake_input authenticate roundtrip")?; + conn.flush().ok(); + + tracing::info!("KWin fake_input ready (headless keyboard/mouse/touch — no portal)"); + Ok(Self { + conn, + queue, + state, + fake, + }) + } +} + +impl InputInjector for KwinFakeInjector { + fn inject(&mut self, event: &InputEvent) -> Result<()> { + match event.kind { + InputKind::MouseMove => { + self.fake.pointer_motion(event.x as f64, event.y as f64); + } + InputKind::MouseMoveAbs => { + let w = (event.flags >> 16) & 0xffff; + let h = event.flags & 0xffff; + if w > 0 && h > 0 { + let x = event.x.clamp(0, w as i32) as f64; + let y = event.y.clamp(0, h as i32) as f64; + self.fake.pointer_motion_absolute(x, y); + } + } + InputKind::MouseButtonDown | InputKind::MouseButtonUp => { + if let Some(btn) = gs_button_to_evdev(event.code) { + let st = u32::from(event.kind == InputKind::MouseButtonDown); + self.fake.button(btn, st); + } + } + InputKind::MouseScroll => { + // GameStream sends WHEEL_DELTA(120)-scaled units; a notch ≈ 15px. Vertical flips + // sign on the Wayland axis, horizontal passes through — same as the wlr backend. + let horizontal = event.code == SCROLL_HORIZONTAL; + let axis = if horizontal { + AXIS_HORIZONTAL + } else { + AXIS_VERTICAL + }; + let notches = event.x as f64 / 120.0; + let sign = if horizontal { 1.0 } else { -1.0 }; + self.fake.axis(axis, sign * notches * 15.0); + } + InputKind::KeyDown | InputKind::KeyUp => { + // Raw evdev keycode; KWin resolves it through the session's own keymap (and tracks + // modifier state itself, so no separate modifiers request is needed). + if let Some(evdev) = vk_to_evdev(event.code as u8) { + let st = u32::from(event.kind == InputKind::KeyDown); + self.fake.keyboard_key(evdev as u32, st); + } else { + tracing::debug!(vk = event.code, "unmapped VK keycode — dropped"); + } + } + // Touch: id = event.code, coords in the client surface w×h packed into flags (same + // absolute mapping as MouseMoveAbs). Each event is its own frame. + InputKind::TouchDown | InputKind::TouchMove => { + let w = (event.flags >> 16) & 0xffff; + let h = event.flags & 0xffff; + if w > 0 && h > 0 { + let x = event.x.clamp(0, w as i32) as f64; + let y = event.y.clamp(0, h as i32) as f64; + if event.kind == InputKind::TouchDown { + self.fake.touch_down(event.code, x, y); + } else { + self.fake.touch_motion(event.code, x, y); + } + self.fake.touch_frame(); + } + } + InputKind::TouchUp => { + self.fake.touch_up(event.code); + self.fake.touch_frame(); + } + // Gamepads are injected through uinput, not the compositor. + InputKind::GamepadButton | InputKind::GamepadAxis => {} + } + // Surface protocol errors / disconnects, then push the batch to the compositor. + self.queue + .dispatch_pending(&mut self.state) + .context("wayland dispatch")?; + self.conn.flush().context("wayland flush")?; + Ok(()) + } +}